From 63d298953e126282f46c4cc5985c7937a0081e8e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 00:21:15 -0700 Subject: [PATCH 1/3] Inline agent micro-modules and share one lexical ranker --- src/agent/agent-search.ts | 33 +++++++------------ src/agent/directors/bruckheimer/index.ts | 1 - src/agent/directors/builder/index.ts | 1 - src/agent/directors/counsel/index.ts | 1 - src/agent/directors/critic/index.ts | 1 - src/agent/directors/draper/index.ts | 1 - src/agent/directors/emil/index.ts | 1 - src/agent/directors/explorer/index.ts | 1 - src/agent/directors/gaasbot/index.ts | 1 - src/agent/directors/gauntlet/index.ts | 1 - src/agent/directors/greybeard/index.ts | 1 - src/agent/directors/intern/index.ts | 1 - src/agent/directors/migrator/index.ts | 1 - src/agent/directors/neckbeard/index.ts | 1 - src/agent/directors/prober/index.ts | 1 - src/agent/directors/rand/index.ts | 1 - src/agent/directors/registry.ts | 40 +++++++++++----------- src/agent/directors/shakespeare/index.ts | 1 - src/agent/directors/skywalker/index.ts | 1 - src/agent/directors/tester/index.ts | 1 - src/agent/directors/testsmith/index.ts | 1 - src/agent/directors/warden/index.ts | 1 - src/agent/lexical-rank.ts | 42 ++++++++++++++++++++++++ src/agent/skill-search.ts | 27 +++++++-------- src/agent/tool-search.ts | 31 +++++++---------- src/agent/tools.ts | 2 +- 26 files changed, 98 insertions(+), 97 deletions(-) delete mode 100644 src/agent/directors/bruckheimer/index.ts delete mode 100644 src/agent/directors/builder/index.ts delete mode 100644 src/agent/directors/counsel/index.ts delete mode 100644 src/agent/directors/critic/index.ts delete mode 100644 src/agent/directors/draper/index.ts delete mode 100644 src/agent/directors/emil/index.ts delete mode 100644 src/agent/directors/explorer/index.ts delete mode 100644 src/agent/directors/gaasbot/index.ts delete mode 100644 src/agent/directors/gauntlet/index.ts delete mode 100644 src/agent/directors/greybeard/index.ts delete mode 100644 src/agent/directors/intern/index.ts delete mode 100644 src/agent/directors/migrator/index.ts delete mode 100644 src/agent/directors/neckbeard/index.ts delete mode 100644 src/agent/directors/prober/index.ts delete mode 100644 src/agent/directors/rand/index.ts delete mode 100644 src/agent/directors/shakespeare/index.ts delete mode 100644 src/agent/directors/skywalker/index.ts delete mode 100644 src/agent/directors/tester/index.ts delete mode 100644 src/agent/directors/testsmith/index.ts delete mode 100644 src/agent/directors/warden/index.ts create mode 100644 src/agent/lexical-rank.ts diff --git a/src/agent/agent-search.ts b/src/agent/agent-search.ts index 4e059361d..388c6b75a 100644 --- a/src/agent/agent-search.ts +++ b/src/agent/agent-search.ts @@ -3,12 +3,13 @@ import type { AgentTool } from "@intx/agent"; import type { ToolDefinition } from "@intx/types/runtime"; import { type } from "arktype"; import { scrubSecretShapedContent } from "../plugins/tool-result-secret-scrub.js"; +import { + lexicalFields, + scoreLexical, + tokenizeLexical, +} from "./lexical-rank.js"; import type { AgentProfile } from "./profiles.js"; -function tokenize(text: string): string[] { - return text.toLowerCase().match(/[a-z0-9]+/g) ?? []; -} - function profileSearchText(profile: AgentProfile): string { const parts = [ profile.id, @@ -30,27 +31,17 @@ export function createAgentIndex( profile: AgentProfile, queryTokens: string[], rawQuery: string, - ): number => { - const idTokens = tokenize(profile.id); - const blob = profileSearchText(profile).toLowerCase(); - const blobTokens = new Set(tokenize(blob)); - let total = 0; - for (const token of queryTokens) { - if (idTokens.includes(token)) total += 3; - else if (blobTokens.has(token)) total += 1; - else if (profile.id.toLowerCase().includes(token)) total += 0.75; - else if (blob.includes(token)) total += 0.25; - } - if (profile.id.toLowerCase().includes(rawQuery)) total += 1; - if ((profile.description ?? "").toLowerCase().includes(rawQuery)) - total += 0.5; - return total; - }; + ): number => + scoreLexical( + lexicalFields(profile.id, profileSearchText(profile)), + queryTokens, + rawQuery, + ) + ((profile.description ?? "").toLowerCase().includes(rawQuery) ? 0.5 : 0); return { search(query: string, limit = 12): AgentProfile[] { const rawQuery = query.toLowerCase().trim(); - const queryTokens = tokenize(query); + const queryTokens = tokenizeLexical(query); const profiles = getProfiles(); if (queryTokens.length === 0) return profiles.slice(0, limit); return profiles diff --git a/src/agent/directors/bruckheimer/index.ts b/src/agent/directors/bruckheimer/index.ts deleted file mode 100644 index 51b3fb6e7..000000000 --- a/src/agent/directors/bruckheimer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { bruckheimerPackage } from "./package.js"; diff --git a/src/agent/directors/builder/index.ts b/src/agent/directors/builder/index.ts deleted file mode 100644 index 860538d3f..000000000 --- a/src/agent/directors/builder/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { builderPackage } from "./package.js"; diff --git a/src/agent/directors/counsel/index.ts b/src/agent/directors/counsel/index.ts deleted file mode 100644 index c72bc57f9..000000000 --- a/src/agent/directors/counsel/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { counselPackage } from "./package.js"; diff --git a/src/agent/directors/critic/index.ts b/src/agent/directors/critic/index.ts deleted file mode 100644 index 08423a8ab..000000000 --- a/src/agent/directors/critic/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { criticPackage } from "./package.js"; diff --git a/src/agent/directors/draper/index.ts b/src/agent/directors/draper/index.ts deleted file mode 100644 index 890f5b91e..000000000 --- a/src/agent/directors/draper/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { draperPackage } from "./package.js"; diff --git a/src/agent/directors/emil/index.ts b/src/agent/directors/emil/index.ts deleted file mode 100644 index 8eb14cf5b..000000000 --- a/src/agent/directors/emil/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { emilPackage } from "./package.js"; diff --git a/src/agent/directors/explorer/index.ts b/src/agent/directors/explorer/index.ts deleted file mode 100644 index 8b5ad921b..000000000 --- a/src/agent/directors/explorer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { explorerPackage } from "./package.js"; diff --git a/src/agent/directors/gaasbot/index.ts b/src/agent/directors/gaasbot/index.ts deleted file mode 100644 index 987b2d41c..000000000 --- a/src/agent/directors/gaasbot/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { gaasbotPackage } from "./package.js"; diff --git a/src/agent/directors/gauntlet/index.ts b/src/agent/directors/gauntlet/index.ts deleted file mode 100644 index 305c3c4e8..000000000 --- a/src/agent/directors/gauntlet/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { gauntletPackage } from "./package.js"; diff --git a/src/agent/directors/greybeard/index.ts b/src/agent/directors/greybeard/index.ts deleted file mode 100644 index 435f98be6..000000000 --- a/src/agent/directors/greybeard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { greybeardPackage } from "./package.js"; diff --git a/src/agent/directors/intern/index.ts b/src/agent/directors/intern/index.ts deleted file mode 100644 index be2db9b9a..000000000 --- a/src/agent/directors/intern/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { internPackage } from "./package.js"; diff --git a/src/agent/directors/migrator/index.ts b/src/agent/directors/migrator/index.ts deleted file mode 100644 index fc01065a2..000000000 --- a/src/agent/directors/migrator/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { migratorPackage } from "./package.js"; diff --git a/src/agent/directors/neckbeard/index.ts b/src/agent/directors/neckbeard/index.ts deleted file mode 100644 index 6e9961584..000000000 --- a/src/agent/directors/neckbeard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { neckbeardPackage } from "./package.js"; diff --git a/src/agent/directors/prober/index.ts b/src/agent/directors/prober/index.ts deleted file mode 100644 index 0a0caaa3a..000000000 --- a/src/agent/directors/prober/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { proberPackage } from "./package.js"; diff --git a/src/agent/directors/rand/index.ts b/src/agent/directors/rand/index.ts deleted file mode 100644 index 115866850..000000000 --- a/src/agent/directors/rand/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { randPackage } from "./package.js"; diff --git a/src/agent/directors/registry.ts b/src/agent/directors/registry.ts index d1babff29..b8bb11288 100644 --- a/src/agent/directors/registry.ts +++ b/src/agent/directors/registry.ts @@ -1,24 +1,24 @@ import type { AgentProfile, CapabilityFilter } from "../profile-types.js"; -import { randPackage } from "./rand/index.js"; -import { bruckheimerPackage } from "./bruckheimer/index.js"; -import { criticPackage } from "./critic/index.js"; -import { draperPackage } from "./draper/index.js"; -import { emilPackage } from "./emil/index.js"; -import { explorerPackage } from "./explorer/index.js"; -import { gaasbotPackage } from "./gaasbot/index.js"; -import { greybeardPackage } from "./greybeard/index.js"; -import { builderPackage } from "./builder/index.js"; -import { internPackage } from "./intern/index.js"; -import { migratorPackage } from "./migrator/index.js"; -import { neckbeardPackage } from "./neckbeard/index.js"; -import { counselPackage } from "./counsel/index.js"; -import { shakespearePackage } from "./shakespeare/index.js"; -import { skywalkerPackage } from "./skywalker/index.js"; -import { testerPackage } from "./tester/index.js"; -import { testsmithPackage } from "./testsmith/index.js"; -import { gauntletPackage } from "./gauntlet/index.js"; -import { proberPackage } from "./prober/index.js"; -import { wardenPackage } from "./warden/index.js"; +import { randPackage } from "./rand/package.js"; +import { bruckheimerPackage } from "./bruckheimer/package.js"; +import { criticPackage } from "./critic/package.js"; +import { draperPackage } from "./draper/package.js"; +import { emilPackage } from "./emil/package.js"; +import { explorerPackage } from "./explorer/package.js"; +import { gaasbotPackage } from "./gaasbot/package.js"; +import { greybeardPackage } from "./greybeard/package.js"; +import { builderPackage } from "./builder/package.js"; +import { internPackage } from "./intern/package.js"; +import { migratorPackage } from "./migrator/package.js"; +import { neckbeardPackage } from "./neckbeard/package.js"; +import { counselPackage } from "./counsel/package.js"; +import { shakespearePackage } from "./shakespeare/package.js"; +import { skywalkerPackage } from "./skywalker/package.js"; +import { testerPackage } from "./tester/package.js"; +import { testsmithPackage } from "./testsmith/package.js"; +import { gauntletPackage } from "./gauntlet/package.js"; +import { proberPackage } from "./prober/package.js"; +import { wardenPackage } from "./warden/package.js"; import { formatDirectorSystemPrompt } from "./identity.js"; import { DIRECTOR_IDS, diff --git a/src/agent/directors/shakespeare/index.ts b/src/agent/directors/shakespeare/index.ts deleted file mode 100644 index 706801ab3..000000000 --- a/src/agent/directors/shakespeare/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { shakespearePackage } from "./package.js"; diff --git a/src/agent/directors/skywalker/index.ts b/src/agent/directors/skywalker/index.ts deleted file mode 100644 index 020759240..000000000 --- a/src/agent/directors/skywalker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createSkywalkerSystemPrompt, skywalkerPackage } from "./package.js"; diff --git a/src/agent/directors/tester/index.ts b/src/agent/directors/tester/index.ts deleted file mode 100644 index 5df005fc6..000000000 --- a/src/agent/directors/tester/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { testerPackage } from "./package.js"; diff --git a/src/agent/directors/testsmith/index.ts b/src/agent/directors/testsmith/index.ts deleted file mode 100644 index 438c1f778..000000000 --- a/src/agent/directors/testsmith/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { testsmithPackage } from "./package.js"; diff --git a/src/agent/directors/warden/index.ts b/src/agent/directors/warden/index.ts deleted file mode 100644 index e8d6fbda7..000000000 --- a/src/agent/directors/warden/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { wardenPackage } from "./package.js"; diff --git a/src/agent/lexical-rank.ts b/src/agent/lexical-rank.ts new file mode 100644 index 000000000..c7c31640f --- /dev/null +++ b/src/agent/lexical-rank.ts @@ -0,0 +1,42 @@ +// One shared dependency-free lexical ranker behind tool_search, +// search_agents, and skill_search. Exact name-token hits weigh most, then +// blob-token hits, then raw-substring matches (so "linear" finds +// mcp__linear__* even though it is not a whole token there). + +export function tokenizeLexical(text: string): string[] { + return text.toLowerCase().match(/[a-z0-9]+/g) ?? []; +} + +export interface LexicalFields { + readonly name: string; + readonly blob: string; + readonly nameTokens: readonly string[]; + readonly blobTokens: ReadonlySet; +} + +export function lexicalFields(name: string, blob: string): LexicalFields { + return { + name, + blob, + nameTokens: tokenizeLexical(name), + blobTokens: new Set(tokenizeLexical(blob)), + }; +} + +export function scoreLexical( + fields: LexicalFields, + queryTokens: readonly string[], + rawQuery: string, +): number { + const name = fields.name.toLowerCase(); + const blob = fields.blob.toLowerCase(); + let total = 0; + for (const token of queryTokens) { + if (fields.nameTokens.includes(token)) total += 3; + else if (fields.blobTokens.has(token)) total += 1; + else if (name.includes(token)) total += 0.75; + else if (blob.includes(token)) total += 0.25; + } + if (name.includes(rawQuery)) total += 1; + return total; +} diff --git a/src/agent/skill-search.ts b/src/agent/skill-search.ts index f4ab7f02d..afa1ed779 100644 --- a/src/agent/skill-search.ts +++ b/src/agent/skill-search.ts @@ -4,6 +4,11 @@ import type { ToolDefinition } from "@intx/types/runtime"; import { type } from "arktype"; import type { SkillSummary } from "../extensions/skills.js"; +import { + lexicalFields, + scoreLexical, + tokenizeLexical, +} from "./lexical-rank.js"; // Catalog lookup for skills. Names live in the system prompt; this tool returns // matching name + description so the model can choose. Bodies load via use_skill. @@ -33,10 +38,6 @@ export interface CreateSkillSearchToolArgs { allowedNames?: readonly string[]; } -function tokenize(text: string): string[] { - return text.toLowerCase().match(/[a-z0-9]+/g) ?? []; -} - function visibleSkills( skills: readonly SkillSummary[], allowedNames: readonly string[] | undefined, @@ -51,17 +52,11 @@ function scoreSkill( queryTokens: string[], rawQuery: string, ): number { - const nameTokens = tokenize(skill.name); - const descTokens = new Set(tokenize(skill.description)); - let total = 0; - for (const token of queryTokens) { - if (nameTokens.includes(token)) total += 3; - else if (descTokens.has(token)) total += 1; - else if (skill.name.toLowerCase().includes(token)) total += 0.75; - else if (skill.description.toLowerCase().includes(token)) total += 0.25; - } - if (skill.name.toLowerCase().includes(rawQuery)) total += 1; - return total; + return scoreLexical( + lexicalFields(skill.name, skill.description), + queryTokens, + rawQuery, + ); } const SkillSearchArgs = type({ query: "string" }); @@ -83,7 +78,7 @@ export function createSkillSearchTool( if (query.length === 0) return "Error: skill_search requires a non-empty query."; const rawQuery = query.toLowerCase(); - const queryTokens = tokenize(query); + const queryTokens = tokenizeLexical(query); if (queryTokens.length === 0) { return `No skills matched "${query}". Try different keywords describing the capability.`; } diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index 1d62f274a..11dc8c9d3 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -3,6 +3,11 @@ import type { AgentTool } from "@intx/agent"; import type { ToolDefinition } from "@intx/types/runtime"; import { type } from "arktype"; +import { + lexicalFields, + scoreLexical, + tokenizeLexical, +} from "./lexical-rank.js"; import type { SessionMode } from "../config/session-mode.js"; import { sessionModeEnablesSubAgents } from "../config/session-mode.js"; @@ -229,10 +234,6 @@ export interface ToolIndex { search(query: string, limit?: number): string[]; } -function tokenize(text: string): string[] { - return text.toLowerCase().match(/[a-z0-9]+/g) ?? []; -} - // A dependency-free lexical ranker over each tool's name + description. Exact name // token hits weigh most, then description token hits, then raw-substring matches // (so "linear" finds mcp__linear__* even though it is not a whole token there). @@ -247,25 +248,17 @@ export function createToolIndex( def: ToolDefinition, queryTokens: string[], rawQuery: string, - ): number => { - const nameTokens = tokenize(def.name); - const descTokens = new Set(tokenize(def.description ?? "")); - let total = 0; - for (const token of queryTokens) { - if (nameTokens.includes(token)) total += 3; - else if (descTokens.has(token)) total += 1; - else if (def.name.toLowerCase().includes(token)) total += 0.75; - else if ((def.description ?? "").toLowerCase().includes(token)) - total += 0.25; - } - if (def.name.toLowerCase().includes(rawQuery)) total += 1; - return total; - }; + ): number => + scoreLexical( + lexicalFields(def.name, def.description ?? ""), + queryTokens, + rawQuery, + ); return { search(query: string, limit = 8): string[] { const rawQuery = query.toLowerCase().trim(); - const queryTokens = tokenize(query); + const queryTokens = tokenizeLexical(query); if (queryTokens.length === 0) return []; return getDefs() .filter((def) => !advertisedNames.includes(def.name)) diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 840393820..a53d81255 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -9,7 +9,7 @@ import { submitOutputDefinition, } from "../agent/director.js"; import { manageTasksDefinition } from "./tasks.js"; -import { validateView } from "../tui/view/index.js"; +import { validateView } from "../tui/view/validate.js"; import { SETTINGS_DIR_NAME } from "../branding.js"; import { advertiseShellGuardTimeout, From ec915f876c7dccf62f6e08dcaed28f032ab72b0e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 00:21:15 -0700 Subject: [PATCH 2/3] Merge TUI delivery queue trio and inline micro-module barrels --- docs/ARCHITECTURE.md | 4 +- docs/IMPLEMENTATION.md | 2 +- docs/TUI.md | 4 +- src/tui/agent-ask-wake.test.ts | 4 +- src/tui/collapse.test.ts | 2 +- .../{queued-delivery.ts => delivery-queue.ts} | 259 +++++++++++++++++- src/tui/gate-wire.test.ts | 2 +- src/tui/geometry/index.ts | 6 +- src/tui/geometry/margins.ts | 73 ----- src/tui/geometry/resolve.ts | 2 +- src/tui/geometry/zones.ts | 130 +++++++++ src/tui/history-hydrate.ts | 3 +- src/tui/live-session-port.test.ts | 2 +- src/tui/live-session-port.ts | 3 +- src/tui/pending-column.test.ts | 2 +- src/tui/pending-column.ts | 4 +- src/tui/product-host.ts | 3 +- src/tui/prompt-rows.test.ts | 4 +- src/tui/prompt-rows.ts | 62 ----- src/tui/provider/setup.ts | 2 +- src/tui/queued-delivery-hop.test.ts | 7 +- src/tui/queued-delivery.test.ts | 4 +- src/tui/runner/exit.test.ts | 6 +- src/tui/runner/host.ts | 2 +- src/tui/runner/session.ts | 6 +- src/tui/runner/state.ts | 4 +- src/tui/runner/submit.ts | 2 +- src/tui/runner/wiring.ask-wake.test.ts | 4 +- src/tui/runtime-bridge.test.ts | 2 +- src/tui/runtime-bridge.ts | 4 +- src/tui/session-operation-queue.test.ts | 2 +- src/tui/session-operation-queue.ts | 23 -- src/tui/session-queue.test.ts | 4 +- src/tui/session-queue.ts | 217 --------------- src/tui/shell/chrome.ts | 4 +- src/tui/shell/index.ts | 2 +- src/tui/shell/internals.ts | 2 +- src/tui/shell/keys.ts | 2 +- src/tui/shell/prompt.ts | 2 +- src/tui/steer-worker-invariant.test.ts | 2 +- src/tui/stream-event-map.ts | 2 +- src/tui/tool-args.ts | 4 +- src/tui/transcript-layout.test.ts | 2 +- src/tui/transcript-panels.test.ts | 2 +- src/tui/turns-to-blocks.ts | 3 +- src/tui/view/index.ts | 3 - src/tui/view/lines.ts | 17 +- src/tui/view/registry.ts | 16 -- src/tui/welcome.ts | 2 +- tests/unit/approval-resume.test.ts | 4 +- .../approval-reload-during-suspend.test.ts | 2 +- tests/unit/tui/runner.test.ts | 6 +- tests/unit/tui/view-render.test.ts | 2 +- 53 files changed, 473 insertions(+), 466 deletions(-) rename src/tui/{queued-delivery.ts => delivery-queue.ts} (51%) delete mode 100644 src/tui/geometry/margins.ts delete mode 100644 src/tui/prompt-rows.ts delete mode 100644 src/tui/session-operation-queue.ts delete mode 100644 src/tui/session-queue.ts delete mode 100644 src/tui/view/index.ts delete mode 100644 src/tui/view/registry.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cab2c5ec5..9b0b90f81 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -82,7 +82,7 @@ In TUI chat mode there is no completion gate — the session stays open across t - Wires `ask_operator` to an operator-gate event resolved by a modal - Mounts the OpenTUI host via `mountRunnerHost` (`src/tui/runner/host.ts`), which mounts `mountProductHost` (`src/tui/product-host.ts`) over the shell (`src/tui/shell/`) - Bridges reactor events to the OpenTUI host via a plain `EventEmitter` -- **Mid-run injection** — Shell `session-queue` items drain at the parent +- **Mid-run injection** — Shell `delivery-queue` items drain at the parent `tool.boundary` through `SessionPort.deliver`. Production `routeQueuedDelivery` live-injects in-flight parent-boundary steers via `agentProxy.deliver` (`Agent.deliver`) into one captured agent identity — never re-reading @@ -220,7 +220,7 @@ The **`spawn_agent`** tool starts a fleet agent on a separate inference source ( Workers ask the spawning parent with **`ask_director`** (not the human). That parks a question while the worker stays `running`. On the TUI primary that arrives as an idle-send wake. On a nested orchestrator the question arrives as mailbox mail with an `awaiting_director` status — that is not terminal. Once a parked ask is surfaced (TUI wake, nested mailbox mail, or a successful `list_agents`), further `list_agents` calls fail closed until **`send_input`** answers or the ask is dropped — `list_agents` is not a poll. The parent answers with **`send_input`**, then continues. Escalate to the human with **`ask_operator`** only when the parent cannot resolve it. -When the parent TUI is not blocked in an in-flight tool call, the runner publishes an authoritative snapshot of currently pending top-level questions on each store notification, including empty snapshots before fleet-count updates. During synchronous session rotation, a runner-owned barrier suppresses both publications before delivery-generation invalidation, transcript clearing, and worker cancellation; successful reset reconciles a fresh snapshot before resuming asynchronous backend rebuild. The bridge drops resolved, cancelled, replaced, terminal, and removed asks and delivers each session/question identity once while pending. A coalesced wake starts only when the parent is not processing and every operator gate is closed, including parent-idle fleet holds where the shell stays busy. Worker gates do not manufacture parent processing. Replies use `send_input`'s `target` field with the worker session ID, never its shared catalog ID. Synthetic wakes use `SessionPort.deliver` through queued-delivery's idle-send path without entering the user follow-up queue or composer `/feedback` capture. +When the parent TUI is not blocked in an in-flight tool call, the runner publishes an authoritative snapshot of currently pending top-level questions on each store notification, including empty snapshots before fleet-count updates. During synchronous session rotation, a runner-owned barrier suppresses both publications before delivery-generation invalidation, transcript clearing, and worker cancellation; successful reset reconciles a fresh snapshot before resuming asynchronous backend rebuild. The bridge drops resolved, cancelled, replaced, terminal, and removed asks and delivers each session/question identity once while pending. A coalesced wake starts only when the parent is not processing and every operator gate is closed, including parent-idle fleet holds where the shell stays busy. Worker gates do not manufacture parent processing. Replies use `send_input`'s `target` field with the worker session ID, never its shared catalog ID. Synthetic wakes use `SessionPort.deliver` through delivery-queue's idle-send path without entering the user follow-up queue or composer `/feedback` capture. When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `spawn_agent(agent=...)`. Default results are id, description, and spawn metadata (orchestrator flag, source). Pass `include_body=true` to include each match's loaded system prompt / body (truncated) so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `spawn_agent` and `search_agents` are core tools on the primary session. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 21d226238..af721abfd 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -463,7 +463,7 @@ the directors guard on; the full set of reactor and stream event types is treat that as canonical rather than this section or any other doc's partial list. -Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/session-queue.ts` (interaction contract §3): `enqueue` (kind `"queue"`) and `enqueueSteer` (kind `"steer"`) share one pending pool, drained steer-first, then queue, both FIFO within their class. Mid-run gestures: Enter soft-steers (drain at the next **parent** `tool.boundary` — the parent tool finishing, not a child; parent-busy holds steers), Alt+Enter queues a follow-up (drain on **session-idle**: parent-idle and no live fleet lanes), Ctrl+C stops. Idle-with-fleet is shipped: with live fleet lanes the bridge holds the run busy after the parent turn settles (`fleet` events carry the live count), mid-hold Enter upgrades to an immediate new turn, and the last lane terminalizing releases the hold and drains follow-ups. +Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/delivery-queue.ts` (interaction contract §3): `enqueue` (kind `"queue"`) and `enqueueSteer` (kind `"steer"`) share one pending pool, drained steer-first, then queue, both FIFO within their class. Mid-run gestures: Enter soft-steers (drain at the next **parent** `tool.boundary` — the parent tool finishing, not a child; parent-busy holds steers), Alt+Enter queues a follow-up (drain on **session-idle**: parent-idle and no live fleet lanes), Ctrl+C stops. Idle-with-fleet is shipped: with live fleet lanes the bridge holds the run busy after the parent turn settles (`fleet` events carry the live count), mid-hold Enter upgrades to an immediate new turn, and the last lane terminalizing releases the hold and drains follow-ups. ### Lifecycle Hooks diff --git a/docs/TUI.md b/docs/TUI.md index a9c363377..9b5f88d4f 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -34,7 +34,7 @@ row at a time down to its 3-row base — never the transcript (`COLLAPSE_ORDER` in `zones.ts`). Horizontally, every surface sits inside one shared gutter -(`resolveSideMargin`, `src/tui/geometry/margins.ts`) so the shell reads +(`resolveSideMargin`, `src/tui/geometry/zones.ts`) so the shell reads as a single column of content rather than stacked panes. `resolveGeometry` always returns `layoutMode: "stack"` — full-width y-stack, no dual-column rail. Live workers paint in the agents strip @@ -711,7 +711,7 @@ thing they were trying to deliver. It now reports `interrupt — N pending kept`: the run stops, the queue survives, and those messages are handed over at the interrupt itself (`doInterrupt` drains after `port.interrupt()`), not left waiting on an idle event the stop may never produce (`interrupt` in -`session-queue.ts` no longer clears `items`). +`delivery-queue.ts` no longer clears `items`). **Fleet agent lanes on redirect.** Soft steer (Enter mid-run) and follow-up (queued drain) leave running workers alone — they never call diff --git a/src/tui/agent-ask-wake.test.ts b/src/tui/agent-ask-wake.test.ts index aff44359d..67ba9fa95 100644 --- a/src/tui/agent-ask-wake.test.ts +++ b/src/tui/agent-ask-wake.test.ts @@ -8,9 +8,9 @@ import { classifySubmission, createSubmitHandler } from "./runner/submit.js"; import { createDeliveryGeneration, createLeftoverSend, + createSessionOperationQueue, routeQueuedDelivery, -} from "./queued-delivery.js"; -import { createSessionOperationQueue } from "./session-operation-queue.js"; +} from "./delivery-queue.js"; import { ingestOperatorPrompt } from "./prompt-attachments.js"; import { armFeedbackCapture, diff --git a/src/tui/collapse.test.ts b/src/tui/collapse.test.ts index 90eac1e9d..4f626c672 100644 --- a/src/tui/collapse.test.ts +++ b/src/tui/collapse.test.ts @@ -6,7 +6,7 @@ import { describe, expect, test } from "bun:test"; import { defined } from "../../tests/helpers/defined.js"; import { toolCallRow } from "./diff"; -import { resolveSideMargin } from "./geometry/margins"; +import { resolveSideMargin } from "./geometry/zones"; import { withTestRenderer } from "./harness"; import { pushToolCall, pushToolResult } from "./tool-rows"; import { diff --git a/src/tui/queued-delivery.ts b/src/tui/delivery-queue.ts similarity index 51% rename from src/tui/queued-delivery.ts rename to src/tui/delivery-queue.ts index 1b0428e58..933a154cb 100644 --- a/src/tui/queued-delivery.ts +++ b/src/tui/delivery-queue.ts @@ -1,3 +1,257 @@ +/** + * Delivery queue: mid-run queue / steer / interrupt state machine, the serial + * operation chain that drains it, and the generation-gated delivery hops. + * + * One module: `session-queue.ts` (pure item state), `session-operation-queue.ts` + * (serial promise chain), and `queued-delivery.ts` (kind routing + delivery + * hops) were three slices of the same drain pipeline. No behavior change — + * sections below are verbatim moves. + * + * Mid-run queue / steer / interrupt state machine (interaction contract §3). + * Pure data — no paint, no OpenTUI. Shell + demo own delivery and UI flash. + * + * Product chords (CL-6290): + * - Enter mid-run → kind "steer" (soft steer; drain at tool.boundary) + * - Alt+Enter mid-run → kind "queue" (follow-up; drain only when run goes idle) + * Internal "reinject" is a separate bridge/shell submit kind, not a QueueKind, + * and no product chord wires it anymore — leave the path for tests/API only. + */ + +import type { PendingImageAttachment } from "./image-attachments.js"; +import type { AgentDeliveryResult } from "./deliver-agent-message.js"; +import type { ProductHostDeliver } from "./product-host.js"; +import { ASK_DIRECTOR_WAKE_PREFIX } from "../subagent/fleet-report.js"; +import { MAILBOX_MAIL_WAKE_PREFIX } from "../subagent/mailbox-mail-drive.js"; + +export type QueueKind = "queue" | "steer"; + +export interface QueueItem { + readonly id: string; + readonly text: string; + readonly kind: QueueKind; + readonly enqueuedAt: number; + /** Images attached to this message, delivered with it at the boundary. */ + readonly attachments?: readonly PendingImageAttachment[]; +} + +export type RunState = "idle" | "busy"; + +export interface SessionQueueState { + readonly run: RunState; + readonly items: readonly QueueItem[]; + /** True after interrupt until consumer clears (status flash). */ + readonly interruptFlash: boolean; + /** Monotonic id seed for queue items. */ + readonly nextId: number; +} + +export function createSessionQueue(run: RunState = "idle"): SessionQueueState { + return { + run, + items: [], + interruptFlash: false, + nextId: 1, + }; +} + +/** Pending badge count (queue + steer share one pool for depth totals). */ +export function badgeCount(state: SessionQueueState): number { + return state.items.length; +} + +/** Soft-steer pending count (Enter mid-run). */ +export function steerCount(state: SessionQueueState): number { + return state.items.filter((i) => i.kind === "steer").length; +} + +/** Follow-up pending count (Alt+Enter mid-run). */ +export function queueCount(state: SessionQueueState): number { + return state.items.filter((i) => i.kind === "queue").length; +} + +export function setRunState( + state: SessionQueueState, + run: RunState, +): SessionQueueState { + if (state.run === run) return state; + return { ...state, run }; +} + +/** + * Enqueue a mid-run message. Empty / whitespace-only is a no-op. + * When idle, still accepts into the queue bag for tests; product shell + * may route idle Enter as immediate send instead of calling this. + */ +export function enqueue( + state: SessionQueueState, + text: string, + kind: QueueKind = "queue", + now = Date.now(), + attachments?: readonly PendingImageAttachment[], +): SessionQueueState { + const t = text.trim(); + if ( + t.length === 0 && + (attachments === undefined || attachments.length === 0) + ) { + return state; + } + const item: QueueItem = { + id: `q${state.nextId}`, + text: t, + kind, + enqueuedAt: now, + ...(attachments !== undefined && attachments.length > 0 + ? { attachments } + : {}), + }; + return { + ...state, + items: [...state.items, item], + nextId: state.nextId + 1, + interruptFlash: false, + }; +} + +/** Steer = priority enqueue (same badge pool). */ +export function enqueueSteer( + state: SessionQueueState, + text: string, + now = Date.now(), + attachments?: readonly PendingImageAttachment[], +): SessionQueueState { + return enqueue(state, text, "steer", now, attachments); +} + +/** + * Hard interrupt: stop the run, keep everything the operator queued. Typing a + * correction and then interrupting so it lands sooner is the common shape of + * this gesture, so discarding the queue destroyed exactly the input the + * operator most wanted delivered. Pending items survive to the next drain + * boundary; only the run state and the flash change here. + */ +export function interrupt(state: SessionQueueState): SessionQueueState { + return { + ...state, + run: "idle", + interruptFlash: true, + }; +} + +export function clearInterruptFlash( + state: SessionQueueState, +): SessionQueueState { + if (!state.interruptFlash) return state; + return { ...state, interruptFlash: false }; +} + +/** + * Retract the most recently enqueued item, queue or steer alike. Last-only: + * an operator who wants an earlier item gone has no path here (see + * `applyShellCancelLast` for why that is the shipped scope, not an oversight). + */ +export function cancelLast(state: SessionQueueState): { + state: SessionQueueState; + item: QueueItem | null; +} { + const item = state.items[state.items.length - 1] ?? null; + if (item === null) return { state, item: null }; + return { + state: { ...state, items: state.items.slice(0, -1) }, + item, + }; +} + +/** + * Retract a specific item by id — the pending column's per-row drop, where the + * operator picked exactly which held message to kill rather than the newest. + */ +export function cancelItem( + state: SessionQueueState, + id: string, +): { state: SessionQueueState; item: QueueItem | null } { + const index = state.items.findIndex((item) => item.id === id); + const item = state.items[index] ?? null; + if (item === null) return { state, item: null }; + return { + state: { + ...state, + items: [...state.items.slice(0, index), ...state.items.slice(index + 1)], + }, + item, + }; +} + +/** Drain order: steers first (FIFO within class), then queue (FIFO). */ +export function drainOrder(state: SessionQueueState): readonly QueueItem[] { + const steers = state.items.filter((i) => i.kind === "steer"); + const queues = state.items.filter((i) => i.kind === "queue"); + return [...steers, ...queues]; +} + +/** + * Pop next delivery item. When `kind` is set, only that class (FIFO within + * class); otherwise full `drainOrder` (steer-first, then queue). + */ +export function drainOne( + state: SessionQueueState, + kind?: QueueKind, +): { state: SessionQueueState; item: QueueItem | null } { + const order = + kind === undefined + ? drainOrder(state) + : state.items.filter((i) => i.kind === kind); + const item = order[0] ?? null; + if (!item) return { state, item: null }; + return { + state: { + ...state, + items: state.items.filter((i) => i.id !== item.id), + }, + item, + }; +} + +/** Drain every pending soft-steer; leave follow-ups untouched. */ +export function drainSteersOnly(state: SessionQueueState): { + state: SessionQueueState; + drained: readonly QueueItem[]; +} { + const drained: QueueItem[] = []; + let current = state; + for (;;) { + const next = drainOne(current, "steer"); + if (!next.item) break; + drained.push(next.item); + current = next.state; + } + return { state: current, drained }; +} + +// Serial promise chain for session-scoped operations (reload, interrupt, deliver). +// Each task runs after the previous one settles; failures do not block the tail. + +export interface SessionOperationQueue { + /** Enqueue an async operation; returns a promise for this operation's settlement. */ + enqueue: (op: () => Promise) => Promise; + /** Await the tail of the queue (all prior operations finished or failed). */ + awaitTail: () => Promise; +} + +export function createSessionOperationQueue(): SessionOperationQueue { + let tail: Promise = Promise.resolve(); + + const enqueue = (op: () => Promise): Promise => { + tail = tail.then(op, op); + return tail; + }; + + return { + enqueue, + awaitTail: () => tail.catch(() => undefined), + }; +} + /** * Kind routing for drained queue items, plus a generation token so a * /clear|/new rotation can drop in-flight delivers that belonged to the @@ -7,11 +261,6 @@ * parent tool.boundary. Leftover steers at idle, idle-with-fleet, or * post-interrupt share the send path (sendQueue, inFlight, token refresh). */ -import type { PendingImageAttachment } from "./image-attachments.js"; -import type { AgentDeliveryResult } from "./deliver-agent-message.js"; -import type { ProductHostDeliver } from "./product-host.js"; -import { ASK_DIRECTOR_WAKE_PREFIX } from "../subagent/fleet-report.js"; -import { MAILBOX_MAIL_WAKE_PREFIX } from "../subagent/mailbox-mail-drive.js"; export type DeliverySettle = (result: AgentDeliveryResult) => void; type MaybeAsyncDeliveryResult = diff --git a/src/tui/gate-wire.test.ts b/src/tui/gate-wire.test.ts index fcd835d90..5558b3d97 100644 --- a/src/tui/gate-wire.test.ts +++ b/src/tui/gate-wire.test.ts @@ -22,7 +22,7 @@ import { } from "./shell/overlay-list.js"; import { streamRowGutter } from "./stream.js"; import { APPROVAL_UNAVAILABLE_MESSAGE } from "./gate-events.js"; -import { SESSION_IDENTITY_ABORT_REASON } from "./queued-delivery.js"; +import { SESSION_IDENTITY_ABORT_REASON } from "./delivery-queue.js"; import { approvalOutcomeFromSelection, operatorCancelResult, diff --git a/src/tui/geometry/index.ts b/src/tui/geometry/index.ts index 9a88956e4..19227f1b0 100644 --- a/src/tui/geometry/index.ts +++ b/src/tui/geometry/index.ts @@ -20,6 +20,10 @@ export { zoneDeclaration, type ZoneDeclaration, type ZoneId, + promptBoxCapRows, + promptBoxRows, + promptInputRows, + promptIsScrolling, } from "./zones.js"; export { @@ -33,7 +37,7 @@ export { resolveContentWidth, resolveSideMargin, resolveTopPadRows, -} from "./margins.js"; +} from "./zones.js"; export { desiredHeights, diff --git a/src/tui/geometry/margins.ts b/src/tui/geometry/margins.ts deleted file mode 100644 index 55272d0b1..000000000 --- a/src/tui/geometry/margins.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Optical breathing room shared by every shell surface. - * - * The side gutter is one number for the whole interface — transcript, prompt - * box, model bar, hint row and overlay host all sit inside it — so the shell - * reads as a single column of content rather than panes that happen to be - * stacked. Top and bottom pads are carved out of the transcript residual by - * the shell after the geometry resolver has assigned heights, so they never - * change the resolver's row budget. - */ - -/** - * Gutter columns on each side once the terminal can afford them. - * - * One column at every width the gutter exists at all. A single column is - * already enough to keep content off the frame edge, which is the whole job, - * and a wider gutter only read as excess air on a wide pane. There is no - * middle tier: a width that can spare a column gets one, and a width that - * cannot gets none. - */ -export const SIDE_MARGIN = 1; - -/** Below this width every column belongs to content: the gutter goes to zero. */ -export const MARGIN_MIN_COLUMNS = 40; - -/** Gutter width for a terminal of `columns` columns. */ -export function resolveSideMargin(columns: number): number { - const cols = Math.max(0, Math.floor(columns)); - return cols >= MARGIN_MIN_COLUMNS ? SIDE_MARGIN : 0; -} - -/** Columns left for content after both gutters. */ -export function resolveContentWidth(columns: number): number { - const cols = Math.max(1, Math.floor(columns)); - return Math.max(1, cols - resolveSideMargin(cols) * 2); -} - -/** - * Blank rows above the first transcript row. Carved out of the transcript - * residual by the shell, never out of chrome, so the resolved row budget holds. - */ -export const TOP_PAD_ROWS = 1; - -/** Below this many transcript rows the pad is not worth the row it costs. */ -export const TOP_PAD_MIN_TRANSCRIPT_ROWS = 6; - -/** Top pad rows affordable for a transcript of `transcriptRows` rows. */ -export function resolveTopPadRows(transcriptRows: number): number { - return transcriptRows >= TOP_PAD_MIN_TRANSCRIPT_ROWS ? TOP_PAD_ROWS : 0; -} - -/** - * Rows below the prompt box once the terminal can afford them. - * - * One blank row keeps the prompt off the terminal's last line the same way - * `TOP_PAD_ROWS` keeps the first transcript row off the top edge and - * `SIDE_MARGIN` keeps content off the left and right. More than one only - * reads as the interface floating, so there is no middle tier. - */ -export const BOTTOM_MARGIN_ROWS = 1; - -/** - * Below this terminal height the margin is not worth the row it costs — the - * same 24-row line the resolver already treats as "short terminal" for the - * transcript floor, so every yield point in the layout agrees on where a - * terminal stops being able to afford anything optional. - */ -export const BOTTOM_MARGIN_MIN_ROWS = 24; - -/** Bottom margin rows affordable for a terminal of `terminalRows` rows. */ -export function resolveBottomMarginRows(terminalRows: number): number { - return terminalRows >= BOTTOM_MARGIN_MIN_ROWS ? BOTTOM_MARGIN_ROWS : 0; -} diff --git a/src/tui/geometry/resolve.ts b/src/tui/geometry/resolve.ts index 69a2e1310..03e1d0c42 100644 --- a/src/tui/geometry/resolve.ts +++ b/src/tui/geometry/resolve.ts @@ -1,7 +1,7 @@ // Pure geometry resolver: terminal size + zone visibility + overlay mode → rects. // Caller passes { columns, rows }; this module never reads process.stdout. -import { resolveContentWidth, resolveSideMargin } from "./margins.js"; +import { resolveContentWidth, resolveSideMargin } from "./zones.js"; import { COLLAPSE_ORDER, FLEET_BOARD_CAP_FRACTION, diff --git a/src/tui/geometry/zones.ts b/src/tui/geometry/zones.ts index 6c624a6f7..529073c2c 100644 --- a/src/tui/geometry/zones.ts +++ b/src/tui/geometry/zones.ts @@ -250,3 +250,133 @@ export const PAINT_ORDER = [ export function zoneDeclaration(id: ZoneId): ZoneDeclaration { return ZONE_REGISTRY[id]; } + +/** + * Optical breathing room shared by every shell surface. + * + * The side gutter is one number for the whole interface — transcript, prompt + * box, model bar, hint row and overlay host all sit inside it — so the shell + * reads as a single column of content rather than panes that happen to be + * stacked. Top and bottom pads are carved out of the transcript residual by + * the shell after the geometry resolver has assigned heights, so they never + * change the resolver's row budget. + */ + +/** + * Gutter columns on each side once the terminal can afford them. + * + * One column at every width the gutter exists at all. A single column is + * already enough to keep content off the frame edge, which is the whole job, + * and a wider gutter only read as excess air on a wide pane. There is no + * middle tier: a width that can spare a column gets one, and a width that + * cannot gets none. + */ +export const SIDE_MARGIN = 1; + +/** Below this width every column belongs to content: the gutter goes to zero. */ +export const MARGIN_MIN_COLUMNS = 40; + +/** Gutter width for a terminal of `columns` columns. */ +export function resolveSideMargin(columns: number): number { + const cols = Math.max(0, Math.floor(columns)); + return cols >= MARGIN_MIN_COLUMNS ? SIDE_MARGIN : 0; +} + +/** Columns left for content after both gutters. */ +export function resolveContentWidth(columns: number): number { + const cols = Math.max(1, Math.floor(columns)); + return Math.max(1, cols - resolveSideMargin(cols) * 2); +} + +/** + * Blank rows above the first transcript row. Carved out of the transcript + * residual by the shell, never out of chrome, so the resolved row budget holds. + */ +export const TOP_PAD_ROWS = 1; + +/** Below this many transcript rows the pad is not worth the row it costs. */ +export const TOP_PAD_MIN_TRANSCRIPT_ROWS = 6; + +/** Top pad rows affordable for a transcript of `transcriptRows` rows. */ +export function resolveTopPadRows(transcriptRows: number): number { + return transcriptRows >= TOP_PAD_MIN_TRANSCRIPT_ROWS ? TOP_PAD_ROWS : 0; +} + +/** + * Rows below the prompt box once the terminal can afford them. + * + * One blank row keeps the prompt off the terminal's last line the same way + * `TOP_PAD_ROWS` keeps the first transcript row off the top edge and + * `SIDE_MARGIN` keeps content off the left and right. More than one only + * reads as the interface floating, so there is no middle tier. + */ +export const BOTTOM_MARGIN_ROWS = 1; + +/** + * Below this terminal height the margin is not worth the row it costs — the + * same 24-row line the resolver already treats as "short terminal" for the + * transcript floor, so every yield point in the layout agrees on where a + * terminal stops being able to afford anything optional. + */ +export const BOTTOM_MARGIN_MIN_ROWS = 24; + +/** Bottom margin rows affordable for a terminal of `terminalRows` rows. */ +export function resolveBottomMarginRows(terminalRows: number): number { + return terminalRows >= BOTTOM_MARGIN_MIN_ROWS ? BOTTOM_MARGIN_ROWS : 0; +} + +/** + * How tall the prompt box is for what is being composed. + * + * Three rules, in order of precedence: + * + * 1. The box never shrinks below its resting size — an empty prompt still + * offers PROMPT_IDLE_INPUT_ROWS lines, so there is somewhere to write and so + * the first typed line does not sit against the animated mark in the bottom + * rule. + * 2. It grows a row per visual line of content, so a longer prompt is visible + * while it is being written rather than scrolling under itself immediately. + * 3. It stops at PROMPT_CAP_FRACTION of the terminal. Past that the input + * scrolls internally (OpenTUI's editor view follows the caret), because rows + * spent here come straight out of the transcript. + * + * The resolver has the last word on a short terminal: it collapses the box back + * toward PROMPT_BASE_ROWS when the transcript would otherwise breach its floor. + * Reading the transcript matters more than seeing the whole draft at once. + * + * Pure: line counts in, rows out. The caller measures the wrapped line count + * (OpenTUI's editor view already does the wrapping, including surrogate pairs + * and wide glyphs) and applies the result. + */ + +/** Tallest bordered box the prompt may ask for on a terminal of `rows` rows. */ +export function promptBoxCapRows(terminalRows: number): number { + const rows = Math.max(1, Math.floor(terminalRows)); + return Math.max(PROMPT_BASE_ROWS, Math.floor(rows * PROMPT_CAP_FRACTION)); +} + +/** Input rows to show for `visualLines` of wrapped content. */ +export function promptInputRows( + visualLines: number, + terminalRows: number, +): number { + const wanted = Math.max(PROMPT_IDLE_INPUT_ROWS, Math.floor(visualLines)); + const cap = promptBoxCapRows(terminalRows) - PROMPT_BORDER_ROWS; + return Math.max(1, Math.min(wanted, cap)); +} + +/** Bordered box rows to request from the geometry resolver. */ +export function promptBoxRows( + visualLines: number, + terminalRows: number, +): number { + return promptInputRows(visualLines, terminalRows) + PROMPT_BORDER_ROWS; +} + +/** True once the content no longer fits and the input is scrolling itself. */ +export function promptIsScrolling( + visualLines: number, + terminalRows: number, +): boolean { + return Math.floor(visualLines) > promptInputRows(visualLines, terminalRows); +} diff --git a/src/tui/history-hydrate.ts b/src/tui/history-hydrate.ts index ea660e030..f87071f89 100644 --- a/src/tui/history-hydrate.ts +++ b/src/tui/history-hydrate.ts @@ -5,7 +5,8 @@ * paint without a renderer. No OpenTUI or Ink deps. */ -import { validateView, viewToLines } from "./view/index.js"; +import { validateView } from "./view/validate.js"; +import { viewToLines } from "./view/lines.js"; import { toolCallRow } from "./diff.js"; import { toolResultRow } from "./mcp-view.js"; import type { StreamRow } from "./stream.js"; diff --git a/src/tui/live-session-port.test.ts b/src/tui/live-session-port.test.ts index 46a6e1539..c71f318a5 100644 --- a/src/tui/live-session-port.test.ts +++ b/src/tui/live-session-port.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createLiveSessionPort } from "./live-session-port"; import type { PendingImageAttachment } from "./image-attachments.js"; -import type { QueueItem, QueueKind } from "./session-queue"; +import type { QueueItem, QueueKind } from "./delivery-queue"; type Call = | { op: "send"; text: string } diff --git a/src/tui/live-session-port.ts b/src/tui/live-session-port.ts index 45e6a0087..0196a6e82 100644 --- a/src/tui/live-session-port.ts +++ b/src/tui/live-session-port.ts @@ -4,8 +4,7 @@ */ import type { PendingImageAttachment } from "./image-attachments.js"; -import type { QueueItem, QueueKind } from "./session-queue.js"; -import type { DeliverySettle } from "./queued-delivery.js"; +import type { DeliverySettle, QueueItem, QueueKind } from "./delivery-queue.js"; import type { SessionPort } from "./runtime-bridge.js"; export type SubmitClassification = "agent" | "local" | "empty"; diff --git a/src/tui/pending-column.test.ts b/src/tui/pending-column.test.ts index 751c65cf5..9693272e1 100644 --- a/src/tui/pending-column.test.ts +++ b/src/tui/pending-column.test.ts @@ -6,7 +6,7 @@ import { pendingWindowStart, } from "./pending-column"; import { PENDING_MAX_VISIBLE } from "./geometry/zones"; -import type { QueueItem } from "./session-queue"; +import type { QueueItem } from "./delivery-queue"; let seq = 0; function item(text: string, kind: QueueItem["kind"] = "queue"): QueueItem { diff --git a/src/tui/pending-column.ts b/src/tui/pending-column.ts index 13e2b0e23..ab5643ef3 100644 --- a/src/tui/pending-column.ts +++ b/src/tui/pending-column.ts @@ -9,14 +9,14 @@ * transcript rows on it: items sit here while pending and land in the * transcript as ordinary user rows only when they actually deliver. * - * Pure: session-queue items in, row models out. Paint lives in + * Pure: delivery-queue items in, row models out. Paint lives in * shell/chrome.ts (`syncPendingRows`); the row budget lives in geometry * (zone `pending`). */ import { PENDING_MAX_VISIBLE } from "./geometry/zones.js"; import { sliceToWidth, stringWidth } from "./view/height.js"; -import type { QueueItem } from "./session-queue.js"; +import type { QueueItem } from "./delivery-queue.js"; /** Marker opening every pending row — a pointer at the prompt it sits on. */ const ROW_MARK = "›"; diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index e62612f13..2bf7a4744 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -64,12 +64,11 @@ import { setPaletteCatalog, } from "./shell/palette.js"; import { surfaceSystemNotice } from "./shell/prompt.js"; -import type { QueueKind } from "./session-queue.js"; +import type { DeliverySettle, QueueKind } from "./delivery-queue.js"; import { hydrateHistoryRows } from "./history-hydrate.js"; import type { StreamRow } from "./stream.js"; import type { PendingImageAttachment } from "./image-attachments.js"; -import type { DeliverySettle } from "./queued-delivery.js"; /** Suffix the row matching `activeId` (if any) so it reads as the current pick. */ function annotateCurrent( diff --git a/src/tui/prompt-rows.test.ts b/src/tui/prompt-rows.test.ts index 2ae4fe055..881222aa6 100644 --- a/src/tui/prompt-rows.test.ts +++ b/src/tui/prompt-rows.test.ts @@ -6,13 +6,11 @@ import { PROMPT_CAP_FRACTION, PROMPT_IDLE_INPUT_ROWS, PROMPT_IDLE_ROWS, -} from "./geometry/index.js"; -import { promptBoxCapRows, promptBoxRows, promptInputRows, promptIsScrolling, -} from "./prompt-rows.js"; +} from "./geometry/zones.js"; describe("prompt box sizing", () => { test("an empty prompt still offers a composing area", () => { diff --git a/src/tui/prompt-rows.ts b/src/tui/prompt-rows.ts deleted file mode 100644 index 0093c3990..000000000 --- a/src/tui/prompt-rows.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * How tall the prompt box is for what is being composed. - * - * Three rules, in order of precedence: - * - * 1. The box never shrinks below its resting size — an empty prompt still - * offers PROMPT_IDLE_INPUT_ROWS lines, so there is somewhere to write and so - * the first typed line does not sit against the animated mark in the bottom - * rule. - * 2. It grows a row per visual line of content, so a longer prompt is visible - * while it is being written rather than scrolling under itself immediately. - * 3. It stops at PROMPT_CAP_FRACTION of the terminal. Past that the input - * scrolls internally (OpenTUI's editor view follows the caret), because rows - * spent here come straight out of the transcript. - * - * The resolver has the last word on a short terminal: it collapses the box back - * toward PROMPT_BASE_ROWS when the transcript would otherwise breach its floor. - * Reading the transcript matters more than seeing the whole draft at once. - * - * Pure: line counts in, rows out. The caller measures the wrapped line count - * (OpenTUI's editor view already does the wrapping, including surrogate pairs - * and wide glyphs) and applies the result. - */ - -import { - PROMPT_BASE_ROWS, - PROMPT_BORDER_ROWS, - PROMPT_CAP_FRACTION, - PROMPT_IDLE_INPUT_ROWS, -} from "./geometry/index.js"; - -/** Tallest bordered box the prompt may ask for on a terminal of `rows` rows. */ -export function promptBoxCapRows(terminalRows: number): number { - const rows = Math.max(1, Math.floor(terminalRows)); - return Math.max(PROMPT_BASE_ROWS, Math.floor(rows * PROMPT_CAP_FRACTION)); -} - -/** Input rows to show for `visualLines` of wrapped content. */ -export function promptInputRows( - visualLines: number, - terminalRows: number, -): number { - const wanted = Math.max(PROMPT_IDLE_INPUT_ROWS, Math.floor(visualLines)); - const cap = promptBoxCapRows(terminalRows) - PROMPT_BORDER_ROWS; - return Math.max(1, Math.min(wanted, cap)); -} - -/** Bordered box rows to request from the geometry resolver. */ -export function promptBoxRows( - visualLines: number, - terminalRows: number, -): number { - return promptInputRows(visualLines, terminalRows) + PROMPT_BORDER_ROWS; -} - -/** True once the content no longer fits and the input is scrolling itself. */ -export function promptIsScrolling( - visualLines: number, - terminalRows: number, -): boolean { - return Math.floor(visualLines) > promptInputRows(visualLines, terminalRows); -} diff --git a/src/tui/provider/setup.ts b/src/tui/provider/setup.ts index b8fffa745..8b67e190e 100644 --- a/src/tui/provider/setup.ts +++ b/src/tui/provider/setup.ts @@ -27,7 +27,7 @@ import { import { prefetchGoModels as prefetchGoModelsRequest } from "../../provider/opencode-go-models.js"; import { prefetchZenModels as prefetchZenModelsRequest } from "../../provider/zen-models.js"; import { isZenProviderId } from "../../../packages/zen/src/index.js"; -import { resolveSideMargin } from "../geometry/margins.js"; +import { resolveSideMargin } from "../geometry/zones.js"; import { residualIdFromSelection, residualListFromCatalog, diff --git a/src/tui/queued-delivery-hop.test.ts b/src/tui/queued-delivery-hop.test.ts index 3b0af62c3..a21310cac 100644 --- a/src/tui/queued-delivery-hop.test.ts +++ b/src/tui/queued-delivery-hop.test.ts @@ -8,12 +8,13 @@ import { createLiveSessionPort } from "./live-session-port"; import { createAppShell } from "./shell/index"; import { withTestRenderer } from "./harness"; import { + badgeCount, createLiveSteerDeliver, + createSessionOperationQueue, routeQueuedDelivery, type DeliverySettle, -} from "./queued-delivery.js"; -import { createSessionOperationQueue } from "./session-operation-queue.js"; -import { badgeCount, type QueueItem } from "./session-queue"; + type QueueItem, +} from "./delivery-queue.js"; import type { AgentDeliveryResult } from "./deliver-agent-message.js"; function lastHopPort(bridgeRef: { current: SessionBridge | undefined }) { diff --git a/src/tui/queued-delivery.test.ts b/src/tui/queued-delivery.test.ts index 456340b70..de8e2d0b7 100644 --- a/src/tui/queued-delivery.test.ts +++ b/src/tui/queued-delivery.test.ts @@ -5,10 +5,10 @@ import { createDeliveryGeneration, createLeftoverSend, createLiveSteerDeliver, + createSessionOperationQueue, routeQueuedDelivery, SESSION_IDENTITY_ABORT_REASON, -} from "./queued-delivery.js"; -import { createSessionOperationQueue } from "./session-operation-queue.js"; +} from "./delivery-queue.js"; import type { AgentDeliveryResult } from "./deliver-agent-message.js"; const image: PendingImageAttachment = { diff --git a/src/tui/runner/exit.test.ts b/src/tui/runner/exit.test.ts index b99eb139f..b768e90c9 100644 --- a/src/tui/runner/exit.test.ts +++ b/src/tui/runner/exit.test.ts @@ -7,8 +7,10 @@ import type { InferenceSource } from "@intx/types/runtime"; import * as codexSession from "../../auth/codex/session.js"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; import { defined } from "../../../tests/helpers/defined.js"; -import { createDeliveryGeneration } from "../queued-delivery.js"; -import { createSessionOperationQueue } from "../session-operation-queue.js"; +import { + createDeliveryGeneration, + createSessionOperationQueue, +} from "../delivery-queue.js"; import { createRunLifecycle, finalizeTUIRun, diff --git a/src/tui/runner/host.ts b/src/tui/runner/host.ts index 9b084d017..a637cbdb8 100644 --- a/src/tui/runner/host.ts +++ b/src/tui/runner/host.ts @@ -58,7 +58,7 @@ import { toolCallRow } from "../diff.js"; import { toolResultRow } from "../mcp-view.js"; import { pushToolCall, pushToolResult } from "../tool-rows.js"; import type { StreamRow } from "../stream.js"; -import type { QueueKind } from "../session-queue.js"; +import type { QueueKind } from "../delivery-queue.js"; import type { ShellOutputFeed } from "../../session/shell-output-feed.js"; export interface RunnerHostDeps { diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 200e4e578..2940f5c69 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -77,8 +77,10 @@ import { import { ensureFreshInferenceSource } from "../../subagent/refresh-inference-source.js"; import { setAgentSourceUnlessClosed } from "../agent-source-sync.js"; import { createSessionCostAccumulator } from "../../cost/session-cost.js"; -import { createSessionOperationQueue } from "../session-operation-queue.js"; -import { createDeliveryGeneration } from "../queued-delivery.js"; +import { + createDeliveryGeneration, + createSessionOperationQueue, +} from "../delivery-queue.js"; import { createCorrelationAcceptance } from "../correlation-acceptance.js"; import { createAgentToolset, diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts index 49a7de7ec..42b0101b1 100644 --- a/src/tui/runner/state.ts +++ b/src/tui/runner/state.ts @@ -158,10 +158,10 @@ export interface RunnerServices { typeof import("../../cost/session-cost.js").createSessionCostAccumulator >; sessionOps: ReturnType< - typeof import("../session-operation-queue.js").createSessionOperationQueue + typeof import("../delivery-queue.js").createSessionOperationQueue >; deliveryGeneration: ReturnType< - typeof import("../queued-delivery.js").createDeliveryGeneration + typeof import("../delivery-queue.js").createDeliveryGeneration >; correlationAcceptance: ReturnType< typeof import("../correlation-acceptance.js").createCorrelationAcceptance diff --git a/src/tui/runner/submit.ts b/src/tui/runner/submit.ts index ac380b341..9decc5769 100644 --- a/src/tui/runner/submit.ts +++ b/src/tui/runner/submit.ts @@ -35,7 +35,7 @@ import { createLeftoverSend, createLiveSteerDeliver, routeQueuedDelivery, -} from "../queued-delivery.js"; +} from "../delivery-queue.js"; import type { AgentDeliveryResult } from "../deliver-agent-message.js"; import type { InferenceAttemptIdentity } from "./state.js"; import { tuiSendFailureMessage } from "./send-failure-message.js"; diff --git a/src/tui/runner/wiring.ask-wake.test.ts b/src/tui/runner/wiring.ask-wake.test.ts index 1b321b420..dac583f9c 100644 --- a/src/tui/runner/wiring.ask-wake.test.ts +++ b/src/tui/runner/wiring.ask-wake.test.ts @@ -19,8 +19,8 @@ import { clearTranscript } from "../shell/chrome.js"; import { createDeliveryGeneration, createLeftoverSend, -} from "../queued-delivery.js"; -import { createSessionOperationQueue } from "../session-operation-queue.js"; + createSessionOperationQueue, +} from "../delivery-queue.js"; test("failed reset releases publication without flushing partially cancelled workers", () => { const store = createSubAgentSessionStore(); diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 62c752101..acd45c41d 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -17,7 +17,7 @@ import { getShellBridgeHooks } from "./shell/internals"; import { streamRowCount } from "./shell/transcript"; import { STEER_WAIT_NOTICE_MS } from "./notice-line"; import { withTestRenderer } from "./harness"; -import { badgeCount } from "./session-queue"; +import { badgeCount } from "./delivery-queue"; import { LIVE_ACTIVITY_WORDS } from "./session-chrome"; describe("mapReactorLike", () => { diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 6e7d9f2e5..7a13e347f 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -16,7 +16,8 @@ import { setRunState, type QueueItem, type QueueKind, -} from "./session-queue.js"; + type DeliverySettle, +} from "./delivery-queue.js"; import { appendStreamRow, paintChrome, @@ -72,7 +73,6 @@ import { deliveryResultNotice, type AgentDeliveryResult, } from "./deliver-agent-message.js"; -import type { DeliverySettle } from "./queued-delivery.js"; import { toolCallRow } from "./diff.js"; import { toolResultRow } from "./mcp-view.js"; import { diff --git a/src/tui/session-operation-queue.test.ts b/src/tui/session-operation-queue.test.ts index e8a0d061d..b7a6bb614 100644 --- a/src/tui/session-operation-queue.test.ts +++ b/src/tui/session-operation-queue.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { createSessionOperationQueue } from "./session-operation-queue.js"; +import { createSessionOperationQueue } from "./delivery-queue.js"; test("serial operation queue executes operations in order without interleaving", async () => { const log: string[] = []; diff --git a/src/tui/session-operation-queue.ts b/src/tui/session-operation-queue.ts deleted file mode 100644 index a1383a8be..000000000 --- a/src/tui/session-operation-queue.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Serial promise chain for session-scoped operations (reload, interrupt, deliver). -// Each task runs after the previous one settles; failures do not block the tail. - -export interface SessionOperationQueue { - /** Enqueue an async operation; returns a promise for this operation's settlement. */ - enqueue: (op: () => Promise) => Promise; - /** Await the tail of the queue (all prior operations finished or failed). */ - awaitTail: () => Promise; -} - -export function createSessionOperationQueue(): SessionOperationQueue { - let tail: Promise = Promise.resolve(); - - const enqueue = (op: () => Promise): Promise => { - tail = tail.then(op, op); - return tail; - }; - - return { - enqueue, - awaitTail: () => tail.catch(() => undefined), - }; -} diff --git a/src/tui/session-queue.test.ts b/src/tui/session-queue.test.ts index 6fea1a79e..3f6ca24ea 100644 --- a/src/tui/session-queue.test.ts +++ b/src/tui/session-queue.test.ts @@ -14,9 +14,9 @@ import { queueCount, setRunState, steerCount, -} from "./session-queue"; +} from "./delivery-queue"; -describe("session-queue", () => { +describe("delivery-queue", () => { test("empty enqueue is no-op", () => { const s0 = createSessionQueue("busy"); expect(enqueue(s0, " ")).toBe(s0); diff --git a/src/tui/session-queue.ts b/src/tui/session-queue.ts deleted file mode 100644 index d9ea0fe15..000000000 --- a/src/tui/session-queue.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Mid-run queue / steer / interrupt state machine (interaction contract §3). - * Pure data — no paint, no OpenTUI. Shell + demo own delivery and UI flash. - * - * Product chords (CL-6290): - * - Enter mid-run → kind "steer" (soft steer; drain at tool.boundary) - * - Alt+Enter mid-run → kind "queue" (follow-up; drain only when run goes idle) - * Internal "reinject" is a separate bridge/shell submit kind, not a QueueKind, - * and no product chord wires it anymore — leave the path for tests/API only. - */ - -import type { PendingImageAttachment } from "./image-attachments.js"; - -export type QueueKind = "queue" | "steer"; - -export interface QueueItem { - readonly id: string; - readonly text: string; - readonly kind: QueueKind; - readonly enqueuedAt: number; - /** Images attached to this message, delivered with it at the boundary. */ - readonly attachments?: readonly PendingImageAttachment[]; -} - -export type RunState = "idle" | "busy"; - -export interface SessionQueueState { - readonly run: RunState; - readonly items: readonly QueueItem[]; - /** True after interrupt until consumer clears (status flash). */ - readonly interruptFlash: boolean; - /** Monotonic id seed for queue items. */ - readonly nextId: number; -} - -export function createSessionQueue(run: RunState = "idle"): SessionQueueState { - return { - run, - items: [], - interruptFlash: false, - nextId: 1, - }; -} - -/** Pending badge count (queue + steer share one pool for depth totals). */ -export function badgeCount(state: SessionQueueState): number { - return state.items.length; -} - -/** Soft-steer pending count (Enter mid-run). */ -export function steerCount(state: SessionQueueState): number { - return state.items.filter((i) => i.kind === "steer").length; -} - -/** Follow-up pending count (Alt+Enter mid-run). */ -export function queueCount(state: SessionQueueState): number { - return state.items.filter((i) => i.kind === "queue").length; -} - -export function setRunState( - state: SessionQueueState, - run: RunState, -): SessionQueueState { - if (state.run === run) return state; - return { ...state, run }; -} - -/** - * Enqueue a mid-run message. Empty / whitespace-only is a no-op. - * When idle, still accepts into the queue bag for tests; product shell - * may route idle Enter as immediate send instead of calling this. - */ -export function enqueue( - state: SessionQueueState, - text: string, - kind: QueueKind = "queue", - now = Date.now(), - attachments?: readonly PendingImageAttachment[], -): SessionQueueState { - const t = text.trim(); - if ( - t.length === 0 && - (attachments === undefined || attachments.length === 0) - ) { - return state; - } - const item: QueueItem = { - id: `q${state.nextId}`, - text: t, - kind, - enqueuedAt: now, - ...(attachments !== undefined && attachments.length > 0 - ? { attachments } - : {}), - }; - return { - ...state, - items: [...state.items, item], - nextId: state.nextId + 1, - interruptFlash: false, - }; -} - -/** Steer = priority enqueue (same badge pool). */ -export function enqueueSteer( - state: SessionQueueState, - text: string, - now = Date.now(), - attachments?: readonly PendingImageAttachment[], -): SessionQueueState { - return enqueue(state, text, "steer", now, attachments); -} - -/** - * Hard interrupt: stop the run, keep everything the operator queued. Typing a - * correction and then interrupting so it lands sooner is the common shape of - * this gesture, so discarding the queue destroyed exactly the input the - * operator most wanted delivered. Pending items survive to the next drain - * boundary; only the run state and the flash change here. - */ -export function interrupt(state: SessionQueueState): SessionQueueState { - return { - ...state, - run: "idle", - interruptFlash: true, - }; -} - -export function clearInterruptFlash( - state: SessionQueueState, -): SessionQueueState { - if (!state.interruptFlash) return state; - return { ...state, interruptFlash: false }; -} - -/** - * Retract the most recently enqueued item, queue or steer alike. Last-only: - * an operator who wants an earlier item gone has no path here (see - * `applyShellCancelLast` for why that is the shipped scope, not an oversight). - */ -export function cancelLast(state: SessionQueueState): { - state: SessionQueueState; - item: QueueItem | null; -} { - const item = state.items[state.items.length - 1] ?? null; - if (item === null) return { state, item: null }; - return { - state: { ...state, items: state.items.slice(0, -1) }, - item, - }; -} - -/** - * Retract a specific item by id — the pending column's per-row drop, where the - * operator picked exactly which held message to kill rather than the newest. - */ -export function cancelItem( - state: SessionQueueState, - id: string, -): { state: SessionQueueState; item: QueueItem | null } { - const index = state.items.findIndex((item) => item.id === id); - const item = state.items[index] ?? null; - if (item === null) return { state, item: null }; - return { - state: { - ...state, - items: [...state.items.slice(0, index), ...state.items.slice(index + 1)], - }, - item, - }; -} - -/** Drain order: steers first (FIFO within class), then queue (FIFO). */ -export function drainOrder(state: SessionQueueState): readonly QueueItem[] { - const steers = state.items.filter((i) => i.kind === "steer"); - const queues = state.items.filter((i) => i.kind === "queue"); - return [...steers, ...queues]; -} - -/** - * Pop next delivery item. When `kind` is set, only that class (FIFO within - * class); otherwise full `drainOrder` (steer-first, then queue). - */ -export function drainOne( - state: SessionQueueState, - kind?: QueueKind, -): { state: SessionQueueState; item: QueueItem | null } { - const order = - kind === undefined - ? drainOrder(state) - : state.items.filter((i) => i.kind === kind); - const item = order[0] ?? null; - if (!item) return { state, item: null }; - return { - state: { - ...state, - items: state.items.filter((i) => i.id !== item.id), - }, - item, - }; -} - -/** Drain every pending soft-steer; leave follow-ups untouched. */ -export function drainSteersOnly(state: SessionQueueState): { - state: SessionQueueState; - drained: readonly QueueItem[]; -} { - const drained: QueueItem[] = []; - let current = state; - for (;;) { - const next = drainOne(current, "steer"); - if (!next.item) break; - drained.push(next.item); - current = next.state; - } - return { state: current, drained }; -} diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index c0d69cadf..f9d1896bc 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -18,7 +18,7 @@ import { } from "@opentui/core"; import { sliceTailToWidth, sliceToWidth, stringWidth } from "../view/height.js"; import { promptRowCount } from "../prompt-input.js"; -import { promptBoxRows } from "../prompt-rows.js"; +import { promptBoxRows } from "../geometry/zones.js"; import { composeNoticeLine, resolveWaitingOn } from "../notice-line.js"; import { fitPendingRow, @@ -70,7 +70,7 @@ import { setRunState, steerCount, type RunState, -} from "../session-queue.js"; +} from "../delivery-queue.js"; import { agentVoicesIn, isCollapsibleRow, diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index c49809da0..0e5a6df1b 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -25,7 +25,7 @@ import { import { destroySubtree } from "../teardown.js"; import { createRecordingClipboard } from "../copy-path.js"; import { copyFinishedSelection } from "../selection-copy.js"; -import { badgeCount, createSessionQueue, enqueue } from "../session-queue.js"; +import { badgeCount, createSessionQueue, enqueue } from "../delivery-queue.js"; import { UI } from "../theme.js"; import { createOverlayView, isDecisionOverlay } from "../overlay-view.js"; import { emptyKillRing } from "../prompt-kill-ring.js"; diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index 04ca94784..dd82ed958 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -31,7 +31,7 @@ import { type LandingAbove, type LandingBelowContent } from "../landing.js"; import { type PaletteCommand } from "../command-catalog.js"; import { type ObserveSession } from "../residuals.js"; import { type ClipboardPort, type CopyTarget } from "../copy-path.js"; -import { type RunState, type SessionQueueState } from "../session-queue.js"; +import { type RunState, type SessionQueueState } from "../delivery-queue.js"; import { type StreamRow } from "../stream.js"; import { createOverlayView } from "../overlay-view.js"; import { type KillRing } from "../prompt-kill-ring.js"; diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index 0e93cfd95..d78c1af64 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -7,7 +7,7 @@ import { type KeyEvent, type MouseEvent, } from "@opentui/core"; -import { badgeCount } from "../session-queue.js"; +import { badgeCount } from "../delivery-queue.js"; import { type AppShell, diff --git a/src/tui/shell/prompt.ts b/src/tui/shell/prompt.ts index 2daa6a8e0..837b3f628 100644 --- a/src/tui/shell/prompt.ts +++ b/src/tui/shell/prompt.ts @@ -31,7 +31,7 @@ import { enqueue, enqueueSteer, interrupt, -} from "../session-queue.js"; +} from "../delivery-queue.js"; import { UI } from "../theme.js"; import { diff --git a/src/tui/steer-worker-invariant.test.ts b/src/tui/steer-worker-invariant.test.ts index 913fb35fe..02b7d9584 100644 --- a/src/tui/steer-worker-invariant.test.ts +++ b/src/tui/steer-worker-invariant.test.ts @@ -9,7 +9,7 @@ import { describe, expect, test } from "bun:test"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; import { createAppShell } from "./shell/index"; import { withTestRenderer } from "./harness"; -import { badgeCount } from "./session-queue"; +import { badgeCount } from "./delivery-queue"; describe("CL-6291 worker-alive invariants", () => { test("busy Enter soft-steers: enqueue steer, never port.interrupt", async () => { diff --git a/src/tui/stream-event-map.ts b/src/tui/stream-event-map.ts index 0b11974bc..b4c734da2 100644 --- a/src/tui/stream-event-map.ts +++ b/src/tui/stream-event-map.ts @@ -19,7 +19,7 @@ import { type InferenceErrorLike, } from "../inference-gateway-error.js"; import { isProviderFailurePresentationSuppressed } from "./provider/failure-attempt.js"; -import type { RunState } from "./session-queue.js"; +import type { RunState } from "./delivery-queue.js"; import type { PendingAskWake } from "../subagent/fleet-report.js"; /** Canonical inbound events the bridge understands (fixtures + mapped reactor). */ diff --git a/src/tui/tool-args.ts b/src/tui/tool-args.ts index b3ba6debf..288bb2c7d 100644 --- a/src/tui/tool-args.ts +++ b/src/tui/tool-args.ts @@ -14,7 +14,9 @@ import { isMcpToolName } from "../mcp/tool-name.js"; import type { SemanticRole } from "./semantic-theme.js"; import { summarizeToolArgs } from "./tool-formatter.js"; -import { validateView, viewToLines, type ViewNode } from "./view/index.js"; +import { validateView } from "./view/validate.js"; +import { viewToLines } from "./view/lines.js"; +import type { ViewNode } from "./view/spec.js"; import type { StyledBodyLine } from "./stream.js"; import { UI } from "./theme.js"; diff --git a/src/tui/transcript-layout.test.ts b/src/tui/transcript-layout.test.ts index b5a0bd4db..1ef850d8f 100644 --- a/src/tui/transcript-layout.test.ts +++ b/src/tui/transcript-layout.test.ts @@ -3,7 +3,7 @@ * the screen, and that a right-aligned turn stops at the shared gutter. */ import { describe, expect, test } from "bun:test"; -import { resolveSideMargin } from "./geometry/margins"; +import { resolveSideMargin } from "./geometry/zones"; import { withTestRenderer } from "./harness"; import { appendStreamRow } from "./shell/chrome"; import { createAppShell } from "./shell/index"; diff --git a/src/tui/transcript-panels.test.ts b/src/tui/transcript-panels.test.ts index 21e77a6b9..67228472c 100644 --- a/src/tui/transcript-panels.test.ts +++ b/src/tui/transcript-panels.test.ts @@ -5,7 +5,7 @@ * gutter, and the reasoning line has to keep costing exactly one turn gap. */ import { describe, expect, test } from "bun:test"; -import { resolveContentWidth, resolveSideMargin } from "./geometry/margins"; +import { resolveContentWidth, resolveSideMargin } from "./geometry/zones"; import { withTestRenderer, type Harness } from "./harness"; import { appendStreamRow } from "./shell/chrome"; import { createAppShell } from "./shell/index"; diff --git a/src/tui/turns-to-blocks.ts b/src/tui/turns-to-blocks.ts index 65e10409c..b08a6471c 100644 --- a/src/tui/turns-to-blocks.ts +++ b/src/tui/turns-to-blocks.ts @@ -4,7 +4,8 @@ import type { } from "@intx/types/runtime"; import { isPersistedOccupancyWakeText } from "../subagent/mailbox-mail-drive.js"; -import { validateView, type ViewNode } from "./view/index.js"; +import { validateView } from "./view/validate.js"; +import type { ViewNode } from "./view/spec.js"; interface PlanBlockStep { file: string; diff --git a/src/tui/view/index.ts b/src/tui/view/index.ts deleted file mode 100644 index 33962c30e..000000000 --- a/src/tui/view/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type { ViewNode, Tone } from "./spec.js"; -export { validateView, type ViewValidation } from "./validate.js"; -export { viewToLines, type StyledLine, type ViewPalette } from "./lines.js"; diff --git a/src/tui/view/lines.ts b/src/tui/view/lines.ts index 01192a3ab..f1855d17b 100644 --- a/src/tui/view/lines.ts +++ b/src/tui/view/lines.ts @@ -1,9 +1,22 @@ import type { StyledSegment } from "../markdown-parser.js"; import { color, type SemanticRole } from "../semantic-theme.js"; import { wrapLines } from "./height.js"; -import type { ViewNode } from "./spec.js"; +import type { Tone, ViewNode } from "./spec.js"; import { VIEW_GRID_MAX_ROWS } from "./spec.js"; -import { GAP, PAD, toneRole, truncate } from "./registry.js"; + +const GAP = 2; +const PAD = 2; + +function toneRole(tone: Tone | undefined): SemanticRole | undefined { + if (tone === undefined || tone === "default") return undefined; + return tone; +} + +function truncate(text: string, width: number): string { + if (width <= 0) return ""; + if (text.length <= width) return text; + return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; +} export type StyledLine = StyledSegment[]; diff --git a/src/tui/view/registry.ts b/src/tui/view/registry.ts deleted file mode 100644 index e7897b3f7..000000000 --- a/src/tui/view/registry.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { SemanticRole } from "../semantic-theme.js"; -import type { Tone } from "./spec.js"; - -export const GAP = 2; -export const PAD = 2; - -export function toneRole(tone: Tone | undefined): SemanticRole | undefined { - if (tone === undefined || tone === "default") return undefined; - return tone; -} - -export function truncate(text: string, width: number): string { - if (width <= 0) return ""; - if (text.length <= width) return text; - return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; -} diff --git a/src/tui/welcome.ts b/src/tui/welcome.ts index 797c3bbfb..ecd984de9 100644 --- a/src/tui/welcome.ts +++ b/src/tui/welcome.ts @@ -29,7 +29,7 @@ import { MARK_SMALL, type MarkGrid, } from "./mark-shape.js"; -import { resolveSideMargin } from "./geometry/margins.js"; +import { resolveSideMargin } from "./geometry/zones.js"; import { destroySubtree } from "./teardown.js"; import { UI } from "./theme.js"; import { stringWidth } from "./view/height.js"; diff --git a/tests/unit/approval-resume.test.ts b/tests/unit/approval-resume.test.ts index 1d1eebc67..fc8ce1d92 100644 --- a/tests/unit/approval-resume.test.ts +++ b/tests/unit/approval-resume.test.ts @@ -20,12 +20,12 @@ import { createCorrelationAcceptance } from "../../src/tui/correlation-acceptanc import type { PermissionGateEvent } from "../../src/tui/gate-events.js"; import { createDeliveryGeneration, + createSessionOperationQueue, SESSION_IDENTITY_ABORT_REASON, -} from "../../src/tui/queued-delivery.js"; +} from "../../src/tui/delivery-queue.js"; import { createGateRequestApproval } from "../../src/tui/request-approval.js"; import { startInterruptRebuild } from "../../src/tui/runner/exit.js"; import { runWhileAgentBusy } from "../../src/tui/runner/state.js"; -import { createSessionOperationQueue } from "../../src/tui/session-operation-queue.js"; const SUSPENDED: SendResult = { type: "suspended", diff --git a/tests/unit/tui/approval-reload-during-suspend.test.ts b/tests/unit/tui/approval-reload-during-suspend.test.ts index aff0ea252..93b253c6e 100644 --- a/tests/unit/tui/approval-reload-during-suspend.test.ts +++ b/tests/unit/tui/approval-reload-during-suspend.test.ts @@ -5,7 +5,7 @@ import type { ConversationTurn } from "@intx/types/runtime"; import type { PermissionGate } from "../../../src/permission/gate.js"; import { createApprovalResume } from "../../../src/session/approval-resume.js"; -import { createSessionOperationQueue } from "../../../src/tui/session-operation-queue.js"; +import { createSessionOperationQueue } from "../../../src/tui/delivery-queue.js"; import { runWhileAgentBusy, type RunnerState, diff --git a/tests/unit/tui/runner.test.ts b/tests/unit/tui/runner.test.ts index fbe5b7373..fd97e037e 100644 --- a/tests/unit/tui/runner.test.ts +++ b/tests/unit/tui/runner.test.ts @@ -14,7 +14,7 @@ import { import { loadLocalSettingsWriteBase } from "../../../src/tui/runner/settings.js"; import { tuiSendFailureMessage } from "../../../src/tui/runner/send-failure-message.js"; import { defined } from "../../helpers/defined.js"; -import { createSessionOperationQueue } from "../../../src/tui/session-operation-queue.js"; +import { createSessionOperationQueue } from "../../../src/tui/delivery-queue.js"; import { createRunSink } from "../../../src/session/run-sink.js"; test("createTUIEventEmitter returns an EventEmitter", () => { @@ -247,14 +247,14 @@ test("a failed close followed by a lock error never surfaces as a raw AgentConte // standing up the full TUI runner — provider config, plugin discovery, MCP // wiring, and a real OpenTUI host. That is out of scope for this fix; it // would be its own extraction. What can be driven directly, and is exactly -// the failure this bug reports, is the real `session-operation-queue.ts` +// the failure this bug reports, is the real `delivery-queue.ts` // queue exercised the same way every rebuild site uses it: `void // enqueueOp(async () => { try { ... } catch (err) { fatalBuildError = ... } })`. // `enqueue` is `tail = tail.then(op, op); return tail;` — if `op` rejects and // nothing internally catches it, that returned promise is the only thing // that ever observes the rejection, and `void` discards it, which is // precisely how the unhandled rejection in the ticket escaped. -test("a rejecting reload op through the real session-operation-queue never triggers an unhandled rejection", async () => { +test("a rejecting reload op through the real delivery-queue never triggers an unhandled rejection", async () => { const { enqueue, awaitTail } = createSessionOperationQueue(); const agent = stubAgent(() => Promise.reject(new AgentContextLockError("/tmp/workdir")), diff --git a/tests/unit/tui/view-render.test.ts b/tests/unit/tui/view-render.test.ts index 9bb327070..de69d5a4f 100644 --- a/tests/unit/tui/view-render.test.ts +++ b/tests/unit/tui/view-render.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe } from "bun:test"; -import { viewToLines } from "../../../src/tui/view/index.js"; +import { viewToLines } from "../../../src/tui/view/lines.js"; import type { ViewNode } from "../../../src/tui/view/spec.js"; const textLines = (node: ViewNode, columns = 80): string[] => From 9ff99c98fa5df99c0cb72522219fe1b7c3182035 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 08:49:58 -0700 Subject: [PATCH 3/3] Wrap the overlong description bonus line in agent search --- src/agent/agent-search.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agent/agent-search.ts b/src/agent/agent-search.ts index 388c6b75a..af6666c71 100644 --- a/src/agent/agent-search.ts +++ b/src/agent/agent-search.ts @@ -36,7 +36,8 @@ export function createAgentIndex( lexicalFields(profile.id, profileSearchText(profile)), queryTokens, rawQuery, - ) + ((profile.description ?? "").toLowerCase().includes(rawQuery) ? 0.5 : 0); + ) + + ((profile.description ?? "").toLowerCase().includes(rawQuery) ? 0.5 : 0); return { search(query: string, limit = 12): AgentProfile[] {