@pellux/goodvibes-tui 1.20.0 → 1.21.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 (65) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +7 -7
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/bundle-command.ts +1 -1
  6. package/src/cli/doctor.ts +7 -1
  7. package/src/cli/management.ts +1 -1
  8. package/src/cli/package-verification.ts +92 -7
  9. package/src/cli/status.ts +16 -0
  10. package/src/config/read-versioned.ts +115 -2
  11. package/src/core/active-model-identity.ts +153 -0
  12. package/src/core/conversation-event-navigation.ts +42 -0
  13. package/src/core/conversation-search-expansion.ts +67 -0
  14. package/src/core/conversation-splash-state.ts +58 -0
  15. package/src/core/conversation-user-receipts.ts +42 -0
  16. package/src/core/conversation.ts +43 -40
  17. package/src/core/model-routing-chip.ts +38 -18
  18. package/src/core/rewind-turn-anchors.ts +155 -3
  19. package/src/core/stream-event-wiring.ts +298 -17
  20. package/src/core/system-message-router.ts +22 -0
  21. package/src/core/transcript-journal.ts +145 -7
  22. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  23. package/src/daemon/handlers/inbox/index.ts +14 -1
  24. package/src/daemon/handlers/sqlite-store.ts +160 -6
  25. package/src/input/command-registry.ts +8 -0
  26. package/src/input/commands/local-runtime.ts +6 -1
  27. package/src/input/commands/schedule-runtime.ts +15 -2
  28. package/src/input/commands/shell-core.ts +87 -38
  29. package/src/input/composer-capture.ts +47 -0
  30. package/src/input/handler-command-route.ts +4 -0
  31. package/src/input/handler-content-actions.ts +9 -0
  32. package/src/input/handler-feed-routes.ts +1 -0
  33. package/src/input/handler-modal-routes.ts +12 -1
  34. package/src/input/handler-picker-routes.ts +43 -5
  35. package/src/input/handler-shortcuts.ts +1 -1
  36. package/src/input/handler-ui-state.ts +8 -3
  37. package/src/input/model-picker-items.ts +17 -2
  38. package/src/input/model-picker.ts +19 -2
  39. package/src/input/search.ts +183 -89
  40. package/src/main.ts +31 -38
  41. package/src/{cli → providers}/provider-classification.ts +12 -0
  42. package/src/providers/reasoning-effort-surface.ts +399 -0
  43. package/src/renderer/compositor.ts +68 -18
  44. package/src/renderer/header-line.ts +146 -0
  45. package/src/renderer/markdown-inline.ts +143 -0
  46. package/src/renderer/markdown-table.ts +459 -0
  47. package/src/renderer/markdown.ts +6 -335
  48. package/src/renderer/model-workspace.ts +48 -5
  49. package/src/renderer/shell-surface.ts +36 -0
  50. package/src/renderer/ui-factory.ts +25 -78
  51. package/src/runtime/bootstrap-command-parts.ts +36 -4
  52. package/src/runtime/bootstrap-core.ts +1 -1
  53. package/src/runtime/durability-housekeeping.ts +183 -0
  54. package/src/runtime/durability-services.ts +68 -11
  55. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  56. package/src/runtime/provider-fallback.ts +16 -2
  57. package/src/runtime/recovery-decisions.ts +295 -0
  58. package/src/runtime/recovery-prompt.ts +66 -6
  59. package/src/runtime/services.ts +9 -4
  60. package/src/runtime/session-liveness-marker.ts +142 -5
  61. package/src/runtime/workstream-draft-store.ts +198 -25
  62. package/src/runtime/workstream-services.ts +17 -2
  63. package/src/shell/ui-openers.ts +1 -1
  64. package/src/version.ts +1 -1
  65. 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
  }
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';
@@ -24,7 +25,8 @@ import { getTierPromptSupplement, getTierForContextWindow } from '@pellux/goodvi
24
25
  import { GitStatusProvider } from './renderer/git-status.ts';
25
26
  import type { GitHeaderInfo } from './renderer/git-status.ts';
26
27
  import { createShellLayout } from './renderer/layout-engine.ts';
27
- import { buildShellFooter, estimateShellFooterHeight } from './renderer/shell-surface.ts';
28
+ import { buildShellFooter, estimateShellFooterHeight, promptCursorOffset } from './renderer/shell-surface.ts';
29
+ import { createFailoverTurnState, resolveActiveModelDisplay } from './core/active-model-identity.ts';
28
30
  import { computePromptContentWidth } from './renderer/prompt-content-width.ts';
29
31
  import { buildConversationViewport } from './renderer/conversation-layout.ts';
30
32
  import { applyConversationOverlays } from './renderer/conversation-overlays.ts';
@@ -66,7 +68,7 @@ import { wireTurnEventHandlers } from './core/turn-event-wiring.ts';
66
68
  import { resolveContextStatusHint } from './renderer/context-status-hint.ts';
67
69
  import { isEffectiveDangerMode } from './config/index.ts';
68
70
  import { createScriptableStatusline } from './core/scriptable-statusline.ts';
69
- import { applyComposerCapture } from './input/composer-capture.ts';
71
+ import { applyComposerCapture, applyAtModelDirective } from './input/composer-capture.ts';
70
72
  import { createSessionAutoTitler } from './core/session-auto-titler.ts';
71
73
  import { makeComposerEditorOpener } from './input/composer-editor.ts';
72
74
  import { evaluateSessionMaintenance } from '@/runtime/index.ts';
@@ -178,6 +180,8 @@ async function main() {
178
180
  let lastMaxScroll: number | null = null;
179
181
  // Stream and tool-timer state; mutated by wireStreamEventMetrics handlers, read during render.
180
182
  const streamMetrics: StreamMetrics = createStreamMetrics();
183
+ // Live failover record — written by the failover path, read every frame so the header and footer agree.
184
+ const failoverState = createFailoverTurnState();
181
185
 
182
186
  const getPromptContentWidth = () => computePromptContentWidth(stdout.columns);
183
187
 
@@ -271,23 +275,10 @@ async function main() {
271
275
  const submitInput = (text: string, content?: ContentPart[], options: { readonly spokenOutput?: boolean } = {}) => {
272
276
  input.clearModalStack();
273
277
  scrollLocked = true; // Re-lock on any user input
274
- const AT_MODEL_RE = /@model:([^\s]+)/g;
275
- let processedText = text;
276
- let atModelMatch: RegExpExecArray | null;
277
- while ((atModelMatch = AT_MODEL_RE.exec(text)) !== null) {
278
- const modelId = atModelMatch[1];
279
- try {
280
- providerRegistry.setCurrentModel(modelId);
281
- const def = providerRegistry.getCurrentModel();
282
- runtime.model = def.id;
283
- runtime.provider = def.provider;
284
- configManager.set('provider.model', def.registryKey);
285
- systemMessageRouter.high(`[Model] Switched to ${def.displayName} (${def.provider}) via @model:`);
286
- } catch {
287
- systemMessageRouter.high(`[Model] Unknown model: ${modelId}`);
288
- }
289
- processedText = processedText.replace(atModelMatch[0], '').trim();
290
- }
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
+ });
291
282
  // Composer capture markers: `!#` pins + sends; `#` saves a note without sending.
292
283
  processedText = applyComposerCapture(processedText, {
293
284
  sessionMemoryStore: ctx.services.sessionMemoryStore,
@@ -454,10 +445,9 @@ async function main() {
454
445
  const sessionSnapshot = uiServices.readModels.session.getSnapshot();
455
446
  const agentSnapshot = uiServices.readModels.agents.getSnapshot();
456
447
 
457
- const headerLines = UIFactory.createHeader(width, currentModel.id, currentModel.provider, conversation.title || undefined, lastGitInfoRef.value);
458
- const managerAgents = agentManager.list().filter(
459
- (a) => a.status === 'running' || a.status === 'pending',
460
- );
448
+ const activeModel = resolveActiveModelDisplay({ serving: currentModel, configuredRegistryKey: configManager.get('provider.model') as string, configuredLabel: runtime.model, configuredProvider: runtime.provider, failover: failoverState.current() });
449
+ const headerLines = UIFactory.createHeader(width, activeModel.headerModel, activeModel.headerProvider, conversation.title || undefined, lastGitInfoRef.value, undefined, activeModel.divergenceNote);
450
+ const managerAgents = agentManager.list().filter((a) => a.status === 'running' || a.status === 'pending');
461
451
  const runtimeAgents = agentSnapshot.active;
462
452
  const runningAgentSummary = summarizeRunningAgents(managerAgents, runtimeAgents, ctx.services.wrfcController.listChains());
463
453
  const runningAgentCount = runningAgentSummary.count;
@@ -470,30 +460,24 @@ async function main() {
470
460
  commandMode: input.commandMode,
471
461
  panelFocused: input.panelFocused,
472
462
  pendingApproval: pendingPermission !== null,
473
- hasAttachments: input.getImageAttachments().size > 0,
474
- turnState: sessionSnapshot.turnState,
463
+ hasAttachments: input.getImageAttachments().size > 0, turnState: sessionSnapshot.turnState,
475
464
  });
476
465
  const contextStatusHint = resolveContextStatusHint({
477
466
  evaluate: (args) => evaluateSessionMaintenance({ configManager, ...args, sessionMemoryCount: ctx.services.sessionMemoryStore.list().length }),
478
- currentTokens: orchestrator.lastInputTokens,
479
- contextWindow,
467
+ currentTokens: orchestrator.lastInputTokens, contextWindow,
480
468
  });
481
469
  const footerLines = buildShellFooter({
482
470
  width,
483
471
  promptText: promptInfo.visibleLines.join('\n'),
484
472
  promptLineCount: promptInfo.visibleLines.length,
485
- promptCursorPos: promptInfo.visibleCursorLine >= 0
486
- ? promptInfo.visibleLines
487
- .slice(0, promptInfo.visibleCursorLine)
488
- .reduce((sum: number, line: string) => sum + line.length + 1, 0) + promptInfo.visibleCursorCol
489
- : undefined,
473
+ promptCursorPos: promptCursorOffset(promptInfo),
490
474
  usage: { up: orchestrator.usage.input, down: orchestrator.usage.output, fleetCostUsd: footerFleetCost(() => ctx.services.processRegistry.query().nodes, runningAgentCount > 0) },
491
475
  showExitNotice: input.showExitNotice,
492
476
  lastCopyTime: input.lastCopyTime,
493
- model: runtime.model,
477
+ model: activeModel.footerModel, modelNote: activeModel.divergenceNote,
494
478
  toolCount: toolRegistry.list().length,
495
479
  workingDir,
496
- provider: runtime.provider,
480
+ provider: activeModel.footerProvider,
497
481
  contextWindow,
498
482
  contextStatusHint,
499
483
  retryHint: retryAffordanceHint(retryAffordance),
@@ -554,6 +538,7 @@ async function main() {
554
538
  // Flush pending renders after updating the width provider and splash posture
555
539
  // so the transcript and splash rebuild against the current shell layout.
556
540
  conversation.getDisplayBlocks();
541
+ if (conversation.consumeSplashTransition()) compositor.requestFullRepaint(); // splash → transcript: repaint the whole viewport once
557
542
 
558
543
  // Calculate how many rows are consumed by overlays (thinking, permissions, queue, file picker)
559
544
  let overlayRows = 0;
@@ -709,17 +694,25 @@ async function main() {
709
694
  // Time-bounded: onExpire repaints once the 60s disarm timer fires, so a stray keypress hours
710
695
  // later can never trigger a real retry — see retry-affordance.ts.
711
696
  const retryAffordance = createRetryAffordanceState({ onExpire: render });
712
- const retryTurn = (): void => {
713
- if (!retryCtx) return;
697
+ const retryTurn = (notice?: string): boolean => {
698
+ if (!retryCtx) return false; // nothing to roll back to; the caller narrates instead
714
699
  const { count, text, content: rContent, opts: rOpts } = retryCtx;
715
700
  // Roll back to pre-submission count, then re-submit. SDK gap — no retry-in-place (see handoff).
701
+ // The rollback erases the failed turn's transcript — the failover notice included, which is how
702
+ // that notice used to vanish before anyone could read it. The caller hands it over instead, and
703
+ // it is posted here: after the rollback, above the prompt it explains.
716
704
  conversation.removeMessagesAfter(count);
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,
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),
723
716
  isApprovalPending: () => pendingPermission !== null,
724
717
  });
725
718
  unsubs.push(...streamResult.unsubs);
@@ -743,7 +736,7 @@ async function main() {
743
736
  const themeProbe = installBackgroundThemeProbe({ configManager, isTTY: Boolean(stdout.isTTY), env: process.env, writeQuery: (b) => allowTerminalWrite(() => stdout.write(b)), requestRepaint: () => { compositor.resetDiff(); render(); } });
744
737
 
745
738
  // continueRecovery lets --continue/bare --resume check the target session for a live crash snapshot newer than its store before resuming (see tui-startup.ts).
746
- applyInitialTuiCliState({ cli, input, commandRegistry, commandContext, shellPaths: ctx.services.shellPaths, surface: ctx.services.surface, render, continueRecovery: { sessionManager: ctx.services.sessionManager, runtime, conversation, writeLastSessionPointer, receipt: (line) => systemMessageRouter.high(line) } });
739
+ applyInitialTuiCliState({ cli, input, commandRegistry, commandContext, shellPaths: ctx.services.shellPaths, surface: ctx.services.surface, render, continueRecovery: { sessionManager: ctx.services.sessionManager, runtime, conversation, writeLastSessionPointer, receipt: (line) => systemMessageRouter.userReceipt(line) } });
747
740
 
748
741
  stdin.on('data', (raw: string) => {
749
742
  const data = themeProbe.filterInput(raw); if (data.length === 0) return;
@@ -788,7 +781,7 @@ async function main() {
788
781
  // frame so the question is drawn rather than posed at a blank terminal.
789
782
  scheduleRecoveryOffer(buildRecoveryOfferWiring({
790
783
  surface: ctx.services.surface, sessionManager: ctx.services.sessionManager, runtime, conversation, commandContext,
791
- writeLastSessionPointer, receipt: (line) => systemMessageRouter.high(line), render,
784
+ writeLastSessionPointer, receipt: (line) => systemMessageRouter.userReceipt(line), render,
792
785
  }));
793
786
 
794
787
  // Auto-save to recovery file every 60s + multi-instance liveness-marker
@@ -1,3 +1,15 @@
1
+ /**
2
+ * provider-classification.ts — how a provider is paid for: a raw API key, a
3
+ * cloud account, a stored subscription/OAuth session, a local runtime, or
4
+ * honestly unknown.
5
+ *
6
+ * Lives in the foundation `providers` layer (it imports nothing) because both
7
+ * the CLI management surfaces and the in-session failover notice need it: when
8
+ * automatic failover moves a turn onto a different backend, the notice names
9
+ * the billing class it moved from and to, so a switch from a metered API key
10
+ * onto the user's own subscription is something the user can see and object to
11
+ * rather than discover on an invoice.
12
+ */
1
13
  export type ProviderSetupClass =
2
14
  | 'api-key'
3
15
  | 'cloud-account'