@agi-fans/dsh-tui 0.14.0 → 0.16.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 (123) hide show
  1. package/lib/CHANGELOG.md +77 -1
  2. package/lib/chrome/diff-render.js +12 -0
  3. package/lib/chrome/diff-render.js.map +1 -1
  4. package/lib/chrome/goal-bar.d.ts +13 -0
  5. package/lib/chrome/goal-bar.js +45 -0
  6. package/lib/chrome/goal-bar.js.map +1 -0
  7. package/lib/chrome/grapheme.d.ts +15 -0
  8. package/lib/chrome/grapheme.js +49 -0
  9. package/lib/chrome/grapheme.js.map +1 -0
  10. package/lib/chrome/main-screen-renderer.js +21 -5
  11. package/lib/chrome/main-screen-renderer.js.map +1 -1
  12. package/lib/chrome/markdown.js +101 -30
  13. package/lib/chrome/markdown.js.map +1 -1
  14. package/lib/chrome/renderer.d.ts +13 -61
  15. package/lib/chrome/renderer.js +17 -181
  16. package/lib/chrome/renderer.js.map +1 -1
  17. package/lib/chrome/status-config.d.ts +9 -4
  18. package/lib/chrome/status-config.js +13 -4
  19. package/lib/chrome/status-config.js.map +1 -1
  20. package/lib/chrome/status-line.d.ts +3 -1
  21. package/lib/chrome/status-line.js +42 -10
  22. package/lib/chrome/status-line.js.map +1 -1
  23. package/lib/chrome/theme.d.ts +9 -2
  24. package/lib/chrome/theme.js +60 -12
  25. package/lib/chrome/theme.js.map +1 -1
  26. package/lib/chrome/tool-renderers.d.ts +2 -0
  27. package/lib/chrome/tool-renderers.js +149 -2
  28. package/lib/chrome/tool-renderers.js.map +1 -1
  29. package/lib/chrome/welcome-tips.js +1 -0
  30. package/lib/chrome/welcome-tips.js.map +1 -1
  31. package/lib/chrome/width.d.ts +19 -1
  32. package/lib/chrome/width.js +135 -41
  33. package/lib/chrome/width.js.map +1 -1
  34. package/lib/commands/diff.d.ts +32 -0
  35. package/lib/commands/diff.js +116 -0
  36. package/lib/commands/diff.js.map +1 -0
  37. package/lib/commands/jobs.d.ts +14 -0
  38. package/lib/commands/jobs.js +82 -0
  39. package/lib/commands/jobs.js.map +1 -0
  40. package/lib/commands/model.d.ts +25 -0
  41. package/lib/commands/model.js +168 -4
  42. package/lib/commands/model.js.map +1 -1
  43. package/lib/commands/permission.d.ts +10 -1
  44. package/lib/commands/permission.js +23 -14
  45. package/lib/commands/permission.js.map +1 -1
  46. package/lib/commands/session.js +75 -1
  47. package/lib/commands/session.js.map +1 -1
  48. package/lib/definition.d.ts +38 -4
  49. package/lib/definition.js.map +1 -1
  50. package/lib/input/editor.d.ts +2 -0
  51. package/lib/input/editor.js +19 -9
  52. package/lib/input/editor.js.map +1 -1
  53. package/lib/input/keybindings-config.d.ts +1 -1
  54. package/lib/input/keybindings-config.js +9 -0
  55. package/lib/input/keybindings-config.js.map +1 -1
  56. package/lib/runtime/agent-profile.d.ts +11 -0
  57. package/lib/runtime/agent-profile.js +20 -1
  58. package/lib/runtime/agent-profile.js.map +1 -1
  59. package/lib/runtime/provider-local.d.ts +15 -5
  60. package/lib/runtime/provider-local.js +392 -98
  61. package/lib/runtime/provider-local.js.map +1 -1
  62. package/lib/runtime/runner.js +33 -8
  63. package/lib/runtime/runner.js.map +1 -1
  64. package/lib/runtime/tool-presentation.js +13 -2
  65. package/lib/runtime/tool-presentation.js.map +1 -1
  66. package/lib/session/interaction-adapter.d.ts +10 -0
  67. package/lib/session/interaction-adapter.js +12 -1
  68. package/lib/session/interaction-adapter.js.map +1 -1
  69. package/lib/session/job-notice.d.ts +28 -0
  70. package/lib/session/job-notice.js +34 -0
  71. package/lib/session/job-notice.js.map +1 -0
  72. package/lib/session/live-attempt-tracker.d.ts +43 -0
  73. package/lib/session/live-attempt-tracker.js +58 -0
  74. package/lib/session/live-attempt-tracker.js.map +1 -0
  75. package/lib/session/project-context.d.ts +2 -0
  76. package/lib/session/project-context.js +22 -11
  77. package/lib/session/project-context.js.map +1 -1
  78. package/lib/session/session-configuration.js +1 -1
  79. package/lib/session/session-configuration.js.map +1 -1
  80. package/lib/session/session-controller.d.ts +10 -4
  81. package/lib/session/session-controller.js +293 -65
  82. package/lib/session/session-controller.js.map +1 -1
  83. package/lib/session/subagent-roster.d.ts +31 -1
  84. package/lib/session/subagent-roster.js +79 -19
  85. package/lib/session/subagent-roster.js.map +1 -1
  86. package/lib/session/tui-settings.js +1 -0
  87. package/lib/session/tui-settings.js.map +1 -1
  88. package/lib/views/agent-hub.d.ts +3 -0
  89. package/lib/views/agent-hub.js +40 -4
  90. package/lib/views/agent-hub.js.map +1 -1
  91. package/lib/views/copy-selector.d.ts +3 -0
  92. package/lib/views/copy-selector.js +26 -6
  93. package/lib/views/copy-selector.js.map +1 -1
  94. package/lib/views/event-views.d.ts +59 -1
  95. package/lib/views/event-views.js +245 -73
  96. package/lib/views/event-views.js.map +1 -1
  97. package/lib/views/history-search.d.ts +3 -0
  98. package/lib/views/history-search.js +40 -4
  99. package/lib/views/history-search.js.map +1 -1
  100. package/lib/views/hotkey-format.d.ts +39 -0
  101. package/lib/views/hotkey-format.js +42 -0
  102. package/lib/views/hotkey-format.js.map +1 -0
  103. package/lib/views/hotkeys.d.ts +3 -0
  104. package/lib/views/hotkeys.js +44 -7
  105. package/lib/views/hotkeys.js.map +1 -1
  106. package/lib/views/prompt-selector.d.ts +3 -0
  107. package/lib/views/prompt-selector.js +46 -7
  108. package/lib/views/prompt-selector.js.map +1 -1
  109. package/lib/views/settings-list.d.ts +3 -0
  110. package/lib/views/settings-list.js +36 -5
  111. package/lib/views/settings-list.js.map +1 -1
  112. package/lib/views/stream-time.d.ts +14 -0
  113. package/lib/views/stream-time.js +32 -0
  114. package/lib/views/stream-time.js.map +1 -0
  115. package/lib/views/streaming-reveal.js +56 -8
  116. package/lib/views/streaming-reveal.js.map +1 -1
  117. package/lib/views/trajectory.d.ts +57 -1
  118. package/lib/views/trajectory.js +452 -60
  119. package/lib/views/trajectory.js.map +1 -1
  120. package/lib/views/transcript-search.d.ts +45 -0
  121. package/lib/views/transcript-search.js +105 -0
  122. package/lib/views/transcript-search.js.map +1 -0
  123. package/package.json +41 -30
@@ -12,8 +12,9 @@
12
12
  */
13
13
  import { TerminalNotificationController, terminalNotificationSequence, terminalProgressSequence, } from "./terminal-notifications.js";
14
14
  import { homedir } from 'node:os';
15
- import { join } from 'node:path';
15
+ import { join, sep } from 'node:path';
16
16
  import { createInterface } from 'node:readline';
17
+ import { StringDecoder } from 'node:string_decoder';
17
18
  import { TUI_SERVICE, } from "../definition.js";
18
19
  import { applySlashCompletion, formatHelpText, parseSlashInput, resolveSlashCommand, slashSuggestions, BUILTIN_SLASH_COMMANDS, } from "../views/autocomplete.js";
19
20
  import { applyPathCompletion, defaultPathSource, editorLineAt, findPathToken, parsePathPrefix, pathSuggestions, searchPathSuggestions, } from "../views/path-complete.js";
@@ -25,20 +26,22 @@ import { buildCopyTargets, extractCopyTarget, parseCopyKind } from "../views/cop
25
26
  import { applyHistorySearchEvent, createHistorySearch, } from "../views/history-search.js";
26
27
  import { InputEditor, lineEnd, lineStart } from "../input/editor.js";
27
28
  import { applySettingsEvent, createSettings, } from "../views/settings-list.js";
28
- import { appendTrajectoryEvent, applyTrajectoryEvent, createTrajectory, } from "../views/trajectory.js";
29
+ import { appendTrajectoryEvent, applyTrajectoryEvent, createTrajectory, trajectoryDetailMetrics, trajectoryListMetrics, } from "../views/trajectory.js";
29
30
  import { applyAgentHubEvent, createAgentHub, updateAgentHub, } from "../views/agent-hub.js";
30
- import { applyEvent, blockLines, initialTranscript, replayEvents, renderView, TRANSCRIPT_FAST_SCROLL, } from "../views/event-views.js";
31
+ import { applyEvent, applyStreamChunk, blockLines, blockSearchText, initialTranscript, replayEvents, renderView, TRANSCRIPT_FAST_SCROLL, } from "../views/event-views.js";
32
+ import { applyTranscriptSearchEvent, createTranscriptSearch, searchBlockIndexes, } from "../views/transcript-search.js";
31
33
  import { nextRevealStep, revealStreamingAssistant, streamingAssistantKey, streamingAssistantUnits, } from "../views/streaming-reveal.js";
32
34
  import { flushPending, parseKeys } from "../input/keys.js";
33
35
  import { MainScreenRenderer } from "../chrome/main-screen-renderer.js";
34
- import { createTheme, detectTrueColor, parseThemeName } from "../chrome/theme.js";
36
+ import { colorDisabledByEnv, createTheme, detectTrueColor, parseThemeName } from "../chrome/theme.js";
37
+ import { renderTool } from "../chrome/tool-renderers.js";
35
38
  import { TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "../session/tui-settings.js";
36
39
  import { defaultStatusBarConfig, resolveStatusBarConfig } from "../chrome/status-config.js";
37
40
  import { HistoryStore } from "../views/history-store.js";
38
41
  import { loadKeybindings } from "../input/keybindings-config.js";
39
42
  import { editExternally } from "../input/external-editor.js";
40
43
  import { movePromptSelection, filteredPromptOptions, selectedPromptAnswer, selectedFilteredPromptAnswer, togglePromptSelection, } from "../views/prompt-selector.js";
41
- import { resolveProjectContext } from "../session/project-context.js";
44
+ import { refreshProjectContext, resolveProjectContext } from "../session/project-context.js";
42
45
  import { pickWelcomeTips } from "../chrome/welcome-tips.js";
43
46
  import { formatEssentialHotkeysText, formatHotkeysText, hotkeyCount } from "../views/hotkeys.js";
44
47
  import { imageMarker, imagePathCandidates, probeImageDimensions, stripComposerImageMarkers, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
@@ -51,10 +54,24 @@ function shortenPath(cwd) {
51
54
  const home = homedir();
52
55
  if (cwd === home)
53
56
  return '~';
54
- if (cwd.startsWith(home + '/'))
57
+ // Windows separates with `\`, so the home prefix check follows the host.
58
+ if (cwd.startsWith(home + sep))
55
59
  return '~' + cwd.slice(home.length);
56
60
  return cwd;
57
61
  }
62
+ /** Maximum code points written into the OSC 2 window title. */
63
+ const WINDOW_TITLE_MAX = 120;
64
+ /**
65
+ * Make a session title safe for an OSC 2 write: control characters are
66
+ * replaced so a title cannot terminate or inject the escape sequence, and the
67
+ * payload is bounded without splitting a surrogate pair.
68
+ */
69
+ function windowTitleText(title) {
70
+ if (title === undefined)
71
+ return '';
72
+ const cleaned = title.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').replace(/\s+/gu, ' ').trim();
73
+ return [...cleaned].slice(0, WINDOW_TITLE_MAX).join('');
74
+ }
58
75
  export const name = 'omdsh-tui';
59
76
  /**
60
77
  * Local terminal presentation service.
@@ -65,6 +82,18 @@ function detectTerminalProfile() {
65
82
  }
66
83
  return process.platform === 'win32' ? 'conpty' : 'direct';
67
84
  }
85
+ /**
86
+ * Decide whether SGR is emitted. An explicit preference — plugin config or a
87
+ * value the user picked in `/settings` — always wins, so a non-empty `NO_COLOR`
88
+ * can never block someone who asked for color. Without a preference, a
89
+ * non-empty `NO_COLOR` or `FORCE_COLOR=0` suppresses color and the output
90
+ * stream's tty-ness decides.
91
+ */
92
+ function resolveColors(preference, isTty) {
93
+ if (preference !== undefined)
94
+ return preference;
95
+ return isTty && !colorDisabledByEnv();
96
+ }
68
97
  export class LocalTui {
69
98
  #deferInitialRender = false;
70
99
  #terminalProfile;
@@ -86,6 +115,7 @@ export class LocalTui {
86
115
  #draft = '';
87
116
  #ac = null;
88
117
  #search = null;
118
+ #transcriptSearch = null;
89
119
  #settings = null;
90
120
  #copySelector = null;
91
121
  #trajectory = null;
@@ -151,9 +181,18 @@ export class LocalTui {
151
181
  #tools = [];
152
182
  #runtimeCommands = [];
153
183
  #prompt = null;
184
+ /**
185
+ * The overlay surface displaced by an in-flight prompt: while a human
186
+ * prompt owns the keyboard, its screen must not compete with a stale
187
+ * trajectory/hub surface that accepts invisible confirmations. Restored
188
+ * when the prompt settles.
189
+ */
190
+ #promptDisplaced = null;
154
191
  #recentSessions = [];
155
192
  #welcomeTips;
156
193
  #sessionId;
194
+ #sessionTitle;
195
+ #writtenWindowTitle;
157
196
  #sessionStats;
158
197
  #sessionControls;
159
198
  #loopStatus;
@@ -161,7 +200,12 @@ export class LocalTui {
161
200
  #subagentLauncherFocused = false;
162
201
  #inspected;
163
202
  #promptDocument;
164
- #trueColor;
203
+ /**
204
+ * 24-bit switch for the current color preference, re-derived whenever that
205
+ * preference changes so a session that starts colorless can still enable
206
+ * hexadecimal palettes without a restart.
207
+ */
208
+ #trueColor = false;
165
209
  #copy;
166
210
  #readClipboard;
167
211
  #readClipboardImage;
@@ -186,7 +230,8 @@ export class LocalTui {
186
230
  /**
187
231
  * @param term - terminal surface (injectable for tests).
188
232
  * @param model - model label for the status line.
189
- * @param colors - SGR styling switch.
233
+ * @param colors - SGR styling switch; `undefined` derives one from the
234
+ * output stream's tty-ness and the environment.
190
235
  * @param themeName - shipped palette.
191
236
  * @param copy - clipboard writer (defaults to the platform tool).
192
237
  * @param paths - cwd/home/listing used by `@` and path autocomplete.
@@ -194,7 +239,7 @@ export class LocalTui {
194
239
  constructor(term, model, colors, themeName = 'dark', copy = copyToClipboard, paths = {}) {
195
240
  this.#term = term;
196
241
  this.#model = model;
197
- this.#colors = colors;
242
+ this.#colors = resolveColors(colors, term.output.isTTY === true);
198
243
  this.#themeName = themeName;
199
244
  this.#copy = copy;
200
245
  this.#readClipboard = paths.readClipboard ?? readFromClipboard;
@@ -219,7 +264,7 @@ export class LocalTui {
219
264
  this.#terminalProfile = paths.terminalProfile ?? detectTerminalProfile();
220
265
  this.#resizeDebounceMs = Math.max(0, paths.resizeDebounceMs ?? 120);
221
266
  this.#streamRenderMs = Math.max(0, paths.streamRenderMs ?? 8);
222
- this.#trueColor = colors && detectTrueColor();
267
+ this.#syncTrueColor();
223
268
  this.#tty = term.input.isTTY === true;
224
269
  this.#pwd = shortenPath(project.root);
225
270
  this.#branch = project.gitLabel;
@@ -257,6 +302,15 @@ export class LocalTui {
257
302
  }
258
303
  this.#render();
259
304
  }
305
+ /** Refresh Git workspace metadata after a turn; the footer shows realtime branch/dirty state. */
306
+ #refreshProjectContext() {
307
+ const branch = refreshProjectContext(this.#cwd).gitLabel;
308
+ if (branch === this.#branch)
309
+ return;
310
+ this.#branch = branch;
311
+ if (this.#tty)
312
+ this.#render();
313
+ }
260
314
  event(event, presentation) {
261
315
  this.#state = applyEvent(this.#state, event, presentation);
262
316
  this.#emitNotification(this.#notifications.event({
@@ -266,22 +320,37 @@ export class LocalTui {
266
320
  }));
267
321
  if (this.#trajectory !== null)
268
322
  this.#trajectory = appendTrajectoryEvent(this.#trajectory, event);
269
- const smoothStream = this.#syncStreamingReveal(event);
323
+ if (event.type === 'turn/end')
324
+ this.#refreshProjectContext();
325
+ this.#syncStreamingReveal(undefined);
326
+ this.#syncTick();
327
+ if (this.#tty) {
328
+ this.#render();
329
+ }
330
+ else if (event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'tool/result' || event.type === 'turn/end') {
331
+ this.#printPlain();
332
+ }
333
+ }
334
+ /**
335
+ * Fold one live `agent/assistant-stream` chunk into the transcript and
336
+ * schedule the streaming reveal. Durable settlement follows on the session
337
+ * log as `assistant/message` or `assistant/attempt`.
338
+ */
339
+ streamDelta(delta) {
340
+ this.#state = applyStreamChunk(this.#state, delta);
341
+ const smoothStream = this.#syncStreamingReveal(delta);
270
342
  this.#syncTick();
271
343
  if (this.#tty) {
272
344
  if (smoothStream) {
273
345
  // The 30 fps reveal clock owns presentation for textual deltas.
274
346
  }
275
- else if (event.type === 'assistant/chunk' && this.#streamRenderMs > 0) {
347
+ else if (this.#streamRenderMs > 0) {
276
348
  this.#scheduleStreamRender();
277
349
  }
278
350
  else {
279
351
  this.#render();
280
352
  }
281
353
  }
282
- else if (event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'tool/result' || event.type === 'turn/end') {
283
- this.#printPlain();
284
- }
285
354
  }
286
355
  setStatus(status) {
287
356
  if (this.#state.status === 'compacting')
@@ -304,6 +373,13 @@ export class LocalTui {
304
373
  this.#render();
305
374
  }
306
375
  setSubagents(roster) {
376
+ const previous = this.#subagents?.agents ?? [];
377
+ const incoming = roster?.agents ?? [];
378
+ const statusChanged = previous.length !== incoming.length || incoming.some((agent, index) => {
379
+ const before = previous[index];
380
+ return before === undefined || before.id !== agent.id || before.label !== agent.label
381
+ || before.phase !== agent.phase || before.depth !== agent.depth || before.mode !== agent.mode;
382
+ });
307
383
  this.#subagents = roster === undefined
308
384
  ? undefined
309
385
  : { agents: roster.agents.map(agent => ({ ...agent, activity: [...agent.activity] })) };
@@ -328,11 +404,17 @@ export class LocalTui {
328
404
  };
329
405
  }
330
406
  }
407
+ if (!statusChanged && this.#agentHub === null)
408
+ return;
331
409
  this.#syncTick();
332
410
  if (this.#tty)
333
- this.#render();
411
+ this.#scheduleStreamRender();
334
412
  }
335
413
  setInspectedSubagent(inspected) {
414
+ if (this.#inspected?.id === inspected?.id && this.#inspected?.label === inspected?.label
415
+ && this.#inspected?.phase === inspected?.phase && this.#inspected?.mode === inspected?.mode
416
+ && this.#inspected?.writable === inspected?.writable)
417
+ return;
336
418
  this.#inspected = inspected === undefined ? undefined : { ...inspected };
337
419
  if (inspected !== undefined) {
338
420
  this.#inspectEscapeFenceUntil = 0;
@@ -358,9 +440,9 @@ export class LocalTui {
358
440
  openTrajectory(events) {
359
441
  if (!this.#tty)
360
442
  return false;
443
+ this.#finishPrompt(null);
361
444
  this.#trajectory = createTrajectory(events);
362
445
  this.#agentHub = null;
363
- this.#prompt = null;
364
446
  this.#settings = null;
365
447
  this.#copySelector = null;
366
448
  this.#search = null;
@@ -383,6 +465,31 @@ export class LocalTui {
383
465
  setImageValidator(validate) {
384
466
  this.#validateImageDraft = validate;
385
467
  }
468
+ toolCallContext(callId) {
469
+ const block = this.#state.blocks.findLast(candidate => candidate.kind === 'tool' && String(candidate.callId) === callId);
470
+ if (block?.kind !== 'tool')
471
+ return undefined;
472
+ // Reuse the card renderer so a decision explains the call the same way the
473
+ // transcript already does. The approval prompt renders its detail on one
474
+ // line, so the parts are joined here rather than handed over as rows.
475
+ const view = renderTool({
476
+ name: block.name,
477
+ arguments: block.args,
478
+ output: block.output,
479
+ status: block.status,
480
+ expanded: false,
481
+ ...(block.presentation === undefined ? {} : { presentation: block.presentation }),
482
+ });
483
+ const detail = view.summary === undefined || view.summary === ''
484
+ ? view.input.join(' ').replace(/\s+/gu, ' ').trim()
485
+ : '';
486
+ // A card with a summary already says what the call is. Without one the raw
487
+ // argument text is all there is, and its pretty-printed form would reduce to
488
+ // a lone brace on a single row, so the whole body is folded into one line.
489
+ return [view.title, view.summary, detail === '' ? undefined : detail]
490
+ .filter((part) => part !== undefined && part !== '')
491
+ .join(' · ');
492
+ }
386
493
  notice(text, options = {}) {
387
494
  const block = {
388
495
  kind: 'notice',
@@ -411,6 +518,7 @@ export class LocalTui {
411
518
  this.#emitNotification(this.#notifications.humanPrompt());
412
519
  this.#editor.setText('');
413
520
  this.#ac = null;
521
+ const displaced = this.#displaceSurface();
414
522
  return new Promise((resolve) => {
415
523
  const selected = Math.max(0, request.options?.findIndex(option => (option.value ?? option.label) === request.initialValue) ?? 0);
416
524
  const pending = { request, selected, checked: new Set(), resolve };
@@ -420,6 +528,7 @@ export class LocalTui {
420
528
  pending.offAbort = () => { request.signal?.removeEventListener('abort', onAbort); };
421
529
  }
422
530
  this.#prompt = pending;
531
+ this.#promptDisplaced = displaced;
423
532
  if (this.#tty) {
424
533
  this.#render();
425
534
  }
@@ -454,6 +563,8 @@ export class LocalTui {
454
563
  }
455
564
  setSession(info) {
456
565
  this.#sessionId = info.id;
566
+ const title = info.title?.trim();
567
+ this.#sessionTitle = title === undefined || title === '' ? undefined : title;
457
568
  this.#recentSessions = info.recent.map((session) => ({ ...session }));
458
569
  this.#sessionStats = info.stats === undefined ? undefined : { ...info.stats };
459
570
  this.#sessionControls = info.controls === undefined
@@ -464,6 +575,7 @@ export class LocalTui {
464
575
  };
465
576
  if (!this.#tty)
466
577
  return;
578
+ this.#syncWindowTitle();
467
579
  if (this.#reveal !== undefined && this.#motion !== 'off')
468
580
  return;
469
581
  if (this.#streamRenderMs > 0 && this.#busy())
@@ -471,11 +583,31 @@ export class LocalTui {
471
583
  else
472
584
  this.#render();
473
585
  }
586
+ /**
587
+ * Mirror the folded session title into the terminal's own window/tab title.
588
+ * Control characters are stripped so a title can never inject an escape
589
+ * sequence, and the payload is bounded to keep tab strips readable.
590
+ */
591
+ #syncWindowTitle() {
592
+ const title = windowTitleText(this.#sessionTitle);
593
+ if (title === this.#writtenWindowTitle)
594
+ return;
595
+ this.#writtenWindowTitle = title;
596
+ this.#term.output.write(`\x1b]2;${title}\x07`);
597
+ }
598
+ /**
599
+ * Re-derive the 24-bit switch from the active color preference. Every path
600
+ * that changes `#colors` calls this so `/settings` takes effect immediately.
601
+ */
602
+ #syncTrueColor() {
603
+ this.#trueColor = this.#colors && detectTrueColor();
604
+ }
474
605
  /** Apply prefs loaded from the settings document (does not persist). */
475
606
  applyStoredPrefs(prefs) {
476
607
  const previousMotion = this.#motion;
477
608
  this.#themeName = prefs.theme;
478
609
  this.#colors = prefs.colors;
610
+ this.#syncTrueColor();
479
611
  this.#motion = prefs.motion ?? 'full';
480
612
  this.#terminalProgress = prefs.terminalProgress ?? false;
481
613
  this.#expandTools = prefs.expandTools;
@@ -523,10 +655,10 @@ export class LocalTui {
523
655
  this.#render();
524
656
  };
525
657
  }
526
- readInput() {
658
+ readInput(signal) {
527
659
  if (this.#pending !== null)
528
660
  return Promise.reject(new Error('omdsh-tui: input read already in flight'));
529
- if (this.#disposed)
661
+ if (this.#disposed || signal?.aborted === true)
530
662
  return Promise.resolve(null);
531
663
  // A Ctrl-D pressed while the previous turn was still settling lands here
532
664
  // (no pending readline existed to resolve); honor it now.
@@ -535,7 +667,7 @@ export class LocalTui {
535
667
  return Promise.resolve(null);
536
668
  }
537
669
  if (!this.#tty)
538
- return this.#readlinePlain();
670
+ return this.#readlinePlain(signal);
539
671
  // Lines submitted while a turn was still running were queued instead of
540
672
  // dropped; serve the oldest before waiting for fresh input.
541
673
  const queued = this.#queuedSubmissions.shift();
@@ -544,7 +676,18 @@ export class LocalTui {
544
676
  return Promise.resolve(queued);
545
677
  }
546
678
  return new Promise((resolve) => {
547
- this.#pending = { resolve };
679
+ const pending = { resolve, signal: signal ?? null };
680
+ this.#pending = pending;
681
+ if (signal !== undefined) {
682
+ const onAbort = () => {
683
+ if (this.#pending !== pending)
684
+ return;
685
+ this.#pending = null;
686
+ resolve(null);
687
+ };
688
+ signal.addEventListener('abort', onAbort, { once: true });
689
+ pending.offAbort = () => { signal.removeEventListener('abort', onAbort); };
690
+ }
548
691
  });
549
692
  }
550
693
  /** Compatibility helper for provider-level text editing tests and embedders. */
@@ -651,6 +794,11 @@ export class LocalTui {
651
794
  // prompt does not overwrite the transcript. Disable bracketed paste
652
795
  // and restore the cursor.
653
796
  this.#renderer.finish();
797
+ // Clear the session title we owned so the shell's own title returns.
798
+ if (this.#writtenWindowTitle !== undefined && this.#writtenWindowTitle !== '') {
799
+ this.#term.output.write('\x1b]2;\x07');
800
+ this.#writtenWindowTitle = undefined;
801
+ }
654
802
  this.#term.output.write('\x1b[?2004l\x1b[?25h\r\n');
655
803
  if (this.#resumeHintRequested && this.#sessionId !== undefined) {
656
804
  this.#term.output.write(`\r\nResume this session with ${APP_NAME} --resume ${this.#sessionId}\r\n`);
@@ -669,15 +817,23 @@ export class LocalTui {
669
817
  this.#lineReader?.close();
670
818
  this.#offAgentBehaviorWatch?.();
671
819
  this.#offAgentBehaviorWatch = undefined;
672
- this.#pending?.resolve(null);
673
- this.#pending = null;
820
+ this.#settlePending(null);
674
821
  this.#finishPrompt(null);
675
822
  }
823
+ /** Settle one pending read, detaching its abort listener. */
824
+ #settlePending(submission) {
825
+ const pending = this.#pending;
826
+ if (pending === null)
827
+ return;
828
+ pending.offAbort?.();
829
+ this.#pending = null;
830
+ pending.resolve(submission);
831
+ }
676
832
  /** Re-render the current frame (resize reflow). */
677
833
  refresh() {
678
834
  this.#render();
679
835
  }
680
- #readlinePlain() {
836
+ #readlinePlain(signal) {
681
837
  return new Promise((resolve) => {
682
838
  if (this.#lineReader === null) {
683
839
  this.#lineReader = createInterface({ input: this.#term.input });
@@ -689,13 +845,26 @@ export class LocalTui {
689
845
  this.#plainResolve(null);
690
846
  });
691
847
  }
692
- if (this.#plainClosed) {
693
- resolve(null);
694
- return;
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); };
695
859
  }
696
- this.#plainPending = { resolve };
860
+ this.#pumpPlain();
697
861
  });
698
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
+ */
699
868
  #plainResolve(line) {
700
869
  if (this.#prompt !== null && line !== null) {
701
870
  const value = line.trim();
@@ -717,9 +886,27 @@ export class LocalTui {
717
886
  }
718
887
  if (this.#prompt !== null)
719
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() {
720
895
  const pending = this.#plainPending;
721
- this.#plainPending = null;
722
- pending?.resolve(line === null ? null : { text: line, images: [] });
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
+ }
723
910
  }
724
911
  /** Print plain-mode blocks that settled since the last flush. */
725
912
  #printPlain() {
@@ -761,9 +948,9 @@ export class LocalTui {
761
948
  this.#tick = null;
762
949
  }
763
950
  }
764
- #syncStreamingReveal(event) {
765
- const textualDelta = event.type === 'assistant/chunk'
766
- && (event.data.chunk.type === 'text-delta' || event.data.chunk.type === 'reasoning-delta');
951
+ #syncStreamingReveal(delta) {
952
+ const textualDelta = delta !== undefined
953
+ && (delta.chunk.type === 'text-delta' || delta.chunk.type === 'reasoning-delta');
767
954
  const key = streamingAssistantKey(this.#state);
768
955
  if (!textualDelta || this.#motion === 'off' || key === undefined) {
769
956
  this.#stopRevealTick();
@@ -868,6 +1055,7 @@ export class LocalTui {
868
1055
  colors: this.#colors,
869
1056
  pwd: this.#pwd,
870
1057
  ...(this.#branch !== undefined ? { branch: this.#branch } : {}),
1058
+ ...(this.#sessionTitle === undefined ? {} : { sessionTitle: this.#sessionTitle }),
871
1059
  version: APP_VERSION,
872
1060
  appName: APP_NAME,
873
1061
  spinnerFrame: this.#spinner,
@@ -876,6 +1064,14 @@ export class LocalTui {
876
1064
  themeName: this.#themeName,
877
1065
  scrollStart: this.#follow ? Number.POSITIVE_INFINITY : this.#scrollStart,
878
1066
  ...(this.#focusBlock === undefined ? {} : { focusBlock: this.#focusBlock }),
1067
+ ...(this.#transcriptSearch === null ? {} : {
1068
+ transcriptSearch: {
1069
+ query: this.#transcriptSearch.query,
1070
+ matches: searchBlockIndexes(this.#state.blocks.map(blockSearchText), this.#transcriptSearch.query),
1071
+ focus: this.#transcriptSearch.focus,
1072
+ editing: this.#transcriptSearch.editing,
1073
+ },
1074
+ }),
879
1075
  toolsExpanded: this.#toolsExpanded,
880
1076
  expandedTools: this.#expandedToolCalls,
881
1077
  commands: this.#commands(),
@@ -946,8 +1142,13 @@ export class LocalTui {
946
1142
  this.#follow = true;
947
1143
  this.#scrollStart = this.#maxStart;
948
1144
  }
1145
+ #utf8 = new StringDecoder('utf8');
1146
+ #plainQueue = [];
949
1147
  #onData(chunk) {
950
- const { events, rest } = parseKeys(this.#pendingKeys + chunk.toString('utf8'));
1148
+ // Decode bytes across the whole stream so a multi-byte UTF-8 character
1149
+ // split between data events is never corrupted into replacement chars.
1150
+ const text = this.#utf8.write(chunk);
1151
+ const { events, rest } = parseKeys(this.#pendingKeys + text);
951
1152
  this.#pendingKeys = rest;
952
1153
  if (this.#escapeTimer !== null) {
953
1154
  clearTimeout(this.#escapeTimer);
@@ -1211,7 +1412,9 @@ export class LocalTui {
1211
1412
  if (this.#handlePrompt(event))
1212
1413
  return;
1213
1414
  if (this.#trajectory !== null) {
1214
- const command = applyTrajectoryEvent(this.#trajectory, event);
1415
+ const { pageSize } = trajectoryListMetrics(this.#trajectory, this.#term.height());
1416
+ const { pageLines } = trajectoryDetailMetrics(this.#trajectory, this.#term.height());
1417
+ const command = applyTrajectoryEvent(this.#trajectory, event, { pageSize, detailPageLines: pageLines });
1215
1418
  if ('close' in command)
1216
1419
  this.#trajectory = null;
1217
1420
  else
@@ -1232,12 +1435,14 @@ export class LocalTui {
1232
1435
  this.#render();
1233
1436
  return;
1234
1437
  }
1438
+ if (this.#transcriptSearch !== null) {
1439
+ this.#applyTranscriptSearch(event);
1440
+ return;
1441
+ }
1235
1442
  if (event.type === 'key') {
1236
- const action = this.#keybindings[event.id];
1237
- if (action !== undefined) {
1238
- this.#runAction(action);
1443
+ const action = this.#boundAction(event.id);
1444
+ if (action !== undefined && this.#runAction(action))
1239
1445
  return;
1240
- }
1241
1446
  }
1242
1447
  if (this.#inspected !== undefined && this.#inspected.writable !== true && event.type === 'text'
1243
1448
  && this.#prompt === null && this.#settings === null && this.#copySelector === null && this.#search === null) {
@@ -1293,14 +1498,6 @@ export class LocalTui {
1293
1498
  this.#applyCopySelector(event);
1294
1499
  return;
1295
1500
  }
1296
- if (event.type === 'key' && event.id === 'ctrl+r') {
1297
- if (this.#images.length > 0)
1298
- return;
1299
- this.#search = createHistorySearch(this.#history);
1300
- this.#ac = null;
1301
- this.#render();
1302
- return;
1303
- }
1304
1501
  if (this.#search !== null) {
1305
1502
  this.#applySearch(event);
1306
1503
  return;
@@ -1344,44 +1541,6 @@ export class LocalTui {
1344
1541
  if (event.type === 'key' && (event.id === 'backspace' || event.id === 'delete')
1345
1542
  && this.#removeImageAtCursor(event.id))
1346
1543
  return;
1347
- if (event.type === 'key') {
1348
- if (event.id === 'pageUp') {
1349
- this.#scrollBy(-this.#pageSize());
1350
- return;
1351
- }
1352
- if (event.id === 'pageDown') {
1353
- this.#scrollBy(this.#pageSize());
1354
- return;
1355
- }
1356
- if (event.id === 'shift+up') {
1357
- this.#scrollBy(-TRANSCRIPT_FAST_SCROLL);
1358
- return;
1359
- }
1360
- if (event.id === 'shift+down') {
1361
- this.#scrollBy(TRANSCRIPT_FAST_SCROLL);
1362
- return;
1363
- }
1364
- if (event.id === 'ctrl+o') {
1365
- const last = this.#state.blocks.at(-1);
1366
- const tool = last?.kind === 'toolCatalog'
1367
- ? undefined
1368
- : this.#state.blocks.findLast(block => block.kind === 'tool');
1369
- if (last?.kind === 'toolCatalog') {
1370
- this.#toolsExpanded = !this.#toolsExpanded;
1371
- }
1372
- else if (tool?.kind === 'tool') {
1373
- if (this.#expandedToolCalls.has(tool.callId))
1374
- this.#expandedToolCalls.delete(tool.callId);
1375
- else
1376
- this.#expandedToolCalls.add(tool.callId);
1377
- }
1378
- else {
1379
- this.#toolsExpanded = !this.#toolsExpanded;
1380
- }
1381
- this.#render();
1382
- return;
1383
- }
1384
- }
1385
1544
  this.#applyCommand(this.#editor.handle(event));
1386
1545
  }
1387
1546
  #handlePrompt(event) {
@@ -1651,6 +1810,7 @@ export class LocalTui {
1651
1810
  const expandChanged = prefs.expandTools !== this.#expandTools;
1652
1811
  this.#themeName = prefs.theme;
1653
1812
  this.#colors = prefs.colors;
1813
+ this.#syncTrueColor();
1654
1814
  const previousMotion = this.#motion;
1655
1815
  this.#motion = prefs.motion ?? 'full';
1656
1816
  this.#terminalProgress = prefs.terminalProgress ?? false;
@@ -2053,6 +2213,7 @@ export class LocalTui {
2053
2213
  this.#resumeHintRequested = true;
2054
2214
  if (this.#pending !== null) {
2055
2215
  const pending = this.#pending;
2216
+ pending.offAbort?.();
2056
2217
  this.#pending = null;
2057
2218
  pending.resolve(null);
2058
2219
  }
@@ -2102,6 +2263,7 @@ export class LocalTui {
2102
2263
  }
2103
2264
  const pending = this.#pending;
2104
2265
  if (pending !== null) {
2266
+ pending.offAbort?.();
2105
2267
  this.#pending = null;
2106
2268
  pending.resolve({ text: submittedText, images });
2107
2269
  if (queueEditNewer !== null)
@@ -2163,7 +2325,10 @@ export class LocalTui {
2163
2325
  return;
2164
2326
  }
2165
2327
  if (command.name === 'clear') {
2166
- this.#state = initialTranscript();
2328
+ // Presentation-only reset: the agent may still own an active turn with
2329
+ // live status, todos, and queued inbox state. Clearing those would make
2330
+ // the next Ctrl-C (or follow-up) behave as if the session had finished.
2331
+ this.#state = { ...this.#state, blocks: [] };
2167
2332
  this.#followTail();
2168
2333
  this.#renderer.startEpoch();
2169
2334
  this.#render();
@@ -2191,6 +2356,7 @@ export class LocalTui {
2191
2356
  const raw = '/' + name + (args === '' ? '' : ' ' + args);
2192
2357
  const pending = this.#pending;
2193
2358
  if (pending !== null) {
2359
+ pending.offAbort?.();
2194
2360
  this.#pending = null;
2195
2361
  pending.resolve({ text: raw, images: [] });
2196
2362
  }
@@ -2215,7 +2381,7 @@ export class LocalTui {
2215
2381
  formatHelpText(this.#commands()),
2216
2382
  '',
2217
2383
  full
2218
- ? `**Keyboard Shortcuts · ${hotkeyCount(this.#keybindings)} bindings**`
2384
+ ? `**Keyboard Shortcuts · ${hotkeyCount(this.#keybindings)} shortcuts**`
2219
2385
  : '**Essential Shortcuts**',
2220
2386
  '',
2221
2387
  full
@@ -2246,6 +2412,42 @@ export class LocalTui {
2246
2412
  this.#prompt = null;
2247
2413
  pending.offAbort?.();
2248
2414
  pending.resolve(answer);
2415
+ this.#restoreDisplacedSurface();
2416
+ }
2417
+ /** Save and close the visible overlay so a prompt owns both input and screen. */
2418
+ #displaceSurface() {
2419
+ const displaced = this.#trajectory !== null
2420
+ ? { kind: 'trajectory', state: this.#trajectory }
2421
+ : this.#agentHub !== null
2422
+ ? { kind: 'agentHub', state: this.#agentHub }
2423
+ : this.#settings !== null
2424
+ ? { kind: 'settings', state: this.#settings }
2425
+ : this.#copySelector !== null
2426
+ ? { kind: 'copySelector', state: this.#copySelector }
2427
+ : this.#search !== null ? { kind: 'search', state: this.#search } : null;
2428
+ this.#trajectory = null;
2429
+ this.#agentHub = null;
2430
+ this.#settings = null;
2431
+ this.#copySelector = null;
2432
+ this.#search = null;
2433
+ return displaced;
2434
+ }
2435
+ /** Restore the surface displaced by the settled prompt, if one existed. */
2436
+ #restoreDisplacedSurface() {
2437
+ const displaced = this.#promptDisplaced;
2438
+ this.#promptDisplaced = null;
2439
+ if (displaced === null)
2440
+ return;
2441
+ if (displaced.kind === 'trajectory')
2442
+ this.#trajectory = displaced.state;
2443
+ else if (displaced.kind === 'agentHub')
2444
+ this.#agentHub = displaced.state;
2445
+ else if (displaced.kind === 'settings')
2446
+ this.#settings = displaced.state;
2447
+ else if (displaced.kind === 'copySelector')
2448
+ this.#copySelector = displaced.state;
2449
+ else
2450
+ this.#search = displaced.state;
2249
2451
  }
2250
2452
  async #runCopy(args) {
2251
2453
  if (args.trim() === '') {
@@ -2318,41 +2520,96 @@ export class LocalTui {
2318
2520
  this.#follow = false;
2319
2521
  this.#focusBlock = Math.max(0, this.#state.blocks.length - 1);
2320
2522
  }
2523
+ /** Resolve a configured action for one key id, tolerating camelCase ids. */
2524
+ #boundAction(id) {
2525
+ return this.#keybindings[id] ?? this.#keybindings[id.toLowerCase()];
2526
+ }
2527
+ /** Run one configured action; returns true when the event was consumed. */
2321
2528
  #runAction(action) {
2322
2529
  if (this.#prompt !== null || this.#settings !== null || this.#copySelector !== null)
2323
- return;
2530
+ return false;
2531
+ if (action === 'scroll-page-up') {
2532
+ if (this.#search !== null)
2533
+ return false;
2534
+ this.#scrollBy(-this.#pageSize());
2535
+ return true;
2536
+ }
2537
+ if (action === 'scroll-page-down') {
2538
+ if (this.#search !== null)
2539
+ return false;
2540
+ this.#scrollBy(this.#pageSize());
2541
+ return true;
2542
+ }
2543
+ if (action === 'scroll-fast-up') {
2544
+ if (this.#search !== null)
2545
+ return false;
2546
+ this.#scrollBy(-TRANSCRIPT_FAST_SCROLL);
2547
+ return true;
2548
+ }
2549
+ if (action === 'scroll-fast-down') {
2550
+ if (this.#search !== null)
2551
+ return false;
2552
+ this.#scrollBy(TRANSCRIPT_FAST_SCROLL);
2553
+ return true;
2554
+ }
2555
+ if (action === 'toggle-tools') {
2556
+ if (this.#search !== null)
2557
+ return false;
2558
+ this.#toggleToolExpansion();
2559
+ return true;
2560
+ }
2561
+ if (action === 'search-history') {
2562
+ if (this.#images.length > 0)
2563
+ return false;
2564
+ this.#search = createHistorySearch(this.#history);
2565
+ this.#ac = null;
2566
+ this.#render();
2567
+ return true;
2568
+ }
2569
+ if (action === 'search-transcript') {
2570
+ // An empty composer has no forward-char target, so Ctrl+F searches the
2571
+ // transcript there; a non-empty draft keeps the editor's own binding.
2572
+ if (this.#search !== null)
2573
+ return false;
2574
+ if (this.#editor.text !== '' || this.#images.length > 0)
2575
+ return false;
2576
+ this.#transcriptSearch = createTranscriptSearch();
2577
+ this.#ac = null;
2578
+ this.#render();
2579
+ return true;
2580
+ }
2324
2581
  if (action === 'inspect-subagent') {
2325
2582
  void this.#pickSubagent();
2326
- return;
2583
+ return true;
2327
2584
  }
2328
2585
  if (action === 'retry') {
2329
2586
  this.#submit('/retry');
2330
- return;
2587
+ return true;
2331
2588
  }
2332
2589
  if (action === 'cycle-model-forward') {
2333
2590
  this.#submit('/model next');
2334
- return;
2591
+ return true;
2335
2592
  }
2336
2593
  if (action === 'cycle-model-backward') {
2337
2594
  this.#submit('/model previous');
2338
- return;
2595
+ return true;
2339
2596
  }
2340
2597
  if (action === 'cycle-reasoning') {
2341
2598
  this.#submit('/model reasoning');
2342
- return;
2599
+ return true;
2343
2600
  }
2344
2601
  if (action === 'copy-prompt') {
2345
2602
  void this.#copyPicked(this.#editor.text, 'current prompt');
2346
- return;
2603
+ return true;
2347
2604
  }
2348
2605
  if (action === 'copy-line') {
2349
2606
  const text = this.#editor.text.slice(lineStart(this.#editor.text, this.#editor.cursor), lineEnd(this.#editor.text, this.#editor.cursor));
2350
2607
  void this.#copyPicked(text, 'current line');
2351
- return;
2608
+ return true;
2352
2609
  }
2353
2610
  if (action === 'paste-clipboard') {
2354
2611
  this.#startAsyncPaste(this.#pasteClipboard());
2355
- return;
2612
+ return true;
2356
2613
  }
2357
2614
  try {
2358
2615
  this.#term.input.setRawMode?.(false);
@@ -2369,6 +2626,43 @@ export class LocalTui {
2369
2626
  this.#refreshAutocomplete();
2370
2627
  this.#render();
2371
2628
  }
2629
+ return true;
2630
+ }
2631
+ /** Expand or collapse the newest tool card, or the whole tool catalog. */
2632
+ #toggleToolExpansion() {
2633
+ const last = this.#state.blocks.at(-1);
2634
+ const tool = last?.kind === 'toolCatalog'
2635
+ ? undefined
2636
+ : this.#state.blocks.findLast(block => block.kind === 'tool');
2637
+ if (last?.kind === 'toolCatalog') {
2638
+ this.#toolsExpanded = !this.#toolsExpanded;
2639
+ }
2640
+ else if (tool?.kind === 'tool') {
2641
+ if (this.#expandedToolCalls.has(tool.callId))
2642
+ this.#expandedToolCalls.delete(tool.callId);
2643
+ else
2644
+ this.#expandedToolCalls.add(tool.callId);
2645
+ }
2646
+ else {
2647
+ this.#toolsExpanded = !this.#toolsExpanded;
2648
+ }
2649
+ this.#render();
2650
+ }
2651
+ /** Fold one event into the active transcript search. */
2652
+ #applyTranscriptSearch(event) {
2653
+ const state = this.#transcriptSearch;
2654
+ if (state === null)
2655
+ return;
2656
+ const command = applyTranscriptSearchEvent(state, event, this.#state.blocks.map(blockSearchText));
2657
+ if (command.kind === 'close') {
2658
+ this.#transcriptSearch = null;
2659
+ this.#render();
2660
+ return;
2661
+ }
2662
+ this.#transcriptSearch = command.state;
2663
+ if (command.kind === 'focus')
2664
+ this.#focusBlock = command.block;
2665
+ this.#render();
2372
2666
  }
2373
2667
  }
2374
2668
  /**
@@ -2389,7 +2683,7 @@ export function apply(ctx, config) {
2389
2683
  return () => { process.stdout.removeListener('resize', listener); };
2390
2684
  },
2391
2685
  };
2392
- const tui = new LocalTui(term, config.model, config.colors ?? term.output.isTTY === true, parseThemeName(config.theme), copyToClipboard, {
2686
+ const tui = new LocalTui(term, config.model, config.colors, parseThemeName(config.theme), copyToClipboard, {
2393
2687
  deferInitialRender: true,
2394
2688
  terminalProfile,
2395
2689
  alternateScreenOverlays: terminalProfile === 'direct',
@@ -2399,7 +2693,7 @@ export function apply(ctx, config) {
2399
2693
  ctx.provide(TUI_SERVICE, tui);
2400
2694
  ctx.effect(() => () => { tui.dispose(); });
2401
2695
  ctx.inject(['settings'], (settingsCtx) => {
2402
- const scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema, { base: { theme: parseThemeName(config.theme), colors: config.colors ?? term.output.isTTY === true, expandTools: false } });
2696
+ const scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema, { base: { theme: parseThemeName(config.theme), colors: resolveColors(config.colors, term.output.isTTY === true), expandTools: false } });
2403
2697
  tui.applyStoredPrefs(scope.get());
2404
2698
  tui.setPrefsPersist((prefs) => { void scope.update(prefs); });
2405
2699
  scope.watch((next) => { tui.applyStoredPrefs(next); });