diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..58e8989c6 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,24 @@ +# Copilot Instructions — singularity-forge + +See [CLAUDE.md](../CLAUDE.md) for build pipeline details and test commands. +See [AGENTS.md](../AGENTS.md) for planning conventions and spec-first TDD doctrine. + +## DB-first — non-negotiable + +All state lives in SQLite via Node's built-in `node:sqlite` (`DatabaseSync`). + +- **Never** use `better-sqlite3` or any native SQLite addon +- **Never** use file-based fallbacks for state that belongs in the DB (milestone context, sessions, memories, mode state, etc.) +- When checking if something "exists", query the DB — not the filesystem +- Sift indexes codebase files only; session/turn search uses FTS5 in `sf.db` + +If a pattern uses files as a proxy for DB state (e.g., checking for `CONTEXT.md` instead of a DB row), treat that as a bug to fix, not a convention to follow. + +## YOLO is a flag, not a mode + +SF has exactly **two work modes**: **Ask** and **Build**. + +- `Shift+Tab` cycles between Ask and Build +- **YOLO** (Ctrl+Y) is a flag layered on top of Build — it removes safety rails (no confirmations, no git prompts, full send) +- YOLO is never a Shift+Tab stop; it is not a third mode +- `/mode yolo` is equivalent to Ctrl+Y — it enables the flag, it doesn't switch modes diff --git a/.sf/backups/db/maintenance.json b/.sf/backups/db/maintenance.json index 6ed72f3c8..4d2c3a222 100644 --- a/.sf/backups/db/maintenance.json +++ b/.sf/backups/db/maintenance.json @@ -1,3 +1,3 @@ { - "lastFullVacuumAt": "2026-05-10T05:57:58.807Z" + "lastFullVacuumAt": "2026-05-10T13:59:26.619Z" } diff --git a/.sf/backups/db/sf.db.2026-05-10T11-10-21-954Z b/.sf/backups/db/sf.db.2026-05-10T11-10-21-954Z new file mode 100644 index 000000000..953e14884 Binary files /dev/null and b/.sf/backups/db/sf.db.2026-05-10T11-10-21-954Z differ diff --git a/.sf/backups/db/sf.db.2026-05-10T13-59-26-519Z b/.sf/backups/db/sf.db.2026-05-10T13-59-26-519Z new file mode 100644 index 000000000..953e14884 Binary files /dev/null and b/.sf/backups/db/sf.db.2026-05-10T13-59-26-519Z differ diff --git a/.sf/metrics.db-shm b/.sf/metrics.db-shm new file mode 100644 index 000000000..fe9ac2845 Binary files /dev/null and b/.sf/metrics.db-shm differ diff --git a/.sf/metrics.db-wal b/.sf/metrics.db-wal new file mode 100644 index 000000000..e69de29bb diff --git a/packages/coding-agent/src/cli/list-models.ts b/packages/coding-agent/src/cli/list-models.ts index 5e0e60981..8d056f7d7 100644 --- a/packages/coding-agent/src/cli/list-models.ts +++ b/packages/coding-agent/src/cli/list-models.ts @@ -3,7 +3,7 @@ */ import type { Api, Model } from "@singularity-forge/ai"; -import { fuzzyFilter } from "@singularity-forge/tui"; +import { fuzzyFilter } from "@singularity-forge/tui/fuzzy"; import { getDiscoverableProviders } from "../core/model-discovery.js"; import type { ModelRegistry } from "../core/model-registry.js"; diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index d3d2664f2..3fa82476e 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -363,6 +363,10 @@ function getAliases(): Record { "tui/dist/index.js", "@singularity-forge/tui", ), + "@singularity-forge/tui/fuzzy": resolveWorkspaceOrImport( + "tui/dist/fuzzy.js", + "@singularity-forge/tui/fuzzy", + ), "@singularity-forge/ai": resolveWorkspaceOrImport( "ai/dist/index.js", "@singularity-forge/ai", @@ -383,6 +387,10 @@ function getAliases(): Record { "tui/dist/index.js", "@singularity-forge/tui", ), + "@mariozechner/tui/fuzzy": resolveWorkspaceOrImport( + "tui/dist/fuzzy.js", + "@singularity-forge/tui/fuzzy", + ), "@mariozechner/pi-ai": resolveWorkspaceOrImport( "ai/dist/index.js", "@singularity-forge/ai", diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index ad817a3ed..fcc19eeae 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -1,8 +1,8 @@ import { type Model, modelsAreEqual } from "@singularity-forge/ai"; +import { fuzzyFilter } from "@singularity-forge/tui/fuzzy"; import { Container, type Focusable, - fuzzyFilter, getEditorKeybindings, Input, Spacer, diff --git a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts index f7e05b1ac..ab84c41a5 100644 --- a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -1,8 +1,8 @@ import type { Model } from "@singularity-forge/ai"; +import { fuzzyFilter } from "@singularity-forge/tui/fuzzy"; import { Container, type Focusable, - fuzzyFilter, getEditorKeybindings, Input, Key, diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts index 15225d14f..70c208b16 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts @@ -1,4 +1,4 @@ -import { fuzzyMatch } from "@singularity-forge/tui"; +import { fuzzyMatch } from "@singularity-forge/tui/fuzzy"; import type { SessionInfo } from "../../../core/session-manager.js"; export type SortMode = "threaded" | "recent" | "relevance"; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8162587ee..a5de30e24 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -26,11 +26,11 @@ import type { OverlayOptions, SlashCommand, } from "@singularity-forge/tui"; +import { fuzzyFilter } from "@singularity-forge/tui/fuzzy"; import { CombinedAutocompleteProvider, type Component, Container, - fuzzyFilter, Loader, Markdown, matchesKey, diff --git a/packages/coding-agent/src/modes/interactive/slash-command-handlers.ts b/packages/coding-agent/src/modes/interactive/slash-command-handlers.ts index 93f97f3f2..bfd0769ff 100644 --- a/packages/coding-agent/src/modes/interactive/slash-command-handlers.ts +++ b/packages/coding-agent/src/modes/interactive/slash-command-handlers.ts @@ -561,6 +561,7 @@ function handleHotkeysCommand(ctx: SlashCommandContext): void { const yank = getEditorKeyDisplay("yank"); const yankPop = getEditorKeyDisplay("yankPop"); const undo = getEditorKeyDisplay("undo"); + const redo = getEditorKeyDisplay("redo"); const tab = getEditorKeyDisplay("tab"); // App keybindings @@ -612,6 +613,7 @@ function handleHotkeysCommand(ctx: SlashCommandContext): void { | \`${yank}\` | Paste the most-recently-deleted text | | \`${yankPop}\` | Cycle through the deleted text after pasting | | \`${undo}\` | Undo | +| \`${redo}\` | Redo | **Other** | Key | Action | diff --git a/packages/tui/package.json b/packages/tui/package.json index 0c1dfb1f5..4a8d189c5 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -10,6 +10,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.js" + }, + "./fuzzy": { + "types": "./dist/fuzzy.d.ts", + "import": "./dist/fuzzy.js", + "require": "./dist/fuzzy.js" } }, "scripts": { diff --git a/packages/tui/src/__tests__/cell-size-drain.test.ts b/packages/tui/src/__tests__/cell-size-drain.test.ts new file mode 100644 index 000000000..871408753 --- /dev/null +++ b/packages/tui/src/__tests__/cell-size-drain.test.ts @@ -0,0 +1,31 @@ +// cell-size buffer drain — behaviour matches legacy TUI.parseCellSizeResponse + +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import { drainCellSizeQueryBuffer } from "../cell-size.js"; + +describe("drainCellSizeQueryBuffer", () => { + it("waits_on_incomplete_escape_tail", () => { + const r = drainCellSizeQueryBuffer("\x1b[6;10;10"); + assert.equal(r.wait, true); + assert.equal(r.nextBuffer, "\x1b[6;10;10"); + assert.equal(r.forwardInput, ""); + }); + + it("forwards_bare_escape_after_pending_buffer", () => { + const r = drainCellSizeQueryBuffer("\x1b"); + assert.equal(r.wait, false); + assert.equal(r.forwardInput, "\x1b"); + assert.equal(r.nextBuffer, ""); + }); + + it("strips_reported_cell_size_and_emits_cellPixels", () => { + const buf = "\x1b[6;20;40trest"; + const r = drainCellSizeQueryBuffer(buf); + assert.ok(r.cellPixels); + assert.equal(r.cellPixels?.widthPx, 40); + assert.equal(r.cellPixels?.heightPx, 20); + assert.equal(r.forwardInput, "rest"); + assert.equal(r.wait, false); + }); +}); diff --git a/packages/tui/src/__tests__/hardware-cursor.test.ts b/packages/tui/src/__tests__/hardware-cursor.test.ts new file mode 100644 index 000000000..80dac9917 --- /dev/null +++ b/packages/tui/src/__tests__/hardware-cursor.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import { syncHardwareCursorForIME } from "../hardware-cursor.js"; + +describe("syncHardwareCursorForIME", () => { + it("hides_cursor_when_no_marker", () => { + let hid = 0; + const term = { + hideCursor: () => { + hid++; + }, + showCursor: () => {}, + write: () => {}, + }; + const row = syncHardwareCursorForIME(term, { + cursorPos: null, + totalLines: 5, + hardwareCursorRow: 0, + showHardwareCursor: true, + }); + assert.equal(hid, 1); + assert.equal(row, 0); + }); + + it("writes_row_and_column_escapes", () => { + const writes: string[] = []; + const term = { + hideCursor: () => {}, + showCursor: () => {}, + write: (s: string) => { + writes.push(s); + }, + }; + const next = syncHardwareCursorForIME(term, { + cursorPos: { row: 2, col: 3 }, + totalLines: 10, + hardwareCursorRow: 0, + showHardwareCursor: false, + }); + assert.equal(next, 2); + const joined = writes.join(""); + assert.ok(joined.includes("\x1b[2B")); + assert.ok(joined.includes("\x1b[4G")); + }); +}); diff --git a/packages/tui/src/__tests__/viewport-sticky.test.ts b/packages/tui/src/__tests__/viewport-sticky.test.ts new file mode 100644 index 000000000..65f5b4120 --- /dev/null +++ b/packages/tui/src/__tests__/viewport-sticky.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import { + buildStickyScrollVerticalEscapes, + isStickyViewportAtBottom, +} from "../viewport-sticky.js"; + +describe("viewport-sticky", () => { + it("isStickyViewportAtBottom_matches_maxLinesRendering", () => { + assert.equal( + isStickyViewportAtBottom({ + maxLinesRendered: 100, + terminalRows: 24, + previousContentLineCount: 100, + }), + true, + ); + assert.equal( + isStickyViewportAtBottom({ + maxLinesRendered: 100, + terminalRows: 24, + previousContentLineCount: 150, + }), + false, + ); + }); + + it("buildStickyScrollVerticalEscapes_null_when_content_fits", () => { + assert.equal( + buildStickyScrollVerticalEscapes({ + contentLineCount: 10, + terminalRows: 24, + previousViewportTop: 0, + hardwareCursorRow: 0, + }), + null, + ); + }); + + it("buildStickyScrollVerticalEscapes_emits_down_moves", () => { + const r = buildStickyScrollVerticalEscapes({ + contentLineCount: 100, + terminalRows: 24, + previousViewportTop: 0, + hardwareCursorRow: 0, + }); + assert.ok(r); + assert.ok(r!.escapes.includes("B")); + assert.ok(r!.nextPreviousViewportTop > 0); + }); +}); diff --git a/packages/tui/src/cell-size.ts b/packages/tui/src/cell-size.ts index b4a6c621c..a629807f0 100644 --- a/packages/tui/src/cell-size.ts +++ b/packages/tui/src/cell-size.ts @@ -35,3 +35,75 @@ export function parseCellSizeResponse( export function stripCellSizeResponse(chunk: string): string { return chunk.replace(CELL_SIZE_PATTERN, ""); } + +/** + * Result of splitting stdin while a CSI 16 cell-size query response may arrive. + * + * Purpose: encapsulate buffering policy (bare Escape, incomplete CSI tails) while + * keeping `parseCellSizeResponse` / `stripCellSizeResponse` reusable as pure parsers. + * + * Consumer: `TUI.handleInput` when `cellSizeQueryPending` — applies `cellPixels` via + * `setCellDimensions` when present, then honours `wait` / `forwardInput`. + */ +export interface DrainCellSizeQueryBufferResult { + /** Stored buffer state for subsequent chunks */ + nextBuffer: string; + /** + * When the terminal reported pixel cell dimensions — caller applies once (`setCellDimensions`) + * before `invalidate` / `requestRender`. + */ + cellPixels?: { widthPx: number; heightPx: number }; + /** + * When true — no keystrokes forwarded yet (partial escape still accumulating). + */ + wait: boolean; + /** Keyboard input unlocked from the pending buffer once `wait` is false */ + forwardInput: string; +} + +/** + * Consume stdin buffered after CSI 16 t was sent: extract optional pixel cell size, + * bare Escape forwarding, incomplete sequence detection (legacy `parseCellSizeResponse`). + */ +export function drainCellSizeQueryBuffer( + buffer: string, +): DrainCellSizeQueryBufferResult { + let cellPixels: DrainCellSizeQueryBufferResult["cellPixels"]; + let buf = buffer; + + const parsed = parseCellSizeResponse(buf); + if (parsed) { + const { height, width } = parsed; + cellPixels = { widthPx: width, heightPx: height }; + buf = stripCellSizeResponse(buf); + } + + if (buf === "\x1b") { + return { + nextBuffer: "", + cellPixels, + wait: false, + forwardInput: "\x1b", + }; + } + + const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/; + if (partialCellSizePattern.test(buf)) { + const lastChar = buf.length > 0 ? buf[buf.length - 1]! : ""; + if (!/[a-zA-Z~]/.test(lastChar)) { + return { + nextBuffer: buf, + cellPixels, + wait: true, + forwardInput: "", + }; + } + } + + return { + nextBuffer: "", + cellPixels, + wait: false, + forwardInput: buf, + }; +} diff --git a/packages/tui/src/components/__tests__/editor.test.ts b/packages/tui/src/components/__tests__/editor.test.ts index 850ddda45..42969c32a 100644 --- a/packages/tui/src/components/__tests__/editor.test.ts +++ b/packages/tui/src/components/__tests__/editor.test.ts @@ -78,4 +78,21 @@ describe("Editor", () => { assert.equal(editor.getText(), ""); }); + + it("redo_restores_after_undo_via_ctrl_shift_z_CSI_u", () => { + const editor = new Editor(new TUI(makeTerminal()), theme); + editor.focused = true; + editor.setText(""); + + for (const ch of "hello") { + editor.handleInput(ch); + } + assert.equal(editor.getText(), "hello"); + + editor.handleInput("\x1f"); // ctrl+- + assert.equal(editor.getText(), ""); + + editor.handleInput("\x1b[122;6u"); // ctrl+shift+z (Kitty CSI-u) + assert.equal(editor.getText(), "hello"); + }); }); diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 2975626f3..0e0e75b72 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -50,9 +50,8 @@ export class Input implements Component, Focusable { private killRing = new KillRing(); private lastAction: "kill" | "yank" | "type-word" | null = null; - // Undo support + // Undo / redo — single UndoStack matches Editor semantics (undo-stack.ts redo API). private undoStack = new UndoStack(); - private redoStack = new UndoStack(); getValue(): string { return this.value; @@ -376,23 +375,22 @@ export class Input implements Component, Focusable { } private pushUndo(): void { - this.redoStack.clear(); this.undoStack.push({ value: this.value, cursor: this.cursor }); } private undo(): void { const snapshot = this.undoStack.pop(); if (!snapshot) return; - this.redoStack.push({ value: this.value, cursor: this.cursor }); + this.undoStack.pushRedo({ value: this.value, cursor: this.cursor }); this.value = snapshot.value; this.cursor = snapshot.cursor; this.lastAction = null; } private redo(): void { - const snapshot = this.redoStack.pop(); + const snapshot = this.undoStack.redo(); if (!snapshot) return; - this.undoStack.push({ value: this.value, cursor: this.cursor }); + this.undoStack.pushUndo({ value: this.value, cursor: this.cursor }); this.value = snapshot.value; this.cursor = snapshot.cursor; this.lastAction = null; diff --git a/packages/tui/src/hardware-cursor.ts b/packages/tui/src/hardware-cursor.ts new file mode 100644 index 000000000..15dc37593 --- /dev/null +++ b/packages/tui/src/hardware-cursor.ts @@ -0,0 +1,55 @@ +import type { Terminal } from "./terminal.js"; + +/** Logical cursor row/col extracted from overlay-stripped rendered lines */ +export type HardwareCursorLogicalPos = { row: number; col: number }; + +/** + * Move the terminal cursor so IME anchors to the rendered logical cursor (`CURSOR_MARKER`). + * + * Purpose: IME placement stays testable without the differential render pipeline. + * + * Consumer: `TUI.doRender` after marker strip (`extractCursorPosition`). + * + * Returns the next logical hardware cursor row used for incremental cursor-move math. + */ +export function syncHardwareCursorForIME( + terminal: Pick, + options: { + cursorPos: HardwareCursorLogicalPos | null; + totalLines: number; + hardwareCursorRow: number; + showHardwareCursor: boolean; + }, +): number { + const { cursorPos, totalLines, hardwareCursorRow, showHardwareCursor } = + options; + + if (!cursorPos || totalLines <= 0) { + terminal.hideCursor(); + return hardwareCursorRow; + } + + const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1)); + const targetCol = Math.max(0, cursorPos.col); + + const rowDelta = targetRow - hardwareCursorRow; + let buffer = ""; + if (rowDelta > 0) { + buffer += `\x1b[${rowDelta}B`; + } else if (rowDelta < 0) { + buffer += `\x1b[${-rowDelta}A`; + } + buffer += `\x1b[${targetCol + 1}G`; + + if (buffer) { + terminal.write(buffer); + } + + if (showHardwareCursor) { + terminal.showCursor(); + } else { + terminal.hideCursor(); + } + + return targetRow; +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 62627adc2..ff8f8ac13 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -46,6 +46,8 @@ export { Text } from "./components/text.js"; export { TruncatedText } from "./components/truncated-text.js"; // Editor component interface (for custom editors) export type { EditorComponent } from "./editor-component.js"; +// Fuzzy helpers are imported from `@singularity-forge/tui/fuzzy` (package subpath — +// avoids barrel regressions dropping exports). See package.json `"exports"` map. // Ink bridge — gradual migration infrastructure export { startInkRenderer } from "./ink-bridge.js"; // Keybindings @@ -72,6 +74,8 @@ export { } from "./keys.js"; // Render safety — prevents one failing component from blanking the TUI export { tryRender } from "./render-guard.js"; +// TUI input listener — used with `TUI.addInputListener` +export type { InputListener } from "./tui-input-dispatch.js"; // Input buffering for batch splitting export { StdinBuffer, diff --git a/packages/tui/src/overlay-layout.ts b/packages/tui/src/overlay-layout.ts index e01520f0a..0110f0f57 100644 --- a/packages/tui/src/overlay-layout.ts +++ b/packages/tui/src/overlay-layout.ts @@ -5,9 +5,13 @@ * positions and composite overlay content onto base terminal lines. */ +import type { + OverlayAnchor, + OverlayOptions, + SizeValue, +} from "./overlay-types.js"; import { tryRender } from "./render-guard.js"; import { isImageLine } from "./terminal-image.js"; -import type { OverlayAnchor, OverlayOptions, SizeValue } from "./tui.js"; import { CURSOR_MARKER } from "./tui.js"; import { applyBackgroundToLine, diff --git a/packages/tui/src/overlay-types.ts b/packages/tui/src/overlay-types.ts new file mode 100644 index 000000000..bc9ac00f8 --- /dev/null +++ b/packages/tui/src/overlay-types.ts @@ -0,0 +1,90 @@ +/** + * Overlay sizing, anchoring, and handle types for layered TUI content. + */ + +/** Margin configuration for overlays */ +export interface OverlayMargin { + top?: number; + right?: number; + bottom?: number; + left?: number; +} + +/** Value that can be absolute (number) or percentage (string like "50%") */ +export type SizeValue = number | `${number}%`; + +/** + * Anchor position for overlays + */ +export type OverlayAnchor = + | "center" + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "top-center" + | "bottom-center" + | "left-center" + | "right-center"; + +/** + * Options for overlay positioning and sizing. + * Values can be absolute numbers or percentage strings (e.g., "50%"). + */ +export interface OverlayOptions { + // === Sizing === + /** Width in columns, or percentage of terminal width (e.g., "50%") */ + width?: SizeValue; + /** Minimum width in columns */ + minWidth?: number; + /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */ + maxHeight?: SizeValue; + + // === Positioning - anchor-based === + /** Anchor point for positioning (default: 'center') */ + anchor?: OverlayAnchor; + /** Horizontal offset from anchor position (positive = right) */ + offsetX?: number; + /** Vertical offset from anchor position (positive = down) */ + offsetY?: number; + + // === Positioning - percentage or absolute === + /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */ + row?: SizeValue; + /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */ + col?: SizeValue; + + // === Margin from terminal edges === + /** Margin from terminal edges. Number applies to all sides. */ + margin?: OverlayMargin | number; + + // === Visibility === + /** + * Control overlay visibility based on terminal dimensions. + * If provided, overlay is only rendered when this returns true. + * Called each render cycle with current terminal dimensions. + */ + visible?: (termWidth: number, termHeight: number) => boolean; + /** If true, don't capture keyboard focus when shown */ + nonCapturing?: boolean; + /** If true, dim the background behind the overlay */ + backdrop?: boolean; +} + +/** + * Handle returned by showOverlay for controlling the overlay + */ +export interface OverlayHandle { + /** Permanently remove the overlay (cannot be shown again) */ + hide(): void; + /** Temporarily hide or show the overlay */ + setHidden(hidden: boolean): void; + /** Check if this overlay temporarily hidden */ + isHidden(): boolean; + /** Focus this overlay and bring it to the visual front */ + focus(): void; + /** Release focus to the previous target */ + unfocus(): void; + /** Check if this overlay currently has focus */ + isFocused(): boolean; +} diff --git a/packages/tui/src/tui-input-dispatch.ts b/packages/tui/src/tui-input-dispatch.ts new file mode 100644 index 000000000..12e9fa64c --- /dev/null +++ b/packages/tui/src/tui-input-dispatch.ts @@ -0,0 +1,34 @@ +/** + * Small pure helpers for TUI stdin routing (`TUI.handleInput`). + * + * Purpose: keep listener fan-out isolated from viewport / overlay routing so + * `tui.ts` stays readable without changing behaviour. + */ + +export type InputListenerResult = + | { consume?: boolean; data?: string } + | undefined; +export type InputListener = (data: string) => InputListenerResult; + +/** + * Run stdin through each listener in insertion order until one consumes or + * rewrites `data`. + * + * Returns `undefined` when a listener consumes the chunk (caller should exit). + */ +export function applyInputListeners( + listeners: Set, + intake: string, +): string | undefined { + let current = intake; + for (const listener of listeners) { + const result = listener(current); + if (result?.consume) { + return undefined; + } + if (result?.data !== undefined) { + current = result.data; + } + } + return current.length === 0 ? undefined : current; +} diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 593a079e2..2c5f916ca 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -5,14 +5,12 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { - parseCellSizeResponse as parseCellSizeResponseFn, - stripCellSizeResponse, -} from "./cell-size.js"; +import { drainCellSizeQueryBuffer } from "./cell-size.js"; import { type AutonomousModeStatus, AutonomousStatusBar, } from "./components/autonomous-status-bar.js"; +import { syncHardwareCursorForIME } from "./hardware-cursor.js"; import { startInkRenderer } from "./ink-bridge.js"; import { isKeyRelease, matchesKey } from "./keys.js"; import { @@ -21,6 +19,7 @@ import { extractCursorPosition, isOverlayVisible as isOverlayEntryVisible, } from "./overlay-layout.js"; +import type { OverlayHandle, OverlayOptions } from "./overlay-types.js"; import { tryRender } from "./render-guard.js"; import type { Terminal } from "./terminal.js"; import { @@ -28,7 +27,15 @@ import { isImageLine, setCellDimensions, } from "./terminal-image.js"; +import { + applyInputListeners, + type InputListener, +} from "./tui-input-dispatch.js"; import { truncateToWidth, visibleWidth } from "./utils.js"; +import { + buildStickyScrollVerticalEscapes, + isStickyViewportAtBottom, +} from "./viewport-sticky.js"; /** * Component interface - all components must implement this @@ -59,9 +66,6 @@ export interface Component { invalidate(): void; } -type InputListenerResult = { consume?: boolean; data?: string } | undefined; -type InputListener = (data: string) => InputListenerResult; - /** * Interface for components that can receive focus and display a hardware cursor. * When focused, the component should emit CURSOR_MARKER at the cursor position @@ -88,97 +92,15 @@ export function isFocusable( */ export const CURSOR_MARKER = "\x1b_sf:c\x07"; +export type { + OverlayAnchor, + OverlayHandle, + OverlayMargin, + OverlayOptions, + SizeValue, +} from "./overlay-types.js"; export { visibleWidth }; -/** - * Anchor position for overlays - */ -export type OverlayAnchor = - | "center" - | "top-left" - | "top-right" - | "bottom-left" - | "bottom-right" - | "top-center" - | "bottom-center" - | "left-center" - | "right-center"; - -/** - * Margin configuration for overlays - */ -export interface OverlayMargin { - top?: number; - right?: number; - bottom?: number; - left?: number; -} - -/** Value that can be absolute (number) or percentage (string like "50%") */ -export type SizeValue = number | `${number}%`; - -/** - * Options for overlay positioning and sizing. - * Values can be absolute numbers or percentage strings (e.g., "50%"). - */ -export interface OverlayOptions { - // === Sizing === - /** Width in columns, or percentage of terminal width (e.g., "50%") */ - width?: SizeValue; - /** Minimum width in columns */ - minWidth?: number; - /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */ - maxHeight?: SizeValue; - - // === Positioning - anchor-based === - /** Anchor point for positioning (default: 'center') */ - anchor?: OverlayAnchor; - /** Horizontal offset from anchor position (positive = right) */ - offsetX?: number; - /** Vertical offset from anchor position (positive = down) */ - offsetY?: number; - - // === Positioning - percentage or absolute === - /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */ - row?: SizeValue; - /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */ - col?: SizeValue; - - // === Margin from terminal edges === - /** Margin from terminal edges. Number applies to all sides. */ - margin?: OverlayMargin | number; - - // === Visibility === - /** - * Control overlay visibility based on terminal dimensions. - * If provided, overlay is only rendered when this returns true. - * Called each render cycle with current terminal dimensions. - */ - visible?: (termWidth: number, termHeight: number) => boolean; - /** If true, don't capture keyboard focus when shown */ - nonCapturing?: boolean; - /** If true, dim the background behind the overlay */ - backdrop?: boolean; -} - -/** - * Handle returned by showOverlay for controlling the overlay - */ -export interface OverlayHandle { - /** Permanently remove the overlay (cannot be shown again) */ - hide(): void; - /** Temporarily hide or show the overlay */ - setHidden(hidden: boolean): void; - /** Check if overlay is temporarily hidden */ - isHidden(): boolean; - /** Focus this overlay and bring it to the visual front */ - focus(): void; - /** Release focus to the previous target */ - unfocus(): void; - /** Check if this overlay currently has focus */ - isFocused(): boolean; -} - /** * Container - a component that contains other components */ @@ -605,34 +527,32 @@ export class TUI extends Container { * Check if user is scrolled to the bottom of the content */ private isAtBottom(): boolean { - const height = this.terminal.rows; - const viewportTop = Math.max(0, this.maxLinesRendered - height); - const viewportBottom = viewportTop + height; - return viewportBottom >= this.previousLines.length; + return isStickyViewportAtBottom({ + maxLinesRendered: this.maxLinesRendered, + terminalRows: this.terminal.rows, + previousContentLineCount: this.previousLines.length, + }); } /** * Scroll to bottom of content (sticky bottom) */ private scrollToBottom(): void { - const height = this.terminal.rows; - const contentHeight = this.previousLines.length; - if (contentHeight <= height) return; // No scrolling needed if content fits in viewport - - // For terminal scrolling, we can use cursor movement or scroll sequences - // The simplest approach is to move the cursor to the bottom line - const viewportTop = Math.max(0, contentHeight - height); - const targetScreenRow = contentHeight - 1; - const currentScreenRow = this.hardwareCursorRow - this.previousViewportTop; - const lineDiff = targetScreenRow - currentScreenRow; - - if (lineDiff > 0) { - this.terminal.write(`\x1b[${lineDiff}B`); // Move cursor down - } else if (lineDiff < 0) { - this.terminal.write(`\x1b[${-lineDiff}A`); // Move cursor up + const sticky = buildStickyScrollVerticalEscapes({ + contentLineCount: this.previousLines.length, + terminalRows: this.terminal.rows, + previousViewportTop: this.previousViewportTop, + hardwareCursorRow: this.hardwareCursorRow, + }); + if (!sticky) { + return; } - this.previousViewportTop = viewportTop; + const { escapes, nextPreviousViewportTop } = sticky; + if (escapes) { + this.terminal.write(escapes); + } + this.previousViewportTop = nextPreviousViewportTop; this.isScrolledToBottom = true; } @@ -644,39 +564,84 @@ export class TUI extends Container { this.requestRender(); } - private handleInput(data: string): void { - if (this.inputListeners.size > 0) { - let current = data; - for (const listener of this.inputListeners) { - const result = listener(current); - if (result?.consume) { - return; - } - if (result?.data !== undefined) { - current = result.data; - } + private flushCellSizeBufferedInput(chunk: string): string | undefined { + this.inputBuffer += chunk; + const drained = drainCellSizeQueryBuffer(this.inputBuffer); + this.inputBuffer = drained.nextBuffer; + + if (drained.cellPixels) { + setCellDimensions(drained.cellPixels); + this.invalidate(); + this.requestRender(); + } + + if (drained.wait) { + return undefined; + } + + this.cellSizeQueryPending = false; + if (drained.forwardInput.length === 0) { + return undefined; + } + return drained.forwardInput; + } + + private repairFocusedOverlayVisibility(): void { + const focusedOverlay = this.overlayStack.find( + (o) => o.component === this.focusedComponent, + ); + if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) { + const topVisible = this.getTopmostVisibleOverlay(); + if (topVisible) { + this.setFocus(topVisible.component); + } else { + this.setFocus(focusedOverlay.preFocus); } - if (current.length === 0) { + } + } + + private maybeHandleEnterAsScrollToBottom(data: string): boolean { + if (data !== "\r" && data !== "\n") { + return false; + } + if (!this.isAtBottom()) { + this.scrollToBottom(); + return true; + } + return false; + } + + private dispatchFocusedComponentInput(data: string): void { + if (!this.focusedComponent?.handleInput) { + return; + } + if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) { + return; + } + this.focusedComponent.handleInput(data); + this.requestRender(); + } + + private handleInput(data: string): void { + const afterListeners = applyInputListeners(this.inputListeners, data); + if (afterListeners === undefined) { + return; + } + data = afterListeners; + + if (this.cellSizeQueryPending) { + const released = this.flushCellSizeBufferedInput(data); + if (released === undefined) { return; } - data = current; + data = released; } - // If we're waiting for cell size response, buffer input and parse - if (this.cellSizeQueryPending) { - this.inputBuffer += data; - const filtered = this.parseCellSizeResponse(); - if (filtered.length === 0) return; - data = filtered; - } - - // Global debug key handler (Shift+Ctrl+D) if (matchesKey(data, "shift+ctrl+d") && this.onDebug) { this.onDebug(); return; } - // Detect scrolling keys (Page Up/Down, arrow keys) to break sticky bottom if ( this.isScrolledToBottom && (matchesKey(data, "pageUp") || matchesKey(data, "up")) @@ -684,88 +649,13 @@ export class TUI extends Container { this.isScrolledToBottom = false; } - // If focused component is an overlay, verify it's still visible - // (visibility can change due to terminal resize or visible() callback) - const focusedOverlay = this.overlayStack.find( - (o) => o.component === this.focusedComponent, - ); - if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) { - // Focused overlay is no longer visible, redirect to topmost visible overlay - const topVisible = this.getTopmostVisibleOverlay(); - if (topVisible) { - this.setFocus(topVisible.component); - } else { - // No visible overlays, restore to preFocus - this.setFocus(focusedOverlay.preFocus); - } + this.repairFocusedOverlayVisibility(); + + if (this.maybeHandleEnterAsScrollToBottom(data)) { + return; } - // Enter key scrolling behavior: if not at bottom, scroll down instead of sending input - if (data === "\r" || data === "\n") { - // Enter key - if (!this.isAtBottom()) { - // Scroll down one page or to bottom - this.scrollToBottom(); - return; - } - // If we're at bottom, let Enter pass through to focused component - } - - // Pass input to focused component (including Ctrl+C) - // The focused component can decide how to handle Ctrl+C - if (this.focusedComponent?.handleInput) { - // Filter out key release events unless component opts in - if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) { - return; - } - this.focusedComponent.handleInput(data); - this.requestRender(); - } - } - - private parseCellSizeResponse(): string { - // Response format: ESC [ 6 ; height ; width t — parsed via cell-size.ts - const parsed = parseCellSizeResponseFn(this.inputBuffer); - - if (parsed) { - const { height: heightPx, width: widthPx } = parsed; - setCellDimensions({ widthPx, heightPx }); - // Invalidate all components so images re-render with correct dimensions - this.invalidate(); - this.requestRender(); - - // Remove the response from buffer - this.inputBuffer = stripCellSizeResponse(this.inputBuffer); - this.cellSizeQueryPending = false; - } - - // Don't hold a bare Escape keypress hostage while waiting for the - // optional cell-size response. This is the most common early input race. - if (this.inputBuffer === "\x1b") { - const result = this.inputBuffer; - this.inputBuffer = ""; - this.cellSizeQueryPending = false; - return result; - } - - // Check if we have a partial cell size response starting (wait for more data) - // Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet) - const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/; - if (partialCellSizePattern.test(this.inputBuffer)) { - // Check if it's actually a complete different escape sequence (ends with a letter) - // Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc. - const lastChar = this.inputBuffer[this.inputBuffer.length - 1]; - if (!/[a-zA-Z~]/.test(lastChar)) { - // Doesn't end with a terminator, might be incomplete - wait for more - return ""; - } - } - - // No cell size response found, return buffered data as user input - const result = this.inputBuffer; - this.inputBuffer = ""; - this.cellSizeQueryPending = false; // Give up waiting - return result; + this.dispatchFocusedComponentInput(data); } private doRender(): void { @@ -854,7 +744,7 @@ export class TUI extends Container { ); } this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); - this.positionHardwareCursor(cursorPos, newLines.length); + this.applyIMEHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousWidth = width; this.previousHeight = height; @@ -947,7 +837,7 @@ export class TUI extends Container { // No changes - but still need to update hardware cursor position if it moved if (firstChanged === -1) { - this.positionHardwareCursor(cursorPos, newLines.length); + this.applyIMEHardwareCursor(cursorPos, newLines.length); this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); this.previousHeight = height; return; @@ -985,7 +875,7 @@ export class TUI extends Container { this.cursorRow = targetRow; this.hardwareCursorRow = targetRow; } - this.positionHardwareCursor(cursorPos, newLines.length); + this.applyIMEHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousWidth = width; this.previousHeight = height; @@ -1124,7 +1014,7 @@ export class TUI extends Container { } // Position hardware cursor for IME - this.positionHardwareCursor(cursorPos, newLines.length); + this.applyIMEHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousWidth = width; @@ -1138,43 +1028,17 @@ export class TUI extends Container { } /** - * Position the hardware cursor for IME candidate window. - * @param cursorPos The cursor position extracted from rendered output, or null - * @param totalLines Total number of rendered lines + * Position the hardware cursor for IME (`CURSOR_MARKER` anchor). */ - private positionHardwareCursor( + private applyIMEHardwareCursor( cursorPos: { row: number; col: number } | null, totalLines: number, ): void { - if (!cursorPos || totalLines <= 0) { - this.terminal.hideCursor(); - return; - } - - // Clamp cursor position to valid range - const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1)); - const targetCol = Math.max(0, cursorPos.col); - - // Move cursor from current position to target - const rowDelta = targetRow - this.hardwareCursorRow; - let buffer = ""; - if (rowDelta > 0) { - buffer += `\x1b[${rowDelta}B`; // Move down - } else if (rowDelta < 0) { - buffer += `\x1b[${-rowDelta}A`; // Move up - } - // Move to absolute column (1-indexed) - buffer += `\x1b[${targetCol + 1}G`; - - if (buffer) { - this.terminal.write(buffer); - } - - this.hardwareCursorRow = targetRow; - if (this.showHardwareCursor) { - this.terminal.showCursor(); - } else { - this.terminal.hideCursor(); - } + this.hardwareCursorRow = syncHardwareCursorForIME(this.terminal, { + cursorPos, + totalLines, + hardwareCursorRow: this.hardwareCursorRow, + showHardwareCursor: this.showHardwareCursor, + }); } } diff --git a/packages/tui/src/viewport-sticky.ts b/packages/tui/src/viewport-sticky.ts new file mode 100644 index 000000000..5a79a2e3a --- /dev/null +++ b/packages/tui/src/viewport-sticky.ts @@ -0,0 +1,57 @@ +/** + * Viewport calculations for sticky-bottom scrolling in the differential TUI renderer. + * + * Purpose: isolate arithmetic used by sticky-bottom scrolling so behaviour stays + * explicit and easier to regression-test. + */ + +/** + * Whether the bottom of the content is visible in the working viewport (`TUI.isAtBottom`). + */ +export function isStickyViewportAtBottom(params: { + maxLinesRendered: number; + terminalRows: number; + previousContentLineCount: number; +}): boolean { + const { maxLinesRendered, terminalRows, previousContentLineCount } = params; + const height = terminalRows; + const viewportTop = Math.max(0, maxLinesRendered - height); + const viewportBottom = viewportTop + height; + return viewportBottom >= previousContentLineCount; +} + +/** + * CSI escapes to reposition the cursor vertically for sticky-bottom (`TUI.scrollToBottom`). + */ +export function buildStickyScrollVerticalEscapes(params: { + contentLineCount: number; + terminalRows: number; + previousViewportTop: number; + hardwareCursorRow: number; +}): { escapes: string; nextPreviousViewportTop: number } | null { + const { + contentLineCount, + terminalRows, + previousViewportTop, + hardwareCursorRow, + } = params; + const height = terminalRows; + + if (contentLineCount <= height) { + return null; + } + + const viewportTop = Math.max(0, contentLineCount - height); + const targetScreenRow = contentLineCount - 1; + const currentScreenRow = hardwareCursorRow - previousViewportTop; + const lineDiff = targetScreenRow - currentScreenRow; + + let escapes = ""; + if (lineDiff > 0) { + escapes += `\x1b[${lineDiff}B`; + } else if (lineDiff < 0) { + escapes += `\x1b[${-lineDiff}A`; + } + + return { escapes, nextPreviousViewportTop: viewportTop }; +} diff --git a/scripts/copy-resources.cjs b/scripts/copy-resources.cjs index 65189ebfc..74d697e8e 100644 --- a/scripts/copy-resources.cjs +++ b/scripts/copy-resources.cjs @@ -75,7 +75,11 @@ function hasTsFilesRecursive(dir) { const hasTsFiles = hasTsFilesRecursive(srcResources); if (hasTsFiles) { - const tsgoBin = require.resolve("@typescript/native-preview/bin/tsgo.js"); + const tsgoBin = join( + dirname(require.resolve("@typescript/native-preview/package.json")), + "bin", + "tsgo.js", + ); const compile = spawnSync( process.execPath, [tsgoBin, "--project", resourcesTsconfig], diff --git a/src/resources/extensions/sf/skills/dispatching-subagents/SKILL.md b/src/resources/extensions/sf/skills/dispatching-subagents/SKILL.md index fc5e73ef8..14ec679c8 100644 --- a/src/resources/extensions/sf/skills/dispatching-subagents/SKILL.md +++ b/src/resources/extensions/sf/skills/dispatching-subagents/SKILL.md @@ -19,6 +19,7 @@ This skill is sf-internal only. **Do not** shell out to external `claude`, `code | Multi-stakeholder critique of a milestone roadmap | Debate mode or parallel swarm: PM / User / Combatant / Architect / Specialist (5–8 subagents). | | Pre-execution gate evaluation | sf's built-in Q3 / Q4 gates — already wired in `gate-evaluate.md`. | | Post-execution milestone review | sf's `validate-milestone` — already 3 parallel reviewers. | +| Iterative refinement of a result without re-briefing | Background single + `write_subagent` / `read_subagent` (multi-turn). | Don't dispatch a subagent for tasks the parent agent can do in 2–3 tool calls. Subagent overhead beats parent-agent work only when the task is large enough or the parallelism actually buys something. @@ -170,6 +171,35 @@ Use `model` only when you need an explicit override: { agent: "reviewer", task: "...", model: "claude-sonnet-4-5" } ``` +### Multi-turn background (write_subagent / read_subagent) + +Single-mode background jobs support iterative refinement without re-establishing context. + +``` +// Turn 0: launch +subagent({ agent: "researcher", task: "Analyse X. Return findings.", background: true }) +// → "Background job started: sub_a1b2c3d4" + +// After await_subagent returns: +write_subagent({ job_id: "sub_a1b2c3d4", message: "Focus on the Y aspect and expand." }) +// → "Follow-up dispatched on sub_a1b2c3d4 (turn 2). Use await_subagent to get the response." + +// After second await_subagent: +read_subagent({ job_id: "sub_a1b2c3d4" }) +// → Full turn history: agent (turn 0), user (turn 1), agent (turn 2) + +// Read only new turns: +read_subagent({ job_id: "sub_a1b2c3d4", since_turn: 2 }) +``` + +**How it works:** The subagent receives the original task + full conversation history + new message. The agent retains the role/context of the original dispatch — no re-briefing needed. + +**Constraints:** +- Only available for single-mode jobs (`agent` + `task`, no `tasks`/`chain`). +- `write_subagent` on a running job is rejected — wait for completion first. +- Counts against the same `maxRunning` concurrency limit as new jobs. +- The conversation history is injected into the task string. For very long conversations (10+ turns, multi-KB each), prefer starting a fresh subagent with a synthesised briefing to avoid hitting OS argument length limits. + ## Patterns ### Pattern 1 — Parallel research diff --git a/src/resources/extensions/sf/tests/dist-redirect.mjs b/src/resources/extensions/sf/tests/dist-redirect.mjs index cee788358..3468a6027 100644 --- a/src/resources/extensions/sf/tests/dist-redirect.mjs +++ b/src/resources/extensions/sf/tests/dist-redirect.mjs @@ -25,6 +25,8 @@ export function resolve(specifier, context, nextResolve) { specifier = new URL("packages/agent-core/src/index.ts", ROOT).href; } else if (specifier === "@singularity-forge/tui") { specifier = new URL("packages/tui/src/index.ts", ROOT).href; + } else if (specifier === "@singularity-forge/tui/fuzzy") { + specifier = new URL("packages/tui/src/fuzzy.ts", ROOT).href; } else if (specifier === "@singularity-forge/native") { specifier = new URL("packages/native/src/index.ts", ROOT).href; } else if (specifier.startsWith("@singularity-forge/native/")) { @@ -53,12 +55,19 @@ export function resolve(specifier, context, nextResolve) { } else { candidate = specifier.replace(/\.js$/, ".ts"); } - // Only rewrite if the .ts file exists - const candidatePath = fileURLToPath( + const candidatePathTs = fileURLToPath( new URL(candidate, context.parentURL), ); - if (existsSync(candidatePath)) { + if (existsSync(candidatePathTs)) { specifier = candidate; + } else { + const candidateTsx = candidate.replace(/\.ts$/, ".tsx"); + const candidatePathTsx = fileURLToPath( + new URL(candidateTsx, context.parentURL), + ); + if (existsSync(candidatePathTsx)) { + specifier = candidateTsx; + } } } } diff --git a/src/resources/extensions/subagent/background-jobs.js b/src/resources/extensions/subagent/background-jobs.js index 3ef7f5f51..e8759cb53 100644 --- a/src/resources/extensions/subagent/background-jobs.js +++ b/src/resources/extensions/subagent/background-jobs.js @@ -12,7 +12,7 @@ export class SubagentBackgroundJobManager { this.evictionMs = options.evictionMs ?? 10 * 60 * 1000; this.onJobComplete = options.onJobComplete; } - register(label, runFn) { + register(label, runFn, dispatchContext = null) { const running = this.getRunningJobs(); if (running.length >= this.maxRunning) { throw new Error( @@ -38,26 +38,74 @@ export class SubagentBackgroundJobManager { label, abortController, promise: undefined, + turns: [], + dispatchContext, }; - job.promise = runFn(abortController.signal) + job.promise = this._attachRun(job, runFn); + this.jobs.set(id, job); + return id; + } + /** @internal Attaches a runFn to a job and wires up completion/failure. */ + _attachRun(job, runFn) { + return runFn(job.abortController.signal) .then((result) => { job.result = result; job.status = result.isError ? "failed" : "completed"; - this.scheduleEviction(id); + this.scheduleEviction(job.id); this.deliverResult(job); }) .catch((err) => { if (job.status === "cancelled") { - this.scheduleEviction(id); + this.scheduleEviction(job.id); return; } job.status = "failed"; job.errorText = err instanceof Error ? err.message : String(err); - this.scheduleEviction(id); + this.scheduleEviction(job.id); this.deliverResult(job); }); - this.jobs.set(id, job); - return id; + } + /** + * Resume a completed (or failed) job with a new runFn. + * Used by write_subagent for multi-turn conversations. + * Returns "resumed" | "not_found" | "already_running" | "concurrency_limit" + */ + resume(id, runFn) { + const job = this.jobs.get(id); + if (!job) return "not_found"; + if (job.status === "running") return "already_running"; + if (this.getRunningJobs().length >= this.maxRunning) return "concurrency_limit"; + // Cancel pending eviction — job is active again + const evTimer = this.evictionTimers.get(id); + if (evTimer) { + clearTimeout(evTimer); + this.evictionTimers.delete(id); + } + // Cancel pending delivery notification (avoids stale follow-up firing mid-run) + if (job.deliveryTimer !== undefined) { + clearTimeout(job.deliveryTimer); + job.deliveryTimer = undefined; + } + // Fresh AbortController — previous one may be spent/aborted + job.abortController = new AbortController(); + job.status = "running"; + job.awaited = false; + job.result = undefined; + job.errorText = undefined; + job.promise = this._attachRun(job, runFn); + return "resumed"; + } + /** Append a conversation turn (role: "agent" | "user") to the job's turn history. */ + appendTurn(id, role, content) { + const job = this.jobs.get(id); + if (!job) return; + job.turns.push({ turnIndex: job.turns.length, role, content, timestamp: Date.now() }); + } + /** Return all turns for a job, optionally filtered to those after `since` (exclusive). */ + getTurns(id, since = 0) { + const job = this.jobs.get(id); + if (!job) return null; + return job.turns.slice(since); } cancel(id) { const job = this.jobs.get(id); diff --git a/src/resources/extensions/subagent/index.js b/src/resources/extensions/subagent/index.js index 1209e6aa8..71175a904 100644 --- a/src/resources/extensions/subagent/index.js +++ b/src/resources/extensions/subagent/index.js @@ -1777,27 +1777,61 @@ export default function (pi) { } if (params.background) { const manager = getBackgroundJobs(); - const jobId = manager.register( - summarizeBackgroundInvocation(params), - (backgroundSignal) => - executeSubagentInvocation({ - defaultCwd: ctx.cwd, - agents, - agentScope, - projectAgentsDir: discovery.projectAgentsDir, - params: { - ...params, - confirmProjectAgents: false, - background: false, - }, - signal: backgroundSignal, - onUpdate: undefined, - cmuxClient, - cmuxSplitsEnabled, - useIsolation, - inheritanceEnvelope, - }), - ); + // Build a rerun factory for write_subagent multi-turn follow-ups. + // Only single-mode dispatches (params.agent + params.task) support write_subagent. + const isSingleMode = Boolean(params.agent && params.task && !params.tasks && !params.chain); + const dispatchContext = isSingleMode + ? { + originalTask: params.task, + rerunWithTask: (enrichedTask, signal) => + executeSubagentInvocation({ + defaultCwd: ctx.cwd, + agents, + agentScope, + projectAgentsDir: discovery.projectAgentsDir, + params: { + ...params, + task: enrichedTask, + confirmProjectAgents: false, + background: false, + }, + signal, + onUpdate: undefined, + cmuxClient, + cmuxSplitsEnabled, + useIsolation, + inheritanceEnvelope, + }), + } + : null; + // Use a ref so the wrapped runFn can append the agent turn after completion. + let jobId; + const wrappedRun = (backgroundSignal) => + executeSubagentInvocation({ + defaultCwd: ctx.cwd, + agents, + agentScope, + projectAgentsDir: discovery.projectAgentsDir, + params: { + ...params, + confirmProjectAgents: false, + background: false, + }, + signal: backgroundSignal, + onUpdate: undefined, + cmuxClient, + cmuxSplitsEnabled, + useIsolation, + inheritanceEnvelope, + }).then((result) => { + // Append agent turn BEFORE deliverResult fires the notification. + if (jobId && isSingleMode) { + const text = getPrimaryTextContent(result); + if (text) manager.appendTurn(jobId, "agent", text); + } + return result; + }); + jobId = manager.register(summarizeBackgroundInvocation(params), wrappedRun, dispatchContext); return { content: [ { @@ -1805,7 +1839,7 @@ export default function (pi) { text: `Background subagent job started: **${jobId}**\n` + `Invocation: \`${summarizeBackgroundInvocation(params)}\`\n\n` + - "Use `await_subagent` to retrieve the result or `cancel_subagent` to stop it.", + "Use `await_subagent` to retrieve the result, `write_subagent` to send a follow-up, or `cancel_subagent` to stop it.", }, ], details: undefined, @@ -2691,4 +2725,163 @@ export default function (pi) { }; }, }); + pi.registerTool({ + name: "write_subagent", + label: "Write Subagent", + description: + "Send a follow-up message to a completed background subagent job, resuming the conversation. " + + "Only available for single-mode jobs (agent + task). " + + "The subagent receives the original task, the full conversation history, and the new message. " + + "Returns immediately — use await_subagent to retrieve the response.", + parameters: Type.Object({ + job_id: Type.String({ + description: "Background subagent job ID (for example sub_a1b2c3d4)", + }), + message: Type.String({ + description: "Follow-up message or instruction to send to the subagent.", + }), + }), + async execute(_toolCallId, params) { + const manager = getBackgroundJobs(); + const job = manager.getJob(params.job_id); + if (!job) { + return { + content: [{ type: "text", text: `Background subagent job not found: ${params.job_id}` }], + details: undefined, + isError: true, + }; + } + if (job.status === "running") { + return { + content: [ + { + type: "text", + text: `Background subagent ${params.job_id} is still running. Wait for it to complete before sending a follow-up.`, + }, + ], + details: undefined, + isError: true, + }; + } + if (!job.dispatchContext?.rerunWithTask) { + return { + content: [ + { + type: "text", + text: `Background subagent ${params.job_id} was launched in batch/chain mode and does not support write_subagent. Only single-mode (agent + task) jobs support multi-turn.`, + }, + ], + details: undefined, + isError: true, + }; + } + // Append the user's follow-up turn to the history + manager.appendTurn(params.job_id, "user", params.message); + // Build an enriched task string: original task + full conversation history + new message + const allTurns = manager.getTurns(params.job_id); + // allTurns includes the just-appended user turn; exclude it from the "history" block + const historyTurns = allTurns.slice(0, -1); + let enrichedTask = job.dispatchContext.originalTask; + if (historyTurns.length > 0) { + const historyText = historyTurns + .map((t) => `[${t.role === "agent" ? "Agent" : "User"} — turn ${t.turnIndex}]\n${t.content}`) + .join("\n\n"); + enrichedTask += `\n\n---\nConversation history:\n${historyText}\n---`; + } + enrichedTask += `\n\nUser follow-up: ${params.message}`; + const resumeResult = manager.resume( + params.job_id, + (signal) => job.dispatchContext.rerunWithTask(enrichedTask, signal).then((result) => { + // Append the new agent response as a turn before the notification fires + const text = getPrimaryTextContent(result); + if (text) manager.appendTurn(params.job_id, "agent", text); + return result; + }), + ); + if (resumeResult !== "resumed") { + const messages = { + concurrency_limit: `Cannot resume ${params.job_id}: maximum concurrent background jobs reached. Cancel another job first.`, + not_found: `Background subagent job not found: ${params.job_id}`, + already_running: `Background subagent ${params.job_id} is already running.`, + }; + return { + content: [{ type: "text", text: messages[resumeResult] ?? `Resume failed: ${resumeResult}` }], + details: undefined, + isError: true, + }; + } + return { + content: [ + { + type: "text", + text: + `Follow-up dispatched on **${params.job_id}** (turn ${allTurns.length}).\n\n` + + `Use \`await_subagent\` to retrieve the response or \`read_subagent\` to see the full conversation.`, + }, + ], + details: undefined, + }; + }, + }); + pi.registerTool({ + name: "read_subagent", + label: "Read Subagent", + description: + "Read the conversation turn history for a background subagent job. " + + "Returns all turns (agent responses and user follow-ups) since the job was started, " + + "optionally filtered to turns after a given index.", + parameters: Type.Object({ + job_id: Type.String({ + description: "Background subagent job ID (for example sub_a1b2c3d4)", + }), + since_turn: Type.Optional( + Type.Integer({ + description: + "Return only turns with turnIndex >= this value. Omit to return all turns.", + minimum: 0, + }), + ), + }), + async execute(_toolCallId, params) { + const manager = getBackgroundJobs(); + const job = manager.getJob(params.job_id); + if (!job) { + return { + content: [{ type: "text", text: `Background subagent job not found: ${params.job_id}` }], + details: undefined, + isError: true, + }; + } + const since = params.since_turn ?? 0; + const turns = manager.getTurns(params.job_id, since); + if (!turns || turns.length === 0) { + const status = job.status === "running" ? " (still running)" : ` (${job.status})`; + return { + content: [ + { + type: "text", + text: `No turns found for ${params.job_id}${status}${since > 0 ? ` since turn ${since}` : ""}.`, + }, + ], + details: undefined, + }; + } + const formatted = turns + .map((t) => { + const label = t.role === "agent" ? "🤖 Agent" : "👤 User"; + return `**${label} [turn ${t.turnIndex}]**\n${t.content}`; + }) + .join("\n\n---\n\n"); + const statusLine = job.status === "running" ? " *(running)*" : ` *(${job.status})*`; + return { + content: [ + { + type: "text", + text: `**${params.job_id}**${statusLine} — ${turns.length} turn(s)${since > 0 ? ` since turn ${since}` : ""}:\n\n${formatted}`, + }, + ], + details: undefined, + }; + }, + }); } diff --git a/vitest.config.ts b/vitest.config.ts index c9f128882..81036e14a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -70,6 +70,10 @@ export default defineConfig({ __dirname, "packages/agent-core/src/index.ts", ), + "@singularity-forge/tui/fuzzy": resolve( + __dirname, + "packages/tui/src/fuzzy.ts", + ), "@singularity-forge/tui": resolve( __dirname, "packages/tui/src/index.ts",