@pellux/goodvibes-tui 1.20.0 → 1.20.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.
@@ -2,6 +2,8 @@ import type { UiRuntimeEvents } from '@/runtime/index.ts';
2
2
  import { createStreamStallWatchdog } from './stream-stall-watchdog.ts';
3
3
  import { buildRoutingChip } from './model-routing-chip.ts';
4
4
  import { formatUserFacingErrorLine } from './format-user-error.ts';
5
+ import { classifyProviderSetup } from '../providers/provider-classification.ts';
6
+ import type { FailoverTurnState } from './active-model-identity.ts';
5
7
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
6
8
 
7
9
  /**
@@ -101,6 +103,14 @@ interface FailoverOptimizer {
101
103
  interface StreamSystemMessageRouter {
102
104
  high(message: string): void;
103
105
  low(message: string): void;
106
+ /**
107
+ * Unconditional conversation delivery (see system-message-router.ts). Used
108
+ * for provider-switch notices: which backend serves a turn — and therefore
109
+ * who bills for it — is never ambient chatter that a routing preference or
110
+ * the noise gate may filter out. Optional so bare test doubles that supply
111
+ * only high/low still type-check; announce() below falls back to high().
112
+ */
113
+ userReceipt?(message: string): void;
104
114
  }
105
115
 
106
116
  /**
@@ -166,8 +176,15 @@ export interface WireStreamEventMetricsOptions {
166
176
  * Callback the caller provides to re-submit the last user turn on a
167
177
  * different provider after a successful failover switch. Called only when
168
178
  * the optimizer is enabled and a viable next provider exists in the chain.
179
+ *
180
+ * The re-submission rolls the conversation back to its pre-submission
181
+ * message count, which deletes everything the failed turn added — including
182
+ * a failover notice appended before the call. So the notice is passed IN
183
+ * rather than emitted before: the caller re-posts it after its rollback, and
184
+ * it survives to be read. Implementations that do not roll back may ignore
185
+ * the argument, but must then post the notice themselves.
169
186
  */
170
- readonly retryTurn?: () => void;
187
+ readonly retryTurn?: (notice?: string) => void;
171
188
  /**
172
189
  * Optional cost catalog for attaching per-1M-token cost information to
173
190
  * the failover notice. When provided and both models have non-zero pricing,
@@ -189,6 +206,22 @@ export interface WireStreamEventMetricsOptions {
189
206
  * callers omit it.
190
207
  */
191
208
  readonly stallThresholdMs?: number | undefined;
209
+ /**
210
+ * Shared holder for the live failover record (core/active-model-identity.ts).
211
+ * Set when failover switches the registry off the user's configured
212
+ * selection, cleared once serving is restored to it. The render frame reads
213
+ * the same object, so the header and footer describe the switch while it is
214
+ * in effect. Omitted by unit tests that only exercise notice content.
215
+ */
216
+ readonly failoverState?: FailoverTurnState;
217
+ /**
218
+ * The user's configured model selection (config `provider.model`) as a
219
+ * registry key. Read at the moment of a failover switch so the turn-end
220
+ * restore targets what the user actually chose — not whatever the registry
221
+ * happened to hold. Without it, failover still works but no restore or
222
+ * divergence marker is possible, so both are skipped rather than guessed at.
223
+ */
224
+ readonly getConfiguredRegistryKey?: () => string | undefined;
192
225
  }
193
226
 
194
227
  /** Result of wireStreamEventMetrics. */
@@ -196,10 +229,12 @@ export interface WireStreamEventMetricsResult {
196
229
  /** Unsubscribe functions; push into the parent unsubs array for cleanup on exit. */
197
230
  readonly unsubs: ReadonlyArray<() => void>;
198
231
  /**
199
- * Clear the per-turn failover visited-provider set.
200
- * Call this on every new user submission so the visited set does not bleed
201
- * across independent turns (the set is also cleared automatically on
202
- * TURN_COMPLETED, but a new submission may arrive before TURN_COMPLETED fires).
232
+ * Clear the per-turn failover visited-provider set AND restore the user's
233
+ * configured model selection if a failover left serving somewhere else.
234
+ * Call this on every new user submission so neither the visited set nor a
235
+ * per-turn provider switch bleeds across independent turns (both are also
236
+ * handled on TURN_COMPLETED/TURN_CANCEL, but a new submission may arrive
237
+ * before either fires).
203
238
  */
204
239
  readonly clearFailoverVisited: () => void;
205
240
  /**
@@ -253,6 +288,33 @@ function buildCostDeltaSuffix(
253
288
  return ` [cost/1M: input ${fmt(fromCost.input)}→${fmt(toCost.input)}, output ${fmt(fromCost.output)}→${fmt(toCost.output)}]`;
254
289
  }
255
290
 
291
+ /**
292
+ * Build the billing-class segment of a failover notice, e.g.
293
+ * ` [billing: API key → Subscription — billing class changed]`.
294
+ *
295
+ * This path is NOT the synthetic provider's tier-isolated failover, which is
296
+ * where the documented "free, paid and subscription tiers never mix" contract
297
+ * lives (docs/providers-and-routing.md:77-128, enforced in the SDK's
298
+ * synthetic.ts by CanonicalModel.tier). The optimizer chain consumed here
299
+ * carries no tier metadata at all — its nodes are
300
+ * `{ position, providerId, modelId, capable, explanation }` and `explanation`
301
+ * describes functional capability (streaming, tool calling, context size),
302
+ * never billing. So the switch cannot be constrained by a tier it cannot see;
303
+ * what it CAN do is say out loud which billing class it moved to, so a user
304
+ * who does not want their subscription spent on an automatic retry can object
305
+ * and turn the optimizer off.
306
+ *
307
+ * Classification comes from providers/provider-classification.ts, which is
308
+ * honest about ignorance: an unrecognised provider id reports "Unknown" rather
309
+ * than being quietly assumed safe.
310
+ */
311
+ function buildBillingSuffix(fromProviderId: string, toProviderId: string): string {
312
+ const from = classifyProviderSetup({ providerId: fromProviderId }).setupLabel;
313
+ const to = classifyProviderSetup({ providerId: toProviderId }).setupLabel;
314
+ const changed = from !== to ? ' — billing class changed' : '';
315
+ return ` [billing: ${from} → ${to}${changed}]`;
316
+ }
317
+
256
318
  /**
257
319
  * Wire STREAM_* and TOOL_* runtime events to the provided StreamMetrics object
258
320
  * and install the stream-stall watchdog. The caller owns the metrics object
@@ -273,11 +335,73 @@ export function wireStreamEventMetrics(
273
335
  const {
274
336
  events, metrics, orchestrator, providerRegistry,
275
337
  systemMessageRouter, render, providerOptimizer, retryTurn, costLookup,
276
- isApprovalPending, stallThresholdMs,
338
+ isApprovalPending, stallThresholdMs, failoverState, getConfiguredRegistryKey,
277
339
  } = options;
278
340
 
279
341
  const unsubs: Array<() => void> = [];
280
342
 
343
+ /**
344
+ * Deliver a provider-switch notice to the conversation unconditionally.
345
+ * Prefers the router's userReceipt channel, which skips the noise gate and
346
+ * the ui.operationalMessages routing target entirely, so the line cannot be
347
+ * filtered away by a preference and is treated as real content even while
348
+ * the splash still owns the screen. Falls back to high() for bare doubles.
349
+ */
350
+ const announce = (message: string): void => {
351
+ if (systemMessageRouter.userReceipt) systemMessageRouter.userReceipt(message);
352
+ else systemMessageRouter.high(message);
353
+ };
354
+
355
+ /**
356
+ * Put serving back on the user's configured selection after a failed-over
357
+ * turn ends.
358
+ *
359
+ * Failover is per-turn recovery, not a permanent re-selection. Without this,
360
+ * the one setCurrentModel() call that rescued a single failed turn stayed in
361
+ * force for the rest of the session: every later turn ran on the fallback
362
+ * backend while nothing in the session state or the footer had changed to
363
+ * say so. The user's configured selection is authoritative, so the NEXT turn
364
+ * starts from it again. If that backend is still unhealthy, the normal
365
+ * per-turn failover handles it again — visibly, with a fresh notice — which
366
+ * is exactly the behaviour a sticky override was hiding.
367
+ *
368
+ * The record is kept (not cleared) when the restore itself fails, so the
369
+ * header and footer keep reporting the real serving backend rather than
370
+ * claiming a revert that did not happen.
371
+ */
372
+ /**
373
+ * The registry key the restore below is switching back to, held only across
374
+ * that one setCurrentModel call. The MODEL_CHANGED listener reads it to keep
375
+ * the routing chip quiet for this change: the chip's copy is "reason
376
+ * unknown", which would be false here — the reason is known and the
377
+ * `[Failover] Restored …` line states it.
378
+ */
379
+ let pendingRestoreKey: string | null = null;
380
+
381
+ const restoreConfiguredSelection = (): void => {
382
+ const record = failoverState?.current();
383
+ if (!record || !failoverState) return;
384
+ if (providerRegistry.getCurrentModel().registryKey === record.configuredRegistryKey) {
385
+ failoverState.clear();
386
+ return;
387
+ }
388
+ try {
389
+ pendingRestoreKey = record.configuredRegistryKey;
390
+ providerRegistry.setCurrentModel(record.configuredRegistryKey);
391
+ failoverState.clear();
392
+ announce(`[Failover] Restored ${record.configuredRegistryKey} for the next turn.`);
393
+ } catch (restoreErr) {
394
+ pendingRestoreKey = null;
395
+ logger.debug('failover restore failed', {
396
+ configuredRegistryKey: record.configuredRegistryKey, error: String(restoreErr),
397
+ });
398
+ announce(
399
+ `[Failover] Could not switch back to ${record.configuredRegistryKey}; still serving ${record.servingRegistryKey}.`,
400
+ );
401
+ }
402
+ render();
403
+ };
404
+
281
405
  unsubs.push(events.turns.on('STREAM_START', () => {
282
406
  metrics.startTime = Date.now();
283
407
  metrics.deltaCount = 0;
@@ -318,8 +442,20 @@ export function wireStreamEventMetrics(
318
442
  // (caller clears via clearFailoverVisited(), wired in main.ts).
319
443
  const failoverVisited = new Set<string>();
320
444
 
445
+ // Both terminal turn outcomes end the failover's authority over the
446
+ // registry: a completed turn got its answer, a cancelled one will not.
447
+ // (TURN_COMPLETED and TURN_ERROR are mutually exclusive per turn in the SDK
448
+ // — TURN_COMPLETED is emitted only on the success path in
449
+ // orchestrator-turn-helpers, TURN_ERROR only from the orchestrator's catch —
450
+ // so restoring here can never undo a switch whose retry has not run yet.)
321
451
  unsubs.push(events.turns.on('TURN_COMPLETED', () => {
322
452
  failoverVisited.clear();
453
+ restoreConfiguredSelection();
454
+ }));
455
+
456
+ unsubs.push(events.turns.on('TURN_CANCEL', () => {
457
+ failoverVisited.clear();
458
+ restoreConfiguredSelection();
323
459
  }));
324
460
 
325
461
  // Routing chip (never-silent model change): every MODEL_CHANGED that is not
@@ -330,6 +466,11 @@ export function wireStreamEventMetrics(
330
466
  // Degrade gracefully when a bare test double omits the providers feed.
331
467
  if (events.providers) {
332
468
  unsubs.push(events.providers.on('MODEL_CHANGED', (change) => {
469
+ // Listeners run synchronously inside setCurrentModel, so this reads the
470
+ // restore flag before anything else can touch it.
471
+ const wasRestore = pendingRestoreKey !== null && change.registryKey === pendingRestoreKey;
472
+ pendingRestoreKey = null;
473
+ if (wasRestore) return;
333
474
  queueMicrotask(() => {
334
475
  const chip = buildRoutingChip(change, providerOptimizer?.fallbackLog ?? [], Date.now());
335
476
  if (chip === null) return;
@@ -373,30 +514,45 @@ export function wireStreamEventMetrics(
373
514
  try {
374
515
  providerRegistry.setCurrentModel(toRegistryKey);
375
516
  } catch (switchErr) {
376
- // Switch failed — fall through to honest error display.
517
+ // Switch failed — fall through to honest error display. This ends the
518
+ // turn, so an EARLIER hop's switch (if this is a second failover
519
+ // within the same turn) loses its authority here just as it would on
520
+ // any other terminal outcome.
377
521
  logger.debug('failover setCurrentModel failed', { toRegistryKey, error: String(switchErr) });
378
522
  systemMessageRouter.high(`[Error] ${errorClass}`);
523
+ restoreConfiguredSelection();
379
524
  render();
380
525
  return;
381
526
  }
382
527
  // Record the selected provider as visited before the retry fires so
383
528
  // a subsequent TURN_ERROR from that provider also skips it.
384
529
  failoverVisited.add(next.providerId);
530
+ // Remember the user's configured selection so the turn-end restore
531
+ // targets it, and so both shell surfaces can name it while the switch
532
+ // is in force. Sticky across a second hop within the same turn.
533
+ const configuredRegistryKey = getConfiguredRegistryKey?.();
534
+ if (configuredRegistryKey) {
535
+ failoverState?.begin({ configuredRegistryKey, servingRegistryKey: toRegistryKey });
536
+ }
385
537
  providerOptimizer.recordFallbackTransition(fromProvider, next.providerId, errorClass);
386
538
  const costSuffix = buildCostDeltaSuffix(costLookup, fromRegistryKey, toRegistryKey);
387
- systemMessageRouter.high(
388
- `[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})${costSuffix}`,
389
- );
539
+ const billingSuffix = buildBillingSuffix(fromProvider, next.providerId);
540
+ // Re-submit the last user turn on the new provider, handing the notice
541
+ // to retryTurn so it outlives that call's transcript rollback (see the
542
+ // retryTurn option doc). Emitting it here instead would delete it.
543
+ retryTurn(`[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})${billingSuffix}${costSuffix}`);
390
544
  render();
391
- // Re-submit the last user turn on the new provider.
392
- retryTurn();
393
545
  return;
394
546
  }
395
547
 
396
548
  // Chain exhausted — all capable candidates have been visited or none exist.
397
- systemMessageRouter.high(
549
+ // The turn is over, so any switch made earlier in it loses its authority:
550
+ // restore the configured selection before surfacing the error, or the
551
+ // user's next turn would silently start on the last fallback tried.
552
+ announce(
398
553
  `[Failover] Chain exhausted — no alternative provider available. Original error: ${formatUserFacingErrorLine(errVal)}`,
399
554
  );
555
+ restoreConfiguredSelection();
400
556
  notifyErrorSurfaced(true);
401
557
  render();
402
558
  return;
@@ -494,7 +650,11 @@ export function wireStreamEventMetrics(
494
650
  function notifyErrorSurfaced(exhausted: boolean) { _errorSurfacedCb?.(exhausted); }
495
651
  return {
496
652
  unsubs,
497
- clearFailoverVisited: () => failoverVisited.clear(),
653
+ // A new user submission is also a turn boundary: restore here too, so a
654
+ // turn that ended without any terminal event (an aborted stream that
655
+ // emitted neither TURN_COMPLETED nor TURN_CANCEL) still cannot leave the
656
+ // next turn silently pinned to a fallback backend.
657
+ clearFailoverVisited: () => { failoverVisited.clear(); restoreConfiguredSelection(); },
498
658
  onErrorSurfaced: (cb: (exhausted: boolean) => void) => { _errorSurfacedCb = cb; },
499
659
  };
500
660
  }
@@ -224,6 +224,28 @@ export class SystemMessageRouter {
224
224
  wrfc(message: string, priority: SystemMessagePriority = 'high'): void {
225
225
  this.routeTypedSystemMessage(message, priority, 'wrfc');
226
226
  }
227
+
228
+ /**
229
+ * Route a message that must reach the conversation unconditionally. Skips
230
+ * the noise gate and the configured routing target, and marks the message
231
+ * `isUserReceipt` so the splash check in
232
+ * ConversationManager.rebuildHistory() treats it as real visible content —
233
+ * otherwise it would be invisible while the splash still owns the screen.
234
+ *
235
+ * Two callers qualify today:
236
+ * - a receipt for an explicit user action at this shell (answering the
237
+ * startup recovery modal's Resume/Keep/Remove choice): the answer to a
238
+ * question the user was just asked, never ambient chatter;
239
+ * - an automatic provider failover switch (stream-event-wiring.ts):
240
+ * which backend serves a turn decides who bills for it, so it must not
241
+ * be filterable by a routing preference. `[Failover]` is also in
242
+ * FORCE_CONVERSATION_PREFIXES, but that list is a per-prefix opt-in
243
+ * applied AFTER the noise gate; this channel is the structural
244
+ * guarantee rather than a second string match.
245
+ */
246
+ userReceipt(message: string): void {
247
+ this.conversation.addTypedSystemMessage(message, classifySystemMessageKind(message), { isUserReceipt: true });
248
+ }
227
249
  }
228
250
 
229
251
  // ---------------------------------------------------------------------------
package/src/main.ts CHANGED
@@ -24,7 +24,8 @@ import { getTierPromptSupplement, getTierForContextWindow } from '@pellux/goodvi
24
24
  import { GitStatusProvider } from './renderer/git-status.ts';
25
25
  import type { GitHeaderInfo } from './renderer/git-status.ts';
26
26
  import { createShellLayout } from './renderer/layout-engine.ts';
27
- import { buildShellFooter, estimateShellFooterHeight } from './renderer/shell-surface.ts';
27
+ import { buildShellFooter, estimateShellFooterHeight, promptCursorOffset } from './renderer/shell-surface.ts';
28
+ import { createFailoverTurnState, resolveActiveModelDisplay } from './core/active-model-identity.ts';
28
29
  import { computePromptContentWidth } from './renderer/prompt-content-width.ts';
29
30
  import { buildConversationViewport } from './renderer/conversation-layout.ts';
30
31
  import { applyConversationOverlays } from './renderer/conversation-overlays.ts';
@@ -178,6 +179,8 @@ async function main() {
178
179
  let lastMaxScroll: number | null = null;
179
180
  // Stream and tool-timer state; mutated by wireStreamEventMetrics handlers, read during render.
180
181
  const streamMetrics: StreamMetrics = createStreamMetrics();
182
+ // Live failover record — written by the failover path, read every frame so the header and footer agree.
183
+ const failoverState = createFailoverTurnState();
181
184
 
182
185
  const getPromptContentWidth = () => computePromptContentWidth(stdout.columns);
183
186
 
@@ -454,10 +457,9 @@ async function main() {
454
457
  const sessionSnapshot = uiServices.readModels.session.getSnapshot();
455
458
  const agentSnapshot = uiServices.readModels.agents.getSnapshot();
456
459
 
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
- );
460
+ const activeModel = resolveActiveModelDisplay({ serving: currentModel, configuredRegistryKey: configManager.get('provider.model') as string, configuredLabel: runtime.model, configuredProvider: runtime.provider, failover: failoverState.current() });
461
+ const headerLines = UIFactory.createHeader(width, activeModel.headerModel, activeModel.headerProvider, conversation.title || undefined, lastGitInfoRef.value, undefined, activeModel.divergenceNote);
462
+ const managerAgents = agentManager.list().filter((a) => a.status === 'running' || a.status === 'pending');
461
463
  const runtimeAgents = agentSnapshot.active;
462
464
  const runningAgentSummary = summarizeRunningAgents(managerAgents, runtimeAgents, ctx.services.wrfcController.listChains());
463
465
  const runningAgentCount = runningAgentSummary.count;
@@ -470,30 +472,24 @@ async function main() {
470
472
  commandMode: input.commandMode,
471
473
  panelFocused: input.panelFocused,
472
474
  pendingApproval: pendingPermission !== null,
473
- hasAttachments: input.getImageAttachments().size > 0,
474
- turnState: sessionSnapshot.turnState,
475
+ hasAttachments: input.getImageAttachments().size > 0, turnState: sessionSnapshot.turnState,
475
476
  });
476
477
  const contextStatusHint = resolveContextStatusHint({
477
478
  evaluate: (args) => evaluateSessionMaintenance({ configManager, ...args, sessionMemoryCount: ctx.services.sessionMemoryStore.list().length }),
478
- currentTokens: orchestrator.lastInputTokens,
479
- contextWindow,
479
+ currentTokens: orchestrator.lastInputTokens, contextWindow,
480
480
  });
481
481
  const footerLines = buildShellFooter({
482
482
  width,
483
483
  promptText: promptInfo.visibleLines.join('\n'),
484
484
  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,
485
+ promptCursorPos: promptCursorOffset(promptInfo),
490
486
  usage: { up: orchestrator.usage.input, down: orchestrator.usage.output, fleetCostUsd: footerFleetCost(() => ctx.services.processRegistry.query().nodes, runningAgentCount > 0) },
491
487
  showExitNotice: input.showExitNotice,
492
488
  lastCopyTime: input.lastCopyTime,
493
- model: runtime.model,
489
+ model: activeModel.footerModel, modelNote: activeModel.divergenceNote,
494
490
  toolCount: toolRegistry.list().length,
495
491
  workingDir,
496
- provider: runtime.provider,
492
+ provider: activeModel.footerProvider,
497
493
  contextWindow,
498
494
  contextStatusHint,
499
495
  retryHint: retryAffordanceHint(retryAffordance),
@@ -709,17 +705,22 @@ async function main() {
709
705
  // Time-bounded: onExpire repaints once the 60s disarm timer fires, so a stray keypress hours
710
706
  // later can never trigger a real retry — see retry-affordance.ts.
711
707
  const retryAffordance = createRetryAffordanceState({ onExpire: render });
712
- const retryTurn = (): void => {
708
+ const retryTurn = (notice?: string): void => {
713
709
  if (!retryCtx) return;
714
710
  const { count, text, content: rContent, opts: rOpts } = retryCtx;
715
711
  // Roll back to pre-submission count, then re-submit. SDK gap — no retry-in-place (see handoff).
712
+ // The rollback erases the failed turn's transcript — the failover notice included, which is how
713
+ // that notice used to vanish before anyone could read it. The caller hands it over instead, and
714
+ // it is posted here: after the rollback, above the prompt it explains.
716
715
  conversation.removeMessagesAfter(count);
716
+ if (notice) systemMessageRouter.userReceipt(notice);
717
717
  void refreshMemoryRecallSnapshot(ctx.services).then(() => orchestrator.handleUserInput(text, rContent, rOpts)).catch((e: unknown) => logger.debug('retryTurn', { error: summarizeError(e) }));
718
718
  };
719
719
  const streamResult: WireStreamEventMetricsResult = wireStreamEventMetrics({
720
720
  events: uiServices.events, orchestrator, providerRegistry,
721
721
  systemMessageRouter, render, metrics: streamMetrics,
722
722
  providerOptimizer: ctx.services.providerOptimizer, costLookup: providerRegistry, retryTurn,
723
+ failoverState, getConfiguredRegistryKey: () => configManager.get('provider.model') as string | undefined,
723
724
  isApprovalPending: () => pendingPermission !== null,
724
725
  });
725
726
  unsubs.push(...streamResult.unsubs);
@@ -743,7 +744,7 @@ async function main() {
743
744
  const themeProbe = installBackgroundThemeProbe({ configManager, isTTY: Boolean(stdout.isTTY), env: process.env, writeQuery: (b) => allowTerminalWrite(() => stdout.write(b)), requestRepaint: () => { compositor.resetDiff(); render(); } });
744
745
 
745
746
  // 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) } });
747
+ 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
748
 
748
749
  stdin.on('data', (raw: string) => {
749
750
  const data = themeProbe.filterInput(raw); if (data.length === 0) return;
@@ -788,7 +789,7 @@ async function main() {
788
789
  // frame so the question is drawn rather than posed at a blank terminal.
789
790
  scheduleRecoveryOffer(buildRecoveryOfferWiring({
790
791
  surface: ctx.services.surface, sessionManager: ctx.services.sessionManager, runtime, conversation, commandContext,
791
- writeLastSessionPointer, receipt: (line) => systemMessageRouter.high(line), render,
792
+ writeLastSessionPointer, receipt: (line) => systemMessageRouter.userReceipt(line), render,
792
793
  }));
793
794
 
794
795
  // 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'
@@ -0,0 +1,146 @@
1
+ /**
2
+ * header-line.ts — the top-of-screen header row: brand, version, optional
3
+ * conversation title, optional git segment, and the right-hand model/provider
4
+ * pair.
5
+ *
6
+ * Extracted from ui-factory.ts (which sits on the 800-line source gate) when
7
+ * the header gained the failover divergence marker. `UIFactory.createHeader`
8
+ * remains the call name everywhere and delegates here.
9
+ *
10
+ * The model/provider pair rendered here is the SERVING backend, resolved by
11
+ * core/active-model-identity.ts — the same resolution the footer uses, so the
12
+ * two surfaces can no longer disagree about who is answering. `note` carries
13
+ * the divergence marker naming the user's configured selection when serving
14
+ * has moved off it.
15
+ */
16
+
17
+ import { type Line, createEmptyLine } from '../types/grid.ts';
18
+ import { VERSION } from '../version.ts';
19
+ import { getDisplayWidth } from '../utils/terminal-width.ts';
20
+ import type { GitHeaderInfo } from './git-status.ts';
21
+ import { activeUiTones } from './theme.ts';
22
+
23
+ /** Build the git segment string and its display width. Single source of truth for header layout. */
24
+ function buildGitSegment(gitInfo: GitHeaderInfo): { text: string; width: number } {
25
+ const branch = ` git:${gitInfo.branch}`;
26
+ if (gitInfo.dirty) {
27
+ const text = `${branch} * `;
28
+ return { text, width: getDisplayWidth(text) };
29
+ }
30
+ if (gitInfo.ahead > 0 || gitInfo.behind > 0) {
31
+ const arrows = (gitInfo.ahead > 0 ? ` +${gitInfo.ahead}` : '') + (gitInfo.behind > 0 ? ` -${gitInfo.behind}` : '');
32
+ const text = `${branch}${arrows} `;
33
+ return { text, width: getDisplayWidth(text) };
34
+ }
35
+ const text = `${branch} `;
36
+ return { text, width: getDisplayWidth(text) };
37
+ }
38
+
39
+ /**
40
+ * Pick the widest divergence marker that still fits beside the brand and the
41
+ * model/provider pair.
42
+ *
43
+ * Graded, never truncated mid-word: the full marker (which names the
44
+ * configured selection) is preferred; a bare "divergent" keyword is the
45
+ * fallback on a narrow terminal; on a very narrow terminal the marker is
46
+ * dropped entirely. Dropping it is safe — what remains is still the SERVING
47
+ * backend, so the header never claims the configured backend is answering.
48
+ * The footer carries the full marker with far more room (see ui-factory's
49
+ * prioritized context segments).
50
+ */
51
+ function fitHeaderNote(note: string, usedWidth: number, width: number): string {
52
+ if (!note) return '';
53
+ const full = `· ${note} `;
54
+ if (usedWidth + getDisplayWidth(full) <= width) return full;
55
+ const short = '· divergent ';
56
+ return usedWidth + getDisplayWidth(short) <= width ? short : '';
57
+ }
58
+
59
+ /**
60
+ * Render the header row plus its underline.
61
+ *
62
+ * @param width - Terminal columns.
63
+ * @param model - Serving model id (already resolved; see file doc).
64
+ * @param provider - Serving provider id.
65
+ * @param title - Optional conversation title, truncated to fit.
66
+ * @param gitInfo - Optional git branch/dirty/ahead-behind segment.
67
+ * @param version - Defaults to the live build VERSION; tests pass a pinned
68
+ * fixture so golden snapshots survive release bumps.
69
+ * @param note - Divergence marker naming the configured selection when the
70
+ * serving backend is not the one the user chose; '' otherwise.
71
+ */
72
+ export function renderHeaderLine(
73
+ width: number,
74
+ model: string,
75
+ provider: string,
76
+ title?: string,
77
+ gitInfo?: GitHeaderInfo,
78
+ version: string = VERSION,
79
+ note: string = '',
80
+ ): Line[] {
81
+ const lines: Line[] = [];
82
+ // Header/footer/thinking paint on the transparent terminal background, so
83
+ // they read chrome.* (light-terminal-aware) — NOT fg.*/state.*, which stay
84
+ // tuned for the opaque dark modal/panel boxes. Read live per render so a
85
+ // mode flip re-resolves without any module reload (see theme.ts).
86
+ const t = activeUiTones();
87
+ const CYAN = t.accent.brand;
88
+ const GREY = t.chrome.faint;
89
+ const TITLE_COLOR = t.chrome.label;
90
+ const brand = ` GoodVibes `;
91
+ const ver = `v${version} `;
92
+ const stats = ` ${model} `;
93
+ const prov = `(${provider}) `;
94
+ const gitWidth = gitInfo ? buildGitSegment(gitInfo).width : 0;
95
+ // Resolve the marker before the title so the title's fit calculation
96
+ // reserves room for it too.
97
+ const noteStr = fitHeaderNote(note, getDisplayWidth(brand + ver + stats + prov) + gitWidth, width);
98
+ const line = createEmptyLine(width);
99
+ let curX = 0;
100
+ for (const char of brand) { line[curX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
101
+ for (const char of ver) { line[curX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
102
+ // Optional conversation title — shown after brand/ver, truncated to fit
103
+ if (title) {
104
+ const titleStr = `│ ${title} `;
105
+ // Reserve space for git info (if present) + model/provider + marker on the right
106
+ const rightReserved = getDisplayWidth(stats + prov + noteStr) + gitWidth;
107
+ const maxTitleW = width - curX - rightReserved - 1;
108
+ let displayTitle: string;
109
+ if (getDisplayWidth(titleStr) <= maxTitleW) {
110
+ displayTitle = titleStr;
111
+ } else {
112
+ let truncated = '';
113
+ let w = 0;
114
+ for (const ch of titleStr) {
115
+ const cw = getDisplayWidth(ch);
116
+ if (w + cw > maxTitleW - 3) { truncated += '...'; break; }
117
+ truncated += ch;
118
+ w += cw;
119
+ }
120
+ displayTitle = truncated;
121
+ }
122
+ for (const char of displayTitle) { if (curX < width) line[curX++] = { char, fg: TITLE_COLOR, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
123
+ }
124
+ // Build git info segment
125
+ let gitStr = '';
126
+ let gitFg: string = t.chrome.faint;
127
+ if (gitInfo) {
128
+ gitStr = buildGitSegment(gitInfo).text;
129
+ if (gitInfo.dirty || gitInfo.ahead > 0 || gitInfo.behind > 0) {
130
+ gitFg = t.chrome.warn; // yellow when dirty or out-of-sync
131
+ }
132
+ }
133
+ const rightSideW = getDisplayWidth(stats + prov + noteStr) + getDisplayWidth(gitStr);
134
+ let rightX = width - rightSideW;
135
+ for (const char of gitStr) { if (rightX >= 0 && rightX < width) line[rightX++] = { char, fg: gitFg, bg: '', bold: false, dim: !gitInfo?.dirty && !(gitInfo?.ahead || gitInfo?.behind), underline: false, italic: false, strikethrough: false }; }
136
+ for (const char of stats) { if (rightX < width) line[rightX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
137
+ for (const char of prov) { if (rightX < width) line[rightX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
138
+ // The marker reads as a warning, not as chrome: serving has left the
139
+ // selection the user made and that is worth noticing.
140
+ for (const char of noteStr) { if (rightX >= 0 && rightX < width) line[rightX++] = { char, fg: t.chrome.warn, bg: '', bold: false, dim: false, underline: false, italic: false, strikethrough: false }; }
141
+ lines.push(line);
142
+ const underline = createEmptyLine(width);
143
+ for (let x = 0; x < width; x++) underline[x] = { char: '━', fg: t.chrome.faint, bg: '', bold: false, dim: false, underline: false, italic: false, strikethrough: false };
144
+ lines.push(underline);
145
+ return lines;
146
+ }
@@ -19,6 +19,13 @@ export interface ShellFooterBuildOptions {
19
19
  readonly showExitNotice: boolean;
20
20
  readonly lastCopyTime: number;
21
21
  readonly model?: string;
22
+ /**
23
+ * Divergence marker appended to the model segment while the serving backend
24
+ * is not the user's configured selection (core/active-model-identity.ts),
25
+ * e.g. "failover from abacusai:route-llm". Absent/empty in the normal case,
26
+ * where the context-info line renders exactly as it always has.
27
+ */
28
+ readonly modelNote?: string;
22
29
  readonly toolCount?: number;
23
30
  readonly workingDir?: string;
24
31
  readonly provider?: string;
@@ -112,6 +119,34 @@ const DIM_STATUS_FG = '#64748b';
112
119
  */
113
120
  let lastRenderedFooterHeight: { compact: boolean; height: number } | null = null;
114
121
 
122
+ /**
123
+ * The wrapped-prompt shape the composer reports each frame — the subset
124
+ * promptCursorOffset needs (see InputHandler.getWrappedPromptInfo).
125
+ */
126
+ export interface WrappedPromptCursorInfo {
127
+ readonly visibleLines: readonly string[];
128
+ readonly visibleCursorLine: number;
129
+ readonly visibleCursorCol: number;
130
+ }
131
+
132
+ /**
133
+ * Flatten a wrapped-prompt cursor position (line + column) into the single
134
+ * character offset createFooter's `cursorPos` expects, counting one separator
135
+ * per wrapped line. Returns undefined when the cursor is not on a visible line
136
+ * (scrolled out of the composer's window), which suppresses the cursor overlay
137
+ * rather than drawing it in the wrong place.
138
+ *
139
+ * Lives here rather than inline at the render site so main.ts (a composition
140
+ * root already at the source-line gate) stays a wiring file.
141
+ */
142
+ export function promptCursorOffset(info: WrappedPromptCursorInfo): number | undefined {
143
+ if (info.visibleCursorLine < 0) return undefined;
144
+ const precedingChars = info.visibleLines
145
+ .slice(0, info.visibleCursorLine)
146
+ .reduce((sum: number, line: string) => sum + line.length + 1, 0);
147
+ return precedingChars + info.visibleCursorCol;
148
+ }
149
+
115
150
  export function estimateShellFooterHeight(
116
151
  promptLineCount: number,
117
152
  contextWindow?: number,
@@ -158,6 +193,7 @@ export function buildShellFooter(
158
193
  options.permissionMode,
159
194
  options.webSurfaceUrl,
160
195
  options.powerKeepAwake,
196
+ options.modelNote,
161
197
  );
162
198
  // Compact posture drops the process indicator and context hint entirely so
163
199
  // the footer fits within ~5 rows on short terminals.