@agi-fans/dsh-tui 0.6.1 → 0.8.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 (55) hide show
  1. package/lib/CHANGELOG.md +40 -1
  2. package/lib/chrome/box.d.ts +0 -7
  3. package/lib/chrome/box.js +1 -21
  4. package/lib/chrome/box.js.map +1 -1
  5. package/lib/chrome/main-screen-renderer.d.ts +55 -0
  6. package/lib/chrome/main-screen-renderer.js +343 -0
  7. package/lib/chrome/main-screen-renderer.js.map +1 -0
  8. package/lib/chrome/path-mentions.js +12 -3
  9. package/lib/chrome/path-mentions.js.map +1 -1
  10. package/lib/chrome/renderer.d.ts +8 -31
  11. package/lib/chrome/renderer.js.map +1 -1
  12. package/lib/chrome/welcome-tips.js +1 -1
  13. package/lib/chrome/welcome-tips.js.map +1 -1
  14. package/lib/definition.d.ts +14 -1
  15. package/lib/input/image-paste.d.ts +12 -0
  16. package/lib/input/image-paste.js +41 -0
  17. package/lib/input/image-paste.js.map +1 -1
  18. package/lib/input/keys.d.ts +1 -28
  19. package/lib/input/keys.js +15 -31
  20. package/lib/input/keys.js.map +1 -1
  21. package/lib/runtime/provider-local.d.ts +14 -6
  22. package/lib/runtime/provider-local.js +243 -238
  23. package/lib/runtime/provider-local.js.map +1 -1
  24. package/lib/runtime/runner.js +9 -3
  25. package/lib/runtime/runner.js.map +1 -1
  26. package/lib/session/session-controller.d.ts +5 -2
  27. package/lib/session/session-controller.js +50 -6
  28. package/lib/session/session-controller.js.map +1 -1
  29. package/lib/views/at-complete.d.ts +31 -0
  30. package/lib/views/at-complete.js +104 -0
  31. package/lib/views/at-complete.js.map +1 -0
  32. package/lib/views/autocomplete.d.ts +2 -4
  33. package/lib/views/autocomplete.js +8 -11
  34. package/lib/views/autocomplete.js.map +1 -1
  35. package/lib/views/copy-selector.d.ts +0 -4
  36. package/lib/views/copy-selector.js +0 -17
  37. package/lib/views/copy-selector.js.map +1 -1
  38. package/lib/views/event-views.d.ts +4 -16
  39. package/lib/views/event-views.js +85 -76
  40. package/lib/views/event-views.js.map +1 -1
  41. package/lib/views/history-search.d.ts +0 -5
  42. package/lib/views/history-search.js +0 -16
  43. package/lib/views/history-search.js.map +1 -1
  44. package/lib/views/hotkeys.js +0 -1
  45. package/lib/views/hotkeys.js.map +1 -1
  46. package/lib/views/path-complete.d.ts +6 -0
  47. package/lib/views/path-complete.js +19 -2
  48. package/lib/views/path-complete.js.map +1 -1
  49. package/lib/views/prompt-selector.d.ts +0 -5
  50. package/lib/views/prompt-selector.js +0 -13
  51. package/lib/views/prompt-selector.js.map +1 -1
  52. package/lib/views/settings-list.d.ts +0 -5
  53. package/lib/views/settings-list.js +0 -23
  54. package/lib/views/settings-list.js.map +1 -1
  55. package/package.json +27 -25
@@ -3,7 +3,7 @@
3
3
 
4
4
  * Owns the tty: raw-mode key input (editing, history, slash/tab
5
5
  * autocomplete, /settings overlay, /copy picker, Ctrl-R history search, PgUp/PgDn
6
- * and mouse-wheel transcript scroll, click-to-caret, Ctrl-O tool
6
+ * and Shift+Up/Down transcript scroll, Ctrl-O tool
7
7
  * expand, bracketed paste, double-Escape conversation rewind, double Ctrl-C exit, Ctrl-D quit),
8
8
  * SIGWINCH reflow, and the differential renderer. In non-tty mode
9
9
  * (pipes, CI) it degrades to line-based input with plain append-only
@@ -14,18 +14,19 @@ import { homedir } from 'node:os';
14
14
  import { join } from 'node:path';
15
15
  import { createInterface } from 'node:readline';
16
16
  import { TUI_SERVICE, } from "../definition.js";
17
- import { applySlashCompletion, formatHelpText, hitTestAutocomplete, parseSlashInput, resolveSlashCommand, slashSuggestions, BUILTIN_SLASH_COMMANDS, } from "../views/autocomplete.js";
18
- import { applyPathCompletion, defaultPathSource, findPathToken, parsePathPrefix, pathSuggestions, searchPathSuggestions, } from "../views/path-complete.js";
17
+ import { applySlashCompletion, formatHelpText, parseSlashInput, resolveSlashCommand, slashSuggestions, BUILTIN_SLASH_COMMANDS, } from "../views/autocomplete.js";
18
+ import { applyPathCompletion, defaultPathSource, editorLineAt, findPathToken, parsePathPrefix, pathSuggestions, searchPathSuggestions, } from "../views/path-complete.js";
19
+ import { activeAtToken } from '@deepseek-ai/dsh-file-reference/grammar';
20
+ import { nextSelectableAutocompleteIndex, searchAtSuggestions, } from "../views/at-complete.js";
19
21
  import { copyToClipboard, readFromClipboard } from "../input/clipboard.js";
20
- import { applyCopySelectorEvent, createCopySelector, hitTestCopySelector, selectCopyTarget, } from "../views/copy-selector.js";
22
+ import { applyCopySelectorEvent, createCopySelector, } from "../views/copy-selector.js";
21
23
  import { buildCopyTargets, extractCopyTarget, parseCopyKind } from "../views/copy-targets.js";
22
- import { applyHistorySearchEvent, createHistorySearch, hitTestHistorySearch, } from "../views/history-search.js";
24
+ import { applyHistorySearchEvent, createHistorySearch, } from "../views/history-search.js";
23
25
  import { InputEditor, lineEnd, lineStart } from "../input/editor.js";
24
- import { applySettingsEvent, createSettings, hitTestSettings, selectSetting, tuiSettingItems, } from "../views/settings-list.js";
25
- import { applyEvent, blockLines, hitTestSubagentRoster, initialTranscript, replayEvents, renderView, TRANSCRIPT_FAST_SCROLL, TRANSCRIPT_WHEEL_SCROLL, } from "../views/event-views.js";
26
- import { flushPending, MOUSE_TRACKING_OFF, MOUSE_TRACKING_ON, parseKeys } from "../input/keys.js";
27
- import { LineRenderer } from "../chrome/renderer.js";
28
- import { hitTestEditor } from "../chrome/box.js";
26
+ import { applySettingsEvent, createSettings, } from "../views/settings-list.js";
27
+ import { applyEvent, blockLines, initialTranscript, replayEvents, renderView, TRANSCRIPT_FAST_SCROLL, } from "../views/event-views.js";
28
+ import { flushPending, parseKeys } from "../input/keys.js";
29
+ import { MainScreenRenderer } from "../chrome/main-screen-renderer.js";
29
30
  import { createTheme, detectTrueColor, parseThemeName } from "../chrome/theme.js";
30
31
  import { TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "../session/tui-settings.js";
31
32
  import { defaultStatusBarConfig, resolveStatusBarConfig } from "../chrome/status-config.js";
@@ -37,7 +38,7 @@ import { movePromptSelection, filteredPromptOptions, selectedPromptAnswer, selec
37
38
  import { resolveProjectContext } from "../session/project-context.js";
38
39
  import { pickWelcomeTips } from "../chrome/welcome-tips.js";
39
40
  import { formatEssentialHotkeysText, formatHotkeysText, hotkeyCount } from "../views/hotkeys.js";
40
- import { imageMarker, imagePathCandidates, probeImageDimensions, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
41
+ import { imageMarker, imagePathCandidates, probeImageDimensions, stripComposerImageMarkers, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
41
42
  import { APP_NAME, APP_VERSION } from "../session/package-metadata.js";
42
43
  const DOUBLE_CTRL_C_MS = 500;
43
44
  const DOUBLE_ESCAPE_MS = 500;
@@ -53,7 +54,19 @@ export const name = 'omdsh-tui';
53
54
  /**
54
55
  * Local terminal presentation service.
55
56
  */
57
+ function detectTerminalProfile() {
58
+ if (process.env.TMUX !== undefined || process.env.STY !== undefined || process.env.ZELLIJ !== undefined) {
59
+ return 'multiplexer';
60
+ }
61
+ return process.platform === 'win32' ? 'conpty' : 'direct';
62
+ }
56
63
  export class LocalTui {
64
+ #deferInitialRender = false;
65
+ #terminalProfile;
66
+ #resizeDebounceMs;
67
+ #resizeTimer = null;
68
+ #streamRenderMs;
69
+ #streamRenderTimer = null;
57
70
  #term;
58
71
  #model;
59
72
  #reasoningEffort;
@@ -91,7 +104,6 @@ export class LocalTui {
91
104
  #autocompleteTimer = null;
92
105
  #autocompleteAbort = null;
93
106
  #autocompleteRequestId = 0;
94
- #scrollRender = null;
95
107
  #paste = false;
96
108
  #pasteBuf = '';
97
109
  #pasteInFlight = 0;
@@ -129,10 +141,9 @@ export class LocalTui {
129
141
  #sessionControls;
130
142
  #loopStatus;
131
143
  #subagents;
144
+ #subagentLauncherFocused = false;
132
145
  #inspected;
133
- #editorHit = null;
134
- #chromeHit = null;
135
- #overlayHit = null;
146
+ #promptDocument;
136
147
  #trueColor;
137
148
  #copy;
138
149
  #readClipboard;
@@ -146,6 +157,9 @@ export class LocalTui {
146
157
  #home;
147
158
  #listDir;
148
159
  #searchFiles;
160
+ #searchFileMentions;
161
+ #searchSessions;
162
+ #validateImageDraft;
149
163
  #autocompleteDebounceMs;
150
164
  #persistPrefs = null;
151
165
  /**
@@ -176,32 +190,49 @@ export class LocalTui {
176
190
  this.#home = paths.home ?? fallback.home;
177
191
  this.#listDir = paths.listDir ?? fallback.listDir;
178
192
  this.#searchFiles = paths.searchFiles ?? fallback.searchFiles;
193
+ this.#searchFileMentions = paths.searchFileMentions;
194
+ this.#searchSessions = paths.searchSessions;
179
195
  this.#autocompleteDebounceMs = Math.max(0, paths.autocompleteDebounceMs ?? 100);
180
196
  this.#welcomeTips = pickWelcomeTips();
197
+ this.#deferInitialRender = paths.deferInitialRender === true;
198
+ this.#terminalProfile = paths.terminalProfile ?? detectTerminalProfile();
199
+ this.#resizeDebounceMs = Math.max(0, paths.resizeDebounceMs ?? 120);
200
+ this.#streamRenderMs = Math.max(0, paths.streamRenderMs ?? 8);
181
201
  this.#trueColor = colors && detectTrueColor();
182
202
  this.#tty = term.input.isTTY === true;
183
203
  this.#pwd = shortenPath(project.root);
184
204
  this.#branch = project.gitLabel;
185
- this.#renderer = new LineRenderer({ write: (chunk) => { this.#term.output.write(chunk); } }, { synchronized: this.#tty });
205
+ this.#renderer = new MainScreenRenderer({ write: (chunk) => { this.#term.output.write(chunk); } }, {
206
+ width: this.#term.width(),
207
+ height: this.#term.height(),
208
+ synchronized: this.#tty,
209
+ clearScrollback: this.#terminalProfile === 'direct',
210
+ alternateScreenOverlays: paths.alternateScreenOverlays === true,
211
+ });
186
212
  if (this.#tty) {
213
+ this.#renderer.startEpoch();
187
214
  term.input.setRawMode?.(true);
188
215
  const listener = (chunk) => { this.#onData(chunk); };
189
216
  term.input.on('data', listener);
190
217
  this.#offData = () => { term.input.off('data', listener); };
191
218
  this.#offResize = term.onResize?.(() => {
192
- // A terminal resize invalidates both the physical cursor position and
193
- // the screen-relative frame retained by the differential renderer.
194
- this.#renderer.reset();
195
- this.#term.output.write('\x1b[2J\x1b[H');
196
- this.#render();
219
+ // A terminal resize changes the committed/live seam; re-anchor the
220
+ // live window so it stays anchored at the bottom.
221
+ const repaint = () => {
222
+ this.#resizeTimer = null;
223
+ this.#renderer.resize(this.#term.width(), this.#term.height());
224
+ this.#render();
225
+ };
226
+ if (this.#terminalProfile === 'multiplexer') {
227
+ if (this.#resizeTimer !== null)
228
+ clearTimeout(this.#resizeTimer);
229
+ this.#resizeTimer = setTimeout(repaint, this.#resizeDebounceMs);
230
+ }
231
+ else {
232
+ repaint();
233
+ }
197
234
  }) ?? null;
198
- // Boot output above us (package-manager warnings, loader logs) has
199
- // already scrolled the cursor off row 0; the renderer's screen-relative
200
- // frames require a clean origin. Clear the screen and home the cursor
201
- // before the first frame. Enable bracketed paste and SGR mouse
202
- // tracking (wheel drives the virtual transcript; native scrollback
203
- // is already unusable under full-screen diffs).
204
- term.output.write('\x1b[2J\x1b[H\x1b[?2004h' + MOUSE_TRACKING_ON);
235
+ term.output.write('\x1b[?2004h');
205
236
  }
206
237
  this.#render();
207
238
  }
@@ -209,7 +240,12 @@ export class LocalTui {
209
240
  this.#state = applyEvent(this.#state, event, presentation);
210
241
  this.#syncTick();
211
242
  if (this.#tty) {
212
- this.#render();
243
+ if (event.type === 'assistant/chunk' && this.#streamRenderMs > 0) {
244
+ this.#scheduleStreamRender();
245
+ }
246
+ else {
247
+ this.#render();
248
+ }
213
249
  }
214
250
  else if (event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'tool/result' || event.type === 'turn/end') {
215
251
  this.#printPlain();
@@ -239,6 +275,8 @@ export class LocalTui {
239
275
  this.#subagents = roster === undefined
240
276
  ? undefined
241
277
  : { agents: roster.agents.map(agent => ({ ...agent, activity: [...agent.activity] })) };
278
+ if ((this.#subagents?.agents.length ?? 0) === 0)
279
+ this.#subagentLauncherFocused = false;
242
280
  const inspected = this.#inspected;
243
281
  if (inspected !== undefined) {
244
282
  const current = this.#subagents?.agents.find(agent => agent.id === inspected.id);
@@ -258,6 +296,8 @@ export class LocalTui {
258
296
  }
259
297
  setInspectedSubagent(inspected) {
260
298
  this.#inspected = inspected === undefined ? undefined : { ...inspected };
299
+ if (inspected !== undefined)
300
+ this.#subagentLauncherFocused = false;
261
301
  this.#syncTick();
262
302
  if (this.#tty)
263
303
  this.#render();
@@ -275,6 +315,21 @@ export class LocalTui {
275
315
  if (this.#tty)
276
316
  this.#render();
277
317
  }
318
+ setSessionSearch(search) {
319
+ this.#searchSessions = search;
320
+ this.#refreshAutocomplete();
321
+ if (this.#tty)
322
+ this.#render();
323
+ }
324
+ setFileSearch(search) {
325
+ this.#searchFileMentions = search;
326
+ this.#refreshAutocomplete();
327
+ if (this.#tty)
328
+ this.#render();
329
+ }
330
+ setImageValidator(validate) {
331
+ this.#validateImageDraft = validate;
332
+ }
278
333
  notice(text, options = {}) {
279
334
  const block = {
280
335
  kind: 'notice',
@@ -330,11 +385,13 @@ export class LocalTui {
330
385
  }
331
386
  });
332
387
  }
333
- replaceSession(events, presentations) {
388
+ replaceSession(events, presentations, status = 'idle') {
334
389
  const state = replayEvents(events, presentations);
335
- this.#state = { ...state, status: 'idle', compactCommandId: undefined };
390
+ this.#state = { ...state, status, compactCommandId: undefined };
336
391
  this.#plainPrinted = 0;
337
392
  this.#followTail();
393
+ this.#deferInitialRender = false;
394
+ this.#renderer.startEpoch({ replay: status === 'idle' ? 'full' : 'pinned' });
338
395
  if (this.#tty)
339
396
  this.#render();
340
397
  else
@@ -473,6 +530,14 @@ export class LocalTui {
473
530
  if (this.#disposed)
474
531
  return;
475
532
  this.#disposed = true;
533
+ if (this.#resizeTimer !== null) {
534
+ clearTimeout(this.#resizeTimer);
535
+ this.#resizeTimer = null;
536
+ }
537
+ if (this.#streamRenderTimer !== null) {
538
+ clearTimeout(this.#streamRenderTimer);
539
+ this.#streamRenderTimer = null;
540
+ }
476
541
  if (this.#tick !== null) {
477
542
  clearInterval(this.#tick);
478
543
  this.#tick = null;
@@ -481,19 +546,15 @@ export class LocalTui {
481
546
  clearInterval(this.#loopTick);
482
547
  this.#loopTick = null;
483
548
  }
484
- if (this.#scrollRender !== null) {
485
- clearImmediate(this.#scrollRender);
486
- this.#scrollRender = null;
487
- }
488
549
  if (this.#tty) {
489
550
  this.#offData?.();
490
551
  this.#offResize?.();
491
552
  this.#term.input.setRawMode?.(false);
492
553
  // Leave the cursor on a fresh line below the last frame so the shell
493
- // prompt does not overwrite the transcript. Disable mouse tracking
494
- // and bracketed paste.
554
+ // prompt does not overwrite the transcript. Disable bracketed paste
555
+ // and restore the cursor.
495
556
  this.#renderer.finish();
496
- this.#term.output.write(MOUSE_TRACKING_OFF + '\x1b[?2004l\x1b[?25h\r\n');
557
+ this.#term.output.write('\x1b[?2004l\x1b[?25h\r\n');
497
558
  if (this.#resumeHintRequested && this.#sessionId !== undefined) {
498
559
  this.#term.output.write(`\r\nResume this session with ${APP_NAME} --resume ${this.#sessionId}\r\n`);
499
560
  }
@@ -613,10 +674,12 @@ export class LocalTui {
613
674
  }
614
675
  }
615
676
  #render() {
616
- if (this.#scrollRender !== null) {
617
- clearImmediate(this.#scrollRender);
618
- this.#scrollRender = null;
677
+ if (this.#streamRenderTimer !== null) {
678
+ clearTimeout(this.#streamRenderTimer);
679
+ this.#streamRenderTimer = null;
619
680
  }
681
+ if (this.#deferInitialRender)
682
+ return;
620
683
  const width = this.#term.width();
621
684
  const frame = this.#tty
622
685
  ? renderView(this.#state, {
@@ -649,6 +712,7 @@ export class LocalTui {
649
712
  ...(this.#sessionControls === undefined ? {} : { sessionControls: this.#sessionControls }),
650
713
  ...(this.#loopStatus === undefined ? {} : { loopStatus: this.#loopStatus }),
651
714
  ...(this.#subagents === undefined ? {} : { subagents: this.#subagents }),
715
+ subagentLauncherFocused: this.#subagentLauncherFocused,
652
716
  ...(this.#inspected === undefined ? {} : { inspected: this.#inspected }),
653
717
  statusBar: this.#statusBar,
654
718
  ...(this.#prompt === null ? {} : { promptSelector: this.#prompt }),
@@ -662,12 +726,19 @@ export class LocalTui {
662
726
  })
663
727
  : { lines: [] };
664
728
  this.#focusBlock = undefined;
665
- this.#editorHit = frame.editor ?? null;
666
- this.#overlayHit = frame.overlay ?? null;
667
- this.#chromeHit = frame.chrome ?? null;
729
+ this.#promptDocument = frame.promptDocument;
668
730
  this.#syncScroll(frame.transcript);
669
731
  this.#renderer.render(frame);
670
732
  }
733
+ #scheduleStreamRender() {
734
+ if (this.#streamRenderTimer !== null)
735
+ return;
736
+ this.#streamRenderTimer = setTimeout(() => {
737
+ this.#streamRenderTimer = null;
738
+ if (!this.#disposed)
739
+ this.#render();
740
+ }, this.#streamRenderMs);
741
+ }
671
742
  #syncScroll(scroll) {
672
743
  if (scroll === undefined) {
673
744
  this.#scrollStart = 0;
@@ -687,24 +758,14 @@ export class LocalTui {
687
758
  #pageSize() {
688
759
  return Math.max(1, this.#scrollBudget > 2 ? this.#scrollBudget - 2 : 1);
689
760
  }
690
- #scrollBy(delta, coalesce = false) {
761
+ #scrollBy(delta) {
691
762
  if (delta === 0 && this.#maxStart === 0)
692
763
  return;
693
764
  this.#follow = false;
694
765
  this.#scrollStart += delta;
695
766
  if (this.#scrollStart <= 0)
696
767
  this.#scrollStart = 0;
697
- if (!coalesce) {
698
- this.#render();
699
- return;
700
- }
701
- if (this.#scrollRender !== null)
702
- return;
703
- this.#scrollRender = setImmediate(() => {
704
- this.#scrollRender = null;
705
- if (!this.#disposed)
706
- this.#render();
707
- });
768
+ this.#render();
708
769
  }
709
770
  #followTail() {
710
771
  this.#follow = true;
@@ -765,10 +826,17 @@ export class LocalTui {
765
826
  if (paths.length > 0) {
766
827
  const images = await Promise.all(paths.map(path => this.#readImagePath(path)));
767
828
  if (images.every((image) => image !== null)) {
768
- for (const image of images)
769
- this.#insertImageDraft(image);
770
- this.#refreshAutocomplete();
771
- this.#render();
829
+ let inserted = false;
830
+ for (const image of images) {
831
+ if (await this.#admitImage(image)) {
832
+ this.#insertImageDraft(image);
833
+ inserted = true;
834
+ }
835
+ }
836
+ if (inserted) {
837
+ this.#refreshAutocomplete();
838
+ this.#render();
839
+ }
772
840
  return;
773
841
  }
774
842
  // Screenshot tools sometimes paste a transient cache path and remove
@@ -790,9 +858,11 @@ export class LocalTui {
790
858
  if (this.#search === null && this.#settings === null && this.#copySelector === null) {
791
859
  const image = await this.#readClipboardImage();
792
860
  if (image !== null) {
793
- this.#insertImageDraft(image);
794
- this.#refreshAutocomplete();
795
- this.#render();
861
+ if (await this.#admitImage(image)) {
862
+ this.#insertImageDraft(image);
863
+ this.#refreshAutocomplete();
864
+ this.#render();
865
+ }
796
866
  return;
797
867
  }
798
868
  const files = await this.#readClipboardFiles();
@@ -801,10 +871,17 @@ export class LocalTui {
801
871
  const images = (await Promise.all(imagePaths.map(path => this.#readImagePath(path))))
802
872
  .filter((candidate) => candidate !== null);
803
873
  if (images.length > 0) {
804
- for (const candidate of images)
805
- this.#insertImageDraft(candidate);
806
- this.#refreshAutocomplete();
807
- this.#render();
874
+ let inserted = false;
875
+ for (const candidate of images) {
876
+ if (await this.#admitImage(candidate)) {
877
+ this.#insertImageDraft(candidate);
878
+ inserted = true;
879
+ }
880
+ }
881
+ if (inserted) {
882
+ this.#refreshAutocomplete();
883
+ this.#render();
884
+ }
808
885
  return;
809
886
  }
810
887
  }
@@ -813,6 +890,23 @@ export class LocalTui {
813
890
  if (text !== '')
814
891
  await this.#acceptPastedText(text);
815
892
  }
893
+ /**
894
+ * Run the Harness image-admission check for one paste candidate. A refusal
895
+ * becomes an error notice and skips the draft, instead of failing the whole
896
+ * submission after the user has typed a prompt around it.
897
+ */
898
+ async #admitImage(image) {
899
+ if (this.#validateImageDraft === undefined)
900
+ return true;
901
+ try {
902
+ await this.#validateImageDraft(image);
903
+ return true;
904
+ }
905
+ catch (error) {
906
+ this.notice(error instanceof Error ? error.message : String(error), { level: 'error' });
907
+ return false;
908
+ }
909
+ }
816
910
  #insertImageDraft(input) {
817
911
  const size = input.width === undefined || input.height === undefined
818
912
  ? probeImageDimensions(input.data, input.mediaType)
@@ -890,10 +984,6 @@ export class LocalTui {
890
984
  }
891
985
  #dispatch(event) {
892
986
  if (this.#state.status === 'compacting') {
893
- if (event.type === 'mouse' && event.wheel !== null) {
894
- this.#scrollBy(event.wheel < 0 ? -TRANSCRIPT_WHEEL_SCROLL : TRANSCRIPT_WHEEL_SCROLL, true);
895
- return;
896
- }
897
987
  if (event.type === 'key' && event.id === 'pageUp') {
898
988
  this.#scrollBy(-this.#pageSize());
899
989
  return;
@@ -932,11 +1022,6 @@ export class LocalTui {
932
1022
  this.#pasteBuf = '';
933
1023
  return;
934
1024
  }
935
- if (event.type === 'mouse') {
936
- this.#lastEscapeTime = 0;
937
- this.#handleMouse(event);
938
- return;
939
- }
940
1025
  if (event.type !== 'key' || event.id !== 'escape')
941
1026
  this.#lastEscapeTime = 0;
942
1027
  if (this.#handlePrompt(event))
@@ -1019,6 +1104,8 @@ export class LocalTui {
1019
1104
  this.#lastEscapeTime = 0;
1020
1105
  return;
1021
1106
  }
1107
+ if (this.#handleSubagentLauncher(event))
1108
+ return;
1022
1109
  if (event.type === 'key' && event.id === 'escape') {
1023
1110
  if (this.#inspected !== undefined) {
1024
1111
  if (this.#inspected.writable === true && (this.#editor.text !== '' || this.#images.length > 0)) {
@@ -1212,7 +1299,7 @@ export class LocalTui {
1212
1299
  this.#render();
1213
1300
  return true;
1214
1301
  }
1215
- const scroll = this.#overlayHit?.kind === 'prompt' ? this.#overlayHit.document : undefined;
1302
+ const scroll = this.#promptDocument;
1216
1303
  let documentScroll;
1217
1304
  if (event.id === 'up')
1218
1305
  documentScroll = (scroll?.start ?? prompt.documentScroll ?? 0) - 1;
@@ -1262,73 +1349,42 @@ export class LocalTui {
1262
1349
  }
1263
1350
  return true;
1264
1351
  }
1265
- #handleMouse(event) {
1266
- if (event.leftClick) {
1267
- if (this.#clickChrome(event.row))
1268
- return;
1269
- if (this.#clickOverlay(event.row))
1270
- return;
1271
- this.#clickEditor(event.row, event.col);
1272
- return;
1273
- }
1274
- if (event.wheel === null)
1275
- return;
1276
- const dir = event.wheel;
1277
- if (this.#prompt !== null) {
1278
- this.#handlePrompt({ type: 'key', id: dir < 0 ? 'up' : 'down' });
1279
- return;
1280
- }
1281
- if (this.#settings !== null) {
1282
- this.#applySettings({ type: 'key', id: dir < 0 ? 'up' : 'down' });
1283
- return;
1284
- }
1285
- if (this.#copySelector !== null) {
1286
- this.#applyCopySelector({ type: 'key', id: dir < 0 ? 'up' : 'down' });
1287
- return;
1288
- }
1289
- if (this.#search !== null) {
1290
- this.#applySearch({ type: 'key', id: dir < 0 ? 'up' : 'down' });
1291
- return;
1292
- }
1293
- if (this.#ac !== null) {
1294
- this.#moveAutocomplete(dir);
1295
- this.#render();
1296
- return;
1297
- }
1298
- this.#scrollBy(dir < 0 ? -TRANSCRIPT_WHEEL_SCROLL : TRANSCRIPT_WHEEL_SCROLL, true);
1352
+ #openInspect(id) {
1353
+ for (const listener of this.#inspects)
1354
+ listener(id);
1299
1355
  }
1300
- #clickChrome(row) {
1301
- const hit = this.#chromeHit;
1302
- if (hit === undefined || hit === null)
1356
+ #handleSubagentLauncher(event) {
1357
+ const hasAgents = (this.#subagents?.agents.length ?? 0) > 0;
1358
+ if (this.#subagentLauncherFocused) {
1359
+ if (!hasAgents) {
1360
+ this.#subagentLauncherFocused = false;
1361
+ return false;
1362
+ }
1363
+ if (event.type === 'key' && event.id === 'enter') {
1364
+ this.#subagentLauncherFocused = false;
1365
+ void this.#pickSubagent();
1366
+ return true;
1367
+ }
1368
+ if (event.type === 'key' && (event.id === 'escape' || event.id === 'up')) {
1369
+ this.#subagentLauncherFocused = false;
1370
+ this.#render();
1371
+ return true;
1372
+ }
1373
+ if (event.type === 'key' && event.id === 'down')
1374
+ return true;
1375
+ this.#subagentLauncherFocused = false;
1303
1376
  return false;
1304
- const inspect = hit.inspect;
1305
- if (inspect !== undefined && row >= inspect.start && row < inspect.start + inspect.rows) {
1306
- this.#closeInspect();
1307
- return true;
1308
1377
  }
1309
- const roster = hit.subagents;
1310
- if (roster === undefined)
1378
+ if (!hasAgents || event.type !== 'key' || event.id !== 'down')
1311
1379
  return false;
1312
- const localRow = row - roster.start;
1313
- if (localRow < 0 || localRow >= roster.rows)
1380
+ if (this.#editor.text !== '' || this.#images.length > 0 || this.#historyIndex !== 0 || this.#ac !== null)
1314
1381
  return false;
1315
- if (localRow < roster.headerRows) {
1316
- void this.#pickSubagent();
1317
- return true;
1318
- }
1319
- const id = hitTestSubagentRoster(roster.items, localRow);
1320
- if (id === undefined)
1321
- return true;
1322
- if (this.#inspected?.id === id)
1323
- this.#closeInspect();
1324
- else
1325
- this.#openInspect(id);
1382
+ if (this.#queuedSubmissions.length > 0 || this.#queueEditNewer !== null || this.#queueEditPending)
1383
+ return false;
1384
+ this.#subagentLauncherFocused = true;
1385
+ this.#render();
1326
1386
  return true;
1327
1387
  }
1328
- #openInspect(id) {
1329
- for (const listener of this.#inspects)
1330
- listener(id);
1331
- }
1332
1388
  #closeInspect() {
1333
1389
  if (this.#inspected === undefined)
1334
1390
  return;
@@ -1336,6 +1392,7 @@ export class LocalTui {
1336
1392
  listener();
1337
1393
  }
1338
1394
  async #pickSubagent() {
1395
+ this.#subagentLauncherFocused = false;
1339
1396
  const agents = this.#subagents?.agents ?? [];
1340
1397
  if (agents.length === 0) {
1341
1398
  this.notice('No subagents are available in this session.');
@@ -1343,7 +1400,7 @@ export class LocalTui {
1343
1400
  }
1344
1401
  const initialValue = this.#inspected?.id ?? agents[0]?.id;
1345
1402
  const answer = await this.prompt({
1346
- title: 'Agents',
1403
+ title: 'Agent Hub',
1347
1404
  question: 'Open a subagent transcript',
1348
1405
  options: agents.map(agent => ({
1349
1406
  label: agent.label,
@@ -1352,93 +1409,14 @@ export class LocalTui {
1352
1409
  })),
1353
1410
  ...(initialValue === undefined ? {} : { initialValue }),
1354
1411
  allowCustom: false,
1355
- ...(agents.length > 8 ? { presentation: 'fullscreen-list', filterable: true } : {}),
1412
+ presentation: 'fullscreen-list',
1413
+ filterable: true,
1356
1414
  submitLabel: 'open',
1357
1415
  });
1358
1416
  if (answer === null || answer === '')
1359
1417
  return;
1360
1418
  this.#openInspect(answer);
1361
1419
  }
1362
- #clickOverlay(row) {
1363
- const hit = this.#overlayHit;
1364
- if (hit === null)
1365
- return false;
1366
- const localRow = row - hit.start;
1367
- if (hit.kind === 'settings' && this.#settings !== null) {
1368
- const index = hitTestSettings(hit.itemRows ?? tuiSettingItems(this.#settings.prefs).length, localRow);
1369
- if (index === undefined)
1370
- return true;
1371
- if (index === this.#settings.selected) {
1372
- this.#applySettings({ type: 'key', id: 'enter' });
1373
- }
1374
- else {
1375
- this.#settings = selectSetting(this.#settings, index);
1376
- this.#render();
1377
- }
1378
- return true;
1379
- }
1380
- if (hit.kind === 'copy' && this.#copySelector !== null) {
1381
- const index = hitTestCopySelector(this.#copySelector.items.length, this.#copySelector.selected, localRow);
1382
- if (index === undefined)
1383
- return true;
1384
- if (index === this.#copySelector.selected) {
1385
- this.#applyCopySelector({ type: 'key', id: 'enter' });
1386
- }
1387
- else {
1388
- this.#copySelector = selectCopyTarget(this.#copySelector, index);
1389
- this.#render();
1390
- }
1391
- return true;
1392
- }
1393
- if (hit.kind === 'autocomplete' && this.#ac !== null) {
1394
- const index = hitTestAutocomplete(this.#ac.items.length, this.#ac.selected, localRow);
1395
- if (index === undefined)
1396
- return true;
1397
- this.#ac = { ...this.#ac, selected: index };
1398
- this.#applySelectedCompletion();
1399
- this.#render();
1400
- return true;
1401
- }
1402
- if (hit.kind === 'search' && this.#search !== null) {
1403
- const index = hitTestHistorySearch(this.#search.results.length, this.#search.selected, localRow, hit.resultsRow ?? 0);
1404
- if (index === undefined)
1405
- return true;
1406
- this.#search = { ...this.#search, selected: index };
1407
- this.#applySearch({ type: 'key', id: 'enter' });
1408
- return true;
1409
- }
1410
- if (hit.kind === 'prompt' && this.#prompt !== null) {
1411
- const index = hit.itemRows?.[localRow];
1412
- if (index === undefined)
1413
- return true;
1414
- if (index === this.#prompt.selected) {
1415
- this.#finishPrompt(selectedFilteredPromptAnswer(this.#prompt, this.#editor.text));
1416
- this.#editor.setText('');
1417
- }
1418
- else {
1419
- this.#prompt = { ...this.#prompt, selected: index };
1420
- }
1421
- this.#render();
1422
- return true;
1423
- }
1424
- return false;
1425
- }
1426
- #clickEditor(row, col) {
1427
- if (this.#settings !== null || this.#copySelector !== null || this.#search !== null)
1428
- return;
1429
- const hit = this.#editorHit;
1430
- if (hit === null)
1431
- return;
1432
- const localRow = row - hit.start;
1433
- if (localRow < 0 || localRow >= hit.rows)
1434
- return;
1435
- const index = hitTestEditor(this.#editor.text, this.#term.width(), localRow, col);
1436
- if (index === undefined)
1437
- return;
1438
- this.#editor.setCursor(index);
1439
- this.#refreshAutocomplete();
1440
- this.#render();
1441
- }
1442
1420
  #prefs() {
1443
1421
  return {
1444
1422
  theme: this.#themeName,
@@ -1603,10 +1581,20 @@ export class LocalTui {
1603
1581
  force: forcePath,
1604
1582
  };
1605
1583
  const token = findPathToken(this.#editor.text, this.#editor.cursor, forcePath);
1606
- const atPrefix = token?.kind === 'at' ? parsePathPrefix(token.prefix).raw.replaceAll('\\', '/') : '';
1607
- const fuzzyAt = token?.kind === 'at' && atPrefix !== '' && !atPrefix.endsWith('/');
1608
- if (fuzzyAt) {
1609
- this.#ac = null;
1584
+ const { line, col } = editorLineAt(this.#editor.text, this.#editor.cursor);
1585
+ const at = forcePath ? undefined : activeAtToken(line, col);
1586
+ const atPrefix = at?.query.replaceAll('\\', '/')
1587
+ ?? (token?.kind === 'at' ? parsePathPrefix(token.prefix).raw.replaceAll('\\', '/') : '');
1588
+ const fuzzyAt = (at !== undefined || token?.kind === 'at') && atPrefix !== '' && !atPrefix.endsWith('/');
1589
+ const harnessAt = at !== undefined
1590
+ && (this.#searchFileMentions !== undefined || this.#searchSessions !== undefined);
1591
+ if (fuzzyAt || harnessAt) {
1592
+ if (harnessAt && !fuzzyAt) {
1593
+ this.#setAutocomplete(pathSuggestions(this.#editor.text, this.#editor.cursor, pathOptions, commands));
1594
+ }
1595
+ else {
1596
+ this.#ac = null;
1597
+ }
1610
1598
  const text = this.#editor.text;
1611
1599
  const cursor = this.#editor.cursor;
1612
1600
  this.#autocompleteTimer = setTimeout(() => {
@@ -1615,11 +1603,20 @@ export class LocalTui {
1615
1603
  return;
1616
1604
  const controller = new AbortController();
1617
1605
  this.#autocompleteAbort = controller;
1618
- void searchPathSuggestions(text, cursor, {
1619
- ...pathOptions,
1620
- searchFiles: this.#searchFiles,
1621
- signal: controller.signal,
1622
- }, commands).then((result) => {
1606
+ const search = this.#searchFileMentions === undefined && this.#searchSessions === undefined
1607
+ ? searchPathSuggestions(text, cursor, {
1608
+ ...pathOptions,
1609
+ searchFiles: this.#searchFiles,
1610
+ signal: controller.signal,
1611
+ }, commands)
1612
+ : searchAtSuggestions(text, cursor, {
1613
+ ...pathOptions,
1614
+ searchFiles: this.#searchFiles,
1615
+ ...(this.#searchFileMentions === undefined ? {} : { searchFileMentions: this.#searchFileMentions }),
1616
+ ...(this.#searchSessions === undefined ? {} : { searchSessions: this.#searchSessions }),
1617
+ signal: controller.signal,
1618
+ }, commands);
1619
+ void search.then((result) => {
1623
1620
  if (this.#disposed || controller.signal.aborted || requestId !== this.#autocompleteRequestId)
1624
1621
  return;
1625
1622
  this.#autocompleteAbort = null;
@@ -1645,9 +1642,9 @@ export class LocalTui {
1645
1642
  return;
1646
1643
  }
1647
1644
  const prev = this.#ac?.items[this.#ac.selected]?.value;
1648
- let selected = 0;
1645
+ let selected = nextSelectableAutocompleteIndex(result.items, 0, 1);
1649
1646
  if (prev !== undefined) {
1650
- const idx = result.items.findIndex((item) => item.value === prev);
1647
+ const idx = result.items.findIndex((item) => item.value === prev && item.kind !== 'heading');
1651
1648
  if (idx >= 0)
1652
1649
  selected = idx;
1653
1650
  }
@@ -1656,14 +1653,16 @@ export class LocalTui {
1656
1653
  #moveAutocomplete(dir) {
1657
1654
  if (this.#ac === null || this.#ac.items.length === 0)
1658
1655
  return;
1659
- const n = this.#ac.items.length;
1660
- this.#ac = { ...this.#ac, selected: (this.#ac.selected + dir + n) % n };
1656
+ this.#ac = {
1657
+ ...this.#ac,
1658
+ selected: nextSelectableAutocompleteIndex(this.#ac.items, this.#ac.selected + dir, dir),
1659
+ };
1661
1660
  }
1662
1661
  #applySelectedCompletion() {
1663
1662
  const item = this.#ac?.items[this.#ac.selected];
1664
- if (item === undefined)
1663
+ if (item === undefined || item.kind === 'heading')
1665
1664
  return;
1666
- const next = item.kind === 'path'
1665
+ const next = item.kind === 'path' || item.kind === 'session'
1667
1666
  ? applyPathCompletion(this.#editor.text, this.#editor.cursor, item)
1668
1667
  : applySlashCompletion(this.#editor.text, this.#editor.cursor, item);
1669
1668
  this.#editor.setText(next.text, next.cursor);
@@ -1730,7 +1729,6 @@ export class LocalTui {
1730
1729
  }
1731
1730
  if (command.kind === 'resetDisplay') {
1732
1731
  this.#renderer.reset();
1733
- this.#term.output.write('\x1b[2J\x1b[H');
1734
1732
  this.#render();
1735
1733
  }
1736
1734
  }
@@ -1812,7 +1810,7 @@ export class LocalTui {
1812
1810
  const images = this.#images.map(image => ({ ...image }));
1813
1811
  const submittedText = images.length > 0 ? text.trim() : text;
1814
1812
  const queueEditNewer = this.#queueEditNewer;
1815
- const historyText = images.reduce((value, image, index) => value.replaceAll(imageMarker(index, image), ''), submittedText)
1813
+ const historyText = stripComposerImageMarkers(submittedText, images)
1816
1814
  .replace(/[ \t]{2,}/gu, ' ')
1817
1815
  .trim();
1818
1816
  if (historyText !== '' && this.#history[this.#history.length - 1] !== historyText) {
@@ -1830,6 +1828,9 @@ export class LocalTui {
1830
1828
  this.#settings = null;
1831
1829
  this.#copySelector = null;
1832
1830
  this.#followTail();
1831
+ // Image placeholders are TUI-owned. Mixed image+slash drafts stay a
1832
+ // submission so restore can keep the original markers; the runner strips
1833
+ // them only to detect and execute Harness commands.
1833
1834
  const slash = images.length === 0 ? parseSlashInput(submittedText) : null;
1834
1835
  if (slash !== null) {
1835
1836
  if (queueEditNewer !== null)
@@ -1856,7 +1857,7 @@ export class LocalTui {
1856
1857
  #submitInspect(text) {
1857
1858
  const images = this.#images.map(image => ({ ...image }));
1858
1859
  const submittedText = images.length > 0 ? text.trim() : text;
1859
- const historyText = images.reduce((value, image, index) => value.replaceAll(imageMarker(index, image), ''), submittedText)
1860
+ const historyText = stripComposerImageMarkers(submittedText, images)
1860
1861
  .replace(/[ \t]{2,}/gu, ' ')
1861
1862
  .trim();
1862
1863
  if (historyText !== '' && this.#history[this.#history.length - 1] !== historyText) {
@@ -1902,6 +1903,7 @@ export class LocalTui {
1902
1903
  if (command.name === 'clear') {
1903
1904
  this.#state = initialTranscript();
1904
1905
  this.#followTail();
1906
+ this.#renderer.startEpoch();
1905
1907
  this.#render();
1906
1908
  return;
1907
1909
  }
@@ -2076,7 +2078,6 @@ export class LocalTui {
2076
2078
  finally {
2077
2079
  this.#term.input.setRawMode?.(true);
2078
2080
  this.#renderer.reset();
2079
- this.#term.output.write('\x1b[2J\x1b[H');
2080
2081
  this.#refreshAutocomplete();
2081
2082
  this.#render();
2082
2083
  }
@@ -2089,6 +2090,7 @@ export class LocalTui {
2089
2090
  */
2090
2091
  export function apply(ctx, config) {
2091
2092
  const dshHome = process.env.OMDSH_HOME ?? process.env.DSH_HOME ?? join(homedir(), '.dsh');
2093
+ const terminalProfile = detectTerminalProfile();
2092
2094
  const term = {
2093
2095
  output: process.stdout,
2094
2096
  input: process.stdin,
@@ -2100,6 +2102,9 @@ export function apply(ctx, config) {
2100
2102
  },
2101
2103
  };
2102
2104
  const tui = new LocalTui(term, config.model, config.colors ?? term.output.isTTY === true, parseThemeName(config.theme), copyToClipboard, {
2105
+ deferInitialRender: true,
2106
+ terminalProfile,
2107
+ alternateScreenOverlays: terminalProfile === 'direct',
2103
2108
  historyPath: config.historyPath ?? join(dshHome, 'omdsh', 'history.jsonl'),
2104
2109
  keybindingsPath: config.keybindingsPath ?? join(dshHome, 'omdsh', 'keybindings.json'),
2105
2110
  });