run: make minimal mode more minimal (#31227)
This commit is contained in:
@@ -181,7 +181,7 @@ function showSubagent(
|
|||||||
callID: string
|
callID: string
|
||||||
label: string
|
label: string
|
||||||
description: string
|
description: string
|
||||||
status: "running" | "completed" | "error"
|
status: "running" | "completed" | "cancelled" | "error"
|
||||||
title?: string
|
title?: string
|
||||||
toolCalls?: number
|
toolCalls?: number
|
||||||
commits: StreamCommit[]
|
commits: StreamCommit[]
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function entryFlags(commit: StreamCommit): EntryFlags {
|
export function entryFlags(commit: StreamCommit): EntryFlags {
|
||||||
|
if (commit.summary) {
|
||||||
|
return {
|
||||||
|
startOnNewLine: true,
|
||||||
|
trailingNewline: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (commit.kind === "user") {
|
if (commit.kind === "user") {
|
||||||
return {
|
return {
|
||||||
startOnNewLine: true,
|
startOnNewLine: true,
|
||||||
@@ -156,6 +163,10 @@ export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolea
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function entryBody(commit: StreamCommit): RunEntryBody {
|
export function entryBody(commit: StreamCommit): RunEntryBody {
|
||||||
|
if (commit.summary) {
|
||||||
|
return RUN_ENTRY_NONE
|
||||||
|
}
|
||||||
|
|
||||||
const raw = cleanRunText(commit.text)
|
const raw = cleanRunText(commit.text)
|
||||||
|
|
||||||
if (commit.kind === "user") {
|
if (commit.kind === "user") {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type PanelEntry = RunFooterMenuItem & {
|
|||||||
|
|
||||||
type CommandEntry =
|
type CommandEntry =
|
||||||
| (PanelEntry & { action: "model" })
|
| (PanelEntry & { action: "model" })
|
||||||
|
| (PanelEntry & { action: "editor" })
|
||||||
|
| (PanelEntry & { action: "skill" })
|
||||||
| (PanelEntry & { action: "queued" })
|
| (PanelEntry & { action: "queued" })
|
||||||
| (PanelEntry & { action: "subagent" })
|
| (PanelEntry & { action: "subagent" })
|
||||||
| (PanelEntry & { action: "variant.cycle" })
|
| (PanelEntry & { action: "variant.cycle" })
|
||||||
@@ -33,6 +35,10 @@ type VariantEntry = PanelEntry & {
|
|||||||
current: boolean
|
current: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SkillEntry = PanelEntry & {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
type SubagentEntry = PanelEntry & {
|
type SubagentEntry = PanelEntry & {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
current: boolean
|
current: boolean
|
||||||
@@ -107,6 +113,10 @@ function subagentStatusLabel(status: FooterSubagentTab["status"]) {
|
|||||||
return "done"
|
return "done"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return "error"
|
return "error"
|
||||||
}
|
}
|
||||||
@@ -203,94 +213,122 @@ function PanelShell(props: {
|
|||||||
inputRef: (input: InputRenderable) => void
|
inputRef: (input: InputRenderable) => void
|
||||||
onQuery: (query: string) => void
|
onQuery: (query: string) => void
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
|
dark?: boolean
|
||||||
|
chrome?: "default" | "minimal"
|
||||||
}) {
|
}) {
|
||||||
return (
|
const background = () => (props.dark ? props.theme().shade : props.theme().surface)
|
||||||
<box id={props.id} width="100%" flexDirection="column" backgroundColor="transparent" flexShrink={0}>
|
const minimal = () => props.chrome === "minimal"
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<box height={1} flexShrink={0} backgroundColor={background()} />
|
||||||
<box
|
<box
|
||||||
width="100%"
|
|
||||||
flexDirection="column"
|
|
||||||
border={["left"]}
|
|
||||||
borderColor={props.theme().highlight}
|
|
||||||
backgroundColor="transparent"
|
|
||||||
customBorderChars={PANEL_BORDER}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
<box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
|
|
||||||
<box
|
|
||||||
width="100%"
|
|
||||||
height={1}
|
|
||||||
paddingLeft={PANEL_PAD}
|
|
||||||
paddingRight={PANEL_PAD}
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
flexShrink={0}
|
|
||||||
backgroundColor={props.theme().surface}
|
|
||||||
>
|
|
||||||
<text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
|
|
||||||
{props.title}
|
|
||||||
</text>
|
|
||||||
{props.countVisible !== false ? (
|
|
||||||
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
|
|
||||||
{countLabel(props.count, props.total, props.query)}
|
|
||||||
</text>
|
|
||||||
) : null}
|
|
||||||
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
|
||||||
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
|
|
||||||
esc
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
|
|
||||||
<box
|
|
||||||
width="100%"
|
|
||||||
height={1}
|
|
||||||
paddingLeft={PANEL_PAD}
|
|
||||||
paddingRight={PANEL_PAD}
|
|
||||||
flexShrink={0}
|
|
||||||
backgroundColor={props.theme().surface}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
width="100%"
|
|
||||||
focusedBackgroundColor={props.theme().surface}
|
|
||||||
focusedTextColor={props.theme().text}
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
placeholderColor={props.theme().muted}
|
|
||||||
cursorColor={props.theme().highlight}
|
|
||||||
onInput={props.onQuery}
|
|
||||||
ref={(input) => {
|
|
||||||
props.inputRef(input)
|
|
||||||
input.traits = { status: "FILTER" }
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (!input.isDestroyed) {
|
|
||||||
input.focus()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
<box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
|
|
||||||
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={props.theme().surface}>
|
|
||||||
{props.children}
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
<box
|
|
||||||
id={`${props.id}-bottom`}
|
|
||||||
width="100%"
|
width="100%"
|
||||||
height={1}
|
height={1}
|
||||||
border={["left"]}
|
paddingLeft={PANEL_PAD}
|
||||||
borderColor={props.theme().highlight}
|
paddingRight={PANEL_PAD}
|
||||||
backgroundColor="transparent"
|
flexDirection="row"
|
||||||
customBorderChars={PANEL_BOTTOM_BORDER}
|
gap={1}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
|
backgroundColor={background()}
|
||||||
>
|
>
|
||||||
<box
|
<text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
|
||||||
|
{props.title}
|
||||||
|
</text>
|
||||||
|
{props.countVisible !== false ? (
|
||||||
|
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
|
||||||
|
{countLabel(props.count, props.total, props.query)}
|
||||||
|
</text>
|
||||||
|
) : null}
|
||||||
|
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||||
|
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
|
||||||
|
esc
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box height={1} flexShrink={0} backgroundColor={background()} />
|
||||||
|
<box
|
||||||
|
width="100%"
|
||||||
|
height={1}
|
||||||
|
paddingLeft={PANEL_PAD}
|
||||||
|
paddingRight={PANEL_PAD}
|
||||||
|
flexShrink={0}
|
||||||
|
backgroundColor={background()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
width="100%"
|
width="100%"
|
||||||
height={1}
|
focusedBackgroundColor={background()}
|
||||||
border={["bottom"]}
|
focusedTextColor={props.theme().text}
|
||||||
borderColor={props.theme().surface}
|
placeholder={props.placeholder}
|
||||||
backgroundColor="transparent"
|
placeholderColor={props.theme().muted}
|
||||||
customBorderChars={HALF_BLOCK_BORDER}
|
cursorColor={props.theme().highlight}
|
||||||
|
onInput={props.onQuery}
|
||||||
|
ref={(input) => {
|
||||||
|
props.inputRef(input)
|
||||||
|
input.traits = { status: "FILTER" }
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!input.isDestroyed) {
|
||||||
|
input.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
<box height={1} flexShrink={0} backgroundColor={background()} />
|
||||||
|
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={background()}>
|
||||||
|
{props.children}
|
||||||
|
</box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<box id={props.id} width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
|
||||||
|
{minimal() ? (
|
||||||
|
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
|
||||||
|
{content}
|
||||||
|
</box>
|
||||||
|
) : (
|
||||||
|
<box
|
||||||
|
width="100%"
|
||||||
|
flexDirection="column"
|
||||||
|
border={["left"]}
|
||||||
|
borderColor={props.theme().highlight}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
customBorderChars={PANEL_BORDER}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
{minimal() ? (
|
||||||
|
<box id={`${props.id}-bottom`} width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
|
||||||
|
<box
|
||||||
|
width="100%"
|
||||||
|
height={1}
|
||||||
|
border={["bottom"]}
|
||||||
|
borderColor={background()}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
customBorderChars={HALF_BLOCK_BORDER}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
) : (
|
||||||
|
<box
|
||||||
|
id={`${props.id}-bottom`}
|
||||||
|
width="100%"
|
||||||
|
height={1}
|
||||||
|
border={["left"]}
|
||||||
|
borderColor={props.theme().highlight}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
customBorderChars={PANEL_BOTTOM_BORDER}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<box
|
||||||
|
width="100%"
|
||||||
|
height={1}
|
||||||
|
border={["bottom"]}
|
||||||
|
borderColor={background()}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
customBorderChars={HALF_BLOCK_BORDER}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -304,6 +342,8 @@ export function RunCommandMenuBody(props: {
|
|||||||
variantCycle: string
|
variantCycle: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onModel: () => void
|
onModel: () => void
|
||||||
|
onEditor: () => void
|
||||||
|
onSkill: () => void
|
||||||
onSubagent: () => void
|
onSubagent: () => void
|
||||||
onQueued: () => void
|
onQueued: () => void
|
||||||
onVariant: () => void
|
onVariant: () => void
|
||||||
@@ -314,58 +354,31 @@ export function RunCommandMenuBody(props: {
|
|||||||
}) {
|
}) {
|
||||||
let field: InputRenderable | undefined
|
let field: InputRenderable | undefined
|
||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal("")
|
||||||
|
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||||
|
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
|
||||||
const entries = createMemo<CommandEntry[]>(() => {
|
const entries = createMemo<CommandEntry[]>(() => {
|
||||||
const builtins = ["new"]
|
const builtins = ["editor", "new"]
|
||||||
return [
|
const session: CommandEntry[] = [
|
||||||
{
|
{
|
||||||
action: "model",
|
action: "editor",
|
||||||
category: "Suggested",
|
category: "Session",
|
||||||
display: "Switch model",
|
display: "Open editor",
|
||||||
|
footer: "/editor",
|
||||||
|
keywords: "editor compose draft external editor",
|
||||||
},
|
},
|
||||||
...(props.queued().length > 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
action: "queued" as const,
|
|
||||||
category: "Suggested",
|
|
||||||
display: "Manage queued prompts",
|
|
||||||
footer: `${props.queued().length} queued`,
|
|
||||||
keywords: props
|
|
||||||
.queued()
|
|
||||||
.map((item) => item.prompt.text)
|
|
||||||
.join(" "),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(props.subagents().length > 0
|
...(props.subagents().length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
action: "subagent" as const,
|
action: "subagent" as const,
|
||||||
category: "Suggested",
|
category: "Session",
|
||||||
display: "View subagents",
|
display: "View subagents",
|
||||||
footer: `${props.subagents().length} active`,
|
footer: activeSubagentCount() > 0 ? `${activeSubagentCount()} active` : `${props.subagents().length} recent`,
|
||||||
keywords: props
|
keywords: props
|
||||||
.subagents()
|
.subagents()
|
||||||
.map((item) => `${item.label} ${item.description} ${item.title ?? ""}`)
|
.map((item) => `${item.label} ${item.description} ${item.title ?? ""}`)
|
||||||
.join(" "),
|
.join(" "),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
|
||||||
{
|
|
||||||
action: "variant.cycle",
|
|
||||||
category: "Suggested",
|
|
||||||
display: "Variant cycle",
|
|
||||||
footer: props.variantCycle,
|
|
||||||
keywords: "variant cycle",
|
|
||||||
},
|
|
||||||
...(props.variants().length > 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
action: "variant.list" as const,
|
|
||||||
category: "Suggested",
|
|
||||||
display: "Switch model variant",
|
|
||||||
keywords: `variant variants ${props.variants().join(" ")}`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
action: "slash",
|
action: "slash",
|
||||||
@@ -375,23 +388,82 @@ export function RunCommandMenuBody(props: {
|
|||||||
footer: "/new",
|
footer: "/new",
|
||||||
keywords: "new session clear",
|
keywords: "new session clear",
|
||||||
},
|
},
|
||||||
...(props.commands() ?? [])
|
]
|
||||||
.filter((item) => item.source !== "skill" && !builtins.includes(item.name))
|
const prompt: CommandEntry[] =
|
||||||
.map(
|
props.commands() === undefined || skills().length > 0
|
||||||
(item) =>
|
? [
|
||||||
({
|
{
|
||||||
action: "slash",
|
action: "skill" as const,
|
||||||
category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
|
category: "Prompt",
|
||||||
name: item.name,
|
display: "Skills",
|
||||||
display: item.name,
|
footer: "/skills",
|
||||||
footer: `/${item.name}`,
|
keywords: `skill skills ${skills()
|
||||||
keywords:
|
.map((item) => `${item.name} ${item.description ?? ""}`)
|
||||||
item.source === "mcp"
|
.join(" ")}`.trim(),
|
||||||
? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
|
},
|
||||||
: `/${item.name} ${item.name} ${item.description ?? ""}`,
|
]
|
||||||
}) satisfies CommandEntry,
|
: []
|
||||||
)
|
const agent: CommandEntry[] = [
|
||||||
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)),
|
{
|
||||||
|
action: "model",
|
||||||
|
category: "Agent",
|
||||||
|
display: "Switch model",
|
||||||
|
},
|
||||||
|
...(props.queued().length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
action: "queued" as const,
|
||||||
|
category: "Agent",
|
||||||
|
display: "Manage queued prompts",
|
||||||
|
footer: `${props.queued().length} queued`,
|
||||||
|
keywords: props
|
||||||
|
.queued()
|
||||||
|
.map((item) => item.prompt.text)
|
||||||
|
.join(" "),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
action: "variant.cycle",
|
||||||
|
category: "Agent",
|
||||||
|
display: "Variant cycle",
|
||||||
|
footer: props.variantCycle,
|
||||||
|
keywords: "variant cycle",
|
||||||
|
},
|
||||||
|
...(props.variants().length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
action: "variant.list" as const,
|
||||||
|
category: "Agent",
|
||||||
|
display: "Switch model variant",
|
||||||
|
keywords: `variant variants ${props.variants().join(" ")}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
const commands = (props.commands() ?? [])
|
||||||
|
.filter((item) => item.source !== "skill" && !builtins.includes(item.name))
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
({
|
||||||
|
action: "slash",
|
||||||
|
category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
|
||||||
|
name: item.name,
|
||||||
|
display: item.name,
|
||||||
|
footer: `/${item.name}`,
|
||||||
|
keywords:
|
||||||
|
item.source === "mcp"
|
||||||
|
? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
|
||||||
|
: `/${item.name} ${item.name} ${item.description ?? ""}`,
|
||||||
|
}) satisfies CommandEntry,
|
||||||
|
)
|
||||||
|
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display))
|
||||||
|
|
||||||
|
return [
|
||||||
|
...session,
|
||||||
|
...prompt,
|
||||||
|
...agent,
|
||||||
|
...commands,
|
||||||
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
|
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -403,6 +475,16 @@ export function RunCommandMenuBody(props: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.action === "editor") {
|
||||||
|
props.onEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.action === "skill") {
|
||||||
|
props.onSkill()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (item.action === "subagent") {
|
if (item.action === "subagent") {
|
||||||
props.onSubagent()
|
props.onSubagent()
|
||||||
return
|
return
|
||||||
@@ -471,6 +553,8 @@ export function RunCommandMenuBody(props: {
|
|||||||
field = input
|
field = input
|
||||||
}}
|
}}
|
||||||
onQuery={setQuery}
|
onQuery={setQuery}
|
||||||
|
dark
|
||||||
|
chrome="minimal"
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
id="run-direct-footer-command-list"
|
id="run-direct-footer-command-list"
|
||||||
@@ -485,6 +569,8 @@ export function RunCommandMenuBody(props: {
|
|||||||
paddingLeft={PANEL_PAD}
|
paddingLeft={PANEL_PAD}
|
||||||
paddingRight={PANEL_PAD}
|
paddingRight={PANEL_PAD}
|
||||||
grouped={!query().trim()}
|
grouped={!query().trim()}
|
||||||
|
background
|
||||||
|
headerColor={props.theme().muted}
|
||||||
/>
|
/>
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
)
|
)
|
||||||
@@ -566,6 +652,8 @@ export function RunSubagentSelectBody(props: {
|
|||||||
field = input
|
field = input
|
||||||
}}
|
}}
|
||||||
onQuery={setQuery}
|
onQuery={setQuery}
|
||||||
|
dark
|
||||||
|
chrome="minimal"
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
id="run-direct-footer-subagent-list"
|
id="run-direct-footer-subagent-list"
|
||||||
@@ -575,11 +663,12 @@ export function RunSubagentSelectBody(props: {
|
|||||||
offset={menu.offset}
|
offset={menu.offset}
|
||||||
rows={menu.rows}
|
rows={menu.rows}
|
||||||
limit={SUBAGENT_LIST_ROWS}
|
limit={SUBAGENT_LIST_ROWS}
|
||||||
empty="No active subagents"
|
empty="No subagents found"
|
||||||
border={false}
|
border={false}
|
||||||
paddingLeft={PANEL_PAD}
|
paddingLeft={PANEL_PAD}
|
||||||
paddingRight={PANEL_PAD}
|
paddingRight={PANEL_PAD}
|
||||||
grouped={false}
|
grouped={false}
|
||||||
|
background
|
||||||
/>
|
/>
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
)
|
)
|
||||||
@@ -662,6 +751,8 @@ export function RunQueuedPromptSelectBody(props: {
|
|||||||
field = input
|
field = input
|
||||||
}}
|
}}
|
||||||
onQuery={setQuery}
|
onQuery={setQuery}
|
||||||
|
dark
|
||||||
|
chrome="minimal"
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
id="run-direct-footer-queued-list"
|
id="run-direct-footer-queued-list"
|
||||||
@@ -676,6 +767,86 @@ export function RunQueuedPromptSelectBody(props: {
|
|||||||
paddingLeft={PANEL_PAD}
|
paddingLeft={PANEL_PAD}
|
||||||
paddingRight={PANEL_PAD}
|
paddingRight={PANEL_PAD}
|
||||||
grouped={false}
|
grouped={false}
|
||||||
|
background
|
||||||
|
/>
|
||||||
|
</PanelShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunSkillSelectBody(props: {
|
||||||
|
theme: Accessor<RunFooterTheme>
|
||||||
|
commands: Accessor<RunCommand[] | undefined>
|
||||||
|
onClose: () => void
|
||||||
|
onSelect: (name: string) => void
|
||||||
|
}) {
|
||||||
|
let field: InputRenderable | undefined
|
||||||
|
const [query, setQuery] = createSignal("")
|
||||||
|
const entries = createMemo<SkillEntry[]>(() =>
|
||||||
|
(props.commands() ?? [])
|
||||||
|
.filter((item) => item.source === "skill")
|
||||||
|
.map((item) => ({
|
||||||
|
category: "",
|
||||||
|
display: item.name,
|
||||||
|
description: item.description?.replace(/\s+/g, " ").trim() || undefined,
|
||||||
|
keywords: `skill ${item.name} ${item.description ?? ""}`,
|
||||||
|
name: item.name,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.display.localeCompare(b.display)),
|
||||||
|
)
|
||||||
|
const items = createMemo<SkillEntry[]>(() => match(query(), entries()))
|
||||||
|
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
||||||
|
const select = () => {
|
||||||
|
const item = items()[menu.selected()]
|
||||||
|
if (!item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onSelect(item.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
query()
|
||||||
|
menu.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
useKeyboard((event) => {
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelShell
|
||||||
|
id="run-direct-footer-skill-panel"
|
||||||
|
title="Skills"
|
||||||
|
query={query()}
|
||||||
|
count={items().length}
|
||||||
|
total={entries().length}
|
||||||
|
placeholder="Search"
|
||||||
|
theme={props.theme}
|
||||||
|
inputRef={(input) => {
|
||||||
|
field = input
|
||||||
|
}}
|
||||||
|
onQuery={setQuery}
|
||||||
|
dark
|
||||||
|
chrome="minimal"
|
||||||
|
>
|
||||||
|
<RunFooterMenu
|
||||||
|
id="run-direct-footer-skill-list"
|
||||||
|
theme={props.theme}
|
||||||
|
items={items}
|
||||||
|
selected={menu.selected}
|
||||||
|
offset={menu.offset}
|
||||||
|
rows={() => PANEL_LIST_ROWS}
|
||||||
|
limit={PANEL_LIST_ROWS}
|
||||||
|
empty={props.commands() ? "No skills found" : "Skills loading"}
|
||||||
|
border={false}
|
||||||
|
paddingLeft={PANEL_PAD}
|
||||||
|
paddingRight={PANEL_PAD}
|
||||||
|
grouped={false}
|
||||||
|
background
|
||||||
/>
|
/>
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
)
|
)
|
||||||
@@ -759,6 +930,8 @@ export function RunVariantSelectBody(props: {
|
|||||||
field = input
|
field = input
|
||||||
}}
|
}}
|
||||||
onQuery={setQuery}
|
onQuery={setQuery}
|
||||||
|
dark
|
||||||
|
chrome="minimal"
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
id="run-direct-footer-variant-list"
|
id="run-direct-footer-variant-list"
|
||||||
@@ -773,6 +946,7 @@ export function RunVariantSelectBody(props: {
|
|||||||
paddingLeft={PANEL_PAD}
|
paddingLeft={PANEL_PAD}
|
||||||
paddingRight={PANEL_PAD}
|
paddingRight={PANEL_PAD}
|
||||||
grouped={false}
|
grouped={false}
|
||||||
|
background
|
||||||
/>
|
/>
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
)
|
)
|
||||||
@@ -879,6 +1053,8 @@ export function RunModelSelectBody(props: {
|
|||||||
field = input
|
field = input
|
||||||
}}
|
}}
|
||||||
onQuery={setQuery}
|
onQuery={setQuery}
|
||||||
|
dark
|
||||||
|
chrome="minimal"
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
id="run-direct-footer-model-list"
|
id="run-direct-footer-model-list"
|
||||||
@@ -893,6 +1069,8 @@ export function RunModelSelectBody(props: {
|
|||||||
paddingLeft={PANEL_PAD}
|
paddingLeft={PANEL_PAD}
|
||||||
paddingRight={PANEL_PAD}
|
paddingRight={PANEL_PAD}
|
||||||
grouped={!query().trim()}
|
grouped={!query().trim()}
|
||||||
|
background
|
||||||
|
headerColor={props.theme().muted}
|
||||||
/>
|
/>
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @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 { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||||
import { transparent, type RunFooterTheme } from "./theme"
|
import { transparent, type RunFooterTheme } from "./theme"
|
||||||
|
import * as Locale from "@/util/locale"
|
||||||
|
|
||||||
export const FOOTER_MENU_ROWS = 8
|
export const FOOTER_MENU_ROWS = 8
|
||||||
|
|
||||||
@@ -125,7 +127,10 @@ export function RunFooterMenu(props: {
|
|||||||
paddingLeft?: number
|
paddingLeft?: number
|
||||||
paddingRight?: number
|
paddingRight?: number
|
||||||
grouped?: boolean
|
grouped?: boolean
|
||||||
|
background?: boolean
|
||||||
|
headerColor?: ColorInput
|
||||||
}) {
|
}) {
|
||||||
|
const term = useTerminalDimensions()
|
||||||
const limit = () => props.limit ?? FOOTER_MENU_ROWS
|
const limit = () => props.limit ?? FOOTER_MENU_ROWS
|
||||||
const border = () => props.border ?? true
|
const border = () => props.border ?? true
|
||||||
const [groupOffset, setGroupOffset] = createSignal(0)
|
const [groupOffset, setGroupOffset] = createSignal(0)
|
||||||
@@ -203,16 +208,36 @@ export function RunFooterMenu(props: {
|
|||||||
|
|
||||||
return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display)))
|
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 (
|
return (
|
||||||
<box
|
<box
|
||||||
id={props.id ?? "run-direct-footer-menu"}
|
id={props.id ?? "run-direct-footer-menu"}
|
||||||
width="100%"
|
width="100%"
|
||||||
height={props.rows()}
|
height={props.rows()}
|
||||||
backgroundColor={transparent}
|
backgroundColor={props.background ? props.theme().shade : transparent}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
>
|
>
|
||||||
{rows().length === 0 ? (
|
{rows().length === 0 ? (
|
||||||
<box paddingRight={0} flexDirection="row" backgroundColor={transparent}>
|
<box
|
||||||
|
paddingRight={0}
|
||||||
|
flexDirection="row"
|
||||||
|
backgroundColor={props.background ? props.theme().shade : transparent}
|
||||||
|
>
|
||||||
{border() ? (
|
{border() ? (
|
||||||
<text fg={props.theme().border} wrapMode="none">
|
<text fg={props.theme().border} wrapMode="none">
|
||||||
┃
|
┃
|
||||||
@@ -223,7 +248,7 @@ export function RunFooterMenu(props: {
|
|||||||
flexShrink={1}
|
flexShrink={1}
|
||||||
paddingLeft={props.paddingLeft ?? 1}
|
paddingLeft={props.paddingLeft ?? 1}
|
||||||
paddingRight={props.paddingRight ?? 0}
|
paddingRight={props.paddingRight ?? 0}
|
||||||
backgroundColor={props.theme().surface}
|
backgroundColor={props.background ? props.theme().shade : transparent}
|
||||||
>
|
>
|
||||||
<text fg={props.theme().muted} wrapMode="none" truncate>
|
<text fg={props.theme().muted} wrapMode="none" truncate>
|
||||||
{props.empty ?? "No matching items"}
|
{props.empty ?? "No matching items"}
|
||||||
@@ -239,7 +264,7 @@ export function RunFooterMenu(props: {
|
|||||||
if (row.type === "header") {
|
if (row.type === "header") {
|
||||||
return (
|
return (
|
||||||
<box paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 1}>
|
<box paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 1}>
|
||||||
<text fg={props.theme().highlight} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
|
<text fg={props.headerColor ?? props.theme().highlight} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
|
||||||
{row.label}
|
{row.label}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -247,54 +272,75 @@ export function RunFooterMenu(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const active = () => row.index === props.selected()
|
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 (
|
return (
|
||||||
<box paddingRight={0} flexDirection="row" backgroundColor={transparent}>
|
<box
|
||||||
|
paddingRight={0}
|
||||||
|
flexDirection="row"
|
||||||
|
backgroundColor={background()}
|
||||||
|
>
|
||||||
{border() ? (
|
{border() ? (
|
||||||
<text fg={active() ? props.theme().highlight : props.theme().border} wrapMode="none">
|
<text fg={props.theme().highlight} bg={background()} wrapMode="none">
|
||||||
┃
|
{active() ? "▌" : " "}
|
||||||
</text>
|
</text>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<box
|
<box
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
flexShrink={1}
|
flexShrink={1}
|
||||||
paddingLeft={inset()}
|
paddingLeft={props.paddingLeft ?? 1}
|
||||||
paddingRight={inset()}
|
paddingRight={props.paddingRight ?? 0}
|
||||||
backgroundColor={props.theme().surface}
|
backgroundColor={background()}
|
||||||
>
|
>
|
||||||
<box
|
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
|
||||||
flexGrow={1}
|
<box flexDirection="row" gap={0} flexGrow={1} flexShrink={1}>
|
||||||
flexShrink={1}
|
|
||||||
paddingLeft={Math.max(0, (props.paddingLeft ?? 1) - inset())}
|
|
||||||
paddingRight={Math.max(0, (props.paddingRight ?? 0) - inset())}
|
|
||||||
backgroundColor={active() ? props.theme().highlight : props.theme().surface}
|
|
||||||
>
|
|
||||||
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
|
|
||||||
<text
|
<text
|
||||||
fg={active() ? props.theme().surface : props.theme().text}
|
fg={active() ? props.theme().selectedText : props.theme().text}
|
||||||
|
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
truncate
|
truncate
|
||||||
flexGrow={1}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
{row.item.display}
|
{row.item.display}
|
||||||
{row.item.description ? (
|
|
||||||
<span style={{ fg: active() ? props.theme().surface : props.theme().muted }}>
|
|
||||||
{descriptionPad(row.item)}
|
|
||||||
{row.item.description}
|
|
||||||
</span>
|
|
||||||
) : undefined}
|
|
||||||
</text>
|
</text>
|
||||||
{row.item.footer ? (
|
{row.item.description ? (
|
||||||
<text
|
<>
|
||||||
fg={active() ? props.theme().surface : props.theme().muted}
|
<text
|
||||||
wrapMode="none"
|
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||||
truncate
|
wrapMode="none"
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
{row.item.footer}
|
{descriptionPad(row.item)}
|
||||||
</text>
|
</text>
|
||||||
|
<text
|
||||||
|
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
flexGrow={1}
|
||||||
|
flexShrink={1}
|
||||||
|
>
|
||||||
|
{descriptionText(row.item)}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</box>
|
</box>
|
||||||
|
{row.item.footer ? (
|
||||||
|
<text
|
||||||
|
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||||
|
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
{row.item.footer}
|
||||||
|
</text>
|
||||||
|
) : undefined}
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
permissionShift,
|
permissionShift,
|
||||||
type PermissionOption,
|
type PermissionOption,
|
||||||
} from "./permission.shared"
|
} from "./permission.shared"
|
||||||
|
import { footerWidthPolicy } from "./footer.width"
|
||||||
import { toolFiletype } from "./tool"
|
import { toolFiletype } from "./tool"
|
||||||
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
||||||
import type { PermissionReply, RunDiffStyle } from "./types"
|
import type { PermissionReply, RunDiffStyle } from "./types"
|
||||||
@@ -140,7 +141,7 @@ export function RunPermissionBody(props: {
|
|||||||
const [state, setState] = createSignal(createPermissionBodyState(props.request.id))
|
const [state, setState] = createSignal(createPermissionBodyState(props.request.id))
|
||||||
const info = createMemo(() => permissionInfo(props.request))
|
const info = createMemo(() => permissionInfo(props.request))
|
||||||
const ft = createMemo(() => toolFiletype(info().file))
|
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 opts = createMemo(() => permissionOptions(state().stage))
|
||||||
const busy = createMemo(() => state().submitting)
|
const busy = createMemo(() => state().submitting)
|
||||||
const title = createMemo(() => {
|
const title = createMemo(() => {
|
||||||
@@ -257,7 +258,13 @@ export function RunPermissionBody(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box id="run-direct-footer-permission-body" width="100%" height="100%" flexDirection="column">
|
<box
|
||||||
|
id="run-direct-footer-permission-body"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
flexDirection="column"
|
||||||
|
backgroundColor={props.theme.surface}
|
||||||
|
>
|
||||||
<box
|
<box
|
||||||
id="run-direct-footer-permission-head"
|
id="run-direct-footer-permission-head"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
// Prompt textarea component and its state machine for direct interactive mode.
|
// Prompt composer and its state machine for direct interactive mode.
|
||||||
//
|
//
|
||||||
// createPromptState() wires keymap command layers, history navigation, and
|
// createPromptState() wires keymap command layers, history navigation, and
|
||||||
// `@` autocomplete for files, subagents, and MCP resources.
|
// `@` autocomplete for files, subagents, and MCP resources.
|
||||||
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
|
// It produces a PromptState that RunPromptBody renders as a slim single-line
|
||||||
// while the footer view renders the current menu state below it.
|
// composer while the footer view renders any active menus below it.
|
||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { pathToFileURL } from "bun"
|
import { pathToFileURL } from "bun"
|
||||||
import { StyledText, bg, fg, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||||
import { useRenderer } from "@opentui/solid"
|
import { useRenderer } from "@opentui/solid"
|
||||||
|
import { normalizePromptContent } from "@opencode-ai/tui/editor"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
pushPromptHistory,
|
pushPromptHistory,
|
||||||
} from "./prompt.shared"
|
} from "./prompt.shared"
|
||||||
import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
|
import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
|
||||||
|
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
||||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||||
import type { RunFooterTheme } from "./theme"
|
import type { RunFooterTheme } from "./theme"
|
||||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"
|
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"
|
||||||
@@ -34,13 +36,6 @@ export const TEXTAREA_MIN_ROWS = 1
|
|||||||
export const TEXTAREA_MAX_ROWS = 6
|
export const TEXTAREA_MAX_ROWS = 6
|
||||||
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
|
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
|
||||||
|
|
||||||
export const HINT_BREAKPOINTS = {
|
|
||||||
send: 50,
|
|
||||||
newline: 66,
|
|
||||||
history: 80,
|
|
||||||
command: 95,
|
|
||||||
}
|
|
||||||
|
|
||||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
||||||
|
|
||||||
type Auto = RunFooterMenuItem & {
|
type Auto = RunFooterMenuItem & {
|
||||||
@@ -53,6 +48,7 @@ type Auto = RunFooterMenuItem & {
|
|||||||
type SlashOption = RunFooterMenuItem & {
|
type SlashOption = RunFooterMenuItem & {
|
||||||
kind: "slash"
|
kind: "slash"
|
||||||
name: string
|
name: string
|
||||||
|
action?: "skill-menu" | "editor"
|
||||||
}
|
}
|
||||||
|
|
||||||
type PromptOption = Auto | SlashOption
|
type PromptOption = Auto | SlashOption
|
||||||
@@ -75,9 +71,11 @@ type PromptInput = {
|
|||||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||||
onCycle: () => void
|
onCycle: () => void
|
||||||
onInterrupt: () => boolean
|
onInterrupt: () => boolean
|
||||||
|
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||||
onInputClear: () => void
|
onInputClear: () => void
|
||||||
onExitRequest?: () => boolean
|
onExitRequest?: () => boolean
|
||||||
onExit: () => void
|
onExit: () => void
|
||||||
|
onSkillMenu: () => void
|
||||||
onRows: (rows: number) => void
|
onRows: (rows: number) => void
|
||||||
onStatus: (text: string) => void
|
onStatus: (text: string) => void
|
||||||
}
|
}
|
||||||
@@ -93,6 +91,7 @@ export type PromptState = {
|
|||||||
requestExit: () => boolean
|
requestExit: () => boolean
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
submitText: (text: string) => void
|
submitText: (text: string) => void
|
||||||
|
openEditor: (input?: { value?: string }) => Promise<void>
|
||||||
onKeyDown: (event: KeyEvent) => void
|
onKeyDown: (event: KeyEvent) => void
|
||||||
onContentChange: () => void
|
onContentChange: () => void
|
||||||
replaceDraft: (text: string) => void
|
replaceDraft: (text: string) => void
|
||||||
@@ -109,6 +108,7 @@ function clonePrompt(prompt: RunPrompt): RunPrompt {
|
|||||||
text: prompt.text,
|
text: prompt.text,
|
||||||
parts: structuredClone(prompt.parts),
|
parts: structuredClone(prompt.parts),
|
||||||
...(prompt.mode ? { mode: prompt.mode } : {}),
|
...(prompt.mode ? { mode: prompt.mode } : {}),
|
||||||
|
...(prompt.command ? { command: prompt.command } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,17 +182,25 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
|
|||||||
return { type: "command" as const, command: { name: head.name, arguments: head.arguments } }
|
return { type: "command" as const, command: { name: head.name, arguments: head.arguments } }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hintFlags(width: number) {
|
function selectedCommand(text: string, command: RunPrompt["command"]) {
|
||||||
|
if (!command) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = slashHead(text)
|
||||||
|
if (!head || head.name !== command.name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
send: width >= HINT_BREAKPOINTS.send,
|
name: command.name,
|
||||||
newline: width >= HINT_BREAKPOINTS.newline,
|
arguments: head.arguments,
|
||||||
history: width >= HINT_BREAKPOINTS.history,
|
|
||||||
command: width >= HINT_BREAKPOINTS.command,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RunPromptBody(props: {
|
export function RunPromptBody(props: {
|
||||||
theme: () => RunFooterTheme
|
theme: () => RunFooterTheme
|
||||||
|
background: () => ColorInput
|
||||||
placeholder: () => StyledText | string
|
placeholder: () => StyledText | string
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
onKeyDown: (event: KeyEvent) => void
|
onKeyDown: (event: KeyEvent) => void
|
||||||
@@ -226,7 +234,7 @@ export function RunPromptBody(props: {
|
|||||||
|
|
||||||
props.onContentChange()
|
props.onContentChange()
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => { })
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +251,7 @@ export function RunPromptBody(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box id="run-direct-footer-prompt" width="100%">
|
<box id="run-direct-footer-prompt" width="100%">
|
||||||
<box id="run-direct-footer-input-shell" paddingTop={1} paddingLeft={2} paddingRight={2}>
|
<box id="run-direct-footer-input-shell" paddingTop={1} paddingBottom={1} paddingRight={2}>
|
||||||
<textarea
|
<textarea
|
||||||
id="run-direct-footer-composer"
|
id="run-direct-footer-composer"
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -254,8 +262,8 @@ export function RunPromptBody(props: {
|
|||||||
placeholderColor={props.theme().muted}
|
placeholderColor={props.theme().muted}
|
||||||
textColor={props.theme().text}
|
textColor={props.theme().text}
|
||||||
focusedTextColor={props.theme().text}
|
focusedTextColor={props.theme().text}
|
||||||
backgroundColor={props.theme().surface}
|
backgroundColor={props.background()}
|
||||||
focusedBackgroundColor={props.theme().surface}
|
focusedBackgroundColor={props.background()}
|
||||||
cursorColor={props.theme().text}
|
cursorColor={props.theme().text}
|
||||||
onSubmit={props.onSubmit}
|
onSubmit={props.onSubmit}
|
||||||
onKeyDown={props.onKeyDown}
|
onKeyDown={props.onKeyDown}
|
||||||
@@ -276,16 +284,14 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
const [shell, setShell] = createSignal(false)
|
const [shell, setShell] = createSignal(false)
|
||||||
const placeholder = createMemo(() => {
|
const placeholder = createMemo(() => {
|
||||||
if (shell()) {
|
if (shell()) {
|
||||||
return new StyledText([bg(input.theme().surface)(fg(input.theme().muted)('Run a command... "git status"'))])
|
return new StyledText([fg(input.theme().muted)('Run a command... "git status"')])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!input.state().first) {
|
if (!input.state().first) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return new StyledText([
|
return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
|
||||||
bg(input.theme().surface)(fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')),
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let history = createPromptHistory(input.history)
|
let history = createPromptHistory(input.history)
|
||||||
@@ -412,13 +418,40 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
{ initialValue: [] as Auto[] },
|
{ initialValue: [] as Auto[] },
|
||||||
)
|
)
|
||||||
const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()])
|
const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()])
|
||||||
|
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
|
||||||
|
const hasSkillsCommand = createMemo(() =>
|
||||||
|
(input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
|
||||||
|
)
|
||||||
const slashOptions = createMemo<SlashOption[]>(() => {
|
const slashOptions = createMemo<SlashOption[]>(() => {
|
||||||
const builtins = [
|
const builtins = [
|
||||||
|
{
|
||||||
|
kind: "slash",
|
||||||
|
action: "editor" as const,
|
||||||
|
name: "editor",
|
||||||
|
display: "/editor",
|
||||||
|
description: "compose in your external editor",
|
||||||
|
} satisfies SlashOption,
|
||||||
{ kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption,
|
{ kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption,
|
||||||
{ kind: "slash", name: "exit", display: "/exit", description: "close direct mode" } satisfies SlashOption,
|
{ kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
|
||||||
]
|
]
|
||||||
const hidden = new Set(builtins.map((item) => item.name))
|
const hidden = new Set(builtins.map((item) => item.name))
|
||||||
|
const showSkillMenu = !shell() && skillCommands().length > 0 && !hasSkillsCommand()
|
||||||
|
if (showSkillMenu) {
|
||||||
|
hidden.add("skills")
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
...(showSkillMenu
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
kind: "slash",
|
||||||
|
action: "skill-menu" as const,
|
||||||
|
name: "skills",
|
||||||
|
display: "/skills",
|
||||||
|
description: "browse available skills",
|
||||||
|
} satisfies SlashOption,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(input.commands() ?? [])
|
...(input.commands() ?? [])
|
||||||
.filter((item) => item.source !== "skill" && !hidden.has(item.name))
|
.filter((item) => item.source !== "skill" && !hidden.has(item.name))
|
||||||
.map(
|
.map(
|
||||||
@@ -461,7 +494,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input.onRows(clamp(area.virtualLineCount || 1) + popup())
|
input.onRows(clamp(Math.max(area.lineCount, area.virtualLineCount)) + popup())
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleRows = () => {
|
const scheduleRows = () => {
|
||||||
@@ -695,6 +728,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncParts()
|
syncParts()
|
||||||
|
const command = shell() ? undefined : selectedCommand(area.plainText, draft.command)
|
||||||
draft = shell()
|
draft = shell()
|
||||||
? {
|
? {
|
||||||
text: area.plainText,
|
text: area.plainText,
|
||||||
@@ -704,6 +738,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
: {
|
: {
|
||||||
text: area.plainText,
|
text: area.plainText,
|
||||||
parts: structuredClone(parts),
|
parts: structuredClone(parts),
|
||||||
|
...(command ? { command } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,6 +818,33 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
area.focus()
|
area.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openEditor = async (inputValue?: { value?: string }) => {
|
||||||
|
input.onInputClear()
|
||||||
|
syncDraft()
|
||||||
|
hide()
|
||||||
|
|
||||||
|
const current = clonePrompt(draft)
|
||||||
|
try {
|
||||||
|
const content = await input.onEditorOpen({
|
||||||
|
value: inputValue?.value ?? current.text,
|
||||||
|
})
|
||||||
|
if (content === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalized = normalizePromptContent(content)
|
||||||
|
|
||||||
|
restore({
|
||||||
|
text: normalized,
|
||||||
|
parts: realignEditorPromptParts(normalized, current.parts),
|
||||||
|
...(current.mode ? { mode: current.mode } : {}),
|
||||||
|
...(current.command ? { command: current.command } : {}),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
restore(current)
|
||||||
|
input.onStatus("failed to open editor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const select = (item?: PromptOption) => {
|
const select = (item?: PromptOption) => {
|
||||||
const next = item ?? options()[menu.selected()]
|
const next = item ?? options()[menu.selected()]
|
||||||
if (!next || !area || area.isDestroyed) {
|
if (!next || !area || area.isDestroyed) {
|
||||||
@@ -790,6 +852,19 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (next.kind === "slash") {
|
if (next.kind === "slash") {
|
||||||
|
if (next.action === "editor") {
|
||||||
|
void openEditor({
|
||||||
|
value: resolveEditorSlashValue(area.plainText),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.action === "skill-menu") {
|
||||||
|
cancelAutocomplete()
|
||||||
|
input.onSkillMenu()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const cursor = area.cursorOffset
|
const cursor = area.cursorOffset
|
||||||
const head = slashHead(area.plainText)
|
const head = slashHead(area.plainText)
|
||||||
const local = !shell() && (next.name === "new" || next.name === "exit")
|
const local = !shell() && (next.name === "new" || next.name === "exit")
|
||||||
@@ -898,6 +973,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
const baseBindingsEnabled = () => {
|
const baseBindingsEnabled = () => {
|
||||||
const current = input.view()
|
const current = input.view()
|
||||||
if (current === "command") return false
|
if (current === "command") return false
|
||||||
|
if (current === "skill") return false
|
||||||
if (current === "model") return false
|
if (current === "model") return false
|
||||||
if (current === "variant") return false
|
if (current === "variant") return false
|
||||||
if (current === "queued-menu") return false
|
if (current === "queued-menu") return false
|
||||||
@@ -939,6 +1015,22 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
bindings: input.tuiConfig.keybinds.get("session.interrupt"),
|
bindings: input.tuiConfig.keybinds.get("session.interrupt"),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
|
enabled: input.prompt() && !visible(),
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: "prompt.editor",
|
||||||
|
title: "Open editor",
|
||||||
|
category: "Prompt",
|
||||||
|
run() {
|
||||||
|
void openEditor()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bindings: input.tuiConfig.keybinds.get("prompt.editor"),
|
||||||
|
}))
|
||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
mode: OPENCODE_BASE_MODE,
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: input.prompt() && !visible(),
|
enabled: input.prompt() && !visible(),
|
||||||
@@ -1068,7 +1160,11 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
]),
|
]),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const onKeyDown = (_event: KeyEvent) => {}
|
const onKeyDown = (event: KeyEvent) => {
|
||||||
|
if (input.state().phase === "idle" && event.name.toLowerCase() === "escape") {
|
||||||
|
input.onInputClear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const submitPrompt = (next: RunPrompt) => {
|
const submitPrompt = (next: RunPrompt) => {
|
||||||
if (!area || area.isDestroyed) {
|
if (!area || area.isDestroyed) {
|
||||||
@@ -1089,19 +1185,22 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.mode !== "shell" && isExitCommand(next.text)) {
|
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
|
||||||
|
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
|
||||||
input.onExit()
|
input.onExit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed =
|
const parsed =
|
||||||
next.mode === "shell" || isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands())
|
command || next.mode === "shell" || isNewCommand(next.text)
|
||||||
|
? undefined
|
||||||
|
: parseSlashCommand(next.text, input.commands())
|
||||||
if (parsed?.type === "pending") {
|
if (parsed?.type === "pending") {
|
||||||
input.onStatus("loading commands")
|
input.onStatus("loading commands")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = parsed?.type === "command" ? { ...next, command: parsed.command } : next
|
const submit = command ? { ...next, command } : parsed?.type === "command" ? { ...next, command: parsed.command } : next
|
||||||
const shellMode = next.mode === "shell"
|
const shellMode = next.mode === "shell"
|
||||||
|
|
||||||
resetDraft()
|
resetDraft()
|
||||||
@@ -1194,8 +1293,10 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
requestExit,
|
requestExit,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitText,
|
submitText,
|
||||||
|
openEditor,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onContentChange: () => {
|
onContentChange: () => {
|
||||||
|
input.onInputClear()
|
||||||
syncDraft()
|
syncDraft()
|
||||||
refresh()
|
refresh()
|
||||||
scheduleRows()
|
scheduleRows()
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
questionTabs,
|
questionTabs,
|
||||||
questionTotal,
|
questionTotal,
|
||||||
} from "./question.shared"
|
} from "./question.shared"
|
||||||
|
import { footerWidthPolicy } from "./footer.width"
|
||||||
import type { RunFooterTheme } from "./theme"
|
import type { RunFooterTheme } from "./theme"
|
||||||
import type { QuestionReject, QuestionReply } from "./types"
|
import type { QuestionReject, QuestionReply } from "./types"
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ export function RunQuestionBody(props: {
|
|||||||
const other = createMemo(() => questionOther(props.request, state()))
|
const other = createMemo(() => questionOther(props.request, state()))
|
||||||
const picked = createMemo(() => questionPicked(state()))
|
const picked = createMemo(() => questionPicked(state()))
|
||||||
const disabled = createMemo(() => state().submitting)
|
const disabled = createMemo(() => state().submitting)
|
||||||
const narrow = createMemo(() => dims().width < 80)
|
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
||||||
const verb = createMemo(() => {
|
const verb = createMemo(() => {
|
||||||
if (confirm()) {
|
if (confirm()) {
|
||||||
return "submit"
|
return "submit"
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"])
|
|||||||
return theme.highlight
|
return theme.highlight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return theme.muted
|
||||||
|
}
|
||||||
|
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return theme.error
|
return theme.error
|
||||||
}
|
}
|
||||||
@@ -27,6 +31,10 @@ function statusIcon(status: FooterSubagentTab["status"]) {
|
|||||||
return "●"
|
return "●"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return "○"
|
||||||
|
}
|
||||||
|
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return "◍"
|
return "◍"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import type { Keymap } from "@opentui/keymap"
|
|||||||
import { render } from "@opentui/solid"
|
import { render } from "@opentui/solid"
|
||||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { OpencodeKeymapProvider, formatKeyBindings } from "@opencode-ai/tui/keymap"
|
import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap"
|
||||||
import { withRunSpan } from "./otel"
|
import { withRunSpan } from "./otel"
|
||||||
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
||||||
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
||||||
@@ -37,6 +37,7 @@ import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
|||||||
import { RunFooterView } from "./footer.view"
|
import { RunFooterView } from "./footer.view"
|
||||||
import { RunScrollbackStream } from "./scrollback.surface"
|
import { RunScrollbackStream } from "./scrollback.surface"
|
||||||
import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
|
import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
|
||||||
|
import { modelInfo } from "./variant.shared"
|
||||||
import type {
|
import type {
|
||||||
FooterApi,
|
FooterApi,
|
||||||
FooterEvent,
|
FooterEvent,
|
||||||
@@ -94,6 +95,7 @@ type RunFooterOptions = {
|
|||||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||||
onInterrupt?: () => void
|
onInterrupt?: () => void
|
||||||
onBackground?: () => void
|
onBackground?: () => void
|
||||||
|
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||||
onExit?: () => void
|
onExit?: () => void
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||||
treeSitterClient?: TreeSitterClient
|
treeSitterClient?: TreeSitterClient
|
||||||
@@ -102,10 +104,11 @@ type RunFooterOptions = {
|
|||||||
const PERMISSION_ROWS = 12
|
const PERMISSION_ROWS = 12
|
||||||
const QUESTION_ROWS = 14
|
const QUESTION_ROWS = 14
|
||||||
const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
|
const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||||
|
const SKILL_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||||
const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
|
const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
|
||||||
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
|
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||||
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
|
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||||
const AUTOCOMPLETE_COMPACT_ROWS = 2
|
const NOTICE_DURATION = 3000
|
||||||
const THEME_REFRESH_DELAYS = [1000, 1000] as const
|
const THEME_REFRESH_DELAYS = [1000, 1000] as const
|
||||||
|
|
||||||
function createEmptySubagentState(): FooterSubagentState {
|
function createEmptySubagentState(): FooterSubagentState {
|
||||||
@@ -135,6 +138,8 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
|||||||
phase: "running",
|
phase: "running",
|
||||||
status: "sending prompt",
|
status: "sending prompt",
|
||||||
queue: next.queue,
|
queue: next.queue,
|
||||||
|
interrupt: 0,
|
||||||
|
exit: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,10 +158,6 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.type === "turn.duration") {
|
|
||||||
return { duration: next.duration }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next.type === "stream.patch") {
|
if (next.type === "stream.patch") {
|
||||||
return next.patch
|
return next.patch
|
||||||
}
|
}
|
||||||
@@ -207,6 +208,9 @@ export class RunFooter implements FooterApi {
|
|||||||
private autocomplete = false
|
private autocomplete = false
|
||||||
private interruptTimeout: NodeJS.Timeout | undefined
|
private interruptTimeout: NodeJS.Timeout | undefined
|
||||||
private exitTimeout: NodeJS.Timeout | undefined
|
private exitTimeout: NodeJS.Timeout | undefined
|
||||||
|
private noticeTimeout: NodeJS.Timeout | undefined
|
||||||
|
private noticeRestoreStatus = ""
|
||||||
|
private statusVersion = 0
|
||||||
private requestExitHandler: (() => boolean) | undefined
|
private requestExitHandler: (() => boolean) | undefined
|
||||||
private scrollback: RunScrollbackStream
|
private scrollback: RunScrollbackStream
|
||||||
private themes: RunTheme[]
|
private themes: RunTheme[]
|
||||||
@@ -327,6 +331,7 @@ export class RunFooter implements FooterApi {
|
|||||||
onCycle: footer.handleCycle,
|
onCycle: footer.handleCycle,
|
||||||
onInterrupt: footer.handleInterrupt,
|
onInterrupt: footer.handleInterrupt,
|
||||||
onBackground: options.onBackground,
|
onBackground: options.onBackground,
|
||||||
|
onEditorOpen: options.onEditorOpen,
|
||||||
onInputClear: footer.handleInputClear,
|
onInputClear: footer.handleInputClear,
|
||||||
onExitRequest: footer.handleExit,
|
onExitRequest: footer.handleExit,
|
||||||
onRequestExit: footer.setRequestExitHandler,
|
onRequestExit: footer.setRequestExitHandler,
|
||||||
@@ -384,6 +389,23 @@ export class RunFooter implements FooterApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public event(next: FooterEvent): void {
|
public event(next: FooterEvent): void {
|
||||||
|
if (next.type === "turn.duration") {
|
||||||
|
const current = this.currentModel()
|
||||||
|
this.flush()
|
||||||
|
this.flushing = this.flushing
|
||||||
|
.then(() =>
|
||||||
|
this.scrollback.writeTurnSummary({
|
||||||
|
agent: this.options.agentLabel,
|
||||||
|
model: current ? modelInfo(this.providers(), current).model : this.state().model,
|
||||||
|
duration: next.duration,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
this.flushError = error
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (next.type === "catalog") {
|
if (next.type === "catalog") {
|
||||||
if (this.isGone) {
|
if (this.isGone) {
|
||||||
return
|
return
|
||||||
@@ -427,6 +449,13 @@ export class RunFooter implements FooterApi {
|
|||||||
|
|
||||||
const patch = eventPatch(next)
|
const patch = eventPatch(next)
|
||||||
if (patch) {
|
if (patch) {
|
||||||
|
if (typeof patch.status === "string") {
|
||||||
|
this.clearNoticeTimer()
|
||||||
|
}
|
||||||
|
if (next.type === "turn.send") {
|
||||||
|
this.clearInterruptTimer()
|
||||||
|
this.clearExitTimer()
|
||||||
|
}
|
||||||
this.patch(patch)
|
this.patch(patch)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -452,6 +481,9 @@ export class RunFooter implements FooterApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prev = this.state()
|
const prev = this.state()
|
||||||
|
if (typeof next.status === "string") {
|
||||||
|
this.statusVersion++
|
||||||
|
}
|
||||||
const state = {
|
const state = {
|
||||||
phase: next.phase ?? prev.phase,
|
phase: next.phase ?? prev.phase,
|
||||||
status: typeof next.status === "string" ? next.status : prev.status,
|
status: typeof next.status === "string" ? next.status : prev.status,
|
||||||
@@ -626,7 +658,31 @@ export class RunFooter implements FooterApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setStatus = (status: string): void => {
|
private setStatus = (status: string): void => {
|
||||||
|
this.setNotice(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setNotice(status: string): void {
|
||||||
|
const restore = this.noticeTimeout ? this.noticeRestoreStatus : this.state().status
|
||||||
|
this.clearNoticeTimer(false)
|
||||||
this.patch({ status })
|
this.patch({ status })
|
||||||
|
if (!status) {
|
||||||
|
this.noticeRestoreStatus = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.noticeRestoreStatus = restore
|
||||||
|
const version = this.statusVersion
|
||||||
|
this.noticeTimeout = setTimeout(() => {
|
||||||
|
this.noticeTimeout = undefined
|
||||||
|
if (this.isGone || version !== this.statusVersion) {
|
||||||
|
this.noticeRestoreStatus = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = this.noticeRestoreStatus
|
||||||
|
this.noticeRestoreStatus = ""
|
||||||
|
this.patch({ status: next })
|
||||||
|
}, NOTICE_DURATION)
|
||||||
}
|
}
|
||||||
|
|
||||||
private setRequestExitHandler = (fn?: () => boolean): void => {
|
private setRequestExitHandler = (fn?: () => boolean): void => {
|
||||||
@@ -652,8 +708,6 @@ export class RunFooter implements FooterApi {
|
|||||||
// get fixed extra rows; the prompt view scales with textarea line count.
|
// get fixed extra rows; the prompt view scales with textarea line count.
|
||||||
private applyHeight(): void {
|
private applyHeight(): void {
|
||||||
const type = this.view().type
|
const type = this.view().type
|
||||||
const compact = this.promptRoute.type === "composer" && this.autocomplete ? AUTOCOMPLETE_COMPACT_ROWS : 0
|
|
||||||
const base = this.base - compact
|
|
||||||
const height =
|
const height =
|
||||||
type === "permission"
|
type === "permission"
|
||||||
? this.base + PERMISSION_ROWS
|
? this.base + PERMISSION_ROWS
|
||||||
@@ -661,9 +715,11 @@ export class RunFooter implements FooterApi {
|
|||||||
? this.base + QUESTION_ROWS
|
? this.base + QUESTION_ROWS
|
||||||
: this.promptRoute.type === "command"
|
: this.promptRoute.type === "command"
|
||||||
? 1 + COMMAND_ROWS
|
? 1 + COMMAND_ROWS
|
||||||
: this.promptRoute.type === "model"
|
: this.promptRoute.type === "skill"
|
||||||
? 1 + MODEL_ROWS
|
? 1 + SKILL_ROWS
|
||||||
: this.promptRoute.type === "variant"
|
: this.promptRoute.type === "model"
|
||||||
|
? 1 + MODEL_ROWS
|
||||||
|
: this.promptRoute.type === "variant"
|
||||||
? 1 + VARIANT_ROWS
|
? 1 + VARIANT_ROWS
|
||||||
: this.promptRoute.type === "queued-menu"
|
: this.promptRoute.type === "queued-menu"
|
||||||
? 1 + this.subagentMenuRows
|
? 1 + this.subagentMenuRows
|
||||||
@@ -671,7 +727,7 @@ export class RunFooter implements FooterApi {
|
|||||||
? 1 + this.subagentMenuRows
|
? 1 + this.subagentMenuRows
|
||||||
: this.promptRoute.type === "subagent"
|
: this.promptRoute.type === "subagent"
|
||||||
? this.base + SUBAGENT_INSPECTOR_ROWS
|
? this.base + SUBAGENT_INSPECTOR_ROWS
|
||||||
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
|
: this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
|
||||||
|
|
||||||
if (height !== this.renderer.footerHeight) {
|
if (height !== this.renderer.footerHeight) {
|
||||||
this.renderer.footerHeight = height
|
this.renderer.footerHeight = height
|
||||||
@@ -713,7 +769,7 @@ export class RunFooter implements FooterApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.prompts.size === 0) {
|
if (this.prompts.size === 0) {
|
||||||
this.patch({ status: "input queue unavailable" })
|
this.setNotice("input queue unavailable")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,13 +807,11 @@ export class RunFooter implements FooterApi {
|
|||||||
private handleCycle = (): void => {
|
private handleCycle = (): void => {
|
||||||
const result = this.options.onCycleVariant?.()
|
const result = this.options.onCycleVariant?.()
|
||||||
if (!result) {
|
if (!result) {
|
||||||
this.patch({ status: "no variants available" })
|
this.setNotice("no variants available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const patch: FooterPatch = {
|
const patch: FooterPatch = {}
|
||||||
status: result.status ?? "variant updated",
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("variants" in result) {
|
if ("variants" in result) {
|
||||||
this.setVariants(result.variants ?? [])
|
this.setVariants(result.variants ?? [])
|
||||||
@@ -772,6 +826,7 @@ export class RunFooter implements FooterApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.patch(patch)
|
this.patch(patch)
|
||||||
|
this.setNotice(result.status ?? "variant updated")
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleModelSelect = (model: NonNullable<RunInput["model"]>): void => {
|
private handleModelSelect = (model: NonNullable<RunInput["model"]>): void => {
|
||||||
@@ -811,13 +866,12 @@ export class RunFooter implements FooterApi {
|
|||||||
patch.model = result.modelLabel
|
patch.model = result.modelLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status) {
|
if (patch.model) {
|
||||||
patch.status = result.status
|
|
||||||
}
|
|
||||||
|
|
||||||
if (patch.model || patch.status) {
|
|
||||||
this.patch(patch)
|
this.patch(patch)
|
||||||
}
|
}
|
||||||
|
if (result.status) {
|
||||||
|
this.setNotice(result.status)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -853,13 +907,12 @@ export class RunFooter implements FooterApi {
|
|||||||
patch.model = result.modelLabel
|
patch.model = result.modelLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status) {
|
if (patch.model) {
|
||||||
patch.status = result.status
|
|
||||||
}
|
|
||||||
|
|
||||||
if (patch.model || patch.status) {
|
|
||||||
this.patch(patch)
|
this.patch(patch)
|
||||||
}
|
}
|
||||||
|
if (result.status) {
|
||||||
|
this.setNotice(result.status)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -873,6 +926,21 @@ export class RunFooter implements FooterApi {
|
|||||||
this.interruptTimeout = undefined
|
this.interruptTimeout = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearNoticeTimer(reset = true): void {
|
||||||
|
if (!this.noticeTimeout) {
|
||||||
|
if (reset) {
|
||||||
|
this.noticeRestoreStatus = ""
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(this.noticeTimeout)
|
||||||
|
this.noticeTimeout = undefined
|
||||||
|
if (reset) {
|
||||||
|
this.noticeRestoreStatus = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private armInterruptTimer(): void {
|
private armInterruptTimer(): void {
|
||||||
this.clearInterruptTimer()
|
this.clearInterruptTimer()
|
||||||
this.interruptTimeout = setTimeout(() => {
|
this.interruptTimeout = setTimeout(() => {
|
||||||
@@ -885,13 +953,6 @@ export class RunFooter implements FooterApi {
|
|||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
private interruptHint(): string {
|
|
||||||
const bindings = this.options.keymap
|
|
||||||
.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
|
|
||||||
.get("session.interrupt")
|
|
||||||
return formatKeyBindings(bindings, this.options.tuiConfig) || "esc"
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearExitTimer(): void {
|
private clearExitTimer(): void {
|
||||||
if (!this.exitTimeout) {
|
if (!this.exitTimeout) {
|
||||||
return
|
return
|
||||||
@@ -926,12 +987,12 @@ export class RunFooter implements FooterApi {
|
|||||||
|
|
||||||
if (next < 2) {
|
if (next < 2) {
|
||||||
this.armInterruptTimer()
|
this.armInterruptTimer()
|
||||||
this.patch({ status: `${this.interruptHint()} again to interrupt` })
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clearInterruptTimer()
|
this.clearInterruptTimer()
|
||||||
this.patch({ interrupt: 0, status: "interrupting" })
|
this.patch({ interrupt: 0 })
|
||||||
|
this.setNotice("interrupting")
|
||||||
this.options.onInterrupt?.()
|
this.options.onInterrupt?.()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -947,7 +1008,6 @@ export class RunFooter implements FooterApi {
|
|||||||
|
|
||||||
if (next < 2) {
|
if (next < 2) {
|
||||||
this.armExitTimer()
|
this.armExitTimer()
|
||||||
this.patch({ status: "Press Ctrl-c again to exit" })
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,6 +1104,7 @@ export class RunFooter implements FooterApi {
|
|||||||
this.notifyClose()
|
this.notifyClose()
|
||||||
this.clearInterruptTimer()
|
this.clearInterruptTimer()
|
||||||
this.clearExitTimer()
|
this.clearExitTimer()
|
||||||
|
this.clearNoticeTimer()
|
||||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||||
this.renderer.off(CliRenderEvents.PALETTE, this.handlePalette)
|
this.renderer.off(CliRenderEvents.PALETTE, this.handlePalette)
|
||||||
this.renderer.off(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
|
this.renderer.off(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
// Shared responsive width policy
|
||||||
|
|
||||||
|
const FOOTER_WIDTH_BREAKPOINTS = {
|
||||||
|
compact: 80,
|
||||||
|
commandHint: 66,
|
||||||
|
model: 120,
|
||||||
|
spacious: 150,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function footerWidthPolicy(width: number) {
|
||||||
|
const compact = width >= FOOTER_WIDTH_BREAKPOINTS.compact
|
||||||
|
const model = width >= FOOTER_WIDTH_BREAKPOINTS.model
|
||||||
|
const spacious = width >= FOOTER_WIDTH_BREAKPOINTS.spacious
|
||||||
|
|
||||||
|
return {
|
||||||
|
dialog: {
|
||||||
|
narrow: !compact,
|
||||||
|
},
|
||||||
|
statusline: {
|
||||||
|
showActivityMeta: compact,
|
||||||
|
showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint,
|
||||||
|
showContextHints: compact,
|
||||||
|
contextHintLimit: !compact ? 0 : spacious ? undefined : model ? 2 : 1,
|
||||||
|
showModel: model,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { RunPromptPart } from "./types"
|
||||||
|
|
||||||
|
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
||||||
|
|
||||||
|
export function resolveEditorSlashValue(text: string) {
|
||||||
|
const head = slashHead(text)
|
||||||
|
if (!head || head.name.toLowerCase() !== "editor") {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
return head.arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
export function realignEditorPromptParts(content: string, parts: RunPromptPart[]): RunPromptPart[] {
|
||||||
|
const matches = new Map<number, Mention | undefined>()
|
||||||
|
const used: Array<{ start: number; end: number }> = []
|
||||||
|
|
||||||
|
for (const [index, part] of parts.entries()) {
|
||||||
|
if (part.type !== "file" && part.type !== "agent") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = promptPartText(part)
|
||||||
|
if (!text) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = findPromptPartIndex(content, text, used, promptPartStart(part))
|
||||||
|
if (start === -1) {
|
||||||
|
matches.set(index, undefined)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = start + text.length
|
||||||
|
used.push({ start, end })
|
||||||
|
matches.set(index, updatePromptPart(part, start, end, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: RunPromptPart[] = []
|
||||||
|
for (const [index, part] of parts.entries()) {
|
||||||
|
if (part.type !== "file" && part.type !== "agent") {
|
||||||
|
next.push(part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!promptPartText(part)) {
|
||||||
|
next.push(part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = matches.get(index)
|
||||||
|
if (match) {
|
||||||
|
next.push(match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function slashHead(text: string) {
|
||||||
|
if (!text.startsWith("/")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < text.length; i++) {
|
||||||
|
switch (text[i]) {
|
||||||
|
case " ":
|
||||||
|
case "\t":
|
||||||
|
case "\n":
|
||||||
|
return {
|
||||||
|
name: text.slice(1, i),
|
||||||
|
arguments: text.slice(i + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: text.slice(1),
|
||||||
|
arguments: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptPartText(part: Mention) {
|
||||||
|
if (part.type === "agent") {
|
||||||
|
return part.source?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return part.source?.text.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptPartStart(part: Mention) {
|
||||||
|
if (part.type === "agent") {
|
||||||
|
return part.source?.start ?? Number.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
|
||||||
|
return part.source?.text.start ?? Number.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPromptPartIndex(
|
||||||
|
content: string,
|
||||||
|
text: string,
|
||||||
|
used: Array<{ start: number; end: number }>,
|
||||||
|
hint: number,
|
||||||
|
) {
|
||||||
|
let searchFrom = 0
|
||||||
|
let best = -1
|
||||||
|
let distance = Number.POSITIVE_INFINITY
|
||||||
|
const hinted = Number.isFinite(hint)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const start = content.indexOf(text, searchFrom)
|
||||||
|
if (start === -1) {
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = start + text.length
|
||||||
|
searchFrom = start + 1
|
||||||
|
if (used.some((range) => start < range.end && end > range.start)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hinted) {
|
||||||
|
return start
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDistance = Math.abs(start - hint)
|
||||||
|
if (nextDistance < distance) {
|
||||||
|
best = start
|
||||||
|
distance = nextDistance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePromptPart(part: Mention, start: number, end: number, text: string): Mention {
|
||||||
|
if (part.type === "agent") {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
source: {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
value: text,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!part.source?.text) {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
source: {
|
||||||
|
...part.source,
|
||||||
|
text: {
|
||||||
|
...part.source.text,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
value: text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,11 +30,17 @@ export function promptCopy(prompt: RunPrompt): RunPrompt {
|
|||||||
text: prompt.text,
|
text: prompt.text,
|
||||||
parts: structuredClone(prompt.parts),
|
parts: structuredClone(prompt.parts),
|
||||||
...(prompt.mode ? { mode: prompt.mode } : {}),
|
...(prompt.mode ? { mode: prompt.mode } : {}),
|
||||||
|
...(prompt.command ? { command: prompt.command } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
|
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 {
|
export function isExitCommand(input: string): boolean {
|
||||||
|
|||||||
@@ -8,10 +8,13 @@
|
|||||||
//
|
//
|
||||||
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
|
// 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().
|
// 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 { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
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 { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
||||||
|
import { Session as SessionApi } from "@/session/session"
|
||||||
import * as Locale from "@/util/locale"
|
import * as Locale from "@/util/locale"
|
||||||
import { withRunSpan } from "./otel"
|
import { withRunSpan } from "./otel"
|
||||||
import { resolveInteractiveStdin } from "./runtime.stdin"
|
import { resolveInteractiveStdin } from "./runtime.stdin"
|
||||||
@@ -30,7 +33,7 @@ import type {
|
|||||||
} from "./types"
|
} from "./types"
|
||||||
import { formatModelLabel } from "./variant.shared"
|
import { formatModelLabel } from "./variant.shared"
|
||||||
|
|
||||||
const FOOTER_HEIGHT = 7
|
const FOOTER_HEIGHT = 4
|
||||||
|
|
||||||
type SplashState = {
|
type SplashState = {
|
||||||
entry: boolean
|
entry: boolean
|
||||||
@@ -135,6 +138,17 @@ function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): Foo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function directoryLabel(directory: string) {
|
||||||
|
const resolved = path.resolve(directory)
|
||||||
|
const display =
|
||||||
|
resolved === Global.Path.home
|
||||||
|
? "~"
|
||||||
|
: resolved.startsWith(`${Global.Path.home}${path.sep}`)
|
||||||
|
? resolved.replace(Global.Path.home, "~")
|
||||||
|
: resolved
|
||||||
|
return display.replaceAll("\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
function queueSplash(
|
function queueSplash(
|
||||||
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
|
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
|
||||||
state: SplashState,
|
state: SplashState,
|
||||||
@@ -205,6 +219,11 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
title: splash.title,
|
title: splash.title,
|
||||||
session_id: input.sessionID,
|
session_id: input.sessionID,
|
||||||
})
|
})
|
||||||
|
const labels = footerLabels({
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model,
|
||||||
|
variant: input.variant,
|
||||||
|
})
|
||||||
const footerTask = import("./footer")
|
const footerTask = import("./footer")
|
||||||
const wrote = queueSplash(
|
const wrote = queueSplash(
|
||||||
renderer,
|
renderer,
|
||||||
@@ -214,17 +233,15 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
...meta,
|
...meta,
|
||||||
theme: theme.splash,
|
theme: theme.splash,
|
||||||
showSession: splash.showSession,
|
showSession: splash.showSession,
|
||||||
|
detail: directoryLabel(input.directory),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await renderer.idle().catch(() => {})
|
await renderer.idle().catch(() => {})
|
||||||
|
|
||||||
const { RunFooter } = await footerTask
|
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, {
|
const footer = new RunFooter(renderer, {
|
||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
findFiles: input.findFiles,
|
findFiles: input.findFiles,
|
||||||
@@ -250,15 +267,54 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
onVariantSelect: input.onVariantSelect,
|
onVariantSelect: input.onVariantSelect,
|
||||||
onInterrupt: input.onInterrupt,
|
onInterrupt: input.onInterrupt,
|
||||||
onBackground: input.onBackground,
|
onBackground: input.onBackground,
|
||||||
|
onEditorOpen: async ({ value }) => {
|
||||||
|
if (closed || renderer.isDestroyed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderer.idle().catch(() => {})
|
||||||
|
const ignore = () => {}
|
||||||
|
detachSigint()
|
||||||
|
process.on("SIGINT", ignore)
|
||||||
|
try {
|
||||||
|
return await openEditor({
|
||||||
|
value,
|
||||||
|
cwd: input.directory,
|
||||||
|
renderer,
|
||||||
|
stdin: source.stdin,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
process.off("SIGINT", ignore)
|
||||||
|
attachSigint()
|
||||||
|
}
|
||||||
|
},
|
||||||
onSubagentSelect: input.onSubagentSelect,
|
onSubagentSelect: input.onSubagentSelect,
|
||||||
})
|
})
|
||||||
|
|
||||||
const sigint = () => {
|
const sigint = () => {
|
||||||
footer.requestExit()
|
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: {
|
const close = async (next: {
|
||||||
showExit: boolean
|
showExit: boolean
|
||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
@@ -277,7 +333,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
"session.id": next.sessionID || input.getSessionID?.() || input.sessionID || undefined,
|
"session.id": next.sessionID || input.getSessionID?.() || input.sessionID || undefined,
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
process.off("SIGINT", sigint)
|
detachSigint()
|
||||||
|
let wroteExit = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await footer.idle().catch(() => {})
|
await footer.idle().catch(() => {})
|
||||||
@@ -286,7 +343,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
if (!renderer.isDestroyed && show) {
|
if (!renderer.isDestroyed && show) {
|
||||||
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
|
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
|
||||||
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
|
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
|
||||||
queueSplash(
|
wroteExit = queueSplash(
|
||||||
renderer,
|
renderer,
|
||||||
state,
|
state,
|
||||||
"exit",
|
"exit",
|
||||||
@@ -306,6 +363,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
footer.destroy()
|
footer.destroy()
|
||||||
unregisterKeymap?.()
|
unregisterKeymap?.()
|
||||||
shutdown(renderer)
|
shutdown(renderer)
|
||||||
|
if (!wroteExit) {
|
||||||
|
process.stdout.write("\n")
|
||||||
|
}
|
||||||
source.cleanup?.()
|
source.cleanup?.()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -353,6 +413,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
}),
|
}),
|
||||||
theme: footer.currentTheme().splash,
|
theme: footer.currentTheme().splash,
|
||||||
showSession: splash.showSession,
|
showSession: splash.showSession,
|
||||||
|
detail: directoryLabel(input.directory),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
renderer.requestRender()
|
renderer.requestRender()
|
||||||
|
|||||||
@@ -228,16 +228,18 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||||||
state.ctrl = undefined
|
state.ctrl = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = Locale.duration(Math.max(0, Date.now() - start))
|
if (sent.mode !== "shell") {
|
||||||
emit(
|
const duration = Locale.duration(Math.max(0, Date.now() - start))
|
||||||
{
|
emit(
|
||||||
type: "turn.duration",
|
{
|
||||||
duration,
|
type: "turn.duration",
|
||||||
},
|
duration,
|
||||||
{
|
},
|
||||||
duration,
|
{
|
||||||
},
|
duration,
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
state.active = undefined
|
state.active = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,19 @@ type RunLocalInput = {
|
|||||||
demo?: RunInput["demo"]
|
demo?: RunInput["demo"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StreamTransportModule = Pick<
|
||||||
|
Awaited<typeof import("./stream.transport")>,
|
||||||
|
"createSessionTransport" | "formatUnknownError"
|
||||||
|
>
|
||||||
|
|
||||||
|
export type RunRuntimeDeps = {
|
||||||
|
createRuntimeLifecycle?: typeof createRuntimeLifecycle
|
||||||
|
streamTransport?: Promise<StreamTransportModule>
|
||||||
|
}
|
||||||
|
|
||||||
type StreamState = {
|
type StreamState = {
|
||||||
mod: Awaited<typeof import("./stream.transport")>
|
mod: StreamTransportModule
|
||||||
handle: Awaited<ReturnType<Awaited<typeof import("./stream.transport")>["createSessionTransport"]>>
|
handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>>
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResolvedSession = {
|
type ResolvedSession = {
|
||||||
@@ -169,7 +179,7 @@ async function resolveExitTitle(
|
|||||||
//
|
//
|
||||||
// Files only attach on the first prompt turn -- after that, includeFiles
|
// Files only attach on the first prompt turn -- after that, includeFiles
|
||||||
// flips to false so subsequent turns don't re-send attachments.
|
// flips to false so subsequent turns don't re-send attachments.
|
||||||
async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
|
||||||
return withRunSpan(
|
return withRunSpan(
|
||||||
"RunInteractive.session",
|
"RunInteractive.session",
|
||||||
{
|
{
|
||||||
@@ -237,7 +247,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
return state.session
|
return state.session
|
||||||
}
|
}
|
||||||
|
|
||||||
const shell = await createRuntimeLifecycle({
|
const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
findFiles: (query) =>
|
findFiles: (query) =>
|
||||||
ctx.sdk.find
|
ctx.sdk.find
|
||||||
@@ -479,7 +489,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamTask = import("./stream.transport")
|
const streamTask = deps.streamTransport ?? import("./stream.transport")
|
||||||
const ensureStream = () => {
|
const ensureStream = () => {
|
||||||
if (state.stream) {
|
if (state.stream) {
|
||||||
return state.stream
|
return state.stream
|
||||||
@@ -506,6 +516,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
replay: input.replay,
|
replay: input.replay,
|
||||||
replayLimit: input.replayLimit,
|
replayLimit: input.replayLimit,
|
||||||
limits: () => state.limits,
|
limits: () => state.limits,
|
||||||
|
providers: () => state.providers,
|
||||||
footer,
|
footer,
|
||||||
trace: log,
|
trace: log,
|
||||||
})
|
})
|
||||||
@@ -746,6 +757,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const eager = eagerStream(input, ctx)
|
const eager = eagerStream(input, ctx)
|
||||||
if (eager) {
|
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()
|
await ensureStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,7 +863,10 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attach mode. Uses the caller-provided SDK client directly.
|
// Attach mode. Uses the caller-provided SDK client directly.
|
||||||
export async function runInteractiveMode(input: RunInput & { createSession?: CreateSession }): Promise<void> {
|
export async function runInteractiveMode(
|
||||||
|
input: RunInput & { createSession?: CreateSession },
|
||||||
|
deps?: RunRuntimeDeps,
|
||||||
|
): Promise<void> {
|
||||||
return withRunSpan(
|
return withRunSpan(
|
||||||
"RunInteractive.attachMode",
|
"RunInteractive.attachMode",
|
||||||
{
|
{
|
||||||
@@ -855,25 +875,28 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
|
|||||||
"session.id": input.sessionID,
|
"session.id": input.sessionID,
|
||||||
},
|
},
|
||||||
async () =>
|
async () =>
|
||||||
runInteractiveRuntime({
|
runInteractiveRuntime(
|
||||||
files: input.files,
|
{
|
||||||
initialInput: input.initialInput,
|
files: input.files,
|
||||||
thinking: input.thinking,
|
initialInput: input.initialInput,
|
||||||
backgroundSubagents: input.backgroundSubagents,
|
thinking: input.thinking,
|
||||||
replay: input.replay,
|
backgroundSubagents: input.backgroundSubagents,
|
||||||
replayLimit: input.replayLimit,
|
replay: input.replay,
|
||||||
demo: input.demo,
|
replayLimit: input.replayLimit,
|
||||||
boot: async () => ({
|
demo: input.demo,
|
||||||
sdk: input.sdk,
|
boot: async () => ({
|
||||||
directory: input.directory,
|
sdk: input.sdk,
|
||||||
sessionID: input.sessionID,
|
directory: input.directory,
|
||||||
sessionTitle: input.sessionTitle,
|
sessionID: input.sessionID,
|
||||||
resume: input.resume,
|
sessionTitle: input.sessionTitle,
|
||||||
agent: input.agent,
|
resume: input.resume,
|
||||||
model: input.model,
|
agent: input.agent,
|
||||||
variant: input.variant,
|
model: input.model,
|
||||||
}),
|
variant: input.variant,
|
||||||
createSession: createSessionResolver(input.createSession),
|
}),
|
||||||
}),
|
createSession: createSessionResolver(input.createSession),
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
|
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
|
||||||
import { withRunSpan } from "./otel"
|
import { withRunSpan } from "./otel"
|
||||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
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 RunTheme } from "./theme"
|
||||||
import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
|
import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
|
||||||
|
|
||||||
@@ -357,6 +358,14 @@ export class RunScrollbackStream {
|
|||||||
this.markRendered(await this.finishActive(false))
|
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)
|
const body = entryBody(commit)
|
||||||
if (body.type === "none") {
|
if (body.type === "none") {
|
||||||
if (entryDone(commit)) {
|
if (entryDone(commit)) {
|
||||||
@@ -428,6 +437,10 @@ export class RunScrollbackStream {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async writeTurnSummary(input: { agent: string; model: string; duration: string }): Promise<void> {
|
||||||
|
await this.append(turnSummaryCommit(input))
|
||||||
|
}
|
||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this.resetActive()
|
this.resetActive()
|
||||||
this.releasePendingThemes()
|
this.releasePendingThemes()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function todoText(item: { status: string; content: string }): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function todoColor(theme: RunTheme, status: 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 {
|
export function entryGroupKey(commit: StreamCommit): string | undefined {
|
||||||
@@ -333,3 +333,18 @@ export function spacerWriter(): ScrollbackWriter {
|
|||||||
trailingNewline: true,
|
trailingNewline: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function turnSummaryWriter(input: { agent: string; model: string; duration: string; theme: RunTheme }) {
|
||||||
|
return createScrollbackWriter(
|
||||||
|
() => (
|
||||||
|
<box width="100%" height={1}>
|
||||||
|
<text wrapMode="none" truncate>
|
||||||
|
<span style={{ fg: input.theme.block.highlight }}>▣ </span>
|
||||||
|
<span style={{ fg: input.theme.block.text }}>{input.agent}</span>
|
||||||
|
<span style={{ fg: input.theme.block.muted }}> · {input.model} · {input.duration}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
{ startOnNewLine: true, trailingNewline: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||||
import { messagePrompt, type SessionMessages } from "./session.shared"
|
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 = {
|
type ReplayInput = {
|
||||||
messages: SessionMessages
|
messages: SessionMessages
|
||||||
@@ -9,6 +10,13 @@ type ReplayInput = {
|
|||||||
questions: QuestionRequest[]
|
questions: QuestionRequest[]
|
||||||
thinking: boolean
|
thinking: boolean
|
||||||
limits: Record<string, number>
|
limits: Record<string, number>
|
||||||
|
providers?: RunProvider[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplayConfig = {
|
||||||
|
limits: Record<string, number>
|
||||||
|
providers?: RunProvider[]
|
||||||
|
summaries: ReadonlySet<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionReplay = {
|
export type SessionReplay = {
|
||||||
@@ -22,6 +30,8 @@ type ReplayMessage = {
|
|||||||
patch?: FooterPatch
|
patch?: FooterPatch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SHELL_SYNTHETIC_USER_TEXT = "The following tool was executed by the user"
|
||||||
|
|
||||||
function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
|
function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
|
||||||
return reduceSessionData({
|
return reduceSessionData({
|
||||||
data,
|
data,
|
||||||
@@ -89,11 +99,62 @@ function replayPatch(data: SessionData, patch: FooterPatch | undefined) {
|
|||||||
} satisfies FooterPatch
|
} satisfies FooterPatch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isShellSyntheticUser(message: SessionMessages[number]) {
|
||||||
|
if (message.info.role !== "user") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = messagePrompt(message)
|
||||||
|
return (
|
||||||
|
!prompt.text.trim() &&
|
||||||
|
prompt.parts.length === 0 &&
|
||||||
|
message.parts.some((part) => part.type === "text" && part.synthetic && part.text === SHELL_SYNTHETIC_USER_TEXT)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShellSyntheticAssistant(message: SessionMessages[number], shellParents: ReadonlySet<string>) {
|
||||||
|
return (
|
||||||
|
message.info.role === "assistant" &&
|
||||||
|
shellParents.has(message.info.parentID) &&
|
||||||
|
message.parts.some((part) => part.type === "tool" && part.tool === "bash")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryMessageIDs(messages: SessionMessages): ReadonlySet<string> {
|
||||||
|
const shellParents = new Set(messages.filter(isShellSyntheticUser).map((message) => message.info.id))
|
||||||
|
const parents = new Set<string>()
|
||||||
|
const summaries = new Set<string>()
|
||||||
|
|
||||||
|
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
|
||||||
|
const message = messages[idx]
|
||||||
|
if (!message || message.info.role !== "assistant") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isShellSyntheticAssistant(message, shellParents)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parents.has(message.info.parentID)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parents.add(message.info.parentID)
|
||||||
|
|
||||||
|
const completed = message.info.time.completed
|
||||||
|
if (typeof completed === "number" && completed > message.info.time.created) {
|
||||||
|
summaries.add(message.info.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries
|
||||||
|
}
|
||||||
|
|
||||||
function replayMessage(
|
function replayMessage(
|
||||||
data: SessionData,
|
data: SessionData,
|
||||||
message: SessionMessages[number],
|
message: SessionMessages[number],
|
||||||
thinking: boolean,
|
thinking: boolean,
|
||||||
limits: Record<string, number>,
|
config: ReplayConfig,
|
||||||
): ReplayMessage {
|
): ReplayMessage {
|
||||||
if (message.info.role === "user") {
|
if (message.info.role === "user") {
|
||||||
const prompt = messagePrompt(message)
|
const prompt = messagePrompt(message)
|
||||||
@@ -131,7 +192,7 @@ function replayMessage(
|
|||||||
},
|
},
|
||||||
message.info.sessionID,
|
message.info.sessionID,
|
||||||
thinking,
|
thinking,
|
||||||
limits,
|
config.limits,
|
||||||
)
|
)
|
||||||
commits.push(...info.commits)
|
commits.push(...info.commits)
|
||||||
patch = mergePatch(patch, info.footer?.patch)
|
patch = mergePatch(patch, info.footer?.patch)
|
||||||
@@ -150,12 +211,17 @@ function replayMessage(
|
|||||||
},
|
},
|
||||||
message.info.sessionID,
|
message.info.sessionID,
|
||||||
thinking,
|
thinking,
|
||||||
limits,
|
config.limits,
|
||||||
)
|
)
|
||||||
patch = mergePatch(patch, next.footer?.patch)
|
patch = mergePatch(patch, next.footer?.patch)
|
||||||
commits.push(...next.commits)
|
commits.push(...next.commits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const summary = config.summaries.has(message.info.id) ? messageTurnSummaryCommit(message, config.providers) : undefined
|
||||||
|
if (summary) {
|
||||||
|
commits.push(summary)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commits,
|
commits,
|
||||||
patch,
|
patch,
|
||||||
@@ -166,6 +232,7 @@ export function replaySession(input: ReplayInput): SessionReplay {
|
|||||||
const data = createSessionData()
|
const data = createSessionData()
|
||||||
const commits: StreamCommit[] = []
|
const commits: StreamCommit[] = []
|
||||||
let patch: FooterPatch | undefined
|
let patch: FooterPatch | undefined
|
||||||
|
const summaries = summaryMessageIDs(input.messages)
|
||||||
|
|
||||||
bootstrapSessionData({
|
bootstrapSessionData({
|
||||||
data,
|
data,
|
||||||
@@ -175,7 +242,11 @@ export function replaySession(input: ReplayInput): SessionReplay {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for (const message of input.messages) {
|
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)
|
commits.push(...next.commits)
|
||||||
patch = mergePatch(patch, next.patch)
|
patch = mergePatch(patch, next.patch)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
import {
|
import {
|
||||||
BoxRenderable,
|
BoxRenderable,
|
||||||
type ColorInput,
|
type ColorInput,
|
||||||
RGBA,
|
|
||||||
TextAttributes,
|
TextAttributes,
|
||||||
TextRenderable,
|
TextRenderable,
|
||||||
type ScrollbackRenderContext,
|
type ScrollbackRenderContext,
|
||||||
@@ -19,7 +18,7 @@ import {
|
|||||||
type ScrollbackWriter,
|
type ScrollbackWriter,
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
import * as Locale from "@/util/locale"
|
import * as Locale from "@/util/locale"
|
||||||
import { go, logo } from "@/cli/logo"
|
import { go } from "@/cli/logo"
|
||||||
import type { RunSplashTheme } from "./theme"
|
import type { RunSplashTheme } from "./theme"
|
||||||
|
|
||||||
export const SPLASH_TITLE_LIMIT = 50
|
export const SPLASH_TITLE_LIMIT = 50
|
||||||
@@ -33,6 +32,7 @@ type SplashInput = {
|
|||||||
type SplashWriterInput = SplashInput & {
|
type SplashWriterInput = SplashInput & {
|
||||||
theme: RunSplashTheme
|
theme: RunSplashTheme
|
||||||
showSession?: boolean
|
showSession?: boolean
|
||||||
|
detail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SplashMeta = {
|
export type SplashMeta = {
|
||||||
@@ -144,28 +144,6 @@ function push(
|
|||||||
lines.push({ left, top, text, fg, bg, attrs })
|
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(
|
function draw(
|
||||||
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
|
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
|
||||||
row: string,
|
row: string,
|
||||||
@@ -200,41 +178,37 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
|
|||||||
const width = Math.max(1, ctx.width)
|
const width = Math.max(1, ctx.width)
|
||||||
const meta = splashMeta(input)
|
const meta = splashMeta(input)
|
||||||
const lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }> = []
|
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 left = input.theme.left
|
||||||
const right = color(input.theme.right, RGBA.defaultForeground(RGBA.fromHex("#f8fafc")))
|
const right = input.theme.right
|
||||||
const leftShadow = color(input.theme.leftShadow, fallback(238, "#334155"))
|
const leftShadow = input.theme.leftShadow
|
||||||
let height = 1
|
let height = 1
|
||||||
|
|
||||||
if (kind === "entry") {
|
if (kind === "entry") {
|
||||||
const rightShadow = color(input.theme.rightShadow, fallback(240, "#475569"))
|
const mark = go.right.slice(1)
|
||||||
|
const top = 1
|
||||||
|
const body_left = (mark[0]?.length ?? 0) + 2
|
||||||
|
|
||||||
for (let i = 0; i < logo.left.length; i += 1) {
|
for (let i = 0; i < mark.length; i += 1) {
|
||||||
const leftText = logo.left[i] ?? ""
|
draw(lines, mark[i] ?? "", {
|
||||||
const rightText = logo.right[i] ?? ""
|
|
||||||
|
|
||||||
draw(lines, leftText, {
|
|
||||||
left: 0,
|
left: 0,
|
||||||
top: i,
|
top: top + i,
|
||||||
fg: left,
|
fg: left,
|
||||||
shadow: leftShadow,
|
shadow: leftShadow,
|
||||||
})
|
})
|
||||||
draw(lines, rightText, {
|
|
||||||
left: leftText.length + 1,
|
|
||||||
top: i,
|
|
||||||
fg: right,
|
|
||||||
shadow: rightShadow,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
height = logo.left.length
|
push(lines, body_left, top, "OpenCode", right, undefined, TextAttributes.BOLD)
|
||||||
|
if (input.detail) {
|
||||||
if (input.showSession !== false) {
|
push(
|
||||||
const top = logo.left.length + 1
|
lines,
|
||||||
const label = "Session".padEnd(10, " ")
|
body_left,
|
||||||
push(lines, 0, top, label, left, undefined, TextAttributes.DIM)
|
top + 1,
|
||||||
push(lines, label.length, top, meta.title, right, undefined, TextAttributes.BOLD)
|
Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
|
||||||
height = top + 1
|
left,
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
height = top + mark.length
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === "exit") {
|
if (kind === "exit") {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { replayActiveText, replayLocalRows, replaySession } from "./session-repl
|
|||||||
import {
|
import {
|
||||||
bootstrapSubagentCalls,
|
bootstrapSubagentCalls,
|
||||||
bootstrapSubagentData,
|
bootstrapSubagentData,
|
||||||
clearFinishedSubagents,
|
|
||||||
createSubagentData,
|
createSubagentData,
|
||||||
listSubagentPermissions,
|
listSubagentPermissions,
|
||||||
listSubagentQuestions,
|
listSubagentQuestions,
|
||||||
@@ -57,6 +56,7 @@ import type {
|
|||||||
RunInput,
|
RunInput,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
RunPromptPart,
|
RunPromptPart,
|
||||||
|
RunProvider,
|
||||||
StreamCommit,
|
StreamCommit,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
@@ -74,6 +74,7 @@ type StreamInput = {
|
|||||||
replay?: boolean
|
replay?: boolean
|
||||||
replayLimit?: number
|
replayLimit?: number
|
||||||
limits: () => Record<string, number>
|
limits: () => Record<string, number>
|
||||||
|
providers?: () => RunProvider[]
|
||||||
footer: FooterApi
|
footer: FooterApi
|
||||||
trace?: Trace
|
trace?: Trace
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
@@ -712,9 +713,10 @@ function createLayer(input: StreamInput) {
|
|||||||
messages: messagesList,
|
messages: messagesList,
|
||||||
permissions: sessionPermissions,
|
permissions: sessionPermissions,
|
||||||
questions: sessionQuestions,
|
questions: sessionQuestions,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
limits: input.limits(),
|
limits: input.limits(),
|
||||||
})
|
providers: input.providers?.(),
|
||||||
|
})
|
||||||
: undefined
|
: undefined
|
||||||
const replay =
|
const replay =
|
||||||
history && input.replayLimit !== undefined && messagesList.length > input.replayLimit
|
history && input.replayLimit !== undefined && messagesList.length > input.replayLimit
|
||||||
@@ -724,6 +726,7 @@ function createLayer(input: StreamInput) {
|
|||||||
questions: sessionQuestions,
|
questions: sessionQuestions,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
limits: input.limits(),
|
limits: input.limits(),
|
||||||
|
providers: input.providers?.(),
|
||||||
})
|
})
|
||||||
: history
|
: history
|
||||||
|
|
||||||
@@ -751,7 +754,6 @@ function createLayer(input: StreamInput) {
|
|||||||
permissions,
|
permissions,
|
||||||
questions,
|
questions,
|
||||||
})
|
})
|
||||||
clearFinishedSubagents(state.subagent)
|
|
||||||
|
|
||||||
for (const request of [
|
for (const request of [
|
||||||
...state.data.permissions,
|
...state.data.permissions,
|
||||||
@@ -1026,6 +1028,7 @@ function createLayer(input: StreamInput) {
|
|||||||
questions: sessionQuestions,
|
questions: sessionQuestions,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
limits: input.limits(),
|
limits: input.limits(),
|
||||||
|
providers: input.providers?.(),
|
||||||
})
|
})
|
||||||
const activeCommits = replayActiveText(history.data, state.data)
|
const activeCommits = replayActiveText(history.data, state.data)
|
||||||
return {
|
return {
|
||||||
@@ -1043,6 +1046,7 @@ function createLayer(input: StreamInput) {
|
|||||||
questions: sessionQuestions,
|
questions: sessionQuestions,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
limits: input.limits(),
|
limits: input.limits(),
|
||||||
|
providers: input.providers?.(),
|
||||||
})
|
})
|
||||||
: history,
|
: history,
|
||||||
}
|
}
|
||||||
@@ -1195,13 +1199,6 @@ function createLayer(input: StreamInput) {
|
|||||||
return
|
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 = {
|
const item: Wait = {
|
||||||
tick: state.tick,
|
tick: state.tick,
|
||||||
armed: false,
|
armed: false,
|
||||||
|
|||||||
@@ -292,10 +292,25 @@ function metadata(part: ToolPart, key: string) {
|
|||||||
return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
|
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 {
|
function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
|
||||||
const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general")
|
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 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 {
|
return {
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -303,7 +318,7 @@ function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
|
|||||||
callID: part.callID,
|
callID: part.callID,
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
status,
|
status: taskStatus(part),
|
||||||
background: metadata(part, "background") === true,
|
background: metadata(part, "background") === true,
|
||||||
title: stateTitle(part),
|
title: stateTitle(part),
|
||||||
toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
|
toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
|
||||||
@@ -457,6 +472,29 @@ function ensureBlockerTab(
|
|||||||
return true
|
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) {
|
function compactCallMap(detail: DetailState) {
|
||||||
const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT))
|
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)
|
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: {
|
export function reduceSubagentData(input: {
|
||||||
data: SubagentData
|
data: SubagentData
|
||||||
event: Event
|
event: Event
|
||||||
@@ -807,12 +829,16 @@ export function reduceSubagentData(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detail = ensureDetail(input.data, sessionID)
|
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.type === "session.status") {
|
||||||
if (event.properties.status.type !== "retry") {
|
if (event.properties.status.type !== "retry") {
|
||||||
return false
|
return cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
return appendCommits(detail, [
|
return (
|
||||||
|
appendCommits(detail, [
|
||||||
{
|
{
|
||||||
kind: "error",
|
kind: "error",
|
||||||
text: event.properties.status.message,
|
text: event.properties.status.message,
|
||||||
@@ -820,11 +846,13 @@ export function reduceSubagentData(input: {
|
|||||||
source: "system",
|
source: "system",
|
||||||
messageID: `retry:${event.properties.status.attempt}`,
|
messageID: `retry:${event.properties.status.attempt}`,
|
||||||
},
|
},
|
||||||
])
|
]) || cancelled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.error" && event.properties.error) {
|
if (event.type === "session.error" && event.properties.error) {
|
||||||
return appendCommits(detail, [
|
return (
|
||||||
|
appendCommits(detail, [
|
||||||
{
|
{
|
||||||
kind: "error",
|
kind: "error",
|
||||||
text: formatError(event.properties.error),
|
text: formatError(event.properties.error),
|
||||||
@@ -832,13 +860,16 @@ export function reduceSubagentData(input: {
|
|||||||
source: "system",
|
source: "system",
|
||||||
messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`,
|
messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`,
|
||||||
},
|
},
|
||||||
])
|
]) || cancelled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return applyChildEvent({
|
return (
|
||||||
detail,
|
applyChildEvent({
|
||||||
event,
|
detail,
|
||||||
thinking: input.thinking,
|
event,
|
||||||
limits: input.limits,
|
thinking: input.thinking,
|
||||||
})
|
limits: input.limits,
|
||||||
|
}) || cancelled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,15 @@ export type RunSplashTheme = {
|
|||||||
|
|
||||||
export type RunFooterTheme = {
|
export type RunFooterTheme = {
|
||||||
highlight: ColorInput
|
highlight: ColorInput
|
||||||
|
selected: ColorInput
|
||||||
|
selectedText: ColorInput
|
||||||
warning: ColorInput
|
warning: ColorInput
|
||||||
success: ColorInput
|
success: ColorInput
|
||||||
error: ColorInput
|
error: ColorInput
|
||||||
muted: ColorInput
|
muted: ColorInput
|
||||||
text: ColorInput
|
text: ColorInput
|
||||||
|
status: ColorInput
|
||||||
|
statusAccent: ColorInput
|
||||||
shade: ColorInput
|
shade: ColorInput
|
||||||
surface: ColorInput
|
surface: ColorInput
|
||||||
pane: ColorInput
|
pane: ColorInput
|
||||||
@@ -38,6 +42,8 @@ export type RunFooterTheme = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type RunBlockTheme = {
|
export type RunBlockTheme = {
|
||||||
|
highlight: ColorInput
|
||||||
|
warning: ColorInput
|
||||||
text: ColorInput
|
text: ColorInput
|
||||||
muted: ColorInput
|
muted: ColorInput
|
||||||
syntax?: SyntaxStyle
|
syntax?: SyntaxStyle
|
||||||
@@ -95,8 +101,11 @@ function rgba(hex: string, value?: number): RGBA {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mode(bg: RGBA): "dark" | "light" {
|
function mode(bg: RGBA): "dark" | "light" {
|
||||||
const lum = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
|
return luminance(bg) > 0.5 ? "light" : "dark"
|
||||||
return lum > 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 {
|
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 {
|
function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
|
||||||
|
const target = oklab(rgba)
|
||||||
const hit = indexed.reduce(
|
const hit = indexed.reduce(
|
||||||
(best, item) => {
|
(best, item) => {
|
||||||
const dr = item.r - rgba.r
|
const sample = oklab(item)
|
||||||
const dg = item.g - rgba.g
|
const dl = sample.l - target.l
|
||||||
const db = item.b - rgba.b
|
const da = sample.a - target.a
|
||||||
const dist = dr * dr + dg * dg + db * db
|
const db = sample.b - target.b
|
||||||
|
const dist = dl * dl * 2 + da * da + db * db
|
||||||
if (dist >= best.dist) return best
|
if (dist >= best.dist) return best
|
||||||
return {
|
return {
|
||||||
dist,
|
dist,
|
||||||
@@ -228,6 +263,11 @@ function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
|
|||||||
return RGBA.clone(hit.item)
|
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 {
|
function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||||
const mixed = tint(base, overlay, value)
|
const mixed = tint(base, overlay, value)
|
||||||
return nearestIndexed(indexed, mixed)
|
return nearestIndexed(indexed, mixed)
|
||||||
@@ -346,13 +386,10 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
|
|||||||
const fg = RGBA.defaultForeground(fg_snapshot)
|
const fg = RGBA.defaultForeground(fg_snapshot)
|
||||||
const isDark = pick === "dark"
|
const isDark = pick === "dark"
|
||||||
|
|
||||||
const indexed = indexedPalette(colors)
|
const color = (index: number) => paletteColor(colors, index)
|
||||||
const color = (index: number) => RGBA.clone(indexed[index]!)
|
|
||||||
const nearest = (rgba: RGBA) => nearestIndexed(indexed, rgba)
|
|
||||||
|
|
||||||
const grays = generateGrayScale(bg_snapshot, isDark, nearest)
|
const grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
|
||||||
const menu_grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
|
const textMuted = generateMutedTextColor(bg_snapshot, isDark, (rgba) => rgba)
|
||||||
const textMuted = generateMutedTextColor(bg_snapshot, isDark, nearest)
|
|
||||||
|
|
||||||
const ansi = {
|
const ansi = {
|
||||||
red: color(1),
|
red: color(1),
|
||||||
@@ -385,7 +422,7 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
|
|||||||
background: alpha(bg, 0),
|
background: alpha(bg, 0),
|
||||||
backgroundPanel: grays[2],
|
backgroundPanel: grays[2],
|
||||||
backgroundElement: grays[3],
|
backgroundElement: grays[3],
|
||||||
backgroundMenu: menu_grays[3],
|
backgroundMenu: grays[3],
|
||||||
borderSubtle: grays[6],
|
borderSubtle: grays[6],
|
||||||
border: grays[7],
|
border: grays[7],
|
||||||
borderActive: grays[8],
|
borderActive: grays[8],
|
||||||
@@ -395,12 +432,12 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
|
|||||||
diffHunkHeader: grays[7],
|
diffHunkHeader: grays[7],
|
||||||
diffHighlightAdded: ansi.green_bright,
|
diffHighlightAdded: ansi.green_bright,
|
||||||
diffHighlightRemoved: ansi.red_bright,
|
diffHighlightRemoved: ansi.red_bright,
|
||||||
diffAddedBg: nearest(tint(bg_snapshot, ansi.green, diff_alpha)),
|
diffAddedBg: tint(bg_snapshot, ansi.green, diff_alpha),
|
||||||
diffRemovedBg: nearest(tint(bg_snapshot, ansi.red, diff_alpha)),
|
diffRemovedBg: tint(bg_snapshot, ansi.red, diff_alpha),
|
||||||
diffContextBg: diff_context_bg,
|
diffContextBg: diff_context_bg,
|
||||||
diffLineNumber: textMuted,
|
diffLineNumber: textMuted,
|
||||||
diffAddedLineNumberBg: nearest(tint(diff_context_bg, ansi.green, diff_alpha)),
|
diffAddedLineNumberBg: tint(diff_context_bg, ansi.green, diff_alpha),
|
||||||
diffRemovedLineNumberBg: nearest(tint(diff_context_bg, ansi.red, diff_alpha)),
|
diffRemovedLineNumberBg: tint(diff_context_bg, ansi.red, diff_alpha),
|
||||||
markdownText: fg,
|
markdownText: fg,
|
||||||
markdownHeading: fg,
|
markdownHeading: fg,
|
||||||
markdownLink: ansi.blue,
|
markdownLink: ansi.blue,
|
||||||
@@ -428,6 +465,27 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quantizeColor(indexed: RGBA[], rgba: RGBA): RGBA {
|
||||||
|
if (rgba.a === 0 || rgba.intent === "default" || rgba.intent === "indexed") {
|
||||||
|
return RGBA.clone(rgba)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearestIndexed(indexed, rgba)
|
||||||
|
}
|
||||||
|
|
||||||
|
function quantizeTheme(theme: TuiThemeCurrent, indexed: RGBA[]): TuiThemeCurrent {
|
||||||
|
const resolved = Object.fromEntries(
|
||||||
|
Object.entries(theme)
|
||||||
|
.filter(([key]) => key !== "thinkingOpacity")
|
||||||
|
.map(([key, value]) => [key, quantizeColor(indexed, value as RGBA)]),
|
||||||
|
) as Partial<Record<ThemeColor, RGBA>>
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(resolved as Record<ThemeColor, RGBA>),
|
||||||
|
thinkingOpacity: theme.thinkingOpacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
|
function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
|
||||||
const left = nearestIndexed(indexed, theme.textMuted)
|
const left = nearestIndexed(indexed, theme.textMuted)
|
||||||
const right = nearestIndexed(indexed, theme.text)
|
const right = nearestIndexed(indexed, theme.text)
|
||||||
@@ -440,69 +498,85 @@ function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function map(
|
function map(
|
||||||
theme: TuiThemeCurrent,
|
footerTheme: TuiThemeCurrent,
|
||||||
|
scrollbackTheme: TuiThemeCurrent,
|
||||||
splash: RunSplashTheme,
|
splash: RunSplashTheme,
|
||||||
syntax?: SyntaxStyle,
|
syntax?: SyntaxStyle,
|
||||||
subtleSyntax?: SyntaxStyle,
|
subtleSyntax?: SyntaxStyle,
|
||||||
): RunTheme {
|
): RunTheme {
|
||||||
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, theme.background)
|
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background)
|
||||||
subtleSyntax?.destroy()
|
subtleSyntax?.destroy()
|
||||||
const shade = fade(theme.backgroundMenu, theme.background, 0.12, 0.56, 0.72)
|
const footerBackground = alpha(footerTheme.background, 1)
|
||||||
const surface = fade(theme.backgroundMenu, theme.background, 0.18, 0.76, 0.9)
|
const footerMode = mode(footerBackground)
|
||||||
const line = fade(theme.backgroundMenu, theme.background, 0.24, 0.9, 0.98)
|
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 {
|
return {
|
||||||
background: theme.background,
|
background: footerTheme.background,
|
||||||
footer: {
|
footer: {
|
||||||
highlight: theme.primary,
|
highlight: footerTheme.primary,
|
||||||
warning: theme.warning,
|
selected: footerTheme.backgroundElement,
|
||||||
success: theme.success,
|
selectedText: footerTheme.selectedListItemText,
|
||||||
error: theme.error,
|
warning: footerTheme.warning,
|
||||||
muted: theme.textMuted,
|
success: footerTheme.success,
|
||||||
text: theme.text,
|
error: footerTheme.error,
|
||||||
|
muted: footerTheme.textMuted,
|
||||||
|
text: footerTheme.text,
|
||||||
|
status,
|
||||||
|
statusAccent,
|
||||||
shade,
|
shade,
|
||||||
surface,
|
surface,
|
||||||
pane: theme.backgroundMenu,
|
pane: footerTheme.backgroundMenu,
|
||||||
border: theme.border,
|
border: footerTheme.border,
|
||||||
line,
|
line,
|
||||||
},
|
},
|
||||||
entry: {
|
entry: {
|
||||||
system: {
|
system: {
|
||||||
body: theme.textMuted,
|
body: scrollbackTheme.textMuted,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
body: theme.primary,
|
body: scrollbackTheme.primary,
|
||||||
},
|
},
|
||||||
assistant: {
|
assistant: {
|
||||||
body: theme.text,
|
body: scrollbackTheme.text,
|
||||||
},
|
},
|
||||||
reasoning: {
|
reasoning: {
|
||||||
body: theme.textMuted,
|
body: scrollbackTheme.textMuted,
|
||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
body: theme.text,
|
body: scrollbackTheme.text,
|
||||||
start: theme.textMuted,
|
start: scrollbackTheme.textMuted,
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
body: theme.error,
|
body: scrollbackTheme.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
splash,
|
splash,
|
||||||
block: {
|
block: {
|
||||||
text: theme.text,
|
highlight: scrollbackTheme.primary,
|
||||||
muted: theme.textMuted,
|
warning: scrollbackTheme.warning,
|
||||||
|
text: scrollbackTheme.text,
|
||||||
|
muted: scrollbackTheme.textMuted,
|
||||||
syntax,
|
syntax,
|
||||||
subtleSyntax: opaqueSubtleSyntax,
|
subtleSyntax: opaqueSubtleSyntax,
|
||||||
diffAdded: theme.diffAdded,
|
diffAdded: scrollbackTheme.diffAdded,
|
||||||
diffRemoved: theme.diffRemoved,
|
diffRemoved: scrollbackTheme.diffRemoved,
|
||||||
diffAddedBg: transparent,
|
diffAddedBg: transparent,
|
||||||
diffRemovedBg: transparent,
|
diffRemovedBg: transparent,
|
||||||
diffContextBg: transparent,
|
diffContextBg: transparent,
|
||||||
diffHighlightAdded: theme.diffHighlightAdded,
|
diffHighlightAdded: scrollbackTheme.diffHighlightAdded,
|
||||||
diffHighlightRemoved: theme.diffHighlightRemoved,
|
diffHighlightRemoved: scrollbackTheme.diffHighlightRemoved,
|
||||||
diffLineNumber: theme.diffLineNumber,
|
diffLineNumber: scrollbackTheme.diffLineNumber,
|
||||||
diffAddedLineNumberBg: theme.diffAddedLineNumberBg,
|
diffAddedLineNumberBg: scrollbackTheme.diffAddedLineNumberBg,
|
||||||
diffRemovedLineNumberBg: theme.diffRemovedLineNumberBg,
|
diffRemovedLineNumberBg: scrollbackTheme.diffRemovedLineNumberBg,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -532,11 +606,15 @@ export const RUN_THEME_FALLBACK: RunTheme = {
|
|||||||
background: RGBA.fromValues(0, 0, 0, 0),
|
background: RGBA.fromValues(0, 0, 0, 0),
|
||||||
footer: {
|
footer: {
|
||||||
highlight: seed.highlight,
|
highlight: seed.highlight,
|
||||||
|
selected: seed.text,
|
||||||
|
selectedText: seed.panel,
|
||||||
warning: seed.warning,
|
warning: seed.warning,
|
||||||
success: seed.success,
|
success: seed.success,
|
||||||
error: seed.error,
|
error: seed.error,
|
||||||
muted: seed.muted,
|
muted: seed.muted,
|
||||||
text: seed.text,
|
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),
|
shade: alpha(seed.panel, 0.68),
|
||||||
surface: alpha(seed.panel, 0.86),
|
surface: alpha(seed.panel, 0.86),
|
||||||
pane: seed.panel,
|
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),
|
rightShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashRight, 0.14),
|
||||||
},
|
},
|
||||||
block: {
|
block: {
|
||||||
|
highlight: seed.highlight,
|
||||||
|
warning: seed.warning,
|
||||||
text: seed.text,
|
text: seed.text,
|
||||||
muted: seed.muted,
|
muted: seed.muted,
|
||||||
diffAdded: seed.success,
|
diffAdded: seed.success,
|
||||||
@@ -588,15 +668,22 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
|
|||||||
const pick = colors.defaultBackground
|
const pick = colors.defaultBackground
|
||||||
? mode(RGBA.fromHex(colors.defaultBackground))
|
? mode(RGBA.fromHex(colors.defaultBackground))
|
||||||
: (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
|
: (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 indexed = indexedPalette(colors, 256)
|
||||||
|
const scrollbackTheme = quantizeTheme(footerTheme, indexed)
|
||||||
const shared = await import("@opencode-ai/tui/context/theme")
|
const shared = await import("@opencode-ai/tui/context/theme")
|
||||||
const syntaxTheme: SharedSyntaxTheme = {
|
const syntaxTheme: SharedSyntaxTheme = {
|
||||||
...theme,
|
...scrollbackTheme,
|
||||||
_hasSelectedListItemText: true,
|
_hasSelectedListItemText: true,
|
||||||
}
|
}
|
||||||
const syntax = shared.generateSyntax(syntaxTheme)
|
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 {
|
} catch {
|
||||||
return RUN_THEME_FALLBACK
|
return RUN_THEME_FALLBACK
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as Locale from "@/util/locale"
|
||||||
|
import type { SessionMessages } from "./session.shared"
|
||||||
|
import type { RunProvider, StreamCommit } from "./types"
|
||||||
|
|
||||||
|
export function turnSummaryCommit(input: {
|
||||||
|
agent: string
|
||||||
|
model: string
|
||||||
|
duration: string
|
||||||
|
messageID?: string
|
||||||
|
}): StreamCommit {
|
||||||
|
return {
|
||||||
|
kind: "system",
|
||||||
|
text: `▣ ${input.agent} · ${input.model} · ${input.duration}`,
|
||||||
|
phase: "final",
|
||||||
|
source: "system",
|
||||||
|
summary: {
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model,
|
||||||
|
duration: input.duration,
|
||||||
|
},
|
||||||
|
messageID: input.messageID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageTurnSummaryCommit(
|
||||||
|
message: SessionMessages[number],
|
||||||
|
providers?: RunProvider[],
|
||||||
|
): StreamCommit | undefined {
|
||||||
|
const info = message.info
|
||||||
|
if (info.role !== "assistant") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = info.time.completed
|
||||||
|
if (typeof completed !== "number" || completed <= info.time.created) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = providers?.find((item) => item.id === info.providerID)?.models[info.modelID]?.name
|
||||||
|
|
||||||
|
return turnSummaryCommit({
|
||||||
|
agent: Locale.titlecase(info.agent),
|
||||||
|
model: model ?? info.modelID,
|
||||||
|
duration: Locale.duration(completed - info.time.created),
|
||||||
|
messageID: info.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -97,6 +97,12 @@ export type FooterPatch = Partial<FooterState>
|
|||||||
|
|
||||||
export type RunDiffStyle = "auto" | "stacked"
|
export type RunDiffStyle = "auto" | "stacked"
|
||||||
|
|
||||||
|
export type TurnSummary = {
|
||||||
|
agent: string
|
||||||
|
model: string
|
||||||
|
duration: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ScrollbackOptions = {
|
export type ScrollbackOptions = {
|
||||||
diffStyle?: RunDiffStyle
|
diffStyle?: RunDiffStyle
|
||||||
suppressBackgrounds?: boolean
|
suppressBackgrounds?: boolean
|
||||||
@@ -175,6 +181,7 @@ export type FooterPromptRoute =
|
|||||||
| { type: "subagent-menu" }
|
| { type: "subagent-menu" }
|
||||||
| { type: "subagent"; sessionID: string }
|
| { type: "subagent"; sessionID: string }
|
||||||
| { type: "command" }
|
| { type: "command" }
|
||||||
|
| { type: "skill" }
|
||||||
| { type: "model" }
|
| { type: "model" }
|
||||||
| { type: "variant" }
|
| { type: "variant" }
|
||||||
|
|
||||||
@@ -184,7 +191,7 @@ export type FooterSubagentTab = {
|
|||||||
callID: string
|
callID: string
|
||||||
label: string
|
label: string
|
||||||
description: string
|
description: string
|
||||||
status: "running" | "completed" | "error"
|
status: "running" | "completed" | "cancelled" | "error"
|
||||||
background?: boolean
|
background?: boolean
|
||||||
title?: string
|
title?: string
|
||||||
toolCalls?: number
|
toolCalls?: number
|
||||||
@@ -298,6 +305,7 @@ export type StreamCommit = {
|
|||||||
text: string
|
text: string
|
||||||
phase: StreamPhase
|
phase: StreamPhase
|
||||||
source: StreamSource
|
source: StreamSource
|
||||||
|
summary?: TurnSummary
|
||||||
messageID?: string
|
messageID?: string
|
||||||
partID?: string
|
partID?: string
|
||||||
tool?: string
|
tool?: string
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { type ChildProcess } from "child_process"
|
import { type ChildProcess } from "child_process"
|
||||||
|
import type { Stream } from "node:stream"
|
||||||
import launch from "cross-spawn"
|
import launch from "cross-spawn"
|
||||||
import { buffer } from "node:stream/consumers"
|
import { buffer } from "node:stream/consumers"
|
||||||
import { errorMessage } from "./error"
|
import { errorMessage } from "./error"
|
||||||
|
|
||||||
export type Stdio = "inherit" | "pipe" | "ignore"
|
export type Stdio = "inherit" | "pipe" | "ignore" | number | Stream
|
||||||
export type Shell = boolean | string
|
export type Shell = boolean | string
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
RunCommandMenuBody,
|
RunCommandMenuBody,
|
||||||
RunModelSelectBody,
|
RunModelSelectBody,
|
||||||
RunQueuedPromptSelectBody,
|
RunQueuedPromptSelectBody,
|
||||||
|
RunSkillSelectBody,
|
||||||
RunSubagentSelectBody,
|
RunSubagentSelectBody,
|
||||||
RunVariantSelectBody,
|
RunVariantSelectBody,
|
||||||
} from "@/cli/cmd/run/footer.command"
|
} from "@/cli/cmd/run/footer.command"
|
||||||
@@ -155,13 +156,23 @@ async function renderFooter(
|
|||||||
tuiConfig?: RunTuiConfig
|
tuiConfig?: RunTuiConfig
|
||||||
commands?: RunCommand[]
|
commands?: RunCommand[]
|
||||||
theme?: () => RunTheme
|
theme?: () => RunTheme
|
||||||
|
providers?: RunProvider[]
|
||||||
|
currentModel?: RunInput["model"]
|
||||||
|
currentVariant?: string
|
||||||
|
subagents?: FooterSubagentState
|
||||||
|
backgroundSubagents?: boolean
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
state?: Partial<FooterState>
|
||||||
onCycle?: () => void
|
onCycle?: () => void
|
||||||
onSubmit?: (prompt: RunPrompt) => boolean
|
onSubmit?: (prompt: RunPrompt) => boolean
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const [view] = createSignal<FooterView>({ type: "prompt" })
|
const [view] = createSignal<FooterView>({ type: "prompt" })
|
||||||
const [subagents] = createSignal<FooterSubagentState>({ tabs: [], details: {}, permissions: [], questions: [] })
|
const [subagents] = createSignal<FooterSubagentState>(
|
||||||
const state = footerState()
|
input.subagents ?? { tabs: [], details: {}, permissions: [], questions: [] },
|
||||||
|
)
|
||||||
|
const state = footerState(input.state)
|
||||||
const config = input.tuiConfig ?? tuiConfig
|
const config = input.tuiConfig ?? tuiConfig
|
||||||
let offKeymap: (() => void) | undefined
|
let offKeymap: (() => void) | undefined
|
||||||
|
|
||||||
@@ -178,16 +189,16 @@ async function renderFooter(
|
|||||||
agents={() => []}
|
agents={() => []}
|
||||||
resources={() => []}
|
resources={() => []}
|
||||||
commands={() => input.commands ?? []}
|
commands={() => input.commands ?? []}
|
||||||
providers={() => undefined}
|
providers={() => input.providers}
|
||||||
currentModel={() => undefined}
|
currentModel={() => input.currentModel}
|
||||||
variants={() => []}
|
variants={() => []}
|
||||||
currentVariant={() => undefined}
|
currentVariant={() => input.currentVariant}
|
||||||
state={state}
|
state={state}
|
||||||
view={view}
|
view={view}
|
||||||
subagent={subagents}
|
subagent={subagents}
|
||||||
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
||||||
tuiConfig={config}
|
tuiConfig={config}
|
||||||
backgroundSubagents={true}
|
backgroundSubagents={input.backgroundSubagents ?? true}
|
||||||
agent="opencode"
|
agent="opencode"
|
||||||
onSubmit={input.onSubmit ?? (() => true)}
|
onSubmit={input.onSubmit ?? (() => true)}
|
||||||
onPermissionReply={() => {}}
|
onPermissionReply={() => {}}
|
||||||
@@ -195,6 +206,7 @@ async function renderFooter(
|
|||||||
onQuestionReject={() => {}}
|
onQuestionReject={() => {}}
|
||||||
onCycle={input.onCycle ?? (() => {})}
|
onCycle={input.onCycle ?? (() => {})}
|
||||||
onInterrupt={() => false}
|
onInterrupt={() => false}
|
||||||
|
onEditorOpen={async () => undefined}
|
||||||
onInputClear={() => {}}
|
onInputClear={() => {}}
|
||||||
onExit={() => {}}
|
onExit={() => {}}
|
||||||
onModelSelect={() => {}}
|
onModelSelect={() => {}}
|
||||||
@@ -210,11 +222,11 @@ async function renderFooter(
|
|||||||
|
|
||||||
const app = await testRender(
|
const app = await testRender(
|
||||||
() => (
|
() => (
|
||||||
<box width={100} height={8}>
|
<box width={input.width ?? 100} height={input.height ?? 8}>
|
||||||
<Harness />
|
<Harness />
|
||||||
</box>
|
</box>
|
||||||
),
|
),
|
||||||
{ width: 100, height: 8, kittyKeyboard: true },
|
{ width: input.width ?? 100, height: input.height ?? 8, kittyKeyboard: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
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 surface = RGBA.fromHex("#123456")
|
||||||
const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
|
const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
|
||||||
const app = await renderFooter({ theme })
|
const app = await renderFooter({ theme })
|
||||||
@@ -248,7 +267,7 @@ test("direct footer updates composer background when theme changes", async () =>
|
|||||||
})
|
})
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
|
|
||||||
expect(area.backgroundColor.toInts()).toEqual(surface.toInts())
|
expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts())
|
||||||
} finally {
|
} finally {
|
||||||
app.cleanup()
|
app.cleanup()
|
||||||
}
|
}
|
||||||
@@ -319,6 +338,8 @@ test("direct command panel renders grouped command palette", async () => {
|
|||||||
variantCycle="ctrl+t"
|
variantCycle="ctrl+t"
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
onModel={() => {}}
|
onModel={() => {}}
|
||||||
|
onEditor={() => {}}
|
||||||
|
onSkill={() => {}}
|
||||||
onSubagent={() => {}}
|
onSubagent={() => {}}
|
||||||
onQueued={() => {}}
|
onQueued={() => {}}
|
||||||
onVariant={() => {}}
|
onVariant={() => {}}
|
||||||
@@ -341,17 +362,17 @@ test("direct command panel renders grouped command palette", async () => {
|
|||||||
|
|
||||||
expect(frame).toContain("Commands")
|
expect(frame).toContain("Commands")
|
||||||
expect(frame).toContain("Search")
|
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("Session")
|
||||||
expect(frame).toContain("New session")
|
expect(frame).toContain("Agent")
|
||||||
expect(frame).toContain("/new")
|
expect(frame).toContain("Prompt")
|
||||||
expect(frame).toContain("Project Commands")
|
expect(frame).toContain("Open editor")
|
||||||
expect(frame).toContain("review")
|
expect(frame).toContain("/editor")
|
||||||
expect(frame).toContain("/review")
|
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("/internal")
|
||||||
expect(frame).not.toContain("Choose model for future turns")
|
expect(frame).not.toContain("Choose model for future turns")
|
||||||
expect(frame).not.toContain("Cycle reasoning effort 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<RunCommand[] | undefined>([
|
||||||
|
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(
|
||||||
|
() => (
|
||||||
|
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
||||||
|
<RunSkillSelectBody
|
||||||
|
theme={() => RUN_THEME_FALLBACK.footer}
|
||||||
|
commands={commands}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
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<RunCommand[] | undefined>([
|
||||||
|
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(
|
||||||
|
() => (
|
||||||
|
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
||||||
|
<RunSkillSelectBody
|
||||||
|
theme={() => RUN_THEME_FALLBACK.footer}
|
||||||
|
commands={commands}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
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 () => {
|
test("direct command panel shows subagent entry when available", async () => {
|
||||||
const [commands] = createSignal<RunCommand[] | undefined>([])
|
const [commands] = createSignal<RunCommand[] | undefined>([])
|
||||||
const [subagents] = createSignal([subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })])
|
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"
|
variantCycle="ctrl+t"
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
onModel={() => {}}
|
onModel={() => {}}
|
||||||
|
onEditor={() => {}}
|
||||||
|
onSkill={() => {}}
|
||||||
onSubagent={() => {}}
|
onSubagent={() => {}}
|
||||||
onQueued={() => {}}
|
onQueued={() => {}}
|
||||||
onVariant={() => {}}
|
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<RunCommand[] | undefined>([])
|
||||||
|
const [subagents] = createSignal([
|
||||||
|
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow", status: "completed" }),
|
||||||
|
])
|
||||||
|
const [variants] = createSignal<string[]>([])
|
||||||
|
|
||||||
|
const app = await testRender(
|
||||||
|
() => (
|
||||||
|
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
||||||
|
<RunCommandMenuBody
|
||||||
|
theme={() => 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={() => {}}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
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 () => {
|
test("direct subagent panel renders active subagents", async () => {
|
||||||
const [tabs] = createSignal([
|
const [tabs] = createSignal([
|
||||||
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
|
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
|
||||||
@@ -438,11 +588,15 @@ test("direct subagent panel renders active subagents", async () => {
|
|||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
const frame = app.captureCharFrame()
|
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("Select subagent")
|
||||||
expect(frame).toContain("Inspect auth flow")
|
expect(frame).toContain("Inspect auth flow")
|
||||||
expect(frame).toContain("Write migration plan")
|
expect(frame).toContain("Write migration plan")
|
||||||
expect(frame).toContain("done")
|
expect(frame).toContain("done")
|
||||||
|
expect(frame).not.toContain("┌")
|
||||||
|
expect(frame).not.toContain("┃")
|
||||||
|
expectPaletteList(list, 0)
|
||||||
expect(rows).toBe(8)
|
expect(rows).toBe(8)
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
@@ -471,25 +625,41 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
expect(app.captureCharFrame()).toContain("Queued prompts")
|
const frame = app.captureCharFrame()
|
||||||
expect(app.captureCharFrame()).toContain("fix the auth test")
|
const list = app.renderer.root.findDescendantById("run-direct-footer-queued-list") as BoxRenderable
|
||||||
expect(app.captureCharFrame()).toContain("queued")
|
|
||||||
|
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 {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// OpenTUI currently segfaults when the full footer view suite creates several
|
// OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
|
||||||
// keymap-backed test renderers in one process. Re-enable after the runtime fix.
|
// Re-enable after the upstream OpenTUI fix lands in this repo.
|
||||||
test.skip("direct footer opens command panel through keymap binding", async () => {
|
test.skip("direct footer recreates the frame across command panel transitions", async () => {
|
||||||
const app = await renderFooter()
|
const app = await renderFooter()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
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 {
|
} finally {
|
||||||
app.cleanup()
|
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 () => {
|
test("direct footer shows editable prompts and additional queued work while running", async () => {
|
||||||
const [state] = createSignal<FooterState>({
|
const [state] = createSignal<FooterState>({
|
||||||
phase: "running",
|
phase: "running",
|
||||||
@@ -630,7 +898,10 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
resources={() => []}
|
resources={() => []}
|
||||||
commands={() => []}
|
commands={() => []}
|
||||||
providers={() => undefined}
|
providers={() => undefined}
|
||||||
currentModel={() => undefined}
|
currentModel={() => ({
|
||||||
|
providerID: "opencode",
|
||||||
|
modelID: "a-model-name-long-enough-to-force-responsive-truncation",
|
||||||
|
})}
|
||||||
variants={() => []}
|
variants={() => []}
|
||||||
currentVariant={() => undefined}
|
currentVariant={() => undefined}
|
||||||
state={state}
|
state={state}
|
||||||
@@ -649,6 +920,7 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
onQuestionReject={() => {}}
|
onQuestionReject={() => {}}
|
||||||
onCycle={() => {}}
|
onCycle={() => {}}
|
||||||
onInterrupt={() => false}
|
onInterrupt={() => false}
|
||||||
|
onEditorOpen={async () => undefined}
|
||||||
onInputClear={() => {}}
|
onInputClear={() => {}}
|
||||||
onExit={() => {}}
|
onExit={() => {}}
|
||||||
onModelSelect={() => {}}
|
onModelSelect={() => {}}
|
||||||
@@ -676,10 +948,34 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • ctrl+b background • 1 queued ctrl+x q")
|
const frame = app.captureCharFrame()
|
||||||
expect(app.captureCharFrame()).toContain("2 queued")
|
const transparent = RGBA.fromValues(0, 0, 0, 0).toInts()
|
||||||
expect(app.captureCharFrame()).not.toContain("to view")
|
const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
|
||||||
expect(app.captureCharFrame()).not.toContain("edit/remove")
|
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 {
|
} finally {
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
app.renderer.currentFocusedEditor?.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 () => {
|
test("direct question body separates single-select checkmark from label", async () => {
|
||||||
const request = {
|
const request = {
|
||||||
id: "question-1",
|
id: "question-1",
|
||||||
@@ -876,6 +1276,7 @@ test("direct model panel renders current model selector", async () => {
|
|||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
const frame = app.captureCharFrame()
|
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("Select model")
|
||||||
expect(frame).toContain("Search")
|
expect(frame).toContain("Search")
|
||||||
@@ -884,7 +1285,10 @@ test("direct model panel renders current model selector", async () => {
|
|||||||
expect(frame).toContain("current")
|
expect(frame).toContain("current")
|
||||||
expect(frame).toContain("GPT Free")
|
expect(frame).toContain("GPT Free")
|
||||||
expect(frame).toContain("Free")
|
expect(frame).toContain("Free")
|
||||||
|
expect(frame).not.toContain("┌")
|
||||||
|
expect(frame).not.toContain("┃")
|
||||||
expect(frame).not.toContain("Old Model")
|
expect(frame).not.toContain("Old Model")
|
||||||
|
expectPaletteList(list, 2)
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
@@ -915,12 +1319,16 @@ test("direct variant panel renders current variant selector", async () => {
|
|||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
const frame = app.captureCharFrame()
|
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("Select variant")
|
||||||
expect(frame).toContain("Default")
|
expect(frame).toContain("Default")
|
||||||
expect(frame).toContain("high")
|
expect(frame).toContain("high")
|
||||||
expect(frame).toContain("minimal")
|
expect(frame).toContain("minimal")
|
||||||
expect(frame).toContain("current")
|
expect(frame).toContain("current")
|
||||||
|
expect(frame).not.toContain("┌")
|
||||||
|
expect(frame).not.toContain("┃")
|
||||||
|
expectPaletteList(list, 1)
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -206,6 +206,22 @@ describe("run runtime queue", () => {
|
|||||||
await task
|
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 () => {
|
test("preserves whitespace for initial input", async () => {
|
||||||
const ui = footer()
|
const ui = footer()
|
||||||
const seen: string[] = []
|
const seen: string[] = []
|
||||||
|
|||||||
@@ -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<Awaited<ReturnType<OpencodeClient["session"]["messages"]>>["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<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok<T>(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<void>()
|
||||||
|
const providers = defer<void>()
|
||||||
|
|
||||||
|
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]])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 () => {
|
test("theme swaps restyle active reasoning without resetting the stream", async () => {
|
||||||
const previousSyntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } })
|
const previousSyntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } })
|
||||||
const nextSyntax = SyntaxStyle.fromStyles({ default: { fg: "#abcdef" } })
|
const nextSyntax = SyntaxStyle.fromStyles({ default: { fg: "#abcdef" } })
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
|
import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
|
||||||
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
|
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] {
|
function userMessage(id: string, text: string): SessionMessages[number] {
|
||||||
return {
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
time: {
|
time: input.time ?? { created: 2 },
|
||||||
created: 2,
|
parentID: input.parentID ?? "msg-user-1",
|
||||||
},
|
modelID: input.modelID ?? "gpt-5",
|
||||||
parentID: "msg-user-1",
|
providerID: input.providerID ?? "openai",
|
||||||
modelID: "gpt-5",
|
|
||||||
providerID: "openai",
|
|
||||||
mode: "chat",
|
mode: "chat",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
path: {
|
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 {
|
return {
|
||||||
info: assistantInfo(id),
|
info: assistantInfo(id, {
|
||||||
|
...input,
|
||||||
|
time,
|
||||||
|
}),
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
id: `${id}-text`,
|
id: `${id}-text`,
|
||||||
@@ -70,14 +94,72 @@ function assistantMessage(id: string, text: string): SessionMessages[number] {
|
|||||||
type: "text",
|
type: "text",
|
||||||
text,
|
text,
|
||||||
time: {
|
time: {
|
||||||
start: 2,
|
start: time.created,
|
||||||
end: 3,
|
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] {
|
function runningToolMessage(id: string): SessionMessages[number] {
|
||||||
return {
|
return {
|
||||||
info: assistantInfo(id),
|
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", () => {
|
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({
|
const out = replaySession({
|
||||||
messages: [
|
messages: [
|
||||||
userMessage("msg-user-1", "Hello, whats the weather today?"),
|
userMessage("msg-user-1", "Hello, whats the weather today?"),
|
||||||
@@ -131,6 +279,18 @@ describe("run session replay", () => {
|
|||||||
source: "assistant",
|
source: "assistant",
|
||||||
messageID: "msg-1",
|
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(out.patch).toEqual(
|
||||||
expect.objectContaining({
|
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", () => {
|
test("keeps the footer in a running state for resumed active tools", () => {
|
||||||
const out = replaySession({
|
const out = replaySession({
|
||||||
messages: [runningToolMessage("msg-1")],
|
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", () => {
|
test("merges failed local rows ahead of later persisted prompts", () => {
|
||||||
const persisted = {
|
const persisted = {
|
||||||
kind: "user",
|
kind: "user",
|
||||||
|
|||||||
@@ -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 src = eventFeed()
|
||||||
const ui = footer()
|
const ui = footer()
|
||||||
const transport = await createSessionTransport({
|
const transport = await createSessionTransport({
|
||||||
@@ -1231,7 +1231,7 @@ describe("run stream transport", () => {
|
|||||||
return item?.type === "stream.subagent" ? item.state : undefined
|
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({})
|
expect(state.details).toEqual({})
|
||||||
} finally {
|
} finally {
|
||||||
src.close()
|
src.close()
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { entryBody } from "@/cli/cmd/run/entry.body"
|
|||||||
import {
|
import {
|
||||||
bootstrapSubagentCalls,
|
bootstrapSubagentCalls,
|
||||||
bootstrapSubagentData,
|
bootstrapSubagentData,
|
||||||
clearFinishedSubagents,
|
|
||||||
createSubagentData,
|
createSubagentData,
|
||||||
reduceSubagentData,
|
reduceSubagentData,
|
||||||
snapshotSubagentData,
|
snapshotSubagentData,
|
||||||
@@ -50,7 +49,7 @@ function reduce(data: ReturnType<typeof createSubagentData>, event: unknown) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskMessage(sessionID: string, status: "running" | "completed" = "completed"): SessionMessage {
|
function taskMessage(sessionID: string, status: "running" | "completed" | "interrupted" = "completed"): SessionMessage {
|
||||||
if (status === "running") {
|
if (status === "running") {
|
||||||
return {
|
return {
|
||||||
parts: [
|
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 {
|
return {
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
@@ -234,6 +262,25 @@ describe("run subagent data", () => {
|
|||||||
expect(snapshot.questions.map((item) => item.id)).toEqual(["question-1"])
|
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", () => {
|
test("captures child activity and blocker metadata in the footer detail state", () => {
|
||||||
const data = createSubagentData()
|
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()
|
const data = createSubagentData()
|
||||||
|
|
||||||
bootstrapSubagentData({
|
bootstrapSubagentData({
|
||||||
data,
|
data,
|
||||||
messages: [taskMessage("child-1", "completed"), taskMessage("child-2", "running")],
|
messages: [taskMessage("child-1", "running")],
|
||||||
children: [{ id: "child-1" }, { id: "child-2" }],
|
children: [{ id: "child-1" }],
|
||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
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(snapshotSubagentData(data).tabs).toEqual([
|
||||||
expect.objectContaining({ sessionID: "child-2", status: "running" }),
|
expect.objectContaining({
|
||||||
|
sessionID: "child-1",
|
||||||
|
status: "cancelled",
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,8 +74,33 @@ test("returns syntax styles and indexed splash colors", async () => {
|
|||||||
expectIndexed(theme.splash.right)
|
expectIndexed(theme.splash.right)
|
||||||
expectIndexed(theme.splash.leftShadow)
|
expectIndexed(theme.splash.leftShadow)
|
||||||
expectIndexed(theme.splash.rightShadow)
|
expectIndexed(theme.splash.rightShadow)
|
||||||
|
expectIndexed(theme.block.highlight)
|
||||||
|
expectIndexed(theme.block.warning)
|
||||||
expectRgba(theme.footer.highlight)
|
expectRgba(theme.footer.highlight)
|
||||||
|
expectRgba(theme.footer.statusAccent)
|
||||||
expectRgba(theme.footer.surface)
|
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 {
|
} finally {
|
||||||
theme.block.syntax?.destroy()
|
theme.block.syntax?.destroy()
|
||||||
theme.block.subtleSyntax?.destroy()
|
theme.block.subtleSyntax?.destroy()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { useProject } from "../../context/project"
|
|||||||
import { useSync } from "../../context/sync"
|
import { useSync } from "../../context/sync"
|
||||||
import { useEvent } from "../../context/event"
|
import { useEvent } from "../../context/event"
|
||||||
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
|
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
|
||||||
import { openEditor } from "../../editor"
|
import { normalizePromptContent, openEditor } from "../../editor"
|
||||||
import { destroyRenderer } from "../../util/renderer"
|
import { destroyRenderer } from "../../util/renderer"
|
||||||
import { promptOffsetWidth } from "../../prompt/display"
|
import { promptOffsetWidth } from "../../prompt/display"
|
||||||
import { createStore, produce, unwrap } from "solid-js/store"
|
import { createStore, produce, unwrap } from "solid-js/store"
|
||||||
@@ -442,8 +442,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
paths.cwd,
|
paths.cwd,
|
||||||
})
|
})
|
||||||
if (!content) return
|
if (!content) return
|
||||||
|
const normalized = normalizePromptContent(content)
|
||||||
|
|
||||||
input.setText(content)
|
input.setText(normalized)
|
||||||
|
|
||||||
// Update positions for nonTextParts based on their location in new content
|
// Update positions for nonTextParts based on their location in new content
|
||||||
// Filter out parts whose virtual text was deleted
|
// Filter out parts whose virtual text was deleted
|
||||||
@@ -460,7 +461,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
if (!virtualText) return part
|
if (!virtualText) return part
|
||||||
|
|
||||||
const newStart = content.indexOf(virtualText)
|
const newStart = normalized.indexOf(virtualText)
|
||||||
// if the virtual text is deleted, remove the part
|
// if the virtual text is deleted, remove the part
|
||||||
if (newStart === -1) return null
|
if (newStart === -1) return null
|
||||||
|
|
||||||
@@ -495,14 +496,14 @@ export function Prompt(props: PromptProps) {
|
|||||||
})
|
})
|
||||||
.filter((part) => part !== null)
|
.filter((part) => part !== null)
|
||||||
|
|
||||||
setStore("prompt", {
|
setStore("prompt", {
|
||||||
input: content,
|
input: normalized,
|
||||||
// keep only the non-text parts because the text parts were
|
// keep only the non-text parts because the text parts were
|
||||||
// already expanded inline
|
// already expanded inline
|
||||||
parts: updatedNonTextParts,
|
parts: updatedNonTextParts,
|
||||||
})
|
})
|
||||||
restoreExtmarksFromParts(updatedNonTextParts)
|
restoreExtmarksFromParts(updatedNonTextParts)
|
||||||
input.cursorOffset = Bun.stringWidth(content)
|
input.cursorOffset = Bun.stringWidth(normalized)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,9 +4,26 @@ import { readFile, rm, writeFile } from "node:fs/promises"
|
|||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { spawn } from "node:child_process"
|
import { spawn } from "node:child_process"
|
||||||
|
import type { Stream } from "node:stream"
|
||||||
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
|
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
|
const editor = process.env.VISUAL || process.env.EDITOR
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
const file = path.join(os.tmpdir(), `${Date.now()}.md`)
|
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 parts = editor.split(" ")
|
||||||
const child = spawn(parts[0]!, [...parts.slice(1), file], {
|
const child = spawn(parts[0]!, [...parts.slice(1), file], {
|
||||||
cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(),
|
cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(),
|
||||||
stdio: "inherit",
|
stdio: [input.stdin ?? "inherit", "inherit", "inherit"],
|
||||||
shell: process.platform === "win32",
|
shell: process.platform === "win32",
|
||||||
})
|
})
|
||||||
child.on("error", reject)
|
child.on("error", reject)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, expect, test } from "bun:test"
|
import { afterEach, expect, test } from "bun:test"
|
||||||
import { openEditor } from "../src/editor"
|
import { normalizePromptContent, openEditor } from "../src/editor"
|
||||||
|
|
||||||
const editor = process.env.EDITOR
|
const editor = process.env.EDITOR
|
||||||
const visual = process.env.VISUAL
|
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()
|
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")
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user