diff --git a/.changeset/hook-suppress-tui-display.md b/.changeset/hook-suppress-tui-display.md
new file mode 100644
index 000000000..e99d64822
--- /dev/null
+++ b/.changeset/hook-suppress-tui-display.md
@@ -0,0 +1,5 @@
+---
+"@moonshot-ai/kimi-code": minor
+---
+
+Add a per-hook suppress_tui_display option for UserPromptSubmit context injection.
diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts
index 4a13fe373..6c456fdb1 100644
--- a/apps/kimi-code/src/tui/controllers/session-replay.ts
+++ b/apps/kimi-code/src/tui/controllers/session-replay.ts
@@ -440,6 +440,7 @@ export class SessionReplayRenderer {
private renderHookResult(context: ReplayRenderContext, message: ContextMessage): void {
if (message.origin?.kind !== 'hook_result') return;
+ if (message.origin.suppressTuiDisplay === true) return;
this.flushAssistant(context);
this.host.appendTranscriptEntry(
replayEntry(
diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts
index f54bac27b..b9a684f26 100644
--- a/apps/kimi-code/test/tui/message-replay.test.ts
+++ b/apps/kimi-code/test/tui/message-replay.test.ts
@@ -990,6 +990,26 @@ describe('KimiTUI resume message replay', () => {
expect(transcript).toContain('hook response 2');
});
+ it('skips replayed hook results suppressed from TUI display', async () => {
+ const hookResult =
+ '\nhidden hook response\n';
+ const driver = await replayIntoDriver([
+ message('user', [{ type: 'text', text: 'prompt' }]),
+ message('user', [{ type: 'text', text: hookResult }], {
+ origin: {
+ kind: 'hook_result',
+ event: 'UserPromptSubmit',
+ suppressTuiDisplay: true,
+ },
+ }),
+ ]);
+
+ const transcript = driver.state.transcriptContainer.render(120).join('\n');
+
+ expect(transcript).not.toContain('UserPromptSubmit hook');
+ expect(transcript).not.toContain('hidden hook response');
+ });
+
it('renders replayed compaction records as completed compaction blocks', async () => {
const driver = await replayIntoDriver([
message('user', [{ type: 'text', text: 'prompt before compaction' }]),
diff --git a/docs/en/customization/hooks.md b/docs/en/customization/hooks.md
index 1f966e341..f53a35e24 100644
--- a/docs/en/customization/hooks.md
+++ b/docs/en/customization/hooks.md
@@ -47,8 +47,9 @@ All hook rules are written in the `[[hooks]]` array in `~/.kimi-code/config.toml
| `matcher` | `string` | No | A regular expression to filter event targets; if omitted, matches all |
| `command` | `string` | Yes | The shell command to run when triggered |
| `timeout` | `integer` | No | Timeout in seconds, range 1–600; defaults to 30 seconds |
+| `suppress_tui_display` | `boolean` | No | For `UserPromptSubmit` allow-output, inject the hook result into model context without showing it in the terminal transcript |
-`[[hooks]]` only allows these four fields; extra fields will cause the config file to fail to load.
+`[[hooks]]` only allows these five fields; extra fields will cause the config file to fail to load.
**When multiple rules match the same event**, all matching hooks run in parallel; multiple rules with identical `command` values run only once.
@@ -98,7 +99,7 @@ Only **blockable events** (`PreToolUse`, `Stop`, `UserPromptSubmit`) have return
| Event | Matcher matches | Supports blocking? | Description |
| --- | --- | --- | --- |
-| `UserPromptSubmit` | The text submitted by the user | ✓ | Triggered when the user sends a message; returned text is appended to context; if blocked, the model is not called for this turn |
+| `UserPromptSubmit` | The text submitted by the user | ✓ | Triggered when the user sends a message; returned text is appended to context; if blocked, the model is not called for this turn. Set `suppress_tui_display = true` on a hook to hide allow-output from the terminal while still injecting it into context |
| `PreToolUse` | Tool name | ✓ | Triggered before a tool call (before permission checks); the tool will not execute if blocked |
| `Stop` | Empty string | ✓ | Triggered when the model is about to end the current turn; if blocked, a message can be appended to let the model continue |
| `PostToolUse` | Tool name | — | Triggered after a tool executes successfully (observation only) |
diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md
index a506923b9..18c7abb05 100644
--- a/docs/zh/customization/hooks.md
+++ b/docs/zh/customization/hooks.md
@@ -47,8 +47,9 @@ command = "terminal-notifier -title Kimi -message 'Task done'"
| `matcher` | `string` | 否 | 用正则表达式(一种字符串匹配语法)过滤事件目标;不填则匹配全部 |
| `command` | `string` | 是 | 触发时要运行的 Shell 命令 |
| `timeout` | `integer` | 否 | 超时秒数,范围 1–600;默认 30 秒 |
+| `suppress_tui_display` | `boolean` | 否 | 对 `UserPromptSubmit` 的 allow 输出生效:把 hook 结果注入模型上下文,但不显示在终端 transcript 里 |
-`[[hooks]]` 只允许这四个字段,多写会导致配置文件加载失败。
+`[[hooks]]` 只允许这五个字段,多写会导致配置文件加载失败。
**同一事件匹配多条规则时**,所有命中的 hook 并行运行;`command` 完全相同的多条规则只运行一次。
@@ -98,7 +99,7 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上
| 事件 | Matcher 匹配的是 | 会触发阻断? | 说明 |
| --- | --- | --- | --- |
-| `UserPromptSubmit` | 用户提交的文本内容 | ✓ | 用户发送消息时触发;返回文本会附加到上下文;若阻断,本轮不调用模型 |
+| `UserPromptSubmit` | 用户提交的文本内容 | ✓ | 用户发送消息时触发;返回文本会附加到上下文;若阻断,本轮不调用模型。可以在 hook 上设置 `suppress_tui_display = true`,隐藏终端中的 allow 输出,同时仍注入上下文 |
| `PreToolUse` | 工具名 | ✓ | 工具调用前触发(权限检查前);阻断后工具不会执行 |
| `Stop` | 空字符串 | ✓ | 模型准备结束本轮时触发;阻断后可追加一条消息让模型继续 |
| `PostToolUse` | 工具名 | — | 工具成功执行后触发(观察用) |
diff --git a/packages/agent-core/src/agent/context/types.ts b/packages/agent-core/src/agent/context/types.ts
index d0a78b975..044b4c5da 100644
--- a/packages/agent-core/src/agent/context/types.ts
+++ b/packages/agent-core/src/agent/context/types.ts
@@ -62,6 +62,7 @@ export interface HookResultOrigin {
readonly kind: 'hook_result';
readonly event: string;
readonly blocked?: boolean;
+ readonly suppressTuiDisplay?: boolean;
}
export interface RetryOrigin {
diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts
index 7fff37876..ee7afcb8c 100644
--- a/packages/agent-core/src/agent/turn/index.ts
+++ b/packages/agent-core/src/agent/turn/index.ts
@@ -37,7 +37,10 @@ import type { AgentEvent, TurnEndedEvent } from '../../rpc';
import type { TelemetryPropertyValue } from '../../telemetry';
import { abortable, isUserCancellation, userCancellationReason } from '../../utils/abort';
import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context';
-import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks';
+import {
+ renderUserPromptHookBlockResult,
+ renderUserPromptHookResultChunks,
+} from '../../session/hooks';
import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args';
import { ToolCallDeduplicator } from './tool-dedup';
@@ -45,7 +48,7 @@ interface ActiveTurn {
readonly turnId: number;
readonly controller: AbortController;
readonly promise: Promise;
- readonly firstRequest: ControlledPromise;
+ readonly firstRequest: ControlledPromise;
}
interface BufferedSteer {
@@ -594,19 +597,21 @@ export class TurnFlow {
};
}
- const hookResult = renderUserPromptHookResult(promptHookResults);
- if (hookResult === undefined) return undefined;
-
- this.agent.context.appendUserMessage([{ type: 'text', text: hookResult.text }], {
- kind: 'hook_result',
- event: 'UserPromptSubmit',
- });
- this.agent.emitEvent({
- type: 'hook.result',
- turnId,
- hookEvent: hookResult.event,
- content: hookResult.message,
- });
+ for (const hookResult of renderUserPromptHookResultChunks(promptHookResults)) {
+ const suppressTuiDisplay = hookResult.suppressTuiDisplay === true;
+ this.agent.context.appendUserMessage([{ type: 'text', text: hookResult.text }], {
+ kind: 'hook_result',
+ event: 'UserPromptSubmit',
+ ...(suppressTuiDisplay ? { suppressTuiDisplay: true } : {}),
+ });
+ if (suppressTuiDisplay) continue;
+ this.agent.emitEvent({
+ type: 'hook.result',
+ turnId,
+ hookEvent: hookResult.event,
+ content: hookResult.message,
+ });
+ }
return undefined;
}
@@ -794,6 +799,12 @@ export class TurnFlow {
active.firstRequest.resolve();
return;
}
+ case 'step.begin':
+ case 'step.retrying':
+ case 'tool.progress':
+ case 'tool.result':
+ case 'turn.interrupted':
+ return;
default:
return;
}
diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts
index 9b3d11cf0..fd88af45f 100644
--- a/packages/agent-core/src/config/schema.ts
+++ b/packages/agent-core/src/config/schema.ts
@@ -114,6 +114,7 @@ export const HookDefSchema = z
matcher: z.string().optional(),
command: z.string().min(1),
timeout: z.number().int().min(1).max(600).optional(),
+ suppressTuiDisplay: z.boolean().optional(),
})
.strict();
diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts
index 172e97cfc..3ee334db0 100644
--- a/packages/agent-core/src/config/toml.ts
+++ b/packages/agent-core/src/config/toml.ts
@@ -312,6 +312,10 @@ export function transformTomlData(data: Record): Record
+ isPlainObject(entry) ? transformPlainObject(entry) : entry,
+ );
} else if (targetKey === 'experimental' && isPlainObject(value)) {
result[targetKey] = cloneRecord(value);
} else if (!isPlainObject(value)) {
diff --git a/packages/agent-core/src/session/hooks/engine.ts b/packages/agent-core/src/session/hooks/engine.ts
index 832ecd620..aa2e1e6a5 100644
--- a/packages/agent-core/src/session/hooks/engine.ts
+++ b/packages/agent-core/src/session/hooks/engine.ts
@@ -87,7 +87,10 @@ export class HookEngine {
timeout: hook.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS,
cwd: this.options.cwd === '' ? undefined : this.options.cwd,
signal: args.signal,
- }),
+ }).then((result) => ({
+ ...result,
+ suppressTuiDisplay: hook.suppressTuiDisplay === true,
+ })),
),
);
const { action, reason } = aggregateResults(event, results);
diff --git a/packages/agent-core/src/session/hooks/types.ts b/packages/agent-core/src/session/hooks/types.ts
index cf05ca747..75eee021f 100644
--- a/packages/agent-core/src/session/hooks/types.ts
+++ b/packages/agent-core/src/session/hooks/types.ts
@@ -26,6 +26,7 @@ export interface HookDef {
readonly matcher?: string;
readonly command: string;
readonly timeout?: number;
+ readonly suppressTuiDisplay?: boolean;
}
export interface HookResult {
@@ -37,6 +38,7 @@ export interface HookResult {
readonly exitCode?: number;
readonly timedOut?: boolean;
readonly structuredOutput?: boolean;
+ readonly suppressTuiDisplay?: boolean;
}
export interface HookBlockDecision {
diff --git a/packages/agent-core/src/session/hooks/user-prompt.ts b/packages/agent-core/src/session/hooks/user-prompt.ts
index 1b81c9f61..e3e7c764a 100644
--- a/packages/agent-core/src/session/hooks/user-prompt.ts
+++ b/packages/agent-core/src/session/hooks/user-prompt.ts
@@ -8,14 +8,21 @@ export interface RenderedHookResult {
readonly event: string;
readonly message: string;
readonly text: string;
+ readonly suppressTuiDisplay?: boolean;
+}
+
+export interface RenderUserPromptHookResultOptions {
+ readonly suppressTuiDisplay?: boolean;
}
export function renderUserPromptHookResult(
results: readonly HookResult[] | undefined,
+ options: RenderUserPromptHookResultOptions = {},
): RenderedHookResult | undefined {
const messages =
results
?.filter((result) => result.action !== 'block')
+ ?.filter((result) => matchesDisplayFilter(result, options.suppressTuiDisplay))
?.map(userPromptHookMessage)
.filter(isNonEmptyString) ??
[];
@@ -28,6 +35,33 @@ export function renderUserPromptHookResult(
};
}
+export function renderUserPromptHookResultChunks(
+ results: readonly HookResult[] | undefined,
+): readonly RenderedHookResult[] {
+ const rendered: RenderedHookResult[] = [];
+ let current: { suppressTuiDisplay: boolean; messages: string[] } | undefined;
+
+ for (const result of results ?? []) {
+ if (result.action === 'block') continue;
+ const message = userPromptHookMessage(result);
+ if (message === undefined) continue;
+ const suppressTuiDisplay = result.suppressTuiDisplay === true;
+
+ if (current === undefined || current.suppressTuiDisplay !== suppressTuiDisplay) {
+ if (current !== undefined) {
+ rendered.push(renderMessages(current.messages, current.suppressTuiDisplay));
+ }
+ current = { suppressTuiDisplay, messages: [] };
+ }
+ current.messages.push(message);
+ }
+
+ if (current !== undefined) {
+ rendered.push(renderMessages(current.messages, current.suppressTuiDisplay));
+ }
+ return rendered;
+}
+
export function renderUserPromptHookBlockResult(
results: readonly HookResult[] | undefined,
): RenderedHookResult | undefined {
@@ -64,3 +98,21 @@ function userPromptHookMessage(result: HookResult): string | undefined {
function isNonEmptyString(value: string | undefined): value is string {
return value !== undefined && value.length > 0;
}
+
+function renderMessages(messages: readonly string[], suppressTuiDisplay = false): RenderedHookResult {
+ const displayMessage = messages.join('\n\n');
+ return {
+ event: 'UserPromptSubmit',
+ message: displayMessage,
+ text: messages.map((message) => renderHookResult('UserPromptSubmit', message)).join('\n'),
+ suppressTuiDisplay: suppressTuiDisplay ? true : undefined,
+ };
+}
+
+function matchesDisplayFilter(
+ result: HookResult,
+ suppressTuiDisplay: boolean | undefined,
+): boolean {
+ if (suppressTuiDisplay === undefined) return true;
+ return (result.suppressTuiDisplay === true) === suppressTuiDisplay;
+}
diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts
index e39094fb4..90b82755c 100644
--- a/packages/agent-core/test/agent/turn.test.ts
+++ b/packages/agent-core/test/agent/turn.test.ts
@@ -509,6 +509,120 @@ describe('Agent turn flow', () => {
]);
});
+ it('injects suppressed UserPromptSubmit hook output without emitting a visible hook result', async () => {
+ const hookEngine = new HookEngine([
+ {
+ event: 'UserPromptSubmit',
+ matcher: 'hooked input',
+ command: "echo 'hidden hook response'",
+ suppressTuiDisplay: true,
+ },
+ ]);
+ const ctx = testAgent({ hookEngine });
+ ctx.configure();
+ ctx.mockNextResponse({ type: 'text', text: 'model saw hidden hook context' });
+
+ await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hooked input' }] });
+ const events = await ctx.untilTurnEnd();
+
+ const hookResult =
+ '\nhidden hook response\n';
+ expect(ctx.llmCalls).toHaveLength(1);
+ expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
+ system:
+ tools: []
+ messages:
+ user: text "hooked input"
+ user: text "\\nhidden hook response\\n"
+ `);
+ expect(events).not.toContainEqual(
+ expect.objectContaining({
+ event: 'hook.result',
+ }),
+ );
+ expect(events).toContainEqual(
+ expect.objectContaining({
+ event: 'assistant.delta',
+ args: expect.objectContaining({ delta: 'model saw hidden hook context' }),
+ }),
+ );
+ expect(ctx.agent.context.data().history).toEqual([
+ {
+ role: 'user',
+ content: [{ type: 'text', text: 'hooked input' }],
+ toolCalls: [],
+ origin: { kind: 'user' },
+ },
+ {
+ role: 'user',
+ content: [{ type: 'text', text: hookResult }],
+ toolCalls: [],
+ origin: {
+ kind: 'hook_result',
+ event: 'UserPromptSubmit',
+ suppressTuiDisplay: true,
+ },
+ },
+ {
+ role: 'assistant',
+ content: [{ type: 'text', text: 'model saw hidden hook context' }],
+ toolCalls: [],
+ },
+ ]);
+ });
+
+ it('preserves UserPromptSubmit hook output order when only some results are hidden', async () => {
+ const hookEngine = new HookEngine([
+ {
+ event: 'UserPromptSubmit',
+ matcher: 'hooked input',
+ command: "echo 'visible hook before'",
+ },
+ {
+ event: 'UserPromptSubmit',
+ matcher: 'hooked input',
+ command: "echo 'hidden hook middle'",
+ suppressTuiDisplay: true,
+ },
+ {
+ event: 'UserPromptSubmit',
+ matcher: 'hooked input',
+ command: "echo 'visible hook after'",
+ },
+ ]);
+ const ctx = testAgent({ hookEngine });
+ ctx.configure();
+ ctx.mockNextResponse({ type: 'text', text: 'model saw ordered hook context' });
+
+ await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hooked input' }] });
+ const events = await ctx.untilTurnEnd();
+
+ expect(ctx.llmCalls).toHaveLength(1);
+ expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
+ system:
+ tools: []
+ messages:
+ user: text "hooked input"
+ user: text "\\nvisible hook before\\n"
+ user: text "\\nhidden hook middle\\n"
+ user: text "\\nvisible hook after\\n"
+ `);
+
+ const hookResultContents = (events as Array<{ event?: string; args?: { content?: unknown } }>)
+ .filter((event) => event.event === 'hook.result')
+ .map((event) => event.args?.content);
+ expect(hookResultContents).toEqual(['visible hook before', 'visible hook after']);
+
+ const hookOrigins = ctx.agent.context.data().history
+ .filter((message) => message.origin?.kind === 'hook_result')
+ .map((message) => message.origin);
+ expect(hookOrigins).toEqual([
+ { kind: 'hook_result', event: 'UserPromptSubmit' },
+ { kind: 'hook_result', event: 'UserPromptSubmit', suppressTuiDisplay: true },
+ { kind: 'hook_result', event: 'UserPromptSubmit' },
+ ]);
+ });
+
it('projects structured UserPromptSubmit stdout', async () => {
const hookEngine = new HookEngine([
{
@@ -578,6 +692,7 @@ describe('Agent turn flow', () => {
event: 'UserPromptSubmit',
matcher: 'bad words',
command: "echo 'no profanity' >&2; exit 2",
+ suppressTuiDisplay: true,
},
]);
const ctx = testAgent({ hookEngine });
diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts
index 091eee384..404ce2ba7 100644
--- a/packages/agent-core/test/config/configs.test.ts
+++ b/packages/agent-core/test/config/configs.test.ts
@@ -423,20 +423,22 @@ pattern = "Bash(rm *"
const config = parseConfigString(
`
[[hooks]]
-event = "PreToolUse"
-matcher = "Shell"
+event = "UserPromptSubmit"
+matcher = "prompt"
command = "echo hi"
timeout = 5
+suppress_tui_display = true
`,
'hooks.toml',
);
expect(config.hooks).toEqual([
{
- event: 'PreToolUse',
- matcher: 'Shell',
+ event: 'UserPromptSubmit',
+ matcher: 'prompt',
command: 'echo hi',
timeout: 5,
+ suppressTuiDisplay: true,
},
]);
});
diff --git a/packages/protocol/src/__tests__/events.test.ts b/packages/protocol/src/__tests__/events.test.ts
index b53d80215..18dbc5912 100644
--- a/packages/protocol/src/__tests__/events.test.ts
+++ b/packages/protocol/src/__tests__/events.test.ts
@@ -103,6 +103,28 @@ describe('events / display re-exports', () => {
expect(parsed.sessionId).toBe('sess_1');
});
+ it('preserves hook-result display suppression in prompt origins', () => {
+ const parsed = eventSchema.parse({
+ type: 'turn.started',
+ agentId: 'agent_1',
+ sessionId: 'sess_1',
+ turnId: 1,
+ origin: {
+ kind: 'hook_result',
+ event: 'UserPromptSubmit',
+ suppressTuiDisplay: true,
+ },
+ });
+
+ expect(parsed.type).toBe('turn.started');
+ if (parsed.type !== 'turn.started') throw new Error('expected turn.started');
+ expect(parsed.origin).toEqual({
+ kind: 'hook_result',
+ event: 'UserPromptSubmit',
+ suppressTuiDisplay: true,
+ });
+ });
+
it('validates prompt.submitted events', () => {
const parsed = eventSchema.parse({
type: 'prompt.submitted',
diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts
index 49224b6f6..47bcdec1d 100644
--- a/packages/protocol/src/events.ts
+++ b/packages/protocol/src/events.ts
@@ -94,6 +94,7 @@ export interface HookResultOrigin {
readonly kind: 'hook_result';
readonly event: string;
readonly blocked?: boolean;
+ readonly suppressTuiDisplay?: boolean;
}
export interface RetryOrigin {
@@ -718,6 +719,7 @@ export const hookResultOriginSchema = z.object({
kind: z.literal('hook_result'),
event: z.string(),
blocked: z.boolean().optional(),
+ suppressTuiDisplay: z.boolean().optional(),
}) satisfies z.ZodType;
export const retryOriginSchema = z.object({