@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
@@ -0,0 +1,399 @@
1
+ /**
2
+ * reasoning-effort-surface.ts — the TUI's single reading of the SDK's
3
+ * per-model reasoning-effort spec.
4
+ *
5
+ * Every effort-shaped surface (the `/effort` command, the model picker's
6
+ * effort step, the model-switch and failover notices) asks this module what a
7
+ * specific model offers, instead of each one carrying its own list. The list
8
+ * used to be the hardcoded four levels `instant, low, medium, high`, which was
9
+ * wrong in both directions: it offered `instant` to models that reject it and
10
+ * hid `xhigh`/`max`/`none` from models that accept them.
11
+ *
12
+ * The honest presentation differs per spec kind, so the wording is generated
13
+ * here rather than templated at each call site:
14
+ *
15
+ * - `effort` — the model names its levels; offer exactly those.
16
+ * - `budget_tokens` — the model takes a thinking-token budget, so each level
17
+ * is shown with the budget it sends.
18
+ * - `toggle` — reasoning is on or off only; say so, and do not imply
19
+ * that the levels in between mean anything.
20
+ * - `unavailable` — nothing is configurable; the picker says that instead
21
+ * of presenting an empty list.
22
+ *
23
+ * A `fallback`-sourced spec is a labelled guess: neither the live catalog nor
24
+ * the curated family table recognised the model. The SDK's OpenAI and Gemini
25
+ * adapters send nothing at all for such a model (silence beats a guess on an
26
+ * endpoint that also serves non-reasoning models), so surfaces built here say
27
+ * that outright rather than implying the choice takes effect.
28
+ */
29
+
30
+ import {
31
+ EFFORT_DESCRIPTIONS,
32
+ budgetTokensForLevel,
33
+ describeReasoningWire,
34
+ reasoningEffortLevels,
35
+ resolveEffortForModel,
36
+ resolveReasoningEffortSpec,
37
+ setActiveReasoningEffortOptions,
38
+ type ReasoningEffortSpec,
39
+ type ResolvedReasoningEffort,
40
+ } from '@pellux/goodvibes-sdk/platform/providers';
41
+
42
+ /** The subset of a model this module needs; `ModelDefinition` satisfies it. */
43
+ export interface EffortModelLike {
44
+ readonly id: string;
45
+ readonly provider?: string | undefined;
46
+ readonly displayName?: string | undefined;
47
+ readonly reasoningEffort?: ReasoningEffortSpec | undefined;
48
+ }
49
+
50
+ /** Sentence appended wherever a best-guess spec is presented as choices. */
51
+ const FALLBACK_EFFORT_CAVEAT =
52
+ 'Best guess: this model is not in the model catalog, so these levels are unverified and some providers send nothing at all for it.';
53
+
54
+ /**
55
+ * Whether a value really is a ReasoningEffortSpec.
56
+ *
57
+ * `ModelDefinition.reasoningEffort` used to be a bare `string[]`. A definition
58
+ * built by older code, restored from an older cache, or supplied by a plugin
59
+ * can still arrive in that shape, and passing it through as a spec would make
60
+ * every surface read `.values` off an array and crash. A value that fails this
61
+ * check is treated as "the model said nothing", which falls through to the
62
+ * curated family table and then to the labelled best guess.
63
+ */
64
+ function isReasoningEffortSpec(value: unknown): value is ReasoningEffortSpec {
65
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
66
+ const record = value as { kind?: unknown; values?: unknown };
67
+ return typeof record.kind === 'string' && Array.isArray(record.values);
68
+ }
69
+
70
+ /**
71
+ * The spec that governs one model, applying the SDK's source precedence
72
+ * (live catalog, then curated family table, then the labelled best guess).
73
+ */
74
+ export function effortSpecForModel(model: EffortModelLike): ReasoningEffortSpec {
75
+ const declared = isReasoningEffortSpec(model.reasoningEffort) ? model.reasoningEffort : undefined;
76
+ return resolveReasoningEffortSpec({
77
+ modelId: model.id,
78
+ ...(declared ? { spec: declared } : {}),
79
+ });
80
+ }
81
+
82
+ /** The levels a model actually offers, in severity order. */
83
+ export function effortLevelsForModel(model: EffortModelLike): readonly string[] {
84
+ return reasoningEffortLevels(effortSpecForModel(model));
85
+ }
86
+
87
+ /**
88
+ * Whether a surface should offer an effort CHOICE for this model.
89
+ *
90
+ * Stricter than "has levels": a `fallback`-sourced spec is a labelled guess at
91
+ * a model nothing recognises, and the SDK's OpenAI and Gemini adapters send
92
+ * nothing at all for such a model. Putting a picker step in front of a choice
93
+ * that will be discarded is worse than not offering it, so an automatic step
94
+ * (the model picker's effort stage) is skipped. `/effort`, which the user asked
95
+ * for explicitly, still lists the guessed levels with their caveat attached.
96
+ */
97
+ export function offersConfigurableEffort(model: EffortModelLike): boolean {
98
+ const spec = effortSpecForModel(model);
99
+ return spec.values.length > 0 && spec.source !== 'fallback';
100
+ }
101
+
102
+ /**
103
+ * Publish the current model's real levels to the SDK so a
104
+ * `provider.reasoningEffort` write is validated against them rather than
105
+ * against a fixed list. Pass no model to clear.
106
+ */
107
+ export function publishActiveEffortOptions(model: EffortModelLike | null | undefined): void {
108
+ if (!model) {
109
+ setActiveReasoningEffortOptions(null);
110
+ return;
111
+ }
112
+ setActiveReasoningEffortOptions(effortLevelsForModel(model));
113
+ }
114
+
115
+ /** Map a requested level onto what a model accepts (snapping down, never up). */
116
+ export function resolveEffortForModelSurface(
117
+ requested: string | undefined,
118
+ model: EffortModelLike,
119
+ ): ResolvedReasoningEffort {
120
+ return resolveEffortForModel(requested, {
121
+ id: model.id,
122
+ ...(model.displayName ? { displayName: model.displayName } : {}),
123
+ reasoningEffort: effortSpecForModel(model),
124
+ });
125
+ }
126
+
127
+ /** One level plus the line describing what choosing it does on this model. */
128
+ export interface EffortChoice {
129
+ readonly level: string;
130
+ readonly description: string;
131
+ }
132
+
133
+ /**
134
+ * A level's description under a specific spec. Budget-typed models get the
135
+ * token budget the level actually sends, because "medium" means nothing to a
136
+ * user comparing it against a `thinking.budget_tokens` figure.
137
+ */
138
+ function describeEffortLevel(level: string, spec: ReasoningEffortSpec): string {
139
+ const base = EFFORT_DESCRIPTIONS[level] ?? '';
140
+ if (spec.kind === 'budget_tokens') {
141
+ const budget = budgetTokensForLevel(level, spec);
142
+ const budgetText = budget > 0 ? `${budget.toLocaleString()} thinking tokens` : 'thinking off';
143
+ return base ? `${base} (${budgetText})` : budgetText;
144
+ }
145
+ if (spec.kind === 'toggle') {
146
+ return level === 'none' ? 'Reasoning off' : 'Reasoning on, at the model\'s own depth';
147
+ }
148
+ return base;
149
+ }
150
+
151
+ /** What a surface needs to present one model's effort options honestly. */
152
+ export interface EffortPresentation {
153
+ readonly spec: ReasoningEffortSpec;
154
+ readonly choices: readonly EffortChoice[];
155
+ /** True when there is nothing to choose and the surface must say so. */
156
+ readonly configurable: boolean;
157
+ /** One line stating what this model does with the setting; always present. */
158
+ readonly headline: string;
159
+ /** Caveat to show beneath the choices, when the spec carries one. */
160
+ readonly caveat?: string | undefined;
161
+ }
162
+
163
+ /** Build the full presentation for a model's effort options. */
164
+ export function effortPresentationForModel(model: EffortModelLike): EffortPresentation {
165
+ const spec = effortSpecForModel(model);
166
+ const name = model.displayName ?? model.id;
167
+ const choices = spec.values.map((level) => ({ level, description: describeEffortLevel(level, spec) }));
168
+ const caveat = spec.source === 'fallback' ? FALLBACK_EFFORT_CAVEAT : spec.note;
169
+
170
+ let headline: string;
171
+ switch (spec.kind) {
172
+ case 'effort':
173
+ headline = `${name} accepts these reasoning levels.`;
174
+ break;
175
+ case 'budget_tokens':
176
+ headline = `${name} takes a thinking-token budget; each level below sends the budget shown.`;
177
+ break;
178
+ case 'toggle':
179
+ headline = `${name} only exposes reasoning on or off — there is no depth to choose.`;
180
+ break;
181
+ case 'unavailable':
182
+ headline = `${name} has no configurable reasoning level; it runs at its own fixed depth.`;
183
+ break;
184
+ }
185
+
186
+ return {
187
+ spec,
188
+ choices,
189
+ configurable: choices.length > 0,
190
+ headline,
191
+ ...(caveat ? { caveat } : {}),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * The `/effort` explainer lines for one model, generated from the resolved
197
+ * spec. Replaces the old fixed block that named Mercury-2, Claude, Gemini and
198
+ * GPT-5 for every model regardless of which one was actually serving.
199
+ */
200
+ export function describeEffortForModel(model: EffortModelLike, current: string | undefined): string[] {
201
+ const presentation = effortPresentationForModel(model);
202
+ const { spec } = presentation;
203
+ const wire = describeReasoningWire(spec, model.provider);
204
+ const resolved = resolveEffortForModelSurface(current, model);
205
+
206
+ const lines = [`Reasoning effort: ${current ?? '(not set)'}`, presentation.headline];
207
+
208
+ if (spec.kind === 'unavailable') {
209
+ lines.push(` Sent on the wire: ${wire}`);
210
+ if (presentation.caveat) lines.push(` ${presentation.caveat}`);
211
+ return lines;
212
+ }
213
+
214
+ if (resolved.value === undefined) {
215
+ lines.push(` Sent on the wire: nothing — ${model.displayName ?? model.id} runs at its own default.`);
216
+ } else if (spec.kind === 'budget_tokens') {
217
+ lines.push(` Sent on the wire: ${wire} = ${budgetTokensForLevel(resolved.value, spec).toLocaleString()}`);
218
+ } else {
219
+ lines.push(` Sent on the wire: ${wire} = '${resolved.value}'`);
220
+ }
221
+
222
+ if (resolved.note) lines.push(` ${resolved.note}`);
223
+ lines.push('');
224
+ for (const choice of presentation.choices) {
225
+ const marker = choice.level === resolved.value ? '◉' : ' ';
226
+ lines.push(` ${marker} ${choice.level.padEnd(8)}${choice.description}`);
227
+ }
228
+ if (presentation.caveat && spec.source === 'fallback') {
229
+ lines.push('');
230
+ lines.push(` ${presentation.caveat}`);
231
+ }
232
+ return lines;
233
+ }
234
+
235
+ /**
236
+ * One line for a non-interactive surface (`goodvibes status`, `goodvibes
237
+ * doctor`) saying what the configured level becomes on the configured model.
238
+ *
239
+ * Printing the raw config value alone was misleading in three separate ways:
240
+ * a model that does not offer the level receives the next one down, a model
241
+ * with no configurable reasoning receives nothing, and a model the catalog
242
+ * does not carry may have the field dropped entirely.
243
+ *
244
+ * `registryKey` is the `provider:model` form stored in `provider.model`; a
245
+ * value without a colon is treated as a bare model id.
246
+ */
247
+ export function describeConfiguredEffort(registryKey: string, configured: string): string {
248
+ const trimmed = registryKey.trim();
249
+ if (trimmed === '') return 'no model configured';
250
+ const colon = trimmed.indexOf(':');
251
+ const providerId = colon === -1 ? '' : trimmed.slice(0, colon);
252
+ const modelId = colon === -1 ? trimmed : trimmed.slice(colon + 1);
253
+ const model: EffortModelLike = { id: modelId, ...(providerId ? { provider: providerId } : {}) };
254
+
255
+ const spec = effortSpecForModel(model);
256
+ const resolved = resolveEffortForModelSurface(configured === '' ? undefined : configured, model);
257
+ const wire = describeReasoningWire(spec, providerId);
258
+
259
+ if (spec.kind === 'unavailable') return `not sent — ${modelId} has no configurable reasoning level`;
260
+ if (spec.source === 'fallback') {
261
+ return `${resolved.value ?? '(model default)'} — unverified: ${modelId} is not in the model catalog, so some providers send nothing for it`;
262
+ }
263
+ if (resolved.value === undefined) return `not sent — ${modelId} applies its own default`;
264
+ if (spec.kind === 'budget_tokens') {
265
+ return `${resolved.value} (${wire} = ${budgetTokensForLevel(resolved.value, spec).toLocaleString()})`;
266
+ }
267
+ if (resolved.value !== configured && configured !== '') {
268
+ return `${resolved.value} (${wire}) — '${configured}' is not available on ${modelId}`;
269
+ }
270
+ return `${resolved.value} (${wire})`;
271
+ }
272
+
273
+ /**
274
+ * Re-resolve a configured level against a model that is about to serve, for
275
+ * the model-switch and failover paths. Returns the level to use and, when it
276
+ * had to change, the SDK's own sentence explaining why — printed verbatim so
277
+ * the wording cannot drift from the resolution that produced it.
278
+ */
279
+ export function remapEffortForServingModel(
280
+ configured: string | undefined,
281
+ servingModel: EffortModelLike,
282
+ ): { readonly value: string | undefined; readonly note?: string | undefined } {
283
+ const resolved = resolveEffortForModelSurface(configured, servingModel);
284
+ const changed = resolved.value !== configured;
285
+ return {
286
+ value: resolved.value,
287
+ ...(changed && resolved.note ? { note: resolved.note } : {}),
288
+ };
289
+ }
290
+
291
+ /**
292
+ * The two levels every effort surface has to keep apart.
293
+ *
294
+ * `requested` is what the user asked for and what config `provider.reasoningEffort`
295
+ * holds. `effective` is what the model now serving will actually receive, which
296
+ * is `requested` snapped DOWN to that model's own levels (or `undefined` when the
297
+ * model takes no reasoning parameter at all).
298
+ */
299
+ export interface ServingEffort {
300
+ /** The user's stored preference; '' when nothing has ever been chosen. */
301
+ readonly requested: string;
302
+ /** What this model receives; undefined means the field is not sent at all. */
303
+ readonly effective: string | undefined;
304
+ /** True when the serving model could not honour the requested level. */
305
+ readonly capped: boolean;
306
+ /** The SDK's own sentence explaining the snap, when there was one. */
307
+ readonly note?: string | undefined;
308
+ }
309
+
310
+ /**
311
+ * Minimal reader shape for config `provider.reasoningEffort`. A real
312
+ * ConfigManager satisfies it; a test double can be a one-method object.
313
+ */
314
+ export interface RequestedEffortReader {
315
+ get(key: 'provider.reasoningEffort'): unknown;
316
+ }
317
+
318
+ /**
319
+ * Read the user's REQUESTED reasoning level.
320
+ *
321
+ * This is the ONE place any automatic (model-driven) resolution is allowed to
322
+ * get its input from, and it deliberately does not consult the session's
323
+ * effective level. Reading the effective level back as the next resolution's
324
+ * baseline is what used to make a downgrade permanent: switching to a model
325
+ * that caps at 'medium' stored 'medium' as the preference, so switching back to
326
+ * a model that accepts 'xhigh' had nothing left to restore. Config holds what
327
+ * was asked for; only an explicit user choice writes it.
328
+ */
329
+ export function requestedEffortLevel(config: RequestedEffortReader): string {
330
+ const raw = config.get('provider.reasoningEffort');
331
+ return typeof raw === 'string' ? raw.trim() : '';
332
+ }
333
+
334
+ /** Resolve an explicit level against the model now serving. */
335
+ export function servingEffortForLevel(requested: string, model: EffortModelLike): ServingEffort {
336
+ if (requested === '') return { requested, effective: undefined, capped: false };
337
+ const remapped = remapEffortForServingModel(requested, model);
338
+ return {
339
+ requested,
340
+ effective: remapped.value,
341
+ capped: remapped.value !== requested,
342
+ ...(remapped.note ? { note: remapped.note } : {}),
343
+ };
344
+ }
345
+
346
+ /**
347
+ * Resolve the user's REQUESTED level against the model now serving. Every
348
+ * automatic model-switch path goes through this, so none of them can
349
+ * accidentally resolve from a previously snapped value.
350
+ */
351
+ export function resolveRequestedEffortForServingModel(
352
+ config: RequestedEffortReader,
353
+ model: EffortModelLike,
354
+ ): ServingEffort {
355
+ return servingEffortForLevel(requestedEffortLevel(config), model);
356
+ }
357
+
358
+ /**
359
+ * How an effort level is shown once the requested and effective levels can
360
+ * differ.
361
+ *
362
+ * The stored preference is no longer overwritten with the snapped value, so a
363
+ * surface that printed the stored level alone would claim 'xhigh' while 'high'
364
+ * went on the wire. Instead of corrupting the preference to keep the display
365
+ * honest, the display carries both values and names the model that caps it.
366
+ * When the two agree, the single value is printed exactly as before.
367
+ */
368
+ export function describeServingEffort(state: ServingEffort, model: EffortModelLike): string {
369
+ const name = model.displayName ?? model.id;
370
+ if (state.requested === '') return '(not set)';
371
+ if (!state.capped) return state.requested;
372
+ if (state.effective === undefined) {
373
+ // Two different reasons the field is dropped, and they must not be
374
+ // conflated: the model takes no reasoning parameter at all, or it takes one
375
+ // but offers nothing at or below the requested level (resolution snaps DOWN
376
+ // only, never up to a costlier level).
377
+ const reason = effortLevelsForModel(model).length === 0
378
+ ? `${name} has no configurable reasoning level`
379
+ : `${name} offers nothing at or below ${state.requested}`;
380
+ return `(not sent) (requested ${state.requested}; ${reason})`;
381
+ }
382
+ return `${state.effective} (requested ${state.requested}; ${name} caps at ${state.effective})`;
383
+ }
384
+
385
+ /**
386
+ * Normalize any model-shaped record to what this module consumes.
387
+ *
388
+ * Deliberately structural rather than `ModelDefinition`: the model-selection
389
+ * callback carries a lighter record (id, provider, displayName, registryKey)
390
+ * and still needs the same resolution.
391
+ */
392
+ export function toEffortModel(model: EffortModelLike): EffortModelLike {
393
+ return {
394
+ id: model.id,
395
+ provider: model.provider,
396
+ displayName: model.displayName,
397
+ ...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}),
398
+ };
399
+ }
@@ -1,6 +1,6 @@
1
1
  import { TerminalBuffer } from './buffer.ts';
2
2
  import { DiffEngine } from './diff.ts';
3
- import { type Line, createEmptyCell, createStyledCell } from '../types/grid.ts';
3
+ import { type Line, createEmptyCell, createEmptyLine, createStyledCell } from '../types/grid.ts';
4
4
  import { getDisplayWidth } from '../utils/terminal-width.ts';
5
5
  import type { SearchManager } from '../input/search.ts';
6
6
  import { allowTerminalWrite } from '../runtime/terminal-output-guard.ts';
@@ -70,6 +70,22 @@ export class Compositor {
70
70
  private backBuffer: TerminalBuffer | null = null;
71
71
  private readonly caps: TermColorCaps;
72
72
  private diffEngine: DiffEngine;
73
+ /**
74
+ * When true the next composite() repaints the WHOLE screen instead of
75
+ * diffing against the last frame: erase display, then emit every cell.
76
+ *
77
+ * The incremental path is correct only while the front buffer still
78
+ * describes what is physically on screen. Two things break that assumption:
79
+ * a buffer reallocation (resize) makes the compositor forget the old frame,
80
+ * and anything that writes to the terminal outside composite() moves content
81
+ * the compositor never wrote. After either, a row the model believes is
82
+ * already blank is never re-emitted, so whatever the terminal is showing
83
+ * there — splash art, a stale rule line — survives every later frame. The
84
+ * erase is what clears cells the diff structurally skips (wide-glyph
85
+ * continuation cells, see DiffEngine.diff), which a diff against a null
86
+ * front buffer alone cannot do.
87
+ */
88
+ private fullRepaintPending = false;
73
89
 
74
90
  constructor(private stdout: NodeJS.WriteStream) {
75
91
  // Probe terminal capabilities once at construction time.
@@ -91,15 +107,40 @@ export class Compositor {
91
107
  this.diffEngine.reset();
92
108
  this.frontBuffer = null;
93
109
  this.backBuffer = null;
110
+ this.fullRepaintPending = true;
111
+ }
112
+
113
+ /**
114
+ * Force the next frame to repaint the entire screen exactly once.
115
+ *
116
+ * Called at a state transition that replaces one full-screen composition
117
+ * with another (the splash giving way to the transcript), where any cell the
118
+ * incremental path leaves behind reads as corruption rather than as a stale
119
+ * pixel. Unlike resetDiff() this keeps the buffers, so the frame after the
120
+ * repaint resumes normal differential rendering.
121
+ */
122
+ public requestFullRepaint(): void {
123
+ this.fullRepaintPending = true;
94
124
  }
95
125
 
96
126
  public composite(params: CompositeRequest): void {
97
127
  const { width, height, header, viewport, footer, selection, search, panel, panelWidth } = params;
98
- // Reuse back-buffer instead of allocating each frame
128
+ // A size change reallocates the buffer, which drops every record of what
129
+ // the terminal is currently showing — repaint in full rather than diff
130
+ // against a model that no longer describes the screen.
131
+ const resized = this.frontBuffer !== null
132
+ && (this.frontBuffer.width !== width || this.frontBuffer.height !== height);
133
+ const fullRepaint = this.fullRepaintPending || resized;
134
+ this.fullRepaintPending = false;
135
+ // Reuse back-buffer instead of allocating each frame. A freshly allocated
136
+ // back buffer is seeded from the front buffer for the same reason reset()
137
+ // is: rows this frame does not write must keep describing what is on
138
+ // screen, or the diff will skip them forever.
99
139
  if (!this.backBuffer) {
100
140
  this.backBuffer = new TerminalBuffer(width, height);
141
+ if (!fullRepaint) this.backBuffer.reset(width, height, this.frontBuffer);
101
142
  } else {
102
- this.backBuffer.reset(width, height, this.frontBuffer);
143
+ this.backBuffer.reset(width, height, fullRepaint ? null : this.frontBuffer);
103
144
  }
104
145
  const newBuffer = this.backBuffer;
105
146
 
@@ -145,9 +186,20 @@ export class Compositor {
145
186
  return panel!.bottomFocused ? PANEL_FOCUS_ACCENT : PANEL_BORDER_DIM;
146
187
  };
147
188
 
148
- viewport.forEach((line, i) => {
189
+ // Every body row is written every frame, including rows the caller did not
190
+ // supply a line for. A viewport array shorter than the body (a docked
191
+ // overlay reserves a bottom inset, an overlay-row reservation overshoots
192
+ // what actually rendered) used to leave those rows untouched: the buffer
193
+ // then described them as blank while the terminal still showed the
194
+ // previous composition there, and since a blank-over-blank blit is a no-op
195
+ // no later frame ever repainted them. Supplying a blank line keeps the
196
+ // model and the screen in agreement.
197
+ const blankRow = createEmptyLine(width);
198
+ const bodyRows = Math.max(viewport.length, vHeight);
199
+ for (let i = 0; i < bodyRows; i++) {
200
+ const line = viewport[i] ?? blankRow;
149
201
  const screenY = viewportStartY + i;
150
- if (screenY >= height) return;
202
+ if (screenY >= height) break;
151
203
 
152
204
  if (!hasPanel) {
153
205
  // No panel: existing fast path
@@ -264,16 +316,9 @@ export class Compositor {
264
316
  }
265
317
  }
266
318
  }
267
- });
268
-
269
- // Draw separator on remaining viewport rows past content (when panel is active)
270
- if (hasPanel && panel!.separator) {
271
- for (let i = viewport.length; i < vHeight; i++) {
272
- const screenY = viewportStartY + i;
273
- if (screenY >= height) break;
274
- newBuffer.setCell(sepX, screenY, createStyledCell('│', { fg: borderFgForRow(i) }));
275
- }
276
319
  }
320
+ // (rows past the supplied viewport lines are covered by the loop above,
321
+ // separator column included, so they can no longer keep a stale frame.)
277
322
 
278
323
  // 3. Draw Footer (Pinned to Bottom) — always full width
279
324
  const footerStart = height - footer.length;
@@ -284,10 +329,15 @@ export class Compositor {
284
329
  });
285
330
 
286
331
  // 4. Diff and Render
287
- // Diff against front-buffer (last-rendered), then swap front/back — no clone() needed
288
- const diff = this.diffEngine.diff(this.frontBuffer, newBuffer);
289
- if (diff) {
290
- allowTerminalWrite(() => this.stdout.write(diff));
332
+ // Diff against front-buffer (last-rendered), then swap front/back — no clone() needed.
333
+ // On a full repaint the SGR run-state is reset (the erase below leaves the
334
+ // terminal's attributes unknown) and the diff runs against no previous
335
+ // frame, so every cell of the grid is emitted.
336
+ if (fullRepaint) this.diffEngine.reset();
337
+ const diff = this.diffEngine.diff(fullRepaint ? null : this.frontBuffer, newBuffer);
338
+ const payload = fullRepaint ? `\x1b[H\x1b[2J${diff}` : diff;
339
+ if (payload) {
340
+ allowTerminalWrite(() => this.stdout.write(payload));
291
341
  }
292
342
 
293
343
  // Swap: back (just written) becomes the new front reference; old front becomes the next back
@@ -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
+ }