@agi-fans/dsh-tui 0.16.0 → 0.17.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 (62) hide show
  1. package/lib/CHANGELOG.md +18 -1
  2. package/lib/chrome/tool-args.d.ts +9 -0
  3. package/lib/chrome/tool-args.js +20 -0
  4. package/lib/chrome/tool-args.js.map +1 -0
  5. package/lib/chrome/tool-renderers.js +4 -13
  6. package/lib/chrome/tool-renderers.js.map +1 -1
  7. package/lib/commands/session.js +2 -4
  8. package/lib/commands/session.js.map +1 -1
  9. package/lib/runtime/composer-images.d.ts +44 -0
  10. package/lib/runtime/composer-images.js +149 -0
  11. package/lib/runtime/composer-images.js.map +1 -0
  12. package/lib/runtime/herdr-agent.d.ts +95 -0
  13. package/lib/runtime/herdr-agent.js +258 -0
  14. package/lib/runtime/herdr-agent.js.map +1 -0
  15. package/lib/runtime/plain-tui.d.ts +34 -0
  16. package/lib/runtime/plain-tui.js +119 -0
  17. package/lib/runtime/plain-tui.js.map +1 -0
  18. package/lib/runtime/provider-local.d.ts +3 -0
  19. package/lib/runtime/provider-local.js +107 -268
  20. package/lib/runtime/provider-local.js.map +1 -1
  21. package/lib/runtime/tool-presentation.js +15 -0
  22. package/lib/runtime/tool-presentation.js.map +1 -1
  23. package/lib/session/content-text.d.ts +12 -0
  24. package/lib/session/content-text.js +13 -0
  25. package/lib/session/content-text.js.map +1 -0
  26. package/lib/session/history-store.js.map +1 -0
  27. package/lib/session/json-file.d.ts +5 -0
  28. package/lib/session/json-file.js +21 -0
  29. package/lib/session/json-file.js.map +1 -0
  30. package/lib/session/model-favorites.js +15 -25
  31. package/lib/session/model-favorites.js.map +1 -1
  32. package/lib/session/release-notes.js +1 -18
  33. package/lib/session/release-notes.js.map +1 -1
  34. package/lib/session/session-controller.js +64 -140
  35. package/lib/session/session-controller.js.map +1 -1
  36. package/lib/session/session-library.js +4 -14
  37. package/lib/session/session-library.js.map +1 -1
  38. package/lib/session/subagent-roster.js +13 -43
  39. package/lib/session/subagent-roster.js.map +1 -1
  40. package/lib/session/subagent-tracker.d.ts +58 -0
  41. package/lib/session/subagent-tracker.js +129 -0
  42. package/lib/session/subagent-tracker.js.map +1 -0
  43. package/lib/session/update-check.js +2 -15
  44. package/lib/session/update-check.js.map +1 -1
  45. package/lib/session/version-compare.d.ts +3 -0
  46. package/lib/session/version-compare.js +21 -0
  47. package/lib/session/version-compare.js.map +1 -0
  48. package/lib/views/event-views.d.ts +11 -270
  49. package/lib/views/event-views.js +12 -964
  50. package/lib/views/event-views.js.map +1 -1
  51. package/lib/views/settings-list.js +12 -11
  52. package/lib/views/settings-list.js.map +1 -1
  53. package/lib/views/transcript-render.d.ts +179 -0
  54. package/lib/views/transcript-render.js +916 -0
  55. package/lib/views/transcript-render.js.map +1 -0
  56. package/lib/views/transcript-types.d.ts +104 -0
  57. package/lib/views/transcript-types.js +53 -0
  58. package/lib/views/transcript-types.js.map +1 -0
  59. package/package.json +2 -1
  60. package/lib/views/history-store.js.map +0 -1
  61. /package/lib/{views → session}/history-store.d.ts +0 -0
  62. /package/lib/{views → session}/history-store.js +0 -0
@@ -11,9 +11,11 @@
11
11
  * @module @agi-fans/dsh-tui
12
12
  */
13
13
  import { TerminalNotificationController, terminalNotificationSequence, terminalProgressSequence, } from "./terminal-notifications.js";
14
+ import { HerdrAgentReporter } from "./herdr-agent.js";
15
+ import { ComposerImages } from "./composer-images.js";
16
+ import { PlainTui } from "./plain-tui.js";
14
17
  import { homedir } from 'node:os';
15
18
  import { join, sep } from 'node:path';
16
- import { createInterface } from 'node:readline';
17
19
  import { StringDecoder } from 'node:string_decoder';
18
20
  import { TUI_SERVICE, } from "../definition.js";
19
21
  import { applySlashCompletion, formatHelpText, parseSlashInput, resolveSlashCommand, slashSuggestions, BUILTIN_SLASH_COMMANDS, } from "../views/autocomplete.js";
@@ -28,28 +30,42 @@ import { InputEditor, lineEnd, lineStart } from "../input/editor.js";
28
30
  import { applySettingsEvent, createSettings, } from "../views/settings-list.js";
29
31
  import { appendTrajectoryEvent, applyTrajectoryEvent, createTrajectory, trajectoryDetailMetrics, trajectoryListMetrics, } from "../views/trajectory.js";
30
32
  import { applyAgentHubEvent, createAgentHub, updateAgentHub, } from "../views/agent-hub.js";
31
- import { applyEvent, applyStreamChunk, blockLines, blockSearchText, initialTranscript, replayEvents, renderView, TRANSCRIPT_FAST_SCROLL, } from "../views/event-views.js";
33
+ import { applyEvent, applyStreamChunk, blockSearchText, initialTranscript, replayEvents, renderView, TRANSCRIPT_FAST_SCROLL, } from "../views/event-views.js";
32
34
  import { applyTranscriptSearchEvent, createTranscriptSearch, searchBlockIndexes, } from "../views/transcript-search.js";
33
35
  import { nextRevealStep, revealStreamingAssistant, streamingAssistantKey, streamingAssistantUnits, } from "../views/streaming-reveal.js";
34
36
  import { flushPending, parseKeys } from "../input/keys.js";
35
37
  import { MainScreenRenderer } from "../chrome/main-screen-renderer.js";
36
- import { colorDisabledByEnv, createTheme, detectTrueColor, parseThemeName } from "../chrome/theme.js";
38
+ import { colorDisabledByEnv, detectTrueColor, parseThemeName } from "../chrome/theme.js";
37
39
  import { renderTool } from "../chrome/tool-renderers.js";
38
40
  import { TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "../session/tui-settings.js";
39
41
  import { defaultStatusBarConfig, resolveStatusBarConfig } from "../chrome/status-config.js";
40
- import { HistoryStore } from "../views/history-store.js";
42
+ import { HistoryStore } from "../session/history-store.js";
41
43
  import { loadKeybindings } from "../input/keybindings-config.js";
42
44
  import { editExternally } from "../input/external-editor.js";
43
45
  import { movePromptSelection, filteredPromptOptions, selectedPromptAnswer, selectedFilteredPromptAnswer, togglePromptSelection, } from "../views/prompt-selector.js";
44
46
  import { refreshProjectContext, resolveProjectContext } from "../session/project-context.js";
45
47
  import { pickWelcomeTips } from "../chrome/welcome-tips.js";
46
48
  import { formatEssentialHotkeysText, formatHotkeysText, hotkeyCount } from "../views/hotkeys.js";
47
- import { imageMarker, imagePathCandidates, probeImageDimensions, stripComposerImageMarkers, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
49
+ import { imagePathCandidates, stripComposerImageMarkers, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
48
50
  import { APP_NAME, APP_VERSION } from "../session/package-metadata.js";
49
51
  const DOUBLE_CTRL_C_MS = 500;
50
52
  const DOUBLE_ESCAPE_MS = 500;
51
53
  const STREAMING_REVEAL_MS = 1000 / 30;
52
54
  const TERMINAL_PROGRESS_KEEPALIVE_MS = 1_000;
55
+ /**
56
+ * Durable settlement events that arrive in bursts while a turn runs. Renders
57
+ * for these are coalesced through the stream-render timer; rarer control
58
+ * events (command lifecycle, inbox splices, titles, notices) render at once.
59
+ */
60
+ const BURST_EVENT_TYPES = new Set([
61
+ 'step/start',
62
+ 'step/end',
63
+ 'tool/call',
64
+ 'tool/result',
65
+ 'assistant/message',
66
+ 'assistant/attempt',
67
+ 'llm/retry',
68
+ ]);
53
69
  function shortenPath(cwd) {
54
70
  const home = homedir();
55
71
  if (cwd === home)
@@ -146,11 +162,11 @@ export class LocalTui {
146
162
  #pasteBuf = '';
147
163
  #pasteInFlight = 0;
148
164
  #deferredPasteEvents = [];
149
- #images = [];
150
- #lineReader = null;
151
- #plainPending = null;
152
- #plainClosed = false;
153
- #plainPrinted = 0;
165
+ #images = new ComposerImages({
166
+ editor: this.#editor,
167
+ notice: text => { this.notice(text, { level: 'error' }); },
168
+ });
169
+ #plain;
154
170
  #offData = null;
155
171
  #offResize = null;
156
172
  #pwd;
@@ -173,6 +189,12 @@ export class LocalTui {
173
189
  #checkUpdates = true;
174
190
  #startupChangelog = 'summary';
175
191
  #notifications = new TerminalNotificationController();
192
+ /**
193
+ * Herdr lifecycle authority for this pane. The plugin entry injects the
194
+ * environment-aware reporter; a directly constructed instance is inert so
195
+ * tests running inside a Herdr pane cannot report against the live server.
196
+ */
197
+ #herdr;
176
198
  #notificationPolicy = 'off';
177
199
  #notificationThreshold = '30s';
178
200
  #statusBar = defaultStatusBarConfig();
@@ -220,7 +242,6 @@ export class LocalTui {
220
242
  #searchFiles;
221
243
  #searchFileMentions;
222
244
  #searchSessions;
223
- #validateImageDraft;
224
245
  #autocompleteDebounceMs;
225
246
  #persistPrefs = null;
226
247
  #agentBehavior;
@@ -264,8 +285,15 @@ export class LocalTui {
264
285
  this.#terminalProfile = paths.terminalProfile ?? detectTerminalProfile();
265
286
  this.#resizeDebounceMs = Math.max(0, paths.resizeDebounceMs ?? 120);
266
287
  this.#streamRenderMs = Math.max(0, paths.streamRenderMs ?? 8);
288
+ this.#herdr = paths.herdrReporter ?? new HerdrAgentReporter({ env: {} });
267
289
  this.#syncTrueColor();
268
290
  this.#tty = term.input.isTTY === true;
291
+ this.#plain = new PlainTui({
292
+ term,
293
+ blocks: () => this.#state.blocks,
294
+ promptRequest: () => this.#prompt?.request,
295
+ finishPrompt: answer => { this.#finishPrompt(answer); },
296
+ });
269
297
  this.#pwd = shortenPath(project.root);
270
298
  this.#branch = project.gitLabel;
271
299
  this.#renderer = new MainScreenRenderer({ write: (chunk) => { this.#term.output.write(chunk); } }, {
@@ -301,6 +329,7 @@ export class LocalTui {
301
329
  term.output.write('\x1b[?2004h');
302
330
  }
303
331
  this.#render();
332
+ this.#herdr.start();
304
333
  }
305
334
  /** Refresh Git workspace metadata after a turn; the footer shows realtime branch/dirty state. */
306
335
  #refreshProjectContext() {
@@ -325,10 +354,18 @@ export class LocalTui {
325
354
  this.#syncStreamingReveal(undefined);
326
355
  this.#syncTick();
327
356
  if (this.#tty) {
328
- this.#render();
357
+ // Settlement events arrive in bursts (parallel tool calls, in-process
358
+ // subagent settlements); coalesce them like setSession. Control events
359
+ // (user echo, command lifecycle, inbox splices) stay immediate.
360
+ if (this.#streamRenderMs > 0 && this.#busy() && BURST_EVENT_TYPES.has(event.type)) {
361
+ this.#scheduleStreamRender();
362
+ }
363
+ else {
364
+ this.#render();
365
+ }
329
366
  }
330
367
  else if (event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'tool/result' || event.type === 'turn/end') {
331
- this.#printPlain();
368
+ this.#plain.print();
332
369
  }
333
370
  }
334
371
  /**
@@ -353,6 +390,10 @@ export class LocalTui {
353
390
  }
354
391
  }
355
392
  setStatus(status) {
393
+ // Only the displayed top-level agent drives the Herdr pane state; an
394
+ // inspected child must not, and closing the inspector re-syncs the root.
395
+ if (this.#inspected === undefined)
396
+ this.#herdr.setRunning(status === 'running');
356
397
  if (this.#state.status === 'compacting')
357
398
  return;
358
399
  this.#state = { ...this.#state, status };
@@ -463,7 +504,7 @@ export class LocalTui {
463
504
  this.#render();
464
505
  }
465
506
  setImageValidator(validate) {
466
- this.#validateImageDraft = validate;
507
+ this.#images.setValidator(validate);
467
508
  }
468
509
  toolCallContext(callId) {
469
510
  const block = this.#state.blocks.findLast(candidate => candidate.kind === 'tool' && String(candidate.callId) === callId);
@@ -501,14 +542,14 @@ export class LocalTui {
501
542
  if (this.#tty)
502
543
  this.#render();
503
544
  else
504
- this.#printPlain();
545
+ this.#plain.print();
505
546
  }
506
547
  commandOutput(command, text) {
507
548
  this.#state = { ...this.#state, blocks: [...this.#state.blocks, { kind: 'commandOutput', command, text }] };
508
549
  if (this.#tty)
509
550
  this.#render();
510
551
  else
511
- this.#printPlain();
552
+ this.#plain.print();
512
553
  }
513
554
  prompt(request) {
514
555
  if (this.#prompt !== null)
@@ -516,6 +557,7 @@ export class LocalTui {
516
557
  if (this.#disposed || request.signal?.aborted === true)
517
558
  return Promise.resolve(null);
518
559
  this.#emitNotification(this.#notifications.humanPrompt());
560
+ this.#herdr.prompt(request.title);
519
561
  this.#editor.setText('');
520
562
  this.#ac = null;
521
563
  const displaced = this.#displaceSurface();
@@ -552,17 +594,18 @@ export class LocalTui {
552
594
  this.#trajectory = null;
553
595
  const state = replayEvents(events, presentations);
554
596
  this.#state = { ...state, status, compactCommandId: undefined };
555
- this.#plainPrinted = 0;
597
+ this.#plain.resetPrinted();
556
598
  this.#followTail();
557
599
  this.#deferInitialRender = false;
558
600
  this.#renderer.startEpoch({ replay: status === 'idle' ? 'full' : 'pinned' });
559
601
  if (this.#tty)
560
602
  this.#render();
561
603
  else
562
- this.#printPlain();
604
+ this.#plain.print();
563
605
  }
564
606
  setSession(info) {
565
607
  this.#sessionId = info.id;
608
+ this.#herdr.setSession(info.id);
566
609
  const title = info.title?.trim();
567
610
  this.#sessionTitle = title === undefined || title === '' ? undefined : title;
568
611
  this.#recentSessions = info.recent.map((session) => ({ ...session }));
@@ -604,25 +647,7 @@ export class LocalTui {
604
647
  }
605
648
  /** Apply prefs loaded from the settings document (does not persist). */
606
649
  applyStoredPrefs(prefs) {
607
- const previousMotion = this.#motion;
608
- this.#themeName = prefs.theme;
609
- this.#colors = prefs.colors;
610
- this.#syncTrueColor();
611
- this.#motion = prefs.motion ?? 'full';
612
- this.#terminalProgress = prefs.terminalProgress ?? false;
613
- this.#expandTools = prefs.expandTools;
614
- this.#checkUpdates = prefs.checkUpdates ?? true;
615
- this.#startupChangelog = prefs.startupChangelog ?? 'summary';
616
- this.#notificationPolicy = prefs.notifications ?? 'off';
617
- this.#notificationThreshold = prefs.notificationThreshold ?? '30s';
618
- this.#configureNotifications();
619
- this.#statusBar = resolveStatusBarConfig(prefs.statusBar, prefs.statusPreset);
620
- this.#toolsExpanded = prefs.expandTools;
621
- if (previousMotion !== this.#motion) {
622
- this.#stopRevealTick();
623
- this.#reveal = undefined;
624
- }
625
- this.#syncTick();
650
+ this.#applyPrefs(prefs, { persist: false, forceToolsSync: true });
626
651
  if (this.#settings !== null)
627
652
  this.#settings = { ...this.#settings, prefs };
628
653
  if (this.#tty)
@@ -667,7 +692,7 @@ export class LocalTui {
667
692
  return Promise.resolve(null);
668
693
  }
669
694
  if (!this.#tty)
670
- return this.#readlinePlain(signal);
695
+ return this.#plain.readline(signal);
671
696
  // Lines submitted while a turn was still running were queued instead of
672
697
  // dropped; serve the oldest before waiting for fresh input.
673
698
  const queued = this.#queuedSubmissions.shift();
@@ -695,16 +720,7 @@ export class LocalTui {
695
720
  return (await this.readInput())?.text ?? null;
696
721
  }
697
722
  restoreInput(submission) {
698
- const currentText = this.#editor.text;
699
- const currentImages = this.#images;
700
- let rebasedCurrent = currentText;
701
- for (let index = currentImages.length - 1; index >= 0; index -= 1) {
702
- const image = currentImages[index];
703
- rebasedCurrent = rebasedCurrent.replaceAll(imageMarker(index, image), imageMarker(index + submission.images.length, image));
704
- }
705
- const separator = submission.text !== '' && rebasedCurrent !== '' ? '\n' : '';
706
- this.#images = [...submission.images.map(image => ({ ...image })), ...currentImages];
707
- this.#editor.setText(submission.text + separator + rebasedCurrent);
723
+ this.#images.restore(submission);
708
724
  this.#refreshAutocomplete();
709
725
  if (this.#tty)
710
726
  this.#render();
@@ -714,14 +730,14 @@ export class LocalTui {
714
730
  return;
715
731
  this.#queueEditPending = false;
716
732
  if (submission === null) {
717
- if (this.#editor.text === '' && this.#images.length === 0 && this.#queueEditNewer?.length === 0) {
733
+ if (this.#editor.text === '' && this.#images.count === 0 && this.#queueEditNewer?.length === 0) {
718
734
  this.#queueEditNewer = null;
719
735
  }
720
736
  return;
721
737
  }
722
738
  if (this.#queueEditNewer === null)
723
739
  this.#queueEditNewer = [];
724
- if (this.#editor.text !== '' || this.#images.length > 0) {
740
+ if (this.#editor.text !== '' || this.#images.count > 0) {
725
741
  this.#queueEditNewer.unshift(this.#currentSubmission());
726
742
  }
727
743
  this.#replaceInput(submission);
@@ -729,12 +745,11 @@ export class LocalTui {
729
745
  #currentSubmission() {
730
746
  return {
731
747
  text: this.#editor.text,
732
- images: this.#images.map(image => ({ ...image })),
748
+ images: this.#images.copies(),
733
749
  };
734
750
  }
735
751
  #replaceInput(submission) {
736
- this.#images = submission.images.map(image => ({ ...image }));
737
- this.#editor.setText(submission.text);
752
+ this.#images.replace(submission);
738
753
  this.#historyIndex = 0;
739
754
  this.#refreshAutocomplete();
740
755
  if (this.#tty)
@@ -814,11 +829,12 @@ export class LocalTui {
814
829
  this.#autocompleteAbort?.abort();
815
830
  this.#autocompleteTimer = null;
816
831
  this.#autocompleteAbort = null;
817
- this.#lineReader?.close();
832
+ this.#plain.dispose();
818
833
  this.#offAgentBehaviorWatch?.();
819
834
  this.#offAgentBehaviorWatch = undefined;
820
835
  this.#settlePending(null);
821
836
  this.#finishPrompt(null);
837
+ this.#herdr.release();
822
838
  }
823
839
  /** Settle one pending read, detaching its abort listener. */
824
840
  #settlePending(submission) {
@@ -833,96 +849,6 @@ export class LocalTui {
833
849
  refresh() {
834
850
  this.#render();
835
851
  }
836
- #readlinePlain(signal) {
837
- return new Promise((resolve) => {
838
- if (this.#lineReader === null) {
839
- this.#lineReader = createInterface({ input: this.#term.input });
840
- // Permanent listeners: once() handlers would auto-pause the input
841
- // stream after one line and miss the EOF close.
842
- this.#lineReader.on('line', (line) => { this.#plainResolve(line); });
843
- this.#lineReader.on('close', () => {
844
- this.#plainClosed = true;
845
- this.#plainResolve(null);
846
- });
847
- }
848
- const pending = { resolve, signal: signal ?? null };
849
- this.#plainPending = pending;
850
- if (signal !== undefined) {
851
- const onAbort = () => {
852
- if (this.#plainPending !== pending)
853
- return;
854
- this.#plainPending = null;
855
- resolve(null);
856
- };
857
- signal.addEventListener('abort', onAbort, { once: true });
858
- pending.offAbort = () => { signal.removeEventListener('abort', onAbort); };
859
- }
860
- this.#pumpPlain();
861
- });
862
- }
863
- /**
864
- * Queue one readline delivery, then drain. A single stream chunk can carry
865
- * several lines; every line is buffered until the runner asks for the next
866
- * read, and EOF waits for the queue before closing the reader.
867
- */
868
- #plainResolve(line) {
869
- if (this.#prompt !== null && line !== null) {
870
- const value = line.trim();
871
- if (value === '') {
872
- this.#finishPrompt(null);
873
- }
874
- else if (this.#prompt.request.allowCustom === false) {
875
- const options = this.#prompt.request.options ?? [];
876
- const numeric = /^\d+$/u.test(value) ? Number(value) - 1 : -1;
877
- const option = numeric >= 0
878
- ? options[numeric]
879
- : options.find(item => item.label.toLowerCase() === value.toLowerCase());
880
- this.#finishPrompt(option?.value ?? option?.label ?? null);
881
- }
882
- else {
883
- this.#finishPrompt(value);
884
- }
885
- return;
886
- }
887
- if (this.#prompt !== null)
888
- this.#finishPrompt(null);
889
- if (line !== null)
890
- this.#plainQueue.push(line);
891
- this.#pumpPlain();
892
- }
893
- /** Resolve the pending plain read from the queue, or close it after EOF drained. */
894
- #pumpPlain() {
895
- const pending = this.#plainPending;
896
- if (pending === null)
897
- return;
898
- const line = this.#plainQueue.shift();
899
- if (line !== undefined) {
900
- pending.offAbort?.();
901
- this.#plainPending = null;
902
- pending.resolve({ text: line, images: [] });
903
- return;
904
- }
905
- if (this.#plainClosed) {
906
- pending.offAbort?.();
907
- this.#plainPending = null;
908
- pending.resolve(null);
909
- }
910
- }
911
- /** Print plain-mode blocks that settled since the last flush. */
912
- #printPlain() {
913
- const theme = createTheme(false, false);
914
- const width = this.#term.width();
915
- const fresh = this.#state.blocks.slice(this.#plainPrinted);
916
- let out = '';
917
- for (const block of fresh) {
918
- // Pipe / CI output is not a viewport: print the full tool body.
919
- for (const line of blockLines(block, theme, width, 0, true))
920
- out += line + '\n';
921
- }
922
- this.#plainPrinted = this.#state.blocks.length;
923
- if (out !== '')
924
- this.#term.output.write(out);
925
- }
926
852
  #busy() {
927
853
  if (this.#state.status !== 'idle')
928
854
  return true;
@@ -1048,7 +974,7 @@ export class LocalTui {
1048
974
  ...(this.#reasoningEffort === undefined ? {} : { reasoningEffort: this.#reasoningEffort }),
1049
975
  input: this.#editor.text,
1050
976
  inputCursor: this.#editor.cursor,
1051
- inputImages: this.#images.length,
977
+ inputImages: this.#images.count,
1052
978
  queuedSubmissions: this.#queueEditNewer === null
1053
979
  ? this.#queuedSubmissions
1054
980
  : [...this.#queuedSubmissions, ...this.#queueEditNewer],
@@ -1143,7 +1069,6 @@ export class LocalTui {
1143
1069
  this.#scrollStart = this.#maxStart;
1144
1070
  }
1145
1071
  #utf8 = new StringDecoder('utf8');
1146
- #plainQueue = [];
1147
1072
  #onData(chunk) {
1148
1073
  // Decode bytes across the whole stream so a multi-byte UTF-8 character
1149
1074
  // split between data events is never corrupted into replacement chars.
@@ -1204,8 +1129,8 @@ export class LocalTui {
1204
1129
  if (images.every((image) => image !== null)) {
1205
1130
  let inserted = false;
1206
1131
  for (const image of images) {
1207
- if (await this.#admitImage(image)) {
1208
- this.#insertImageDraft(image);
1132
+ if (await this.#images.admit(image)) {
1133
+ this.#images.insert(image);
1209
1134
  inserted = true;
1210
1135
  }
1211
1136
  }
@@ -1220,7 +1145,7 @@ export class LocalTui {
1220
1145
  // image instead of leaking that stale path into the prompt.
1221
1146
  const clipboardImage = await this.#readClipboardImage();
1222
1147
  if (clipboardImage !== null) {
1223
- this.#insertImageDraft(clipboardImage);
1148
+ this.#images.insert(clipboardImage);
1224
1149
  this.#refreshAutocomplete();
1225
1150
  this.#render();
1226
1151
  return;
@@ -1234,8 +1159,8 @@ export class LocalTui {
1234
1159
  if (this.#search === null && this.#settings === null && this.#copySelector === null) {
1235
1160
  const image = await this.#readClipboardImage();
1236
1161
  if (image !== null) {
1237
- if (await this.#admitImage(image)) {
1238
- this.#insertImageDraft(image);
1162
+ if (await this.#images.admit(image)) {
1163
+ this.#images.insert(image);
1239
1164
  this.#refreshAutocomplete();
1240
1165
  this.#render();
1241
1166
  }
@@ -1249,8 +1174,8 @@ export class LocalTui {
1249
1174
  if (images.length > 0) {
1250
1175
  let inserted = false;
1251
1176
  for (const candidate of images) {
1252
- if (await this.#admitImage(candidate)) {
1253
- this.#insertImageDraft(candidate);
1177
+ if (await this.#images.admit(candidate)) {
1178
+ this.#images.insert(candidate);
1254
1179
  inserted = true;
1255
1180
  }
1256
1181
  }
@@ -1266,98 +1191,6 @@ export class LocalTui {
1266
1191
  if (text !== '')
1267
1192
  await this.#acceptPastedText(text);
1268
1193
  }
1269
- /**
1270
- * Run the Harness image-admission check for one paste candidate. A refusal
1271
- * becomes an error notice and skips the draft, instead of failing the whole
1272
- * submission after the user has typed a prompt around it.
1273
- */
1274
- async #admitImage(image) {
1275
- if (this.#validateImageDraft === undefined)
1276
- return true;
1277
- try {
1278
- await this.#validateImageDraft(image);
1279
- return true;
1280
- }
1281
- catch (error) {
1282
- this.notice(error instanceof Error ? error.message : String(error), { level: 'error' });
1283
- return false;
1284
- }
1285
- }
1286
- #insertImageDraft(input) {
1287
- const size = input.width === undefined || input.height === undefined
1288
- ? probeImageDimensions(input.data, input.mediaType)
1289
- : undefined;
1290
- const image = {
1291
- ...input,
1292
- ...(input.width === undefined && size !== undefined ? { width: size.width } : {}),
1293
- ...(input.height === undefined && size !== undefined ? { height: size.height } : {}),
1294
- };
1295
- const marker = imageMarker(this.#images.length, image);
1296
- const before = this.#editor.cursor > 0 && !/\s/u.test(this.#editor.text[this.#editor.cursor - 1] ?? '') ? ' ' : '';
1297
- const after = this.#editor.cursor >= this.#editor.text.length || !/\s/u.test(this.#editor.text[this.#editor.cursor] ?? '')
1298
- ? ' '
1299
- : '';
1300
- this.#images.push(image);
1301
- this.#editor.handle({ type: 'text', value: before + marker + after });
1302
- }
1303
- #removeImageAtCursor(key) {
1304
- const cursor = this.#editor.cursor;
1305
- for (let index = 0; index < this.#images.length; index += 1) {
1306
- const image = this.#images[index];
1307
- const marker = imageMarker(index, image);
1308
- const start = this.#editor.text.indexOf(marker);
1309
- if (start < 0)
1310
- continue;
1311
- let from = start;
1312
- let to = start + marker.length;
1313
- const touches = key === 'backspace'
1314
- ? cursor > start && cursor <= to
1315
- : cursor >= start && cursor < to;
1316
- if (!touches)
1317
- continue;
1318
- if (this.#editor.text[to] === ' ')
1319
- to += 1;
1320
- else if (from > 0 && this.#editor.text[from - 1] === ' ')
1321
- from -= 1;
1322
- const oldImages = this.#images;
1323
- let text = this.#editor.text.slice(0, from) + this.#editor.text.slice(to);
1324
- const nextImages = oldImages.filter((_, oldIndex) => oldIndex !== index);
1325
- let nextIndex = 0;
1326
- for (let oldIndex = 0; oldIndex < oldImages.length; oldIndex += 1) {
1327
- if (oldIndex === index)
1328
- continue;
1329
- const remaining = oldImages[oldIndex];
1330
- text = text.replaceAll(imageMarker(oldIndex, remaining), imageMarker(nextIndex, remaining));
1331
- nextIndex += 1;
1332
- }
1333
- this.#images = nextImages;
1334
- this.#editor.setText(text, Math.min(from, text.length));
1335
- this.#refreshAutocomplete();
1336
- this.#render();
1337
- return true;
1338
- }
1339
- return false;
1340
- }
1341
- #reconcileImageDrafts() {
1342
- if (this.#images.length === 0)
1343
- return;
1344
- const oldImages = this.#images;
1345
- const retained = oldImages.filter((image, index) => this.#editor.text.includes(imageMarker(index, image)));
1346
- if (retained.length === oldImages.length)
1347
- return;
1348
- let text = this.#editor.text;
1349
- let nextIndex = 0;
1350
- for (let oldIndex = 0; oldIndex < oldImages.length; oldIndex += 1) {
1351
- const image = oldImages[oldIndex];
1352
- const oldMarker = imageMarker(oldIndex, image);
1353
- if (!text.includes(oldMarker))
1354
- continue;
1355
- text = text.replaceAll(oldMarker, imageMarker(nextIndex, image));
1356
- nextIndex += 1;
1357
- }
1358
- this.#images = retained;
1359
- this.#editor.setText(text, Math.min(this.#editor.cursor, text.length));
1360
- }
1361
1194
  #dispatch(event) {
1362
1195
  if (this.#state.status === 'compacting') {
1363
1196
  if (event.type === 'key' && event.id === 'pageUp') {
@@ -1483,7 +1316,7 @@ export class LocalTui {
1483
1316
  }
1484
1317
  else {
1485
1318
  this.#editor.clear();
1486
- this.#images = [];
1319
+ this.#images.clear();
1487
1320
  this.#historyIndex = 0;
1488
1321
  this.#ac = null;
1489
1322
  this.#render();
@@ -1511,9 +1344,9 @@ export class LocalTui {
1511
1344
  return;
1512
1345
  if (event.type === 'key' && event.id === 'escape') {
1513
1346
  if (this.#inspected !== undefined) {
1514
- if (this.#inspected.writable === true && (this.#editor.text !== '' || this.#images.length > 0)) {
1347
+ if (this.#inspected.writable === true && (this.#editor.text !== '' || this.#images.count > 0)) {
1515
1348
  this.#editor.clear();
1516
- this.#images = [];
1349
+ this.#images.clear();
1517
1350
  this.#ac = null;
1518
1351
  this.#render();
1519
1352
  return;
@@ -1522,7 +1355,7 @@ export class LocalTui {
1522
1355
  return;
1523
1356
  }
1524
1357
  if (this.#state.status !== 'idle' || this.#pending === null
1525
- || this.#editor.text !== '' || this.#images.length > 0) {
1358
+ || this.#editor.text !== '' || this.#images.count > 0) {
1526
1359
  this.#lastEscapeTime = 0;
1527
1360
  }
1528
1361
  else {
@@ -1539,8 +1372,11 @@ export class LocalTui {
1539
1372
  }
1540
1373
  }
1541
1374
  if (event.type === 'key' && (event.id === 'backspace' || event.id === 'delete')
1542
- && this.#removeImageAtCursor(event.id))
1375
+ && this.#images.removeAtCursor(event.id)) {
1376
+ this.#refreshAutocomplete();
1377
+ this.#render();
1543
1378
  return;
1379
+ }
1544
1380
  this.#applyCommand(this.#editor.handle(event));
1545
1381
  }
1546
1382
  #handlePrompt(event) {
@@ -1751,7 +1587,7 @@ export class LocalTui {
1751
1587
  }
1752
1588
  if (!hasAgents || event.type !== 'key' || event.id !== 'down')
1753
1589
  return false;
1754
- if (this.#editor.text !== '' || this.#images.length > 0 || this.#historyIndex !== 0 || this.#ac !== null)
1590
+ if (this.#editor.text !== '' || this.#images.count > 0 || this.#historyIndex !== 0 || this.#ac !== null)
1755
1591
  return false;
1756
1592
  if (this.#queuedSubmissions.length > 0 || this.#queueEditNewer !== null || this.#queueEditPending)
1757
1593
  return false;
@@ -1806,12 +1642,12 @@ export class LocalTui {
1806
1642
  },
1807
1643
  };
1808
1644
  }
1809
- #applyPrefs(prefs) {
1645
+ #applyPrefs(prefs, options) {
1646
+ const previousMotion = this.#motion;
1810
1647
  const expandChanged = prefs.expandTools !== this.#expandTools;
1811
1648
  this.#themeName = prefs.theme;
1812
1649
  this.#colors = prefs.colors;
1813
1650
  this.#syncTrueColor();
1814
- const previousMotion = this.#motion;
1815
1651
  this.#motion = prefs.motion ?? 'full';
1816
1652
  this.#terminalProgress = prefs.terminalProgress ?? false;
1817
1653
  this.#expandTools = prefs.expandTools;
@@ -1821,14 +1657,15 @@ export class LocalTui {
1821
1657
  this.#notificationThreshold = prefs.notificationThreshold ?? '30s';
1822
1658
  this.#configureNotifications();
1823
1659
  this.#statusBar = resolveStatusBarConfig(prefs.statusBar, prefs.statusPreset);
1824
- if (expandChanged)
1660
+ if (options.forceToolsSync || expandChanged)
1825
1661
  this.#toolsExpanded = prefs.expandTools;
1826
1662
  if (previousMotion !== this.#motion) {
1827
1663
  this.#stopRevealTick();
1828
1664
  this.#reveal = undefined;
1829
1665
  }
1830
1666
  this.#syncTick();
1831
- this.#persistPrefs?.(prefs);
1667
+ if (options.persist)
1668
+ this.#persistPrefs?.(prefs);
1832
1669
  }
1833
1670
  #applyStoredAgentBehavior(next) {
1834
1671
  this.#agentBehavior = { language: next.language };
@@ -1878,7 +1715,7 @@ export class LocalTui {
1878
1715
  return;
1879
1716
  }
1880
1717
  this.#settings = command.state;
1881
- this.#applyPrefs(command.state.prefs);
1718
+ this.#applyPrefs(command.state.prefs, { persist: true, forceToolsSync: false });
1882
1719
  this.#render();
1883
1720
  return;
1884
1721
  }
@@ -2090,7 +1927,7 @@ export class LocalTui {
2090
1927
  }
2091
1928
  #applyCommand(command) {
2092
1929
  if (command.kind === 'changed') {
2093
- this.#reconcileImageDrafts();
1930
+ this.#images.reconcile();
2094
1931
  if (command.edited === true)
2095
1932
  this.#historyIndex = 0;
2096
1933
  this.#refreshAutocomplete();
@@ -2125,7 +1962,7 @@ export class LocalTui {
2125
1962
  }
2126
1963
  if (command.kind === 'clear') {
2127
1964
  this.#editor.clear();
2128
- this.#images = [];
1965
+ this.#images.clear();
2129
1966
  this.#queueEditNewer = null;
2130
1967
  this.#queueEditPending = false;
2131
1968
  this.#historyIndex = 0;
@@ -2153,7 +1990,7 @@ export class LocalTui {
2153
1990
  }
2154
1991
  }
2155
1992
  #historyPrev() {
2156
- if (this.#images.length > 0)
1993
+ if (this.#images.count > 0)
2157
1994
  return;
2158
1995
  if (this.#history.length === 0 || this.#historyIndex >= this.#history.length)
2159
1996
  return;
@@ -2182,7 +2019,7 @@ export class LocalTui {
2182
2019
  listener();
2183
2020
  return true;
2184
2021
  }
2185
- if (this.#editor.text !== '' || this.#images.length > 0 || this.#historyIndex !== 0)
2022
+ if (this.#editor.text !== '' || this.#images.count > 0 || this.#historyIndex !== 0)
2186
2023
  return false;
2187
2024
  const submission = this.#queuedSubmissions.pop();
2188
2025
  if (submission !== undefined) {
@@ -2200,7 +2037,7 @@ export class LocalTui {
2200
2037
  return true;
2201
2038
  }
2202
2039
  #historyNext() {
2203
- if (this.#images.length > 0)
2040
+ if (this.#images.count > 0)
2204
2041
  return;
2205
2042
  if (this.#historyIndex === 0)
2206
2043
  return;
@@ -2228,7 +2065,7 @@ export class LocalTui {
2228
2065
  this.#render();
2229
2066
  return;
2230
2067
  }
2231
- const images = this.#images.map(image => ({ ...image }));
2068
+ const images = this.#images.copies();
2232
2069
  const submittedText = images.length > 0 ? text.trim() : text;
2233
2070
  const queueEditNewer = this.#queueEditNewer;
2234
2071
  const historyText = stripComposerImageMarkers(submittedText, images)
@@ -2243,7 +2080,7 @@ export class LocalTui {
2243
2080
  this.#queueEditNewer = null;
2244
2081
  this.#queueEditPending = false;
2245
2082
  this.#editor.setText('');
2246
- this.#images = [];
2083
+ this.#images.clear();
2247
2084
  this.#ac = null;
2248
2085
  this.#search = null;
2249
2086
  this.#settings = null;
@@ -2279,7 +2116,7 @@ export class LocalTui {
2279
2116
  this.#render();
2280
2117
  }
2281
2118
  #submitInspect(text) {
2282
- const images = this.#images.map(image => ({ ...image }));
2119
+ const images = this.#images.copies();
2283
2120
  const submittedText = images.length > 0 ? text.trim() : text;
2284
2121
  const historyText = stripComposerImageMarkers(submittedText, images)
2285
2122
  .replace(/[ \t]{2,}/gu, ' ')
@@ -2291,7 +2128,7 @@ export class LocalTui {
2291
2128
  this.#historyIndex = 0;
2292
2129
  this.#draft = '';
2293
2130
  this.#editor.setText('');
2294
- this.#images = [];
2131
+ this.#images.clear();
2295
2132
  this.#ac = null;
2296
2133
  this.#search = null;
2297
2134
  this.#followTail();
@@ -2410,6 +2247,7 @@ export class LocalTui {
2410
2247
  if (pending === null)
2411
2248
  return;
2412
2249
  this.#prompt = null;
2250
+ this.#herdr.promptResolved();
2413
2251
  pending.offAbort?.();
2414
2252
  pending.resolve(answer);
2415
2253
  this.#restoreDisplacedSurface();
@@ -2559,7 +2397,7 @@ export class LocalTui {
2559
2397
  return true;
2560
2398
  }
2561
2399
  if (action === 'search-history') {
2562
- if (this.#images.length > 0)
2400
+ if (this.#images.count > 0)
2563
2401
  return false;
2564
2402
  this.#search = createHistorySearch(this.#history);
2565
2403
  this.#ac = null;
@@ -2571,7 +2409,7 @@ export class LocalTui {
2571
2409
  // transcript there; a non-empty draft keeps the editor's own binding.
2572
2410
  if (this.#search !== null)
2573
2411
  return false;
2574
- if (this.#editor.text !== '' || this.#images.length > 0)
2412
+ if (this.#editor.text !== '' || this.#images.count > 0)
2575
2413
  return false;
2576
2414
  this.#transcriptSearch = createTranscriptSearch();
2577
2415
  this.#ac = null;
@@ -2615,7 +2453,7 @@ export class LocalTui {
2615
2453
  this.#term.input.setRawMode?.(false);
2616
2454
  const text = editExternally(this.#editor.text);
2617
2455
  this.#editor.setText(text);
2618
- this.#reconcileImageDrafts();
2456
+ this.#images.reconcile();
2619
2457
  }
2620
2458
  catch (error) {
2621
2459
  this.notice(error instanceof Error ? error.message : String(error), { level: 'error' });
@@ -2687,6 +2525,7 @@ export function apply(ctx, config) {
2687
2525
  deferInitialRender: true,
2688
2526
  terminalProfile,
2689
2527
  alternateScreenOverlays: terminalProfile === 'direct',
2528
+ herdrReporter: new HerdrAgentReporter(),
2690
2529
  historyPath: config.historyPath ?? join(dshHome, 'omdsh', 'history.jsonl'),
2691
2530
  keybindingsPath: config.keybindingsPath ?? join(dshHome, 'omdsh', 'keybindings.json'),
2692
2531
  });