Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ Profiles with `orchestrator: true` may themselves call `task` (one hop only): ne

**Session records** (`src/subagent/session-store.ts`): each spawn is retained as an inspectable child session (id, profile, description, brief, status, tool activity, transcript entries). Child events land only in this store — not in the parent chat transcript. Live progress still uses the light `onProgress` channel for the status bar. Completed sessions are capped (`maxCompleted`) so a long chat does not grow without bound.

**Observe (OpenTUI)**: the command palette's **observe** action (`src/tui-opentui/palette.ts`) asks the host for a live session (`onObserveRequest` → `observeSessionFromSubAgents`, `src/tui-opentui/runner-host.ts`), which picks the newest running child, else the most recent session of any status. Entering observe swaps the transcript for that child's stream (live while running, historical when done) without stealing the parent reactor; child events are mapped to stream rows by `src/tui-opentui/observe-map.ts`. Esc leaves observe and restores the parent transcript. Parent Esc/stop and `/clear` still call `cancelAll` so live children close (`agent.close`) instead of continuing after the parent stops.
**Observe (OpenTUI)**: `shell.ts:enterSubagentObserve` swaps the transcript for a child's stream (live while running, historical when done) without stealing the parent reactor; child events are mapped to stream rows by `src/tui-opentui/observe-map.ts`. Esc leaves observe and restores the parent transcript. Parent Esc/stop and `/clear` still call `cancelAll` so live children close (`agent.close`) instead of continuing after the parent stops. The host-injection point that resolves a live session (`onObserveRequest` → `observeSessionFromSubAgents`, `src/tui-opentui/runner-host.ts`, picking the newest running child else the most recent session of any status) is triggered by Alt+O (`shell.ts:observeActiveSubagent`) — the command palette action that used to call it is gone along with `src/tui-opentui/palette.ts` itself, but the chord replaces it rather than dropping the feature.

Data-only agent plugins (`src/plugins/data-only-agent.ts`) synthesize `agentPlugin.agents[]` from `agents/*.md` or flat `*.md` in the plugin directory, with optional co-located `skills/`. `loadPluginEntry` tries JS entrypoints first, then falls back to this layout (`/plugins` add-by-path supports filesystem completion via `listPathSuggestions`).

Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ src/
runner-host.ts Runner-facing mount: catalogs, chrome, quit key
list-modal.ts Shared list-picker overlay (runListModal)
command-surfaces.ts Slash-command surface routing (openCommandSurface)
command-catalog.ts, model-catalog.ts, chrome-state.ts, palette.ts,
command-catalog.ts, model-catalog.ts, chrome-state.ts,
provider-setup.ts Onboarding provider setup flow
docs/
PRODUCT.md, ARCHITECTURE.md, IMPLEMENTATION.md, TUI.md, HOOKS.md, MCP.md,
Expand Down
4 changes: 2 additions & 2 deletions docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ $ corbits "Add JWT auth to the API"
A full-screen terminal interface: a pinned header (session title and workflow progress), a scrollable event log, modals for permission prompts and operator questions, and a chat input for follow-up turns.

**Behavior spec** (OpenTUI is the shipping shell): `docs/TUI.md` — layout,
chrome budget, overlays, selectors, palette, prompt box, and scroll/mouse
behavior.
chrome budget, overlays, selectors, the `/` command list, prompt box, and
scroll/mouse behavior.

### Exec mode (non-TUI product path)

Expand Down
61 changes: 45 additions & 16 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ on any terminal, tall or short; the collapse order bounds what other zones
are allowed to take from it once the transcript floor is at risk.

The panel is toggleable independent of its live data: `toggleTasksPanel`
(bound to the `toggle_task` palette action) flips a hidden flag held on the
shell for its lifetime — in memory only, nothing written to storage — while
(bound to Alt+T) flips a hidden flag held on the shell for its lifetime — in
memory only, nothing written to storage — while
the live task list keeps updating underneath it. Un-hiding shows the current
list, not a stale snapshot from before the hide. Hidden or empty, the zone
costs zero rows.
Expand Down Expand Up @@ -200,7 +200,7 @@ same last-resort floor every other optional zone shares.
## How pop-ups should feel

A blocking surface (permissions, an operator question, the model/provider
picker, settings, help, the command palette, …) shares one overlay host and
picker, settings, help, the `/` command list, …) shares one overlay host and
one height path — there is no second modal stack with independent row
accounting (`src/tui-opentui/geometry/resolve.ts`,
`src/tui-opentui/shell.ts:openListOverlay`). Opening a second surface either
Expand Down Expand Up @@ -238,7 +238,7 @@ rather than a background fill.
## How selectors should work

Every list surface — permissions, the operator question, the model picker,
the command palette, resume/session-mode pickers, settings — shares one list
the `/` command list, resume/session-mode pickers, settings — shares one list
viewport kit: shared windowing, keep-active-visible, and page/jump behavior.
There is exactly one scroll lease at a time; keyboard paging and the mouse
wheel both follow whichever surface currently holds it, so a modal open on
Expand All @@ -250,28 +250,57 @@ every picker open — independent of the recents list, which only moves on an
explicit pick and can go stale (`ProductHostConfig.activeModelId`'s doc
comment and `annotateCurrent` in `src/tui-opentui/product-host.ts`).

The command palette specifically (`src/tui-opentui/palette.ts`,
The `/` command list specifically (`src/tui-opentui/command-catalog.ts`,
`shell.ts:openPalette`/`repaintPalette`): width matches the prompt box — both
are painted at the geometry resolver's shared `contentWidth`
(`geometry/resolve.ts:assignRects`, `shell.ts:overlayRowWidth`). There is no
leading marker column and no per-row kind column; the selected row is marked
by text color only (`paintPaletteList` in `shell.ts`: "the highlighted row
already stands out by sitting under the cursor, so a leading `>` and a grey
block would both be saying the same thing twice"). The palette also paints
with no title rule — the filter row (`> query`) directly under the box
already shows what was typed, so a second header line would say nothing new
block would both be saying the same thing twice"). The list also paints with
no title rule — the filter row (`> query`) directly under the box already
shows what was typed, so a second header line would say nothing new
(`repaintPalette`).

## Slash commands and pickers

`Ctrl+O` opens the command palette from anywhere in the shell (reclaimed from
the Ink-era tool-expand chord); `/` at an empty prompt opens the same
palette narrowed to registry slash commands. Every user-facing slash command
has a palette twin. Palette entries are either "residual" product actions
owned by the shell (open permissions, switch model, toggle a chrome zone,
copy, toggle mouse capture, help, insert a mention, observe a subagent) or
"command" entries backed by the live command registry
(`src/tui-opentui/palette.ts`).
`/` at an empty prompt opens the command list, narrowed by name prefix as
more is typed; Tab completes the name so arguments can be typed, Enter runs
it. Every entry is backed by the live command registry
(`src/tui-opentui/command-catalog.ts:commandItemsFromRegistry`) — there is no
separate palette overlay and no shell-owned action outside the registry. The
overlay this reuses is still internally called `"palette"` (`shell.ts`'s
`PrimaryOverlayKind`), a naming leftover from when a Ctrl+O command palette
also opened it; that chord is gone (see keybindings.ts), and the identifier
stayed because renaming an internal overlay tag has no user-facing effect.

`?` no longer binds anything — it is a literal character everywhere, prompt
or transcript. The shortcut list it used to open is still reachable, as
`/help` (`src/tui/commands/built-in.ts`, routed to `shell.ts:openHelpOverlay`
via `openCommandSurface`'s `"help"` case, `command-surfaces.ts`); the `/` row
in `SHELL_SHORTCUTS` documents that in place of a dedicated `?` row.

The running build version is chrome, not part of the landing composition:
`shell.ts`'s `versionRow`/`versionBadge`, a dedicated row pinned to the
terminal's last line and right-aligned, distinct from `landing.ts`'s hero and
below sections. It only reserves that row while the landing screen is
showing (`relayout`'s `versionReserved`/`terminalForGeometry`) — once there
is real transcript content the row goes back to whatever needed it, and the
badge stops rendering. On a narrow or short terminal it hides
(`versionBadgeVisible`, thresholds `VERSION_BADGE_MIN_COLUMNS`/
`VERSION_BADGE_MIN_ROWS` in `landing.ts`) before the prompt box or any other
actionable chrome would degrade for width/height reasons.

This is not a free row, though, while it is showing: `terminalForGeometry`
subtracts it from the terminal size handed to the geometry resolver before
the resolver runs, so every height the resolver derives — including
`PROMPT_CAP_FRACTION * terminal.rows`, computed before `COLLAPSE_ORDER` ever
runs — sees one row fewer than the real terminal. The badge does not sit in
`COLLAPSE_ORDER` and is never given back under prompt-growth pressure the
way the task or agents panel is. An operator composing a long prompt on the
landing screen at, say, 23 rows gets an 8-row cap instead of 9. This is a
known, accepted cost of the badge rather than an oversight — see
`terminalForGeometry`'s doc comment in `shell.ts` for the exact mechanism.

The model/provider picker is provider-first
(`src/tui-opentui/product-host.ts:groupModelsForPicker`/`openLevel`): recent
Expand Down
84 changes: 36 additions & 48 deletions src/tui-opentui/command-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,12 @@
import { describe, expect, test } from "bun:test"
import {
buildCommandCatalog,
commandItemsFromRegistry,
filterPaletteCommands,
paletteLabels,
} from "./command-catalog"
import { DEFAULT_PALETTE_COMMANDS, paletteDispatchOf } from "./palette"

describe("buildCommandCatalog", () => {
test("maps listCommands-shaped entries to dispatch command names", () => {
const catalog = buildCommandCatalog([
{ name: "compact", description: "Compact history" },
{ name: "model", description: "Open model picker" },
])

const compact = catalog.find((c) => c.id === "compact")
expect(compact).toBeDefined()
expect(compact!.dispatch).toBe("command")
expect(compact!.label).toContain("/compact")
expect(compact!.label).toContain("Compact history")
expect(paletteDispatchOf(compact!)).toBe("command")

const model = catalog.find((c) => c.id === "model")
expect(model?.dispatch).toBe("command")
})

test("includes residual openers alongside registry commands", () => {
const catalog = buildCommandCatalog([
{ name: "compact", description: "Compact history" },
])
expect(catalog.some((c) => c.id === "permissions")).toBe(true)
expect(catalog.some((c) => c.id === "compact")).toBe(true)
expect(catalog.length).toBeGreaterThan(DEFAULT_PALETTE_COMMANDS.length)
})

test("preferRegistry drops residual when registry reuses id", () => {
const catalog = buildCommandCatalog([
{ name: "help", description: "Slash help" },
])
const helps = catalog.filter((c) => c.id === "help")
expect(helps.length).toBe(1)
expect(helps[0]!.dispatch).toBe("command")
})

test("empty registry still yields residual catalog", () => {
const catalog = buildCommandCatalog([])
expect(catalog.length).toBe(DEFAULT_PALETTE_COMMANDS.length)
expect(catalog.every((c) => c.dispatch === "residual")).toBe(true)
})
})

describe("commandItemsFromRegistry", () => {
test("registry-only items all dispatch as command", () => {
test("maps listCommands-shaped entries to `/`-prefixed labels", () => {
const items = commandItemsFromRegistry([
{ name: "tasks", description: "Show work list" },
{ name: "clear", description: "Clear screen" },
Expand All @@ -59,14 +16,45 @@ describe("commandItemsFromRegistry", () => {
id: "tasks",
label: "/tasks — Show work list",
keywords: ["tasks", "slash", "command"],
dispatch: "command",
},
{
id: "clear",
label: "/clear — Clear screen",
keywords: ["clear", "slash", "command"],
dispatch: "command",
},
])
})
})

describe("filterPaletteCommands", () => {
const catalog = commandItemsFromRegistry([
{ name: "compact", description: "Compact history" },
{ name: "model", description: "Open model picker" },
])

test("empty query returns the full catalog", () => {
expect(filterPaletteCommands("", catalog)).toEqual(catalog)
})

test("matches by id, label, or keyword substring", () => {
expect(filterPaletteCommands("compact", catalog).map((c) => c.id)).toEqual([
"compact",
])
expect(filterPaletteCommands("picker", catalog).map((c) => c.id)).toEqual([
"model",
])
})

test("no match returns an empty list", () => {
expect(filterPaletteCommands("zzzz", catalog)).toEqual([])
})
})

describe("paletteLabels", () => {
test("returns just the display labels", () => {
const catalog = commandItemsFromRegistry([
{ name: "tasks", description: "Show work list" },
])
expect(paletteLabels(catalog)).toEqual(["/tasks — Show work list"])
})
})
91 changes: 61 additions & 30 deletions src/tui-opentui/command-catalog.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,78 @@
/**
* Registry → palette catalog bridge for OpenTUI production host.
* Registry → `/` command list catalog (pure).
*
* Pure: host injects `listCommands()` results (or fixtures). No registry import
* here — avoids circular / heavy deps from `src/tui/commands`.
*
* setPaletteCatalog(shell, buildCommandCatalog(listCommands()))
* setPaletteCatalog(shell, commandItemsFromRegistry(listCommands()))
*/

import {
buildPaletteCatalog,
commandsToPaletteItems,
type BuildPaletteCatalogOpts,
type PaletteCommand,
type RegistryCommandSource,
} from "./palette.js"
import { sliceToWidth, stringWidth } from "../tui/view/height.js"

export type { PaletteCommand, RegistryCommandSource }
/** Minimal registry shape — matches `listCommands()` entries without importing them. */
export type RegistryCommandSource = {
readonly name: string
readonly description: string
}

export type BuildCommandCatalogOpts = Omit<BuildPaletteCatalogOpts, "commands">
/** One entry in the `/` command list: registry command name + display label. */
export type PaletteCommand = {
readonly id: string
readonly label: string
/** Optional keywords for name-prefix / substring filter. */
readonly keywords?: readonly string[]
}

/** Map registry command definitions to `/` list items. */
export function commandItemsFromRegistry(
commands: readonly RegistryCommandSource[],
): PaletteCommand[] {
return commands.map((c) => ({
id: c.name,
label: `/${c.name} — ${c.description}`,
keywords: [c.name, "slash", "command"],
}))
}

/**
* Map `listCommands()`-shaped entries into a palette catalog for setPaletteCatalog.
*
* Includes residual product openers (permissions, model picker, …) plus registry
* slash commands with `dispatch: "command"` and `id` = command name. Registry
* names win over residual openers with the same id (preferRegistry default).
* Case-insensitive substring filter over label + keywords.
* Empty query returns the full catalog (stable order).
*/
export function buildCommandCatalog(
commands: readonly RegistryCommandSource[],
opts?: BuildCommandCatalogOpts,
export function filterPaletteCommands(
query: string,
catalog: readonly PaletteCommand[],
): readonly PaletteCommand[] {
return buildPaletteCatalog({
...opts,
commands,
const q = query.trim().toLowerCase()
if (q.length === 0) return catalog
return catalog.filter((cmd) => {
if (cmd.label.toLowerCase().includes(q)) return true
if (cmd.id.toLowerCase().includes(q)) return true
return (cmd.keywords ?? []).some((k) => k.toLowerCase().includes(q))
})
}

/**
* Registry slash entries only (no residual openers). Each item has
* `dispatch: "command"` and `id` equal to the command name.
*/
export function commandItemsFromRegistry(
commands: readonly RegistryCommandSource[],
): PaletteCommand[] {
return commandsToPaletteItems(commands)
/** Labels for the shared list viewport. */
export function paletteLabels(
commands: readonly PaletteCommand[],
): readonly string[] {
return commands.map((c) => c.label)
}

function fitLabel(label: string, width: number): string {
if (width <= 0) return ""
const columns = stringWidth(label)
// padEnd counts code units, so a label carrying a wide glyph has to be padded
// by the column shortfall rather than to a code-unit length.
if (columns <= width) return label + " ".repeat(width - columns)
if (width === 1) return "…"
const cut = `${sliceToWidth(label, width - 1)}…`
return cut + " ".repeat(Math.max(0, width - stringWidth(cut)))
}

/** Render labels to exactly `width` columns each, ellipsizing long ones. */
export function formatPaletteRows(
labels: readonly string[],
width: number,
): readonly string[] {
return labels.map((label) => fitLabel(label, width))
}
2 changes: 1 addition & 1 deletion src/tui-opentui/command-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export type CollapsedSegment = {
// collapsed segment always render as a single line.
display: string;
// The full text of each collapsed payload, in placeholder order, shown when
// the operator expands via Ctrl+O.
// the operator expands via Alt+E.
payloads: CollapsedPayload[];
};

Expand Down
6 changes: 3 additions & 3 deletions src/tui-opentui/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*
* Keys:
* Enter=queue · Alt+Enter=steer · Ctrl+C=stop
* Ctrl+O=palette · Alt+C=copy
* Alt+C=copy
* p=permissions · o=operator · m=model
* s=settings · h=help · l=plugins · e=resume · n=mentions · v=observe
* t/a=toggle task/agents chrome
Expand Down Expand Up @@ -101,7 +101,7 @@ const bridge = attachSessionBridge(shell, port)

appendStreamRow(shell, {
role: "system",
text: "Wave 7 — residuals + observe (s/h/l/e/n/v · Ctrl+O palette)",
text: "Wave 7 — residuals + observe (s/h/l/e/n/v)",
})
appendStreamRow(shell, {
role: "system",
Expand Down Expand Up @@ -331,5 +331,5 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => {
})

console.log(
"OpenTUI Wave 7 demo — residuals s/h/l/e/n · observe v · Ctrl+O palette · q quit",
"OpenTUI Wave 7 demo — residuals s/h/l/e/n · observe v · q quit",
)
4 changes: 2 additions & 2 deletions src/tui-opentui/gate-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export const PERMISSION_ONCE_ID = "__once__" as const
/**
* Expand/collapse chord for collapsed payloads. Scoped to the open permission
* overlay rather than registered in SHELL_SHORTCUTS: the overlay is modal, so
* a bare letter is free there, and Ctrl+O (the Ink-era chord) is the command
* palette in this shell. Shared with the transcript's collapsed rows so the
* a bare letter is free there — nothing else in the shell claims it while
* this overlay is open. Shared with the transcript's collapsed rows so the
* product has one expand idiom.
*/
export const PERMISSION_EXPAND_KEY = EXPAND_KEY
Expand Down
1 change: 0 additions & 1 deletion src/tui-opentui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export {
export * from "./live-session-port"
export * from "./overlays"
export * from "./long-log"
export * from "./palette"
export * from "./command-catalog"
export * from "./model-catalog"
export * from "./copy-path"
Expand Down
Loading
Loading