run: replace subagent tabs with on-demand picker (#28508)
Move subagent navigation into the existing palette: a "View subagents" command entry, a dedicated picker panel, and a Down-arrow shortcut from the empty composer.
This commit is contained in:
@@ -6,7 +6,7 @@ import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
|||||||
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||||
import { formatBindings } from "./keymap.shared"
|
import { formatBindings } from "./keymap.shared"
|
||||||
import type { RunFooterTheme } from "./theme"
|
import type { RunFooterTheme } from "./theme"
|
||||||
import type { FooterKeybinds, RunCommand, RunInput, RunProvider } from "./types"
|
import type { FooterKeybinds, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
|
||||||
|
|
||||||
type PanelEntry = RunFooterMenuItem & {
|
type PanelEntry = RunFooterMenuItem & {
|
||||||
category: string
|
category: string
|
||||||
@@ -15,6 +15,7 @@ type PanelEntry = RunFooterMenuItem & {
|
|||||||
|
|
||||||
type CommandEntry =
|
type CommandEntry =
|
||||||
| (PanelEntry & { action: "model" })
|
| (PanelEntry & { action: "model" })
|
||||||
|
| (PanelEntry & { action: "subagent" })
|
||||||
| (PanelEntry & { action: "variant.cycle" })
|
| (PanelEntry & { action: "variant.cycle" })
|
||||||
| (PanelEntry & { action: "variant.list" })
|
| (PanelEntry & { action: "variant.list" })
|
||||||
| (PanelEntry & { action: "slash"; name: string })
|
| (PanelEntry & { action: "slash"; name: string })
|
||||||
@@ -32,11 +33,19 @@ type VariantEntry = PanelEntry & {
|
|||||||
current: boolean
|
current: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubagentEntry = PanelEntry & {
|
||||||
|
sessionID: string
|
||||||
|
current: boolean
|
||||||
|
}
|
||||||
|
|
||||||
type MenuState = ReturnType<typeof createFooterMenuState>
|
type MenuState = ReturnType<typeof createFooterMenuState>
|
||||||
|
|
||||||
const PANEL_PAD = 2
|
const PANEL_PAD = 2
|
||||||
const PANEL_LIST_ROWS = 10
|
const PANEL_LIST_ROWS = 10
|
||||||
export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + 6
|
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 PANEL_PAGE = PANEL_LIST_ROWS - 1
|
||||||
const PANEL_BORDER = {
|
const PANEL_BORDER = {
|
||||||
topLeft: "",
|
topLeft: "",
|
||||||
@@ -89,6 +98,18 @@ function categoryRank(category: string) {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subagentStatusLabel(status: FooterSubagentTab["status"]) {
|
||||||
|
if (status === "completed") {
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "running"
|
||||||
|
}
|
||||||
|
|
||||||
function handleKey(input: {
|
function handleKey(input: {
|
||||||
event: KeyEvent
|
event: KeyEvent
|
||||||
menu: MenuState
|
menu: MenuState
|
||||||
@@ -273,10 +294,12 @@ function PanelShell(props: {
|
|||||||
export function RunCommandMenuBody(props: {
|
export function RunCommandMenuBody(props: {
|
||||||
theme: Accessor<RunFooterTheme>
|
theme: Accessor<RunFooterTheme>
|
||||||
commands: Accessor<RunCommand[] | undefined>
|
commands: Accessor<RunCommand[] | undefined>
|
||||||
|
subagents: Accessor<FooterSubagentTab[]>
|
||||||
variants: Accessor<string[]>
|
variants: Accessor<string[]>
|
||||||
keybinds: FooterKeybinds
|
keybinds: FooterKeybinds
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onModel: () => void
|
onModel: () => void
|
||||||
|
onSubagent: () => void
|
||||||
onVariant: () => void
|
onVariant: () => void
|
||||||
onVariantCycle: () => void
|
onVariantCycle: () => void
|
||||||
onCommand: (name: string) => void
|
onCommand: (name: string) => void
|
||||||
@@ -293,6 +316,19 @@ export function RunCommandMenuBody(props: {
|
|||||||
category: "Suggested",
|
category: "Suggested",
|
||||||
display: "Switch model",
|
display: "Switch model",
|
||||||
},
|
},
|
||||||
|
...(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: "variant.cycle",
|
action: "variant.cycle",
|
||||||
category: "Suggested",
|
category: "Suggested",
|
||||||
@@ -346,6 +382,11 @@ export function RunCommandMenuBody(props: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.action === "subagent") {
|
||||||
|
props.onSubagent()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (item.action === "variant.cycle") {
|
if (item.action === "variant.cycle") {
|
||||||
props.onVariantCycle()
|
props.onVariantCycle()
|
||||||
return
|
return
|
||||||
@@ -423,6 +464,101 @@ export function RunCommandMenuBody(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RunSubagentSelectBody(props: {
|
||||||
|
theme: Accessor<RunFooterTheme>
|
||||||
|
tabs: Accessor<FooterSubagentTab[]>
|
||||||
|
current: Accessor<string | undefined>
|
||||||
|
onClose: () => void
|
||||||
|
onSelect: (sessionID: string) => void
|
||||||
|
onRows?: (rows: number) => void
|
||||||
|
}) {
|
||||||
|
let field: InputRenderable | undefined
|
||||||
|
const [query, setQuery] = createSignal("")
|
||||||
|
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 items = createMemo<SubagentEntry[]>(() => match(query(), entries()))
|
||||||
|
const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
|
||||||
|
const select = () => {
|
||||||
|
const item = items()[menu.selected()]
|
||||||
|
if (!item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onSelect(item.sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
query()
|
||||||
|
menu.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (query().trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = items().findIndex((item) => item.current)
|
||||||
|
if (index !== -1) {
|
||||||
|
menu.reveal(index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
|
||||||
|
})
|
||||||
|
|
||||||
|
useKeyboard((event) => {
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelShell
|
||||||
|
id="run-direct-footer-subagent-panel"
|
||||||
|
title="Select subagent"
|
||||||
|
query={query()}
|
||||||
|
count={items().length}
|
||||||
|
total={entries().length}
|
||||||
|
placeholder="Search"
|
||||||
|
theme={props.theme}
|
||||||
|
inputRef={(input) => {
|
||||||
|
field = input
|
||||||
|
}}
|
||||||
|
onQuery={setQuery}
|
||||||
|
>
|
||||||
|
<RunFooterMenu
|
||||||
|
id="run-direct-footer-subagent-list"
|
||||||
|
theme={props.theme}
|
||||||
|
items={items}
|
||||||
|
selected={menu.selected}
|
||||||
|
offset={menu.offset}
|
||||||
|
rows={menu.rows}
|
||||||
|
limit={SUBAGENT_LIST_ROWS}
|
||||||
|
empty="No active subagents"
|
||||||
|
border={false}
|
||||||
|
paddingLeft={PANEL_PAD}
|
||||||
|
paddingRight={PANEL_PAD}
|
||||||
|
grouped={false}
|
||||||
|
/>
|
||||||
|
</PanelShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function RunVariantSelectBody(props: {
|
export function RunVariantSelectBody(props: {
|
||||||
theme: Accessor<RunFooterTheme>
|
theme: Accessor<RunFooterTheme>
|
||||||
variants: Accessor<string[]>
|
variants: Accessor<string[]>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type PromptInput = {
|
|||||||
directory: string
|
directory: string
|
||||||
findFiles: (query: string) => Promise<string[]>
|
findFiles: (query: string) => Promise<string[]>
|
||||||
agents: Accessor<RunAgent[]>
|
agents: Accessor<RunAgent[]>
|
||||||
|
subagents: Accessor<number>
|
||||||
resources: Accessor<RunResource[]>
|
resources: Accessor<RunResource[]>
|
||||||
commands: Accessor<RunCommand[] | undefined>
|
commands: Accessor<RunCommand[] | undefined>
|
||||||
keybinds: FooterKeybinds
|
keybinds: FooterKeybinds
|
||||||
@@ -81,6 +82,7 @@ type PromptInput = {
|
|||||||
onInputClear: () => void
|
onInputClear: () => void
|
||||||
onExitRequest?: () => boolean
|
onExitRequest?: () => boolean
|
||||||
onExit: () => void
|
onExit: () => void
|
||||||
|
onSubagentMenu?: () => void
|
||||||
onRows: (rows: number) => void
|
onRows: (rows: number) => void
|
||||||
onStatus: (text: string) => void
|
onStatus: (text: string) => void
|
||||||
}
|
}
|
||||||
@@ -995,6 +997,23 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
key.name === "down" &&
|
||||||
|
!visible() &&
|
||||||
|
!event.ctrl &&
|
||||||
|
!event.meta &&
|
||||||
|
!event.shift &&
|
||||||
|
!event.super &&
|
||||||
|
area &&
|
||||||
|
!area.isDestroyed &&
|
||||||
|
area.plainText.length === 0 &&
|
||||||
|
input.subagents() > 0
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
input.onSubagentMenu?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (promptHit(keys().clear, key)) {
|
if (promptHit(keys().clear, key)) {
|
||||||
const handled = requestExit()
|
const handled = requestExit()
|
||||||
if (handled) {
|
if (handled) {
|
||||||
@@ -1049,7 +1068,12 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.view() === "command" || input.view() === "model" || input.view() === "variant") {
|
if (
|
||||||
|
input.view() === "command" ||
|
||||||
|
input.view() === "model" ||
|
||||||
|
input.view() === "variant" ||
|
||||||
|
input.view() === "subagent-menu"
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard } from "@opentui/solid"
|
||||||
import "opentui-spinner/solid"
|
import "opentui-spinner/solid"
|
||||||
import { createMemo, indexArray, mapArray } from "solid-js"
|
import { Show, createMemo, indexArray } from "solid-js"
|
||||||
import { SPINNER_FRAMES } from "../tui/component/spinner"
|
import { SPINNER_FRAMES } from "../tui/component/spinner"
|
||||||
import { RunEntryContent, separatorRows } from "./scrollback.writer"
|
import { RunEntryContent, separatorRows } from "./scrollback.writer"
|
||||||
import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
|
import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
|
||||||
import type { RunFooterTheme, RunTheme } from "./theme"
|
import type { RunFooterTheme, RunTheme } from "./theme"
|
||||||
|
|
||||||
export const SUBAGENT_TAB_ROWS = 2
|
export const SUBAGENT_INSPECTOR_ROWS = 14
|
||||||
export const SUBAGENT_INSPECTOR_ROWS = 8
|
|
||||||
|
|
||||||
function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
|
function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
@@ -35,74 +34,12 @@ function statusIcon(status: FooterSubagentTab["status"]) {
|
|||||||
return "◔"
|
return "◔"
|
||||||
}
|
}
|
||||||
|
|
||||||
function tabText(tab: FooterSubagentTab, slot: string, count: number, width: number) {
|
|
||||||
const perTab = Math.max(1, Math.floor((width - 4 - Math.max(0, count - 1) * 3) / Math.max(1, count)))
|
|
||||||
if (count >= 8 || perTab < 12) {
|
|
||||||
return `[${slot}]`
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefix = `[${slot}]`
|
|
||||||
if (count >= 5 || perTab < 24) {
|
|
||||||
return prefix
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = tab.description || tab.title || tab.label
|
|
||||||
return `${prefix} ${label}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RunFooterSubagentTabs(props: {
|
|
||||||
tabs: FooterSubagentTab[]
|
|
||||||
selected?: string
|
|
||||||
theme: RunFooterTheme
|
|
||||||
width: number
|
|
||||||
}) {
|
|
||||||
const items = mapArray(
|
|
||||||
() => props.tabs,
|
|
||||||
(tab, index) => {
|
|
||||||
const active = () => props.selected === tab.sessionID
|
|
||||||
const slot = () => String(index() + 1)
|
|
||||||
return (
|
|
||||||
<box paddingRight={1}>
|
|
||||||
<box flexDirection="row" gap={1} width="100%">
|
|
||||||
{tab.status === "running" ? (
|
|
||||||
<box flexShrink={0}>
|
|
||||||
<spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(props.theme, tab.status)} />
|
|
||||||
</box>
|
|
||||||
) : (
|
|
||||||
<text fg={statusColor(props.theme, tab.status)} wrapMode="none" truncate flexShrink={0}>
|
|
||||||
{statusIcon(tab.status)}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
<text fg={active() ? props.theme.text : props.theme.muted} wrapMode="none" truncate>
|
|
||||||
{tabText(tab, slot(), props.tabs.length, props.width)}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
id="run-direct-footer-subagent-tabs"
|
|
||||||
width="100%"
|
|
||||||
height={SUBAGENT_TAB_ROWS}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={2}
|
|
||||||
paddingBottom={1}
|
|
||||||
flexDirection="row"
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={3} flexShrink={1} flexGrow={1}>
|
|
||||||
{items()}
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RunFooterSubagentBody(props: {
|
export function RunFooterSubagentBody(props: {
|
||||||
active: () => boolean
|
active: () => boolean
|
||||||
theme: () => RunTheme
|
theme: () => RunTheme
|
||||||
|
tab: () => FooterSubagentTab | undefined
|
||||||
|
index: () => number
|
||||||
|
total: () => number
|
||||||
detail: () => FooterSubagentDetail | undefined
|
detail: () => FooterSubagentDetail | undefined
|
||||||
width: () => number
|
width: () => number
|
||||||
diffStyle?: RunDiffStyle
|
diffStyle?: RunDiffStyle
|
||||||
@@ -111,6 +48,7 @@ export function RunFooterSubagentBody(props: {
|
|||||||
}) {
|
}) {
|
||||||
const theme = createMemo(() => props.theme())
|
const theme = createMemo(() => props.theme())
|
||||||
const footer = createMemo(() => theme().footer)
|
const footer = createMemo(() => theme().footer)
|
||||||
|
const tab = createMemo(() => props.tab())
|
||||||
const commits = createMemo(() => props.detail()?.commits ?? [])
|
const commits = createMemo(() => props.detail()?.commits ?? [])
|
||||||
const opts = createMemo(() => ({ diffStyle: props.diffStyle }))
|
const opts = createMemo(() => ({ diffStyle: props.diffStyle }))
|
||||||
const scrollbar = createMemo(() => ({
|
const scrollbar = createMemo(() => ({
|
||||||
@@ -119,6 +57,22 @@ export function RunFooterSubagentBody(props: {
|
|||||||
foregroundColor: footer().line,
|
foregroundColor: footer().line,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
const title = createMemo(() => {
|
||||||
|
const current = tab()
|
||||||
|
if (!current) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.description || current.title || current.label
|
||||||
|
})
|
||||||
|
const subtitle = createMemo(() => {
|
||||||
|
const current = tab()
|
||||||
|
if (!current || title() === current.label) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.label
|
||||||
|
})
|
||||||
const rows = indexArray(commits, (commit, index) => (
|
const rows = indexArray(commits, (commit, index) => (
|
||||||
<box flexDirection="column" gap={0} flexShrink={0}>
|
<box flexDirection="column" gap={0} flexShrink={0}>
|
||||||
{index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
|
{index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
|
||||||
@@ -165,6 +119,32 @@ export function RunFooterSubagentBody(props: {
|
|||||||
backgroundColor={footer().surface}
|
backgroundColor={footer().surface}
|
||||||
>
|
>
|
||||||
<box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
|
<box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
|
||||||
|
<Show when={tab()}>
|
||||||
|
{(current) => (
|
||||||
|
<box width="100%" flexDirection="row" gap={1} paddingBottom={1} flexShrink={0}>
|
||||||
|
{current().status === "running" ? (
|
||||||
|
<box flexShrink={0}>
|
||||||
|
<spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(footer(), current().status)} />
|
||||||
|
</box>
|
||||||
|
) : (
|
||||||
|
<text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
|
||||||
|
{statusIcon(current().status)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text fg={footer().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||||
|
{title()}
|
||||||
|
<Show when={subtitle().length > 0}>
|
||||||
|
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
|
||||||
|
</Show>
|
||||||
|
</text>
|
||||||
|
<Show when={props.total() > 1 && props.index() > 0}>
|
||||||
|
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
|
||||||
|
{props.index()} of {props.total()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<scrollbox
|
<scrollbox
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ import { render } from "@opentui/solid"
|
|||||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { withRunSpan } from "./otel"
|
import { withRunSpan } from "./otel"
|
||||||
import { RUN_COMMAND_PANEL_ROWS } from "./footer.command"
|
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
||||||
import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent"
|
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
||||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||||
import { printableBinding } from "./prompt.shared"
|
import { printableBinding } from "./prompt.shared"
|
||||||
import { RunFooterView } from "./footer.view"
|
import { RunFooterView } from "./footer.view"
|
||||||
@@ -97,6 +97,7 @@ type RunFooterOptions = {
|
|||||||
const PERMISSION_ROWS = 12
|
const PERMISSION_ROWS = 12
|
||||||
const QUESTION_ROWS = 14
|
const QUESTION_ROWS = 14
|
||||||
const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
|
const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||||
|
const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
|
||||||
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
|
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||||
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
|
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||||
const AUTOCOMPLETE_COMPACT_ROWS = 2
|
const AUTOCOMPLETE_COMPACT_ROWS = 2
|
||||||
@@ -190,7 +191,7 @@ export class RunFooter implements FooterApi {
|
|||||||
private subagent: Accessor<FooterSubagentState>
|
private subagent: Accessor<FooterSubagentState>
|
||||||
private setSubagent: (next: FooterSubagentState) => void
|
private setSubagent: (next: FooterSubagentState) => void
|
||||||
private promptRoute: FooterPromptRoute = { type: "composer" }
|
private promptRoute: FooterPromptRoute = { type: "composer" }
|
||||||
private tabsVisible = false
|
private subagentMenuRows = SUBAGENT_ROWS
|
||||||
private autocomplete = false
|
private autocomplete = false
|
||||||
private interruptTimeout: NodeJS.Timeout | undefined
|
private interruptTimeout: NodeJS.Timeout | undefined
|
||||||
private exitTimeout: NodeJS.Timeout | undefined
|
private exitTimeout: NodeJS.Timeout | undefined
|
||||||
@@ -553,22 +554,23 @@ export class RunFooter implements FooterApi {
|
|||||||
// get fixed extra rows; the prompt view scales with textarea line count.
|
// get fixed extra rows; the prompt view scales with textarea line count.
|
||||||
private applyHeight(): void {
|
private applyHeight(): void {
|
||||||
const type = this.view().type
|
const type = this.view().type
|
||||||
const tabs = this.tabsVisible ? SUBAGENT_TAB_ROWS : 0
|
|
||||||
const compact = this.promptRoute.type === "composer" && this.autocomplete ? AUTOCOMPLETE_COMPACT_ROWS : 0
|
const compact = this.promptRoute.type === "composer" && this.autocomplete ? AUTOCOMPLETE_COMPACT_ROWS : 0
|
||||||
const base = this.base + tabs - compact
|
const base = this.base - compact
|
||||||
const height =
|
const height =
|
||||||
type === "permission"
|
type === "permission"
|
||||||
? this.base + PERMISSION_ROWS
|
? this.base + PERMISSION_ROWS
|
||||||
: type === "question"
|
: type === "question"
|
||||||
? this.base + QUESTION_ROWS
|
? this.base + QUESTION_ROWS
|
||||||
: this.promptRoute.type === "command"
|
: this.promptRoute.type === "command"
|
||||||
? 1 + tabs + COMMAND_ROWS
|
? 1 + COMMAND_ROWS
|
||||||
: this.promptRoute.type === "model"
|
: this.promptRoute.type === "model"
|
||||||
? 1 + tabs + MODEL_ROWS
|
? 1 + MODEL_ROWS
|
||||||
: this.promptRoute.type === "variant"
|
: this.promptRoute.type === "variant"
|
||||||
? 1 + tabs + VARIANT_ROWS
|
? 1 + VARIANT_ROWS
|
||||||
|
: this.promptRoute.type === "subagent-menu"
|
||||||
|
? 1 + this.subagentMenuRows
|
||||||
: this.promptRoute.type === "subagent"
|
: this.promptRoute.type === "subagent"
|
||||||
? this.base + tabs + SUBAGENT_INSPECTOR_ROWS
|
? this.base + SUBAGENT_INSPECTOR_ROWS
|
||||||
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
|
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
|
||||||
|
|
||||||
if (height !== this.renderer.footerHeight) {
|
if (height !== this.renderer.footerHeight) {
|
||||||
@@ -592,10 +594,10 @@ export class RunFooter implements FooterApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncLayout = (next: { route: FooterPromptRoute; tabs: boolean; autocomplete: boolean }): void => {
|
private syncLayout = (next: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }): void => {
|
||||||
this.promptRoute = next.route
|
this.promptRoute = next.route
|
||||||
this.tabsVisible = next.tabs
|
|
||||||
this.autocomplete = next.autocomplete
|
this.autocomplete = next.autocomplete
|
||||||
|
this.subagentMenuRows = next.subagentRows
|
||||||
if (this.view().type === "prompt") {
|
if (this.view().type === "prompt") {
|
||||||
this.applyHeight()
|
this.applyHeight()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
|||||||
import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import "opentui-spinner/solid"
|
import "opentui-spinner/solid"
|
||||||
import { createColors, createFrames } from "../tui/ui/spinner"
|
import { createColors, createFrames } from "../tui/ui/spinner"
|
||||||
import { RunCommandMenuBody, RunModelSelectBody, RunVariantSelectBody } from "./footer.command"
|
import {
|
||||||
|
RUN_SUBAGENT_PANEL_ROWS,
|
||||||
|
RunCommandMenuBody,
|
||||||
|
RunModelSelectBody,
|
||||||
|
RunSubagentSelectBody,
|
||||||
|
RunVariantSelectBody,
|
||||||
|
} from "./footer.command"
|
||||||
import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu"
|
import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu"
|
||||||
import { RunFooterSubagentBody, RunFooterSubagentTabs } from "./footer.subagent"
|
import { RunFooterSubagentBody } from "./footer.subagent"
|
||||||
import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
|
import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
|
||||||
import { RunPermissionBody } from "./footer.permission"
|
import { RunPermissionBody } from "./footer.permission"
|
||||||
import { RunQuestionBody } from "./footer.question"
|
import { RunQuestionBody } from "./footer.question"
|
||||||
@@ -85,30 +91,11 @@ type RunFooterViewProps = {
|
|||||||
onModelSelect: (model: NonNullable<RunInput["model"]>) => void
|
onModelSelect: (model: NonNullable<RunInput["model"]>) => void
|
||||||
onVariantSelect: (variant: string | undefined) => void
|
onVariantSelect: (variant: string | undefined) => void
|
||||||
onRows: (rows: number) => void
|
onRows: (rows: number) => void
|
||||||
onLayout: (input: { route: FooterPromptRoute; tabs: boolean; autocomplete: boolean }) => void
|
onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void
|
||||||
onStatus: (text: string) => void
|
onStatus: (text: string) => void
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function subagentShortcut(event: {
|
|
||||||
name: string
|
|
||||||
ctrl?: boolean
|
|
||||||
meta?: boolean
|
|
||||||
shift?: boolean
|
|
||||||
super?: boolean
|
|
||||||
}): number | undefined {
|
|
||||||
if (!event.ctrl || event.meta || event.super) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^[0-9]$/.test(event.name)) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const slot = Number(event.name)
|
|
||||||
return slot === 0 ? 9 : slot - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
|
export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
|
||||||
|
|
||||||
export function RunFooterView(props: RunFooterViewProps) {
|
export function RunFooterView(props: RunFooterViewProps) {
|
||||||
@@ -125,18 +112,39 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
|
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
|
||||||
|
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
|
||||||
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
||||||
|
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
|
||||||
const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
|
const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
|
||||||
const commanding = createMemo(() => active().type === "prompt" && route().type === "command")
|
const commanding = createMemo(() => active().type === "prompt" && route().type === "command")
|
||||||
const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
|
const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
|
||||||
const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
|
const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
|
||||||
const panel = createMemo(() => commanding() || modeling() || varianting())
|
const panel = createMemo(() => selectingSubagent() || commanding() || modeling() || varianting())
|
||||||
const selected = createMemo(() => {
|
const selected = createMemo(() => {
|
||||||
const current = route()
|
const current = route()
|
||||||
return current.type === "subagent" ? current.sessionID : undefined
|
return current.type === "subagent" ? current.sessionID : undefined
|
||||||
})
|
})
|
||||||
const tabs = createMemo(() => subagent().tabs)
|
const tabs = createMemo(() => subagent().tabs)
|
||||||
const showTabs = createMemo(() => active().type === "prompt" && tabs().length > 0)
|
const selectedTab = createMemo(() => tabs().find((item) => item.sessionID === selected()))
|
||||||
|
const selectedIndex = createMemo(() => {
|
||||||
|
const sessionID = selected()
|
||||||
|
if (!sessionID) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabs().findIndex((item) => item.sessionID === sessionID) + 1
|
||||||
|
})
|
||||||
|
const subagentIndicator = createMemo(() => {
|
||||||
|
const count = tabs().length
|
||||||
|
if (count === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
label: count === 1 ? "agent" : "agents",
|
||||||
|
}
|
||||||
|
})
|
||||||
const detail = createMemo(() => {
|
const detail = createMemo(() => {
|
||||||
const current = route()
|
const current = route()
|
||||||
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
||||||
@@ -203,6 +211,15 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
props.onSubagentSelect?.(undefined)
|
props.onSubagentSelect?.(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openSubagentMenu = () => {
|
||||||
|
if (tabs().length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setRoute({ type: "subagent-menu" })
|
||||||
|
props.onSubagentSelect?.(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
const closePanel = () => {
|
const closePanel = () => {
|
||||||
setRoute({ type: "composer" })
|
setRoute({ type: "composer" })
|
||||||
}
|
}
|
||||||
@@ -217,16 +234,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
props.onSubagentSelect?.(undefined)
|
props.onSubagentSelect?.(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleTab = (sessionID: string) => {
|
|
||||||
const current = route()
|
|
||||||
if (current.type === "subagent" && current.sessionID === sessionID) {
|
|
||||||
closeTab()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
openTab(sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cycleTab = (dir: -1 | 1) => {
|
const cycleTab = (dir: -1 | 1) => {
|
||||||
if (tabs().length === 0) {
|
if (tabs().length === 0) {
|
||||||
return
|
return
|
||||||
@@ -247,6 +254,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
directory: props.directory,
|
directory: props.directory,
|
||||||
findFiles: props.findFiles,
|
findFiles: props.findFiles,
|
||||||
agents: props.agents,
|
agents: props.agents,
|
||||||
|
subagents: () => tabs().length,
|
||||||
resources: props.resources,
|
resources: props.resources,
|
||||||
commands: props.commands,
|
commands: props.commands,
|
||||||
keybinds: props.keybinds,
|
keybinds: props.keybinds,
|
||||||
@@ -262,6 +270,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
onInputClear: props.onInputClear,
|
onInputClear: props.onInputClear,
|
||||||
onExitRequest: props.onExitRequest,
|
onExitRequest: props.onExitRequest,
|
||||||
onExit: props.onExit,
|
onExit: props.onExit,
|
||||||
|
onSubagentMenu: openSubagentMenu,
|
||||||
onRows: props.onRows,
|
onRows: props.onRows,
|
||||||
onStatus: props.onStatus,
|
onStatus: props.onStatus,
|
||||||
})
|
})
|
||||||
@@ -301,23 +310,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
openCommand()
|
openCommand()
|
||||||
})
|
})
|
||||||
|
|
||||||
useKeyboard((event) => {
|
|
||||||
if (active().type !== "prompt") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const slot = subagentShortcut(event)
|
|
||||||
if (slot !== undefined) {
|
|
||||||
const next = tabs()[slot]
|
|
||||||
if (!next) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
toggleTab(next.sessionID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current = route()
|
const current = route()
|
||||||
if (current.type !== "subagent") {
|
if (current.type !== "subagent") {
|
||||||
@@ -331,13 +323,30 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
closeTab()
|
closeTab()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (route().type !== "subagent-menu") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabs().length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closePanel()
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (active().type === "prompt") {
|
if (active().type === "prompt") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = route()
|
const current = route()
|
||||||
if (current.type !== "command" && current.type !== "model" && current.type !== "variant") {
|
if (
|
||||||
|
current.type !== "command" &&
|
||||||
|
current.type !== "model" &&
|
||||||
|
current.type !== "variant" &&
|
||||||
|
current.type !== "subagent-menu"
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,8 +356,8 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.onLayout({
|
props.onLayout({
|
||||||
route: route(),
|
route: route(),
|
||||||
tabs: tabs().length > 0,
|
|
||||||
autocomplete: menu(),
|
autocomplete: menu(),
|
||||||
|
subagentRows: subagentMenuRows(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -365,10 +374,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
>
|
>
|
||||||
<box id="run-direct-footer-top-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
|
<box id="run-direct-footer-top-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
|
||||||
|
|
||||||
<Show when={showTabs()}>
|
|
||||||
<RunFooterSubagentTabs tabs={tabs()} selected={selected()} theme={theme()} width={term().width} />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={inspecting()}
|
when={inspecting()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -409,14 +414,26 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
bind={composer.bind}
|
bind={composer.bind}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={selectingSubagent()}>
|
||||||
|
<RunSubagentSelectBody
|
||||||
|
theme={theme}
|
||||||
|
tabs={tabs}
|
||||||
|
current={selected}
|
||||||
|
onClose={closePanel}
|
||||||
|
onSelect={openTab}
|
||||||
|
onRows={setSubagentMenuRows}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
<Match when={commanding()}>
|
<Match when={commanding()}>
|
||||||
<RunCommandMenuBody
|
<RunCommandMenuBody
|
||||||
theme={theme}
|
theme={theme}
|
||||||
commands={props.commands}
|
commands={props.commands}
|
||||||
|
subagents={tabs}
|
||||||
variants={props.variants}
|
variants={props.variants}
|
||||||
keybinds={props.keybinds}
|
keybinds={props.keybinds}
|
||||||
onClose={closePanel}
|
onClose={closePanel}
|
||||||
onModel={openModel}
|
onModel={openModel}
|
||||||
|
onSubagent={openSubagentMenu}
|
||||||
onVariant={openVariant}
|
onVariant={openVariant}
|
||||||
onVariantCycle={() => {
|
onVariantCycle={() => {
|
||||||
props.onCycle()
|
props.onCycle()
|
||||||
@@ -573,22 +590,16 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
gap={1}
|
gap={1}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
<Show when={busy() || exiting()}>
|
<Show when={busy() || exiting() || duration().length > 0 || subagentIndicator()}>
|
||||||
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
|
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0} marginLeft={1}>
|
||||||
<Show when={exiting()}>
|
<Show when={exiting()}>
|
||||||
<text
|
<text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate>
|
||||||
id="run-direct-footer-hint-exit"
|
|
||||||
fg={theme().highlight}
|
|
||||||
wrapMode="none"
|
|
||||||
truncate
|
|
||||||
marginLeft={1}
|
|
||||||
>
|
|
||||||
Press Ctrl-c again to exit
|
Press Ctrl-c again to exit
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={busy() && !exiting()}>
|
<Show when={busy() && !exiting()}>
|
||||||
<box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
|
<box id="run-direct-footer-status-spinner" flexShrink={0}>
|
||||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
@@ -604,11 +615,9 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
</span>
|
</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!busy() && !exiting() && duration().length > 0}>
|
<Show when={!busy() && !exiting() && duration().length > 0}>
|
||||||
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
|
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0}>
|
||||||
<text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
|
<text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
|
||||||
▣
|
▣
|
||||||
</text>
|
</text>
|
||||||
@@ -623,6 +632,22 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={subagentIndicator()}>
|
||||||
|
{(info) => (
|
||||||
|
<text id="run-direct-footer-subagents-label" fg={theme().text} wrapMode="none" truncate>
|
||||||
|
<Show when={busy() || exiting() || duration().length > 0}>
|
||||||
|
<span style={{ fg: theme().muted }}>· </span>
|
||||||
|
</Show>
|
||||||
|
{info().count} <span style={{ fg: theme().muted }}>{info().label}</span>
|
||||||
|
<span style={{ fg: theme().muted }}> · </span>
|
||||||
|
<span style={{ fg: theme().highlight }}>↓</span>
|
||||||
|
<span style={{ fg: theme().muted }}> to view</span>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||||
|
|
||||||
<box
|
<box
|
||||||
@@ -720,6 +745,9 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
<RunFooterSubagentBody
|
<RunFooterSubagentBody
|
||||||
active={inspecting}
|
active={inspecting}
|
||||||
theme={runTheme}
|
theme={runTheme}
|
||||||
|
tab={selectedTab}
|
||||||
|
index={selectedIndex}
|
||||||
|
total={() => tabs().length}
|
||||||
detail={detail}
|
detail={detail}
|
||||||
width={() => term().width}
|
width={() => term().width}
|
||||||
diffStyle={props.diffStyle}
|
diffStyle={props.diffStyle}
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export type FooterView =
|
|||||||
|
|
||||||
export type FooterPromptRoute =
|
export type FooterPromptRoute =
|
||||||
| { type: "composer" }
|
| { type: "composer" }
|
||||||
|
| { type: "subagent-menu" }
|
||||||
| { type: "subagent"; sessionID: string }
|
| { type: "subagent"; sessionID: string }
|
||||||
| { type: "command" }
|
| { type: "command" }
|
||||||
| { type: "model" }
|
| { type: "model" }
|
||||||
|
|||||||
@@ -4,13 +4,26 @@ import { testRender } from "@opentui/solid"
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import {
|
import {
|
||||||
RUN_COMMAND_PANEL_ROWS,
|
RUN_COMMAND_PANEL_ROWS,
|
||||||
|
RUN_SUBAGENT_PANEL_ROWS,
|
||||||
RunCommandMenuBody,
|
RunCommandMenuBody,
|
||||||
RunModelSelectBody,
|
RunModelSelectBody,
|
||||||
|
RunSubagentSelectBody,
|
||||||
RunVariantSelectBody,
|
RunVariantSelectBody,
|
||||||
} from "@/cli/cmd/run/footer.command"
|
} from "@/cli/cmd/run/footer.command"
|
||||||
|
import { RunFooterView } from "@/cli/cmd/run/footer.view"
|
||||||
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
|
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
|
||||||
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
|
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
|
||||||
import type { FooterKeybinds, RunCommand, RunInput, RunProvider, StreamCommit } from "@/cli/cmd/run/types"
|
import type {
|
||||||
|
FooterKeybinds,
|
||||||
|
FooterState,
|
||||||
|
FooterSubagentState,
|
||||||
|
FooterSubagentTab,
|
||||||
|
FooterView,
|
||||||
|
RunCommand,
|
||||||
|
RunInput,
|
||||||
|
RunProvider,
|
||||||
|
StreamCommit,
|
||||||
|
} from "@/cli/cmd/run/types"
|
||||||
|
|
||||||
function bindings(...keys: string[]) {
|
function bindings(...keys: string[]) {
|
||||||
return keys.map((key) => ({ key }))
|
return keys.map((key) => ({ key }))
|
||||||
@@ -111,6 +124,18 @@ function provider() {
|
|||||||
} satisfies RunProvider
|
} satisfies RunProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subagent(input: { sessionID: string; label: string; description: string; status?: FooterSubagentTab["status"] }) {
|
||||||
|
return {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
partID: `part-${input.sessionID}`,
|
||||||
|
callID: `call-${input.sessionID}`,
|
||||||
|
label: input.label,
|
||||||
|
description: input.description,
|
||||||
|
status: input.status ?? "running",
|
||||||
|
lastUpdatedAt: 1,
|
||||||
|
} satisfies FooterSubagentTab
|
||||||
|
}
|
||||||
|
|
||||||
test("run entry content updates when live commit text changes", async () => {
|
test("run entry content updates when live commit text changes", async () => {
|
||||||
const [commit, setCommit] = createSignal<StreamCommit>({
|
const [commit, setCommit] = createSignal<StreamCommit>({
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
@@ -161,6 +186,7 @@ test("direct command panel renders grouped command palette", async () => {
|
|||||||
command({ name: "deploy", description: "Deploy prompt", source: "mcp" }),
|
command({ name: "deploy", description: "Deploy prompt", source: "mcp" }),
|
||||||
command({ name: "internal", description: "Skill command", source: "skill" }),
|
command({ name: "internal", description: "Skill command", source: "skill" }),
|
||||||
])
|
])
|
||||||
|
const [subagents] = createSignal([])
|
||||||
const [variants] = createSignal(["high", "minimal"])
|
const [variants] = createSignal(["high", "minimal"])
|
||||||
|
|
||||||
const app = await testRender(
|
const app = await testRender(
|
||||||
@@ -169,10 +195,12 @@ test("direct command panel renders grouped command palette", async () => {
|
|||||||
<RunCommandMenuBody
|
<RunCommandMenuBody
|
||||||
theme={() => RUN_THEME_FALLBACK.footer}
|
theme={() => RUN_THEME_FALLBACK.footer}
|
||||||
commands={commands}
|
commands={commands}
|
||||||
|
subagents={subagents}
|
||||||
variants={variants}
|
variants={variants}
|
||||||
keybinds={keybinds}
|
keybinds={keybinds}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
onModel={() => {}}
|
onModel={() => {}}
|
||||||
|
onSubagent={() => {}}
|
||||||
onVariant={() => {}}
|
onVariant={() => {}}
|
||||||
onVariantCycle={() => {}}
|
onVariantCycle={() => {}}
|
||||||
onCommand={() => {}}
|
onCommand={() => {}}
|
||||||
@@ -214,6 +242,160 @@ test("direct command panel renders grouped command palette", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("direct command panel shows subagent entry when available", async () => {
|
||||||
|
const [commands] = createSignal<RunCommand[] | undefined>([])
|
||||||
|
const [subagents] = createSignal([subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })])
|
||||||
|
const [variants] = createSignal<string[]>([])
|
||||||
|
|
||||||
|
const app = await testRender(
|
||||||
|
() => (
|
||||||
|
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
||||||
|
<RunCommandMenuBody
|
||||||
|
theme={() => RUN_THEME_FALLBACK.footer}
|
||||||
|
commands={commands}
|
||||||
|
subagents={subagents}
|
||||||
|
variants={variants}
|
||||||
|
keybinds={keybinds}
|
||||||
|
onClose={() => {}}
|
||||||
|
onModel={() => {}}
|
||||||
|
onSubagent={() => {}}
|
||||||
|
onVariant={() => {}}
|
||||||
|
onVariantCycle={() => {}}
|
||||||
|
onCommand={() => {}}
|
||||||
|
onNew={() => {}}
|
||||||
|
onExit={() => {}}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 100,
|
||||||
|
height: RUN_COMMAND_PANEL_ROWS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
const frame = app.captureCharFrame()
|
||||||
|
|
||||||
|
expect(frame).toContain("View subagents")
|
||||||
|
expect(frame).toContain("1 active")
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("direct subagent panel renders active subagents", async () => {
|
||||||
|
const [tabs] = createSignal([
|
||||||
|
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
|
||||||
|
subagent({ sessionID: "s-2", label: "General", description: "Write migration plan", status: "completed" }),
|
||||||
|
])
|
||||||
|
const [current] = createSignal<string | undefined>("s-1")
|
||||||
|
let rows = 0
|
||||||
|
|
||||||
|
const app = await testRender(
|
||||||
|
() => (
|
||||||
|
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
|
||||||
|
<RunSubagentSelectBody
|
||||||
|
theme={() => RUN_THEME_FALLBACK.footer}
|
||||||
|
tabs={tabs}
|
||||||
|
current={current}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onRows={(value) => {
|
||||||
|
rows = value
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 100,
|
||||||
|
height: RUN_SUBAGENT_PANEL_ROWS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
const frame = app.captureCharFrame()
|
||||||
|
|
||||||
|
expect(frame).toContain("Select subagent")
|
||||||
|
expect(frame).toContain("Inspect auth flow")
|
||||||
|
expect(frame).toContain("Write migration plan")
|
||||||
|
expect(frame).toContain("done")
|
||||||
|
expect(rows).toBe(8)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("direct footer shows subagent indicator while prompt is running", async () => {
|
||||||
|
const [state] = createSignal<FooterState>({
|
||||||
|
phase: "running",
|
||||||
|
status: "",
|
||||||
|
queue: 0,
|
||||||
|
model: "gpt-5",
|
||||||
|
duration: "",
|
||||||
|
usage: "",
|
||||||
|
first: false,
|
||||||
|
interrupt: 0,
|
||||||
|
exit: 0,
|
||||||
|
})
|
||||||
|
const [view] = createSignal<FooterView>({ type: "prompt" })
|
||||||
|
const [subagents] = createSignal<FooterSubagentState>({
|
||||||
|
tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
|
||||||
|
details: {},
|
||||||
|
permissions: [],
|
||||||
|
questions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = await testRender(
|
||||||
|
() => (
|
||||||
|
<box width={100} height={8}>
|
||||||
|
<RunFooterView
|
||||||
|
directory="/tmp"
|
||||||
|
findFiles={async () => []}
|
||||||
|
agents={() => []}
|
||||||
|
resources={() => []}
|
||||||
|
commands={() => []}
|
||||||
|
providers={() => undefined}
|
||||||
|
currentModel={() => undefined}
|
||||||
|
variants={() => []}
|
||||||
|
currentVariant={() => undefined}
|
||||||
|
state={state}
|
||||||
|
view={view}
|
||||||
|
subagent={subagents}
|
||||||
|
theme={RUN_THEME_FALLBACK}
|
||||||
|
keybinds={keybinds}
|
||||||
|
agent="opencode"
|
||||||
|
onSubmit={() => true}
|
||||||
|
onPermissionReply={() => {}}
|
||||||
|
onQuestionReply={() => {}}
|
||||||
|
onQuestionReject={() => {}}
|
||||||
|
onCycle={() => {}}
|
||||||
|
onInterrupt={() => false}
|
||||||
|
onInputClear={() => {}}
|
||||||
|
onExit={() => {}}
|
||||||
|
onModelSelect={() => {}}
|
||||||
|
onVariantSelect={() => {}}
|
||||||
|
onRows={() => {}}
|
||||||
|
onLayout={() => {}}
|
||||||
|
onStatus={() => {}}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 100,
|
||||||
|
height: 8,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ↓ to view")
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("direct model panel renders current model selector", async () => {
|
test("direct model panel renders current model selector", async () => {
|
||||||
const [providers] = createSignal<RunProvider[] | undefined>([provider()])
|
const [providers] = createSignal<RunProvider[] | undefined>([provider()])
|
||||||
const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })
|
const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })
|
||||||
|
|||||||
Reference in New Issue
Block a user