diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts index d0d72ce00273..94450f124257 100644 --- a/packages/opencode/src/cli/cmd/run/demo.ts +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -181,7 +181,7 @@ function showSubagent( callID: string label: string description: string - status: "running" | "completed" | "error" + status: "running" | "completed" | "cancelled" | "error" title?: string toolCalls?: number commits: StreamCommit[] diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/opencode/src/cli/cmd/run/entry.body.ts index bb058e8a37f6..205e14ce25c6 100644 --- a/packages/opencode/src/cli/cmd/run/entry.body.ts +++ b/packages/opencode/src/cli/cmd/run/entry.body.ts @@ -79,6 +79,13 @@ function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody { } export function entryFlags(commit: StreamCommit): EntryFlags { + if (commit.summary) { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + if (commit.kind === "user") { return { startOnNewLine: true, @@ -156,6 +163,10 @@ export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolea } export function entryBody(commit: StreamCommit): RunEntryBody { + if (commit.summary) { + return RUN_ENTRY_NONE + } + const raw = cleanRunText(commit.text) if (commit.kind === "user") { diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index 90ba6fc6734c..c35a2b6abc39 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -14,6 +14,8 @@ type PanelEntry = RunFooterMenuItem & { type CommandEntry = | (PanelEntry & { action: "model" }) + | (PanelEntry & { action: "editor" }) + | (PanelEntry & { action: "skill" }) | (PanelEntry & { action: "queued" }) | (PanelEntry & { action: "subagent" }) | (PanelEntry & { action: "variant.cycle" }) @@ -33,6 +35,10 @@ type VariantEntry = PanelEntry & { current: boolean } +type SkillEntry = PanelEntry & { + name: string +} + type SubagentEntry = PanelEntry & { sessionID: string current: boolean @@ -107,6 +113,10 @@ function subagentStatusLabel(status: FooterSubagentTab["status"]) { return "done" } + if (status === "cancelled") { + return "cancelled" + } + if (status === "error") { return "error" } @@ -203,94 +213,122 @@ function PanelShell(props: { inputRef: (input: InputRenderable) => void onQuery: (query: string) => void children: JSX.Element + dark?: boolean + chrome?: "default" | "minimal" }) { - return ( - + const background = () => (props.dark ? props.theme().shade : props.theme().surface) + const minimal = () => props.chrome === "minimal" + const content = ( + <> + - - - - {props.title} - - {props.countVisible !== false ? ( - - {countLabel(props.count, props.total, props.query)} - - ) : null} - - - esc + + {props.title} + + {props.countVisible !== false ? ( + + {countLabel(props.count, props.total, props.query)} + ) : null} + + + esc + + + + + { + props.inputRef(input) + input.traits = { status: "FILTER" } + queueMicrotask(() => { + if (!input.isDestroyed) { + input.focus() + } + }) + }} + /> + + + + {props.children} + + + ) + return ( + + {minimal() ? ( + + {content} - + ) : ( - + )} + {minimal() ? ( + + { - props.inputRef(input) - input.traits = { status: "FILTER" } - queueMicrotask(() => { - if (!input.isDestroyed) { - input.focus() - } - }) - }} + height={1} + border={["bottom"]} + borderColor={background()} + backgroundColor="transparent" + customBorderChars={HALF_BLOCK_BORDER} /> - - - {props.children} - - - + ) : ( - + customBorderChars={PANEL_BOTTOM_BORDER} + flexShrink={0} + > + + + )} ) } @@ -304,6 +342,8 @@ export function RunCommandMenuBody(props: { variantCycle: string onClose: () => void onModel: () => void + onEditor: () => void + onSkill: () => void onSubagent: () => void onQueued: () => void onVariant: () => void @@ -314,84 +354,116 @@ export function RunCommandMenuBody(props: { }) { let field: InputRenderable | undefined const [query, setQuery] = createSignal("") + const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill")) + const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length) const entries = createMemo(() => { - const builtins = ["new"] - return [ + const builtins = ["editor", "new"] + const session: CommandEntry[] = [ + { + action: "editor", + category: "Session", + display: "Open editor", + footer: "/editor", + keywords: "editor compose draft external editor", + }, + ...(props.subagents().length > 0 + ? [ + { + action: "subagent" as const, + category: "Session", + display: "View subagents", + footer: activeSubagentCount() > 0 ? `${activeSubagentCount()} active` : `${props.subagents().length} recent`, + keywords: props + .subagents() + .map((item) => `${item.label} ${item.description} ${item.title ?? ""}`) + .join(" "), + }, + ] + : []), + { + action: "slash", + category: "Session", + name: "new", + display: "New session", + footer: "/new", + keywords: "new session clear", + }, + ] + const prompt: CommandEntry[] = + props.commands() === undefined || skills().length > 0 + ? [ + { + action: "skill" as const, + category: "Prompt", + display: "Skills", + footer: "/skills", + keywords: `skill skills ${skills() + .map((item) => `${item.name} ${item.description ?? ""}`) + .join(" ")}`.trim(), + }, + ] + : [] + const agent: CommandEntry[] = [ { action: "model", - category: "Suggested", + category: "Agent", display: "Switch model", }, ...(props.queued().length > 0 ? [ - { - action: "queued" as const, - category: "Suggested", - display: "Manage queued prompts", - footer: `${props.queued().length} queued`, - keywords: props - .queued() - .map((item) => item.prompt.text) - .join(" "), - }, - ] - : []), - ...(props.subagents().length > 0 - ? [ - { - action: "subagent" as const, - category: "Suggested", - display: "View subagents", - footer: `${props.subagents().length} active`, - keywords: props - .subagents() - .map((item) => `${item.label} ${item.description} ${item.title ?? ""}`) - .join(" "), - }, - ] + { + action: "queued" as const, + category: "Agent", + display: "Manage queued prompts", + footer: `${props.queued().length} queued`, + keywords: props + .queued() + .map((item) => item.prompt.text) + .join(" "), + }, + ] : []), { action: "variant.cycle", - category: "Suggested", + category: "Agent", display: "Variant cycle", footer: props.variantCycle, keywords: "variant cycle", }, ...(props.variants().length > 0 ? [ - { - action: "variant.list" as const, - category: "Suggested", - display: "Switch model variant", - keywords: `variant variants ${props.variants().join(" ")}`, - }, - ] + { + action: "variant.list" as const, + category: "Agent", + display: "Switch model variant", + keywords: `variant variants ${props.variants().join(" ")}`, + }, + ] : []), - { - action: "slash", - category: "Session", - name: "new", - display: "New session", - footer: "/new", - keywords: "new session clear", - }, - ...(props.commands() ?? []) - .filter((item) => item.source !== "skill" && !builtins.includes(item.name)) - .map( - (item) => - ({ - action: "slash", - category: item.source === "mcp" ? "MCP Commands" : "Project Commands", - name: item.name, - display: item.name, - footer: `/${item.name}`, - keywords: - item.source === "mcp" - ? `/${item.name} ${item.name} mcp ${item.description ?? ""}` - : `/${item.name} ${item.name} ${item.description ?? ""}`, - }) satisfies CommandEntry, - ) - .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)), + ] + const commands = (props.commands() ?? []) + .filter((item) => item.source !== "skill" && !builtins.includes(item.name)) + .map( + (item) => + ({ + action: "slash", + category: item.source === "mcp" ? "MCP Commands" : "Project Commands", + name: item.name, + display: item.name, + footer: `/${item.name}`, + keywords: + item.source === "mcp" + ? `/${item.name} ${item.name} mcp ${item.description ?? ""}` + : `/${item.name} ${item.name} ${item.description ?? ""}`, + }) satisfies CommandEntry, + ) + .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)) + + return [ + ...session, + ...prompt, + ...agent, + ...commands, { action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" }, ] }) @@ -403,6 +475,16 @@ export function RunCommandMenuBody(props: { return } + if (item.action === "editor") { + props.onEditor() + return + } + + if (item.action === "skill") { + props.onSkill() + return + } + if (item.action === "subagent") { props.onSubagent() return @@ -471,6 +553,8 @@ export function RunCommandMenuBody(props: { field = input }} onQuery={setQuery} + dark + chrome="minimal" > ) @@ -566,6 +652,8 @@ export function RunSubagentSelectBody(props: { field = input }} onQuery={setQuery} + dark + chrome="minimal" > ) @@ -662,6 +751,8 @@ export function RunQueuedPromptSelectBody(props: { field = input }} onQuery={setQuery} + dark + chrome="minimal" > + + ) +} + +export function RunSkillSelectBody(props: { + theme: Accessor + commands: Accessor + onClose: () => void + onSelect: (name: string) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => + (props.commands() ?? []) + .filter((item) => item.source === "skill") + .map((item) => ({ + category: "", + display: item.name, + description: item.description?.replace(/\s+/g, " ").trim() || undefined, + keywords: `skill ${item.name} ${item.description ?? ""}`, + name: item.name, + })) + .sort((a, b) => a.display.localeCompare(b.display)), + ) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + props.onSelect(item.name) + } + + createEffect(() => { + query() + menu.reset() + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + dark + chrome="minimal" + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty={props.commands() ? "No skills found" : "Skills loading"} + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={false} + background /> ) @@ -759,6 +930,8 @@ export function RunVariantSelectBody(props: { field = input }} onQuery={setQuery} + dark + chrome="minimal" > ) @@ -879,6 +1053,8 @@ export function RunModelSelectBody(props: { field = input }} onQuery={setQuery} + dark + chrome="minimal" > ) diff --git a/packages/opencode/src/cli/cmd/run/footer.menu.tsx b/packages/opencode/src/cli/cmd/run/footer.menu.tsx index c3770b27b04a..abd1a6ea7851 100644 --- a/packages/opencode/src/cli/cmd/run/footer.menu.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.menu.tsx @@ -1,7 +1,9 @@ /** @jsxImportSource @opentui/solid */ -import { TextAttributes } from "@opentui/core" +import { TextAttributes, type ColorInput } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import { transparent, type RunFooterTheme } from "./theme" +import * as Locale from "@/util/locale" export const FOOTER_MENU_ROWS = 8 @@ -125,7 +127,10 @@ export function RunFooterMenu(props: { paddingLeft?: number paddingRight?: number grouped?: boolean + background?: boolean + headerColor?: ColorInput }) { + const term = useTerminalDimensions() const limit = () => props.limit ?? FOOTER_MENU_ROWS const border = () => props.border ?? true const [groupOffset, setGroupOffset] = createSignal(0) @@ -203,16 +208,36 @@ export function RunFooterMenu(props: { return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display))) } + const descriptionText = (item: RunFooterMenuItem) => { + if (!item.description) { + return + } + + const footerWidth = item.footer ? Bun.stringWidth(item.footer) + 1 : 0 + const available = + term().width - + (border() ? 1 : 0) - + (props.paddingLeft ?? 1) - + (props.paddingRight ?? 0) - + descriptionColumn() - + footerWidth - + 4 + return Locale.truncate(item.description, Math.max(12, available)) + } return ( {rows().length === 0 ? ( - + {border() ? ( ┃ @@ -223,7 +248,7 @@ export function RunFooterMenu(props: { flexShrink={1} paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 0} - backgroundColor={props.theme().surface} + backgroundColor={props.background ? props.theme().shade : transparent} > {props.empty ?? "No matching items"} @@ -239,7 +264,7 @@ export function RunFooterMenu(props: { if (row.type === "header") { return ( - + {row.label} @@ -247,54 +272,75 @@ export function RunFooterMenu(props: { } const active = () => row.index === props.selected() - const inset = () => (active() ? 1 : 0) + const background = () => + active() + ? props.background + ? props.theme().selected + : props.theme().shade + : props.background + ? props.theme().shade + : transparent return ( - + {border() ? ( - - ┃ + + {active() ? "▌" : " "} ) : undefined} - - + + {row.item.display} - {row.item.description ? ( - - {descriptionPad(row.item)} - {row.item.description} - - ) : undefined} - {row.item.footer ? ( - - {row.item.footer} - + {row.item.description ? ( + <> + + {descriptionPad(row.item)} + + + {descriptionText(row.item)} + + ) : undefined} + {row.item.footer ? ( + + {row.item.footer} + + ) : undefined} diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx index 2790a9e0b66c..2ad7e5e4a3dc 100644 --- a/packages/opencode/src/cli/cmd/run/footer.permission.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -29,6 +29,7 @@ import { permissionShift, type PermissionOption, } from "./permission.shared" +import { footerWidthPolicy } from "./footer.width" import { toolFiletype } from "./tool" import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme" import type { PermissionReply, RunDiffStyle } from "./types" @@ -140,7 +141,7 @@ export function RunPermissionBody(props: { const [state, setState] = createSignal(createPermissionBodyState(props.request.id)) const info = createMemo(() => permissionInfo(props.request)) const ft = createMemo(() => toolFiletype(info().file)) - const narrow = createMemo(() => dims().width < 80) + const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow) const opts = createMemo(() => permissionOptions(state().stage)) const busy = createMemo(() => state().submitting) const title = createMemo(() => { @@ -257,7 +258,13 @@ export function RunPermissionBody(props: { }) return ( - + type Auto = RunFooterMenuItem & { @@ -53,6 +48,7 @@ type Auto = RunFooterMenuItem & { type SlashOption = RunFooterMenuItem & { kind: "slash" name: string + action?: "skill-menu" | "editor" } type PromptOption = Auto | SlashOption @@ -75,9 +71,11 @@ type PromptInput = { onSubmit: (input: RunPrompt) => boolean | Promise onCycle: () => void onInterrupt: () => boolean + onEditorOpen: (input: { value: string }) => Promise onInputClear: () => void onExitRequest?: () => boolean onExit: () => void + onSkillMenu: () => void onRows: (rows: number) => void onStatus: (text: string) => void } @@ -93,6 +91,7 @@ export type PromptState = { requestExit: () => boolean onSubmit: () => void submitText: (text: string) => void + openEditor: (input?: { value?: string }) => Promise onKeyDown: (event: KeyEvent) => void onContentChange: () => void replaceDraft: (text: string) => void @@ -109,6 +108,7 @@ function clonePrompt(prompt: RunPrompt): RunPrompt { text: prompt.text, parts: structuredClone(prompt.parts), ...(prompt.mode ? { mode: prompt.mode } : {}), + ...(prompt.command ? { command: prompt.command } : {}), } } @@ -182,17 +182,25 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) { return { type: "command" as const, command: { name: head.name, arguments: head.arguments } } } -export function hintFlags(width: number) { +function selectedCommand(text: string, command: RunPrompt["command"]) { + if (!command) { + return + } + + const head = slashHead(text) + if (!head || head.name !== command.name) { + return + } + return { - send: width >= HINT_BREAKPOINTS.send, - newline: width >= HINT_BREAKPOINTS.newline, - history: width >= HINT_BREAKPOINTS.history, - command: width >= HINT_BREAKPOINTS.command, + name: command.name, + arguments: head.arguments, } } export function RunPromptBody(props: { theme: () => RunFooterTheme + background: () => ColorInput placeholder: () => StyledText | string onSubmit: () => void onKeyDown: (event: KeyEvent) => void @@ -226,7 +234,7 @@ export function RunPromptBody(props: { props.onContentChange() }) - .catch(() => {}) + .catch(() => { }) }, 0) } @@ -243,7 +251,7 @@ export function RunPromptBody(props: { return ( - +