Compare commits

...
Author SHA1 Message Date
David Hill 1febd81548 feat(tui): refresh Mini presentation 2026-08-28 10:22:20 -06:00
28 changed files with 755 additions and 339 deletions
+3
View File
@@ -4,6 +4,8 @@ import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
import packageJson from "../package.json"
import { OPENCODE_VERSION } from "./version"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
@@ -159,6 +161,7 @@ export function createMiniHost(input: {
sigusr2: signal("SIGUSR2"),
},
startup: {
version: OPENCODE_VERSION === "local" ? packageJson.version : OPENCODE_VERSION,
showTiming: ["1", "true"].includes(process.env.OPENCODE_SHOW_TTFD?.toLowerCase() ?? ""),
now: () => performance.now(),
},
+1
View File
@@ -155,6 +155,7 @@ describe("Mini CLI host", () => {
const file = path.join(directory, "attachment.txt")
await Bun.write(file, "attachment contents")
expect(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents")
expect(input.startup.version).toMatch(/^\d+\.\d+\.\d+$/)
expect(typeof input.startup.showTiming).toBe("boolean")
expect(typeof input.startup.now()).toBe("number")
})
+1
View File
@@ -37,6 +37,7 @@ function runAgent(input: CurrentAgent): RunAgent {
description: input.description,
mode: input.mode,
hidden: input.hidden,
color: input.color,
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ function userBody(raw: string, mono: boolean): RunEntryBody {
const lead = raw.match(/^\n+/)?.[0] ?? ""
const body = lead ? raw.slice(lead.length) : raw
return textBody(`${lead}${mono ? ">" : ""} ${body}`)
return textBody(`${lead}${mono ? ">" : ""} ${body}`)
}
function reasoningBody(raw: string, mono: boolean): RunEntryBody {
+62 -28
View File
@@ -1,12 +1,12 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/core"
import { StyledText, TextAttributes, dim, fg, type InputRenderable, type KeyEvent } from "@opentui/core"
import { useKeyboard, type JSX } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { Keymap } from "../context/keymap"
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { monoShortcut } from "./mono"
import type { RunFooterTheme } from "./theme"
import { transparent, type RunFooterTheme } from "./theme"
import type {
FooterQueuedPrompt,
FooterSubagentTab,
@@ -76,6 +76,7 @@ const panelPad = (mono?: boolean) => (mono ? 1 : PANEL_PAD)
const PANEL_LIST_ROWS = 10
const PANEL_FRAME_ROWS = 6
export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + PANEL_FRAME_ROWS
export const RUN_COMMAND_PALETTE_ROWS = PANEL_LIST_ROWS + 3
const SUBAGENT_LIST_ROWS = 12
export const RUN_SUBAGENT_PANEL_ROWS = SUBAGENT_LIST_ROWS + PANEL_FRAME_ROWS
const PANEL_PAGE = PANEL_LIST_ROWS - 1
@@ -263,9 +264,60 @@ function PanelShell(props: {
children: JSX.Element
hint?: string
mono?: boolean
compact?: boolean
}) {
const background = () => props.theme().shade
const content = (
const background = () => (props.compact ? transparent : props.theme().shade)
const search = () => (
<input
width="100%"
flexGrow={1}
flexShrink={1}
focusedBackgroundColor={background()}
focusedTextColor={props.theme().text}
placeholder={
(props.compact
? new StyledText([dim(fg(props.theme().muted)(props.placeholder))])
: props.placeholder) as unknown as string
}
placeholderColor={props.theme().muted}
cursorColor={props.theme().text}
onInput={props.onQuery}
ref={(input) => {
props.inputRef(input)
input.traits = { status: "FILTER" }
queueMicrotask(() => {
if (input.isDestroyed) return
input.focus()
})
}}
/>
)
const list = () => (
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={background()}>
{props.children}
</box>
)
const content = props.compact ? (
<>
<box
width="100%"
height={1}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
flexDirection="row"
gap={1}
flexShrink={0}
backgroundColor={background()}
>
{search()}
<text fg={props.theme().muted} attributes={TextAttributes.DIM} wrapMode="none" flexShrink={0}>
esc
</text>
</box>
<box height={1} flexShrink={0} backgroundColor={background()} />
{list()}
</>
) : (
<>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box
@@ -300,29 +352,10 @@ function PanelShell(props: {
flexShrink={0}
backgroundColor={background()}
>
<input
width="100%"
focusedBackgroundColor={background()}
focusedTextColor={props.theme().text}
placeholder={props.placeholder}
placeholderColor={props.theme().muted}
cursorColor={props.theme().highlight}
onInput={props.onQuery}
ref={(input) => {
props.inputRef(input)
input.traits = { status: "FILTER" }
queueMicrotask(() => {
if (!input.isDestroyed) {
input.focus()
}
})
}}
/>
{search()}
</box>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={background()}>
{props.children}
</box>
{list()}
</>
)
return (
@@ -331,7 +364,7 @@ function PanelShell(props: {
{content}
</box>
<box width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
{props.mono ? null : (
{props.mono || props.compact ? null : (
<box
width="100%"
height={1}
@@ -566,11 +599,12 @@ export function RunCommandMenuBody(props: {
query={controller.query()}
count={controller.items().length}
total={entries().length}
placeholder="Search"
placeholder="search commands"
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
mono={props.mono}
compact
>
<RunFooterMenu
theme={props.theme}
@@ -584,8 +618,8 @@ export function RunCommandMenuBody(props: {
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
grouped={!controller.query().trim()}
background
headerColor={props.theme().muted}
dimFooters
mono={props.mono}
/>
</PanelShell>
+9 -10
View File
@@ -79,6 +79,7 @@ export function RunFooterMenu(props: {
background?: boolean
headerColor?: ColorInput
mono?: boolean
dimFooters?: boolean
}) {
const term = useTerminalDimensions()
const limit = () => props.limit ?? FOOTER_MENU_ROWS
@@ -190,7 +191,7 @@ export function RunFooterMenu(props: {
>
{border() ? (
<text fg={props.theme().border} wrapMode="none">
{props.mono ? "|" : ""}
{props.mono ? "|" : ""}
</text>
) : undefined}
<box
@@ -230,13 +231,9 @@ export function RunFooterMenu(props: {
const attributes = () =>
active() ? TextAttributes.BOLD | (props.mono ? TextAttributes.INVERSE : 0) : undefined
const background = () =>
active()
? props.background
? props.theme().selected
: props.theme().shade
: props.background
? props.theme().shade
: transparent
active() ? props.theme().selected : props.background ? props.theme().shade : transparent
const slash = row.item.display.startsWith("/")
const autocomplete = slash || row.item.display.startsWith("@")
return (
<box paddingRight={0} flexDirection="row" backgroundColor={background()}>
{border() ? (
@@ -247,7 +244,7 @@ export function RunFooterMenu(props: {
<box
flexGrow={1}
flexShrink={1}
paddingLeft={props.paddingLeft ?? 1}
paddingLeft={(props.paddingLeft ?? 1) + (autocomplete ? 2 : 0)}
paddingRight={props.paddingRight ?? 0}
backgroundColor={background()}
>
@@ -266,6 +263,7 @@ export function RunFooterMenu(props: {
<>
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
attributes={slash ? TextAttributes.DIM : undefined}
wrapMode="none"
flexShrink={0}
>
@@ -273,6 +271,7 @@ export function RunFooterMenu(props: {
</text>
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
attributes={slash ? TextAttributes.DIM : undefined}
wrapMode="none"
truncate
flexGrow={1}
@@ -286,7 +285,7 @@ export function RunFooterMenu(props: {
{row.item.footer ? (
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
attributes={attributes()}
attributes={(attributes() ?? 0) | (props.dimFooters ? TextAttributes.DIM : 0)}
wrapMode="none"
truncate
flexShrink={0}
+10 -4
View File
@@ -5,7 +5,7 @@
// It produces a PromptState that RunPromptBody renders as a slim single-line
// composer while the footer view renders any active menus below it.
/** @jsxImportSource @opentui/solid */
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { StyledText, dim, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { normalizePromptContent } from "../prompt/content"
import fuzzysort from "fuzzysort"
@@ -176,6 +176,8 @@ export function selectedCommand(text: string, command: RunPrompt["command"]) {
export function RunPromptBody(props: {
theme: () => RunFooterTheme
background: () => ColorInput
agentColor: () => ColorInput
mono: boolean
placeholder: () => StyledText | string
onSubmit: () => void
onKeyDown: (event: KeyEvent) => void
@@ -226,9 +228,13 @@ export function RunPromptBody(props: {
return (
<box width="100%">
<box paddingTop={1} paddingBottom={1} paddingRight={2}>
<box width="100%" flexDirection="row" paddingTop={1} paddingBottom={1} paddingRight={2}>
<text fg={props.agentColor()} flexShrink={0}>
{props.mono ? "| " : "│ "}
</text>
<textarea
width="100%"
flexShrink={1}
minHeight={TEXTAREA_MIN_ROWS}
maxHeight={TEXTAREA_MAX_ROWS}
wrapMode="word"
@@ -258,14 +264,14 @@ export function createPromptState(input: PromptInput): PromptState {
const [shell, setShell] = createSignal(false)
const placeholder = createMemo(() => {
if (shell()) {
return new StyledText([fg(input.theme().muted)('Run a command... "git status"')])
return new StyledText([dim(fg(input.theme().muted)('Run a command... "git status"'))])
}
if (!input.state().first) {
return ""
}
return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
return new StyledText([dim(fg(input.theme().muted)("Ask anything, / for commands, @ for context…"))])
})
let history = createPromptHistory(input.history?.())
+39 -14
View File
@@ -24,18 +24,18 @@
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
// two-press pattern where the first press shows a hint and the second press
// within 5 seconds actually fires the action.
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
import { CliRenderEvents, type CliRenderer, type ColorInput } from "@opentui/core"
import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { RUN_COMMAND_PALETTE_ROWS, RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
import { RunFooterView } from "./footer.view"
import { RunScrollbackStream } from "./scrollback.surface"
import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
import { RUN_THEME_FALLBACK, resolveAgentColor, resolveRunTheme, type RunTheme } from "./theme"
import { modelInfo } from "./variant.shared"
import type {
FooterApi,
@@ -207,6 +207,9 @@ export class RunFooter implements FooterApi {
private exitTimeout: NodeJS.Timeout | undefined
private noticeTimeout: NodeJS.Timeout | undefined
private turnAgent: string | undefined
private turnAgentColor: Accessor<ColorInput | undefined>
private setTurnAgentColor: Setter<ColorInput | undefined>
private activityRows = 0
private requestExitHandler: (() => boolean) | undefined
private scrollback: RunScrollbackStream
private themes: RunTheme[]
@@ -268,8 +271,11 @@ export class RunFooter implements FooterApi {
const agent = currentAgent()
if (agent) return agent.name
const selected = selectedAgentID()
return selected ? Locale.titlecase(selected) : "Default"
return selected ? Locale.titlecase(selected) : ""
}
const [turnAgentColor, setTurnAgentColor] = createSignal<ColorInput | undefined>()
this.turnAgentColor = turnAgentColor
this.setTurnAgentColor = setTurnAgentColor
const [currentModel, setCurrentModel] = createSignal<RunInput["model"]>(options.model)
this.currentModel = currentModel
this.setCurrentModel = setCurrentModel
@@ -331,6 +337,7 @@ export class RunFooter implements FooterApi {
currentAgent: footer.currentAgent,
currentAgentID: footer.currentAgentID,
currentAgentExplicit: () => selectedAgentID() !== undefined,
activeAgentColor: footer.turnAgentColor,
currentModel: footer.currentModel,
variants: footer.variants,
currentVariant: footer.currentVariant,
@@ -415,6 +422,9 @@ export class RunFooter implements FooterApi {
if (next.type === "turn.duration") {
const agent = this.turnAgent ?? this.currentAgent()
const agentColor =
this.turnAgentColor() ??
resolveAgentColor(this.theme().footer, this.agents(), this.currentAgentID(), this.options.mono)
this.turnAgent = undefined
if (this.miniSettings().turn_summary === "hide") return
const current = this.currentModel()
@@ -423,6 +433,7 @@ export class RunFooter implements FooterApi {
.then(() =>
this.scrollback.writeTurnSummary({
agent,
agentColor,
model: current ? modelInfo(this.providers(), current).model : this.state().model,
duration: next.duration,
}),
@@ -482,10 +493,14 @@ export class RunFooter implements FooterApi {
}
if (next.type === "turn.send") {
this.turnAgent = this.currentAgent()
this.setTurnAgentColor(
resolveAgentColor(this.theme().footer, this.agents(), this.currentAgentID(), this.options.mono),
)
this.clearInterruptTimer()
this.clearExitTimer()
}
this.patch(patch)
if (next.type === "turn.idle") this.setTurnAgentColor(undefined)
return
}
@@ -563,10 +578,17 @@ export class RunFooter implements FooterApi {
return
}
const next =
commit.kind === "user" && commit.agentColor === undefined
? {
...commit,
agentColor: resolveAgentColor(this.theme().footer, this.agents(), this.currentAgentID(), this.options.mono),
}
: commit
const last = this.queue.at(-1)
const merged = last ? coalesceProgressCommit(last, commit) : undefined
const merged = last ? coalesceProgressCommit(last, next) : undefined
if (merged) this.queue[this.queue.length - 1] = merged
else this.queue.push(commit)
else this.queue.push(next)
if (this.pending) {
return
@@ -702,13 +724,15 @@ export class RunFooter implements FooterApi {
? this.base + PERMISSION_ROWS
: type === "form"
? this.base + FORM_ROWS
: ["command", "skill", "agent", "model", "variant", "settings"].includes(route)
? 1 + RUN_COMMAND_PANEL_ROWS
: route === "queued-menu" || route === "subagent-menu"
? 1 + this.subagentMenuRows
: route === "subagent"
? this.base + SUBAGENT_INSPECTOR_ROWS
: this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
: route === "command"
? 1 + RUN_COMMAND_PALETTE_ROWS
: ["skill", "agent", "model", "variant", "settings"].includes(route)
? 1 + RUN_COMMAND_PANEL_ROWS
: route === "queued-menu" || route === "subagent-menu"
? 1 + this.subagentMenuRows
: route === "subagent"
? this.base + SUBAGENT_INSPECTOR_ROWS
: this.base + this.activityRows + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
if (height !== this.renderer.footerHeight) {
this.renderer.footerHeight = height
@@ -731,9 +755,10 @@ export class RunFooter implements FooterApi {
}
}
private syncLayout = (next: { route: FooterPromptRoute; subagentRows: number }): void => {
private syncLayout = (next: { route: FooterPromptRoute; subagentRows: number; activityRows: number }): void => {
this.promptRoute = next.route
this.subagentMenuRows = next.subagentRows
this.activityRows = next.activityRows
if (this.view().type === "prompt") {
this.applyHeight()
}
+107 -128
View File
@@ -8,10 +8,10 @@
// All state comes from the parent RunFooter through SolidJS signals.
// The view itself is stateless except for derived memos.
/** @jsxImportSource @opentui/solid */
import type { ColorInput } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { registerOpencodeSpinner } from "../component/register-spinner"
import { createColors, createFrames } from "../ui/spinner"
import {
RUN_SUBAGENT_PANEL_ROWS,
RunAgentSelectBody,
@@ -56,10 +56,12 @@ import type {
RunProvider,
RunReference,
} from "./types"
import type { RunTheme } from "./theme"
import { resolveAgentColor, resolveAgentSelectionColor, type RunTheme } from "./theme"
registerOpencodeSpinner()
const ACTIVITY_INTERVAL = 500
const EMPTY_BORDER = {
topLeft: "",
bottomLeft: "",
@@ -84,6 +86,7 @@ type RunFooterViewProps = {
currentAgent: () => string
currentAgentID: () => string | undefined
currentAgentExplicit: () => boolean
activeAgentColor: () => ColorInput | undefined
currentModel: () => RunInput["model"]
variants: () => string[]
currentVariant: () => string | undefined
@@ -112,7 +115,7 @@ type RunFooterViewProps = {
onModelSelect: (model: NonNullable<RunInput["model"]>) => void
onVariantSelect: (variant: string | undefined) => void
onRows: (rows: number) => void
onLayout: (input: { route: FooterPromptRoute; subagentRows: number }) => void
onLayout: (input: { route: FooterPromptRoute; subagentRows: number; activityRows: number }) => void
onStatus: (text: string) => void
onMiniSettingChange: (change: MiniSettingChange) => void | Promise<void>
onSubagentSelect?: (sessionID: string | undefined) => void
@@ -187,7 +190,6 @@ export function RunFooterView(props: RunFooterViewProps) {
})
const shortcuts = Keymap.useShortcuts()
const shortcut = (id: string) => monoShortcut(shortcuts.get(id) ?? "", props.mono)
const command = () => shortcut("command.palette.show")
const subagentShortcut = () => shortcut("session.child.first")
const queuedShortcut = () => shortcut("session.queued_prompts")
const backgroundShortcut = () => shortcut("session.background")
@@ -210,24 +212,16 @@ export function RunFooterView(props: RunFooterViewProps) {
const runTheme = createMemo(() => props.theme())
const theme = createMemo(() => runTheme().footer)
const block = createMemo(() => runTheme().block)
const spin = createMemo(() => {
if (props.mono) {
return {
frames: ["-", "\\", "|", "/"],
color: theme().text,
}
}
const options = {
color: theme().highlight,
style: "blocks" as const,
inactiveFactor: 0.6,
minAlpha: 0.3,
}
return {
frames: createFrames(options),
color: createColors(options),
}
})
const agentColor = createMemo(() => resolveAgentColor(theme(), props.agents(), props.currentAgentID(), props.mono))
const autocompleteTheme = createMemo(() => ({
...theme(),
selected: resolveAgentSelectionColor(agentColor(), theme().selected, props.mono),
selectedText: theme().text,
}))
const activity = createMemo(() => ({
frames: props.mono ? ["*", " "] : ["◼", " "],
color: props.activeAgentColor() ?? agentColor(),
}))
const footerStatus = createMemo(() => {
const current = model() ?? props.state().model.trim()
const variant = props.currentVariant()
@@ -423,10 +417,10 @@ export function RunFooterView(props: RunFooterViewProps) {
if (notice()) return notice()
if (!footerDetails()) return shell() ? "Shell mode" : ""
if (busy()) return "interrupt"
if (!footerDetails()) return shell() ? "Shell mode" : ""
if (stateStatus().length > 0) {
return stateStatus()
}
@@ -438,14 +432,21 @@ export function RunFooterView(props: RunFooterViewProps) {
return props.mono ? usage().replaceAll(" · ", " - ") : usage()
})
const agentStatus = createMemo(() => {
if (!footerDetails() || !prompt() || shell() || !props.currentAgentExplicit()) return undefined
if (!footerDetails() || !prompt() || shell()) return undefined
return props.currentAgent()
})
const leadingActivity = createMemo(() => prompt() && busy() && !exiting())
const modelStatus = createMemo(() => {
const current = model() ?? props.state().model.trim()
if (!footerDetails() || !prompt() || shell() || !current) return
if (!footerDetails() || !prompt() || shell()) return
const current = props.currentModel()
if (!current) {
const label = props.state().model.trim()
return label ? { model: label, provider: undefined, variant: props.currentVariant() } : undefined
}
const info = modelInfo(props.providers(), current)
return {
model: current,
model: info.model,
provider: info.provider,
variant: props.currentVariant(),
}
})
@@ -464,7 +465,6 @@ export function RunFooterView(props: RunFooterViewProps) {
return theme().muted
})
const statuslineBackground = createMemo(() => theme().status)
const contextHintCandidates = createMemo(() => {
if (!footerDetails() || !prompt() || shell()) {
return []
@@ -482,40 +482,20 @@ export function RunFooterView(props: RunFooterViewProps) {
}
return items
})
const commandHint = createMemo(() => {
if (!prompt()) return
if (shell()) {
return { key: "esc", label: "normal" }
}
if (command()) {
return { key: command(), label: "cmd" }
}
})
const commandHintWidth = createMemo(() => {
const hint = commandHint()
return hint ? stringWidth(`${hint.key} ${hint.label}`) : 0
})
const statuslineText = createMemo(() =>
busy() && !exiting() && (footerDetails() || armed())
? `${interruptLabel() ? `${interruptLabel()} ` : ""}${statusText()}`
: statusText(),
)
const statuslineText = createMemo(() => (leadingActivity() ? "" : statusText()))
const statuslineMainWidth = createMemo(() => {
const mode = modeLabel()
const modeWidth = mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0
const spinnerWidth = footerDetails() && busy() && !exiting() ? stringWidth(spin().frames[0] ?? "") + 1 : 0
return modeWidth + Math.max(12, (props.mono ? 1 : 2) + spinnerWidth + stringWidth(statuslineText()))
return modeWidth + Math.max(12, (props.mono ? 1 : 2) + stringWidth(statuslineText()))
})
const visibleModeLabel = createMemo(() => {
const mode = modeLabel()
if (!mode || width() - commandHintWidth() < stringWidth(mode) + (props.mono ? 1 : 2)) return undefined
if (!mode || width() < stringWidth(mode) + (props.mono ? 1 : 2)) return undefined
return mode
})
const statuslineMainAvailable = createMemo(() => {
const mode = visibleModeLabel()
return width() - commandHintWidth() - (mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0)
return width() - (mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0)
})
const statuslineLayout = createMemo(() => {
const agent = agentStatus()
@@ -523,10 +503,9 @@ export function RunFooterView(props: RunFooterViewProps) {
return footerStatuslinePolicy({
width: width(),
mainWidth: statuslineMainWidth(),
commandWidth: commandHint() ? commandHintWidth() : undefined,
agentWidth: agent ? stringWidth(agent) : undefined,
contextWidths: contextHintCandidates().map((item) => stringWidth(`${item.key} ${item.label}`)),
modelWidth: info ? stringWidth(info.model) : undefined,
modelWidth: info ? stringWidth(`${info.model}${info.provider ? ` ${info.provider}` : ""}`) : undefined,
variantWidth: info?.variant ? stringWidth(` ${info.variant}`) : undefined,
usageWidth: activityMeta() ? stringWidth(activityMeta()) : undefined,
})
@@ -681,6 +660,7 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onLayout({
route: route(),
subagentRows: subagentMenuRows(),
activityRows: leadingActivity() ? 2 : 0,
})
})
@@ -702,6 +682,27 @@ export function RunFooterView(props: RunFooterViewProps) {
when={inspecting()}
fallback={
<box width="100%" flexDirection="column" gap={0}>
<Show when={leadingActivity()}>
<box width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
<box
id="mini-activity"
width="100%"
height={1}
flexDirection="row"
gap={1}
flexShrink={0}
backgroundColor="transparent"
>
<box id="mini-activity-animation" flexShrink={0}>
<spinner color={activity().color} frames={activity().frames} interval={ACTIVITY_INTERVAL} />
</box>
<text wrapMode="none" truncate>
<Show when={interruptLabel()}>{(label) => <span style={{ fg: theme().text }}>{label()} </span>}</Show>
<span style={{ fg: theme().muted, dim: true }}>{statusText()}</span>
</text>
</box>
</Show>
<For each={[promptView()]}>
{() => (
<box
@@ -734,6 +735,8 @@ export function RunFooterView(props: RunFooterViewProps) {
<RunPromptBody
theme={theme}
background={() => runTheme().background}
agentColor={agentColor}
mono={props.mono}
placeholder={composer.placeholder}
onSubmit={composer.onSubmit}
onKeyDown={composer.onKeyDown}
@@ -771,7 +774,7 @@ export function RunFooterView(props: RunFooterViewProps) {
</Match>
<Match when={commanding()}>
<RunCommandMenuBody
theme={theme}
theme={autocompleteTheme}
commands={props.commands}
subagents={tabs}
queued={queue}
@@ -844,7 +847,7 @@ export function RunFooterView(props: RunFooterViewProps) {
</Match>
<Match when={modeling()}>
<RunModelSelectBody
theme={theme}
theme={autocompleteTheme}
providers={props.providers}
current={props.currentModel}
onClose={closePanel}
@@ -922,7 +925,7 @@ export function RunFooterView(props: RunFooterViewProps) {
<Show when={!panel() && menu()}>
<RunFooterMenu
theme={theme}
theme={autocompleteTheme}
items={composer.options}
selected={composer.selected}
offset={composer.offset}
@@ -936,13 +939,56 @@ export function RunFooterView(props: RunFooterViewProps) {
<Show when={!panel() && !menu()}>
<box
id="mini-statusline"
width="100%"
height={1}
flexDirection="row"
gap={0}
flexShrink={0}
backgroundColor={statuslineBackground()}
backgroundColor="transparent"
>
<Show when={statuslineLayout().showAgent && agentStatus()}>
{(agent) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={agentColor()} wrapMode="none">
{agent()}
</text>
</box>
)}
</Show>
<Show when={statuslineLayout().showModel && modelStatus()}>
{(info) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text wrapMode="none">
<Show when={statuslineLayout().showAgent}>
<span style={{ fg: theme().muted, dim: true }}>· </span>
</Show>
<span style={{ fg: theme().text }}>{info().model}</span>
<Show when={statuslineLayout().showVariant && info().variant}>
{(variant) => <span style={{ fg: theme().warning, bold: true }}> {variant()}</span>}
</Show>
<Show when={info().provider}>
{(provider) => <span style={{ fg: theme().muted, dim: true }}> {provider()}</span>}
</Show>
</text>
</box>
)}
</Show>
<Show when={statuslineLayout().showUsage && activityMeta()}>
{(usage) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text wrapMode="none">
<span style={{ fg: theme().muted, dim: true }}>
{props.mono ? "- " : "· "}
{usage()}
</span>
</text>
</box>
)}
</Show>
<Show when={visibleModeLabel()}>
{(label) => (
<box
@@ -969,67 +1015,11 @@ export function RunFooterView(props: RunFooterViewProps) {
backgroundColor="transparent"
overflow="hidden"
>
<Show
when={
footerDetails() &&
busy() &&
!exiting() &&
statuslineMainAvailable() >=
(props.mono ? 1 : 2) + stringWidth(spin().frames[0] ?? "") + 1 + stringWidth(statuslineText())
}
>
<box flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} />
</box>
</Show>
<text fg={statusColor()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
<Show when={busy() && !exiting() && (footerDetails() || armed())} fallback={statusText()}>
<Show when={interruptLabel()}>
{(label) => <span style={{ fg: armed() ? statusColor() : theme().muted }}>{label()} </span>}
</Show>
{statusText()}
</Show>
{statuslineText()}
</text>
</box>
<Show when={statuslineLayout().showUsage && activityMeta()}>
{(usage) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().muted} wrapMode="none">
{usage()}
</text>
</box>
)}
</Show>
<Show when={statuslineLayout().showAgent && agentStatus()}>
{(agent) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
<Show when={statuslineLayout().showUsage}>{sectionSeparator()}</Show>
{agent()}
</text>
</box>
)}
</Show>
<Show when={statuslineLayout().showModel && modelStatus()}>
{(info) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
<Show when={statuslineLayout().showUsage || statuslineLayout().showAgent}>
{sectionSeparator()}
</Show>
{info().model}
<Show when={statuslineLayout().showVariant && info().variant}>
{(variant) => <span style={{ fg: theme().warning, bold: true }}> {variant()}</span>}
</Show>
</text>
</box>
)}
</Show>
<For each={contextHints()}>
{(hint, index) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
@@ -1041,17 +1031,6 @@ export function RunFooterView(props: RunFooterViewProps) {
</box>
)}
</For>
<Show when={commandHint()}>
{(hint) => (
<box backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
<Show when={hasStatuslineInfo() || contextHints().length > 0}>{sectionSeparator()}</Show>
<span style={{ fg: theme().text }}>{hint().key}</span>{" "}
<span style={{ fg: theme().muted }}>{hint().label}</span>
</text>
</box>
)}
</Show>
</box>
</Show>
</box>
@@ -1065,7 +1044,7 @@ export function RunFooterView(props: RunFooterViewProps) {
borderColor={theme().highlight}
customBorderChars={{
...EMPTY_BORDER,
vertical: props.mono ? "|" : "",
vertical: props.mono ? "|" : "",
}}
>
<RunFooterSubagentBody
+1 -1
View File
@@ -29,8 +29,8 @@ export function footerStatuslinePolicy(input: {
return true
}
const showModel = include(input.modelWidth)
const showAgent = include(input.agentWidth)
const showModel = include(input.modelWidth)
const hiddenContext = input.contextWidths.findIndex((width) => !include(width))
const contextCount = hiddenContext === -1 ? input.contextWidths.length : hiddenContext
const contextComplete = contextCount === input.contextWidths.length
+9 -8
View File
@@ -121,13 +121,6 @@ function splashInfo(title: string | undefined, history: RunPrompt[]) {
}
}
function directoryLabel(directory: string, home: string) {
const resolved = path.resolve(directory)
const display =
resolved === home ? "~" : resolved.startsWith(`${home}${path.sep}`) ? resolved.replace(home, "~") : resolved
return display.replaceAll("\\", "/")
}
function queueSplash(
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
state: SplashState,
@@ -204,6 +197,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
theme: theme.splash,
showSession: splash.showSession,
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
version: input.host.startup.version,
mono,
})
: undefined,
@@ -221,7 +215,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
agents: input.agents,
references: input.references,
agent: input.agent,
modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "Default model",
modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "",
model: input.model,
variant: input.variant,
first: input.first,
@@ -389,6 +383,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
theme: footer.currentTheme().splash,
showSession: splash.showSession,
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
version: input.host.startup.version,
mono,
}),
)
@@ -398,3 +393,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
close,
}
}
function directoryLabel(directory: string, home: string) {
const resolved = path.resolve(directory)
const display =
resolved === home ? "~" : resolved.startsWith(`${home}${path.sep}`) ? resolved.replace(home, "~") : resolved
return display.replaceAll("\\", "/")
}
+4 -2
View File
@@ -19,7 +19,7 @@ import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { turnSummaryCommit } from "./turn-summary"
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
import { type RunTheme } from "./theme"
import type { RunEntryBody, StreamCommit } from "./types"
import type { RunEntryBody, StreamCommit, TurnSummary } from "./types"
type ActiveBody = Exclude<RunEntryBody, { type: "none" | "structured" }>
@@ -158,6 +158,7 @@ export class RunScrollbackStream {
? new TextRenderable(surface.renderContext, {
content: "",
width: "100%",
paddingLeft: commit.kind === "assistant" ? 2 : 0,
wrapMode: "word",
fg: style.fg,
attributes: style.attrs,
@@ -178,6 +179,7 @@ export class RunScrollbackStream {
content: "",
syntaxStyle: entrySyntax(this.theme),
width: "100%",
paddingLeft: commit.kind === "assistant" ? 2 : 0,
streaming: true,
internalBlockMode: "top-level",
tableOptions: this.mono ? monoMarkdownTableOptions : { widthMode: "content" },
@@ -421,7 +423,7 @@ export class RunScrollbackStream {
this.markRendered(await this.finishActive(trailingNewline))
}
public async writeTurnSummary(input: { agent: string; model: string; duration: string }): Promise<void> {
public async writeTurnSummary(input: TurnSummary): Promise<void> {
await this.append(turnSummaryCommit(input))
}
+34 -5
View File
@@ -6,7 +6,7 @@ import {
type ScrollbackRenderContext,
type ScrollbackWriter,
} from "@opentui/core"
import { Match, Switch, createMemo } from "solid-js"
import { Match, Show, Switch, createMemo } from "solid-js"
import { entryBody, entryFlags } from "./entry.body"
import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
@@ -93,6 +93,17 @@ export function RunEntryContent(props: {
const next = body()
return next.type === "text" ? next : undefined
})
const user = createMemo(() => {
const value = text()?.content
if (props.commit.kind !== "user" || !value) return
const lead = value.match(/^\n+/)?.[0] ?? ""
const content = value.slice(lead.length)
return {
lead,
marker: content[0] ?? "",
content: content.slice(1),
}
})
const code = createMemo(() => {
const next = body()
return next.type === "code" ? next : undefined
@@ -134,8 +145,22 @@ export function RunEntryContent(props: {
</box>
</Match>
<Match when={text()}>
<text width="100%" wrapMode="word" fg={style().fg} attributes={style().attrs}>
{text()!.content}
<text
width="100%"
paddingLeft={props.commit.kind === "assistant" ? 2 : 0}
wrapMode="word"
fg={style().fg}
attributes={style().attrs}
>
<Show when={user()} fallback={text()!.content}>
{(value) => (
<>
{value().lead}
<span style={{ fg: props.commit.agentColor ?? style().fg }}>{value().marker}</span>
{value().content}
</>
)}
</Show>
</text>
</Match>
<Match when={code()}>
@@ -258,6 +283,7 @@ export function RunEntryContent(props: {
if (props.opts?.mono) monoMarkdownRenderable(renderable)
}}
width="100%"
paddingLeft={props.commit.kind === "assistant" ? 2 : 0}
syntaxStyle={syntax()}
streaming={streaming()}
content={markdown()!.content}
@@ -307,10 +333,13 @@ export function turnSummaryWriter(input: TurnSummary & { theme: RunTheme; mono?:
() => (
<box width="100%" height={1}>
<text wrapMode="none" truncate>
<span style={{ fg: input.agentColor ?? input.theme.block.text }}>{input.mono ? "#" : "▣"}</span>{" "}
<span style={{ fg: input.theme.block.text }}>{input.agent}</span>
<span style={{ fg: input.theme.block.muted }}>
<span style={{ fg: input.theme.block.muted, dim: true }}> {input.mono ? "-" : "·"} </span>
<span style={{ fg: input.theme.block.muted, dim: true }}>{input.model}</span>
<span style={{ fg: input.theme.block.muted, dim: true }}>
{" "}
{input.mono ? "-" : "·"} {input.model} {input.mono ? "-" : "·"} {input.duration}
{input.mono ? "-" : "·"} {input.duration}
</span>
</text>
</box>
+19 -29
View File
@@ -1,8 +1,8 @@
// Entry and exit splash banners for direct interactive mode scrollback.
//
// Renders the full opencode entry logo and a compact [O] exit badge, plus
// session metadata and the resume command. These are scrollback snapshots, so
// they become immutable terminal history once committed.
// Renders a compact entry mark and the full opencode exit badge, plus session
// metadata and the resume command. These are scrollback snapshots, so they
// become immutable terminal history once committed.
//
// Both variants use a cell-based renderer. cells() classifies each character
// in the source template as text, full-block, half-block-mix, or
@@ -35,6 +35,7 @@ type SplashWriterInput = SplashInput & {
theme: RunSplashTheme
showSession?: boolean
detail?: string
version?: string
}
export type SplashMeta = {
@@ -183,33 +184,22 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
let height = 1
if (kind === "entry") {
const mark = input.mono ? ["[O]"] : go.right.slice(1)
const top = 1
const body_left = (mark[0]?.length ?? 0) + 2
for (let i = 0; i < mark.length; i += 1) {
draw(lines, mark[i] ?? "", {
left: 0,
top: top + i,
fg: left,
shadow: leftShadow,
})
}
push(lines, body_left, top, "OpenCode", right, undefined, TextAttributes.BOLD)
if (input.detail) {
push(
lines,
body_left,
top + 1,
input.mono
? monoTruncateMiddle(input.detail, Math.max(1, width - body_left), true)
: Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
left,
undefined,
)
}
height = top + Math.max(mark.length, input.detail ? 2 : 1)
const mark = input.mono ? "[O]" : "◼"
const body_left = mark.length + 1
const label = "oc mini"
const version = input.version ? `v${input.version}` : ""
const version_left = body_left + label.length + (version ? 1 : 0)
const detail_left = version_left + version.length
const separator = input.mono ? " - " : " · "
const detail = input.mono
? monoTruncateMiddle(input.detail ?? "", Math.max(1, width - detail_left - separator.length), true)
: Locale.truncateMiddle(input.detail ?? "", Math.max(1, width - detail_left - separator.length))
push(lines, 0, top, mark, right)
push(lines, body_left, top, label, right)
if (version) push(lines, version_left, top, version, right, undefined, TextAttributes.DIM)
push(lines, detail_left, top, `${separator}${detail}`, left, undefined, TextAttributes.DIM)
height = top + 1
}
if (kind === "exit") {
+50 -3
View File
@@ -11,7 +11,7 @@ import { ansiToRgba } from "../theme/color"
import { resolveThemeColors } from "../theme/resolve"
import { terminalMode } from "../theme/system"
import type { ThemeV1Json } from "../theme/v1"
import type { EntryKind, RunTuiConfig } from "./types"
import type { EntryKind, RunAgent, RunTuiConfig } from "./types"
type Tone = {
body: ColorInput
@@ -28,6 +28,8 @@ export type RunSplashTheme = {
export type RunFooterTheme = {
highlight: ColorInput
cursor: ColorInput
agent: readonly ColorInput[]
selected: ColorInput
selectedText: ColorInput
warning: ColorInput
@@ -66,6 +68,25 @@ export type RunTheme = {
block: RunBlockTheme
}
export function resolveAgentColor(
theme: RunFooterTheme,
agents: RunAgent[],
current: string | undefined,
mono = false,
) {
if (mono) return theme.text
const visible = agents.filter((agent) => !agent.hidden)
const index = visible.findIndex((agent) => agent.id === current)
const agent = visible[index]
if (agent?.color) return RGBA.fromHex(agent.color)
return theme.agent[Math.max(0, index) % theme.agent.length]!
}
export function resolveAgentSelectionColor(color: ColorInput, fallback: ColorInput, mono = false) {
if (mono || !(color instanceof RGBA)) return fallback
return tint(color, RGBA.fromInts(0, 0, 0), 0.55)
}
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
type SharedSyntaxTheme = TuiThemeCurrent & {
@@ -367,6 +388,8 @@ function map(
footerTheme: TuiThemeCurrent,
scrollbackTheme: TuiThemeCurrent,
splash: RunSplashTheme,
agent: readonly ColorInput[],
cursor: ColorInput,
syntax?: SyntaxStyle,
): RunTheme {
const footerBackground = alpha(footerTheme.background, 1)
@@ -386,6 +409,8 @@ function map(
background: footerTheme.background,
footer: {
highlight: footerTheme.primary,
cursor,
agent,
selected: footerTheme.backgroundElement,
selectedText: footerTheme.selectedListItemText,
warning: footerTheme.warning,
@@ -441,12 +466,15 @@ function map(
const seed = {
highlight: RGBA.fromIndex(6, rgba("#38bdf8")),
secondary: RGBA.fromIndex(4, rgba("#5c9cf5")),
accent: RGBA.fromIndex(5, rgba("#9d7cd8")),
muted: RGBA.fromIndex(8, rgba("#64748b")),
text: RGBA.defaultForeground(rgba("#f8fafc")),
panel: rgba("#0f172a"),
success: RGBA.fromIndex(2, rgba("#22c55e")),
warning: RGBA.fromIndex(3, rgba("#f59e0b")),
error: RGBA.fromIndex(1, rgba("#ef4444")),
info: RGBA.fromIndex(6, rgba("#56b6c2")),
}
function tone(body: ColorInput, start?: ColorInput): Tone {
@@ -464,6 +492,8 @@ export const RUN_THEME_FALLBACK: RunTheme = {
background: RGBA.fromValues(0, 0, 0, 0),
footer: {
highlight: seed.highlight,
cursor: seed.warning,
agent: [seed.secondary, seed.accent, seed.success, seed.warning, seed.highlight, seed.error, seed.info],
selected: seed.text,
selectedText: seed.panel,
warning: seed.warning,
@@ -513,6 +543,8 @@ function monoTheme(mode: "dark" | "light"): RunTheme {
background,
footer: {
highlight: foreground,
cursor: foreground,
agent: [foreground],
selected: background,
selectedText: foreground,
warning: foreground,
@@ -586,15 +618,30 @@ export async function resolveRunTheme(
config?.mode === "dark" || config?.mode === "light"
? config.mode
: (terminalMode(colors) ?? renderer.themeMode ?? colorMode(RGBA.fromHex(bg)))
const { generateSyntax } = await import("../theme")
const { allThemes, generateSyntax, parseTheme, resolveThemeDocument } = await import("../theme")
const indexed = indexedPalette(colors, 256)
const footerTheme = resolveTheme(generateSystem(colors, pick), pick)
const scrollbackTheme = quantizeTheme(footerTheme, indexed)
const name = config?.name ?? "opencode"
const source = allThemes()[name]
const selected = source ? resolveThemeDocument(parseTheme(source, name), pick) : undefined
const agent = selected
? selected.categorical
.map((scale) => scale[pick === "light" ? 800 : 200])
.filter((color, index, items) => items.findIndex((item) => item.equals(color)) === index)
: [4, 5, 2, 3, 6, 1].map((index) => paletteColor(colors, index))
const syntaxTheme: SharedSyntaxTheme = {
...scrollbackTheme,
_hasSelectedListItemText: true,
}
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), generateSyntax(syntaxTheme))
return map(
footerTheme,
scrollbackTheme,
splashTheme(scrollbackTheme, indexed),
agent,
selected?.text.formfield.focused ?? footerTheme.text,
generateSyntax(syntaxTheme),
)
} catch {
return RUN_THEME_FALLBACK
}
+3 -7
View File
@@ -1,11 +1,6 @@
import type { StreamCommit } from "./types"
import type { StreamCommit, TurnSummary } from "./types"
export function turnSummaryCommit(input: {
agent: string
model: string
duration: string
messageID?: string
}): StreamCommit {
export function turnSummaryCommit(input: TurnSummary & { messageID?: string }): StreamCommit {
return {
kind: "system",
text: `${input.agent} · ${input.model} · ${input.duration}`,
@@ -13,6 +8,7 @@ export function turnSummaryCommit(input: {
source: "system",
summary: {
agent: input.agent,
agentColor: input.agentColor,
model: input.model,
duration: input.duration,
},
+5 -1
View File
@@ -22,7 +22,7 @@ import type {
SessionMessageAssistantTool,
} from "@opencode-ai/client/promise"
import type { Config } from "../config"
import type { CliRenderer } from "@opentui/core"
import type { CliRenderer, ColorInput } from "@opentui/core"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
export type RunFilePart = {
@@ -103,6 +103,7 @@ export type RunAgent = {
description?: string
mode: "subagent" | "primary" | "all"
hidden: boolean
color?: string
}
export type RunReference = ReferenceListOutput["data"][number]
@@ -148,6 +149,7 @@ export type MiniHost = {
}
}
startup: {
version: string
showTiming: boolean
now(): number
}
@@ -186,6 +188,7 @@ export type FooterPatch = Partial<FooterState>
export type TurnSummary = {
agent: string
agentColor?: ColorInput
model: string
duration: string
}
@@ -434,6 +437,7 @@ export type StreamCommit = {
interrupted?: boolean
toolState?: StreamToolState
toolError?: string
agentColor?: ColorInput
shell?: {
command: string
}
+4
View File
@@ -0,0 +1,4 @@
export const DEFAULT_PROMPT_PLACEHOLDERS = {
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
shell: ["ls -la", "git status", "pwd"],
}
+2 -5
View File
@@ -11,12 +11,9 @@ import { useLocation } from "../context/location"
import { FormPrompt } from "./session/form"
import { Slot } from "../plugin/render"
import { useTerminalDimensions } from "@opentui/solid"
import { DEFAULT_PROMPT_PLACEHOLDERS } from "../prompt/placeholders"
let once = false
const placeholder = {
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
shell: ["ls -la", "git status", "pwd"],
}
export function Home() {
const route = useRouteData("home")
@@ -86,7 +83,7 @@ export function Home() {
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<Prompt ref={bind} placeholders={placeholder} disabled={forms().length > 0} />
<Prompt ref={bind} placeholders={DEFAULT_PROMPT_PLACEHOLDERS} disabled={forms().length > 0} />
</box>
<box flexGrow={1} minHeight={0} />
</box>
+32 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import { loadRunReferences, runProviders } from "../../src/mini/catalog.shared"
import { loadRunAgents, loadRunReferences, runProviders } from "../../src/mini/catalog.shared"
import { catalogModel, catalogProvider } from "./fixture/catalog"
afterEach(() => {
@@ -8,6 +8,37 @@ afterEach(() => {
})
describe("run catalog shared", () => {
test("preserves configured agent colors", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.agent, "list").mockImplementation(
() =>
Promise.resolve({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: [
{
id: "build",
name: "Build",
description: "Default agent",
mode: "primary",
hidden: false,
color: "#5c9cf5",
},
],
}) as never,
)
expect(await loadRunAgents(client, { directory: "/tmp" })).toEqual([
{
id: "build",
name: "Build",
description: "Default agent",
mode: "primary",
hidden: false,
color: "#5c9cf5",
},
])
})
test("loads visible project references from the current reference catalog", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const list = spyOn(client.reference, "list").mockImplementation(
+1 -1
View File
@@ -117,7 +117,7 @@ describe("run entry body", () => {
),
).toEqual({
type: "text",
content: " Inspect footer tabs",
content: " Inspect footer tabs",
})
expect(
entryBody(commit({ kind: "user", text: "Inspect footer tabs", phase: "start", source: "system" }), {
@@ -58,6 +58,7 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
currentAgent={() => "Build"}
currentAgentID={() => "build"}
currentAgentExplicit={() => false}
activeAgentColor={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
+250 -80
View File
@@ -19,9 +19,10 @@ import {
} from "../../src/mini/footer.command"
import { RunFooterView } from "../../src/mini/footer.view"
import { RunEntryContent } from "../../src/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
import { RUN_THEME_FALLBACK, resolveAgentSelectionColor, type RunTheme } from "../../src/mini/theme"
import type {
FooterQueuedPrompt,
FooterPromptRoute,
FooterState,
FooterSubagentState,
FooterSubagentTab,
@@ -110,6 +111,7 @@ function footerState(input: Partial<FooterState> = {}) {
async function renderFooter(
input: {
tuiConfig?: RunTuiConfig
agents?: RunAgent[]
commands?: RunCommand[]
theme?: () => RunTheme
providers?: RunProvider[]
@@ -128,6 +130,7 @@ async function renderFooter(
mono?: boolean
onStatus?: (status: string) => void
onMiniSettingChange?: (change: MiniSettingChange) => void
onLayout?: (input: { route: FooterPromptRoute; subagentRows: number; activityRows: number }) => void
queuedPrompts?: FooterQueuedPrompt[]
onQueuedPromptAction?: (action: "steer" | "cancel", inboxID: string) => Promise<void>
} = {},
@@ -154,13 +157,14 @@ async function renderFooter(
<RunFooterView
directory={() => "/tmp"}
findFiles={async () => []}
agents={() => []}
agents={() => input.agents ?? []}
references={() => []}
commands={() => input.commands ?? []}
providers={() => input.providers}
currentAgent={() => input.currentAgent ?? "Build"}
currentAgentID={() => input.currentAgent?.toLowerCase() ?? "build"}
currentAgentExplicit={() => input.currentAgent !== undefined}
activeAgentColor={() => undefined}
currentModel={() => input.currentModel}
variants={() => []}
currentVariant={() => input.currentVariant}
@@ -185,7 +189,7 @@ async function renderFooter(
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onLayout={(value) => input.onLayout?.(value)}
onStatus={(status) => input.onStatus?.(status)}
onMiniSettingChange={(change) => input.onMiniSettingChange?.(change)}
/>
@@ -214,13 +218,58 @@ async function renderFooter(
}
}
test("direct footer shows the default model without the fallback agent", async () => {
test("direct footer shows the default model and agent", async () => {
const app = await renderFooter({ state: { model: "Default model" } })
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Default model")
expect(frame).not.toContain("Build")
expect(frame).toContain("Build · Default model")
expect(
frame
.split("\n")
.find((line) => line.includes("Default model"))
?.startsWith("Build · Default model"),
).toBe(true)
expect(footerStatusline(app.renderer.root).backgroundColor.a).toBe(0)
expect(frame).toContain("Build")
} finally {
app.cleanup()
}
})
test("direct footer orders agent, model provider, and usage from the left", async () => {
const app = await renderFooter({
providers: [provider()],
currentModel: { providerID: "opencode", modelID: "gpt-5" },
state: { usage: "159.6K (16%) · $4.23" },
width: 160,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Build · GPT-5 opencode · 159.6K (16%) · $4.23")
} finally {
app.cleanup()
}
})
test("direct footer prefixes the composer with the current agent marker", async () => {
const app = await renderFooter({
agents: [
{
id: "build",
name: "Build",
description: "Default agent",
mode: "primary",
hidden: false,
color: "#5c9cf5",
},
],
state: { first: true },
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("│ Ask anything, / for commands, @ for context…")
} finally {
app.cleanup()
}
@@ -297,20 +346,31 @@ function boxPath(root: BoxRenderable | RootRenderable, name: string): BoxRendera
}
}
function hasBackground(root: BoxRenderable | RootRenderable, color: RGBA) {
const boxes = root.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
for (const box of boxes) {
if (box.backgroundColor.toInts().every((value, index) => value === color.toInts()[index])) return true
boxes.push(...box.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable))
}
return false
}
function footerComposerFrame(root: BoxRenderable | RootRenderable) {
return boxPath(root, "TextareaRenderable")!.at(-5)!
}
function footerStatusline(root: BoxRenderable | RootRenderable) {
const status = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
function footerRow(root: BoxRenderable | RootRenderable, id: string) {
const boxes = root.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
for (const box of boxes) {
if (box.backgroundColor?.toInts().every((value, index) => value === status[index])) return box
if (box.id === id) return box
boxes.push(...box.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable))
}
throw new Error("Footer statusline not found")
throw new Error(`Footer row not found: ${id}`)
}
const footerStatusline = (root: BoxRenderable | RootRenderable) => footerRow(root, "mini-statusline")
const footerActivity = (root: BoxRenderable | RootRenderable) => footerRow(root, "mini-activity")
function panelMenu(root: BoxRenderable | RootRenderable) {
const panel = child(child(root, 0), 0)
const content = child(panel, 0)
@@ -410,9 +470,9 @@ test("run entry content preserves monochrome markdown grammar", async () => {
.captureCharFrame()
.split("\n")
.map((row) => row.trimEnd())
expect(rows).toContain("* literal")
expect(rows).toContain("------")
expect(rows).toContain("arrow ->")
expect(rows).toContain(" * literal")
expect(rows).toContain(" ------")
expect(rows).toContain(" arrow ->")
expect(rows.join("\n")).not.toMatch(/[^\x00-\x7f]/)
setCommit({ ...commit(), text: "- Café\n- arrow →\n- third …" })
@@ -510,9 +570,18 @@ test("direct command panel renders grouped actions without catalog commands", as
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Commands")
expect(frame).toMatch(/^ {2}Commands/m)
expect(frame).toContain("Search")
expect(app.renderer.currentFocusedEditor?.cursorColor.toInts()).toEqual(
(RUN_THEME_FALLBACK.footer.text as RGBA).toInts(),
)
expect(frame).not.toContain("Commands")
expect(frame).toContain("search commands")
expect(
frame
.split("\n")
.find((line) => line.includes("search commands"))
?.trimEnd()
.endsWith("esc"),
).toBe(true)
expect(frame).toContain("Session")
expect(frame).toContain("Agent")
expect(frame).toContain("Prompt")
@@ -525,13 +594,12 @@ test("direct command panel renders grouped actions without catalog commands", as
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("")
expect(frame).not.toContain("/internal")
expect(frame).not.toContain("Choose model for future turns")
expect(frame).not.toContain("Cycle reasoning effort for future turns")
expect(frame).not.toContain("Review code")
expect(frame).not.toContain("Commands 8")
await app.mockInput.typeText("agent")
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Switch agent")
@@ -865,7 +933,7 @@ test("direct subagent panel toggles between active and inactive subagents", asyn
expect(frame).not.toContain("done")
expect(frame).toContain("tab show inactive")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("")
expect(frame).not.toContain("")
expectPaletteList(list, 0)
expect(rows).toBe(7)
@@ -956,7 +1024,7 @@ test("direct queued panel steers and deletes selected prompts", async () => {
expect(frame).toContain("queued")
expect(frame).toContain("enter steer · ctrl+d delete")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("")
expect(frame).not.toContain("")
expectPaletteList(list, 0)
app.mockInput.pressEnter()
app.mockInput.pressKey("d", { ctrl: true })
@@ -1063,7 +1131,7 @@ test.skip("direct footer recreates the frame across command panel transitions",
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("")
expect(app.captureCharFrame()).not.toContain("█")
}
} finally {
@@ -1124,6 +1192,13 @@ test("direct footer submits slash autocomplete selections without dispatching sh
await app.renderOnce()
"/rev".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
expect(
app
.captureCharFrame()
.split("\n")
.find((line) => line.includes("/review"))
?.startsWith(" /review"),
).toBe(true)
app.mockInput.pressEnter()
await app.renderOnce()
@@ -1172,6 +1247,106 @@ test("direct footer submits slash autocomplete selections without dispatching sh
}
})
test("direct footer indents mention autocomplete entries", async () => {
const app = await renderFooter({
agents: [
{
id: "review",
name: "Review",
description: "Review changes",
mode: "subagent",
hidden: false,
},
],
})
try {
app.mockInput.pressKey("@")
await app.renderOnce()
expect(
app
.captureCharFrame()
.split("\n")
.find((line) => line.includes("@Review"))
?.startsWith(" @Review"),
).toBe(true)
} finally {
app.cleanup()
}
})
test("direct footer uses the dark build-agent color for autocomplete and command selections", async () => {
const agents: RunAgent[] = [
{
id: "build",
name: "Build",
description: "Default agent",
mode: "primary",
hidden: false,
color: "#5c9cf5",
},
{
id: "review",
name: "Review",
description: "Review changes",
mode: "subagent",
hidden: false,
},
]
const selected = resolveAgentSelectionColor(
RGBA.fromHex("#5c9cf5"),
RUN_THEME_FALLBACK.footer.selected,
) as RGBA
const inputs = [
(app: Awaited<ReturnType<typeof renderFooter>>) => app.mockInput.pressKey("@"),
(app: Awaited<ReturnType<typeof renderFooter>>) => app.mockInput.pressKey("/"),
(app: Awaited<ReturnType<typeof renderFooter>>) => app.mockInput.pressKey("p", { ctrl: true }),
]
for (const open of inputs) {
const app = await renderFooter({ agents })
try {
open(app)
await app.renderOnce()
expect(hasBackground(app.renderer.root, selected)).toBe(true)
} finally {
app.cleanup()
}
}
})
test("direct footer uses the dark build-agent color in the model picker", async () => {
const app = await renderFooter({
agents: [
{
id: "build",
name: "Build",
description: "Default agent",
mode: "primary",
hidden: false,
color: "#5c9cf5",
},
],
providers: [provider()],
})
try {
app.mockInput.pressKey("p", { ctrl: true })
await app.renderOnce()
await app.mockInput.typeText("switch model")
app.mockInput.pressEnter()
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Select model")
expect(
hasBackground(
app.renderer.root,
resolveAgentSelectionColor(RGBA.fromHex("#5c9cf5"), RUN_THEME_FALLBACK.footer.selected) as RGBA,
),
).toBe(true)
} finally {
app.cleanup()
}
})
test("direct footer slash autocomplete keeps a real skills command", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
@@ -1406,7 +1581,7 @@ test.skip("direct footer clears the synthetic skills draft when the panel closes
}
})
test("direct footer shows authoritative queued work while running", async () => {
test("direct footer keeps status metadata below activity while running", async () => {
const [state] = createSignal<FooterState>({
phase: "running",
status: "",
@@ -1437,6 +1612,7 @@ test("direct footer shows authoritative queued work while running", async () =>
currentAgent={() => "Build"}
currentAgentID={() => "build"}
currentAgentExplicit={() => false}
activeAgentColor={() => undefined}
currentModel={() => ({
providerID: "opencode",
modelID: "a-model-name-long-enough-to-force-responsive-truncation",
@@ -1500,28 +1676,25 @@ test("direct footer shows authoritative queued work while running", async () =>
await app.renderOnce()
const frame = app.captureCharFrame()
const transparent = RGBA.fromValues(0, 0, 0, 0).toInts()
const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
const statusline = footerStatusline(app.renderer.root)
const statusItems = statusline.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
const main = statusItems[0]
const spinner = main.getChildren()[0]
const background = statusItems[2]
const queued = statusItems[3]
const hint = statusItems.at(-1)!
const activity = footerActivity(app.renderer.root)
const boxes = [activity]
boxes.forEach((box) =>
boxes.push(...box.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)),
)
expect(spinner).toBeDefined()
expect(frame).toContain("esc interrupt")
expect(frame.match(/interrupt/g)).toHaveLength(1)
expect(footerRow(activity, "mini-activity-animation")).toBeDefined()
expect(frame).toContain("1 queued")
expect(frame).toContain("ctrl+b background")
expect(frame).toContain("ctrl+x q 1 queued")
expect(frame).toContain("↓ subagents")
expect(frame).toContain("ctrl+p cmd")
expect(frame).toContain("subagents · ctrl+p cmd")
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
expect(frame).not.toContain("ctrl+p cmd")
expect(frame).not.toContain("1 agent")
expect(statusline.backgroundColor.toInts()).toEqual(tinted)
expect(main.backgroundColor.toInts()).toEqual(transparent)
expect(background.backgroundColor.toInts()).toEqual(transparent)
expect(queued.backgroundColor.toInts()).toEqual(transparent)
expect(hint.backgroundColor.toInts()).toEqual(transparent)
expect(
boxes.every((box) => box.backgroundColor.toInts().every((value, index) => value === transparent[index])),
).toBe(true)
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
@@ -1529,11 +1702,11 @@ test("direct footer shows authoritative queued work while running", async () =>
}
})
test("direct footer progressively adds model details after the command hint", async () => {
test("direct footer progressively adds model details", async () => {
for (const expected of [
{ width: 24, agent: false, model: false, variant: false },
{ width: 32, agent: false, model: true, variant: false },
{ width: 40, agent: true, model: true, variant: false },
{ width: 24, agent: true, model: false, variant: false },
{ width: 32, agent: true, model: true, variant: true },
{ width: 40, agent: true, model: true, variant: true },
{ width: 48, agent: true, model: true, variant: true },
]) {
const app = await renderFooter({
@@ -1548,18 +1721,18 @@ test("direct footer progressively adds model details after the command hint", as
const frame = app.captureCharFrame()
expect({
width: expected.width,
command: frame.includes("ctrl+p cmd"),
agent: frame.includes("Plan"),
model: frame.includes("GPT-5"),
variant: frame.includes("xhigh"),
}).toEqual({ ...expected, command: true })
}).toEqual(expected)
expect(frame).not.toContain("ctrl+p cmd")
} finally {
app.cleanup()
}
}
})
test("direct footer keeps commands and active work ahead of usage under width pressure", async () => {
test("direct footer keeps agent metadata below activity under width pressure", async () => {
const app = await renderFooter({
currentAgent: "Plan",
subagents: {
@@ -1580,11 +1753,10 @@ test("direct footer keeps commands and active work ahead of usage under width pr
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("esc interrupt")
expect(frame.match(/interrupt/g)).toHaveLength(1)
expect(frame).toContain("Plan")
expect(frame).toContain("ctrl+b background")
expect(frame).toContain("↓ subagents")
expect(frame).toContain("ctrl+p cmd")
expect(frame).not.toContain("a-model-name")
expect(frame).not.toContain("ctrl+p cmd")
expect(frame).not.toContain("159.6K")
expect(frame).not.toContain("$4.23")
} finally {
@@ -1592,28 +1764,29 @@ test("direct footer keeps commands and active work ahead of usage under width pr
}
})
test("direct footer keeps the command hint at its minimum width", async () => {
const app = await renderFooter({ state: { phase: "running" }, width: 10 })
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("ctrl+p cmd")
} finally {
app.cleanup()
}
})
test("direct footer keeps complete status text ahead of the spinner", async () => {
test("direct footer keeps the activity mark with complete status text", async () => {
let activityRows = 0
const app = await renderFooter({
tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none" } }),
state: { phase: "running" },
width: 22,
onLayout: (value) => (activityRows = value.activityRows),
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("◼")
expect(app.captureCharFrame()).toContain("interrupt")
expect(boxPath(footerStatusline(app.renderer.root), "SpinnerRenderable")).toBeUndefined()
expect(activityRows).toBe(2)
expect(
app
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("interrupt")),
).toBe(1)
app.setState((state) => ({ ...state, phase: "idle" }))
await app.renderOnce()
expect(activityRows).toBe(0)
} finally {
app.cleanup()
}
@@ -1634,7 +1807,8 @@ test("direct footer always offers backgrounding for a foreground subagent", asyn
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("ctrl+b background · ↓ subagents · ctrl+p cmd")
expect(frame).toContain("ctrl+b background · ↓ subagents")
expect(frame).not.toContain("ctrl+p cmd")
expect(frame).not.toContain("queued")
} finally {
app.cleanup()
@@ -1656,7 +1830,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("ctrl+p cmd")
expect(frame).not.toContain("ctrl+p cmd")
expect(frame).not.toContain("↓ subagents")
} finally {
app.cleanup()
@@ -1698,7 +1872,7 @@ test("direct footer shows full usage metadata when room is available", async ()
}
})
test("direct footer omits usage when it would fill the statusline", async () => {
test("direct footer keeps idle metadata below the activity row", async () => {
const app = await renderFooter({
state: { phase: "running", model: "GPT-5.6 SoL", usage: "8.4K (1%) · $0.01" },
currentVariant: "high",
@@ -1711,9 +1885,11 @@ test("direct footer omits usage when it would fill the statusline", async () =>
const frame = app.captureCharFrame()
expect(frame).toContain("esc interrupt")
expect(footerRow(footerActivity(app.renderer.root), "mini-activity-animation")).toBeDefined()
expect(frame).toContain("GPT-5.6 SoL high")
expect(frame).toContain("ctrl+p cmd")
expect(frame).not.toContain("8.4K")
expect(frame).not.toContain("ctrl+p cmd")
expect(frame).toContain("Build")
expect(frame).toContain("8.4K (1%) - $0.01")
} finally {
app.cleanup()
}
@@ -1740,7 +1916,7 @@ test("direct footer hides routine activity and shows explicit notices", async ()
try {
await app.renderOnce()
const initial = app.captureCharFrame()
expect(initial).toContain("ctrl+p cmd")
expect(initial).not.toContain("ctrl+p cmd")
expect(initial).not.toContain("Plan")
expect(initial).not.toContain("gpt-5")
expect(initial).not.toContain("159.6K")
@@ -1748,14 +1924,14 @@ test("direct footer hides routine activity and shows explicit notices", async ()
app.setState((state) => ({ ...state, phase: "running", status: "assistant responding" }))
await app.renderOnce()
const changed = app.captureCharFrame()
const statusline = footerStatusline(app.renderer.root)
const activity = footerActivity(app.renderer.root)
expect(changed).not.toContain("running")
expect(changed).not.toContain("assistant responding")
expect(changed).not.toContain("interrupt")
expect(changed).toContain("interrupt")
expect(changed).not.toContain("gpt-5")
expect(changed).not.toContain("159.6K")
expect(boxPath(statusline, "SpinnerRenderable")).toBeUndefined()
expect(footerRow(activity, "mini-activity-animation")).toBeDefined()
app.mockInput.pressKey("p", { ctrl: true })
await app.renderOnce()
@@ -1771,18 +1947,12 @@ test("direct footer hides routine activity and shows explicit notices", async ()
}
})
test("direct footer does not label normal mode as build", async () => {
test("direct footer does not show a normal-mode badge", async () => {
const app = await renderFooter()
try {
await app.renderOnce()
const statusline = app
.captureCharFrame()
.split("\n")
.find((line) => line.includes("cmd"))
expect(statusline).toBeDefined()
expect(statusline).not.toContain("BUILD")
expect(app.captureCharFrame()).not.toContain("BUILD")
} finally {
app.cleanup()
}
@@ -1872,7 +2042,7 @@ test("direct model panel renders current model selector", async () => {
expect(frame).toContain("GPT Free")
expect(frame).toContain("Free")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("")
expect(frame).not.toContain("")
expect(frame).not.toContain("Old Model")
expectPaletteList(list, 2)
@@ -1968,7 +2138,7 @@ test("direct variant panel renders current variant selector", async () => {
expect(frame).toContain("minimal")
expect(frame).toContain("current")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("")
expect(frame).not.toContain("")
expectPaletteList(list, 1)
} finally {
app.renderer.destroy()
+13 -1
View File
@@ -1,9 +1,21 @@
import { describe, expect, test } from "bun:test"
import { footerWidthPolicy } from "../../src/mini/footer.width"
import { footerStatuslinePolicy, footerWidthPolicy } from "../../src/mini/footer.width"
describe("run footer width", () => {
test("preserves the dialog breakpoint", () => {
expect(footerWidthPolicy(79).dialog.narrow).toBe(true)
expect(footerWidthPolicy(80).dialog.narrow).toBe(false)
})
test("prioritizes the agent before the model", () => {
expect(
footerStatuslinePolicy({
width: 17,
mainWidth: 10,
agentWidth: 5,
modelWidth: 8,
contextWidths: [],
}),
).toMatchObject({ showAgent: true, showModel: false })
})
})
+1 -1
View File
@@ -31,7 +31,7 @@ function host(): MiniHost {
sigint: { subscribe: () => () => {} },
sigusr2: { subscribe: () => () => {} },
},
startup: { showTiming: false, now: () => 0 },
startup: { version: "test", showTiming: false, now: () => 0 },
diagnostics: {},
preferences: {
resolveVariant: async () => undefined,
@@ -131,7 +131,7 @@ test("turn summary starts at the left edge", async () => {
const commits = claim(out.renderer)
try {
expect(renderRows(commits.at(-1)!)[0]).toBe("Build · Little Frank · 2.2s")
expect(renderRows(commits.at(-1)!)[0]).toBe("Build · Little Frank · 2.2s")
} finally {
destroy(commits)
}
@@ -140,6 +140,18 @@ test("turn summary starts at the left edge", async () => {
}
})
test("assistant responses are indented two columns", async () => {
const out = await setup()
await out.scrollback.append(assistant("Hello", "progress"))
await out.scrollback.complete()
const commits = claim(out.renderer)
try {
expect(renderRows(commits.at(-1)!)[0]).toBe(" Hello")
} finally {
destroy(commits)
}
})
test("theme swaps restyle active reasoning without resetting the stream", async () => {
const previousSyntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } })
const nextSyntax = SyntaxStyle.fromStyles({ default: { fg: "#abcdef" } })
@@ -257,7 +269,7 @@ test("renders monochrome scrollback as ASCII markdown", async () => {
const rendered = output.join("").replace(/ +\n/g, "\n")
expect(rendered).toContain("# H?ading ->")
expect(rendered).toContain('| "quote"')
expect(rendered).toContain("------------------------------------------------------------")
expect(rendered).toContain("----------------------------------------------------------")
expect(rendered).toContain("? ?")
expect(rendered).toContain("* literal")
expect(rendered).toContain("------")
@@ -538,7 +550,7 @@ test("inserts spacers for new visible groups", async () => {
try {
expect(commits).toHaveLength(2)
expect(renderCommit(commits[0]!).trim()).toBe("")
expect(renderCommit(commits[1]!).trim()).toBe(" use subagent to explore run.ts")
expect(renderCommit(commits[1]!).trim()).toBe(" use subagent to explore run.ts")
} finally {
destroy(commits)
}
@@ -645,7 +657,7 @@ test.skipIf(process.platform === "win32")(
take()
const output = lines.join("\n")
expect(output).toContain(" Hello you")
expect(output).toContain(" Hello you")
expect(output).toContain("Say hello.")
expect(output).toContain("Hello.")
} finally {
@@ -789,8 +801,8 @@ test("renders plain errors with one blank line before and after the error block"
take()
const output = lines.join("\n")
expect(output).toContain(" /fmt error\n\ndemo error event")
expect(output).toContain("demo error event\n\nnext line")
expect(output).toContain(" /fmt error\n\ndemo error event")
expect(output).toContain("demo error event\n\n next line")
expect(output).not.toContain("demo error event\n\n\nnext line")
} finally {
out.scrollback.destroy()
+53
View File
@@ -0,0 +1,53 @@
import { afterEach, expect, test } from "bun:test"
import { createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { entrySplash } from "../../src/mini/splash"
import { RUN_THEME_FALLBACK } from "../../src/mini/theme"
type Commit = {
snapshot: {
height: number
getRealCharBytes(addLineBreaks?: boolean): Uint8Array
destroy(): void
}
}
const active: TestRenderer[] = []
afterEach(() => {
for (const renderer of active.splice(0)) renderer.destroy()
})
async function render(mono: boolean) {
const out = await createTestRenderer({
width: 80,
screenMode: "split-footer",
footerHeight: 4,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
const renderer = out.renderer
active.push(renderer)
renderer.writeToScrollback(
entrySplash({
title: undefined,
session_id: "ses_test",
theme: RUN_THEME_FALLBACK.splash,
detail: "~/project",
version: "1.18.4",
mono,
}),
)
const queue = Reflect.get(renderer, "externalOutputQueue") as { claim(): Commit[] }
const commits = queue.claim()
const text = new TextDecoder().decode(commits[0]!.snapshot.getRealCharBytes(true)).replace(/ +\n/g, "\n")
commits.forEach((commit) => commit.snapshot.destroy())
return text
}
test("renders the compact Mini identity", async () => {
expect(await render(false)).toContain("◼ oc mini v1.18.4 · ~/project")
})
test("renders an ASCII compact identity in monochrome mode", async () => {
expect(await render(true)).toContain("[O] oc mini v1.18.4 - ~/project")
})
+22 -3
View File
@@ -1,10 +1,26 @@
import { expect, test } from "bun:test"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { RUN_THEME_MONO, RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme"
import { DEFAULT_THEMES } from "../../src/theme"
import {
RUN_THEME_MONO,
RUN_THEME_FALLBACK,
generateSystem,
resolveAgentSelectionColor,
resolveRunTheme,
resolveTheme,
} from "../../src/mini/theme"
import { DEFAULT_THEMES, parseTheme, resolveThemeDocument } from "../../src/theme"
const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
test("darkens the agent color for menu selections", () => {
expect((resolveAgentSelectionColor(RGBA.fromHex("#5c9cf5"), RGBA.fromHex("#ffffff")) as RGBA).toInts()).toEqual([
41, 70, 110, 255,
])
expect(resolveAgentSelectionColor(RGBA.fromHex("#5c9cf5"), RUN_THEME_MONO.footer.selected, true)).toBe(
RUN_THEME_MONO.footer.selected,
)
})
function terminalColors(input: Partial<TerminalColors> = {}): TerminalColors {
return {
palette: Array.from({ length: 256 }, (_, index) => input.palette?.[index] ?? palette[index % palette.length]!),
@@ -69,7 +85,7 @@ test("falls back when palette lookup fails", async () => {
expect(RUN_THEME_MONO.block.syntax).toBeUndefined()
for (const color of [
RUN_THEME_MONO.background,
...Object.values(RUN_THEME_MONO.footer),
...Object.entries(RUN_THEME_MONO.footer).flatMap(([key, value]) => (key === "agent" ? value : [value])),
...Object.values(RUN_THEME_MONO.splash),
...Object.values(RUN_THEME_MONO.entry).flatMap((tone) => [tone.body, tone.start].filter(Boolean)),
...Object.entries(RUN_THEME_MONO.block)
@@ -94,6 +110,7 @@ test("resolveTheme preserves Mini indexed color and result shape semantics", ()
test("returns syntax styles and indexed splash colors", async () => {
const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))
const regular = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode, "opencode"), "dark")
try {
expect(theme.block.syntax).toBeDefined()
@@ -104,6 +121,8 @@ test("returns syntax styles and indexed splash colors", async () => {
expectRgba(theme.footer.highlight)
expectRgba(theme.footer.statusAccent)
expectRgba(theme.footer.surface)
expect(expectRgba(theme.footer.agent[0]).toInts()).toEqual(regular.categorical[0]![200].toInts())
expect(expectRgba(theme.footer.cursor).toInts()).toEqual(regular.text.formfield.focused.toInts())
expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts())
} finally {
theme.block.syntax?.destroy()