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/notify-user-panel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add the experimental `NotifyUser` tool so the model can show you short progress updates while it is still working. The updates of the current turn stack up in an `Update` panel above the input box; press `Ctrl+N` to page back through earlier ones, and the panel closes when the next turn starts. TUI only; enable it with `KIMI_CODE_EXPERIMENTAL_NOTIFY_USER=1` or `[experimental] notify_user = true` in `config.toml`.
5 changes: 4 additions & 1 deletion apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
withTelemetryContext,
} from '@moonshot-ai/kimi-telemetry';

import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app';
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, TUI_HOST_UI_CAPABILITIES } from '#/constant/app';
import { detectPendingMigration, resolveLegacySourceHome, sameLegacyPath } from '#/migration/index';
import type { TuiConfig } from '#/tui/config';
import { loadTuiConfig, TuiConfigParseError } from '#/tui/config';
Expand Down Expand Up @@ -68,6 +68,9 @@ export async function runShell(
homeDir: telemetryBootstrap.homeDir,
identity: createKimiCodeHostIdentity(version),
skillDirs: opts.skillsDirs,
// The TUI renders the mid-turn update panel; declaring it here is what
// makes the engine offer NotifyUser to this process and to no other host.
uiCapabilities: TUI_HOST_UI_CAPABILITIES,
telemetry: telemetryClient,
onOAuthRefresh: (outcome) => {
if (outcome.success) {
Expand Down
5 changes: 4 additions & 1 deletion apps/kimi-code/src/constant/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ErrorCodes } from '@moonshot-ai/kimi-code-sdk';
import { ErrorCodes, type HostUiCapability } from '@moonshot-ai/kimi-code-sdk';

import { currentKimiProfile } from '#/utils/region';

Expand All @@ -9,6 +9,9 @@ export const PROCESS_NAME = 'kimi-code';
// Used in telemetry app names and HTTP User-Agent headers.
export const CLI_USER_AGENT_PRODUCT = 'kimi-code-cli';
export const CLI_UI_MODE = 'shell';
// UI surfaces the TUI renders; declared to the engine at bootstrap so features that need a
// host-side surface (the NotifyUser update panel) are offered to this process only.
export const TUI_HOST_UI_CAPABILITIES: readonly HostUiCapability[] = ['update_panel'];
// Telemetry ui_mode for the `kimi web` host. Same product
// as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode.
export const WEB_UI_MODE = 'web';
Expand Down
181 changes: 181 additions & 0 deletions apps/kimi-code/src/tui/components/chrome/notify-panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* NotifyPanel — the model's mid-turn updates, shown right above the input
* area (below the Todo panel).
*
* Fed by `NotifyUser` tool calls: every call is one entry, and the entries
* of the current turn stack chronologically, newest at the bottom, each
* rendered as Markdown behind a marker (`◆` newest, `◇` earlier). The body
* is a window of {@link NOTIFY_PANEL_MAX_BODY_LINES} rows that follows the
* tail, so the latest updates are always in view; `Ctrl+N` pages up through
* earlier rows and wraps back to the tail, and a new update snaps the view
* back to the tail. The host clears the panel when the next turn starts, so
* it never mixes turns; a finished turn only dims the title.
*/

import type { Component } from '@moonshot-ai/pi-tui';
import { Markdown, truncateToWidth } from '@moonshot-ai/pi-tui';
import chalk from 'chalk';

import { NOTIFY_PANEL_MAX_BODY_LINES } from '#/tui/constant/rendering';
import { currentTheme } from '#/tui/theme';
import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme';
import { createMarkdownOptions } from '#/tui/utils/markdown-options';

const BODY_INDENT = ' ';
/** `◆ ` in front of an entry's first row; continuation rows get the same width of spaces. */
const MARKER_INDENT = ' ';
const PAGE_KEY_HINT = 'ctrl+n earlier';

interface NotifyEntry {
readonly id: string;
text: string;
}

export class NotifyPanelComponent implements Component {
private readonly entries: NotifyEntry[] = [];
/** First body row in view; `null` follows the tail. */
private scrollTop: number | null = null;
private ended = false;
/** Total stacked body rows from the last render; drives paging. */
private lastTotalRows = 0;

/**
* Add or update an entry. A repeated `id` updates the entry in place (the
* same tool call streaming its `message`); a new id appends and snaps the
* view back to the tail.
*/
upsert(id: string, text: string): void {
const existing = this.entries.find((entry) => entry.id === id);
if (existing !== undefined) {
existing.text = text;
return;
}
this.entries.push({ id, text });
this.scrollTop = null;
this.ended = false;
}

clear(): void {
this.entries.length = 0;
this.scrollTop = null;
this.ended = false;
this.lastTotalRows = 0;
}

/**
* Drop one entry — a call that was denied, failed, or never completed. The
* view snaps back to the tail. Returns false when the id is unknown.
*/
remove(id: string): boolean {
const index = this.entries.findIndex((entry) => entry.id === id);
if (index === -1) return false;
this.entries.splice(index, 1);
this.scrollTop = null;
if (this.entries.length === 0) this.lastTotalRows = 0;
return true;
}

isEmpty(): boolean {
return this.entries.length === 0;
}

getEntries(): readonly { readonly id: string; readonly text: string }[] {
return this.entries.map((entry) => ({ id: entry.id, text: entry.text }));
}

/** The turn that produced these updates has ended; keep them, dim the title. */
setEnded(ended: boolean): void {
this.ended = ended;
}

/** True when the stacked rows overflow the window, so Ctrl+N has somewhere to go. */
hasMorePages(): boolean {
return this.lastTotalRows > NOTIFY_PANEL_MAX_BODY_LINES;
}

/**
* Page up one window through earlier rows; from the top, wrap back to the
* tail. Returns false when everything already fits so the key can fall
* through.
*/
nextPage(): boolean {
if (!this.hasMorePages()) return false;
const cap = NOTIFY_PANEL_MAX_BODY_LINES;
const tailStart = this.lastTotalRows - cap;
const current = this.scrollTop ?? tailStart;
if (current <= 0) {
this.scrollTop = null;
return true;
}
this.scrollTop = Math.max(0, current - cap);
return true;
}

invalidate(): void {}

render(width: number): string[] {
if (this.entries.length === 0) return [];
const c = currentTheme.palette;
const rows = this.renderRows(width);
this.lastTotalRows = rows.length;

const cap = NOTIFY_PANEL_MAX_BODY_LINES;
const tailStart = Math.max(0, rows.length - cap);
let start = this.scrollTop ?? tailStart;
if (start > tailStart) {
start = tailStart;
this.scrollTop = null;
}
const shown = rows.slice(start, start + cap);
const later = rows.length - (start + shown.length);

const lines: string[] = [chalk.hex(c.border)('─'.repeat(width)), this.renderTitle()];
if (start > 0) {
lines.push(chalk.hex(c.textDim)(`${BODY_INDENT}… ${String(start)} earlier lines`));
}
lines.push(...shown);
if (later > 0) {
lines.push(chalk.hex(c.textDim)(`${BODY_INDENT}… ${String(later)} later lines`));
}
return lines.map((line) => truncateToWidth(line, width));
}

/** Every entry's Markdown rows, stacked in order, each behind its marker. */
private renderRows(width: number): string[] {
const c = currentTheme.palette;
const markdownWidth = Math.max(1, width - BODY_INDENT.length - MARKER_INDENT.length);
const rows: string[] = [];
for (const [index, entry] of this.entries.entries()) {
const newest = index === this.entries.length - 1;
const marker = newest ? chalk.hex(c.primary)('◆') : chalk.hex(c.textDim)('◇');
const body = new Markdown(
entry.text.trim(),
Comment on lines +148 to +152

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound or cache off-screen update rendering

In a long turn with many updates, every TUI paint recreates a Markdown component and renders every accumulated entry before slicing the result down to the eight visible rows. Because neither the schema nor this panel caps retained message size/count, a verbose or repeatedly updating model can make spinner-driven paints repeatedly process a large, entirely off-screen history, causing avoidable CPU usage and UI lag; cap retained updates or cache rendered rows and only invalidate changed entries.

Useful? React with 👍 / 👎.

0,
0,
createMarkdownTheme(),
undefined,
createMarkdownOptions(),
).render(markdownWidth);
for (const [i, row] of body.entries()) {
rows.push(i === 0 ? `${BODY_INDENT}${marker} ${row}` : `${BODY_INDENT}${MARKER_INDENT}${row}`);
}
}
return rows;
}

private renderTitle(): string {
const c = currentTheme.palette;
const marker = this.ended ? '◇' : '◆';
const label =
this.entries.length > 1 ? `Updates (${String(this.entries.length)})` : 'Update';
const title = `${BODY_INDENT}${marker} ${label}`;
const styledTitle = this.ended
? chalk.hex(c.textDim).bold(title)
: chalk.hex(c.primary).bold(title);
const hints: string[] = [];
if (this.hasMorePages()) hints.push(PAGE_KEY_HINT);
if (this.ended) hints.push('turn ended · next message clears');
const hint = hints.length > 0 ? chalk.hex(c.textDim)(` · ${hints.join(' · ')}`) : '';
return `${styledTitle}${hint}`;
}
}
8 changes: 8 additions & 0 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export class CustomEditor extends Editor {
public onCtrlB?: () => boolean;
/** Return `true` to consume Ctrl+T (the todo list had overflow to toggle); return `false`/`undefined` to fall through to the editor default. */
public onToggleTodoExpand?: () => boolean;
/** Return `true` to consume Ctrl+N (the update panel had another page); return `false`/`undefined` to fall through to the editor default. */
public onCycleNotifyPage?: () => boolean;
public onUndo?: () => void;
public onTextPaste?: () => void;
/**
Expand Down Expand Up @@ -483,6 +485,12 @@ export class CustomEditor extends Editor {
if (this.onToggleTodoExpand?.() === true) return;
}

if (matchesKey(normalized, Key.ctrl('n'))) {
// Only consume the key when the update panel has another page to show;
// otherwise fall through to the editor default.
if (this.onCycleNotifyPage?.() === true) return;
}

if (matchesKey(normalized, 'shift+tab')) {
this.onShiftTab?.();
return;
Expand Down
48 changes: 46 additions & 2 deletions apps/kimi-code/src/tui/components/messages/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { isAbsolute, relative, sep } from 'node:path';

import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui';
import { Container, Markdown, Spacer, Text, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui';
import type { Component, TUI } from '@moonshot-ai/pi-tui';
import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight';
import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview';
Expand All @@ -26,6 +26,7 @@ import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme';
import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';
import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk';
import { appendStreamingArgsPreview } from '#/tui/utils/event-payload';
import { createMarkdownOptions } from '#/tui/utils/markdown-options';
import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name';
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
import { formatTokenCount } from '#/utils/usage/usage-format';
Expand Down Expand Up @@ -293,7 +294,7 @@ function unescapeJsonString(s: string): string {
* real newline we can highlight. Returns `undefined` if the field hasn't
* started streaming yet.
*/
function extractPartialStringField(text: string, key: string): string | undefined {
export function extractPartialStringField(text: string, key: string): string | undefined {
const opener = new RegExp(`"${key}"\\s*:\\s*"`);
const match = opener.exec(text);
if (match === null) return undefined;
Expand Down Expand Up @@ -459,6 +460,7 @@ export function extractKeyArgumentDetail(
// Prefer the short `description` so the header preview never spills a
// multi-line `prompt` into the TUI chrome.
Agent: ['description', 'prompt'],
NotifyUser: ['message'],
};

// Glob: concatenate multiple args into a single summary so the header
Expand Down Expand Up @@ -1527,6 +1529,31 @@ export class ToolCallComponent extends Container {
return `${bullet}${currentTheme.boldFg(tone, label)}`;
}

if (toolCall.name === 'NotifyUser') {
// The update itself lives in the panel above the input box; the card
// is the durable trace in the transcript, so the header carries the
// first line and ctrl+o shows the whole message.
if (isTruncated) {
// max_tokens cut the arguments short: the call never ran and the
// panel entry was dropped, so the card must not read as in flight.
return `${bullet}${currentTheme.boldFg('error', 'Update cut off')}${currentTheme.dim(' (arguments truncated by max_tokens)')}`;
}
const label = isFinished
? isError
? 'Could not send you an update'
: 'Sent you an update'
: 'Sending you an update';
Comment thread
RealKai42 marked this conversation as resolved.
const tone = isError ? 'error' : 'primary';
const preview = extractKeyArgumentDetail(toolCall.name, toolCall.args, this.workspaceDir);
const head = `${bullet}${currentTheme.boldFg(tone, label)}`;
if (preview === null) return head;
return {
head: `${head}${currentTheme.dim(' (')}`,
flex: { text: preview.text, style: dimHeaderStyle, keep: 'head' },
tail: currentTheme.dim(')'),
};
}

if (toolCall.name === 'Bash') {
// The collapsed card is this header plus one outcome row, so the header
// carries the command's first line; the full command and its output only
Expand Down Expand Up @@ -2045,6 +2072,17 @@ export class ToolCallComponent extends Container {
);
return;
}
if (name === 'NotifyUser') {
// Collapsed: header only (the panel shows the live text). Expanded:
// the full message as Markdown, indented under the header.
if (!this.expanded) return;
const message = str(this.toolCall.args['message']).trim();
if (message.length === 0) return;
this.addChild(
new Markdown(message, 2, 0, this.markdownTheme, undefined, createMarkdownOptions()),
);
return;
}
if (this.result === undefined && this.toolCall.streamingArguments !== undefined) {
this.buildStreamingPreview(this.toolCall.streamingArguments);
return;
Expand Down Expand Up @@ -2282,6 +2320,12 @@ export class ToolCallComponent extends Container {
return;
}

// NotifyUser: the message is the call's argument (rendered by
// buildCallPreview when expanded); the acknowledgement output is noise.
if (this.toolCall.name === 'NotifyUser' && !result.is_error) {
return;
}

if (
this.toolCall.name === 'AskUserQuestion' &&
this.toolCall.args['background'] !== true &&
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/src/tui/constant/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const RESULT_PREVIEW_LINES = 3;
export const SHELL_OUTPUT_PREVIEW_LINES = 10;
export const THINKING_PREVIEW_LINES = 2;
export const COMMAND_PREVIEW_LINES = 10;
// Body rows the mid-turn update panel (NotifyUser) shows per page.
export const NOTIFY_PANEL_MAX_BODY_LINES = 8;

// Cap on the step-retry detail line under the waiting spinner, so huge
// provider error bodies (occasionally whole HTML error pages) can't flood
Expand Down
9 changes: 9 additions & 0 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export interface EditorKeyboardHost {
updateQueueDisplay(): void;
toggleToolOutputExpansion(): void;
toggleTodoPanelExpansion(): void;
/** Returns `true` when the update panel had another page to show. */
cycleNotifyPanelPage(): boolean;
detachCurrentForegroundTask(): void;
cancelRunningShellCommand(): void;
hideSessionPicker(): void;
Expand Down Expand Up @@ -314,6 +316,13 @@ export class EditorKeyboardController {
return true;
};

editor.onCycleNotifyPage = (): boolean => {
if (!host.cycleNotifyPanelPage()) return false;
this.clearPendingExit();
host.track('shortcut_notify_page');
return true;
};

editor.onCtrlS = () => {
if (
host.state.appState.streamingPhase === 'idle' ||
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,9 @@ export class SessionEventHandler {
}
this.clearAgentSwarmProgress();
this.host.streamingUI.resetToolUi();
// A new turn closes the previous turn's update panel; the first
// NotifyUser call of this turn reopens it.
this.host.streamingUI.clearNotifyPanel();
Comment on lines +334 to +336

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear updates when a local shell turn starts

After a turn ends with updates visible, submitting a local ! command goes through runShellCommandFromInput, which appends a new user turn boundary but emits no turn.started event, so this is never called and the previous turn's panel remains above the input throughout the shell command despite saying the next message clears it. Replay does clear the panel for the same shell_command input via advanceTurn, so the live and resumed views also disagree; clear it when the local shell command begins.

Useful? React with 👍 / 👎.

this.host.streamingUI.setStep(0);
this.host.patchLivePane({
mode: 'waiting',
Expand Down Expand Up @@ -383,6 +386,7 @@ export class SessionEventHandler {
this.host.streamingUI.setTodoList([]);
}
this.host.streamingUI.resetToolUi();
this.host.streamingUI.markNotifyPanelEnded();
Comment thread
RealKai42 marked this conversation as resolved.
this.host.streamingUI.finalizeTurn(sendQueued);
this.host.recordSessionActivity();
this.renderPendingModelBlockedFallback();
Expand Down
Loading
Loading