agent-sh 0.15.0 → 0.15.2
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/dist/agent/agent-loop.js +11 -8
- package/dist/agent/events.d.ts +4 -0
- package/docs/README.md +14 -0
- package/docs/agent.md +398 -0
- package/docs/architecture.md +196 -0
- package/docs/context-management.md +200 -0
- package/docs/extensions.md +951 -0
- package/docs/library.md +84 -0
- package/docs/troubleshooting.md +65 -0
- package/docs/tui-composition.md +294 -0
- package/docs/usage.md +306 -0
- package/examples/extensions/ash-scheme/package.json +1 -1
- package/examples/extensions/ashi/EXTENDING.md +2 -2
- package/examples/extensions/ashi/README.md +2 -2
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
- package/examples/extensions/ashi/package.json +5 -3
- package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
- package/examples/extensions/ashi/src/cli.ts +9 -8
- package/examples/extensions/ashi/src/dialogs.ts +16 -1
- package/examples/extensions/ashi/src/events.ts +1 -0
- package/examples/extensions/ashi/src/frontend.ts +26 -6
- package/examples/extensions/ashi/src/renderer.ts +24 -4
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
- package/examples/extensions/ashi/src/ui.ts +11 -0
- package/examples/extensions/ashi-ink/package.json +2 -2
- package/examples/extensions/claude-code-bridge/package.json +1 -1
- package/examples/extensions/opencode-bridge/package.json +1 -1
- package/package.json +3 -1
- package/src/agent/agent-loop.ts +1566 -0
- package/src/agent/entry-format.ts +19 -0
- package/src/agent/events.ts +153 -0
- package/src/agent/extensions/rolling-history/constants.ts +1 -0
- package/src/agent/extensions/rolling-history/index.ts +202 -0
- package/src/agent/extensions/rolling-history/recall.ts +131 -0
- package/src/agent/extensions/rolling-history/strategy.ts +404 -0
- package/src/agent/host-types.ts +192 -0
- package/src/agent/index.ts +591 -0
- package/src/agent/live-view.ts +279 -0
- package/src/agent/llm-client.ts +111 -0
- package/src/agent/llm-facade.ts +43 -0
- package/src/agent/normalize-args.ts +61 -0
- package/src/agent/nuclear-form.ts +382 -0
- package/src/agent/providers/deepseek.ts +39 -0
- package/src/agent/providers/ollama.ts +92 -0
- package/src/agent/providers/openai-compatible.ts +36 -0
- package/src/agent/providers/openai.ts +52 -0
- package/src/agent/providers/opencode.ts +142 -0
- package/src/agent/providers/openrouter.ts +105 -0
- package/src/agent/providers/zai-coding-plan.ts +33 -0
- package/src/agent/session-store.ts +336 -0
- package/src/agent/skills.ts +228 -0
- package/src/agent/store.ts +310 -0
- package/src/agent/subagent.ts +305 -0
- package/src/agent/system-prompt.ts +151 -0
- package/src/agent/token-budget.ts +12 -0
- package/src/agent/tool-protocol.ts +722 -0
- package/src/agent/tool-registry.ts +66 -0
- package/src/agent/tools/bash.ts +95 -0
- package/src/agent/tools/edit-file.ts +154 -0
- package/src/agent/tools/expand-home.ts +7 -0
- package/src/agent/tools/glob.ts +108 -0
- package/src/agent/tools/grep.ts +228 -0
- package/src/agent/tools/list-skills.ts +37 -0
- package/src/agent/tools/ls.ts +81 -0
- package/src/agent/tools/pwsh.ts +140 -0
- package/src/agent/tools/read-file.ts +164 -0
- package/src/agent/tools/write-file.ts +72 -0
- package/src/agent/types.ts +149 -0
- package/src/cli/args.ts +91 -0
- package/src/cli/auth/cli.ts +244 -0
- package/src/cli/auth/discover.ts +52 -0
- package/src/cli/auth/keys.ts +143 -0
- package/src/cli/index.ts +295 -0
- package/src/cli/init.ts +74 -0
- package/src/cli/install.ts +439 -0
- package/src/cli/shell-env.ts +68 -0
- package/src/cli/subcommands.ts +24 -0
- package/src/core/event-bus.ts +252 -0
- package/src/core/extension-loader.ts +347 -0
- package/src/core/index.ts +152 -0
- package/src/core/settings.ts +398 -0
- package/src/core/types.ts +61 -0
- package/src/extensions/file-autocomplete.ts +71 -0
- package/src/extensions/index.ts +38 -0
- package/src/extensions/slash-commands/events.ts +14 -0
- package/src/extensions/slash-commands/index.ts +269 -0
- package/src/shell/events.ts +73 -0
- package/src/shell/host-types.ts +150 -0
- package/src/shell/index.ts +159 -0
- package/src/shell/input-handler.ts +505 -0
- package/src/shell/output-parser.ts +156 -0
- package/src/shell/shell-context.ts +193 -0
- package/src/shell/shell.ts +414 -0
- package/src/shell/strategies/bash.ts +83 -0
- package/src/shell/strategies/fish.ts +77 -0
- package/src/shell/strategies/index.ts +24 -0
- package/src/shell/strategies/types.ts +64 -0
- package/src/shell/strategies/zsh.ts +92 -0
- package/src/shell/terminal.ts +124 -0
- package/src/shell/tui-input-view.ts +222 -0
- package/src/shell/tui-renderer.ts +1126 -0
- package/src/utils/ansi.ts +140 -0
- package/src/utils/box-frame.ts +138 -0
- package/src/utils/compositor.ts +157 -0
- package/src/utils/diff-renderer.ts +829 -0
- package/src/utils/diff.ts +244 -0
- package/src/utils/executor.ts +305 -0
- package/src/utils/file-watcher.ts +110 -0
- package/src/utils/floating-panel.ts +1160 -0
- package/src/utils/handler-registry.ts +110 -0
- package/src/utils/line-editor.ts +636 -0
- package/src/utils/markdown.ts +437 -0
- package/src/utils/message-utils.ts +113 -0
- package/src/utils/package-version.ts +12 -0
- package/src/utils/palette.ts +64 -0
- package/src/utils/ref-counter.ts +9 -0
- package/src/utils/ripgrep-path.ts +17 -0
- package/src/utils/shell-output-spill.ts +76 -0
- package/src/utils/stream-transform.ts +292 -0
- package/src/utils/terminal-buffer.ts +213 -0
- package/src/utils/tool-display.ts +315 -0
- package/src/utils/tool-interactive.ts +71 -0
- package/src/utils/tty.ts +14 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
import stripAnsiPkg from "strip-ansi";
|
|
3
|
+
|
|
4
|
+
// ── ANSI escape code constants ────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export const CYAN = "\x1b[36m";
|
|
7
|
+
export const DIM = "\x1b[2m";
|
|
8
|
+
export const YELLOW = "\x1b[33m";
|
|
9
|
+
export const GREEN = "\x1b[32m";
|
|
10
|
+
export const RED = "\x1b[31m";
|
|
11
|
+
export const GRAY = "\x1b[90m";
|
|
12
|
+
export const BOLD = "\x1b[1m";
|
|
13
|
+
export const RESET = "\x1b[0m";
|
|
14
|
+
|
|
15
|
+
// ── ANSI utility functions ───────────────────────────────────
|
|
16
|
+
|
|
17
|
+
// Reused across iterations. Segmenter construction is not free, and the API
|
|
18
|
+
// is pure (no per-call state) so a module-level instance is safe.
|
|
19
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Width of a single Unicode code point in terminal columns.
|
|
23
|
+
*
|
|
24
|
+
* For correct rendering of emoji clusters (ZWJ, flags, skin-tone, VS16)
|
|
25
|
+
* prefer `clusterWidth` or `visibleLen`, which segment graphemes first.
|
|
26
|
+
* This code-point-level primitive is kept for callers that iterate over
|
|
27
|
+
* chars for wrap-detection purposes (e.g. CJK line-break rules).
|
|
28
|
+
*/
|
|
29
|
+
export function charWidth(codePoint: number): number {
|
|
30
|
+
return stringWidth(String.fromCodePoint(codePoint));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Width of one grapheme cluster in terminal columns. Handles ZWJ sequences,
|
|
35
|
+
* regional-indicator flags, skin-tone modifiers, and VS16 emoji presentation.
|
|
36
|
+
*/
|
|
37
|
+
export function clusterWidth(cluster: string): number {
|
|
38
|
+
return stringWidth(cluster);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Strip SGR (color/style) sequences from a string. */
|
|
42
|
+
function stripSGR(str: string): string {
|
|
43
|
+
return str.replace(/\x1b\[[^m]*m/g, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Measure visible string length in terminal columns.
|
|
48
|
+
* Excludes SGR (color/style) sequences, and counts each grapheme cluster
|
|
49
|
+
* (emoji, CJK, combining marks) as one terminal-visible unit.
|
|
50
|
+
*/
|
|
51
|
+
export function visibleLen(str: string): number {
|
|
52
|
+
return stringWidth(stripSGR(str));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Truncate a string to fit within `maxWidth` visible columns.
|
|
57
|
+
* Iterates by grapheme cluster so emoji sequences (ZWJ, flags, VS16) are
|
|
58
|
+
* kept intact rather than split mid-cluster. Appends `…` if truncated.
|
|
59
|
+
*/
|
|
60
|
+
export function truncateToWidth(str: string, maxWidth: number): string {
|
|
61
|
+
const clean = stripSGR(str);
|
|
62
|
+
if (maxWidth <= 0) return "";
|
|
63
|
+
if (visibleLen(clean) <= maxWidth) return clean;
|
|
64
|
+
if (maxWidth === 1) return "…";
|
|
65
|
+
const target = maxWidth - 1;
|
|
66
|
+
let width = 0;
|
|
67
|
+
let out = "";
|
|
68
|
+
for (const { segment } of GRAPHEME_SEGMENTER.segment(clean)) {
|
|
69
|
+
const cw = clusterWidth(segment);
|
|
70
|
+
if (width + cw > target) break;
|
|
71
|
+
width += cw;
|
|
72
|
+
out += segment;
|
|
73
|
+
}
|
|
74
|
+
if (out === "") return "…";
|
|
75
|
+
return out + "…";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Truncate to visible width while preserving SGR sequences — use when
|
|
79
|
+
* input carries color/bold codes. `truncateToWidth` strips them. */
|
|
80
|
+
export function truncateAnsiToWidth(str: string, maxWidth: number): string {
|
|
81
|
+
if (maxWidth <= 0) return "";
|
|
82
|
+
if (visibleLen(str) <= maxWidth) return str;
|
|
83
|
+
if (maxWidth === 1) return "…";
|
|
84
|
+
const target = maxWidth - 1;
|
|
85
|
+
|
|
86
|
+
// Walk the string preserving SGR escapes in-place; buffer text between
|
|
87
|
+
// escapes and segment it into graphemes to count width correctly.
|
|
88
|
+
let width = 0;
|
|
89
|
+
let out = "";
|
|
90
|
+
let buf = "";
|
|
91
|
+
let i = 0;
|
|
92
|
+
const flushBuf = (): boolean => {
|
|
93
|
+
if (!buf) return false;
|
|
94
|
+
for (const { segment } of GRAPHEME_SEGMENTER.segment(buf)) {
|
|
95
|
+
const cw = clusterWidth(segment);
|
|
96
|
+
if (width + cw > target) {
|
|
97
|
+
buf = "";
|
|
98
|
+
return true; // budget exhausted
|
|
99
|
+
}
|
|
100
|
+
width += cw;
|
|
101
|
+
out += segment;
|
|
102
|
+
}
|
|
103
|
+
buf = "";
|
|
104
|
+
return false;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
while (i < str.length) {
|
|
108
|
+
if (str[i] === "\x1b" && str[i + 1] === "[") {
|
|
109
|
+
const end = str.indexOf("m", i);
|
|
110
|
+
if (end !== -1) {
|
|
111
|
+
if (flushBuf()) break;
|
|
112
|
+
out += str.slice(i, end + 1);
|
|
113
|
+
i = end + 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const cp = str.codePointAt(i) ?? 0;
|
|
118
|
+
const chLen = cp > 0xffff ? 2 : 1;
|
|
119
|
+
buf += str.slice(i, i + chLen);
|
|
120
|
+
i += chLen;
|
|
121
|
+
}
|
|
122
|
+
flushBuf();
|
|
123
|
+
return out + "\x1b[0m…";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Pad a string with spaces to fill `targetWidth` visible columns.
|
|
128
|
+
*/
|
|
129
|
+
export function padEndToWidth(str: string, targetWidth: number): string {
|
|
130
|
+
const gap = targetWidth - visibleLen(str);
|
|
131
|
+
return gap > 0 ? str + " ".repeat(gap) : str;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Strip ANSI escape sequences and carriage returns.
|
|
135
|
+
* Delegates escape handling to the `strip-ansi` package (covers SGR, OSC,
|
|
136
|
+
* CSI, private-mode, 8-bit CSI, and newer variants). `\r` is not an escape
|
|
137
|
+
* but callers rely on it being stripped alongside. */
|
|
138
|
+
export function stripAnsi(str: string): string {
|
|
139
|
+
return stripAnsiPkg(str).replace(/\r/g, "");
|
|
140
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box frame component for bordered TUI panels.
|
|
3
|
+
*
|
|
4
|
+
* Follows the render(width) -> string[] protocol — pure function,
|
|
5
|
+
* never writes to stdout. Supports multiple border styles and
|
|
6
|
+
* optional title/footer sections with dividers.
|
|
7
|
+
*/
|
|
8
|
+
import { visibleLen, truncateToWidth, truncateAnsiToWidth } from "./ansi.js";
|
|
9
|
+
import { palette as p } from "./palette.js";
|
|
10
|
+
|
|
11
|
+
// ── Types ────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export type BorderStyle = "rounded" | "square" | "double" | "heavy";
|
|
14
|
+
|
|
15
|
+
interface BorderChars {
|
|
16
|
+
tl: string; // top-left
|
|
17
|
+
tr: string; // top-right
|
|
18
|
+
bl: string; // bottom-left
|
|
19
|
+
br: string; // bottom-right
|
|
20
|
+
h: string; // horizontal
|
|
21
|
+
v: string; // vertical
|
|
22
|
+
ml: string; // middle-left (├)
|
|
23
|
+
mr: string; // middle-right (┤)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const BORDERS: Record<BorderStyle, BorderChars> = {
|
|
27
|
+
rounded: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", ml: "├", mr: "┤" },
|
|
28
|
+
square: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│", ml: "├", mr: "┤" },
|
|
29
|
+
double: { tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║", ml: "╠", mr: "╣" },
|
|
30
|
+
heavy: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃", ml: "┣", mr: "┫" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface BoxFrameOptions {
|
|
34
|
+
/** Total width including borders. */
|
|
35
|
+
width: number;
|
|
36
|
+
/** Border style. Default "rounded". */
|
|
37
|
+
style?: BorderStyle;
|
|
38
|
+
/** Border color (ANSI escape). Default DIM. */
|
|
39
|
+
borderColor?: string;
|
|
40
|
+
/** Title text shown on the left of the top border. */
|
|
41
|
+
title?: string;
|
|
42
|
+
/** Title text shown on the right of the top border. */
|
|
43
|
+
titleRight?: string;
|
|
44
|
+
/** Footer lines shown below a divider, inside the box. */
|
|
45
|
+
footer?: string[];
|
|
46
|
+
/** Raw bg ANSI open code (e.g. "\x1b[48;2;40;50;40m"). When set, fills
|
|
47
|
+
* the frame interior with this bg and rewrites internal `\x1b[49m` /
|
|
48
|
+
* `\x1b[0m` resets so per-cell colors in content don't punch holes. */
|
|
49
|
+
bgColor?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Public API ───────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Render content lines inside a bordered frame.
|
|
56
|
+
*
|
|
57
|
+
* @param content - Array of pre-rendered content lines (no border)
|
|
58
|
+
* @param opts - Frame options
|
|
59
|
+
* @returns Array of terminal-ready lines with borders
|
|
60
|
+
*/
|
|
61
|
+
export function renderBoxFrame(content: string[], opts: BoxFrameOptions): string[] {
|
|
62
|
+
const { width: rawWidth, borderColor = p.dim } = opts;
|
|
63
|
+
const width = Math.max(6, rawWidth);
|
|
64
|
+
const style = opts.style ?? "rounded";
|
|
65
|
+
const b = BORDERS[style];
|
|
66
|
+
const bc = borderColor;
|
|
67
|
+
const bg = opts.bgColor ?? "";
|
|
68
|
+
|
|
69
|
+
// Content area width = total - 2 borders - 2 padding spaces
|
|
70
|
+
const innerW = Math.max(1, width - 4);
|
|
71
|
+
const output: string[] = [];
|
|
72
|
+
|
|
73
|
+
// Top border (with optional left/right titles)
|
|
74
|
+
if (opts.title || opts.titleRight) {
|
|
75
|
+
// Budget: 2 corners + 1 minimum dash + space-padding around each title.
|
|
76
|
+
// Truncate the left title first if combined widths overflow — titleRight
|
|
77
|
+
// is typically short metadata (model name, stats) worth preserving.
|
|
78
|
+
let title = opts.title;
|
|
79
|
+
const rightVis = opts.titleRight ? visibleLen(opts.titleRight) + 2 : 0;
|
|
80
|
+
const leftBudget = width - 2 - 1 - rightVis; // total - corners - min dash - right
|
|
81
|
+
let leftVis = title ? visibleLen(title) + 2 : 0;
|
|
82
|
+
if (title && leftVis > leftBudget) {
|
|
83
|
+
const maxTitleVis = Math.max(1, leftBudget - 2);
|
|
84
|
+
title = truncateAnsiToWidth(title, maxTitleVis);
|
|
85
|
+
leftVis = visibleLen(title) + 2;
|
|
86
|
+
}
|
|
87
|
+
const leftPart = title ? `${p.reset} ${title} ${bc}` : "";
|
|
88
|
+
const rightPart = opts.titleRight ? `${p.reset} ${opts.titleRight} ${bc}` : "";
|
|
89
|
+
|
|
90
|
+
const dashCount = Math.max(1, width - 2 - leftVis - rightVis);
|
|
91
|
+
output.push(
|
|
92
|
+
paintBg(`${bc}${b.tl}${leftPart}${b.h.repeat(dashCount)}${rightPart}${b.tr}${p.reset}`, bg),
|
|
93
|
+
);
|
|
94
|
+
} else {
|
|
95
|
+
output.push(paintBg(`${bc}${b.tl}${b.h.repeat(width - 2)}${b.tr}${p.reset}`, bg));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Content lines
|
|
99
|
+
for (const line of content) {
|
|
100
|
+
output.push(paintBg(boxLine(line, innerW, b.v, bc), bg));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Footer with divider
|
|
104
|
+
if (opts.footer && opts.footer.length > 0) {
|
|
105
|
+
output.push(paintBg(`${bc}${b.ml}${b.h.repeat(width - 2)}${b.mr}${p.reset}`, bg));
|
|
106
|
+
for (const line of opts.footer) {
|
|
107
|
+
output.push(paintBg(boxLine(line, innerW, b.v, bc), bg));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Bottom border
|
|
112
|
+
output.push(paintBg(`${bc}${b.bl}${b.h.repeat(width - 2)}${b.br}${p.reset}`, bg));
|
|
113
|
+
|
|
114
|
+
return output;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Wrap a line with a uniform bg, rewriting internal `\x1b[49m` (bg-default)
|
|
118
|
+
* and `\x1b[0m` (full-reset) so embedded colors don't punch through. */
|
|
119
|
+
function paintBg(line: string, bg: string): string {
|
|
120
|
+
if (!bg) return line;
|
|
121
|
+
const fixed = line
|
|
122
|
+
.replaceAll("\x1b[49m", bg)
|
|
123
|
+
.replaceAll("\x1b[0m", "\x1b[0m" + bg);
|
|
124
|
+
return `${bg}${fixed}\x1b[49m`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function boxLine(text: string, innerW: number, v: string, bc: string): string {
|
|
130
|
+
const textWidth = visibleLen(text);
|
|
131
|
+
if (textWidth > innerW) {
|
|
132
|
+
// Content is too wide — truncate to fit exactly
|
|
133
|
+
const truncated = truncateToWidth(text, innerW);
|
|
134
|
+
return `${bc}${v}${p.reset} ${truncated} ${bc}${v}${p.reset}`;
|
|
135
|
+
}
|
|
136
|
+
const pad = innerW - textWidth;
|
|
137
|
+
return `${bc}${v}${p.reset} ${text}${" ".repeat(pad)} ${bc}${v}${p.reset}`;
|
|
138
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
|
|
28
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
29
|
+
|
|
30
|
+
import type { EventBus } from "../core/event-bus.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A surface accepts rendered output. Stdout is a surface.
|
|
34
|
+
* A floating panel's content area is a surface. A test buffer is a surface.
|
|
35
|
+
*/
|
|
36
|
+
export interface RenderSurface {
|
|
37
|
+
/** Raw write — supports \r, partial lines, escape codes. */
|
|
38
|
+
write(text: string): void;
|
|
39
|
+
/** Convenience: write + newline. */
|
|
40
|
+
writeLine(line: string): void;
|
|
41
|
+
/** Available width in columns. */
|
|
42
|
+
readonly columns: number;
|
|
43
|
+
/** Available height in rows. */
|
|
44
|
+
readonly rows: number;
|
|
45
|
+
/** Subscribe to size changes. Returns unsubscribe. */
|
|
46
|
+
onResize(cb: (cols: number, rows: number) => void): () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Compositor {
|
|
50
|
+
/** Get the currently active surface for a stream. */
|
|
51
|
+
surface(stream: string): RenderSurface;
|
|
52
|
+
|
|
53
|
+
/** Override routing: redirect a stream to a different surface.
|
|
54
|
+
* Returns a restore function that undoes the redirect. */
|
|
55
|
+
redirect(stream: string, target: RenderSurface): () => void;
|
|
56
|
+
|
|
57
|
+
/** Register the default surface for a stream. */
|
|
58
|
+
setDefault(stream: string, target: RenderSurface): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Silent sink — drops all output. Used when no surface is registered. */
|
|
62
|
+
export const nullSurface: RenderSurface = {
|
|
63
|
+
write() {},
|
|
64
|
+
writeLine() {},
|
|
65
|
+
get columns() { return 80; },
|
|
66
|
+
get rows() { return 24; },
|
|
67
|
+
onResize() { return () => {}; },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Surface backed by process.stdout — the only sanctioned bridge to it. */
|
|
71
|
+
export class StdoutSurface implements RenderSurface {
|
|
72
|
+
write(text: string): void {
|
|
73
|
+
if (process.stdout.writable) {
|
|
74
|
+
// OPOST is cleared on the TTY; add CR to lone \n so we don't staircase.
|
|
75
|
+
try { process.stdout.write(text.replace(/(?<!\r)\n/g, "\r\n")); } catch { /* ignore */ }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
writeLine(line: string): void {
|
|
79
|
+
this.write(line + "\n");
|
|
80
|
+
}
|
|
81
|
+
get columns(): number {
|
|
82
|
+
return process.stdout.columns || 80;
|
|
83
|
+
}
|
|
84
|
+
get rows(): number {
|
|
85
|
+
return process.stdout.rows || 24;
|
|
86
|
+
}
|
|
87
|
+
onResize(cb: (cols: number, rows: number) => void): () => void {
|
|
88
|
+
const handler = () => cb(this.columns, this.rows);
|
|
89
|
+
process.stdout.on("resize", handler);
|
|
90
|
+
return () => { process.stdout.off("resize", handler); };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class DefaultCompositor implements Compositor {
|
|
95
|
+
private defaults = new Map<string, RenderSurface>();
|
|
96
|
+
private overrides = new Map<string, RenderSurface[]>();
|
|
97
|
+
private readonly bus?: EventBus;
|
|
98
|
+
|
|
99
|
+
constructor(bus?: EventBus) {
|
|
100
|
+
this.bus = bus;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
surface(stream: string): RenderSurface {
|
|
104
|
+
const stack = this.overrides.get(stream);
|
|
105
|
+
if (stack && stack.length > 0) return stack[stack.length - 1]!;
|
|
106
|
+
if (this.defaults.has(stream)) return this.defaults.get(stream)!;
|
|
107
|
+
|
|
108
|
+
// Hierarchical fallback: "agent:diff" → "agent"
|
|
109
|
+
const colon = stream.lastIndexOf(":");
|
|
110
|
+
if (colon !== -1) return this.surface(stream.slice(0, colon));
|
|
111
|
+
|
|
112
|
+
return nullSurface;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
redirect(stream: string, target: RenderSurface): () => void {
|
|
116
|
+
const wrapped = this.wrap(stream, target);
|
|
117
|
+
let stack = this.overrides.get(stream);
|
|
118
|
+
if (!stack) {
|
|
119
|
+
stack = [];
|
|
120
|
+
this.overrides.set(stream, stack);
|
|
121
|
+
}
|
|
122
|
+
stack.push(wrapped);
|
|
123
|
+
|
|
124
|
+
let restored = false;
|
|
125
|
+
return () => {
|
|
126
|
+
if (restored) return;
|
|
127
|
+
restored = true;
|
|
128
|
+
const s = this.overrides.get(stream);
|
|
129
|
+
if (!s) return;
|
|
130
|
+
const idx = s.indexOf(wrapped);
|
|
131
|
+
if (idx !== -1) s.splice(idx, 1);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
setDefault(stream: string, target: RenderSurface): void {
|
|
136
|
+
this.defaults.set(stream, this.wrap(stream, target));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Wrap a surface so writes emit `compositor:write` before delegating. */
|
|
140
|
+
private wrap(stream: string, target: RenderSurface): RenderSurface {
|
|
141
|
+
const bus = this.bus;
|
|
142
|
+
if (!bus) return target;
|
|
143
|
+
return {
|
|
144
|
+
write: (text: string) => {
|
|
145
|
+
try { bus.emit("compositor:write", { stream, text }); } catch {}
|
|
146
|
+
target.write(text);
|
|
147
|
+
},
|
|
148
|
+
writeLine: (line: string) => {
|
|
149
|
+
try { bus.emit("compositor:write", { stream, text: line + "\n" }); } catch {}
|
|
150
|
+
target.writeLine(line);
|
|
151
|
+
},
|
|
152
|
+
get columns() { return target.columns; },
|
|
153
|
+
get rows() { return target.rows; },
|
|
154
|
+
onResize: (cb) => target.onResize(cb),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|