@pellux/goodvibes-tui 1.20.1 → 1.21.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/doctor.ts +7 -1
  6. package/src/cli/package-verification.ts +92 -7
  7. package/src/cli/status.ts +16 -0
  8. package/src/config/read-versioned.ts +115 -2
  9. package/src/core/conversation-event-navigation.ts +42 -0
  10. package/src/core/conversation-search-expansion.ts +67 -0
  11. package/src/core/conversation-splash-state.ts +58 -0
  12. package/src/core/conversation.ts +23 -23
  13. package/src/core/model-routing-chip.ts +38 -18
  14. package/src/core/rewind-turn-anchors.ts +155 -3
  15. package/src/core/stream-event-wiring.ts +141 -20
  16. package/src/core/transcript-journal.ts +145 -7
  17. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  18. package/src/daemon/handlers/inbox/index.ts +14 -1
  19. package/src/daemon/handlers/sqlite-store.ts +160 -6
  20. package/src/input/command-registry.ts +8 -0
  21. package/src/input/commands/local-runtime.ts +6 -1
  22. package/src/input/commands/schedule-runtime.ts +15 -2
  23. package/src/input/commands/shell-core.ts +87 -38
  24. package/src/input/composer-capture.ts +47 -0
  25. package/src/input/handler-command-route.ts +4 -0
  26. package/src/input/handler-content-actions.ts +9 -0
  27. package/src/input/handler-feed-routes.ts +1 -0
  28. package/src/input/handler-modal-routes.ts +12 -1
  29. package/src/input/handler-picker-routes.ts +43 -5
  30. package/src/input/handler-shortcuts.ts +1 -1
  31. package/src/input/handler-ui-state.ts +8 -3
  32. package/src/input/model-picker-items.ts +17 -2
  33. package/src/input/model-picker.ts +19 -2
  34. package/src/input/search.ts +183 -89
  35. package/src/input/settings-modal-types.ts +2 -1
  36. package/src/main.ts +12 -20
  37. package/src/providers/reasoning-effort-surface.ts +399 -0
  38. package/src/renderer/compositor.ts +68 -18
  39. package/src/renderer/markdown-inline.ts +143 -0
  40. package/src/renderer/markdown-table.ts +459 -0
  41. package/src/renderer/markdown.ts +6 -335
  42. package/src/renderer/model-workspace.ts +48 -5
  43. package/src/renderer/settings-modal-helpers.ts +53 -1
  44. package/src/renderer/settings-modal.ts +1 -50
  45. package/src/runtime/bootstrap-command-parts.ts +36 -4
  46. package/src/runtime/bootstrap-core.ts +1 -1
  47. package/src/runtime/durability-housekeeping.ts +183 -0
  48. package/src/runtime/durability-services.ts +68 -11
  49. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  50. package/src/runtime/provider-fallback.ts +16 -2
  51. package/src/runtime/recovery-decisions.ts +180 -11
  52. package/src/runtime/recovery-prompt.ts +11 -1
  53. package/src/runtime/services.ts +9 -4
  54. package/src/runtime/session-liveness-marker.ts +142 -5
  55. package/src/runtime/workstream-draft-store.ts +198 -25
  56. package/src/runtime/workstream-services.ts +17 -2
  57. package/src/shell/ui-openers.ts +1 -1
  58. package/src/version.ts +1 -1
  59. 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,143 @@
1
+ /**
2
+ * markdown-inline.ts — inline markdown tokenizer (bold, italic, inline code,
3
+ * links, bare URLs, absolute file paths).
4
+ *
5
+ * Extracted from markdown.ts so both the block renderer (markdown.ts) and the
6
+ * table renderer (markdown-table.ts) can tokenize cell/line text without the
7
+ * two modules importing each other. markdown.ts re-exports
8
+ * renderInlineMarkdown so existing importers are unaffected.
9
+ */
10
+
11
+ import type { Cell } from '../types/grid.ts';
12
+
13
+ /** Module-level set of inline markdown special characters (hoisted out of hot loop). */
14
+ const INLINE_SPECIAL_CHARS = new Set(['[', '`', '*', '_', '~']);
15
+
16
+ /**
17
+ * Inline markdown token types.
18
+ */
19
+ export type InlineToken =
20
+ | { type: 'text'; text: string; style: Partial<Cell> }
21
+ | { type: 'code'; text: string }
22
+ | { type: 'link'; text: string; url: string };
23
+
24
+ /**
25
+ * renderInlineMarkdown - Parse inline markdown (bold, italic, inline code, links)
26
+ * and return a flat array of tokens with style info.
27
+ */
28
+ export function renderInlineMarkdown(text: string): InlineToken[] {
29
+ const tokens: InlineToken[] = [];
30
+ let i = 0;
31
+
32
+ while (i < text.length) {
33
+ // Link: [text](url)
34
+ if (text[i] === '[') {
35
+ const closeB = text.indexOf(']', i);
36
+ if (closeB !== -1 && text[closeB + 1] === '(') {
37
+ const closeP = text.indexOf(')', closeB + 2);
38
+ if (closeP !== -1) {
39
+ const linkText = text.slice(i + 1, closeB);
40
+ const url = text.slice(closeB + 2, closeP);
41
+ tokens.push({ type: 'link', text: linkText, url });
42
+ i = closeP + 1;
43
+ continue;
44
+ }
45
+ }
46
+ }
47
+
48
+ // Inline code: `code`
49
+ if (text[i] === '`') {
50
+ const end = text.indexOf('`', i + 1);
51
+ if (end !== -1) {
52
+ tokens.push({ type: 'code', text: text.slice(i + 1, end) });
53
+ i = end + 1;
54
+ continue;
55
+ }
56
+ }
57
+
58
+ // Bold+italic: ***text***
59
+ if (text.slice(i, i + 3) === '***') {
60
+ const end = text.indexOf('***', i + 3);
61
+ if (end !== -1) {
62
+ tokens.push({ type: 'text', text: text.slice(i + 3, end), style: { bold: true, italic: true } });
63
+ i = end + 3;
64
+ continue;
65
+ }
66
+ // No closing *** found — emit the leading * as plain text so the ** bold
67
+ // check can handle the remaining ** on the next iteration.
68
+ tokens.push({ type: 'text', text: '*', style: {} });
69
+ i += 1;
70
+ continue;
71
+ }
72
+
73
+ // Bold: **text**
74
+ if (text.slice(i, i + 2) === '**') {
75
+ const end = text.indexOf('**', i + 2);
76
+ if (end !== -1) {
77
+ tokens.push({ type: 'text', text: text.slice(i + 2, end), style: { bold: true } });
78
+ i = end + 2;
79
+ continue;
80
+ }
81
+ }
82
+
83
+ // Italic: *text* or _text_
84
+ // Guard: text[i - 1] !== '*' prevents the second * of ** from starting italic.
85
+ // Guard: text[i + 1] !== text[i] prevents the first * of ** (or _ of __) from
86
+ // starting italic when bold/underscore-bold detection failed (e.g. unclosed **).
87
+ if (
88
+ (text[i] === '*' || text[i] === '_') &&
89
+ text[i - 1] !== text[i] &&
90
+ text[i + 1] !== text[i]
91
+ ) {
92
+ const closer = text[i];
93
+ const end = text.indexOf(closer, i + 1);
94
+ if (end !== -1 && end > i + 1) {
95
+ tokens.push({ type: 'text', text: text.slice(i + 1, end), style: { italic: true } });
96
+ i = end + 1;
97
+ continue;
98
+ }
99
+ }
100
+
101
+ // Strikethrough: ~~text~~
102
+ if (text.slice(i, i + 2) === '~~') {
103
+ const end = text.indexOf('~~', i + 2);
104
+ if (end !== -1) {
105
+ tokens.push({ type: 'text', text: text.slice(i + 2, end), style: { strikethrough: true, fg: '244' } });
106
+ i = end + 2;
107
+ continue;
108
+ }
109
+ }
110
+
111
+ // Plain text — accumulate until next special char, detect bare URLs and file paths
112
+ let end = i + 1;
113
+ while (end < text.length && !INLINE_SPECIAL_CHARS.has(text[end])) {
114
+ const code = text.charCodeAt(end);
115
+ end += (code >= 0xD800 && code <= 0xDBFF) ? 2 : 1;
116
+ }
117
+ const plainText = text.slice(i, end);
118
+
119
+ // Detect http/https URLs in plain text
120
+ const urlMatch = plainText.match(/^(https?:\/\/[^\s,)>"]+)/);
121
+ if (urlMatch) {
122
+ const url = urlMatch[1];
123
+ tokens.push({ type: 'link', text: url, url });
124
+ i += url.length;
125
+ continue;
126
+ }
127
+
128
+ // Detect absolute file paths in plain text (require at least one directory separator)
129
+ const fileMatch = plainText.match(/^(\/[^\s,)>"]+\/[^\s,)>"]+(?:\.[a-zA-Z0-9]+)?)/);
130
+ if (fileMatch) {
131
+ const filePath = fileMatch[1];
132
+ const fileUrl = `file://${filePath}`;
133
+ tokens.push({ type: 'link', text: filePath, url: fileUrl });
134
+ i += filePath.length;
135
+ continue;
136
+ }
137
+
138
+ tokens.push({ type: 'text', text: plainText, style: {} });
139
+ i = end;
140
+ }
141
+
142
+ return tokens;
143
+ }