@agi-fans/dsh-tui 0.24.0 → 0.25.0

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 (67) hide show
  1. package/lib/CHANGELOG.md +36 -2
  2. package/lib/chrome/main-screen-renderer.d.ts +2 -2
  3. package/lib/chrome/main-screen-renderer.js +5 -5
  4. package/lib/chrome/main-screen-renderer.js.map +1 -1
  5. package/lib/chrome/renderer.js +1 -1
  6. package/lib/chrome/renderer.js.map +1 -1
  7. package/lib/chrome/theme.js +1 -1
  8. package/lib/chrome/theme.js.map +1 -1
  9. package/lib/chrome/tool-renderers.d.ts +2 -0
  10. package/lib/chrome/tool-renderers.js +5 -0
  11. package/lib/chrome/tool-renderers.js.map +1 -1
  12. package/lib/definition.d.ts +2 -0
  13. package/lib/input/clipboard.d.ts +1 -1
  14. package/lib/input/clipboard.js +10 -4
  15. package/lib/input/clipboard.js.map +1 -1
  16. package/lib/input/editor.d.ts +14 -0
  17. package/lib/input/editor.js +55 -5
  18. package/lib/input/editor.js.map +1 -1
  19. package/lib/input/image-paste.d.ts +21 -5
  20. package/lib/input/image-paste.js +69 -21
  21. package/lib/input/image-paste.js.map +1 -1
  22. package/lib/locale/zh.d.ts +2 -1
  23. package/lib/locale/zh.js +2 -1
  24. package/lib/locale/zh.js.map +1 -1
  25. package/lib/runtime/composer-images.d.ts +11 -11
  26. package/lib/runtime/composer-images.js +105 -96
  27. package/lib/runtime/composer-images.js.map +1 -1
  28. package/lib/runtime/herdr-agent.d.ts +2 -2
  29. package/lib/runtime/herdr-agent.js +2 -2
  30. package/lib/runtime/herdr-agent.js.map +1 -1
  31. package/lib/runtime/provider-local.js +206 -99
  32. package/lib/runtime/provider-local.js.map +1 -1
  33. package/lib/runtime/runner.js +3 -3
  34. package/lib/runtime/runner.js.map +1 -1
  35. package/lib/runtime/terminal-notifications.d.ts +3 -1
  36. package/lib/runtime/terminal-notifications.js +7 -2
  37. package/lib/runtime/terminal-notifications.js.map +1 -1
  38. package/lib/runtime/terminal-session.d.ts +1 -0
  39. package/lib/runtime/terminal-session.js +1 -0
  40. package/lib/runtime/terminal-session.js.map +1 -1
  41. package/lib/runtime/terminal-state.d.ts +10 -1
  42. package/lib/runtime/terminal-state.js +39 -7
  43. package/lib/runtime/terminal-state.js.map +1 -1
  44. package/lib/session/authorization-interaction.js +3 -3
  45. package/lib/session/authorization-interaction.js.map +1 -1
  46. package/lib/session/composer-drafts.js +25 -1
  47. package/lib/session/composer-drafts.js.map +1 -1
  48. package/lib/session/content-text.d.ts +5 -0
  49. package/lib/session/content-text.js +7 -0
  50. package/lib/session/content-text.js.map +1 -1
  51. package/lib/session/interaction-adapter.js +1 -0
  52. package/lib/session/interaction-adapter.js.map +1 -1
  53. package/lib/session/session-controller.js +2 -3
  54. package/lib/session/session-controller.js.map +1 -1
  55. package/lib/views/prompt-selector.d.ts +1 -0
  56. package/lib/views/prompt-selector.js +6 -3
  57. package/lib/views/prompt-selector.js.map +1 -1
  58. package/lib/views/settings-list.js +1 -1
  59. package/lib/views/settings-list.js.map +1 -1
  60. package/lib/views/trajectory.js +5 -5
  61. package/lib/views/trajectory.js.map +1 -1
  62. package/lib/views/transcript-render.js +35 -27
  63. package/lib/views/transcript-render.js.map +1 -1
  64. package/lib/views/transcript-types.d.ts +1 -4
  65. package/lib/views/transcript-types.js +4 -7
  66. package/lib/views/transcript-types.js.map +1 -1
  67. package/package.json +1 -1
@@ -23,7 +23,7 @@ import { localizePrompt } from "../locale/prompt.js";
23
23
  import { resolveUiLanguage, uiText } from "../locale/index.js";
24
24
  import { formatAgentPreset } from "../session/session-configuration.js";
25
25
  import { TerminalNotificationController, TerminalNotificationQueue, terminalNotificationSequence, } from "./terminal-notifications.js";
26
- import { PROGRAM_STATUS_QUERY, programStatusSequence, terminalTitle } from "./terminal-state.js";
26
+ import { PROGRAM_STATUS_QUERY, programStatusNeedsAttention, programStatusSequence, terminalProgressSequence, terminalTitle, workingDirectorySequence, } from "./terminal-state.js";
27
27
  import { TerminalSession } from "./terminal-session.js";
28
28
  import { BRACKETED_PASTE, FOCUS_REPORTING, MOUSE_TRACKING, } from "./terminal-modes.js";
29
29
  import { IDLE_WHEEL, terminalAcceleratesWheel, wheelStep } from "../input/wheel-scroll.js";
@@ -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,
@@ -377,6 +378,10 @@ export class LocalTui {
377
378
  #compactionStartedAt;
378
379
  #readImageInput;
379
380
  #writtenProgramState;
381
+ #writtenDirectory;
382
+ #launchDirectory = process.cwd();
383
+ #writtenProgress;
384
+ #progressTimer;
380
385
  #checkUpdates = true;
381
386
  #startupChangelog = 'summary';
382
387
  #notifications = new TerminalNotificationController();
@@ -622,14 +627,7 @@ export class LocalTui {
622
627
  const resume = () => {
623
628
  if (this.#disposed)
624
629
  return;
625
- this.#terminalFocused = undefined;
626
- this.#writtenWindowTitle = undefined;
627
- this.#writtenProgramState = undefined;
628
- this.#programQuerySent = false;
629
- this.#programSupported = false;
630
- this.#syncWindowTitle();
631
- this.#session.takeBack();
632
- this.#renderer.reset();
630
+ this.#takeBackTerminal();
633
631
  this.#render();
634
632
  };
635
633
  process.on('SIGCONT', resume);
@@ -704,6 +702,8 @@ export class LocalTui {
704
702
  else if (event.type === 'session/end-seed')
705
703
  this.#directInputIds.clear();
706
704
  this.#state = applyEvent(this.#state, event, presentation, workspace);
705
+ if (event.type === 'turn/start' && this.#herdr.programStatus.state !== 'blocked')
706
+ this.#notificationQueue.clear();
707
707
  if (claimedHumanInput && this.#tty && event.type === 'agent/inbox/spliced') {
708
708
  this.#showTransientStatus(event.data.target === 'next-step' ? 'Steer taken for the next step' : 'Queue taken for the next turn');
709
709
  }
@@ -889,6 +889,7 @@ export class LocalTui {
889
889
  this.#render();
890
890
  }
891
891
  async reviewFiles(request) {
892
+ this.#cancelPaste();
892
893
  if (!this.#tty || this.#disposed || request.signal.aborted)
893
894
  return;
894
895
  this.#workspacePress = undefined;
@@ -1244,11 +1245,12 @@ export class LocalTui {
1244
1245
  this.notice(error instanceof Error ? error.message : String(error), { level: 'error' });
1245
1246
  }
1246
1247
  }
1248
+ this.#cancelPaste();
1247
1249
  this.#flushPasteBurst(true);
1248
1250
  this.#pasteBurst.clear();
1249
1251
  if (request.notify !== false) {
1250
1252
  this.#emitNotification(this.#notifications.humanPrompt());
1251
- this.#herdr.prompt(request.title);
1253
+ this.#herdr.prompt(request.title, request.attentionKind);
1252
1254
  this.#syncWindowTitle();
1253
1255
  }
1254
1256
  if (this.#tty && request.documentImage !== undefined && this.#imageProtocol !== undefined)
@@ -1384,6 +1386,8 @@ export class LocalTui {
1384
1386
  this.#workspacePress = undefined;
1385
1387
  this.#fileReviewAbort?.abort();
1386
1388
  this.#closeFileBrowser();
1389
+ this.#notificationQueue.clear();
1390
+ this.#notifications.reset();
1387
1391
  }
1388
1392
  this.#switchDraft(this.#inspected?.id ?? info.id);
1389
1393
  this.#sessionId = info.id;
@@ -1410,10 +1414,12 @@ export class LocalTui {
1410
1414
  this.#render();
1411
1415
  }
1412
1416
  /** Only the foreground terminal owner emits title and program-state sequences. */
1413
- #syncWindowTitle() {
1414
- if (!this.#tty || this.#disposed)
1417
+ #syncWindowTitle(notify = false) {
1418
+ if (!this.#tty || this.#disposed || this.#session.handedOff)
1415
1419
  return;
1416
1420
  const status = this.#herdr.programStatus;
1421
+ this.#writeWorkingDirectory(this.#cwd);
1422
+ this.#syncProgress(status.state);
1417
1423
  const title = terminalTitle(this.#terminalTitle, status, this.#sessionTitle, this.#cwd, this.#branch);
1418
1424
  if (title !== this.#writtenWindowTitle && !(this.#terminalTitle === 'off' && this.#writtenWindowTitle === undefined)) {
1419
1425
  this.#writtenWindowTitle = title;
@@ -1423,20 +1429,78 @@ export class LocalTui {
1423
1429
  this.#programQuerySent = true;
1424
1430
  this.#term.output.write(PROGRAM_STATUS_QUERY);
1425
1431
  }
1426
- const enabled = this.#terminalState === 'on' || this.#terminalState === 'auto' && this.#programSupported;
1427
- if (!enabled) {
1432
+ if (!this.#reportsProgramState) {
1428
1433
  if (this.#writtenProgramState !== undefined)
1429
1434
  this.#term.output.write(programStatusSequence('clear'));
1430
1435
  this.#writtenProgramState = undefined;
1431
1436
  return;
1432
1437
  }
1433
1438
  // Only the session name is exported as metadata; prompt bodies and errors stay in the TUI.
1434
- const sequence = programStatusSequence({ state: status.state, ...(this.#sessionTitle === undefined ? {} : { message: this.#sessionTitle }) });
1439
+ const sequence = programStatusSequence({ state: status.state,
1440
+ ...(status.kind === undefined ? {} : { kind: status.kind }),
1441
+ ...(this.#sessionTitle === undefined ? {} : { message: this.#sessionTitle }),
1442
+ });
1443
+ // Supporting terminals can turn these states into desktop notifications.
1444
+ // New attention reports use the same coalescing and focus gate as OSC 9.
1445
+ if (programStatusNeedsAttention(status.state)
1446
+ && (this.#notificationPolicy === 'off' || !notify && sequence !== this.#writtenProgramState)) {
1447
+ if (this.#writtenProgramState !== undefined)
1448
+ this.#term.output.write(programStatusSequence('clear'));
1449
+ this.#writtenProgramState = undefined;
1450
+ return;
1451
+ }
1435
1452
  if (sequence === this.#writtenProgramState)
1436
1453
  return;
1437
1454
  this.#writtenProgramState = sequence;
1438
1455
  this.#term.output.write(sequence);
1439
1456
  }
1457
+ get #reportsProgramState() {
1458
+ return this.#terminalState === 'on' || this.#terminalState === 'auto' && this.#programSupported;
1459
+ }
1460
+ #writeWorkingDirectory(cwd) {
1461
+ const sequence = workingDirectorySequence(cwd);
1462
+ if (sequence === this.#writtenDirectory)
1463
+ return;
1464
+ this.#term.output.write(sequence);
1465
+ this.#writtenDirectory = sequence;
1466
+ }
1467
+ #syncProgress(state) {
1468
+ const sequence = this.#terminalState === 'off' ? terminalProgressSequence('clear') : terminalProgressSequence(state);
1469
+ const active = sequence !== terminalProgressSequence('clear');
1470
+ if (sequence !== this.#writtenProgress && (active || this.#writtenProgress !== undefined))
1471
+ this.#term.output.write(sequence);
1472
+ this.#writtenProgress = active ? sequence : undefined;
1473
+ if (this.#progressTimer !== undefined && (state !== 'working' || this.#terminalState === 'off')) {
1474
+ clearInterval(this.#progressTimer);
1475
+ this.#progressTimer = undefined;
1476
+ }
1477
+ // Some terminals expire indeterminate progress without a heartbeat.
1478
+ if (state === 'working' && this.#terminalState !== 'off' && this.#progressTimer === undefined) {
1479
+ this.#progressTimer = setInterval(() => {
1480
+ if (!this.#disposed && !this.#session.handedOff && this.#writtenProgress !== undefined)
1481
+ this.#term.output.write(this.#writtenProgress);
1482
+ }, 1000);
1483
+ this.#progressTimer.unref?.();
1484
+ }
1485
+ }
1486
+ /** Give shell/editor ownership back without retaining omdsh metadata. */
1487
+ #releaseTerminalMetadata() {
1488
+ if (this.#progressTimer !== undefined)
1489
+ clearInterval(this.#progressTimer);
1490
+ this.#progressTimer = undefined;
1491
+ if (this.#writtenProgress !== undefined)
1492
+ this.#term.output.write(terminalProgressSequence('clear'));
1493
+ this.#writtenProgress = undefined;
1494
+ if (this.#writtenProgramState !== undefined)
1495
+ this.#term.output.write(programStatusSequence('clear'));
1496
+ this.#writtenProgramState = undefined;
1497
+ if (this.#writtenWindowTitle !== undefined && this.#writtenWindowTitle !== '')
1498
+ this.#term.output.write('\x1b]2;\x07');
1499
+ this.#writtenWindowTitle = undefined;
1500
+ if (this.#writtenDirectory !== undefined)
1501
+ this.#writeWorkingDirectory(this.#launchDirectory);
1502
+ this.#writtenDirectory = undefined;
1503
+ }
1440
1504
  /**
1441
1505
  * Re-derive the color depth from the active color preference. Every path
1442
1506
  * that changes `#colors` calls this so `/settings` takes effect immediately.
@@ -1607,8 +1671,8 @@ export class LocalTui {
1607
1671
  this.#reportDraftError('restore', error);
1608
1672
  }
1609
1673
  if (existing !== undefined) {
1610
- images.replace({ text: existing.editor.text, images: existing.images });
1611
1674
  editor.restore(existing.editor);
1675
+ images.restoreSnapshot(existing.images);
1612
1676
  files.restoreSnapshot(existing.files);
1613
1677
  }
1614
1678
  images.restore(submission, files.adopt(submission.files ?? []));
@@ -1832,7 +1896,9 @@ export class LocalTui {
1832
1896
  this.#renderer.finish();
1833
1897
  }
1834
1898
  catch { /* best effort while exiting */ }
1899
+ this.#releaseTerminalMetadata();
1835
1900
  this.#session.release();
1901
+ this.#term.input.setRawMode?.(false);
1836
1902
  this.#term.output.write('\x1b[?25h');
1837
1903
  }
1838
1904
  dispose() {
@@ -1842,6 +1908,7 @@ export class LocalTui {
1842
1908
  this.#flushDraft();
1843
1909
  this.#pendingInputs.flush(this.#pendingScope());
1844
1910
  this.#disposed = true;
1911
+ this.#cancelPaste();
1845
1912
  this.#imagePreviewAbort?.abort();
1846
1913
  this.#fileReviewAbort?.abort();
1847
1914
  this.#closeFileBrowser();
@@ -1894,14 +1961,7 @@ export class LocalTui {
1894
1961
  // prompt does not overwrite the transcript. Disable bracketed paste
1895
1962
  // and restore the cursor.
1896
1963
  this.#renderer.finish();
1897
- // Clear the session title we owned so the shell's own title returns.
1898
- if (this.#writtenWindowTitle !== undefined && this.#writtenWindowTitle !== '') {
1899
- this.#term.output.write('\x1b]2;\x07');
1900
- this.#writtenWindowTitle = undefined;
1901
- }
1902
- if (this.#writtenProgramState !== undefined)
1903
- this.#term.output.write(programStatusSequence('clear'));
1904
- this.#writtenProgramState = undefined;
1964
+ this.#releaseTerminalMetadata();
1905
1965
  this.#offExit?.();
1906
1966
  this.#offExit = undefined;
1907
1967
  this.#session.dispose();
@@ -2049,11 +2109,11 @@ export class LocalTui {
2049
2109
  if (this.#historyIndex > 0 && this.#historyDraft !== undefined)
2050
2110
  return this.#historyDraft;
2051
2111
  const editor = this.#prompt?.savedInput ?? this.#editor.snapshot();
2052
- return { editor, images: this.#images.copies(), files: this.#files.snapshot(editor.text), literal: this.#literalInput };
2112
+ return { editor, images: this.#images.copies(editor), files: this.#files.snapshot(editor.text), literal: this.#literalInput };
2053
2113
  }
2054
2114
  #restoreDraft(draft) {
2055
- this.#images.replace({ text: draft.editor.text, images: draft.images });
2056
2115
  this.#editor.restore(draft.editor);
2116
+ this.#images.restoreSnapshot(draft.images);
2057
2117
  this.#files.restoreSnapshot(draft.files);
2058
2118
  this.#literalInput = draft.literal;
2059
2119
  }
@@ -2117,6 +2177,7 @@ export class LocalTui {
2117
2177
  #switchDraft(id) {
2118
2178
  if (id === this.#draftScope)
2119
2179
  return;
2180
+ this.#cancelPaste();
2120
2181
  this.#goalAbort?.abort();
2121
2182
  this.#goalAbort = undefined;
2122
2183
  this.#queueHotkeyAbort?.abort();
@@ -2206,9 +2267,9 @@ export class LocalTui {
2206
2267
  themeName: this.#themeName,
2207
2268
  terminal: this.#terminalColors,
2208
2269
  ...(workingLabel === undefined ? {} : { workingLabel }),
2270
+ ...(this.#selection.active ? { selectionActions: this.#canQuoteSelection() ? 'copy-quote' : 'copy' } : {}),
2209
2271
  ...(this.#transientStatus !== undefined ? { transientStatus: uiText(resolveUiLanguage(this.#uiLanguage), this.#transientStatus) }
2210
- : this.#selection.active ? { selectionActions: this.#canQuoteSelection() ? 'copy-quote' : 'copy' }
2211
- : this.#processSelection !== undefined ? { transientStatus: uiText(resolveUiLanguage(this.#uiLanguage), 'Process · ↑↓ select · Enter toggle · {key} actions · Home title · Esc return', { key: keysForAction(this.#keybindings, 'tool-actions') }) } : {}),
2272
+ : !this.#selection.active && this.#processSelection !== undefined ? { transientStatus: uiText(resolveUiLanguage(this.#uiLanguage), 'Process · ↑↓ select · Enter toggle · {key} actions · Home title · Esc return', { key: keysForAction(this.#keybindings, 'tool-actions') }) } : {}),
2212
2273
  scrollStart: this.#follow ? Number.POSITIVE_INFINITY : this.#scrollStart,
2213
2274
  ...(this.#focusBlock === undefined ? {} : { focusBlock: this.#focusBlock, focusBlockEdge: this.#focusBlockEdge ?? 'start', ...(this.#focusBlockOffset === undefined ? {} : { focusBlockOffset: this.#focusBlockOffset }) }),
2214
2275
  ...(this.#transcriptSearch === null ? {} : {
@@ -2385,20 +2446,31 @@ export class LocalTui {
2385
2446
  /** Give every terminal mode back before the terminal is lent out (Ctrl+Z, external editor). */
2386
2447
  #handOffTerminal() {
2387
2448
  this.#setMouseTracking(false);
2449
+ this.#releaseTerminalMetadata();
2388
2450
  this.#session.handOff();
2451
+ this.#term.input.setRawMode?.(false);
2452
+ }
2453
+ #takeBackTerminal() {
2454
+ this.#session.takeBack();
2455
+ this.#term.input.setRawMode?.(true);
2456
+ this.#terminalFocused = undefined;
2457
+ this.#writtenWindowTitle = undefined;
2458
+ this.#writtenProgramState = undefined;
2459
+ this.#writtenDirectory = undefined;
2460
+ this.#programQuerySent = false;
2461
+ this.#programSupported = false;
2462
+ this.#syncWindowTitle();
2463
+ this.#renderer.reset();
2389
2464
  }
2390
2465
  /** Run `run` with the terminal handed off; take it back and repaint afterwards. */
2391
2466
  #handTerminal(run) {
2392
2467
  this.#handOffTerminal();
2393
- this.#term.input.setRawMode?.(false);
2394
2468
  try {
2395
2469
  return run();
2396
2470
  }
2397
2471
  finally {
2398
2472
  if (!this.#disposed) {
2399
- this.#term.input.setRawMode?.(true);
2400
- this.#session.takeBack();
2401
- this.#renderer.reset();
2473
+ this.#takeBackTerminal();
2402
2474
  }
2403
2475
  }
2404
2476
  }
@@ -2509,14 +2581,26 @@ export class LocalTui {
2509
2581
  for (const event of this.#pasteBurst.flush(Date.now(), force))
2510
2582
  this.#deliverPasteEmission(event);
2511
2583
  }
2584
+ #cancelPaste() {
2585
+ this.#pasteAbort?.abort();
2586
+ this.#pasteAbort = undefined;
2587
+ this.#pasteInFlight = 0;
2588
+ this.#deferredPasteEvents = [];
2589
+ this.#paste = false;
2590
+ this.#pasteBuf = '';
2591
+ }
2512
2592
  #startAsyncPaste(operation) {
2513
- this.#pasteInFlight += 1;
2514
- void operation.catch((error) => {
2515
- this.notice('Paste failed: ' + (error instanceof Error ? error.message : String(error)), { level: 'error' });
2593
+ const abort = new AbortController();
2594
+ this.#pasteAbort = abort;
2595
+ this.#pasteInFlight = 1;
2596
+ void operation(abort.signal).catch((error) => {
2597
+ if (!abort.signal.aborted)
2598
+ this.notice('Paste failed: ' + (error instanceof Error ? error.message : String(error)), { level: 'error' });
2516
2599
  }).finally(() => {
2517
- this.#pasteInFlight = Math.max(0, this.#pasteInFlight - 1);
2518
- if (this.#pasteInFlight !== 0 || this.#deferredPasteEvents.length === 0)
2600
+ if (this.#pasteAbort !== abort)
2519
2601
  return;
2602
+ this.#pasteAbort = undefined;
2603
+ this.#pasteInFlight = 0;
2520
2604
  const deferred = this.#deferredPasteEvents.splice(0);
2521
2605
  for (let index = 0; index < deferred.length; index += 1) {
2522
2606
  this.#dispatch(deferred[index]);
@@ -2527,7 +2611,10 @@ export class LocalTui {
2527
2611
  }
2528
2612
  });
2529
2613
  }
2530
- async #acceptPastedText(text) {
2614
+ async #acceptPastedText(text, signal) {
2615
+ if (signal.aborted)
2616
+ return;
2617
+ text = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
2531
2618
  if (this.#fileBrowser !== null) {
2532
2619
  this.#applyFileBrowserInput({ type: 'text', value: text });
2533
2620
  return;
@@ -2555,42 +2642,50 @@ export class LocalTui {
2555
2642
  }
2556
2643
  const paths = imagePathCandidates(text);
2557
2644
  if (paths.length > 0) {
2558
- const images = await Promise.all(paths.map(path => this.#readImagePath(path)));
2559
- if (images.every((image) => image !== null)) {
2560
- let inserted = false;
2561
- for (const image of images) {
2562
- if (await this.#images.admit(image)) {
2563
- this.#images.insert(image);
2564
- inserted = true;
2565
- }
2645
+ const images = await Promise.all(paths.map(async (path) => {
2646
+ try {
2647
+ return await this.#readImagePath(path);
2566
2648
  }
2567
- if (inserted) {
2568
- this.#refreshAutocomplete();
2569
- this.#render();
2649
+ catch {
2650
+ return null;
2570
2651
  }
2652
+ }));
2653
+ if (signal.aborted)
2571
2654
  return;
2655
+ let inserted = false;
2656
+ for (const [index, image] of images.entries()) {
2657
+ if (image === null) {
2658
+ this.notice(uiText(resolveUiLanguage(this.#uiLanguage), 'Could not read image {path}', { path: paths[index] }), { level: 'error' });
2659
+ continue;
2660
+ }
2661
+ const admitted = await this.#images.admit(image, signal);
2662
+ if (signal.aborted)
2663
+ return;
2664
+ if (admitted) {
2665
+ this.#images.insert(image);
2666
+ inserted = true;
2667
+ }
2572
2668
  }
2573
- // Screenshot tools sometimes paste a transient cache path and remove
2574
- // it before Node reads it. OMP falls back to the still-live clipboard
2575
- // image instead of leaking that stale path into the prompt.
2576
- const clipboardImage = await this.#readClipboardImage();
2577
- if (clipboardImage !== null) {
2578
- this.#images.insert(clipboardImage);
2669
+ if (inserted)
2579
2670
  this.#refreshAutocomplete();
2580
- this.#render();
2581
- return;
2582
- }
2671
+ this.#render();
2672
+ return;
2583
2673
  }
2584
2674
  this.#literalInput ||= this.#editor.cursor === 0 && text.startsWith('!');
2585
2675
  this.#editor.paste(text);
2586
2676
  this.#refreshAutocomplete();
2587
2677
  this.#render();
2588
2678
  }
2589
- async #pasteClipboard() {
2590
- if (this.#search === null && this.#settings === null) {
2679
+ async #pasteClipboard(signal) {
2680
+ if (this.#prompt === null && this.#search === null && this.#settings === null) {
2591
2681
  const image = await this.#readClipboardImage();
2682
+ if (signal.aborted)
2683
+ return;
2592
2684
  if (image !== null) {
2593
- if (await this.#images.admit(image)) {
2685
+ const admitted = await this.#images.admit(image, signal);
2686
+ if (signal.aborted)
2687
+ return;
2688
+ if (admitted) {
2594
2689
  this.#images.insert(image);
2595
2690
  this.#refreshAutocomplete();
2596
2691
  this.#render();
@@ -2598,29 +2693,17 @@ export class LocalTui {
2598
2693
  return;
2599
2694
  }
2600
2695
  const files = await this.#readClipboardFiles();
2696
+ if (signal.aborted)
2697
+ return;
2601
2698
  const imagePaths = files.filter(path => imagePathCandidates(path).length === 1);
2602
2699
  if (imagePaths.length > 0) {
2603
- const images = (await Promise.all(imagePaths.map(path => this.#readImagePath(path))))
2604
- .filter((candidate) => candidate !== null);
2605
- if (images.length > 0) {
2606
- let inserted = false;
2607
- for (const candidate of images) {
2608
- if (await this.#images.admit(candidate)) {
2609
- this.#images.insert(candidate);
2610
- inserted = true;
2611
- }
2612
- }
2613
- if (inserted) {
2614
- this.#refreshAutocomplete();
2615
- this.#render();
2616
- }
2617
- return;
2618
- }
2700
+ await this.#acceptPastedText(imagePaths.join('\n'), signal);
2701
+ return;
2619
2702
  }
2620
2703
  }
2621
2704
  const text = await this.#readClipboard();
2622
- if (text !== '')
2623
- await this.#acceptPastedText(text);
2705
+ if (text !== '' && !signal.aborted)
2706
+ await this.#acceptPastedText(text, signal);
2624
2707
  }
2625
2708
  /** Resolve one bounded tmux probe without overwriting an explicit preference. */
2626
2709
  setTmuxMouseEnabled(enabled) {
@@ -2803,7 +2886,7 @@ export class LocalTui {
2803
2886
  this.#workspacePress = undefined;
2804
2887
  if (event.button === 'left' && !event.shift && target?.key === pressedAction.key && target.row === pressedAction.row && target.column === pressedAction.column) {
2805
2888
  if (target.action === 'copy')
2806
- void this.#copySelection(true);
2889
+ void this.#copySelection();
2807
2890
  else if (target.action === 'quote')
2808
2891
  this.#quoteSelection();
2809
2892
  else {
@@ -2835,7 +2918,7 @@ export class LocalTui {
2835
2918
  this.#disclosurePress = undefined;
2836
2919
  if (event.action === 'press') {
2837
2920
  if (this.#selection.active)
2838
- void this.#copySelection(true);
2921
+ void this.#copySelection();
2839
2922
  else {
2840
2923
  const region = this.#fileRegionAt(event.row);
2841
2924
  if (region !== undefined) {
@@ -2910,7 +2993,7 @@ export class LocalTui {
2910
2993
  #handleTrajectoryMouse(event) {
2911
2994
  if (event.button === 'right') {
2912
2995
  if (event.action === 'press' && this.#selection.active)
2913
- void this.#copySelection(true);
2996
+ void this.#copySelection();
2914
2997
  return;
2915
2998
  }
2916
2999
  if (event.action === 'press') {
@@ -2982,7 +3065,7 @@ export class LocalTui {
2982
3065
  this.#copyTask = task.catch(() => { });
2983
3066
  return task;
2984
3067
  }
2985
- async #copySelection(explicit = false) {
3068
+ async #copySelection() {
2986
3069
  if (!this.#selection.valid(this.#renderer.viewport())) {
2987
3070
  this.#selection.clear();
2988
3071
  return;
@@ -2992,16 +3075,13 @@ export class LocalTui {
2992
3075
  return;
2993
3076
  try {
2994
3077
  const delivery = await this.#deliverCopy(text);
2995
- if (explicit && !this.#disposed) {
3078
+ if (!this.#disposed && this.#selection.text() === text) {
2996
3079
  this.#showTransientStatus(delivery === 'requested' ? 'Selection sent to terminal clipboard' : 'Copied selection');
2997
- this.#render();
2998
3080
  }
2999
3081
  }
3000
3082
  catch {
3001
- if (!this.#disposed) {
3083
+ if (!this.#disposed && this.#selection.text() === text)
3002
3084
  this.#showTransientStatus('Copy failed');
3003
- this.#render();
3004
- }
3005
3085
  }
3006
3086
  }
3007
3087
  #canQuoteSelection() {
@@ -3163,7 +3243,7 @@ export class LocalTui {
3163
3243
  return;
3164
3244
  }
3165
3245
  if (event.type === 'key' && (['super+c', 'enter'].includes(event.id) || this.#boundAction(event.id) === 'copy-prompt')) {
3166
- void this.#copySelection(true);
3246
+ void this.#copySelection();
3167
3247
  return;
3168
3248
  }
3169
3249
  this.#selection.clear();
@@ -3208,6 +3288,8 @@ export class LocalTui {
3208
3288
  // Clipboard image inspection is asynchronous. Preserve the exact key
3209
3289
  // order so a fast Ctrl+V, Enter submits the finished image draft rather
3210
3290
  // than an empty prompt.
3291
+ if (this.#pasteInFlight > 0 && event.type === 'key' && (event.id === 'ctrl+c' || event.id === 'escape'))
3292
+ this.#cancelPaste();
3211
3293
  if (this.#pasteInFlight > 0) {
3212
3294
  this.#deferredPasteEvents.push(event);
3213
3295
  return;
@@ -3218,7 +3300,7 @@ export class LocalTui {
3218
3300
  const text = this.#pasteBuf.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
3219
3301
  this.#pasteBuf = '';
3220
3302
  if (text !== '')
3221
- this.#startAsyncPaste(this.#acceptPastedText(text));
3303
+ this.#startAsyncPaste(signal => this.#acceptPastedText(text, signal));
3222
3304
  return;
3223
3305
  }
3224
3306
  if (event.type === 'text')
@@ -3996,7 +4078,6 @@ export class LocalTui {
3996
4078
  this.#externalEditor = prefs.editor ?? 'auto';
3997
4079
  this.#terminalTitle = prefs.terminalTitle ?? 'status';
3998
4080
  this.#terminalState = prefs.terminalState ?? 'auto';
3999
- this.#syncWindowTitle();
4000
4081
  this.#copyOnSelect = prefs.copyOnSelect ?? true;
4001
4082
  this.#mathMode = prefs.mathMode ?? 'auto';
4002
4083
  this.#mermaidMode = prefs.mermaidMode ?? 'auto';
@@ -4009,6 +4090,7 @@ export class LocalTui {
4009
4090
  if (this.#notificationPolicy === 'off')
4010
4091
  this.#notificationQueue.clear();
4011
4092
  this.#configureNotifications();
4093
+ this.#syncWindowTitle();
4012
4094
  this.#statusBar = resolveStatusBarConfig(prefs.statusBar, prefs.statusPreset);
4013
4095
  if (options.forceToolsSync)
4014
4096
  this.#toolsExpanded = false;
@@ -4504,7 +4586,10 @@ export class LocalTui {
4504
4586
  try {
4505
4587
  process.kill(process.pid, 'SIGTSTP');
4506
4588
  }
4507
- catch { /* no controlling tty */ }
4589
+ catch {
4590
+ this.#takeBackTerminal();
4591
+ this.#render();
4592
+ }
4508
4593
  }
4509
4594
  #historyPrev() {
4510
4595
  if (this.#images.count > 0)
@@ -4606,11 +4691,20 @@ export class LocalTui {
4606
4691
  #buildSubmission(text) {
4607
4692
  const images = this.#images.copies();
4608
4693
  const files = this.#files.copies();
4609
- const typedText = this.#files.text(images.length > 0 ? text.trim() : text);
4694
+ const markers = this.#files.snapshot(this.#editor.text).entries.map(item => item.marker);
4695
+ const ranges = this.#images.ranges();
4696
+ for (const [index, marker] of markers.entries()) {
4697
+ const start = text.indexOf(marker);
4698
+ if (start < 0)
4699
+ continue;
4700
+ const end = start + marker.length;
4701
+ ranges.push({ kind: 'file', index, start: start > 0 && text[start - 1] === ' ' ? start - 1 : start,
4702
+ end: text[end] === ' ' ? end + 1 : end });
4703
+ }
4610
4704
  const segments = images.length === 0 && files.length === 0 ? []
4611
- : splitComposerAttachmentMarkers(text.trim(), images, this.#files.snapshot(text).entries.map(item => item.marker));
4612
- // Stripping a marker that sat on its own line leaves that line break behind.
4613
- const submittedText = images.length > 0 ? stripComposerImageMarkers(typedText, images).trim() : typedText;
4705
+ : splitComposerAttachmentMarkers(text, images, markers, images.length > 0 && text === this.#editor.expandedText ? ranges : undefined);
4706
+ const submittedText = images.length === 0 ? this.#files.text(text)
4707
+ : segments.flatMap(segment => segment.kind === 'text' ? [segment.text] : []).join('');
4614
4708
  const literal = text === this.#editor.expandedText && (this.#literalInput || this.#editor.startsWithPaste);
4615
4709
  const historyText = submittedText.replace(/[ \t]{2,}/gu, ' ').trim();
4616
4710
  return {
@@ -4829,6 +4923,7 @@ export class LocalTui {
4829
4923
  const pending = this.#prompt;
4830
4924
  if (pending === null)
4831
4925
  return;
4926
+ this.#cancelPaste();
4832
4927
  const draft = structuredPromptAnswer(pending, this.#editor.expandedText);
4833
4928
  try {
4834
4929
  pending.request.onAnswerDraft?.(draft);
@@ -4852,6 +4947,8 @@ export class LocalTui {
4852
4947
  this.#wheelGesture = IDLE_WHEEL;
4853
4948
  if (pending.request.notify !== false) {
4854
4949
  this.#herdr.promptResolved();
4950
+ if (this.#herdr.programStatus.state !== 'blocked')
4951
+ this.#notificationQueue.clear('blocked');
4855
4952
  this.#syncWindowTitle();
4856
4953
  }
4857
4954
  pending.offAbort?.();
@@ -4979,6 +5076,7 @@ export class LocalTui {
4979
5076
  }
4980
5077
  this.#search = null;
4981
5078
  this.#ac = null;
5079
+ this.#cancelPaste();
4982
5080
  this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features, this.#pluginSettings?.entries(), discoverEditors(this.#externalEditor), this.#settingsActions());
4983
5081
  this.#refreshSettingsCatalog();
4984
5082
  this.#render();
@@ -5063,10 +5161,18 @@ export class LocalTui {
5063
5161
  const pending = this.#notificationQueue.take();
5064
5162
  if (pending === undefined || this.#disposed || this.#notificationPolicy === 'off')
5065
5163
  return;
5164
+ if (this.#session.handedOff)
5165
+ return;
5066
5166
  // Re-read focus at delivery time: the reader may have come back inside the
5067
5167
  // coalescing window, and a turn finishing while they watch is not news.
5068
5168
  if (this.#notificationPolicy === 'unfocused' && this.#terminalFocused === true)
5069
5169
  return;
5170
+ if (pending.programState !== undefined && pending.programState !== this.#herdr.programStatus.state)
5171
+ return;
5172
+ if (this.#reportsProgramState && programStatusNeedsAttention(this.#herdr.programStatus.state)) {
5173
+ this.#syncWindowTitle(true);
5174
+ return;
5175
+ }
5070
5176
  const sequence = terminalNotificationSequence(pending);
5071
5177
  if (sequence !== '')
5072
5178
  this.#term.output.write(sequence);
@@ -5391,7 +5497,7 @@ export class LocalTui {
5391
5497
  return true;
5392
5498
  }
5393
5499
  if (action === 'paste-clipboard') {
5394
- this.#startAsyncPaste(this.#pasteClipboard());
5500
+ this.#startAsyncPaste(signal => this.#pasteClipboard(signal));
5395
5501
  return true;
5396
5502
  }
5397
5503
  // Explicit queue delivery is independent of the busy-Enter preference.
@@ -5407,7 +5513,8 @@ export class LocalTui {
5407
5513
  this.#handTerminal(() => {
5408
5514
  this.#literalInput ||= this.#editor.startsWithPaste;
5409
5515
  const text = editExternally(this.#editor.expandedText, this.#externalEditor);
5410
- this.#editor.setText(text);
5516
+ while (this.#editor.expandPaste()) { /* Keep marker positions aligned with expanded source. */ }
5517
+ this.#editor.replaceText(text);
5411
5518
  this.#images.reconcile();
5412
5519
  });
5413
5520
  }