refactor(tui): narrow mini compatibility surfaces (#38262)

This commit is contained in:
Simon Klee 2026-07-22 10:35:35 +02:00 committed by GitHub
commit 794137b33b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 67 additions and 159 deletions

View file

@ -16,8 +16,9 @@
// the synthetic tool parts through the same callbacks used by the live footer.
import path from "path"
import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { parseSlashHead } from "../prompt/parse"
import { writeSessionOutput } from "./stream"
import { toolCommit } from "./stream-v2.subagent"
import { toolCommit, toolFinalPhase } from "./stream-v2.subagent"
import type {
FooterApi,
FooterView,
@ -110,7 +111,6 @@ const SAMPLE_TABLE = [
type Ref = {
msg: string
part: string
call: string
tool: string
input: Record<string, JsonValue>
@ -126,7 +126,6 @@ type FormRequest = {
type Perm = {
ref: Ref
done: {
title: string
output: string
metadata?: Record<string, JsonValue>
}
@ -203,7 +202,6 @@ function showSubagent(
description: input.description,
status: input.status,
title: input.title,
lastUpdatedAt: Date.now(),
},
],
details: {
@ -337,7 +335,6 @@ async function emitReasoning(state: State, body: string, signal?: AbortSignal):
function make(state: State, tool: string, input: Record<string, JsonValue>): Ref {
return {
msg: open(state),
part: take(state, "part", "part"),
call: take(state, "call", "call"),
tool,
input,
@ -346,7 +343,7 @@ function make(state: State, tool: string, input: Record<string, JsonValue>): Ref
}
function startTool(state: State, ref: Ref, structured: Record<string, JsonValue> = {}): SessionMessageAssistantTool {
state.started.add(ref.part)
state.started.add(ref.call)
const part = {
type: "tool" as const,
id: ref.call,
@ -386,12 +383,11 @@ function doneTool(
state: State,
ref: Ref,
output: {
title: string
output: string
metadata?: Record<string, JsonValue>
},
): void {
if (!state.started.has(ref.part)) startTool(state, ref)
if (!state.started.has(ref.call)) startTool(state, ref)
const part: SessionMessageAssistantTool = {
type: "tool",
id: ref.call,
@ -404,11 +400,11 @@ function doneTool(
},
time: { created: ref.start, ran: ref.start, completed: Date.now() },
}
present(state, [toolCommit(part, ref.msg, output.output ? "progress" : "final")])
present(state, [toolCommit(part, ref.msg, toolFinalPhase(part))])
}
function failTool(state: State, ref: Ref, error: string): void {
if (!state.started.has(ref.part)) startTool(state, ref)
if (!state.started.has(ref.call)) startTool(state, ref)
present(state, [
toolCommit(
{
@ -443,7 +439,6 @@ async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
startTool(state, ref)
await wait(70, signal)
doneTool(state, ref, {
title: "git status",
output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`,
metadata: {
exit: 0,
@ -458,7 +453,6 @@ function emitWrite(state: State): void {
content: "export const demo = 42\n",
})
doneTool(state, ref, {
title: "write",
output: "",
metadata: {},
})
@ -470,7 +464,6 @@ function emitEdit(state: State): void {
path: file,
})
doneTool(state, ref, {
title: "edit",
output: "",
metadata: {
files: [
@ -490,7 +483,6 @@ function emitPatch(state: State): void {
patchText: "*** Begin Patch\n*** End Patch",
})
doneTool(state, ref, {
title: "patch",
output: "",
metadata: {
files: [
@ -517,10 +509,9 @@ function emitTask(state: State): void {
agent: "explore",
})
doneTool(state, ref, {
title: "Reducer touchpoints found",
output: "",
metadata: {
sessionID: "sub_demo_1",
sessionID: "ses_demo_child",
status: "completed",
output: "",
},
@ -542,7 +533,7 @@ function emitTask(state: State): void {
time: { created: Date.now(), ran: Date.now() },
} satisfies SessionMessageAssistantTool
showSubagent(state, {
sessionID: "sub_demo_1",
sessionID: "ses_demo_child",
label: "Explore",
description: "Scan run/* for reducer touchpoints",
status: "completed",
@ -562,16 +553,7 @@ function emitTask(state: State): void {
messageID: "sub_demo_msg_reasoning",
partID: "sub_demo_reasoning_1",
},
{
kind: "tool",
text: "running read",
phase: "start",
source: "tool",
messageID: "sub_demo_msg_tool",
partID: "sub_demo_tool_1",
tool: "read",
part,
},
toolCommit(part, "sub_demo_msg_tool", "start"),
{
kind: "assistant",
text: "Footer updates flow through stream.ts into RunFooter",
@ -610,7 +592,6 @@ function emitQuestionTool(state: State): void {
],
})
doneTool(state, ref, {
title: "question",
output: "",
metadata: {
answers: [["Diff"], ["Usage", "custom-note"]],
@ -635,7 +616,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
patterns: [command],
always: ["*"],
done: {
title: "git status --short",
output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`,
metadata: {
exit: 0,
@ -658,7 +638,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
patterns: [target],
always: [target],
done: {
title: "read",
output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"),
metadata: {},
},
@ -677,7 +656,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
patterns: ["explore"],
always: ["*"],
done: {
title: "Footer spacing checked",
output: "",
metadata: {
sessionID: "sub_demo_perm_1",
@ -707,7 +685,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
},
always: [`${dir}/**`],
done: {
title: "read",
output: `1: # External demo\n2: Shared preview file\nPath: ${target}`,
metadata: {},
},
@ -726,7 +703,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
patterns: ["*"],
always: ["*"],
done: {
title: "Retry allowed",
output: "Continuing after repeated failures.\n",
metadata: {},
},
@ -744,7 +720,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
patterns: [file],
always: [file],
done: {
title: "edit",
output: "",
metadata: {
files: [{ file, status: "modified", patch: diff }],
@ -955,8 +930,9 @@ export function createRunDemo(input: Input) {
const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise<boolean> => {
const text = line.text.trim()
const list = text.split(/\s+/)
const cmd = list[0] || ""
const head = parseSlashHead(text)
const list = head?.arguments.split(/\s+/).filter(Boolean) ?? []
const cmd = head ? `/${head.name}` : ""
clearSubagent(state.footer)
@ -966,7 +942,7 @@ export function createRunDemo(input: Input) {
}
if (cmd === "/permission") {
const kind = permissionKind(list[1])
const kind = permissionKind(list[0])
if (!kind) {
note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`)
return true
@ -977,7 +953,7 @@ export function createRunDemo(input: Input) {
}
if (cmd === "/form") {
const kind = formKind(list[1])
const kind = formKind(list[0])
if (!kind) {
note(state.footer, `Pick a form kind: ${FORMS.join(", ")}`)
return true
@ -988,8 +964,8 @@ export function createRunDemo(input: Input) {
}
if (cmd === "/fmt") {
const kind = (list[1] || "").toLowerCase()
const body = list.slice(2).join(" ")
const kind = (list[0] || "").toLowerCase()
const body = list.slice(1).join(" ")
if (!kind) {
note(state.footer, `Pick a kind: ${KINDS.join(", ")}`)
return true
@ -1032,7 +1008,6 @@ export function createRunDemo(input: Input) {
clearBlocker(state)
if (form.kind === "question") {
doneTool(state, form.ref, {
title: "question",
output: "",
metadata: {
answers: form.request.fields.map((field) => {
@ -1045,7 +1020,6 @@ export function createRunDemo(input: Input) {
return true
}
doneTool(state, form.ref, {
title: form.request.title,
output: `Form submitted: ${Object.entries(input.answer)
.map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(", ") : String(value)}`)
.join("; ")}\n`,

View file

@ -54,10 +54,6 @@ type SubagentEntry = PanelEntry & {
current: boolean
}
type QueuedEntry = PanelEntry & {
prompt: FooterQueuedPrompt
}
type SettingEntry = PanelEntry & {
key: keyof MiniSettings
}
@ -747,13 +743,12 @@ export function RunQueuedPromptSelectBody(props: {
onRows?: (rows: number) => void
mono?: boolean
}) {
const entries = createMemo<QueuedEntry[]>(() =>
const entries = createMemo(() =>
props.prompts().map((prompt) => ({
category: "",
display: prompt.prompt.text.replaceAll("\n", " "),
footer: prompt.delivery,
keywords: prompt.prompt.text,
prompt,
})),
)
const controller = createSearchablePanelController({

View file

@ -31,13 +31,13 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
import { monoTruncateMiddle } from "./mono"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1
export const TEXTAREA_MIN_ROWS = 1
export const TEXTAREA_MAX_ROWS = 6
const TEXTAREA_MAX_ROWS = 6
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
@ -65,7 +65,6 @@ type PromptInput = {
agents: Accessor<RunAgent[]>
references: Accessor<RunReference[]>
commands: Accessor<RunCommand[] | undefined>
tuiConfig: RunTuiConfig
state: Accessor<FooterState>
view: Accessor<string>
prompt: Accessor<boolean>
@ -142,7 +141,7 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
}
}
export function selectedCommand(text: string, command: RunPrompt["command"], commands?: RunCommand[]) {
export function selectedCommand(text: string, command: RunPrompt["command"]) {
if (!command) {
return
}
@ -152,14 +151,10 @@ export function selectedCommand(text: string, command: RunPrompt["command"], com
return
}
// Bound drafts (e.g. the skill picker) may predate or omit the catalog
// source; resolve it at submit time so routing never degrades to a plain
// command for a skill entry.
const source = command.source ?? commands?.find((item) => item.name === command.name)?.source
return {
name: command.name,
arguments: head.arguments,
...(source ? { source } : {}),
...(command.source ? { source: command.source } : {}),
}
}
@ -1140,7 +1135,7 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command, input.commands())
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit()
return

View file

@ -74,7 +74,6 @@ type RunFooterOptions = {
agents: RunAgent[]
references: RunReference[]
wrote?: boolean
sessionID: () => string | undefined
agentLabel: string
modelLabel: string
model: RunInput["model"]
@ -312,7 +311,6 @@ export class RunFooter implements FooterApi {
currentVariant: footer.currentVariant,
theme: footer.theme,
mono: options.mono,
tuiConfig: options.tuiConfig,
miniSettings: footer.miniSettings,
history: footer.history,
onSubmit: footer.handlePrompt,

View file

@ -50,7 +50,6 @@ import type {
RunPrompt,
RunProvider,
RunReference,
RunTuiConfig,
} from "./types"
import type { RunTheme } from "./theme"
@ -88,7 +87,6 @@ type RunFooterViewProps = {
queuedPrompts?: () => FooterQueuedPrompt[]
theme: () => RunTheme
mono: boolean
tuiConfig: RunTuiConfig
miniSettings: () => MiniSettings
history?: () => RunPrompt[]
onSubmit: (input: RunPrompt) => boolean
@ -359,7 +357,6 @@ export function RunFooterView(props: RunFooterViewProps) {
agents: props.agents,
references: props.references,
commands: props.commands,
tuiConfig: props.tuiConfig,
state: props.state,
view: promptView,
prompt,

View file

@ -14,7 +14,7 @@ import {
import type { FormAnswerField } from "../util/form"
import type { FormReply, MiniFormRequest } from "./types"
export { formCustom, formLabel, formRows, formSelected, formTextual, formValidateValue }
export { formCustom, formLabel, formRows, formTextual, formValidateValue }
export type FormBodyState = {
formID: string
@ -97,7 +97,7 @@ export function formSetSelected(state: FormBodyState, selected: number): FormBod
return { ...state, selected, error: "" }
}
export function formSetEditing(state: FormBodyState, editing: boolean): FormBodyState {
function formSetEditing(state: FormBodyState, editing: boolean): FormBodyState {
return { ...state, editing, error: "" }
}

View file

@ -76,7 +76,7 @@ export function permissionLabel(option: PermissionOption): string {
export { permissionAlwaysLines }
export function permissionReply(
function permissionReply(
sessionID: string,
requestID: string,
reply: PermissionReply["reply"],

View file

@ -232,7 +232,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
findFiles: input.findFiles,
agents: input.agents,
references: input.references,
sessionID: input.getSessionID ?? (() => input.sessionID),
...labels,
model: input.model,
variant: input.variant,

View file

@ -30,7 +30,7 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm
return Boolean(current && next && current === next)
}
export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
if (commit.kind === "tool") {
if (body.type === "structured" || body.type === "markdown") {
return "block"

View file

@ -194,7 +194,6 @@ function tab(child: ChildState): FooterSubagentTab {
status: child.status,
background: child.background ? true : undefined,
title: child.title,
lastUpdatedAt: child.lastUpdatedAt,
}
}
@ -1084,11 +1083,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
input.emit()
},
snapshot() {
const tabs = [...children.values()].map(tab).toSorted((a, b) => {
const tabs = [...children.values()].toSorted((a, b) => {
const active = Number(b.status === "running") - Number(a.status === "running")
if (active !== 0) return active
return b.lastUpdatedAt - a.lastUpdatedAt
})
}).map(tab)
const child = selected ? children.get(selected) : undefined
const details: Record<string, FooterSubagentDetail> =
child && !child.detailStale ? { [child.sessionID]: { commits: child.frames.map((item) => item.commit) } } : {}

View file

@ -85,7 +85,7 @@ function traceCommit(commit: StreamCommit) {
}
}
export function traceSubagentState(state: FooterSubagentState) {
function traceSubagentState(state: FooterSubagentState) {
return {
tabs: state.tabs,
details: Object.fromEntries(

View file

@ -0,0 +1,2 @@
export { toolInlineInfo, toolOutputText } from "./tool"
export type { MiniToolPart } from "./types"

View file

@ -27,18 +27,17 @@ import {
import { formatPath } from "../util/path-format"
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
export type { MiniToolPart } from "./types"
export { canonicalToolName } from "../util/tool-display"
export type ToolView = {
type ToolView = {
output: boolean
final: boolean
snap?: "code" | "diff" | "structured"
}
export type ToolPhase = "start" | "progress" | "final"
type ToolPhase = "start" | "progress" | "final"
export type ToolDict = Record<string, unknown>
type ToolDict = Record<string, unknown>
type PatchFile = {
status?: string
@ -78,7 +77,7 @@ type ToolMetadata = ToolDict & {
exit?: number
}
export type ToolFrame = {
type ToolFrame = {
directory?: string
raw: string
name: string
@ -94,7 +93,7 @@ export type ToolFrame = {
}
}
export type ToolInline = {
type ToolInline = {
icon: string
title: string
description?: string
@ -102,7 +101,7 @@ export type ToolInline = {
body?: string
}
export type ToolProps = {
type ToolProps = {
input: ToolInput
metadata: ToolMetadata
frame: ToolFrame
@ -166,7 +165,7 @@ export function toolOutputText(name: string, content: ReadonlyArray<{ type: stri
function normalizeInput(name: string, value: unknown) {
const input = dict(value)
const path = typeof input.path === "string" ? input.path : text(input.filePath) || text(input.filepath)
const path = typeof input.path === "string" ? input.path : text(input.filePath)
const agent = typeof input.agent === "string" ? input.agent : text(input.subagent_type)
return {
...input,
@ -191,7 +190,7 @@ function normalizeFile(value: unknown): PatchFile | undefined {
: legacy === "move"
? "moved"
: legacy)
const patch = typeof file.patch === "string" ? file.patch : text(file.diff) || undefined
const patch = typeof file.patch === "string" ? file.patch : undefined
const deletions = finiteNumber(file.deletions)
return {
...file,
@ -214,11 +213,6 @@ function normalizeStructured(name: string, value: unknown) {
...structured,
...(["edit", "patch"].includes(name) && Array.isArray(structured.files) ? { files } : {}),
...(name === "subagent" && sessionID ? { sessionID } : {}),
...(name === "shell" &&
finiteNumber(structured.exit) === undefined &&
finiteNumber(structured.exitCode) !== undefined
? { exit: finiteNumber(structured.exitCode) }
: {}),
}
}
@ -674,7 +668,7 @@ function scrollShellFinal(p: ToolProps): string {
return fail(p.frame)
}
const code = p.metadata.exit ?? finiteNumber(p.frame.meta.exitCode) ?? finiteNumber(p.frame.meta.exit_code)
const code = p.metadata.exit
const time = span(p.frame)
if (code === undefined) {
if (!time) {
@ -1113,7 +1107,7 @@ function frame(part: SessionMessageAssistantTool, directory?: string): ToolFrame
}
}
export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
const current = commit.part ? frame(commit.part, commit.directory) : undefined
return {
directory: commit.directory,
@ -1198,7 +1192,7 @@ export function toolScroll(phase: ToolPhase, ctx: ToolFrame): string {
return fallbackFinal(ctx)
}
export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined {
function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined {
const ctx = toolFrame(commit, raw)
const draw = rule(ctx.name)?.snap
if (!draw) {

View file

@ -53,7 +53,7 @@ export type RunCommand = {
source?: string
}
export type RunProviderModel = {
type RunProviderModel = {
name?: string
cost?: {
input: number
@ -159,7 +159,7 @@ export type MiniHost = {
export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error"
// Whether the assistant is actively processing a turn.
export type FooterPhase = "idle" | "running"
type FooterPhase = "idle" | "running"
// Full snapshot of footer status bar state. Every update replaces the whole
// object in the SolidJS signal so the view re-renders atomically.
@ -188,14 +188,14 @@ export type ScrollbackOptions = {
mono?: boolean
}
export type ToolCodeSnapshot = {
type ToolCodeSnapshot = {
kind: "code"
title: string
content: string
file?: string
}
export type ToolDiffSnapshot = {
type ToolDiffSnapshot = {
kind: "diff"
items: Array<{
title: string
@ -205,14 +205,14 @@ export type ToolDiffSnapshot = {
}>
}
export type ToolTaskSnapshot = {
type ToolTaskSnapshot = {
kind: "task"
title: string
rows: string[]
tail: string
}
export type ToolQuestionSnapshot = {
type ToolQuestionSnapshot = {
kind: "question"
items: Array<{
question: string
@ -223,15 +223,7 @@ export type ToolQuestionSnapshot = {
export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot
export type MiniToolState =
| { status: "pending"; input: Record<string, unknown>; raw?: string }
| {
status: "running"
input: Record<string, unknown>
title?: string
metadata?: Record<string, unknown>
time: { start: number }
}
type MiniToolState =
| {
status: "completed"
input: Record<string, unknown>
@ -304,7 +296,6 @@ export type FooterSubagentTab = {
status: "running" | "completed" | "cancelled" | "error"
background?: boolean
title?: string
lastUpdatedAt: number
}
export type FooterSubagentDetail = {
@ -392,7 +383,7 @@ export type FormCancel = {
location?: LocationRef
}
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "session" | "mini">
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini">
export type MiniSettings = {
thinking: "show" | "hide"
@ -408,11 +399,11 @@ export type MiniSettingChange = {
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
// appends content (coalesced in the footer queue), "final" closes it.
export type StreamPhase = "start" | "progress" | "final"
type StreamPhase = "start" | "progress" | "final"
export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
type StreamSource = "assistant" | "reasoning" | "tool" | "system"
export type StreamToolState = "running" | "completed" | "error"
type StreamToolState = "running" | "completed" | "error"
// A single append-only commit to scrollback. The transport produces these from
// V2 events, and RunFooter.append() queues them for the next