sf snapshot: uncommitted changes after 197m inactivity
This commit is contained in:
parent
de77cf439f
commit
924383b6f7
33 changed files with 952 additions and 303 deletions
24
.github/copilot-instructions.md
vendored
Normal file
24
.github/copilot-instructions.md
vendored
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"lastFullVacuumAt": "2026-05-10T05:57:58.807Z"
|
"lastFullVacuumAt": "2026-05-10T13:59:26.619Z"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
.sf/backups/db/sf.db.2026-05-10T11-10-21-954Z
Normal file
BIN
.sf/backups/db/sf.db.2026-05-10T11-10-21-954Z
Normal file
Binary file not shown.
BIN
.sf/backups/db/sf.db.2026-05-10T13-59-26-519Z
Normal file
BIN
.sf/backups/db/sf.db.2026-05-10T13-59-26-519Z
Normal file
Binary file not shown.
BIN
.sf/metrics.db-shm
Normal file
BIN
.sf/metrics.db-shm
Normal file
Binary file not shown.
0
.sf/metrics.db-wal
Normal file
0
.sf/metrics.db-wal
Normal file
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Api, Model } from "@singularity-forge/ai";
|
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 { getDiscoverableProviders } from "../core/model-discovery.js";
|
||||||
import type { ModelRegistry } from "../core/model-registry.js";
|
import type { ModelRegistry } from "../core/model-registry.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,10 @@ function getAliases(): Record<string, string> {
|
||||||
"tui/dist/index.js",
|
"tui/dist/index.js",
|
||||||
"@singularity-forge/tui",
|
"@singularity-forge/tui",
|
||||||
),
|
),
|
||||||
|
"@singularity-forge/tui/fuzzy": resolveWorkspaceOrImport(
|
||||||
|
"tui/dist/fuzzy.js",
|
||||||
|
"@singularity-forge/tui/fuzzy",
|
||||||
|
),
|
||||||
"@singularity-forge/ai": resolveWorkspaceOrImport(
|
"@singularity-forge/ai": resolveWorkspaceOrImport(
|
||||||
"ai/dist/index.js",
|
"ai/dist/index.js",
|
||||||
"@singularity-forge/ai",
|
"@singularity-forge/ai",
|
||||||
|
|
@ -383,6 +387,10 @@ function getAliases(): Record<string, string> {
|
||||||
"tui/dist/index.js",
|
"tui/dist/index.js",
|
||||||
"@singularity-forge/tui",
|
"@singularity-forge/tui",
|
||||||
),
|
),
|
||||||
|
"@mariozechner/tui/fuzzy": resolveWorkspaceOrImport(
|
||||||
|
"tui/dist/fuzzy.js",
|
||||||
|
"@singularity-forge/tui/fuzzy",
|
||||||
|
),
|
||||||
"@mariozechner/pi-ai": resolveWorkspaceOrImport(
|
"@mariozechner/pi-ai": resolveWorkspaceOrImport(
|
||||||
"ai/dist/index.js",
|
"ai/dist/index.js",
|
||||||
"@singularity-forge/ai",
|
"@singularity-forge/ai",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { type Model, modelsAreEqual } from "@singularity-forge/ai";
|
import { type Model, modelsAreEqual } from "@singularity-forge/ai";
|
||||||
|
import { fuzzyFilter } from "@singularity-forge/tui/fuzzy";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
type Focusable,
|
type Focusable,
|
||||||
fuzzyFilter,
|
|
||||||
getEditorKeybindings,
|
getEditorKeybindings,
|
||||||
Input,
|
Input,
|
||||||
Spacer,
|
Spacer,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { Model } from "@singularity-forge/ai";
|
import type { Model } from "@singularity-forge/ai";
|
||||||
|
import { fuzzyFilter } from "@singularity-forge/tui/fuzzy";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
type Focusable,
|
type Focusable,
|
||||||
fuzzyFilter,
|
|
||||||
getEditorKeybindings,
|
getEditorKeybindings,
|
||||||
Input,
|
Input,
|
||||||
Key,
|
Key,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
import type { SessionInfo } from "../../../core/session-manager.js";
|
||||||
|
|
||||||
export type SortMode = "threaded" | "recent" | "relevance";
|
export type SortMode = "threaded" | "recent" | "relevance";
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,11 @@ import type {
|
||||||
OverlayOptions,
|
OverlayOptions,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
} from "@singularity-forge/tui";
|
} from "@singularity-forge/tui";
|
||||||
|
import { fuzzyFilter } from "@singularity-forge/tui/fuzzy";
|
||||||
import {
|
import {
|
||||||
CombinedAutocompleteProvider,
|
CombinedAutocompleteProvider,
|
||||||
type Component,
|
type Component,
|
||||||
Container,
|
Container,
|
||||||
fuzzyFilter,
|
|
||||||
Loader,
|
Loader,
|
||||||
Markdown,
|
Markdown,
|
||||||
matchesKey,
|
matchesKey,
|
||||||
|
|
|
||||||
|
|
@ -561,6 +561,7 @@ function handleHotkeysCommand(ctx: SlashCommandContext): void {
|
||||||
const yank = getEditorKeyDisplay("yank");
|
const yank = getEditorKeyDisplay("yank");
|
||||||
const yankPop = getEditorKeyDisplay("yankPop");
|
const yankPop = getEditorKeyDisplay("yankPop");
|
||||||
const undo = getEditorKeyDisplay("undo");
|
const undo = getEditorKeyDisplay("undo");
|
||||||
|
const redo = getEditorKeyDisplay("redo");
|
||||||
const tab = getEditorKeyDisplay("tab");
|
const tab = getEditorKeyDisplay("tab");
|
||||||
|
|
||||||
// App keybindings
|
// App keybindings
|
||||||
|
|
@ -612,6 +613,7 @@ function handleHotkeysCommand(ctx: SlashCommandContext): void {
|
||||||
| \`${yank}\` | Paste the most-recently-deleted text |
|
| \`${yank}\` | Paste the most-recently-deleted text |
|
||||||
| \`${yankPop}\` | Cycle through the deleted text after pasting |
|
| \`${yankPop}\` | Cycle through the deleted text after pasting |
|
||||||
| \`${undo}\` | Undo |
|
| \`${undo}\` | Undo |
|
||||||
|
| \`${redo}\` | Redo |
|
||||||
|
|
||||||
**Other**
|
**Other**
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"require": "./dist/index.js"
|
"require": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./fuzzy": {
|
||||||
|
"types": "./dist/fuzzy.d.ts",
|
||||||
|
"import": "./dist/fuzzy.js",
|
||||||
|
"require": "./dist/fuzzy.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
31
packages/tui/src/__tests__/cell-size-drain.test.ts
Normal file
31
packages/tui/src/__tests__/cell-size-drain.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
packages/tui/src/__tests__/hardware-cursor.test.ts
Normal file
45
packages/tui/src/__tests__/hardware-cursor.test.ts
Normal file
|
|
@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
51
packages/tui/src/__tests__/viewport-sticky.test.ts
Normal file
51
packages/tui/src/__tests__/viewport-sticky.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -35,3 +35,75 @@ export function parseCellSizeResponse(
|
||||||
export function stripCellSizeResponse(chunk: string): string {
|
export function stripCellSizeResponse(chunk: string): string {
|
||||||
return chunk.replace(CELL_SIZE_PATTERN, "");
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,4 +78,21 @@ describe("Editor", () => {
|
||||||
|
|
||||||
assert.equal(editor.getText(), "");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,8 @@ export class Input implements Component, Focusable {
|
||||||
private killRing = new KillRing();
|
private killRing = new KillRing();
|
||||||
private lastAction: "kill" | "yank" | "type-word" | null = null;
|
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<InputState>();
|
private undoStack = new UndoStack<InputState>();
|
||||||
private redoStack = new UndoStack<InputState>();
|
|
||||||
|
|
||||||
getValue(): string {
|
getValue(): string {
|
||||||
return this.value;
|
return this.value;
|
||||||
|
|
@ -376,23 +375,22 @@ export class Input implements Component, Focusable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private pushUndo(): void {
|
private pushUndo(): void {
|
||||||
this.redoStack.clear();
|
|
||||||
this.undoStack.push({ value: this.value, cursor: this.cursor });
|
this.undoStack.push({ value: this.value, cursor: this.cursor });
|
||||||
}
|
}
|
||||||
|
|
||||||
private undo(): void {
|
private undo(): void {
|
||||||
const snapshot = this.undoStack.pop();
|
const snapshot = this.undoStack.pop();
|
||||||
if (!snapshot) return;
|
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.value = snapshot.value;
|
||||||
this.cursor = snapshot.cursor;
|
this.cursor = snapshot.cursor;
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private redo(): void {
|
private redo(): void {
|
||||||
const snapshot = this.redoStack.pop();
|
const snapshot = this.undoStack.redo();
|
||||||
if (!snapshot) return;
|
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.value = snapshot.value;
|
||||||
this.cursor = snapshot.cursor;
|
this.cursor = snapshot.cursor;
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
|
|
|
||||||
55
packages/tui/src/hardware-cursor.ts
Normal file
55
packages/tui/src/hardware-cursor.ts
Normal file
|
|
@ -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<Terminal, "hideCursor" | "showCursor" | "write">,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,8 @@ export { Text } from "./components/text.js";
|
||||||
export { TruncatedText } from "./components/truncated-text.js";
|
export { TruncatedText } from "./components/truncated-text.js";
|
||||||
// Editor component interface (for custom editors)
|
// Editor component interface (for custom editors)
|
||||||
export type { EditorComponent } from "./editor-component.js";
|
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
|
// Ink bridge — gradual migration infrastructure
|
||||||
export { startInkRenderer } from "./ink-bridge.js";
|
export { startInkRenderer } from "./ink-bridge.js";
|
||||||
// Keybindings
|
// Keybindings
|
||||||
|
|
@ -72,6 +74,8 @@ export {
|
||||||
} from "./keys.js";
|
} from "./keys.js";
|
||||||
// Render safety — prevents one failing component from blanking the TUI
|
// Render safety — prevents one failing component from blanking the TUI
|
||||||
export { tryRender } from "./render-guard.js";
|
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
|
// Input buffering for batch splitting
|
||||||
export {
|
export {
|
||||||
StdinBuffer,
|
StdinBuffer,
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,13 @@
|
||||||
* positions and composite overlay content onto base terminal lines.
|
* 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 { tryRender } from "./render-guard.js";
|
||||||
import { isImageLine } from "./terminal-image.js";
|
import { isImageLine } from "./terminal-image.js";
|
||||||
import type { OverlayAnchor, OverlayOptions, SizeValue } from "./tui.js";
|
|
||||||
import { CURSOR_MARKER } from "./tui.js";
|
import { CURSOR_MARKER } from "./tui.js";
|
||||||
import {
|
import {
|
||||||
applyBackgroundToLine,
|
applyBackgroundToLine,
|
||||||
|
|
|
||||||
90
packages/tui/src/overlay-types.ts
Normal file
90
packages/tui/src/overlay-types.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
34
packages/tui/src/tui-input-dispatch.ts
Normal file
34
packages/tui/src/tui-input-dispatch.ts
Normal file
|
|
@ -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<InputListener>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -5,14 +5,12 @@
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import {
|
import { drainCellSizeQueryBuffer } from "./cell-size.js";
|
||||||
parseCellSizeResponse as parseCellSizeResponseFn,
|
|
||||||
stripCellSizeResponse,
|
|
||||||
} from "./cell-size.js";
|
|
||||||
import {
|
import {
|
||||||
type AutonomousModeStatus,
|
type AutonomousModeStatus,
|
||||||
AutonomousStatusBar,
|
AutonomousStatusBar,
|
||||||
} from "./components/autonomous-status-bar.js";
|
} from "./components/autonomous-status-bar.js";
|
||||||
|
import { syncHardwareCursorForIME } from "./hardware-cursor.js";
|
||||||
import { startInkRenderer } from "./ink-bridge.js";
|
import { startInkRenderer } from "./ink-bridge.js";
|
||||||
import { isKeyRelease, matchesKey } from "./keys.js";
|
import { isKeyRelease, matchesKey } from "./keys.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -21,6 +19,7 @@ import {
|
||||||
extractCursorPosition,
|
extractCursorPosition,
|
||||||
isOverlayVisible as isOverlayEntryVisible,
|
isOverlayVisible as isOverlayEntryVisible,
|
||||||
} from "./overlay-layout.js";
|
} from "./overlay-layout.js";
|
||||||
|
import type { OverlayHandle, OverlayOptions } from "./overlay-types.js";
|
||||||
import { tryRender } from "./render-guard.js";
|
import { tryRender } from "./render-guard.js";
|
||||||
import type { Terminal } from "./terminal.js";
|
import type { Terminal } from "./terminal.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,7 +27,15 @@ import {
|
||||||
isImageLine,
|
isImageLine,
|
||||||
setCellDimensions,
|
setCellDimensions,
|
||||||
} from "./terminal-image.js";
|
} from "./terminal-image.js";
|
||||||
|
import {
|
||||||
|
applyInputListeners,
|
||||||
|
type InputListener,
|
||||||
|
} from "./tui-input-dispatch.js";
|
||||||
import { truncateToWidth, visibleWidth } from "./utils.js";
|
import { truncateToWidth, visibleWidth } from "./utils.js";
|
||||||
|
import {
|
||||||
|
buildStickyScrollVerticalEscapes,
|
||||||
|
isStickyViewportAtBottom,
|
||||||
|
} from "./viewport-sticky.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component interface - all components must implement this
|
* Component interface - all components must implement this
|
||||||
|
|
@ -59,9 +66,6 @@ export interface Component {
|
||||||
invalidate(): void;
|
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.
|
* Interface for components that can receive focus and display a hardware cursor.
|
||||||
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
* 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 const CURSOR_MARKER = "\x1b_sf:c\x07";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
OverlayAnchor,
|
||||||
|
OverlayHandle,
|
||||||
|
OverlayMargin,
|
||||||
|
OverlayOptions,
|
||||||
|
SizeValue,
|
||||||
|
} from "./overlay-types.js";
|
||||||
export { visibleWidth };
|
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
|
* 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
|
* Check if user is scrolled to the bottom of the content
|
||||||
*/
|
*/
|
||||||
private isAtBottom(): boolean {
|
private isAtBottom(): boolean {
|
||||||
const height = this.terminal.rows;
|
return isStickyViewportAtBottom({
|
||||||
const viewportTop = Math.max(0, this.maxLinesRendered - height);
|
maxLinesRendered: this.maxLinesRendered,
|
||||||
const viewportBottom = viewportTop + height;
|
terminalRows: this.terminal.rows,
|
||||||
return viewportBottom >= this.previousLines.length;
|
previousContentLineCount: this.previousLines.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll to bottom of content (sticky bottom)
|
* Scroll to bottom of content (sticky bottom)
|
||||||
*/
|
*/
|
||||||
private scrollToBottom(): void {
|
private scrollToBottom(): void {
|
||||||
const height = this.terminal.rows;
|
const sticky = buildStickyScrollVerticalEscapes({
|
||||||
const contentHeight = this.previousLines.length;
|
contentLineCount: this.previousLines.length,
|
||||||
if (contentHeight <= height) return; // No scrolling needed if content fits in viewport
|
terminalRows: this.terminal.rows,
|
||||||
|
previousViewportTop: this.previousViewportTop,
|
||||||
// For terminal scrolling, we can use cursor movement or scroll sequences
|
hardwareCursorRow: this.hardwareCursorRow,
|
||||||
// The simplest approach is to move the cursor to the bottom line
|
});
|
||||||
const viewportTop = Math.max(0, contentHeight - height);
|
if (!sticky) {
|
||||||
const targetScreenRow = contentHeight - 1;
|
return;
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.previousViewportTop = viewportTop;
|
const { escapes, nextPreviousViewportTop } = sticky;
|
||||||
|
if (escapes) {
|
||||||
|
this.terminal.write(escapes);
|
||||||
|
}
|
||||||
|
this.previousViewportTop = nextPreviousViewportTop;
|
||||||
this.isScrolledToBottom = true;
|
this.isScrolledToBottom = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -644,39 +564,84 @@ export class TUI extends Container {
|
||||||
this.requestRender();
|
this.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
private handleInput(data: string): void {
|
||||||
if (this.inputListeners.size > 0) {
|
const afterListeners = applyInputListeners(this.inputListeners, data);
|
||||||
let current = data;
|
if (afterListeners === undefined) {
|
||||||
for (const listener of this.inputListeners) {
|
|
||||||
const result = listener(current);
|
|
||||||
if (result?.consume) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result?.data !== undefined) {
|
data = afterListeners;
|
||||||
current = result.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (current.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
data = current;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're waiting for cell size response, buffer input and parse
|
|
||||||
if (this.cellSizeQueryPending) {
|
if (this.cellSizeQueryPending) {
|
||||||
this.inputBuffer += data;
|
const released = this.flushCellSizeBufferedInput(data);
|
||||||
const filtered = this.parseCellSizeResponse();
|
if (released === undefined) {
|
||||||
if (filtered.length === 0) return;
|
return;
|
||||||
data = filtered;
|
}
|
||||||
|
data = released;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global debug key handler (Shift+Ctrl+D)
|
|
||||||
if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
|
if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
|
||||||
this.onDebug();
|
this.onDebug();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect scrolling keys (Page Up/Down, arrow keys) to break sticky bottom
|
|
||||||
if (
|
if (
|
||||||
this.isScrolledToBottom &&
|
this.isScrolledToBottom &&
|
||||||
(matchesKey(data, "pageUp") || matchesKey(data, "up"))
|
(matchesKey(data, "pageUp") || matchesKey(data, "up"))
|
||||||
|
|
@ -684,88 +649,13 @@ export class TUI extends Container {
|
||||||
this.isScrolledToBottom = false;
|
this.isScrolledToBottom = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If focused component is an overlay, verify it's still visible
|
this.repairFocusedOverlayVisibility();
|
||||||
// (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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter key scrolling behavior: if not at bottom, scroll down instead of sending input
|
if (this.maybeHandleEnterAsScrollToBottom(data)) {
|
||||||
if (data === "\r" || data === "\n") {
|
|
||||||
// Enter key
|
|
||||||
if (!this.isAtBottom()) {
|
|
||||||
// Scroll down one page or to bottom
|
|
||||||
this.scrollToBottom();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If we're at bottom, let Enter pass through to focused component
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass input to focused component (including Ctrl+C)
|
this.dispatchFocusedComponentInput(data);
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private doRender(): void {
|
private doRender(): void {
|
||||||
|
|
@ -854,7 +744,7 @@ export class TUI extends Container {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.applyIMEHardwareCursor(cursorPos, newLines.length);
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
this.previousHeight = height;
|
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
|
// No changes - but still need to update hardware cursor position if it moved
|
||||||
if (firstChanged === -1) {
|
if (firstChanged === -1) {
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.applyIMEHardwareCursor(cursorPos, newLines.length);
|
||||||
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
||||||
this.previousHeight = height;
|
this.previousHeight = height;
|
||||||
return;
|
return;
|
||||||
|
|
@ -985,7 +875,7 @@ export class TUI extends Container {
|
||||||
this.cursorRow = targetRow;
|
this.cursorRow = targetRow;
|
||||||
this.hardwareCursorRow = targetRow;
|
this.hardwareCursorRow = targetRow;
|
||||||
}
|
}
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.applyIMEHardwareCursor(cursorPos, newLines.length);
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
this.previousHeight = height;
|
this.previousHeight = height;
|
||||||
|
|
@ -1124,7 +1014,7 @@ export class TUI extends Container {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position hardware cursor for IME
|
// Position hardware cursor for IME
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.applyIMEHardwareCursor(cursorPos, newLines.length);
|
||||||
|
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
|
|
@ -1138,43 +1028,17 @@ export class TUI extends Container {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Position the hardware cursor for IME candidate window.
|
* Position the hardware cursor for IME (`CURSOR_MARKER` anchor).
|
||||||
* @param cursorPos The cursor position extracted from rendered output, or null
|
|
||||||
* @param totalLines Total number of rendered lines
|
|
||||||
*/
|
*/
|
||||||
private positionHardwareCursor(
|
private applyIMEHardwareCursor(
|
||||||
cursorPos: { row: number; col: number } | null,
|
cursorPos: { row: number; col: number } | null,
|
||||||
totalLines: number,
|
totalLines: number,
|
||||||
): void {
|
): void {
|
||||||
if (!cursorPos || totalLines <= 0) {
|
this.hardwareCursorRow = syncHardwareCursorForIME(this.terminal, {
|
||||||
this.terminal.hideCursor();
|
cursorPos,
|
||||||
return;
|
totalLines,
|
||||||
}
|
hardwareCursorRow: this.hardwareCursorRow,
|
||||||
|
showHardwareCursor: this.showHardwareCursor,
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
packages/tui/src/viewport-sticky.ts
Normal file
57
packages/tui/src/viewport-sticky.ts
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -75,7 +75,11 @@ function hasTsFilesRecursive(dir) {
|
||||||
const hasTsFiles = hasTsFilesRecursive(srcResources);
|
const hasTsFiles = hasTsFilesRecursive(srcResources);
|
||||||
|
|
||||||
if (hasTsFiles) {
|
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(
|
const compile = spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[tsgoBin, "--project", resourcesTsconfig],
|
[tsgoBin, "--project", resourcesTsconfig],
|
||||||
|
|
|
||||||
|
|
@ -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). |
|
| 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`. |
|
| 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. |
|
| 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.
|
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" }
|
{ 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
|
## Patterns
|
||||||
|
|
||||||
### Pattern 1 — Parallel research
|
### Pattern 1 — Parallel research
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export function resolve(specifier, context, nextResolve) {
|
||||||
specifier = new URL("packages/agent-core/src/index.ts", ROOT).href;
|
specifier = new URL("packages/agent-core/src/index.ts", ROOT).href;
|
||||||
} else if (specifier === "@singularity-forge/tui") {
|
} else if (specifier === "@singularity-forge/tui") {
|
||||||
specifier = new URL("packages/tui/src/index.ts", ROOT).href;
|
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") {
|
} else if (specifier === "@singularity-forge/native") {
|
||||||
specifier = new URL("packages/native/src/index.ts", ROOT).href;
|
specifier = new URL("packages/native/src/index.ts", ROOT).href;
|
||||||
} else if (specifier.startsWith("@singularity-forge/native/")) {
|
} else if (specifier.startsWith("@singularity-forge/native/")) {
|
||||||
|
|
@ -53,12 +55,19 @@ export function resolve(specifier, context, nextResolve) {
|
||||||
} else {
|
} else {
|
||||||
candidate = specifier.replace(/\.js$/, ".ts");
|
candidate = specifier.replace(/\.js$/, ".ts");
|
||||||
}
|
}
|
||||||
// Only rewrite if the .ts file exists
|
const candidatePathTs = fileURLToPath(
|
||||||
const candidatePath = fileURLToPath(
|
|
||||||
new URL(candidate, context.parentURL),
|
new URL(candidate, context.parentURL),
|
||||||
);
|
);
|
||||||
if (existsSync(candidatePath)) {
|
if (existsSync(candidatePathTs)) {
|
||||||
specifier = candidate;
|
specifier = candidate;
|
||||||
|
} else {
|
||||||
|
const candidateTsx = candidate.replace(/\.ts$/, ".tsx");
|
||||||
|
const candidatePathTsx = fileURLToPath(
|
||||||
|
new URL(candidateTsx, context.parentURL),
|
||||||
|
);
|
||||||
|
if (existsSync(candidatePathTsx)) {
|
||||||
|
specifier = candidateTsx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export class SubagentBackgroundJobManager {
|
||||||
this.evictionMs = options.evictionMs ?? 10 * 60 * 1000;
|
this.evictionMs = options.evictionMs ?? 10 * 60 * 1000;
|
||||||
this.onJobComplete = options.onJobComplete;
|
this.onJobComplete = options.onJobComplete;
|
||||||
}
|
}
|
||||||
register(label, runFn) {
|
register(label, runFn, dispatchContext = null) {
|
||||||
const running = this.getRunningJobs();
|
const running = this.getRunningJobs();
|
||||||
if (running.length >= this.maxRunning) {
|
if (running.length >= this.maxRunning) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -38,26 +38,74 @@ export class SubagentBackgroundJobManager {
|
||||||
label,
|
label,
|
||||||
abortController,
|
abortController,
|
||||||
promise: undefined,
|
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) => {
|
.then((result) => {
|
||||||
job.result = result;
|
job.result = result;
|
||||||
job.status = result.isError ? "failed" : "completed";
|
job.status = result.isError ? "failed" : "completed";
|
||||||
this.scheduleEviction(id);
|
this.scheduleEviction(job.id);
|
||||||
this.deliverResult(job);
|
this.deliverResult(job);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (job.status === "cancelled") {
|
if (job.status === "cancelled") {
|
||||||
this.scheduleEviction(id);
|
this.scheduleEviction(job.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job.status = "failed";
|
job.status = "failed";
|
||||||
job.errorText = err instanceof Error ? err.message : String(err);
|
job.errorText = err instanceof Error ? err.message : String(err);
|
||||||
this.scheduleEviction(id);
|
this.scheduleEviction(job.id);
|
||||||
this.deliverResult(job);
|
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) {
|
cancel(id) {
|
||||||
const job = this.jobs.get(id);
|
const job = this.jobs.get(id);
|
||||||
|
|
|
||||||
|
|
@ -1777,9 +1777,36 @@ export default function (pi) {
|
||||||
}
|
}
|
||||||
if (params.background) {
|
if (params.background) {
|
||||||
const manager = getBackgroundJobs();
|
const manager = getBackgroundJobs();
|
||||||
const jobId = manager.register(
|
// Build a rerun factory for write_subagent multi-turn follow-ups.
|
||||||
summarizeBackgroundInvocation(params),
|
// Only single-mode dispatches (params.agent + params.task) support write_subagent.
|
||||||
(backgroundSignal) =>
|
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({
|
executeSubagentInvocation({
|
||||||
defaultCwd: ctx.cwd,
|
defaultCwd: ctx.cwd,
|
||||||
agents,
|
agents,
|
||||||
|
|
@ -1796,8 +1823,15 @@ export default function (pi) {
|
||||||
cmuxSplitsEnabled,
|
cmuxSplitsEnabled,
|
||||||
useIsolation,
|
useIsolation,
|
||||||
inheritanceEnvelope,
|
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 {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
|
|
@ -1805,7 +1839,7 @@ export default function (pi) {
|
||||||
text:
|
text:
|
||||||
`Background subagent job started: **${jobId}**\n` +
|
`Background subagent job started: **${jobId}**\n` +
|
||||||
`Invocation: \`${summarizeBackgroundInvocation(params)}\`\n\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,
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,10 @@ export default defineConfig({
|
||||||
__dirname,
|
__dirname,
|
||||||
"packages/agent-core/src/index.ts",
|
"packages/agent-core/src/index.ts",
|
||||||
),
|
),
|
||||||
|
"@singularity-forge/tui/fuzzy": resolve(
|
||||||
|
__dirname,
|
||||||
|
"packages/tui/src/fuzzy.ts",
|
||||||
|
),
|
||||||
"@singularity-forge/tui": resolve(
|
"@singularity-forge/tui": resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
"packages/tui/src/index.ts",
|
"packages/tui/src/index.ts",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue