Jelajahi Sumber

mini: add quiet transcript settings (#38152)

Simon Klee 3 minggu lalu
induk
melakukan
dd6c95fdc7

+ 3 - 0
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)),
+        },
       }),
     )
   }),

+ 2 - 0
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)

+ 6 - 1
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}`

+ 10 - 0
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" }),

+ 3 - 3
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") {

+ 111 - 3
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}
         <box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
         <text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
-          esc
+          {props.hint ? `${props.hint} · ` : ""}esc
         </text>
       </box>
       <box height={1} flexShrink={0} backgroundColor={background()} />
@@ -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<CommandEntry[]>(() => {
-    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<RunFooterTheme>
+  settings: Accessor<MiniSettings>
+  onClose: () => void
+  onChange: (change: MiniSettingChange) => void | Promise<void>
+}) {
+  const [saving, setSaving] = createSignal<keyof MiniSettings>()
+  const entries = createMemo<SettingEntry[]>(() => [
+    {
+      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 (
+    <PanelShell
+      title="Settings"
+      countVisible={false}
+      query={controller.query()}
+      count={controller.items().length}
+      total={entries().length}
+      placeholder="Search"
+      theme={props.theme}
+      inputRef={controller.inputRef}
+      onQuery={controller.setQuery}
+      hint="left/right change"
+      dark
+      chrome="minimal"
+    >
+      <RunFooterMenu
+        theme={props.theme}
+        items={controller.items}
+        selected={controller.menu.selected}
+        offset={controller.menu.offset}
+        rows={() => 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}
+      />
+    </PanelShell>
+  )
+}
+
 export function RunSubagentSelectBody(props: {
   theme: Accessor<RunFooterTheme>
   tabs: Accessor<FooterSubagentTab[]>

+ 22 - 1
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

+ 2 - 1
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) => (
     <box flexDirection="column" gap={0} flexShrink={0}>
       {index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
-      <RunEntryContent commit={commit()} theme={theme()} />
+      <RunEntryContent commit={commit()} theme={theme()} opts={{ shellOutput: props.shellOutput?.() ?? true }} />
     </box>
   ))
   let scroll: ScrollBoxRenderable | undefined

+ 37 - 19
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<MiniSettings>
+  }
   onPermissionReply: (input: PermissionReply) => void | Promise<void>
   onFormReply: (input: FormReply) => void | Promise<void>
   onFormCancel: (input: FormCancel) => void | Promise<void>
@@ -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<FooterQueuedPrompt[]>
   private history: Accessor<RunPrompt[]>
   private setHistory: Setter<RunPrompt[]>
+  private miniSettings: Accessor<MiniSettings>
+  private setMiniSettings: Setter<MiniSettings>
   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<void> => {
+    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

+ 25 - 1
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<void>
@@ -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<void>
   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) {
                             }}
                           />
                         </Match>
+                        <Match when={setting()}>
+                          <RunSettingsBody
+                            theme={theme}
+                            settings={props.miniSettings}
+                            onClose={closePanel}
+                            onChange={props.onMiniSettingChange}
+                          />
+                        </Match>
                         <Match when={active().type === "permission"}>
                           <RunPermissionBody
                             request={permission()!.request}
@@ -904,6 +927,7 @@ export function RunFooterView(props: RunFooterViewProps) {
             onCycle={cycleTab}
             onClose={closeTab}
             interrupt={() => subagentInterruptShortcut() || undefined}
+            shellOutput={() => props.miniSettings().shell_output === "show"}
           />
         </box>
       </Show>

+ 8 - 1
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> }): MiniSettings {
+  return {
+    thinking: config?.mini?.thinking ?? "hide",
+    shell_output: config?.mini?.shell_output ?? "hide",
+  }
+}

+ 8 - 0
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<RunTuiConfig>
+  onMiniSettingChange?: (change: MiniSettingChange) => Promise<MiniSettings>
   onPermissionReply: (input: PermissionReply) => void | Promise<void>
   onFormReply: (input: FormReply) => void | Promise<void>
   onFormCancel: (input: FormCancel) => void | Promise<void>
@@ -224,6 +228,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
     theme,
     wrote,
     tuiConfig,
+    miniSettings: {
+      current: resolveMiniSettings(tuiConfig),
+      update: input.onMiniSettingChange,
+    },
     onPermissionReply: input.onPermissionReply,
     onFormReply: input.onFormReply,
     onFormCancel: input.onFormCancel,

+ 41 - 7
packages/tui/src/mini/runtime.ts

@@ -10,11 +10,27 @@
 //   4. runs the prompt queue until the footer closes.
 import { SessionMessage } from "@opencode-ai/schema/session-message"
 import type { LocationRef } from "@opencode-ai/client/promise"
+import type { Config } from "../config"
 import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
-import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
+import {
+  resolveMiniSettings,
+  resolveModelInfo,
+  resolveModelInfoStrict,
+  resolveRunTuiConfig,
+  resolveSessionInfo,
+} from "./runtime.boot"
 import { createRuntimeLifecycle } from "./runtime.lifecycle"
 import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
-import type { LocalReplayRow, MiniHost, RunInput, RunPrompt, RunProvider, RunTuiConfig, StreamCommit } from "./types"
+import type {
+  LocalReplayRow,
+  MiniHost,
+  MiniSettings,
+  RunInput,
+  RunPrompt,
+  RunProvider,
+  RunTuiConfig,
+  StreamCommit,
+} from "./types"
 
 type BootContext = Pick<RunInput, "sdk" | "agent" | "model" | "variant"> & {
   location: LocationRef
@@ -43,6 +59,7 @@ type RunRuntimeInput = {
   replayLimit?: number
   demo?: RunInput["demo"]
   tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
+  config?: Pick<Config.Interface, "update">
 }
 
 export type RunDeferredInput = {
@@ -62,6 +79,7 @@ export type RunDeferredInput = {
   replayLimit?: number
   demo?: RunInput["demo"]
   tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
+  config?: Pick<Config.Interface, "update">
 }
 
 type StreamTransportModule = Pick<
@@ -174,7 +192,12 @@ function abortable<A>(task: Promise<A>, signal: AbortSignal): Promise<A | undefi
 async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
   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,

+ 4 - 1
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))

+ 1 - 1
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()))

+ 6 - 4
packages/tui/src/mini/stream-v2.transport.ts

@@ -758,10 +758,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       client.session.active(options),
     ])
     if (!current(attempt)) return
-    state.pending = new Map(pending.flatMap((item) => {
-      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()

+ 7 - 1
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

+ 13 - 1
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<Config.Resolved, "keybinds" | "leader" | "theme" | "session">
+export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "session" | "mini">
+
+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.

+ 9 - 1
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(

+ 2 - 0
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={() => {}}
         />
       </Keymap.Provider>
     )

+ 69 - 2
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<FooterView>(input.view ?? { type: "prompt" })
@@ -125,6 +130,7 @@ async function renderFooter(
   )
   const state = footerState(input.state)
   const config = input.tuiConfig ?? tuiConfig
+  const [miniSettings] = createSignal<MiniSettings>(input.miniSettings ?? { thinking: "hide", shell_output: "hide" })
   function Harness() {
     return (
       <Keymap.Provider config={config}>
@@ -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)}
         />
       </Keymap.Provider>
     )
@@ -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<MiniSettings>({ thinking: "hide", shell_output: "hide" })
+  const app = await testRender(
+    () => (
+      <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
+        <RunSettingsBody
+          theme={() => RUN_THEME_FALLBACK.footer}
+          settings={settings}
+          onClose={() => {}}
+          onChange={(change) => {
+            setSettings((current) => ({ ...current, [change.key]: change.value }))
+          }}
+        />
+      </box>
+    ),
+    { 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<RunCommand[] | undefined>([
     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={() => {}}
         />
       </Keymap.Provider>
     )

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