diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 1dd0a464a8b..4d872fa188a 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -191,6 +191,9 @@ export const Info = Schema.Struct({ thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ description: "Show or hide model reasoning", }), + tools: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ + description: "Show or hide tool calls and the assistant text that precedes them", + }), shell_output: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ description: "Show or hide raw shell tool output", }), diff --git a/packages/tui/src/mini/footer.command.tsx b/packages/tui/src/mini/footer.command.tsx index 2fa44d74a3b..3fa2d31adcb 100644 --- a/packages/tui/src/mini/footer.command.tsx +++ b/packages/tui/src/mini/footer.command.tsx @@ -27,6 +27,7 @@ import type { RunInput, RunProvider, } from "./types" +import { matchMiniVerbosity, verbosityChange, verbosityLabel } from "./verbosity" type PanelEntry = RunFooterMenuItem & { category: string @@ -79,7 +80,7 @@ type SubagentEntry = PanelEntry & { } type SettingEntry = PanelEntry & { - key: keyof MiniSettings + key: keyof MiniSettings | "verbosity" } const PANEL_PAD = 2 @@ -726,8 +727,16 @@ export function RunSettingsBody(props: { mono?: boolean animations?: boolean }) { - const [saving, setSaving] = createSignal() + const [saving, setSaving] = createSignal() const entries = createMemo(() => [ + { + category: "Transcript", + display: "Verbosity", + footer: saving() === "verbosity" ? "saving" : verbosityLabel(matchMiniVerbosity(props.settings())), + footerTone: saving() === "verbosity" ? "running" : "selection", + keywords: `verbosity quiet default everything custom noise ${verbosityLabel(matchMiniVerbosity(props.settings()))}`, + key: "verbosity", + }, { category: "Transcript", display: "Thinking", @@ -736,6 +745,14 @@ export function RunSettingsBody(props: { keywords: `thinking reasoning ${props.settings().thinking}`, key: "thinking", }, + { + category: "Transcript", + display: "Tools", + footer: saving() === "tools" ? "saving" : props.settings().tools, + footerTone: saving() === "tools" ? "running" : "selection", + keywords: `tools files skills activity work steps intermediate ${props.settings().tools}`, + key: "tools", + }, { category: "Transcript", display: "Shell", @@ -800,18 +817,21 @@ export function RunSettingsBody(props: { const change = (item: SettingEntry, direction = 1) => { if (saving()) return const spinners = Config.MiniWorkSpinner.literals - const next: MiniSettingChange = - item.key === "work_spinner" - ? { - key: "work_spinner", - value: - spinners[ - (spinners.indexOf(props.settings().work_spinner) + direction + spinners.length) % spinners.length - ]!, - } - : item.key === "mono" - ? { key: "mono", value: !props.settings().mono } - : { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" } + const next: MiniSettingChange | undefined = + item.key === "verbosity" + ? verbosityChange(props.settings(), direction < 0 ? -1 : 1) + : item.key === "work_spinner" + ? { + key: "work_spinner", + value: + spinners[ + (spinners.indexOf(props.settings().work_spinner) + direction + spinners.length) % spinners.length + ]!, + } + : item.key === "mono" + ? { key: "mono", value: !props.settings().mono } + : { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" } + if (!next) return setSaving(item.key) void Promise.resolve(props.onChange(next)) .catch(() => {}) diff --git a/packages/tui/src/mini/footer.ts b/packages/tui/src/mini/footer.ts index b86bd649954..3627a1f1d15 100644 --- a/packages/tui/src/mini/footer.ts +++ b/packages/tui/src/mini/footer.ts @@ -603,6 +603,7 @@ export class RunFooter implements FooterApi { } this.finishStartup() + if (this.miniSettings().tools === "hide" && toolTranscript(commit)) return const last = this.queue.at(-1) const merged = last ? coalesceProgressCommit(last, commit) : undefined if (merged) this.queue[this.queue.length - 1] = merged @@ -1168,6 +1169,10 @@ export class RunFooter implements FooterApi { } } +function toolTranscript(commit: StreamCommit) { + return commit.kind === "tool" || commit.partID?.startsWith("skill:") +} + /** @internal Exported for queue identity regression tests. */ export function coalesceProgressCommit(previous: StreamCommit, current: StreamCommit): StreamCommit | undefined { if ( diff --git a/packages/tui/src/mini/runtime.boot.ts b/packages/tui/src/mini/runtime.boot.ts index 3534e8bf03b..7f1b0d97e02 100644 --- a/packages/tui/src/mini/runtime.boot.ts +++ b/packages/tui/src/mini/runtime.boot.ts @@ -86,7 +86,8 @@ export async function resolveRunTuiConfig( export function resolveMiniSettings(config?: { mini?: Partial }): MiniSettings { return { - thinking: config?.mini?.thinking ?? "hide", + thinking: config?.mini?.thinking ?? "show", + tools: config?.mini?.tools ?? "hide", shell_output: config?.mini?.shell_output ?? "hide", turn_summary: config?.mini?.turn_summary ?? "show", footer: config?.mini?.footer ?? "show", diff --git a/packages/tui/src/mini/runtime.ts b/packages/tui/src/mini/runtime.ts index f30482e3f16..ad7297dcf00 100644 --- a/packages/tui/src/mini/runtime.ts +++ b/packages/tui/src/mini/runtime.ts @@ -22,6 +22,7 @@ import { } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared" +import { verbosityPreset } from "./verbosity" import type { LocalReplayRow, MiniHost, @@ -255,6 +256,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep ? async (change) => { const info = await config.update((draft) => { if (!draft.mini || typeof draft.mini !== "object") draft.mini = {} + if (change.key === "verbosity") { + Object.assign(draft.mini, verbosityPreset(change.value)) + return + } draft.mini[change.key] = change.value }) configState.current = resolveMiniSettings(info) @@ -793,6 +798,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep location: state.location, sessionID: state.sessionID, thinking: thinking(), + tools: configState.current.tools === "show", replay: input.replay, replayLimit: input.replayLimit, footer, diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index 0c14e717655..a775a244cb2 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -47,6 +47,7 @@ type StreamInput = { location?: LocationRef sessionID: string thinking: boolean + tools?: boolean replay?: boolean replayLimit?: number footer: FooterApi @@ -141,6 +142,8 @@ type State = { tools: Map toolSources: Map finishedTools: Set + toolMessages: Set + quietText: Map> skillMessages: Set shellCommands: Map shellStarted: Set @@ -488,6 +491,8 @@ export async function createSessionTransport(input: StreamInput): Promise input.tools !== false + + const rememberToolMessage = (messageID: string) => { + state.toolMessages.add(messageID) + state.quietText.delete(messageID) + } + + const bufferQuietText = (messageID: string, partID: string, text: string, replace: boolean) => { + if (!text || state.toolMessages.has(messageID)) return + const parts = state.quietText.get(messageID) ?? [] + const current = parts.find((part) => part.partID === partID) + if (current) { + current.text = replace ? text : current.text + text + return + } + parts.push({ partID, text }) + state.quietText.set(messageID, parts) + } + + const flushQuietText = (messageID: string) => { + const parts = state.quietText.get(messageID) + state.quietText.delete(messageID) + if (!parts || state.toolMessages.has(messageID)) return + write( + parts + .filter((part) => part.text) + .map((part) => ({ + kind: "assistant" as const, + source: "assistant" as const, + text: part.text, + phase: "progress" as const, + messageID, + partID: part.partID, + })), + ) + } + const renderTool = (messageID: string, item: SessionMessageAssistantTool, render = true) => { + if (!showTools()) { + rememberToolMessage(messageID) + render = false + } const part = normalizeTool(item) const key = permissionSourceKey(messageID, part.id) if (state.finishedTools.has(key)) { @@ -721,7 +767,7 @@ export async function createSessionTransport(input: StreamInput): Promise item.type === "tool") + if (hasTools) rememberToolMessage(message.id) let textOrdinal = 0 let reasoningOrdinal = 0 for (const item of message.content) { if (item.type === "text") { const fragment = fragmentRef(message.id, "text", textOrdinal++) - const update = state.fragments.project(fragment, item.text, render) - if (render && item.text.length > update.previous.length) + const visible = render && (showTools() || !hasTools) + const update = state.fragments.project(fragment, item.text, visible) + if (visible && item.text.length > update.previous.length) write([ { kind: "assistant", @@ -1024,7 +1073,7 @@ export async function createSessionTransport(input: StreamInput): Promise update.previous.length) write([ { @@ -1303,6 +1360,7 @@ export async function createSessionTransport(input: StreamInput): Promise 0 || event.data.cost @@ -1317,6 +1375,7 @@ export async function createSessionTransport(input: StreamInput): Promise + +const presets = { + quiet: { + thinking: "hide", + tools: "hide", + shell_output: "hide", + turn_summary: "hide", + footer: "hide", + }, + default: { + thinking: "show", + tools: "hide", + shell_output: "hide", + turn_summary: "show", + footer: "show", + }, + everything: { + thinking: "show", + tools: "show", + shell_output: "show", + turn_summary: "show", + footer: "show", + }, +} as const satisfies Record + +const labels = { + quiet: "Quiet", + default: "Default", + everything: "Everything", + custom: "Custom", +} as const + +export function verbosityPreset(level: MiniVerbosity): VerbosityKnobs { + return { ...presets[level] } +} + +export function verbosityLabel(level: MiniVerbosity | "custom") { + return labels[level] +} + +export function matchMiniVerbosity(settings: MiniSettings): MiniVerbosity | "custom" { + for (const level of levels) { + if (knobs.every((key) => settings[key] === presets[level][key])) return level + } + return "custom" +} + +export function cycleMiniVerbosity(settings: MiniSettings, direction: 1 | -1): MiniVerbosity { + const current = matchMiniVerbosity(settings) + const index = current === "custom" ? nearestMiniVerbosity(settings) : levels.indexOf(current) + const next = index + direction + if (next < 0) return levels[0]! + if (next >= levels.length) return levels[levels.length - 1]! + return levels[next]! +} + +export function verbosityChange(settings: MiniSettings, direction: 1 | -1): MiniSettingChange | undefined { + const value = cycleMiniVerbosity(settings, direction) + if (matchMiniVerbosity(settings) === value) return + return { key: "verbosity", value } +} + +export function applyMiniSettingChange(settings: MiniSettings, change: MiniSettingChange): MiniSettings { + if (change.key === "verbosity") return { ...settings, ...verbosityPreset(change.value) } + return { ...settings, [change.key]: change.value } +} + +function nearestMiniVerbosity(settings: MiniSettings) { + return levels.reduce((best, level, index) => { + if (verbosityDistance(settings, level) < verbosityDistance(settings, levels[best]!)) return index + return best + }, 0) +} + +function verbosityDistance(settings: MiniSettings, level: MiniVerbosity) { + return knobs.filter((key) => settings[key] !== presets[level][key]).length +} diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index ac725f3ad63..98bca5cf556 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -18,9 +18,10 @@ test("validates the three explicit diff source defaults", () => { }) test("validates mini replay and work spinner settings", () => { - expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({ - mini: { replay: false, replay_limit: 50 }, + expect(decodeInfo({ mini: { tools: "hide", replay: false, replay_limit: 50 } })).toEqual({ + mini: { tools: "hide", replay: false, replay_limit: 50 }, }) + expect(() => decodeInfo({ mini: { tools: "quiet" } })).toThrow() expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow() expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow() expect(decodeInfo({ mini: { work_spinner: "quadrant-orbit" } })).toEqual({ diff --git a/packages/tui/test/mini/blocker.responsive.test.tsx b/packages/tui/test/mini/blocker.responsive.test.tsx index ba6aa7913d9..0e333d83027 100644 --- a/packages/tui/test/mini/blocker.responsive.test.tsx +++ b/packages/tui/test/mini/blocker.responsive.test.tsx @@ -379,6 +379,7 @@ test.each(["once", "always", "reject"] as const)( miniSettings: { current: { thinking: "hide", + tools: "show", shell_output: "hide", turn_summary: "hide", footer: "show", diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx index dd295963d4f..75d8e1414a1 100644 --- a/packages/tui/test/mini/footer-keymap.test.tsx +++ b/packages/tui/test/mini/footer-keymap.test.tsx @@ -67,6 +67,7 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") { tuiConfig={config} miniSettings={() => ({ thinking: "hide", + tools: "show", shell_output: "hide", turn_summary: "show", footer: "show", diff --git a/packages/tui/test/mini/footer.test.ts b/packages/tui/test/mini/footer.test.ts index 8f5869064c3..6a0b224b3be 100644 --- a/packages/tui/test/mini/footer.test.ts +++ b/packages/tui/test/mini/footer.test.ts @@ -34,6 +34,52 @@ test("coalesces progress only within the same message and tool state", () => { ) }) +test.each(["show", "hide"] as const)("tools setting %s controls tool and skill transcript", async (tools) => { + const app = await setup({ tools }) + try { + app.footer.append({ kind: "user", text: "do the thing", phase: "start", source: "system" }) + app.footer.append({ + kind: "tool", + text: "running read", + phase: "start", + source: "tool", + tool: "read", + messageID: "msg_1", + partID: "prt_1", + }) + app.footer.append({ + kind: "system", + text: `→ Skill "demo"`, + phase: "start", + source: "system", + messageID: "msg_skill", + partID: "skill:demo", + }) + app.footer.append({ + kind: "assistant", + text: "all done", + phase: "progress", + source: "assistant", + messageID: "msg_2", + partID: "prt_text", + }) + await app.footer.idle() + const text = app.externalOutput.takeText() + expect(text).toContain("do the thing") + expect(text).toContain("all done") + if (tools === "hide") { + expect(text).not.toContain("Read") + expect(text).not.toContain("Skill") + return + } + expect(text).toContain("-> Read") + expect(text).toContain("Skill") + } finally { + app.footer.destroy() + app.renderer.destroy() + } +}) + test("falls back only when no agent is selected", () => { const agents: RunAgent[] = [ { id: "task", name: "Task", mode: "subagent", hidden: false }, @@ -50,6 +96,7 @@ test("falls back only when no agent is selected", () => { async function setup( input: { mono?: boolean + tools?: MiniSettings["tools"] theme?: RunTuiConfig["theme"] startup?: { version: string; detail: string } cursorRow?: number @@ -93,7 +140,7 @@ async function setup( theme: mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK, tuiConfig: createTuiResolvedConfig({ theme: input.theme }), miniSettings: { - current: { ...resolveMiniSettings(), mono }, + current: { ...resolveMiniSettings(), mono, ...(input.tools ? { tools: input.tools } : {}) }, update: input.update, }, onPermissionReply: () => {}, diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 3065abaa319..39f51a93db8 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -24,6 +24,7 @@ import { RunEntryContent } from "../../src/mini/scrollback.writer" import { RUN_THEME_FALLBACK, RUN_THEME_FALLBACK_LIGHT, resolveRunTheme, type RunTheme } from "../../src/mini/theme" import { BLOCK_SOFT_SLIDE, SEED_MONO, WORK_SPINNERS } from "../../src/ui/one-cell-motion" import { resolveMiniSettings } from "../../src/mini/runtime.boot" +import { applyMiniSettingChange } from "../../src/mini/verbosity" import type { FooterQueuedPrompt, FooterState, @@ -936,7 +937,7 @@ test.each([false, true])("settings change preferences and preview the work spinn settings={settings} onClose={() => {}} onChange={(change) => { - setSettings((current) => ({ ...current, [change.key]: change.value })) + setSettings((current) => applyMiniSettingChange(current, change)) }} mono={mono} animations={animations()} @@ -951,7 +952,9 @@ test.each([false, true])("settings change preferences and preview the work spinn const frame = app.captureCharFrame() expect(frame).toContain("Settings") expect(frame).toMatch(/^ +Settings/m) + expect(frame).toContain("Verbosity") expect(frame).toContain("Thinking") + expect(frame).toContain("Tools") expect(frame).toContain("Shell") expect(frame).toContain("Turn summary") expect(frame).toContain("Footer details") @@ -960,14 +963,37 @@ test.each([false, true])("settings change preferences and preview the work spinn expect(frame).toContain("left/right change") if (mono) expect(frame).not.toMatch(/[^\x00-\x7F]/) + app.mockInput.pressKey("ARROW_RIGHT") + await app.renderOnce() + expect(settings()).toEqual(applyMiniSettingChange(resolveMiniSettings(), { key: "verbosity", value: "everything" })) + + app.mockInput.pressKey("ARROW_LEFT") + await app.renderOnce() + expect(settings()).toEqual(resolveMiniSettings()) + + app.mockInput.pressKey("ARROW_LEFT") + await app.renderOnce() + expect(settings()).toEqual(applyMiniSettingChange(resolveMiniSettings(), { key: "verbosity", value: "quiet" })) + + app.mockInput.pressKey("ARROW_LEFT") + await app.renderOnce() + expect(settings()).toEqual(applyMiniSettingChange(resolveMiniSettings(), { key: "verbosity", value: "quiet" })) + + app.mockInput.pressKey("ARROW_RIGHT") + await app.renderOnce() + expect(settings()).toEqual(resolveMiniSettings()) + + app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_RIGHT") await app.renderOnce() expect(settings()).toEqual({ ...resolveMiniSettings(), - thinking: "show", + thinking: "hide", }) + expect(app.captureCharFrame()).toContain("Custom") + app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_RIGHT") @@ -975,7 +1001,7 @@ test.each([false, true])("settings change preferences and preview the work spinn expect(settings()).toEqual({ ...resolveMiniSettings(), - thinking: "show", + thinking: "hide", turn_summary: "hide", }) @@ -1987,6 +2013,7 @@ test.each([8, 12])("production footer grows for wrapped instructions in %i rows" miniSettings: { current: { thinking: "hide", + tools: "show", shell_output: "hide", turn_summary: "show", footer: "show", @@ -2054,6 +2081,7 @@ test.each(["ctrl+i", "none"])("takeovers preserve configured shortcuts with hidd state: { phase: "running", interrupt: 1 }, miniSettings: { thinking: "hide", + tools: "show", shell_output: "hide", turn_summary: "show", footer: "hide", @@ -2371,6 +2399,7 @@ test("direct footer hides routine activity and shows explicit notices", async () currentAgent: "Plan", miniSettings: { thinking: "hide", + tools: "show", shell_output: "hide", turn_summary: "show", footer: "hide", diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index 0e7b128dc9d..98700369369 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/packages/tui/test/mini/runtime.boot.test.ts @@ -39,7 +39,8 @@ describe("run runtime boot", () => { expect(result.leader.timeout).toBe(450) expect(result.cursor).toEqual({ style: "underline", blinking: false }) expect(resolveMiniSettings(result)).toEqual({ - thinking: "hide", + thinking: "show", + tools: "hide", shell_output: "hide", turn_summary: "show", footer: "show", @@ -51,6 +52,7 @@ describe("run runtime boot", () => { resolveMiniSettings({ mini: { thinking: "show", + tools: "hide", shell_output: "show", turn_summary: "hide", footer: "hide", @@ -61,6 +63,7 @@ describe("run runtime boot", () => { }), ).toEqual({ thinking: "show", + tools: "hide", shell_output: "show", turn_summary: "hide", footer: "hide", diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 40277dc1cb3..b1a0f215c53 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -635,6 +635,135 @@ describe("V2 mini transport", () => { await transport.close() }) + test("hides tool-side assistant narration when tools are disabled", async () => { + const events = feed() + events.push(connected()) + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk({ streams: [events], messages: { ses_1: [] } }), + sessionID: "ses_1", + thinking: false, + tools: false, + footer: ui.api, + }) + const tokens = { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } + + events.push({ + id: "evt_work_text", + created: 1, + type: "session.text.delta", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_work", + ordinal: 0, + delta: "I'll check.", + }, + }) + events.push({ + id: "evt_tool_start", + created: 2, + type: "session.tool.input.started", + durable: durable("ses_1", 1), + data: { sessionID: "ses_1", assistantMessageID: "msg_work", id: "call_read", name: "read" }, + }) + events.push({ + id: "evt_tool_called", + created: 3, + type: "session.tool.called", + durable: durable("ses_1", 2), + data: { sessionID: "ses_1", assistantMessageID: "msg_work", id: "call_read", input: {}, executed: true }, + }) + events.push({ + id: "evt_work_step", + created: 4, + type: "session.step.ended", + durable: durable("ses_1", 3), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_work", + finish: "tool-calls", + cost: 0, + tokens, + }, + }) + events.push({ + id: "evt_final_text", + created: 5, + type: "session.text.delta", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_final", + ordinal: 0, + delta: "Done.", + }, + }) + events.push({ + id: "evt_final_step", + created: 6, + type: "session.step.ended", + durable: durable("ses_1", 4), + data: { + sessionID: "ses_1", + assistantMessageID: "msg_final", + finish: "stop", + cost: 0, + tokens, + }, + }) + + while (!ui.commits.some((commit) => commit.text === "Done.")) await Bun.sleep(0) + expect(ui.commits.filter((commit) => commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text)).toEqual([ + "Done.", + ]) + await transport.close() + }) + + test("hides tool-side assistant narration from hydrated history when tools are disabled", async () => { + const events = feed() + events.push(connected()) + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk({ + streams: [events], + messages: { + ses_1: [ + { + id: "msg_final", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + content: [{ type: "text", text: "Done." }], + time: { created: 4, completed: 5 }, + }, + { + id: "msg_work", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + content: [ + { type: "text", text: "I'll check." }, + canonicalToolPart("read", { status: "completed", input: {}, content: [{ type: "text", text: "file" }] }), + ], + time: { created: 2, completed: 3 }, + }, + { id: "msg_user", type: "user", text: "what happened", files: [], agents: [], time: { created: 1 } }, + ], + }, + }), + sessionID: "ses_1", + thinking: false, + tools: false, + replay: true, + footer: ui.api, + }) + + while (!ui.commits.some((commit) => commit.text === "Done.")) await Bun.sleep(0) + expect( + ui.commits.filter((commit) => commit.kind === "user" || commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text), + ).toEqual(["what happened", "Done."]) + await transport.close() + }) + test("recursively hydrates blockers for direct and transitive descendants", async () => { const events = feed() events.push(connected()) diff --git a/packages/tui/test/mini/verbosity.test.ts b/packages/tui/test/mini/verbosity.test.ts new file mode 100644 index 00000000000..103b2a975a4 --- /dev/null +++ b/packages/tui/test/mini/verbosity.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test" +import { resolveMiniSettings } from "../../src/mini/runtime.boot" +import { + applyMiniSettingChange, + cycleMiniVerbosity, + matchMiniVerbosity, + verbosityChange, + verbosityLabel, + verbosityPreset, +} from "../../src/mini/verbosity" + +test("default Mini settings match the default verbosity preset", () => { + expect(matchMiniVerbosity(resolveMiniSettings())).toBe("default") +}) + +test("verbosity presets leave splash, spinner, and mono alone", () => { + const current = resolveMiniSettings({ mini: { splash: "hide", work_spinner: "seed", mono: true } }) + const quiet = applyMiniSettingChange(current, { key: "verbosity", value: "quiet" }) + expect(quiet).toEqual({ + ...current, + ...verbosityPreset("quiet"), + }) + expect(quiet.splash).toBe("hide") + expect(quiet.footer).toBe("hide") + expect(applyMiniSettingChange(current, { key: "verbosity", value: "everything" }).mono).toBe(true) + expect(applyMiniSettingChange(current, { key: "thinking", value: "show" }).thinking).toBe("show") +}) + +test("individual knobs mark verbosity custom until a preset matches again", () => { + const louder = applyMiniSettingChange(resolveMiniSettings(), { key: "tools", value: "show" }) + expect(matchMiniVerbosity(louder)).toBe("custom") + expect(verbosityLabel("custom")).toBe("Custom") + expect(matchMiniVerbosity(applyMiniSettingChange(louder, { key: "verbosity", value: "quiet" }))).toBe("quiet") +}) + +test("verbosity cycles like a clamped slider", () => { + const settings = resolveMiniSettings() + expect(cycleMiniVerbosity(settings, 1)).toBe("everything") + expect(cycleMiniVerbosity(settings, -1)).toBe("quiet") + expect(cycleMiniVerbosity({ ...settings, ...verbosityPreset("quiet") }, -1)).toBe("quiet") + expect(cycleMiniVerbosity({ ...settings, ...verbosityPreset("everything") }, 1)).toBe("everything") + expect(verbosityChange({ ...settings, ...verbosityPreset("quiet") }, -1)).toBeUndefined() + expect(verbosityChange(settings, 1)).toEqual({ key: "verbosity", value: "everything" }) + expect(verbosityLabel("quiet")).toBe("Quiet") + expect(verbosityLabel("everything")).toBe("Everything") +}) + +test("custom verbosity moves toward the nearest preset", () => { + const custom = applyMiniSettingChange(resolveMiniSettings(), { key: "tools", value: "show" }) + expect(matchMiniVerbosity(custom)).toBe("custom") + expect(cycleMiniVerbosity(custom, 1)).toBe("everything") + expect(cycleMiniVerbosity(custom, -1)).toBe("quiet") +})