@pellux/goodvibes-tui 1.20.0 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { LAYOUT } from './layout.ts';
3
3
  import { VERSION } from '../version.ts';
4
4
  import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText, interpolateColor, joinPrioritizedSegments, type PrioritizedSegment } from '../utils/terminal-width.ts';
5
5
  import type { GitHeaderInfo } from './git-status.ts';
6
+ import { renderHeaderLine } from './header-line.ts';
6
7
  import { renderConversationFragment, renderConversationStatusLine, type ConversationStatusSegment } from './conversation-surface.ts';
7
8
  import { GLYPHS } from './ui-primitives.ts';
8
9
  import { activeUiTones } from './theme.ts';
@@ -42,22 +43,6 @@ export interface ThinkingStallInfo {
42
43
  readonly reconnect?: { readonly attempt: number; readonly maxAttempts: number };
43
44
  }
44
45
 
45
- /** Build the git segment string and its display width. Single source of truth for header layout. */
46
- function buildGitSegment(gitInfo: GitHeaderInfo): { text: string; width: number } {
47
- const branch = ` git:${gitInfo.branch}`;
48
- if (gitInfo.dirty) {
49
- const text = `${branch} * `;
50
- return { text, width: getDisplayWidth(text) };
51
- }
52
- if (gitInfo.ahead > 0 || gitInfo.behind > 0) {
53
- const arrows = (gitInfo.ahead > 0 ? ` +${gitInfo.ahead}` : '') + (gitInfo.behind > 0 ? ` -${gitInfo.behind}` : '');
54
- const text = `${branch}${arrows} `;
55
- return { text, width: getDisplayWidth(text) };
56
- }
57
- const text = `${branch} `;
58
- return { text, width: getDisplayWidth(text) };
59
- }
60
-
61
46
  /** Format a number: up to 999, then 1.0k, 1.0M, 1.0B, 1.0T */
62
47
  function fmtNum(n: number): string {
63
48
  return abbreviateCount(n, { bSuffix: true });
@@ -75,68 +60,19 @@ function fmtCost(usd: number): string {
75
60
  * UIFactory - Generates standard UI fragments without needing Ink/React overhead.
76
61
  */
77
62
  export class UIFactory {
78
- // `version` defaults to the live build VERSION; tests pass a pinned fixture so
79
- // golden snapshots don't break on every release bump (version-decoupled goldens,
80
- // mirroring the splash fixture-version pattern).
81
- public static createHeader(width: number, model: string, provider: string, title?: string, gitInfo?: GitHeaderInfo, version: string = VERSION): Line[] {
82
- const lines: Line[] = [];
83
- // Header/footer/thinking paint on the transparent terminal background, so
84
- // they read chrome.* (light-terminal-aware) — NOT fg.*/state.*, which stay
85
- // tuned for the opaque dark modal/panel boxes. Read live per render so a
86
- // mode flip re-resolves without any module reload (see theme.ts).
87
- const t = activeUiTones();
88
- const CYAN = t.accent.brand;
89
- const GREY = t.chrome.faint;
90
- const TITLE_COLOR = t.chrome.label;
91
- const brand = ` GoodVibes `;
92
- const ver = `v${version} `;
93
- const stats = ` ${model} `;
94
- const prov = `(${provider}) `;
95
- const line = createEmptyLine(width);
96
- let curX = 0;
97
- for (const char of brand) { line[curX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
98
- for (const char of ver) { line[curX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
99
- // Optional conversation title — shown after brand/ver, truncated to fit
100
- if (title) {
101
- const titleStr = `│ ${title} `;
102
- // Reserve space for git info (if present) + model/provider on the right
103
- const gitReserved = gitInfo ? buildGitSegment(gitInfo).width : 0;
104
- const rightReserved = getDisplayWidth(stats + prov) + gitReserved;
105
- const maxTitleW = width - curX - rightReserved - 1;
106
- let displayTitle: string;
107
- if (getDisplayWidth(titleStr) <= maxTitleW) {
108
- displayTitle = titleStr;
109
- } else {
110
- let truncated = '';
111
- let w = 0;
112
- for (const ch of titleStr) {
113
- const cw = getDisplayWidth(ch);
114
- if (w + cw > maxTitleW - 3) { truncated += '...'; break; }
115
- truncated += ch;
116
- w += cw;
117
- }
118
- displayTitle = truncated;
119
- }
120
- 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 }; }
121
- }
122
- // Build git info segment
123
- let gitStr = '';
124
- let gitFg: string = t.chrome.faint;
125
- if (gitInfo) {
126
- gitStr = buildGitSegment(gitInfo).text;
127
- if (gitInfo.dirty || gitInfo.ahead > 0 || gitInfo.behind > 0) {
128
- gitFg = t.chrome.warn; // yellow when dirty or out-of-sync
129
- }
130
- }
131
- const rightSideText = stats + prov;
132
- const rightSideW = getDisplayWidth(rightSideText) + getDisplayWidth(gitStr);
133
- let rightX = width - rightSideW;
134
- 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 }; }
135
- for (const char of stats) { if (rightX < width) line[rightX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
136
- for (const char of prov) { if (rightX < width) line[rightX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
137
- lines.push(line);
138
- lines.push(this.stringToLine('━'.repeat(width), width, { fg: t.chrome.faint }));
139
- return lines;
63
+ /**
64
+ * The header row. Delegates to renderHeaderLine (header-line.ts) — extracted
65
+ * when the header gained the failover divergence marker and this file was
66
+ * already on the 800-line source gate.
67
+ *
68
+ * `model`/`provider` are the SERVING backend and `note` the divergence
69
+ * marker, both resolved by core/active-model-identity.ts so the header and
70
+ * the footer answer "who is serving?" from one place. `version` defaults to
71
+ * the live build VERSION; tests pass a pinned fixture so golden snapshots
72
+ * don't break on every release bump.
73
+ */
74
+ public static createHeader(width: number, model: string, provider: string, title?: string, gitInfo?: GitHeaderInfo, version: string = VERSION, note: string = ''): Line[] {
75
+ return renderHeaderLine(width, model, provider, title, gitInfo, version, note);
140
76
  }
141
77
 
142
78
  /**
@@ -221,6 +157,11 @@ export class UIFactory {
221
157
  // "sleep disabled" chip in the posture block (the danger-mode idiom: a
222
158
  // persistent, colored surface indicator, the safety mechanism itself).
223
159
  powerKeepAwake?: boolean,
160
+ // Divergence marker for the model segment (core/active-model-identity.ts):
161
+ // set only while the serving backend is not the user's configured
162
+ // selection, e.g. "failover from abacusai:route-llm". Empty/undefined
163
+ // renders the context-info line exactly as it did before the marker existed.
164
+ modelNote?: string,
224
165
  ): Line[] {
225
166
  const lines: Line[] = [];
226
167
  const promptLines = prompt.split('\n');
@@ -499,6 +440,12 @@ export class UIFactory {
499
440
  if (model) {
500
441
  ctxParts.push({ text: model + (provider ? ` (${provider})` : ''), priority: 0 });
501
442
  }
443
+ // Divergence marker — its own segment so a narrow line drops it WHOLE
444
+ // (joinPrioritizedSegments breaks priority ties toward the later
445
+ // segment) rather than truncating it into a half-word. Priority 0: it
446
+ // outranks every decorative segment, and what survives without it is
447
+ // still the SERVING backend, never a claim about the configured one.
448
+ if (modelNote) ctxParts.push({ text: modelNote, priority: 0 });
502
449
  // Cross-surface spine posture — plain words, no blame. Adopted mode only.
503
450
  if (sessionSpineStatus) ctxParts.push({ text: `spine:${sessionSpineStatus}`, priority: 1 });
504
451
  // Web surface reachability — plain, persistent; dropped first under width pressure.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * recovery-decisions.ts — a durable record of the recovery snapshots the user
3
+ * has already said "Remove" to.
4
+ *
5
+ * The defect this exists for: answering Remove deletes the snapshot file, and
6
+ * that was the whole memory of the decision. Anything that puts the file back
7
+ * — a session still running on an older build rewriting its snapshot every
8
+ * 60s, a restored backup, a synced directory — produced the same question
9
+ * again on the next launch, and again after the next removal, with no way for
10
+ * the user to make it stop. A decision the product forgets the instant it acts
11
+ * on it is not a decision the user gets to make.
12
+ *
13
+ * So Remove now writes a record that outlives the process, and a recorded
14
+ * snapshot found on disk again is deleted without asking. "Keep" is
15
+ * deliberately NOT recorded here: Keep means "leave it, ask me next launch",
16
+ * and recovery-prompt.ts's in-process set already holds it for the rest of the
17
+ * run.
18
+ *
19
+ * WHERE IT LIVES, and why not under `surface.stateDir`:
20
+ * `<homeDirectory>/.goodvibes/<surfaceRoot>/recovery-decisions.json` — home-
21
+ * anchored, matching session-liveness-marker.ts, and deliberately NOT the
22
+ * project-anchored `surface.stateDir`. The snapshots this defends against
23
+ * include the ones in the SDK's legacy shared recovery directory, which is
24
+ * itself home-anchored and shared by every project that ever used this
25
+ * surfaceRoot; the current build dual-reads it, so ONE such snapshot is
26
+ * offered at the launch of EVERY project. A project-scoped record could only
27
+ * ever silence one project at a time, which is the same nag wearing a
28
+ * different hat. Records are matched on session id alone; ids are unique, so
29
+ * a record made in one project cannot suppress a different snapshot in
30
+ * another. Each record still carries the workspace it was made in, so the file
31
+ * says plainly where the decision came from.
32
+ */
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
34
+ import { dirname, join } from 'node:path';
35
+ import type { SessionSurface } from '@/runtime/index.ts';
36
+
37
+ /** One "the user removed this snapshot" decision. */
38
+ export interface RecoveryRemovalRecord {
39
+ readonly sessionId: string;
40
+ /** The project the decision was made in. Provenance — matching is on sessionId alone. */
41
+ readonly workspace: string;
42
+ readonly removedAt: number;
43
+ }
44
+
45
+ /**
46
+ * Bounds on the ledger. It is append-mostly and never read by anything but
47
+ * the boot offer, so it stays small by construction; these are the guards
48
+ * against a pathological case, not a routine cleanup.
49
+ *
50
+ * Note what is deliberately NOT the pruning rule: "drop records whose snapshot
51
+ * file no longer exists". Every record in here describes a file that was just
52
+ * deleted, so that rule would empty the ledger immediately and reinstate the
53
+ * exact defect it was written to fix — the point is precisely to still
54
+ * remember once the file comes back. Age and count are the honest bounds.
55
+ */
56
+ const MAX_RECORDS = 200;
57
+ const RECORD_TTL_MS = 90 * 24 * 60 * 60 * 1000;
58
+
59
+ /** Absolute path to this surface's removal ledger. */
60
+ export function recoveryDecisionsPathFor(surface: SessionSurface): string {
61
+ return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, 'recovery-decisions.json');
62
+ }
63
+
64
+ function isRecord(value: unknown): value is RecoveryRemovalRecord {
65
+ if (typeof value !== 'object' || value === null) return false;
66
+ const candidate = value as Partial<RecoveryRemovalRecord>;
67
+ return typeof candidate.sessionId === 'string'
68
+ && candidate.sessionId.length > 0
69
+ && typeof candidate.workspace === 'string'
70
+ && typeof candidate.removedAt === 'number'
71
+ && Number.isFinite(candidate.removedAt);
72
+ }
73
+
74
+ /**
75
+ * Every removal decision still in force, oldest first. A missing, unreadable
76
+ * or malformed ledger reads as "no decisions recorded" rather than throwing:
77
+ * a corrupt file must not be able to take a boot down, and the worst outcome
78
+ * of losing it is that the user is asked once more.
79
+ */
80
+ export function readRecoveryRemovals(surface: SessionSurface, nowMs: number = Date.now()): RecoveryRemovalRecord[] {
81
+ try {
82
+ const path = recoveryDecisionsPathFor(surface);
83
+ if (!existsSync(path)) return [];
84
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
85
+ if (!Array.isArray(parsed)) return [];
86
+ return parsed.filter(isRecord).filter((record) => nowMs - record.removedAt < RECORD_TTL_MS);
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Has the user already chosen Remove for this snapshot's session, in this or
94
+ * any earlier run?
95
+ */
96
+ export function isRecoveryRemovalRecorded(surface: SessionSurface, sessionId: string, nowMs: number = Date.now()): boolean {
97
+ if (!sessionId.trim()) return false;
98
+ return readRecoveryRemovals(surface, nowMs).some((record) => record.sessionId === sessionId);
99
+ }
100
+
101
+ /**
102
+ * Record that the user chose Remove for `sessionId`. Idempotent — a repeat
103
+ * removal of a snapshot that came back refreshes the existing record's
104
+ * timestamp rather than adding a second one, so the ledger cannot grow by one
105
+ * entry per reappearance.
106
+ *
107
+ * Best-effort: a ledger that cannot be written must not break the removal the
108
+ * user actually asked for. The cost of a failed write is one more question on
109
+ * a future launch, which is exactly the behaviour that shipped before this
110
+ * file existed.
111
+ */
112
+ export function recordRecoveryRemoval(surface: SessionSurface, sessionId: string, nowMs: number = Date.now()): void {
113
+ if (!sessionId.trim()) return;
114
+ try {
115
+ const kept = readRecoveryRemovals(surface, nowMs).filter((record) => record.sessionId !== sessionId);
116
+ kept.push({ sessionId, workspace: surface.workingDirectory, removedAt: nowMs });
117
+ // Oldest decisions fall off first; the newest are the ones a reappearing
118
+ // snapshot is most likely to match.
119
+ const bounded = kept.slice(-MAX_RECORDS);
120
+ const path = recoveryDecisionsPathFor(surface);
121
+ mkdirSync(dirname(path), { recursive: true });
122
+ writeFileSync(path, JSON.stringify(bounded, null, 2) + '\n', { mode: 0o600 });
123
+ } catch {
124
+ // Best-effort by construction — see the doc comment above.
125
+ }
126
+ }
@@ -39,10 +39,18 @@
39
39
  * without reading keeps the snapshot.
40
40
  * - Keep (or a dismissal) stays quiet for the rest of the run. The snapshot
41
41
  * is offered again on the next launch, not again in this one.
42
+ * - Remove is permanent, not just for this file. The decision is written to
43
+ * the durable ledger in recovery-decisions.ts, so a snapshot that comes
44
+ * back byte-for-byte — because a session still running on an older build
45
+ * is rewriting it every minute — is deleted on sight instead of asked
46
+ * about again. Deleting the file used to be the entire memory of the
47
+ * answer, which is how the same question survived being answered three
48
+ * times.
42
49
  */
43
50
  import { statSync } from 'node:fs';
44
51
  import { checkRecoveryFile, checkRecoveryForSession, consumeRecovery, removeRecoveryPoint } from '@/runtime/index.ts';
45
52
  import type { RecoveryFileInfo, SessionSurface } from '@/runtime/index.ts';
53
+ import { isRecoveryRemovalRecorded, recordRecoveryRemoval } from './recovery-decisions.ts';
46
54
  import { checkSessionLiveness } from './session-liveness-marker.ts';
47
55
  import type { SelectionItem, SelectionResult } from '../input/selection-modal.ts';
48
56
 
@@ -213,11 +221,50 @@ function defaultSnapshotBytes(surface: SessionSurface, sessionId: string): numbe
213
221
  */
214
222
  const answeredThisRun = new Set<string>();
215
223
 
216
- /** Test seam: a fresh process has no answered offers. */
224
+ /**
225
+ * Test seam: a fresh process has no answered offers. Only clears the
226
+ * in-process Keep memory — removal decisions are durable on purpose and live
227
+ * in the surface's own directory, so a test gets a clean ledger by using a
228
+ * fresh temp surface, the same way a real user gets one by using a different
229
+ * machine.
230
+ */
217
231
  export function resetAnsweredRecoveryOffersForTest(): void {
218
232
  answeredThisRun.clear();
219
233
  }
220
234
 
235
+ /**
236
+ * How many times one offer pass will delete a recorded-removed snapshot and
237
+ * look again. A bound rather than a `while (true)`: the same session can have
238
+ * a snapshot in both the canonical and the legacy shared directory, so one
239
+ * removal can legitimately need a second pass, but nothing here should be able
240
+ * to spin if a delete ever fails to take.
241
+ */
242
+ const MAX_RECORDED_REMOVAL_PASSES = 8;
243
+
244
+ /**
245
+ * The snapshot to actually ask about, after honouring decisions already made.
246
+ *
247
+ * A snapshot whose session is in the removal ledger is deleted here and NOT
248
+ * offered: the user answered that question already, and re-asking because the
249
+ * file came back is the defect. Deleting rather than merely skipping matters —
250
+ * skipping would leave the file to mask an older, genuinely-orphaned snapshot
251
+ * underneath it forever.
252
+ */
253
+ function findOfferableSnapshot(deps: RecoveryPromptDeps): RecoveryFileInfo | null {
254
+ for (let pass = 0; pass < MAX_RECORDED_REMOVAL_PASSES; pass += 1) {
255
+ const info = deps.targetSessionId !== undefined
256
+ ? checkRecoveryForSession(deps.surface, deps.targetSessionId)
257
+ : checkRecoveryFile({ surface: deps.surface });
258
+ if (!info) return null;
259
+ if (!isRecoveryRemovalRecorded(deps.surface, info.sessionId)) return info;
260
+ // Silently, because the user already said remove. A receipt here would be
261
+ // a notification about a decision they made and do not need re-confirmed.
262
+ const { removed } = removeRecoveryPoint(deps.surface, info.sessionId);
263
+ if (!removed) return null;
264
+ }
265
+ return null;
266
+ }
267
+
221
268
  /**
222
269
  * Run the startup recovery offer. Resolves once the user has answered (or
223
270
  * once it is established that there is nothing to ask about). Never throws:
@@ -225,9 +272,7 @@ export function resetAnsweredRecoveryOffersForTest(): void {
225
272
  */
226
273
  export async function offerRecoverySnapshot(deps: RecoveryPromptDeps): Promise<RecoveryPromptOutcome> {
227
274
  try {
228
- const info = deps.targetSessionId !== undefined
229
- ? checkRecoveryForSession(deps.surface, deps.targetSessionId)
230
- : checkRecoveryFile({ surface: deps.surface });
275
+ const info = findOfferableSnapshot(deps);
231
276
  if (!info) return 'none';
232
277
  // Already answered about this snapshot earlier in this same run (the
233
278
  // targeted --continue offer and the general startup offer can both find
@@ -270,9 +315,14 @@ export async function offerRecoverySnapshot(deps: RecoveryPromptDeps): Promise<R
270
315
  const retire = await ask(open, RECOVERY_RETIRE_TITLE, buildRecoveryRetireItems(facts));
271
316
  if (retire === 'remove') {
272
317
  const { removed } = removeRecoveryPoint(deps.surface, info.sessionId);
318
+ // Recorded whether or not a file was there to delete: the answer is
319
+ // about this snapshot, and a snapshot that vanished between the question
320
+ // and the answer can still be rewritten a minute later by whatever wrote
321
+ // it in the first place.
322
+ recordRecoveryRemoval(deps.surface, info.sessionId);
273
323
  deps.receipt(removed
274
- ? `Recovery point removed (session ${info.sessionId}).`
275
- : `No recovery point was found to remove (session ${info.sessionId}).`);
324
+ ? `Recovery point removed (session ${info.sessionId}) — it will not be offered again, even if the file reappears.`
325
+ : `No recovery point was found to remove (session ${info.sessionId}) — if one reappears for that session it will be discarded, not offered.`);
276
326
  deps.render();
277
327
  return 'removed';
278
328
  }
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.20.0';
9
+ let _version = '1.20.1';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  // Only trust a version read from OUR OWN package.json. A Bun single-file