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 () => {