Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hook-suppress-tui-display.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add a per-hook suppress_tui_display option for UserPromptSubmit context injection.
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
20 changes: 20 additions & 0 deletions apps/kimi-code/test/tui/message-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
'<hook_result hook_event="UserPromptSubmit">\nhidden hook response\n</hook_result>';
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' }]),
Expand Down
5 changes: 3 additions & 2 deletions docs/en/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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) |
Expand Down
5 changes: 3 additions & 2 deletions docs/zh/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 完全相同的多条规则只运行一次。

Expand Down Expand Up @@ -98,7 +99,7 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上

| 事件 | Matcher 匹配的是 | 会触发阻断? | 说明 |
| --- | --- | --- | --- |
| `UserPromptSubmit` | 用户提交的文本内容 | ✓ | 用户发送消息时触发;返回文本会附加到上下文;若阻断,本轮不调用模型 |
| `UserPromptSubmit` | 用户提交的文本内容 | ✓ | 用户发送消息时触发;返回文本会附加到上下文;若阻断,本轮不调用模型。可以在 hook 上设置 `suppress_tui_display = true`,隐藏终端中的 allow 输出,同时仍注入上下文 |
| `PreToolUse` | 工具名 | ✓ | 工具调用前触发(权限检查前);阻断后工具不会执行 |
| `Stop` | 空字符串 | ✓ | 模型准备结束本轮时触发;阻断后可追加一条消息让模型继续 |
| `PostToolUse` | 工具名 | — | 工具成功执行后触发(观察用) |
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/agent/context/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export interface HookResultOrigin {
readonly kind: 'hook_result';
readonly event: string;
readonly blocked?: boolean;
readonly suppressTuiDisplay?: boolean;
}

export interface RetryOrigin {
Expand Down
41 changes: 26 additions & 15 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,18 @@ 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';

interface ActiveTurn {
readonly turnId: number;
readonly controller: AbortController;
readonly promise: Promise<TurnEndResult>;
readonly firstRequest: ControlledPromise<void>;
readonly firstRequest: ControlledPromise;
}

interface BufferedSteer {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core/src/config/toml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ export function transformTomlData(data: Record<string, unknown>): Record<string,
result[targetKey] = transformLoopControlData(value);
} else if (targetKey === 'background' && isPlainObject(value)) {
result[targetKey] = transformPlainObject(value);
} else if (targetKey === 'hooks' && Array.isArray(value)) {
result[targetKey] = value.map((entry) =>
isPlainObject(entry) ? transformPlainObject(entry) : entry,
);
} else if (targetKey === 'experimental' && isPlainObject(value)) {
result[targetKey] = cloneRecord(value);
} else if (!isPlainObject(value)) {
Expand Down
5 changes: 4 additions & 1 deletion packages/agent-core/src/session/hooks/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/src/session/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface HookDef {
readonly matcher?: string;
readonly command: string;
readonly timeout?: number;
readonly suppressTuiDisplay?: boolean;
}

export interface HookResult {
Expand All @@ -37,6 +38,7 @@ export interface HookResult {
readonly exitCode?: number;
readonly timedOut?: boolean;
readonly structuredOutput?: boolean;
readonly suppressTuiDisplay?: boolean;
}

export interface HookBlockDecision {
Expand Down
52 changes: 52 additions & 0 deletions packages/agent-core/src/session/hooks/user-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ??
[];
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
Loading