mini: add quiet transcript settings (#38152)

This commit is contained in:
Simon Klee 2026-07-21 19:36:34 +02:00 committed by GitHub
commit dd6c95fdc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 398 additions and 52 deletions

View file

@ -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)),
},
}),
)
}),

View file

@ -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)

View file

@ -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}`

View file

@ -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" }),

View file

@ -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") {

View file

@ -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[]>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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",
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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))

View file

@ -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()))

View file

@ -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()

View file

@ -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

View file

@ -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.

View file

@ -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(

View file

@ -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>
)

View file

@ -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>
)

View file

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