@bahulam/code 2.6.14 → 2.6.15

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.
@@ -52,6 +52,7 @@ const INPUT_RIGHT_PAD = 2;
52
52
  const META_INDENT = 4;
53
53
 
54
54
  const DEFAULT_MAX_INPUT_ROWS = 6;
55
+ const DEFAULT_OVERLAY_MAX_ROWS = 8;
55
56
  const MIN_INPUT_ROWS = 1;
56
57
  const MAX_INPUT_ROWS_CAP = 12;
57
58
 
@@ -74,8 +75,22 @@ let unsubResize = null;
74
75
  let lastFrame = { context: '', meta: '', tips: '', prefix: '', value: '', cursor: null, overlayLines: null };
75
76
  let resetting = false;
76
77
  let lastGeometry = null;
77
-
78
- function write(s) { try { OUT.write(s); } catch {} }
78
+ let contentCursorRow = 1;
79
+ let contentCursorCol = 1;
80
+ let contentTrackingActive = false;
81
+ let suppressWriteTracking = 0;
82
+ let originalStdoutWrite = null;
83
+ let originalStderrWrite = null;
84
+
85
+ function write(s) {
86
+ try {
87
+ suppressWriteTracking++;
88
+ OUT.write(s);
89
+ } catch {
90
+ } finally {
91
+ suppressWriteTracking = Math.max(0, suppressWriteTracking - 1);
92
+ }
93
+ }
79
94
  function setScrollRegion(top, bottom) { write(`${ESC}${top};${bottom}r`); }
80
95
  function clearScrollRegion() { write(`${ESC}r`); }
81
96
  function saveCursor() { write(`${ESC}s`); }
@@ -102,6 +117,69 @@ function contentBottomRow() {
102
117
  return Math.max(1, rows() - reservedRows);
103
118
  }
104
119
 
120
+ function clampContentCursor() {
121
+ const bottom = contentBottomRow();
122
+ contentCursorRow = Math.max(1, Math.min(bottom, contentCursorRow || 1));
123
+ contentCursorCol = Math.max(1, Math.min(cols(), contentCursorCol || 1));
124
+ }
125
+
126
+ function resetContentCursor(row = 1, col = 1) {
127
+ contentCursorRow = Math.max(1, Math.min(contentBottomRow(), Math.floor(row || 1)));
128
+ contentCursorCol = Math.max(1, Math.min(cols(), Math.floor(col || 1)));
129
+ }
130
+
131
+ function trackContentWrite(chunk) {
132
+ if (!mounted || !contentTrackingActive || suppressWriteTracking > 0) return;
133
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? '');
134
+ if (!text) return;
135
+ const clean = text
136
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
137
+ .replace(/\x1b[()][A-Za-z0-9]/g, '');
138
+ const bottom = contentBottomRow();
139
+ const width = Math.max(1, drawableColumns());
140
+ for (const ch of clean) {
141
+ if (ch === '\r') {
142
+ contentCursorCol = 1;
143
+ continue;
144
+ }
145
+ if (ch === '\n') {
146
+ contentCursorRow = Math.min(bottom, contentCursorRow + 1);
147
+ contentCursorCol = 1;
148
+ continue;
149
+ }
150
+ contentCursorCol++;
151
+ if (contentCursorCol > width) {
152
+ contentCursorRow = Math.min(bottom, contentCursorRow + 1);
153
+ contentCursorCol = 1;
154
+ }
155
+ }
156
+ }
157
+
158
+ function patchOutputTracking() {
159
+ if (originalStdoutWrite || originalStderrWrite) return;
160
+ originalStdoutWrite = process.stdout.write.bind(process.stdout);
161
+ originalStderrWrite = process.stderr.write.bind(process.stderr);
162
+ process.stdout.write = function trackedStdoutWrite(chunk, ...args) {
163
+ trackContentWrite(chunk);
164
+ return originalStdoutWrite(chunk, ...args);
165
+ };
166
+ process.stderr.write = function trackedStderrWrite(chunk, ...args) {
167
+ trackContentWrite(chunk);
168
+ return originalStderrWrite(chunk, ...args);
169
+ };
170
+ }
171
+
172
+ function unpatchOutputTracking() {
173
+ if (originalStdoutWrite) {
174
+ process.stdout.write = originalStdoutWrite;
175
+ originalStdoutWrite = null;
176
+ }
177
+ if (originalStderrWrite) {
178
+ process.stderr.write = originalStderrWrite;
179
+ originalStderrWrite = null;
180
+ }
181
+ }
182
+
105
183
  // Row map (top → bottom of the reserved region).
106
184
  function topRuleRow() { return contentBottomRow() + 1; }
107
185
  function spacerAboveRow() { return topRuleRow() + 1; }
@@ -125,6 +203,18 @@ function resolveMaxInputRows(requested) {
125
203
  return Math.max(MIN_INPUT_ROWS, Math.min(MAX_INPUT_ROWS_CAP, n));
126
204
  }
127
205
 
206
+ function resolveOverlayRowCap(requested = DEFAULT_OVERLAY_MAX_ROWS) {
207
+ const n = Number.parseInt(String(requested), 10);
208
+ if (!Number.isFinite(n)) return DEFAULT_OVERLAY_MAX_ROWS;
209
+ return Math.max(MIN_INPUT_ROWS, Math.min(MAX_INPUT_ROWS_CAP, n));
210
+ }
211
+
212
+ function overlayRowsForWrapped(wrappedLength, requestedMaxRows = DEFAULT_OVERLAY_MAX_ROWS) {
213
+ const rowCap = resolveOverlayRowCap(requestedMaxRows);
214
+ const wanted = Math.max(MIN_INPUT_ROWS, Math.floor(Number(wrappedLength) || 0));
215
+ return Math.min(rowCap, wanted);
216
+ }
217
+
128
218
  // How many input rows does this (prefix + value) buffer need? Wrapped line
129
219
  // count clamped to [1, inputRowsMax]. Beyond the cap, tail-with-ellipsis
130
220
  // takes over inside drawInputLines so at most inputRowsMax rows render.
@@ -250,6 +340,7 @@ function applyLayout({ clearPrevious = false } = {}) {
250
340
  const bottom = contentBottomRow();
251
341
  setScrollRegion(1, bottom);
252
342
  renderFrame(lastFrame);
343
+ clampContentCursor();
253
344
  // On (re)mount and resize, park at input if we have one; otherwise sit at
254
345
  // the bottom of the content region so any pending content writes flush
255
346
  // above the dock rather than into a stale mid-frame position.
@@ -385,7 +476,11 @@ export function isInputDockMounted() {
385
476
  return mounted;
386
477
  }
387
478
 
388
- export function mountInputDock({ inputRowsMax: requestedMax } = {}) {
479
+ export function mountInputDock({
480
+ inputRowsMax: requestedMax,
481
+ initialContentRow = 1,
482
+ initialContentCol = 1,
483
+ } = {}) {
389
484
  const t = term();
390
485
  if (!t.isTTY || t.plain) return false;
391
486
  if (t.ttyMode !== 'rich' || t.fixedInput === false) return false;
@@ -395,7 +490,10 @@ export function mountInputDock({ inputRowsMax: requestedMax } = {}) {
395
490
  inputRowsMax = resolveMaxInputRows(requestedMax);
396
491
  inputRows = MIN_INPUT_ROWS;
397
492
  reservedRows = FIXED_ROWS + inputRows;
493
+ resetContentCursor(initialContentRow, initialContentCol);
494
+ contentTrackingActive = false;
398
495
  mounted = true;
496
+ patchOutputTracking();
399
497
  applyLayout();
400
498
 
401
499
  unsubResize = onResize(() => applyLayout({ clearPrevious: true }));
@@ -415,6 +513,8 @@ export function unmountInputDock() {
415
513
  if (unsubResize) { unsubResize(); unsubResize = null; }
416
514
  } finally {
417
515
  mounted = false;
516
+ contentTrackingActive = false;
517
+ unpatchOutputTracking();
418
518
  resetting = false;
419
519
  lastGeometry = null;
420
520
  }
@@ -424,17 +524,19 @@ function safeUnmount() { try { unmountInputDock(); } catch {} }
424
524
 
425
525
  export function moveToContent() {
426
526
  if (!mounted) return false;
427
- moveTo(contentBottomRow(), 1);
527
+ clampContentCursor();
528
+ contentTrackingActive = true;
529
+ moveTo(contentCursorRow, contentCursorCol);
428
530
  return true;
429
531
  }
430
532
 
431
- // One row above the bottom of the scroll region. Writes here won't trigger
432
- // the scroll-on-LF that happens when writing '\n' at the region's bottom row
433
- // exactly what the persistent explore/spinner line needs to overwrite in place
434
- // without piling copies of itself into scrollback.
533
+ // The next transcript row. Spinner/status overlays live next to the latest
534
+ // content instead of near the scroll-region bottom; otherwise sparse agent
535
+ // events leave large blank holes between visible lines.
435
536
  export function pinnedStatusRow() {
436
537
  if (!mounted) return null;
437
- return Math.max(1, contentBottomRow() - 1);
538
+ clampContentCursor();
539
+ return Math.max(1, Math.min(contentBottomRow(), contentCursorRow));
438
540
  }
439
541
 
440
542
  export function drawPinnedStatus(line) {
@@ -466,6 +568,7 @@ export function clearPinnedStatus() {
466
568
  // clearing their overlay.
467
569
  export function redrawDockFrame() {
468
570
  if (!mounted) return false;
571
+ contentTrackingActive = false;
469
572
  renderFrame(lastFrame);
470
573
  parkCursorAtInput();
471
574
  return true;
@@ -473,6 +576,7 @@ export function redrawDockFrame() {
473
576
 
474
577
  export function prepareInputPrompt({ context = '', tips = '', meta = '' } = {}) {
475
578
  if (!mounted) return false;
579
+ contentTrackingActive = false;
476
580
  setInputRowsTo(MIN_INPUT_ROWS);
477
581
  clearInputRows();
478
582
  renderFrame({ context, tips, meta, prefix: '', value: '', overlayLines: null });
@@ -482,9 +586,11 @@ export function prepareInputPrompt({ context = '', tips = '', meta = '' } = {})
482
586
 
483
587
  export function clearInputPrompt() {
484
588
  if (!mounted) return false;
485
- clearInputRows();
589
+ contentTrackingActive = false;
486
590
  lastFrame.value = '';
487
591
  lastFrame.overlayLines = null;
592
+ setInputRowsTo(MIN_INPUT_ROWS);
593
+ clearInputRows();
488
594
  renderFrame(lastFrame);
489
595
  parkCursorAtInput();
490
596
  return true;
@@ -492,6 +598,7 @@ export function clearInputPrompt() {
492
598
 
493
599
  export function renderDockInput(prefix, value, { context = '', tips = '', meta = '', cursor = null } = {}) {
494
600
  if (!mounted) return false;
601
+ contentTrackingActive = false;
495
602
  setInputRowsTo(computeInputRowsForBuffer(prefix, value));
496
603
  renderFrame({ context, tips, meta, prefix, value, cursor, overlayLines: null });
497
604
  const layout = layoutInput(prefix, value);
@@ -505,13 +612,14 @@ export function renderDockOverlay({
505
612
  lines = [],
506
613
  meta = '',
507
614
  tips = '',
508
- maxRows = 8,
615
+ maxRows = DEFAULT_OVERLAY_MAX_ROWS,
509
616
  } = {}) {
510
617
  if (!mounted) return false;
618
+ contentTrackingActive = false;
511
619
  const sourceLines = Array.isArray(lines) ? lines : String(lines || '').split('\n');
512
620
  const wrapped = layoutOverlayLines(sourceLines);
513
- const rowCap = Math.max(MIN_INPUT_ROWS, Math.min(MAX_INPUT_ROWS_CAP, Math.max(inputRowsMax, maxRows)));
514
- setInputRowsTo(Math.min(rowCap, Math.max(MIN_INPUT_ROWS, wrapped.length)), { maxRows: rowCap });
621
+ const rowCap = resolveOverlayRowCap(maxRows);
622
+ setInputRowsTo(overlayRowsForWrapped(wrapped.length, rowCap), { maxRows: rowCap });
515
623
  const tail = tailWithEllipsis(wrapped, inputRows);
516
624
  renderFrame({
517
625
  context,
@@ -537,6 +645,7 @@ export function renderDockOverlay({
537
645
  */
538
646
  export function focusDockInput(prefix, value = '', cursorInValue = null) {
539
647
  if (!mounted) return false;
648
+ contentTrackingActive = false;
540
649
  const layout = layoutInput(prefix, value);
541
650
  const valueStr = String(value || '');
542
651
  const rawCursor = cursorInValue == null
@@ -570,7 +679,12 @@ export function _internals() {
570
679
  bottomRuleLine,
571
680
  padLine,
572
681
  drawableColumns,
682
+ resetContentCursor,
683
+ contentCursor: () => ({ row: contentCursorRow, col: contentCursorCol, active: contentTrackingActive }),
684
+ overlayRowsForWrapped,
573
685
  FIXED_ROWS,
686
+ MAX_INPUT_ROWS_CAP,
687
+ DEFAULT_OVERLAY_MAX_ROWS,
574
688
  BRAND_LABEL,
575
689
  };
576
690
  }
@@ -231,32 +231,47 @@ function summarizeJsonOutput(value) {
231
231
 
232
232
  export function formatCompactFileDiff(result, {
233
233
  indent = ' ',
234
- maxLines = 14,
235
- maxFiles = 2,
234
+ maxLines = Infinity,
235
+ maxFiles = Infinity,
236
236
  columns = term().columns || 120,
237
+ showFileHeader = false,
237
238
  } = {}) {
238
- const diffs = fileDiffs(result).filter(diff => diff?.hunks?.length);
239
+ const diffs = fileDiffs(result)
240
+ .map(normalizeFileDiff)
241
+ .filter(diff => diff && (diff.redacted || diff?.hunks?.length));
239
242
  if (!diffs.length) return '';
240
243
 
241
244
  const out = [];
242
245
  let shown = 0;
243
246
  let truncated = false;
247
+ const lineLimit = Number.isFinite(maxLines) ? Math.max(0, Math.floor(maxLines)) : Infinity;
248
+ const fileLimit = Number.isFinite(maxFiles) ? Math.max(0, Math.floor(maxFiles)) : diffs.length;
244
249
  const lineBudget = Math.max(40, columns - visibleWidth(indent) - 4);
245
250
 
246
- for (const diff of diffs.slice(0, maxFiles)) {
247
- if (diffs.length > 1) {
248
- if (shown >= maxLines) { truncated = true; break; }
251
+ for (const diff of diffs.slice(0, fileLimit)) {
252
+ if (showFileHeader || diffs.length > 1) {
253
+ if (shown >= lineLimit) { truncated = true; break; }
249
254
  out.push(`${indent}${paint.brand.primary(diff.relative_path || diff.path || 'file')} ${paint.text.dim(diffDelta(diff))}`);
250
255
  shown++;
251
256
  }
252
257
 
258
+ if (diff.redacted) {
259
+ if (shown >= lineLimit) { truncated = true; break; }
260
+ const subject = (showFileHeader || diffs.length > 1)
261
+ ? ''
262
+ : `${[diff.relative_path || diff.path || 'file', diffDelta(diff)].filter(Boolean).join(' ')} · `;
263
+ out.push(`${indent}${paint.text.dim(`${subject}diff redacted for sensitive config`)}`);
264
+ shown++;
265
+ continue;
266
+ }
267
+
253
268
  for (const hunk of diff.hunks || []) {
254
- if (shown >= maxLines) { truncated = true; break; }
269
+ if (shown >= lineLimit) { truncated = true; break; }
255
270
  out.push(`${indent}${paint.text.dim(`@@ -${hunk.old_start},${hunk.old_count} +${hunk.new_start},${hunk.new_count} @@`)}`);
256
271
  shown++;
257
272
 
258
273
  for (const line of hunk.lines || []) {
259
- if (shown >= maxLines) { truncated = true; break; }
274
+ if (shown >= lineLimit) { truncated = true; break; }
260
275
  out.push(`${indent}${paintDiffLine(line, lineBudget)}`);
261
276
  shown++;
262
277
  }
@@ -265,7 +280,7 @@ export function formatCompactFileDiff(result, {
265
280
  if (truncated) break;
266
281
  }
267
282
 
268
- if (diffs.length > maxFiles) truncated = true;
283
+ if (diffs.length > fileLimit) truncated = true;
269
284
  if (truncated) out.push(`${indent}${paint.text.dim('… diff preview truncated; use /last to expand')}`);
270
285
  return out.join('\n');
271
286
  }
@@ -274,9 +289,112 @@ function fileDiffs(result) {
274
289
  if (!result) return [];
275
290
  if (Array.isArray(result.file_diffs)) return result.file_diffs;
276
291
  if (result.file_diff) return [result.file_diff];
292
+ if (result.type === 'file_diff' || result.hunks || result.unified) return [result];
277
293
  return [];
278
294
  }
279
295
 
296
+ function normalizeFileDiff(diff) {
297
+ if (!diff) return null;
298
+ return {
299
+ ...diff,
300
+ hunks: normalizeHunks(diff),
301
+ };
302
+ }
303
+
304
+ function normalizeHunks(diff) {
305
+ if (Array.isArray(diff?.hunks) && diff.hunks.length) {
306
+ return diff.hunks.map(normalizeHunk).filter(hunk => hunk.lines.length);
307
+ }
308
+ if (diff?.unified) return parseUnifiedHunks(diff.unified);
309
+ return [];
310
+ }
311
+
312
+ function normalizeHunk(hunk = {}) {
313
+ const lines = Array.isArray(hunk.lines)
314
+ ? hunk.lines.map(normalizeDiffLine).filter(Boolean)
315
+ : typeof hunk.body === 'string'
316
+ ? parseDiffBody(hunk.body)
317
+ : [];
318
+ const oldCount = hunk.old_count ?? hunk.old_lines ?? hunk.oldCount ?? countDiffLines(lines, 'old');
319
+ const newCount = hunk.new_count ?? hunk.new_lines ?? hunk.newCount ?? countDiffLines(lines, 'new');
320
+ return {
321
+ ...hunk,
322
+ old_start: hunk.old_start ?? hunk.oldStart ?? 1,
323
+ old_count: oldCount,
324
+ new_start: hunk.new_start ?? hunk.newStart ?? 1,
325
+ new_count: newCount,
326
+ lines,
327
+ };
328
+ }
329
+
330
+ function normalizeDiffLine(line) {
331
+ if (typeof line === 'string') return parseUnifiedLine(line);
332
+ if (!line || typeof line !== 'object') return null;
333
+ const rawType = String(line.type || line.kind || '').toLowerCase();
334
+ const text = String(line.text ?? line.content ?? line.value ?? '');
335
+ if (rawType === 'add' || rawType === 'added' || rawType === '+') return { ...line, type: 'add', text };
336
+ if (rawType === 'remove' || rawType === 'removed' || rawType === 'delete' || rawType === '-') return { ...line, type: 'remove', text };
337
+ if (rawType === 'context' || rawType === 'same' || rawType === ' ') return { ...line, type: 'context', text };
338
+ return parseUnifiedLine(text);
339
+ }
340
+
341
+ function parseDiffBody(body) {
342
+ return String(body || '')
343
+ .replace(/\r\n?/g, '\n')
344
+ .split('\n')
345
+ .filter(line => line && !line.startsWith('@@'))
346
+ .map(parseUnifiedLine)
347
+ .filter(Boolean);
348
+ }
349
+
350
+ function parseUnifiedHunks(unified) {
351
+ const hunks = [];
352
+ let current = null;
353
+ for (const raw of String(unified || '').replace(/\r\n?/g, '\n').split('\n')) {
354
+ if (raw.startsWith('--- ') || raw.startsWith('+++ ')) continue;
355
+ const header = raw.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/);
356
+ if (header) {
357
+ current = {
358
+ old_start: Number(header[1]) || 1,
359
+ old_count: Number(header[2] || 1),
360
+ new_start: Number(header[3]) || 1,
361
+ new_count: Number(header[4] || 1),
362
+ lines: [],
363
+ };
364
+ hunks.push(current);
365
+ continue;
366
+ }
367
+ if (!current) {
368
+ if (!raw || (!raw.startsWith('+') && !raw.startsWith('-') && !raw.startsWith(' '))) continue;
369
+ current = { old_start: 1, old_count: 0, new_start: 1, new_count: 0, lines: [] };
370
+ hunks.push(current);
371
+ }
372
+ const line = parseUnifiedLine(raw);
373
+ if (line) current.lines.push(line);
374
+ }
375
+ for (const hunk of hunks) {
376
+ if (!hunk.old_count) hunk.old_count = countDiffLines(hunk.lines, 'old');
377
+ if (!hunk.new_count) hunk.new_count = countDiffLines(hunk.lines, 'new');
378
+ }
379
+ return hunks.filter(hunk => hunk.lines.length);
380
+ }
381
+
382
+ function parseUnifiedLine(raw) {
383
+ const line = String(raw ?? '');
384
+ if (!line && raw !== '') return null;
385
+ if (line.startsWith('+') && !line.startsWith('+++')) return { type: 'add', text: line.slice(1) };
386
+ if (line.startsWith('-') && !line.startsWith('---')) return { type: 'remove', text: line.slice(1) };
387
+ if (line.startsWith(' ')) return { type: 'context', text: line.slice(1) };
388
+ return { type: 'context', text: line };
389
+ }
390
+
391
+ function countDiffLines(lines, side) {
392
+ return lines.filter(line => {
393
+ if (side === 'old') return line.type !== 'add';
394
+ return line.type !== 'remove';
395
+ }).length;
396
+ }
397
+
280
398
  function paintDiffLine(line, maxWidth) {
281
399
  const text = truncatePlain(String(line?.text ?? ''), Math.max(20, maxWidth - 2));
282
400
  if (line?.type === 'add') return paint.state.success(`+ ${text}`);
@@ -365,8 +483,19 @@ export function formatCardHead(tool, args, opts = {}) {
365
483
  if (tool === 'shell') {
366
484
  const profile = shellCommandProfile(toolDisplaySummary(tool, args || {}, { cwd }), { cwd });
367
485
  if (profile.compact) {
368
- const argsTruncated = truncateMiddle(argsText, budget);
369
486
  const head = `${indent}${leadText}`;
487
+ if (profile.preview) {
488
+ const fullArgs = compactShellProfile(profile);
489
+ if (visibleWidth(fullArgs) <= budget) return `${head} ${fullArgs}`;
490
+ const previewTail = `${paint.text.dim(' · preview:')} ${paint.text.primary(profile.preview)}`;
491
+ const baseArgs = compactShellProfile(profile, { includePreview: false, includeDetails: false });
492
+ const baseBudget = budget - visibleWidth(previewTail);
493
+ if (baseBudget >= 12) {
494
+ const baseTruncated = truncateEndVisible(baseArgs, baseBudget);
495
+ return `${head} ${baseTruncated}${previewTail}`;
496
+ }
497
+ }
498
+ const argsTruncated = truncateMiddle(argsText, budget);
370
499
  return argsTruncated ? `${head} ${argsTruncated}` : head;
371
500
  }
372
501
  }
@@ -394,10 +523,15 @@ function formatHeadLead(tool, label) {
394
523
  return `${paint.text.dim('• shell ·')} ${paintLabel(tool, label)}`;
395
524
  }
396
525
 
397
- function compactShellProfile(profile) {
398
- const parts = [`${paint.text.dim('$')} ${paint.text.primary(profile.summary)}`];
526
+ function compactShellProfile(profile, { includePreview = true, includeDetails = true } = {}) {
527
+ const previewSuffix = profile.preview ? ` · preview: ${profile.preview}` : '';
528
+ const summary = previewSuffix && profile.summary.endsWith(previewSuffix)
529
+ ? profile.summary.slice(0, -previewSuffix.length)
530
+ : profile.summary;
531
+ const parts = [`${paint.text.dim('$')} ${paint.text.primary(summary)}`];
399
532
  if (profile.cwdLabel) parts.push(`${paint.text.dim('in')} ${paint.brand.data(profile.cwdLabel)}`);
400
- parts.push(paint.text.dim(profile.detailHint || 'details: F2 or /last'));
533
+ if (includeDetails) parts.push(paint.text.dim(profile.detailHint || 'details: F2 or /last'));
534
+ if (includePreview && profile.preview) parts.push(`${paint.text.dim('preview:')} ${paint.text.primary(profile.preview)}`);
401
535
  return parts.join(' · ');
402
536
  }
403
537
 
@@ -478,6 +612,15 @@ function truncateMiddle(text, max) {
478
612
  return paint.text.muted(`${head}…${tail}`);
479
613
  }
480
614
 
615
+ function truncateEndVisible(text, max) {
616
+ if (!text) return '';
617
+ if (visibleWidth(text) <= max) return text;
618
+ const plain = text.replace(/\x1b\[[0-9;]*m/g, '');
619
+ const limit = Math.max(1, Math.floor(max));
620
+ if (limit <= 1) return '';
621
+ return paint.text.muted(`${plain.slice(0, limit - 1)}…`);
622
+ }
623
+
481
624
  function wrapCommand(command, width) {
482
625
  const text = String(command || '');
483
626
  if (!text) return ['(empty command)'];
@@ -14,6 +14,7 @@
14
14
  import { paint } from './palette.mjs';
15
15
  import { icon, toolFamily } from './icons.mjs';
16
16
  import { shellCommandProfile, toolDisplayLabel, toolDisplaySummary } from '../terminal/tool-display.mjs';
17
+ import { isSensitiveConfigPath } from '../core/safety.mjs';
17
18
 
18
19
  const MAX_DETAIL_LINES = 60;
19
20
  const MAX_SHELL_DETAIL_LINES = 220;
@@ -136,6 +137,12 @@ function safeDetailArgs(tool, args) {
136
137
  }
137
138
  if (tool === 'edit_file') {
138
139
  const next = { ...args };
140
+ if (isSensitiveConfigPath(next.file_path || next.path)) {
141
+ for (const key of ['search', 'replace', 'old_string', 'new_string']) {
142
+ if (typeof next[key] === 'string') next[key] = '[redacted]';
143
+ }
144
+ return next;
145
+ }
139
146
  for (const key of ['search', 'replace', 'old_string', 'new_string']) {
140
147
  if (typeof next[key] === 'string' && next[key].length > 80) {
141
148
  next[key] = `[${next[key].split('\n').length} lines omitted]`;
@@ -213,7 +220,16 @@ function detailListFiles(card) {
213
220
  // ── Write / edit ────────────────────────────────────────────────────────
214
221
 
215
222
  function detailEditFile(card) {
216
- const diff = card.result?.file_diff?.unified || card.result?.diff || card.result?.patch || card.result?.output;
223
+ const redacted = redactedFileDiff(card.result?.file_diff)
224
+ || (isSensitiveConfigPath(card.args?.file_path || card.args?.path)
225
+ ? sensitiveFallbackDiff(card)
226
+ : null);
227
+ if (redacted) return renderRedactedDiff(redacted);
228
+
229
+ const diff = unifiedForFileDiff(card.result?.file_diff)
230
+ || card.result?.diff
231
+ || card.result?.patch
232
+ || card.result?.output;
217
233
  if (diff) return renderDiff(String(diff));
218
234
 
219
235
  const before = card.args?.search;
@@ -228,7 +244,13 @@ function detailEditFile(card) {
228
244
  }
229
245
 
230
246
  function detailWriteFile(card) {
231
- const diff = card.result?.file_diff?.unified || card.result?.diff;
247
+ const redacted = redactedFileDiff(card.result?.file_diff)
248
+ || (isSensitiveConfigPath(card.args?.file_path || card.args?.path)
249
+ ? sensitiveFallbackDiff(card)
250
+ : null);
251
+ if (redacted) return renderRedactedDiff(redacted);
252
+
253
+ const diff = unifiedForFileDiff(card.result?.file_diff) || card.result?.diff;
232
254
  if (diff) return renderDiff(String(diff));
233
255
  const content = card.args?.content;
234
256
  if (!content) return paint.text.dim(' (no content)');
@@ -238,7 +260,12 @@ function detailWriteFile(card) {
238
260
  function detailWriteProject(card) {
239
261
  const diffs = card.result?.file_diffs || [];
240
262
  if (diffs.length) {
241
- return renderDiff(diffs.map(diff => diff.unified).filter(Boolean).join('\n'));
263
+ return diffs.map(diff => {
264
+ const redacted = redactedFileDiff(diff);
265
+ if (redacted) return renderRedactedDiff(redacted);
266
+ const unified = unifiedForFileDiff(diff);
267
+ return unified ? renderDiff(unified) : '';
268
+ }).filter(Boolean).join('\n');
242
269
  }
243
270
  const files = card.args?.files || [];
244
271
  if (!files.length) return paint.text.dim(' (no files)');
@@ -249,6 +276,26 @@ function detailWriteProject(card) {
249
276
  }).join('\n');
250
277
  }
251
278
 
279
+ function redactedFileDiff(diff) {
280
+ return diff?.redacted ? diff : null;
281
+ }
282
+
283
+ function sensitiveFallbackDiff(card) {
284
+ return {
285
+ relative_path: card.args?.file_path || card.args?.path || 'sensitive config',
286
+ lines_added: card.result?.lines_added,
287
+ lines_removed: card.result?.lines_removed,
288
+ redacted: true,
289
+ };
290
+ }
291
+
292
+ function renderRedactedDiff(diff = {}) {
293
+ const file = diff.relative_path || diff.path || 'sensitive config';
294
+ const add = diff.lines_added ?? 0;
295
+ const rem = diff.lines_removed ?? 0;
296
+ return ` ${paint.brand.primary(file)} ${paint.text.dim(`+${add} −${rem}`)}\n ${paint.text.dim('diff redacted for sensitive config')}`;
297
+ }
298
+
252
299
  function detailDeleteFile(card) {
253
300
  const p = card.args?.file_path || card.args?.path || '';
254
301
  return ` ${paint.state.danger('✗')} ${paint.text.primary(p)}`;
@@ -405,6 +452,52 @@ function renderDiff(text) {
405
452
  }).join('\n');
406
453
  }
407
454
 
455
+ function unifiedForFileDiff(diff) {
456
+ if (!diff) return '';
457
+ if (diff.unified) return String(diff.unified);
458
+ const hunks = Array.isArray(diff.hunks) ? diff.hunks : [];
459
+ if (!hunks.length) return '';
460
+ const file = diff.relative_path || diff.path || 'file';
461
+ const out = [`--- a/${file}`, `+++ b/${file}`];
462
+ for (const hunk of hunks) {
463
+ const lines = hunkLines(hunk);
464
+ if (!lines.length) continue;
465
+ const oldCount = hunk.old_count ?? hunk.old_lines ?? countLinesForSide(lines, 'old');
466
+ const newCount = hunk.new_count ?? hunk.new_lines ?? countLinesForSide(lines, 'new');
467
+ out.push(`@@ -${hunk.old_start ?? 1},${oldCount} +${hunk.new_start ?? 1},${newCount} @@`);
468
+ for (const line of lines) out.push(toUnifiedLine(line));
469
+ }
470
+ return out.length > 2 ? out.join('\n') : '';
471
+ }
472
+
473
+ function hunkLines(hunk = {}) {
474
+ if (Array.isArray(hunk.lines)) return hunk.lines;
475
+ if (typeof hunk.body !== 'string') return [];
476
+ return hunk.body.replace(/\r\n?/g, '\n').split('\n').filter(line => line && !line.startsWith('@@'));
477
+ }
478
+
479
+ function toUnifiedLine(line) {
480
+ if (typeof line === 'string') {
481
+ if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) return line;
482
+ return ` ${line}`;
483
+ }
484
+ const type = String(line?.type || '').toLowerCase();
485
+ const text = String(line?.text ?? line?.content ?? '');
486
+ if (type === 'add' || type === 'added') return `+${text}`;
487
+ if (type === 'remove' || type === 'removed' || type === 'delete') return `-${text}`;
488
+ return ` ${text}`;
489
+ }
490
+
491
+ function countLinesForSide(lines, side) {
492
+ return lines.filter(line => {
493
+ const type = typeof line === 'string'
494
+ ? (line.startsWith('+') ? 'add' : line.startsWith('-') ? 'remove' : 'context')
495
+ : String(line?.type || 'context').toLowerCase();
496
+ if (side === 'old') return type !== 'add' && type !== 'added';
497
+ return type !== 'remove' && type !== 'removed' && type !== 'delete';
498
+ }).length;
499
+ }
500
+
408
501
  function formatDuration(ms) {
409
502
  if (ms < 1000) return `${Math.round(ms)}ms`;
410
503
  return `${(ms / 1000).toFixed(1)}s`;