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