agent-sh 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +21 -0
  2. package/dist/acp-client.d.ts +24 -0
  3. package/dist/acp-client.js +155 -33
  4. package/dist/context-manager.d.ts +5 -3
  5. package/dist/context-manager.js +62 -31
  6. package/dist/core.js +10 -0
  7. package/dist/event-bus.d.ts +26 -0
  8. package/dist/event-bus.js +10 -0
  9. package/dist/extension-loader.js +3 -14
  10. package/dist/extensions/shell-exec.js +27 -22
  11. package/dist/extensions/tui-renderer.d.ts +1 -1
  12. package/dist/extensions/tui-renderer.js +369 -126
  13. package/dist/index.js +184 -37
  14. package/dist/input-handler.d.ts +10 -0
  15. package/dist/input-handler.js +169 -10
  16. package/dist/mcp-server.js +37 -8
  17. package/dist/settings.d.ts +44 -0
  18. package/dist/settings.js +61 -0
  19. package/dist/shell.d.ts +1 -0
  20. package/dist/shell.js +44 -4
  21. package/dist/types.d.ts +17 -0
  22. package/dist/utils/ansi.d.ts +4 -1
  23. package/dist/utils/ansi.js +60 -2
  24. package/dist/utils/box-frame.js +2 -1
  25. package/dist/utils/diff-renderer.js +1 -1
  26. package/dist/utils/frame-renderer.d.ts +26 -0
  27. package/dist/utils/frame-renderer.js +76 -0
  28. package/dist/utils/handler-registry.d.ts +41 -0
  29. package/dist/utils/handler-registry.js +52 -0
  30. package/dist/utils/line-editor.d.ts +21 -1
  31. package/dist/utils/line-editor.js +193 -99
  32. package/dist/utils/markdown.d.ts +15 -6
  33. package/dist/utils/markdown.js +106 -67
  34. package/dist/utils/output-writer.d.ts +22 -0
  35. package/dist/utils/output-writer.js +29 -0
  36. package/dist/utils/stream-transform.d.ts +70 -0
  37. package/dist/utils/stream-transform.js +229 -0
  38. package/dist/utils/tool-display.d.ts +11 -8
  39. package/dist/utils/tool-display.js +69 -46
  40. package/examples/extensions/latex-images.ts +142 -0
  41. package/examples/pi-agent-sh.ts +166 -0
  42. package/package.json +10 -2
@@ -24,13 +24,27 @@ function sendError(id, code, message) {
24
24
  send({ id, error: { code, message } });
25
25
  }
26
26
  // ── Tool definition ─────────────────────────────────────────────
27
+ const SHELL_CWD_TOOL = {
28
+ name: "shell_cwd",
29
+ description: "Get the user's current working directory in their live shell. " +
30
+ "IMPORTANT: Your internal working directory may differ from the user's actual shell cwd — " +
31
+ "the user may have cd'd after your session started. Call this tool to get the real cwd " +
32
+ "before file operations if you're unsure.",
33
+ inputSchema: {
34
+ type: "object",
35
+ properties: {},
36
+ },
37
+ };
27
38
  const USER_SHELL_TOOL = {
28
39
  name: "user_shell",
29
40
  description: "Execute a command in the user's live terminal session. " +
30
41
  "Use this for commands that should affect the user's shell state: " +
31
42
  "cd, export, source, pushd/popd, alias, etc. " +
32
43
  "The command runs in the user's actual shell with their full environment " +
33
- "(aliases, functions, PATH), not an isolated subprocess.",
44
+ "(aliases, functions, PATH), not an isolated subprocess. " +
45
+ "NOTE: Your internal cwd may be stale — the user may have cd'd. " +
46
+ "Check the shell context for [shell cwd:...] labels or call shell_cwd " +
47
+ "to determine the real working directory. Use absolute paths when possible.",
34
48
  inputSchema: {
35
49
  type: "object",
36
50
  properties: {
@@ -46,10 +60,11 @@ const SHELL_RECALL_TOOL = {
46
60
  name: "shell_recall",
47
61
  description: "Retrieve past shell commands, agent responses, and tool executions from the session history. " +
48
62
  "Use this to look up truncated output, search for previous commands or errors, " +
49
- "or browse recent exchanges. Three operations: " +
50
- '"search" finds exchanges matching a query, ' +
51
- '"expand" retrieves full untruncated content by exchange ID, ' +
52
- '"browse" lists recent exchange summaries.',
63
+ "or browse recent exchanges. Each entry shows [shell cwd:...] so you can see " +
64
+ "which directory commands were run in. Operations: " +
65
+ '"browse" lists recent exchange summaries with line counts, ' +
66
+ '"search" finds exchanges matching a regex query, ' +
67
+ '"expand" retrieves content by exchange ID (use start/end for specific line ranges).',
53
68
  inputSchema: {
54
69
  type: "object",
55
70
  properties: {
@@ -60,13 +75,21 @@ const SHELL_RECALL_TOOL = {
60
75
  },
61
76
  query: {
62
77
  type: "string",
63
- description: 'Search query (required for "search" operation)',
78
+ description: 'Search query — supports regex (required for "search" operation)',
64
79
  },
65
80
  ids: {
66
81
  type: "array",
67
82
  items: { type: "number" },
68
83
  description: 'Exchange IDs to expand (required for "expand" operation)',
69
84
  },
85
+ start: {
86
+ type: "number",
87
+ description: "Start line number, 1-indexed (optional, for expand)",
88
+ },
89
+ end: {
90
+ type: "number",
91
+ description: "End line number, inclusive (optional, for expand)",
92
+ },
70
93
  },
71
94
  },
72
95
  };
@@ -127,14 +150,18 @@ async function handleRequest(id, method, params) {
127
150
  // Client acknowledgement — nothing to do
128
151
  break;
129
152
  case "tools/list":
130
- sendResult(id, { tools: [USER_SHELL_TOOL, SHELL_RECALL_TOOL] });
153
+ sendResult(id, { tools: [SHELL_CWD_TOOL, USER_SHELL_TOOL, SHELL_RECALL_TOOL] });
131
154
  break;
132
155
  case "tools/call": {
133
156
  const toolName = params?.name;
134
157
  const args = params?.arguments ?? {};
135
158
  try {
136
159
  let text;
137
- if (toolName === "user_shell") {
160
+ if (toolName === "shell_cwd") {
161
+ const result = await callSocket("shell/cwd", {});
162
+ text = `User's current working directory: ${result.cwd}`;
163
+ }
164
+ else if (toolName === "user_shell") {
138
165
  const command = args.command;
139
166
  if (!command || typeof command !== "string") {
140
167
  sendError(id, -32602, "Missing required parameter: command");
@@ -148,6 +175,8 @@ async function handleRequest(id, method, params) {
148
175
  operation: args.operation || "browse",
149
176
  query: args.query,
150
177
  ids: args.ids,
178
+ start: args.start,
179
+ end: args.end,
151
180
  });
152
181
  text = result.result || "(no results)";
153
182
  }
@@ -0,0 +1,44 @@
1
+ export declare const CONFIG_DIR: string;
2
+ export interface Settings {
3
+ /** Extensions to load (npm packages or file paths). */
4
+ extensions?: string[];
5
+ /** Max agent query history entries to keep. */
6
+ historySize?: number;
7
+ /** Recent exchanges included in agent context window. */
8
+ contextWindowSize?: number;
9
+ /** Context budget in bytes (~4 chars per token). */
10
+ contextBudget?: number;
11
+ /** Shell output lines before truncation kicks in. */
12
+ shellTruncateThreshold?: number;
13
+ /** Lines kept from start of truncated shell output. */
14
+ shellHeadLines?: number;
15
+ /** Lines kept from end of truncated shell output. */
16
+ shellTailLines?: number;
17
+ /** Max lines for recall expand before requiring line ranges. */
18
+ recallExpandMaxLines?: number;
19
+ /** Max command output lines shown inline in TUI. */
20
+ maxCommandOutputLines?: number;
21
+ /** Max read tool output lines shown inline in TUI (0 = hide). */
22
+ readOutputMaxLines?: number;
23
+ /** Max diff lines shown before "ctrl+o to expand". */
24
+ diffMaxLines?: number;
25
+ /** Register MCP server for bridge tools (shell_cwd, user_shell, shell_recall). Default true. */
26
+ enableMcp?: boolean;
27
+ }
28
+ declare const DEFAULTS: Required<Settings>;
29
+ /** Load settings from disk (cached after first call). */
30
+ export declare function getSettings(): Settings & typeof DEFAULTS;
31
+ /**
32
+ * Get settings for an extension, namespaced under its key in settings.json.
33
+ *
34
+ * Example settings.json:
35
+ * { "latex-images": { "dpi": 600, "fgColor": "ffffff" } }
36
+ *
37
+ * Usage in extension:
38
+ * const config = getExtensionSettings("latex-images", { dpi: 300, fgColor: "d4d4d4" });
39
+ * // config.dpi === 600 (overridden), config.fgColor === "ffffff" (overridden)
40
+ */
41
+ export declare function getExtensionSettings<T extends Record<string, unknown>>(namespace: string, defaults: T): T;
42
+ /** Reset cached settings (for testing or after external edit). */
43
+ export declare function reloadSettings(): void;
44
+ export {};
@@ -0,0 +1,61 @@
1
+ /**
2
+ * User settings loaded from ~/.agent-sh/settings.json.
3
+ *
4
+ * Settings are loaded once at startup and available synchronously
5
+ * throughout the app. Unknown keys are preserved on write.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import * as os from "node:os";
10
+ export const CONFIG_DIR = path.join(os.homedir(), ".agent-sh");
11
+ const SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
12
+ const DEFAULTS = {
13
+ extensions: [],
14
+ historySize: 500,
15
+ contextWindowSize: 20,
16
+ contextBudget: 16384,
17
+ shellTruncateThreshold: 10,
18
+ shellHeadLines: 5,
19
+ shellTailLines: 5,
20
+ recallExpandMaxLines: 100,
21
+ maxCommandOutputLines: 3,
22
+ readOutputMaxLines: 0,
23
+ diffMaxLines: 20,
24
+ enableMcp: true,
25
+ };
26
+ let cached = null;
27
+ /** Load settings from disk (cached after first call). */
28
+ export function getSettings() {
29
+ if (!cached) {
30
+ try {
31
+ const raw = fs.readFileSync(SETTINGS_PATH, "utf-8");
32
+ cached = JSON.parse(raw);
33
+ }
34
+ catch {
35
+ cached = {};
36
+ }
37
+ }
38
+ return { ...DEFAULTS, ...cached };
39
+ }
40
+ /**
41
+ * Get settings for an extension, namespaced under its key in settings.json.
42
+ *
43
+ * Example settings.json:
44
+ * { "latex-images": { "dpi": 600, "fgColor": "ffffff" } }
45
+ *
46
+ * Usage in extension:
47
+ * const config = getExtensionSettings("latex-images", { dpi: 300, fgColor: "d4d4d4" });
48
+ * // config.dpi === 600 (overridden), config.fgColor === "ffffff" (overridden)
49
+ */
50
+ export function getExtensionSettings(namespace, defaults) {
51
+ const all = getSettings();
52
+ const ext = all[namespace];
53
+ if (ext && typeof ext === "object" && !Array.isArray(ext)) {
54
+ return { ...defaults, ...ext };
55
+ }
56
+ return defaults;
57
+ }
58
+ /** Reset cached settings (for testing or after external edit). */
59
+ export function reloadSettings() {
60
+ cached = null;
61
+ }
package/dist/shell.d.ts CHANGED
@@ -6,6 +6,7 @@ export declare class Shell implements InputContext {
6
6
  private inputHandler;
7
7
  private outputParser;
8
8
  private paused;
9
+ private echoSkip;
9
10
  private agentActive;
10
11
  private isZsh;
11
12
  private tmpDir?;
package/dist/shell.js CHANGED
@@ -10,6 +10,7 @@ export class Shell {
10
10
  inputHandler;
11
11
  outputParser;
12
12
  paused = false;
13
+ echoSkip = false;
13
14
  agentActive = false;
14
15
  isZsh = false;
15
16
  tmpDir;
@@ -98,6 +99,18 @@ export class Shell {
98
99
  ].join("\n") + "\n");
99
100
  shellArgs = ["--rcfile", path.join(this.tmpDir, ".bashrc")];
100
101
  }
102
+ // Pause stdin before spawning PTY to avoid TTY contention on macOS.
103
+ // The PTY will become the controlling terminal for the child shell.
104
+ const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
105
+ if (process.stdin.isTTY) {
106
+ try {
107
+ process.stdin.setRawMode(false);
108
+ process.stdin.pause();
109
+ }
110
+ catch {
111
+ // Ignore
112
+ }
113
+ }
101
114
  this.ptyProcess = pty.spawn(shellBin, shellArgs, {
102
115
  name: "xterm-256color",
103
116
  cols: opts.cols,
@@ -105,6 +118,18 @@ export class Shell {
105
118
  cwd: opts.cwd,
106
119
  env,
107
120
  });
121
+ // Restore stdin after PTY is created
122
+ if (process.stdin.isTTY) {
123
+ try {
124
+ process.stdin.resume();
125
+ if (wasRaw) {
126
+ process.stdin.setRawMode(true);
127
+ }
128
+ }
129
+ catch {
130
+ // Ignore - will be set up later in index.ts
131
+ }
132
+ }
108
133
  this.bus = opts.bus;
109
134
  this.outputParser = new OutputParser(opts.bus, opts.cwd);
110
135
  // Ensure temp dir cleanup on abnormal exit (SIGKILL won't fire this,
@@ -178,9 +203,20 @@ export class Shell {
178
203
  setupOutput() {
179
204
  this.ptyProcess.onData((data) => {
180
205
  this.outputParser.processData(data);
181
- if (!this.paused) {
182
- process.stdout.write(data);
206
+ if (this.paused)
207
+ return;
208
+ // During user_shell exec, skip the command echo (first line)
209
+ if (this.echoSkip) {
210
+ const nlIdx = data.indexOf("\n");
211
+ if (nlIdx === -1)
212
+ return;
213
+ this.echoSkip = false;
214
+ const rest = data.slice(nlIdx + 1);
215
+ if (rest)
216
+ process.stdout.write(rest);
217
+ return;
183
218
  }
219
+ process.stdout.write(data);
184
220
  });
185
221
  }
186
222
  setupInput() {
@@ -202,6 +238,7 @@ export class Shell {
202
238
  this.bus.on("agent:processing-done", () => {
203
239
  this.paused = false;
204
240
  this.agentActive = false;
241
+ this.echoSkip = true;
205
242
  this.freshPrompt();
206
243
  });
207
244
  // Permission prompts need stdout unpaused so the interactive UI renders,
@@ -217,10 +254,12 @@ export class Shell {
217
254
  // stdout is paused during agent processing, so PTY output flows through
218
255
  // OutputParser (for OSC detection) but never reaches the terminal.
219
256
  this.bus.onPipeAsync("shell:exec-request", async (payload) => {
257
+ this.echoSkip = true;
258
+ this.paused = false;
259
+ process.stdout.write("\n");
220
260
  const output = await new Promise((resolve, reject) => {
221
261
  const timeout = setTimeout(() => {
222
262
  this.bus.off("shell:command-done", handler);
223
- // Kill any hung command
224
263
  this.ptyProcess.write("\x03");
225
264
  reject(new Error("Shell exec timed out after 30s"));
226
265
  }, 30_000);
@@ -230,10 +269,11 @@ export class Shell {
230
269
  resolve({ output: e.output, cwd: e.cwd });
231
270
  };
232
271
  this.bus.on("shell:command-done", handler);
233
- // Start capture and write to PTY
234
272
  this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
235
273
  this.ptyProcess.write(payload.command + "\r");
236
274
  });
275
+ this.paused = true;
276
+ this.echoSkip = false;
237
277
  return { ...payload, output: output.output, cwd: output.cwd, done: true };
238
278
  });
239
279
  }
package/dist/types.d.ts CHANGED
@@ -2,6 +2,9 @@ import type { EventBus } from "./event-bus.js";
2
2
  import type { ContextManager } from "./context-manager.js";
3
3
  import type { AcpClient } from "./acp-client.js";
4
4
  import type { ColorPalette } from "./utils/palette.js";
5
+ import type { BlockTransformOptions, FencedBlockTransformOptions } from "./utils/stream-transform.js";
6
+ export type { ContentBlock } from "./event-bus.js";
7
+ export type { BlockTransformOptions, FencedBlockTransformOptions } from "./utils/stream-transform.js";
5
8
  export interface AgentShellConfig {
6
9
  agentCommand: string;
7
10
  agentArgs: string[];
@@ -24,6 +27,18 @@ export interface ExtensionContext {
24
27
  quit: () => void;
25
28
  /** Override color palette slots for theming. */
26
29
  setPalette: (overrides: Partial<ColorPalette>) => void;
30
+ /** Register a delimiter-based content transform (e.g. $$...$$ → image). */
31
+ createBlockTransform: (opts: BlockTransformOptions) => void;
32
+ /** Register a fenced block transform (e.g. ```lang...``` → code-block). */
33
+ createFencedBlockTransform: (opts: FencedBlockTransformOptions) => void;
34
+ /** Read extension-namespaced settings from ~/.agent-sh/settings.json. */
35
+ getExtensionSettings: <T extends Record<string, unknown>>(namespace: string, defaults: T) => T;
36
+ /** Register a named handler. */
37
+ define: (name: string, fn: (...args: any[]) => any) => void;
38
+ /** Wrap a named handler. Receives `next` (original) + args. */
39
+ advise: (name: string, wrapper: (next: (...args: any[]) => any, ...args: any[]) => any) => void;
40
+ /** Call a named handler. */
41
+ call: (name: string, ...args: any[]) => any;
27
42
  }
28
43
  export interface TerminalSession {
29
44
  id: string;
@@ -49,6 +64,8 @@ export type Exchange = {
49
64
  exitCode: number | null;
50
65
  outputLines: number;
51
66
  outputBytes: number;
67
+ /** Who initiated this command: "user" (typed) or "agent" (via user_shell). */
68
+ source: "user" | "agent";
52
69
  } | {
53
70
  type: "agent_query";
54
71
  id: number;
@@ -6,7 +6,10 @@ export declare const RED = "\u001B[31m";
6
6
  export declare const GRAY = "\u001B[90m";
7
7
  export declare const BOLD = "\u001B[1m";
8
8
  export declare const RESET = "\u001B[0m";
9
- /** Measure visible string length, excluding SGR (color/style) sequences. */
9
+ /**
10
+ * Measure visible string length in terminal columns.
11
+ * Excludes SGR (color/style) sequences and accounts for CJK double-width chars.
12
+ */
10
13
  export declare function visibleLen(str: string): number;
11
14
  /** Strip all ANSI escape sequences (SGR, OSC, CSI, private mode) and carriage returns. */
12
15
  export declare function stripAnsi(str: string): string;
@@ -8,9 +8,67 @@ export const GRAY = "\x1b[90m";
8
8
  export const BOLD = "\x1b[1m";
9
9
  export const RESET = "\x1b[0m";
10
10
  // ── ANSI utility functions ───────────────────────────────────
11
- /** Measure visible string length, excluding SGR (color/style) sequences. */
11
+ /**
12
+ * Check if a Unicode code point is a wide character (CJK, fullwidth, emoji, etc.)
13
+ * Returns 2 for wide chars, 1 for normal chars.
14
+ */
15
+ function charWidth(codePoint) {
16
+ // CJK Unified Ideographs
17
+ if (codePoint >= 0x4e00 && codePoint <= 0x9fff)
18
+ return 2;
19
+ // CJK Unified Ideographs Extension A
20
+ if (codePoint >= 0x3400 && codePoint <= 0x4dbf)
21
+ return 2;
22
+ // Hangul Syllables
23
+ if (codePoint >= 0xac00 && codePoint <= 0xd7af)
24
+ return 2;
25
+ // CJK Unified Ideographs Extension B-F and other CJK blocks
26
+ if (codePoint >= 0x20000 && codePoint <= 0x2ebef)
27
+ return 2;
28
+ // Fullwidth ASCII variants
29
+ if (codePoint >= 0xff01 && codePoint <= 0xff5e)
30
+ return 2;
31
+ // Halfwidth Katakana (actually narrow, skip)
32
+ // Fullwidth bracket forms
33
+ if (codePoint >= 0xff5f && codePoint <= 0xff60)
34
+ return 2;
35
+ // Fullwidth symbol variants
36
+ if (codePoint >= 0xffe0 && codePoint <= 0xffe6)
37
+ return 2;
38
+ // Japanese hiragana and katakana
39
+ if (codePoint >= 0x3040 && codePoint <= 0x309f)
40
+ return 2;
41
+ if (codePoint >= 0x30a0 && codePoint <= 0x30ff)
42
+ return 2;
43
+ // CJK symbols and punctuation
44
+ if (codePoint >= 0x3000 && codePoint <= 0x303f)
45
+ return 2;
46
+ // Enclosed CJK letters and months
47
+ if (codePoint >= 0x3200 && codePoint <= 0x32ff)
48
+ return 2;
49
+ // CJK compatibility
50
+ if (codePoint >= 0x3300 && codePoint <= 0x33ff)
51
+ return 2;
52
+ // Hangul Jamo
53
+ if (codePoint >= 0x1100 && codePoint <= 0x11ff)
54
+ return 2;
55
+ // Hangul compatibility Jamo
56
+ if (codePoint >= 0x3130 && codePoint <= 0x318f)
57
+ return 2;
58
+ return 1;
59
+ }
60
+ /**
61
+ * Measure visible string length in terminal columns.
62
+ * Excludes SGR (color/style) sequences and accounts for CJK double-width chars.
63
+ */
12
64
  export function visibleLen(str) {
13
- return str.replace(/\x1b\[[^m]*m/g, "").length;
65
+ // First strip ANSI escape sequences
66
+ const cleanStr = str.replace(/\x1b\[[^m]*m/g, "");
67
+ let width = 0;
68
+ for (const char of cleanStr) {
69
+ width += charWidth(char.codePointAt(0) ?? 0);
70
+ }
71
+ return width;
14
72
  }
15
73
  /** Strip all ANSI escape sequences (SGR, OSC, CSI, private mode) and carriage returns. */
16
74
  export function stripAnsi(str) {
@@ -22,7 +22,8 @@ const BORDERS = {
22
22
  * @returns Array of terminal-ready lines with borders
23
23
  */
24
24
  export function renderBoxFrame(content, opts) {
25
- const { width, borderColor = p.dim } = opts;
25
+ const { width: rawWidth, borderColor = p.dim } = opts;
26
+ const width = Math.max(6, rawWidth);
26
27
  const style = opts.style ?? "rounded";
27
28
  const b = BORDERS[style];
28
29
  const bc = borderColor;
@@ -320,7 +320,7 @@ function renderSplit(diff, opts) {
320
320
  const lang = useSyntax ? detectLanguage(opts.filePath) : undefined;
321
321
  const totalWidth = opts.width;
322
322
  // 3 chars for " │ " separator
323
- const colWidth = Math.floor((totalWidth - 3) / 2);
323
+ const colWidth = Math.max(1, Math.floor((totalWidth - 3) / 2));
324
324
  // Compute max line number width
325
325
  let maxNo = 0;
326
326
  for (const hunk of diff.hunks) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Differential frame renderer.
3
+ *
4
+ * Accepts a frame (string[]) and writes only the lines that changed
5
+ * compared to the previous frame. Designed for scrolling content
6
+ * (not full-screen ownership like pi-tui).
7
+ *
8
+ * Fast paths:
9
+ * 1. First render → write everything
10
+ * 2. Append-only → write only new lines
11
+ * 3. Last line changed → \r overwrite (for spinner / partial streaming)
12
+ * 4. General diff → cursor-up, rewrite changed region, cursor-down
13
+ */
14
+ import type { OutputWriter } from "./output-writer.js";
15
+ export declare class FrameRenderer {
16
+ private writer;
17
+ private prevLines;
18
+ constructor(writer: OutputWriter);
19
+ /**
20
+ * Render a new frame, writing only the diff to the output.
21
+ * Each line in `lines` should NOT include a trailing newline.
22
+ */
23
+ update(lines: string[]): void;
24
+ /** Reset state — next update will be treated as a first render. */
25
+ reset(): void;
26
+ }
@@ -0,0 +1,76 @@
1
+ export class FrameRenderer {
2
+ writer;
3
+ prevLines = [];
4
+ constructor(writer) {
5
+ this.writer = writer;
6
+ }
7
+ /**
8
+ * Render a new frame, writing only the diff to the output.
9
+ * Each line in `lines` should NOT include a trailing newline.
10
+ */
11
+ update(lines) {
12
+ const prev = this.prevLines;
13
+ if (prev.length === 0) {
14
+ // Fast path 1: first render
15
+ for (const line of lines) {
16
+ this.writer.write(line + "\n");
17
+ }
18
+ this.prevLines = lines.slice();
19
+ return;
20
+ }
21
+ // Find first and last changed indices
22
+ const minLen = Math.min(prev.length, lines.length);
23
+ let firstChanged = -1;
24
+ let lastChanged = -1;
25
+ for (let i = 0; i < minLen; i++) {
26
+ if (prev[i] !== lines[i]) {
27
+ if (firstChanged === -1)
28
+ firstChanged = i;
29
+ lastChanged = i;
30
+ }
31
+ }
32
+ // Check for appended or removed lines
33
+ const appended = lines.length > prev.length;
34
+ const truncated = lines.length < prev.length;
35
+ if (firstChanged === -1 && !appended && !truncated) {
36
+ // No changes at all
37
+ this.prevLines = lines.slice();
38
+ return;
39
+ }
40
+ if (firstChanged === -1 && appended) {
41
+ // Fast path 2: only new lines appended, existing unchanged
42
+ for (let i = prev.length; i < lines.length; i++) {
43
+ this.writer.write(lines[i] + "\n");
44
+ }
45
+ this.prevLines = lines.slice();
46
+ return;
47
+ }
48
+ // General diff: move cursor up to first changed line, rewrite
49
+ const linesFromBottom = prev.length - (firstChanged === -1 ? prev.length : firstChanged);
50
+ if (linesFromBottom > 0) {
51
+ this.writer.write(`\x1b[${linesFromBottom}A`); // cursor up
52
+ }
53
+ this.writer.write("\r"); // start of line
54
+ // Rewrite from firstChanged to end of new frame
55
+ const start = firstChanged === -1 ? prev.length : firstChanged;
56
+ for (let i = start; i < lines.length; i++) {
57
+ this.writer.write(`\x1b[2K${lines[i]}\n`); // clear line + write + newline
58
+ }
59
+ // If new frame is shorter, clear remaining old lines
60
+ if (truncated) {
61
+ for (let i = lines.length; i < prev.length; i++) {
62
+ this.writer.write("\x1b[2K\n");
63
+ }
64
+ // Move cursor back up to end of new content
65
+ const extra = prev.length - lines.length;
66
+ if (extra > 0) {
67
+ this.writer.write(`\x1b[${extra}A`);
68
+ }
69
+ }
70
+ this.prevLines = lines.slice();
71
+ }
72
+ /** Reset state — next update will be treated as a first render. */
73
+ reset() {
74
+ this.prevLines = [];
75
+ }
76
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Named handler registry with Emacs-style advice.
3
+ *
4
+ * Built-in extensions register named handlers with `define`.
5
+ * User extensions wrap them with `advise` — each advisor receives
6
+ * `next` (the previous handler) and decides whether to call it.
7
+ *
8
+ * registry.define("render:code-block", (lang, code) => highlight(lang, code));
9
+ *
10
+ * registry.advise("render:code-block", (next, lang, code) => {
11
+ * if (lang === "latex") return renderLatex(code);
12
+ * return next(lang, code); // call original
13
+ * });
14
+ */
15
+ export declare class HandlerRegistry {
16
+ private handlers;
17
+ /**
18
+ * Register a named handler. If one already exists, it's replaced.
19
+ */
20
+ define(name: string, fn: (...args: any[]) => any): void;
21
+ /**
22
+ * Wrap a named handler with advice. The wrapper receives the
23
+ * previous handler as `next` and all original arguments.
24
+ *
25
+ * - Call `next(...args)` to invoke the original (around/before/after)
26
+ * - Don't call `next` to replace entirely (override)
27
+ * - Call `next` conditionally to wrap (around)
28
+ *
29
+ * Multiple advisors chain: each wraps the previous one.
30
+ * If no handler exists yet, `next` is a no-op.
31
+ */
32
+ advise(name: string, wrapper: (next: (...args: any[]) => any, ...args: any[]) => any): void;
33
+ /**
34
+ * Call a named handler. Returns undefined if no handler is registered.
35
+ */
36
+ call(name: string, ...args: any[]): any;
37
+ /**
38
+ * Check if a named handler exists.
39
+ */
40
+ has(name: string): boolean;
41
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Named handler registry with Emacs-style advice.
3
+ *
4
+ * Built-in extensions register named handlers with `define`.
5
+ * User extensions wrap them with `advise` — each advisor receives
6
+ * `next` (the previous handler) and decides whether to call it.
7
+ *
8
+ * registry.define("render:code-block", (lang, code) => highlight(lang, code));
9
+ *
10
+ * registry.advise("render:code-block", (next, lang, code) => {
11
+ * if (lang === "latex") return renderLatex(code);
12
+ * return next(lang, code); // call original
13
+ * });
14
+ */
15
+ /* eslint-disable @typescript-eslint/no-explicit-any */
16
+ export class HandlerRegistry {
17
+ handlers = new Map();
18
+ /**
19
+ * Register a named handler. If one already exists, it's replaced.
20
+ */
21
+ define(name, fn) {
22
+ this.handlers.set(name, fn);
23
+ }
24
+ /**
25
+ * Wrap a named handler with advice. The wrapper receives the
26
+ * previous handler as `next` and all original arguments.
27
+ *
28
+ * - Call `next(...args)` to invoke the original (around/before/after)
29
+ * - Don't call `next` to replace entirely (override)
30
+ * - Call `next` conditionally to wrap (around)
31
+ *
32
+ * Multiple advisors chain: each wraps the previous one.
33
+ * If no handler exists yet, `next` is a no-op.
34
+ */
35
+ advise(name, wrapper) {
36
+ const original = this.handlers.get(name) ?? (() => undefined);
37
+ this.handlers.set(name, (...args) => wrapper(original, ...args));
38
+ }
39
+ /**
40
+ * Call a named handler. Returns undefined if no handler is registered.
41
+ */
42
+ call(name, ...args) {
43
+ const fn = this.handlers.get(name);
44
+ return fn?.(...args);
45
+ }
46
+ /**
47
+ * Check if a named handler exists.
48
+ */
49
+ has(name) {
50
+ return this.handlers.has(name);
51
+ }
52
+ }