@thegitai/cli 1.0.0-beta.9 → 1.0.0-preview.10

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.
Files changed (64) hide show
  1. package/README.md +49 -3
  2. package/dist/bin/ai.js +83 -197
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +4 -4
  6. package/dist/src/api/browser-login.js +72 -19
  7. package/dist/src/api/chat.js +182 -35
  8. package/dist/src/api/http.js +65 -4
  9. package/dist/src/api/models.js +33 -22
  10. package/dist/src/artifact-policy.js +3 -0
  11. package/dist/src/background-jobs.js +410 -0
  12. package/dist/src/cli-args.js +0 -5
  13. package/dist/src/client-environment.js +2 -0
  14. package/dist/src/colors.js +50 -0
  15. package/dist/src/core/clipboard.js +19 -0
  16. package/dist/src/core/image-path-extractor.js +144 -0
  17. package/dist/src/executor.js +48 -12
  18. package/dist/src/help-text.js +30 -13
  19. package/dist/src/patcher.js +97 -12
  20. package/dist/src/project-index.js +13 -1
  21. package/dist/src/project-orientation.js +99 -0
  22. package/dist/src/scanner.js +50 -12
  23. package/dist/src/scratch-dir.js +75 -0
  24. package/dist/src/secret-preview.js +0 -10
  25. package/dist/src/session-safety.js +0 -19
  26. package/dist/src/session-store.js +52 -21
  27. package/dist/src/session.js +8 -0
  28. package/dist/src/todo-list.js +106 -0
  29. package/dist/src/tool-executor.js +194 -21
  30. package/dist/src/tools/delete-file.js +23 -5
  31. package/dist/src/tools/index.js +6 -0
  32. package/dist/src/tools/patch-file.js +33 -7
  33. package/dist/src/tools/path-suggest.js +81 -8
  34. package/dist/src/tools/read-document.js +2 -2
  35. package/dist/src/tools/read-file.js +17 -8
  36. package/dist/src/tools/replace-document-text.js +10 -12
  37. package/dist/src/tools/restore-checkpoint.js +1 -1
  38. package/dist/src/tools/run-command.js +109 -24
  39. package/dist/src/tools/run-node-script.js +27 -5
  40. package/dist/src/tools/shell-job-kill.js +48 -0
  41. package/dist/src/tools/shell-job-output.js +51 -0
  42. package/dist/src/tools/str-replace.js +33 -7
  43. package/dist/src/tools/undo-edit.js +1 -1
  44. package/dist/src/tools/update-todos.js +27 -0
  45. package/dist/src/tools/write-file.js +26 -6
  46. package/dist/src/tree-sitter-runtime.js +8 -1
  47. package/dist/src/turn-failure-marker.js +11 -0
  48. package/dist/src/ui/prompt-history-store.js +1 -1
  49. package/dist/src/ui/repl.js +500 -71
  50. package/dist/src/ui/tui/bridge.js +3 -4
  51. package/dist/src/ui/tui/build-frame.js +393 -100
  52. package/dist/src/ui/tui/markdown-render.js +72 -73
  53. package/dist/src/ui/tui/shell-input.js +75 -17
  54. package/dist/src/ui/tui/terminal-title.js +84 -0
  55. package/dist/src/ui/tui/terminal-writes.js +48 -0
  56. package/dist/src/ui/tui/text.js +158 -4
  57. package/dist/src/utils.js +9 -0
  58. package/dist/src/version.js +0 -6
  59. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  60. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  61. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  62. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  63. package/package.json +27 -16
  64. package/dist/src/markdown-renderer.js +0 -112
@@ -1,6 +1,8 @@
1
- import { line, plainLine, span, wrapText } from './text.js';
2
- const TABLE_BORDER_WIDTH = 2;
3
- const TABLE_CELL_OVERHEAD_WIDTH = 3;
1
+ import { displayWidth, line, padToWidth, plainLine, sliceToWidth, span, wrapText, wrapToWidth, } from './text.js';
2
+ const MIN_TABLE_COLUMN_WIDTH = 3;
3
+ function tableRowOverhead(columnCount) {
4
+ return 3 * columnCount + 1;
5
+ }
4
6
  function parseInlineSegments(text) {
5
7
  const source = String(text ?? '');
6
8
  const segments = [];
@@ -110,45 +112,37 @@ function stripInlineFormattingForWidth(text) {
110
112
  .replace(/\*\*([^*]+)\*\*/g, '$1');
111
113
  }
112
114
  function fitTableColumnWidths(columnWidths, maxWidth) {
113
- const widths = columnWidths.map((width) => Math.max(3, Math.floor(width)));
114
- if (widths.length === 0)
115
- return widths;
116
- const overhead = TABLE_BORDER_WIDTH + widths.length * TABLE_CELL_OVERHEAD_WIDTH;
117
- const budget = Math.max(widths.length * 3, maxWidth - overhead);
118
- const total = widths.reduce((sum, width) => sum + width, 0);
119
- if (total <= budget)
120
- return widths;
121
- let remaining = budget;
122
- const scaled = widths.map((width) => {
123
- const next = Math.max(3, Math.floor((width / total) * budget));
124
- remaining -= next;
125
- return next;
126
- });
127
- while (remaining > 0) {
128
- let targetIndex = 0;
129
- for (let index = 1; index < widths.length; index++) {
130
- if (widths[index] - scaled[index] > widths[targetIndex] - scaled[targetIndex]) {
131
- targetIndex = index;
132
- }
133
- }
134
- scaled[targetIndex]++;
135
- remaining--;
115
+ const columnCount = columnWidths.length;
116
+ if (columnCount === 0)
117
+ return [];
118
+ const natural = columnWidths.map((width) => Math.max(1, Math.floor(width)));
119
+ const budget = Math.floor(maxWidth) - tableRowOverhead(columnCount);
120
+ const total = natural.reduce((sum, width) => sum + width, 0);
121
+ if (budget >= total)
122
+ return natural;
123
+ const floorWidth = Math.max(1, Math.min(MIN_TABLE_COLUMN_WIDTH, Math.floor(budget / columnCount)));
124
+ const widths = natural.map((width) => Math.min(width, floorWidth));
125
+ let used = widths.reduce((sum, width) => sum + width, 0);
126
+ if (used > budget) {
127
+ const share = Math.max(1, Math.floor(budget / columnCount));
128
+ for (let index = 0; index < columnCount; index++)
129
+ widths[index] = share;
130
+ used = share * columnCount;
136
131
  }
137
- while (remaining < 0) {
138
- let targetIndex = -1;
139
- for (let index = 0; index < scaled.length; index++) {
140
- if (scaled[index] <= 3)
132
+ let remaining = budget - used;
133
+ while (remaining > 0) {
134
+ let grew = false;
135
+ for (let index = 0; index < columnCount && remaining > 0; index++) {
136
+ if (widths[index] >= natural[index])
141
137
  continue;
142
- if (targetIndex === -1 || scaled[index] > scaled[targetIndex]) {
143
- targetIndex = index;
144
- }
138
+ widths[index]++;
139
+ remaining--;
140
+ grew = true;
145
141
  }
146
- if (targetIndex === -1)
142
+ if (!grew)
147
143
  break;
148
- scaled[targetIndex]--;
149
- remaining++;
150
144
  }
151
- return scaled;
145
+ return widths;
152
146
  }
153
147
  function normalizeMarkdownTableCells(cells, columnCount) {
154
148
  return Array.from({ length: columnCount }, (_, index) => cells[index] ?? '');
@@ -176,7 +170,7 @@ function parseMarkdownTableBlock(lines, startIndex) {
176
170
  const normalizedHeaders = normalizeMarkdownTableCells(headers, columnCount);
177
171
  const columnWidths = normalizedHeaders.map((header, columnIndex) => {
178
172
  const values = [header, ...rows.map((row) => row[columnIndex] ?? '')];
179
- return Math.max(3, ...values.map((value) => stripInlineFormattingForWidth(value).length));
173
+ return Math.max(MIN_TABLE_COLUMN_WIDTH, ...values.map((value) => displayWidth(stripInlineFormattingForWidth(value))));
180
174
  });
181
175
  return {
182
176
  nextIndex,
@@ -280,24 +274,33 @@ function wrapInlineToLines(text, width, bodyColor, prefix = '') {
280
274
  rowWidth = 0;
281
275
  };
282
276
  const appendSpan = (part) => {
283
- const current = rows[rows.length - 1];
284
- const limit = safeWidth - (indent ? prefix.length : 0);
277
+ let current = rows[rows.length - 1];
278
+ const limit = safeWidth - (rows.length === 1 && indent ? displayWidth(prefix) : 0);
285
279
  let remaining = part.text;
286
280
  while (remaining.length > 0) {
287
281
  const room = limit - rowWidth;
288
282
  if (room <= 0) {
289
283
  startRow();
284
+ current = rows[rows.length - 1];
290
285
  continue;
291
286
  }
292
- if (remaining.length <= room) {
287
+ const width = displayWidth(remaining);
288
+ if (width <= room) {
293
289
  current.push(span(remaining, styleFromSpan(part)));
294
- rowWidth += remaining.length;
290
+ rowWidth += width;
295
291
  remaining = '';
296
292
  break;
297
293
  }
298
- current.push(span(remaining.slice(0, room), styleFromSpan(part)));
299
- remaining = remaining.slice(room);
294
+ const head = sliceToWidth(remaining, room);
295
+ if (displayWidth(head) > room && current.length > 0) {
296
+ startRow();
297
+ current = rows[rows.length - 1];
298
+ continue;
299
+ }
300
+ current.push(span(head, styleFromSpan(part)));
301
+ remaining = remaining.slice(head.length);
300
302
  startRow();
303
+ current = rows[rows.length - 1];
301
304
  }
302
305
  };
303
306
  for (const segment of segments) {
@@ -316,36 +319,35 @@ function wrapInlineToLines(text, width, bodyColor, prefix = '') {
316
319
  return line(...bodySpans);
317
320
  });
318
321
  }
319
- function padCell(text, width) {
320
- const plain = stripInlineFormattingForWidth(text);
321
- if (plain.length >= width)
322
- return plain.slice(0, width);
323
- return plain + ' '.repeat(width - plain.length);
324
- }
325
322
  function renderTableLines(table, width) {
326
323
  const columnWidths = fitTableColumnWidths(table.columnWidths, width);
324
+ const border = (left, joint, right) => line(span(left +
325
+ columnWidths.map((colWidth) => '─'.repeat(colWidth + 2)).join(joint) +
326
+ right, { color: 'cyan', dim: true }));
327
327
  const renderRow = (cells, bold) => {
328
- const parts = columnWidths.map((colWidth, index) => span(` ${padCell(cells[index] ?? '', colWidth)} `, {
329
- color: 'cyan',
330
- bold,
331
- }));
332
- return line(span('│', { color: 'cyan' }), ...parts, span('│', { color: 'cyan' }));
328
+ const wrapped = columnWidths.map((colWidth, index) => wrapToWidth(stripInlineFormattingForWidth(cells[index] ?? ''), colWidth));
329
+ const height = Math.max(1, ...wrapped.map((cellLines) => cellLines.length));
330
+ const rows = [];
331
+ for (let row = 0; row < height; row++) {
332
+ const spans = [span('│', { color: 'cyan' })];
333
+ for (let column = 0; column < columnWidths.length; column++) {
334
+ const text = wrapped[column]?.[row] ?? '';
335
+ spans.push(span(` ${padToWidth(text, columnWidths[column])} `, {
336
+ color: 'cyan',
337
+ bold,
338
+ }));
339
+ spans.push(span('│', { color: 'cyan' }));
340
+ }
341
+ rows.push(line(...spans));
342
+ }
343
+ return rows;
333
344
  };
334
- const separator = line(span('├' +
335
- columnWidths.map((colWidth) => '─'.repeat(colWidth + 2)).join('┼') +
336
- '┤', { color: 'cyan', dim: true }));
337
- const top = line(span('┌' +
338
- columnWidths.map((colWidth) => '─'.repeat(colWidth + 2)).join('┬') +
339
- '┐', { color: 'cyan', dim: true }));
340
- const bottom = line(span('└' +
341
- columnWidths.map((colWidth) => '─'.repeat(colWidth + 2)).join('┴') +
342
- '┘', { color: 'cyan', dim: true }));
343
345
  return [
344
- top,
345
- renderRow(table.headers, true),
346
- separator,
347
- ...table.rows.map((row) => renderRow(row, false)),
348
- bottom,
346
+ border('┌', '┬', '┐'),
347
+ ...renderRow(table.headers, true),
348
+ border('├', '┼', '┤'),
349
+ ...table.rows.flatMap((row) => renderRow(row, false)),
350
+ border('└', '┴', '┘'),
349
351
  ];
350
352
  }
351
353
  function getEntryColor(kind) {
@@ -410,10 +412,7 @@ export function renderFormattedBodyLines(body, width, kind) {
410
412
  }
411
413
  if (formattedLine.kind === 'table' && formattedLine.table) {
412
414
  output.push(...renderTableLines(formattedLine.table, bodyWidth).map((tableLine) => {
413
- const spans = tableLine.spans.map((part) => ({
414
- ...part,
415
- text: ` ${part.text}`,
416
- }));
415
+ const spans = tableLine.spans.map((part, index) => index === 0 ? { ...part, text: ` ${part.text}` } : part);
417
416
  return { spans };
418
417
  }));
419
418
  continue;
@@ -9,9 +9,21 @@ function isClipboardImagePasteKey(key) {
9
9
  }
10
10
  return key.ctrl && key.input === 'v' && !key.shift && !key.meta;
11
11
  }
12
+ function composerIsEmpty(state) {
13
+ return (!state.input &&
14
+ state.cursor === 0 &&
15
+ state.pastedChunks.length === 0 &&
16
+ state.promptHistoryCursor === null);
17
+ }
18
+ function composerHasDiscardableDraft(state) {
19
+ if (!composerIsEmpty(state))
20
+ return true;
21
+ return !state.busy && state.imageAttachments.length > 0;
22
+ }
12
23
  function shouldShowCommandPalette(state) {
13
24
  if (state.busy ||
14
25
  state.exiting ||
26
+ state.jobsPickerOpen ||
15
27
  state.modelPickerOpen ||
16
28
  state.resumePickerOpen) {
17
29
  return false;
@@ -57,6 +69,7 @@ function pasteTextFromClipboard(store, handlers) {
57
69
  const current = store.getState();
58
70
  if (current.exiting ||
59
71
  current.approvalPrompt ||
72
+ current.jobsPickerOpen ||
60
73
  current.modelPickerOpen ||
61
74
  current.resumePickerOpen ||
62
75
  current.sudoPrompt) {
@@ -130,11 +143,41 @@ export function handleShellKeyEvent(store, handlers, event) {
130
143
  return;
131
144
  const key = event;
132
145
  const state = store.getState();
146
+ const commandPaletteActive = shouldShowCommandPalette(state);
147
+ const commandSuggestions = commandPaletteActive
148
+ ? getSlashCommandSuggestions(state.input)
149
+ : [];
150
+ const prepareForComposerInputChange = (current, nextInput) => {
151
+ if (shouldRemountLiveFrameForComposerInputChange(current, nextInput)) {
152
+ handlers.onLiveFrameShapeChange();
153
+ }
154
+ };
133
155
  if (key.ctrl && key.input === 'c') {
134
156
  if (state.sudoPrompt) {
135
157
  handlers.onSudoPasswordInput({ kind: 'cancel' });
136
158
  return;
137
159
  }
160
+ if (state.queuedMessage) {
161
+ handlers.onLiveFrameShapeChange();
162
+ store.update((current) => ({ ...current, queuedMessage: null }));
163
+ return;
164
+ }
165
+ if (composerHasDiscardableDraft(state)) {
166
+ store.update((current) => {
167
+ prepareForComposerInputChange(current, '');
168
+ return {
169
+ ...current,
170
+ commandCursor: 0,
171
+ cursor: 0,
172
+ imageAttachments: current.busy ? current.imageAttachments : [],
173
+ input: '',
174
+ pastedChunks: [],
175
+ promptHistoryCursor: null,
176
+ promptHistoryDraft: '',
177
+ };
178
+ });
179
+ return;
180
+ }
138
181
  if (handlers.onCtrlC) {
139
182
  handlers.onCtrlC();
140
183
  return;
@@ -142,15 +185,6 @@ export function handleShellKeyEvent(store, handlers, event) {
142
185
  handlers.onRequestExit();
143
186
  return;
144
187
  }
145
- const commandPaletteActive = shouldShowCommandPalette(state);
146
- const commandSuggestions = commandPaletteActive
147
- ? getSlashCommandSuggestions(state.input)
148
- : [];
149
- const prepareForComposerInputChange = (current, nextInput) => {
150
- if (shouldRemountLiveFrameForComposerInputChange(current, nextInput)) {
151
- handlers.onLiveFrameShapeChange();
152
- }
153
- };
154
188
  if (state.sudoPrompt) {
155
189
  if (key.escape) {
156
190
  handlers.onSudoPasswordInput({ kind: 'cancel' });
@@ -210,6 +244,37 @@ export function handleShellKeyEvent(store, handlers, event) {
210
244
  });
211
245
  return;
212
246
  }
247
+ if (state.jobsPickerOpen) {
248
+ if (key.escape) {
249
+ store.update((current) => ({
250
+ ...current,
251
+ jobsPickerExpandedId: null,
252
+ jobsPickerOpen: false,
253
+ status: 'Ready',
254
+ }));
255
+ return;
256
+ }
257
+ if (key.upArrow || key.downArrow) {
258
+ store.update((current) => {
259
+ const count = (current.backgroundJobs ?? []).length;
260
+ const next = current.jobsPickerIndex + (key.upArrow ? -1 : 1);
261
+ return {
262
+ ...current,
263
+ jobsPickerExpandedId: null,
264
+ jobsPickerIndex: Math.max(0, Math.min(next, Math.max(count - 1, 0))),
265
+ };
266
+ });
267
+ return;
268
+ }
269
+ if (key.returnKey) {
270
+ void handlers.onJobsPickerOutput();
271
+ return;
272
+ }
273
+ if (key.input === 'k' && !key.ctrl && !key.meta && !key.shift) {
274
+ void handlers.onJobsPickerKill();
275
+ }
276
+ return;
277
+ }
213
278
  if (state.modelPickerOpen) {
214
279
  if (key.escape) {
215
280
  store.update((current) => ({ ...current, modelPickerOpen: false }));
@@ -275,8 +340,6 @@ export function handleShellKeyEvent(store, handlers, event) {
275
340
  }
276
341
  if (key.escape) {
277
342
  if (state.busy) {
278
- // With a queued message, Esc clears the queue only (turn keeps running).
279
- // With nothing queued, Esc cancels the turn (Ctrl+C also cancels).
280
343
  if (state.queuedMessage) {
281
344
  handlers.onLiveFrameShapeChange();
282
345
  store.update((current) => ({ ...current, queuedMessage: null }));
@@ -286,10 +349,7 @@ export function handleShellKeyEvent(store, handlers, event) {
286
349
  return;
287
350
  }
288
351
  store.update((current) => {
289
- if (!current.input &&
290
- current.cursor === 0 &&
291
- current.pastedChunks.length === 0 &&
292
- current.promptHistoryCursor === null) {
352
+ if (composerIsEmpty(current)) {
293
353
  return current;
294
354
  }
295
355
  prepareForComposerInputChange(current, '');
@@ -317,8 +377,6 @@ export function handleShellKeyEvent(store, handlers, event) {
317
377
  return;
318
378
  }
319
379
  if (key.upArrow) {
320
- // While busy with an empty composer, Up recalls and dequeues the queued
321
- // message for editing; otherwise it walks prompt history as usual.
322
380
  if (state.busy && state.input.trim() === '' && state.queuedMessage) {
323
381
  handlers.onLiveFrameShapeChange();
324
382
  store.update((current) => {
@@ -0,0 +1,84 @@
1
+ import { isTuiMode } from '../../runtime-mode.js';
2
+ const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴'];
3
+ const TITLE_BRAND = 'TheGitAI';
4
+ const TITLE_MARK_PREFIX = '❯_';
5
+ export const TERMINAL_TITLE_SPINNER_MS = 500;
6
+ export function resolveTerminalTitleState(input) {
7
+ if (input.awaitingReview)
8
+ return 'review';
9
+ if (input.busy)
10
+ return 'wip';
11
+ return 'clean';
12
+ }
13
+ export function formatTerminalTitle(state, spinnerFrame = 0) {
14
+ if (state === 'review')
15
+ return `${TITLE_MARK_PREFIX}▸ ${TITLE_BRAND}`;
16
+ if (state === 'wip') {
17
+ const frame = BRAILLE_SPINNER_FRAMES[spinnerFrame % BRAILLE_SPINNER_FRAMES.length];
18
+ return `${TITLE_MARK_PREFIX}${frame} ${TITLE_BRAND}`;
19
+ }
20
+ return `${TITLE_MARK_PREFIX}● ${TITLE_BRAND}`;
21
+ }
22
+ export function writeTerminalTitle(title, stream = process.stdout) {
23
+ if (isTuiMode())
24
+ return;
25
+ if (!('isTTY' in stream) || !stream.isTTY)
26
+ return;
27
+ stream.write(`\x1b]0;${title}\x07`);
28
+ }
29
+ export function createTerminalTitleController(options) {
30
+ const write = options?.write ?? writeTerminalTitle;
31
+ let currentState = 'clean';
32
+ let spinnerFrame = 0;
33
+ let lastTitle = '';
34
+ let timer = null;
35
+ const paint = () => {
36
+ const title = formatTerminalTitle(currentState, spinnerFrame);
37
+ if (title === lastTitle)
38
+ return;
39
+ lastTitle = title;
40
+ write(title);
41
+ };
42
+ const stopTimer = () => {
43
+ if (!timer)
44
+ return;
45
+ clearInterval(timer);
46
+ timer = null;
47
+ };
48
+ const startTimer = () => {
49
+ if (timer)
50
+ return;
51
+ timer = setInterval(() => {
52
+ spinnerFrame = (spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length;
53
+ paint();
54
+ }, TERMINAL_TITLE_SPINNER_MS);
55
+ };
56
+ return {
57
+ sync(input) {
58
+ const next = resolveTerminalTitleState(input);
59
+ if (next !== currentState) {
60
+ currentState = next;
61
+ if (next === 'wip') {
62
+ spinnerFrame = 0;
63
+ startTimer();
64
+ }
65
+ else {
66
+ stopTimer();
67
+ }
68
+ }
69
+ else if (next === 'wip') {
70
+ startTimer();
71
+ }
72
+ else {
73
+ stopTimer();
74
+ }
75
+ paint();
76
+ },
77
+ dispose() {
78
+ stopTimer();
79
+ currentState = 'clean';
80
+ spinnerFrame = 0;
81
+ paint();
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,48 @@
1
+ const MAX_CAPTURED_CHARS = 1_000_000;
2
+ let restore = null;
3
+ let captured = [];
4
+ let capturedChars = 0;
5
+ function chunkToString(chunk, encoding) {
6
+ if (typeof chunk === 'string')
7
+ return chunk;
8
+ if (chunk instanceof Uint8Array) {
9
+ return Buffer.from(chunk).toString(typeof encoding === 'string' ? encoding : 'utf8');
10
+ }
11
+ return String(chunk ?? '');
12
+ }
13
+ export function captureTerminalWrites() {
14
+ if (restore)
15
+ return;
16
+ const streams = [process.stdout, process.stderr];
17
+ const originals = streams.map((stream) => stream.write.bind(stream));
18
+ for (const stream of streams) {
19
+ stream.write = (chunk, encoding, callback) => {
20
+ if (capturedChars < MAX_CAPTURED_CHARS) {
21
+ const text = chunkToString(chunk, encoding);
22
+ captured.push(text);
23
+ capturedChars += text.length;
24
+ }
25
+ const done = typeof encoding === 'function' ? encoding : callback;
26
+ if (typeof done === 'function')
27
+ done();
28
+ return true;
29
+ };
30
+ }
31
+ restore = () => {
32
+ streams.forEach((stream, index) => {
33
+ stream.write = originals[index];
34
+ });
35
+ };
36
+ }
37
+ export function releaseTerminalWrites() {
38
+ if (!restore)
39
+ return;
40
+ restore();
41
+ restore = null;
42
+ if (captured.length > 0) {
43
+ const text = captured.join('');
44
+ captured = [];
45
+ capturedChars = 0;
46
+ process.stderr.write(text);
47
+ }
48
+ }
@@ -1,5 +1,14 @@
1
+ const CSI_PATTERN = /\u001B\[[0-9;?]*[ -\/]*[@-~]/g;
2
+ const OSC_PATTERN = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)?/g;
3
+ const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
4
+ export function stripControlCharacters(text) {
5
+ return String(text ?? '')
6
+ .replace(OSC_PATTERN, '')
7
+ .replace(CSI_PATTERN, '')
8
+ .replace(CONTROL_CHAR_PATTERN, '');
9
+ }
1
10
  export function span(text, style = {}) {
2
- return { text, ...style };
11
+ return { text: stripControlCharacters(text), ...style };
3
12
  }
4
13
  export function line(...spans) {
5
14
  return { spans };
@@ -14,10 +23,11 @@ export function wrapText(text, width) {
14
23
  const lines = [];
15
24
  for (const rawLine of text.split('\n')) {
16
25
  let remaining = rawLine;
17
- while (remaining.length > safeWidth) {
18
- let breakAt = remaining.lastIndexOf(' ', safeWidth);
26
+ while (displayWidth(remaining) > safeWidth) {
27
+ const head = sliceToWidth(remaining, safeWidth);
28
+ let breakAt = head.lastIndexOf(' ');
19
29
  if (breakAt <= 0)
20
- breakAt = safeWidth;
30
+ breakAt = head.length;
21
31
  lines.push(remaining.slice(0, breakAt).trimEnd());
22
32
  remaining = remaining.slice(breakAt).trimStart();
23
33
  }
@@ -28,3 +38,147 @@ export function wrapText(text, width) {
28
38
  export function joinLines(blocks) {
29
39
  return blocks.flat();
30
40
  }
41
+ function isZeroWidthCodePoint(codePoint) {
42
+ return (codePoint === 0x200d ||
43
+ (codePoint >= 0x0300 && codePoint <= 0x036f) ||
44
+ (codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
45
+ (codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
46
+ (codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
47
+ (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
48
+ (codePoint >= 0xfe20 && codePoint <= 0xfe2f));
49
+ }
50
+ function isWideCodePoint(codePoint) {
51
+ return ((codePoint >= 0x1100 && codePoint <= 0x115f) ||
52
+ codePoint === 0x231a ||
53
+ codePoint === 0x231b ||
54
+ (codePoint >= 0x23e9 && codePoint <= 0x23ec) ||
55
+ codePoint === 0x23f0 ||
56
+ codePoint === 0x23f3 ||
57
+ (codePoint >= 0x25fd && codePoint <= 0x25fe) ||
58
+ (codePoint >= 0x2614 && codePoint <= 0x2615) ||
59
+ (codePoint >= 0x2648 && codePoint <= 0x2653) ||
60
+ codePoint === 0x267f ||
61
+ codePoint === 0x2693 ||
62
+ codePoint === 0x26a1 ||
63
+ (codePoint >= 0x26aa && codePoint <= 0x26ab) ||
64
+ (codePoint >= 0x26bd && codePoint <= 0x26be) ||
65
+ (codePoint >= 0x26c4 && codePoint <= 0x26c5) ||
66
+ codePoint === 0x26ce ||
67
+ codePoint === 0x26d4 ||
68
+ codePoint === 0x26ea ||
69
+ (codePoint >= 0x26f2 && codePoint <= 0x26f3) ||
70
+ codePoint === 0x26f5 ||
71
+ codePoint === 0x26fa ||
72
+ codePoint === 0x26fd ||
73
+ codePoint === 0x2705 ||
74
+ (codePoint >= 0x270a && codePoint <= 0x270b) ||
75
+ codePoint === 0x2728 ||
76
+ codePoint === 0x274c ||
77
+ codePoint === 0x274e ||
78
+ (codePoint >= 0x2753 && codePoint <= 0x2755) ||
79
+ codePoint === 0x2757 ||
80
+ (codePoint >= 0x2795 && codePoint <= 0x2797) ||
81
+ codePoint === 0x27b0 ||
82
+ codePoint === 0x27bf ||
83
+ (codePoint >= 0x2b1b && codePoint <= 0x2b1c) ||
84
+ codePoint === 0x2b50 ||
85
+ codePoint === 0x2b55 ||
86
+ (codePoint >= 0x2e80 && codePoint <= 0x303e) ||
87
+ (codePoint >= 0x3041 && codePoint <= 0x33ff) ||
88
+ (codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
89
+ (codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
90
+ (codePoint >= 0xa000 && codePoint <= 0xa4cf) ||
91
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
92
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
93
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
94
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
95
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
96
+ (codePoint >= 0x1f300 && codePoint <= 0x1f64f) ||
97
+ (codePoint >= 0x1f680 && codePoint <= 0x1f6ff) ||
98
+ (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
99
+ (codePoint >= 0x1fa70 && codePoint <= 0x1faff) ||
100
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd));
101
+ }
102
+ export function displayWidth(text) {
103
+ const chars = [...String(text ?? '')];
104
+ let width = 0;
105
+ for (let index = 0; index < chars.length; index++) {
106
+ const codePoint = chars[index].codePointAt(0);
107
+ if (isZeroWidthCodePoint(codePoint))
108
+ continue;
109
+ const next = chars[index + 1]?.codePointAt(0);
110
+ if (next === 0xfe0f) {
111
+ width += 2;
112
+ index++;
113
+ continue;
114
+ }
115
+ if (next === 0xfe0e) {
116
+ width += 1;
117
+ index++;
118
+ continue;
119
+ }
120
+ width += isWideCodePoint(codePoint) ? 2 : 1;
121
+ }
122
+ return width;
123
+ }
124
+ export function sliceToWidth(text, width) {
125
+ const limit = Math.max(1, Math.floor(width));
126
+ const chars = [...String(text ?? '')];
127
+ let out = '';
128
+ let used = 0;
129
+ for (let index = 0; index < chars.length; index++) {
130
+ const char = chars[index];
131
+ const next = chars[index + 1];
132
+ const selector = next === '️' || next === '︎';
133
+ const cluster = selector ? `${char}${next}` : char;
134
+ const clusterWidth = displayWidth(cluster);
135
+ if (used + clusterWidth > limit)
136
+ break;
137
+ out += cluster;
138
+ used += clusterWidth;
139
+ if (selector)
140
+ index++;
141
+ }
142
+ if (!out)
143
+ return chars[0] ?? '';
144
+ return out;
145
+ }
146
+ export function padToWidth(text, width) {
147
+ const current = displayWidth(text);
148
+ if (current >= width)
149
+ return text;
150
+ return text + ' '.repeat(width - current);
151
+ }
152
+ export function wrapToWidth(text, width) {
153
+ const limit = Math.max(1, Math.floor(width));
154
+ const out = [];
155
+ for (const rawLine of String(text ?? '').split('\n')) {
156
+ let current = '';
157
+ const flush = () => {
158
+ out.push(current);
159
+ current = '';
160
+ };
161
+ for (const token of rawLine.split(/\s+/).filter(Boolean)) {
162
+ let rest = token;
163
+ while (displayWidth(rest) > limit) {
164
+ if (current)
165
+ flush();
166
+ const head = sliceToWidth(rest, limit);
167
+ out.push(head);
168
+ rest = rest.slice(head.length);
169
+ }
170
+ if (!rest)
171
+ continue;
172
+ const candidate = current ? `${current} ${rest}` : rest;
173
+ if (displayWidth(candidate) > limit) {
174
+ flush();
175
+ current = rest;
176
+ }
177
+ else {
178
+ current = candidate;
179
+ }
180
+ }
181
+ flush();
182
+ }
183
+ return out.length > 0 ? out : [''];
184
+ }