run: make minimal mode more minimal (#31227)

This commit is contained in:
Simon Klee
2026-06-07 23:26:14 +02:00
committed by GitHub
parent 914a643ab2
commit 07808bea12
39 changed files with 3138 additions and 959 deletions
+1 -1
View File
@@ -181,7 +181,7 @@ function showSubagent(
callID: string
label: string
description: string
status: "running" | "completed" | "error"
status: "running" | "completed" | "cancelled" | "error"
title?: string
toolCalls?: number
commits: StreamCommit[]
@@ -79,6 +79,13 @@ function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
}
export function entryFlags(commit: StreamCommit): EntryFlags {
if (commit.summary) {
return {
startOnNewLine: true,
trailingNewline: false,
}
}
if (commit.kind === "user") {
return {
startOnNewLine: true,
@@ -156,6 +163,10 @@ export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolea
}
export function entryBody(commit: StreamCommit): RunEntryBody {
if (commit.summary) {
return RUN_ENTRY_NONE
}
const raw = cleanRunText(commit.text)
if (commit.kind === "user") {
@@ -14,6 +14,8 @@ type PanelEntry = RunFooterMenuItem & {
type CommandEntry =
| (PanelEntry & { action: "model" })
| (PanelEntry & { action: "editor" })
| (PanelEntry & { action: "skill" })
| (PanelEntry & { action: "queued" })
| (PanelEntry & { action: "subagent" })
| (PanelEntry & { action: "variant.cycle" })
@@ -33,6 +35,10 @@ type VariantEntry = PanelEntry & {
current: boolean
}
type SkillEntry = PanelEntry & {
name: string
}
type SubagentEntry = PanelEntry & {
sessionID: string
current: boolean
@@ -107,6 +113,10 @@ function subagentStatusLabel(status: FooterSubagentTab["status"]) {
return "done"
}
if (status === "cancelled") {
return "cancelled"
}
if (status === "error") {
return "error"
}
@@ -203,94 +213,122 @@ function PanelShell(props: {
inputRef: (input: InputRenderable) => void
onQuery: (query: string) => void
children: JSX.Element
dark?: boolean
chrome?: "default" | "minimal"
}) {
return (
<box id={props.id} width="100%" flexDirection="column" backgroundColor="transparent" flexShrink={0}>
const background = () => (props.dark ? props.theme().shade : props.theme().surface)
const minimal = () => props.chrome === "minimal"
const content = (
<>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box
width="100%"
flexDirection="column"
border={["left"]}
borderColor={props.theme().highlight}
backgroundColor="transparent"
customBorderChars={PANEL_BORDER}
flexShrink={0}
>
<box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
<box
width="100%"
height={1}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
flexDirection="row"
gap={1}
flexShrink={0}
backgroundColor={props.theme().surface}
>
<text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
{props.title}
</text>
{props.countVisible !== false ? (
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
{countLabel(props.count, props.total, props.query)}
</text>
) : null}
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
esc
</text>
</box>
<box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
<box
width="100%"
height={1}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
flexShrink={0}
backgroundColor={props.theme().surface}
>
<input
width="100%"
focusedBackgroundColor={props.theme().surface}
focusedTextColor={props.theme().text}
placeholder={props.placeholder}
placeholderColor={props.theme().muted}
cursorColor={props.theme().highlight}
onInput={props.onQuery}
ref={(input) => {
props.inputRef(input)
input.traits = { status: "FILTER" }
queueMicrotask(() => {
if (!input.isDestroyed) {
input.focus()
}
})
}}
/>
</box>
<box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={props.theme().surface}>
{props.children}
</box>
</box>
<box
id={`${props.id}-bottom`}
width="100%"
height={1}
border={["left"]}
borderColor={props.theme().highlight}
backgroundColor="transparent"
customBorderChars={PANEL_BOTTOM_BORDER}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
flexDirection="row"
gap={1}
flexShrink={0}
backgroundColor={background()}
>
<box
<text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
{props.title}
</text>
{props.countVisible !== false ? (
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
{countLabel(props.count, props.total, props.query)}
</text>
) : null}
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
esc
</text>
</box>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box
width="100%"
height={1}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
flexShrink={0}
backgroundColor={background()}
>
<input
width="100%"
height={1}
border={["bottom"]}
borderColor={props.theme().surface}
backgroundColor="transparent"
customBorderChars={HALF_BLOCK_BORDER}
focusedBackgroundColor={background()}
focusedTextColor={props.theme().text}
placeholder={props.placeholder}
placeholderColor={props.theme().muted}
cursorColor={props.theme().highlight}
onInput={props.onQuery}
ref={(input) => {
props.inputRef(input)
input.traits = { status: "FILTER" }
queueMicrotask(() => {
if (!input.isDestroyed) {
input.focus()
}
})
}}
/>
</box>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={background()}>
{props.children}
</box>
</>
)
return (
<box id={props.id} width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{minimal() ? (
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{content}
</box>
) : (
<box
width="100%"
flexDirection="column"
border={["left"]}
borderColor={props.theme().highlight}
backgroundColor="transparent"
customBorderChars={PANEL_BORDER}
flexShrink={0}
>
{content}
</box>
)}
{minimal() ? (
<box id={`${props.id}-bottom`} width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
<box
width="100%"
height={1}
border={["bottom"]}
borderColor={background()}
backgroundColor="transparent"
customBorderChars={HALF_BLOCK_BORDER}
/>
</box>
) : (
<box
id={`${props.id}-bottom`}
width="100%"
height={1}
border={["left"]}
borderColor={props.theme().highlight}
backgroundColor="transparent"
customBorderChars={PANEL_BOTTOM_BORDER}
flexShrink={0}
>
<box
width="100%"
height={1}
border={["bottom"]}
borderColor={background()}
backgroundColor="transparent"
customBorderChars={HALF_BLOCK_BORDER}
/>
</box>
)}
</box>
)
}
@@ -304,6 +342,8 @@ export function RunCommandMenuBody(props: {
variantCycle: string
onClose: () => void
onModel: () => void
onEditor: () => void
onSkill: () => void
onSubagent: () => void
onQueued: () => void
onVariant: () => void
@@ -314,58 +354,31 @@ export function RunCommandMenuBody(props: {
}) {
let field: InputRenderable | undefined
const [query, setQuery] = createSignal("")
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 = ["new"]
return [
const builtins = ["editor", "new"]
const session: CommandEntry[] = [
{
action: "model",
category: "Suggested",
display: "Switch model",
action: "editor",
category: "Session",
display: "Open editor",
footer: "/editor",
keywords: "editor compose draft external editor",
},
...(props.queued().length > 0
? [
{
action: "queued" as const,
category: "Suggested",
display: "Manage queued prompts",
footer: `${props.queued().length} queued`,
keywords: props
.queued()
.map((item) => item.prompt.text)
.join(" "),
},
]
: []),
...(props.subagents().length > 0
? [
{
action: "subagent" as const,
category: "Suggested",
display: "View subagents",
footer: `${props.subagents().length} active`,
keywords: props
.subagents()
.map((item) => `${item.label} ${item.description} ${item.title ?? ""}`)
.join(" "),
},
]
: []),
{
action: "variant.cycle",
category: "Suggested",
display: "Variant cycle",
footer: props.variantCycle,
keywords: "variant cycle",
},
...(props.variants().length > 0
? [
{
action: "variant.list" as const,
category: "Suggested",
display: "Switch model variant",
keywords: `variant variants ${props.variants().join(" ")}`,
},
]
{
action: "subagent" as const,
category: "Session",
display: "View subagents",
footer: activeSubagentCount() > 0 ? `${activeSubagentCount()} active` : `${props.subagents().length} recent`,
keywords: props
.subagents()
.map((item) => `${item.label} ${item.description} ${item.title ?? ""}`)
.join(" "),
},
]
: []),
{
action: "slash",
@@ -375,23 +388,82 @@ export function RunCommandMenuBody(props: {
footer: "/new",
keywords: "new session clear",
},
...(props.commands() ?? [])
.filter((item) => item.source !== "skill" && !builtins.includes(item.name))
.map(
(item) =>
({
action: "slash",
category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
name: item.name,
display: item.name,
footer: `/${item.name}`,
keywords:
item.source === "mcp"
? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
: `/${item.name} ${item.name} ${item.description ?? ""}`,
}) satisfies CommandEntry,
)
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)),
]
const prompt: CommandEntry[] =
props.commands() === undefined || skills().length > 0
? [
{
action: "skill" as const,
category: "Prompt",
display: "Skills",
footer: "/skills",
keywords: `skill skills ${skills()
.map((item) => `${item.name} ${item.description ?? ""}`)
.join(" ")}`.trim(),
},
]
: []
const agent: CommandEntry[] = [
{
action: "model",
category: "Agent",
display: "Switch model",
},
...(props.queued().length > 0
? [
{
action: "queued" as const,
category: "Agent",
display: "Manage queued prompts",
footer: `${props.queued().length} queued`,
keywords: props
.queued()
.map((item) => item.prompt.text)
.join(" "),
},
]
: []),
{
action: "variant.cycle",
category: "Agent",
display: "Variant cycle",
footer: props.variantCycle,
keywords: "variant cycle",
},
...(props.variants().length > 0
? [
{
action: "variant.list" as const,
category: "Agent",
display: "Switch model variant",
keywords: `variant variants ${props.variants().join(" ")}`,
},
]
: []),
]
const commands = (props.commands() ?? [])
.filter((item) => item.source !== "skill" && !builtins.includes(item.name))
.map(
(item) =>
({
action: "slash",
category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
name: item.name,
display: item.name,
footer: `/${item.name}`,
keywords:
item.source === "mcp"
? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
: `/${item.name} ${item.name} ${item.description ?? ""}`,
}) satisfies CommandEntry,
)
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display))
return [
...session,
...prompt,
...agent,
...commands,
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
]
})
@@ -403,6 +475,16 @@ export function RunCommandMenuBody(props: {
return
}
if (item.action === "editor") {
props.onEditor()
return
}
if (item.action === "skill") {
props.onSkill()
return
}
if (item.action === "subagent") {
props.onSubagent()
return
@@ -471,6 +553,8 @@ export function RunCommandMenuBody(props: {
field = input
}}
onQuery={setQuery}
dark
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-command-list"
@@ -485,6 +569,8 @@ export function RunCommandMenuBody(props: {
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={!query().trim()}
background
headerColor={props.theme().muted}
/>
</PanelShell>
)
@@ -566,6 +652,8 @@ export function RunSubagentSelectBody(props: {
field = input
}}
onQuery={setQuery}
dark
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-subagent-list"
@@ -575,11 +663,12 @@ export function RunSubagentSelectBody(props: {
offset={menu.offset}
rows={menu.rows}
limit={SUBAGENT_LIST_ROWS}
empty="No active subagents"
empty="No subagents found"
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
background
/>
</PanelShell>
)
@@ -662,6 +751,8 @@ export function RunQueuedPromptSelectBody(props: {
field = input
}}
onQuery={setQuery}
dark
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-queued-list"
@@ -676,6 +767,86 @@ export function RunQueuedPromptSelectBody(props: {
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
background
/>
</PanelShell>
)
}
export function RunSkillSelectBody(props: {
theme: Accessor<RunFooterTheme>
commands: Accessor<RunCommand[] | undefined>
onClose: () => void
onSelect: (name: string) => void
}) {
let field: InputRenderable | undefined
const [query, setQuery] = createSignal("")
const entries = createMemo<SkillEntry[]>(() =>
(props.commands() ?? [])
.filter((item) => item.source === "skill")
.map((item) => ({
category: "",
display: item.name,
description: item.description?.replace(/\s+/g, " ").trim() || undefined,
keywords: `skill ${item.name} ${item.description ?? ""}`,
name: item.name,
}))
.sort((a, b) => a.display.localeCompare(b.display)),
)
const items = createMemo<SkillEntry[]>(() => match(query(), entries()))
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
const select = () => {
const item = items()[menu.selected()]
if (!item) {
return
}
props.onSelect(item.name)
}
createEffect(() => {
query()
menu.reset()
})
useKeyboard((event) => {
if (event.defaultPrevented) {
return
}
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
})
return (
<PanelShell
id="run-direct-footer-skill-panel"
title="Skills"
query={query()}
count={items().length}
total={entries().length}
placeholder="Search"
theme={props.theme}
inputRef={(input) => {
field = input
}}
onQuery={setQuery}
dark
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-skill-list"
theme={props.theme}
items={items}
selected={menu.selected}
offset={menu.offset}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
empty={props.commands() ? "No skills found" : "Skills loading"}
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
background
/>
</PanelShell>
)
@@ -759,6 +930,8 @@ export function RunVariantSelectBody(props: {
field = input
}}
onQuery={setQuery}
dark
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-variant-list"
@@ -773,6 +946,7 @@ export function RunVariantSelectBody(props: {
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
background
/>
</PanelShell>
)
@@ -879,6 +1053,8 @@ export function RunModelSelectBody(props: {
field = input
}}
onQuery={setQuery}
dark
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-model-list"
@@ -893,6 +1069,8 @@ export function RunModelSelectBody(props: {
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={!query().trim()}
background
headerColor={props.theme().muted}
/>
</PanelShell>
)
@@ -1,7 +1,9 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes } from "@opentui/core"
import { TextAttributes, type ColorInput } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { transparent, type RunFooterTheme } from "./theme"
import * as Locale from "@/util/locale"
export const FOOTER_MENU_ROWS = 8
@@ -125,7 +127,10 @@ export function RunFooterMenu(props: {
paddingLeft?: number
paddingRight?: number
grouped?: boolean
background?: boolean
headerColor?: ColorInput
}) {
const term = useTerminalDimensions()
const limit = () => props.limit ?? FOOTER_MENU_ROWS
const border = () => props.border ?? true
const [groupOffset, setGroupOffset] = createSignal(0)
@@ -203,16 +208,36 @@ export function RunFooterMenu(props: {
return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display)))
}
const descriptionText = (item: RunFooterMenuItem) => {
if (!item.description) {
return
}
const footerWidth = item.footer ? Bun.stringWidth(item.footer) + 1 : 0
const available =
term().width -
(border() ? 1 : 0) -
(props.paddingLeft ?? 1) -
(props.paddingRight ?? 0) -
descriptionColumn() -
footerWidth -
4
return Locale.truncate(item.description, Math.max(12, available))
}
return (
<box
id={props.id ?? "run-direct-footer-menu"}
width="100%"
height={props.rows()}
backgroundColor={transparent}
backgroundColor={props.background ? props.theme().shade : transparent}
flexDirection="column"
>
{rows().length === 0 ? (
<box paddingRight={0} flexDirection="row" backgroundColor={transparent}>
<box
paddingRight={0}
flexDirection="row"
backgroundColor={props.background ? props.theme().shade : transparent}
>
{border() ? (
<text fg={props.theme().border} wrapMode="none">
@@ -223,7 +248,7 @@ export function RunFooterMenu(props: {
flexShrink={1}
paddingLeft={props.paddingLeft ?? 1}
paddingRight={props.paddingRight ?? 0}
backgroundColor={props.theme().surface}
backgroundColor={props.background ? props.theme().shade : transparent}
>
<text fg={props.theme().muted} wrapMode="none" truncate>
{props.empty ?? "No matching items"}
@@ -239,7 +264,7 @@ export function RunFooterMenu(props: {
if (row.type === "header") {
return (
<box paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 1}>
<text fg={props.theme().highlight} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
<text fg={props.headerColor ?? props.theme().highlight} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
{row.label}
</text>
</box>
@@ -247,54 +272,75 @@ export function RunFooterMenu(props: {
}
const active = () => row.index === props.selected()
const inset = () => (active() ? 1 : 0)
const background = () =>
active()
? props.background
? props.theme().selected
: props.theme().shade
: props.background
? props.theme().shade
: transparent
return (
<box paddingRight={0} flexDirection="row" backgroundColor={transparent}>
<box
paddingRight={0}
flexDirection="row"
backgroundColor={background()}
>
{border() ? (
<text fg={active() ? props.theme().highlight : props.theme().border} wrapMode="none">
<text fg={props.theme().highlight} bg={background()} wrapMode="none">
{active() ? "▌" : " "}
</text>
) : undefined}
<box
flexGrow={1}
flexShrink={1}
paddingLeft={inset()}
paddingRight={inset()}
backgroundColor={props.theme().surface}
paddingLeft={props.paddingLeft ?? 1}
paddingRight={props.paddingRight ?? 0}
backgroundColor={background()}
>
<box
flexGrow={1}
flexShrink={1}
paddingLeft={Math.max(0, (props.paddingLeft ?? 1) - inset())}
paddingRight={Math.max(0, (props.paddingRight ?? 0) - inset())}
backgroundColor={active() ? props.theme().highlight : props.theme().surface}
>
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
<box flexDirection="row" gap={0} flexGrow={1} flexShrink={1}>
<text
fg={active() ? props.theme().surface : props.theme().text}
fg={active() ? props.theme().selectedText : props.theme().text}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
flexGrow={1}
flexShrink={0}
>
{row.item.display}
{row.item.description ? (
<span style={{ fg: active() ? props.theme().surface : props.theme().muted }}>
{descriptionPad(row.item)}
{row.item.description}
</span>
) : undefined}
</text>
{row.item.footer ? (
<text
fg={active() ? props.theme().surface : props.theme().muted}
wrapMode="none"
truncate
flexShrink={0}
>
{row.item.footer}
</text>
{row.item.description ? (
<>
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
wrapMode="none"
flexShrink={0}
>
{descriptionPad(row.item)}
</text>
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
wrapMode="none"
truncate
flexGrow={1}
flexShrink={1}
>
{descriptionText(row.item)}
</text>
</>
) : undefined}
</box>
{row.item.footer ? (
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
flexShrink={0}
>
{row.item.footer}
</text>
) : undefined}
</box>
</box>
</box>
@@ -29,6 +29,7 @@ import {
permissionShift,
type PermissionOption,
} from "./permission.shared"
import { footerWidthPolicy } from "./footer.width"
import { toolFiletype } from "./tool"
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
import type { PermissionReply, RunDiffStyle } from "./types"
@@ -140,7 +141,7 @@ export function RunPermissionBody(props: {
const [state, setState] = createSignal(createPermissionBodyState(props.request.id))
const info = createMemo(() => permissionInfo(props.request))
const ft = createMemo(() => toolFiletype(info().file))
const narrow = createMemo(() => dims().width < 80)
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const opts = createMemo(() => permissionOptions(state().stage))
const busy = createMemo(() => state().submitting)
const title = createMemo(() => {
@@ -257,7 +258,13 @@ export function RunPermissionBody(props: {
})
return (
<box id="run-direct-footer-permission-body" width="100%" height="100%" flexDirection="column">
<box
id="run-direct-footer-permission-body"
width="100%"
height="100%"
flexDirection="column"
backgroundColor={props.theme.surface}
>
<box
id="run-direct-footer-permission-head"
flexDirection="column"
@@ -1,13 +1,14 @@
// Prompt textarea component and its state machine for direct interactive mode.
// Prompt composer and its state machine for direct interactive mode.
//
// createPromptState() wires keymap command layers, history navigation, and
// `@` autocomplete for files, subagents, and MCP resources.
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
// while the footer view renders the current menu state below it.
// It produces a PromptState that RunPromptBody renders as a slim single-line
// composer while the footer view renders any active menus below it.
/** @jsxImportSource @opentui/solid */
import { pathToFileURL } from "bun"
import { StyledText, bg, fg, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { normalizePromptContent } from "@opencode-ai/tui/editor"
import fuzzysort from "fuzzysort"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
@@ -23,6 +24,7 @@ import {
pushPromptHistory,
} from "./prompt.shared"
import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"
@@ -34,13 +36,6 @@ export const TEXTAREA_MIN_ROWS = 1
export const TEXTAREA_MAX_ROWS = 6
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
export const HINT_BREAKPOINTS = {
send: 50,
newline: 66,
history: 80,
command: 95,
}
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
type Auto = RunFooterMenuItem & {
@@ -53,6 +48,7 @@ type Auto = RunFooterMenuItem & {
type SlashOption = RunFooterMenuItem & {
kind: "slash"
name: string
action?: "skill-menu" | "editor"
}
type PromptOption = Auto | SlashOption
@@ -75,9 +71,11 @@ type PromptInput = {
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void
onInterrupt: () => boolean
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onInputClear: () => void
onExitRequest?: () => boolean
onExit: () => void
onSkillMenu: () => void
onRows: (rows: number) => void
onStatus: (text: string) => void
}
@@ -93,6 +91,7 @@ export type PromptState = {
requestExit: () => boolean
onSubmit: () => void
submitText: (text: string) => void
openEditor: (input?: { value?: string }) => Promise<void>
onKeyDown: (event: KeyEvent) => void
onContentChange: () => void
replaceDraft: (text: string) => void
@@ -109,6 +108,7 @@ function clonePrompt(prompt: RunPrompt): RunPrompt {
text: prompt.text,
parts: structuredClone(prompt.parts),
...(prompt.mode ? { mode: prompt.mode } : {}),
...(prompt.command ? { command: prompt.command } : {}),
}
}
@@ -182,17 +182,25 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
return { type: "command" as const, command: { name: head.name, arguments: head.arguments } }
}
export function hintFlags(width: number) {
function selectedCommand(text: string, command: RunPrompt["command"]) {
if (!command) {
return
}
const head = slashHead(text)
if (!head || head.name !== command.name) {
return
}
return {
send: width >= HINT_BREAKPOINTS.send,
newline: width >= HINT_BREAKPOINTS.newline,
history: width >= HINT_BREAKPOINTS.history,
command: width >= HINT_BREAKPOINTS.command,
name: command.name,
arguments: head.arguments,
}
}
export function RunPromptBody(props: {
theme: () => RunFooterTheme
background: () => ColorInput
placeholder: () => StyledText | string
onSubmit: () => void
onKeyDown: (event: KeyEvent) => void
@@ -226,7 +234,7 @@ export function RunPromptBody(props: {
props.onContentChange()
})
.catch(() => {})
.catch(() => { })
}, 0)
}
@@ -243,7 +251,7 @@ export function RunPromptBody(props: {
return (
<box id="run-direct-footer-prompt" width="100%">
<box id="run-direct-footer-input-shell" paddingTop={1} paddingLeft={2} paddingRight={2}>
<box id="run-direct-footer-input-shell" paddingTop={1} paddingBottom={1} paddingRight={2}>
<textarea
id="run-direct-footer-composer"
width="100%"
@@ -254,8 +262,8 @@ export function RunPromptBody(props: {
placeholderColor={props.theme().muted}
textColor={props.theme().text}
focusedTextColor={props.theme().text}
backgroundColor={props.theme().surface}
focusedBackgroundColor={props.theme().surface}
backgroundColor={props.background()}
focusedBackgroundColor={props.background()}
cursorColor={props.theme().text}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
@@ -276,16 +284,14 @@ export function createPromptState(input: PromptInput): PromptState {
const [shell, setShell] = createSignal(false)
const placeholder = createMemo(() => {
if (shell()) {
return new StyledText([bg(input.theme().surface)(fg(input.theme().muted)('Run a command... "git status"'))])
return new StyledText([fg(input.theme().muted)('Run a command... "git status"')])
}
if (!input.state().first) {
return ""
}
return new StyledText([
bg(input.theme().surface)(fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')),
])
return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
})
let history = createPromptHistory(input.history)
@@ -412,13 +418,40 @@ export function createPromptState(input: PromptInput): PromptState {
{ initialValue: [] as Auto[] },
)
const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()])
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
const hasSkillsCommand = createMemo(() =>
(input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
)
const slashOptions = createMemo<SlashOption[]>(() => {
const builtins = [
{
kind: "slash",
action: "editor" as const,
name: "editor",
display: "/editor",
description: "compose in your external editor",
} satisfies SlashOption,
{ kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption,
{ kind: "slash", name: "exit", display: "/exit", description: "close direct mode" } satisfies SlashOption,
{ kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
]
const hidden = new Set(builtins.map((item) => item.name))
const showSkillMenu = !shell() && skillCommands().length > 0 && !hasSkillsCommand()
if (showSkillMenu) {
hidden.add("skills")
}
return [
...(showSkillMenu
? [
{
kind: "slash",
action: "skill-menu" as const,
name: "skills",
display: "/skills",
description: "browse available skills",
} satisfies SlashOption,
]
: []),
...(input.commands() ?? [])
.filter((item) => item.source !== "skill" && !hidden.has(item.name))
.map(
@@ -461,7 +494,7 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
input.onRows(clamp(area.virtualLineCount || 1) + popup())
input.onRows(clamp(Math.max(area.lineCount, area.virtualLineCount)) + popup())
}
const scheduleRows = () => {
@@ -695,6 +728,7 @@ export function createPromptState(input: PromptInput): PromptState {
}
syncParts()
const command = shell() ? undefined : selectedCommand(area.plainText, draft.command)
draft = shell()
? {
text: area.plainText,
@@ -704,6 +738,7 @@ export function createPromptState(input: PromptInput): PromptState {
: {
text: area.plainText,
parts: structuredClone(parts),
...(command ? { command } : {}),
}
}
@@ -783,6 +818,33 @@ export function createPromptState(input: PromptInput): PromptState {
area.focus()
}
const openEditor = async (inputValue?: { value?: string }) => {
input.onInputClear()
syncDraft()
hide()
const current = clonePrompt(draft)
try {
const content = await input.onEditorOpen({
value: inputValue?.value ?? current.text,
})
if (content === undefined) {
return
}
const normalized = normalizePromptContent(content)
restore({
text: normalized,
parts: realignEditorPromptParts(normalized, current.parts),
...(current.mode ? { mode: current.mode } : {}),
...(current.command ? { command: current.command } : {}),
})
} catch {
restore(current)
input.onStatus("failed to open editor")
}
}
const select = (item?: PromptOption) => {
const next = item ?? options()[menu.selected()]
if (!next || !area || area.isDestroyed) {
@@ -790,6 +852,19 @@ export function createPromptState(input: PromptInput): PromptState {
}
if (next.kind === "slash") {
if (next.action === "editor") {
void openEditor({
value: resolveEditorSlashValue(area.plainText),
})
return
}
if (next.action === "skill-menu") {
cancelAutocomplete()
input.onSkillMenu()
return
}
const cursor = area.cursorOffset
const head = slashHead(area.plainText)
const local = !shell() && (next.name === "new" || next.name === "exit")
@@ -898,6 +973,7 @@ export function createPromptState(input: PromptInput): PromptState {
const baseBindingsEnabled = () => {
const current = input.view()
if (current === "command") return false
if (current === "skill") return false
if (current === "model") return false
if (current === "variant") return false
if (current === "queued-menu") return false
@@ -939,6 +1015,22 @@ export function createPromptState(input: PromptInput): PromptState {
bindings: input.tuiConfig.keybinds.get("session.interrupt"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
commands: [
{
name: "prompt.editor",
title: "Open editor",
category: "Prompt",
run() {
void openEditor()
},
},
],
bindings: input.tuiConfig.keybinds.get("prompt.editor"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
@@ -1068,7 +1160,11 @@ export function createPromptState(input: PromptInput): PromptState {
]),
}))
const onKeyDown = (_event: KeyEvent) => {}
const onKeyDown = (event: KeyEvent) => {
if (input.state().phase === "idle" && event.name.toLowerCase() === "escape") {
input.onInputClear()
}
}
const submitPrompt = (next: RunPrompt) => {
if (!area || area.isDestroyed) {
@@ -1089,19 +1185,22 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
if (next.mode !== "shell" && isExitCommand(next.text)) {
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit()
return
}
const parsed =
next.mode === "shell" || isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands())
command || next.mode === "shell" || isNewCommand(next.text)
? undefined
: parseSlashCommand(next.text, input.commands())
if (parsed?.type === "pending") {
input.onStatus("loading commands")
return
}
const submit = parsed?.type === "command" ? { ...next, command: parsed.command } : next
const submit = command ? { ...next, command } : parsed?.type === "command" ? { ...next, command: parsed.command } : next
const shellMode = next.mode === "shell"
resetDraft()
@@ -1194,8 +1293,10 @@ export function createPromptState(input: PromptInput): PromptState {
requestExit,
onSubmit,
submitText,
openEditor,
onKeyDown,
onContentChange: () => {
input.onInputClear()
syncDraft()
refresh()
scheduleRows()
@@ -40,6 +40,7 @@ import {
questionTabs,
questionTotal,
} from "./question.shared"
import { footerWidthPolicy } from "./footer.width"
import type { RunFooterTheme } from "./theme"
import type { QuestionReject, QuestionReply } from "./types"
@@ -58,7 +59,7 @@ export function RunQuestionBody(props: {
const other = createMemo(() => questionOther(props.request, state()))
const picked = createMemo(() => questionPicked(state()))
const disabled = createMemo(() => state().submitting)
const narrow = createMemo(() => dims().width < 80)
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const verb = createMemo(() => {
if (confirm()) {
return "submit"
@@ -15,6 +15,10 @@ function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"])
return theme.highlight
}
if (status === "cancelled") {
return theme.muted
}
if (status === "error") {
return theme.error
}
@@ -27,6 +31,10 @@ function statusIcon(status: FooterSubagentTab["status"]) {
return "●"
}
if (status === "cancelled") {
return "○"
}
if (status === "error") {
return "◍"
}
+98 -37
View File
@@ -29,7 +29,7 @@ import type { Keymap } from "@opentui/keymap"
import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { OpencodeKeymapProvider, formatKeyBindings } from "@opencode-ai/tui/keymap"
import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap"
import { withRunSpan } from "./otel"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
@@ -37,6 +37,7 @@ import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
import { RunFooterView } from "./footer.view"
import { RunScrollbackStream } from "./scrollback.surface"
import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
import { modelInfo } from "./variant.shared"
import type {
FooterApi,
FooterEvent,
@@ -94,6 +95,7 @@ type RunFooterOptions = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onExit?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
treeSitterClient?: TreeSitterClient
@@ -102,10 +104,11 @@ type RunFooterOptions = {
const PERMISSION_ROWS = 12
const QUESTION_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 AUTOCOMPLETE_COMPACT_ROWS = 2
const NOTICE_DURATION = 3000
const THEME_REFRESH_DELAYS = [1000, 1000] as const
function createEmptySubagentState(): FooterSubagentState {
@@ -135,6 +138,8 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
phase: "running",
status: "sending prompt",
queue: next.queue,
interrupt: 0,
exit: 0,
}
}
@@ -153,10 +158,6 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
}
}
if (next.type === "turn.duration") {
return { duration: next.duration }
}
if (next.type === "stream.patch") {
return next.patch
}
@@ -207,6 +208,9 @@ export class RunFooter implements FooterApi {
private autocomplete = false
private interruptTimeout: NodeJS.Timeout | undefined
private exitTimeout: NodeJS.Timeout | undefined
private noticeTimeout: NodeJS.Timeout | undefined
private noticeRestoreStatus = ""
private statusVersion = 0
private requestExitHandler: (() => boolean) | undefined
private scrollback: RunScrollbackStream
private themes: RunTheme[]
@@ -327,6 +331,7 @@ export class RunFooter implements FooterApi {
onCycle: footer.handleCycle,
onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground,
onEditorOpen: options.onEditorOpen,
onInputClear: footer.handleInputClear,
onExitRequest: footer.handleExit,
onRequestExit: footer.setRequestExitHandler,
@@ -384,6 +389,23 @@ export class RunFooter implements FooterApi {
}
public event(next: FooterEvent): void {
if (next.type === "turn.duration") {
const current = this.currentModel()
this.flush()
this.flushing = this.flushing
.then(() =>
this.scrollback.writeTurnSummary({
agent: this.options.agentLabel,
model: current ? modelInfo(this.providers(), current).model : this.state().model,
duration: next.duration,
}),
)
.catch((error) => {
this.flushError = error
})
return
}
if (next.type === "catalog") {
if (this.isGone) {
return
@@ -427,6 +449,13 @@ export class RunFooter implements FooterApi {
const patch = eventPatch(next)
if (patch) {
if (typeof patch.status === "string") {
this.clearNoticeTimer()
}
if (next.type === "turn.send") {
this.clearInterruptTimer()
this.clearExitTimer()
}
this.patch(patch)
return
}
@@ -452,6 +481,9 @@ export class RunFooter implements FooterApi {
}
const prev = this.state()
if (typeof next.status === "string") {
this.statusVersion++
}
const state = {
phase: next.phase ?? prev.phase,
status: typeof next.status === "string" ? next.status : prev.status,
@@ -626,7 +658,31 @@ export class RunFooter implements FooterApi {
}
private setStatus = (status: string): void => {
this.setNotice(status)
}
private setNotice(status: string): void {
const restore = this.noticeTimeout ? this.noticeRestoreStatus : this.state().status
this.clearNoticeTimer(false)
this.patch({ status })
if (!status) {
this.noticeRestoreStatus = ""
return
}
this.noticeRestoreStatus = restore
const version = this.statusVersion
this.noticeTimeout = setTimeout(() => {
this.noticeTimeout = undefined
if (this.isGone || version !== this.statusVersion) {
this.noticeRestoreStatus = ""
return
}
const next = this.noticeRestoreStatus
this.noticeRestoreStatus = ""
this.patch({ status: next })
}, NOTICE_DURATION)
}
private setRequestExitHandler = (fn?: () => boolean): void => {
@@ -652,8 +708,6 @@ 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 compact = this.promptRoute.type === "composer" && this.autocomplete ? AUTOCOMPLETE_COMPACT_ROWS : 0
const base = this.base - compact
const height =
type === "permission"
? this.base + PERMISSION_ROWS
@@ -661,9 +715,11 @@ export class RunFooter implements FooterApi {
? this.base + QUESTION_ROWS
: this.promptRoute.type === "command"
? 1 + COMMAND_ROWS
: this.promptRoute.type === "model"
? 1 + MODEL_ROWS
: this.promptRoute.type === "variant"
: 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
@@ -671,7 +727,7 @@ export class RunFooter implements FooterApi {
? 1 + this.subagentMenuRows
: this.promptRoute.type === "subagent"
? this.base + SUBAGENT_INSPECTOR_ROWS
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
: this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
if (height !== this.renderer.footerHeight) {
this.renderer.footerHeight = height
@@ -713,7 +769,7 @@ export class RunFooter implements FooterApi {
}
if (this.prompts.size === 0) {
this.patch({ status: "input queue unavailable" })
this.setNotice("input queue unavailable")
return false
}
@@ -751,13 +807,11 @@ export class RunFooter implements FooterApi {
private handleCycle = (): void => {
const result = this.options.onCycleVariant?.()
if (!result) {
this.patch({ status: "no variants available" })
this.setNotice("no variants available")
return
}
const patch: FooterPatch = {
status: result.status ?? "variant updated",
}
const patch: FooterPatch = {}
if ("variants" in result) {
this.setVariants(result.variants ?? [])
@@ -772,6 +826,7 @@ export class RunFooter implements FooterApi {
}
this.patch(patch)
this.setNotice(result.status ?? "variant updated")
}
private handleModelSelect = (model: NonNullable<RunInput["model"]>): void => {
@@ -811,13 +866,12 @@ export class RunFooter implements FooterApi {
patch.model = result.modelLabel
}
if (result.status) {
patch.status = result.status
}
if (patch.model || patch.status) {
if (patch.model) {
this.patch(patch)
}
if (result.status) {
this.setNotice(result.status)
}
})
.catch(() => {})
}
@@ -853,13 +907,12 @@ export class RunFooter implements FooterApi {
patch.model = result.modelLabel
}
if (result.status) {
patch.status = result.status
}
if (patch.model || patch.status) {
if (patch.model) {
this.patch(patch)
}
if (result.status) {
this.setNotice(result.status)
}
})
.catch(() => {})
}
@@ -873,6 +926,21 @@ export class RunFooter implements FooterApi {
this.interruptTimeout = undefined
}
private clearNoticeTimer(reset = true): void {
if (!this.noticeTimeout) {
if (reset) {
this.noticeRestoreStatus = ""
}
return
}
clearTimeout(this.noticeTimeout)
this.noticeTimeout = undefined
if (reset) {
this.noticeRestoreStatus = ""
}
}
private armInterruptTimer(): void {
this.clearInterruptTimer()
this.interruptTimeout = setTimeout(() => {
@@ -885,13 +953,6 @@ export class RunFooter implements FooterApi {
}, 5000)
}
private interruptHint(): string {
const bindings = this.options.keymap
.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
.get("session.interrupt")
return formatKeyBindings(bindings, this.options.tuiConfig) || "esc"
}
private clearExitTimer(): void {
if (!this.exitTimeout) {
return
@@ -926,12 +987,12 @@ export class RunFooter implements FooterApi {
if (next < 2) {
this.armInterruptTimer()
this.patch({ status: `${this.interruptHint()} again to interrupt` })
return true
}
this.clearInterruptTimer()
this.patch({ interrupt: 0, status: "interrupting" })
this.patch({ interrupt: 0 })
this.setNotice("interrupting")
this.options.onInterrupt?.()
return true
}
@@ -947,7 +1008,6 @@ export class RunFooter implements FooterApi {
if (next < 2) {
this.armExitTimer()
this.patch({ status: "Press Ctrl-c again to exit" })
return true
}
@@ -1044,6 +1104,7 @@ export class RunFooter implements FooterApi {
this.notifyClose()
this.clearInterruptTimer()
this.clearExitTimer()
this.clearNoticeTimer()
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
this.renderer.off(CliRenderEvents.PALETTE, this.handlePalette)
this.renderer.off(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
+488 -442
View File
@@ -1,17 +1,15 @@
// Top-level footer layout for direct interactive mode.
// Footer layout
//
// Renders the footer region as a vertical stack:
// 1. Spacer row (visual separation from scrollback)
// 2. Composer frame with left-border accent -- swaps between prompt,
// permission, and question bodies via Switch/Match
// 3. Meta row showing agent name and model label in the normal composer view
// 4. Bottom border + status row (spinner, interrupt hint, duration, usage)
// Renders the footer region as a compact vertical stack:
// 1. Single-line composer or active footer body
// 2. Optional autocomplete/menu panels below the composer
// 3. A statusline-style footer row carrying state, hints, and model info
//
// All state comes from the parent RunFooter through SolidJS signals.
// The view itself is stateless except for derived memos.
/** @jsxImportSource @opentui/solid */
import { useTerminalDimensions } from "@opentui/solid"
import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import "opentui-spinner/solid"
import { createColors, createFrames } from "@opencode-ai/tui/ui/spinner"
import {
@@ -19,17 +17,20 @@ import {
RunCommandMenuBody,
RunModelSelectBody,
RunQueuedPromptSelectBody,
RunSkillSelectBody,
RunSubagentSelectBody,
RunVariantSelectBody,
} from "./footer.command"
import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu"
import { RunFooterSubagentBody } from "./footer.subagent"
import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
import { RunPromptBody, createPromptState } from "./footer.prompt"
import { RunPermissionBody } from "./footer.permission"
import { RunQuestionBody } from "./footer.question"
import { footerWidthPolicy } from "./footer.width"
import {
OPENCODE_BASE_MODE,
formatKeyBindings,
formatKeySequence,
useBindings,
useKeymapSelector,
type OpenTuiKeymap,
@@ -96,6 +97,7 @@ type RunFooterViewProps = {
onCycle: () => void
onInterrupt: () => boolean
onBackground?: () => void
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onInputClear: () => void
onExitRequest?: () => boolean
onRequestExit?: (fn: (() => boolean) | undefined) => void
@@ -113,6 +115,8 @@ export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
export function RunFooterView(props: RunFooterViewProps) {
const term = useTerminalDimensions()
const width = createMemo(() => term().width)
const responsive = createMemo(() => footerWidthPolicy(width()))
const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
const subagent = createMemo<FooterSubagentState>(() => {
return (
@@ -127,19 +131,32 @@ export function RunFooterView(props: RunFooterViewProps) {
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
const selectingQueued = createMemo(() => active().type === "prompt" && route().type === "queued-menu")
const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
const commanding = createMemo(() => active().type === "prompt" && route().type === "command")
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 panel = createMemo(() => selectingQueued() || selectingSubagent() || commanding() || modeling() || varianting())
const panel = createMemo(
() =>
active().type === "permission" ||
active().type === "question" ||
selectingQueued() ||
selectingSubagent() ||
commanding() ||
skilling() ||
modeling() ||
varianting(),
)
const selected = createMemo(() => {
const current = route()
return current.type === "subagent" ? current.sessionID : undefined
})
const tabs = createMemo(() => subagent().tabs)
const activeTabs = createMemo(() => tabs().filter((item) => item.status === "running"))
const selectedTab = createMemo(() => tabs().find((item) => item.sessionID === selected()))
const selectedIndex = createMemo(() => {
const sessionID = selected()
@@ -149,25 +166,7 @@ export function RunFooterView(props: RunFooterViewProps) {
return tabs().findIndex((item) => item.sessionID === sessionID) + 1
})
const subagentIndicator = createMemo(() => {
const count = tabs().length
if (count === 0) {
return
}
return {
count,
label: count === 1 ? "agent" : "agents",
}
})
const foregroundSubagents = createMemo(
() => props.backgroundSubagents && tabs().some((item) => item.status === "running" && !item.background),
)
const queuedIndicator = createMemo(() => {
const count = queuedPrompts().length
if (count === 0) return
return { count }
})
const foregroundSubagents = createMemo(() => props.backgroundSubagents && activeTabs().some((item) => !item.background))
const model = createMemo(() => {
const current = props.currentModel()
return current ? modelInfo(props.providers(), current) : { model: props.state().model, provider: undefined }
@@ -178,19 +177,46 @@ export function RunFooterView(props: RunFooterViewProps) {
})
const command = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] })
.get("command.palette.show"),
.get("command.palette.show")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const subagentShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] })
.get("session.child.first")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const queuedShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] })
.get("session.queued_prompts")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const backgroundShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.background"] })
.get("session.background")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const interrupt = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
.get("session.interrupt"),
.get("session.interrupt")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
@@ -201,42 +227,26 @@ export function RunFooterView(props: RunFooterViewProps) {
props.tuiConfig,
) ?? "",
)
const queuedShortcut = useKeymapSelector(
const clearShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] })
.get("session.queued_prompts"),
formatKeySequence(
keymap.getCommandBindings({ visibility: "registered", commands: ["prompt.clear"] }).get("prompt.clear")?.[0]
?.sequence,
props.tuiConfig,
) ?? "",
)
const subagentShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] })
.get("session.child.first"),
props.tuiConfig,
) ?? "",
)
const backgroundShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.background"] })
.get("session.background"),
props.tuiConfig,
) ?? "",
)
const hints = createMemo(() => hintFlags(term().width))
const busy = createMemo(() => props.state().phase === "running")
const armed = createMemo(() => props.state().interrupt > 0)
const exiting = createMemo(() => props.state().exit > 0)
const queue = createMemo(() => props.state().queue)
const additionalQueue = createMemo(() => Math.max(0, queue() - queuedPrompts().length))
const duration = createMemo(() => props.state().duration)
const usage = createMemo(() => props.state().usage)
const interruptKey = createMemo(() => interrupt() || "/exit")
const interruptLabel = createMemo(() => {
if (!interrupt()) {
return
}
return interrupt() === "escape" ? "esc" : interrupt()
})
const runTheme = createMemo(() => props.theme())
const theme = createMemo(() => runTheme().footer)
const block = createMemo(() => runTheme().block)
@@ -283,6 +293,15 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onSubagentSelect?.(undefined)
}
const openSkillMenu = () => {
if (props.commands() && skills().length === 0) {
return
}
setRoute({ type: "skill" })
props.onSubagentSelect?.(undefined)
}
const openVariant = () => {
setRoute({ type: "variant" })
props.onSubagentSelect?.(undefined)
@@ -343,20 +362,128 @@ export function RunFooterView(props: RunFooterViewProps) {
state: props.state,
view: promptView,
prompt,
width: () => term().width,
width,
theme,
history: props.history,
onSubmit: props.onSubmit,
onCycle: props.onCycle,
onInterrupt: props.onInterrupt,
onEditorOpen: props.onEditorOpen,
onInputClear: props.onInputClear,
onExitRequest: props.onExitRequest,
onExit: props.onExit,
onSkillMenu: openSkillMenu,
onRows: props.onRows,
onStatus: props.onStatus,
})
const shell = createMemo(() => prompt() && composer.shell())
const menu = createMemo(() => prompt() && composer.visible())
const stateStatus = createMemo(() => props.state().status.trim())
const modeLabel = createMemo(() => {
if (exiting()) {
return "EXIT"
}
return shell() ? "SHELL" : "BUILD"
})
const modeColor = createMemo(() => {
if (exiting()) {
return theme().error
}
if (shell()) {
return theme().warning
}
return theme().highlight
})
const statusText = createMemo(() => {
if (exiting()) {
return `Press ${clearShortcut() || "ctrl+c"} again to exit`
}
if (busy()) {
return armed() ? "again to interrupt" : "interrupt"
}
if (stateStatus().length > 0) {
return stateStatus()
}
return shell() ? "Shell mode" : ""
})
const activityMeta = createMemo(() => {
if (!responsive().statusline.showActivityMeta || usage().length === 0) {
return ""
}
return usage()
})
const modelStatus = createMemo(() => {
const current = props.currentModel()
if (!prompt() || shell() || !current) {
return
}
return {
model: model().model,
variant: props.currentVariant(),
provider: undefined,
// Prefer without provider, but keep it on the shared width policy if we add it back.
}
})
const statusColor = createMemo(() => {
if (exiting()) {
return theme().error
}
if (armed()) {
return theme().highlight
}
if (busy() || stateStatus().length > 0) {
return theme().text
}
return theme().muted
})
const statuslineBackground = createMemo(() => theme().status)
const hasActivityMeta = createMemo(() => activityMeta().length > 0)
const hasModelStatus = createMemo(() => responsive().statusline.showModel && Boolean(modelStatus()))
const contextHints = createMemo(() => {
if (!prompt() || shell() || !responsive().statusline.showContextHints) {
return []
}
const items: Array<{ kind: string; key: string; label: string }> = []
if (foregroundSubagents() && backgroundShortcut()) {
items.push({ kind: "background", key: backgroundShortcut(), label: "background" })
}
if (queuedPrompts().length > 0 && queuedShortcut()) {
items.push({ kind: "queued", key: queuedShortcut(), label: `${queue()} queued` })
}
if (activeTabs().length > 0 && subagentShortcut()) {
items.push({ kind: "subagents", key: subagentShortcut(), label: "subagents" })
}
const limit = responsive().statusline.contextHintLimit
return limit === undefined ? items : items.slice(0, limit)
})
const hasContextHints = createMemo(() => contextHints().length > 0)
const commandHint = createMemo(() => {
if (!prompt() || !responsive().statusline.showCommandHint) {
return
}
if (shell()) {
return { key: "esc", label: "normal" }
}
if (command()) {
return { key: command(), label: "cmd" }
}
})
const sectionSeparator = () => <span style={{ fg: theme().muted }}>· </span>
createEffect(() => {
props.onRequestExit?.(composer.requestExit)
@@ -470,6 +597,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const current = route()
if (
current.type !== "command" &&
current.type !== "skill" &&
current.type !== "model" &&
current.type !== "variant" &&
current.type !== "queued-menu" &&
@@ -500,406 +628,324 @@ export function RunFooterView(props: RunFooterViewProps) {
gap={0}
padding={0}
>
<box id="run-direct-footer-top-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
<Show when={panel() || inspecting()}>
<box id="run-direct-footer-panel-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
</Show>
<Show
when={inspecting()}
fallback={
<box width="100%" flexDirection="column" gap={0}>
<box
id="run-direct-footer-composer-frame"
width="100%"
flexShrink={0}
border={panel() ? false : ["left"]}
borderColor={theme().highlight}
customBorderChars={{
...EMPTY_BORDER,
vertical: "┃",
bottomLeft: "╹",
}}
>
<box
id="run-direct-footer-composer-area"
width="100%"
flexGrow={1}
<For each={[promptView()]}>
{() => (
<box
id="run-direct-footer-composer-frame"
width="100%"
flexShrink={0}
border={panel() || prompt() ? false : ["left"]}
borderColor={panel() || prompt() ? undefined : theme().highlight}
customBorderChars={
panel() || prompt()
? undefined
: {
...EMPTY_BORDER,
vertical: "█",
}
}
>
<box
id="run-direct-footer-composer-area"
width="100%"
flexGrow={1}
paddingLeft={0}
paddingRight={0}
paddingTop={0}
flexDirection="column"
backgroundColor={panel() || prompt() ? "transparent" : theme().surface}
gap={0}
>
<box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
<Switch>
<Match when={active().type === "prompt" && route().type === "composer"}>
<RunPromptBody
theme={theme}
background={() => runTheme().background}
placeholder={composer.placeholder}
onSubmit={composer.onSubmit}
onKeyDown={composer.onKeyDown}
onContentChange={composer.onContentChange}
bind={composer.bind}
/>
</Match>
<Match when={selectingSubagent()}>
<RunSubagentSelectBody
theme={theme}
tabs={tabs}
current={selected}
onClose={closePanel}
onSelect={openTab}
onRows={setSubagentMenuRows}
/>
</Match>
<Match when={selectingQueued()}>
<RunQueuedPromptSelectBody
theme={theme}
prompts={queuedPrompts}
onClose={closePanel}
onDelete={(item) => void props.onQueuedRemove(item.messageID)}
onEdit={async (item) => {
if (!(await props.onQueuedRemove(item.messageID))) return
closePanel()
queueMicrotask(() => composer.replacePrompt(item.prompt))
}}
onRows={setSubagentMenuRows}
/>
</Match>
<Match when={commanding()}>
<RunCommandMenuBody
theme={theme}
commands={props.commands}
subagents={tabs}
queued={queuedPrompts}
variants={props.variants}
variantCycle={variantCycle()}
onClose={closePanel}
onModel={openModel}
onEditor={() => {
closePanel()
void composer.openEditor()
}}
onSkill={openSkillMenu}
onSubagent={openSubagentMenu}
onQueued={openQueuedMenu}
onVariant={openVariant}
onVariantCycle={() => {
props.onCycle()
closePanel()
}}
onCommand={(name) => {
composer.submitText(`/${name}`)
closePanel()
}}
onNew={() => {
composer.submitText("/new")
closePanel()
}}
onExit={props.onExit}
/>
</Match>
<Match when={skilling()}>
<RunSkillSelectBody
theme={theme}
commands={props.commands}
onClose={closePanel}
onSelect={(name) => {
composer.replacePrompt({
text: `/${name} `,
parts: [],
command: {
name,
arguments: "",
},
})
closePanel()
}}
/>
</Match>
<Match when={modeling()}>
<RunModelSelectBody
theme={theme}
providers={props.providers}
current={props.currentModel}
onClose={closePanel}
onSelect={(model) => {
props.onModelSelect(model)
closePanel()
}}
/>
</Match>
<Match when={varianting()}>
<RunVariantSelectBody
theme={theme}
variants={props.variants}
current={props.currentVariant}
onClose={closePanel}
onSelect={(variant) => {
props.onVariantSelect(variant)
closePanel()
}}
/>
</Match>
<Match when={active().type === "permission"}>
<RunPermissionBody
request={permission()!.request}
theme={theme()}
block={block()}
diffStyle={props.diffStyle}
onReply={props.onPermissionReply}
/>
</Match>
<Match when={active().type === "question"}>
<RunQuestionBody
request={question()!.request}
theme={theme()}
onReply={props.onQuestionReply}
onReject={props.onQuestionReject}
/>
</Match>
</Switch>
</box>
</box>
</box>
)}
</For>
<Show when={!panel() && menu()}>
<RunFooterMenu
id="run-direct-footer-complete"
theme={theme}
items={composer.options}
selected={composer.selected}
offset={composer.offset}
rows={composer.rows}
limit={FOOTER_MENU_ROWS}
border={false}
paddingLeft={0}
paddingRight={0}
paddingTop={0}
flexDirection="column"
backgroundColor={panel() ? "transparent" : theme().surface}
/>
</Show>
<Show when={!panel() && !menu()}>
<box
id="run-direct-footer-statusline"
width="100%"
height={1}
flexDirection="row"
gap={0}
flexShrink={0}
backgroundColor={statuslineBackground()}
>
<box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
<Switch>
<Match when={active().type === "prompt" && route().type === "composer"}>
<RunPromptBody
theme={theme}
placeholder={composer.placeholder}
onSubmit={composer.onSubmit}
onKeyDown={composer.onKeyDown}
onContentChange={composer.onContentChange}
bind={composer.bind}
/>
</Match>
<Match when={selectingSubagent()}>
<RunSubagentSelectBody
theme={theme}
tabs={tabs}
current={selected}
onClose={closePanel}
onSelect={openTab}
onRows={setSubagentMenuRows}
/>
</Match>
<Match when={selectingQueued()}>
<RunQueuedPromptSelectBody
theme={theme}
prompts={queuedPrompts}
onClose={closePanel}
onDelete={(item) => void props.onQueuedRemove(item.messageID)}
onEdit={async (item) => {
if (!(await props.onQueuedRemove(item.messageID))) return
closePanel()
queueMicrotask(() => composer.replacePrompt(item.prompt))
}}
onRows={setSubagentMenuRows}
/>
</Match>
<Match when={commanding()}>
<RunCommandMenuBody
theme={theme}
commands={props.commands}
subagents={tabs}
queued={queuedPrompts}
variants={props.variants}
variantCycle={variantCycle()}
onClose={closePanel}
onModel={openModel}
onSubagent={openSubagentMenu}
onQueued={openQueuedMenu}
onVariant={openVariant}
onVariantCycle={() => {
props.onCycle()
closePanel()
}}
onCommand={(name) => {
composer.submitText(`/${name}`)
closePanel()
}}
onNew={() => {
composer.submitText("/new")
closePanel()
}}
onExit={props.onExit}
/>
</Match>
<Match when={modeling()}>
<RunModelSelectBody
theme={theme}
providers={props.providers}
current={props.currentModel}
onClose={closePanel}
onSelect={(model) => {
props.onModelSelect(model)
closePanel()
}}
/>
</Match>
<Match when={varianting()}>
<RunVariantSelectBody
theme={theme}
variants={props.variants}
current={props.currentVariant}
onClose={closePanel}
onSelect={(variant) => {
props.onVariantSelect(variant)
closePanel()
}}
/>
</Match>
<Match when={active().type === "permission"}>
<RunPermissionBody
request={permission()!.request}
theme={theme()}
block={block()}
diffStyle={props.diffStyle}
onReply={props.onPermissionReply}
/>
</Match>
<Match when={active().type === "question"}>
<RunQuestionBody
request={question()!.request}
theme={theme()}
onReply={props.onQuestionReply}
onReject={props.onQuestionReject}
/>
</Match>
</Switch>
<box
id="run-direct-footer-statusline-mode"
paddingLeft={1}
paddingRight={1}
backgroundColor={theme().statusAccent}
flexShrink={0}
>
<text wrapMode="none" truncate>
<span style={{ fg: modeColor(), bold: true }}>{modeLabel()}</span>
</text>
</box>
<Show when={!menu() && !panel()}>
<box
id="run-direct-footer-meta-row"
width="100%"
flexDirection="row"
gap={1}
paddingLeft={2}
flexShrink={0}
paddingTop={1}
<box
id="run-direct-footer-statusline-main"
flexDirection="row"
gap={1}
flexGrow={1}
flexShrink={1}
minWidth={12}
paddingLeft={1}
paddingRight={1}
backgroundColor="transparent"
>
<Show when={busy() && !exiting()}>
<box id="run-direct-footer-status-spinner" flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} />
</box>
</Show>
<text
id="run-direct-footer-statusline-text"
fg={statusColor()}
wrapMode="none"
truncate
flexGrow={1}
flexShrink={1}
>
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
{shell() ? "Shell" : props.agent}
<Show when={busy() && !exiting()} fallback={statusText()}>
<Show when={interruptLabel()}>
{(label) => <span style={{ fg: armed() ? statusColor() : theme().muted }}>{label()} </span>}
</Show>
{statusText()}
</Show>
</text>
</box>
<Show when={activityMeta().length > 0}>
<box
id="run-direct-footer-statusline-meta"
paddingRight={1}
backgroundColor="transparent"
flexShrink={1}
>
<text fg={theme().muted} wrapMode="none" truncate>
{activityMeta()}
</text>
<Show when={!shell()}>
<box id="run-direct-footer-model" flexDirection="row" gap={1} flexGrow={1} flexShrink={1}>
<text fg={theme().muted} wrapMode="none" flexShrink={0}>
·
</text>
<text fg={theme().text} wrapMode="none" truncate flexShrink={1}>
{model().model}
</text>
<Show when={model().provider}>
{(provider) => (
<text fg={theme().muted} wrapMode="none" truncate flexShrink={1}>
{provider()}
</text>
)}
</box>
</Show>
<Show when={responsive().statusline.showModel && modelStatus()}>
{(info) => (
<box
id="run-direct-footer-statusline-model"
paddingRight={1}
backgroundColor="transparent"
flexShrink={0}
>
<text fg={theme().text} wrapMode="none">
{info().model}
<Show when={info().provider}>
{(provider) => <span style={{ fg: theme().muted }}> {provider()}</span>}
</Show>
<Show when={props.currentVariant()}>
<Show when={info().variant}>
{(variant) => (
<>
<text fg={theme().muted} wrapMode="none" flexShrink={0}>
·
</text>
<text wrapMode="none" truncate flexShrink={1}>
<span style={{ fg: theme().warning, bold: true }}>{variant()}</span>
</text>
<span style={{ fg: theme().warning, bold: true }}> {variant()}</span>
</>
)}
</Show>
</box>
</Show>
</box>
</text>
</box>
)}
</Show>
<For each={contextHints()}>
{(hint, index) => (
<box
id={`run-direct-footer-statusline-${hint.kind}`}
paddingRight={1}
backgroundColor="transparent"
flexShrink={0}
maxWidth={24}
>
<text fg={theme().text} wrapMode="none" truncate>
<Show when={index() > 0 || ((hasActivityMeta() || hasModelStatus()) && index() === 0)}>
{sectionSeparator()}
</Show>
<span style={{ fg: theme().text }}>{hint.key}</span>{" "}
<span style={{ fg: theme().muted }}>{hint.label}</span>
</text>
</box>
)}
</For>
<Show when={commandHint()}>
{(hint) => (
<box
id="run-direct-footer-statusline-hint"
paddingRight={1}
backgroundColor="transparent"
flexShrink={0}
maxWidth={18}
>
<text fg={theme().text} wrapMode="none" truncate>
<Show when={hasActivityMeta() || hasModelStatus() || hasContextHints()}>{sectionSeparator()}</Show>
<span style={{ fg: theme().text }}>{hint().key}</span>{" "}
<span style={{ fg: theme().muted }}>{hint().label}</span>
</text>
</box>
)}
</Show>
</box>
</box>
<Show when={!panel()}>
<Show
when={menu()}
fallback={
<box
id="run-direct-footer-line-6"
width="100%"
height={1}
border={["left"]}
borderColor={theme().highlight}
backgroundColor="transparent"
customBorderChars={{
...EMPTY_BORDER,
vertical: "╹",
}}
flexShrink={0}
>
<box
id="run-direct-footer-line-6-fill"
width="100%"
height={1}
border={["bottom"]}
borderColor={theme().surface}
backgroundColor="transparent"
customBorderChars={{
...EMPTY_BORDER,
horizontal: "▀",
}}
/>
</box>
}
>
<box
id="run-direct-footer-menu-transition"
width="100%"
height={1}
border={["left"]}
borderColor={theme().highlight}
backgroundColor="transparent"
customBorderChars={{
...EMPTY_BORDER,
vertical: "┃",
}}
flexShrink={0}
>
<box
id="run-direct-footer-menu-transition-fill"
width="100%"
height={1}
backgroundColor={theme().surface}
/>
</box>
</Show>
<Show
when={menu()}
fallback={
<box
id="run-direct-footer-row"
width="100%"
height={1}
flexDirection="row"
justifyContent="space-between"
gap={1}
flexShrink={0}
>
<Show
when={busy() || exiting() || duration().length > 0 || queuedIndicator() || subagentIndicator()}
>
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0} marginLeft={1}>
<Show when={exiting()}>
<text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate>
Press Ctrl-c again to exit
</text>
</Show>
<Show when={busy() && !exiting()}>
<box id="run-direct-footer-status-spinner" flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} />
</box>
<text
id="run-direct-footer-hint-interrupt"
fg={armed() ? theme().highlight : theme().text}
wrapMode="none"
truncate
>
{interruptKey()}{" "}
<span style={{ fg: armed() ? theme().highlight : theme().muted }}>
{armed() ? "again to interrupt" : "interrupt"}
</span>
</text>
</Show>
<Show when={!busy() && !exiting() && duration().length > 0}>
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0}>
<text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
</text>
<box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
<text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
·
</text>
<text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
{duration()}
</text>
</box>
</box>
</Show>
<Show when={subagentIndicator()}>
{(info) => (
<text id="run-direct-footer-subagents-label" fg={theme().text} wrapMode="none" truncate>
<span style={{ fg: theme().highlight }}> </span>
{info().count} <span style={{ fg: theme().muted }}>{info().label} </span>
<span style={{ fg: theme().highlight }}>{subagentShortcut() || "leader+down"}</span>
</text>
)}
</Show>
<Show when={foregroundSubagents() && backgroundShortcut()}>
<text id="run-direct-footer-background-label" fg={theme().text} wrapMode="none" truncate>
<span style={{ fg: theme().highlight }}> </span>
<span style={{ fg: theme().highlight }}>{backgroundShortcut()}</span>{" "}
<span style={{ fg: theme().muted }}>background</span>
</text>
</Show>
<Show when={queuedIndicator()}>
{(info) => (
<text id="run-direct-footer-queued-label" fg={theme().text} wrapMode="none" truncate>
<span style={{ fg: theme().warning }}> </span>
{info().count} <span style={{ fg: theme().muted }}>queued </span>
<span style={{ fg: theme().highlight }}>{queuedShortcut() || "leader+q"}</span>
</text>
)}
</Show>
</box>
</Show>
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<box
id="run-direct-footer-hint-group"
flexDirection="row"
gap={2}
flexShrink={0}
justifyContent="flex-end"
>
<Show
when={shell()}
fallback={
<>
<Show when={additionalQueue() > 0}>
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
{additionalQueue()} queued
</text>
</Show>
<Show when={usage().length > 0}>
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
{usage()}
</text>
</Show>
<Show when={command().length > 0 && hints().command}>
<text id="run-direct-footer-hint-command" fg={theme().text} wrapMode="none" truncate>
{command()} <span style={{ fg: theme().muted }}>commands</span>
</text>
</Show>
</>
}
>
<text id="run-direct-footer-hint-shell" fg={theme().text} wrapMode="none" truncate>
esc <span style={{ fg: theme().muted }}>exit shell mode</span>
</text>
</Show>
</box>
</box>
}
>
<box id="run-direct-footer-complete-shell" width="100%" flexDirection="column" flexShrink={0}>
<RunFooterMenu
id="run-direct-footer-complete"
theme={theme}
items={composer.options}
selected={composer.selected}
offset={composer.offset}
rows={composer.rows}
limit={FOOTER_MENU_ROWS}
paddingLeft={2}
/>
<box
id="run-direct-footer-complete-bottom"
width="100%"
height={1}
border={["left"]}
borderColor={theme().border}
backgroundColor="transparent"
customBorderChars={{
...EMPTY_BORDER,
vertical: "╹",
}}
flexShrink={0}
>
<box
id="run-direct-footer-complete-bottom-fill"
width="100%"
height={1}
border={["bottom"]}
borderColor={theme().surface}
backgroundColor="transparent"
customBorderChars={{
...EMPTY_BORDER,
horizontal: "▀",
}}
/>
</box>
</box>
</Show>
</Show>
</box>
}
@@ -923,7 +969,7 @@ export function RunFooterView(props: RunFooterViewProps) {
index={selectedIndex}
total={() => tabs().length}
detail={detail}
width={() => term().width}
width={width}
diffStyle={props.diffStyle}
onCycle={cycleTab}
onClose={closeTab}
@@ -0,0 +1,27 @@
// Shared responsive width policy
const FOOTER_WIDTH_BREAKPOINTS = {
compact: 80,
commandHint: 66,
model: 120,
spacious: 150,
} as const
export function footerWidthPolicy(width: number) {
const compact = width >= FOOTER_WIDTH_BREAKPOINTS.compact
const model = width >= FOOTER_WIDTH_BREAKPOINTS.model
const spacious = width >= FOOTER_WIDTH_BREAKPOINTS.spacious
return {
dialog: {
narrow: !compact,
},
statusline: {
showActivityMeta: compact,
showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint,
showContextHints: compact,
contextHintLimit: !compact ? 0 : spacious ? undefined : model ? 2 : 1,
showModel: model,
},
}
}
@@ -0,0 +1,162 @@
import type { RunPromptPart } from "./types"
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
export function resolveEditorSlashValue(text: string) {
const head = slashHead(text)
if (!head || head.name.toLowerCase() !== "editor") {
return text
}
return head.arguments
}
export function realignEditorPromptParts(content: string, parts: RunPromptPart[]): RunPromptPart[] {
const matches = new Map<number, Mention | undefined>()
const used: Array<{ start: number; end: number }> = []
for (const [index, part] of parts.entries()) {
if (part.type !== "file" && part.type !== "agent") {
continue
}
const text = promptPartText(part)
if (!text) {
continue
}
const start = findPromptPartIndex(content, text, used, promptPartStart(part))
if (start === -1) {
matches.set(index, undefined)
continue
}
const end = start + text.length
used.push({ start, end })
matches.set(index, updatePromptPart(part, start, end, text))
}
const next: RunPromptPart[] = []
for (const [index, part] of parts.entries()) {
if (part.type !== "file" && part.type !== "agent") {
next.push(part)
continue
}
if (!promptPartText(part)) {
next.push(part)
continue
}
const match = matches.get(index)
if (match) {
next.push(match)
}
}
return next
}
function slashHead(text: string) {
if (!text.startsWith("/")) {
return
}
for (let i = 1; i < text.length; i++) {
switch (text[i]) {
case " ":
case "\t":
case "\n":
return {
name: text.slice(1, i),
arguments: text.slice(i + 1),
}
}
}
return {
name: text.slice(1),
arguments: "",
}
}
function promptPartText(part: Mention) {
if (part.type === "agent") {
return part.source?.value
}
return part.source?.text.value
}
function promptPartStart(part: Mention) {
if (part.type === "agent") {
return part.source?.start ?? Number.POSITIVE_INFINITY
}
return part.source?.text.start ?? Number.POSITIVE_INFINITY
}
function findPromptPartIndex(
content: string,
text: string,
used: Array<{ start: number; end: number }>,
hint: number,
) {
let searchFrom = 0
let best = -1
let distance = Number.POSITIVE_INFINITY
const hinted = Number.isFinite(hint)
while (true) {
const start = content.indexOf(text, searchFrom)
if (start === -1) {
return best
}
const end = start + text.length
searchFrom = start + 1
if (used.some((range) => start < range.end && end > range.start)) {
continue
}
if (!hinted) {
return start
}
const nextDistance = Math.abs(start - hint)
if (nextDistance < distance) {
best = start
distance = nextDistance
}
}
}
function updatePromptPart(part: Mention, start: number, end: number, text: string): Mention {
if (part.type === "agent") {
return {
...part,
source: {
start,
end,
value: text,
},
}
}
if (!part.source?.text) {
return part
}
return {
...part,
source: {
...part.source,
text: {
...part.source.text,
start,
end,
value: text,
},
},
}
}
@@ -30,11 +30,17 @@ export function promptCopy(prompt: RunPrompt): RunPrompt {
text: prompt.text,
parts: structuredClone(prompt.parts),
...(prompt.mode ? { mode: prompt.mode } : {}),
...(prompt.command ? { command: prompt.command } : {}),
}
}
export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
return a.mode === b.mode && a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
return (
a.mode === b.mode &&
a.text === b.text &&
JSON.stringify(a.parts) === JSON.stringify(b.parts) &&
JSON.stringify(a.command) === JSON.stringify(b.command)
)
}
export function isExitCommand(input: string): boolean {
@@ -8,10 +8,13 @@
//
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Session as SessionApi } from "@/session/session"
import { Global } from "@opencode-ai/core/global"
import { openEditor } from "@opencode-ai/tui/editor"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { Session as SessionApi } from "@/session/session"
import * as Locale from "@/util/locale"
import { withRunSpan } from "./otel"
import { resolveInteractiveStdin } from "./runtime.stdin"
@@ -30,7 +33,7 @@ import type {
} from "./types"
import { formatModelLabel } from "./variant.shared"
const FOOTER_HEIGHT = 7
const FOOTER_HEIGHT = 4
type SplashState = {
entry: boolean
@@ -135,6 +138,17 @@ function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): Foo
}
}
function directoryLabel(directory: string) {
const resolved = path.resolve(directory)
const display =
resolved === Global.Path.home
? "~"
: resolved.startsWith(`${Global.Path.home}${path.sep}`)
? resolved.replace(Global.Path.home, "~")
: resolved
return display.replaceAll("\\", "/")
}
function queueSplash(
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
state: SplashState,
@@ -205,6 +219,11 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
title: splash.title,
session_id: input.sessionID,
})
const labels = footerLabels({
agent: input.agent,
model: input.model,
variant: input.variant,
})
const footerTask = import("./footer")
const wrote = queueSplash(
renderer,
@@ -214,17 +233,15 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
...meta,
theme: theme.splash,
showSession: splash.showSession,
detail: directoryLabel(input.directory),
}),
)
await renderer.idle().catch(() => {})
const { RunFooter } = await footerTask
let closed = false
let sigintRegistered = false
const labels = footerLabels({
agent: input.agent,
model: input.model,
variant: input.variant,
})
const footer = new RunFooter(renderer, {
directory: input.directory,
findFiles: input.findFiles,
@@ -250,15 +267,54 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
onVariantSelect: input.onVariantSelect,
onInterrupt: input.onInterrupt,
onBackground: input.onBackground,
onEditorOpen: async ({ value }) => {
if (closed || renderer.isDestroyed) {
return
}
await renderer.idle().catch(() => {})
const ignore = () => {}
detachSigint()
process.on("SIGINT", ignore)
try {
return await openEditor({
value,
cwd: input.directory,
renderer,
stdin: source.stdin,
})
} finally {
process.off("SIGINT", ignore)
attachSigint()
}
},
onSubagentSelect: input.onSubagentSelect,
})
const sigint = () => {
footer.requestExit()
}
process.on("SIGINT", sigint)
let closed = false
const attachSigint = () => {
if (closed || sigintRegistered) {
return
}
process.on("SIGINT", sigint)
sigintRegistered = true
}
const detachSigint = () => {
if (!sigintRegistered) {
return
}
process.off("SIGINT", sigint)
sigintRegistered = false
}
attachSigint()
const close = async (next: {
showExit: boolean
sessionTitle?: string
@@ -277,7 +333,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
"session.id": next.sessionID || input.getSessionID?.() || input.sessionID || undefined,
},
async () => {
process.off("SIGINT", sigint)
detachSigint()
let wroteExit = false
try {
await footer.idle().catch(() => {})
@@ -286,7 +343,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
if (!renderer.isDestroyed && show) {
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
queueSplash(
wroteExit = queueSplash(
renderer,
state,
"exit",
@@ -306,6 +363,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
footer.destroy()
unregisterKeymap?.()
shutdown(renderer)
if (!wroteExit) {
process.stdout.write("\n")
}
source.cleanup?.()
}
},
@@ -353,6 +413,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
}),
theme: footer.currentTheme().splash,
showSession: splash.showSession,
detail: directoryLabel(input.directory),
}),
)
renderer.requestRender()
@@ -228,16 +228,18 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
state.ctrl = undefined
}
const duration = Locale.duration(Math.max(0, Date.now() - start))
emit(
{
type: "turn.duration",
duration,
},
{
duration,
},
)
if (sent.mode !== "shell") {
const duration = Locale.duration(Math.max(0, Date.now() - start))
emit(
{
type: "turn.duration",
duration,
},
{
duration,
},
)
}
state.active = undefined
}
}
+49 -26
View File
@@ -77,9 +77,19 @@ type RunLocalInput = {
demo?: RunInput["demo"]
}
type StreamTransportModule = Pick<
Awaited<typeof import("./stream.transport")>,
"createSessionTransport" | "formatUnknownError"
>
export type RunRuntimeDeps = {
createRuntimeLifecycle?: typeof createRuntimeLifecycle
streamTransport?: Promise<StreamTransportModule>
}
type StreamState = {
mod: Awaited<typeof import("./stream.transport")>
handle: Awaited<ReturnType<Awaited<typeof import("./stream.transport")>["createSessionTransport"]>>
mod: StreamTransportModule
handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>>
}
type ResolvedSession = {
@@ -169,7 +179,7 @@ async function resolveExitTitle(
//
// Files only attach on the first prompt turn -- after that, includeFiles
// flips to false so subsequent turns don't re-send attachments.
async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
return withRunSpan(
"RunInteractive.session",
{
@@ -237,7 +247,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
return state.session
}
const shell = await createRuntimeLifecycle({
const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
directory: ctx.directory,
findFiles: (query) =>
ctx.sdk.find
@@ -479,7 +489,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
})
})
const streamTask = import("./stream.transport")
const streamTask = deps.streamTransport ?? import("./stream.transport")
const ensureStream = () => {
if (state.stream) {
return state.stream
@@ -506,6 +516,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
replay: input.replay,
replayLimit: input.replayLimit,
limits: () => state.limits,
providers: () => state.providers,
footer,
trace: log,
})
@@ -746,6 +757,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
try {
const eager = eagerStream(input, ctx)
if (eager) {
if (input.replay && state.shown) {
// Replay commits immutable scrollback rows, so wait for provider names
// before bootstrapping existing session history.
await modelTask
}
await ensureStream()
}
@@ -846,7 +863,10 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
}
// Attach mode. Uses the caller-provided SDK client directly.
export async function runInteractiveMode(input: RunInput & { createSession?: CreateSession }): Promise<void> {
export async function runInteractiveMode(
input: RunInput & { createSession?: CreateSession },
deps?: RunRuntimeDeps,
): Promise<void> {
return withRunSpan(
"RunInteractive.attachMode",
{
@@ -855,25 +875,28 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
"session.id": input.sessionID,
},
async () =>
runInteractiveRuntime({
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
boot: async () => ({
sdk: input.sdk,
directory: input.directory,
sessionID: input.sessionID,
sessionTitle: input.sessionTitle,
resume: input.resume,
agent: input.agent,
model: input.model,
variant: input.variant,
}),
createSession: createSessionResolver(input.createSession),
}),
runInteractiveRuntime(
{
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
boot: async () => ({
sdk: input.sdk,
directory: input.directory,
sessionID: input.sessionID,
sessionTitle: input.sessionTitle,
resume: input.resume,
agent: input.agent,
model: input.model,
variant: input.variant,
}),
createSession: createSessionResolver(input.createSession),
},
deps,
),
)
}
@@ -16,7 +16,8 @@ import {
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
import { withRunSpan } from "./otel"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter } from "./scrollback.writer"
import { turnSummaryCommit } from "./turn-summary"
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
import { type RunTheme } from "./theme"
import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
@@ -357,6 +358,14 @@ export class RunScrollbackStream {
this.markRendered(await this.finishActive(false))
}
if (commit.summary) {
this.writeSpacer(1)
this.renderer.writeToScrollback(turnSummaryWriter({ ...commit.summary, theme: this.theme }))
this.markRendered(commit)
this.tail = commit
return
}
const body = entryBody(commit)
if (body.type === "none") {
if (entryDone(commit)) {
@@ -428,6 +437,10 @@ export class RunScrollbackStream {
)
}
public async writeTurnSummary(input: { agent: string; model: string; duration: string }): Promise<void> {
await this.append(turnSummaryCommit(input))
}
public destroy(): void {
this.resetActive()
this.releasePendingThemes()
@@ -24,7 +24,7 @@ function todoText(item: { status: string; content: string }): string {
}
function todoColor(theme: RunTheme, status: string) {
return status === "in_progress" ? theme.footer.warning : theme.block.muted
return status === "in_progress" ? theme.block.warning : theme.block.muted
}
export function entryGroupKey(commit: StreamCommit): string | undefined {
@@ -333,3 +333,18 @@ export function spacerWriter(): ScrollbackWriter {
trailingNewline: true,
})
}
export function turnSummaryWriter(input: { agent: string; model: string; duration: string; theme: RunTheme }) {
return createScrollbackWriter(
() => (
<box width="100%" height={1}>
<text wrapMode="none" truncate>
<span style={{ fg: input.theme.block.highlight }}> </span>
<span style={{ fg: input.theme.block.text }}>{input.agent}</span>
<span style={{ fg: input.theme.block.muted }}> · {input.model} · {input.duration}</span>
</text>
</box>
),
{ startOnNewLine: true, trailingNewline: false },
)
}
@@ -1,7 +1,8 @@
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { messagePrompt, type SessionMessages } from "./session.shared"
import type { FooterPatch, LocalReplayRow, StreamCommit } from "./types"
import { messageTurnSummaryCommit } from "./turn-summary"
import type { FooterPatch, LocalReplayRow, RunProvider, StreamCommit } from "./types"
type ReplayInput = {
messages: SessionMessages
@@ -9,6 +10,13 @@ type ReplayInput = {
questions: QuestionRequest[]
thinking: boolean
limits: Record<string, number>
providers?: RunProvider[]
}
type ReplayConfig = {
limits: Record<string, number>
providers?: RunProvider[]
summaries: ReadonlySet<string>
}
export type SessionReplay = {
@@ -22,6 +30,8 @@ type ReplayMessage = {
patch?: FooterPatch
}
const SHELL_SYNTHETIC_USER_TEXT = "The following tool was executed by the user"
function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
return reduceSessionData({
data,
@@ -89,11 +99,62 @@ function replayPatch(data: SessionData, patch: FooterPatch | undefined) {
} satisfies FooterPatch
}
function isShellSyntheticUser(message: SessionMessages[number]) {
if (message.info.role !== "user") {
return false
}
const prompt = messagePrompt(message)
return (
!prompt.text.trim() &&
prompt.parts.length === 0 &&
message.parts.some((part) => part.type === "text" && part.synthetic && part.text === SHELL_SYNTHETIC_USER_TEXT)
)
}
function isShellSyntheticAssistant(message: SessionMessages[number], shellParents: ReadonlySet<string>) {
return (
message.info.role === "assistant" &&
shellParents.has(message.info.parentID) &&
message.parts.some((part) => part.type === "tool" && part.tool === "bash")
)
}
function summaryMessageIDs(messages: SessionMessages): ReadonlySet<string> {
const shellParents = new Set(messages.filter(isShellSyntheticUser).map((message) => message.info.id))
const parents = new Set<string>()
const summaries = new Set<string>()
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
const message = messages[idx]
if (!message || message.info.role !== "assistant") {
continue
}
if (isShellSyntheticAssistant(message, shellParents)) {
continue
}
if (parents.has(message.info.parentID)) {
continue
}
parents.add(message.info.parentID)
const completed = message.info.time.completed
if (typeof completed === "number" && completed > message.info.time.created) {
summaries.add(message.info.id)
}
}
return summaries
}
function replayMessage(
data: SessionData,
message: SessionMessages[number],
thinking: boolean,
limits: Record<string, number>,
config: ReplayConfig,
): ReplayMessage {
if (message.info.role === "user") {
const prompt = messagePrompt(message)
@@ -131,7 +192,7 @@ function replayMessage(
},
message.info.sessionID,
thinking,
limits,
config.limits,
)
commits.push(...info.commits)
patch = mergePatch(patch, info.footer?.patch)
@@ -150,12 +211,17 @@ function replayMessage(
},
message.info.sessionID,
thinking,
limits,
config.limits,
)
patch = mergePatch(patch, next.footer?.patch)
commits.push(...next.commits)
}
const summary = config.summaries.has(message.info.id) ? messageTurnSummaryCommit(message, config.providers) : undefined
if (summary) {
commits.push(summary)
}
return {
commits,
patch,
@@ -166,6 +232,7 @@ export function replaySession(input: ReplayInput): SessionReplay {
const data = createSessionData()
const commits: StreamCommit[] = []
let patch: FooterPatch | undefined
const summaries = summaryMessageIDs(input.messages)
bootstrapSessionData({
data,
@@ -175,7 +242,11 @@ export function replaySession(input: ReplayInput): SessionReplay {
})
for (const message of input.messages) {
const next = replayMessage(data, message, input.thinking, input.limits)
const next = replayMessage(data, message, input.thinking, {
limits: input.limits,
providers: input.providers,
summaries,
})
commits.push(...next.commits)
patch = mergePatch(patch, next.patch)
}
+22 -48
View File
@@ -11,7 +11,6 @@
import {
BoxRenderable,
type ColorInput,
RGBA,
TextAttributes,
TextRenderable,
type ScrollbackRenderContext,
@@ -19,7 +18,7 @@ import {
type ScrollbackWriter,
} from "@opentui/core"
import * as Locale from "@/util/locale"
import { go, logo } from "@/cli/logo"
import { go } from "@/cli/logo"
import type { RunSplashTheme } from "./theme"
export const SPLASH_TITLE_LIMIT = 50
@@ -33,6 +32,7 @@ type SplashInput = {
type SplashWriterInput = SplashInput & {
theme: RunSplashTheme
showSession?: boolean
detail?: string
}
export type SplashMeta = {
@@ -144,28 +144,6 @@ function push(
lines.push({ left, top, text, fg, bg, attrs })
}
function color(input: ColorInput, fallback: RGBA): RGBA {
if (input instanceof RGBA) {
return input
}
if (typeof input === "string") {
if (input === "transparent" || input === "none") {
return RGBA.fromValues(0, 0, 0, 0)
}
if (input.startsWith("#")) {
return RGBA.fromHex(input)
}
}
return fallback
}
function fallback(index: number, hex: string): RGBA {
return RGBA.fromIndex(index, RGBA.fromHex(hex))
}
function draw(
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
row: string,
@@ -200,41 +178,37 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
const width = Math.max(1, ctx.width)
const meta = splashMeta(input)
const lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }> = []
const left = color(input.theme.left, fallback(81, "#38bdf8"))
const right = color(input.theme.right, RGBA.defaultForeground(RGBA.fromHex("#f8fafc")))
const leftShadow = color(input.theme.leftShadow, fallback(238, "#334155"))
const left = input.theme.left
const right = input.theme.right
const leftShadow = input.theme.leftShadow
let height = 1
if (kind === "entry") {
const rightShadow = color(input.theme.rightShadow, fallback(240, "#475569"))
const mark = go.right.slice(1)
const top = 1
const body_left = (mark[0]?.length ?? 0) + 2
for (let i = 0; i < logo.left.length; i += 1) {
const leftText = logo.left[i] ?? ""
const rightText = logo.right[i] ?? ""
draw(lines, leftText, {
for (let i = 0; i < mark.length; i += 1) {
draw(lines, mark[i] ?? "", {
left: 0,
top: i,
top: top + i,
fg: left,
shadow: leftShadow,
})
draw(lines, rightText, {
left: leftText.length + 1,
top: i,
fg: right,
shadow: rightShadow,
})
}
height = logo.left.length
if (input.showSession !== false) {
const top = logo.left.length + 1
const label = "Session".padEnd(10, " ")
push(lines, 0, top, label, left, undefined, TextAttributes.DIM)
push(lines, label.length, top, meta.title, right, undefined, TextAttributes.BOLD)
height = top + 1
push(lines, body_left, top, "OpenCode", right, undefined, TextAttributes.BOLD)
if (input.detail) {
push(
lines,
body_left,
top + 1,
Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
left,
undefined,
)
}
height = top + mark.length
}
if (kind === "exit") {
@@ -31,7 +31,6 @@ import { replayActiveText, replayLocalRows, replaySession } from "./session-repl
import {
bootstrapSubagentCalls,
bootstrapSubagentData,
clearFinishedSubagents,
createSubagentData,
listSubagentPermissions,
listSubagentQuestions,
@@ -57,6 +56,7 @@ import type {
RunInput,
RunPrompt,
RunPromptPart,
RunProvider,
StreamCommit,
} from "./types"
@@ -74,6 +74,7 @@ type StreamInput = {
replay?: boolean
replayLimit?: number
limits: () => Record<string, number>
providers?: () => RunProvider[]
footer: FooterApi
trace?: Trace
signal?: AbortSignal
@@ -712,9 +713,10 @@ function createLayer(input: StreamInput) {
messages: messagesList,
permissions: sessionPermissions,
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
})
thinking: input.thinking,
limits: input.limits(),
providers: input.providers?.(),
})
: undefined
const replay =
history && input.replayLimit !== undefined && messagesList.length > input.replayLimit
@@ -724,6 +726,7 @@ function createLayer(input: StreamInput) {
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
providers: input.providers?.(),
})
: history
@@ -751,7 +754,6 @@ function createLayer(input: StreamInput) {
permissions,
questions,
})
clearFinishedSubagents(state.subagent)
for (const request of [
...state.data.permissions,
@@ -1026,6 +1028,7 @@ function createLayer(input: StreamInput) {
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
providers: input.providers?.(),
})
const activeCommits = replayActiveText(history.data, state.data)
return {
@@ -1043,6 +1046,7 @@ function createLayer(input: StreamInput) {
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
providers: input.providers?.(),
})
: history,
}
@@ -1195,13 +1199,6 @@ function createLayer(input: StreamInput) {
return
}
const prev = listSubagentTabs(state.subagent)
if (clearFinishedSubagents(state.subagent)) {
const snapshot = currentSubagentState()
traceTabs(input.trace, prev, snapshot.tabs)
syncFooter([], undefined, snapshot)
}
const item: Wait = {
tick: state.tick,
armed: false,
@@ -292,10 +292,25 @@ function metadata(part: ToolPart, key: string) {
return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
}
function taskStatus(part: ToolPart): FooterSubagentTab["status"] {
if (part.state.status === "completed") {
return "completed"
}
if (part.state.status === "error") {
if (metadata(part, "interrupted") === true || text(part.state.error) === "Tool execution aborted") {
return "cancelled"
}
return "error"
}
return "running"
}
function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general")
const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? ""
const status = part.state.status === "error" ? "error" : part.state.status === "completed" ? "completed" : "running"
return {
sessionID,
@@ -303,7 +318,7 @@ function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
callID: part.callID,
label,
description,
status,
status: taskStatus(part),
background: metadata(part, "background") === true,
title: stateTitle(part),
toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
@@ -457,6 +472,29 @@ function ensureBlockerTab(
return true
}
function isAbortedAssistantMessage(info: Message) {
return info.role === "assistant" && info.error?.name === "MessageAbortedError"
}
function cancelSubagentTab(data: SubagentData, sessionID: string) {
const current = data.tabs.get(sessionID)
if (!current || current.status !== "running") {
return false
}
const next = {
...current,
status: "cancelled" as const,
lastUpdatedAt: Date.now(),
}
if (sameSubagentTab(current, next)) {
return false
}
data.tabs.set(sessionID, next)
return true
}
function compactCallMap(detail: DetailState) {
const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT))
@@ -751,22 +789,6 @@ export function bootstrapSubagentCalls(input: {
return changed || beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before)
}
export function clearFinishedSubagents(data: SubagentData) {
let changed = false
for (const [sessionID, tab] of data.tabs.entries()) {
if (tab.status === "running") {
continue
}
data.tabs.delete(sessionID)
data.details.delete(sessionID)
changed = true
}
return changed
}
export function reduceSubagentData(input: {
data: SubagentData
event: Event
@@ -807,12 +829,16 @@ export function reduceSubagentData(input: {
}
const detail = ensureDetail(input.data, sessionID)
const cancelled = event.type === "message.updated" && isAbortedAssistantMessage(event.properties.info)
? cancelSubagentTab(input.data, sessionID)
: false
if (event.type === "session.status") {
if (event.properties.status.type !== "retry") {
return false
return cancelled
}
return appendCommits(detail, [
return (
appendCommits(detail, [
{
kind: "error",
text: event.properties.status.message,
@@ -820,11 +846,13 @@ export function reduceSubagentData(input: {
source: "system",
messageID: `retry:${event.properties.status.attempt}`,
},
])
]) || cancelled
)
}
if (event.type === "session.error" && event.properties.error) {
return appendCommits(detail, [
return (
appendCommits(detail, [
{
kind: "error",
text: formatError(event.properties.error),
@@ -832,13 +860,16 @@ export function reduceSubagentData(input: {
source: "system",
messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`,
},
])
]) || cancelled
)
}
return applyChildEvent({
detail,
event,
thinking: input.thinking,
limits: input.limits,
})
return (
applyChildEvent({
detail,
event,
thinking: input.thinking,
limits: input.limits,
}) || cancelled
)
}
+137 -50
View File
@@ -25,11 +25,15 @@ export type RunSplashTheme = {
export type RunFooterTheme = {
highlight: ColorInput
selected: ColorInput
selectedText: ColorInput
warning: ColorInput
success: ColorInput
error: ColorInput
muted: ColorInput
text: ColorInput
status: ColorInput
statusAccent: ColorInput
shade: ColorInput
surface: ColorInput
pane: ColorInput
@@ -38,6 +42,8 @@ export type RunFooterTheme = {
}
export type RunBlockTheme = {
highlight: ColorInput
warning: ColorInput
text: ColorInput
muted: ColorInput
syntax?: SyntaxStyle
@@ -95,8 +101,11 @@ function rgba(hex: string, value?: number): RGBA {
}
function mode(bg: RGBA): "dark" | "light" {
const lum = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
return lum > 0.5 ? "light" : "dark"
return luminance(bg) > 0.5 ? "light" : "dark"
}
function luminance(color: RGBA): number {
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
}
function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
@@ -206,13 +215,39 @@ function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.p
})
}
function srgbToLinear(value: number): number {
if (value <= 0.04045) {
return value / 12.92
}
return ((value + 0.055) / 1.055) ** 2.4
}
function oklab(color: RGBA) {
const r = srgbToLinear(color.r)
const g = srgbToLinear(color.g)
const b = srgbToLinear(color.b)
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
return {
l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
}
}
function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
const target = oklab(rgba)
const hit = indexed.reduce(
(best, item) => {
const dr = item.r - rgba.r
const dg = item.g - rgba.g
const db = item.b - rgba.b
const dist = dr * dr + dg * dg + db * db
const sample = oklab(item)
const dl = sample.l - target.l
const da = sample.a - target.a
const db = sample.b - target.b
const dist = dl * dl * 2 + da * da + db * db
if (dist >= best.dist) return best
return {
dist,
@@ -228,6 +263,11 @@ function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
return RGBA.clone(hit.item)
}
function paletteColor(colors: TerminalColors, index: number): RGBA {
const value = colors.palette[index]
return value ? RGBA.fromHex(value) : ansiToRgba(index)
}
function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number): RGBA {
const mixed = tint(base, overlay, value)
return nearestIndexed(indexed, mixed)
@@ -346,13 +386,10 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
const fg = RGBA.defaultForeground(fg_snapshot)
const isDark = pick === "dark"
const indexed = indexedPalette(colors)
const color = (index: number) => RGBA.clone(indexed[index]!)
const nearest = (rgba: RGBA) => nearestIndexed(indexed, rgba)
const color = (index: number) => paletteColor(colors, index)
const grays = generateGrayScale(bg_snapshot, isDark, nearest)
const menu_grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
const textMuted = generateMutedTextColor(bg_snapshot, isDark, nearest)
const grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
const textMuted = generateMutedTextColor(bg_snapshot, isDark, (rgba) => rgba)
const ansi = {
red: color(1),
@@ -385,7 +422,7 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
background: alpha(bg, 0),
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: menu_grays[3],
backgroundMenu: grays[3],
borderSubtle: grays[6],
border: grays[7],
borderActive: grays[8],
@@ -395,12 +432,12 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
diffHunkHeader: grays[7],
diffHighlightAdded: ansi.green_bright,
diffHighlightRemoved: ansi.red_bright,
diffAddedBg: nearest(tint(bg_snapshot, ansi.green, diff_alpha)),
diffRemovedBg: nearest(tint(bg_snapshot, ansi.red, diff_alpha)),
diffAddedBg: tint(bg_snapshot, ansi.green, diff_alpha),
diffRemovedBg: tint(bg_snapshot, ansi.red, diff_alpha),
diffContextBg: diff_context_bg,
diffLineNumber: textMuted,
diffAddedLineNumberBg: nearest(tint(diff_context_bg, ansi.green, diff_alpha)),
diffRemovedLineNumberBg: nearest(tint(diff_context_bg, ansi.red, diff_alpha)),
diffAddedLineNumberBg: tint(diff_context_bg, ansi.green, diff_alpha),
diffRemovedLineNumberBg: tint(diff_context_bg, ansi.red, diff_alpha),
markdownText: fg,
markdownHeading: fg,
markdownLink: ansi.blue,
@@ -428,6 +465,27 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
}
}
function quantizeColor(indexed: RGBA[], rgba: RGBA): RGBA {
if (rgba.a === 0 || rgba.intent === "default" || rgba.intent === "indexed") {
return RGBA.clone(rgba)
}
return nearestIndexed(indexed, rgba)
}
function quantizeTheme(theme: TuiThemeCurrent, indexed: RGBA[]): TuiThemeCurrent {
const resolved = Object.fromEntries(
Object.entries(theme)
.filter(([key]) => key !== "thinkingOpacity")
.map(([key, value]) => [key, quantizeColor(indexed, value as RGBA)]),
) as Partial<Record<ThemeColor, RGBA>>
return {
...(resolved as Record<ThemeColor, RGBA>),
thinkingOpacity: theme.thinkingOpacity,
}
}
function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
const left = nearestIndexed(indexed, theme.textMuted)
const right = nearestIndexed(indexed, theme.text)
@@ -440,69 +498,85 @@ function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
}
function map(
theme: TuiThemeCurrent,
footerTheme: TuiThemeCurrent,
scrollbackTheme: TuiThemeCurrent,
splash: RunSplashTheme,
syntax?: SyntaxStyle,
subtleSyntax?: SyntaxStyle,
): RunTheme {
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, theme.background)
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background)
subtleSyntax?.destroy()
const shade = fade(theme.backgroundMenu, theme.background, 0.12, 0.56, 0.72)
const surface = fade(theme.backgroundMenu, theme.background, 0.18, 0.76, 0.9)
const line = fade(theme.backgroundMenu, theme.background, 0.24, 0.9, 0.98)
const footerBackground = alpha(footerTheme.background, 1)
const footerMode = mode(footerBackground)
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
const surface = fade(footerTheme.backgroundMenu, footerTheme.background, 0.18, 0.76, 0.9)
const line = fade(footerTheme.backgroundMenu, footerTheme.background, 0.24, 0.9, 0.98)
const statusBase = tint(footerBackground, rgba("#000000"), footerMode === "dark" ? 0.12 : 0.06)
const statusAccentBase =
footerMode === "dark" ? tint(footerBackground, rgba("#ffffff"), 0.06) : tint(statusBase, rgba("#000000"), 0.04)
const collapsedStatus = footerMode === "dark" && luminance(statusBase) <= 0.04
// Pure-black backgrounds need a slight lift or the row disappears into the terminal background.
const status = collapsedStatus ? tint(statusBase, statusAccentBase, 0.7) : statusBase
const statusAccent = collapsedStatus ? tint(status, rgba("#ffffff"), 0.06) : statusAccentBase
return {
background: theme.background,
background: footerTheme.background,
footer: {
highlight: theme.primary,
warning: theme.warning,
success: theme.success,
error: theme.error,
muted: theme.textMuted,
text: theme.text,
highlight: footerTheme.primary,
selected: footerTheme.backgroundElement,
selectedText: footerTheme.selectedListItemText,
warning: footerTheme.warning,
success: footerTheme.success,
error: footerTheme.error,
muted: footerTheme.textMuted,
text: footerTheme.text,
status,
statusAccent,
shade,
surface,
pane: theme.backgroundMenu,
border: theme.border,
pane: footerTheme.backgroundMenu,
border: footerTheme.border,
line,
},
entry: {
system: {
body: theme.textMuted,
body: scrollbackTheme.textMuted,
},
user: {
body: theme.primary,
body: scrollbackTheme.primary,
},
assistant: {
body: theme.text,
body: scrollbackTheme.text,
},
reasoning: {
body: theme.textMuted,
body: scrollbackTheme.textMuted,
},
tool: {
body: theme.text,
start: theme.textMuted,
body: scrollbackTheme.text,
start: scrollbackTheme.textMuted,
},
error: {
body: theme.error,
body: scrollbackTheme.error,
},
},
splash,
block: {
text: theme.text,
muted: theme.textMuted,
highlight: scrollbackTheme.primary,
warning: scrollbackTheme.warning,
text: scrollbackTheme.text,
muted: scrollbackTheme.textMuted,
syntax,
subtleSyntax: opaqueSubtleSyntax,
diffAdded: theme.diffAdded,
diffRemoved: theme.diffRemoved,
diffAdded: scrollbackTheme.diffAdded,
diffRemoved: scrollbackTheme.diffRemoved,
diffAddedBg: transparent,
diffRemovedBg: transparent,
diffContextBg: transparent,
diffHighlightAdded: theme.diffHighlightAdded,
diffHighlightRemoved: theme.diffHighlightRemoved,
diffLineNumber: theme.diffLineNumber,
diffAddedLineNumberBg: theme.diffAddedLineNumberBg,
diffRemovedLineNumberBg: theme.diffRemovedLineNumberBg,
diffHighlightAdded: scrollbackTheme.diffHighlightAdded,
diffHighlightRemoved: scrollbackTheme.diffHighlightRemoved,
diffLineNumber: scrollbackTheme.diffLineNumber,
diffAddedLineNumberBg: scrollbackTheme.diffAddedLineNumberBg,
diffRemovedLineNumberBg: scrollbackTheme.diffRemovedLineNumberBg,
},
}
}
@@ -532,11 +606,15 @@ export const RUN_THEME_FALLBACK: RunTheme = {
background: RGBA.fromValues(0, 0, 0, 0),
footer: {
highlight: seed.highlight,
selected: seed.text,
selectedText: seed.panel,
warning: seed.warning,
success: seed.success,
error: seed.error,
muted: seed.muted,
text: seed.text,
status: tint(seed.panel, rgba("#000000"), 0.12),
statusAccent: tint(seed.panel, rgba("#ffffff"), 0.06),
shade: alpha(seed.panel, 0.68),
surface: alpha(seed.panel, 0.86),
pane: seed.panel,
@@ -558,6 +636,8 @@ export const RUN_THEME_FALLBACK: RunTheme = {
rightShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashRight, 0.14),
},
block: {
highlight: seed.highlight,
warning: seed.warning,
text: seed.text,
muted: seed.muted,
diffAdded: seed.success,
@@ -588,15 +668,22 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
const pick = colors.defaultBackground
? mode(RGBA.fromHex(colors.defaultBackground))
: (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
const theme = resolveTheme(generateSystem(colors, pick), pick)
const footerTheme = resolveTheme(generateSystem(colors, pick), pick)
const indexed = indexedPalette(colors, 256)
const scrollbackTheme = quantizeTheme(footerTheme, indexed)
const shared = await import("@opencode-ai/tui/context/theme")
const syntaxTheme: SharedSyntaxTheme = {
...theme,
...scrollbackTheme,
_hasSelectedListItemText: true,
}
const syntax = shared.generateSyntax(syntaxTheme)
return map(theme, splashTheme(theme, indexed), syntax, shared.generateSubtleSyntax(syntaxTheme))
return map(
footerTheme,
scrollbackTheme,
splashTheme(scrollbackTheme, indexed),
syntax,
shared.generateSubtleSyntax(syntaxTheme),
)
} catch {
return RUN_THEME_FALLBACK
}
@@ -0,0 +1,47 @@
import * as Locale from "@/util/locale"
import type { SessionMessages } from "./session.shared"
import type { RunProvider, StreamCommit } from "./types"
export function turnSummaryCommit(input: {
agent: string
model: string
duration: string
messageID?: string
}): StreamCommit {
return {
kind: "system",
text: `${input.agent} · ${input.model} · ${input.duration}`,
phase: "final",
source: "system",
summary: {
agent: input.agent,
model: input.model,
duration: input.duration,
},
messageID: input.messageID,
}
}
export function messageTurnSummaryCommit(
message: SessionMessages[number],
providers?: RunProvider[],
): StreamCommit | undefined {
const info = message.info
if (info.role !== "assistant") {
return
}
const completed = info.time.completed
if (typeof completed !== "number" || completed <= info.time.created) {
return
}
const model = providers?.find((item) => item.id === info.providerID)?.models[info.modelID]?.name
return turnSummaryCommit({
agent: Locale.titlecase(info.agent),
model: model ?? info.modelID,
duration: Locale.duration(completed - info.time.created),
messageID: info.id,
})
}
+9 -1
View File
@@ -97,6 +97,12 @@ export type FooterPatch = Partial<FooterState>
export type RunDiffStyle = "auto" | "stacked"
export type TurnSummary = {
agent: string
model: string
duration: string
}
export type ScrollbackOptions = {
diffStyle?: RunDiffStyle
suppressBackgrounds?: boolean
@@ -175,6 +181,7 @@ export type FooterPromptRoute =
| { type: "subagent-menu" }
| { type: "subagent"; sessionID: string }
| { type: "command" }
| { type: "skill" }
| { type: "model" }
| { type: "variant" }
@@ -184,7 +191,7 @@ export type FooterSubagentTab = {
callID: string
label: string
description: string
status: "running" | "completed" | "error"
status: "running" | "completed" | "cancelled" | "error"
background?: boolean
title?: string
toolCalls?: number
@@ -298,6 +305,7 @@ export type StreamCommit = {
text: string
phase: StreamPhase
source: StreamSource
summary?: TurnSummary
messageID?: string
partID?: string
tool?: string