agent-sh 0.15.5 → 0.15.6

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.
@@ -49,7 +49,10 @@ export type ToolResultBody = {
49
49
  maxLines?: number;
50
50
  };
51
51
  export interface ToolDisplayInfo {
52
- kind: "read" | "write" | "execute" | "search";
52
+ /** Verb shown next to the detail (e.g. "execute foo.py"). Omit when a custom
53
+ * `icon` already makes the action self-evident — the renderer then shows
54
+ * icon + detail with no verb. */
55
+ kind?: "read" | "write" | "execute" | "search";
53
56
  locations?: {
54
57
  path: string;
55
58
  line?: number | null;
@@ -644,6 +644,7 @@ export default function activate(ctx) {
644
644
  filePath,
645
645
  maxLines,
646
646
  trueColor: true,
647
+ gutterLine: false,
647
648
  });
648
649
  const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
649
650
  return renderBoxFrame(body, {
@@ -8,6 +8,7 @@
8
8
  import { highlight } from "cli-highlight";
9
9
  import { visibleLen, charWidth } from "./ansi.js";
10
10
  import { palette as p } from "./palette.js";
11
+ import { wrapLine } from "./markdown.js";
11
12
  // ── Constants ────────────────────────────────────────────────────
12
13
  const SPLIT_MIN_WIDTH = 120;
13
14
  const UNIFIED_MIN_WIDTH = 40;
@@ -102,6 +103,42 @@ function tokenLcs(a, b) {
102
103
  }
103
104
  return { oldMatch, newMatch };
104
105
  }
106
+ /**
107
+ * Anchors the shared prefix/suffix as unchanged and runs the O(m·n) LCS only on
108
+ * the differing middle. Null when that middle exceeds maxProduct.
109
+ */
110
+ function inlineMatches(a, b, maxProduct) {
111
+ const m = a.length;
112
+ const n = b.length;
113
+ let pre = 0;
114
+ while (pre < m && pre < n && a[pre].text === b[pre].text)
115
+ pre++;
116
+ let suf = 0;
117
+ while (suf < m - pre && suf < n - pre && a[m - 1 - suf].text === b[n - 1 - suf].text)
118
+ suf++;
119
+ const midM = m - pre - suf;
120
+ const midN = n - pre - suf;
121
+ if (midM * midN > maxProduct)
122
+ return null;
123
+ const oldMatch = new Array(m).fill(false);
124
+ const newMatch = new Array(n).fill(false);
125
+ for (let i = 0; i < pre; i++) {
126
+ oldMatch[i] = true;
127
+ newMatch[i] = true;
128
+ }
129
+ for (let i = 0; i < suf; i++) {
130
+ oldMatch[m - 1 - i] = true;
131
+ newMatch[n - 1 - i] = true;
132
+ }
133
+ if (midM > 0 && midN > 0) {
134
+ const mid = tokenLcs(a.slice(pre, m - suf), b.slice(pre, n - suf));
135
+ for (let i = 0; i < midM; i++)
136
+ oldMatch[pre + i] = mid.oldMatch[i];
137
+ for (let j = 0; j < midN; j++)
138
+ newMatch[pre + j] = mid.newMatch[j];
139
+ }
140
+ return { oldMatch, newMatch };
141
+ }
105
142
  /**
106
143
  * Rewrite full ANSI resets (\x1b[0m) to foreground-only resets,
107
144
  * preserving the given background color across the line.
@@ -139,14 +176,14 @@ function highlightInlineChanges(oldLine, newLine, oldPalette, newPalette, useTru
139
176
  new: language ? highlightLine(newLine, language) : newLine,
140
177
  };
141
178
  }
142
- // Safety guard: skip if LCS matrix would be too large
143
- if (oldTokens.length * newTokens.length > 50000) {
179
+ const matches = inlineMatches(oldTokens, newTokens, 1_000_000);
180
+ if (!matches) {
144
181
  return {
145
182
  old: language ? highlightLine(oldLine, language) : oldLine,
146
183
  new: language ? highlightLine(newLine, language) : newLine,
147
184
  };
148
185
  }
149
- const { oldMatch, newMatch } = tokenLcs(oldTokens, newTokens);
186
+ const { oldMatch, newMatch } = matches;
150
187
  const buildHighlighted = (tokens, matched, palette) => {
151
188
  let result = "";
152
189
  for (let i = 0; i < tokens.length; i++) {
@@ -248,13 +285,19 @@ function renderUnifiedHunk(hunk, layout) {
248
285
  const pairs = findChangePairs(hunk);
249
286
  const bgWidth = Math.max(1, textWidth - noW - 3);
250
287
  const gutter = (n) => `${p.dim}${n} │${p.reset} `;
288
+ const continuationNo = " ".repeat(noW);
289
+ // Wrapped rows after the first blank the line number and sigil.
251
290
  const change = (no, sigil, bg, fg, text) => {
252
- if (!gutterLine) {
253
- return `${bg}${padToWidth(`${fg}${no} ${sigil}${p.diffText} ${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
254
- }
255
- if (useTrueColor)
256
- return gutter(no) + padToWidth(`${bg}${fg}${sigil}${p.diffText} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
257
- return `${gutter(no)}${fg}${sigil} ${text}${p.reset}`;
291
+ return wrapLine(text, lineTextW).map((seg, r) => {
292
+ const n = r === 0 ? no : continuationNo;
293
+ const sg = r === 0 ? sigil : " ";
294
+ if (!gutterLine) {
295
+ return `${bg}${padToWidth(`${fg}${n} ${sg}${p.diffText} ${preserveBg(seg, bg)}`, textWidth)}${p.reset}`;
296
+ }
297
+ if (useTrueColor)
298
+ return gutter(n) + padToWidth(`${bg}${fg}${sg}${p.diffText} ${preserveBg(seg, bg)}`, bgWidth) + p.reset;
299
+ return `${gutter(n)}${fg}${sg} ${seg}${p.reset}`;
300
+ });
258
301
  };
259
302
  const hlCache = new Map();
260
303
  const highlightedPair = (pair) => {
@@ -269,33 +312,25 @@ function renderUnifiedHunk(hunk, layout) {
269
312
  const line = hunk.lines[i];
270
313
  const no = String(line.type === "removed" ? (line.oldNo ?? "") : (line.newNo ?? line.oldNo ?? "")).padStart(noW);
271
314
  if (line.type === "context") {
272
- const raw = truncateText(line.text, lineTextW);
273
- const text = lang ? highlightLine(raw, lang) : raw;
274
- out.push(!gutterLine ? `${p.dim}${no}${p.reset} ${text}` : `${gutter(no)} ${p.dim}${text}${p.reset}`);
315
+ const text = lang ? highlightLine(line.text, lang) : line.text;
316
+ wrapLine(text, lineTextW).forEach((seg, r) => {
317
+ const n = r === 0 ? no : continuationNo;
318
+ out.push(!gutterLine ? `${p.dim}${n}${p.reset} ${seg}` : `${gutter(n)} ${p.dim}${seg}${p.reset}`);
319
+ });
275
320
  continue;
276
321
  }
277
322
  const pair = pairs.get(i);
278
323
  if (line.type === "removed") {
279
- let removedText;
280
- if (pair) {
281
- removedText = truncateText(highlightedPair(pair).old, lineTextW);
282
- }
283
- else {
284
- const raw = truncateText(line.text, lineTextW);
285
- removedText = lang ? highlightLine(raw, lang) : raw;
286
- }
287
- out.push(change(no, "-", p.errorBg, p.error, removedText));
324
+ const removedText = pair
325
+ ? highlightedPair(pair).old
326
+ : (lang ? highlightLine(line.text, lang) : line.text);
327
+ out.push(...change(no, "-", p.errorBg, p.error, removedText));
288
328
  }
289
329
  else {
290
- let addedText;
291
- if (pair) {
292
- addedText = truncateText(highlightedPair(pair).new, lineTextW);
293
- }
294
- else {
295
- const raw = truncateText(line.text, lineTextW);
296
- addedText = lang ? highlightLine(raw, lang) : raw;
297
- }
298
- out.push(change(no, "+", p.successBg, p.success, addedText));
330
+ const addedText = pair
331
+ ? highlightedPair(pair).new
332
+ : (lang ? highlightLine(line.text, lang) : line.text);
333
+ out.push(...change(no, "+", p.successBg, p.success, addedText));
299
334
  }
300
335
  }
301
336
  return out;
@@ -78,6 +78,34 @@ export function wrapLine(text, maxWidth) {
78
78
  lineWidth = 0;
79
79
  lastVisibleIdx = -1;
80
80
  };
81
+ const hardBreak = (token) => {
82
+ let remaining = token;
83
+ while (remaining.length > 0) {
84
+ let fitLen = 0, fitWidth = 0;
85
+ for (const ch of remaining) {
86
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
87
+ if (fitWidth + cw > maxWidth - lineWidth)
88
+ break;
89
+ fitWidth += cw;
90
+ fitLen += ch.length;
91
+ }
92
+ if (fitLen === 0) {
93
+ // Force one char on an empty line so an over-wide char can't loop forever.
94
+ if (lineWidth > 0) {
95
+ commit();
96
+ continue;
97
+ }
98
+ fitLen = remaining[0]?.length ?? 1;
99
+ }
100
+ const chunk = remaining.slice(0, fitLen);
101
+ remaining = remaining.slice(fitLen);
102
+ lineTokens.push(chunk);
103
+ lineWidth += visibleLen(chunk);
104
+ lastVisibleIdx = lineTokens.length - 1;
105
+ if (remaining.length > 0)
106
+ commit();
107
+ }
108
+ };
81
109
  for (const seg of segments) {
82
110
  if (seg.startsWith("\x1b[")) {
83
111
  lineTokens.push(seg);
@@ -102,26 +130,7 @@ export function wrapLine(text, maxWidth) {
102
130
  continue; // spaces at wrap points are dropped
103
131
  if (lineWidth === 0) {
104
132
  // Token longer than the entire line — hard-break by char width.
105
- let remaining = token;
106
- while (remaining.length > 0) {
107
- let fitLen = 0, fitWidth = 0;
108
- for (const ch of remaining) {
109
- const cw = charWidth(ch.codePointAt(0) ?? 0);
110
- if (fitWidth + cw > maxWidth)
111
- break;
112
- fitWidth += cw;
113
- fitLen += ch.length;
114
- }
115
- if (fitLen === 0)
116
- fitLen = remaining[0]?.length ?? 1;
117
- const chunk = remaining.slice(0, fitLen);
118
- remaining = remaining.slice(fitLen);
119
- lineTokens.push(chunk);
120
- lineWidth += visibleLen(chunk);
121
- lastVisibleIdx = lineTokens.length - 1;
122
- if (remaining.length > 0)
123
- commit();
124
- }
133
+ hardBreak(token);
125
134
  continue;
126
135
  }
127
136
  // Rule (a): closing punctuation must not start a line. Allow up to 2
@@ -146,9 +155,14 @@ export function wrapLine(text, maxWidth) {
146
155
  lineTokens.push(t);
147
156
  lineWidth += visibleLen(t);
148
157
  }
149
- lineTokens.push(token);
150
- lineWidth += tokenWidth;
151
- lastVisibleIdx = lineTokens.length - 1;
158
+ if (lineWidth + tokenWidth <= maxWidth) {
159
+ lineTokens.push(token);
160
+ lineWidth += tokenWidth;
161
+ lastVisibleIdx = lineTokens.length - 1;
162
+ }
163
+ else {
164
+ hardBreak(token);
165
+ }
152
166
  }
153
167
  }
154
168
  if (lineWidth > 0) {
@@ -0,0 +1,86 @@
1
+ /**
2
+ * command-suggest extension
3
+ *
4
+ * Registers the suggest_command tool. When the agent calls it, the response
5
+ * finishes and the user drops to the shell prompt with the command pre-typed
6
+ * — no copy-paste, no mode toggle, just review and press Enter.
7
+ *
8
+ * Usage:
9
+ * agent-sh -e ./examples/extensions/command-suggest.ts
10
+ *
11
+ * # Or install permanently:
12
+ * cp examples/extensions/command-suggest.ts ~/.agent-sh/extensions/
13
+ */
14
+ import type { ExtensionContext } from "agent-sh/types";
15
+
16
+ export default function activate(ctx: ExtensionContext): void {
17
+ const { bus } = ctx;
18
+ let pendingCommand: string | null = null;
19
+
20
+ // ── Tool ────────────────────────────────────────────────────────
21
+
22
+ ctx.agent?.registerTool({
23
+ name: "suggest_command",
24
+ description:
25
+ "Stage a shell command at the user's prompt. After this response " +
26
+ "completes, the command appears in their shell prompt (not inside " +
27
+ "agent-input mode), ready to edit or run with Enter. " +
28
+ "Only call this when the user is asking for a command to run, or otherwise " +
29
+ "signals they want one staged — e.g. \"give me the command to …\", " +
30
+ "\"what do I run to …\". Do NOT call it unprompted after a general question, " +
31
+ "an explanation, or any turn where no command was requested. " +
32
+ "Prefer it over telling the user to copy-paste a command. " +
33
+ "Only the most recent call matters. Call with an empty string to clear.",
34
+ input_schema: {
35
+ type: "object",
36
+ properties: {
37
+ command: {
38
+ type: "string",
39
+ description:
40
+ "The shell command to place in the user's prompt. " +
41
+ "Multi-line commands are collapsed to a single line.",
42
+ },
43
+ },
44
+ required: ["command"],
45
+ },
46
+ showOutput: true,
47
+
48
+ getDisplayInfo: () => ({ icon: "⏎" }),
49
+
50
+ formatCall: (args) => {
51
+ const cmd = (args.command as string).trim();
52
+ if (!cmd) return "(clear suggestion)";
53
+ return cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
54
+ },
55
+
56
+ async execute(args) {
57
+ const cmd = (args.command as string).trim();
58
+ if (!cmd) {
59
+ pendingCommand = null;
60
+ return { content: "Cleared pending command suggestion.", exitCode: 0, isError: false };
61
+ }
62
+ // Collapse newlines to spaces so the command stays on one readline buffer.
63
+ pendingCommand = cmd.replace(/\n/g, " ");
64
+ return {
65
+ content: `Will suggest at shell prompt: ${pendingCommand}`,
66
+ exitCode: 0,
67
+ isError: false,
68
+ };
69
+ },
70
+ });
71
+
72
+ // ── Injection hook ──────────────────────────────────────────────
73
+
74
+ // Replace the default handler — which re-enters agent-input mode when sticky —
75
+ // so a pending command lands at a fresh shell prompt instead. The "\n" leads
76
+ // the same PTY write so the new prompt appears before the command text fills it.
77
+ ctx.advise("shell:on-processing-redraw", (next) => {
78
+ if (pendingCommand) {
79
+ const cmd = pendingCommand;
80
+ pendingCommand = null;
81
+ bus.emit("shell:pty-write", { data: "\n" + cmd });
82
+ } else {
83
+ next();
84
+ }
85
+ });
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.15.5",
3
+ "version": "0.15.6",
4
4
  "description": "A composable agent runtime — pair any frontend with any agent backend over one shared extension layer",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -73,6 +73,10 @@
73
73
  "types": "./dist/agent/types.d.ts",
74
74
  "default": "./dist/agent/types.js"
75
75
  },
76
+ "./skills": {
77
+ "types": "./dist/agent/skills.d.ts",
78
+ "default": "./dist/agent/skills.js"
79
+ },
76
80
  "./store": {
77
81
  "types": "./dist/agent/store.d.ts",
78
82
  "default": "./dist/agent/store.js"
@@ -109,10 +113,6 @@
109
113
  "types": "./dist/agent/token-budget.d.ts",
110
114
  "default": "./dist/agent/token-budget.js"
111
115
  },
112
- "./agent/history-file": {
113
- "types": "./dist/agent/history-file.d.ts",
114
- "default": "./dist/agent/history-file.js"
115
- },
116
116
  "./agent/nuclear-form": {
117
117
  "types": "./dist/agent/nuclear-form.d.ts",
118
118
  "default": "./dist/agent/nuclear-form.js"
@@ -54,7 +54,10 @@ export type ToolResultBody =
54
54
  | { kind: "lines"; lines: string[]; maxLines?: number }
55
55
 
56
56
  export interface ToolDisplayInfo {
57
- kind: "read" | "write" | "execute" | "search";
57
+ /** Verb shown next to the detail (e.g. "execute foo.py"). Omit when a custom
58
+ * `icon` already makes the action self-evident — the renderer then shows
59
+ * icon + detail with no verb. */
60
+ kind?: "read" | "write" | "execute" | "search";
58
61
  locations?: { path: string; line?: number | null }[];
59
62
  /** Custom icon character for TUI display (e.g., "◆", "⌕"). When set, the TUI shows
60
63
  * icon + detail only. When absent, the tool name is shown alongside the detail. */
@@ -731,6 +731,7 @@ export default function activate(ctx: ExtensionContext): void {
731
731
  filePath,
732
732
  maxLines,
733
733
  trueColor: true,
734
+ gutterLine: false,
734
735
  });
735
736
  const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
736
737
 
@@ -140,6 +140,39 @@ function tokenLcs(
140
140
  return { oldMatch, newMatch };
141
141
  }
142
142
 
143
+ /**
144
+ * Anchors the shared prefix/suffix as unchanged and runs the O(m·n) LCS only on
145
+ * the differing middle. Null when that middle exceeds maxProduct.
146
+ */
147
+ function inlineMatches(
148
+ a: Token[],
149
+ b: Token[],
150
+ maxProduct: number,
151
+ ): { oldMatch: boolean[]; newMatch: boolean[] } | null {
152
+ const m = a.length;
153
+ const n = b.length;
154
+ let pre = 0;
155
+ while (pre < m && pre < n && a[pre].text === b[pre].text) pre++;
156
+ let suf = 0;
157
+ while (suf < m - pre && suf < n - pre && a[m - 1 - suf].text === b[n - 1 - suf].text) suf++;
158
+
159
+ const midM = m - pre - suf;
160
+ const midN = n - pre - suf;
161
+ if (midM * midN > maxProduct) return null;
162
+
163
+ const oldMatch = new Array<boolean>(m).fill(false);
164
+ const newMatch = new Array<boolean>(n).fill(false);
165
+ for (let i = 0; i < pre; i++) { oldMatch[i] = true; newMatch[i] = true; }
166
+ for (let i = 0; i < suf; i++) { oldMatch[m - 1 - i] = true; newMatch[n - 1 - i] = true; }
167
+
168
+ if (midM > 0 && midN > 0) {
169
+ const mid = tokenLcs(a.slice(pre, m - suf), b.slice(pre, n - suf));
170
+ for (let i = 0; i < midM; i++) oldMatch[pre + i] = mid.oldMatch[i];
171
+ for (let j = 0; j < midN; j++) newMatch[pre + j] = mid.newMatch[j];
172
+ }
173
+ return { oldMatch, newMatch };
174
+ }
175
+
143
176
  /**
144
177
  * Rewrite full ANSI resets (\x1b[0m) to foreground-only resets,
145
178
  * preserving the given background color across the line.
@@ -193,15 +226,14 @@ function highlightInlineChanges(
193
226
  };
194
227
  }
195
228
 
196
- // Safety guard: skip if LCS matrix would be too large
197
- if (oldTokens.length * newTokens.length > 50000) {
229
+ const matches = inlineMatches(oldTokens, newTokens, 1_000_000);
230
+ if (!matches) {
198
231
  return {
199
232
  old: language ? highlightLine(oldLine, language) : oldLine,
200
233
  new: language ? highlightLine(newLine, language) : newLine,
201
234
  };
202
235
  }
203
-
204
- const { oldMatch, newMatch } = tokenLcs(oldTokens, newTokens);
236
+ const { oldMatch, newMatch } = matches;
205
237
 
206
238
  const buildHighlighted = (
207
239
  tokens: Token[],
@@ -340,12 +372,19 @@ function renderUnifiedHunk(hunk: DiffHunk, layout: UnifiedLayout): string[] {
340
372
  const bgWidth = Math.max(1, textWidth - noW - 3);
341
373
  const gutter = (n: string): string => `${p.dim}${n} │${p.reset} `;
342
374
 
343
- const change = (no: string, sigil: string, bg: string, fg: string, text: string): string => {
344
- if (!gutterLine) {
345
- return `${bg}${padToWidth(`${fg}${no} ${sigil}${p.diffText} ${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
346
- }
347
- if (useTrueColor) return gutter(no) + padToWidth(`${bg}${fg}${sigil}${p.diffText} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
348
- return `${gutter(no)}${fg}${sigil} ${text}${p.reset}`;
375
+ const continuationNo = " ".repeat(noW);
376
+
377
+ // Wrapped rows after the first blank the line number and sigil.
378
+ const change = (no: string, sigil: string, bg: string, fg: string, text: string): string[] => {
379
+ return wrapLine(text, lineTextW).map((seg, r) => {
380
+ const n = r === 0 ? no : continuationNo;
381
+ const sg = r === 0 ? sigil : " ";
382
+ if (!gutterLine) {
383
+ return `${bg}${padToWidth(`${fg}${n} ${sg}${p.diffText} ${preserveBg(seg, bg)}`, textWidth)}${p.reset}`;
384
+ }
385
+ if (useTrueColor) return gutter(n) + padToWidth(`${bg}${fg}${sg}${p.diffText} ${preserveBg(seg, bg)}`, bgWidth) + p.reset;
386
+ return `${gutter(n)}${fg}${sg} ${seg}${p.reset}`;
387
+ });
349
388
  };
350
389
 
351
390
  const hlCache = new Map<ChangePair, { old: string; new: string }>();
@@ -367,31 +406,25 @@ function renderUnifiedHunk(hunk: DiffHunk, layout: UnifiedLayout): string[] {
367
406
  ).padStart(noW);
368
407
 
369
408
  if (line.type === "context") {
370
- const raw = truncateText(line.text, lineTextW);
371
- const text = lang ? highlightLine(raw, lang) : raw;
372
- out.push(!gutterLine ? `${p.dim}${no}${p.reset} ${text}` : `${gutter(no)} ${p.dim}${text}${p.reset}`);
409
+ const text = lang ? highlightLine(line.text, lang) : line.text;
410
+ wrapLine(text, lineTextW).forEach((seg, r) => {
411
+ const n = r === 0 ? no : continuationNo;
412
+ out.push(!gutterLine ? `${p.dim}${n}${p.reset} ${seg}` : `${gutter(n)} ${p.dim}${seg}${p.reset}`);
413
+ });
373
414
  continue;
374
415
  }
375
416
 
376
417
  const pair = pairs.get(i);
377
418
  if (line.type === "removed") {
378
- let removedText: string;
379
- if (pair) {
380
- removedText = truncateText(highlightedPair(pair).old, lineTextW);
381
- } else {
382
- const raw = truncateText(line.text, lineTextW);
383
- removedText = lang ? highlightLine(raw, lang) : raw;
384
- }
385
- out.push(change(no, "-", p.errorBg, p.error, removedText));
419
+ const removedText = pair
420
+ ? highlightedPair(pair).old
421
+ : (lang ? highlightLine(line.text, lang) : line.text);
422
+ out.push(...change(no, "-", p.errorBg, p.error, removedText));
386
423
  } else {
387
- let addedText: string;
388
- if (pair) {
389
- addedText = truncateText(highlightedPair(pair).new, lineTextW);
390
- } else {
391
- const raw = truncateText(line.text, lineTextW);
392
- addedText = lang ? highlightLine(raw, lang) : raw;
393
- }
394
- out.push(change(no, "+", p.successBg, p.success, addedText));
424
+ const addedText = pair
425
+ ? highlightedPair(pair).new
426
+ : (lang ? highlightLine(line.text, lang) : line.text);
427
+ out.push(...change(no, "+", p.successBg, p.success, addedText));
395
428
  }
396
429
  }
397
430
  return out;
@@ -79,6 +79,30 @@ export function wrapLine(text: string, maxWidth: number): string[] {
79
79
  lastVisibleIdx = -1;
80
80
  };
81
81
 
82
+ const hardBreak = (token: string): void => {
83
+ let remaining = token;
84
+ while (remaining.length > 0) {
85
+ let fitLen = 0, fitWidth = 0;
86
+ for (const ch of remaining) {
87
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
88
+ if (fitWidth + cw > maxWidth - lineWidth) break;
89
+ fitWidth += cw;
90
+ fitLen += ch.length;
91
+ }
92
+ if (fitLen === 0) {
93
+ // Force one char on an empty line so an over-wide char can't loop forever.
94
+ if (lineWidth > 0) { commit(); continue; }
95
+ fitLen = remaining[0]?.length ?? 1;
96
+ }
97
+ const chunk = remaining.slice(0, fitLen);
98
+ remaining = remaining.slice(fitLen);
99
+ lineTokens.push(chunk);
100
+ lineWidth += visibleLen(chunk);
101
+ lastVisibleIdx = lineTokens.length - 1;
102
+ if (remaining.length > 0) commit();
103
+ }
104
+ };
105
+
82
106
  for (const seg of segments) {
83
107
  if (seg.startsWith("\x1b[")) {
84
108
  lineTokens.push(seg);
@@ -103,23 +127,7 @@ export function wrapLine(text: string, maxWidth: number): string[] {
103
127
 
104
128
  if (lineWidth === 0) {
105
129
  // Token longer than the entire line — hard-break by char width.
106
- let remaining = token;
107
- while (remaining.length > 0) {
108
- let fitLen = 0, fitWidth = 0;
109
- for (const ch of remaining) {
110
- const cw = charWidth(ch.codePointAt(0) ?? 0);
111
- if (fitWidth + cw > maxWidth) break;
112
- fitWidth += cw;
113
- fitLen += ch.length;
114
- }
115
- if (fitLen === 0) fitLen = remaining[0]?.length ?? 1;
116
- const chunk = remaining.slice(0, fitLen);
117
- remaining = remaining.slice(fitLen);
118
- lineTokens.push(chunk);
119
- lineWidth += visibleLen(chunk);
120
- lastVisibleIdx = lineTokens.length - 1;
121
- if (remaining.length > 0) commit();
122
- }
130
+ hardBreak(token);
123
131
  continue;
124
132
  }
125
133
 
@@ -147,9 +155,13 @@ export function wrapLine(text: string, maxWidth: number): string[] {
147
155
  lineTokens.push(t);
148
156
  lineWidth += visibleLen(t);
149
157
  }
150
- lineTokens.push(token);
151
- lineWidth += tokenWidth;
152
- lastVisibleIdx = lineTokens.length - 1;
158
+ if (lineWidth + tokenWidth <= maxWidth) {
159
+ lineTokens.push(token);
160
+ lineWidth += tokenWidth;
161
+ lastVisibleIdx = lineTokens.length - 1;
162
+ } else {
163
+ hardBreak(token);
164
+ }
153
165
  }
154
166
  }
155
167