@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.
- package/CHANGELOG.md +7 -0
- package/README.md +7 -7
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/management.ts +1 -1
- package/src/core/active-model-identity.ts +153 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +20 -17
- package/src/core/stream-event-wiring.ts +174 -14
- package/src/core/system-message-router.ts +22 -0
- package/src/main.ts +20 -19
- package/src/{cli → providers}/provider-classification.ts +12 -0
- package/src/renderer/header-line.ts +146 -0
- package/src/renderer/shell-surface.ts +36 -0
- package/src/renderer/ui-factory.ts +25 -78
- package/src/runtime/recovery-decisions.ts +126 -0
- package/src/runtime/recovery-prompt.ts +56 -6
- package/src/version.ts +1 -1
|
@@ -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
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
458
|
-
const
|
|
459
|
-
|
|
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
|
|
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:
|
|
489
|
+
model: activeModel.footerModel, modelNote: activeModel.divergenceNote,
|
|
494
490
|
toolCount: toolRegistry.list().length,
|
|
495
491
|
workingDir,
|
|
496
|
-
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.
|
|
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.
|
|
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.
|