@pellux/goodvibes-tui 1.20.1 → 1.21.1

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 (59) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/doctor.ts +7 -1
  6. package/src/cli/package-verification.ts +92 -7
  7. package/src/cli/status.ts +16 -0
  8. package/src/config/read-versioned.ts +115 -2
  9. package/src/core/conversation-event-navigation.ts +42 -0
  10. package/src/core/conversation-search-expansion.ts +67 -0
  11. package/src/core/conversation-splash-state.ts +58 -0
  12. package/src/core/conversation.ts +23 -23
  13. package/src/core/model-routing-chip.ts +38 -18
  14. package/src/core/rewind-turn-anchors.ts +155 -3
  15. package/src/core/stream-event-wiring.ts +141 -20
  16. package/src/core/transcript-journal.ts +145 -7
  17. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  18. package/src/daemon/handlers/inbox/index.ts +14 -1
  19. package/src/daemon/handlers/sqlite-store.ts +160 -6
  20. package/src/input/command-registry.ts +8 -0
  21. package/src/input/commands/local-runtime.ts +6 -1
  22. package/src/input/commands/schedule-runtime.ts +15 -2
  23. package/src/input/commands/shell-core.ts +87 -38
  24. package/src/input/composer-capture.ts +47 -0
  25. package/src/input/handler-command-route.ts +4 -0
  26. package/src/input/handler-content-actions.ts +9 -0
  27. package/src/input/handler-feed-routes.ts +1 -0
  28. package/src/input/handler-modal-routes.ts +12 -1
  29. package/src/input/handler-picker-routes.ts +43 -5
  30. package/src/input/handler-shortcuts.ts +1 -1
  31. package/src/input/handler-ui-state.ts +8 -3
  32. package/src/input/model-picker-items.ts +17 -2
  33. package/src/input/model-picker.ts +19 -2
  34. package/src/input/search.ts +183 -89
  35. package/src/input/settings-modal-types.ts +2 -1
  36. package/src/main.ts +12 -20
  37. package/src/providers/reasoning-effort-surface.ts +399 -0
  38. package/src/renderer/compositor.ts +68 -18
  39. package/src/renderer/markdown-inline.ts +143 -0
  40. package/src/renderer/markdown-table.ts +459 -0
  41. package/src/renderer/markdown.ts +6 -335
  42. package/src/renderer/model-workspace.ts +48 -5
  43. package/src/renderer/settings-modal-helpers.ts +53 -1
  44. package/src/renderer/settings-modal.ts +1 -50
  45. package/src/runtime/bootstrap-command-parts.ts +36 -4
  46. package/src/runtime/bootstrap-core.ts +1 -1
  47. package/src/runtime/durability-housekeeping.ts +183 -0
  48. package/src/runtime/durability-services.ts +68 -11
  49. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  50. package/src/runtime/provider-fallback.ts +16 -2
  51. package/src/runtime/recovery-decisions.ts +180 -11
  52. package/src/runtime/recovery-prompt.ts +11 -1
  53. package/src/runtime/services.ts +9 -4
  54. package/src/runtime/session-liveness-marker.ts +142 -5
  55. package/src/runtime/workstream-draft-store.ts +198 -25
  56. package/src/runtime/workstream-services.ts +17 -2
  57. package/src/shell/ui-openers.ts +1 -1
  58. package/src/version.ts +1 -1
  59. package/src/work-plans/work-plan-store.ts +334 -16
@@ -5,6 +5,34 @@ export interface SearchMatch {
5
5
  line: number;
6
6
  col: number;
7
7
  length: number;
8
+ /**
9
+ * Present when this match lives inside content that is still collapsed —
10
+ * `line`/`col` are placeholders (the containing block's own header line,
11
+ * col 0) rather than a real navigable position. `collapseKeys` names every
12
+ * collapse key that must be expanded to make the hit a real rendered
13
+ * line; `primaryKey` is the specific key whose block range identifies
14
+ * where the revealed match will land (see revealCurrentMatch).
15
+ * Absent entirely for a match already visible in the rendered buffer.
16
+ */
17
+ collapseKeys?: readonly string[];
18
+ primaryKey?: string;
19
+ }
20
+
21
+ /** Count non-overlapping occurrences of `needle` in `haystack` — used for
22
+ * the collapsed-content scan below, mirroring the per-line indexOf loop
23
+ * search() already runs over the rendered buffer, so a hidden hit counts
24
+ * the same way a visible one would once revealed. */
25
+ function countOccurrences(haystack: string, needle: string): number {
26
+ if (needle.length === 0) return 0;
27
+ let count = 0;
28
+ let col = 0;
29
+ while (col <= haystack.length - needle.length) {
30
+ const idx = haystack.indexOf(needle, col);
31
+ if (idx === -1) break;
32
+ count++;
33
+ col = idx + 1;
34
+ }
35
+ return count;
8
36
  }
9
37
 
10
38
  /**
@@ -45,127 +73,187 @@ export class SearchManager {
45
73
  this.locked = false;
46
74
  }
47
75
 
48
- /** Close search mode. */
49
- close(): void {
76
+ /**
77
+ * Close search mode. Re-collapses every block/group search auto-expanded
78
+ * while it was open — except ones the user explicitly acted on (toggled,
79
+ * copied, bookmarked, saved) while they were expanded, which stay exactly
80
+ * as the user left them (see SearchExpansionTracker.restoreOnto).
81
+ * `conversationManager` is optional so existing call sites that never had
82
+ * one (and therefore never auto-expanded anything) keep working unchanged.
83
+ */
84
+ close(conversationManager?: ConversationManager | null): void {
85
+ conversationManager?.searchExpansion.restoreOnto(conversationManager);
50
86
  this.active = false;
51
87
  this.locked = false;
52
88
  }
53
89
 
54
90
  /**
55
- * Update query and find matches in the history buffer.
56
- *
57
- * A collapsed block (or folded tool-result group) renders 1-2 summary
58
- * lines, so scanning only the rendered buffer misses text the user watched
59
- * stream by before it folded. When `conversationManager` is provided, any
60
- * currently-collapsed block whose searchable corpus contains the query is
61
- * expanded first (never re-collapsed — a false "No matches" is a lie, an
62
- * extra expand is not), so the match becomes a real, navigable line in the
63
- * rendered buffer scanned below.
91
+ * Update query and find matches — honestly counting hits inside collapsed
92
+ * blocks and folded tool-result groups WITHOUT expanding them. Expansion
93
+ * only happens on navigation (see revealCurrentMatch): typing a query must
94
+ * never collapse-destroy the transcript the user folded on purpose, but a
95
+ * "no matches" while text the user watched stream by sits collapsed
96
+ * somewhere would be a lie too — so the count includes it, tagged as
97
+ * hidden until the user actually navigates there.
64
98
  *
65
- * That corpus is the block's own RAW content, plus — for a folded
66
- * tool-result group — the raw content of every one of its member messages.
67
- * A group's own rawContent is only its summary header line ("2 tool results
68
- * folded (read exec, 18 lines total)") and its members push no BlockMeta of
69
- * their own while folded (see conversation-tool-groups.ts), so without the
70
- * member corpus a needle living inside a member matches nothing at all and
71
- * the user gets "No matches" for text they watched stream by. Member text is
72
- * read off the message snapshot, and is the same string the renderer stores
73
- * as that member's own block rawContent once the group is expanded.
99
+ * That collapsed corpus is the block's own RAW content, plus — for a
100
+ * folded tool-result group — the raw content of every one of its member
101
+ * messages (a group's own rawContent is only its summary header line, and
102
+ * its members push no BlockMeta of their own while folded — see
103
+ * conversation-tool-groups.ts — so without the member corpus a needle
104
+ * living inside a member would count as zero hits for text the user
105
+ * watched stream by).
74
106
  *
75
- * A hit anywhere in that corpus expands the group's own collapseKey AND
76
- * every member's `msg_<idx>` key — expanding the header alone would leave
77
- * the member holding the hit invisible, and expanding all members keeps the
78
- * behavior identical whether the hit came from the header or a member.
79
- *
80
- * `getDisplayBlocks()` is flushed before the buffer is scanned, and the
81
- * overlay's match count is always derived from that same rendered scan, so
82
- * it never claims more than it can actually navigate to.
107
+ * Matches are built in one pass over `history.getAllLines()` combined with
108
+ * one pass over collapsed blocks, then sorted into document order (by
109
+ * rendered line, with a collapsed block's hidden hits keyed to its own
110
+ * still-real header line) so that navigation lands on hits in the order
111
+ * they actually appear in the transcript.
83
112
  */
84
113
  search(query: string, history: InfiniteBuffer, conversationManager?: ConversationManager | null): void {
85
114
  this.query = query;
86
- this.matches = [];
87
115
  this.currentMatch = 0;
88
-
89
116
  this.wrapAround = false;
90
- if (query.length === 0) return;
91
117
 
118
+ if (query.length === 0) {
119
+ this.matches = [];
120
+ return;
121
+ }
122
+
123
+ this.matches = this.buildMatches(query, history, conversationManager);
124
+ }
125
+
126
+ /**
127
+ * Shared match builder for search() and revealCurrentMatch() — scans the
128
+ * currently-rendered buffer for visible matches and every collapsed
129
+ * block/folded group for hidden ones, without mutating any collapse
130
+ * state. Kept separate from search() so revealCurrentMatch can rebuild the
131
+ * match list after an expansion without resetting currentMatch back to 0.
132
+ */
133
+ private buildMatches(query: string, history: InfiniteBuffer, conversationManager?: ConversationManager | null): SearchMatch[] {
92
134
  const lowerQuery = query.toLowerCase();
135
+ const hiddenMatches: SearchMatch[] = [];
93
136
 
94
137
  if (conversationManager) {
95
138
  const registry = conversationManager.getBlockRegistry();
96
139
 
97
- // A folded tool-result group's members render nothing and register no
98
- // BlockMeta while collapsed (see conversation-tool-groups.ts), so the
99
- // group's searchable corpus is its own header rawContent PLUS each
100
- // member's raw content — which only the message snapshot can supply,
101
- // since there is no member block to read it from.
102
- const memberIndexes: number[] = [];
103
140
  for (const block of registry) {
104
- if (block.type !== 'tool_group' || !block.groupMemberIndexes) continue;
105
141
  if (!conversationManager.isCollapsed(block.blockIndex)) continue;
106
- memberIndexes.push(...block.groupMemberIndexes);
107
- }
108
142
 
109
- // Read in ONE snapshot pass: getMessageSnapshot() clones the whole
110
- // message array (~0.8ms on a 900-message conversation) and search() runs
111
- // on every keystroke, so a per-member read would stall typing outright.
112
- // Skipped entirely when nothing is folded.
113
- const memberContent = new Map<number, string>();
114
- if (memberIndexes.length > 0) {
115
- const snapshot = conversationManager.getMessageSnapshot();
116
- for (const memberIdx of memberIndexes) {
117
- // This is the exact string the renderer stores as a member's own
118
- // block rawContent (renderConversationToolMessage in
119
- // conversation-rendering.ts) — the corpus is what expanding really
120
- // reveals, not a second notion of "content". A member index that
121
- // outlived its message (undo() splices the messages tail while a
122
- // stale registry still names it) reads back undefined and is simply
123
- // skipped rather than throwing.
124
- const content = snapshot[memberIdx]?.content;
125
- if (typeof content === 'string') memberContent.set(memberIdx, content.toLowerCase());
126
- }
127
- }
143
+ if (block.type === 'tool_group' && block.groupMemberIndexes) {
144
+ // The header's own synthetic summary rarely matches, but count it
145
+ // honestly too — expanding the whole group is the only way to even
146
+ // attempt to reveal a header-corpus hit, since there is no single
147
+ // member to isolate it to.
148
+ const headerHits = countOccurrences(block.rawContent.toLowerCase(), lowerQuery);
149
+ const allKeys = [block.collapseKey, ...block.groupMemberIndexes.map((idx) => `msg_${idx}`)];
150
+ for (let i = 0; i < headerHits; i++) {
151
+ hiddenMatches.push({
152
+ line: block.startLine, col: 0, length: query.length,
153
+ collapseKeys: allKeys, primaryKey: block.collapseKey,
154
+ });
155
+ }
128
156
 
129
- let expandedAny = false;
130
- for (const block of registry) {
131
- if (!conversationManager.isCollapsed(block.blockIndex)) continue;
132
- const members = block.type === 'tool_group' ? (block.groupMemberIndexes ?? []) : [];
133
- const hit = block.rawContent.toLowerCase().includes(lowerQuery)
134
- || members.some((memberIdx) => (memberContent.get(memberIdx) ?? '').includes(lowerQuery));
135
- if (!hit) continue;
136
- conversationManager.setCollapsed(block.collapseKey, false);
137
- // Expanding just the group header would still hide each member's own
138
- // content — including the member the query actually matched — so open
139
- // every member's own collapse key in the same pass. Already-expanded
140
- // keys are unaffected; setting false over false is a no-op.
141
- for (const memberIdx of members) {
142
- conversationManager.setCollapsed(`msg_${memberIdx}`, false);
157
+ if (block.groupMemberIndexes.length > 0) {
158
+ // Read in ONE snapshot pass: getMessageSnapshot() clones the
159
+ // whole message array and search() runs on every keystroke, so a
160
+ // per-member read would stall typing outright.
161
+ const snapshot = conversationManager.getMessageSnapshot();
162
+ for (const memberIdx of block.groupMemberIndexes) {
163
+ const content = snapshot[memberIdx]?.content;
164
+ if (typeof content !== 'string') continue;
165
+ const memberKey = `msg_${memberIdx}`;
166
+ const memberHits = countOccurrences(content.toLowerCase(), lowerQuery);
167
+ for (let i = 0; i < memberHits; i++) {
168
+ hiddenMatches.push({
169
+ line: block.startLine, col: 0, length: query.length,
170
+ // Revealing a member-specific hit only needs the group
171
+ // header (to unfold the group at all) and that member's own
172
+ // key — not its siblings, which stay exactly as they were.
173
+ collapseKeys: [block.collapseKey, memberKey], primaryKey: memberKey,
174
+ });
175
+ }
176
+ }
177
+ }
178
+ } else {
179
+ const hits = countOccurrences(block.rawContent.toLowerCase(), lowerQuery);
180
+ for (let i = 0; i < hits; i++) {
181
+ hiddenMatches.push({
182
+ line: block.startLine, col: 0, length: query.length,
183
+ collapseKeys: [block.collapseKey], primaryKey: block.collapseKey,
184
+ });
185
+ }
143
186
  }
144
- expandedAny = true;
145
- }
146
- if (expandedAny) {
147
- // Flush the pending re-render so `history` (the same buffer
148
- // instance) reflects the newly-expanded content before it's scanned
149
- // below — mirrors the existing getDisplayBlocks()-before-read idiom
150
- // used by jumpToBookmark/scrollToLine.
151
- conversationManager.getDisplayBlocks();
152
187
  }
153
188
  }
154
189
 
190
+ const visibleMatches: SearchMatch[] = [];
155
191
  const lines = history.getAllLines();
156
-
157
192
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
158
193
  const line = lines[lineIdx];
159
- // Build text from cells
160
194
  const text = line.map(c => c.char).join('').toLowerCase();
161
195
  let col = 0;
162
196
  while (col <= text.length - lowerQuery.length) {
163
197
  const idx = text.indexOf(lowerQuery, col);
164
198
  if (idx === -1) break;
165
- this.matches.push({ line: lineIdx, col: idx, length: query.length });
199
+ visibleMatches.push({ line: lineIdx, col: idx, length: query.length });
166
200
  col = idx + 1;
167
201
  }
168
202
  }
203
+
204
+ // Stable sort (V8/JSC have guaranteed stable Array#sort) puts each
205
+ // block's hidden hits in the same relative order they were generated
206
+ // in — self-corpus first, then members in order — right where that
207
+ // block's header line falls among the visible matches.
208
+ return [...visibleMatches, ...hiddenMatches].sort((a, b) => a.line - b.line || a.col - b.col);
209
+ }
210
+
211
+ /**
212
+ * Reveal the current match if it lives inside collapsed content: expands
213
+ * exactly the block (and, for a folded tool-result group member, its
214
+ * containing group) needed to make the hit a real navigable line, marks
215
+ * those keys as search-owned so close() can restore them, and rebuilds
216
+ * the match list against the now-expanded buffer.
217
+ *
218
+ * `currentMatch` is repointed at the first now-visible match inside the
219
+ * revealed block's new line range, rather than reset to 0 — expansion
220
+ * only changes how many lines that ONE block renders as, so every match
221
+ * belonging to earlier blocks keeps its relative position.
222
+ *
223
+ * No-op when the current match is already visible, or when no
224
+ * conversationManager was supplied (nothing to expand against).
225
+ */
226
+ revealCurrentMatch(history: InfiniteBuffer, conversationManager?: ConversationManager | null): void {
227
+ const match = this.matches[this.currentMatch];
228
+ if (!match || !match.collapseKeys || match.collapseKeys.length === 0) return;
229
+ if (!conversationManager) return;
230
+
231
+ for (const key of match.collapseKeys) {
232
+ conversationManager.setCollapsed(key, false);
233
+ conversationManager.searchExpansion.markSearchExpanded(key);
234
+ }
235
+ // Flush so the registry read below reflects the newly-expanded content.
236
+ conversationManager.getDisplayBlocks();
237
+
238
+ const primaryKey = match.primaryKey ?? match.collapseKeys[match.collapseKeys.length - 1];
239
+ const revealedBlock = conversationManager.getBlockRegistry().find((b) => b.collapseKey === primaryKey);
240
+
241
+ this.matches = this.buildMatches(this.query, history, conversationManager);
242
+
243
+ if (revealedBlock) {
244
+ const idx = this.matches.findIndex((m) =>
245
+ !m.collapseKeys
246
+ && m.line >= revealedBlock.startLine
247
+ && m.line < revealedBlock.startLine + revealedBlock.lineCount,
248
+ );
249
+ if (idx >= 0) {
250
+ this.currentMatch = idx;
251
+ return;
252
+ }
253
+ }
254
+ // Fallback: keep the pointer in range rather than throwing if the exact
255
+ // target couldn't be relocated.
256
+ if (this.currentMatch >= this.matches.length) this.currentMatch = Math.max(0, this.matches.length - 1);
169
257
  }
170
258
 
171
259
  /** Jump to next match. Wraps around; sets wrapAround when it does. */
@@ -184,21 +272,27 @@ export class SearchManager {
184
272
  this.currentMatch = prev;
185
273
  }
186
274
 
187
- /** Get the line number of the current match (for scroll). */
275
+ /** Get the line number of the current match (for scroll). Returns -1 for a
276
+ * match that's still hidden inside collapsed content — revealCurrentMatch
277
+ * must run first to give it a real line. */
188
278
  getCurrentMatchLine(): number {
189
279
  if (this.matches.length === 0) return -1;
190
- return this.matches[this.currentMatch]?.line ?? -1;
280
+ const match = this.matches[this.currentMatch];
281
+ if (!match) return -1;
282
+ if (match.collapseKeys && match.collapseKeys.length > 0) return -1;
283
+ return match.line;
191
284
  }
192
285
 
193
- /** Get matches on a given line. */
286
+ /** Get matches on a given line. Hidden (still-collapsed) matches are
287
+ * excluded — they have no real line to render a highlight on yet. */
194
288
  getMatchesOnLine(lineIdx: number): SearchMatch[] {
195
- return this.matches.filter(m => m.line === lineIdx);
289
+ return this.matches.filter(m => m.line === lineIdx && !m.collapseKeys);
196
290
  }
197
291
 
198
292
  /** Is the current match on the given line at the given col? */
199
293
  isCurrentMatch(lineIdx: number, col: number): boolean {
200
294
  if (this.matches.length === 0) return false;
201
295
  const m = this.matches[this.currentMatch];
202
- return m !== undefined && m.line === lineIdx && m.col === col;
296
+ return m !== undefined && !m.collapseKeys && m.line === lineIdx && m.col === col;
203
297
  }
204
298
  }
@@ -27,6 +27,7 @@ export type SettingsCategory =
27
27
  | 'network'
28
28
  | 'relay'
29
29
  | 'surfaces'
30
+ | 'conversationGate'
30
31
  | 'cloudflare'
31
32
  | 'batch'
32
33
  | 'automation'
@@ -60,7 +61,7 @@ export const SETTINGS_CATEGORY_GROUPS: ReadonlyArray<{
60
61
  { label: 'Interface', categories: ['display', 'ui', 'behavior', 'notifications', 'permissions', 'policy', 'security'] },
61
62
  { label: 'AI Routing', categories: ['provider', 'pricing', 'subscriptions', 'helper', 'tools', 'tts', 'voice'] },
62
63
  { label: 'Service & Network', categories: ['service', 'daemon', 'network', 'controlPlane', 'httpListener', 'web', 'relay'] },
63
- { label: 'Surfaces & Cloud', categories: ['surfaces', 'integrations', 'mcp', 'cloudflare'] },
64
+ { label: 'Surfaces & Cloud', categories: ['surfaces', 'conversationGate', 'integrations', 'mcp', 'cloudflare'] },
64
65
  { label: 'Automation', categories: ['batch', 'automation', 'checkin', 'watchers', 'orchestration', 'planner', 'wrfc'] },
65
66
  { label: 'Runtime & Data', categories: ['storage', 'atRest', 'sandbox', 'fetch', 'agents', 'runtime', 'power', 'memory', 'cache', 'telemetry', 'diagnostics', 'learning'] },
66
67
  { label: 'Advanced', categories: ['release', 'update', 'danger'] },
package/src/main.ts CHANGED
@@ -16,6 +16,7 @@ import { PermissionPromptUI, buildPendingPermissionExtras } from './permissions/
16
16
  import { handleBrokerApprovalChange, buildFixSessionAffordance, buildFixSessionErrorNotice, handleFixSessionAttachKey, refreshFixSessionsFromApprovals } from './permissions/broker-approval-card.ts';
17
17
  import { CommandRegistry } from './input/command-registry.ts';
18
18
  import type { CommandContext } from './input/command-registry.ts';
19
+ import { requestedEffortLevel } from './providers/reasoning-effort-surface.ts';
19
20
  import { renderProcessIndicator } from './renderer/process-indicator.ts';
20
21
  import { registerBuiltinCommands } from './input/commands.ts';
21
22
  import { ScheduleManager } from '@pellux/goodvibes-sdk/platform/tools';
@@ -67,7 +68,7 @@ import { wireTurnEventHandlers } from './core/turn-event-wiring.ts';
67
68
  import { resolveContextStatusHint } from './renderer/context-status-hint.ts';
68
69
  import { isEffectiveDangerMode } from './config/index.ts';
69
70
  import { createScriptableStatusline } from './core/scriptable-statusline.ts';
70
- import { applyComposerCapture } from './input/composer-capture.ts';
71
+ import { applyComposerCapture, applyAtModelDirective } from './input/composer-capture.ts';
71
72
  import { createSessionAutoTitler } from './core/session-auto-titler.ts';
72
73
  import { makeComposerEditorOpener } from './input/composer-editor.ts';
73
74
  import { evaluateSessionMaintenance } from '@/runtime/index.ts';
@@ -274,23 +275,10 @@ async function main() {
274
275
  const submitInput = (text: string, content?: ContentPart[], options: { readonly spokenOutput?: boolean } = {}) => {
275
276
  input.clearModalStack();
276
277
  scrollLocked = true; // Re-lock on any user input
277
- const AT_MODEL_RE = /@model:([^\s]+)/g;
278
- let processedText = text;
279
- let atModelMatch: RegExpExecArray | null;
280
- while ((atModelMatch = AT_MODEL_RE.exec(text)) !== null) {
281
- const modelId = atModelMatch[1];
282
- try {
283
- providerRegistry.setCurrentModel(modelId);
284
- const def = providerRegistry.getCurrentModel();
285
- runtime.model = def.id;
286
- runtime.provider = def.provider;
287
- configManager.set('provider.model', def.registryKey);
288
- systemMessageRouter.high(`[Model] Switched to ${def.displayName} (${def.provider}) via @model:`);
289
- } catch {
290
- systemMessageRouter.high(`[Model] Unknown model: ${modelId}`);
291
- }
292
- processedText = processedText.replace(atModelMatch[0], '').trim();
293
- }
278
+ conversation.dismissSplash(); // owner rule: any submission retires the splash for the run
279
+ let processedText = applyAtModelDirective(text, {
280
+ providerRegistry, runtime, configManager, notify: (m) => systemMessageRouter.high(m),
281
+ });
294
282
  // Composer capture markers: `!#` pins + sends; `#` saves a note without sending.
295
283
  processedText = applyComposerCapture(processedText, {
296
284
  sessionMemoryStore: ctx.services.sessionMemoryStore,
@@ -550,6 +538,7 @@ async function main() {
550
538
  // Flush pending renders after updating the width provider and splash posture
551
539
  // so the transcript and splash rebuild against the current shell layout.
552
540
  conversation.getDisplayBlocks();
541
+ if (conversation.consumeSplashTransition()) compositor.requestFullRepaint(); // splash → transcript: repaint the whole viewport once
553
542
 
554
543
  // Calculate how many rows are consumed by overlays (thinking, permissions, queue, file picker)
555
544
  let overlayRows = 0;
@@ -705,8 +694,8 @@ async function main() {
705
694
  // Time-bounded: onExpire repaints once the 60s disarm timer fires, so a stray keypress hours
706
695
  // later can never trigger a real retry — see retry-affordance.ts.
707
696
  const retryAffordance = createRetryAffordanceState({ onExpire: render });
708
- const retryTurn = (notice?: string): void => {
709
- if (!retryCtx) return;
697
+ const retryTurn = (notice?: string): boolean => {
698
+ if (!retryCtx) return false; // nothing to roll back to; the caller narrates instead
710
699
  const { count, text, content: rContent, opts: rOpts } = retryCtx;
711
700
  // Roll back to pre-submission count, then re-submit. SDK gap — no retry-in-place (see handoff).
712
701
  // The rollback erases the failed turn's transcript — the failover notice included, which is how
@@ -715,12 +704,15 @@ async function main() {
715
704
  conversation.removeMessagesAfter(count);
716
705
  if (notice) systemMessageRouter.userReceipt(notice);
717
706
  void refreshMemoryRecallSnapshot(ctx.services).then(() => orchestrator.handleUserInput(text, rContent, rOpts)).catch((e: unknown) => logger.debug('retryTurn', { error: summarizeError(e) }));
707
+ return true;
718
708
  };
719
709
  const streamResult: WireStreamEventMetricsResult = wireStreamEventMetrics({
720
710
  events: uiServices.events, orchestrator, providerRegistry,
721
711
  systemMessageRouter, render, metrics: streamMetrics,
722
712
  providerOptimizer: ctx.services.providerOptimizer, costLookup: providerRegistry, retryTurn,
723
713
  failoverState, getConfiguredRegistryKey: () => configManager.get('provider.model') as string | undefined,
714
+ // The REQUESTED level, through the one helper every remap site reads from.
715
+ getConfiguredReasoningEffort: () => requestedEffortLevel(configManager),
724
716
  isApprovalPending: () => pendingPermission !== null,
725
717
  });
726
718
  unsubs.push(...streamResult.unsubs);