From efd3fbabb239da59eaa731e97fc75daccb164b7d Mon Sep 17 00:00:00 2001 From: ZouR-Ma <2605315944@qq.com> Date: Tue, 3 Nov 2026 15:56:43 +0800 Subject: [PATCH] revert(cli): drop the ambient warning footer badge Reverts PR #345 (4aa229d). The badge is shelved while it is reworked together with the web badge. --- apps/cli/src/ui/interactive-mode.ts | 124 ++-------- apps/cli/src/ui/runtime/context.ts | 9 - apps/cli/src/ui/runtime/input-dispatch.ts | 17 +- apps/cli/src/ui/view/chrome/footer.ts | 52 +---- apps/cli/src/ui/view/dialogs/warning-list.ts | 118 ---------- apps/cli/test/footer-width.test.ts | 76 +------ .../test/interactive-mode-notify-echo.test.ts | 17 +- ...nteractive-mode-user-input-replies.test.ts | 100 --------- .../interactive-mode-warning-badge.test.ts | 212 ------------------ .../7829-invalid-settings-warning.test.ts | 3 - docs/step-goal-ui.md | 4 +- packages/coding-agent/docs/keybindings.md | 1 - packages/coding-agent/src/core/keybindings.ts | 2 - .../coding-agent/src/core/slash-commands.ts | 1 - 14 files changed, 29 insertions(+), 707 deletions(-) delete mode 100644 apps/cli/src/ui/view/dialogs/warning-list.ts delete mode 100644 apps/cli/test/interactive-mode-user-input-replies.test.ts delete mode 100644 apps/cli/test/interactive-mode-warning-badge.test.ts diff --git a/apps/cli/src/ui/interactive-mode.ts b/apps/cli/src/ui/interactive-mode.ts index 6f3a251c3..fff1f3341 100644 --- a/apps/cli/src/ui/interactive-mode.ts +++ b/apps/cli/src/ui/interactive-mode.ts @@ -83,7 +83,6 @@ import { openBrowser, openStepSession, ProjectTrustStore, - type PromptOptions, parseChangelog, parseGitUrl, parseSkillBlock, @@ -170,7 +169,7 @@ import { registerTaskUpdateCall, resetTaskUpdateTranscript, } from "./runtime/task-update-transcript.ts"; -import { FooterComponent, formatTokens, warningListHint } from "./view/chrome/footer.ts"; +import { FooterComponent, formatTokens } from "./view/chrome/footer.ts"; import { BranchSummaryStatusIndicator, IdleStatus, @@ -201,7 +200,6 @@ import { ThinkingSelectorComponent } from "./view/dialogs/thinking-selector.ts"; import { TreeSelectorComponent } from "./view/dialogs/tree-selector.ts"; import { TrustSelectorComponent } from "./view/dialogs/trust-selector.ts"; import { UserMessageSelectorComponent } from "./view/dialogs/user-message-selector.ts"; -import { type SessionWarning, WarningListComponent, warningListCapacity } from "./view/dialogs/warning-list.ts"; import { CustomEntryComponent } from "./view/editor/custom-entry.ts"; import { STEP_EDITOR_PLACEHOLDER, StepEditor } from "./view/editor/step-editor.ts"; import { AssistantMessageComponent } from "./view/transcript/assistant-message.ts"; @@ -373,9 +371,6 @@ type LoginProviderCompletionOption = { const AUTH_TYPE_ORDER = { oauth: 0, api_key: 1 } satisfies Record; -/** Warnings one host keeps; older ones fall off so a long session cannot grow this list. */ -const WARNING_LOG_LIMIT = 50; - function createFuzzyAutocompleteItems( items: T[], prefix: string, @@ -630,14 +625,6 @@ export class InteractiveMode { // Shutdown state private shutdownRequested = false; - // Warnings the Step footer counts. They never enter the transcript or the - // session file: the badge and its list are the only place they surface. - private warningLog: Array = []; - // User inputs the kernel is still accepting (a command's handler, the input - // hooks before a turn). Warnings they publish are replies and stay inline; - // see promptUserInput and showExtensionNotify. - private acceptingUserInput = 0; - // Extension UI state private extensionDialogsBlocked = false; private extensionSelector: ExtensionSelectorComponent | undefined = undefined; @@ -796,8 +783,6 @@ export class InteractiveMode { options.tuiStyle === "step" && this.session.extensionRunner.getCommand("permissions") ? keyText("app.thinking.cycle") : undefined, - warningCount: () => this.warningsForCurrentSession().length, - warningListKey: () => warningListHint(this.keybindings.getKeys("app.warnings.open")), }); this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); this.footerContainer = new Container(); @@ -1369,8 +1354,8 @@ export class InteractiveMode { } if (mcpImportNotice) this.showNotice(mcpImportNotice); - if (mcpImportError) this.recordWarning(mcpImportError); - if (themePromptError) this.recordWarning(themePromptError); + if (mcpImportError) this.showWarning(mcpImportError); + if (themePromptError) this.showWarning(themePromptError); if (!this.options.disableBackgroundServices) { const controller = new AbortController(); @@ -1399,7 +1384,7 @@ export class InteractiveMode { // Check tmux keyboard setup asynchronously this.checkTmuxKeyboardSetup().then((warning) => { if (warning) { - this.recordWarning(warning); + this.showWarning(warning); } }); @@ -1417,14 +1402,14 @@ export class InteractiveMode { if (diagnostic.type === "error") { this.showError(diagnostic.message); } else if (diagnostic.type === "warning") { - this.recordWarning(diagnostic.message); + this.showWarning(diagnostic.message); } else { this.showStatus(diagnostic.message); } } if (migratedProviders && migratedProviders.length > 0) { - this.recordWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); + this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); } const modelsJsonError = this.session.modelRuntime.getError(); @@ -1433,16 +1418,15 @@ export class InteractiveMode { } if (modelFallbackMessage) { - this.recordWarning(modelFallbackMessage); + this.showWarning(modelFallbackMessage); } void this.maybeWarnAboutAnthropicSubscriptionAuth(); - // Process initial messages. They are the user's input from the command - // line, so their replies answer inline like anything typed in the editor. + // Process initial messages if (initialMessage) { try { - await this.promptUserInput(initialMessage, { images: initialImages }); + await this.session.prompt(initialMessage, { images: initialImages }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); @@ -1452,7 +1436,7 @@ export class InteractiveMode { if (initialMessages) { for (const message of initialMessages) { try { - await this.promptUserInput(message); + await this.session.prompt(message); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); @@ -1469,7 +1453,7 @@ export class InteractiveMode { const { text, images } = await resolvePastedImages(this.pastedImages, userInput, { autoResizeImages: this.settingsManager.getImageAutoResize(), }); - await this.promptUserInput(text, { images: images.length ? images : undefined }); + await this.session.prompt(text, { images: images.length ? images : undefined }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); @@ -3287,11 +3271,7 @@ export class InteractiveMode { if (type === "error") { this.showError(message); } else if (type === "warning") { - // A warning raised while the kernel accepts the user's input is its answer - // ("Usage: ...", a refusal), so it belongs where the user is looking. - // Anything later arrived on its own and only counts in the badge. - if (this.acceptingUserInput > 0) this.showWarning(message); - else this.recordWarning(message); + this.showWarning(message); } else if (options?.echoesInput) { this.showInputEcho(message); } else { @@ -4151,7 +4131,7 @@ export class InteractiveMode { if (this.isExtensionCommand(text)) { this.editor.addToHistory?.(text); this.editor.setText(""); - await this.promptUserInput(text); + await this.session.prompt(text); } else { const { text: message, images } = await resolvePastedImages(this.pastedImages, text, { autoResizeImages: this.settingsManager.getImageAutoResize(), @@ -4169,7 +4149,7 @@ export class InteractiveMode { const { text: message, images } = await resolvePastedImages(this.pastedImages, text, { autoResizeImages: this.settingsManager.getImageAutoResize(), }); - await this.promptUserInput(message, { + await this.session.prompt(message, { streamingBehavior: "followUp", images: images.length ? images : undefined, }); @@ -4329,80 +4309,6 @@ export class InteractiveMode { this.redraw.requestRender(); } - /** - * Report a warning nobody asked for: startup diagnostics, a background - * failure. Step counts it in the footer badge so it does not push the - * conversation up the screen; the Pi presentation keeps its transcript row. - * A warning that answers the user's own action uses showWarning instead. - */ - recordWarning(warningMessage: string): void { - if (this.presentation !== "step") { - this.showWarning(warningMessage); - return; - } - this.warningLog.push({ message: warningMessage, at: Date.now(), sessionId: this.sessionManager.getSessionId() }); - if (this.warningLog.length > WARNING_LOG_LIMIT) { - this.warningLog.splice(0, this.warningLog.length - WARNING_LOG_LIMIT); - } - this.redraw.requestRender(); - } - - async promptUserInput(text: string, options?: PromptOptions): Promise { - // The kernel reports acceptance once a command's handler returned, or once - // a message passed its input hooks and is about to run. Until then every - // warning answers this input; the turn that follows is ambient. - this.acceptingUserInput += 1; - let accepting = true; - const accepted = () => { - if (!accepting) return; - accepting = false; - this.acceptingUserInput -= 1; - }; - try { - await this.session.prompt(text, { - ...options, - preflightResult: (success) => { - accepted(); - options?.preflightResult?.(success); - }, - }); - } finally { - accepted(); - } - } - - /** The warnings the footer badge counts and the list dialog shows. */ - private warningsForCurrentSession(): SessionWarning[] { - const sessionId = this.sessionManager.getSessionId(); - return this.warningLog - .filter((entry) => entry.sessionId === sessionId) - .map(({ message, at }) => ({ message, at })); - } - - showWarningList(): void { - if (this.warningsForCurrentSession().length === 0) { - this.showStatus("No warnings"); - return; - } - const sessionId = this.sessionManager.getSessionId(); - this.showSelector((done) => { - const list = new WarningListComponent({ - getWarnings: () => this.warningsForCurrentSession(), - maxVisible: warningListCapacity(this.ui.terminal.rows), - onClose: () => { - done(); - this.redraw.requestRender(); - }, - onClear: () => { - this.warningLog = this.warningLog.filter((entry) => entry.sessionId !== sessionId); - done(); - this.redraw.requestRender(); - }, - }); - return { component: list, focus: list }; - }); - } - /** * A thing that succeeded and is worth a line in the transcript. * @@ -6699,7 +6605,6 @@ export class InteractiveMode { const cycleModelForward = this.getAppKeyDisplay("app.model.cycleForward"); const selectModel = this.getAppKeyDisplay("app.model.select"); const expandTools = this.getAppKeyDisplay("app.tools.expand"); - const openWarnings = this.getAppKeyDisplay("app.warnings.open"); const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle"); const externalEditor = this.getAppKeyDisplay("app.editor.external"); const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward"); @@ -6746,7 +6651,6 @@ export class InteractiveMode { | \`${cycleModelForward}\` / \`${cycleModelBackward}\` | Cycle models | | \`${selectModel}\` | Open model selector | | \`${expandTools}\` | Toggle tool output expansion | -| \`${openWarnings}\` | Show the warnings collected in this session | | \`${toggleThinking}\` | Toggle thinking block visibility | | \`${externalEditor}\` | Edit message in external editor | | \`${copyMessage}\` | Copy last assistant message | diff --git a/apps/cli/src/ui/runtime/context.ts b/apps/cli/src/ui/runtime/context.ts index 45bd93df3..702db137b 100644 --- a/apps/cli/src/ui/runtime/context.ts +++ b/apps/cli/src/ui/runtime/context.ts @@ -28,7 +28,6 @@ import type { ExtensionUIDialogOptions, InteractiveModeOptions, MarkdownTransformer, - PromptOptions, SessionEntry, } from "@step-harness/coding-agent"; import type { Container, EditorComponent, MarkdownTheme, TUI, TuiAltScreen, TuiMainScreen } from "@step-harness/pi-tui"; @@ -188,14 +187,6 @@ export interface RuntimeContext { showStatus(message: string): void; showTrustSelector(): void; showWarning(warningMessage: string): void; - /** Open the session's collected warnings behind the footer badge. */ - showWarningList(): void; - /** - * Submit what the user typed. Until the kernel has accepted it (a command's - * handler returned, a message passed its input hooks), warnings it publishes - * are its reply and stay in the transcript instead of the badge. - */ - promptUserInput(text: string, options?: PromptOptions): Promise; toggleThinkingBlockVisibility(): void; toggleToolOutputExpansion(): void; updatePendingMessagesDisplay(): void; diff --git a/apps/cli/src/ui/runtime/input-dispatch.ts b/apps/cli/src/ui/runtime/input-dispatch.ts index beafee9ca..db83f3dd9 100644 --- a/apps/cli/src/ui/runtime/input-dispatch.ts +++ b/apps/cli/src/ui/runtime/input-dispatch.ts @@ -182,9 +182,7 @@ export function wireKeyHandlers(ctx: RuntimeContext): void { // Shift+Tab for its permission preset cycle. The command is handled by // the Step extension; ordinary pi sessions retain thinking-level cycling. if (ctx.options.tuiStyle === "step" && ctx.session.extensionRunner.getCommand("permissions")) { - // The key press is the user's input: a refusal ("Full access was not - // enabled.") answers it inline instead of only counting in the badge. - void ctx.promptUserInput("/permissions --cycle"); + void ctx.session.prompt("/permissions --cycle"); return; } ctx.cycleThinkingLevel(); @@ -217,7 +215,6 @@ export function wireKeyHandlers(ctx: RuntimeContext): void { return steering.length > 0 || followUp.length > 0; }; ctx.defaultEditor.onAction("app.message.dequeue", () => ctx.handleDequeue()); - ctx.defaultEditor.onAction("app.warnings.open", () => ctx.showWarningList()); ctx.defaultEditor.onAction("app.session.new", () => ctx.handleClearCommand()); ctx.defaultEditor.onAction("app.session.tree", () => ctx.showTreeSelector()); ctx.defaultEditor.onAction("app.session.fork", () => ctx.showUserMessageSelector()); @@ -335,11 +332,6 @@ export function wireSubmitHandler(ctx: RuntimeContext): void { ctx.editor.setText(""); return; } - if (text === "/warnings") { - ctx.editor.setText(""); - ctx.showWarningList(); - return; - } if (text === "/fork") { ctx.showUserMessageSelector(); ctx.editor.setText(""); @@ -442,7 +434,7 @@ export function wireSubmitHandler(ctx: RuntimeContext): void { if (ctx.isExtensionCommand(text)) { ctx.editor.addToHistory?.(text); ctx.editor.setText(""); - await ctx.promptUserInput(text); + await ctx.session.prompt(text); } else { const { text: message, images } = await resolvePastedImages(ctx.pastedImages, text, { autoResizeImages: ctx.settingsManager.getImageAutoResize(), @@ -460,10 +452,7 @@ export function wireSubmitHandler(ctx: RuntimeContext): void { const { text: message, images } = await resolvePastedImages(ctx.pastedImages, text, { autoResizeImages: ctx.settingsManager.getImageAutoResize(), }); - await ctx.promptUserInput(message, { - streamingBehavior: "steer", - images: images.length ? images : undefined, - }); + await ctx.session.prompt(message, { streamingBehavior: "steer", images: images.length ? images : undefined }); ctx.updatePendingMessagesDisplay(); ctx.redraw.requestRender(); return; diff --git a/apps/cli/src/ui/view/chrome/footer.ts b/apps/cli/src/ui/view/chrome/footer.ts index 933ab0ca6..2655d9c56 100644 --- a/apps/cli/src/ui/view/chrome/footer.ts +++ b/apps/cli/src/ui/view/chrome/footer.ts @@ -1,19 +1,8 @@ import { isAbsolute, relative, resolve, sep } from "node:path"; import type { AgentSession, ReadonlyFooterDataProvider } from "@step-harness/coding-agent"; -import { addUsageToTotals, createUsageTotals, formatKeyText, theme } from "@step-harness/coding-agent"; +import { addUsageToTotals, createUsageTotals, theme } from "@step-harness/coding-agent"; import { type Component, sliceByColumn, truncateToWidth, visibleWidth } from "@step-harness/pi-tui"; -/** - * What the footer offers for opening the warning list. On macOS, Terminal.app - * and iTerm2 (unless Option is set to send Meta) turn Option+letter into a - * dead key, so an `alt+` binding may never reach Step there; the slash - * command works in every terminal and stands in when no binding is reachable. - */ -export function warningListHint(keys: readonly string[], platform: NodeJS.Platform = process.platform): string { - const reachable = platform === "darwin" ? keys.filter((key) => !/^alt\+[a-z]$/iu.test(key)) : keys; - return reachable.length > 0 ? formatKeyText(reachable.join("/")) : "/warnings"; -} - /** * Sanitize text for display in a single-line status. * Removes newlines, tabs, carriage returns, and other control characters. @@ -80,8 +69,6 @@ export class FooterComponent implements Component { private footerData: ReadonlyFooterDataProvider; private readonly presentation: FooterPresentation; private readonly permissionCycleKey: () => string | undefined; - private readonly warningCount: () => number; - private readonly warningListKey: () => string | undefined; constructor( session: AgentSession, @@ -94,22 +81,12 @@ export class FooterComponent implements Component { * presentational and does not have to resolve keybindings itself. */ permissionCycleKey?: () => string | undefined; - /** - * Number of ambient warnings the session is holding for the reader. - * Step's footer shows them as an attention badge instead of transcript - * rows; zero renders no badge at all. - */ - warningCount?: () => number; - /** Key that opens the warning list, shown beside the badge on wide terminals. */ - warningListKey?: () => string | undefined; } = {}, ) { this.session = session; this.footerData = footerData; this.presentation = options.presentation ?? "native"; this.permissionCycleKey = options.permissionCycleKey ?? (() => undefined); - this.warningCount = options.warningCount ?? (() => 0); - this.warningListKey = options.warningListKey ?? (() => undefined); } setSession(session: AgentSession): void { @@ -414,27 +391,16 @@ export class FooterComponent implements Component { const left = segments.join(muted(" · ")); const usage = this.session.getContextUsage(); - const warnings = Math.max(0, Math.floor(this.warningCount())); - // Step reports ambient warnings here instead of in the transcript, so this - // badge is the only sign that the session recorded one. It stays until the - // reader clears the list, and names the key that opens it when there is room. - const openKey = warnings > 0 && safeWidth >= 100 ? this.warningListKey() : undefined; - const badge = warnings > 0 ? warning(`⚠ ${warnings}`) + (openKey ? muted(` (${openKey})`) : "") : ""; - - let contextLeft = ""; - if (usage?.percent !== null && usage?.percent !== undefined) { - const usedPercent = Math.max(0, Math.min(100, usage.percent)); - const contextLeftText = `${Math.round(100 - usedPercent)}% context left`; - // Same thresholds as the native footer: >90% used turns error, >70% warning. - const contextPaint = usedPercent > 90 ? error : usedPercent > 70 ? warning : muted; - contextLeft = contextPaint(contextLeftText); + + if (usage?.percent === null || usage?.percent === undefined) { + return [truncateToWidth(left, safeWidth, "")]; } - const fullRight = [contextLeft, badge].filter((part) => part.length > 0).join(" "); - if (fullRight.length === 0) return [truncateToWidth(left, safeWidth, "")]; - // On a line too narrow for both, the context readout yields to the badge: - // the readout returns once there is room, the warnings are not shown elsewhere. - const right = badge && visibleWidth(fullRight) >= safeWidth ? badge : fullRight; + const usedPercent = Math.max(0, Math.min(100, usage.percent)); + const contextLeftText = `${Math.round(100 - usedPercent)}% context left`; + // Same thresholds as the native footer: >90% used turns error, >70% warning. + const contextPaint = usedPercent > 90 ? error : usedPercent > 70 ? warning : muted; + const right = contextPaint(contextLeftText); const leftWidth = visibleWidth(left); const contextWidth = visibleWidth(right); diff --git a/apps/cli/src/ui/view/dialogs/warning-list.ts b/apps/cli/src/ui/view/dialogs/warning-list.ts deleted file mode 100644 index b54c12fd3..000000000 --- a/apps/cli/src/ui/view/dialogs/warning-list.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { DynamicBorder, theme } from "@step-harness/coding-agent"; -import { Container, getKeybindings, Spacer, Text } from "@step-harness/pi-tui"; - -/** One warning the session recorded for the footer badge. */ -export interface SessionWarning { - readonly message: string; - readonly at: number; -} - -export interface WarningListOptions { - /** Read on every render, so a warning that arrives while the list is open appears in it. */ - readonly getWarnings: () => readonly SessionWarning[]; - /** Warnings shown at once; see warningListCapacity. */ - readonly maxVisible: number; - readonly onClose: () => void; - /** Clear the session's warnings; the owner closes the list. */ - readonly onClear: () => void; -} - -/** Upper bound on warnings shown at once, however tall the terminal is. */ -const MAX_VISIBLE = 6; -/** Rows the dialog spends on its frame, header, hint and scroll position. */ -const CHROME_ROWS = 10; -/** A warning row is a spacer plus, typically, up to two wrapped lines. */ -const ROWS_PER_WARNING = 3; - -/** How many warnings fit a terminal `rows` tall without pushing the dialog off screen. */ -export function warningListCapacity(rows: number): number { - return Math.max(1, Math.min(MAX_VISIBLE, Math.floor((rows - CHROME_ROWS) / ROWS_PER_WARNING))); -} - -/** - * The scrollable list behind the footer's warning badge. - * - * Step keeps ambient warnings out of the transcript, so this dialog is where - * their text lives. Newest first: the warning that just arrived is the one the - * reader is most likely opening the list for. - */ -export class WarningListComponent extends Container { - private readonly options: WarningListOptions; - private newestFirst: readonly SessionWarning[] = []; - /** Identifies the list last built, so a render rebuilds only when warnings changed. */ - private shownKey = ""; - /** Index into the newest-first list of the first visible row. */ - private start = 0; - - constructor(options: WarningListOptions) { - super(); - this.options = options; - this.sync(); - } - - override render(width: number): string[] { - this.sync(); - return super.render(width); - } - - handleInput(data: string): void { - const kb = getKeybindings(); - if (kb.matches(data, "tui.select.up")) { - if (this.start > 0) { - this.start -= 1; - this.rebuild(); - } - return; - } - if (kb.matches(data, "tui.select.down")) { - if (this.start + this.options.maxVisible < this.newestFirst.length) { - this.start += 1; - this.rebuild(); - } - return; - } - if (kb.matches(data, "tui.select.cancel")) { - this.options.onClose(); - return; - } - if (data === "c") { - this.options.onClear(); - } - } - - private sync(): void { - const warnings = this.options.getWarnings(); - const key = `${warnings.length}:${warnings[0]?.at ?? 0}:${warnings.at(-1)?.at ?? 0}`; - if (key === this.shownKey) return; - const grew = warnings.length - this.newestFirst.length; - this.shownKey = key; - this.newestFirst = [...warnings].reverse(); - // New warnings land at the top; keep the rows the reader scrolled to in place. - if (this.start > 0 && grew > 0) this.start += grew; - this.start = Math.max(0, Math.min(this.start, this.newestFirst.length - this.options.maxVisible)); - this.rebuild(); - } - - private rebuild(): void { - this.clear(); - const total = this.newestFirst.length; - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.bold(`Warnings (${total})`), 1, 0)); - this.addChild(new Text(theme.fg("muted", "↑↓ scroll · c clears · Esc closes"), 1, 0)); - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); - - const visible = this.newestFirst.slice(this.start, this.start + this.options.maxVisible); - for (const warning of visible) { - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("warning", "⚠ ") + warning.message, 1, 0)); - } - - if (total > visible.length) { - this.addChild(new Spacer(1)); - this.addChild( - new Text(theme.fg("muted", ` ${this.start + 1}-${this.start + visible.length} of ${total}`), 1, 0), - ); - } - } -} diff --git a/apps/cli/test/footer-width.test.ts b/apps/cli/test/footer-width.test.ts index cf8b01e87..d2cb11fbf 100644 --- a/apps/cli/test/footer-width.test.ts +++ b/apps/cli/test/footer-width.test.ts @@ -2,8 +2,7 @@ import { visibleWidth } from "@step-harness/pi-tui"; import { beforeAll, describe, expect, it, vi } from "vitest"; import type { AgentSession } from "../../../packages/coding-agent/src/core/agent-session.ts"; import type { ReadonlyFooterDataProvider } from "../../../packages/coding-agent/src/core/footer-data-provider.ts"; -import { FooterComponent, formatCwdForFooter, warningListHint } from "../src/ui/view/chrome/footer.ts"; -import { formatKeyText } from "../../../packages/coding-agent/src/render/keybinding-hints.ts"; +import { FooterComponent, formatCwdForFooter } from "../src/ui/view/chrome/footer.ts"; import { initTheme } from "../../../packages/coding-agent/src/theme/theme.ts"; import { stripAnsi } from "../../../packages/coding-agent/src/utils/ansi.ts"; @@ -118,22 +117,6 @@ describe("formatCwdForFooter", () => { }); }); -describe("warningListHint", () => { - it("offers the slash command on macOS when the binding is an Option+letter dead key", () => { - expect(warningListHint(["alt+i"], "darwin")).toBe("/warnings"); - }); - - it("keeps a reachable binding on macOS and every binding elsewhere", () => { - expect(warningListHint(["alt+i", "ctrl+shift+w"], "darwin")).toBe(formatKeyText("ctrl+shift+w")); - expect(warningListHint(["alt+i"], "linux")).toBe(formatKeyText("alt+i")); - expect(warningListHint(["alt+i"], "win32")).toBe(formatKeyText("alt+i")); - }); - - it("falls back to the slash command when nothing is bound", () => { - expect(warningListHint([], "linux")).toBe("/warnings"); - }); -}); - describe("FooterComponent width handling", () => { beforeAll(() => { initTheme(undefined, false); @@ -460,63 +443,6 @@ describe("FooterComponent width handling", () => { } } }); - - it("shows the warning count after the context readout in the Step footer", () => { - const footer = new FooterComponent(createSession({ sessionName: "" }), createFooterData(1), { - presentation: "step", - warningCount: () => 3, - }); - - const [line = ""] = footer.render(120); - const plain = stripAnsi(line); - expect(plain).toContain("⚠ 3"); - expect(plain).toMatch(/88% context left\s+⚠ 3$/u); - expect(visibleWidth(line)).toBe(120); - }); - - it("omits the warning badge when the session has none", () => { - const footer = new FooterComponent(createSession({ sessionName: "" }), createFooterData(1), { - presentation: "step", - warningCount: () => 0, - }); - - expect(stripAnsi(footer.render(120)[0] ?? "")).not.toContain("⚠"); - }); - - it("keeps the warning badge within narrow widths", () => { - const footer = new FooterComponent(createSession({ sessionName: "" }), createFooterData(1), { - presentation: "step", - warningCount: () => 12, - }); - for (const width of [20, 40, 60]) { - for (const line of footer.render(width)) { - expect(visibleWidth(line)).toBeLessThanOrEqual(width); - } - } - }); - - it("names the key that opens the warning list on a wide footer only", () => { - const footer = new FooterComponent(createSession({ sessionName: "" }), createFooterData(1), { - presentation: "step", - warningCount: () => 2, - warningListKey: () => "alt+i", - }); - - expect(stripAnsi(footer.render(120)[0] ?? "")).toMatch(/88% context left\s+⚠ 2 \(alt\+i\)$/u); - expect(stripAnsi(footer.render(80)[0] ?? "")).toMatch(/⚠ 2$/u); - }); - - it("drops the context readout before the warning badge on a narrow footer", () => { - const footer = new FooterComponent(createSession({ sessionName: "" }), createFooterData(1), { - presentation: "step", - warningCount: () => 12, - }); - - const plain = stripAnsi(footer.render(22)[0] ?? ""); - expect(plain).toMatch(/⚠ 12$/u); - expect(plain).not.toContain("context left"); - expect(visibleWidth(footer.render(22)[0] ?? "")).toBeLessThanOrEqual(22); - }); it.each([20, 60, 80, 120])("renders Lark on its own row above the unchanged operational footer at %i columns", (width) => { const statuses = new Map([["lark", "lark · connected · @周旭的飞书 CLI"]]); const footer = new FooterComponent(createSession({ sessionName: "", modelId: "model-".repeat(30) }), createFooterData(1, statuses), { presentation: "step" }); diff --git a/apps/cli/test/interactive-mode-notify-echo.test.ts b/apps/cli/test/interactive-mode-notify-echo.test.ts index cee566155..f30729b08 100644 --- a/apps/cli/test/interactive-mode-notify-echo.test.ts +++ b/apps/cli/test/interactive-mode-notify-echo.test.ts @@ -13,8 +13,6 @@ type NotifyContext = { lastStatusText: Text | undefined; showError: (message: string) => void; showWarning: (message: string) => void; - recordWarning: (message: string) => void; - acceptingUserInput: number; showStatus: (message: string) => void; showInputEcho: (message: string) => void; }; @@ -39,8 +37,6 @@ function createContext(): NotifyContext { lastStatusText: undefined, showError: vi.fn(), showWarning: vi.fn(), - recordWarning: vi.fn(), - acceptingUserInput: 0, showStatus: vi.fn(), showInputEcho: (message: string) => prototype.showInputEcho.call(context, message), }; @@ -96,19 +92,8 @@ describe("InteractiveMode notification routing", () => { prototype.showExtensionNotify.call(context, "careful", "warning", { echoesInput: true }); prototype.showExtensionNotify.call(context, "broken", "error", { echoesInput: true }); - // No command is running, so the warning arrived on its own: it goes to the badge. - expect(context.recordWarning).toHaveBeenCalledWith("careful"); - expect(context.showWarning).not.toHaveBeenCalled(); + expect(context.showWarning).toHaveBeenCalledWith("careful"); expect(context.showError).toHaveBeenCalledWith("broken"); expect(context.chatContainer.children).toHaveLength(0); }); - - it("answers the input the kernel is still accepting inline", () => { - const context = createContext(); - context.acceptingUserInput = 1; - prototype.showExtensionNotify.call(context, "Usage: /cron delete ", "warning"); - - expect(context.showWarning).toHaveBeenCalledWith("Usage: /cron delete "); - expect(context.recordWarning).not.toHaveBeenCalled(); - }); }); diff --git a/apps/cli/test/interactive-mode-user-input-replies.test.ts b/apps/cli/test/interactive-mode-user-input-replies.test.ts deleted file mode 100644 index 1cd71493b..000000000 --- a/apps/cli/test/interactive-mode-user-input-replies.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Container } from "@step-harness/pi-tui"; -import { describe, expect, it, vi } from "vitest"; -import { createHarness } from "../../../packages/coding-agent/test/suite/harness.ts"; -import { InteractiveMode } from "../src/ui/interactive-mode.ts"; -import type { RuntimeContext } from "../src/ui/runtime/context.ts"; -import { wireKeyHandlers } from "../src/ui/runtime/input-dispatch.ts"; - -/** - * Every way the user hands Step input must go through `promptUserInput`, which - * marks the window in which a warning is that input's reply and stays inline. - * An entry point that calls `session.prompt` directly sends its replies to the - * footer badge, where the user who just pressed a key never sees them. - */ - -function method(name: string): T { - return Reflect.get(InteractiveMode.prototype, name) as T; -} - -describe("user input reaches the kernel through promptUserInput", () => { - it("cycles the permission preset from Shift+Tab as user input", () => { - const actions = new Map void>(); - const ctx = { - defaultEditor: { onAction: (name: string, handler: () => void) => actions.set(name, handler) }, - ui: {}, - options: { tuiStyle: "step" }, - session: { extensionRunner: { getCommand: (name: string) => (name === "permissions" ? {} : undefined) } }, - promptUserInput: vi.fn(async () => {}), - cycleThinkingLevel: vi.fn(), - showWarningList: vi.fn(), - }; - - wireKeyHandlers(ctx as unknown as RuntimeContext); - actions.get("app.thinking.cycle")?.(); - actions.get("app.warnings.open")?.(); - - expect(ctx.promptUserInput).toHaveBeenCalledWith("/permissions --cycle"); - expect(ctx.cycleThinkingLevel).not.toHaveBeenCalled(); - expect(ctx.showWarningList).toHaveBeenCalledOnce(); - }); - - it.each([ - ["a command during compaction", { isCompacting: true, isStreaming: false }, "/goal status", undefined], - [ - "a follow-up during a turn", - { isCompacting: false, isStreaming: true }, - "also check the tests", - { streamingBehavior: "followUp", images: undefined }, - ], - ] as const)("sends %s from Alt+Enter as user input", async (_label, state, text, options) => { - const prompt = vi.fn(async () => {}); - const host = { - editor: { getText: () => text, setText: vi.fn(), addToHistory: vi.fn() }, - session: { ...state, prompt }, - isExtensionCommand: () => true, - pastedImages: { scan: () => [], reset: () => {} }, - settingsManager: { getImageAutoResize: () => false }, - promptUserInput: vi.fn(async () => {}), - updatePendingMessagesDisplay: vi.fn(), - redraw: { requestRender: vi.fn() }, - }; - - await method<(this: typeof host) => Promise>("handleFollowUp").call(host); - - expect(host.promptUserInput).toHaveBeenCalledWith(text, ...(options ? [options] : [])); - expect(prompt).not.toHaveBeenCalled(); - }); - - it("sends the launch message as user input", async () => { - const harness = await createHarness(); - try { - const promptUserInput = vi.fn(async () => {}); - const context = { - init: vi.fn(async () => {}), - options: { initialMessage: "/goal status", initialMessages: ["/cron list"] }, - chatContainer: new Container(), - outputPad: 1, - ui: { requestRender: vi.fn() }, - redraw: { requestRender: vi.fn() }, - version: "test", - showWarning: vi.fn(), - recordWarning: vi.fn(), - showError: vi.fn(), - session: harness.session, - promptUserInput, - checkForPackageUpdates: vi.fn().mockResolvedValue([]), - checkTmuxKeyboardSetup: vi.fn().mockResolvedValue(undefined), - maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(), - getUserInput: vi.fn(() => new Promise(() => {})), - }; - - void method<(this: typeof context) => Promise>("run").call(context); - - await vi.waitFor(() => expect(promptUserInput).toHaveBeenCalledTimes(2)); - expect(promptUserInput).toHaveBeenNthCalledWith(1, "/goal status", { images: undefined }); - expect(promptUserInput).toHaveBeenNthCalledWith(2, "/cron list"); - } finally { - harness.cleanup(); - } - }); -}); diff --git a/apps/cli/test/interactive-mode-warning-badge.test.ts b/apps/cli/test/interactive-mode-warning-badge.test.ts deleted file mode 100644 index d2eaf714d..000000000 --- a/apps/cli/test/interactive-mode-warning-badge.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { Container } from "@step-harness/pi-tui"; -import { beforeAll, describe, expect, it, vi } from "vitest"; -import type { PromptOptions } from "../../../packages/coding-agent/src/core/agent-session.ts"; -import { initTheme } from "../../../packages/coding-agent/src/theme/theme.ts"; -import { stripAnsi } from "../../../packages/coding-agent/src/utils/ansi.ts"; -import { InteractiveMode } from "../src/ui/interactive-mode.ts"; -import { - type SessionWarning, - WarningListComponent, - warningListCapacity, -} from "../src/ui/view/dialogs/warning-list.ts"; - -type SessionWarningRecord = SessionWarning & { sessionId: string }; - -type WarningHost = { - presentation: "native" | "step"; - chatContainer: Container; - warningLog: SessionWarningRecord[]; - acceptingUserInput: number; - redraw: { requestRender: ReturnType }; - sessionManager: { getSessionId: () => string }; - session: { prompt: ReturnType }; - showWarning(message: string): void; - recordWarning(message: string): void; - showStatus: ReturnType; -}; - -function method(name: string): T { - return Reflect.get(InteractiveMode.prototype, name) as T; -} - -const showWarning = method<(this: WarningHost, message: string) => void>("showWarning"); -const recordWarning = method<(this: WarningHost, message: string) => void>("recordWarning"); -const showExtensionNotify = method<(this: WarningHost, message: string, type?: "info" | "warning" | "error") => void>( - "showExtensionNotify", -); -const promptUserInput = method<(this: WarningHost, text: string) => Promise>("promptUserInput"); -const warningsForCurrentSession = method<(this: WarningHost) => SessionWarning[]>("warningsForCurrentSession"); - -function createHost(overrides: Partial = {}): WarningHost { - const host: WarningHost = { - presentation: "step", - chatContainer: new Container(), - warningLog: [], - acceptingUserInput: 0, - redraw: { requestRender: vi.fn() }, - sessionManager: { getSessionId: () => "session-1" }, - session: { prompt: vi.fn(async () => {}) }, - showWarning, - recordWarning, - showStatus: vi.fn(), - ...overrides, - }; - return host; -} - -describe("Step warning routing", () => { - beforeAll(() => { - initTheme("dark"); - }); - - it("counts an ambient warning in the badge instead of writing a transcript row", () => { - const host = createHost(); - - host.recordWarning("Invalid settings file /tmp/settings.json"); - - expect(host.chatContainer.children).toHaveLength(0); - expect(host.warningLog).toEqual([ - { message: "Invalid settings file /tmp/settings.json", at: expect.any(Number), sessionId: "session-1" }, - ]); - }); - - it("keeps ambient warnings in the transcript for the Pi presentation", () => { - const host = createHost({ presentation: "native" }); - - host.recordWarning("Invalid settings file /tmp/settings.json"); - - expect(host.warningLog).toHaveLength(0); - expect(stripAnsi(host.chatContainer.render(120).join("\n"))).toContain("Warning: Invalid settings file"); - }); - - it("answers the user's action inline", () => { - const host = createHost(); - - host.showWarning("Usage: /name "); - - expect(host.warningLog).toHaveLength(0); - expect(stripAnsi(host.chatContainer.render(120).join("\n"))).toContain("Warning: Usage: /name "); - }); - - it("shows a command's notify warning inline and a background one in the badge", async () => { - const host = createHost(); - host.session.prompt.mockImplementation(async () => { - showExtensionNotify.call(host, "Usage: /cron delete ", "warning"); - }); - - await promptUserInput.call(host, "/cron delete"); - showExtensionNotify.call(host, "Lark connection failed during handshake.", "warning"); - - expect(stripAnsi(host.chatContainer.render(120).join("\n"))).toContain("Usage: /cron delete "); - expect(host.warningLog.map((entry) => entry.message)).toEqual(["Lark connection failed during handshake."]); - expect(host.acceptingUserInput).toBe(0); - }); - - it("keeps an input hook's warning inline and sends the turn's warnings to the badge", async () => { - const host = createHost(); - host.session.prompt.mockImplementation(async (_text: string, options?: PromptOptions) => { - // The paused-goal reminder runs in the input hook, before the kernel accepts the message. - showExtensionNotify.call(host, "Goal g1 is paused; run /goal resume to continue it.", "warning"); - options?.preflightResult?.(true); - showExtensionNotify.call(host, "Context is almost full.", "warning"); - }); - - await promptUserInput.call(host, "refactor the parser"); - - expect(stripAnsi(host.chatContainer.render(120).join("\n"))).toContain("Goal g1 is paused"); - expect(host.warningLog.map((entry) => entry.message)).toEqual(["Context is almost full."]); - }); - - it("passes acceptance on to a caller that observes it", async () => { - const host = createHost(); - const observed = vi.fn(); - host.session.prompt.mockImplementation(async (_text: string, options?: PromptOptions) => { - options?.preflightResult?.(true); - }); - - await (promptUserInput as (this: WarningHost, text: string, options?: PromptOptions) => Promise).call( - host, - "/goal status", - { preflightResult: observed }, - ); - - expect(observed).toHaveBeenCalledWith(true); - expect(host.acceptingUserInput).toBe(0); - }); - - it("closes the reply window when the prompt fails", async () => { - const host = createHost(); - host.session.prompt.mockRejectedValue(new Error("boom")); - - await expect(promptUserInput.call(host, "/goal start")).rejects.toThrow("boom"); - expect(host.acceptingUserInput).toBe(0); - }); - - it("counts only the warnings recorded for the current session", () => { - let sessionId = "session-1"; - const host = createHost({ sessionManager: { getSessionId: () => sessionId } }); - host.recordWarning("first"); - sessionId = "session-2"; - host.recordWarning("second"); - - expect(warningsForCurrentSession.call(host).map((warning) => warning.message)).toEqual(["second"]); - sessionId = "session-1"; - expect(warningsForCurrentSession.call(host).map((warning) => warning.message)).toEqual(["first"]); - }); -}); - -describe("WarningListComponent", () => { - beforeAll(() => { - initTheme("dark"); - }); - - function createList(warnings: SessionWarning[], maxVisible = 6) { - const onClose = vi.fn(); - const onClear = vi.fn(); - const list = new WarningListComponent({ getWarnings: () => warnings, maxVisible, onClose, onClear }); - return { list, onClose, onClear, warnings }; - } - - it("lists the newest warning first and reports a clear request", () => { - const { list, onClose, onClear } = createList([ - { message: "older warning", at: 1 }, - { message: "newer warning", at: 2 }, - ]); - - const rendered = stripAnsi(list.render(80).join("\n")); - expect(rendered).toContain("Warnings (2)"); - expect(rendered.indexOf("newer warning")).toBeLessThan(rendered.indexOf("older warning")); - - list.handleInput("c"); - expect(onClear).toHaveBeenCalledOnce(); - expect(onClose).not.toHaveBeenCalled(); - }); - - it("shows a warning that arrives while the list is open", () => { - const { list, warnings } = createList([{ message: "first", at: 1 }]); - expect(stripAnsi(list.render(80).join("\n"))).toContain("Warnings (1)"); - - warnings.push({ message: "arrived later", at: 2 }); - - const rendered = stripAnsi(list.render(80).join("\n")); - expect(rendered).toContain("Warnings (2)"); - expect(rendered).toContain("arrived later"); - }); - - it("shows only as many warnings as fit and says where the window is", () => { - const warnings = Array.from({ length: 5 }, (_, index) => ({ message: `warning ${index}`, at: index })); - const { list } = createList(warnings, 2); - - const rendered = stripAnsi(list.render(80).join("\n")); - expect(rendered).toContain("warning 4"); - expect(rendered).toContain("warning 3"); - expect(rendered).not.toContain("warning 2"); - expect(rendered).toContain("1-2 of 5"); - }); - - it("sizes the list to the terminal height", () => { - expect(warningListCapacity(12)).toBe(1); - expect(warningListCapacity(19)).toBe(3); - expect(warningListCapacity(80)).toBe(6); - }); -}); diff --git a/apps/cli/test/suite/regressions/7829-invalid-settings-warning.test.ts b/apps/cli/test/suite/regressions/7829-invalid-settings-warning.test.ts index 0d2323ae1..b2d69381d 100644 --- a/apps/cli/test/suite/regressions/7829-invalid-settings-warning.test.ts +++ b/apps/cli/test/suite/regressions/7829-invalid-settings-warning.test.ts @@ -31,9 +31,6 @@ describe("issue #7829 invalid settings warning", () => { redraw: { requestRender: vi.fn() }, version: "test", showWarning: (InteractiveMode.prototype as unknown as { showWarning(message: string): void }).showWarning, - // The Pi presentation (no tuiStyle here) keeps ambient warnings in the transcript. - recordWarning: (InteractiveMode.prototype as unknown as { recordWarning(message: string): void }) - .recordWarning, session: harness.session, checkForPackageUpdates: vi.fn().mockResolvedValue([]), checkTmuxKeyboardSetup: vi.fn().mockResolvedValue(undefined), diff --git a/docs/step-goal-ui.md b/docs/step-goal-ui.md index 954f14bc9..678aa5c3a 100644 --- a/docs/step-goal-ui.md +++ b/docs/step-goal-ui.md @@ -6,9 +6,7 @@ - 未完成目标拒绝被覆盖时,按状态提示 status/resume/edit/clear;预算耗尽不提示无法使用的 resume。 - 工作行 tip 每轮一条,无计时轮播:active 80% 为 status/pause/edit/clear 引导,paused 优先 resume,无目标时加入长程任务发现入口。 - tip 的目标状态从当前会话分支中最新有效 `step-goal` 快照投影,遵循清空记录并忽略无效或其他会话数据。界面不持有第二套 Goal runtime。 -- Step footer 右侧显示 `xx% context left`,保留告警色;未知上下文时隐藏读数,不再扫描历史累计 token。 -- 没人主动要的 warning(启动诊断、MCP 导入、后台失败、回合进行中的提醒)不进 transcript,只在 footer 最右侧累计为 `⚠ N`;宽度 ≥100 列时附上打开键(默认 `alt+i`;macOS 的 Option+字母常被终端当作死键,因此 macOS 上只有非 `alt+字母` 的绑定才会显示,否则与未绑定时一样显示 `/warnings`),窄屏时先让出 context 读数、保留徽标。`alt+i` 或 `/warnings` 打开本会话的警告列表(新的在前,`c` 清空,Esc 关闭),列表按会话隔离,只存于内存。 -- 回应用户自己输入的 warning 仍直接写在 transcript:内核接受该输入之前(命令 handler 执行期间、普通消息的 input hook,例如暂停目标的 `/goal resume` 提醒)发出的 warning 都算回复;接受之后的回合内 warning 归入徽标。Pi 样式不使用徽标,所有 warning 保持原来的 transcript 行。 +- Step footer 右侧只显示 `xx% context left`,保留告警色;未知上下文时隐藏读数,不再扫描历史累计 token。 - 输入框首个 `/command` token 使用主题 accent,不给路径或正文中的斜杠着色;光标覆盖 token 时整词跳过。 - 独立 `ultracode` 关键词(不区分大小写)使用 accent,完整输入时一次高光从左扫向右,约 600ms 后保持静态;追加参数不重播,删除后重输可再次触发。光标覆盖整词时不着色或显示高光。 - 扫光使用当前主题 `accent`、`text` 和 bold,沿用 truecolor/256 色管线;最多 10 次刷新,失焦、清空、提交或退出时停止。没有常驻动画计时器。 diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 78086b6c2..3e4fdcb6c 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -157,7 +157,6 @@ This routing remains configurable through the ordinary action bindings. For exam | Keybinding id | Default | Description | |--------|---------|-------------| | `app.tools.expand` | `ctrl+o` | Collapse or expand tool output | -| `app.warnings.open` | `alt+i` | Show the warnings collected in this session | | `app.message.copy` | `ctrl+x` | Copy the selected message in `/tree`; otherwise copy the last assistant message, or the active fullscreen text selection when `fullscreenCopyOnSelect` is disabled | | `app.message.followUp` | _(unbound)_ | Queue follow-up message | | `app.message.dequeue` | `alt+up` (`alt+q` on Windows and WSL) | Restore queued messages to editor | diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 9e5d1dac0..7de60f90b 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -22,7 +22,6 @@ export interface AppKeybindings { "app.model.select": true; "app.redraw": true; "app.tools.expand": true; - "app.warnings.open": true; "app.thinking.toggle": true; "app.session.toggleNamedFilter": true; "app.editor.external": true; @@ -120,7 +119,6 @@ export const KEYBINDINGS = { description: "Repaint the screen (recovers a garbled terminal)", }, "app.tools.expand": { defaultKeys: "ctrl+o", description: "Toggle tool output" }, - "app.warnings.open": { defaultKeys: "alt+i", description: "Show warnings" }, "app.thinking.toggle": { defaultKeys: "ctrl+t", description: "Toggle thinking blocks", diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 1a441ec0f..16e978e21 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -45,7 +45,6 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "name", description: "Set session display name" }, { name: "session", description: "Show session info and stats" }, { name: "hotkeys", description: "Show all keyboard shortcuts" }, - { name: "warnings", description: "Show the warnings collected in this session" }, { name: "fork", description: "Create a new fork from a previous user message",