@sagmans/dsh-tui 0.1.1 → 0.2.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 (94) hide show
  1. package/README.md +84 -10
  2. package/cordis.patch.yml +4 -2
  3. package/lib/agent/host.d.ts +14 -0
  4. package/lib/agent/host.d.ts.map +1 -1
  5. package/lib/agent/host.js +20 -4
  6. package/lib/agent/host.js.map +1 -1
  7. package/lib/agent/model.d.ts +24 -0
  8. package/lib/agent/model.d.ts.map +1 -1
  9. package/lib/agent/model.js +46 -1
  10. package/lib/agent/model.js.map +1 -1
  11. package/lib/agent/present.d.ts +7 -1
  12. package/lib/agent/present.d.ts.map +1 -1
  13. package/lib/agent/present.js +33 -4
  14. package/lib/agent/present.js.map +1 -1
  15. package/lib/agent/presets.d.ts +7 -1
  16. package/lib/agent/presets.d.ts.map +1 -1
  17. package/lib/agent/presets.js +7 -3
  18. package/lib/agent/presets.js.map +1 -1
  19. package/lib/cards.d.ts +127 -7
  20. package/lib/cards.d.ts.map +1 -1
  21. package/lib/cards.js +380 -52
  22. package/lib/cards.js.map +1 -1
  23. package/lib/export.d.ts +5 -4
  24. package/lib/export.d.ts.map +1 -1
  25. package/lib/export.js +62 -13
  26. package/lib/export.js.map +1 -1
  27. package/lib/fold-cursor.d.ts +23 -0
  28. package/lib/fold-cursor.d.ts.map +1 -0
  29. package/lib/fold-cursor.js +31 -0
  30. package/lib/fold-cursor.js.map +1 -0
  31. package/lib/index.d.ts +7 -1
  32. package/lib/index.d.ts.map +1 -1
  33. package/lib/index.js +310 -32
  34. package/lib/index.js.map +1 -1
  35. package/lib/input/submission.d.ts +3 -1
  36. package/lib/input/submission.d.ts.map +1 -1
  37. package/lib/input/submission.js +5 -2
  38. package/lib/input/submission.js.map +1 -1
  39. package/lib/terminal/warning-screen.d.ts +8 -0
  40. package/lib/terminal/warning-screen.d.ts.map +1 -0
  41. package/lib/terminal/warning-screen.js +66 -0
  42. package/lib/terminal/warning-screen.js.map +1 -0
  43. package/lib/theme-capability.d.ts +36 -0
  44. package/lib/theme-capability.d.ts.map +1 -0
  45. package/lib/theme-capability.js +190 -0
  46. package/lib/theme-capability.js.map +1 -0
  47. package/lib/theme-command.d.ts +10 -0
  48. package/lib/theme-command.d.ts.map +1 -0
  49. package/lib/theme-command.js +61 -0
  50. package/lib/theme-command.js.map +1 -0
  51. package/lib/theme-settings.d.ts +161 -0
  52. package/lib/theme-settings.d.ts.map +1 -0
  53. package/lib/theme-settings.js +166 -0
  54. package/lib/theme-settings.js.map +1 -0
  55. package/lib/theme-tokens.d.ts +110 -0
  56. package/lib/theme-tokens.d.ts.map +1 -0
  57. package/lib/theme-tokens.js +401 -0
  58. package/lib/theme-tokens.js.map +1 -0
  59. package/lib/theme.d.ts +32 -35
  60. package/lib/theme.d.ts.map +1 -1
  61. package/lib/theme.js +112 -63
  62. package/lib/theme.js.map +1 -1
  63. package/lib/tokens.d.ts +15 -0
  64. package/lib/tokens.d.ts.map +1 -0
  65. package/lib/tokens.js +29 -0
  66. package/lib/tokens.js.map +1 -0
  67. package/lib/transcript.d.ts +26 -1
  68. package/lib/transcript.d.ts.map +1 -1
  69. package/lib/transcript.js +153 -25
  70. package/lib/transcript.js.map +1 -1
  71. package/lib/ui/dock.d.ts.map +1 -1
  72. package/lib/ui/dock.js +67 -28
  73. package/lib/ui/dock.js.map +1 -1
  74. package/lib/ui/editor.d.ts +34 -0
  75. package/lib/ui/editor.d.ts.map +1 -0
  76. package/lib/ui/editor.js +98 -0
  77. package/lib/ui/editor.js.map +1 -0
  78. package/lib/ui/markdown.d.ts +8 -0
  79. package/lib/ui/markdown.d.ts.map +1 -1
  80. package/lib/ui/markdown.js +12 -0
  81. package/lib/ui/markdown.js.map +1 -1
  82. package/lib/ui/picker.d.ts +37 -0
  83. package/lib/ui/picker.d.ts.map +1 -1
  84. package/lib/ui/picker.js +53 -0
  85. package/lib/ui/picker.js.map +1 -1
  86. package/lib/ui/status.d.ts +3 -1
  87. package/lib/ui/status.d.ts.map +1 -1
  88. package/lib/ui/status.js +76 -24
  89. package/lib/ui/status.js.map +1 -1
  90. package/lib/ui/view.d.ts +43 -0
  91. package/lib/ui/view.d.ts.map +1 -1
  92. package/lib/ui/view.js +254 -54
  93. package/lib/ui/view.js.map +1 -1
  94. package/package.json +3 -1
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { writeFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
- import { Editor, ProcessTerminal, ScrollView, TuiAltScreen, VStack, isKeyRelease, matchesKey } from '@earendil-works/pi-tui';
4
+ import { ProcessTerminal, ScrollView, VStack, isKeyRelease, matchesKey } from '@earendil-works/pi-tui';
5
5
  import { startAgent } from "./agent/host.js";
6
6
  import { PICKER_LIMIT, createSessionHistory, presetOfStoredSession, readSessionTitle, } from "./agent/history.js";
7
7
  import { createPresetRoster, parsePresetArgument } from "./agent/presets.js";
@@ -17,25 +17,38 @@ import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
17
17
  import { createCompletionProvider } from "./input/completion.js";
18
18
  import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
19
19
  import { resolveConfig } from "./config.js";
20
+ import { FoldCursor } from "./fold-cursor.js";
20
21
  import { createRestoreRegistry } from "./terminal/restore.js";
22
+ import { WarningSafeTui } from "./terminal/warning-screen.js";
21
23
  import { BELL, shouldRingBell } from "./terminal/bell.js";
22
24
  import { clipboardSequence } from "./terminal/clipboard.js";
23
25
  import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
24
26
  import { defaultExportFile, transcriptToText } from "./export.js";
25
- import { colorEnabled, createTheme } from "./theme.js";
27
+ import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
28
+ import { detectColourMode } from "./theme-capability.js";
29
+ import { defaultSettings, readScope, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
30
+ import { renderThemeTable } from "./theme-command.js";
26
31
  import { SessionId } from '@deepseek-ai/dsh-session';
32
+ import { formatTokens } from "./tokens.js";
27
33
  import { TranscriptModel } from "./transcript.js";
28
34
  import { WorkFold, describeTodos } from "./work.js";
29
35
  import { WorkDock } from "./ui/dock.js";
36
+ import { BoxedEditor } from "./ui/editor.js";
30
37
  import { MarkdownRenderer } from "./ui/markdown.js";
31
- import { PresetPicker, SessionPicker } from "./ui/picker.js";
32
- import { StatusBar, formatTokens } from "./ui/status.js";
33
- import { TranscriptView } from "./ui/view.js";
38
+ import { EffortPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
39
+ import { StatusBar } from "./ui/status.js";
40
+ import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
34
41
  export const name = 'tui';
35
- /** `agents` is the only service the surface cannot run without. */
36
- export const inject = ['agents'];
42
+ /**
43
+ * Services the surface cannot run without.
44
+ *
45
+ * `tools` is what lets a card read its tool's own render intent; a context that
46
+ * has not injected it throws on the property read, which silently degraded
47
+ * every card to a bare generic row.
48
+ */
49
+ export const inject = ['agents', 'tools'];
37
50
  /** Keys the surface answers itself, listed wherever the reader asks for help. */
38
- const LOCAL_KEYS = 'ctrl+o tool detail · ctrl+t reasoning · ctrl+b back to this session · ctrl+c interrupt or exit';
51
+ const LOCAL_KEYS = 'ctrl+o tool detail · ctrl+y nested calls · shift+tab reasoning · ctrl+t reasoning effort · ctrl+b back to this session · ctrl+c interrupt or exit';
39
52
  /** The one thing to say about a view a reader did not open. */
40
53
  const LOCAL_KEYS_BACK = 'ctrl+b returns';
41
54
  /** Stored sessions titled at once when the picker opens. */
@@ -78,32 +91,116 @@ export function apply(ctx, config) {
78
91
  if (appExit === undefined) {
79
92
  throw new Error('dsh-tui: the dsh launcher must provide appExit; start this surface with dsh --profile tui');
80
93
  }
81
- const theme = createTheme(colorEnabled(resolved.color));
82
- const model = new TranscriptModel(createToolPresenter(ctx));
94
+ /**
95
+ * The surface's appearance, rebuilt whenever the reader's settings change.
96
+ *
97
+ * Renderers hold this object for the life of the session, so the current
98
+ * theme is swapped *behind* a stable delegate rather than reassigned: every
99
+ * row then reads one whole table, and a repaint can never observe a
100
+ * half-applied one. `--no-color` still outranks anything configured.
101
+ */
102
+ const themeMode = () => (resolved.color ? detectColourMode(process.env) : 'none');
103
+ /**
104
+ * The reader's section, or nothing when the service is not mounted.
105
+ *
106
+ * The service is only readable inside an `inject` scope — asking for it
107
+ * outside one is a composition error, not a missing value — so this stays a
108
+ * late-bound read that the injection point and the change event both use.
109
+ */
110
+ let readSection = () => defaultSettings();
111
+ /** A refused settings edit, kept until the surface can show it: stderr is behind the alt screen. */
112
+ let pendingSettingsProblem;
113
+ let current = createTheme(themeMode());
114
+ const applyTheme = () => {
115
+ current = createTheme(themeMode(), toOverrides(readSection()));
116
+ };
117
+ const theme = {
118
+ get revision() { return current.revision; },
119
+ get color() { return current.color; },
120
+ style: (token, text) => current.style(token, text),
121
+ cut: (text, width, ellipsis) => current.cut(text, width, ellipsis),
122
+ glyph: token => current.glyph(token),
123
+ visible: token => current.visible(token),
124
+ // Forwarded rather than read, because the editor and the markdown view keep
125
+ // the theme object they were built with: a settings change has to reach them
126
+ // through a stable delegate or they would keep the boot appearance.
127
+ editor: forwardEditorTheme(() => current.editor),
128
+ markdown: forwardMarkdownTheme(() => current.markdown),
129
+ };
130
+ /**
131
+ * Rows the reader has opened. The model stays untouched; only the view reads
132
+ * this. A thought starts folded: it is the longest, least scannable row in the
133
+ * transcript, so leaving it open pushes the answer a reader came for off the
134
+ * screen, and a folded row still names itself and its key. A PTC card's calls
135
+ * start open for the opposite reason: each is one clipped line under a header
136
+ * that already names the program.
137
+ */
138
+ const viewState = { ...DEFAULT_VIEW_STATE };
139
+ /**
140
+ * Seed the nested-call display the reader configured.
141
+ *
142
+ * The key toggles it for one session, but a settings edit is a deliberate act,
143
+ * so it re-seeds and becomes the new starting point.
144
+ */
145
+ const applyDisplay = () => {
146
+ viewState.expandSubCalls = readSection().subcalls === 'inline';
147
+ };
148
+ /**
149
+ * Own the section, so the harness validates and persists it for the reader.
150
+ *
151
+ * Registration is how the document learns the section exists at all; without
152
+ * it a hand-written `dsh-tui:` block would be dropped on the next save. The
153
+ * first read happens here too, because this is the only scope the service
154
+ * may be touched in.
155
+ */
156
+ ctx.inject(['settings'], settingsCtx => {
157
+ const scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema);
158
+ readSection = () => readScope(scope, message => { pendingSettingsProblem = message; });
159
+ applyTheme();
160
+ applyDisplay();
161
+ });
162
+ /**
163
+ * The agent scope the tool presenter resolves against.
164
+ *
165
+ * Tools are registered in the scoped world the session's preset mounts, so a
166
+ * card can only read its tool's own render intent while this names that
167
+ * agent. It follows whatever session the transcript is folding, because a
168
+ * child on screen reads through the child's scope, not the parent's.
169
+ */
170
+ let presentScope;
171
+ const model = new TranscriptModel(createToolPresenter(ctx, () => presentScope));
83
172
  const work = new WorkFold();
84
173
  const modelSwitch = new ModelSwitch();
85
174
  const agentPresets = createPresetRoster(ctx);
175
+ /** The mode named on the command line, which is the only one that may conflict. */
176
+ const requestedPreset = resolved.preset;
86
177
  /**
87
178
  * The mode a session the reader starts from here on joins.
88
179
  *
89
180
  * A launch flag seeds it and a successful switch updates it, so `/new` and
90
181
  * `/fork` land in the mode the reader last chose; nothing is written to
91
182
  * settings, because the mode of a session is not a property of the machine.
183
+ * Unset means nobody chose, which is where the roster's default applies.
92
184
  */
93
- /** The mode named on the command line, which is the only one that may conflict. */
94
- const requestedPreset = resolved.preset;
95
- let seat = requestedPreset ?? agentPresets?.defaultId;
185
+ let seat = requestedPreset;
186
+ /**
187
+ * The mode a session takes its seat in: the reader's latest choice, or the
188
+ * roster's default read at this moment.
189
+ *
190
+ * The default cannot be captured when this row applies: it comes from the
191
+ * settings document, which may be read after that, so a captured copy would
192
+ * pin every later session to the mode the bundle happened to ship.
193
+ */
194
+ const seatMode = () => seat ?? agentPresets?.defaultId;
96
195
  const catalog = createModelCatalog(ctx);
97
196
  const jobDirectory = createJobDirectory(ctx);
98
197
  let jobs = [];
99
198
  const roster = new SubagentRoster();
100
199
  const subagentControl = createSubagentControl(ctx);
101
200
  const markdown = new MarkdownRenderer(theme.markdown);
102
- /** Rows the reader has opened. The model stays untouched; only the view reads this. */
103
- const viewState = { expandCards: false, expandReasoning: false };
104
201
  const restore = createRestoreRegistry();
105
202
  const terminal = new ProcessTerminal();
106
- const tui = new TuiAltScreen(terminal);
203
+ const tui = new WarningSafeTui(terminal);
107
204
  /** The session this surface drives: commands, approvals, and the bell belong to it. */
108
205
  let activeSession = resolved.sessionId;
109
206
  /** The session the transcript is showing, which can be one of its children. */
@@ -117,8 +214,13 @@ export function apply(ctx, config) {
117
214
  gate: () => pending?.gate.card(),
118
215
  picker: () => pendingPicker?.picker.card(),
119
216
  });
120
- const editor = new Editor(tui, theme.editor);
217
+ const editor = new BoxedEditor(tui, theme.editor);
121
218
  const disposers = [];
219
+ // The presenter closure outlives the composition's own teardown, so it must
220
+ // not keep an agent alive after its world unwinds.
221
+ disposers.push(() => {
222
+ presentScope = undefined;
223
+ });
122
224
  let agent;
123
225
  let turnOpen = false;
124
226
  let turnStartedAt;
@@ -275,11 +377,20 @@ export function apply(ctx, config) {
275
377
  tui.requestRender();
276
378
  return { consume: true };
277
379
  }
278
- if (matchesKey(data, 'ctrl+t')) {
380
+ if (matchesKey(data, 'shift+tab')) {
279
381
  viewState.expandReasoning = !viewState.expandReasoning;
280
382
  tui.requestRender();
281
383
  return { consume: true };
282
384
  }
385
+ if (matchesKey(data, 'ctrl+y')) {
386
+ viewState.expandSubCalls = !viewState.expandSubCalls;
387
+ tui.requestRender();
388
+ return { consume: true };
389
+ }
390
+ if (matchesKey(data, 'ctrl+t')) {
391
+ void openEffortPicker();
392
+ return { consume: true };
393
+ }
283
394
  if (matchesKey(data, 'ctrl+b')) {
284
395
  if (viewedSession !== activeSession)
285
396
  void showAgentSession();
@@ -429,11 +540,31 @@ export function apply(ctx, config) {
429
540
  * materialized; a session this process is not running falls back to storage.
430
541
  */
431
542
  const liveSession = (id) => ctx.get('sessions')?.get?.(id);
543
+ /**
544
+ * The fold's place in the viewed session's durable sequence.
545
+ *
546
+ * A resumed session is folded while its agent's loop is already live, so the
547
+ * same event can reach the surface twice: once on the stream and once from the
548
+ * log the fold is reading. The sequence number every durable event carries is
549
+ * what tells the two apart.
550
+ */
551
+ const foldCursor = new FoldCursor();
552
+ /** Feed one durable event to the model, unless a fold has already folded it. */
553
+ const applyDurable = (event) => {
554
+ if (foldCursor.accept(event))
555
+ applyEvent(event);
556
+ };
432
557
  const foldHistory = async (id) => {
558
+ // Resolved before the fold so every card reads its tool through the scope
559
+ // that actually registered it; a stored session nobody runs has none.
560
+ presentScope = ctx.agents?.get(id);
561
+ // Every fold starts a cleared transcript, so this session's own numbering is
562
+ // where the cursor begins rather than the previous session's.
563
+ foldCursor.reset();
433
564
  const inMemory = liveSession(id)?.snapshotEvents?.();
434
565
  if (inMemory !== undefined) {
435
566
  for (const event of inMemory)
436
- applyEvent(event);
567
+ applyDurable(event);
437
568
  return inMemory.length;
438
569
  }
439
570
  const history = createSessionHistory(ctx);
@@ -441,7 +572,7 @@ export function apply(ctx, config) {
441
572
  return 0;
442
573
  const events = await history.read(id);
443
574
  for (const event of events)
444
- applyEvent(event);
575
+ applyDurable(event);
445
576
  return events.length;
446
577
  };
447
578
  const replayHistory = async (id) => {
@@ -507,13 +638,13 @@ export function apply(ctx, config) {
507
638
  if (agentPresets === undefined)
508
639
  return undefined;
509
640
  if (fork !== undefined)
510
- return agentPresets.current(liveSession(fork.from)) ?? seat;
641
+ return agentPresets.current(liveSession(fork.from)) ?? seatMode();
511
642
  if (!resume)
512
- return seat;
643
+ return seatMode();
513
644
  const history = createSessionHistory(ctx);
514
645
  const stored = history === undefined ? undefined : await presetOfStoredSession(history, id);
515
646
  if (stored === undefined)
516
- return seat;
647
+ return seatMode();
517
648
  const resolvedStored = await resolveStored(id, stored);
518
649
  if (resolvedStored === undefined) {
519
650
  // The composition this session recorded is gone. Naming one explicitly is
@@ -541,8 +672,6 @@ export function apply(ctx, config) {
541
672
  // Settled before the transcript is touched, so a refusal leaves neither a
542
673
  // half-replayed session nor a half-composed agent behind.
543
674
  const preset = await presetFor(id, resume, fork);
544
- if (resume)
545
- await replayHistory(id);
546
675
  const handle = await startAgent(ctx, {
547
676
  sessionId: id,
548
677
  resume,
@@ -561,6 +690,16 @@ export function apply(ctx, config) {
561
690
  activeSession = id;
562
691
  viewedSession = id;
563
692
  agent = handle;
693
+ // A session with no history to fold still has to present its first live
694
+ // card through the right scope, so the scope is set before any event can.
695
+ presentScope = handle.agent;
696
+ // Replayed only once the agent exists, because the fold reads every card
697
+ // through the scope the preset mounted; a fold before that scope existed
698
+ // degraded each replayed card to a bare generic row. The agent's loop is
699
+ // live by now, so the fold and the stream race over the same events; the
700
+ // durable sequence number is what keeps one event from landing twice.
701
+ if (resume)
702
+ await replayHistory(id);
564
703
  // A branch inherits the conversation the reader was already reading, so it
565
704
  // opens on that history rather than on an empty screen.
566
705
  if (fork !== undefined)
@@ -576,6 +715,9 @@ export function apply(ctx, config) {
576
715
  const switchSession = async (id) => {
577
716
  const previous = agent;
578
717
  agent = undefined;
718
+ // Drop the outgoing scope before its agent is disposed, so no card folded
719
+ // during the transition can read a torn-down world.
720
+ presentScope = undefined;
579
721
  turnOpen = false;
580
722
  model.reset();
581
723
  work.reset();
@@ -833,7 +975,7 @@ export function apply(ctx, config) {
833
975
  ? `${facts.model ?? 'unset'}${facts.effort === undefined ? '' : ` (${facts.effort})`} · composition default`
834
976
  : `${current.provider}/${current.model}${current.reasoningEffort === undefined ? '' : ` (${current.reasoningEffort})`}`;
835
977
  const providers = catalog.providers().map(provider => provider.id);
836
- model.notice(`model ${route} · providers: ${providers.length === 0 ? 'none' : providers.join(', ')} · /model <provider>/<model> switches, /model <provider> lists its models`);
978
+ model.notice(`model ${route} · providers: ${providers.length === 0 ? 'none' : providers.join(', ')} · /model <provider>/<model>[/<effort>] switches, /model <provider> lists its models`);
837
979
  tui.requestRender();
838
980
  return;
839
981
  }
@@ -848,17 +990,95 @@ export function apply(ctx, config) {
848
990
  tui.requestRender();
849
991
  });
850
992
  return;
851
- case 'switch':
852
- modelSwitch.choose(command.choice);
853
- model.notice(`model set to ${command.choice.provider}/${command.choice.model} for the next step`);
854
- tui.requestRender();
993
+ case 'switch': {
994
+ const choice = command.choice;
995
+ if (choice.reasoningEffort === undefined) {
996
+ modelSwitch.choose(choice);
997
+ model.notice(`model set to ${choice.provider}/${choice.model} for the next step`);
998
+ tui.requestRender();
999
+ return;
1000
+ }
1001
+ // The route decides which efforts exist, so an explicit one is checked
1002
+ // against the adapter before it is put in force: a typo must not become
1003
+ // a request the provider rejects.
1004
+ void (async () => {
1005
+ try {
1006
+ const info = await catalog.efforts(choice.provider, choice.model);
1007
+ const efforts = info?.efforts ?? [];
1008
+ if (!efforts.some(effort => effort.id === choice.reasoningEffort)) {
1009
+ model.notice(efforts.length === 0
1010
+ ? `/model: ${choice.provider}/${choice.model} advertises no reasoning efforts`
1011
+ : `/model: ${choice.provider}/${choice.model} does not offer reasoning effort "${choice.reasoningEffort}" — offers: ${efforts.map(effort => effort.id).join(' ')}`);
1012
+ tui.requestRender();
1013
+ return;
1014
+ }
1015
+ modelSwitch.choose(choice);
1016
+ model.notice(`model set to ${choice.provider}/${choice.model} (${choice.reasoningEffort}) for the next step`);
1017
+ }
1018
+ catch (error) {
1019
+ model.notice(`/model: could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
1020
+ }
1021
+ tui.requestRender();
1022
+ })();
855
1023
  return;
1024
+ }
856
1025
  case 'invalid':
857
1026
  model.notice(`/model: ${command.reason}`);
858
1027
  tui.requestRender();
859
1028
  return;
860
1029
  }
861
1030
  };
1031
+ /** Whether a route's effort list is being read, so a second key cannot race it. */
1032
+ let openingEfforts = false;
1033
+ /** Put the reader's effort choice in force for the next step. */
1034
+ const applyEffort = (provider, modelId, effortId) => {
1035
+ modelSwitch.choose(effortId === PROVIDER_DEFAULT_EFFORT_ID
1036
+ ? { provider, model: modelId }
1037
+ : { provider, model: modelId, reasoningEffort: effortId });
1038
+ model.notice(`reasoning effort for ${provider}/${modelId} set to ${effortId === PROVIDER_DEFAULT_EFFORT_ID ? 'provider default' : effortId} for the next step`);
1039
+ tui.requestRender();
1040
+ };
1041
+ /**
1042
+ * Offer the efforts the route in force advertises.
1043
+ *
1044
+ * The list is read before the picker opens because the rows are the route's
1045
+ * own metadata; a menu painted before that arrived could offer a level the
1046
+ * request would then be refused for.
1047
+ */
1048
+ const openEffortPicker = async () => {
1049
+ if (catalog === undefined) {
1050
+ model.notice('this profile has no llm service, so reasoning efforts cannot be read');
1051
+ tui.requestRender();
1052
+ return;
1053
+ }
1054
+ const facts = statusFacts();
1055
+ if (facts.provider === undefined || facts.model === undefined) {
1056
+ model.notice('no model route is in use; /model <provider>/<model> picks one first');
1057
+ tui.requestRender();
1058
+ return;
1059
+ }
1060
+ if (openingEfforts)
1061
+ return;
1062
+ openingEfforts = true;
1063
+ try {
1064
+ const info = await catalog.efforts(facts.provider, facts.model);
1065
+ const efforts = info?.efforts ?? [];
1066
+ if (efforts.length === 0) {
1067
+ model.notice(`${facts.provider}/${facts.model} advertises no reasoning efforts`);
1068
+ return;
1069
+ }
1070
+ const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`));
1071
+ if (picked !== undefined)
1072
+ applyEffort(facts.provider, facts.model, picked);
1073
+ }
1074
+ catch (error) {
1075
+ model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
1076
+ }
1077
+ finally {
1078
+ openingEfforts = false;
1079
+ tui.requestRender();
1080
+ }
1081
+ };
862
1082
  /**
863
1083
  * Show or choose the mode this session runs.
864
1084
  *
@@ -943,6 +1163,26 @@ export function apply(ctx, config) {
943
1163
  tui.requestRender();
944
1164
  });
945
1165
  };
1166
+ /**
1167
+ * Put the deployment default in force before the first turn.
1168
+ *
1169
+ * The agent is created before the settings file has been read, so the route
1170
+ * its loop captured is the composition placeholder; the reader's default —
1171
+ * effort included — only exists by the time they can type. Adopting it here
1172
+ * is what makes the status line's route the one the request actually uses.
1173
+ */
1174
+ const adoptDefaultRoute = () => {
1175
+ if (modelSwitch.current() !== undefined)
1176
+ return;
1177
+ const facts = statusFacts();
1178
+ if (facts.provider === undefined || facts.model === undefined)
1179
+ return;
1180
+ modelSwitch.adopt({
1181
+ provider: facts.provider,
1182
+ model: facts.model,
1183
+ ...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
1184
+ });
1185
+ };
946
1186
  editor.onSubmit = text => {
947
1187
  const submission = classifySubmission(text);
948
1188
  switch (submission.kind) {
@@ -978,6 +1218,11 @@ export function apply(ctx, config) {
978
1218
  case 'todo':
979
1219
  runTodoCommand();
980
1220
  return;
1221
+ case 'theme':
1222
+ for (const line of renderThemeTable(toOverrides(readSection())))
1223
+ model.notice(line);
1224
+ tui.requestRender();
1225
+ return;
981
1226
  case 'copy':
982
1227
  runCopyCommand();
983
1228
  return;
@@ -1030,8 +1275,10 @@ export function apply(ctx, config) {
1030
1275
  // While a turn is running the human is steering it, not opening another.
1031
1276
  if (turnOpen)
1032
1277
  agent.steer(submission.text);
1033
- else
1278
+ else {
1279
+ adoptDefaultRoute();
1034
1280
  agent.submit(submission.text);
1281
+ }
1035
1282
  }
1036
1283
  };
1037
1284
  disposers.push(ctx.on('session/event', (session, event) => {
@@ -1057,7 +1304,8 @@ export function apply(ctx, config) {
1057
1304
  }
1058
1305
  if (session.id !== viewedSession)
1059
1306
  return;
1060
- applyEvent(event);
1307
+ presentScope = ctx.agents?.get(session.id);
1308
+ applyDurable(event);
1061
1309
  tui.requestRender();
1062
1310
  }));
1063
1311
  /**
@@ -1145,6 +1393,30 @@ export function apply(ctx, config) {
1145
1393
  model.applyStreamChunk(payload.frame.chunk);
1146
1394
  tui.requestRender();
1147
1395
  }));
1396
+ /**
1397
+ * Restyle a running session when the reader's section changes.
1398
+ *
1399
+ * The settings document is hot-reloaded by the host, so a reader watching a
1400
+ * shade land never has to leave the session to see it — which is what makes
1401
+ * tuning one bearable instead of a restart per attempt. The event is
1402
+ * namespace-filtered: another surface's preferences are not our repaint.
1403
+ */
1404
+ disposers.push(ctx.on('settings/updated', ns => {
1405
+ if (String(ns) !== TUI_SETTINGS_NAMESPACE)
1406
+ return;
1407
+ pendingSettingsProblem = undefined;
1408
+ applyTheme();
1409
+ applyDisplay();
1410
+ // Both caches hold rows under the old table, so they have to be told the
1411
+ // table moved; a repaint alone would reuse what they already stored.
1412
+ markdown.invalidate();
1413
+ view.invalidate();
1414
+ if (pendingSettingsProblem !== undefined) {
1415
+ model.notice(`dsh-tui settings: ${pendingSettingsProblem}`);
1416
+ pendingSettingsProblem = undefined;
1417
+ }
1418
+ tui.requestRender();
1419
+ }));
1148
1420
  const degraded = describeMissingOptional(probe);
1149
1421
  if (degraded !== undefined)
1150
1422
  model.notice(degraded);
@@ -1187,6 +1459,12 @@ export function apply(ctx, config) {
1187
1459
  await presetFor(resolved.sessionId, resolved.resume, undefined);
1188
1460
  tui.start();
1189
1461
  terminal.write(windowTitle(process.cwd(), 'ready'));
1462
+ // A refused settings edit is only visible now that the surface owns the
1463
+ // screen; a bare stderr line would have been hidden behind it.
1464
+ if (pendingSettingsProblem !== undefined) {
1465
+ model.notice(`dsh-tui settings: ${pendingSettingsProblem}`);
1466
+ pendingSettingsProblem = undefined;
1467
+ }
1190
1468
  await boot();
1191
1469
  };
1192
1470
  void start().catch((error) => {