-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(kimi-code): NotifyUser tool and mid-turn update panel #3524
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/compact-tool-cards
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`. |
| 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(), | ||
| 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}`; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After a turn ends with updates visible, submitting a local Useful? React with 👍 / 👎. |
||
| this.host.streamingUI.setStep(0); | ||
| this.host.patchLivePane({ | ||
| mode: 'waiting', | ||
|
|
@@ -383,6 +386,7 @@ export class SessionEventHandler { | |
| this.host.streamingUI.setTodoList([]); | ||
| } | ||
| this.host.streamingUI.resetToolUi(); | ||
| this.host.streamingUI.markNotifyPanelEnded(); | ||
|
RealKai42 marked this conversation as resolved.
|
||
| this.host.streamingUI.finalizeTurn(sendQueued); | ||
| this.host.recordSessionActivity(); | ||
| this.renderPendingModelBlockedFallback(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In a long turn with many updates, every TUI paint recreates a
Markdowncomponent 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 👍 / 👎.