From dd6c95fdc7db665b21f6d737500ed49b12214a55 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Tue, 21 Jul 2026 19:36:34 +0200 Subject: [PATCH] mini: add quiet transcript settings (#38152) --- packages/cli/src/commands/handlers/mini.ts | 3 + packages/cli/src/mini.ts | 2 + packages/cli/test/config.test.ts | 7 +- packages/tui/src/config/index.tsx | 10 ++ packages/tui/src/mini/entry.body.ts | 6 +- packages/tui/src/mini/footer.command.tsx | 114 +++++++++++++++++- packages/tui/src/mini/footer.prompt.tsx | 23 +++- packages/tui/src/mini/footer.subagent.tsx | 3 +- packages/tui/src/mini/footer.ts | 56 ++++++--- packages/tui/src/mini/footer.view.tsx | 26 +++- packages/tui/src/mini/runtime.boot.ts | 9 +- packages/tui/src/mini/runtime.lifecycle.ts | 8 ++ packages/tui/src/mini/runtime.ts | 48 ++++++-- packages/tui/src/mini/scrollback.surface.ts | 5 +- packages/tui/src/mini/scrollback.writer.tsx | 2 +- packages/tui/src/mini/stream-v2.transport.ts | 10 +- packages/tui/src/mini/tool.ts | 8 +- packages/tui/src/mini/types.ts | 14 ++- packages/tui/test/mini/entry.body.test.ts | 10 +- packages/tui/test/mini/footer-keymap.test.tsx | 2 + packages/tui/test/mini/footer.view.test.tsx | 71 ++++++++++- packages/tui/test/mini/runtime.boot.test.ts | 13 +- 22 files changed, 398 insertions(+), 52 deletions(-) diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index e82b650a06..4f8a16a350 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -32,6 +32,9 @@ export default Runtime.handler(Commands.commands.mini, (input) => replayLimit: Option.getOrUndefined(input.replayLimit), demo: input.demo, tuiConfig: resolved, + config: { + update: (update) => runServicePromise(config.update(update)), + }, }), ) }), diff --git a/packages/cli/src/mini.ts b/packages/cli/src/mini.ts index 035ac485a0..ff578099d7 100644 --- a/packages/cli/src/mini.ts +++ b/packages/cli/src/mini.ts @@ -22,6 +22,7 @@ export type MiniCommandInput = { replayLimit?: number demo?: boolean tuiConfig?: MiniFrontendInput["tuiConfig"] + config?: MiniFrontendInput["config"] } type Model = MiniFrontendInput["model"] @@ -119,6 +120,7 @@ export async function runMini(input: MiniCommandInput) { replayLimit: input.replayLimit, demo: input.demo, tuiConfig: input.tuiConfig, + config: input.config, }) }) if (result.exitCode !== 0) process.exit(result.exitCode) diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index db0074edb2..aea7dc24c8 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -131,11 +131,16 @@ test("updates a config draft while preserving JSONC comments", async () => { const service = yield* Config.Service return yield* service.update((draft) => { draft.prompt = { paste: "compact" } + draft.mini = { thinking: "hide", shell_output: "hide" } }) }), ) - expect(config).toEqual({ animations: true, prompt: { paste: "compact" } }) + expect(config).toEqual({ + animations: true, + prompt: { paste: "compact" }, + mini: { thinking: "hide", shell_output: "hide" }, + }) expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment") } finally { await Bun.$`rm -rf ${directory}` diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index ee6eff84cd..5e314bda55 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -122,6 +122,16 @@ export const Info = Schema.Struct({ }), }), ).annotate({ description: "Session transcript presentation settings" }), + mini: Schema.optional( + Schema.Struct({ + thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ + description: "Show or hide model reasoning in Mini", + }), + shell_output: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ + description: "Show or hide raw shell tool output in Mini", + }), + }), + ).annotate({ description: "Mini transcript presentation settings" }), hints: Schema.optional( Schema.Struct({ onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }), diff --git a/packages/tui/src/mini/entry.body.ts b/packages/tui/src/mini/entry.body.ts index 683a2bf5d1..7d13a0d867 100644 --- a/packages/tui/src/mini/entry.body.ts +++ b/packages/tui/src/mini/entry.body.ts @@ -1,5 +1,5 @@ import { toolEntryBody } from "./tool" -import type { RunEntryBody, StreamCommit } from "./types" +import type { RunEntryBody, ScrollbackOptions, StreamCommit } from "./types" export type EntryFlags = { startOnNewLine: boolean @@ -162,7 +162,7 @@ export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolea return commit.kind === "assistant" || commit.kind === "reasoning" } -export function entryBody(commit: StreamCommit): RunEntryBody { +export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): RunEntryBody { if (commit.summary) { return RUN_ENTRY_NONE } @@ -174,7 +174,7 @@ export function entryBody(commit: StreamCommit): RunEntryBody { } if (commit.kind === "tool") { - return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE + return toolEntryBody(commit, raw, options) ?? RUN_ENTRY_NONE } if (commit.kind === "assistant") { diff --git a/packages/tui/src/mini/footer.command.tsx b/packages/tui/src/mini/footer.command.tsx index ff75321bf6..e4a161e9d9 100644 --- a/packages/tui/src/mini/footer.command.tsx +++ b/packages/tui/src/mini/footer.command.tsx @@ -5,7 +5,15 @@ import fuzzysort from "fuzzysort" import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import type { RunFooterTheme } from "./theme" -import type { FooterQueuedPrompt, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types" +import type { + FooterQueuedPrompt, + FooterSubagentTab, + MiniSettingChange, + MiniSettings, + RunCommand, + RunInput, + RunProvider, +} from "./types" type PanelEntry = RunFooterMenuItem & { category: string @@ -20,6 +28,7 @@ type CommandEntry = | (PanelEntry & { action: "subagent" }) | (PanelEntry & { action: "variant.cycle" }) | (PanelEntry & { action: "variant.list" }) + | (PanelEntry & { action: "settings" }) | (PanelEntry & { action: "slash"; name: string }) | (PanelEntry & { action: "exit" }) @@ -48,6 +57,10 @@ type QueuedEntry = PanelEntry & { prompt: FooterQueuedPrompt } +type SettingEntry = PanelEntry & { + key: keyof MiniSettings +} + const PANEL_PAD = 2 const PANEL_LIST_ROWS = 10 const PANEL_FRAME_ROWS = 6 @@ -266,6 +279,7 @@ function PanelShell(props: { inputRef: (input: InputRenderable) => void onQuery: (query: string) => void children: JSX.Element + hint?: string dark?: boolean chrome?: "default" | "minimal" }) { @@ -294,7 +308,7 @@ function PanelShell(props: { ) : null} - esc + {props.hint ? `${props.hint} ยท ` : ""}esc @@ -400,6 +414,7 @@ export function RunCommandMenuBody(props: { onQueued: () => void onVariant: () => void onVariantCycle: () => void + onSettings: () => void onCommand: (name: string) => void onNew: () => void onExit: () => void @@ -407,7 +422,7 @@ export function RunCommandMenuBody(props: { const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill")) const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length) const entries = createMemo(() => { - const builtins = ["editor", "new"] + const builtins = ["editor", "new", "settings"] const session: CommandEntry[] = [ { action: "editor", @@ -515,6 +530,13 @@ export function RunCommandMenuBody(props: { ...prompt, ...agent, ...commands, + { + action: "settings", + category: "System", + display: "Open settings", + footer: "/settings", + keywords: "/settings settings preferences configuration", + }, { action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" }, ] }) @@ -554,6 +576,11 @@ export function RunCommandMenuBody(props: { return } + if (item.action === "settings") { + props.onSettings() + return + } + if (item.action === "exit") { props.onExit() return @@ -606,6 +633,87 @@ export function RunCommandMenuBody(props: { ) } +export function RunSettingsBody(props: { + theme: Accessor + settings: Accessor + onClose: () => void + onChange: (change: MiniSettingChange) => void | Promise +}) { + const [saving, setSaving] = createSignal() + const entries = createMemo(() => [ + { + category: "Transcript", + display: "Thinking", + description: "future sessions", + footer: saving() === "thinking" ? "saving" : props.settings().thinking, + keywords: `thinking reasoning ${props.settings().thinking}`, + key: "thinking", + }, + { + category: "Transcript", + display: "Shell tool output", + description: "model-issued commands", + footer: saving() === "shell_output" ? "saving" : props.settings().shell_output, + keywords: `shell tool command output ${props.settings().shell_output}`, + key: "shell_output", + }, + ]) + const change = (item: SettingEntry) => { + if (saving()) return + const current = props.settings()[item.key] + setSaving(item.key) + void Promise.resolve(props.onChange({ key: item.key, value: current === "show" ? "hide" : "show" })) + .catch(() => {}) + .finally(() => setSaving()) + } + const controller = createSearchablePanelController({ + entries, + limit: PANEL_LIST_ROWS, + onClose: props.onClose, + onSelect: change, + onKey(event, item) { + const name = event.name.toLowerCase() + if (name !== "left" && name !== "right") return false + event.preventDefault() + if (item) change(item) + return true + }, + }) + + return ( + + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty="No settings found" + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={!controller.query().trim()} + background + headerColor={props.theme().muted} + /> + + ) +} + export function RunSubagentSelectBody(props: { theme: Accessor tabs: Accessor diff --git a/packages/tui/src/mini/footer.prompt.tsx b/packages/tui/src/mini/footer.prompt.tsx index 198f754e18..039c5f4100 100644 --- a/packages/tui/src/mini/footer.prompt.tsx +++ b/packages/tui/src/mini/footer.prompt.tsx @@ -51,7 +51,7 @@ type Auto = RunFooterMenuItem & { type SlashOption = RunFooterMenuItem & { kind: "slash" name: string - action?: "skill-menu" | "editor" + action?: "skill-menu" | "editor" | "settings" } type PromptOption = Auto | SlashOption @@ -79,6 +79,7 @@ type PromptInput = { onExitRequest?: () => boolean onExit: () => void onSkillMenu: () => void + onSettings: () => void onRows: (rows: number) => void onStatus: (text: string) => void } @@ -380,6 +381,13 @@ export function createPromptState(input: PromptInput): PromptState { display: "/editor", description: "compose in your external editor", } satisfies SlashOption, + { + kind: "slash", + action: "settings" as const, + name: "settings", + display: "/settings", + description: "configure Mini transcript output", + } satisfies SlashOption, { kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption, { kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption, ] @@ -815,6 +823,12 @@ export function createPromptState(input: PromptInput): PromptState { return } + if (next.action === "settings" && !shell()) { + cancelAutocomplete() + input.onSettings() + return + } + const cursor = area.cursorOffset const head = parseSlashHead(area.plainText) const local = !shell() && (next.name === "new" || next.name === "exit") @@ -922,6 +936,7 @@ export function createPromptState(input: PromptInput): PromptState { if (current === "skill") return false if (current === "model") return false if (current === "variant") return false + if (current === "settings") return false if (current === "queued-menu") return false if (current === "subagent-menu") return false return true @@ -1119,6 +1134,12 @@ export function createPromptState(input: PromptInput): PromptState { return } + if (!command && next.mode !== "shell" && next.text.trim().toLowerCase() === "/settings") { + resetDraft() + input.onSettings() + return + } + const parsed = command || next.mode === "shell" || isNewCommand(next.text) ? undefined diff --git a/packages/tui/src/mini/footer.subagent.tsx b/packages/tui/src/mini/footer.subagent.tsx index a9d89058f6..90da2ac28a 100644 --- a/packages/tui/src/mini/footer.subagent.tsx +++ b/packages/tui/src/mini/footer.subagent.tsx @@ -56,6 +56,7 @@ export function RunFooterSubagentBody(props: { // Formatted interrupt shortcut from the registered keymap binding; the // command itself is dispatched through the keymap in footer.view. interrupt?: () => string | undefined + shellOutput?: () => boolean }) { const theme = createMemo(() => props.theme()) const footer = createMemo(() => theme().footer) @@ -86,7 +87,7 @@ export function RunFooterSubagentBody(props: { const rows = indexArray(commits, (commit, index) => ( {index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? : null} - + )) let scroll: ScrollBoxRenderable | undefined diff --git a/packages/tui/src/mini/footer.ts b/packages/tui/src/mini/footer.ts index 0ee6f26c59..a8ae8f3c4a 100644 --- a/packages/tui/src/mini/footer.ts +++ b/packages/tui/src/mini/footer.ts @@ -48,6 +48,8 @@ import type { FooterView, FormCancel, FormReply, + MiniSettingChange, + MiniSettings, PermissionReply, RunAgent, RunCommand, @@ -81,6 +83,10 @@ type RunFooterOptions = { history?: RunPrompt[] theme: RunTheme tuiConfig: RunTuiConfig + miniSettings: { + current: MiniSettings + update?: (change: MiniSettingChange) => Promise + } onPermissionReply: (input: PermissionReply) => void | Promise onFormReply: (input: FormReply) => void | Promise onFormCancel: (input: FormCancel) => void | Promise @@ -97,11 +103,7 @@ type RunFooterOptions = { const PERMISSION_ROWS = 12 const FORM_ROWS = 14 -const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS -const SKILL_ROWS = RUN_COMMAND_PANEL_ROWS const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS -const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS -const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS const NOTICE_DURATION = 3000 const THEME_REFRESH_DELAYS = [1000, 1000] as const @@ -185,6 +187,8 @@ export class RunFooter implements FooterApi { private setQueuedPrompts: Setter private history: Accessor private setHistory: Setter + private miniSettings: Accessor + private setMiniSettings: Setter private promptRoute: FooterPromptRoute = { type: "composer" } private subagentMenuRows = SUBAGENT_ROWS private interruptTimeout: NodeJS.Timeout | undefined @@ -209,6 +213,7 @@ export class RunFooter implements FooterApi { .catch(() => {}) .finally(() => this.destroyTheme(theme)) }, + shellOutput: () => this.miniSettings().shell_output === "show", }) } @@ -269,6 +274,9 @@ export class RunFooter implements FooterApi { const [history, setHistory] = createSignal(options.history ?? []) this.history = history this.setHistory = setHistory + const [miniSettings, setMiniSettings] = createSignal(options.miniSettings.current) + this.miniSettings = miniSettings + this.setMiniSettings = setMiniSettings this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS) this.scrollback = this.createScrollback(options.wrote ?? false) @@ -300,6 +308,7 @@ export class RunFooter implements FooterApi { currentVariant: footer.currentVariant, theme: footer.theme, tuiConfig: options.tuiConfig, + miniSettings: footer.miniSettings, history: footer.history, onSubmit: footer.handlePrompt, onPermissionReply: footer.handlePermissionReply, @@ -318,6 +327,7 @@ export class RunFooter implements FooterApi { onRows: footer.syncRows, onLayout: footer.syncLayout, onStatus: footer.setStatus, + onMiniSettingChange: footer.handleMiniSettingChange, onSubagentSelect: options.onSubagentSelect, onSubagentInterrupt: options.onSubagentInterrupt, }) @@ -662,26 +672,19 @@ export class RunFooter implements FooterApi { // get fixed extra rows; the prompt view scales with textarea line count. private applyHeight(): void { const type = this.view().type + const route = this.promptRoute.type const height = type === "permission" ? this.base + PERMISSION_ROWS : type === "form" ? this.base + FORM_ROWS - : this.promptRoute.type === "command" - ? 1 + COMMAND_ROWS - : this.promptRoute.type === "skill" - ? 1 + SKILL_ROWS - : this.promptRoute.type === "model" - ? 1 + MODEL_ROWS - : this.promptRoute.type === "variant" - ? 1 + VARIANT_ROWS - : this.promptRoute.type === "queued-menu" - ? 1 + this.subagentMenuRows - : this.promptRoute.type === "subagent-menu" - ? 1 + this.subagentMenuRows - : this.promptRoute.type === "subagent" - ? this.base + SUBAGENT_INSPECTOR_ROWS - : this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows)) + : ["command", "skill", "model", "variant", "settings"].includes(route) + ? 1 + RUN_COMMAND_PANEL_ROWS + : route === "queued-menu" || route === "subagent-menu" + ? 1 + this.subagentMenuRows + : route === "subagent" + ? this.base + SUBAGENT_INSPECTOR_ROWS + : this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows)) if (height !== this.renderer.footerHeight) { this.renderer.footerHeight = height @@ -864,6 +867,21 @@ export class RunFooter implements FooterApi { .catch(() => {}) } + private handleMiniSettingChange = async (change: MiniSettingChange): Promise => { + if (!this.options.miniSettings.update) { + this.setNotice("settings are unavailable") + return + } + + try { + this.setMiniSettings(await this.options.miniSettings.update(change)) + this.setNotice("settings updated") + } catch (error) { + this.setNotice("failed to save settings") + throw error + } + } + private clearInterruptTimer(): void { if (!this.interruptTimeout) { return diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index ff5199e89f..ad764c329e 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -17,6 +17,7 @@ import { RunCommandMenuBody, RunModelSelectBody, RunQueuedPromptSelectBody, + RunSettingsBody, RunSkillSelectBody, RunSubagentSelectBody, RunVariantSelectBody, @@ -39,6 +40,8 @@ import type { FooterView, FormCancel, FormReply, + MiniSettingChange, + MiniSettings, PermissionReply, RunAgent, RunCommand, @@ -82,6 +85,7 @@ type RunFooterViewProps = { queuedPrompts?: () => FooterQueuedPrompt[] theme: () => RunTheme tuiConfig: RunTuiConfig + miniSettings: () => MiniSettings history?: () => RunPrompt[] onSubmit: (input: RunPrompt) => boolean onPermissionReply: (input: PermissionReply) => void | Promise @@ -100,6 +104,7 @@ type RunFooterViewProps = { onRows: (rows: number) => void onLayout: (input: { route: FooterPromptRoute; subagentRows: number }) => void onStatus: (text: string) => void + onMiniSettingChange: (change: MiniSettingChange) => void | Promise onSubagentSelect?: (sessionID: string | undefined) => void onSubagentInterrupt?: (sessionID: string) => void } @@ -131,6 +136,7 @@ export function RunFooterView(props: RunFooterViewProps) { const skilling = createMemo(() => active().type === "prompt" && route().type === "skill") const modeling = createMemo(() => active().type === "prompt" && route().type === "model") const varianting = createMemo(() => active().type === "prompt" && route().type === "variant") + const setting = createMemo(() => active().type === "prompt" && route().type === "settings") const panel = createMemo( () => active().type === "permission" || @@ -140,7 +146,8 @@ export function RunFooterView(props: RunFooterViewProps) { commanding() || skilling() || modeling() || - varianting(), + varianting() || + setting(), ) const selected = createMemo(() => { const current = route() @@ -257,6 +264,11 @@ export function RunFooterView(props: RunFooterViewProps) { props.onSubagentSelect?.(undefined) } + const openSettings = () => { + setRoute({ type: "settings" }) + props.onSubagentSelect?.(undefined) + } + const openSubagentMenu = () => { if (tabs().length === 0) { return @@ -323,6 +335,7 @@ export function RunFooterView(props: RunFooterViewProps) { onExitRequest: props.onExitRequest, onExit: props.onExit, onSkillMenu: openSkillMenu, + onSettings: openSettings, onRows: props.onRows, onStatus: props.onStatus, }) @@ -559,6 +572,7 @@ export function RunFooterView(props: RunFooterViewProps) { current.type !== "skill" && current.type !== "model" && current.type !== "variant" && + current.type !== "settings" && current.type !== "queued-menu" && current.type !== "subagent-menu" ) { @@ -668,6 +682,7 @@ export function RunFooterView(props: RunFooterViewProps) { onSubagent={openSubagentMenu} onQueued={openQueuedMenu} onVariant={openVariant} + onSettings={openSettings} onVariantCycle={() => { props.onCycle() closePanel() @@ -726,6 +741,14 @@ export function RunFooterView(props: RunFooterViewProps) { }} /> + + + subagentInterruptShortcut() || undefined} + shellOutput={() => props.miniSettings().shell_output === "show"} /> diff --git a/packages/tui/src/mini/runtime.boot.ts b/packages/tui/src/mini/runtime.boot.ts index 2d855f5719..5b2f640f09 100644 --- a/packages/tui/src/mini/runtime.boot.ts +++ b/packages/tui/src/mini/runtime.boot.ts @@ -8,7 +8,7 @@ import type { LocationRef } from "@opencode-ai/client/promise" import { resolve } from "../config" import { loadRunProviders } from "./catalog.shared" import { resolveCurrentSession, sessionHistory } from "./session.shared" -import type { RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types" +import type { MiniSettings, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types" import { pickVariant } from "./variant.shared" export type ModelInfo = { @@ -83,3 +83,10 @@ export async function resolveRunTuiConfig( .then((value) => value ?? defaultRunTuiConfig(platform)) .catch(() => defaultRunTuiConfig(platform)) } + +export function resolveMiniSettings(config?: { mini?: Partial }): MiniSettings { + return { + thinking: config?.mini?.thinking ?? "hide", + shell_output: config?.mini?.shell_output ?? "hide", + } +} diff --git a/packages/tui/src/mini/runtime.lifecycle.ts b/packages/tui/src/mini/runtime.lifecycle.ts index f503ccd7bf..64b68803a6 100644 --- a/packages/tui/src/mini/runtime.lifecycle.ts +++ b/packages/tui/src/mini/runtime.lifecycle.ts @@ -18,6 +18,8 @@ import type { FooterApi, FormCancel, FormReply, + MiniSettingChange, + MiniSettings, MiniHost, PermissionReply, RunAgent, @@ -26,6 +28,7 @@ import type { RunReference, RunTuiConfig, } from "./types" +import { resolveMiniSettings } from "./runtime.boot" import { formatModelLabel } from "./variant.shared" const FOOTER_HEIGHT = 4 @@ -62,6 +65,7 @@ export type LifecycleInput = { model: RunInput["model"] variant: string | undefined tuiConfig: RunTuiConfig | Promise + onMiniSettingChange?: (change: MiniSettingChange) => Promise onPermissionReply: (input: PermissionReply) => void | Promise onFormReply: (input: FormReply) => void | Promise onFormCancel: (input: FormCancel) => void | Promise @@ -224,6 +228,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise & { location: LocationRef @@ -43,6 +59,7 @@ type RunRuntimeInput = { replayLimit?: number demo?: RunInput["demo"] tuiConfig?: RunTuiConfig | Promise + config?: Pick } export type RunDeferredInput = { @@ -62,6 +79,7 @@ export type RunDeferredInput = { replayLimit?: number demo?: RunInput["demo"] tuiConfig?: RunTuiConfig | Promise + config?: Pick } type StreamTransportModule = Pick< @@ -174,7 +192,12 @@ function abortable(task: Promise, signal: AbortSignal): Promise { const start = input.host.startup.now() const log = input.host.diagnostics.trace - const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig, input.host.platform) + const config = input.config + const configState: { current: MiniSettings } = { current: resolveMiniSettings() } + const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig, input.host.platform).then((tuiConfig) => { + configState.current = resolveMiniSettings(tuiConfig) + return tuiConfig + }) const ctx = await input.boot() const runtimeController = new AbortController() const session = { @@ -226,6 +249,16 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep model: state.model, variant: state.activeVariant, tuiConfig: tuiConfigTask, + onMiniSettingChange: config + ? async (change) => { + const info = await config.update((draft) => { + if (!draft.mini || typeof draft.mini !== "object") draft.mini = {} + draft.mini[change.key] = change.value + }) + configState.current = resolveMiniSettings(info) + return configState.current + } + : undefined, onPermissionReply: async (next) => { if (state.demo?.permission(next)) { return @@ -359,8 +392,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }) }, }) - const tuiConfig = await tuiConfigTask - const thinking = input.thinking ?? tuiConfig.session?.thinking !== "hide" + await tuiConfigTask + const thinking = () => input.thinking ?? configState.current.thinking === "show" const footer = shell.footer const firstPaint = footer.idle().catch(() => {}) const offRuntimeClose = footer.onClose(() => runtimeController.abort()) @@ -679,7 +712,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep return createRunDemo({ footer, sessionID: state.sessionID, - thinking, + thinking: thinking(), }) } @@ -722,7 +755,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep readTextFile: input.host.files.readText, location: state.location, sessionID: state.sessionID, - thinking, + thinking: thinking(), replay: input.replay, replayLimit: input.replayLimit, footer, @@ -1018,6 +1051,7 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: replayLimit: input.replayLimit, demo: input.demo, tuiConfig: input.tuiConfig, + config: input.config, reconnect: input.reconnect, resolveSession: input.target, createSession: input.createSession, diff --git a/packages/tui/src/mini/scrollback.surface.ts b/packages/tui/src/mini/scrollback.surface.ts index 442b4729a9..3ba49ab8d8 100644 --- a/packages/tui/src/mini/scrollback.surface.ts +++ b/packages/tui/src/mini/scrollback.surface.ts @@ -88,6 +88,7 @@ export class RunScrollbackStream { private active: ActiveEntry | undefined private treeSitterClient: TreeSitterClient | undefined private wrote: boolean + private shellOutput: () => boolean private pendingThemes: RunTheme[] = [] constructor( @@ -97,10 +98,12 @@ export class RunScrollbackStream { wrote?: boolean treeSitterClient?: TreeSitterClient onThemeRelease?: (theme: RunTheme) => void + shellOutput?: () => boolean } = {}, ) { this.treeSitterClient = options.treeSitterClient this.wrote = options.wrote ?? false + this.shellOutput = options.shellOutput ?? (() => true) this.onThemeRelease = options.onThemeRelease } @@ -354,7 +357,7 @@ export class RunScrollbackStream { return } - const body = entryBody(commit) + const body = entryBody(commit, { shellOutput: this.shellOutput() }) if (body.type === "none") { if (entryDone(commit)) { this.markRendered(await this.finishActive(false)) diff --git a/packages/tui/src/mini/scrollback.writer.tsx b/packages/tui/src/mini/scrollback.writer.tsx index 56e85d5c34..cf577ab2ab 100644 --- a/packages/tui/src/mini/scrollback.writer.tsx +++ b/packages/tui/src/mini/scrollback.writer.tsx @@ -74,7 +74,7 @@ export function RunEntryContent(props: { opts?: ScrollbackOptions }) { const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK) - const body = createMemo(() => props.body ?? entryBody(props.commit)) + const body = createMemo(() => props.body ?? entryBody(props.commit, props.opts)) const style = createMemo(() => entryLook(props.commit, theme().entry)) const syntax = createMemo(() => entrySyntax(theme())) const color = createMemo(() => entryColor(props.commit, theme())) diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index 1ec8cdfd31..b90f5e682d 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -758,10 +758,12 @@ export async function createSessionTransport(input: StreamInput): Promise { - const prompt = pendingPrompt(item) - return prompt ? [[prompt.messageID, prompt] as const] : [] - })) + state.pending = new Map( + pending.flatMap((item) => { + const prompt = pendingPrompt(item) + return prompt ? [[prompt.messageID, prompt] as const] : [] + }), + ) syncPending() state.permissions = permissions pruneToolSources() diff --git a/packages/tui/src/mini/tool.ts b/packages/tui/src/mini/tool.ts index d5b20573bb..dd41193526 100644 --- a/packages/tui/src/mini/tool.ts +++ b/packages/tui/src/mini/tool.ts @@ -1273,7 +1273,11 @@ function shellOutput(command: string, raw: string): string | undefined { return `\n${body}` } -export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | undefined { +export function toolEntryBody( + commit: StreamCommit, + raw: string, + options?: { shellOutput?: boolean }, +): RunEntryBody | undefined { if (commit.shell) { if (commit.phase === "start") { return textBody(`$ ${commit.shell.command}`) @@ -1294,6 +1298,8 @@ export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | const ctx = toolFrame(commit, raw) const view = toolView(ctx.name) + if (ctx.name === "shell" && commit.phase === "progress" && options?.shellOutput === false) return undefined + if (ctx.name === "subagent") { if (commit.phase === "start") { return undefined diff --git a/packages/tui/src/mini/types.ts b/packages/tui/src/mini/types.ts index f251973225..72cff6df14 100644 --- a/packages/tui/src/mini/types.ts +++ b/packages/tui/src/mini/types.ts @@ -184,6 +184,7 @@ export type TurnSummary = { export type ScrollbackOptions = { suppressBackgrounds?: boolean + shellOutput?: boolean } export type ToolCodeSnapshot = { @@ -293,6 +294,7 @@ export type FooterPromptRoute = | { type: "skill" } | { type: "model" } | { type: "variant" } + | { type: "settings" } export type FooterSubagentTab = { sessionID: string @@ -389,7 +391,17 @@ export type FormCancel = { location?: LocationRef } -export type RunTuiConfig = Pick +export type RunTuiConfig = Pick + +export type MiniSettings = { + thinking: "show" | "hide" + shell_output: "show" | "hide" +} + +export type MiniSettingChange = { + key: keyof MiniSettings + value: "show" | "hide" +} // Lifecycle phase of a scrollback entry. "start" opens the entry, "progress" // appends content (coalesced in the footer queue), "final" closes it. diff --git a/packages/tui/test/mini/entry.body.test.ts b/packages/tui/test/mini/entry.body.test.ts index 75647ab5b7..284ece5b4b 100644 --- a/packages/tui/test/mini/entry.body.test.ts +++ b/packages/tui/test/mini/entry.body.test.ts @@ -389,7 +389,6 @@ describe("run entry body", () => { type: "text", content: "$ pwd", }) - expect( entryBody( commit({ @@ -411,6 +410,15 @@ describe("run entry body", () => { }) }) + test("hides shell tool output but not direct shell output", () => { + const output = commit({ kind: "tool", text: "output", phase: "progress", source: "tool", tool: "shell" }) + expect(entryBody(output, { shellOutput: false })).toEqual({ type: "none" }) + expect(entryBody({ ...output, shell: { command: "pwd" } }, { shellOutput: false })).toEqual({ + type: "text", + content: "\noutput", + }) + }) + test("falls back to patch summary when patch has no visible diff items", () => { expect( entryBody( diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx index b7904da120..d3930676d2 100644 --- a/packages/tui/test/mini/footer-keymap.test.tsx +++ b/packages/tui/test/mini/footer-keymap.test.tsx @@ -55,6 +55,7 @@ test("down opens subagents from an empty prompt", async () => { subagent={subagents} theme={() => RUN_THEME_FALLBACK} tuiConfig={config} + miniSettings={() => ({ thinking: "hide", shell_output: "hide" })} onSubmit={() => true} onPermissionReply={() => {}} onFormReply={() => {}} @@ -69,6 +70,7 @@ test("down opens subagents from an empty prompt", async () => { onRows={() => {}} onLayout={() => {}} onStatus={() => {}} + onMiniSettingChange={() => {}} /> ) diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 41e3e13568..20ebe797d2 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -11,6 +11,7 @@ import { RunCommandMenuBody, RunModelSelectBody, RunQueuedPromptSelectBody, + RunSettingsBody, RunSkillSelectBody, RunSubagentSelectBody, RunVariantSelectBody, @@ -23,6 +24,8 @@ import type { FooterSubagentState, FooterSubagentTab, FooterView, + MiniSettingChange, + MiniSettings, RunCommand, RunInput, RunPrompt, @@ -117,6 +120,8 @@ async function renderFooter( onSubmit?: (prompt: RunPrompt) => boolean view?: FooterView onFormReply?: (input: unknown) => void + miniSettings?: MiniSettings + onMiniSettingChange?: (change: MiniSettingChange) => void } = {}, ) { const [view, setView] = createSignal(input.view ?? { type: "prompt" }) @@ -125,6 +130,7 @@ async function renderFooter( ) const state = footerState(input.state) const config = input.tuiConfig ?? tuiConfig + const [miniSettings] = createSignal(input.miniSettings ?? { thinking: "hide", shell_output: "hide" }) function Harness() { return ( @@ -143,6 +149,7 @@ async function renderFooter( subagent={subagents} theme={input.theme ?? (() => RUN_THEME_FALLBACK)} tuiConfig={config} + miniSettings={miniSettings} onSubmit={input.onSubmit ?? (() => true)} onPermissionReply={() => {}} onFormReply={(value) => input.onFormReply?.(value)} @@ -157,6 +164,7 @@ async function renderFooter( onRows={() => {}} onLayout={() => {}} onStatus={() => {}} + onMiniSettingChange={(change) => input.onMiniSettingChange?.(change)} /> ) @@ -369,6 +377,7 @@ test("direct command panel renders grouped command palette", async () => { onQueued={() => {}} onVariant={() => {}} onVariantCycle={() => {}} + onSettings={() => {}} onCommand={() => {}} onNew={() => {}} onExit={() => {}} @@ -408,6 +417,40 @@ test("direct command panel renders grouped command palette", async () => { } }) +test("direct settings panel changes Mini transcript preferences", async () => { + const [settings, setSettings] = createSignal({ thinking: "hide", shell_output: "hide" }) + const app = await testRender( + () => ( + + RUN_THEME_FALLBACK.footer} + settings={settings} + onClose={() => {}} + onChange={(change) => { + setSettings((current) => ({ ...current, [change.key]: change.value })) + }} + /> + + ), + { width: 100, height: RUN_COMMAND_PANEL_ROWS }, + ) + + try { + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Settings") + expect(app.captureCharFrame()).toContain("Thinking") + expect(app.captureCharFrame()).toContain("Shell tool output") + expect(app.captureCharFrame()).toContain("left/right change") + + app.mockInput.pressKey("ARROW_RIGHT") + await app.renderOnce() + + expect(settings()).toEqual({ thinking: "show", shell_output: "hide" }) + } finally { + app.renderer.destroy() + } +}) + test("direct skill panel renders searchable skill list", async () => { const [commands] = createSignal([ command({ name: "review", description: "Review code" }), @@ -518,6 +561,7 @@ test("direct command panel shows subagent entry when available", async () => { onQueued={() => {}} onVariant={() => {}} onVariantCycle={() => {}} + onSettings={() => {}} onCommand={() => {}} onNew={() => {}} onExit={() => {}} @@ -566,6 +610,7 @@ test("direct command panel keeps completed subagents available", async () => { onQueued={() => {}} onVariant={() => {}} onVariantCycle={() => {}} + onSettings={() => {}} onCommand={() => {}} onNew={() => {}} onExit={() => {}} @@ -822,7 +867,7 @@ test("direct footer submits slash autocomplete selections without dispatching sh await app.renderOnce() app.mockInput.pressKey("!") - "/rev".split("").forEach((key) => app.mockInput.pressKey(key)) + "/settings".split("").forEach((key) => app.mockInput.pressKey(key)) await app.renderOnce() app.mockInput.pressEnter() await app.renderOnce() @@ -834,7 +879,7 @@ test("direct footer submits slash autocomplete selections without dispatching sh { text: "/new ", parts: [] }, { text: "/new ", parts: [] }, ]) - expect(app.captureCharFrame()).toContain("/review") + expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ") } finally { app.cleanup() } @@ -867,6 +912,26 @@ test("direct footer slash autocomplete keeps a real skills command", async () => } }) +test("direct footer closes settings with ctrl-c instead of arming exit", async () => { + const app = await renderFooter({ height: 20 }) + + try { + await app.renderOnce() + "/settings".split("").forEach((key) => app.mockInput.pressKey(key)) + await app.renderOnce() + app.mockInput.pressEnter() + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Shell tool output") + + app.mockInput.pressKey("c", { ctrl: true }) + await app.renderOnce() + expect(app.captureCharFrame()).not.toContain("Shell tool output") + expect(app.renderer.currentFocusedEditor?.plainText).toBe("") + } finally { + app.cleanup() + } +}) + test("selectedCommand backfills the catalog source for bound drafts", () => { const catalog = [command({ name: "opencode-ts", description: "TS skill", source: "skill" })] @@ -1034,6 +1099,7 @@ test("direct footer shows authoritative pending work while running", async () => ]} theme={() => RUN_THEME_FALLBACK} tuiConfig={tuiConfig} + miniSettings={() => ({ thinking: "hide", shell_output: "hide" })} onSubmit={() => true} onPermissionReply={() => {}} onFormReply={() => {}} @@ -1048,6 +1114,7 @@ test("direct footer shows authoritative pending work while running", async () => onRows={() => {}} onLayout={() => {}} onStatus={() => {}} + onMiniSettingChange={() => {}} /> ) diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index c759201482..d28ecee687 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/packages/tui/test/mini/runtime.boot.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { OpenCode } from "@opencode-ai/client/promise" import type { Resolved } from "../../src/config" -import { resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot" +import { resolveMiniSettings, resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot" import { catalogModel, catalogProvider } from "./fixture/catalog" import { createTuiResolvedConfig } from "./fixture/tui-runtime" @@ -91,18 +91,23 @@ describe("run runtime boot", () => { expect(result.keybinds.get("leader")).toEqual([]) }) - test("preserves current theme mode, leader, and thinking config", async () => { + test("preserves shared config while resolving independent Mini defaults", async () => { const result = await resolveRunTuiConfig( createTuiResolvedConfig({ theme: { mode: "light" }, leader_timeout: 450, - session: { thinking: "hide" }, + session: { thinking: "show" }, }), ) expect(result.theme).toEqual({ mode: "light" }) expect(result.leader.timeout).toBe(450) - expect(result.session?.thinking).toBe("hide") + expect(result.session?.thinking).toBe("show") + expect(resolveMiniSettings(result)).toEqual({ thinking: "hide", shell_output: "hide" }) + expect(resolveMiniSettings({ mini: { thinking: "show", shell_output: "show" } })).toEqual({ + thinking: "show", + shell_output: "show", + }) }) test("loads v2 providers and models for model selector data", async () => {