diff --git a/beehive/src/harness-discovery.ts b/beehive/src/harness-discovery.ts index f37fd20513..bdf20ecc48 100644 --- a/beehive/src/harness-discovery.ts +++ b/beehive/src/harness-discovery.ts @@ -7,7 +7,7 @@ import { spawnOwned } from './owned.ts'; /** Public capabilities, never login evidence. Non-Buzz adapters remain visible, * but cannot consume this catalog's OS-backed provider contract without an adapter. */ -export type DetectedHarness = { id: string; label: string; executable?: string; cli?: string; state: 'available' | 'not-installed' | 'cli-missing' | 'incompatible'; providers: string[]; reason: string }; +export type DetectedHarness = { id: string; label: string; executable?: string; cli?: string; version?: string; state: 'available' | 'not-installed' | 'cli-missing' | 'incompatible'; providers: string[]; reason: string }; const executable = (path: string) => { try { const p = realpathSync(path); accessSync(p, constants.X_OK); return statSync(p).isFile() ? p : undefined; } catch { return undefined; } }; const version = (s: string) => { const last = s.trim().split(/\s+/).at(-1) ?? ''; return /^\d+\.\d+\.\d+$/.test(last) ? last.split('.').map(BigInt) : undefined; }; /** Desktop's strict adapter floor: unknown/prerelease/partial versions fail closed. */ @@ -76,11 +76,17 @@ export async function discoverHarnesses(signal: AbortSignal, options: { home?: s for (const command of row.commands) { binary = await resolve(command); if (binary) break; } const cli = row.underlyingCli ? await resolve(row.underlyingCli) : undefined; let state: DetectedHarness['state'] = !binary ? 'not-installed' : row.underlyingCli && !cli ? 'cli-missing' : 'available'; - if (state === 'available' && row.id === 'codex' && !compatibleCodex(await probe(binary!, ['--version'], [...managed, path].join(delimiter), home, signal) ?? '')) state = 'incompatible'; + let detectedVersion: string | undefined; + if (state === 'available') { + const output = await probe(binary!, ['--version'], [...managed, path].join(delimiter), home, signal); + detectedVersion = output?.trim().split(/\s+/).at(-1); + if (!detectedVersion || !version(detectedVersion)) detectedVersion = undefined; + if (row.id === 'codex' && !compatibleCodex(output ?? '')) state = 'incompatible'; + } signal.throwIfAborted(); const providers = state === 'available' && ['buzz-agent','pi'].includes(row.id) ? row.id === 'buzz-agent' ? ['openai', 'anthropic', 'openai-compat', 'openrouter', 'databricks_v2'] : ['openai', 'databricks_v2'] : state === 'available' && row.id === 'codex' ? ['openai'] : []; const reason = state === 'not-installed' ? 'Not installed' : state === 'cli-missing' ? `Underlying ${row.underlyingCli} CLI not installed` : state === 'incompatible' ? 'Codex ACP 1.10.0 or newer required; version unknown/outdated' : providers.length ? 'Detected; provider/model access unverified' : 'Detected; this catalog’s OS-backed providers are not supported by this adapter. Use existing local setup for native authentication.'; - result.push({ id: row.id, label: row.label!, executable: binary, cli, state, providers, reason }); + result.push({ id: row.id, label: row.label!, executable: binary, cli, ...(detectedVersion ? { version: detectedVersion } : {}), state, providers, reason }); } return result; } diff --git a/beehive/src/harness-inventory.ts b/beehive/src/harness-inventory.ts new file mode 100644 index 0000000000..405269d636 --- /dev/null +++ b/beehive/src/harness-inventory.ts @@ -0,0 +1,100 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { discoverHarnesses, type DetectedHarness } from './harness-discovery.ts'; +import { readSettings, saveSettings, type Settings } from './settings.ts'; + +export type HarnessInventoryPhase = 'idle' | 'refreshing' | 'error'; +export type HarnessInventorySnapshot = Readonly<{ + harnesses: readonly DetectedHarness[]; + savedConfigurations: Readonly>; + phase: HarnessInventoryPhase; + message: string; + generation: number; +}>; +export type HarnessInventoryStore = { + read(): Settings; + save(candidate: Settings, expectedRevision: number): Settings; +}; +export type HarnessInventoryDiscovery = (signal: AbortSignal) => Promise; + +const counts = (settings: Settings) => Object.freeze(settings.runtimes.reduce>((result, runtime) => { + result[runtime.harness] = (result[runtime.harness] ?? 0) + 1; + return result; +}, {})); + +/** Harness-only controller. It owns durable inventory refresh without constructing + * owner, relay, Host, provider, credential, or agent state. */ +export class HarnessInventoryController { + private current: HarnessInventorySnapshot; + private active?: AbortController; + private serial = 0; + private disposed = false; + private listener?: (snapshot: HarnessInventorySnapshot) => void; + + constructor(private readonly store: HarnessInventoryStore, private readonly discover: HarnessInventoryDiscovery) { + try { + const settings = store.read(); + this.current = Object.freeze({ harnesses: Object.freeze([...(settings.harnesses ?? [])]), savedConfigurations: counts(settings), phase: 'idle', message: settings.harnesses ? 'Saved local harness inventory.' : 'No saved harness inventory. Run Refresh harnesses.', generation: 0 }); + } catch { + this.current = Object.freeze({ harnesses: Object.freeze([]), savedConfigurations: Object.freeze({}), phase: 'error', message: 'Saved harness inventory could not be read. Repair the local settings file, then try again.', generation: 0 }); + } + } + + snapshot() { return this.current; } + subscribe(listener: (snapshot: HarnessInventorySnapshot) => void) { this.listener = listener; listener(this.current); return () => { if (this.listener === listener) this.listener = undefined; }; } + private publish(next: Omit) { + if (this.disposed) return; + this.current = Object.freeze({ ...next, harnesses: Object.freeze([...next.harnesses]), savedConfigurations: Object.freeze({ ...next.savedConfigurations }), generation: this.serial }); + this.listener?.(this.current); + } + + /** One bounded refresh at a time. Completion is fenced before durable commit + * and again before presentation, so leaving the screen or disposal wins. */ + async refresh(): Promise { + if (this.disposed || this.active) return false; + const abort = new AbortController(); + this.active = abort; + const generation = ++this.serial; + this.publish({ ...this.current, phase: 'refreshing', message: 'Refreshing local harness inventory…' }); + try { + const harnesses = await this.discover(abort.signal); + if (this.disposed || abort.signal.aborted || generation !== this.serial) return false; + const previous = this.store.read(); + if (this.disposed || abort.signal.aborted || generation !== this.serial) return false; + const committed = this.store.save({ ...previous, harnesses: [...harnesses] }, previous.revision); + if (this.disposed || abort.signal.aborted || generation !== this.serial) return false; + this.publish({ harnesses, savedConfigurations: counts(committed), phase: 'idle', message: `Harness inventory refreshed. ${harnesses.length} harnesses checked.` }); + return true; + } catch (error) { + if (this.disposed || abort.signal.aborted || generation !== this.serial) return false; + const changed = error instanceof Error && error.message === 'Settings changed. Open the form again.'; + this.publish({ ...this.current, phase: 'error', message: changed ? 'Local settings changed during refresh. Existing inventory was kept; try Refresh harnesses again.' : 'Harness refresh failed. Existing inventory was kept; check local executable access and try again.' }); + return false; + } finally { + if (this.active === abort) this.active = undefined; + } + } + + cancel() { + if (!this.active) return; + this.serial++; + this.active.abort(); + this.active = undefined; + this.publish({ ...this.current, phase: 'idle', message: 'Harness refresh stopped. Existing inventory was kept.' }); + } + dispose() { if (this.disposed) return; this.disposed = true; this.serial++; this.active?.abort(); this.active = undefined; this.listener = undefined; } +} + +/** Production adapter. BEEHIVE_HOME and the isolated switch are explicit seams + * for product-shaped tests; normal launch uses the ordinary local HOME/PATH. */ +export function localHarnessInventory(environment: NodeJS.ProcessEnv = process.env) { + const isolated = environment.BEEHIVE_HARNESS_ISOLATED === '1'; + if (isolated && (!environment.BEEHIVE_HOME || environment.BEEHIVE_HARNESS_PATH === undefined)) throw Error('Isolated harness discovery requires an explicit BEEHIVE_HOME and BEEHIVE_HARNESS_PATH'); + const home = environment.BEEHIVE_HOME || homedir(); + const path = environment.BEEHIVE_HARNESS_PATH ?? environment.PATH ?? ''; + const directory = join(home, '.beehive', 'host'); + return new HarnessInventoryController( + { read: () => readSettings(directory), save: (candidate, revision) => saveSettings(directory, candidate, revision) }, + signal => discoverHarnesses(signal, { home, path, ...(isolated ? { bundled: [], common: [], loginShells: [] } : {}) }), + ); +} diff --git a/beehive/src/manager-view.ts b/beehive/src/manager-view.ts index 9e39a07e89..74dc68a8bf 100644 --- a/beehive/src/manager-view.ts +++ b/beehive/src/manager-view.ts @@ -1,5 +1,6 @@ import { createCliRenderer } from '@opentui/core'; import { OpenTuiShell } from './opentui-shell.ts'; +import { localHarnessInventory } from './harness-inventory.ts'; const renderer = await createCliRenderer({ exitOnCtrlC: false, @@ -7,7 +8,7 @@ const renderer = await createCliRenderer({ consoleMode: 'disabled', openConsoleOnError: false, }); -const shell = new OpenTuiShell(renderer); +const shell = new OpenTuiShell(renderer, localHarnessInventory()); const stop = () => shell.close(); process.once('SIGINT', stop); process.once('SIGTERM', stop); diff --git a/beehive/src/opentui-shell.ts b/beehive/src/opentui-shell.ts index 9f8bef9370..787ae5ab3e 100644 --- a/beehive/src/opentui-shell.ts +++ b/beehive/src/opentui-shell.ts @@ -1,5 +1,6 @@ import { BoxRenderable, ScrollBoxRenderable, TextRenderable, type CliRenderer, type KeyEvent } from '@opentui/core'; import { brand, destinations, listWidth, ownerLabel, ShellState, type FocusRegion } from './shell-state.ts'; +import { HarnessInventoryController, type HarnessInventorySnapshot } from './harness-inventory.ts'; export const palette = { surface: '#0d100e', text: '#dce1d8', muted: '#7d867c', divider: '#4a5149', @@ -61,6 +62,14 @@ export class OpenTuiShell { private readonly detailPane: BoxRenderable; private readonly divider: TextRenderable; private readonly focusPerimeter: BoxRenderable; + private readonly listTitle: TextRenderable; + private readonly detailTitle: TextRenderable; + private readonly detailText: TextRenderable; + private readonly detailAction: TextRenderable; + private readonly listRows: TextRenderable[] = []; + private inventorySnapshot: HarnessInventorySnapshot; + private unsubscribeInventory: () => void; + private screenGeneration = 0; private readonly footerRule: TextRenderable; private readonly footer: TextRenderable; private helpScrim?: BoxRenderable; @@ -70,7 +79,8 @@ export class OpenTuiShell { private helpTitle?: TextRenderable; private helpActions?: TextRenderable; - constructor(readonly renderer: CliRenderer, private readonly helpContent = helpBody) { + constructor(readonly renderer: CliRenderer, private readonly inventory: HarnessInventoryController, private readonly helpContent = helpBody) { + this.inventorySnapshot = inventory.snapshot(); this.done = new Promise(resolve => { this.finish = resolve; }); this.root = new BoxRenderable(renderer, { width: '100%', height: '100%', flexDirection: 'column', backgroundColor: palette.surface }); renderer.root.add(this.root); @@ -95,8 +105,29 @@ export class OpenTuiShell { this.root.add(this.body); this.listPane = this.pane('list'); this.detailPane = this.pane('detail'); this.divider = new TextRenderable(renderer, { width: 1, height: '100%', fg: palette.divider, content: '│' }); - this.focusPerimeter = new BoxRenderable(renderer, { position: 'absolute', visible: false, border: true, borderColor: palette.focus, backgroundColor: 'transparent' }); + this.focusPerimeter = new BoxRenderable(renderer, { position: 'absolute', visible: false, border: true, borderColor: palette.focus, backgroundColor: 'transparent', + onMouseDown: event => { + if (this.state.focus === 'list') this.clickListSlot(Math.floor((event.y - 6) / 2)); + else if (this.state.focus === 'detail' && event.y === 15) this.activateSelectedRow(); + }, + onMouseScroll: event => { + if (this.state.focus !== 'list') return; + if (event.scroll?.direction === 'up') this.state.key('up'); + else if (event.scroll?.direction === 'down') this.state.key('down'); + this.paint(); + } }); this.body.add(this.listPane); this.body.add(this.divider); this.body.add(this.detailPane); this.body.add(this.focusPerimeter); + this.listTitle = new TextRenderable(renderer, { position: 'absolute', left: 2, top: 1, height: 1, fg: palette.muted, content: 'LIST' }); + this.detailTitle = new TextRenderable(renderer, { position: 'absolute', left: 2, top: 1, height: 1, fg: palette.muted, content: 'DETAILS' }); + this.detailText = new TextRenderable(renderer, { position: 'absolute', left: 2, top: 3, width: '90%', height: '80%', fg: palette.text, wrapMode: 'word', content: '' }); + this.detailAction = new TextRenderable(renderer, { position: 'absolute', left: 2, top: 12, height: 1, fg: palette.selectedText, bg: palette.selected, content: ' Refresh harnesses ', visible: false, + onMouseDown: () => this.activateSelectedRow() }); + this.listPane.add(this.listTitle); this.detailPane.add(this.detailTitle); this.detailPane.add(this.detailText); this.detailPane.add(this.detailAction); + for (let slot = 0; slot < 32; slot++) { + const row = new TextRenderable(renderer, { position: 'absolute', left: 2, top: slot * 2 + 3, height: 2, fg: palette.text, content: '', visible: false, + onMouseDown: () => this.clickListSlot(slot) }); + this.listPane.add(row); this.listRows.push(row); + } this.footerRule = new TextRenderable(renderer, { height: 1, fg: palette.divider }); this.root.add(this.footerRule); this.footer = new TextRenderable(renderer, { height: 1, fg: palette.muted, content: wideFooter }); this.root.add(this.footer); @@ -104,11 +135,22 @@ export class OpenTuiShell { renderer.keyInput.on('keypress', key => this.key(key)); renderer.on('resize', () => this.resize()); renderer.on('destroy', () => this.close(false)); + this.unsubscribeInventory = inventory.subscribe(snapshot => { + this.inventorySnapshot = snapshot; + this.state.setHarnessRows([...snapshot.harnesses.map(harness => ({ id: `harness:${harness.id}`, kind: 'harness' as const })), { id: 'command:refresh', kind: 'command' }]); + this.paint(); + }); this.resize(); } private pane(region: Exclude) { return new BoxRenderable(this.renderer, { height: '100%', backgroundColor: palette.surface, + onMouseScroll: event => { + if (region !== 'list' || this.state.activeSection !== 2 || !this.visible(region)) return; + if (event.scroll?.direction === 'up') this.state.key('up'); + else if (event.scroll?.direction === 'down') this.state.key('down'); + this.paint(); + }, onMouseDown: () => { if (!this.visible(region)) return; this.state.focus = region; this.paint(); } }); } @@ -118,15 +160,38 @@ export class OpenTuiShell { } private key(key: KeyEvent) { + const wasHarnesses = this.state.mode === 'section' && this.state.activeSection === 2; const effect = this.state.key(key.name, { ctrl: key.ctrl, shift: key.shift }); + if (wasHarnesses && !(this.state.mode === 'section' && this.state.activeSection === 2)) { this.screenGeneration++; this.inventory.cancel(); } if (effect !== 'none') key.preventDefault(); if (effect === 'quit') this.close(); + else if (effect === 'activate') this.activateSelectedRow(); else if (effect === 'render') this.paint(); } private activate(index: number) { if (this.state.belowMinimum || this.state.helpOpen) return; - this.state.activateHeader(index); this.paint(); + const wasHarnesses = this.state.mode === 'section' && this.state.activeSection === 2; + this.state.activateHeader(index); + if (wasHarnesses && !(this.state.mode === 'section' && this.state.activeSection === 2)) { this.screenGeneration++; this.inventory.cancel(); } + this.paint(); + } + + private clickListSlot(slot: number) { + if (this.closed || this.state.helpOpen || this.state.activeSection !== 2 || !this.visible('list')) return; + const generation = this.screenGeneration; + const row = this.state.harnessRows[this.state.listOffset + slot]; + if (!row || generation !== this.screenGeneration || !this.state.selectHarness(row.id)) return; + this.state.focus = 'list'; + this.paint(); + } + + private activateSelectedRow() { + const row = this.state.selectedHarnessRow; + if (this.state.activeSection !== 2 || row?.id !== 'command:refresh') { this.paint(); return; } + void this.inventory.refresh(); + if (!this.state.splitPane) { this.state.focus = 'detail'; this.state.narrowPane = 'detail'; } + this.paint(); } private resize() { @@ -161,6 +226,7 @@ export class OpenTuiShell { } else { this.listPane.width = '100%'; this.detailPane.width = '100%'; this.detailPane.flexGrow = 1; this.divider.visible = false; } + this.paintHarnesses(); const focusedPane = this.state.focus === 'list' || this.state.focus === 'detail' ? this.state.focus : undefined; this.focusPerimeter.visible = Boolean(focusedPane); if (focusedPane) { @@ -172,6 +238,41 @@ export class OpenTuiShell { this.syncHelp(); } + private paintHarnesses() { + const shown = this.state.mode === 'section' && this.state.activeSection === 2; + this.listTitle.visible = shown; this.detailTitle.visible = shown; this.detailText.visible = shown; this.detailAction.visible = false; + for (const row of this.listRows) row.visible = false; + if (!shown) { this.detailText.content = ''; return; } + const bodyHeight = Math.max(1, this.renderer.height - 5); + const capacity = Math.max(1, Math.min(this.listRows.length, Math.floor((bodyHeight - 4) / 2))); + this.state.setListCapacity(capacity); + for (let slot = 0; slot < capacity; slot++) { + const descriptor = this.state.harnessRows[this.state.listOffset + slot]; + const renderable = this.listRows[slot]!; + if (!descriptor) continue; + const selected = descriptor.id === this.state.harnessSelection; + const harness = descriptor.kind === 'harness' ? this.inventorySnapshot.harnesses.find(value => `harness:${value.id}` === descriptor.id) : undefined; + const status = harness ? harness.state === 'available' ? 'Available' : harness.state === 'not-installed' ? 'Not installed' : harness.state === 'cli-missing' ? 'CLI missing' : 'Incompatible' : this.inventorySnapshot.phase === 'refreshing' ? 'Refreshing…' : 'Refresh harnesses'; + renderable.content = descriptor.kind === 'command' ? `↻ ${status}\n Re-read and save local facts` : `${harness?.label ?? descriptor.id} · ${status}\n ${harness?.reason ?? 'Reason unavailable'}`; + renderable.width = Math.max(1, (this.state.splitPane ? listWidth(this.renderer.width) : this.renderer.width) - 4); + renderable.bg = selected ? palette.selected : palette.surface; + renderable.fg = selected ? palette.selectedText : palette.text; + renderable.visible = true; + } + const selected = this.state.selectedHarnessRow; + if (selected?.kind === 'harness') { + const harness = this.inventorySnapshot.harnesses.find(value => `harness:${value.id}` === selected.id); + if (harness) { + const saved = this.inventorySnapshot.savedConfigurations[harness.id] ?? 0; + this.detailText.content = `${harness.label}\n\nState: ${harness.state}\nReason: ${harness.reason}\nVersion: ${harness.version ?? 'Not established'}\nExecutable: ${harness.executable ? 'Detected' : 'Not established'}\nUnderlying CLI: ${harness.cli ? 'Detected' : 'Not established'}\nProvider integrations: ${harness.providers.join(', ') || 'None'}\nSaved configurations: ${saved}\n\nThis inventory is local to this computer.`; + } else this.detailText.content = 'This harness is no longer in the current inventory.'; + } else { + this.detailText.content = `Refresh harnesses\n\nRe-read bounded local executable and version evidence, then save the complete harness inventory.\n\nStatus: ${this.inventorySnapshot.message}\n\nNo owner sign-in, relay, Host, agent, provider, browser, or credential operation is used.`; + this.detailAction.content = this.inventorySnapshot.phase === 'refreshing' ? ' Refresh in progress ' : ' Refresh harnesses '; + this.detailAction.visible = this.state.focus === 'detail' && this.visible('detail'); + } + } + private syncHelp() { if (!this.state.helpOpen) { if (this.helpScrim) { this.help?.destroyRecursively(); this.helpScrim.destroyRecursively(); this.helpScrim = undefined; this.help = undefined; this.helpBody = undefined; this.helpBodyScrollable = false; this.helpTitle = undefined; this.helpActions = undefined; } @@ -214,6 +315,8 @@ export class OpenTuiShell { close(destroy = true) { if (this.closed) return; this.closed = true; + this.unsubscribeInventory(); + this.inventory.dispose(); if (destroy) this.renderer.destroy(); this.finish(); } diff --git a/beehive/src/settings.ts b/beehive/src/settings.ts index a377e13f37..27451fc166 100644 --- a/beehive/src/settings.ts +++ b/beehive/src/settings.ts @@ -42,13 +42,14 @@ export function validateSettings(value: Settings): Settings { if (r.harness === 'codex' ? !r.cli || !isAbsolute(r.cli) || r.effort !== undefined || value.providers.find(p => p.id === r.providerId)?.type !== 'openai' : r.harness === 'pi' ? !['openai','databricks_v2'].includes(value.providers.find(p => p.id === r.providerId)!.type) || !r.cli || !isAbsolute(r.cli) || r.effort !== undefined && !piEfforts(value.providers.find(p => p.id === r.providerId)!.type, r.model).includes(r.effort) : r.cli !== undefined) throw Error('Unsupported runtime provider/CLI/effort combination'); } for (const r of value.runtimes) validateRuntimeEffort(value.providers.find(p => p.id === r.providerId)!.type, r.model, r.effort); - if (value.harnesses !== undefined && (!Array.isArray(value.harnesses) || value.harnesses.length > 32 || value.harnesses.some(h => typeof h.id !== 'string' || typeof h.label !== 'string' || typeof h.reason !== 'string' || !['available','not-installed','cli-missing','incompatible'].includes(h.state) || !Array.isArray(h.providers) || h.providers.some(p => typeof p !== 'string') || (h.executable !== undefined && !isAbsolute(h.executable)) || (h.cli !== undefined && !isAbsolute(h.cli))))) throw Error('Invalid harness inventory'); + if (value.harnesses !== undefined && (!Array.isArray(value.harnesses) || value.harnesses.length > 32 || value.harnesses.some(h => typeof h.id !== 'string' || typeof h.label !== 'string' || typeof h.reason !== 'string' || (h.version !== undefined && (typeof h.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(h.version))) || !['available','not-installed','cli-missing','incompatible'].includes(h.state) || !Array.isArray(h.providers) || h.providers.some(p => typeof p !== 'string') || (h.executable !== undefined && !isAbsolute(h.executable)) || (h.cli !== undefined && !isAbsolute(h.cli))))) throw Error('Invalid harness inventory'); // Reject unknown fields, including accidental secret-bearing input. const fields = (o: object, allowed: string[]) => { if (Object.keys(o).some(k => !allowed.includes(k))) throw Error('Unexpected settings field'); }; fields(value, ['version','revision','agents','providers','runtimes','harnesses']); for (const a of value.agents) { fields(a, ['publicKey','key','profile','profileState','runtimeId']); if (a.profile) fields(a.profile, ['relay','name','picture','about']); } for (const p of value.providers) { fields(p, ['id','name','type','endpoint','key','wire']); fields(p.key, ['service','account']); } for (const r of value.runtimes) fields(r, ['id','name','harness','executable','providerId','model','effort','cli','environment']); + for (const h of value.harnesses ?? []) fields(h, ['id','label','executable','cli','version','state','providers','reason']); return structuredClone(value); } /** Absent settings means an empty catalog, never reconstructed credentials. */ diff --git a/beehive/src/shell-state.ts b/beehive/src/shell-state.ts index 22782058d8..b5fd7a214e 100644 --- a/beehive/src/shell-state.ts +++ b/beehive/src/shell-state.ts @@ -5,7 +5,8 @@ export const brand = '⬢ BEEHIVE'; export type FocusRegion = 'header' | 'list' | 'detail'; export type ShellMode = 'section' | 'owner'; -export type ShellEffect = 'render' | 'quit' | 'none'; +export type ShellEffect = 'render' | 'activate' | 'quit' | 'none'; +export type HarnessListRow = Readonly<{ id: string; kind: 'harness' | 'command' }>; /** Framework-independent state for the never-signed-in shell tracer. */ export class ShellState { @@ -17,6 +18,10 @@ export class ShellState { width = 120; height = 40; narrowPane: 'list' | 'detail' = 'list'; + harnessRows: readonly HarnessListRow[] = [{ id: 'command:refresh', kind: 'command' }]; + harnessSelection = 'command:refresh'; + listOffset = 0; + listCapacity = 1; get belowMinimum() { return this.width < 50 || this.height < 20; } get protectedSection() { return this.activeSection < 2; } @@ -32,6 +37,43 @@ export class ShellState { if (this.belowMinimum) this.helpOpen = false; } + setHarnessRows(rows: readonly HarnessListRow[]) { + const previous = this.harnessSelection; + const previousHarnesses = this.harnessRows.filter(row => row.kind === 'harness'); + const previousHarnessIndex = previousHarnesses.findIndex(row => row.id === previous); + const initialCommandOnly = this.harnessRows.length === 1 && this.harnessRows[0]?.id === 'command:refresh'; + this.harnessRows = rows.length ? rows : [{ id: 'command:refresh', kind: 'command' }]; + if (initialCommandOnly && this.harnessRows[0]?.kind === 'harness') this.harnessSelection = this.harnessRows[0].id; + else if (this.harnessRows.some(row => row.id === previous)) this.harnessSelection = previous; + else { + const harnesses = this.harnessRows.filter(row => row.kind === 'harness'); + this.harnessSelection = harnesses[previousHarnessIndex]?.id + ?? harnesses[Math.min(previousHarnessIndex - 1, harnesses.length - 1)]?.id + ?? this.harnessRows.find(row => row.kind === 'command')?.id + ?? this.harnessRows[0]!.id; + } + this.revealHarnessSelection(); + } + + setListCapacity(capacity: number) { this.listCapacity = Math.max(1, capacity); this.revealHarnessSelection(); } + private revealHarnessSelection() { + const index = Math.max(0, this.harnessRows.findIndex(row => row.id === this.harnessSelection)); + if (index < this.listOffset) this.listOffset = index; + if (index >= this.listOffset + this.listCapacity) this.listOffset = index - this.listCapacity + 1; + this.listOffset = Math.max(0, Math.min(this.listOffset, Math.max(0, this.harnessRows.length - this.listCapacity))); + } + selectHarness(id: string) { + if (!this.harnessRows.some(row => row.id === id)) return false; + this.harnessSelection = id; this.revealHarnessSelection(); return true; + } + private moveHarness(delta: number) { + const index = Math.max(0, this.harnessRows.findIndex(row => row.id === this.harnessSelection)); + const next = Math.max(0, Math.min(this.harnessRows.length - 1, index + delta)); + if (next === index) return false; + this.harnessSelection = this.harnessRows[next]!.id; this.revealHarnessSelection(); return true; + } + get selectedHarnessRow() { return this.harnessRows.find(row => row.id === this.harnessSelection); } + key(name: string, options: { ctrl?: boolean; shift?: boolean } = {}): ShellEffect { if (options.ctrl && name === 'q') return 'quit'; if (this.belowMinimum) return 'none'; @@ -66,7 +108,9 @@ export class ShellState { // active destination remain separate: only header activation changes the // destination, while these transitions only move the focus perimeter. if (this.collection && this.focus === 'list') { + if (this.activeSection === 2 && name === 'down') return this.moveHarness(1) ? 'render' : 'none'; if (name === 'up') { + if (this.activeSection === 2 && this.moveHarness(-1)) return 'render'; this.focus = 'header'; this.headerIndex = this.activeSection; return 'render'; } if (name === 'right' || name === 'return') { @@ -76,6 +120,7 @@ export class ShellState { } } if (this.collection && this.focus === 'detail') { + if (name === 'return' && this.activeSection === 2 && this.selectedHarnessRow?.kind === 'command') return 'activate'; if (name === 'up') { this.focus = 'header'; this.headerIndex = this.activeSection; return 'render'; } @@ -86,8 +131,9 @@ export class ShellState { } } if (name === 'tab') { + const harnessAction = this.activeSection === 2 && this.selectedHarnessRow?.kind === 'command'; const regions: FocusRegion[] = this.collection - ? this.splitPane ? ['header', 'list', 'detail'] : ['header', this.narrowPane] + ? this.splitPane ? harnessAction ? ['header', 'list', 'detail'] : ['header', 'list'] : this.narrowPane === 'list' ? ['header', 'list'] : harnessAction ? ['header', 'detail'] : ['header'] : ['header', 'detail']; const current = Math.max(0, regions.indexOf(this.focus)); this.focus = regions[(current + (options.shift ? regions.length - 1 : 1)) % regions.length]!; diff --git a/beehive/test/harness-inventory.test.ts b/beehive/test/harness-inventory.test.ts new file mode 100644 index 0000000000..81d1ffc68c --- /dev/null +++ b/beehive/test/harness-inventory.test.ts @@ -0,0 +1,123 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HarnessInventoryController, localHarnessInventory } from '../src/harness-inventory.ts'; +import type { DetectedHarness } from '../src/harness-discovery.ts'; +import type { Settings } from '../src/settings.ts'; + +const row = (id: string, state: DetectedHarness['state'] = 'available'): DetectedHarness => ({ id, label: id.toUpperCase(), state, providers: state === 'available' ? ['openai'] : [], reason: state === 'available' ? 'Detected; authorization unverified' : 'Not installed', ...(state === 'available' ? { executable: `/fixture/${id}` } : {}) }); +const fixture = (initial: DetectedHarness[] = []) => { + let settings: Settings = { version: 1, revision: 4, agents: [], providers: [], runtimes: [], harnesses: initial }; + let saves = 0; + const store = { + read: () => structuredClone(settings), + save: (candidate: Settings, expected: number) => { assert.equal(expected, settings.revision); saves++; settings = { ...structuredClone(candidate), revision: expected + 1 }; return structuredClone(settings); }, + }; + return { store, settings: () => settings, saves: () => saves }; +}; + +const deferred = () => { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; + +test('loads durable local harness facts and saved configuration counts without probing', () => { + const data = fixture([row('codex'), row('pi', 'not-installed')]); + const settings = data.settings(); + settings.providers = [{ id: 'provider', name: 'Provider', type: 'openai', endpoint: 'https://api.openai.com/v1', key: { service: 'beehive', account: 'provider' } }]; + settings.runtimes = [{ id: 'runtime', name: 'Saved', harness: 'codex', executable: '/fixture/codex', cli: '/fixture/codex-cli', providerId: 'provider', model: 'model' }]; + let probes = 0; + const controller = new HarnessInventoryController(data.store, async () => { probes++; return []; }); + assert.equal(probes, 0); + assert.deepEqual(controller.snapshot().harnesses.map(value => [value.id, value.state]), [['codex', 'available'], ['pi', 'not-installed']]); + assert.equal(controller.snapshot().savedConfigurations.codex, 1); + controller.dispose(); +}); + +test('refresh commits one complete inventory and rejects a duplicate in-flight request', async () => { + const data = fixture([row('old')]); + const work = deferred(); + const controller = new HarnessInventoryController(data.store, () => work.promise); + const states: string[] = []; + controller.subscribe(snapshot => states.push(snapshot.phase)); + const first = controller.refresh(); + assert.equal(await controller.refresh(), false); + assert.equal(controller.snapshot().phase, 'refreshing'); + work.resolve([row('codex'), row('pi', 'not-installed')]); + assert.equal(await first, true); + assert.equal(data.saves(), 1); + assert.deepEqual(data.settings().harnesses?.map(value => value.id), ['codex', 'pi']); + assert.deepEqual(states, ['idle', 'refreshing', 'idle']); + controller.dispose(); +}); + +test('refresh failure keeps durable rows and exposes a retryable plain reason', async () => { + const data = fixture([row('retained')]); + const controller = new HarnessInventoryController(data.store, async () => { throw Error('/private/path/token=secret'); }); + assert.equal(await controller.refresh(), false); + assert.equal(data.saves(), 0); + assert.equal(controller.snapshot().harnesses[0]?.id, 'retained'); + assert.equal(controller.snapshot().phase, 'error'); + assert.doesNotMatch(controller.snapshot().message, /private|token|secret/); + assert.match(controller.snapshot().message, /try again/i); + controller.dispose(); +}); + +test('production adapter discovers and durably saves only an explicit isolated HOME/PATH fixture', async () => { + const home = mkdtempSync(join(tmpdir(), 'beehive-harness-inventory-')); + try { + const bin = join(home, 'bin'); mkdirSync(bin); + const executable = join(bin, 'buzz-agent'); writeFileSync(executable, '#!/bin/sh\necho "buzz-agent 2.3.4"\n'); chmodSync(executable, 0o700); + const controller = localHarnessInventory({ BEEHIVE_HOME: home, BEEHIVE_HARNESS_PATH: bin, BEEHIVE_HARNESS_ISOLATED: '1', PATH: bin }); + assert.equal(await controller.refresh(), true); + const buzz = controller.snapshot().harnesses.find(value => value.id === 'buzz-agent'); + assert.deepEqual({ state: buzz?.state, version: buzz?.version, executable: buzz?.executable, providers: buzz?.providers }, { state: 'available', version: '2.3.4', executable: realpathSync(executable), providers: ['openai', 'anthropic', 'openai-compat', 'openrouter', 'databricks_v2'] }); + assert.equal(controller.snapshot().harnesses.find(value => value.id === 'codex')?.state, 'not-installed'); + const persisted = JSON.parse(readFileSync(join(home, '.beehive', 'host', 'settings.json'), 'utf8')); + assert.equal(persisted.harnesses.find((value: DetectedHarness) => value.id === 'buzz-agent').executable, realpathSync(executable)); + controller.dispose(); + + rmSync(executable); + const relaunched = localHarnessInventory({ BEEHIVE_HOME: home, BEEHIVE_HARNESS_PATH: bin, BEEHIVE_HARNESS_ISOLATED: '1', PATH: '/ordinary/path-must-not-be-used' }); + assert.deepEqual({ state: relaunched.snapshot().harnesses.find(value => value.id === 'buzz-agent')?.state, version: relaunched.snapshot().harnesses.find(value => value.id === 'buzz-agent')?.version }, { state: 'available', version: '2.3.4' }, 'relaunch reads durable inventory without another probe'); + relaunched.dispose(); + } finally { rmSync(home, { recursive: true, force: true }); } +}); + +test('isolated production adapter fails closed unless both fixture root and executable path are explicit', () => { + assert.throws(() => localHarnessInventory({ BEEHIVE_HARNESS_ISOLATED: '1', BEEHIVE_HARNESS_PATH: '/fixture/bin' }), /explicit BEEHIVE_HOME/); + assert.throws(() => localHarnessInventory({ BEEHIVE_HARNESS_ISOLATED: '1', BEEHIVE_HOME: '/fixture/home' }), /explicit BEEHIVE_HOME/); +}); + +test('a failed refresh and cancellation preserve the last trustworthy inventory across reconstruction', async () => { + const data = fixture([row('trusted')]); + const failed = new HarnessInventoryController(data.store, async () => { throw Error('probe failed'); }); + assert.equal(await failed.refresh(), false); + failed.dispose(); + const reconstructed = new HarnessInventoryController(data.store, async () => [row('must-not-probe')]); + assert.equal(reconstructed.snapshot().harnesses[0]?.id, 'trusted'); + const work = deferred(); + const refresh = new HarnessInventoryController(data.store, () => work.promise); + const pending = refresh.refresh(); refresh.cancel(); work.resolve([row('late')]); + assert.equal(await pending, false); + assert.equal(new HarnessInventoryController(data.store, async () => []).snapshot().harnesses[0]?.id, 'trusted'); + reconstructed.dispose(); refresh.dispose(); +}); + +test('cancel and disposal fence late discovery before persistence or presentation', async () => { + for (const mode of ['cancel', 'dispose'] as const) { + const data = fixture([row('retained')]); + const work = deferred(); + const controller = new HarnessInventoryController(data.store, () => work.promise); + let notifications = 0; + controller.subscribe(() => notifications++); + const refresh = controller.refresh(); + if (mode === 'cancel') controller.cancel(); else controller.dispose(); + const beforeLate = notifications; + work.resolve([row('late')]); + assert.equal(await refresh, false); + assert.equal(data.saves(), 0, `${mode} must fence durable commit`); + assert.equal(notifications, beforeLate, `${mode} must fence late presentation`); + assert.equal(data.settings().harnesses?.[0]?.id, 'retained'); + controller.dispose(); + } +}); diff --git a/beehive/test/shell-opentui.test.ts b/beehive/test/shell-opentui.test.ts index 7f8c781cd0..2a65393edf 100644 --- a/beehive/test/shell-opentui.test.ts +++ b/beehive/test/shell-opentui.test.ts @@ -2,7 +2,14 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createTestRenderer } from '@opentui/core/testing'; import { OpenTuiShell, footerGuides, helpDialogGeometry, palette } from '../src/opentui-shell.ts'; +import { HarnessInventoryController } from '../src/harness-inventory.ts'; +import type { Settings } from '../src/settings.ts'; +const inventoryWith = (harnesses: NonNullable, discover: (signal: AbortSignal) => Promise> = async () => []) => { + let settings: Settings = { version: 1, revision: 0, agents: [], providers: [], runtimes: [], harnesses }; + return new HarnessInventoryController({ read: () => structuredClone(settings), save: (candidate, expected) => settings = { ...structuredClone(candidate), revision: expected + 1 } }, discover); +}; +const inventory = () => inventoryWith([{ id: 'fixture', label: 'Fixture Harness', state: 'available', providers: [], reason: 'Synthetic test executable detected', executable: '/fixture/bin/harness' }]); const text = (frame: string) => frame.endsWith('\n') ? frame.slice(0, -1).split('\n') : frame.split('\n'); const waitForEscape = () => new Promise(resolve => setTimeout(resolve, 50)); const cell = (ui: Awaited>, x: number, y: number) => { @@ -17,7 +24,7 @@ const rgb = (value: any) => [value.buffer['0'], value.buffer['1'], value.buffer[ test('memory renderer draws the exact wide empty shell and focus perimeter', async () => { const ui = await createTestRenderer({ width: 120, height: 40, exitOnCtrlC: false }); - const shell = new OpenTuiShell(ui.renderer); + const shell = new OpenTuiShell(ui.renderer, inventory()); try { await ui.renderOnce(); let frame = ui.captureCharFrame(); assert.ok(frame.includes('⬢ BEEHIVE')); assert.ok(frame.includes('HOST AGENTS HARNESSES PROVIDERS')); assert.ok(frame.includes('SIGNED OUT')); @@ -29,7 +36,8 @@ test('memory renderer draws the exact wide empty shell and focus perimeter', asy assert.equal(shell.state.headerIndex, 1); assert.equal(shell.state.activeSection, 0); ui.mockInput.pressArrow('right'); ui.mockInput.pressEnter(); await ui.renderOnce(); frame = ui.captureCharFrame(); assert.equal(shell.state.activeSection, 2); assert.equal(shell.state.focus, 'list'); assert.ok(frame.includes('│')); - assert.equal(text(frame).slice(3, 38).join('').replace(/[│─┌┐└┘ ]/g, ''), ''); + assert.ok(frame.includes('Fixture Harness · Available')); + assert.ok(frame.includes('State: available')); const spans = JSON.stringify(ui.captureSpans()); assert.ok(spans.includes('"0":255,"1":211,"2":78'), `focus color missing from ${spans.slice(0, 300)}`); } finally { shell.close(); } @@ -37,7 +45,7 @@ test('memory renderer draws the exact wide empty shell and focus perimeter', asy test('memory renderer owns split geometry, flat hierarchy, and focus inset without width shifts', async () => { const ui = await createTestRenderer({ width: 120, height: 40, exitOnCtrlC: false }); - const shell = new OpenTuiShell(ui.renderer); + const shell = new OpenTuiShell(ui.renderer, inventory()); try { ui.mockInput.pressArrow('right'); ui.mockInput.pressArrow('right'); ui.mockInput.pressEnter(); await ui.renderOnce(); let frame = text(ui.captureCharFrame()); @@ -53,7 +61,7 @@ test('memory renderer owns split geometry, flat hierarchy, and focus inset witho ui.mockInput.pressArrow('left'); await ui.renderOnce(); assert.equal(shell.state.focus, 'list'); assert.equal(rgb(cell(ui, 0, 3).fg), '255,211,78'); ui.mockInput.pressTab(); await ui.renderOnce(); frame = text(ui.captureCharFrame()); - assert.equal(shell.state.focus, 'detail'); assert.equal(rgb(cell(ui, 36, 3).fg), '255,211,78'); + assert.equal(shell.state.focus, 'header', 'Tab skips informational Details that have no action'); ui.mockInput.pressEscape(); await waitForEscape(); await ui.renderOnce(); frame = text(ui.captureCharFrame()); assert.equal(frame[3]![35], '│'); assert.doesNotMatch(frame.slice(3, 38).join('\n'), /[┌┐└┘]/); // unfocused panes remain flat } finally { shell.close(); } @@ -61,7 +69,7 @@ test('memory renderer owns split geometry, flat hierarchy, and focus inset witho test('memory renderer keeps keyboard, pointer, resize, and Help return focus visible', async () => { const ui = await createTestRenderer({ width: 120, height: 40, exitOnCtrlC: false }); - const shell = new OpenTuiShell(ui.renderer); + const shell = new OpenTuiShell(ui.renderer, inventory()); try { ui.mockInput.pressArrow('right'); ui.mockInput.pressArrow('right'); ui.mockInput.pressEnter(); await ui.renderOnce(); await ui.mockMouse.click(60, 10); await ui.renderOnce(); @@ -79,7 +87,7 @@ test('memory renderer keeps keyboard, pointer, resize, and Help return focus vis test('memory renderer layers a full scrim and naturally sized help surface with constrained-body geometry', async () => { const ui = await createTestRenderer({ width: 120, height: 40, exitOnCtrlC: false }); - const shell = new OpenTuiShell(ui.renderer); + const shell = new OpenTuiShell(ui.renderer, inventory()); try { ui.mockInput.pressKey('?'); await ui.renderOnce(); const geometry = helpDialogGeometry(120, 40); @@ -97,7 +105,7 @@ test('memory renderer layers a full scrim and naturally sized help surface with test('memory renderer reserves the overflow scrollbar cell outside help text and chrome', async () => { const content = Array.from({ length: 12 }, (_, row) => `overflow row ${row} ends here`).join('\n'); const ui = await createTestRenderer({ width: 50, height: 20, exitOnCtrlC: false }); - const shell = new OpenTuiShell(ui.renderer, content); + const shell = new OpenTuiShell(ui.renderer, inventory(), content); try { ui.mockInput.pressKey('?'); await ui.renderOnce(); const frame = text(ui.captureCharFrame()); @@ -113,9 +121,88 @@ test('memory renderer reserves the overflow scrollbar cell outside help text and } finally { shell.close(); } }); +test('memory renderer binds refresh to its command row and applies real async results coherently', async () => { + let complete!: (rows: NonNullable) => void; + const discovered = new Promise>(resolve => { complete = resolve; }); + const ui = await createTestRenderer({ width: 120, height: 40, exitOnCtrlC: false }); + const controller = inventoryWith([], () => discovered); + const shell = new OpenTuiShell(ui.renderer, controller); + try { + ui.mockInput.pressArrow('right'); ui.mockInput.pressArrow('right'); ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.ok(ui.captureCharFrame().includes('Saved local harness inventory')); + ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.equal(shell.state.focus, 'detail', 'the command row opens its owned Details action without executing'); + assert.ok(ui.captureCharFrame().includes('Refresh harnesses')); + ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.ok(ui.captureCharFrame().includes('Refreshing local harness inventory')); + complete([{ id: 'codex', label: 'Codex', state: 'incompatible', providers: [], reason: 'Codex ACP 1.10.0 or newer required; version unknown/outdated' }]); + await new Promise(resolve => setTimeout(resolve, 0)); await ui.renderOnce(); + const frame = ui.captureCharFrame(); + assert.ok(frame.includes('Codex · Incompatible')); assert.ok(frame.includes('State: incompatible')); + assert.equal(shell.state.harnessSelection, 'harness:codex', 'disappearing command selection uses deterministic first-row fallback'); + } finally { shell.close(); } +}); + +test('renderer shows refresh failure, permits recovery, and fences completion after leaving Harnesses', async () => { + let attempts = 0; + let lateResolve!: (rows: NonNullable) => void; + const late = new Promise>(resolve => { lateResolve = resolve; }); + const controller = inventoryWith([], async () => { + attempts++; + if (attempts === 1) throw Error('/private/diagnostic'); + if (attempts === 2) return [{ id: 'buzz-agent', label: 'Buzz Agent', state: 'available', providers: ['openai'], reason: 'Detected; provider/model access unverified', executable: '/fixture/buzz-agent' }]; + return late; + }); + const ui = await createTestRenderer({ width: 120, height: 40, exitOnCtrlC: false }); + const shell = new OpenTuiShell(ui.renderer, controller); + try { + ui.mockInput.pressArrow('right'); ui.mockInput.pressArrow('right'); ui.mockInput.pressEnter(); await ui.renderOnce(); + ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await new Promise(resolve => setTimeout(resolve, 0)); await ui.renderOnce(); + assert.ok(ui.captureCharFrame().includes('Harness refresh failed')); + assert.doesNotMatch(ui.captureCharFrame(), /private\/diagnostic/); + ui.mockInput.pressEnter(); await new Promise(resolve => setTimeout(resolve, 0)); await ui.renderOnce(); + assert.ok(ui.captureCharFrame().includes('Buzz Agent · Available')); + ui.mockInput.pressArrow('down'); ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await ui.renderOnce(); + ui.mockInput.pressArrow('up'); ui.mockInput.pressArrow('up'); ui.mockInput.pressArrow('right'); ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.equal(shell.state.activeSection, 3); + lateResolve([{ id: 'late', label: 'Late', state: 'available', providers: [], reason: 'late', executable: '/fixture/late' }]); + await new Promise(resolve => setTimeout(resolve, 0)); + assert.equal(controller.snapshot().harnesses.some(row => row.id === 'late'), false); + } finally { shell.close(); } +}); + +test('pointer selection does not execute a command and its Details action dispatches once', async () => { + let attempts = 0; + const ui = await createTestRenderer({ width: 120, height: 40, exitOnCtrlC: false }); + const shell = new OpenTuiShell(ui.renderer, inventoryWith([], async () => { attempts++; return []; })); + try { + ui.mockInput.pressArrow('right'); ui.mockInput.pressArrow('right'); ui.mockInput.pressEnter(); await ui.renderOnce(); + await ui.mockMouse.click(3, 6); await ui.renderOnce(); + assert.equal(attempts, 0, 'a List click only selects the command row'); + ui.mockInput.pressEnter(); await ui.renderOnce(); + await ui.mockMouse.click(40, 15); await new Promise(resolve => setTimeout(resolve, 0)); await ui.renderOnce(); + assert.equal(attempts, 1, 'mouse bubbling cannot double-dispatch the Details action'); + } finally { shell.close(); } +}); + +test('virtual list click mapping targets the currently visible row after overflow scroll', async () => { + const harnesses = Array.from({ length: 20 }, (_, index) => ({ id: `fixture-${index}`, label: `Harness ${index}`, state: 'available' as const, providers: [], reason: 'Synthetic test executable detected', executable: `/fixture/${index}` })); + const ui = await createTestRenderer({ width: 50, height: 20, exitOnCtrlC: false }); + const shell = new OpenTuiShell(ui.renderer, inventoryWith(harnesses)); + try { + ui.mockInput.pressArrow('right'); ui.mockInput.pressArrow('right'); ui.mockInput.pressEnter(); await ui.renderOnce(); + for (let index = 0; index < 14; index++) ui.mockInput.pressArrow('down'); + await ui.renderOnce(); + assert.ok(shell.state.listOffset > 0); + const expected = shell.state.harnessRows[shell.state.listOffset]!.id; + await ui.mockMouse.click(3, 6); await ui.renderOnce(); + assert.equal(shell.state.harnessSelection, expected, 'the slot maps through the current virtual offset, never a stale absolute index'); + } finally { shell.close(); } +}); + test('memory renderer preserves the 50x20 narrow shell, help, keyboard, and pointer activation', async () => { const ui = await createTestRenderer({ width: 50, height: 20, exitOnCtrlC: false }); - const shell = new OpenTuiShell(ui.renderer); + const shell = new OpenTuiShell(ui.renderer, inventory()); try { await ui.renderOnce(); let frame = ui.captureCharFrame(); assert.equal(text(frame).length, 20); assert.ok(frame.includes(footerGuides.compact)); assert.ok(frame.includes('PROVIDERS')); diff --git a/beehive/test/shell-state.test.ts b/beehive/test/shell-state.test.ts index cdb661e32d..5b797caf0c 100644 --- a/beehive/test/shell-state.test.ts +++ b/beehive/test/shell-state.test.ts @@ -12,6 +12,7 @@ test('never-signed-in shell labels and initial state are exact', () => { test('wide collection arrows traverse header, List and Details without activating a new destination', () => { const state = new ShellState(); + state.setHarnessRows([{ id: 'harness:fixture', kind: 'harness' }, { id: 'command:refresh', kind: 'command' }]); assert.equal(state.key('right'), 'render'); assert.equal(state.headerIndex, 1); assert.equal(state.activeSection, 0); state.key('right'); state.key('return'); assert.deepEqual({ active: state.activeSection, header: state.headerIndex, focus: state.focus, split: state.splitPane }, { active: 2, header: 2, focus: 'list', split: true }); @@ -26,6 +27,7 @@ test('wide collection arrows traverse header, List and Details without activatin test('narrow pane transitions, resize, and Help preserve a visible valid focus target', () => { const state = new ShellState(); + state.setHarnessRows([{ id: 'harness:fixture', kind: 'harness' }, { id: 'command:refresh', kind: 'command' }]); state.activateHeader(2); state.key('right'); assert.equal(state.focus, 'detail'); state.resize(50, 20); assert.deepEqual({ focus: state.focus, pane: state.narrowPane, split: state.splitPane }, { focus: 'detail', pane: 'detail', split: false }); @@ -40,6 +42,7 @@ test('narrow pane transitions, resize, and Help preserve a visible valid focus t test('help, q semantics, minimum guard and narrow return are deterministic', () => { const state = new ShellState(); + state.setHarnessRows([{ id: 'harness:fixture', kind: 'harness' }, { id: 'command:refresh', kind: 'command' }]); assert.equal(state.key('?'), 'render'); assert.equal(state.helpOpen, true); assert.equal(state.key('q'), 'none'); assert.equal(state.helpOpen, true); assert.equal(state.key('escape'), 'render'); assert.equal(state.helpOpen, false); @@ -49,6 +52,26 @@ test('help, q semantics, minimum guard and narrow return are deterministic', () state.key('return'); assert.equal(state.narrowPane, 'detail'); state.key('escape'); assert.equal(state.narrowPane, 'list'); assert.equal(state.focus, 'list'); }); +test('harness rows preserve stable identity, fall back deterministically, and virtualize overflow', () => { + const state = new ShellState(); + const rows: Array<{ id: string; kind: 'harness' | 'command' }> = [...Array.from({ length: 20 }, (_, index) => ({ id: `harness:${index}`, kind: 'harness' as const })), { id: 'command:refresh', kind: 'command' }]; + state.setHarnessRows(rows); state.setListCapacity(5); state.activateHeader(2); + for (let index = 0; index < 9; index++) assert.equal(state.key('down'), 'render'); + assert.equal(state.harnessSelection, 'harness:9'); assert.equal(state.listOffset, 5); + state.setHarnessRows(rows.filter(row => row.id !== 'harness:3')); + assert.equal(state.harnessSelection, 'harness:9', 'stable identity survives row reindexing'); + state.setHarnessRows([{ id: 'harness:replacement', kind: 'harness' }, { id: 'command:refresh', kind: 'command' }]); + assert.equal(state.harnessSelection, 'harness:replacement', 'disappearing identity falls back to the next surviving harness'); + state.setHarnessRows([{ id: 'harness:a', kind: 'harness' }, { id: 'harness:b', kind: 'harness' }, { id: 'harness:c', kind: 'harness' }, { id: 'command:refresh', kind: 'command' }]); + state.selectHarness('harness:b'); + state.setHarnessRows([{ id: 'harness:a', kind: 'harness' }, { id: 'harness:c', kind: 'harness' }, { id: 'command:refresh', kind: 'command' }]); + assert.equal(state.harnessSelection, 'harness:c', 'removed row falls forward at the same harness position'); + state.setHarnessRows([{ id: 'harness:a', kind: 'harness' }, { id: 'command:refresh', kind: 'command' }]); + assert.equal(state.harnessSelection, 'harness:a', 'then falls back to the previous surviving harness'); + state.key('down'); assert.equal(state.key('return'), 'render', 'the command row opens Details without executing'); + assert.equal(state.key('return'), 'activate', 'only the explicit command Details action activates'); +}); + test('prototype breakpoint formulas are exact', () => { assert.equal(listWidth(120), 35); assert.equal(listWidth(90), 28); assert.equal(listWidth(72), 25); assert.equal(listWidth(50), 50); });