agent-sh 0.8.0 → 0.10.0

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 (106) hide show
  1. package/README.md +27 -43
  2. package/dist/agent/agent-loop.d.ts +69 -6
  3. package/dist/agent/agent-loop.js +954 -153
  4. package/dist/agent/conversation-state.d.ts +74 -21
  5. package/dist/agent/conversation-state.js +361 -150
  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 +88 -6
  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 +37 -5
  15. package/dist/agent/system-prompt.js +100 -67
  16. package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +5 -4
  17. package/dist/{token-budget.js → agent/token-budget.js} +15 -20
  18. package/dist/agent/tool-protocol.d.ts +105 -0
  19. package/dist/agent/tool-protocol.js +551 -0
  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 +22 -2
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.d.ts +7 -7
  29. package/dist/core.js +99 -196
  30. package/dist/event-bus.d.ts +85 -2
  31. package/dist/event-bus.js +20 -1
  32. package/dist/executor.d.ts +4 -3
  33. package/dist/executor.js +18 -15
  34. package/dist/extension-loader.d.ts +5 -0
  35. package/dist/extension-loader.js +143 -19
  36. package/dist/extensions/agent-backend.d.ts +14 -0
  37. package/dist/extensions/agent-backend.js +188 -0
  38. package/dist/extensions/command-suggest.d.ts +3 -3
  39. package/dist/extensions/command-suggest.js +4 -3
  40. package/dist/extensions/index.d.ts +19 -0
  41. package/dist/extensions/index.js +24 -0
  42. package/dist/extensions/slash-commands.d.ts +1 -1
  43. package/dist/extensions/slash-commands.js +30 -10
  44. package/dist/extensions/tui-renderer.js +117 -113
  45. package/dist/index.js +39 -26
  46. package/dist/settings.d.ts +40 -3
  47. package/dist/settings.js +57 -10
  48. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +3 -2
  49. package/dist/{input-handler.js → shell/input-handler.js} +111 -85
  50. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  51. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  52. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  53. package/dist/{shell.js → shell/shell.js} +39 -8
  54. package/dist/types.d.ts +61 -10
  55. package/dist/utils/ansi.d.ts +5 -0
  56. package/dist/utils/ansi.js +1 -1
  57. package/dist/utils/compositor.d.ts +67 -0
  58. package/dist/utils/compositor.js +116 -0
  59. package/dist/utils/diff-renderer.d.ts +9 -0
  60. package/dist/utils/diff-renderer.js +312 -146
  61. package/dist/utils/diff.d.ts +21 -2
  62. package/dist/utils/diff.js +165 -89
  63. package/dist/utils/floating-panel.d.ts +2 -0
  64. package/dist/utils/floating-panel.js +30 -14
  65. package/dist/utils/handler-registry.d.ts +31 -10
  66. package/dist/utils/handler-registry.js +58 -16
  67. package/dist/utils/line-editor.d.ts +33 -3
  68. package/dist/utils/line-editor.js +221 -44
  69. package/dist/utils/markdown.d.ts +1 -0
  70. package/dist/utils/markdown.js +1 -1
  71. package/dist/utils/message-utils.d.ts +35 -0
  72. package/dist/utils/message-utils.js +75 -0
  73. package/dist/utils/terminal-buffer.d.ts +5 -1
  74. package/dist/utils/terminal-buffer.js +18 -2
  75. package/dist/utils/tool-display.d.ts +1 -1
  76. package/dist/utils/tool-display.js +4 -4
  77. package/dist/utils/tool-interactive.d.ts +12 -0
  78. package/dist/utils/tool-interactive.js +53 -0
  79. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  80. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  81. package/examples/extensions/ash-acp-bridge/src/index.ts +574 -0
  82. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  83. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  84. package/examples/extensions/ash-mcp-bridge/index.ts +164 -0
  85. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  86. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  87. package/examples/extensions/claude-code-bridge/package.json +1 -0
  88. package/examples/extensions/interactive-prompts.ts +98 -112
  89. package/examples/extensions/overlay-agent.ts +84 -38
  90. package/examples/extensions/peer-mesh.ts +565 -0
  91. package/examples/extensions/pi-bridge/index.ts +2 -2
  92. package/examples/extensions/questionnaire.ts +260 -0
  93. package/examples/extensions/subagents.ts +19 -4
  94. package/examples/extensions/terminal-buffer.ts +32 -53
  95. package/examples/extensions/tmux-pane.ts +307 -0
  96. package/examples/extensions/user-shell.ts +136 -0
  97. package/examples/extensions/web-access.ts +335 -0
  98. package/package.json +44 -2
  99. package/dist/agent/tools/display.d.ts +0 -13
  100. package/dist/agent/tools/display.js +0 -70
  101. package/dist/agent/tools/user-shell.d.ts +0 -13
  102. package/dist/agent/tools/user-shell.js +0 -87
  103. package/dist/extensions/overlay-agent.d.ts +0 -14
  104. package/dist/extensions/overlay-agent.js +0 -147
  105. package/dist/extensions/terminal-buffer.d.ts +0 -14
  106. package/dist/extensions/terminal-buffer.js +0 -125
@@ -1,9 +1,85 @@
1
1
  /**
2
- * Lightweight LCS-based line diff for file modification previews.
2
+ * Line-level diff computation, powered by the `diff` npm package.
3
+ *
4
+ * Exposes a unified `DiffResult` interface consumed by the diff renderer.
5
+ * Three entry points cover the main use cases:
6
+ *
7
+ * computeDiff — full-file diff (write_file, or when edit region can't be located)
8
+ * computeEditDiff — edit_file: locates the edit region, builds the new file, full diff
9
+ * computeInputDiff — fast preview: diffs only old_text vs new_text, no file I/O
3
10
  */
11
+ import * as Diff from "diff";
12
+ // ── Helpers ──────────────────────────────────────────────────────────
13
+ /**
14
+ * Convert a `diff` library Change[] into our DiffLine[], tracking real
15
+ * old/new line numbers.
16
+ */
17
+ function changesToDiffLines(changes) {
18
+ const result = [];
19
+ let oldNo = 0;
20
+ let newNo = 0;
21
+ for (const change of changes) {
22
+ const lines = change.value.replace(/\n$/, "").split("\n");
23
+ for (const text of lines) {
24
+ if (change.added) {
25
+ newNo++;
26
+ result.push({ type: "added", oldNo: null, newNo, text });
27
+ }
28
+ else if (change.removed) {
29
+ oldNo++;
30
+ result.push({ type: "removed", oldNo, newNo: null, text });
31
+ }
32
+ else {
33
+ oldNo++;
34
+ newNo++;
35
+ result.push({ type: "context", oldNo, newNo, text });
36
+ }
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+ /**
42
+ * Group raw DiffLines into hunks with `context` lines of surrounding context.
43
+ */
44
+ function groupHunks(lines, ctx) {
45
+ const changeIdx = [];
46
+ for (let i = 0; i < lines.length; i++)
47
+ if (lines[i].type !== "context")
48
+ changeIdx.push(i);
49
+ if (changeIdx.length === 0)
50
+ return [];
51
+ const hunks = [];
52
+ let start = Math.max(0, changeIdx[0] - ctx);
53
+ let end = Math.min(lines.length - 1, changeIdx[0] + ctx);
54
+ for (let k = 1; k < changeIdx.length; k++) {
55
+ const ns = Math.max(0, changeIdx[k] - ctx);
56
+ const ne = Math.min(lines.length - 1, changeIdx[k] + ctx);
57
+ if (ns <= end + 1) {
58
+ end = ne;
59
+ }
60
+ else {
61
+ hunks.push({ lines: lines.slice(start, end + 1) });
62
+ start = ns;
63
+ end = ne;
64
+ }
65
+ }
66
+ hunks.push({ lines: lines.slice(start, end + 1) });
67
+ return hunks;
68
+ }
69
+ function countChanges(lines) {
70
+ let added = 0;
71
+ let removed = 0;
72
+ for (const l of lines) {
73
+ if (l.type === "added")
74
+ added++;
75
+ else if (l.type === "removed")
76
+ removed++;
77
+ }
78
+ return { added, removed };
79
+ }
80
+ // ── Public API ───────────────────────────────────────────────────────
4
81
  /**
5
82
  * Compute a line-level diff between old and new file content.
6
- * Returns grouped hunks with 3 lines of context around each change.
7
83
  */
8
84
  export function computeDiff(oldText, newText) {
9
85
  // New file — everything is an addition
@@ -28,37 +104,11 @@ export function computeDiff(oldText, newText) {
28
104
  }
29
105
  // Identical — nothing to show
30
106
  if (oldText === newText) {
31
- return {
32
- hunks: [],
33
- added: 0,
34
- removed: 0,
35
- isIdentical: true,
36
- isNewFile: false,
37
- };
38
- }
39
- // Build LCS table and backtrack to produce diff lines
40
- const a = oldText.split("\n");
41
- const b = newText.split("\n");
42
- // Bail out if LCS table would be too large (avoids OOM / hang)
43
- if (a.length * b.length > 10_000_000) {
44
- return {
45
- hunks: [],
46
- added: b.length,
47
- removed: a.length,
48
- isIdentical: false,
49
- isNewFile: false,
50
- };
51
- }
52
- const dp = buildLcs(a, b);
53
- const raw = backtrack(dp, a, b);
54
- let added = 0;
55
- let removed = 0;
56
- for (const l of raw) {
57
- if (l.type === "added")
58
- added++;
59
- else if (l.type === "removed")
60
- removed++;
107
+ return { hunks: [], added: 0, removed: 0, isIdentical: true, isNewFile: false };
61
108
  }
109
+ const changes = Diff.diffLines(oldText, newText);
110
+ const raw = changesToDiffLines(changes);
111
+ const { added, removed } = countChanges(raw);
62
112
  return {
63
113
  hunks: groupHunks(raw, 3),
64
114
  added,
@@ -67,66 +117,92 @@ export function computeDiff(oldText, newText) {
67
117
  isNewFile: false,
68
118
  };
69
119
  }
70
- function buildLcs(a, b) {
71
- const m = a.length;
72
- const n = b.length;
73
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
74
- for (let i = 1; i <= m; i++)
75
- for (let j = 1; j <= n; j++)
76
- dp[i][j] =
77
- a[i - 1] === b[j - 1]
78
- ? dp[i - 1][j - 1] + 1
79
- : Math.max(dp[i - 1][j], dp[i][j - 1]);
80
- return dp;
81
- }
82
- function backtrack(dp, a, b) {
83
- const result = [];
84
- let i = a.length;
85
- let j = b.length;
86
- while (i > 0 || j > 0) {
87
- if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
88
- result.unshift({ type: "context", oldNo: i, newNo: j, text: a[i - 1] });
89
- i--;
90
- j--;
91
- }
92
- else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
93
- result.unshift({ type: "added", oldNo: null, newNo: j, text: b[j - 1] });
94
- j--;
95
- }
96
- else {
97
- result.unshift({
98
- type: "removed",
99
- oldNo: i,
100
- newNo: null,
101
- text: a[i - 1],
102
- });
103
- i--;
120
+ /**
121
+ * Compute a diff for an edit operation where we know the old/new text.
122
+ * Locates the edit region(s) in the file, constructs the full new file,
123
+ * then diffs the whole thing so line numbers are file-relative.
124
+ */
125
+ export function computeEditDiff(oldFileText, editOld, editNew, replaceAll = false) {
126
+ const a = oldFileText.split("\n");
127
+ const editOldLines = editOld.split("\n");
128
+ const editNewLines = editNew.split("\n");
129
+ // Find all occurrences of editOld in the file
130
+ const regions = [];
131
+ if (replaceAll) {
132
+ let i = 0;
133
+ while (i <= a.length - editOldLines.length) {
134
+ let match = true;
135
+ for (let k = 0; k < editOldLines.length; k++) {
136
+ if (a[i + k] !== editOldLines[k]) {
137
+ match = false;
138
+ break;
139
+ }
140
+ }
141
+ if (match) {
142
+ regions.push({ start: i, end: i + editOldLines.length });
143
+ i += editOldLines.length;
144
+ }
145
+ else {
146
+ i++;
147
+ }
104
148
  }
105
149
  }
106
- return result;
107
- }
108
- function groupHunks(lines, ctx) {
109
- const changeIdx = [];
110
- for (let i = 0; i < lines.length; i++)
111
- if (lines[i].type !== "context")
112
- changeIdx.push(i);
113
- if (changeIdx.length === 0)
114
- return [];
115
- const hunks = [];
116
- let start = Math.max(0, changeIdx[0] - ctx);
117
- let end = Math.min(lines.length - 1, changeIdx[0] + ctx);
118
- for (let k = 1; k < changeIdx.length; k++) {
119
- const ns = Math.max(0, changeIdx[k] - ctx);
120
- const ne = Math.min(lines.length - 1, changeIdx[k] + ctx);
121
- if (ns <= end + 1) {
122
- end = ne;
150
+ else {
151
+ for (let i = 0; i <= a.length - editOldLines.length; i++) {
152
+ let match = true;
153
+ for (let k = 0; k < editOldLines.length; k++) {
154
+ if (a[i + k] !== editOldLines[k]) {
155
+ match = false;
156
+ break;
157
+ }
158
+ }
159
+ if (match) {
160
+ regions.push({ start: i, end: i + editOldLines.length });
161
+ break;
162
+ }
123
163
  }
124
- else {
125
- hunks.push({ lines: lines.slice(start, end + 1) });
126
- start = ns;
127
- end = ne;
164
+ }
165
+ // Build the full new file
166
+ let newFile;
167
+ if (replaceAll && regions.length > 0) {
168
+ const parts = [];
169
+ let last = 0;
170
+ for (const r of regions) {
171
+ parts.push(...a.slice(last, r.start));
172
+ parts.push(...editNewLines);
173
+ last = r.end;
128
174
  }
175
+ parts.push(...a.slice(last));
176
+ newFile = parts;
129
177
  }
130
- hunks.push({ lines: lines.slice(start, end + 1) });
131
- return hunks;
178
+ else if (regions.length === 1) {
179
+ const r = regions[0];
180
+ newFile = [...a.slice(0, r.start), ...editNewLines, ...a.slice(r.end)];
181
+ }
182
+ else {
183
+ // Couldn't locate edit — fall back to string replace + full diff
184
+ const newContent = replaceAll
185
+ ? oldFileText.split(editOld).join(editNew)
186
+ : oldFileText.replace(editOld, editNew);
187
+ return computeDiff(oldFileText, newContent);
188
+ }
189
+ return computeDiff(oldFileText, newFile.join("\n"));
190
+ }
191
+ /**
192
+ * Diff two edit strings directly — no file read needed.
193
+ * Line numbers are relative to the edit region, not the file.
194
+ * Use for permission prompt previews where speed matters more than
195
+ * exact file-relative line numbers.
196
+ */
197
+ export function computeInputDiff(oldText, newText) {
198
+ const changes = Diff.diffLines(oldText, newText);
199
+ const raw = changesToDiffLines(changes);
200
+ const { added, removed } = countChanges(raw);
201
+ return {
202
+ hunks: groupHunks(raw, 3),
203
+ added,
204
+ removed,
205
+ isIdentical: false,
206
+ isNewFile: false,
207
+ };
132
208
  }
@@ -182,6 +182,8 @@ export declare class FloatingPanel {
182
182
  private ensureBuffer;
183
183
  /** Whether the panel has an active conversation (may be hidden). */
184
184
  get active(): boolean;
185
+ /** Whether the agent is currently processing a query. */
186
+ get processing(): boolean;
185
187
  /** Whether the panel is currently visible on screen. */
186
188
  get visible(): boolean;
187
189
  get terminalBuffer(): TerminalBuffer | null;
@@ -328,6 +328,10 @@ export class FloatingPanel {
328
328
  get active() {
329
329
  return this.phase !== "idle";
330
330
  }
331
+ /** Whether the agent is currently processing a query. */
332
+ get processing() {
333
+ return this.phase === "active";
334
+ }
331
335
  /** Whether the panel is currently visible on screen. */
332
336
  get visible() {
333
337
  return this._visible;
@@ -515,7 +519,7 @@ export class FloatingPanel {
515
519
  this.render();
516
520
  }
517
521
  getInput() {
518
- return this.editor.buffer;
522
+ return this.editor.text;
519
523
  }
520
524
  requestRender() {
521
525
  this.scheduleRender();
@@ -634,7 +638,7 @@ export class FloatingPanel {
634
638
  for (const action of actions) {
635
639
  switch (action.action) {
636
640
  case "submit": {
637
- const query = this.editor.buffer.trim();
641
+ const query = this.editor.text.trim();
638
642
  if (!query) {
639
643
  this.hide();
640
644
  return;
@@ -688,8 +692,8 @@ export class FloatingPanel {
688
692
  width: geo.contentW,
689
693
  height: geo.contentH,
690
694
  phase: this.phase,
691
- inputBuffer: this.editor.buffer,
692
- inputCursor: this.editor.cursor,
695
+ inputBuffer: this.editor.displayText,
696
+ inputCursor: this.editor.displayCursor,
693
697
  scrollOffset: this.scrollOffset,
694
698
  contentLines: this.contentLines,
695
699
  partialLine: this.currentPartialLine,
@@ -752,23 +756,35 @@ export class FloatingPanel {
752
756
  this.resizeHandler = null;
753
757
  }
754
758
  this.suppressNextRedraw = true;
759
+ // Re-check alt screen state: the program we overlaid may have exited
760
+ // (e.g. agent quit vim via terminal_keys) while the panel was active.
761
+ const stillInAltScreen = !this.usedAltScreen && !!this.buffer?.altScreen;
762
+ const programExited = !this.usedAltScreen && !stillInAltScreen;
755
763
  if (this.usedAltScreen) {
756
764
  process.stdout.write("\x1b[?1049l");
757
765
  }
758
- // ncurses's curscr is stale only a real dimension change triggers
759
- // clearok + full repaint (same-size SIGWINCH is a no-op).
760
- const cols = process.stdout.columns || 80;
761
- const rows = process.stdout.rows || 24;
762
- this.bus.emit("shell:pty-resize", { cols, rows: rows - 1 });
763
- setTimeout(() => {
764
- this.bus.emit("shell:pty-resize", { cols, rows });
765
- }, 50);
766
- if (!this.buffer && this.ptyBuffer) {
766
+ // Replay PTY output that arrived while the overlay was active.
767
+ // Without this, commands run by the agent (e.g. user_shell ls)
768
+ // would vanish the alt screen exit restores the saved screen
769
+ // from before the overlay opened, losing any shell output produced
770
+ // during the session.
771
+ if (this.ptyBuffer) {
767
772
  process.stdout.write(this.ptyBuffer);
768
773
  }
769
774
  this.ptyBuffer = "";
770
- this.bus.emit("shell:stdout-hide", {});
771
775
  this.bus.emit("shell:stdout-release", {});
776
+ if (stillInAltScreen || programExited) {
777
+ // Either a TUI app is still running and needs SIGWINCH to repaint,
778
+ // or the overlaid program exited (e.g. agent quit vim) and we
779
+ // discarded its stale buffer — SIGWINCH makes the shell redraw
780
+ // its prompt cleanly.
781
+ const cols = process.stdout.columns || 80;
782
+ const rows = process.stdout.rows || 24;
783
+ this.bus.emit("shell:pty-resize", { cols, rows: rows - 1 });
784
+ setTimeout(() => {
785
+ this.bus.emit("shell:pty-resize", { cols, rows });
786
+ }, 50);
787
+ }
772
788
  }
773
789
  // ── Passthrough rendering ─────────────────────────────────
774
790
  /** Start rendering TerminalBuffer directly (no overlay box). */
@@ -11,31 +11,52 @@
11
11
  * if (lang === "latex") return renderLatex(code);
12
12
  * return next(lang, code); // call original
13
13
  * });
14
+ *
15
+ * Internally, each handler is stored as a base function plus an ordered
16
+ * list of advisors. `call` builds the chain on invocation, so advisors
17
+ * can be added or removed at any time without closure entanglement.
14
18
  */
19
+ type HandlerFn = (...args: any[]) => any;
20
+ type Advisor = (next: HandlerFn, ...args: any[]) => any;
21
+ /** The subset of HandlerRegistry methods available to extensions. */
22
+ export interface HandlerFunctions {
23
+ define(name: string, fn: (...args: any[]) => any): void;
24
+ advise(name: string, advisor: (next: (...args: any[]) => any, ...args: any[]) => any): () => void;
25
+ call(name: string, ...args: any[]): any;
26
+ list(): string[];
27
+ }
15
28
  export declare class HandlerRegistry {
16
- private handlers;
29
+ private entries;
17
30
  /**
18
- * Register a named handler. If one already exists, it's replaced.
31
+ * Register a named handler. If one already exists, its base is replaced
32
+ * but existing advisors are preserved.
19
33
  */
20
- define(name: string, fn: (...args: any[]) => any): void;
34
+ define(name: string, fn: HandlerFn): void;
21
35
  /**
22
- * Wrap a named handler with advice. The wrapper receives the
23
- * previous handler as `next` and all original arguments.
36
+ * Add an advisor to a named handler. The advisor receives `next`
37
+ * (the rest of the chain) and all original arguments.
24
38
  *
25
- * - Call `next(...args)` to invoke the original (around/before/after)
39
+ * - Call `next(...args)` to invoke the rest of the chain
26
40
  * - Don't call `next` to replace entirely (override)
27
41
  * - Call `next` conditionally to wrap (around)
28
42
  *
29
- * Multiple advisors chain: each wraps the previous one.
30
- * If no handler exists yet, `next` is a no-op.
43
+ * Advisors run outermost-first (last added = outermost).
44
+ * Returns an unadvise function that cleanly removes this advisor.
31
45
  */
32
- advise(name: string, wrapper: (next: (...args: any[]) => any, ...args: any[]) => any): void;
46
+ advise(name: string, advisor: Advisor): () => void;
33
47
  /**
34
- * Call a named handler. Returns undefined if no handler is registered.
48
+ * Call a named handler. Builds the advisor chain on each call:
49
+ * outermost advisor wraps the next, down to the base handler.
50
+ * Returns undefined if no handler is registered.
35
51
  */
36
52
  call(name: string, ...args: any[]): any;
37
53
  /**
38
54
  * Check if a named handler exists.
39
55
  */
40
56
  has(name: string): boolean;
57
+ /**
58
+ * Names of all registered handlers. For diagnostic/introspection use.
59
+ */
60
+ list(): string[];
41
61
  }
62
+ export {};
@@ -11,42 +11,84 @@
11
11
  * if (lang === "latex") return renderLatex(code);
12
12
  * return next(lang, code); // call original
13
13
  * });
14
+ *
15
+ * Internally, each handler is stored as a base function plus an ordered
16
+ * list of advisors. `call` builds the chain on invocation, so advisors
17
+ * can be added or removed at any time without closure entanglement.
14
18
  */
15
- /* eslint-disable @typescript-eslint/no-explicit-any */
16
19
  export class HandlerRegistry {
17
- handlers = new Map();
20
+ entries = new Map();
18
21
  /**
19
- * Register a named handler. If one already exists, it's replaced.
22
+ * Register a named handler. If one already exists, its base is replaced
23
+ * but existing advisors are preserved.
20
24
  */
21
25
  define(name, fn) {
22
- this.handlers.set(name, fn);
26
+ const existing = this.entries.get(name);
27
+ if (existing) {
28
+ existing.base = fn;
29
+ }
30
+ else {
31
+ this.entries.set(name, { base: fn, advisors: [] });
32
+ }
23
33
  }
24
34
  /**
25
- * Wrap a named handler with advice. The wrapper receives the
26
- * previous handler as `next` and all original arguments.
35
+ * Add an advisor to a named handler. The advisor receives `next`
36
+ * (the rest of the chain) and all original arguments.
27
37
  *
28
- * - Call `next(...args)` to invoke the original (around/before/after)
38
+ * - Call `next(...args)` to invoke the rest of the chain
29
39
  * - Don't call `next` to replace entirely (override)
30
40
  * - Call `next` conditionally to wrap (around)
31
41
  *
32
- * Multiple advisors chain: each wraps the previous one.
33
- * If no handler exists yet, `next` is a no-op.
42
+ * Advisors run outermost-first (last added = outermost).
43
+ * Returns an unadvise function that cleanly removes this advisor.
34
44
  */
35
- advise(name, wrapper) {
36
- const original = this.handlers.get(name) ?? (() => undefined);
37
- this.handlers.set(name, (...args) => wrapper(original, ...args));
45
+ advise(name, advisor) {
46
+ let entry = this.entries.get(name);
47
+ if (!entry) {
48
+ entry = { base: (() => undefined), advisors: [] };
49
+ this.entries.set(name, entry);
50
+ }
51
+ entry.advisors.push(advisor);
52
+ let removed = false;
53
+ return () => {
54
+ if (removed)
55
+ return;
56
+ removed = true;
57
+ const e = this.entries.get(name);
58
+ if (!e)
59
+ return;
60
+ const idx = e.advisors.indexOf(advisor);
61
+ if (idx !== -1)
62
+ e.advisors.splice(idx, 1);
63
+ };
38
64
  }
39
65
  /**
40
- * Call a named handler. Returns undefined if no handler is registered.
66
+ * Call a named handler. Builds the advisor chain on each call:
67
+ * outermost advisor wraps the next, down to the base handler.
68
+ * Returns undefined if no handler is registered.
41
69
  */
42
70
  call(name, ...args) {
43
- const fn = this.handlers.get(name);
44
- return fn?.(...args);
71
+ const entry = this.entries.get(name);
72
+ if (!entry)
73
+ return undefined;
74
+ // Build chain: base ← advisor[0] ← advisor[1] ← ... ← advisor[n-1]
75
+ let fn = entry.base;
76
+ for (const advisor of entry.advisors) {
77
+ const next = fn;
78
+ fn = (...a) => advisor(next, ...a);
79
+ }
80
+ return fn(...args);
45
81
  }
46
82
  /**
47
83
  * Check if a named handler exists.
48
84
  */
49
85
  has(name) {
50
- return this.handlers.has(name);
86
+ return this.entries.has(name);
87
+ }
88
+ /**
89
+ * Names of all registered handlers. For diagnostic/introspection use.
90
+ */
91
+ list() {
92
+ return [...this.entries.keys()];
51
93
  }
52
94
  }
@@ -2,8 +2,14 @@
2
2
  * Minimal line editor with readline-style keybindings.
3
3
  *
4
4
  * Pure logic — no I/O, no rendering, no event bus. Consumers feed raw
5
- * terminal input bytes and receive high-level actions back. Buffer and
6
- * cursor state are public for rendering.
5
+ * terminal input bytes and receive high-level actions back.
6
+ *
7
+ * The internal buffer may contain PUA placeholder characters for pasted
8
+ * multi-line content. Consumers should use the typed accessors:
9
+ * - `text` — resolved content (pastes expanded), for submit/history/logic
10
+ * - `displayText` — display content (pastes collapsed to labels), for rendering
11
+ * - `displayCursor` — cursor column in display coordinates
12
+ * - `setText()` — replace buffer content (clears paste attachments)
7
13
  */
8
14
  export type LineEditAction = {
9
15
  action: "changed";
@@ -24,12 +30,28 @@ export type LineEditAction = {
24
30
  action: "arrow-down";
25
31
  };
26
32
  export declare class LineEditor {
27
- buffer: string;
33
+ private _buf;
28
34
  cursor: number;
29
35
  private pendingSeq;
36
+ private inPaste;
37
+ private pasteAccum;
38
+ private pastes;
39
+ private pasteCounter;
30
40
  private history;
31
41
  private historyIndex;
32
42
  private savedBuffer;
43
+ /** Resolved text — paste placeholders expanded. For submit, history, logic. */
44
+ get text(): string;
45
+ /** Display text — paste placeholders replaced with labels. For rendering. */
46
+ get displayText(): string;
47
+ /** Cursor position mapped to display-text character offset. */
48
+ get displayCursor(): number;
49
+ /** Cursor position as visible terminal-column width (accounts for CJK etc.). */
50
+ get displayCursorWidth(): number;
51
+ /** Number of logical positions in the buffer. */
52
+ get length(): number;
53
+ /** Replace buffer content. Clears paste attachments. */
54
+ setText(value: string): void;
33
55
  /** Process raw terminal input, return actions for the consumer. */
34
56
  feed(data: string): LineEditAction[];
35
57
  /** Check if there's a pending incomplete escape sequence. */
@@ -53,6 +75,14 @@ export declare class LineEditor {
53
75
  private handleKittyKey;
54
76
  private insertAt;
55
77
  private moveTo;
78
+ /** Move cursor to start of the current logical line. */
79
+ private moveToLineStart;
80
+ /** Move cursor to end of the current logical line. */
81
+ private moveToLineEnd;
82
+ /** Delete from start of current logical line to cursor (Ctrl+U). */
83
+ private deleteLineStart;
84
+ /** Delete from cursor to end of current logical line (Ctrl+K). */
85
+ private deleteLineEnd;
56
86
  private deleteBackward;
57
87
  private deleteForward;
58
88
  private deleteRange;