agent-sh 0.9.0 → 0.10.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 (88) hide show
  1. package/README.md +25 -30
  2. package/dist/agent/agent-loop.d.ts +43 -6
  3. package/dist/agent/agent-loop.js +817 -157
  4. package/dist/agent/conversation-state.d.ts +72 -21
  5. package/dist/agent/conversation-state.js +364 -151
  6. package/dist/agent/history-file.d.ts +13 -4
  7. package/dist/agent/history-file.js +110 -36
  8. package/dist/agent/nuclear-form.d.ts +28 -3
  9. package/dist/agent/nuclear-form.js +84 -3
  10. package/dist/agent/skills.d.ts +2 -4
  11. package/dist/agent/skills.js +10 -4
  12. package/dist/agent/subagent.d.ts +23 -0
  13. package/dist/agent/subagent.js +53 -11
  14. package/dist/agent/system-prompt.d.ts +34 -1
  15. package/dist/agent/system-prompt.js +96 -47
  16. package/dist/agent/token-budget.d.ts +10 -13
  17. package/dist/agent/token-budget.js +6 -46
  18. package/dist/agent/tool-protocol.d.ts +23 -1
  19. package/dist/agent/tool-protocol.js +169 -4
  20. package/dist/agent/tools/bash.js +3 -3
  21. package/dist/agent/tools/edit-file.js +9 -6
  22. package/dist/agent/tools/glob.js +4 -2
  23. package/dist/agent/tools/grep.js +27 -3
  24. package/dist/agent/tools/ls.js +5 -6
  25. package/dist/agent/types.d.ts +1 -2
  26. package/dist/context-manager.d.ts +16 -19
  27. package/dist/context-manager.js +48 -152
  28. package/dist/core.js +27 -6
  29. package/dist/event-bus.d.ts +59 -3
  30. package/dist/executor.d.ts +4 -3
  31. package/dist/executor.js +18 -15
  32. package/dist/extension-loader.js +75 -17
  33. package/dist/extensions/agent-backend.d.ts +8 -7
  34. package/dist/extensions/agent-backend.js +72 -50
  35. package/dist/extensions/index.js +0 -2
  36. package/dist/extensions/slash-commands.js +14 -9
  37. package/dist/extensions/tui-renderer.js +67 -80
  38. package/dist/index.js +25 -6
  39. package/dist/settings.d.ts +39 -16
  40. package/dist/settings.js +51 -11
  41. package/dist/shell/input-handler.d.ts +2 -1
  42. package/dist/shell/input-handler.js +84 -76
  43. package/dist/shell/shell.js +19 -2
  44. package/dist/types.d.ts +15 -0
  45. package/dist/utils/ansi.d.ts +7 -0
  46. package/dist/utils/ansi.js +69 -8
  47. package/dist/utils/box-frame.js +8 -2
  48. package/dist/utils/compositor.d.ts +5 -0
  49. package/dist/utils/compositor.js +31 -3
  50. package/dist/utils/diff-renderer.d.ts +9 -0
  51. package/dist/utils/diff-renderer.js +221 -143
  52. package/dist/utils/diff.d.ts +21 -2
  53. package/dist/utils/diff.js +165 -89
  54. package/dist/utils/handler-registry.d.ts +5 -0
  55. package/dist/utils/handler-registry.js +6 -0
  56. package/dist/utils/line-editor.d.ts +11 -1
  57. package/dist/utils/line-editor.js +44 -5
  58. package/dist/utils/markdown.js +23 -8
  59. package/dist/utils/package-version.d.ts +1 -0
  60. package/dist/utils/package-version.js +10 -0
  61. package/dist/utils/shell-output-spill.d.ts +2 -0
  62. package/dist/utils/shell-output-spill.js +81 -0
  63. package/dist/utils/tool-display.d.ts +1 -1
  64. package/dist/utils/tool-display.js +4 -4
  65. package/examples/extensions/ash-acp-bridge/src/index.ts +4 -1
  66. package/examples/extensions/ash-mcp-bridge/index.ts +13 -3
  67. package/examples/extensions/claude-code-bridge/README.md +14 -0
  68. package/examples/extensions/claude-code-bridge/index.ts +204 -145
  69. package/examples/extensions/claude-code-bridge/package.json +1 -0
  70. package/examples/extensions/interactive-prompts.ts +39 -25
  71. package/examples/extensions/overlay-agent.ts +3 -3
  72. package/examples/extensions/peer-mesh.ts +115 -0
  73. package/examples/extensions/pi-bridge/README.md +16 -0
  74. package/examples/extensions/pi-bridge/index.ts +9 -155
  75. package/examples/extensions/questionnaire.ts +16 -5
  76. package/examples/extensions/subagents.ts +19 -4
  77. package/examples/extensions/terminal-buffer.ts +163 -0
  78. package/examples/extensions/user-shell.ts +136 -0
  79. package/examples/extensions/web-access.ts +8 -0
  80. package/package.json +36 -2
  81. package/dist/agent/tools/display.d.ts +0 -13
  82. package/dist/agent/tools/display.js +0 -70
  83. package/dist/agent/tools/user-shell.d.ts +0 -13
  84. package/dist/agent/tools/user-shell.js +0 -87
  85. package/dist/extensions/shell-recall.d.ts +0 -9
  86. package/dist/extensions/shell-recall.js +0 -8
  87. package/dist/extensions/terminal-buffer.d.ts +0 -14
  88. package/dist/extensions/terminal-buffer.js +0 -134
@@ -4,7 +4,7 @@ import { visibleLen } from "../utils/ansi.js";
4
4
  import { palette as p } from "../utils/palette.js";
5
5
  import { LineEditor } from "../utils/line-editor.js";
6
6
  import { CONFIG_DIR, getSettings } from "../settings.js";
7
- const HISTORY_FILE = path.join(CONFIG_DIR, "history");
7
+ const HISTORY_FILE = path.join(CONFIG_DIR, "input-history");
8
8
  export class InputHandler {
9
9
  ctx;
10
10
  lineBuffer = "";
@@ -20,7 +20,8 @@ export class InputHandler {
20
20
  history = [];
21
21
  historyIndex = -1; // -1 = not browsing history
22
22
  savedBuffer = ""; // buffer saved when entering history
23
- promptWrappedLines = 0; // extra lines from terminal wrapping
23
+ cursorRowsBelow = 0; // rows from prompt top to cursor row
24
+ cursorTermCol = 1; // 1-indexed terminal column of cursor
24
25
  escapeTimer = null;
25
26
  bus;
26
27
  onShowAgentInfo;
@@ -72,9 +73,10 @@ export class InputHandler {
72
73
  /** Write the mode prompt line with cursor at the correct position. */
73
74
  writeModePromptLine(showBuffer = true) {
74
75
  const termW = process.stdout.columns || 80;
75
- // Move cursor to the start of the prompt area (first line of wrapped content)
76
- if (this.promptWrappedLines > 0) {
77
- process.stdout.write(`\x1b[${this.promptWrappedLines}A`);
76
+ // Move cursor to the start of the prompt area.
77
+ // We know exactly how many rows below the top the cursor currently sits.
78
+ if (this.cursorRowsBelow > 0) {
79
+ process.stdout.write(`\x1b[${this.cursorRowsBelow}A`);
78
80
  }
79
81
  // Clear from here to end of screen — removes current + all wrapped lines below
80
82
  process.stdout.write("\r\x1b[J");
@@ -88,37 +90,36 @@ export class InputHandler {
88
90
  const promptVisLen = visibleLen(infoPrefix) + visibleLen(icon) + 1; // icon + space
89
91
  const display = showBuffer ? this.editor.displayText : "";
90
92
  const dCursor = showBuffer ? this.editor.displayCursor : 0;
91
- if (!showBuffer || !display.includes("\n")) {
92
- // Single-line: simple rendering
93
- const bufferText = showBuffer ? p.accent + display + p.reset : "";
94
- process.stdout.write(promptPrefix + bufferText);
95
- const bufferVisLen = display.length;
96
- const totalVisLen = promptVisLen + bufferVisLen;
97
- this.promptWrappedLines = totalVisLen > 0 ? Math.floor((totalVisLen - 1) / termW) : 0;
98
- // Position cursor within the buffer
99
- if (showBuffer && dCursor < display.length) {
100
- const charsAfterCursor = display.length - dCursor;
101
- process.stdout.write(`\x1b[${charsAfterCursor}D`);
102
- }
93
+ if (!showBuffer) {
94
+ // No buffer — just write the prompt prefix, cursor stays at end
95
+ process.stdout.write(promptPrefix);
96
+ const N = promptVisLen;
97
+ this.cursorRowsBelow = N > 0 ? Math.ceil(N / termW) - 1 : 0;
98
+ this.cursorTermCol = N === 0 ? 1 : (N % termW === 0 ? termW : (N % termW) + 1);
99
+ }
100
+ else if (!display.includes("\n")) {
101
+ // Single-line: write up to cursor, save, write rest, restore.
102
+ // The terminal handles all wrapping — no manual row/col math needed.
103
+ const before = display.slice(0, dCursor);
104
+ const after = display.slice(dCursor);
105
+ process.stdout.write(promptPrefix + p.accent + before + p.reset +
106
+ "\x1b7" + // DECSC — save cursor position
107
+ p.accent + after + p.reset +
108
+ "\x1b8" // DECRC — restore cursor position
109
+ );
110
+ // cursorRowsBelow is distance from cursor (restored by DECRC, sitting at
111
+ // the cursor col) back up to the prompt's top row. Next redraw uses it
112
+ // with \x1b[${n}A then \x1b[J — moving past the top scrolls the screen.
113
+ const cursorVisCol = promptVisLen + visibleLen(before);
114
+ this.cursorRowsBelow = cursorVisCol > 0 ? Math.ceil(cursorVisCol / termW) - 1 : 0;
115
+ this.cursorTermCol = cursorVisCol === 0 ? 1 : (cursorVisCol % termW === 0 ? termW : (cursorVisCol % termW) + 1);
103
116
  }
104
117
  else {
105
- // Multi-line: render each line with continuation indent
118
+ // Multi-line: render each line with continuation indent.
119
+ // Same save/restore strategy — cursor position is never computed.
106
120
  const lines = display.split("\n");
107
121
  const indent = " ".repeat(promptVisLen);
108
- let totalTermLines = 0;
109
- for (let li = 0; li < lines.length; li++) {
110
- const prefix = li === 0 ? promptPrefix : indent;
111
- const prefixVisLen = li === 0 ? promptVisLen : promptVisLen;
112
- const lineText = lines[li];
113
- process.stdout.write(prefix + p.accent + lineText + p.reset);
114
- if (li < lines.length - 1)
115
- process.stdout.write("\n");
116
- // Count terminal lines this logical line occupies
117
- const lineVisLen = prefixVisLen + lineText.length;
118
- totalTermLines += lineVisLen > 0 ? Math.ceil(lineVisLen / termW) : 1;
119
- }
120
- this.promptWrappedLines = totalTermLines - 1;
121
- // Position cursor: find which display line and column the cursor is on
122
+ // Locate cursor: which logical line and offset within it.
122
123
  let charsRemaining = dCursor;
123
124
  let cursorLine = 0;
124
125
  for (let li = 0; li < lines.length; li++) {
@@ -129,31 +130,37 @@ export class InputHandler {
129
130
  charsRemaining -= lines[li].length + 1; // +1 for \n
130
131
  cursorLine = li + 1;
131
132
  }
132
- // Compute terminal rows for cursor positioning (not logical lines)
133
- // Each logical line may wrap across multiple terminal rows.
134
- const cursorColAbs = promptVisLen + charsRemaining;
135
- const cursorTermRow = Math.floor(cursorColAbs / termW);
136
- // Count terminal rows occupied by lines after cursor's logical line
137
- let termRowsAfterCursor = 0;
138
- for (let li = cursorLine + 1; li < lines.length; li++) {
139
- const lineVisLen = promptVisLen + lines[li].length;
140
- termRowsAfterCursor += lineVisLen > 0 ? Math.ceil(lineVisLen / termW) : 1;
141
- }
142
- // Also count remaining terminal rows on cursor's own logical line
143
- const cursorLineVisLen = promptVisLen + lines[cursorLine].length;
144
- const cursorLineTotalRows = cursorLineVisLen > 0 ? Math.ceil(cursorLineVisLen / termW) : 1;
145
- const rowsAfterCursorInLine = cursorLineTotalRows - 1 - cursorTermRow;
146
- const totalRowsFromEnd = termRowsAfterCursor + rowsAfterCursorInLine;
147
- if (totalRowsFromEnd > 0) {
148
- process.stdout.write(`\x1b[${totalRowsFromEnd}A`);
149
- }
150
- const cursorCol = cursorColAbs % termW;
151
- if (cursorCol > 0) {
152
- process.stdout.write(`\r\x1b[${cursorCol}C`);
153
- }
154
- else {
155
- process.stdout.write(`\r`);
133
+ let output = "";
134
+ let cursorRowFromTop = 0;
135
+ let rowsSoFar = 0;
136
+ for (let li = 0; li < lines.length; li++) {
137
+ const prefix = li === 0 ? promptPrefix : indent;
138
+ const lineText = lines[li];
139
+ const lineVisLen = promptVisLen + visibleLen(lineText);
140
+ const lineTermRows = lineVisLen > 0 ? Math.ceil(lineVisLen / termW) : 1;
141
+ if (li === cursorLine) {
142
+ // Split this line at the cursor.
143
+ const before = lineText.slice(0, charsRemaining);
144
+ const after = lineText.slice(charsRemaining);
145
+ output += prefix + p.accent + before + p.reset;
146
+ output += "\x1b7"; // DECSC save cursor position
147
+ output += p.accent + after + p.reset;
148
+ const beforeVisCol = promptVisLen + visibleLen(before);
149
+ cursorRowFromTop = rowsSoFar + (beforeVisCol > 0 ? Math.ceil(beforeVisCol / termW) - 1 : 0);
150
+ this.cursorTermCol = beforeVisCol === 0 ? 1 : (beforeVisCol % termW === 0 ? termW : (beforeVisCol % termW) + 1);
151
+ }
152
+ else {
153
+ output += prefix + p.accent + lineText + p.reset;
154
+ }
155
+ if (li < lines.length - 1)
156
+ output += "\n";
157
+ rowsSoFar += lineTermRows;
156
158
  }
159
+ process.stdout.write(output + "\x1b8"); // DECRC — restore cursor position
160
+ // Distance from cursor (where DECRC lands) back to the top row. Next
161
+ // redraw moves up by this and clears to end-of-screen — \x1b[J handles
162
+ // everything below, including rows after the cursor's logical line.
163
+ this.cursorRowsBelow = cursorRowFromTop;
157
164
  }
158
165
  }
159
166
  handleInput(data) {
@@ -281,15 +288,17 @@ export class InputHandler {
281
288
  // Disable kitty keyboard protocol and bracket paste mode
282
289
  process.stdout.write("\x1b[<u\x1b[?2004l");
283
290
  this.clearPromptArea();
291
+ this.cursorRowsBelow = 0;
292
+ this.cursorTermCol = 1;
284
293
  this.printPrompt();
285
294
  }
286
295
  /** Move to the start of the prompt area and clear everything below. */
287
296
  clearPromptArea() {
288
- if (this.promptWrappedLines > 0) {
289
- process.stdout.write(`\x1b[${this.promptWrappedLines}A`);
297
+ if (this.cursorRowsBelow > 0) {
298
+ process.stdout.write(`\x1b[${this.cursorRowsBelow}A`);
290
299
  }
291
300
  process.stdout.write("\r\x1b[J");
292
- this.promptWrappedLines = 0;
301
+ this.cursorRowsBelow = 0;
293
302
  }
294
303
  printPrompt() {
295
304
  this.ctx.redrawPrompt();
@@ -363,16 +372,10 @@ export class InputHandler {
363
372
  if (this.autocompleteLines > 0) {
364
373
  process.stdout.write(`\x1b[${this.autocompleteLines}A`);
365
374
  }
366
- // Reposition cursor: must match the layout in writeModePromptLine()
367
- const agentInfo = this.onShowAgentInfo();
368
- const indicator = this.activeMode?.indicator ?? "●";
369
- const infoPrefix = agentInfo.info
370
- ? `${agentInfo.info} ${indicator} `
371
- : `${indicator} `;
372
- const icon = this.activeMode?.promptIcon ?? "❯";
373
- const promptVisLen = visibleLen(infoPrefix) + visibleLen(icon) + 1;
374
- const col = promptVisLen + this.editor.displayCursor;
375
- process.stdout.write(`\r\x1b[${col}C`);
375
+ // Restore cursor column use explicit column set instead of DECRC
376
+ // because writing \n above may have scrolled the terminal, which
377
+ // invalidates the absolute position saved by DECSC.
378
+ process.stdout.write(`\x1b[${this.cursorTermCol}G`);
376
379
  }
377
380
  applyAutocomplete() {
378
381
  if (!this.autocompleteActive || this.autocompleteItems.length === 0)
@@ -407,11 +410,12 @@ export class InputHandler {
407
410
  clearAutocompleteLines() {
408
411
  if (this.autocompleteLines <= 0)
409
412
  return;
410
- process.stdout.write("\x1b7"); // save cursor
413
+ // Use CSI B (cursor down, bounded) instead of \n to avoid scroll
411
414
  for (let i = 0; i < this.autocompleteLines; i++) {
412
- process.stdout.write("\n\x1b[2K"); // move down, clear line
415
+ process.stdout.write("\x1b[B\x1b[2K"); // move down, clear line
413
416
  }
414
- process.stdout.write("\x1b8"); // restore cursor
417
+ // Move back up and restore column with relative movement (scroll-safe)
418
+ process.stdout.write(`\x1b[${this.autocompleteLines}A\x1b[${this.cursorTermCol}G`);
415
419
  this.autocompleteLines = 0;
416
420
  }
417
421
  handleModeInput(data) {
@@ -475,13 +479,20 @@ export class InputHandler {
475
479
  const currentMode = this.activeMode;
476
480
  this.activeMode = null;
477
481
  this.editor.clear();
482
+ this.cursorRowsBelow = 0;
483
+ this.cursorTermCol = 1;
478
484
  this.dismissAutocomplete();
479
485
  if (query && query.startsWith("/")) {
480
486
  const spaceIdx = query.indexOf(" ");
481
487
  const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
482
488
  const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
483
489
  this.bus.emit("command:execute", { name, args });
484
- this.ctx.freshPrompt();
490
+ if (currentMode.returnToSelf) {
491
+ this.enterMode(currentMode);
492
+ }
493
+ else {
494
+ this.ctx.freshPrompt();
495
+ }
485
496
  }
486
497
  else if (query) {
487
498
  this.pendingReturnMode = currentMode.returnToSelf ? currentMode.id : null;
@@ -510,9 +521,6 @@ export class InputHandler {
510
521
  this.applyAutocomplete();
511
522
  }
512
523
  break;
513
- case "shift+tab":
514
- this.bus.emit("config:cycle", {});
515
- break;
516
524
  case "arrow-up":
517
525
  if (this.autocompleteActive) {
518
526
  this.autocompleteIndex =
@@ -197,6 +197,11 @@ export class Shell {
197
197
  * For bash, falls back to sending \n for a fresh prompt cycle.
198
198
  */
199
199
  redrawPrompt() {
200
+ // A stale echoSkip or paused flag (left over from handleProcessingDone
201
+ // re-entering a mode) would swallow the redrawn prompt and make the
202
+ // terminal appear frozen. Reset both before emitting.
203
+ this.echoSkip = false;
204
+ this.paused = false;
200
205
  const result = this.bus.emitPipe("shell:redraw-prompt", {
201
206
  cwd: this.outputParser.getCwd(),
202
207
  handled: false,
@@ -277,9 +282,13 @@ export class Shell {
277
282
  this.paused = true;
278
283
  });
279
284
  this.handlers.define("shell:on-processing-done", () => {
280
- this.paused = false;
281
285
  this.agentActive = false;
286
+ // If handleProcessingDone re-entered a mode, leave stdout paused so
287
+ // stale PTY output doesn't overwrite the mode prompt (exitMode →
288
+ // redrawPrompt will unpause). Setting echoSkip here would swallow
289
+ // that PTY output since no \n was sent.
282
290
  if (!this.inputHandler.handleProcessingDone()) {
291
+ this.paused = false;
283
292
  if (this.freshPrompt()) {
284
293
  this.echoSkip = true;
285
294
  }
@@ -317,11 +326,19 @@ export class Shell {
317
326
  const handler = (e) => {
318
327
  clearTimeout(timeout);
319
328
  this.bus.off("shell:command-done", handler);
329
+ // Re-pause stdout so the prompt text following the marker doesn't
330
+ // leak to the terminal while the agent is still processing.
331
+ this.paused = true;
320
332
  resolve({ output: e.output, cwd: e.cwd, exitCode: e.exitCode });
321
333
  };
322
334
  this.bus.on("shell:command-done", handler);
323
335
  this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
324
- this.ptyProcess.write(payload.command + "\r");
336
+ // Collapse literal newlines to spaces so the PTY receives a single-line
337
+ // command. Multi-line commands (e.g. git commit -m "...\n...") would
338
+ // cause the shell to execute prematurely, producing garbled output from
339
+ // syntax highlighting plugins (zsh syntax highlighting, etc).
340
+ const oneLine = payload.command.replace(/\n/g, " ");
341
+ this.ptyProcess.write(oneLine + "\r");
325
342
  });
326
343
  this.paused = true;
327
344
  this.echoSkip = false;
package/dist/types.d.ts CHANGED
@@ -80,6 +80,12 @@ export interface ExtensionContext {
80
80
  createFencedBlockTransform: (opts: FencedBlockTransformOptions) => void;
81
81
  /** Read extension-namespaced settings from ~/.agent-sh/settings.json. */
82
82
  getExtensionSettings: <T extends Record<string, unknown>>(namespace: string, defaults: T) => T;
83
+ /**
84
+ * Get (and lazily create) a per-extension storage directory under
85
+ * ~/.agent-sh/<namespace>/. Returns the absolute path. Lets extensions
86
+ * persist state without each one re-deriving the location.
87
+ */
88
+ getStoragePath: (namespace: string) => string;
83
89
  /** Register a slash command available in any input mode. */
84
90
  registerCommand: (name: string, description: string, handler: (args: string) => Promise<void> | void) => void;
85
91
  /** Register a tool for the built-in agent. No-op when using bridge backends. */
@@ -92,12 +98,18 @@ export interface ExtensionContext {
92
98
  registerInstruction: (name: string, text: string) => void;
93
99
  /** Remove a named instruction block from the system prompt. */
94
100
  removeInstruction: (name: string) => void;
101
+ /** Register a skill (on-demand reference material) for the agent. */
102
+ registerSkill: (name: string, description: string, filePath: string) => void;
103
+ /** Remove a registered skill by name. */
104
+ removeSkill: (name: string) => void;
95
105
  /** Register a named handler. */
96
106
  define: (name: string, fn: (...args: any[]) => any) => void;
97
107
  /** Wrap a named handler. Receives `next` (original) + args. Returns an unadvise function. */
98
108
  advise: (name: string, wrapper: (next: (...args: any[]) => any, ...args: any[]) => any) => () => void;
99
109
  /** Call a named handler. */
100
110
  call: (name: string, ...args: any[]) => any;
111
+ /** Names of all registered handlers — for diagnostic / introspection use. */
112
+ list: () => string[];
101
113
  /**
102
114
  * Shared headless terminal buffer mirroring PTY output.
103
115
  * Lazily created on first access. Returns null if @xterm/headless is not installed.
@@ -146,12 +158,15 @@ export type Exchange = {
146
158
  timestamp: number;
147
159
  cwd: string;
148
160
  command: string;
161
+ /** In-context representation: full text if short, head+tail+path stub if spilled. */
149
162
  output: string;
150
163
  exitCode: number | null;
151
164
  outputLines: number;
152
165
  outputBytes: number;
153
166
  /** Who initiated this command: "user" (typed) or "agent" (via user_shell). */
154
167
  source: "user" | "agent";
168
+ /** Path to the tempfile holding the full captured output, if spilled. */
169
+ spillPath?: string;
155
170
  } | {
156
171
  type: "agent_query";
157
172
  id: number;
@@ -6,6 +6,13 @@ 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
+ /**
10
+ * Check if a Unicode code point is a wide character (CJK, fullwidth, emoji, etc.)
11
+ * Returns 2 for wide chars, 1 for normal chars, 0 for combining chars.
12
+ *
13
+ * Based on East Asian Width and Unicode categories.
14
+ */
15
+ export declare function charWidth(codePoint: number): number;
9
16
  /**
10
17
  * Measure visible string length in terminal columns.
11
18
  * Excludes SGR (color/style) sequences and accounts for CJK double-width chars.
@@ -10,9 +10,54 @@ export const RESET = "\x1b[0m";
10
10
  // ── ANSI utility functions ───────────────────────────────────
11
11
  /**
12
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.
13
+ * Returns 2 for wide chars, 1 for normal chars, 0 for combining chars.
14
+ *
15
+ * Based on East Asian Width and Unicode categories.
14
16
  */
15
- function charWidth(codePoint) {
17
+ export function charWidth(codePoint) {
18
+ // Combining characters (zero width)
19
+ if (codePoint >= 0x0300 && codePoint <= 0x036f)
20
+ return 0; // Combining Diacritical Marks
21
+ if (codePoint >= 0x1ab0 && codePoint <= 0x1aff)
22
+ return 0; // Combining Diacritical Marks Extended
23
+ if (codePoint >= 0x1dc0 && codePoint <= 0x1dff)
24
+ return 0; // Combining Diacritical Marks Supplement
25
+ if (codePoint >= 0x20d0 && codePoint <= 0x20ff)
26
+ return 0; // Combining Diacritical Marks for Symbols
27
+ if (codePoint >= 0xfe20 && codePoint <= 0xfe2f)
28
+ return 0; // Combining Half Marks
29
+ if (codePoint >= 0xfe00 && codePoint <= 0xfe0f)
30
+ return 0; // Variation Selectors
31
+ if (codePoint >= 0xe0100 && codePoint <= 0xe01ef)
32
+ return 0; // Variation Selectors Supplement
33
+ // Emoji and symbols that render as wide (2 columns)
34
+ // Emoji presentation sequences and keycap
35
+ if (codePoint === 0x20e3)
36
+ return 2; // Combining Enclosing Keycap
37
+ // Emoji blocks
38
+ if (codePoint >= 0x1f600 && codePoint <= 0x1f64f)
39
+ return 2; // Emoticons
40
+ if (codePoint >= 0x1f300 && codePoint <= 0x1f5ff)
41
+ return 2; // Misc Symbols and Pictographs
42
+ if (codePoint >= 0x1f680 && codePoint <= 0x1f6ff)
43
+ return 2; // Transport and Map
44
+ if (codePoint >= 0x1f700 && codePoint <= 0x1f77f)
45
+ return 2; // Alchemical Symbols
46
+ if (codePoint >= 0x1f780 && codePoint <= 0x1f7ff)
47
+ return 2; // Geometric Shapes Extended
48
+ if (codePoint >= 0x1f800 && codePoint <= 0x1f8ff)
49
+ return 2; // Supplemental Arrows-C
50
+ if (codePoint >= 0x1f900 && codePoint <= 0x1f9ff)
51
+ return 2; // Supplemental Symbols and Pictographs
52
+ if (codePoint >= 0x1fa00 && codePoint <= 0x1faff)
53
+ return 2; // Chess Symbols, Symbols and Pictographs Extended-A
54
+ // NOTE: 0x2300-0x23ff (Misc Technical), 0x2600-0x26ff (Misc Symbols),
55
+ // and 0x2700-0x27bf (Dingbats) are intentionally NOT width 2 — these ranges
56
+ // contain mostly "Ambiguous" width characters that render as 1 column in
57
+ // non-CJK terminal locales (e.g. ❯, ⌘, ★, ♦).
58
+ // Regional indicator symbols (flag emoji components)
59
+ if (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff)
60
+ return 2;
16
61
  // CJK Unified Ideographs
17
62
  if (codePoint >= 0x4e00 && codePoint <= 0x9fff)
18
63
  return 2;
@@ -28,7 +73,6 @@ function charWidth(codePoint) {
28
73
  // Fullwidth ASCII variants
29
74
  if (codePoint >= 0xff01 && codePoint <= 0xff5e)
30
75
  return 2;
31
- // Halfwidth Katakana (actually narrow, skip)
32
76
  // Fullwidth bracket forms
33
77
  if (codePoint >= 0xff5f && codePoint <= 0xff60)
34
78
  return 2;
@@ -76,18 +120,35 @@ export function visibleLen(str) {
76
120
  */
77
121
  export function truncateToWidth(str, maxWidth) {
78
122
  const clean = str.replace(/\x1b\[[^m]*m/g, "");
123
+ if (maxWidth <= 0)
124
+ return "";
125
+ // First check if the entire string fits
126
+ let fullWidth = 0;
127
+ for (const char of clean) {
128
+ fullWidth += charWidth(char.codePointAt(0) ?? 0);
129
+ }
130
+ if (fullWidth <= maxWidth)
131
+ return clean;
132
+ // String doesn't fit — truncate with "…"
133
+ // At maxWidth=1 the ellipsis alone fills the budget.
134
+ if (maxWidth === 1)
135
+ return "…";
136
+ // Reserve 1 column for "…", so target content width is maxWidth - 1
137
+ const target = maxWidth - 1;
79
138
  let width = 0;
80
139
  let i = 0;
81
140
  for (const char of clean) {
82
141
  const cw = charWidth(char.codePointAt(0) ?? 0);
83
- if (width + cw > maxWidth - 1) {
84
- // Need room for the "…" (1 column wide)
85
- return clean.slice(0, i) + "…";
86
- }
142
+ if (width + cw > target)
143
+ break;
87
144
  width += cw;
88
145
  i += char.length;
89
146
  }
90
- return clean;
147
+ // If nothing fit (first char is wider than target), just show the ellipsis
148
+ // rather than emit a character that would overflow the budget.
149
+ if (i === 0)
150
+ return "…";
151
+ return clean.slice(0, i) + "…";
91
152
  }
92
153
  /**
93
154
  * Pad a string with spaces to fill `targetWidth` visible columns.
@@ -5,7 +5,7 @@
5
5
  * never writes to stdout. Supports multiple border styles and
6
6
  * optional title/footer sections with dividers.
7
7
  */
8
- import { visibleLen } from "./ansi.js";
8
+ import { visibleLen, truncateToWidth } from "./ansi.js";
9
9
  import { palette as p } from "./palette.js";
10
10
  const BORDERS = {
11
11
  rounded: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", ml: "├", mr: "┤" },
@@ -63,6 +63,12 @@ export function renderBoxFrame(content, opts) {
63
63
  }
64
64
  // ── Helpers ──────────────────────────────────────────────────────
65
65
  function boxLine(text, innerW, v, bc) {
66
- const pad = Math.max(0, innerW - visibleLen(text));
66
+ const textWidth = visibleLen(text);
67
+ if (textWidth > innerW) {
68
+ // Content is too wide — truncate to fit exactly
69
+ const truncated = truncateToWidth(text, innerW);
70
+ return `${bc}${v}${p.reset} ${truncated} ${bc}${v}${p.reset}`;
71
+ }
72
+ const pad = innerW - textWidth;
67
73
  return `${bc}${v}${p.reset} ${text}${" ".repeat(pad)} ${bc}${v}${p.reset}`;
68
74
  }
@@ -24,6 +24,7 @@
24
24
  * compositor.redirect("agent:diff", diffPanelSurface);
25
25
  * // "agent:text", "agent:tool" etc. still go to stdout
26
26
  */
27
+ import type { EventBus } from "../event-bus.js";
27
28
  /**
28
29
  * A surface accepts rendered output. Stdout is a surface.
29
30
  * A floating panel's content area is a surface. A test buffer is a surface.
@@ -56,7 +57,11 @@ export declare class StdoutSurface implements RenderSurface {
56
57
  export declare class DefaultCompositor implements Compositor {
57
58
  private defaults;
58
59
  private overrides;
60
+ private readonly bus?;
61
+ constructor(bus?: EventBus);
59
62
  surface(stream: string): RenderSurface;
60
63
  redirect(stream: string, target: RenderSurface): () => void;
61
64
  setDefault(stream: string, target: RenderSurface): void;
65
+ /** Wrap a surface so writes emit `compositor:write` before delegating. */
66
+ private wrap;
62
67
  }
@@ -50,6 +50,10 @@ export class StdoutSurface {
50
50
  export class DefaultCompositor {
51
51
  defaults = new Map();
52
52
  overrides = new Map();
53
+ bus;
54
+ constructor(bus) {
55
+ this.bus = bus;
56
+ }
53
57
  surface(stream) {
54
58
  const stack = this.overrides.get(stream);
55
59
  if (stack && stack.length > 0)
@@ -63,12 +67,13 @@ export class DefaultCompositor {
63
67
  return nullSurface;
64
68
  }
65
69
  redirect(stream, target) {
70
+ const wrapped = this.wrap(stream, target);
66
71
  let stack = this.overrides.get(stream);
67
72
  if (!stack) {
68
73
  stack = [];
69
74
  this.overrides.set(stream, stack);
70
75
  }
71
- stack.push(target);
76
+ stack.push(wrapped);
72
77
  let restored = false;
73
78
  return () => {
74
79
  if (restored)
@@ -77,12 +82,35 @@ export class DefaultCompositor {
77
82
  const s = this.overrides.get(stream);
78
83
  if (!s)
79
84
  return;
80
- const idx = s.indexOf(target);
85
+ const idx = s.indexOf(wrapped);
81
86
  if (idx !== -1)
82
87
  s.splice(idx, 1);
83
88
  };
84
89
  }
85
90
  setDefault(stream, target) {
86
- this.defaults.set(stream, target);
91
+ this.defaults.set(stream, this.wrap(stream, target));
92
+ }
93
+ /** Wrap a surface so writes emit `compositor:write` before delegating. */
94
+ wrap(stream, target) {
95
+ const bus = this.bus;
96
+ if (!bus)
97
+ return target;
98
+ return {
99
+ write: (text) => {
100
+ try {
101
+ bus.emit("compositor:write", { stream, text });
102
+ }
103
+ catch { }
104
+ target.write(text);
105
+ },
106
+ writeLine: (line) => {
107
+ try {
108
+ bus.emit("compositor:write", { stream, text: line + "\n" });
109
+ }
110
+ catch { }
111
+ target.writeLine(line);
112
+ },
113
+ get columns() { return target.columns; },
114
+ };
87
115
  }
88
116
  }
@@ -18,3 +18,12 @@ export interface DiffRenderOptions {
18
18
  export declare function selectMode(width: number): DiffDisplayMode;
19
19
  /** Render a diff result as an array of ANSI-formatted terminal lines. */
20
20
  export declare function renderDiff(diff: DiffResult, opts: DiffRenderOptions): string[];
21
+ /**
22
+ * Async variant of renderDiff that yields to the event loop between hunks.
23
+ * Use when rendering in a context where a spinner or other UI needs to stay
24
+ * responsive (e.g. showing a large diff during a permission prompt).
25
+ *
26
+ * @param onLines - Callback invoked with each batch of rendered lines as they
27
+ * are produced. Allows progressive/streaming display.
28
+ */
29
+ export declare function renderDiffAsync(diff: DiffResult, opts: DiffRenderOptions, onLines: (lines: string[]) => void): Promise<void>;