mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): background-agents tray — down-arrow focus + enter to dashboard
This commit is contained in:
parent
6438acec60
commit
c9c6cfc0ee
6 changed files with 546 additions and 8 deletions
265
ui-opentui/src/test/agentsTray.test.tsx
Normal file
265
ui-opentui/src/test/agentsTray.test.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* Background-agents tray tests (Epic 2.7). Headless frames through the real
|
||||
* App + Composer + AgentsTray with a simulated keyboard:
|
||||
*
|
||||
* - visibility: nothing rendered with 0 running agents; a one-line muted
|
||||
* indicator with the count otherwise; completed/failed agents drop out.
|
||||
* - focus-routing table: Down on an EMPTY composer with running agents
|
||||
* focuses/expands the tray; Down with text keeps its meaning; Down with
|
||||
* the slash menu open stays menu navigation (routeMenuKey integration
|
||||
* pin); Down with 0 agents keeps prompt history; Esc from the tray
|
||||
* returns focus to the composer; a printable key from the tray bounces
|
||||
* focus back AND inserts the char (the composer's reclaim rule).
|
||||
* - Enter on a tray row opens the agents dashboard preselected on that row.
|
||||
*
|
||||
* The onType wiring mirrors slashMenu.test.tsx (planCompletion → fake catalog)
|
||||
* so the menu-precedence pin runs against entry-parity completions.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { createPromptHistory } from '../logic/history.ts'
|
||||
import { planCompletion } from '../logic/slash.ts'
|
||||
import { createSessionStore, type CompletionItem, type SessionStore } from '../logic/store.ts'
|
||||
import { App } from '../view/App.tsx'
|
||||
import { isTrayAgent } from '../view/agentsTray.tsx'
|
||||
import { ThemeProvider } from '../view/theme.tsx'
|
||||
import { renderProbe, type RenderProbe } from './lib/render.ts'
|
||||
|
||||
const INDICATOR = 'running — ↓ to inspect'
|
||||
const EXPANDED_HINT = 'Enter inspect'
|
||||
|
||||
/** Fake gateway catalog (what `complete.slash` would return for a `/` prefix). */
|
||||
const CATALOG: CompletionItem[] = [
|
||||
{ display: '/clear', meta: 'clear the transcript', text: '/clear' },
|
||||
{ display: '/copy', meta: 'copy the last response', text: '/copy' }
|
||||
]
|
||||
|
||||
interface Harness {
|
||||
probe: RenderProbe
|
||||
store: SessionStore
|
||||
submitted: string[]
|
||||
typed: string[]
|
||||
}
|
||||
|
||||
/** Mount the real App (entry-parity onType, like slashMenu.test.tsx). */
|
||||
async function mountApp(historyEntries: string[] = []): Promise<Harness> {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
const submitted: string[] = []
|
||||
const typed: string[] = []
|
||||
const history = createPromptHistory({ initial: historyEntries })
|
||||
const onType = (text: string) => {
|
||||
typed.push(text)
|
||||
const plan = planCompletion(text)
|
||||
if (!plan || plan.method !== 'complete.slash') {
|
||||
store.clearCompletions()
|
||||
return
|
||||
}
|
||||
const q = String(plan.params.text).toLowerCase()
|
||||
const items = CATALOG.filter(c => c.text.startsWith(q) && c.text !== q)
|
||||
if (items.length) store.setCompletions(items, plan.from)
|
||||
else store.clearCompletions()
|
||||
}
|
||||
const probe = await renderProbe(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} onSubmit={t => submitted.push(t)} onType={onType} history={history} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
// kitty keyboard: a SIMULATED lone ESC never parses under legacy input, and
|
||||
// the Esc-from-tray test needs it.
|
||||
{ height: 26, kittyKeyboard: true, width: 70 }
|
||||
)
|
||||
return { probe, store, submitted, typed }
|
||||
}
|
||||
|
||||
const spawn = (store: SessionStore, id: string, goal: string) =>
|
||||
store.apply({ type: 'subagent.start', payload: { depth: 0, goal, subagent_id: id } })
|
||||
|
||||
const complete = (store: SessionStore, id: string) =>
|
||||
store.apply({ type: 'subagent.complete', payload: { subagent_id: id, summary: 'done' } })
|
||||
|
||||
describe('agents tray — visibility', () => {
|
||||
test('isTrayAgent: running-ish statuses are in; ALL terminal statuses are out', () => {
|
||||
for (const status of ['running', 'thinking', 'tool', 'working']) {
|
||||
expect(isTrayAgent({ depth: 0, goal: 'g', id: 'x', status })).toBe(true)
|
||||
}
|
||||
// `complete` is the store fallback; the LIVE gateway sends delegate_tool's
|
||||
// payload status verbatim — `completed`/`failed`/`error`/`timeout`/`interrupted`
|
||||
// (verified live: the success path emits status="completed").
|
||||
for (const status of ['complete', 'completed', 'failed', 'error', 'timeout', 'interrupted']) {
|
||||
expect(isTrayAgent({ depth: 0, goal: 'g', id: 'x', status })).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('0 running agents → the tray renders nothing', async () => {
|
||||
const h = await mountApp()
|
||||
try {
|
||||
expect(h.probe.frame()).not.toContain(INDICATOR)
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('2 running agents → a one-line indicator with the count', async () => {
|
||||
const h = await mountApp()
|
||||
try {
|
||||
spawn(h.store, 'a1', 'research X')
|
||||
spawn(h.store, 'a2', 'compile Y')
|
||||
const frame = await h.probe.waitForFrame(f => f.includes(INDICATOR))
|
||||
expect(frame).toContain(`⚡ 2 agents ${INDICATOR}`)
|
||||
expect(frame).not.toContain(EXPANDED_HINT) // collapsed until focused
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('completed agents drop out; the tray empties when all finish', async () => {
|
||||
const h = await mountApp()
|
||||
try {
|
||||
spawn(h.store, 'a1', 'research X')
|
||||
spawn(h.store, 'a2', 'compile Y')
|
||||
await h.probe.waitForFrame(f => f.includes('⚡ 2 agents'))
|
||||
complete(h.store, 'a1')
|
||||
const one = await h.probe.waitForFrame(f => f.includes('⚡ 1 agent '))
|
||||
expect(one).toContain(`⚡ 1 agent ${INDICATOR}`)
|
||||
complete(h.store, 'a2')
|
||||
const none = await h.probe.waitForFrame(f => !f.includes(INDICATOR))
|
||||
expect(none).not.toContain('⚡')
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('agents tray — Down-arrow focus routing', () => {
|
||||
test('Down on an EMPTY composer with running agents focuses + expands the tray', async () => {
|
||||
const h = await mountApp()
|
||||
try {
|
||||
spawn(h.store, 'a1', 'research X')
|
||||
spawn(h.store, 'a2', 'compile Y')
|
||||
await h.probe.waitForFrame(f => f.includes(INDICATOR))
|
||||
h.probe.keys.pressArrow('down')
|
||||
const frame = await h.probe.waitForFrame(f => f.includes(EXPANDED_HINT))
|
||||
// rows show goal + status, with the first row selected
|
||||
expect(frame).toContain('research X')
|
||||
expect(frame).toContain('compile Y')
|
||||
expect(frame).toContain('● running')
|
||||
expect(frame).toMatch(/▸ ● running\s+research X/)
|
||||
expect(frame).not.toContain(INDICATOR) // collapsed line replaced by rows
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Down with TEXT in the composer keeps its meaning (no tray focus)', async () => {
|
||||
const h = await mountApp()
|
||||
try {
|
||||
spawn(h.store, 'a1', 'research X')
|
||||
await h.probe.waitForFrame(f => f.includes(INDICATOR))
|
||||
await h.probe.keys.typeText('hello')
|
||||
await h.probe.settle()
|
||||
h.probe.keys.pressArrow('down')
|
||||
await h.probe.settle()
|
||||
const frame = h.probe.frame()
|
||||
expect(frame).toContain('hello') // text untouched
|
||||
expect(frame).not.toContain(EXPANDED_HINT)
|
||||
expect(frame).toContain(INDICATOR) // still just the indicator
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Down with the slash menu open stays MENU navigation (routeMenuKey pin)', async () => {
|
||||
const h = await mountApp()
|
||||
try {
|
||||
spawn(h.store, 'a1', 'research X')
|
||||
await h.probe.waitForFrame(f => f.includes(INDICATOR))
|
||||
await h.probe.keys.typeText('/')
|
||||
await h.probe.settle()
|
||||
await h.probe.waitForFrame(f => f.includes('/copy'))
|
||||
h.probe.keys.pressArrow('down') // menu: /clear → /copy (NOT the tray)
|
||||
await h.probe.settle()
|
||||
expect(h.probe.frame()).not.toContain(EXPANDED_HINT)
|
||||
h.probe.keys.pressEnter() // accepts the highlighted command
|
||||
await h.probe.settle()
|
||||
expect(h.typed.at(-1)).toBe('/copy ')
|
||||
expect(h.submitted).toEqual([])
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Down with 0 running agents keeps prompt history as today', async () => {
|
||||
const h = await mountApp(['older prompt'])
|
||||
try {
|
||||
h.probe.keys.pressArrow('up') // recall
|
||||
await h.probe.settle()
|
||||
expect(h.probe.frame()).toContain('older prompt')
|
||||
h.probe.keys.pressArrow('down') // back to the (empty) draft — not a tray focus
|
||||
await h.probe.settle()
|
||||
const frame = h.probe.frame()
|
||||
expect(frame).not.toContain('older prompt')
|
||||
expect(frame).not.toContain(EXPANDED_HINT)
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Esc from the focused tray collapses it and refocuses the composer', async () => {
|
||||
const h = await mountApp()
|
||||
try {
|
||||
spawn(h.store, 'a1', 'research X')
|
||||
await h.probe.waitForFrame(f => f.includes(INDICATOR))
|
||||
h.probe.keys.pressArrow('down')
|
||||
await h.probe.waitForFrame(f => f.includes(EXPANDED_HINT))
|
||||
h.probe.keys.pressEscape()
|
||||
const frame = await h.probe.waitForFrame(f => !f.includes(EXPANDED_HINT))
|
||||
expect(frame).toContain(INDICATOR) // back to the collapsed line
|
||||
await h.probe.keys.typeText('hi') // composer has focus again
|
||||
await h.probe.settle()
|
||||
expect(h.probe.frame()).toContain('hi')
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('a printable key from the focused tray bounces to the composer AND inserts', async () => {
|
||||
const h = await mountApp()
|
||||
try {
|
||||
spawn(h.store, 'a1', 'research X')
|
||||
await h.probe.waitForFrame(f => f.includes(INDICATOR))
|
||||
h.probe.keys.pressArrow('down')
|
||||
await h.probe.waitForFrame(f => f.includes(EXPANDED_HINT))
|
||||
await h.probe.keys.typeText('x')
|
||||
const frame = await h.probe.waitForFrame(f => !f.includes(EXPANDED_HINT))
|
||||
expect(frame).toContain(INDICATOR) // tray collapsed (textarea reclaimed focus)
|
||||
expect(frame).toContain('x') // …and the char landed in the composer
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('agents tray — Enter opens the dashboard preselected', () => {
|
||||
test('Down to the second row + Enter → dashboard open on THAT agent', async () => {
|
||||
const h = await mountApp()
|
||||
try {
|
||||
spawn(h.store, 'a1', 'research X')
|
||||
spawn(h.store, 'a2', 'compile Y')
|
||||
await h.probe.waitForFrame(f => f.includes(INDICATOR))
|
||||
h.probe.keys.pressArrow('down') // focus the tray (row 0)
|
||||
await h.probe.waitForFrame(f => f.includes(EXPANDED_HINT))
|
||||
h.probe.keys.pressArrow('down') // select row 1 (compile Y)
|
||||
await h.probe.settle()
|
||||
h.probe.keys.pressEnter()
|
||||
const frame = await h.probe.waitForFrame(f => f.includes('⛓ Agents'))
|
||||
expect(h.store.state.dashboard).toBe(true)
|
||||
expect(h.store.state.dashboardAgent).toBe('a2')
|
||||
expect(frame).toMatch(/▸ ● running\s+compile Y/) // master list preselected
|
||||
expect(h.submitted).toEqual([]) // Enter opened the dashboard, no submit
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -19,6 +19,7 @@ import { deferClose } from '../logic/defer.ts'
|
|||
import type { PromptHistory } from '../logic/history.ts'
|
||||
import type { PasteStore } from '../logic/pastes.ts'
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
import { AgentsTray, type AgentsTrayApi } from './agentsTray.tsx'
|
||||
import { Composer } from './composer.tsx'
|
||||
import { DimensionsProvider } from './dimensions.tsx'
|
||||
import { Header } from './header.tsx'
|
||||
|
|
@ -52,6 +53,11 @@ const NO_SESSION = () => undefined
|
|||
|
||||
export function App(props: AppProps) {
|
||||
const theme = useTheme()
|
||||
// Background-agents tray focus plumbing (Epic 2.7): Down on an empty composer
|
||||
// hands focus to the tray; Esc from the tray hands it back. Plain refs — the
|
||||
// tray persists across composer remounts (overlay close re-registers focus).
|
||||
let trayApi: AgentsTrayApi | undefined
|
||||
let focusComposer: (() => void) | undefined
|
||||
const blocked = () => props.store.state.prompt !== undefined
|
||||
const pager = () => props.store.state.pager
|
||||
const dashboard = () => props.store.state.dashboard
|
||||
|
|
@ -105,6 +111,8 @@ export function App(props: AppProps) {
|
|||
history={props.history}
|
||||
onImagePaste={props.onImagePaste}
|
||||
pasteStore={props.pasteStore}
|
||||
onFocusDown={() => trayApi?.focusTray() ?? false}
|
||||
registerFocus={fn => (focusComposer = fn)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
@ -132,13 +140,26 @@ export function App(props: AppProps) {
|
|||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
{/* background-agents tray (Epic 2.7): renders nothing with no
|
||||
running agents; a one-line indicator otherwise; expands while
|
||||
focused (composer Down). Enter opens the dashboard on that row. */}
|
||||
<AgentsTray
|
||||
subagents={props.store.state.subagents}
|
||||
onOpen={id => props.store.openDashboard(id)}
|
||||
onExit={() => focusComposer?.()}
|
||||
bind={api => (trayApi = api)}
|
||||
/>
|
||||
</box>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Match when={pager()}>{p => <Pager title={p().title} text={p().text} onClose={closePager} />}</Match>
|
||||
<Match when={dashboard()}>
|
||||
<AgentsDashboard subagents={props.store.state.subagents} onClose={closeDashboard} />
|
||||
<AgentsDashboard
|
||||
subagents={props.store.state.subagents}
|
||||
onClose={closeDashboard}
|
||||
preselect={props.store.state.dashboardAgent}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
|
|
|||
203
ui-opentui/src/view/agentsTray.tsx
Normal file
203
ui-opentui/src/view/agentsTray.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* AgentsTray — the background-agents tray docked below the composer (Epic 2.7).
|
||||
*
|
||||
* Collapsed (unfocused): one muted, always-honest line — `⚡ N agents running —
|
||||
* ↓ to inspect`. Nothing at all when no agent is running (no stolen transcript
|
||||
* height). Expanded (focused): one row per RUNNING subagent (status not
|
||||
* complete/failed) showing status · goal · elapsed-ish · last activity line,
|
||||
* with a themed highlight on the selection.
|
||||
*
|
||||
* Focus routing (the hard part): the tray takes NATIVE focus (its root box is
|
||||
* focusable) — `focusRenderable` blurs the composer textarea for us, and
|
||||
* focusing the textarea back blurs the box, whose BLURRED event is the single
|
||||
* collapse trigger. The composer hands focus over via `onFocusDown` (Down on an
|
||||
* EMPTY composer, no dropdown — see composer.tsx); while the tray is focused:
|
||||
* - Up/Down move the selection (composer's history handler is gated on the
|
||||
* textarea being focused, so it stays out of the way);
|
||||
* - Enter opens the agents dashboard preselected on the row (the dashboard
|
||||
* replaces the input zone, unmounting the tray → destroy → blur → collapse);
|
||||
* - Esc returns focus to the composer (`onExit` → the composer's focus());
|
||||
* - a printable key is NOT handled here — the composer's reclaim rule focuses
|
||||
* the textarea and inserts the char, and the resulting blur collapses us.
|
||||
* The `defaultPrevented` guard keeps the very Down that focused the tray (the
|
||||
* composer preventDefaults it) from also moving the selection.
|
||||
*/
|
||||
import { RenderableEvents, type BoxRenderable } from '@opentui/core'
|
||||
import { useKeyboard } from '@opentui/solid'
|
||||
import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'
|
||||
|
||||
import type { SubagentInfo } from '../logic/store.ts'
|
||||
import { elapsedSeconds, useElapsedTick } from './elapsed.ts'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
||||
/** What the App binds to hand the tray keyboard focus (composer Down). */
|
||||
export interface AgentsTrayApi {
|
||||
/** Try to take focus; false when ineligible (no running agents / not mounted). */
|
||||
focusTray: () => boolean
|
||||
}
|
||||
|
||||
/** Terminal subagent statuses — everything the wire can end a branch with.
|
||||
* `complete` is the store's fallback mapping for a status-less
|
||||
* `subagent.complete`; the LIVE gateway sends the payload status verbatim from
|
||||
* delegate_tool: `completed` / `failed` / `error` / `timeout` / `interrupted`. */
|
||||
const TERMINAL_STATUSES = new Set(['complete', 'completed', 'failed', 'error', 'timeout', 'interrupted'])
|
||||
|
||||
/** Tray membership: a subagent still doing work. */
|
||||
export function isTrayAgent(sa: SubagentInfo): boolean {
|
||||
return !TERMINAL_STATUSES.has(sa.status)
|
||||
}
|
||||
|
||||
/** `m:ss` for the row's elapsed-ish counter. */
|
||||
function fmtElapsed(secs: number): string {
|
||||
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Keep a row's activity tail to one line's worth. */
|
||||
function truncate(s: string, max = 48): string {
|
||||
const flat = s.replace(/\s+/g, ' ').trim()
|
||||
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
|
||||
}
|
||||
|
||||
function statusColor(status: string, theme: ReturnType<typeof useTheme>): string {
|
||||
const c = theme().color
|
||||
if (status === 'tool' || status === 'working') return c.accent
|
||||
if (status.includes('error')) return c.error
|
||||
return c.warn
|
||||
}
|
||||
|
||||
export function AgentsTray(props: {
|
||||
subagents: SubagentInfo[]
|
||||
/** Enter on a row — open that agent in the dashboard. */
|
||||
onOpen: (id: string) => void
|
||||
/** Esc (or the tray emptying while focused) — give focus back to the composer. */
|
||||
onExit?: (() => void) | undefined
|
||||
/** Receives the focus-handoff API once (the App wires it to the composer's Down). */
|
||||
bind?: ((api: AgentsTrayApi) => void) | undefined
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const running = createMemo(() => props.subagents.filter(isTrayAgent))
|
||||
const [focused, setFocused] = createSignal(false)
|
||||
const [sel, setSel] = createSignal(0)
|
||||
// Clamp against a shrinking list (an agent above the selection completing).
|
||||
const selected = () => Math.min(sel(), Math.max(0, running().length - 1))
|
||||
let boxRef: BoxRenderable | undefined
|
||||
|
||||
// First-seen wall clock per agent id — the subagent stream carries no start
|
||||
// timestamp, so "elapsed-ish" is time since the tray first saw the agent.
|
||||
// Non-reactive Map; rows repaint via the shared 1s tick while expanded.
|
||||
const firstSeen = new Map<string, number>()
|
||||
createEffect(() => {
|
||||
for (const sa of running()) if (!firstSeen.has(sa.id)) firstSeen.set(sa.id, Date.now())
|
||||
})
|
||||
|
||||
const attach = (el: BoxRenderable) => {
|
||||
boxRef = el
|
||||
// The single collapse trigger: native focus left the box (printable-key
|
||||
// reclaim by the composer, an overlay opening, or unmount-destroy).
|
||||
el.on(RenderableEvents.BLURRED, () => setFocused(false))
|
||||
}
|
||||
|
||||
props.bind?.({
|
||||
focusTray: () => {
|
||||
if (running().length === 0 || !boxRef) return false
|
||||
setSel(0)
|
||||
setFocused(true)
|
||||
boxRef.focus()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
// The last running agent finished while the tray was focused: the box is about
|
||||
// to unmount — hand focus back to the composer instead of leaving it nowhere.
|
||||
createEffect(() => {
|
||||
if (focused() && running().length === 0) {
|
||||
setFocused(false)
|
||||
props.onExit?.()
|
||||
}
|
||||
})
|
||||
|
||||
useKeyboard(key => {
|
||||
// defaultPrevented: the Down that HANDED us focus was consumed by the composer.
|
||||
if (!focused() || key.defaultPrevented) return
|
||||
if (key.name === 'up') {
|
||||
setSel(Math.max(0, selected() - 1))
|
||||
key.preventDefault()
|
||||
} else if (key.name === 'down') {
|
||||
setSel(Math.min(running().length - 1, selected() + 1))
|
||||
key.preventDefault()
|
||||
} else if (key.name === 'return') {
|
||||
const sa = running()[selected()]
|
||||
if (sa) props.onOpen(sa.id) // dashboard replaces the input zone → unmount → blur → collapse
|
||||
key.preventDefault()
|
||||
} else if (key.name === 'escape') {
|
||||
boxRef?.blur()
|
||||
setFocused(false)
|
||||
props.onExit?.()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={running().length > 0}>
|
||||
<box ref={attach} focusable style={{ flexDirection: 'column', flexShrink: 0 }}>
|
||||
<Show
|
||||
when={focused()}
|
||||
fallback={
|
||||
<text selectable={false} fg={theme().color.muted}>
|
||||
{`⚡ ${running().length} agent${running().length === 1 ? '' : 's'} running — ↓ to inspect`}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<TrayRows agents={running()} selected={selected()} firstSeen={firstSeen} />
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
/** The expanded rows — split out so the 1s elapsed tick is only subscribed while
|
||||
* the tray is focused (the `<Show>` scope owns the subscription's onCleanup). */
|
||||
function TrayRows(props: { agents: SubagentInfo[]; selected: number; firstSeen: Map<string, number> }) {
|
||||
const theme = useTheme()
|
||||
const tick = useElapsedTick()
|
||||
return (
|
||||
<box
|
||||
style={{
|
||||
backgroundColor: theme().color.completionBg,
|
||||
flexDirection: 'column',
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1
|
||||
}}
|
||||
>
|
||||
<For each={props.agents}>
|
||||
{(sa, i) => {
|
||||
const active = () => i() === props.selected
|
||||
const last = () => sa.trace?.at(-1) ?? sa.thought
|
||||
const secs = () => (tick(), elapsedSeconds(props.firstSeen.get(sa.id) ?? Date.now()))
|
||||
return (
|
||||
<box
|
||||
style={{
|
||||
backgroundColor: active() ? theme().color.completionCurrentBg : theme().color.completionBg
|
||||
}}
|
||||
>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: active() ? theme().color.accent : theme().color.muted }}>
|
||||
{active() ? '▸ ' : ' '}
|
||||
</span>
|
||||
<span style={{ fg: statusColor(sa.status, theme) }}>{`● ${sa.status}`}</span>
|
||||
<span style={{ fg: active() ? theme().color.text : theme().color.label }}>{` ${truncate(
|
||||
sa.goal || sa.id,
|
||||
72
|
||||
)}`}</span>
|
||||
<span style={{ fg: theme().color.muted }}>{` · ${fmtElapsed(secs())}`}</span>
|
||||
<span style={{ fg: theme().color.muted }}>{last() ? ` ${truncate(last() ?? '')}` : ''}</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<text selectable={false} fg={theme().color.muted}>
|
||||
↑/↓ select · Enter inspect · Esc back
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -89,6 +89,11 @@ export function Composer(props: {
|
|||
history?: PromptHistory | undefined
|
||||
onImagePaste?: (() => void) | undefined
|
||||
pasteStore?: PasteStore | undefined
|
||||
/** Down on an EMPTY focused composer with no dropdown open (Epic 2.7 tray
|
||||
* handoff): return true to consume the key (the tray took focus). */
|
||||
onFocusDown?: (() => boolean) | undefined
|
||||
/** Hands the parent a "focus the textarea" callback (Esc from the tray). */
|
||||
registerFocus?: ((focus: () => void) => void) | undefined
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const dims = useDimensions()
|
||||
|
|
@ -178,10 +183,32 @@ export function Composer(props: {
|
|||
return
|
||||
}
|
||||
}
|
||||
// 2) prompt history (item 6): Up at the first line → older prompt; Down at the
|
||||
// 2) background-agents tray handoff (Epic 2.7): Down on an EMPTY focused
|
||||
// composer with NO dropdown open offers focus to the tray. The parent decides
|
||||
// eligibility (≥1 running agent; overlays/prompts replace the composer
|
||||
// entirely, so they can't get here) and returns true when it took focus —
|
||||
// preventDefault keeps the consumed Down out of the textarea AND out of the
|
||||
// tray's own selection handler (it skips defaultPrevented keys). Otherwise
|
||||
// Down keeps every existing meaning (menu nav above, history below).
|
||||
if (
|
||||
key.name === 'down' &&
|
||||
!key.ctrl &&
|
||||
!key.meta &&
|
||||
!key.option &&
|
||||
menu.length === 0 &&
|
||||
ta?.focused === true &&
|
||||
ta.plainText === '' &&
|
||||
props.onFocusDown?.() === true
|
||||
) {
|
||||
key.preventDefault()
|
||||
return
|
||||
}
|
||||
// 3) prompt history (item 6): Up at the first line → older prompt; Down at the
|
||||
// last line → newer/draft. At the boundary the textarea's own up/down is a
|
||||
// no-op, so there's no conflict; mid-buffer it falls through to cursor moves.
|
||||
if (ta && props.history) {
|
||||
// Gated on the textarea being FOCUSED: while focus is elsewhere (the agents
|
||||
// tray, the transcript scrollbox) arrows must not recall history into the buffer.
|
||||
if (ta?.focused && props.history) {
|
||||
if (key.name === 'up' && ta.logicalCursor.row === 0) {
|
||||
const entry = props.history.prev(ta.plainText)
|
||||
if (entry !== null) setBuffer(entry)
|
||||
|
|
@ -197,7 +224,7 @@ export function Composer(props: {
|
|||
props.history.reset()
|
||||
}
|
||||
}
|
||||
// 3) always-active input (item 2): a printable key while the textarea lost
|
||||
// 4) always-active input (item 2): a printable key while the textarea lost
|
||||
// focus reclaims it. The renderer runs this GLOBAL handler BEFORE routing the
|
||||
// key to the focused renderable, so after focus() the SAME keystroke is still
|
||||
// delivered to the (now-focused) textarea — do NOT insert it here too, or the
|
||||
|
|
@ -207,7 +234,10 @@ export function Composer(props: {
|
|||
}
|
||||
})
|
||||
|
||||
onMount(() => ta?.focus())
|
||||
onMount(() => {
|
||||
ta?.focus()
|
||||
props.registerFocus?.(() => ta?.focus())
|
||||
})
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ function statusColor(status: string, theme: ReturnType<typeof useTheme>): string
|
|||
return c.warn
|
||||
}
|
||||
|
||||
export function AgentsDashboard(props: { subagents: SubagentInfo[]; onClose: () => void }) {
|
||||
export function AgentsDashboard(props: {
|
||||
subagents: SubagentInfo[]
|
||||
onClose: () => void
|
||||
/** Subagent id to preselect on open (Enter from the agents tray — Epic 2.7). */
|
||||
preselect?: string | undefined
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const [sel, setSel] = createSignal(0)
|
||||
let rootRef: BoxRenderable | undefined
|
||||
|
|
@ -38,7 +43,14 @@ export function AgentsDashboard(props: { subagents: SubagentInfo[]; onClose: ()
|
|||
|
||||
// Close (Esc/Ctrl+C) is the native keymap; select + scroll stay in the raw global
|
||||
// handler below. Focus the root box on mount so the focus-within close layer is active.
|
||||
onMount(() => rootRef?.focus())
|
||||
// A `preselect` id (tray Enter) lands the selection on that agent's row.
|
||||
onMount(() => {
|
||||
rootRef?.focus()
|
||||
if (props.preselect) {
|
||||
const idx = props.subagents.findIndex(sa => sa.id === props.preselect)
|
||||
if (idx >= 0) setSel(idx)
|
||||
}
|
||||
})
|
||||
useCloseLayer(
|
||||
() => rootRef,
|
||||
() => props.onClose()
|
||||
|
|
|
|||
|
|
@ -63,7 +63,14 @@ export default defineConfig({
|
|||
// runtime, so post-mount store updates would never repaint test frames
|
||||
// (@opentui/solid itself deep-imports `solid-js/dist/solid.js`, which is
|
||||
// exactly where the alias points — one shared runtime).
|
||||
inline: [/solid-js[/\\]store/]
|
||||
//
|
||||
// Same story for @opentui/keymap: externalized, its bare `import "solid-js"`
|
||||
// gets the SSR server build — a SECOND runtime whose `Owner` is null inside
|
||||
// client-runtime computations, so `useKeymap()` threw "Keymap not found" for
|
||||
// any overlay mounted AFTER the initial render (dashboard/pager opened by a
|
||||
// store update). The production build is immune (esbuild bundles the keymap
|
||||
// and force-resolves solid-js to the client build for every importer).
|
||||
inline: [/solid-js[/\\]store/, /@opentui[/\\]keymap/]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue