agent-sh 0.8.0 → 0.9.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.
- package/README.md +25 -34
- package/dist/agent/agent-loop.d.ts +29 -6
- package/dist/agent/agent-loop.js +177 -59
- package/dist/agent/conversation-state.d.ts +3 -1
- package/dist/agent/conversation-state.js +6 -2
- package/dist/agent/nuclear-form.js +5 -4
- package/dist/agent/system-prompt.d.ts +4 -5
- package/dist/agent/system-prompt.js +12 -28
- package/dist/{token-budget.js → agent/token-budget.js} +1 -1
- package/dist/agent/tool-protocol.d.ts +83 -0
- package/dist/agent/tool-protocol.js +386 -0
- package/dist/agent/types.d.ts +21 -1
- package/dist/core.d.ts +7 -7
- package/dist/core.js +76 -194
- package/dist/event-bus.d.ts +26 -0
- package/dist/event-bus.js +20 -1
- package/dist/extension-loader.d.ts +5 -0
- package/dist/extension-loader.js +104 -17
- package/dist/extensions/agent-backend.d.ts +13 -0
- package/dist/extensions/agent-backend.js +167 -0
- package/dist/extensions/command-suggest.d.ts +3 -3
- package/dist/extensions/command-suggest.js +4 -3
- package/dist/extensions/index.d.ts +19 -0
- package/dist/extensions/index.js +25 -0
- package/dist/extensions/slash-commands.d.ts +1 -1
- package/dist/extensions/slash-commands.js +16 -1
- package/dist/extensions/terminal-buffer.d.ts +1 -1
- package/dist/extensions/terminal-buffer.js +13 -4
- package/dist/extensions/tui-renderer.js +63 -43
- package/dist/index.js +14 -20
- package/dist/settings.d.ts +6 -0
- package/dist/settings.js +4 -1
- package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
- package/dist/{input-handler.js → shell/input-handler.js} +60 -43
- package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
- package/dist/{output-parser.js → shell/output-parser.js} +1 -1
- package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
- package/dist/{shell.js → shell/shell.js} +20 -6
- package/dist/types.d.ts +49 -10
- package/dist/utils/compositor.d.ts +62 -0
- package/dist/utils/compositor.js +88 -0
- package/dist/utils/diff-renderer.js +92 -4
- package/dist/utils/floating-panel.d.ts +2 -0
- package/dist/utils/floating-panel.js +30 -14
- package/dist/utils/handler-registry.d.ts +26 -10
- package/dist/utils/handler-registry.js +52 -16
- package/dist/utils/line-editor.d.ts +23 -3
- package/dist/utils/line-editor.js +180 -42
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +1 -1
- package/dist/utils/message-utils.d.ts +35 -0
- package/dist/utils/message-utils.js +75 -0
- package/dist/utils/terminal-buffer.d.ts +5 -1
- package/dist/utils/terminal-buffer.js +18 -2
- package/dist/utils/tool-interactive.d.ts +12 -0
- package/dist/utils/tool-interactive.js +53 -0
- package/examples/extensions/ash-acp-bridge/README.md +39 -0
- package/examples/extensions/ash-acp-bridge/package.json +23 -0
- package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
- package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
- package/examples/extensions/ash-mcp-bridge/README.md +72 -0
- package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
- package/examples/extensions/ash-mcp-bridge/package.json +9 -0
- package/examples/extensions/interactive-prompts.ts +82 -110
- package/examples/extensions/overlay-agent.ts +84 -38
- package/examples/extensions/peer-mesh.ts +450 -0
- package/examples/extensions/questionnaire.ts +249 -0
- package/examples/extensions/tmux-pane.ts +307 -0
- package/examples/extensions/web-access.ts +327 -0
- package/package.json +9 -1
- package/dist/extensions/overlay-agent.d.ts +0 -14
- package/dist/extensions/overlay-agent.js +0 -147
- package/examples/extensions/terminal-buffer.ts +0 -184
- /package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +0 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compositor — routes named render streams to surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Components write to named streams ("agent", "query", "status").
|
|
5
|
+
* The compositor decides where each stream actually goes based on
|
|
6
|
+
* the current routing table. Extensions override routing with
|
|
7
|
+
* `redirect()` to capture output (e.g. overlay panels).
|
|
8
|
+
*
|
|
9
|
+
* Streams are hierarchical: "agent:diff" falls back to "agent" if
|
|
10
|
+
* no override or default is registered for "agent:diff" specifically.
|
|
11
|
+
* This enables fine-grained interception — redirect just diffs into
|
|
12
|
+
* a panel, or just a subagent's output ("agent:sub:abc123"), while
|
|
13
|
+
* everything else flows to the parent stream's surface.
|
|
14
|
+
*
|
|
15
|
+
* // tui-renderer registers default surfaces
|
|
16
|
+
* compositor.setDefault("agent", stdoutSurface);
|
|
17
|
+
*
|
|
18
|
+
* // overlay-agent redirects when active
|
|
19
|
+
* const restore = compositor.redirect("agent", panelSurface);
|
|
20
|
+
* // ... later ...
|
|
21
|
+
* restore(); // back to stdout
|
|
22
|
+
*
|
|
23
|
+
* // fine-grained: redirect only diffs to a viewer panel
|
|
24
|
+
* compositor.redirect("agent:diff", diffPanelSurface);
|
|
25
|
+
* // "agent:text", "agent:tool" etc. still go to stdout
|
|
26
|
+
*/
|
|
27
|
+
/** Silent sink — drops all output. Used when no surface is registered. */
|
|
28
|
+
export const nullSurface = {
|
|
29
|
+
write() { },
|
|
30
|
+
writeLine() { },
|
|
31
|
+
get columns() { return 80; },
|
|
32
|
+
};
|
|
33
|
+
/** Surface backed by process.stdout. */
|
|
34
|
+
export class StdoutSurface {
|
|
35
|
+
write(text) {
|
|
36
|
+
if (process.stdout.writable) {
|
|
37
|
+
try {
|
|
38
|
+
process.stdout.write(text);
|
|
39
|
+
}
|
|
40
|
+
catch { /* ignore */ }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
writeLine(line) {
|
|
44
|
+
this.write(line + "\n");
|
|
45
|
+
}
|
|
46
|
+
get columns() {
|
|
47
|
+
return process.stdout.columns || 80;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export class DefaultCompositor {
|
|
51
|
+
defaults = new Map();
|
|
52
|
+
overrides = new Map();
|
|
53
|
+
surface(stream) {
|
|
54
|
+
const stack = this.overrides.get(stream);
|
|
55
|
+
if (stack && stack.length > 0)
|
|
56
|
+
return stack[stack.length - 1];
|
|
57
|
+
if (this.defaults.has(stream))
|
|
58
|
+
return this.defaults.get(stream);
|
|
59
|
+
// Hierarchical fallback: "agent:diff" → "agent"
|
|
60
|
+
const colon = stream.lastIndexOf(":");
|
|
61
|
+
if (colon !== -1)
|
|
62
|
+
return this.surface(stream.slice(0, colon));
|
|
63
|
+
return nullSurface;
|
|
64
|
+
}
|
|
65
|
+
redirect(stream, target) {
|
|
66
|
+
let stack = this.overrides.get(stream);
|
|
67
|
+
if (!stack) {
|
|
68
|
+
stack = [];
|
|
69
|
+
this.overrides.set(stream, stack);
|
|
70
|
+
}
|
|
71
|
+
stack.push(target);
|
|
72
|
+
let restored = false;
|
|
73
|
+
return () => {
|
|
74
|
+
if (restored)
|
|
75
|
+
return;
|
|
76
|
+
restored = true;
|
|
77
|
+
const s = this.overrides.get(stream);
|
|
78
|
+
if (!s)
|
|
79
|
+
return;
|
|
80
|
+
const idx = s.indexOf(target);
|
|
81
|
+
if (idx !== -1)
|
|
82
|
+
s.splice(idx, 1);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
setDefault(stream, target) {
|
|
86
|
+
this.defaults.set(stream, target);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -254,7 +254,7 @@ function renderUnified(diff, opts) {
|
|
|
254
254
|
const renderedAsPartOfPair = new Set();
|
|
255
255
|
for (let i = 0; i < hunk.lines.length; i++) {
|
|
256
256
|
const line = hunk.lines[i];
|
|
257
|
-
const no = String(line.oldNo ?? line.newNo ?? "").padStart(noW);
|
|
257
|
+
const no = String(line.type === "removed" ? (line.oldNo ?? "") : (line.newNo ?? line.oldNo ?? "")).padStart(noW);
|
|
258
258
|
if (line.type === "context") {
|
|
259
259
|
const raw = truncateText(line.text, lineTextW);
|
|
260
260
|
const text = lang ? highlightLine(raw, lang) : raw;
|
|
@@ -468,6 +468,91 @@ function truncateText(text, maxWidth) {
|
|
|
468
468
|
}
|
|
469
469
|
return text.slice(0, i) + p.reset + "…";
|
|
470
470
|
}
|
|
471
|
+
// ── Truncation ──────────────────────────────────────────────────
|
|
472
|
+
/**
|
|
473
|
+
* Trim context lines from hunks so the rendered output fits within a budget.
|
|
474
|
+
* Change lines are never removed — only the surrounding context shrinks.
|
|
475
|
+
*/
|
|
476
|
+
function trimHunksToFit(hunks, maxLines) {
|
|
477
|
+
// Count change lines across all hunks
|
|
478
|
+
let changeCount = 0;
|
|
479
|
+
for (const hunk of hunks) {
|
|
480
|
+
for (const line of hunk.lines) {
|
|
481
|
+
if (line.type !== "context")
|
|
482
|
+
changeCount++;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// Separators between hunks
|
|
486
|
+
const separators = Math.max(0, hunks.length - 1);
|
|
487
|
+
// How many context lines can we afford?
|
|
488
|
+
const contextBudget = Math.max(0, maxLines - changeCount - separators);
|
|
489
|
+
// Count total context to see if trimming is needed
|
|
490
|
+
let totalContext = 0;
|
|
491
|
+
for (const hunk of hunks) {
|
|
492
|
+
for (const line of hunk.lines) {
|
|
493
|
+
if (line.type === "context")
|
|
494
|
+
totalContext++;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (totalContext <= contextBudget)
|
|
498
|
+
return hunks;
|
|
499
|
+
// Determine how many context lines to keep per side of each change.
|
|
500
|
+
// Binary-search for the largest per-side context that fits.
|
|
501
|
+
let lo = 0;
|
|
502
|
+
let hi = 3; // original context size from groupHunks
|
|
503
|
+
while (lo < hi) {
|
|
504
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
505
|
+
if (countContextWithLimit(hunks, mid) <= contextBudget)
|
|
506
|
+
lo = mid;
|
|
507
|
+
else
|
|
508
|
+
hi = mid - 1;
|
|
509
|
+
}
|
|
510
|
+
return rebuildHunks(hunks, lo);
|
|
511
|
+
}
|
|
512
|
+
/** Count how many context lines remain if we keep at most `ctx` per side of each change. */
|
|
513
|
+
function countContextWithLimit(hunks, ctx) {
|
|
514
|
+
let count = 0;
|
|
515
|
+
for (const hunk of hunks) {
|
|
516
|
+
const lines = hunk.lines;
|
|
517
|
+
for (let i = 0; i < lines.length; i++) {
|
|
518
|
+
if (lines[i].type !== "context")
|
|
519
|
+
continue;
|
|
520
|
+
// Keep this context line if it's within `ctx` of any change
|
|
521
|
+
let nearChange = false;
|
|
522
|
+
for (let d = 1; d <= ctx; d++) {
|
|
523
|
+
if ((i - d >= 0 && lines[i - d].type !== "context") ||
|
|
524
|
+
(i + d < lines.length && lines[i + d].type !== "context")) {
|
|
525
|
+
nearChange = true;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (nearChange)
|
|
530
|
+
count++;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return count;
|
|
534
|
+
}
|
|
535
|
+
/** Rebuild hunks keeping only context lines within `ctx` distance of a change. */
|
|
536
|
+
function rebuildHunks(hunks, ctx) {
|
|
537
|
+
return hunks.map((hunk) => {
|
|
538
|
+
const lines = hunk.lines;
|
|
539
|
+
const kept = [];
|
|
540
|
+
for (let i = 0; i < lines.length; i++) {
|
|
541
|
+
if (lines[i].type !== "context") {
|
|
542
|
+
kept.push(lines[i]);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
for (let d = 1; d <= ctx; d++) {
|
|
546
|
+
if ((i - d >= 0 && lines[i - d].type !== "context") ||
|
|
547
|
+
(i + d < lines.length && lines[i + d].type !== "context")) {
|
|
548
|
+
kept.push(lines[i]);
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return { lines: kept };
|
|
554
|
+
});
|
|
555
|
+
}
|
|
471
556
|
// ── Public API ───────────────────────────────────────────────────
|
|
472
557
|
/** Select display mode based on available terminal width. */
|
|
473
558
|
export function selectMode(width) {
|
|
@@ -487,16 +572,19 @@ export function renderDiff(diff, opts) {
|
|
|
487
572
|
if (mode === "summary") {
|
|
488
573
|
return [header, ...renderSummary(diff)];
|
|
489
574
|
}
|
|
575
|
+
// Trim context lines from hunks if the diff would exceed the budget,
|
|
576
|
+
// so that actual changes are always visible.
|
|
577
|
+
const trimmed = { ...diff, hunks: trimHunksToFit(diff.hunks, maxLines) };
|
|
490
578
|
let bodyLines;
|
|
491
579
|
switch (mode) {
|
|
492
580
|
case "split":
|
|
493
|
-
bodyLines = renderSplit(
|
|
581
|
+
bodyLines = renderSplit(trimmed, opts);
|
|
494
582
|
break;
|
|
495
583
|
case "unified":
|
|
496
|
-
bodyLines = renderUnified(
|
|
584
|
+
bodyLines = renderUnified(trimmed, opts);
|
|
497
585
|
break;
|
|
498
586
|
}
|
|
499
|
-
//
|
|
587
|
+
// Final safety net — if still over budget, simple tail truncation.
|
|
500
588
|
if (bodyLines.length > maxLines) {
|
|
501
589
|
const overflow = bodyLines.length - maxLines;
|
|
502
590
|
bodyLines = bodyLines.slice(0, maxLines);
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
692
|
-
inputCursor: this.editor.
|
|
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
|
-
//
|
|
759
|
-
//
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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,27 +11,42 @@
|
|
|
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
|
+
}
|
|
15
27
|
export declare class HandlerRegistry {
|
|
16
|
-
private
|
|
28
|
+
private entries;
|
|
17
29
|
/**
|
|
18
|
-
* Register a named handler. If one already exists,
|
|
30
|
+
* Register a named handler. If one already exists, its base is replaced
|
|
31
|
+
* but existing advisors are preserved.
|
|
19
32
|
*/
|
|
20
|
-
define(name: string, fn:
|
|
33
|
+
define(name: string, fn: HandlerFn): void;
|
|
21
34
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
35
|
+
* Add an advisor to a named handler. The advisor receives `next`
|
|
36
|
+
* (the rest of the chain) and all original arguments.
|
|
24
37
|
*
|
|
25
|
-
* - Call `next(...args)` to invoke the
|
|
38
|
+
* - Call `next(...args)` to invoke the rest of the chain
|
|
26
39
|
* - Don't call `next` to replace entirely (override)
|
|
27
40
|
* - Call `next` conditionally to wrap (around)
|
|
28
41
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
42
|
+
* Advisors run outermost-first (last added = outermost).
|
|
43
|
+
* Returns an unadvise function that cleanly removes this advisor.
|
|
31
44
|
*/
|
|
32
|
-
advise(name: string,
|
|
45
|
+
advise(name: string, advisor: Advisor): () => void;
|
|
33
46
|
/**
|
|
34
|
-
* Call a named handler.
|
|
47
|
+
* Call a named handler. Builds the advisor chain on each call:
|
|
48
|
+
* outermost advisor wraps the next, down to the base handler.
|
|
49
|
+
* Returns undefined if no handler is registered.
|
|
35
50
|
*/
|
|
36
51
|
call(name: string, ...args: any[]): any;
|
|
37
52
|
/**
|
|
@@ -39,3 +54,4 @@ export declare class HandlerRegistry {
|
|
|
39
54
|
*/
|
|
40
55
|
has(name: string): boolean;
|
|
41
56
|
}
|
|
57
|
+
export {};
|
|
@@ -11,42 +11,78 @@
|
|
|
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
|
-
|
|
20
|
+
entries = new Map();
|
|
18
21
|
/**
|
|
19
|
-
* Register a named handler. If one already exists,
|
|
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.
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
-
*
|
|
33
|
-
*
|
|
42
|
+
* Advisors run outermost-first (last added = outermost).
|
|
43
|
+
* Returns an unadvise function that cleanly removes this advisor.
|
|
34
44
|
*/
|
|
35
|
-
advise(name,
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
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
|
|
44
|
-
|
|
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.
|
|
86
|
+
return this.entries.has(name);
|
|
51
87
|
}
|
|
52
88
|
}
|
|
@@ -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.
|
|
6
|
-
*
|
|
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,26 @@ export type LineEditAction = {
|
|
|
24
30
|
action: "arrow-down";
|
|
25
31
|
};
|
|
26
32
|
export declare class LineEditor {
|
|
27
|
-
|
|
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 coordinates. */
|
|
48
|
+
get displayCursor(): number;
|
|
49
|
+
/** Number of logical positions in the buffer. */
|
|
50
|
+
get length(): number;
|
|
51
|
+
/** Replace buffer content. Clears paste attachments. */
|
|
52
|
+
setText(value: string): void;
|
|
33
53
|
/** Process raw terminal input, return actions for the consumer. */
|
|
34
54
|
feed(data: string): LineEditAction[];
|
|
35
55
|
/** Check if there's a pending incomplete escape sequence. */
|