1092 lines
29 KiB
TypeScript
1092 lines
29 KiB
TypeScript
/** @jsxImportSource @opentui/solid */
|
|
import { TextAttributes, 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 { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
|
import type { RunFooterTheme } from "./theme"
|
|
import type {
|
|
FooterQueuedPrompt,
|
|
FooterSubagentTab,
|
|
MiniSettingChange,
|
|
MiniSettings,
|
|
RunAgent,
|
|
RunCommand,
|
|
RunInput,
|
|
RunProvider,
|
|
} from "./types"
|
|
|
|
type PanelEntry = RunFooterMenuItem & {
|
|
category: string
|
|
keywords?: string
|
|
}
|
|
|
|
type CommandEntry =
|
|
| (PanelEntry & { action: "agent" })
|
|
| (PanelEntry & { action: "model" })
|
|
| (PanelEntry & { action: "editor" })
|
|
| (PanelEntry & { action: "skill" })
|
|
| (PanelEntry & { action: "queued" })
|
|
| (PanelEntry & { action: "subagent" })
|
|
| (PanelEntry & { action: "status" })
|
|
| (PanelEntry & { action: "variant.cycle" })
|
|
| (PanelEntry & { action: "variant.list" })
|
|
| (PanelEntry & { action: "settings" })
|
|
| (PanelEntry & { action: "slash"; name: string })
|
|
| (PanelEntry & { action: "exit" })
|
|
|
|
type ModelEntry = PanelEntry & {
|
|
providerID: string
|
|
modelID: string
|
|
providerName: string
|
|
current: boolean
|
|
}
|
|
|
|
type AgentEntry = PanelEntry & {
|
|
id: string
|
|
current: boolean
|
|
}
|
|
|
|
type VariantEntry = PanelEntry & {
|
|
variant: string | undefined
|
|
current: boolean
|
|
}
|
|
|
|
type SkillEntry = PanelEntry & {
|
|
name: string
|
|
}
|
|
|
|
type SubagentEntry = PanelEntry & {
|
|
sessionID: string
|
|
current: boolean
|
|
}
|
|
|
|
type SettingEntry = PanelEntry & {
|
|
key: keyof MiniSettings
|
|
}
|
|
|
|
const PANEL_PAD = 2
|
|
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
|
|
const SUBAGENT_LIST_ROWS = 12
|
|
export const RUN_SUBAGENT_PANEL_ROWS = SUBAGENT_LIST_ROWS + PANEL_FRAME_ROWS
|
|
const PANEL_PAGE = PANEL_LIST_ROWS - 1
|
|
const HALF_BLOCK_BORDER = {
|
|
topLeft: "",
|
|
bottomLeft: "",
|
|
vertical: "",
|
|
topRight: "",
|
|
bottomRight: "",
|
|
horizontal: "▀",
|
|
bottomT: "",
|
|
topT: "",
|
|
cross: "",
|
|
leftT: "",
|
|
rightT: "",
|
|
}
|
|
|
|
function countLabel(count: number, total: number, query: string) {
|
|
if (!query.trim()) {
|
|
return `${total}`
|
|
}
|
|
|
|
return `${count}/${total}`
|
|
}
|
|
|
|
function subagentStatusLabel(status: FooterSubagentTab["status"]) {
|
|
if (status === "completed") {
|
|
return "done"
|
|
}
|
|
|
|
if (status === "cancelled") {
|
|
return "cancelled"
|
|
}
|
|
|
|
if (status === "error") {
|
|
return "error"
|
|
}
|
|
|
|
return "running"
|
|
}
|
|
|
|
function match<T extends PanelEntry>(query: string, entries: T[]) {
|
|
const text = query.trim()
|
|
if (!text) {
|
|
return entries
|
|
}
|
|
|
|
return fuzzysort
|
|
.go(text, entries, { keys: ["display", "category", "description", "keywords"] })
|
|
.map((item) => item.obj)
|
|
}
|
|
|
|
function createSearchablePanelController<T extends PanelEntry>(input: {
|
|
entries: Accessor<T[]>
|
|
limit: number
|
|
onClose: () => void
|
|
onSelect: (item: T) => void
|
|
isCurrent?: (item: T) => boolean
|
|
closeOnFirstUp?: boolean
|
|
onKey?: (event: KeyEvent, item: T | undefined) => boolean
|
|
onRows?: (rows: number) => void
|
|
}) {
|
|
let field: InputRenderable | undefined
|
|
const [query, setQuery] = createSignal("")
|
|
const items = createMemo<T[]>(() => match(query(), input.entries()))
|
|
const menu = createFooterMenuState({ count: () => items().length, limit: input.limit })
|
|
const selected = () => items()[menu.selected()]
|
|
|
|
createEffect(() => {
|
|
query()
|
|
menu.reset()
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (!input.isCurrent || query().trim()) {
|
|
return
|
|
}
|
|
|
|
const index = items().findIndex(input.isCurrent)
|
|
if (index !== -1) {
|
|
menu.reveal(index)
|
|
}
|
|
})
|
|
|
|
createEffect(() => {
|
|
input.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
|
|
})
|
|
|
|
useKeyboard((event) => {
|
|
if (event.defaultPrevented) {
|
|
return
|
|
}
|
|
|
|
if (input.onKey?.(event, selected())) {
|
|
return
|
|
}
|
|
|
|
const name = event.name.toLowerCase()
|
|
if (input.closeOnFirstUp && name === "up" && menu.selected() === 0) {
|
|
event.preventDefault()
|
|
input.onClose()
|
|
return
|
|
}
|
|
|
|
const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
|
|
if (name === "escape" || (ctrl && name === "c")) {
|
|
event.preventDefault()
|
|
input.onClose()
|
|
return
|
|
}
|
|
|
|
if (name === "up" || (ctrl && name === "p")) {
|
|
event.preventDefault()
|
|
menu.move(-1)
|
|
return
|
|
}
|
|
|
|
if (name === "down" || (ctrl && name === "n")) {
|
|
event.preventDefault()
|
|
menu.move(1)
|
|
return
|
|
}
|
|
|
|
if (name === "pageup") {
|
|
event.preventDefault()
|
|
menu.reveal(menu.selected() - PANEL_PAGE)
|
|
return
|
|
}
|
|
|
|
if (name === "pagedown") {
|
|
event.preventDefault()
|
|
menu.reveal(menu.selected() + PANEL_PAGE)
|
|
return
|
|
}
|
|
|
|
if (name === "home") {
|
|
event.preventDefault()
|
|
menu.reveal(0)
|
|
return
|
|
}
|
|
|
|
if (name === "end") {
|
|
event.preventDefault()
|
|
menu.reveal(Number.POSITIVE_INFINITY)
|
|
return
|
|
}
|
|
|
|
if (name === "return") {
|
|
event.preventDefault()
|
|
const item = selected()
|
|
if (item) {
|
|
input.onSelect(item)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (ctrl && name === "u") {
|
|
event.preventDefault()
|
|
setQuery("")
|
|
field?.setText("")
|
|
}
|
|
})
|
|
|
|
return {
|
|
query,
|
|
setQuery,
|
|
items,
|
|
menu,
|
|
inputRef(input: InputRenderable) {
|
|
field = input
|
|
},
|
|
}
|
|
}
|
|
|
|
function PanelShell(props: {
|
|
title: string
|
|
countVisible?: boolean
|
|
query: string
|
|
count: number
|
|
total: number
|
|
placeholder: string
|
|
theme: Accessor<RunFooterTheme>
|
|
inputRef: (input: InputRenderable) => void
|
|
onQuery: (query: string) => void
|
|
children: JSX.Element
|
|
hint?: string
|
|
mono?: boolean
|
|
}) {
|
|
const background = () => props.theme().shade
|
|
const content = (
|
|
<>
|
|
<box height={1} flexShrink={0} backgroundColor={background()} />
|
|
<box
|
|
width="100%"
|
|
height={1}
|
|
paddingLeft={panelPad(props.mono)}
|
|
paddingRight={panelPad(props.mono)}
|
|
flexDirection="row"
|
|
gap={1}
|
|
flexShrink={0}
|
|
backgroundColor={background()}
|
|
>
|
|
<text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
|
|
{props.title}
|
|
</text>
|
|
{props.countVisible !== false ? (
|
|
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
|
|
{countLabel(props.count, props.total, props.query)}
|
|
</text>
|
|
) : null}
|
|
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
|
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
|
|
{props.hint ? `${props.hint} ${props.mono ? "-" : "·"} ` : ""}esc
|
|
</text>
|
|
</box>
|
|
<box height={1} flexShrink={0} backgroundColor={background()} />
|
|
<box
|
|
width="100%"
|
|
height={1}
|
|
paddingLeft={panelPad(props.mono)}
|
|
paddingRight={panelPad(props.mono)}
|
|
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()
|
|
}
|
|
})
|
|
}}
|
|
/>
|
|
</box>
|
|
<box height={1} flexShrink={0} backgroundColor={background()} />
|
|
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={background()}>
|
|
{props.children}
|
|
</box>
|
|
</>
|
|
)
|
|
return (
|
|
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
|
|
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
|
|
{content}
|
|
</box>
|
|
<box width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
|
|
{props.mono ? null : (
|
|
<box
|
|
width="100%"
|
|
height={1}
|
|
border={["bottom"]}
|
|
borderColor={background()}
|
|
backgroundColor="transparent"
|
|
customBorderChars={HALF_BLOCK_BORDER}
|
|
/>
|
|
)}
|
|
</box>
|
|
</box>
|
|
)
|
|
}
|
|
|
|
export function RunCommandMenuBody(props: {
|
|
theme: Accessor<RunFooterTheme>
|
|
commands: Accessor<RunCommand[] | undefined>
|
|
subagents: Accessor<FooterSubagentTab[]>
|
|
queued: Accessor<FooterQueuedPrompt[]>
|
|
variants: Accessor<string[]>
|
|
variantCycle: string
|
|
onClose: () => void
|
|
onAgent: () => void
|
|
onModel: () => void
|
|
onEditor: () => void
|
|
onSkill: () => void
|
|
onSubagent: () => void
|
|
onQueued: () => void
|
|
onVariant: () => void
|
|
onVariantCycle: () => void
|
|
onStatus: () => void
|
|
onSettings: () => void
|
|
onCommand: (name: string) => void
|
|
onNew: () => void
|
|
onExit: () => void
|
|
mono?: boolean
|
|
}) {
|
|
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
|
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
|
|
const entries = createMemo<CommandEntry[]>(() => {
|
|
const session: CommandEntry[] = [
|
|
{
|
|
action: "editor",
|
|
category: "Session",
|
|
display: "Open editor",
|
|
footer: "/editor",
|
|
keywords: "editor compose draft external editor",
|
|
},
|
|
{
|
|
action: "status",
|
|
category: "Session",
|
|
display: "Show status",
|
|
keywords: "status activity model context usage footer",
|
|
},
|
|
...(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: "compact",
|
|
display: "Compact session",
|
|
footer: "/compact",
|
|
keywords: "compact summarize session context",
|
|
},
|
|
{
|
|
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: "agent",
|
|
category: "Agent",
|
|
display: "Switch agent",
|
|
},
|
|
{
|
|
action: "model",
|
|
category: "Agent",
|
|
display: "Switch model",
|
|
},
|
|
...(props.queued().length > 0
|
|
? [
|
|
{
|
|
action: "queued" as const,
|
|
category: "Agent",
|
|
display: "View pending work",
|
|
footer: `${props.queued().length} pending`,
|
|
keywords: props
|
|
.queued()
|
|
.map((item) => item.prompt.text)
|
|
.join(" "),
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
action: "variant.cycle",
|
|
category: "Agent",
|
|
display: "Variant cycle",
|
|
footer: props.variantCycle,
|
|
keywords: "variant cycle",
|
|
},
|
|
...(props.variants().length > 0
|
|
? [
|
|
{
|
|
action: "variant.list" as const,
|
|
category: "Agent",
|
|
display: "Switch model variant",
|
|
keywords: `variant variants ${props.variants().join(" ")}`,
|
|
},
|
|
]
|
|
: []),
|
|
]
|
|
return [
|
|
...session,
|
|
...prompt,
|
|
...agent,
|
|
{
|
|
action: "settings",
|
|
category: "System",
|
|
display: "Settings",
|
|
footer: "/settings",
|
|
keywords: "/settings settings preferences configuration",
|
|
},
|
|
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
|
|
]
|
|
})
|
|
const pick = (item: CommandEntry) => {
|
|
if (item.action === "agent") {
|
|
props.onAgent()
|
|
return
|
|
}
|
|
|
|
if (item.action === "model") {
|
|
props.onModel()
|
|
return
|
|
}
|
|
|
|
if (item.action === "editor") {
|
|
props.onEditor()
|
|
return
|
|
}
|
|
|
|
if (item.action === "skill") {
|
|
props.onSkill()
|
|
return
|
|
}
|
|
|
|
if (item.action === "subagent") {
|
|
props.onSubagent()
|
|
return
|
|
}
|
|
|
|
if (item.action === "queued") {
|
|
props.onQueued()
|
|
return
|
|
}
|
|
|
|
if (item.action === "variant.cycle") {
|
|
props.onVariantCycle()
|
|
return
|
|
}
|
|
|
|
if (item.action === "variant.list") {
|
|
props.onVariant()
|
|
return
|
|
}
|
|
|
|
if (item.action === "status") {
|
|
props.onStatus()
|
|
return
|
|
}
|
|
|
|
if (item.action === "settings") {
|
|
props.onSettings()
|
|
return
|
|
}
|
|
|
|
if (item.action === "exit") {
|
|
props.onExit()
|
|
return
|
|
}
|
|
|
|
if (item.name === "new") {
|
|
props.onNew()
|
|
return
|
|
}
|
|
|
|
props.onCommand(item.name)
|
|
}
|
|
const controller = createSearchablePanelController({
|
|
entries,
|
|
limit: PANEL_LIST_ROWS,
|
|
onClose: props.onClose,
|
|
onSelect: pick,
|
|
})
|
|
|
|
return (
|
|
<PanelShell
|
|
title="Commands"
|
|
countVisible={false}
|
|
query={controller.query()}
|
|
count={controller.items().length}
|
|
total={entries().length}
|
|
placeholder="Search"
|
|
theme={props.theme}
|
|
inputRef={controller.inputRef}
|
|
onQuery={controller.setQuery}
|
|
mono={props.mono}
|
|
>
|
|
<RunFooterMenu
|
|
theme={props.theme}
|
|
items={controller.items}
|
|
selected={controller.menu.selected}
|
|
offset={controller.menu.offset}
|
|
rows={() => PANEL_LIST_ROWS}
|
|
limit={PANEL_LIST_ROWS}
|
|
empty="No results found"
|
|
border={false}
|
|
paddingLeft={panelPad(props.mono)}
|
|
paddingRight={panelPad(props.mono)}
|
|
grouped={!controller.query().trim()}
|
|
background
|
|
headerColor={props.theme().muted}
|
|
mono={props.mono}
|
|
/>
|
|
</PanelShell>
|
|
)
|
|
}
|
|
|
|
export function RunAgentSelectBody(props: {
|
|
theme: Accessor<RunFooterTheme>
|
|
agents: Accessor<RunAgent[]>
|
|
current: Accessor<string | undefined>
|
|
onClose: () => void
|
|
onSelect: (agent: string) => void
|
|
mono?: boolean
|
|
}) {
|
|
const entries = createMemo<AgentEntry[]>(() =>
|
|
props
|
|
.agents()
|
|
.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
|
.map((agent) => ({
|
|
category: "",
|
|
display: agent.id,
|
|
description: agent.description,
|
|
footer: props.current() === agent.id ? "current" : undefined,
|
|
keywords: `${agent.id} ${agent.name} ${agent.description ?? ""}`,
|
|
id: agent.id,
|
|
current: props.current() === agent.id,
|
|
})),
|
|
)
|
|
const controller = createSearchablePanelController({
|
|
entries,
|
|
limit: PANEL_LIST_ROWS,
|
|
onClose: props.onClose,
|
|
onSelect: (item) => props.onSelect(item.id),
|
|
isCurrent: (item) => item.current,
|
|
})
|
|
|
|
return (
|
|
<PanelShell
|
|
title="Select agent"
|
|
query={controller.query()}
|
|
count={controller.items().length}
|
|
total={entries().length}
|
|
placeholder="Search"
|
|
theme={props.theme}
|
|
inputRef={controller.inputRef}
|
|
onQuery={controller.setQuery}
|
|
mono={props.mono}
|
|
>
|
|
<RunFooterMenu
|
|
theme={props.theme}
|
|
items={controller.items}
|
|
selected={controller.menu.selected}
|
|
offset={controller.menu.offset}
|
|
rows={() => PANEL_LIST_ROWS}
|
|
limit={PANEL_LIST_ROWS}
|
|
empty="No agents found"
|
|
border={false}
|
|
paddingLeft={panelPad(props.mono)}
|
|
paddingRight={panelPad(props.mono)}
|
|
grouped={false}
|
|
background
|
|
mono={props.mono}
|
|
/>
|
|
</PanelShell>
|
|
)
|
|
}
|
|
|
|
export function RunSettingsBody(props: {
|
|
theme: Accessor<RunFooterTheme>
|
|
settings: Accessor<MiniSettings>
|
|
onClose: () => void
|
|
onChange: (change: MiniSettingChange) => void | Promise<void>
|
|
mono?: boolean
|
|
}) {
|
|
const [saving, setSaving] = createSignal<keyof MiniSettings>()
|
|
const entries = createMemo<SettingEntry[]>(() => [
|
|
{
|
|
category: "Transcript",
|
|
display: "Thinking",
|
|
footer: saving() === "thinking" ? "saving" : props.settings().thinking,
|
|
keywords: `thinking reasoning ${props.settings().thinking}`,
|
|
key: "thinking",
|
|
},
|
|
{
|
|
category: "Transcript",
|
|
display: "Shell",
|
|
footer: saving() === "shell_output" ? "saving" : props.settings().shell_output,
|
|
keywords: `shell tool command output ${props.settings().shell_output}`,
|
|
key: "shell_output",
|
|
},
|
|
{
|
|
category: "Transcript",
|
|
display: "Turn summary",
|
|
footer: saving() === "turn_summary" ? "saving" : props.settings().turn_summary,
|
|
keywords: `turn summary agent model duration ${props.settings().turn_summary}`,
|
|
key: "turn_summary",
|
|
},
|
|
{
|
|
category: "Terminal",
|
|
display: "Footer details",
|
|
footer: saving() === "footer" ? "saving" : props.settings().footer,
|
|
keywords: `footer status activity model context usage ${props.settings().footer}`,
|
|
key: "footer",
|
|
},
|
|
{
|
|
category: "Terminal",
|
|
display: "Splash",
|
|
footer: saving() === "splash" ? "saving" : props.settings().splash,
|
|
keywords: `splash entry exit banner ${props.settings().splash}`,
|
|
key: "splash",
|
|
},
|
|
{
|
|
category: "Terminal",
|
|
display: "Monochrome UI",
|
|
footer: saving() === "mono" ? "saving" : props.settings().mono ? "on" : "off",
|
|
keywords: `mono monochrome ascii legacy compat terminal ${props.settings().mono ? "on" : "off"}`,
|
|
key: "mono",
|
|
},
|
|
])
|
|
const change = (item: SettingEntry) => {
|
|
if (saving()) return
|
|
const next: MiniSettingChange =
|
|
item.key === "mono"
|
|
? { key: "mono", value: !props.settings().mono }
|
|
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
|
|
setSaving(item.key)
|
|
void Promise.resolve(props.onChange(next))
|
|
.catch(() => { })
|
|
.finally(() => setSaving())
|
|
}
|
|
const controller = createSearchablePanelController({
|
|
entries,
|
|
limit: PANEL_LIST_ROWS,
|
|
onClose: props.onClose,
|
|
onSelect: change,
|
|
onKey(event, item) {
|
|
const name = event.name.toLowerCase()
|
|
if (name !== "left" && name !== "right") return false
|
|
event.preventDefault()
|
|
if (item) change(item)
|
|
return true
|
|
},
|
|
})
|
|
|
|
return (
|
|
<PanelShell
|
|
title="Settings"
|
|
countVisible={false}
|
|
query={controller.query()}
|
|
count={controller.items().length}
|
|
total={entries().length}
|
|
placeholder="Search"
|
|
theme={props.theme}
|
|
inputRef={controller.inputRef}
|
|
onQuery={controller.setQuery}
|
|
hint="left/right change"
|
|
mono={props.mono}
|
|
>
|
|
<RunFooterMenu
|
|
theme={props.theme}
|
|
items={controller.items}
|
|
selected={controller.menu.selected}
|
|
offset={controller.menu.offset}
|
|
rows={() => PANEL_LIST_ROWS}
|
|
limit={PANEL_LIST_ROWS}
|
|
empty="No settings found"
|
|
border={false}
|
|
paddingLeft={panelPad(props.mono)}
|
|
paddingRight={panelPad(props.mono)}
|
|
grouped={!controller.query().trim()}
|
|
background
|
|
headerColor={props.theme().muted}
|
|
mono={props.mono}
|
|
/>
|
|
</PanelShell>
|
|
)
|
|
}
|
|
|
|
export function RunSubagentSelectBody(props: {
|
|
theme: Accessor<RunFooterTheme>
|
|
tabs: Accessor<FooterSubagentTab[]>
|
|
current: Accessor<string | undefined>
|
|
onClose: () => void
|
|
onSelect: (sessionID: string) => void
|
|
onRows?: (rows: number) => void
|
|
mono?: boolean
|
|
}) {
|
|
const entries = createMemo<SubagentEntry[]>(() =>
|
|
props.tabs().map((item) => {
|
|
const title = item.description || item.title || item.label
|
|
return {
|
|
category: "",
|
|
display: title,
|
|
description: title === item.label ? undefined : item.label,
|
|
footer: subagentStatusLabel(item.status),
|
|
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
|
|
sessionID: item.sessionID,
|
|
current: props.current() === item.sessionID,
|
|
}
|
|
}),
|
|
)
|
|
const controller = createSearchablePanelController({
|
|
entries,
|
|
limit: SUBAGENT_LIST_ROWS,
|
|
onClose: props.onClose,
|
|
onSelect: (item) => props.onSelect(item.sessionID),
|
|
isCurrent: (item) => item.current,
|
|
closeOnFirstUp: true,
|
|
onRows: props.onRows,
|
|
})
|
|
|
|
return (
|
|
<PanelShell
|
|
title="Select subagent"
|
|
query={controller.query()}
|
|
count={controller.items().length}
|
|
total={entries().length}
|
|
placeholder="Search"
|
|
theme={props.theme}
|
|
inputRef={controller.inputRef}
|
|
onQuery={controller.setQuery}
|
|
mono={props.mono}
|
|
>
|
|
<RunFooterMenu
|
|
theme={props.theme}
|
|
items={controller.items}
|
|
selected={controller.menu.selected}
|
|
offset={controller.menu.offset}
|
|
rows={controller.menu.rows}
|
|
limit={SUBAGENT_LIST_ROWS}
|
|
empty="No subagents found"
|
|
border={false}
|
|
paddingLeft={panelPad(props.mono)}
|
|
paddingRight={panelPad(props.mono)}
|
|
grouped={false}
|
|
background
|
|
mono={props.mono}
|
|
/>
|
|
</PanelShell>
|
|
)
|
|
}
|
|
|
|
export function RunQueuedPromptSelectBody(props: {
|
|
theme: Accessor<RunFooterTheme>
|
|
prompts: Accessor<FooterQueuedPrompt[]>
|
|
onClose: () => void
|
|
onRows?: (rows: number) => void
|
|
mono?: boolean
|
|
}) {
|
|
const entries = createMemo(() =>
|
|
props.prompts().map((prompt) => ({
|
|
category: "",
|
|
display: prompt.prompt.text.replaceAll("\n", " "),
|
|
footer: prompt.delivery,
|
|
keywords: prompt.prompt.text,
|
|
})),
|
|
)
|
|
const controller = createSearchablePanelController({
|
|
entries,
|
|
limit: SUBAGENT_LIST_ROWS,
|
|
onClose: props.onClose,
|
|
onSelect: props.onClose,
|
|
onRows: props.onRows,
|
|
})
|
|
|
|
return (
|
|
<PanelShell
|
|
title="Pending work"
|
|
query={controller.query()}
|
|
count={controller.items().length}
|
|
total={entries().length}
|
|
placeholder="Search"
|
|
theme={props.theme}
|
|
inputRef={controller.inputRef}
|
|
onQuery={controller.setQuery}
|
|
mono={props.mono}
|
|
>
|
|
<RunFooterMenu
|
|
theme={props.theme}
|
|
items={controller.items}
|
|
selected={controller.menu.selected}
|
|
offset={controller.menu.offset}
|
|
rows={controller.menu.rows}
|
|
limit={SUBAGENT_LIST_ROWS}
|
|
empty="No pending work"
|
|
border={false}
|
|
paddingLeft={panelPad(props.mono)}
|
|
paddingRight={panelPad(props.mono)}
|
|
grouped={false}
|
|
background
|
|
mono={props.mono}
|
|
/>
|
|
</PanelShell>
|
|
)
|
|
}
|
|
|
|
export function RunSkillSelectBody(props: {
|
|
theme: Accessor<RunFooterTheme>
|
|
commands: Accessor<RunCommand[] | undefined>
|
|
onClose: () => void
|
|
onSelect: (name: string) => void
|
|
mono?: boolean
|
|
}) {
|
|
const entries = createMemo<SkillEntry[]>(() =>
|
|
(props.commands() ?? [])
|
|
.filter((item) => item.source === "skill")
|
|
.map((item) => ({
|
|
category: "",
|
|
display: item.name,
|
|
description: item.description?.replace(/\s+/g, " ").trim() || undefined,
|
|
keywords: `skill ${item.name} ${item.description ?? ""}`,
|
|
name: item.name,
|
|
}))
|
|
.sort((a, b) => a.display.localeCompare(b.display)),
|
|
)
|
|
const controller = createSearchablePanelController({
|
|
entries,
|
|
limit: PANEL_LIST_ROWS,
|
|
onClose: props.onClose,
|
|
onSelect: (item) => props.onSelect(item.name),
|
|
})
|
|
|
|
return (
|
|
<PanelShell
|
|
title="Skills"
|
|
query={controller.query()}
|
|
count={controller.items().length}
|
|
total={entries().length}
|
|
placeholder="Search"
|
|
theme={props.theme}
|
|
inputRef={controller.inputRef}
|
|
onQuery={controller.setQuery}
|
|
mono={props.mono}
|
|
>
|
|
<RunFooterMenu
|
|
theme={props.theme}
|
|
items={controller.items}
|
|
selected={controller.menu.selected}
|
|
offset={controller.menu.offset}
|
|
rows={() => PANEL_LIST_ROWS}
|
|
limit={PANEL_LIST_ROWS}
|
|
empty={props.commands() ? "No skills found" : "Skills loading"}
|
|
border={false}
|
|
paddingLeft={panelPad(props.mono)}
|
|
paddingRight={panelPad(props.mono)}
|
|
grouped={false}
|
|
background
|
|
mono={props.mono}
|
|
/>
|
|
</PanelShell>
|
|
)
|
|
}
|
|
|
|
export function RunVariantSelectBody(props: {
|
|
theme: Accessor<RunFooterTheme>
|
|
variants: Accessor<string[]>
|
|
current: Accessor<string | undefined>
|
|
onClose: () => void
|
|
onSelect: (variant: string | undefined) => void
|
|
mono?: boolean
|
|
}) {
|
|
const entries = createMemo<VariantEntry[]>(() => [
|
|
{
|
|
category: "",
|
|
display: "Default",
|
|
description: props.current() === undefined ? "current" : undefined,
|
|
keywords: "default",
|
|
variant: undefined,
|
|
current: props.current() === undefined,
|
|
},
|
|
...props.variants().map((variant) => ({
|
|
category: "",
|
|
display: variant,
|
|
description: props.current() === variant ? "current" : undefined,
|
|
keywords: variant,
|
|
variant,
|
|
current: props.current() === variant,
|
|
})),
|
|
])
|
|
const controller = createSearchablePanelController({
|
|
entries,
|
|
limit: PANEL_LIST_ROWS,
|
|
onClose: props.onClose,
|
|
onSelect: (item) => props.onSelect(item.variant),
|
|
isCurrent: (item) => item.current,
|
|
})
|
|
|
|
return (
|
|
<PanelShell
|
|
title="Select variant"
|
|
query={controller.query()}
|
|
count={controller.items().length}
|
|
total={entries().length}
|
|
placeholder="Search"
|
|
theme={props.theme}
|
|
inputRef={controller.inputRef}
|
|
onQuery={controller.setQuery}
|
|
mono={props.mono}
|
|
>
|
|
<RunFooterMenu
|
|
theme={props.theme}
|
|
items={controller.items}
|
|
selected={controller.menu.selected}
|
|
offset={controller.menu.offset}
|
|
rows={() => PANEL_LIST_ROWS}
|
|
limit={PANEL_LIST_ROWS}
|
|
empty="No results found"
|
|
border={false}
|
|
paddingLeft={panelPad(props.mono)}
|
|
paddingRight={panelPad(props.mono)}
|
|
grouped={false}
|
|
background
|
|
mono={props.mono}
|
|
/>
|
|
</PanelShell>
|
|
)
|
|
}
|
|
|
|
export function RunModelSelectBody(props: {
|
|
theme: Accessor<RunFooterTheme>
|
|
providers: Accessor<RunProvider[] | undefined>
|
|
current: Accessor<RunInput["model"]>
|
|
onClose: () => void
|
|
onSelect: (model: NonNullable<RunInput["model"]>) => void
|
|
mono?: boolean
|
|
}) {
|
|
const entries = createMemo<ModelEntry[]>(() =>
|
|
(props.providers() ?? [])
|
|
.flatMap((provider) =>
|
|
Object.entries(provider.models)
|
|
.filter(([, model]) => model.status !== "deprecated")
|
|
.map(([modelID, model]) => {
|
|
const title = model.name ?? modelID
|
|
const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID
|
|
const footer = current
|
|
? "current"
|
|
: model.cost?.input === 0 && provider.id === "opencode"
|
|
? "Free"
|
|
: title !== modelID
|
|
? modelID
|
|
: undefined
|
|
return {
|
|
providerID: provider.id,
|
|
modelID,
|
|
providerName: provider.name,
|
|
category: provider.name,
|
|
display: title,
|
|
footer,
|
|
keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`,
|
|
current,
|
|
}
|
|
}),
|
|
)
|
|
.sort((a, b) => {
|
|
const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode")
|
|
if (provider !== 0) {
|
|
return provider
|
|
}
|
|
|
|
const name = a.providerName.localeCompare(b.providerName)
|
|
if (name !== 0) {
|
|
return name
|
|
}
|
|
|
|
return a.display.localeCompare(b.display)
|
|
}),
|
|
)
|
|
const controller = createSearchablePanelController({
|
|
entries,
|
|
limit: PANEL_LIST_ROWS,
|
|
onClose: props.onClose,
|
|
onSelect: (item) => props.onSelect({ providerID: item.providerID, modelID: item.modelID }),
|
|
isCurrent: (item) => item.current,
|
|
})
|
|
|
|
return (
|
|
<PanelShell
|
|
title="Select model"
|
|
query={controller.query()}
|
|
count={controller.items().length}
|
|
total={entries().length}
|
|
placeholder="Search"
|
|
theme={props.theme}
|
|
inputRef={controller.inputRef}
|
|
onQuery={controller.setQuery}
|
|
mono={props.mono}
|
|
>
|
|
<RunFooterMenu
|
|
theme={props.theme}
|
|
items={() =>
|
|
controller.query().trim()
|
|
? controller.items().map((item) => ({ ...item, footer: item.providerName }))
|
|
: controller.items()
|
|
}
|
|
selected={controller.menu.selected}
|
|
offset={controller.menu.offset}
|
|
rows={() => PANEL_LIST_ROWS}
|
|
limit={PANEL_LIST_ROWS}
|
|
empty={props.providers() ? "No results found" : "Models loading"}
|
|
border={false}
|
|
paddingLeft={panelPad(props.mono)}
|
|
paddingRight={panelPad(props.mono)}
|
|
grouped={!controller.query().trim()}
|
|
background
|
|
headerColor={props.theme().muted}
|
|
mono={props.mono}
|
|
/>
|
|
</PanelShell>
|
|
)
|
|
}
|