@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.
- package/CHANGELOG.md +20 -1
- package/README.md +7 -7
- package/docs/foundation-artifacts/operator-contract.json +106 -22
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/doctor.ts +7 -1
- package/src/cli/management.ts +1 -1
- package/src/cli/package-verification.ts +92 -7
- package/src/cli/status.ts +16 -0
- package/src/config/read-versioned.ts +115 -2
- package/src/core/active-model-identity.ts +153 -0
- package/src/core/conversation-event-navigation.ts +42 -0
- package/src/core/conversation-search-expansion.ts +67 -0
- package/src/core/conversation-splash-state.ts +58 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +43 -40
- package/src/core/model-routing-chip.ts +38 -18
- package/src/core/rewind-turn-anchors.ts +155 -3
- package/src/core/stream-event-wiring.ts +298 -17
- package/src/core/system-message-router.ts +22 -0
- package/src/core/transcript-journal.ts +145 -7
- package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
- package/src/daemon/handlers/inbox/index.ts +14 -1
- package/src/daemon/handlers/sqlite-store.ts +160 -6
- package/src/input/command-registry.ts +8 -0
- package/src/input/commands/local-runtime.ts +6 -1
- package/src/input/commands/schedule-runtime.ts +15 -2
- package/src/input/commands/shell-core.ts +87 -38
- package/src/input/composer-capture.ts +47 -0
- package/src/input/handler-command-route.ts +4 -0
- package/src/input/handler-content-actions.ts +9 -0
- package/src/input/handler-feed-routes.ts +1 -0
- package/src/input/handler-modal-routes.ts +12 -1
- package/src/input/handler-picker-routes.ts +43 -5
- package/src/input/handler-shortcuts.ts +1 -1
- package/src/input/handler-ui-state.ts +8 -3
- package/src/input/model-picker-items.ts +17 -2
- package/src/input/model-picker.ts +19 -2
- package/src/input/search.ts +183 -89
- package/src/main.ts +31 -38
- package/src/{cli → providers}/provider-classification.ts +12 -0
- package/src/providers/reasoning-effort-surface.ts +399 -0
- package/src/renderer/compositor.ts +68 -18
- package/src/renderer/header-line.ts +146 -0
- package/src/renderer/markdown-inline.ts +143 -0
- package/src/renderer/markdown-table.ts +459 -0
- package/src/renderer/markdown.ts +6 -335
- package/src/renderer/model-workspace.ts +48 -5
- package/src/renderer/shell-surface.ts +36 -0
- package/src/renderer/ui-factory.ts +25 -78
- package/src/runtime/bootstrap-command-parts.ts +36 -4
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/durability-housekeeping.ts +183 -0
- package/src/runtime/durability-services.ts +68 -11
- package/src/runtime/legacy-daemon-reconcile.ts +16 -7
- package/src/runtime/provider-fallback.ts +16 -2
- package/src/runtime/recovery-decisions.ts +295 -0
- package/src/runtime/recovery-prompt.ts +66 -6
- package/src/runtime/services.ts +9 -4
- package/src/runtime/session-liveness-marker.ts +142 -5
- package/src/runtime/workstream-draft-store.ts +198 -25
- package/src/runtime/workstream-services.ts +17 -2
- package/src/shell/ui-openers.ts +1 -1
- package/src/version.ts +1 -1
- 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
|
-
//
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
}
|