@agi-fans/dsh-tui 0.23.1 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/lib/CHANGELOG.md +37 -2
  2. package/lib/chrome/theme.js +1 -1
  3. package/lib/chrome/theme.js.map +1 -1
  4. package/lib/chrome/tool-renderers.d.ts +2 -0
  5. package/lib/chrome/tool-renderers.js +5 -0
  6. package/lib/chrome/tool-renderers.js.map +1 -1
  7. package/lib/commands/diff.js +58 -33
  8. package/lib/commands/diff.js.map +1 -1
  9. package/lib/definition.d.ts +7 -0
  10. package/lib/input/editor.d.ts +14 -0
  11. package/lib/input/editor.js +55 -5
  12. package/lib/input/editor.js.map +1 -1
  13. package/lib/input/image-paste.d.ts +20 -3
  14. package/lib/input/image-paste.js +67 -14
  15. package/lib/input/image-paste.js.map +1 -1
  16. package/lib/locale/zh.d.ts +27 -6
  17. package/lib/locale/zh.js +27 -6
  18. package/lib/locale/zh.js.map +1 -1
  19. package/lib/runtime/account-settings.d.ts +1 -0
  20. package/lib/runtime/account-settings.js +2 -2
  21. package/lib/runtime/account-settings.js.map +1 -1
  22. package/lib/runtime/composer-images.d.ts +11 -11
  23. package/lib/runtime/composer-images.js +105 -96
  24. package/lib/runtime/composer-images.js.map +1 -1
  25. package/lib/runtime/model-settings.d.ts +2 -0
  26. package/lib/runtime/model-settings.js +2 -2
  27. package/lib/runtime/model-settings.js.map +1 -1
  28. package/lib/runtime/plugin-settings.d.ts +1 -0
  29. package/lib/runtime/plugin-settings.js +6 -6
  30. package/lib/runtime/plugin-settings.js.map +1 -1
  31. package/lib/runtime/provider-local.d.ts +3 -0
  32. package/lib/runtime/provider-local.js +171 -77
  33. package/lib/runtime/provider-local.js.map +1 -1
  34. package/lib/runtime/runner.js +3 -3
  35. package/lib/runtime/runner.js.map +1 -1
  36. package/lib/runtime/settings-preferences.js +1 -1
  37. package/lib/runtime/settings-preferences.js.map +1 -1
  38. package/lib/session/composer-drafts.js +25 -1
  39. package/lib/session/composer-drafts.js.map +1 -1
  40. package/lib/session/feature-toggles.js +2 -2
  41. package/lib/session/feature-toggles.js.map +1 -1
  42. package/lib/session/session-controller.js +2 -3
  43. package/lib/session/session-controller.js.map +1 -1
  44. package/lib/session/tui-settings.d.ts +2 -0
  45. package/lib/session/tui-settings.js +1 -0
  46. package/lib/session/tui-settings.js.map +1 -1
  47. package/lib/views/settings-list.d.ts +5 -0
  48. package/lib/views/settings-list.js +46 -10
  49. package/lib/views/settings-list.js.map +1 -1
  50. package/lib/views/transcript-render.d.ts +2 -0
  51. package/lib/views/transcript-render.js +41 -27
  52. package/lib/views/transcript-render.js.map +1 -1
  53. package/package.json +1 -1
@@ -87,7 +87,7 @@ import { edgeIndex, listNavigation, navigateIndex } from "../views/list-navigati
87
87
  import { refreshProjectContext, resolveGitStatusFiles, resolveProjectContext } from "../session/project-context.js";
88
88
  import { modelRouteReady } from "../session/model-readiness.js";
89
89
  import { formatEssentialHotkeysText, formatHotkeysText, keysForAction } from "../views/hotkeys.js";
90
- import { imagePathCandidates, splitComposerAttachmentMarkers, stripComposerImageMarkers, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
90
+ import { imagePathCandidates, splitComposerAttachmentMarkers, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
91
91
  import { APP_DISPLAY_NAME, APP_NAME, APP_VERSION } from "../session/package-metadata.js";
92
92
  const DOUBLE_CTRL_C_MS = 500;
93
93
  /**
@@ -316,6 +316,7 @@ export class LocalTui {
316
316
  #paste = false;
317
317
  #pasteBuf = '';
318
318
  #pasteInFlight = 0;
319
+ #pasteAbort;
319
320
  #deferredPasteEvents = [];
320
321
  #images = new ComposerImages({
321
322
  editor: this.#editor,
@@ -414,6 +415,9 @@ export class LocalTui {
414
415
  #mermaidMode = 'auto';
415
416
  /** Transcript collapses every thought to one line; Ctrl+T flips it. */
416
417
  #hideThinking = true;
418
+ #showFileChanges = true;
419
+ #workspaceReview;
420
+ #workspacePress;
417
421
  #mouseInteraction = 'auto';
418
422
  #tmuxMouseEnabled;
419
423
  #disclosurePress;
@@ -598,6 +602,7 @@ export class LocalTui {
598
602
  // live window so it stays anchored at the bottom.
599
603
  this.#selection.clear();
600
604
  this.#mouseActionPress = undefined;
605
+ this.#workspacePress = undefined;
601
606
  this.#jumpPress = undefined;
602
607
  this.#disclosurePress = undefined;
603
608
  const repaint = () => {
@@ -859,6 +864,7 @@ export class LocalTui {
859
864
  && this.#inspected?.writable === inspected?.writable)
860
865
  return;
861
866
  if (this.#inspected?.id !== inspected?.id) {
867
+ this.#workspacePress = undefined;
862
868
  this.#fileReviewAbort?.abort();
863
869
  this.#closeFileBrowser();
864
870
  }
@@ -884,8 +890,10 @@ export class LocalTui {
884
890
  this.#render();
885
891
  }
886
892
  async reviewFiles(request) {
893
+ this.#cancelPaste();
887
894
  if (!this.#tty || this.#disposed || request.signal.aborted)
888
895
  return;
896
+ this.#workspacePress = undefined;
889
897
  this.#flushPasteBurst(true);
890
898
  this.#pasteBurst.clear();
891
899
  this.#finishPrompt(null);
@@ -1220,7 +1228,8 @@ export class LocalTui {
1220
1228
  const browserState = promptBrowserState(request);
1221
1229
  request = localizePrompt(request, resolveUiLanguage(this.#uiLanguage));
1222
1230
  // Background approvals take the terminal from a user-opened file reader.
1223
- if (this.#fileReviewAbort !== undefined && request.signal !== this.#fileReviewAbort.signal)
1231
+ if (this.#fileReviewAbort !== undefined && request.signal !== this.#fileReviewAbort.signal
1232
+ && request.signal !== this.#fileBrowser?.request.signal)
1224
1233
  this.#fileReviewAbort.abort();
1225
1234
  if (this.#disposed || request.signal?.aborted === true)
1226
1235
  return Promise.resolve(null);
@@ -1237,6 +1246,7 @@ export class LocalTui {
1237
1246
  this.notice(error instanceof Error ? error.message : String(error), { level: 'error' });
1238
1247
  }
1239
1248
  }
1249
+ this.#cancelPaste();
1240
1250
  this.#flushPasteBurst(true);
1241
1251
  this.#pasteBurst.clear();
1242
1252
  if (request.notify !== false) {
@@ -1251,6 +1261,7 @@ export class LocalTui {
1251
1261
  this.#promptChoicePress = undefined;
1252
1262
  this.#disclosurePress = undefined;
1253
1263
  this.#mouseActionPress = undefined;
1264
+ this.#workspacePress = undefined;
1254
1265
  this.#jumpPress = undefined;
1255
1266
  this.#wheelGesture = IDLE_WHEEL;
1256
1267
  const savedInput = this.#editor.snapshot();
@@ -1341,6 +1352,7 @@ export class LocalTui {
1341
1352
  });
1342
1353
  }
1343
1354
  replaceSession(events, presentations, status = 'idle', reason = 'open') {
1355
+ this.#workspacePress = undefined;
1344
1356
  if (reason !== 'refresh') {
1345
1357
  this.#fileReviewAbort?.abort();
1346
1358
  this.#closeFileBrowser();
@@ -1372,6 +1384,7 @@ export class LocalTui {
1372
1384
  }
1373
1385
  setSession(info) {
1374
1386
  if (info.id !== this.#sessionId) {
1387
+ this.#workspacePress = undefined;
1375
1388
  this.#fileReviewAbort?.abort();
1376
1389
  this.#closeFileBrowser();
1377
1390
  }
@@ -1456,6 +1469,7 @@ export class LocalTui {
1456
1469
  mathMode: this.#mathMode,
1457
1470
  mermaidMode: this.#mermaidMode,
1458
1471
  hideThinking: this.#hideThinking,
1472
+ showFileChanges: this.#showFileChanges,
1459
1473
  editor: this.#externalEditor,
1460
1474
  terminalTitle: this.#terminalTitle,
1461
1475
  terminalState: this.#terminalState,
@@ -1596,8 +1610,8 @@ export class LocalTui {
1596
1610
  this.#reportDraftError('restore', error);
1597
1611
  }
1598
1612
  if (existing !== undefined) {
1599
- images.replace({ text: existing.editor.text, images: existing.images });
1600
1613
  editor.restore(existing.editor);
1614
+ images.restoreSnapshot(existing.images);
1601
1615
  files.restoreSnapshot(existing.files);
1602
1616
  }
1603
1617
  images.restore(submission, files.adopt(submission.files ?? []));
@@ -1743,6 +1757,15 @@ export class LocalTui {
1743
1757
  return pending.queued.length === 0 && pending.sending.size === 0
1744
1758
  && this.#state.nextTurnInbox.length === 0 && !this.#state.nextStepInbox.some(message => message.source.kind === 'user');
1745
1759
  }
1760
+ setWorkspaceReviewHandler(handler) {
1761
+ this.#workspaceReview = handler;
1762
+ return () => {
1763
+ if (this.#workspaceReview === handler) {
1764
+ this.#workspaceReview = undefined;
1765
+ this.#workspacePress = undefined;
1766
+ }
1767
+ };
1768
+ }
1746
1769
  setGoalHandler(handler) {
1747
1770
  this.#goalHandler = handler;
1748
1771
  this.#syncSettingsActions();
@@ -1822,6 +1845,7 @@ export class LocalTui {
1822
1845
  this.#flushDraft();
1823
1846
  this.#pendingInputs.flush(this.#pendingScope());
1824
1847
  this.#disposed = true;
1848
+ this.#cancelPaste();
1825
1849
  this.#imagePreviewAbort?.abort();
1826
1850
  this.#fileReviewAbort?.abort();
1827
1851
  this.#closeFileBrowser();
@@ -2029,11 +2053,11 @@ export class LocalTui {
2029
2053
  if (this.#historyIndex > 0 && this.#historyDraft !== undefined)
2030
2054
  return this.#historyDraft;
2031
2055
  const editor = this.#prompt?.savedInput ?? this.#editor.snapshot();
2032
- return { editor, images: this.#images.copies(), files: this.#files.snapshot(editor.text), literal: this.#literalInput };
2056
+ return { editor, images: this.#images.copies(editor), files: this.#files.snapshot(editor.text), literal: this.#literalInput };
2033
2057
  }
2034
2058
  #restoreDraft(draft) {
2035
- this.#images.replace({ text: draft.editor.text, images: draft.images });
2036
2059
  this.#editor.restore(draft.editor);
2060
+ this.#images.restoreSnapshot(draft.images);
2037
2061
  this.#files.restoreSnapshot(draft.files);
2038
2062
  this.#literalInput = draft.literal;
2039
2063
  }
@@ -2097,6 +2121,7 @@ export class LocalTui {
2097
2121
  #switchDraft(id) {
2098
2122
  if (id === this.#draftScope)
2099
2123
  return;
2124
+ this.#cancelPaste();
2100
2125
  this.#goalAbort?.abort();
2101
2126
  this.#goalAbort = undefined;
2102
2127
  this.#queueHotkeyAbort?.abort();
@@ -2161,6 +2186,7 @@ export class LocalTui {
2161
2186
  mathMode: this.#mathMode,
2162
2187
  mermaidMode: this.#mermaidMode,
2163
2188
  hideThinking: true,
2189
+ showFileChanges: this.#showFileChanges,
2164
2190
  height: this.#term.height(),
2165
2191
  model: this.#model,
2166
2192
  ...(this.#reasoningEffort === undefined ? {} : { reasoningEffort: this.#reasoningEffort }),
@@ -2286,7 +2312,8 @@ export class LocalTui {
2286
2312
  return Math.max(1, this.#scrollBudget > 2 ? this.#scrollBudget - 2 : 1);
2287
2313
  }
2288
2314
  #scrollBy(delta) {
2289
- if (this.#follow && delta > 0)
2315
+ this.#workspacePress = undefined;
2316
+ if (this.#follow && (delta > 0 || this.#maxStart === 0))
2290
2317
  return;
2291
2318
  if (delta === 0 && this.#maxStart === 0)
2292
2319
  return;
@@ -2349,6 +2376,7 @@ export class LocalTui {
2349
2376
  enabled = enabled && this.#mouseInteraction !== 'native'
2350
2377
  && (this.#mouseInteraction === 'tui' || this.#tmuxMouseEnabled !== false);
2351
2378
  if (!enabled) {
2379
+ this.#workspacePress = undefined;
2352
2380
  this.#mouseActionPress = undefined;
2353
2381
  this.#selection.clear();
2354
2382
  this.#jumpPress = undefined;
@@ -2486,14 +2514,26 @@ export class LocalTui {
2486
2514
  for (const event of this.#pasteBurst.flush(Date.now(), force))
2487
2515
  this.#deliverPasteEmission(event);
2488
2516
  }
2517
+ #cancelPaste() {
2518
+ this.#pasteAbort?.abort();
2519
+ this.#pasteAbort = undefined;
2520
+ this.#pasteInFlight = 0;
2521
+ this.#deferredPasteEvents = [];
2522
+ this.#paste = false;
2523
+ this.#pasteBuf = '';
2524
+ }
2489
2525
  #startAsyncPaste(operation) {
2490
- this.#pasteInFlight += 1;
2491
- void operation.catch((error) => {
2492
- this.notice('Paste failed: ' + (error instanceof Error ? error.message : String(error)), { level: 'error' });
2526
+ const abort = new AbortController();
2527
+ this.#pasteAbort = abort;
2528
+ this.#pasteInFlight = 1;
2529
+ void operation(abort.signal).catch((error) => {
2530
+ if (!abort.signal.aborted)
2531
+ this.notice('Paste failed: ' + (error instanceof Error ? error.message : String(error)), { level: 'error' });
2493
2532
  }).finally(() => {
2494
- this.#pasteInFlight = Math.max(0, this.#pasteInFlight - 1);
2495
- if (this.#pasteInFlight !== 0 || this.#deferredPasteEvents.length === 0)
2533
+ if (this.#pasteAbort !== abort)
2496
2534
  return;
2535
+ this.#pasteAbort = undefined;
2536
+ this.#pasteInFlight = 0;
2497
2537
  const deferred = this.#deferredPasteEvents.splice(0);
2498
2538
  for (let index = 0; index < deferred.length; index += 1) {
2499
2539
  this.#dispatch(deferred[index]);
@@ -2504,7 +2544,10 @@ export class LocalTui {
2504
2544
  }
2505
2545
  });
2506
2546
  }
2507
- async #acceptPastedText(text) {
2547
+ async #acceptPastedText(text, signal) {
2548
+ if (signal.aborted)
2549
+ return;
2550
+ text = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
2508
2551
  if (this.#fileBrowser !== null) {
2509
2552
  this.#applyFileBrowserInput({ type: 'text', value: text });
2510
2553
  return;
@@ -2532,42 +2575,50 @@ export class LocalTui {
2532
2575
  }
2533
2576
  const paths = imagePathCandidates(text);
2534
2577
  if (paths.length > 0) {
2535
- const images = await Promise.all(paths.map(path => this.#readImagePath(path)));
2536
- if (images.every((image) => image !== null)) {
2537
- let inserted = false;
2538
- for (const image of images) {
2539
- if (await this.#images.admit(image)) {
2540
- this.#images.insert(image);
2541
- inserted = true;
2542
- }
2578
+ const images = await Promise.all(paths.map(async (path) => {
2579
+ try {
2580
+ return await this.#readImagePath(path);
2543
2581
  }
2544
- if (inserted) {
2545
- this.#refreshAutocomplete();
2546
- this.#render();
2582
+ catch {
2583
+ return null;
2547
2584
  }
2585
+ }));
2586
+ if (signal.aborted)
2548
2587
  return;
2588
+ let inserted = false;
2589
+ for (const [index, image] of images.entries()) {
2590
+ if (image === null) {
2591
+ this.notice(uiText(resolveUiLanguage(this.#uiLanguage), 'Could not read image {path}', { path: paths[index] }), { level: 'error' });
2592
+ continue;
2593
+ }
2594
+ const admitted = await this.#images.admit(image, signal);
2595
+ if (signal.aborted)
2596
+ return;
2597
+ if (admitted) {
2598
+ this.#images.insert(image);
2599
+ inserted = true;
2600
+ }
2549
2601
  }
2550
- // Screenshot tools sometimes paste a transient cache path and remove
2551
- // it before Node reads it. OMP falls back to the still-live clipboard
2552
- // image instead of leaking that stale path into the prompt.
2553
- const clipboardImage = await this.#readClipboardImage();
2554
- if (clipboardImage !== null) {
2555
- this.#images.insert(clipboardImage);
2602
+ if (inserted)
2556
2603
  this.#refreshAutocomplete();
2557
- this.#render();
2558
- return;
2559
- }
2604
+ this.#render();
2605
+ return;
2560
2606
  }
2561
2607
  this.#literalInput ||= this.#editor.cursor === 0 && text.startsWith('!');
2562
2608
  this.#editor.paste(text);
2563
2609
  this.#refreshAutocomplete();
2564
2610
  this.#render();
2565
2611
  }
2566
- async #pasteClipboard() {
2567
- if (this.#search === null && this.#settings === null) {
2612
+ async #pasteClipboard(signal) {
2613
+ if (this.#prompt === null && this.#search === null && this.#settings === null) {
2568
2614
  const image = await this.#readClipboardImage();
2615
+ if (signal.aborted)
2616
+ return;
2569
2617
  if (image !== null) {
2570
- if (await this.#images.admit(image)) {
2618
+ const admitted = await this.#images.admit(image, signal);
2619
+ if (signal.aborted)
2620
+ return;
2621
+ if (admitted) {
2571
2622
  this.#images.insert(image);
2572
2623
  this.#refreshAutocomplete();
2573
2624
  this.#render();
@@ -2575,29 +2626,17 @@ export class LocalTui {
2575
2626
  return;
2576
2627
  }
2577
2628
  const files = await this.#readClipboardFiles();
2629
+ if (signal.aborted)
2630
+ return;
2578
2631
  const imagePaths = files.filter(path => imagePathCandidates(path).length === 1);
2579
2632
  if (imagePaths.length > 0) {
2580
- const images = (await Promise.all(imagePaths.map(path => this.#readImagePath(path))))
2581
- .filter((candidate) => candidate !== null);
2582
- if (images.length > 0) {
2583
- let inserted = false;
2584
- for (const candidate of images) {
2585
- if (await this.#images.admit(candidate)) {
2586
- this.#images.insert(candidate);
2587
- inserted = true;
2588
- }
2589
- }
2590
- if (inserted) {
2591
- this.#refreshAutocomplete();
2592
- this.#render();
2593
- }
2594
- return;
2595
- }
2633
+ await this.#acceptPastedText(imagePaths.join('\n'), signal);
2634
+ return;
2596
2635
  }
2597
2636
  }
2598
2637
  const text = await this.#readClipboard();
2599
- if (text !== '')
2600
- await this.#acceptPastedText(text);
2638
+ if (text !== '' && !signal.aborted)
2639
+ await this.#acceptPastedText(text, signal);
2601
2640
  }
2602
2641
  /** Resolve one bounded tmux probe without overwriting an explicit preference. */
2603
2642
  setTmuxMouseEnabled(enabled) {
@@ -2689,6 +2728,18 @@ export class LocalTui {
2689
2728
  return { row, column: tool.actionColumn, width: tool.key.startsWith('output:') ? 6 : 5, key: tool.key, action: 'tool', block: tool.block };
2690
2729
  return undefined;
2691
2730
  }
2731
+ #fileRegionAt(row) {
2732
+ const document = this.#renderer.documentRowAt(row);
2733
+ const frame = this.#fileActionFrame;
2734
+ const source = frame?.documentRows;
2735
+ const region = document === undefined ? undefined : frame?.transcript?.fileRegions?.find(region => document >= region.start && document < region.end);
2736
+ const expected = document === undefined || source === undefined || source === null ? undefined
2737
+ : frame?.lines[source.frameStart + document - source.documentStart];
2738
+ const actual = this.#renderer.viewport()[row];
2739
+ // Committed native history can differ from the live document after a reflow.
2740
+ return expected !== undefined && actual !== undefined
2741
+ && stripAnsi(sanitizeDisplayLine(expected)).trimEnd() === stripAnsi(actual).trimEnd() ? region : undefined;
2742
+ }
2692
2743
  #handleMouse(event) {
2693
2744
  if (!this.#mouseTracking || this.#paste || this.#pasteInFlight > 0)
2694
2745
  return;
@@ -2765,6 +2816,7 @@ export class LocalTui {
2765
2816
  const target = this.#mouseActionAt(event.row, event.column);
2766
2817
  if (event.action === 'release') {
2767
2818
  this.#mouseActionPress = undefined;
2819
+ this.#workspacePress = undefined;
2768
2820
  if (event.button === 'left' && !event.shift && target?.key === pressedAction.key && target.row === pressedAction.row && target.column === pressedAction.column) {
2769
2821
  if (target.action === 'copy')
2770
2822
  void this.#copySelection(true);
@@ -2795,21 +2847,14 @@ export class LocalTui {
2795
2847
  }
2796
2848
  const inside = (target) => event.row === target.row && event.column >= target.column && event.column < target.column + target.width;
2797
2849
  if (event.button === 'right') {
2850
+ this.#workspacePress = undefined;
2798
2851
  this.#disclosurePress = undefined;
2799
2852
  if (event.action === 'press') {
2800
2853
  if (this.#selection.active)
2801
2854
  void this.#copySelection(true);
2802
2855
  else {
2803
- const document = this.#renderer.documentRowAt(event.row);
2804
- const frame = this.#fileActionFrame;
2805
- const source = frame?.documentRows;
2806
- const region = document === undefined ? undefined : frame?.transcript?.fileRegions?.find(region => document >= region.start && document < region.end);
2807
- const expected = document === undefined || source === undefined || source === null ? undefined
2808
- : frame?.lines[source.frameStart + document - source.documentStart];
2809
- const actual = this.#renderer.viewport()[event.row];
2810
- // Native-history snapshots may differ from the current document. Do
2811
- // not reinterpret their old text as another file after a reflow.
2812
- if (region !== undefined && expected !== undefined && actual !== undefined && stripAnsi(sanitizeDisplayLine(expected)).trimEnd() === stripAnsi(actual).trimEnd()) {
2856
+ const region = this.#fileRegionAt(event.row);
2857
+ if (region !== undefined) {
2813
2858
  const block = this.#state.blocks[region.block];
2814
2859
  const action = block === undefined ? undefined : blockFileAction(block);
2815
2860
  if (action !== undefined)
@@ -2820,6 +2865,10 @@ export class LocalTui {
2820
2865
  return;
2821
2866
  }
2822
2867
  if (event.action === 'press') {
2868
+ const region = !event.shift && this.#workspaceReview !== undefined ? this.#fileRegionAt(event.row) : undefined;
2869
+ const source = region === undefined ? undefined : this.#state.blocks[region.block];
2870
+ this.#workspacePress = source?.kind === 'workspace' && region !== undefined
2871
+ ? { row: event.row, column: event.column, block: region.block, source, ...(region.file === undefined ? {} : { file: region.file }) } : undefined;
2823
2872
  const disclosure = event.shift ? undefined : this.#disclosureAt(event.row, event.column);
2824
2873
  this.#disclosurePress = disclosure === undefined ? undefined : { target: disclosure, row: event.row, column: event.column };
2825
2874
  const target = this.#jumpToLatest;
@@ -2841,6 +2890,9 @@ export class LocalTui {
2841
2890
  }
2842
2891
  }
2843
2892
  else if (event.action === 'drag') {
2893
+ const pressed = this.#workspacePress;
2894
+ if (pressed !== undefined && (event.shift || event.row !== pressed.row || Math.abs(event.column - pressed.column) > 1))
2895
+ this.#workspacePress = undefined;
2844
2896
  // Motion confined to the empty gutter is click jitter. Once it leaves,
2845
2897
  // selection owns the gesture even if the pointer returns to the arrow.
2846
2898
  if (event.shift || this.#pressedDisclosureAt(event.row, event.column) === undefined) {
@@ -2849,6 +2901,15 @@ export class LocalTui {
2849
2901
  }
2850
2902
  }
2851
2903
  else {
2904
+ const pressed = this.#workspacePress;
2905
+ this.#workspacePress = undefined;
2906
+ const region = pressed === undefined ? undefined : this.#fileRegionAt(event.row);
2907
+ if (!event.shift && pressed !== undefined && event.row === pressed.row && Math.abs(event.column - pressed.column) <= 1
2908
+ && region?.block === pressed.block && region.file === pressed.file && this.#state.blocks[pressed.block] === pressed.source) {
2909
+ this.#selection.clear();
2910
+ this.#openWorkspaceReview(pressed.source, pressed.file);
2911
+ return;
2912
+ }
2852
2913
  const target = this.#pressedDisclosureAt(event.row, event.column);
2853
2914
  this.#disclosurePress = undefined;
2854
2915
  if (!event.shift && target !== undefined) {
@@ -3041,6 +3102,20 @@ export class LocalTui {
3041
3102
  this.#fileReviewAbort = undefined;
3042
3103
  }
3043
3104
  }
3105
+ #openWorkspaceReview(block, file) {
3106
+ const handler = this.#workspaceReview;
3107
+ const sessionId = this.#inspected?.id ?? this.#sessionId;
3108
+ if (handler === undefined || sessionId === undefined || block.kind !== 'workspace' || !this.#tty || this.#disposed
3109
+ || this.#prompt !== null || this.#fileReviewAbort !== undefined)
3110
+ return;
3111
+ const abort = new AbortController();
3112
+ this.#fileReviewAbort = abort;
3113
+ void handler({ sessionId, turn: block.turn, ...(file === undefined ? {} : { file }), signal: abort.signal })
3114
+ .catch(error => { if (!abort.signal.aborted && !this.#disposed)
3115
+ this.notice(error instanceof Error ? error.message : String(error), { level: 'error' }); })
3116
+ .finally(() => { if (this.#fileReviewAbort === abort)
3117
+ this.#fileReviewAbort = undefined; });
3118
+ }
3044
3119
  #openFileAction(action, initial) {
3045
3120
  if (!this.#tty || this.#disposed || this.#prompt !== null || this.#fileReviewAbort !== undefined)
3046
3121
  return;
@@ -3092,6 +3167,7 @@ export class LocalTui {
3092
3167
  this.#jumpPress = undefined;
3093
3168
  this.#disclosurePress = undefined;
3094
3169
  this.#mouseActionPress = undefined;
3170
+ this.#workspacePress = undefined;
3095
3171
  if (this.#keyTest !== undefined && this.#prompt?.request.signal === this.#keyTest.signal
3096
3172
  && (event.type === 'text' || event.type === 'paste-start' || event.type === 'paste-end' || event.type === 'key' && !['escape', 'ctrl+c'].includes(event.id))) {
3097
3173
  this.#keyTest.observe(event);
@@ -3148,6 +3224,8 @@ export class LocalTui {
3148
3224
  // Clipboard image inspection is asynchronous. Preserve the exact key
3149
3225
  // order so a fast Ctrl+V, Enter submits the finished image draft rather
3150
3226
  // than an empty prompt.
3227
+ if (this.#pasteInFlight > 0 && event.type === 'key' && (event.id === 'ctrl+c' || event.id === 'escape'))
3228
+ this.#cancelPaste();
3151
3229
  if (this.#pasteInFlight > 0) {
3152
3230
  this.#deferredPasteEvents.push(event);
3153
3231
  return;
@@ -3158,7 +3236,7 @@ export class LocalTui {
3158
3236
  const text = this.#pasteBuf.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
3159
3237
  this.#pasteBuf = '';
3160
3238
  if (text !== '')
3161
- this.#startAsyncPaste(this.#acceptPastedText(text));
3239
+ this.#startAsyncPaste(signal => this.#acceptPastedText(text, signal));
3162
3240
  return;
3163
3241
  }
3164
3242
  if (event.type === 'text')
@@ -3922,6 +4000,7 @@ export class LocalTui {
3922
4000
  const previous = options.persist ? this.prefs() : undefined;
3923
4001
  const version = this.#prefsVersion;
3924
4002
  const previousMotion = this.#motion;
4003
+ this.#workspacePress = undefined;
3925
4004
  this.#uiLanguage = prefs.uiLanguage ?? 'auto';
3926
4005
  if (this.#prompt !== null)
3927
4006
  this.#prompt = { ...this.#prompt, request: localizePrompt(this.#prompt.sourceRequest, resolveUiLanguage(this.#uiLanguage)) };
@@ -3940,6 +4019,7 @@ export class LocalTui {
3940
4019
  this.#mathMode = prefs.mathMode ?? 'auto';
3941
4020
  this.#mermaidMode = prefs.mermaidMode ?? 'auto';
3942
4021
  this.#hideThinking = prefs.hideThinking !== false;
4022
+ this.#showFileChanges = prefs.showFileChanges !== false;
3943
4023
  this.#mouseInteraction = prefs.mouseInteraction ?? 'auto';
3944
4024
  this.#checkUpdates = prefs.checkUpdates ?? true;
3945
4025
  this.#startupChangelog = prefs.startupChangelog ?? 'summary';
@@ -4544,11 +4624,20 @@ export class LocalTui {
4544
4624
  #buildSubmission(text) {
4545
4625
  const images = this.#images.copies();
4546
4626
  const files = this.#files.copies();
4547
- const typedText = this.#files.text(images.length > 0 ? text.trim() : text);
4627
+ const markers = this.#files.snapshot(this.#editor.text).entries.map(item => item.marker);
4628
+ const ranges = this.#images.ranges();
4629
+ for (const [index, marker] of markers.entries()) {
4630
+ const start = text.indexOf(marker);
4631
+ if (start < 0)
4632
+ continue;
4633
+ const end = start + marker.length;
4634
+ ranges.push({ kind: 'file', index, start: start > 0 && text[start - 1] === ' ' ? start - 1 : start,
4635
+ end: text[end] === ' ' ? end + 1 : end });
4636
+ }
4548
4637
  const segments = images.length === 0 && files.length === 0 ? []
4549
- : splitComposerAttachmentMarkers(text.trim(), images, this.#files.snapshot(text).entries.map(item => item.marker));
4550
- // Stripping a marker that sat on its own line leaves that line break behind.
4551
- const submittedText = images.length > 0 ? stripComposerImageMarkers(typedText, images).trim() : typedText;
4638
+ : splitComposerAttachmentMarkers(text, images, markers, images.length > 0 && text === this.#editor.expandedText ? ranges : undefined);
4639
+ const submittedText = images.length === 0 ? this.#files.text(text)
4640
+ : segments.flatMap(segment => segment.kind === 'text' ? [segment.text] : []).join('');
4552
4641
  const literal = text === this.#editor.expandedText && (this.#literalInput || this.#editor.startsWithPaste);
4553
4642
  const historyText = submittedText.replace(/[ \t]{2,}/gu, ' ').trim();
4554
4643
  return {
@@ -4767,6 +4856,7 @@ export class LocalTui {
4767
4856
  const pending = this.#prompt;
4768
4857
  if (pending === null)
4769
4858
  return;
4859
+ this.#cancelPaste();
4770
4860
  const draft = structuredPromptAnswer(pending, this.#editor.expandedText);
4771
4861
  try {
4772
4862
  pending.request.onAnswerDraft?.(draft);
@@ -4917,23 +5007,24 @@ export class LocalTui {
4917
5007
  }
4918
5008
  this.#search = null;
4919
5009
  this.#ac = null;
5010
+ this.#cancelPaste();
4920
5011
  this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features, this.#pluginSettings?.entries(), discoverEditors(this.#externalEditor), this.#settingsActions());
4921
5012
  this.#refreshSettingsCatalog();
4922
5013
  this.#render();
4923
5014
  }
4924
5015
  #settingsActions() {
4925
5016
  const runtimeActions = [
4926
- { id: 'command:model', behavior: 'Session + Profile · Choose scope in Model', group: 'Models', label: 'Model and reasoning', description: 'Open /model; changes the current selection and saves the default route.', value: [this.#model, this.#reasoningEffort].filter(Boolean).join(' · '), values: [], action: 'model' },
4927
- { id: 'command:permission', behavior: 'Current session · Applies now', group: 'General', label: 'Current session access', description: 'Changes only this session. Default access controls future sessions.', value: this.#sessionControls?.permission ?? 'Choose', values: [], action: 'permission' },
4928
- { id: 'command:agent', behavior: 'Current session · Before the first prompt', group: 'Agent presets', label: 'Current session preset', description: 'Choose before the first prompt; does not change the new-session default.', value: this.#sessionControls?.agentPreset === undefined ? 'Choose' : formatAgentPreset(this.#sessionControls.agentPreset), values: [], action: 'agent' },
4929
- { id: 'command:login', behavior: 'Provider credentials', group: 'Models', label: 'Provider credentials', description: 'Sign in or configure a provider. Does not change the selected model.', value: 'Configure', values: [], action: 'login' },
4930
- { id: 'command:logout', behavior: 'Provider credentials', group: 'Models', label: 'Sign out', description: 'Remove saved provider credentials; environment credentials remain available.', value: 'Choose provider', values: [], action: 'logout' },
4931
- { id: 'command:plugins', group: 'Plugins', label: 'Manage plugins', description: 'Inspect installed entries, enable bundles, discover plugins or start Creator.', value: 'Open', values: [], action: 'plugins' },
5017
+ { id: 'command:model', behavior: 'Session + Profile · Choose scope in Model', group: 'Models', subgroup: 'Model selection', label: 'Model and reasoning', description: 'Choose the current model and reasoning effort. Save as the default or use for this session.', value: [this.#model, this.#reasoningEffort].filter(Boolean).join(' · '), values: [], action: 'model' },
5018
+ { id: 'command:permission', behavior: 'Current session · Applies now', group: 'General', subgroup: 'Session controls', label: 'Current session access', description: 'Changes only this session. Default access controls future sessions.', value: this.#sessionControls?.permission ?? 'Choose', values: [], action: 'permission' },
5019
+ { id: 'command:agent', behavior: 'Current session · Before the first prompt', group: 'Agent presets', subgroup: 'Current session', label: 'Current session preset', description: 'Choose before the first prompt; does not change the new-session default.', value: this.#sessionControls?.agentPreset === undefined ? 'Choose' : formatAgentPreset(this.#sessionControls.agentPreset), values: [], action: 'agent' },
5020
+ { id: 'command:login', behavior: 'Accounts and API keys', group: 'Models', subgroup: 'Accounts and API keys', label: 'Sign in / add API key', description: 'Sign in to a provider account or save an API key.', keywords: ['Provider credentials', 'login', 'API key', 'OAuth'], value: 'Choose provider', values: [], action: 'login' },
5021
+ { id: 'command:logout', behavior: 'Accounts and API keys', group: 'Models', subgroup: 'Accounts and API keys', label: 'Sign out / remove key', description: 'Choose a provider or account to sign out or remove locally saved credentials.', keywords: ['logout', 'credentials'], value: 'Choose provider', values: [], action: 'logout' },
5022
+ { id: 'command:plugins', group: 'Plugins', subgroup: 'Plugin management', label: 'Manage plugins', description: 'Inspect installed entries, enable bundles, discover plugins or start Creator.', value: 'Open', values: [], action: 'plugins' },
4932
5023
  ].filter(item => this.#runtimeCommands.some(command => command.name === item.action));
4933
5024
  const customBindings = Object.entries(this.#keybindings).filter(([key, action]) => DEFAULT_KEYBINDINGS[key] !== action).length;
4934
5025
  return [
4935
5026
  ...runtimeActions,
4936
- ...(this.#goalHandler === undefined ? [] : [{ id: 'action:goal', behavior: 'Current session · Applies now', group: 'General', label: 'Current goal', description: 'Edit, pause, resume or clear this session’s goal.', value: 'Open', values: [], action: 'manage-goal' }]),
5027
+ ...(this.#goalHandler === undefined ? [] : [{ id: 'action:goal', behavior: 'Current session · Applies now', group: 'General', subgroup: 'Session controls', label: 'Current goal', description: 'Edit, pause, resume or clear this session’s goal.', value: 'Open', values: [], action: 'manage-goal' }]),
4937
5028
  ...(this.#keybindingsPath === undefined ? [] : [{
4938
5029
  id: 'action:keybindings',
4939
5030
  label: 'Keybindings',
@@ -5329,7 +5420,7 @@ export class LocalTui {
5329
5420
  return true;
5330
5421
  }
5331
5422
  if (action === 'paste-clipboard') {
5332
- this.#startAsyncPaste(this.#pasteClipboard());
5423
+ this.#startAsyncPaste(signal => this.#pasteClipboard(signal));
5333
5424
  return true;
5334
5425
  }
5335
5426
  // Explicit queue delivery is independent of the busy-Enter preference.
@@ -5345,7 +5436,8 @@ export class LocalTui {
5345
5436
  this.#handTerminal(() => {
5346
5437
  this.#literalInput ||= this.#editor.startsWithPaste;
5347
5438
  const text = editExternally(this.#editor.expandedText, this.#externalEditor);
5348
- this.#editor.setText(text);
5439
+ while (this.#editor.expandPaste()) { /* Keep marker positions aligned with expanded source. */ }
5440
+ this.#editor.replaceText(text);
5349
5441
  this.#images.reconcile();
5350
5442
  });
5351
5443
  }
@@ -5852,6 +5944,7 @@ export class LocalTui {
5852
5944
  toolsExpanded: this.#toolsExpanded,
5853
5945
  expandedOutputs: this.#expandedOutputs,
5854
5946
  hideThinking: true,
5947
+ showFileChanges: this.#showFileChanges,
5855
5948
  expandedTools: this.#detailedToolIds(),
5856
5949
  expandedReasoning: this.#visibleReasoning(),
5857
5950
  ...(this.#openedGroups === undefined ? {} : { openedGroups: this.#openedGroups }),
@@ -5962,6 +6055,7 @@ export function apply(ctx, config) {
5962
6055
  motion: config.motion.get(),
5963
6056
  mathMode: config.mathMode.get(),
5964
6057
  mermaidMode: config.mermaidMode.get(),
6058
+ showFileChanges: config.showFileChanges.get(),
5965
6059
  editor: config.editor.get(),
5966
6060
  terminalTitle: config.terminalTitle.get(),
5967
6061
  terminalState: config.terminalState.get(),