diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts
index d0d72ce00273..94450f124257 100644
--- a/packages/opencode/src/cli/cmd/run/demo.ts
+++ b/packages/opencode/src/cli/cmd/run/demo.ts
@@ -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[]
diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/opencode/src/cli/cmd/run/entry.body.ts
index bb058e8a37f6..205e14ce25c6 100644
--- a/packages/opencode/src/cli/cmd/run/entry.body.ts
+++ b/packages/opencode/src/cli/cmd/run/entry.body.ts
@@ -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") {
diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx
index 90ba6fc6734c..c35a2b6abc39 100644
--- a/packages/opencode/src/cli/cmd/run/footer.command.tsx
+++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx
@@ -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 (
-
+ const background = () => (props.dark ? props.theme().shade : props.theme().surface)
+ const minimal = () => props.chrome === "minimal"
+ const content = (
+ <>
+
-
-
-
- {props.title}
-
- {props.countVisible !== false ? (
-
- {countLabel(props.count, props.total, props.query)}
-
- ) : null}
-
-
- esc
+
+ {props.title}
+
+ {props.countVisible !== false ? (
+
+ {countLabel(props.count, props.total, props.query)}
+ ) : null}
+
+
+ esc
+
+
+
+
+ {
+ props.inputRef(input)
+ input.traits = { status: "FILTER" }
+ queueMicrotask(() => {
+ if (!input.isDestroyed) {
+ input.focus()
+ }
+ })
+ }}
+ />
+
+
+
+ {props.children}
+
+ >
+ )
+ return (
+
+ {minimal() ? (
+
+ {content}
-
+ ) : (
-
+ )}
+ {minimal() ? (
+
+ {
- props.inputRef(input)
- input.traits = { status: "FILTER" }
- queueMicrotask(() => {
- if (!input.isDestroyed) {
- input.focus()
- }
- })
- }}
+ height={1}
+ border={["bottom"]}
+ borderColor={background()}
+ backgroundColor="transparent"
+ customBorderChars={HALF_BLOCK_BORDER}
/>
-
-
- {props.children}
-
-
-
+ ) : (
-
+ customBorderChars={PANEL_BOTTOM_BORDER}
+ flexShrink={0}
+ >
+
+
+ )}
)
}
@@ -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,84 +354,116 @@ 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(() => {
- const builtins = ["new"]
- return [
+ const builtins = ["editor", "new"]
+ const session: CommandEntry[] = [
+ {
+ action: "editor",
+ category: "Session",
+ display: "Open editor",
+ footer: "/editor",
+ keywords: "editor compose draft external editor",
+ },
+ ...(props.subagents().length > 0
+ ? [
+ {
+ 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",
+ category: "Session",
+ name: "new",
+ display: "New session",
+ footer: "/new",
+ keywords: "new session clear",
+ },
+ ]
+ 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: "Suggested",
+ category: "Agent",
display: "Switch model",
},
...(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: "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: "Suggested",
+ category: "Agent",
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: "variant.list" as const,
+ category: "Agent",
+ display: "Switch model variant",
+ keywords: `variant variants ${props.variants().join(" ")}`,
+ },
+ ]
: []),
- {
- action: "slash",
- category: "Session",
- name: "new",
- display: "New session",
- 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 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"
>
)
@@ -566,6 +652,8 @@ export function RunSubagentSelectBody(props: {
field = input
}}
onQuery={setQuery}
+ dark
+ chrome="minimal"
>
)
@@ -662,6 +751,8 @@ export function RunQueuedPromptSelectBody(props: {
field = input
}}
onQuery={setQuery}
+ dark
+ chrome="minimal"
>
+
+ )
+}
+
+export function RunSkillSelectBody(props: {
+ theme: Accessor
+ commands: Accessor
+ onClose: () => void
+ onSelect: (name: string) => void
+}) {
+ let field: InputRenderable | undefined
+ const [query, setQuery] = createSignal("")
+ const entries = createMemo(() =>
+ (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(() => 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 (
+
)
@@ -759,6 +930,8 @@ export function RunVariantSelectBody(props: {
field = input
}}
onQuery={setQuery}
+ dark
+ chrome="minimal"
>
)
@@ -879,6 +1053,8 @@ export function RunModelSelectBody(props: {
field = input
}}
onQuery={setQuery}
+ dark
+ chrome="minimal"
>
)
diff --git a/packages/opencode/src/cli/cmd/run/footer.menu.tsx b/packages/opencode/src/cli/cmd/run/footer.menu.tsx
index c3770b27b04a..abd1a6ea7851 100644
--- a/packages/opencode/src/cli/cmd/run/footer.menu.tsx
+++ b/packages/opencode/src/cli/cmd/run/footer.menu.tsx
@@ -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 (
{rows().length === 0 ? (
-
+
{border() ? (
┃
@@ -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}
>
{props.empty ?? "No matching items"}
@@ -239,7 +264,7 @@ export function RunFooterMenu(props: {
if (row.type === "header") {
return (
-
+
{row.label}
@@ -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 (
-
+
{border() ? (
-
- ┃
+
+ {active() ? "▌" : " "}
) : undefined}
-
-
+
+
{row.item.display}
- {row.item.description ? (
-
- {descriptionPad(row.item)}
- {row.item.description}
-
- ) : undefined}
- {row.item.footer ? (
-
- {row.item.footer}
-
+ {row.item.description ? (
+ <>
+
+ {descriptionPad(row.item)}
+
+
+ {descriptionText(row.item)}
+
+ >
) : undefined}
+ {row.item.footer ? (
+
+ {row.item.footer}
+
+ ) : undefined}
diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx
index 2790a9e0b66c..2ad7e5e4a3dc 100644
--- a/packages/opencode/src/cli/cmd/run/footer.permission.tsx
+++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx
@@ -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 (
-
+
+
+ )}
-
-
-
-
- }
- >
-
-
-
-
+ )}
+
+
+ {(hint) => (
-
- }
- >
-
-
+ )}
+
+
}
@@ -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}
diff --git a/packages/opencode/src/cli/cmd/run/footer.width.ts b/packages/opencode/src/cli/cmd/run/footer.width.ts
new file mode 100644
index 000000000000..db5ed87b69e7
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/run/footer.width.ts
@@ -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,
+ },
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/run/prompt.editor.ts b/packages/opencode/src/cli/cmd/run/prompt.editor.ts
new file mode 100644
index 000000000000..06e63dd1103e
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/run/prompt.editor.ts
@@ -0,0 +1,162 @@
+import type { RunPromptPart } from "./types"
+
+type Mention = Extract
+
+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()
+ 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,
+ },
+ },
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/run/prompt.shared.ts b/packages/opencode/src/cli/cmd/run/prompt.shared.ts
index 5f9570fd98b8..63c33aa34ffd 100644
--- a/packages/opencode/src/cli/cmd/run/prompt.shared.ts
+++ b/packages/opencode/src/cli/cmd/run/prompt.shared.ts
@@ -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 {
diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
index 5f4dcb3455c2..bcabcedb4426 100644
--- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
+++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
@@ -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): 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,
state: SplashState,
@@ -205,6 +219,11 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {})
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 {
+ 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 {
- process.off("SIGINT", sigint)
+ detachSigint()
+ let wroteExit = false
try {
await footer.idle().catch(() => {})
@@ -286,7 +343,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {
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
}
}
diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts
index 89d37e1db219..32ee85f343b8 100644
--- a/packages/opencode/src/cli/cmd/run/runtime.ts
+++ b/packages/opencode/src/cli/cmd/run/runtime.ts
@@ -77,9 +77,19 @@ type RunLocalInput = {
demo?: RunInput["demo"]
}
+type StreamTransportModule = Pick<
+ Awaited,
+ "createSessionTransport" | "formatUnknownError"
+>
+
+export type RunRuntimeDeps = {
+ createRuntimeLifecycle?: typeof createRuntimeLifecycle
+ streamTransport?: Promise
+}
+
type StreamState = {
- mod: Awaited
- handle: Awaited["createSessionTransport"]>>
+ mod: StreamTransportModule
+ handle: Awaited>
}
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 {
+async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise {
return withRunSpan(
"RunInteractive.session",
{
@@ -237,7 +247,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise {
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 {
})
})
- 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 {
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 {
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 {
+export async function runInteractiveMode(
+ input: RunInput & { createSession?: CreateSession },
+ deps?: RunRuntimeDeps,
+): Promise {
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,
+ ),
)
}
diff --git a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts
index 3a3357f566d3..48de08ef3345 100644
--- a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts
+++ b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts
@@ -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 {
+ await this.append(turnSummaryCommit(input))
+ }
+
public destroy(): void {
this.resetActive()
this.releasePendingThemes()
diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
index 94c136415dc2..da9c4b85944f 100644
--- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
+++ b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
@@ -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(
+ () => (
+
+
+ ▣
+ {input.agent}
+ · {input.model} · {input.duration}
+
+
+ ),
+ { startOnNewLine: true, trailingNewline: false },
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/run/session-replay.ts b/packages/opencode/src/cli/cmd/run/session-replay.ts
index 8074aa4f631b..4fd73594b0ee 100644
--- a/packages/opencode/src/cli/cmd/run/session-replay.ts
+++ b/packages/opencode/src/cli/cmd/run/session-replay.ts
@@ -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
+ providers?: RunProvider[]
+}
+
+type ReplayConfig = {
+ limits: Record
+ providers?: RunProvider[]
+ summaries: ReadonlySet
}
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) {
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) {
+ 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 {
+ const shellParents = new Set(messages.filter(isShellSyntheticUser).map((message) => message.info.id))
+ const parents = new Set()
+ const summaries = new Set()
+
+ 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,
+ 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)
}
diff --git a/packages/opencode/src/cli/cmd/run/splash.ts b/packages/opencode/src/cli/cmd/run/splash.ts
index e0c8d938485a..99fad10e2b97 100644
--- a/packages/opencode/src/cli/cmd/run/splash.ts
+++ b/packages/opencode/src/cli/cmd/run/splash.ts
@@ -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"))
-
- for (let i = 0; i < logo.left.length; i += 1) {
- const leftText = logo.left[i] ?? ""
- const rightText = logo.right[i] ?? ""
+ const mark = go.right.slice(1)
+ const top = 1
+ const body_left = (mark[0]?.length ?? 0) + 2
- 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") {
diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts
index 5641b8f07265..cffc6c91035d 100644
--- a/packages/opencode/src/cli/cmd/run/stream.transport.ts
+++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts
@@ -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
+ 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,
diff --git a/packages/opencode/src/cli/cmd/run/subagent-data.ts b/packages/opencode/src/cli/cmd/run/subagent-data.ts
index 62341ca80b4f..a605615f66e8 100644
--- a/packages/opencode/src/cli/cmd/run/subagent-data.ts
+++ b/packages/opencode/src/cli/cmd/run/subagent-data.ts
@@ -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
+ )
}
diff --git a/packages/opencode/src/cli/cmd/run/theme.ts b/packages/opencode/src/cli/cmd/run/theme.ts
index bbaacc0e5f83..e4fb315426fa 100644
--- a/packages/opencode/src/cli/cmd/run/theme.ts
+++ b/packages/opencode/src/cli/cmd/run/theme.ts
@@ -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>
+
+ return {
+ ...(resolved as Record),
+ 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
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
}
diff --git a/packages/opencode/src/cli/cmd/run/turn-summary.ts b/packages/opencode/src/cli/cmd/run/turn-summary.ts
new file mode 100644
index 000000000000..95284dfa0c26
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/run/turn-summary.ts
@@ -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,
+ })
+}
diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts
index 1a0a85b3cb36..62e1a2d8a4c7 100644
--- a/packages/opencode/src/cli/cmd/run/types.ts
+++ b/packages/opencode/src/cli/cmd/run/types.ts
@@ -97,6 +97,12 @@ export type FooterPatch = Partial
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
diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts
index 1fa1a1463beb..173210f23c95 100644
--- a/packages/opencode/src/util/process.ts
+++ b/packages/opencode/src/util/process.ts
@@ -1,9 +1,10 @@
import { type ChildProcess } from "child_process"
+import type { Stream } from "node:stream"
import launch from "cross-spawn"
import { buffer } from "node:stream/consumers"
import { errorMessage } from "./error"
-export type Stdio = "inherit" | "pipe" | "ignore"
+export type Stdio = "inherit" | "pipe" | "ignore" | number | Stream
export type Shell = boolean | string
export interface Options {
diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx
index 5aca4608e4a9..409da43a6698 100644
--- a/packages/opencode/test/cli/run/footer.view.test.tsx
+++ b/packages/opencode/test/cli/run/footer.view.test.tsx
@@ -12,6 +12,7 @@ import {
RunCommandMenuBody,
RunModelSelectBody,
RunQueuedPromptSelectBody,
+ RunSkillSelectBody,
RunSubagentSelectBody,
RunVariantSelectBody,
} from "@/cli/cmd/run/footer.command"
@@ -155,13 +156,23 @@ async function renderFooter(
tuiConfig?: RunTuiConfig
commands?: RunCommand[]
theme?: () => RunTheme
+ providers?: RunProvider[]
+ currentModel?: RunInput["model"]
+ currentVariant?: string
+ subagents?: FooterSubagentState
+ backgroundSubagents?: boolean
+ width?: number
+ height?: number
+ state?: Partial
onCycle?: () => void
onSubmit?: (prompt: RunPrompt) => boolean
} = {},
) {
const [view] = createSignal({ type: "prompt" })
- const [subagents] = createSignal({ tabs: [], details: {}, permissions: [], questions: [] })
- const state = footerState()
+ const [subagents] = createSignal(
+ input.subagents ?? { tabs: [], details: {}, permissions: [], questions: [] },
+ )
+ const state = footerState(input.state)
const config = input.tuiConfig ?? tuiConfig
let offKeymap: (() => void) | undefined
@@ -178,16 +189,16 @@ async function renderFooter(
agents={() => []}
resources={() => []}
commands={() => input.commands ?? []}
- providers={() => undefined}
- currentModel={() => undefined}
+ providers={() => input.providers}
+ currentModel={() => input.currentModel}
variants={() => []}
- currentVariant={() => undefined}
+ currentVariant={() => input.currentVariant}
state={state}
view={view}
subagent={subagents}
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
tuiConfig={config}
- backgroundSubagents={true}
+ backgroundSubagents={input.backgroundSubagents ?? true}
agent="opencode"
onSubmit={input.onSubmit ?? (() => true)}
onPermissionReply={() => {}}
@@ -195,6 +206,7 @@ async function renderFooter(
onQuestionReject={() => {}}
onCycle={input.onCycle ?? (() => {})}
onInterrupt={() => false}
+ onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
@@ -210,11 +222,11 @@ async function renderFooter(
const app = await testRender(
() => (
-
+
),
- { width: 100, height: 8, kittyKeyboard: true },
+ { width: input.width ?? 100, height: input.height ?? 8, kittyKeyboard: true },
)
return {
@@ -229,7 +241,14 @@ async function renderFooter(
}
}
-test("direct footer updates composer background when theme changes", async () => {
+function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
+ expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
+ expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(
+ (RUN_THEME_FALLBACK.footer.selected as RGBA).toInts(),
+ )
+}
+
+test("direct footer composer area does not adopt footer surface", async () => {
const surface = RGBA.fromHex("#123456")
const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
const app = await renderFooter({ theme })
@@ -248,7 +267,7 @@ test("direct footer updates composer background when theme changes", async () =>
})
await app.renderOnce()
- expect(area.backgroundColor.toInts()).toEqual(surface.toInts())
+ expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts())
} finally {
app.cleanup()
}
@@ -319,6 +338,8 @@ test("direct command panel renders grouped command palette", async () => {
variantCycle="ctrl+t"
onClose={() => {}}
onModel={() => {}}
+ onEditor={() => {}}
+ onSkill={() => {}}
onSubagent={() => {}}
onQueued={() => {}}
onVariant={() => {}}
@@ -341,17 +362,17 @@ test("direct command panel renders grouped command palette", async () => {
expect(frame).toContain("Commands")
expect(frame).toContain("Search")
- expect(frame).toContain("Suggested")
- expect(frame).toContain("Switch model")
- expect(frame).toContain("Variant cycle")
- expect(frame).toContain("ctrl+t")
- expect(frame).toContain("Switch model variant")
expect(frame).toContain("Session")
- expect(frame).toContain("New session")
- expect(frame).toContain("/new")
- expect(frame).toContain("Project Commands")
- expect(frame).toContain("review")
- expect(frame).toContain("/review")
+ expect(frame).toContain("Agent")
+ expect(frame).toContain("Prompt")
+ expect(frame).toContain("Open editor")
+ expect(frame).toContain("/editor")
+ expect(frame).toContain("Switch model")
+ expect(frame).toContain("Skills")
+ expect(frame).toContain("/skills")
+ expect(frame.match(/\bAgent\b/g)?.length).toBe(1)
+ expect(frame).not.toContain("┌")
+ expect(frame).not.toContain("┃")
expect(frame).not.toContain("/internal")
expect(frame).not.toContain("Choose model for future turns")
expect(frame).not.toContain("Cycle reasoning effort for future turns")
@@ -362,6 +383,85 @@ test("direct command panel renders grouped command palette", async () => {
}
})
+test("direct skill panel renders searchable skill list", async () => {
+ const [commands] = createSignal([
+ command({ name: "review", description: "Review code" }),
+ command({ name: "internal", description: "Skill command", source: "skill" }),
+ command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
+ ])
+
+ const app = await testRender(
+ () => (
+
+ RUN_THEME_FALLBACK.footer}
+ commands={commands}
+ onClose={() => {}}
+ onSelect={() => {}}
+ />
+
+ ),
+ {
+ width: 100,
+ height: RUN_COMMAND_PANEL_ROWS,
+ },
+ )
+
+ try {
+ await app.renderOnce()
+ const frame = app.captureCharFrame()
+
+ expect(frame).toContain("Skills")
+ expect(frame).toContain("Search")
+ expect(frame).toContain("internal")
+ expect(frame).not.toContain("/internal")
+ expect(frame).toContain("formatter")
+ expect(frame).toContain("Apply formatter fixes")
+ expect(frame).not.toContain("review")
+ } finally {
+ app.renderer.destroy()
+ }
+})
+
+test("direct skill panel truncates long descriptions from the end", async () => {
+ const [commands] = createSignal([
+ command({
+ name: "terminal-control",
+ description:
+ "Control and test terminal applications, REPLs, interactive CLIs, shell processes, OpenTUI applications, or other terminal-backed workflows.",
+ source: "skill",
+ }),
+ ])
+
+ const app = await testRender(
+ () => (
+
+ RUN_THEME_FALLBACK.footer}
+ commands={commands}
+ onClose={() => {}}
+ onSelect={() => {}}
+ />
+
+ ),
+ {
+ width: 100,
+ height: RUN_COMMAND_PANEL_ROWS,
+ },
+ )
+
+ try {
+ await app.renderOnce()
+ const frame = app.captureCharFrame()
+
+ expect(frame).toContain("terminal-control")
+ expect(frame).toContain("Control and test terminal applications")
+ expect(frame).not.toMatch(/application(?:…|\.\.\.)ocess/)
+ } finally {
+ app.renderer.destroy()
+ }
+})
+
test("direct command panel shows subagent entry when available", async () => {
const [commands] = createSignal([])
const [subagents] = createSignal([subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })])
@@ -379,6 +479,8 @@ test("direct command panel shows subagent entry when available", async () => {
variantCycle="ctrl+t"
onClose={() => {}}
onModel={() => {}}
+ onEditor={() => {}}
+ onSkill={() => {}}
onSubagent={() => {}}
onQueued={() => {}}
onVariant={() => {}}
@@ -406,6 +508,54 @@ test("direct command panel shows subagent entry when available", async () => {
}
})
+test("direct command panel keeps completed subagents available", async () => {
+ const [commands] = createSignal([])
+ const [subagents] = createSignal([
+ subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow", status: "completed" }),
+ ])
+ const [variants] = createSignal([])
+
+ const app = await testRender(
+ () => (
+
+ RUN_THEME_FALLBACK.footer}
+ commands={commands}
+ subagents={subagents}
+ queued={() => []}
+ variants={variants}
+ variantCycle="ctrl+t"
+ onClose={() => {}}
+ onModel={() => {}}
+ onEditor={() => {}}
+ onSkill={() => {}}
+ onSubagent={() => {}}
+ onQueued={() => {}}
+ onVariant={() => {}}
+ onVariantCycle={() => {}}
+ onCommand={() => {}}
+ onNew={() => {}}
+ onExit={() => {}}
+ />
+
+ ),
+ {
+ width: 100,
+ height: RUN_COMMAND_PANEL_ROWS,
+ },
+ )
+
+ try {
+ await app.renderOnce()
+ const frame = app.captureCharFrame()
+
+ expect(frame).toContain("View subagents")
+ expect(frame).toContain("1 recent")
+ } finally {
+ app.renderer.destroy()
+ }
+})
+
test("direct subagent panel renders active subagents", async () => {
const [tabs] = createSignal([
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
@@ -438,11 +588,15 @@ test("direct subagent panel renders active subagents", async () => {
try {
await app.renderOnce()
const frame = app.captureCharFrame()
+ const list = app.renderer.root.findDescendantById("run-direct-footer-subagent-list") as BoxRenderable
expect(frame).toContain("Select subagent")
expect(frame).toContain("Inspect auth flow")
expect(frame).toContain("Write migration plan")
expect(frame).toContain("done")
+ expect(frame).not.toContain("┌")
+ expect(frame).not.toContain("┃")
+ expectPaletteList(list, 0)
expect(rows).toBe(8)
} finally {
app.renderer.destroy()
@@ -471,25 +625,41 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
try {
await app.renderOnce()
- expect(app.captureCharFrame()).toContain("Queued prompts")
- expect(app.captureCharFrame()).toContain("fix the auth test")
- expect(app.captureCharFrame()).toContain("queued")
+ const frame = app.captureCharFrame()
+ const list = app.renderer.root.findDescendantById("run-direct-footer-queued-list") as BoxRenderable
+
+ expect(frame).toContain("Queued prompts")
+ expect(frame).toContain("fix the auth test")
+ expect(frame).toContain("queued")
+ expect(frame).not.toContain("┌")
+ expect(frame).not.toContain("┃")
+ expectPaletteList(list, 0)
} finally {
app.renderer.destroy()
}
})
-// OpenTUI currently segfaults when the full footer view suite creates several
-// keymap-backed test renderers in one process. Re-enable after the runtime fix.
-test.skip("direct footer opens command panel through keymap binding", async () => {
+// OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
+// Re-enable after the upstream OpenTUI fix lands in this repo.
+test.skip("direct footer recreates the frame across command panel transitions", async () => {
const app = await renderFooter()
try {
await app.renderOnce()
- app.mockInput.pressKey("p", { ctrl: true })
- await app.renderOnce()
- expect(app.captureCharFrame()).toContain("Commands")
+ for (let index = 0; index < 3; index++) {
+ const composerFrame = app.renderer.root.findDescendantById("run-direct-footer-composer-frame") as BoxRenderable
+ app.mockInput.pressKey("p", { ctrl: true })
+ await app.renderOnce()
+
+ expect(app.captureCharFrame()).toContain("Commands")
+ expect(app.renderer.root.findDescendantById("run-direct-footer-composer-frame")).not.toBe(composerFrame)
+ app.mockInput.pressKey("c", { ctrl: true })
+ await app.renderOnce()
+ expect(app.captureCharFrame()).not.toContain("Commands")
+ expect(app.captureCharFrame()).not.toContain("┃")
+ expect(app.captureCharFrame()).not.toContain("█")
+ }
} finally {
app.cleanup()
}
@@ -596,6 +766,104 @@ test("direct footer submits slash autocomplete selections without dispatching sh
}
})
+test("direct footer slash autocomplete keeps a real skills command", async () => {
+ const submits: RunPrompt[] = []
+ const app = await renderFooter({
+ commands: [
+ command({ name: "skills", description: "Run the real skills command" }),
+ command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
+ ],
+ onSubmit(prompt) {
+ submits.push(prompt)
+ return true
+ },
+ })
+
+ try {
+ await app.renderOnce()
+ "/skills".split("").forEach((key) => app.mockInput.pressKey(key))
+ await app.renderOnce()
+ app.mockInput.pressEnter()
+ await app.renderOnce()
+
+ expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }])
+ expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
+ } finally {
+ app.cleanup()
+ }
+})
+
+// OpenTUI currently segfaults Bun while tearing down this composer-to-skill-panel transition.
+// Re-enable after the upstream renderer teardown fix lands.
+test.skip("direct footer skill picker inserts an editable bound skill command", async () => {
+ const submits: RunPrompt[] = []
+ const app = await renderFooter({
+ commands: [command({ name: "new", description: "Skill named new", source: "skill" })],
+ onSubmit(prompt) {
+ submits.push(prompt)
+ return true
+ },
+ })
+
+ try {
+ await app.renderOnce()
+ "/skills".split("").forEach((key) => app.mockInput.pressKey(key))
+ await app.renderOnce()
+ app.mockInput.pressEnter()
+ await app.renderOnce()
+
+ expect(app.captureCharFrame()).toContain("Skill named new")
+
+ app.mockInput.pressEnter()
+ await app.renderOnce()
+
+ expect(submits).toEqual([])
+ expect(app.captureCharFrame()).toContain("/new")
+
+ "task".split("").forEach((key) => app.mockInput.pressKey(key))
+ await app.renderOnce()
+ app.mockInput.pressEnter()
+ await app.renderOnce()
+
+ expect(submits).toEqual([{ text: "/new task", parts: [], command: { name: "new", arguments: "task" } }])
+ } finally {
+ app.cleanup()
+ }
+})
+
+// OpenTUI currently segfaults Bun while tearing down this skill-panel close transition.
+// Re-enable after the upstream renderer teardown fix lands.
+test.skip("direct footer clears the synthetic skills draft when the panel closes", async () => {
+ const submits: RunPrompt[] = []
+ const app = await renderFooter({
+ commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
+ onSubmit(prompt) {
+ submits.push(prompt)
+ return true
+ },
+ })
+
+ try {
+ await app.renderOnce()
+ "/skills".split("").forEach((key) => app.mockInput.pressKey(key))
+ await app.renderOnce()
+ app.mockInput.pressEnter()
+ await app.renderOnce()
+
+ expect(app.captureCharFrame()).toContain("Apply formatter fixes")
+
+ app.mockInput.pressKey("c", { ctrl: true })
+ await app.renderOnce()
+ app.mockInput.pressEnter()
+ await app.renderOnce()
+
+ expect(submits).toEqual([])
+ expect(app.captureCharFrame()).not.toContain("/skills")
+ } finally {
+ app.cleanup()
+ }
+})
+
test("direct footer shows editable prompts and additional queued work while running", async () => {
const [state] = createSignal({
phase: "running",
@@ -630,7 +898,10 @@ test("direct footer shows editable prompts and additional queued work while runn
resources={() => []}
commands={() => []}
providers={() => undefined}
- currentModel={() => undefined}
+ currentModel={() => ({
+ providerID: "opencode",
+ modelID: "a-model-name-long-enough-to-force-responsive-truncation",
+ })}
variants={() => []}
currentVariant={() => undefined}
state={state}
@@ -649,6 +920,7 @@ test("direct footer shows editable prompts and additional queued work while runn
onQuestionReject={() => {}}
onCycle={() => {}}
onInterrupt={() => false}
+ onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
@@ -676,10 +948,34 @@ test("direct footer shows editable prompts and additional queued work while runn
try {
await app.renderOnce()
- expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • ctrl+b background • 1 queued ctrl+x q")
- expect(app.captureCharFrame()).toContain("2 queued")
- expect(app.captureCharFrame()).not.toContain("to view")
- expect(app.captureCharFrame()).not.toContain("edit/remove")
+ const frame = app.captureCharFrame()
+ const transparent = RGBA.fromValues(0, 0, 0, 0).toInts()
+ const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
+ const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
+ const statusline = app.renderer.root.findDescendantById("run-direct-footer-statusline") as BoxRenderable
+ const mode = app.renderer.root.findDescendantById("run-direct-footer-statusline-mode") as BoxRenderable
+ const main = app.renderer.root.findDescendantById("run-direct-footer-statusline-main") as BoxRenderable
+ const spinner = app.renderer.root.findDescendantById("run-direct-footer-status-spinner")
+ const model = app.renderer.root.findDescendantById("run-direct-footer-statusline-model") as BoxRenderable
+ const queued = app.renderer.root.findDescendantById("run-direct-footer-statusline-queued") as BoxRenderable
+ const hint = app.renderer.root.findDescendantById("run-direct-footer-statusline-hint") as BoxRenderable
+
+ expect(spinner).toBeDefined()
+ expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
+ expect(frame).toContain("3 queued")
+ expect(frame).toContain("ctrl+b background")
+ expect(frame).toContain("ctrl+x q 3 queued")
+ expect(frame).toContain("ctrl+x down subagents")
+ expect(frame).toContain("ctrl+p cmd")
+ expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
+ expect(frame).toContain("subagents · ctrl+p cmd")
+ expect(frame).not.toContain("1 agent")
+ expect(statusline.backgroundColor.toInts()).toEqual(tinted)
+ expect(mode.backgroundColor.toInts()).toEqual(accent)
+ expect(main.backgroundColor.toInts()).toEqual(transparent)
+ expect(model.backgroundColor.toInts()).toEqual(transparent)
+ expect(queued.backgroundColor.toInts()).toEqual(transparent)
+ expect(hint.backgroundColor.toInts()).toEqual(transparent)
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
@@ -688,6 +984,110 @@ test("direct footer shows editable prompts and additional queued work while runn
}
})
+test("direct footer separates a lone context hint from model and command hint", async () => {
+ const app = await renderFooter({
+ providers: [provider()],
+ currentModel: { providerID: "opencode", modelID: "gpt-5" },
+ currentVariant: "xhigh",
+ subagents: {
+ tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
+ details: {},
+ permissions: [],
+ questions: [],
+ },
+ backgroundSubagents: false,
+ width: 160,
+ })
+
+ try {
+ await app.renderOnce()
+ const frame = app.captureCharFrame()
+
+ expect(frame).toContain("GPT-5")
+ expect(frame).toContain("xhigh · ctrl+x down subagents · ctrl+p cmd")
+ expect(frame).not.toContain("ctrl+b background")
+ expect(frame).not.toContain("queued")
+ } finally {
+ app.cleanup()
+ }
+})
+
+test("direct footer hides the subagent hint when only completed subagents remain", async () => {
+ const app = await renderFooter({
+ providers: [provider()],
+ currentModel: { providerID: "opencode", modelID: "gpt-5" },
+ currentVariant: "xhigh",
+ subagents: {
+ tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow", status: "completed" })],
+ details: {},
+ permissions: [],
+ questions: [],
+ },
+ backgroundSubagents: false,
+ width: 160,
+ })
+
+ try {
+ await app.renderOnce()
+ const frame = app.captureCharFrame()
+
+ expect(frame).toContain("GPT-5")
+ expect(frame).toContain("xhigh · ctrl+p cmd")
+ expect(frame).not.toContain("ctrl+x down subagents")
+ } finally {
+ app.cleanup()
+ }
+})
+
+test("direct footer omits interrupt key hint when interrupt is unbound", async () => {
+ const app = await renderFooter({
+ tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none", input_clear: "ctrl+l" } }),
+ state: { phase: "running" },
+ })
+
+ try {
+ await app.renderOnce()
+ const frame = app.captureCharFrame()
+
+ expect(frame).toContain("interrupt")
+ expect(frame).not.toContain("ctrl+l")
+ } finally {
+ app.cleanup()
+ }
+})
+
+test("direct footer shows full usage metadata when room is available", async () => {
+ const app = await renderFooter({
+ state: { usage: "159.6K (16%) · $4.23" },
+ })
+
+ try {
+ await app.renderOnce()
+ const frame = app.captureCharFrame()
+
+ expect(frame).toContain("159.6K (16%) · $4.23")
+ } finally {
+ app.cleanup()
+ }
+})
+
+test("direct footer mode label keeps left padding without a status pill", async () => {
+ const app = await renderFooter()
+
+ try {
+ await app.renderOnce()
+ const statusline = app
+ .captureCharFrame()
+ .split("\n")
+ .find((line) => line.includes("BUILD") && line.includes("cmd"))
+
+ expect(statusline).toBeDefined()
+ expect(statusline?.startsWith(" BUILD ")).toBe(true)
+ } finally {
+ app.cleanup()
+ }
+})
+
test("direct question body separates single-select checkmark from label", async () => {
const request = {
id: "question-1",
@@ -876,6 +1276,7 @@ test("direct model panel renders current model selector", async () => {
try {
await app.renderOnce()
const frame = app.captureCharFrame()
+ const list = app.renderer.root.findDescendantById("run-direct-footer-model-list") as BoxRenderable
expect(frame).toContain("Select model")
expect(frame).toContain("Search")
@@ -884,7 +1285,10 @@ test("direct model panel renders current model selector", async () => {
expect(frame).toContain("current")
expect(frame).toContain("GPT Free")
expect(frame).toContain("Free")
+ expect(frame).not.toContain("┌")
+ expect(frame).not.toContain("┃")
expect(frame).not.toContain("Old Model")
+ expectPaletteList(list, 2)
} finally {
app.renderer.destroy()
}
@@ -915,12 +1319,16 @@ test("direct variant panel renders current variant selector", async () => {
try {
await app.renderOnce()
const frame = app.captureCharFrame()
+ const list = app.renderer.root.findDescendantById("run-direct-footer-variant-list") as BoxRenderable
expect(frame).toContain("Select variant")
expect(frame).toContain("Default")
expect(frame).toContain("high")
expect(frame).toContain("minimal")
expect(frame).toContain("current")
+ expect(frame).not.toContain("┌")
+ expect(frame).not.toContain("┃")
+ expectPaletteList(list, 1)
} finally {
app.renderer.destroy()
}
diff --git a/packages/opencode/test/cli/run/footer.width.test.ts b/packages/opencode/test/cli/run/footer.width.test.ts
new file mode 100644
index 000000000000..75efcb8aaf49
--- /dev/null
+++ b/packages/opencode/test/cli/run/footer.width.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import { footerWidthPolicy } from "@/cli/cmd/run/footer.width"
+
+describe("run footer width", () => {
+ test("preserves shared dialog and statusline breakpoints", () => {
+ const narrow = footerWidthPolicy(79)
+ expect(narrow.dialog.narrow).toBe(true)
+ expect(narrow.statusline.showActivityMeta).toBe(false)
+ expect(narrow.statusline.showCommandHint).toBe(true)
+ expect(narrow.statusline.showContextHints).toBe(false)
+ expect(narrow.statusline.contextHintLimit).toBe(0)
+ expect(narrow.statusline.showModel).toBe(false)
+
+ const command = footerWidthPolicy(65)
+ expect(command.statusline.showCommandHint).toBe(false)
+
+ const commandHint = footerWidthPolicy(66)
+ expect(commandHint.statusline.showCommandHint).toBe(true)
+
+ const compact = footerWidthPolicy(80)
+ expect(compact.dialog.narrow).toBe(false)
+ expect(compact.statusline.showActivityMeta).toBe(true)
+ expect(compact.statusline.showContextHints).toBe(true)
+ expect(compact.statusline.contextHintLimit).toBe(1)
+ expect(compact.statusline.showModel).toBe(false)
+
+ const model = footerWidthPolicy(120)
+ expect(model.statusline.contextHintLimit).toBe(2)
+ expect(model.statusline.showModel).toBe(true)
+
+ const spacious = footerWidthPolicy(150)
+ expect(spacious.statusline.contextHintLimit).toBeUndefined()
+ expect(spacious.statusline.showModel).toBe(true)
+ })
+})
diff --git a/packages/opencode/test/cli/run/prompt.editor.test.ts b/packages/opencode/test/cli/run/prompt.editor.test.ts
new file mode 100644
index 000000000000..872c6030475f
--- /dev/null
+++ b/packages/opencode/test/cli/run/prompt.editor.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, test } from "bun:test"
+import { realignEditorPromptParts, resolveEditorSlashValue } from "@/cli/cmd/run/prompt.editor"
+import type { RunPromptPart } from "@/cli/cmd/run/types"
+
+describe("run prompt editor helpers", () => {
+ test("strips the local /editor command from the initial editor text", () => {
+ expect(resolveEditorSlashValue("/editor")).toBe("")
+ expect(resolveEditorSlashValue("/editor draft message")).toBe("draft message")
+ expect(resolveEditorSlashValue("/editor first line\nsecond line")).toBe("first line\nsecond line")
+ })
+
+ test("realigns file and agent parts after external editing", () => {
+ const filePart = {
+ type: "file",
+ mime: "text/plain",
+ filename: "src/app.ts",
+ url: "file:///src/app.ts",
+ source: {
+ type: "file",
+ path: "src/app.ts",
+ text: {
+ start: 0,
+ end: 11,
+ value: "@src/app.ts",
+ },
+ },
+ } satisfies RunPromptPart
+ const agentPart = {
+ type: "agent",
+ name: "helper",
+ source: {
+ start: 12,
+ end: 19,
+ value: "@helper",
+ },
+ } satisfies RunPromptPart
+ const parts = [filePart, agentPart]
+
+ expect(realignEditorPromptParts("Please check @helper before @src/app.ts", parts)).toEqual([
+ {
+ ...filePart,
+ source: {
+ ...filePart.source,
+ text: {
+ ...filePart.source.text,
+ start: 28,
+ end: 39,
+ value: "@src/app.ts",
+ },
+ },
+ },
+ {
+ ...agentPart,
+ source: {
+ start: 13,
+ end: 20,
+ value: "@helper",
+ },
+ },
+ ])
+ })
+
+ test("drops parts whose virtual text was deleted", () => {
+ const filePart = {
+ type: "file",
+ mime: "text/plain",
+ filename: "src/app.ts",
+ url: "file:///src/app.ts",
+ source: {
+ type: "file",
+ path: "src/app.ts",
+ text: {
+ start: 0,
+ end: 11,
+ value: "@src/app.ts",
+ },
+ },
+ } satisfies RunPromptPart
+ const agentPart = {
+ type: "agent",
+ name: "helper",
+ source: {
+ start: 12,
+ end: 19,
+ value: "@helper",
+ },
+ } satisfies RunPromptPart
+ const parts = [filePart, agentPart]
+
+ expect(realignEditorPromptParts("Only @helper remains", parts)).toEqual([
+ {
+ ...agentPart,
+ source: {
+ start: 5,
+ end: 12,
+ value: "@helper",
+ },
+ },
+ ])
+ })
+})
diff --git a/packages/opencode/test/cli/run/runtime.queue.test.ts b/packages/opencode/test/cli/run/runtime.queue.test.ts
index 7eba8bb251ab..558373a35d35 100644
--- a/packages/opencode/test/cli/run/runtime.queue.test.ts
+++ b/packages/opencode/test/cli/run/runtime.queue.test.ts
@@ -206,6 +206,22 @@ describe("run runtime queue", () => {
await task
})
+ test("shell mode does not emit a turn duration summary", async () => {
+ const ui = footer()
+
+ const task = runPromptQueue({
+ footer: ui.api,
+ run: async () => {
+ ui.api.close()
+ },
+ })
+
+ ui.submit("ls", "shell")
+ await task
+
+ expect(ui.events.some((event) => event.type === "turn.duration")).toBe(false)
+ })
+
test("preserves whitespace for initial input", async () => {
const ui = footer()
const seen: string[] = []
diff --git a/packages/opencode/test/cli/run/runtime.test.ts b/packages/opencode/test/cli/run/runtime.test.ts
new file mode 100644
index 000000000000..2c9eb2bd3d09
--- /dev/null
+++ b/packages/opencode/test/cli/run/runtime.test.ts
@@ -0,0 +1,238 @@
+import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
+import { OpencodeClient } from "@opencode-ai/sdk/v2"
+import { runInteractiveMode } from "@/cli/cmd/run/runtime"
+import type { FooterApi, RunProvider } from "@/cli/cmd/run/types"
+
+type SessionMessage = NonNullable>["data"]>[number]
+
+const provider: RunProvider = {
+ id: "openai",
+ name: "OpenAI",
+ source: "api",
+ env: [],
+ options: {},
+ models: {
+ "gpt-5": {
+ id: "gpt-5",
+ providerID: "openai",
+ api: {
+ id: "openai",
+ url: "https://openai.test",
+ npm: "@ai-sdk/openai",
+ },
+ name: "Little Frank",
+ capabilities: {
+ temperature: true,
+ reasoning: true,
+ attachment: true,
+ toolcall: true,
+ input: {
+ text: true,
+ audio: false,
+ image: false,
+ video: false,
+ pdf: false,
+ },
+ output: {
+ text: true,
+ audio: false,
+ image: false,
+ video: false,
+ pdf: false,
+ },
+ interleaved: false,
+ },
+ cost: {
+ input: 0,
+ output: 0,
+ cache: {
+ read: 0,
+ write: 0,
+ },
+ },
+ limit: {
+ context: 128000,
+ output: 8192,
+ },
+ status: "active",
+ options: {},
+ headers: {},
+ release_date: "2026-01-01",
+ },
+ },
+}
+
+const transportProviders: RunProvider[][] = []
+
+function defer() {
+ let resolve!: (value: T | PromiseLike) => void
+ const promise = new Promise((done) => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+function ok(data: T) {
+ return Promise.resolve({
+ data,
+ error: undefined,
+ request: new Request("https://opencode.test"),
+ response: new Response(),
+ })
+}
+
+function footer(): FooterApi {
+ let closed = false
+ const closes = new Set<() => void>()
+
+ const notify = () => {
+ for (const fn of closes) fn()
+ }
+
+ return {
+ get isClosed() {
+ return closed
+ },
+ onPrompt: () => () => {},
+ onQueuedRemove: () => () => {},
+ onClose(fn) {
+ if (closed) {
+ fn()
+ return () => {}
+ }
+
+ closes.add(fn)
+ return () => {
+ closes.delete(fn)
+ }
+ },
+ event() {},
+ append() {},
+ idle() {
+ return Promise.resolve()
+ },
+ close() {
+ if (closed) {
+ return
+ }
+
+ closed = true
+ notify()
+ },
+ destroy() {
+ if (closed) {
+ return
+ }
+
+ closed = true
+ notify()
+ },
+ }
+}
+
+afterEach(() => {
+ mock.restore()
+ transportProviders.length = 0
+})
+
+describe("run interactive runtime", () => {
+ test("waits for provider metadata before eager replay transport bootstrap", async () => {
+ const providersStarted = defer()
+ const providers = defer()
+
+ const sdk = new OpencodeClient()
+ spyOn(sdk.config, "providers").mockImplementation(async () => {
+ providersStarted.resolve()
+ await providers.promise
+ return ok({ providers: [provider], default: {} })
+ })
+ spyOn(sdk.session, "messages").mockImplementation(() =>
+ ok([
+ {
+ info: {
+ id: "msg-user-1",
+ sessionID: "ses-1",
+ role: "user",
+ time: {
+ created: 1,
+ },
+ agent: "build",
+ model: {
+ providerID: "openai",
+ modelID: "gpt-5",
+ variant: undefined,
+ },
+ },
+ parts: [
+ {
+ id: "part-user-1",
+ sessionID: "ses-1",
+ messageID: "msg-user-1",
+ type: "text",
+ text: "hello",
+ },
+ ],
+ } satisfies SessionMessage,
+ ]),
+ )
+ spyOn(sdk.session, "get").mockRejectedValue(new Error("not needed"))
+ spyOn(sdk.app, "agents").mockImplementation(() => ok([]))
+ spyOn(sdk.experimental.resource, "list").mockImplementation(() => ok({}))
+ spyOn(sdk.command, "list").mockImplementation(() => ok([]))
+
+ const task = runInteractiveMode(
+ {
+ sdk,
+ directory: "/tmp",
+ sessionID: "ses-1",
+ sessionTitle: "Session",
+ resume: true,
+ replay: true,
+ replayLimit: 100,
+ agent: "build",
+ model: {
+ providerID: "openai",
+ modelID: "gpt-5",
+ },
+ variant: undefined,
+ files: [],
+ thinking: true,
+ backgroundSubagents: false,
+ },
+ {
+ createRuntimeLifecycle: async () => ({
+ footer: footer(),
+ onResize: () => () => {},
+ refreshTheme: () => {},
+ resetForReplay: () => Promise.resolve(),
+ close: () => Promise.resolve(),
+ }),
+ streamTransport: Promise.resolve({
+ createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => {
+ transportProviders.push(input.providers?.() ?? [])
+ setTimeout(() => {
+ input.footer.close()
+ }, 0)
+ return {
+ runPromptTurn: async () => {},
+ selectSubagent: () => {},
+ replayOnResize: async () => false,
+ close: async () => {},
+ }
+ },
+ formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
+ }),
+ },
+ )
+
+ await providersStarted.promise
+
+ expect(transportProviders).toEqual([])
+
+ providers.resolve()
+
+ await task
+
+ expect(transportProviders).toEqual([[provider]])
+ })
+})
diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts
index 3a717fb0c276..f1500ec44ae5 100644
--- a/packages/opencode/test/cli/run/scrollback.surface.test.ts
+++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts
@@ -111,6 +111,23 @@ function reasoning(text: string, phase: StreamCommit["phase"] = "progress"): Str
}
}
+test("turn summary starts at the left edge", async () => {
+ const out = await setup()
+
+ try {
+ await out.scrollback.writeTurnSummary({ agent: "Build", model: "Little Frank", duration: "2.2s" })
+
+ const commits = claim(out.renderer)
+ try {
+ expect(renderRows(commits.at(-1)!)[0]).toBe("▣ Build · Little Frank · 2.2s")
+ } finally {
+ destroy(commits)
+ }
+ } finally {
+ out.scrollback.destroy()
+ }
+})
+
test("theme swaps restyle active reasoning without resetting the stream", async () => {
const previousSyntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } })
const nextSyntax = SyntaxStyle.fromStyles({ default: { fg: "#abcdef" } })
diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts
index da4bfd382e54..6fdd845ad34f 100644
--- a/packages/opencode/test/cli/run/session-replay.test.ts
+++ b/packages/opencode/test/cli/run/session-replay.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
+import type { RunProvider } from "@/cli/cmd/run/types"
function userMessage(id: string, text: string): SessionMessages[number] {
return {
@@ -29,17 +30,23 @@ function userMessage(id: string, text: string): SessionMessages[number] {
}
}
-function assistantInfo(id: string) {
+function assistantInfo(
+ id: string,
+ input: {
+ parentID?: string
+ modelID?: string
+ providerID?: string
+ time?: { created: number; completed?: number }
+ } = {},
+) {
return {
id,
sessionID: "session-1",
role: "assistant" as const,
- time: {
- created: 2,
- },
- parentID: "msg-user-1",
- modelID: "gpt-5",
- providerID: "openai",
+ time: input.time ?? { created: 2 },
+ parentID: input.parentID ?? "msg-user-1",
+ modelID: input.modelID ?? "gpt-5",
+ providerID: input.providerID ?? "openai",
mode: "chat",
agent: "build",
path: {
@@ -59,9 +66,26 @@ function assistantInfo(id: string) {
}
}
-function assistantMessage(id: string, text: string): SessionMessages[number] {
+function assistantMessage(
+ id: string,
+ text: string,
+ input: {
+ parentID?: string
+ modelID?: string
+ providerID?: string
+ time?: { created: number; completed?: number }
+ } = {},
+): SessionMessages[number] {
+ const time = input.time ?? {
+ created: 200,
+ completed: 3000,
+ }
+
return {
- info: assistantInfo(id),
+ info: assistantInfo(id, {
+ ...input,
+ time,
+ }),
parts: [
{
id: `${id}-text`,
@@ -70,14 +94,72 @@ function assistantMessage(id: string, text: string): SessionMessages[number] {
type: "text",
text,
time: {
- start: 2,
- end: 3,
+ start: time.created,
+ end: time.completed,
},
},
],
}
}
+const provider = (name: string): RunProvider =>
+ ({
+ id: "openai",
+ name: "OpenAI",
+ source: "api",
+ env: [],
+ options: {},
+ models: {
+ "gpt-5": {
+ id: "gpt-5",
+ providerID: "openai",
+ api: {
+ id: "openai",
+ url: "https://openai.test",
+ npm: "@ai-sdk/openai",
+ },
+ name,
+ capabilities: {
+ temperature: true,
+ reasoning: true,
+ attachment: true,
+ toolcall: true,
+ input: {
+ text: true,
+ audio: false,
+ image: false,
+ video: false,
+ pdf: false,
+ },
+ output: {
+ text: true,
+ audio: false,
+ image: false,
+ video: false,
+ pdf: false,
+ },
+ interleaved: false,
+ },
+ cost: {
+ input: 0,
+ output: 0,
+ cache: {
+ read: 0,
+ write: 0,
+ },
+ },
+ limit: {
+ context: 128000,
+ output: 8192,
+ },
+ status: "active",
+ options: {},
+ headers: {},
+ release_date: "2026-01-01",
+ },
+ },
+ })
+
function runningToolMessage(id: string): SessionMessages[number] {
return {
info: assistantInfo(id),
@@ -103,8 +185,74 @@ function runningToolMessage(id: string): SessionMessages[number] {
}
}
+function shellUserMessage(id: string): SessionMessages[number] {
+ return {
+ info: {
+ id,
+ sessionID: "session-1",
+ role: "user",
+ time: {
+ created: 1,
+ },
+ agent: "build",
+ model: {
+ providerID: "openai",
+ modelID: "gpt-5",
+ },
+ },
+ parts: [
+ {
+ id: `${id}-text`,
+ sessionID: "session-1",
+ messageID: id,
+ type: "text",
+ text: "The following tool was executed by the user",
+ synthetic: true,
+ },
+ ],
+ }
+}
+
+function shellAssistantMessage(id: string, parentID: string): SessionMessages[number] {
+ return {
+ info: assistantInfo(id, {
+ parentID,
+ time: {
+ created: 200,
+ completed: 3000,
+ },
+ }),
+ parts: [
+ {
+ id: `${id}-tool`,
+ sessionID: "session-1",
+ messageID: id,
+ type: "tool",
+ callID: `${id}-call`,
+ tool: "bash",
+ state: {
+ status: "completed",
+ input: {
+ command: "ls",
+ },
+ output: "account.ts\n",
+ title: "",
+ metadata: {
+ output: "account.ts\n",
+ description: "",
+ },
+ time: {
+ start: 200,
+ end: 3000,
+ },
+ },
+ },
+ ],
+ }
+}
+
describe("run session replay", () => {
- test("replays persisted user and assistant history into scrollback commits", () => {
+ test("replays persisted user, assistant, and turn summary history into scrollback commits", () => {
const out = replaySession({
messages: [
userMessage("msg-user-1", "Hello, whats the weather today?"),
@@ -131,6 +279,18 @@ describe("run session replay", () => {
source: "assistant",
messageID: "msg-1",
}),
+ expect.objectContaining({
+ kind: "system",
+ text: "▣ Build · gpt-5 · 2.8s",
+ phase: "final",
+ source: "system",
+ messageID: "msg-1",
+ summary: {
+ agent: "Build",
+ model: "gpt-5",
+ duration: "2.8s",
+ },
+ }),
])
expect(out.patch).toEqual(
expect.objectContaining({
@@ -140,6 +300,60 @@ describe("run session replay", () => {
)
})
+ test("uses provider model names for replayed turn summaries when available", () => {
+ const out = replaySession({
+ messages: [
+ userMessage("msg-user-1", "Hello, whats the weather today?"),
+ assistantMessage("msg-1", "What city or ZIP code should I check?"),
+ ],
+ permissions: [],
+ questions: [],
+ thinking: true,
+ limits: {},
+ providers: [provider("Little Frank")],
+ })
+
+ expect(out.commits.at(-1)).toEqual(
+ expect.objectContaining({
+ kind: "system",
+ text: "▣ Build · Little Frank · 2.8s",
+ summary: {
+ agent: "Build",
+ model: "Little Frank",
+ duration: "2.8s",
+ },
+ }),
+ )
+ })
+
+ test("replays one turn summary for the final assistant in a multi-step turn", () => {
+ const out = replaySession({
+ messages: [
+ userMessage("msg-user-1", "Plan and then answer"),
+ assistantMessage("msg-step-1", "Working", {
+ parentID: "msg-user-1",
+ time: { created: 200, completed: 900 },
+ }),
+ assistantMessage("msg-step-2", "Done", {
+ parentID: "msg-user-1",
+ time: { created: 1000, completed: 3000 },
+ }),
+ ],
+ permissions: [],
+ questions: [],
+ thinking: true,
+ limits: {},
+ })
+
+ expect(out.commits.filter((commit) => commit.summary)).toEqual([
+ expect.objectContaining({
+ kind: "system",
+ text: "▣ Build · gpt-5 · 2.0s",
+ messageID: "msg-step-2",
+ }),
+ ])
+ })
+
test("keeps the footer in a running state for resumed active tools", () => {
const out = replaySession({
messages: [runningToolMessage("msg-1")],
@@ -157,6 +371,29 @@ describe("run session replay", () => {
)
})
+ test("does not replay turn summaries for shell-mode commands", () => {
+ const out = replaySession({
+ messages: [
+ shellUserMessage("msg-shell-user-1"),
+ shellAssistantMessage("msg-shell-assistant-1", "msg-shell-user-1"),
+ ],
+ permissions: [],
+ questions: [],
+ thinking: true,
+ limits: {},
+ })
+
+ expect(out.commits.some((commit) => commit.summary)).toBe(false)
+ expect(out.commits).toContainEqual(
+ expect.objectContaining({
+ kind: "tool",
+ text: "account.ts\n",
+ tool: "bash",
+ toolState: "completed",
+ }),
+ )
+ })
+
test("merges failed local rows ahead of later persisted prompts", () => {
const persisted = {
kind: "user",
diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts
index 2dcfc3c4c8e8..5bb578447f1c 100644
--- a/packages/opencode/test/cli/run/stream.transport.test.ts
+++ b/packages/opencode/test/cli/run/stream.transport.test.ts
@@ -1183,7 +1183,7 @@ describe("run stream transport", () => {
}
})
- test("drops completed historical subagent tabs during bootstrap", async () => {
+ test("keeps completed historical subagent tabs during bootstrap", async () => {
const src = eventFeed()
const ui = footer()
const transport = await createSessionTransport({
@@ -1231,7 +1231,7 @@ describe("run stream transport", () => {
return item?.type === "stream.subagent" ? item.state : undefined
})
- expect(state.tabs).toEqual([])
+ expect(state.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "completed" })])
expect(state.details).toEqual({})
} finally {
src.close()
diff --git a/packages/opencode/test/cli/run/subagent-data.test.ts b/packages/opencode/test/cli/run/subagent-data.test.ts
index e31136b22f0f..4dcbd09608bf 100644
--- a/packages/opencode/test/cli/run/subagent-data.test.ts
+++ b/packages/opencode/test/cli/run/subagent-data.test.ts
@@ -4,7 +4,6 @@ import { entryBody } from "@/cli/cmd/run/entry.body"
import {
bootstrapSubagentCalls,
bootstrapSubagentData,
- clearFinishedSubagents,
createSubagentData,
reduceSubagentData,
snapshotSubagentData,
@@ -50,7 +49,7 @@ function reduce(data: ReturnType, event: unknown) {
})
}
-function taskMessage(sessionID: string, status: "running" | "completed" = "completed"): SessionMessage {
+function taskMessage(sessionID: string, status: "running" | "completed" | "interrupted" = "completed"): SessionMessage {
if (status === "running") {
return {
parts: [
@@ -79,6 +78,35 @@ function taskMessage(sessionID: string, status: "running" | "completed" = "compl
}
}
+ if (status === "interrupted") {
+ return {
+ parts: [
+ {
+ id: `part-${sessionID}`,
+ sessionID: "parent-1",
+ messageID: `msg-${sessionID}`,
+ type: "tool",
+ callID: `call-${sessionID}`,
+ tool: "task",
+ state: {
+ status: "error",
+ input: {
+ description: "Scan reducer paths",
+ subagent_type: "explore",
+ },
+ error: "Tool execution aborted",
+ metadata: {
+ sessionId: sessionID,
+ toolcalls: 4,
+ interrupted: true,
+ },
+ time: { start: 1, end: 2 },
+ },
+ },
+ ],
+ }
+ }
+
return {
parts: [
{
@@ -234,6 +262,25 @@ describe("run subagent data", () => {
expect(snapshot.questions.map((item) => item.id)).toEqual(["question-1"])
})
+ test("marks interrupted task tabs as cancelled during bootstrap", () => {
+ const data = createSubagentData()
+
+ bootstrapSubagentData({
+ data,
+ messages: [taskMessage("child-1", "interrupted")],
+ children: [{ id: "child-1" }],
+ permissions: [],
+ questions: [],
+ })
+
+ expect(snapshotSubagentData(data).tabs).toEqual([
+ expect.objectContaining({
+ sessionID: "child-1",
+ status: "cancelled",
+ }),
+ ])
+ })
+
test("captures child activity and blocker metadata in the footer detail state", () => {
const data = createSubagentData()
@@ -437,20 +484,64 @@ describe("run subagent data", () => {
])
})
- test("clears finished tabs on the next parent prompt", () => {
+ test("marks a running tab cancelled when the child session aborts", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
- messages: [taskMessage("child-1", "completed"), taskMessage("child-2", "running")],
- children: [{ id: "child-1" }, { id: "child-2" }],
+ messages: [taskMessage("child-1", "running")],
+ children: [{ id: "child-1" }],
permissions: [],
questions: [],
})
- expect(clearFinishedSubagents(data)).toBe(true)
+ reduce(data, {
+ type: "message.updated",
+ properties: {
+ sessionID: "child-1",
+ info: {
+ id: "msg-assistant-1",
+ sessionID: "child-1",
+ role: "assistant",
+ time: {
+ created: 1,
+ completed: 2,
+ },
+ error: {
+ name: "MessageAbortedError",
+ data: {
+ message: "Aborted",
+ },
+ },
+ parentID: "msg-user-1",
+ providerID: "openai",
+ modelID: "gpt-5",
+ mode: "default",
+ agent: "explore",
+ path: {
+ cwd: "/tmp",
+ root: "/tmp",
+ },
+ cost: 0,
+ tokens: {
+ input: 1,
+ output: 1,
+ reasoning: 0,
+ cache: {
+ read: 0,
+ write: 0,
+ },
+ },
+ finish: "error",
+ },
+ },
+ })
+
expect(snapshotSubagentData(data).tabs).toEqual([
- expect.objectContaining({ sessionID: "child-2", status: "running" }),
+ expect.objectContaining({
+ sessionID: "child-1",
+ status: "cancelled",
+ }),
])
})
})
diff --git a/packages/opencode/test/cli/run/theme.test.ts b/packages/opencode/test/cli/run/theme.test.ts
index cbb8d3992fa7..4102dea1c9a5 100644
--- a/packages/opencode/test/cli/run/theme.test.ts
+++ b/packages/opencode/test/cli/run/theme.test.ts
@@ -74,8 +74,33 @@ test("returns syntax styles and indexed splash colors", async () => {
expectIndexed(theme.splash.right)
expectIndexed(theme.splash.leftShadow)
expectIndexed(theme.splash.rightShadow)
+ expectIndexed(theme.block.highlight)
+ expectIndexed(theme.block.warning)
expectRgba(theme.footer.highlight)
+ expectRgba(theme.footer.statusAccent)
expectRgba(theme.footer.surface)
+ expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts())
+ } finally {
+ theme.block.syntax?.destroy()
+ theme.block.subtleSyntax?.destroy()
+ }
+})
+
+test("keeps footer surfaces exact while scrollback stays palette matched", async () => {
+ const colors = terminalColors({
+ defaultBackground: "#0f172a",
+ defaultForeground: "#e2e8f0",
+ })
+ const theme = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
+ const exact = resolveTheme(generateSystem(colors, "dark"), "dark")
+
+ try {
+ expect(expectRgba(theme.footer.selected).toInts()).toEqual(expectRgba(exact.backgroundElement).toInts())
+ expect(expectRgba(theme.footer.border).toInts()).toEqual(expectRgba(exact.border).toInts())
+ expect(expectRgba(theme.footer.pane).toInts()).toEqual(expectRgba(exact.backgroundMenu).toInts())
+ expect(expectRgba(theme.footer.selected).intent).toBe("rgb")
+ expectIndexed(theme.block.highlight)
+ expectIndexed(theme.block.warning)
} finally {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx
index eb926491df56..6c0e05ca53b5 100644
--- a/packages/tui/src/component/prompt/index.tsx
+++ b/packages/tui/src/component/prompt/index.tsx
@@ -26,7 +26,7 @@ import { useProject } from "../../context/project"
import { useSync } from "../../context/sync"
import { useEvent } from "../../context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
-import { openEditor } from "../../editor"
+import { normalizePromptContent, openEditor } from "../../editor"
import { destroyRenderer } from "../../util/renderer"
import { promptOffsetWidth } from "../../prompt/display"
import { createStore, produce, unwrap } from "solid-js/store"
@@ -442,8 +442,9 @@ export function Prompt(props: PromptProps) {
paths.cwd,
})
if (!content) return
+ const normalized = normalizePromptContent(content)
- input.setText(content)
+ input.setText(normalized)
// Update positions for nonTextParts based on their location in new content
// Filter out parts whose virtual text was deleted
@@ -460,7 +461,7 @@ export function Prompt(props: PromptProps) {
if (!virtualText) return part
- const newStart = content.indexOf(virtualText)
+ const newStart = normalized.indexOf(virtualText)
// if the virtual text is deleted, remove the part
if (newStart === -1) return null
@@ -495,14 +496,14 @@ export function Prompt(props: PromptProps) {
})
.filter((part) => part !== null)
- setStore("prompt", {
- input: content,
- // keep only the non-text parts because the text parts were
- // already expanded inline
- parts: updatedNonTextParts,
- })
- restoreExtmarksFromParts(updatedNonTextParts)
- input.cursorOffset = Bun.stringWidth(content)
+ setStore("prompt", {
+ input: normalized,
+ // keep only the non-text parts because the text parts were
+ // already expanded inline
+ parts: updatedNonTextParts,
+ })
+ restoreExtmarksFromParts(updatedNonTextParts)
+ input.cursorOffset = Bun.stringWidth(normalized)
},
},
{
diff --git a/packages/tui/src/editor.ts b/packages/tui/src/editor.ts
index 3626ff16aef2..68afba6751b6 100644
--- a/packages/tui/src/editor.ts
+++ b/packages/tui/src/editor.ts
@@ -4,9 +4,26 @@ import { readFile, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { spawn } from "node:child_process"
+import type { Stream } from "node:stream"
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
-export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string }) {
+type EditorStdio = "inherit" | "pipe" | "ignore" | number | Stream
+
+export function normalizePromptContent(content: string) {
+ if (content.endsWith("\r\n")) {
+ const body = content.slice(0, -2)
+ return !body.includes("\n") && !body.includes("\r") ? body : content
+ }
+
+ if (content.endsWith("\n")) {
+ const body = content.slice(0, -1)
+ return !body.includes("\n") && !body.includes("\r") ? body : content
+ }
+
+ return content
+}
+
+export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string; stdin?: EditorStdio }) {
const editor = process.env.VISUAL || process.env.EDITOR
if (!editor) return
const file = path.join(os.tmpdir(), `${Date.now()}.md`)
@@ -18,7 +35,7 @@ export async function openEditor(input: { value: string; renderer: CliRenderer;
const parts = editor.split(" ")
const child = spawn(parts[0]!, [...parts.slice(1), file], {
cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(),
- stdio: "inherit",
+ stdio: [input.stdin ?? "inherit", "inherit", "inherit"],
shell: process.platform === "win32",
})
child.on("error", reject)
diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts
index 369a2136fe5f..9ad33a8766ca 100644
--- a/packages/tui/test/editor.test.ts
+++ b/packages/tui/test/editor.test.ts
@@ -1,5 +1,5 @@
import { afterEach, expect, test } from "bun:test"
-import { openEditor } from "../src/editor"
+import { normalizePromptContent, openEditor } from "../src/editor"
const editor = process.env.EDITOR
const visual = process.env.VISUAL
@@ -21,3 +21,12 @@ test("rejects when the external editor cannot start", async () => {
await expect(openEditor({ value: "original", renderer: renderer as never })).rejects.toThrow()
})
+
+test("normalizes a single trailing editor newline for one-line prompts", () => {
+ expect(normalizePromptContent("hello\n")).toBe("hello")
+ expect(normalizePromptContent("hello\r\n")).toBe("hello")
+})
+
+test("preserves multiline prompts that end with a newline", () => {
+ expect(normalizePromptContent("hello\nworld\n")).toBe("hello\nworld\n")
+})