From 4aa229d295e1b5bdc6f7ffa7767ef848428415d0 Mon Sep 17 00:00:00 2001 From: ZouR-Ma <2605315944@qq.com> Date: Tue, 3 Nov 2026 11:43:29 +0800 Subject: [PATCH] feat(cli): report ambient warnings as a footer badge Startup diagnostics, MCP import problems and warnings raised during a turn no longer interrupt the Step transcript. The footer counts them as a warning badge, and alt+i or /warnings opens the session's list (newest first, c clears, Esc closes). The list is per session and kept in memory only. Warnings that answer the user's own input stay inline: anything published before the kernel accepts the input (a command handler, the input hooks before a turn) is treated as its reply. Every user entry point, including Shift+Tab, Alt+Enter and launch messages, now submits through promptUserInput to mark that window. The Pi presentation keeps all warnings in the transcript. Add the app.warnings.open keybinding and the /warnings command. On macOS the footer hint skips Option+letter bindings, which terminals often treat as dead keys, and offers /warnings instead. --- 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, 707 insertions(+), 29 deletions(-) create mode 100644 apps/cli/src/ui/view/dialogs/warning-list.ts create mode 100644 apps/cli/test/interactive-mode-user-input-replies.test.ts create 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 180975677..94d915b8a 100644 --- a/apps/cli/src/ui/interactive-mode.ts +++ b/apps/cli/src/ui/interactive-mode.ts @@ -83,6 +83,7 @@ import { openBrowser, openStepSession, ProjectTrustStore, + type PromptOptions, parseChangelog, parseGitUrl, parseSkillBlock, @@ -169,7 +170,7 @@ import { registerTaskUpdateCall, resetTaskUpdateTranscript, } from "./runtime/task-update-transcript.ts"; -import { FooterComponent, formatTokens } from "./view/chrome/footer.ts"; +import { FooterComponent, formatTokens, warningListHint } from "./view/chrome/footer.ts"; import { BranchSummaryStatusIndicator, IdleStatus, @@ -200,6 +201,7 @@ 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"; @@ -371,6 +373,9 @@ 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, @@ -625,6 +630,14 @@ 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; @@ -783,6 +796,8 @@ 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(); @@ -1354,8 +1369,8 @@ export class InteractiveMode { } if (mcpImportNotice) this.showNotice(mcpImportNotice); - if (mcpImportError) this.showWarning(mcpImportError); - if (themePromptError) this.showWarning(themePromptError); + if (mcpImportError) this.recordWarning(mcpImportError); + if (themePromptError) this.recordWarning(themePromptError); if (!this.options.disableBackgroundServices) { const controller = new AbortController(); @@ -1384,7 +1399,7 @@ export class InteractiveMode { // Check tmux keyboard setup asynchronously this.checkTmuxKeyboardSetup().then((warning) => { if (warning) { - this.showWarning(warning); + this.recordWarning(warning); } }); @@ -1402,14 +1417,14 @@ export class InteractiveMode { if (diagnostic.type === "error") { this.showError(diagnostic.message); } else if (diagnostic.type === "warning") { - this.showWarning(diagnostic.message); + this.recordWarning(diagnostic.message); } else { this.showStatus(diagnostic.message); } } if (migratedProviders && migratedProviders.length > 0) { - this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); + this.recordWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); } const modelsJsonError = this.session.modelRuntime.getError(); @@ -1418,15 +1433,16 @@ export class InteractiveMode { } if (modelFallbackMessage) { - this.showWarning(modelFallbackMessage); + this.recordWarning(modelFallbackMessage); } void this.maybeWarnAboutAnthropicSubscriptionAuth(); - // Process initial messages + // Process initial messages. They are the user's input from the command + // line, so their replies answer inline like anything typed in the editor. if (initialMessage) { try { - await this.session.prompt(initialMessage, { images: initialImages }); + await this.promptUserInput(initialMessage, { images: initialImages }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); @@ -1436,7 +1452,7 @@ export class InteractiveMode { if (initialMessages) { for (const message of initialMessages) { try { - await this.session.prompt(message); + await this.promptUserInput(message); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); @@ -1453,7 +1469,7 @@ export class InteractiveMode { const { text, images } = await resolvePastedImages(this.pastedImages, userInput, { autoResizeImages: this.settingsManager.getImageAutoResize(), }); - await this.session.prompt(text, { images: images.length ? images : undefined }); + await this.promptUserInput(text, { images: images.length ? images : undefined }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); @@ -3265,7 +3281,11 @@ export class InteractiveMode { if (type === "error") { this.showError(message); } else if (type === "warning") { - this.showWarning(message); + // 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); } else if (options?.echoesInput) { this.showInputEcho(message); } else { @@ -4125,7 +4145,7 @@ export class InteractiveMode { if (this.isExtensionCommand(text)) { this.editor.addToHistory?.(text); this.editor.setText(""); - await this.session.prompt(text); + await this.promptUserInput(text); } else { const { text: message, images } = await resolvePastedImages(this.pastedImages, text, { autoResizeImages: this.settingsManager.getImageAutoResize(), @@ -4143,7 +4163,7 @@ export class InteractiveMode { const { text: message, images } = await resolvePastedImages(this.pastedImages, text, { autoResizeImages: this.settingsManager.getImageAutoResize(), }); - await this.session.prompt(message, { + await this.promptUserInput(message, { streamingBehavior: "followUp", images: images.length ? images : undefined, }); @@ -4303,6 +4323,80 @@ 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. * @@ -6599,6 +6693,7 @@ 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"); @@ -6645,6 +6740,7 @@ 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 702db137b..45bd93df3 100644 --- a/apps/cli/src/ui/runtime/context.ts +++ b/apps/cli/src/ui/runtime/context.ts @@ -28,6 +28,7 @@ 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"; @@ -187,6 +188,14 @@ 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 db83f3dd9..beafee9ca 100644 --- a/apps/cli/src/ui/runtime/input-dispatch.ts +++ b/apps/cli/src/ui/runtime/input-dispatch.ts @@ -182,7 +182,9 @@ 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")) { - void ctx.session.prompt("/permissions --cycle"); + // 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"); return; } ctx.cycleThinkingLevel(); @@ -215,6 +217,7 @@ 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()); @@ -332,6 +335,11 @@ 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(""); @@ -434,7 +442,7 @@ export function wireSubmitHandler(ctx: RuntimeContext): void { if (ctx.isExtensionCommand(text)) { ctx.editor.addToHistory?.(text); ctx.editor.setText(""); - await ctx.session.prompt(text); + await ctx.promptUserInput(text); } else { const { text: message, images } = await resolvePastedImages(ctx.pastedImages, text, { autoResizeImages: ctx.settingsManager.getImageAutoResize(), @@ -452,7 +460,10 @@ export function wireSubmitHandler(ctx: RuntimeContext): void { const { text: message, images } = await resolvePastedImages(ctx.pastedImages, text, { autoResizeImages: ctx.settingsManager.getImageAutoResize(), }); - await ctx.session.prompt(message, { streamingBehavior: "steer", images: images.length ? images : undefined }); + await ctx.promptUserInput(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 2655d9c56..933ab0ca6 100644 --- a/apps/cli/src/ui/view/chrome/footer.ts +++ b/apps/cli/src/ui/view/chrome/footer.ts @@ -1,8 +1,19 @@ import { isAbsolute, relative, resolve, sep } from "node:path"; import type { AgentSession, ReadonlyFooterDataProvider } from "@step-harness/coding-agent"; -import { addUsageToTotals, createUsageTotals, theme } from "@step-harness/coding-agent"; +import { addUsageToTotals, createUsageTotals, formatKeyText, 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. @@ -69,6 +80,8 @@ 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, @@ -81,12 +94,22 @@ 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 { @@ -391,16 +414,27 @@ export class FooterComponent implements Component { const left = segments.join(muted(" · ")); const usage = this.session.getContextUsage(); - - if (usage?.percent === null || usage?.percent === undefined) { - return [truncateToWidth(left, safeWidth, "")]; + 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); } - 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 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 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 new file mode 100644 index 000000000..b54c12fd3 --- /dev/null +++ b/apps/cli/src/ui/view/dialogs/warning-list.ts @@ -0,0 +1,118 @@ +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 d2cb11fbf..cf8b01e87 100644 --- a/apps/cli/test/footer-width.test.ts +++ b/apps/cli/test/footer-width.test.ts @@ -2,7 +2,8 @@ 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 } from "../src/ui/view/chrome/footer.ts"; +import { FooterComponent, formatCwdForFooter, warningListHint } from "../src/ui/view/chrome/footer.ts"; +import { formatKeyText } from "../../../packages/coding-agent/src/render/keybinding-hints.ts"; import { initTheme } from "../../../packages/coding-agent/src/theme/theme.ts"; import { stripAnsi } from "../../../packages/coding-agent/src/utils/ansi.ts"; @@ -117,6 +118,22 @@ 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); @@ -443,6 +460,63 @@ 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 f30729b08..cee566155 100644 --- a/apps/cli/test/interactive-mode-notify-echo.test.ts +++ b/apps/cli/test/interactive-mode-notify-echo.test.ts @@ -13,6 +13,8 @@ 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; }; @@ -37,6 +39,8 @@ 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), }; @@ -92,8 +96,19 @@ describe("InteractiveMode notification routing", () => { prototype.showExtensionNotify.call(context, "careful", "warning", { echoesInput: true }); prototype.showExtensionNotify.call(context, "broken", "error", { echoesInput: true }); - expect(context.showWarning).toHaveBeenCalledWith("careful"); + // 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.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 new file mode 100644 index 000000000..1cd71493b --- /dev/null +++ b/apps/cli/test/interactive-mode-user-input-replies.test.ts @@ -0,0 +1,100 @@ +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 new file mode 100644 index 000000000..d2eaf714d --- /dev/null +++ b/apps/cli/test/interactive-mode-warning-badge.test.ts @@ -0,0 +1,212 @@ +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 b2d69381d..0d2323ae1 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,6 +31,9 @@ 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 678aa5c3a..954f14bc9 100644 --- a/docs/step-goal-ui.md +++ b/docs/step-goal-ui.md @@ -6,7 +6,9 @@ - 未完成目标拒绝被覆盖时,按状态提示 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。 +- 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 行。 - 输入框首个 `/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 3e4fdcb6c..78086b6c2 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -157,6 +157,7 @@ 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 7de60f90b..9e5d1dac0 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -22,6 +22,7 @@ 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; @@ -119,6 +120,7 @@ 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 16e978e21..1a441ec0f 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -45,6 +45,7 @@ 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",