mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 13:06:13 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1febd81548 |
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@ function runAgent(input: CurrentAgent): RunAgent {
|
||||
description: input.description,
|
||||
mode: input.mode,
|
||||
hidden: input.hidden,
|
||||
color: input.color,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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?.())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("\\", "/")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user