agent-sh 0.10.1 → 0.10.3
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 +1 -0
- package/dist/agent/agent-loop.js +9 -2
- package/dist/agent/history-file.d.ts +1 -0
- package/dist/agent/history-file.js +12 -5
- package/dist/agent/nuclear-form.d.ts +2 -0
- package/dist/agent/nuclear-form.js +11 -1
- package/dist/agent/system-prompt.js +1 -1
- package/dist/agent/tool-registry.js +6 -0
- package/dist/agent/types.d.ts +3 -0
- package/dist/extensions/tui-renderer.js +42 -27
- package/dist/settings.d.ts +7 -0
- package/dist/settings.js +1 -0
- package/dist/shell/input-handler.js +6 -0
- package/dist/utils/ansi.d.ts +21 -7
- package/dist/utils/ansi.js +90 -123
- package/dist/utils/llm-client.d.ts +4 -0
- package/dist/utils/llm-client.js +8 -0
- package/dist/utils/markdown.d.ts +4 -0
- package/dist/utils/markdown.js +144 -59
- package/package.json +15 -1
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@ An agent that lives in a shell — not a shell that lives in an agent.
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/agent-sh)
|
|
6
6
|
[](https://github.com/guanyilun/agent-sh/blob/main/LICENSE)
|
|
7
|
+
[](https://agent-sh.dev)
|
|
7
8
|
|
|
8
9
|

|
|
9
10
|
|
package/dist/agent/agent-loop.js
CHANGED
|
@@ -97,7 +97,8 @@ export class AgentLoop {
|
|
|
97
97
|
// Shell-history-shaped log. Default writes go through the advisable
|
|
98
98
|
// `history:append` handler registered below; extensions swap the
|
|
99
99
|
// backend without touching this wiring.
|
|
100
|
-
|
|
100
|
+
const filePath = process.env.AGENT_SH_HISTORY_FILE || getSettings().historyFilePath;
|
|
101
|
+
this.historyFile = new HistoryFile({ instanceId: this.instanceId, filePath });
|
|
101
102
|
this.conversation = new ConversationState(this.handlers, this.instanceId);
|
|
102
103
|
// Fall back to a single-mode placeholder if the caller passed an
|
|
103
104
|
// empty array (agent-backend does this pre-resolution).
|
|
@@ -280,6 +281,9 @@ export class AgentLoop {
|
|
|
280
281
|
if (beforeTokens > this.peakConversationTokens) {
|
|
281
282
|
this.peakConversationTokens = beforeTokens;
|
|
282
283
|
}
|
|
284
|
+
// The "File unchanged" stub assumes the prior read output is still
|
|
285
|
+
// in context; compaction can evict it. Clear so the next read re-emits.
|
|
286
|
+
this.fileReadCache.clear();
|
|
283
287
|
});
|
|
284
288
|
on("shell:cwd-change", ({ cwd }) => {
|
|
285
289
|
const projectSkills = discoverProjectSkills(cwd);
|
|
@@ -1035,7 +1039,10 @@ export class AgentLoop {
|
|
|
1035
1039
|
const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
1036
1040
|
const threshold = Math.floor((contextWindow - RESPONSE_RESERVE) * getSettings().autoCompactThreshold);
|
|
1037
1041
|
if (totalEstimate > threshold) {
|
|
1038
|
-
|
|
1042
|
+
// Compact deeply — shallow targets buy only 1–2 turns of runway on
|
|
1043
|
+
// tool-heavy workloads.
|
|
1044
|
+
const target = Math.floor(threshold * 0.25);
|
|
1045
|
+
const result = this.compactWithHooks(target, 6);
|
|
1039
1046
|
if (!result) {
|
|
1040
1047
|
// Auto-compact fired but nothing was evictable. This can happen
|
|
1041
1048
|
// in short conversations with heavy tool output where the pin
|
|
@@ -12,14 +12,21 @@ import * as crypto from "node:crypto";
|
|
|
12
12
|
import { CONFIG_DIR, getSettings } from "../settings.js";
|
|
13
13
|
import { serializeEntry, deserializeEntry, formatNuclearLine, isReadOnly, } from "./nuclear-form.js";
|
|
14
14
|
const HISTORY_PATH = path.join(CONFIG_DIR, "history");
|
|
15
|
-
const LOCK_PATH = HISTORY_PATH + ".lock";
|
|
16
15
|
const LOCK_STALE_MS = 10_000; // consider lock stale after 10s
|
|
17
16
|
export class HistoryFile {
|
|
18
17
|
instanceId;
|
|
19
18
|
filePath;
|
|
19
|
+
lockPath;
|
|
20
20
|
constructor(opts) {
|
|
21
21
|
this.filePath = opts?.filePath ?? HISTORY_PATH;
|
|
22
|
+
this.lockPath = this.filePath + ".lock";
|
|
22
23
|
this.instanceId = opts?.instanceId ?? crypto.randomBytes(2).toString("hex");
|
|
24
|
+
// Custom paths may target a dir that doesn't exist yet; create sync so
|
|
25
|
+
// the first append() can't race with the mkdir.
|
|
26
|
+
try {
|
|
27
|
+
fss.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
catch { /* ignore */ }
|
|
23
30
|
}
|
|
24
31
|
/**
|
|
25
32
|
* Append entries atomically. Uses O_APPEND for concurrency safety.
|
|
@@ -218,16 +225,16 @@ export class HistoryFile {
|
|
|
218
225
|
try {
|
|
219
226
|
// Check for stale lock
|
|
220
227
|
try {
|
|
221
|
-
const stat = await fs.stat(
|
|
228
|
+
const stat = await fs.stat(this.lockPath);
|
|
222
229
|
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
223
|
-
await fs.unlink(
|
|
230
|
+
await fs.unlink(this.lockPath).catch(() => { });
|
|
224
231
|
}
|
|
225
232
|
}
|
|
226
233
|
catch {
|
|
227
234
|
// Lock doesn't exist — good
|
|
228
235
|
}
|
|
229
236
|
// O_EXCL ensures atomicity
|
|
230
|
-
const fd = await fs.open(
|
|
237
|
+
const fd = await fs.open(this.lockPath, fss.constants.O_CREAT | fss.constants.O_EXCL | fss.constants.O_WRONLY);
|
|
231
238
|
await fd.close();
|
|
232
239
|
return true;
|
|
233
240
|
}
|
|
@@ -236,6 +243,6 @@ export class HistoryFile {
|
|
|
236
243
|
}
|
|
237
244
|
}
|
|
238
245
|
async releaseLock() {
|
|
239
|
-
await fs.unlink(
|
|
246
|
+
await fs.unlink(this.lockPath).catch(() => { });
|
|
240
247
|
}
|
|
241
248
|
}
|
|
@@ -43,6 +43,8 @@ export declare function createSessionMarker(iid: string, seq?: number): NuclearE
|
|
|
43
43
|
export declare function isSessionMarker(entry: NuclearEntry): boolean;
|
|
44
44
|
/** Read-only tools whose results are dropped at Tier 1→2 (agent can re-read). */
|
|
45
45
|
export declare const READ_ONLY_TOOLS: Set<string>;
|
|
46
|
+
export declare function registerReadOnlyTool(name: string): void;
|
|
47
|
+
export declare function unregisterReadOnlyTool(name: string): void;
|
|
46
48
|
/** State-changing tools whose summaries are kept in nuclear memory. */
|
|
47
49
|
export declare const WRITE_TOOLS: Set<string>;
|
|
48
50
|
/**
|
|
@@ -15,6 +15,14 @@ export function isSessionMarker(entry) {
|
|
|
15
15
|
export const READ_ONLY_TOOLS = new Set([
|
|
16
16
|
"read_file", "grep", "glob", "ls", "search",
|
|
17
17
|
]);
|
|
18
|
+
/** Extensions opt their tools in via ToolRegistry.register when readOnly is set. */
|
|
19
|
+
const extraReadOnlyTools = new Set();
|
|
20
|
+
export function registerReadOnlyTool(name) {
|
|
21
|
+
extraReadOnlyTools.add(name);
|
|
22
|
+
}
|
|
23
|
+
export function unregisterReadOnlyTool(name) {
|
|
24
|
+
extraReadOnlyTools.delete(name);
|
|
25
|
+
}
|
|
18
26
|
/** State-changing tools whose summaries are kept in nuclear memory. */
|
|
19
27
|
export const WRITE_TOOLS = new Set([
|
|
20
28
|
"write_file", "edit_file", "write", "edit", "patch",
|
|
@@ -188,7 +196,9 @@ export function deserializeEntry(line) {
|
|
|
188
196
|
// ── Classification helpers ────────────────────────────────────────
|
|
189
197
|
/** Check if a nuclear entry represents a read-only action (should be dropped). */
|
|
190
198
|
export function isReadOnly(entry) {
|
|
191
|
-
|
|
199
|
+
if (entry.kind !== "tool" || entry.tool == null)
|
|
200
|
+
return false;
|
|
201
|
+
return READ_ONLY_TOOLS.has(entry.tool) || extraReadOnlyTools.has(entry.tool);
|
|
192
202
|
}
|
|
193
203
|
// ── Internal helpers ──────────────────────────────────────────────
|
|
194
204
|
function truncate(text, maxLen) {
|
|
@@ -89,7 +89,7 @@ function loadConventionFiles(dir) {
|
|
|
89
89
|
* Static system prompt — identical across all queries, cacheable.
|
|
90
90
|
* Contains only identity and behavioral instructions.
|
|
91
91
|
*/
|
|
92
|
-
export const STATIC_SYSTEM_PROMPT = `You are an AI coding assistant running inside agent-sh, a terminal shell.
|
|
92
|
+
export const STATIC_SYSTEM_PROMPT = `You are ash, an AI coding assistant running inside agent-sh, a terminal shell.
|
|
93
93
|
You have access to the user's shell environment and can read, write, and execute code.
|
|
94
94
|
You share the user's working directory, environment variables, and shell history.
|
|
95
95
|
agent-sh documentation is at ${path.join(CODE_DIR, "docs")} — start with README.md for an index. Read the docs when you need to understand how the runtime works.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerReadOnlyTool, unregisterReadOnlyTool } from "./nuclear-form.js";
|
|
1
2
|
/**
|
|
2
3
|
* Registry for agent tools. Holds tool definitions and converts them
|
|
3
4
|
* to OpenAI-compatible function schemas for API calls.
|
|
@@ -6,9 +7,14 @@ export class ToolRegistry {
|
|
|
6
7
|
tools = new Map();
|
|
7
8
|
register(tool) {
|
|
8
9
|
this.tools.set(tool.name, tool);
|
|
10
|
+
if (tool.readOnly)
|
|
11
|
+
registerReadOnlyTool(tool.name);
|
|
12
|
+
else
|
|
13
|
+
unregisterReadOnlyTool(tool.name);
|
|
9
14
|
}
|
|
10
15
|
unregister(name) {
|
|
11
16
|
this.tools.delete(name);
|
|
17
|
+
unregisterReadOnlyTool(name);
|
|
12
18
|
}
|
|
13
19
|
get(name) {
|
|
14
20
|
return this.tools.get(name);
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -77,6 +77,9 @@ export interface ToolDefinition {
|
|
|
77
77
|
showOutput?: boolean;
|
|
78
78
|
/** Whether this tool may modify files — triggers file watcher (default: false). */
|
|
79
79
|
modifiesFiles?: boolean;
|
|
80
|
+
/** Results are re-fetchable; nuclear compaction drops the tool_result
|
|
81
|
+
* body on eviction (like the builtin read_file/grep/ls). Default: false. */
|
|
82
|
+
readOnly?: boolean;
|
|
80
83
|
/** Whether to gate execution via permission:request (default: false). */
|
|
81
84
|
requiresPermission?: boolean;
|
|
82
85
|
/** Derive display metadata (icon kind, file paths) for the TUI. */
|
|
@@ -48,7 +48,8 @@ function createRenderState() {
|
|
|
48
48
|
spinnerOpts: {},
|
|
49
49
|
spinnerInterval: null,
|
|
50
50
|
spinnerStartTime: 0,
|
|
51
|
-
|
|
51
|
+
openTool: null,
|
|
52
|
+
pendingToolCompletes: new Map(),
|
|
52
53
|
currentToolKind: undefined,
|
|
53
54
|
toolStartTime: 0,
|
|
54
55
|
toolExitCode: null,
|
|
@@ -58,6 +59,7 @@ function createRenderState() {
|
|
|
58
59
|
commandOverflowLines: [],
|
|
59
60
|
toolGroupKind: undefined,
|
|
60
61
|
toolGroupCount: 0,
|
|
62
|
+
toolGroupCompletedCount: 0,
|
|
61
63
|
toolGroupAllOk: true,
|
|
62
64
|
toolGroupRendered: 0,
|
|
63
65
|
toolGroupSummaries: [],
|
|
@@ -231,6 +233,9 @@ export default function activate(ctx) {
|
|
|
231
233
|
return;
|
|
232
234
|
s.isThinking = false;
|
|
233
235
|
if (pendingUsage && s.renderer) {
|
|
236
|
+
// Flush any buffered partial line first — otherwise responses that
|
|
237
|
+
// don't end with a newline emit the usage line before their final text.
|
|
238
|
+
s.renderer.flush();
|
|
234
239
|
const { prompt_tokens, completion_tokens } = pendingUsage;
|
|
235
240
|
const maxTokens = backendInfo?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
236
241
|
s.renderer.writeLine("");
|
|
@@ -300,29 +305,26 @@ export default function activate(ctx) {
|
|
|
300
305
|
group.headerShown = true;
|
|
301
306
|
s.toolGroupKind = kind;
|
|
302
307
|
s.toolGroupCount = 0;
|
|
308
|
+
s.toolGroupCompletedCount = 0;
|
|
303
309
|
s.toolGroupRendered = 0;
|
|
304
310
|
s.toolGroupAllOk = true;
|
|
305
311
|
s.toolGroupSummaries = [];
|
|
306
312
|
}
|
|
307
313
|
s.toolGroupCount++;
|
|
308
314
|
if (s.toolGroupRendered < GROUP_MAX_VISIBLE) {
|
|
309
|
-
showToolCall(e.title, "", {
|
|
310
|
-
...e,
|
|
311
|
-
batchIndex: e.batchIndex,
|
|
312
|
-
batchTotal: e.batchTotal,
|
|
313
|
-
groupContinuation: true,
|
|
314
|
-
});
|
|
315
|
+
showToolCall(e.title, "", { ...e, groupContinuation: true });
|
|
315
316
|
s.toolGroupRendered++;
|
|
316
317
|
}
|
|
318
|
+
// Record identity so late completes (after a premature finalize
|
|
319
|
+
// from a cross-kind standalone start) can render as labeled ⎿ lines.
|
|
320
|
+
if (e.toolCallId) {
|
|
321
|
+
s.pendingToolCompletes.set(e.toolCallId, { title: e.title });
|
|
322
|
+
}
|
|
317
323
|
}
|
|
318
324
|
else {
|
|
319
325
|
// Standalone tool — single in its batch kind, or not groupable
|
|
320
326
|
finalizeToolGroup();
|
|
321
|
-
showToolCall(e.title, "", {
|
|
322
|
-
...e,
|
|
323
|
-
batchIndex: e.batchIndex,
|
|
324
|
-
batchTotal: e.batchTotal,
|
|
325
|
-
});
|
|
327
|
+
showToolCall(e.title, "", { ...e });
|
|
326
328
|
}
|
|
327
329
|
});
|
|
328
330
|
bus.on("agent:tool-completed", (e) => {
|
|
@@ -336,10 +338,17 @@ export default function activate(ctx) {
|
|
|
336
338
|
// Don't restart spinner between grouped tools — it's already running from group start.
|
|
337
339
|
if (e.resultDisplay?.summary)
|
|
338
340
|
s.toolGroupSummaries.push(e.resultDisplay.summary);
|
|
341
|
+
if (e.toolCallId)
|
|
342
|
+
s.pendingToolCompletes.delete(e.toolCallId);
|
|
343
|
+
s.toolGroupCompletedCount++;
|
|
339
344
|
s.currentToolKind = undefined;
|
|
340
345
|
}
|
|
341
346
|
else {
|
|
342
|
-
|
|
347
|
+
// Route by callId — tools that lost the inline slot get a labeled ⎿ line.
|
|
348
|
+
const pending = e.toolCallId ? s.pendingToolCompletes.get(e.toolCallId) : undefined;
|
|
349
|
+
if (pending)
|
|
350
|
+
s.pendingToolCompletes.delete(e.toolCallId);
|
|
351
|
+
showToolComplete(e.exitCode, e.resultDisplay, pending?.title);
|
|
343
352
|
s.currentToolKind = undefined;
|
|
344
353
|
s.spinnerStartTime = 0;
|
|
345
354
|
startThinkingSpinner();
|
|
@@ -734,37 +743,37 @@ export default function activate(ctx) {
|
|
|
734
743
|
// Grouped tools: close the line immediately — checkmarks go on the ⎿ summary
|
|
735
744
|
s.renderer.writeLine(` ${batchPrefix}${lines[lines.length - 1]}`);
|
|
736
745
|
drain();
|
|
737
|
-
s.toolLineOpen = false;
|
|
738
746
|
}
|
|
739
747
|
else {
|
|
740
748
|
out().write(` ${batchPrefix}${lines[lines.length - 1]}`);
|
|
741
|
-
|
|
749
|
+
if (extra?.toolCallId)
|
|
750
|
+
s.openTool = { callId: extra.toolCallId, title };
|
|
742
751
|
}
|
|
743
752
|
}
|
|
744
753
|
s.hadToolCalls = true;
|
|
745
754
|
s.commandOutputLineCount = 0;
|
|
746
755
|
s.commandOutputOverflow = 0;
|
|
747
756
|
}
|
|
748
|
-
function showToolComplete(exitCode, resultDisplay) {
|
|
757
|
+
function showToolComplete(exitCode, resultDisplay, labelTitle) {
|
|
749
758
|
if (!s.renderer)
|
|
750
759
|
return;
|
|
751
760
|
stopCurrentSpinner();
|
|
752
761
|
const elapsed = s.toolStartTime ? formatElapsed(Date.now() - s.toolStartTime) : "";
|
|
753
762
|
const mark = ctx.call("tui:render-tool-complete", exitCode, elapsed, resultDisplay?.summary);
|
|
754
|
-
if (s.
|
|
763
|
+
if (!labelTitle && s.openTool && s.commandOutputLineCount === 0) {
|
|
755
764
|
out().write(` ${mark}\n`);
|
|
756
|
-
s.
|
|
765
|
+
s.openTool = null;
|
|
757
766
|
}
|
|
758
767
|
else {
|
|
759
768
|
closeToolLine();
|
|
760
769
|
flushCommandOutput();
|
|
761
|
-
s.renderer.writeLine(
|
|
770
|
+
s.renderer.writeLine(labelTitle
|
|
771
|
+
? ` ${p.muted}⎿${p.reset} ${p.dim}${labelTitle}${p.reset} ${mark}`
|
|
772
|
+
: ` ${mark}`);
|
|
762
773
|
drain();
|
|
763
774
|
}
|
|
764
|
-
|
|
765
|
-
if (resultDisplay?.body) {
|
|
775
|
+
if (resultDisplay?.body)
|
|
766
776
|
renderResultBody(resultDisplay.body);
|
|
767
|
-
}
|
|
768
777
|
}
|
|
769
778
|
function renderResultBody(body) {
|
|
770
779
|
if (!s.renderer)
|
|
@@ -813,18 +822,23 @@ export default function activate(ctx) {
|
|
|
813
822
|
}
|
|
814
823
|
}
|
|
815
824
|
function closeToolLine() {
|
|
816
|
-
if (s.
|
|
825
|
+
if (s.openTool) {
|
|
817
826
|
out().write("\n");
|
|
818
|
-
|
|
827
|
+
// Stash identity so the completion renders as ⎿ labeled, not orphan ✓.
|
|
828
|
+
s.pendingToolCompletes.set(s.openTool.callId, { title: s.openTool.title });
|
|
829
|
+
s.openTool = null;
|
|
819
830
|
}
|
|
820
831
|
}
|
|
821
|
-
/**
|
|
832
|
+
/** Render the group aggregate ⎿ line, or skip if no members have
|
|
833
|
+
* completed yet (late completes will render individually as ⎿ labeled). */
|
|
822
834
|
function finalizeToolGroup() {
|
|
823
|
-
|
|
824
|
-
|
|
835
|
+
const skipAggregate = s.toolGroupCount > 1 && s.toolGroupCompletedCount === 0;
|
|
836
|
+
if (s.toolGroupCount <= 1 || skipAggregate) {
|
|
825
837
|
s.toolGroupKind = undefined;
|
|
826
838
|
s.toolGroupCount = 0;
|
|
839
|
+
s.toolGroupCompletedCount = 0;
|
|
827
840
|
s.toolGroupRendered = 0;
|
|
841
|
+
s.toolGroupAllOk = true;
|
|
828
842
|
s.toolGroupSummaries = [];
|
|
829
843
|
return;
|
|
830
844
|
}
|
|
@@ -836,6 +850,7 @@ export default function activate(ctx) {
|
|
|
836
850
|
drain();
|
|
837
851
|
s.toolGroupKind = undefined;
|
|
838
852
|
s.toolGroupCount = 0;
|
|
853
|
+
s.toolGroupCompletedCount = 0;
|
|
839
854
|
s.toolGroupAllOk = true;
|
|
840
855
|
s.toolGroupRendered = 0;
|
|
841
856
|
s.toolGroupSummaries = [];
|
package/dist/settings.d.ts
CHANGED
|
@@ -42,6 +42,13 @@ export interface Settings {
|
|
|
42
42
|
historyMaxBytes?: number;
|
|
43
43
|
/** Number of prior history entries to load on startup (default: 50). */
|
|
44
44
|
historyStartupEntries?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Override the history file path. Defaults to `~/.agent-sh/history`.
|
|
47
|
+
* The `AGENT_SH_HISTORY_FILE` env var takes precedence over this setting.
|
|
48
|
+
* Use a per-project path to keep sessions isolated (e.g. embedding apps
|
|
49
|
+
* that boot agent-sh as a library against a specific working tree).
|
|
50
|
+
*/
|
|
51
|
+
historyFilePath?: string;
|
|
45
52
|
/** Auto-compact threshold as fraction of conversation budget (0-1, default 0.5). */
|
|
46
53
|
autoCompactThreshold?: number;
|
|
47
54
|
/** Max command output lines shown inline in TUI. */
|
package/dist/settings.js
CHANGED
|
@@ -21,6 +21,7 @@ const DEFAULTS = {
|
|
|
21
21
|
shellTailLines: 10,
|
|
22
22
|
historyMaxBytes: 104857600, // 100MB — history is only accessed via search/expand, never loaded wholesale
|
|
23
23
|
historyStartupEntries: 100,
|
|
24
|
+
historyFilePath: undefined,
|
|
24
25
|
autoCompactThreshold: 0.5,
|
|
25
26
|
maxCommandOutputLines: 3,
|
|
26
27
|
readOutputMaxLines: 10,
|
|
@@ -218,6 +218,12 @@ export class InputHandler {
|
|
|
218
218
|
this.lineBuffer = "";
|
|
219
219
|
this.ctx.writeToPty(ch);
|
|
220
220
|
}
|
|
221
|
+
else if (ch === "\x0b" || ch === "\x15") {
|
|
222
|
+
// Ctrl-K / Ctrl-U kill the line in the shell; mirror that so the
|
|
223
|
+
// mode-trigger check sees an empty buffer. Not cursor-accurate.
|
|
224
|
+
this.lineBuffer = "";
|
|
225
|
+
this.ctx.writeToPty(ch);
|
|
226
|
+
}
|
|
221
227
|
else if (ch === "\x1b") {
|
|
222
228
|
// Escape sequence — forward the entire sequence to the PTY but
|
|
223
229
|
// don't let it corrupt lineBuffer. Skip CSI (ESC [ ... final)
|
package/dist/utils/ansi.d.ts
CHANGED
|
@@ -7,26 +7,40 @@ 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
|
-
*
|
|
11
|
-
* Returns 2 for wide chars, 1 for normal chars, 0 for combining chars.
|
|
10
|
+
* Width of a single Unicode code point in terminal columns.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
12
|
+
* For correct rendering of emoji clusters (ZWJ, flags, skin-tone, VS16)
|
|
13
|
+
* prefer `clusterWidth` or `visibleLen`, which segment graphemes first.
|
|
14
|
+
* This code-point-level primitive is kept for callers that iterate over
|
|
15
|
+
* chars for wrap-detection purposes (e.g. CJK line-break rules).
|
|
14
16
|
*/
|
|
15
17
|
export declare function charWidth(codePoint: number): number;
|
|
18
|
+
/**
|
|
19
|
+
* Width of one grapheme cluster in terminal columns. Handles ZWJ sequences,
|
|
20
|
+
* regional-indicator flags, skin-tone modifiers, and VS16 emoji presentation.
|
|
21
|
+
*/
|
|
22
|
+
export declare function clusterWidth(cluster: string): number;
|
|
16
23
|
/**
|
|
17
24
|
* Measure visible string length in terminal columns.
|
|
18
|
-
* Excludes SGR (color/style) sequences and
|
|
25
|
+
* Excludes SGR (color/style) sequences, and counts each grapheme cluster
|
|
26
|
+
* (emoji, CJK, combining marks) as one terminal-visible unit.
|
|
19
27
|
*/
|
|
20
28
|
export declare function visibleLen(str: string): number;
|
|
21
29
|
/**
|
|
22
30
|
* Truncate a string to fit within `maxWidth` visible columns.
|
|
23
|
-
*
|
|
31
|
+
* Iterates by grapheme cluster so emoji sequences (ZWJ, flags, VS16) are
|
|
32
|
+
* kept intact rather than split mid-cluster. Appends `…` if truncated.
|
|
24
33
|
*/
|
|
25
34
|
export declare function truncateToWidth(str: string, maxWidth: number): string;
|
|
35
|
+
/** Truncate to visible width while preserving SGR sequences — use when
|
|
36
|
+
* input carries color/bold codes. `truncateToWidth` strips them. */
|
|
37
|
+
export declare function truncateAnsiToWidth(str: string, maxWidth: number): string;
|
|
26
38
|
/**
|
|
27
39
|
* Pad a string with spaces to fill `targetWidth` visible columns.
|
|
28
|
-
* Accounts for CJK double-width characters.
|
|
29
40
|
*/
|
|
30
41
|
export declare function padEndToWidth(str: string, targetWidth: number): string;
|
|
31
|
-
/** Strip
|
|
42
|
+
/** Strip ANSI escape sequences and carriage returns.
|
|
43
|
+
* Delegates escape handling to the `strip-ansi` package (covers SGR, OSC,
|
|
44
|
+
* CSI, private-mode, 8-bit CSI, and newer variants). `\r` is not an escape
|
|
45
|
+
* but callers rely on it being stripped alongside. */
|
|
32
46
|
export declare function stripAnsi(str: string): string;
|
package/dist/utils/ansi.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
import stripAnsiPkg from "strip-ansi";
|
|
1
3
|
// ── ANSI escape code constants ────────────────────────────────
|
|
2
4
|
export const CYAN = "\x1b[36m";
|
|
3
5
|
export const DIM = "\x1b[2m";
|
|
@@ -8,162 +10,127 @@ export const GRAY = "\x1b[90m";
|
|
|
8
10
|
export const BOLD = "\x1b[1m";
|
|
9
11
|
export const RESET = "\x1b[0m";
|
|
10
12
|
// ── ANSI utility functions ───────────────────────────────────
|
|
13
|
+
// Reused across iterations. Segmenter construction is not free, and the API
|
|
14
|
+
// is pure (no per-call state) so a module-level instance is safe.
|
|
15
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
11
16
|
/**
|
|
12
|
-
*
|
|
13
|
-
* Returns 2 for wide chars, 1 for normal chars, 0 for combining chars.
|
|
17
|
+
* Width of a single Unicode code point in terminal columns.
|
|
14
18
|
*
|
|
15
|
-
*
|
|
19
|
+
* For correct rendering of emoji clusters (ZWJ, flags, skin-tone, VS16)
|
|
20
|
+
* prefer `clusterWidth` or `visibleLen`, which segment graphemes first.
|
|
21
|
+
* This code-point-level primitive is kept for callers that iterate over
|
|
22
|
+
* chars for wrap-detection purposes (e.g. CJK line-break rules).
|
|
16
23
|
*/
|
|
17
24
|
export function charWidth(codePoint) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return 0; // Variation Selectors
|
|
31
|
-
if (codePoint >= 0xe0100 && codePoint <= 0xe01ef)
|
|
32
|
-
return 0; // Variation Selectors Supplement
|
|
33
|
-
// Emoji and symbols that render as wide (2 columns)
|
|
34
|
-
// Emoji presentation sequences and keycap
|
|
35
|
-
if (codePoint === 0x20e3)
|
|
36
|
-
return 2; // Combining Enclosing Keycap
|
|
37
|
-
// Emoji blocks
|
|
38
|
-
if (codePoint >= 0x1f600 && codePoint <= 0x1f64f)
|
|
39
|
-
return 2; // Emoticons
|
|
40
|
-
if (codePoint >= 0x1f300 && codePoint <= 0x1f5ff)
|
|
41
|
-
return 2; // Misc Symbols and Pictographs
|
|
42
|
-
if (codePoint >= 0x1f680 && codePoint <= 0x1f6ff)
|
|
43
|
-
return 2; // Transport and Map
|
|
44
|
-
if (codePoint >= 0x1f700 && codePoint <= 0x1f77f)
|
|
45
|
-
return 2; // Alchemical Symbols
|
|
46
|
-
if (codePoint >= 0x1f780 && codePoint <= 0x1f7ff)
|
|
47
|
-
return 2; // Geometric Shapes Extended
|
|
48
|
-
if (codePoint >= 0x1f800 && codePoint <= 0x1f8ff)
|
|
49
|
-
return 2; // Supplemental Arrows-C
|
|
50
|
-
if (codePoint >= 0x1f900 && codePoint <= 0x1f9ff)
|
|
51
|
-
return 2; // Supplemental Symbols and Pictographs
|
|
52
|
-
if (codePoint >= 0x1fa00 && codePoint <= 0x1faff)
|
|
53
|
-
return 2; // Chess Symbols, Symbols and Pictographs Extended-A
|
|
54
|
-
// NOTE: 0x2300-0x23ff (Misc Technical), 0x2600-0x26ff (Misc Symbols),
|
|
55
|
-
// and 0x2700-0x27bf (Dingbats) are intentionally NOT width 2 — these ranges
|
|
56
|
-
// contain mostly "Ambiguous" width characters that render as 1 column in
|
|
57
|
-
// non-CJK terminal locales (e.g. ❯, ⌘, ★, ♦).
|
|
58
|
-
// Regional indicator symbols (flag emoji components)
|
|
59
|
-
if (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff)
|
|
60
|
-
return 2;
|
|
61
|
-
// CJK Unified Ideographs
|
|
62
|
-
if (codePoint >= 0x4e00 && codePoint <= 0x9fff)
|
|
63
|
-
return 2;
|
|
64
|
-
// CJK Unified Ideographs Extension A
|
|
65
|
-
if (codePoint >= 0x3400 && codePoint <= 0x4dbf)
|
|
66
|
-
return 2;
|
|
67
|
-
// Hangul Syllables
|
|
68
|
-
if (codePoint >= 0xac00 && codePoint <= 0xd7af)
|
|
69
|
-
return 2;
|
|
70
|
-
// CJK Unified Ideographs Extension B-F and other CJK blocks
|
|
71
|
-
if (codePoint >= 0x20000 && codePoint <= 0x2ebef)
|
|
72
|
-
return 2;
|
|
73
|
-
// Fullwidth ASCII variants
|
|
74
|
-
if (codePoint >= 0xff01 && codePoint <= 0xff5e)
|
|
75
|
-
return 2;
|
|
76
|
-
// Fullwidth bracket forms
|
|
77
|
-
if (codePoint >= 0xff5f && codePoint <= 0xff60)
|
|
78
|
-
return 2;
|
|
79
|
-
// Fullwidth symbol variants
|
|
80
|
-
if (codePoint >= 0xffe0 && codePoint <= 0xffe6)
|
|
81
|
-
return 2;
|
|
82
|
-
// Japanese hiragana and katakana
|
|
83
|
-
if (codePoint >= 0x3040 && codePoint <= 0x309f)
|
|
84
|
-
return 2;
|
|
85
|
-
if (codePoint >= 0x30a0 && codePoint <= 0x30ff)
|
|
86
|
-
return 2;
|
|
87
|
-
// CJK symbols and punctuation
|
|
88
|
-
if (codePoint >= 0x3000 && codePoint <= 0x303f)
|
|
89
|
-
return 2;
|
|
90
|
-
// Enclosed CJK letters and months
|
|
91
|
-
if (codePoint >= 0x3200 && codePoint <= 0x32ff)
|
|
92
|
-
return 2;
|
|
93
|
-
// CJK compatibility
|
|
94
|
-
if (codePoint >= 0x3300 && codePoint <= 0x33ff)
|
|
95
|
-
return 2;
|
|
96
|
-
// Hangul Jamo
|
|
97
|
-
if (codePoint >= 0x1100 && codePoint <= 0x11ff)
|
|
98
|
-
return 2;
|
|
99
|
-
// Hangul compatibility Jamo
|
|
100
|
-
if (codePoint >= 0x3130 && codePoint <= 0x318f)
|
|
101
|
-
return 2;
|
|
102
|
-
return 1;
|
|
25
|
+
return stringWidth(String.fromCodePoint(codePoint));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Width of one grapheme cluster in terminal columns. Handles ZWJ sequences,
|
|
29
|
+
* regional-indicator flags, skin-tone modifiers, and VS16 emoji presentation.
|
|
30
|
+
*/
|
|
31
|
+
export function clusterWidth(cluster) {
|
|
32
|
+
return stringWidth(cluster);
|
|
33
|
+
}
|
|
34
|
+
/** Strip SGR (color/style) sequences from a string. */
|
|
35
|
+
function stripSGR(str) {
|
|
36
|
+
return str.replace(/\x1b\[[^m]*m/g, "");
|
|
103
37
|
}
|
|
104
38
|
/**
|
|
105
39
|
* Measure visible string length in terminal columns.
|
|
106
|
-
* Excludes SGR (color/style) sequences and
|
|
40
|
+
* Excludes SGR (color/style) sequences, and counts each grapheme cluster
|
|
41
|
+
* (emoji, CJK, combining marks) as one terminal-visible unit.
|
|
107
42
|
*/
|
|
108
43
|
export function visibleLen(str) {
|
|
109
|
-
|
|
110
|
-
const cleanStr = str.replace(/\x1b\[[^m]*m/g, "");
|
|
111
|
-
let width = 0;
|
|
112
|
-
for (const char of cleanStr) {
|
|
113
|
-
width += charWidth(char.codePointAt(0) ?? 0);
|
|
114
|
-
}
|
|
115
|
-
return width;
|
|
44
|
+
return stringWidth(stripSGR(str));
|
|
116
45
|
}
|
|
117
46
|
/**
|
|
118
47
|
* Truncate a string to fit within `maxWidth` visible columns.
|
|
119
|
-
*
|
|
48
|
+
* Iterates by grapheme cluster so emoji sequences (ZWJ, flags, VS16) are
|
|
49
|
+
* kept intact rather than split mid-cluster. Appends `…` if truncated.
|
|
120
50
|
*/
|
|
121
51
|
export function truncateToWidth(str, maxWidth) {
|
|
122
|
-
const clean = str
|
|
52
|
+
const clean = stripSGR(str);
|
|
123
53
|
if (maxWidth <= 0)
|
|
124
54
|
return "";
|
|
125
|
-
|
|
126
|
-
let fullWidth = 0;
|
|
127
|
-
for (const char of clean) {
|
|
128
|
-
fullWidth += charWidth(char.codePointAt(0) ?? 0);
|
|
129
|
-
}
|
|
130
|
-
if (fullWidth <= maxWidth)
|
|
55
|
+
if (visibleLen(clean) <= maxWidth)
|
|
131
56
|
return clean;
|
|
132
|
-
// String doesn't fit — truncate with "…"
|
|
133
|
-
// At maxWidth=1 the ellipsis alone fills the budget.
|
|
134
57
|
if (maxWidth === 1)
|
|
135
58
|
return "…";
|
|
136
|
-
// Reserve 1 column for "…", so target content width is maxWidth - 1
|
|
137
59
|
const target = maxWidth - 1;
|
|
138
60
|
let width = 0;
|
|
139
|
-
let
|
|
140
|
-
for (const
|
|
141
|
-
const cw =
|
|
61
|
+
let out = "";
|
|
62
|
+
for (const { segment } of GRAPHEME_SEGMENTER.segment(clean)) {
|
|
63
|
+
const cw = clusterWidth(segment);
|
|
142
64
|
if (width + cw > target)
|
|
143
65
|
break;
|
|
144
66
|
width += cw;
|
|
145
|
-
|
|
67
|
+
out += segment;
|
|
146
68
|
}
|
|
147
|
-
|
|
148
|
-
// rather than emit a character that would overflow the budget.
|
|
149
|
-
if (i === 0)
|
|
69
|
+
if (out === "")
|
|
150
70
|
return "…";
|
|
151
|
-
return
|
|
71
|
+
return out + "…";
|
|
72
|
+
}
|
|
73
|
+
/** Truncate to visible width while preserving SGR sequences — use when
|
|
74
|
+
* input carries color/bold codes. `truncateToWidth` strips them. */
|
|
75
|
+
export function truncateAnsiToWidth(str, maxWidth) {
|
|
76
|
+
if (maxWidth <= 0)
|
|
77
|
+
return "";
|
|
78
|
+
if (visibleLen(str) <= maxWidth)
|
|
79
|
+
return str;
|
|
80
|
+
if (maxWidth === 1)
|
|
81
|
+
return "…";
|
|
82
|
+
const target = maxWidth - 1;
|
|
83
|
+
// Walk the string preserving SGR escapes in-place; buffer text between
|
|
84
|
+
// escapes and segment it into graphemes to count width correctly.
|
|
85
|
+
let width = 0;
|
|
86
|
+
let out = "";
|
|
87
|
+
let buf = "";
|
|
88
|
+
let i = 0;
|
|
89
|
+
const flushBuf = () => {
|
|
90
|
+
if (!buf)
|
|
91
|
+
return false;
|
|
92
|
+
for (const { segment } of GRAPHEME_SEGMENTER.segment(buf)) {
|
|
93
|
+
const cw = clusterWidth(segment);
|
|
94
|
+
if (width + cw > target) {
|
|
95
|
+
buf = "";
|
|
96
|
+
return true; // budget exhausted
|
|
97
|
+
}
|
|
98
|
+
width += cw;
|
|
99
|
+
out += segment;
|
|
100
|
+
}
|
|
101
|
+
buf = "";
|
|
102
|
+
return false;
|
|
103
|
+
};
|
|
104
|
+
while (i < str.length) {
|
|
105
|
+
if (str[i] === "\x1b" && str[i + 1] === "[") {
|
|
106
|
+
const end = str.indexOf("m", i);
|
|
107
|
+
if (end !== -1) {
|
|
108
|
+
if (flushBuf())
|
|
109
|
+
break;
|
|
110
|
+
out += str.slice(i, end + 1);
|
|
111
|
+
i = end + 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const cp = str.codePointAt(i) ?? 0;
|
|
116
|
+
const chLen = cp > 0xffff ? 2 : 1;
|
|
117
|
+
buf += str.slice(i, i + chLen);
|
|
118
|
+
i += chLen;
|
|
119
|
+
}
|
|
120
|
+
flushBuf();
|
|
121
|
+
return out + "\x1b[0m…";
|
|
152
122
|
}
|
|
153
123
|
/**
|
|
154
124
|
* Pad a string with spaces to fill `targetWidth` visible columns.
|
|
155
|
-
* Accounts for CJK double-width characters.
|
|
156
125
|
*/
|
|
157
126
|
export function padEndToWidth(str, targetWidth) {
|
|
158
127
|
const gap = targetWidth - visibleLen(str);
|
|
159
128
|
return gap > 0 ? str + " ".repeat(gap) : str;
|
|
160
129
|
}
|
|
161
|
-
/** Strip
|
|
130
|
+
/** Strip ANSI escape sequences and carriage returns.
|
|
131
|
+
* Delegates escape handling to the `strip-ansi` package (covers SGR, OSC,
|
|
132
|
+
* CSI, private-mode, 8-bit CSI, and newer variants). `\r` is not an escape
|
|
133
|
+
* but callers rely on it being stripped alongside. */
|
|
162
134
|
export function stripAnsi(str) {
|
|
163
|
-
return str
|
|
164
|
-
.replace(/\x1b\][^\x07]*\x07/g, "") // OSC sequences
|
|
165
|
-
.replace(/\x1b\[[^m]*m/g, "") // SGR (color) sequences
|
|
166
|
-
.replace(/\x1b\[\?[^a-zA-Z]*[a-zA-Z]/g, "") // private mode sequences
|
|
167
|
-
.replace(/\x1b\[[^a-zA-Z]*[a-zA-Z]/g, "") // CSI sequences
|
|
168
|
-
.replace(/\r/g, ""); // carriage returns
|
|
135
|
+
return stripAnsiPkg(str).replace(/\r/g, "");
|
|
169
136
|
}
|
|
@@ -12,6 +12,10 @@ export interface LlmClientConfig {
|
|
|
12
12
|
apiKey: string;
|
|
13
13
|
baseURL?: string;
|
|
14
14
|
model: string;
|
|
15
|
+
/** Sent as OpenRouter X-Title; ignored by other providers. */
|
|
16
|
+
appName?: string;
|
|
17
|
+
/** Sent as OpenRouter HTTP-Referer; ignored by other providers. */
|
|
18
|
+
appUrl?: string;
|
|
15
19
|
}
|
|
16
20
|
export declare class LlmClient {
|
|
17
21
|
private config;
|
package/dist/utils/llm-client.js
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* (command suggestions, completions).
|
|
7
7
|
*/
|
|
8
8
|
import OpenAI from "openai";
|
|
9
|
+
function attributionHeaders(config) {
|
|
10
|
+
return {
|
|
11
|
+
"HTTP-Referer": config.appUrl ?? "https://agent-sh.dev",
|
|
12
|
+
"X-Title": config.appName ?? "agent-sh",
|
|
13
|
+
};
|
|
14
|
+
}
|
|
9
15
|
export class LlmClient {
|
|
10
16
|
config;
|
|
11
17
|
client;
|
|
@@ -15,6 +21,7 @@ export class LlmClient {
|
|
|
15
21
|
this.client = new OpenAI({
|
|
16
22
|
apiKey: config.apiKey,
|
|
17
23
|
baseURL: config.baseURL,
|
|
24
|
+
defaultHeaders: attributionHeaders(config),
|
|
18
25
|
});
|
|
19
26
|
this.model = config.model;
|
|
20
27
|
}
|
|
@@ -24,6 +31,7 @@ export class LlmClient {
|
|
|
24
31
|
this.client = new OpenAI({
|
|
25
32
|
apiKey: newConfig.apiKey,
|
|
26
33
|
baseURL: newConfig.baseURL,
|
|
34
|
+
defaultHeaders: attributionHeaders(newConfig),
|
|
27
35
|
});
|
|
28
36
|
this.model = newConfig.model;
|
|
29
37
|
}
|
package/dist/utils/markdown.d.ts
CHANGED
|
@@ -2,6 +2,10 @@ export declare const MAX_CONTENT_WIDTH = 90;
|
|
|
2
2
|
/**
|
|
3
3
|
* Word-wrap a string (which may contain ANSI codes) to a maximum visible width.
|
|
4
4
|
* Returns an array of lines, each fitting within `maxWidth` visible characters.
|
|
5
|
+
*
|
|
6
|
+
* Handles CJK text by breaking between wide characters and applying basic
|
|
7
|
+
* CJK rules (closing punctuation sticks to the previous line; opening
|
|
8
|
+
* punctuation sticks to the next).
|
|
5
9
|
*/
|
|
6
10
|
export declare function wrapLine(text: string, maxWidth: number): string[];
|
|
7
11
|
/**
|
package/dist/utils/markdown.js
CHANGED
|
@@ -1,9 +1,65 @@
|
|
|
1
|
-
import { visibleLen,
|
|
1
|
+
import { visibleLen, truncateAnsiToWidth, padEndToWidth, charWidth } from "./ansi.js";
|
|
2
2
|
import { palette as p } from "./palette.js";
|
|
3
3
|
export const MAX_CONTENT_WIDTH = 90;
|
|
4
|
+
// CJK line-breaking rules: closing punctuation must not start a line,
|
|
5
|
+
// opening punctuation must not end a line. Both CJK fullwidth and ASCII
|
|
6
|
+
// equivalents are included so mixed text wraps correctly.
|
|
7
|
+
const CJK_NO_LINE_START = new Set([
|
|
8
|
+
"。", ",", "、", ".", ";", ":", "!", "?",
|
|
9
|
+
")", "」", "』", "】", "》", "〉", "〕", "]", "}",
|
|
10
|
+
"・", "々", "〜", "~", "ー",
|
|
11
|
+
".", ",", ";", ":", "!", "?", ")", "]", "}",
|
|
12
|
+
]);
|
|
13
|
+
const CJK_NO_LINE_END = new Set([
|
|
14
|
+
"(", "「", "『", "【", "《", "〈", "〔", "[", "{",
|
|
15
|
+
"(", "[", "{",
|
|
16
|
+
]);
|
|
17
|
+
/**
|
|
18
|
+
* Tokenize a visible-text run into units suitable for wrapping.
|
|
19
|
+
* Each width-2 character (CJK, fullwidth, emoji) becomes its own token so the
|
|
20
|
+
* wrapper can break between them; ASCII runs stay together as word tokens.
|
|
21
|
+
*/
|
|
22
|
+
function tokenizeVisible(text) {
|
|
23
|
+
const tokens = [];
|
|
24
|
+
let ascii = "";
|
|
25
|
+
const flush = () => { if (ascii) {
|
|
26
|
+
tokens.push(ascii);
|
|
27
|
+
ascii = "";
|
|
28
|
+
} };
|
|
29
|
+
let i = 0;
|
|
30
|
+
while (i < text.length) {
|
|
31
|
+
const cp = text.codePointAt(i) ?? 0;
|
|
32
|
+
const chLen = cp > 0xffff ? 2 : 1;
|
|
33
|
+
const ch = text.slice(i, i + chLen);
|
|
34
|
+
if (ch === " ") {
|
|
35
|
+
flush();
|
|
36
|
+
let spaces = "";
|
|
37
|
+
while (i < text.length && text[i] === " ") {
|
|
38
|
+
spaces += " ";
|
|
39
|
+
i += 1;
|
|
40
|
+
}
|
|
41
|
+
tokens.push(spaces);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (charWidth(cp) === 2) {
|
|
45
|
+
flush();
|
|
46
|
+
tokens.push(ch);
|
|
47
|
+
i += chLen;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
ascii += ch;
|
|
51
|
+
i += chLen;
|
|
52
|
+
}
|
|
53
|
+
flush();
|
|
54
|
+
return tokens;
|
|
55
|
+
}
|
|
4
56
|
/**
|
|
5
57
|
* Word-wrap a string (which may contain ANSI codes) to a maximum visible width.
|
|
6
58
|
* Returns an array of lines, each fitting within `maxWidth` visible characters.
|
|
59
|
+
*
|
|
60
|
+
* Handles CJK text by breaking between wide characters and applying basic
|
|
61
|
+
* CJK rules (closing punctuation sticks to the previous line; opening
|
|
62
|
+
* punctuation sticks to the next).
|
|
7
63
|
*/
|
|
8
64
|
export function wrapLine(text, maxWidth) {
|
|
9
65
|
if (!(maxWidth > 0))
|
|
@@ -11,40 +67,44 @@ export function wrapLine(text, maxWidth) {
|
|
|
11
67
|
if (visibleLen(text) <= maxWidth)
|
|
12
68
|
return [text];
|
|
13
69
|
const result = [];
|
|
14
|
-
// Split into segments: ANSI codes and visible text
|
|
15
70
|
const segments = text.match(/(\x1b\[[^m]*m|[^\x1b]+)/g) || [text];
|
|
16
|
-
let
|
|
17
|
-
let
|
|
18
|
-
let activeStyles = "";
|
|
71
|
+
let lineTokens = [];
|
|
72
|
+
let lineWidth = 0;
|
|
73
|
+
let activeStyles = "";
|
|
74
|
+
let lastVisibleIdx = -1;
|
|
75
|
+
const commit = () => {
|
|
76
|
+
result.push(lineTokens.join("") + p.reset);
|
|
77
|
+
lineTokens = activeStyles ? [activeStyles] : [];
|
|
78
|
+
lineWidth = 0;
|
|
79
|
+
lastVisibleIdx = -1;
|
|
80
|
+
};
|
|
19
81
|
for (const seg of segments) {
|
|
20
82
|
if (seg.startsWith("\x1b[")) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (seg === p.reset) {
|
|
83
|
+
lineTokens.push(seg);
|
|
84
|
+
if (seg === p.reset)
|
|
24
85
|
activeStyles = "";
|
|
25
|
-
|
|
26
|
-
else {
|
|
86
|
+
else
|
|
27
87
|
activeStyles += seg;
|
|
28
|
-
}
|
|
29
88
|
continue;
|
|
30
89
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
90
|
+
for (const token of tokenizeVisible(seg)) {
|
|
91
|
+
const tokenWidth = visibleLen(token);
|
|
92
|
+
const isSpace = token[0] === " ";
|
|
93
|
+
if (lineWidth + tokenWidth <= maxWidth) {
|
|
94
|
+
lineTokens.push(token);
|
|
95
|
+
lineWidth += tokenWidth;
|
|
96
|
+
if (!isSpace)
|
|
97
|
+
lastVisibleIdx = lineTokens.length - 1;
|
|
35
98
|
continue;
|
|
36
|
-
const wordWidth = visibleLen(word);
|
|
37
|
-
if (currentWidth + wordWidth <= maxWidth) {
|
|
38
|
-
currentLine += word;
|
|
39
|
-
currentWidth += wordWidth;
|
|
40
99
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
100
|
+
// Token doesn't fit on the current line.
|
|
101
|
+
if (isSpace)
|
|
102
|
+
continue; // spaces at wrap points are dropped
|
|
103
|
+
if (lineWidth === 0) {
|
|
104
|
+
// Token longer than the entire line — hard-break by char width.
|
|
105
|
+
let remaining = token;
|
|
44
106
|
while (remaining.length > 0) {
|
|
45
|
-
|
|
46
|
-
let fitLen = 0;
|
|
47
|
-
let fitWidth = 0;
|
|
107
|
+
let fitLen = 0, fitWidth = 0;
|
|
48
108
|
for (const ch of remaining) {
|
|
49
109
|
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
50
110
|
if (fitWidth + cw > maxWidth)
|
|
@@ -52,37 +112,47 @@ export function wrapLine(text, maxWidth) {
|
|
|
52
112
|
fitWidth += cw;
|
|
53
113
|
fitLen += ch.length;
|
|
54
114
|
}
|
|
55
|
-
if (fitLen === 0)
|
|
56
|
-
// Even one char doesn't fit — force take one char to avoid infinite loop
|
|
115
|
+
if (fitLen === 0)
|
|
57
116
|
fitLen = remaining[0]?.length ?? 1;
|
|
58
|
-
}
|
|
59
117
|
const chunk = remaining.slice(0, fitLen);
|
|
60
118
|
remaining = remaining.slice(fitLen);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
else {
|
|
68
|
-
currentWidth += fitWidth;
|
|
69
|
-
}
|
|
119
|
+
lineTokens.push(chunk);
|
|
120
|
+
lineWidth += visibleLen(chunk);
|
|
121
|
+
lastVisibleIdx = lineTokens.length - 1;
|
|
122
|
+
if (remaining.length > 0)
|
|
123
|
+
commit();
|
|
70
124
|
}
|
|
125
|
+
continue;
|
|
71
126
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
127
|
+
// Rule (a): closing punctuation must not start a line. Allow up to 2
|
|
128
|
+
// columns of overflow so the punctuation stays with its phrase.
|
|
129
|
+
if (CJK_NO_LINE_START.has(token)) {
|
|
130
|
+
lineTokens.push(token);
|
|
131
|
+
lineWidth += tokenWidth;
|
|
132
|
+
commit();
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// Rule (b): opening punctuation must not end a line. Pull the trailing
|
|
136
|
+
// opener down to the next line with us.
|
|
137
|
+
let carried = [];
|
|
138
|
+
if (lastVisibleIdx >= 0 && CJK_NO_LINE_END.has(lineTokens[lastVisibleIdx])) {
|
|
139
|
+
carried = lineTokens.splice(lastVisibleIdx);
|
|
140
|
+
while (lineTokens.length > 0 && /^ +$/.test(lineTokens[lineTokens.length - 1])) {
|
|
141
|
+
lineTokens.pop();
|
|
142
|
+
}
|
|
81
143
|
}
|
|
144
|
+
commit();
|
|
145
|
+
for (const t of carried) {
|
|
146
|
+
lineTokens.push(t);
|
|
147
|
+
lineWidth += visibleLen(t);
|
|
148
|
+
}
|
|
149
|
+
lineTokens.push(token);
|
|
150
|
+
lineWidth += tokenWidth;
|
|
151
|
+
lastVisibleIdx = lineTokens.length - 1;
|
|
82
152
|
}
|
|
83
153
|
}
|
|
84
|
-
if (
|
|
85
|
-
result.push(
|
|
154
|
+
if (lineWidth > 0) {
|
|
155
|
+
result.push(lineTokens.join(""));
|
|
86
156
|
}
|
|
87
157
|
return result;
|
|
88
158
|
}
|
|
@@ -188,23 +258,31 @@ export class MarkdownRenderer {
|
|
|
188
258
|
while (row.length < numCols)
|
|
189
259
|
row.push("");
|
|
190
260
|
}
|
|
191
|
-
//
|
|
261
|
+
// Width from rendered cell — raw `**bold**` over-counts by 4 per pair.
|
|
192
262
|
const colWidths = new Array(numCols).fill(0);
|
|
193
263
|
for (const row of dataRows) {
|
|
194
264
|
for (let c = 0; c < numCols; c++) {
|
|
195
|
-
colWidths[c] = Math.max(colWidths[c], visibleLen(row[c]));
|
|
265
|
+
colWidths[c] = Math.max(colWidths[c], visibleLen(this.renderInline(row[c])));
|
|
196
266
|
}
|
|
197
267
|
}
|
|
198
|
-
//
|
|
199
|
-
// Account for separators: " │ " between cols (3 chars each) + 2 outer padding
|
|
268
|
+
// Tables bypass the prose width cap — borders guide the eye, so wider is fine.
|
|
200
269
|
const separatorWidth = (numCols - 1) * 3;
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
270
|
+
const tableWidth = Math.max(10, this.width - 2);
|
|
271
|
+
const availableWidth = tableWidth - separatorWidth;
|
|
272
|
+
// Shrink the widest column one step at a time until the table fits.
|
|
273
|
+
// Preserves natural width on narrow columns — proportional scaling
|
|
274
|
+
// over-truncates when only one column is oversized.
|
|
275
|
+
let total = colWidths.reduce((a, b) => a + b, 0);
|
|
276
|
+
while (total > availableWidth && availableWidth > numCols) {
|
|
277
|
+
let maxIdx = 0;
|
|
278
|
+
for (let c = 1; c < numCols; c++) {
|
|
279
|
+
if (colWidths[c] > colWidths[maxIdx])
|
|
280
|
+
maxIdx = c;
|
|
207
281
|
}
|
|
282
|
+
if (colWidths[maxIdx] <= 1)
|
|
283
|
+
break;
|
|
284
|
+
colWidths[maxIdx]--;
|
|
285
|
+
total--;
|
|
208
286
|
}
|
|
209
287
|
// Render rows
|
|
210
288
|
const hasHeader = sepIdx.includes(1) && dataRows.length > 1;
|
|
@@ -216,7 +294,14 @@ export class MarkdownRenderer {
|
|
|
216
294
|
const isHeader = hasHeader && i === 0;
|
|
217
295
|
const cells = row.map((cell, c) => {
|
|
218
296
|
const w = colWidths[c];
|
|
219
|
-
const
|
|
297
|
+
const rendered = this.renderInline(cell);
|
|
298
|
+
// Truncation can yield width < w when a CJK double-width char
|
|
299
|
+
// won't fit the remaining budget — always re-pad to keep cells
|
|
300
|
+
// aligned with the border grid.
|
|
301
|
+
const clipped = visibleLen(rendered) > w
|
|
302
|
+
? truncateAnsiToWidth(rendered, w)
|
|
303
|
+
: rendered;
|
|
304
|
+
const text = padEndToWidth(clipped, w);
|
|
220
305
|
return isHeader ? `${p.bold}${text}${p.reset}` : text;
|
|
221
306
|
});
|
|
222
307
|
this.writeLine(`${p.dim}│${p.reset} ${cells.join(` ${p.dim}│${p.reset} `)} ${p.dim}│${p.reset}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sh",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"description": "A shell-first terminal where AI is one keystroke away",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/core.js",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"types": "./dist/extensions/index.d.ts",
|
|
35
35
|
"default": "./dist/extensions/index.js"
|
|
36
36
|
},
|
|
37
|
+
"./shell": {
|
|
38
|
+
"types": "./dist/shell/shell.d.ts",
|
|
39
|
+
"default": "./dist/shell/shell.js"
|
|
40
|
+
},
|
|
37
41
|
"./utils/stream-transform": {
|
|
38
42
|
"types": "./dist/utils/stream-transform.d.ts",
|
|
39
43
|
"default": "./dist/utils/stream-transform.js"
|
|
@@ -70,6 +74,14 @@
|
|
|
70
74
|
"types": "./dist/agent/token-budget.d.ts",
|
|
71
75
|
"default": "./dist/agent/token-budget.js"
|
|
72
76
|
},
|
|
77
|
+
"./agent/history-file": {
|
|
78
|
+
"types": "./dist/agent/history-file.d.ts",
|
|
79
|
+
"default": "./dist/agent/history-file.js"
|
|
80
|
+
},
|
|
81
|
+
"./agent/nuclear-form": {
|
|
82
|
+
"types": "./dist/agent/nuclear-form.d.ts",
|
|
83
|
+
"default": "./dist/agent/nuclear-form.js"
|
|
84
|
+
},
|
|
73
85
|
"./executor": {
|
|
74
86
|
"types": "./dist/executor.d.ts",
|
|
75
87
|
"default": "./dist/executor.js"
|
|
@@ -114,6 +126,8 @@
|
|
|
114
126
|
"marked": "^17.0.6",
|
|
115
127
|
"node-pty": "^1.2.0-beta.12",
|
|
116
128
|
"openai": "^6.34.0",
|
|
129
|
+
"string-width": "^8.2.0",
|
|
130
|
+
"strip-ansi": "^7.2.0",
|
|
117
131
|
"tsx": "^4.19.0"
|
|
118
132
|
},
|
|
119
133
|
"devDependencies": {
|