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 { 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";
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,10 @@ function getAliases(): Record<string, string> {
|
|||
"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<string, string> {
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
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 {
|
||||
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(), "");
|
||||
});
|
||||
|
||||
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 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 redoStack = new UndoStack<InputState>();
|
||||
|
||||
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;
|
||||
|
|
|
|||
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";
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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 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 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 {
|
||||
if (this.inputListeners.size > 0) {
|
||||
let current = data;
|
||||
for (const listener of this.inputListeners) {
|
||||
const result = listener(current);
|
||||
if (result?.consume) {
|
||||
const afterListeners = applyInputListeners(this.inputListeners, data);
|
||||
if (afterListeners === undefined) {
|
||||
return;
|
||||
}
|
||||
if (result?.data !== undefined) {
|
||||
current = result.data;
|
||||
}
|
||||
}
|
||||
if (current.length === 0) {
|
||||
return;
|
||||
}
|
||||
data = current;
|
||||
}
|
||||
data = afterListeners;
|
||||
|
||||
// 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;
|
||||
const released = this.flushCellSizeBufferedInput(data);
|
||||
if (released === undefined) {
|
||||
return;
|
||||
}
|
||||
data = released;
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// 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();
|
||||
if (this.maybeHandleEnterAsScrollToBottom(data)) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
|
||||
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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1777,9 +1777,36 @@ export default function (pi) {
|
|||
}
|
||||
if (params.background) {
|
||||
const manager = getBackgroundJobs();
|
||||
const jobId = manager.register(
|
||||
summarizeBackgroundInvocation(params),
|
||||
(backgroundSignal) =>
|
||||
// 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,
|
||||
|
|
@ -1796,8 +1823,15 @@ export default function (pi) {
|
|||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue