@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,459 @@
1
+ /**
2
+ * markdown-table.ts — box-drawn markdown tables whose cells WRAP.
3
+ *
4
+ * The previous implementation (inside markdown.ts) allocated each column a
5
+ * proportional slice of the terminal width and then ellipsized any cell that
6
+ * did not fit: a comparison table rendered "Files, code, and…" in its label
7
+ * column and "citation coverage, checkpoin…" in its content columns, and the
8
+ * cut text was unrecoverable — it was never written to the buffer at all, so
9
+ * no scroll, copy, or expand could bring it back.
10
+ *
11
+ * This renderer never ellipsizes. A cell that exceeds its column width wraps
12
+ * onto as many physical lines as it needs, the row grows to the tallest cell,
13
+ * and shorter cells are blank-padded so the borders stay aligned. A single
14
+ * word longer than its column is force-broken across lines rather than cut.
15
+ *
16
+ * Column allocation gives every column a workable minimum before any column
17
+ * gets its full natural width, so a narrow label column is never starved down
18
+ * to one word plus an ellipsis while a wide prose column takes the rest:
19
+ *
20
+ * 1. Each column asks for `desiredMin` — enough to hold its longest single
21
+ * word (capped at WORD_MIN_CAP so one long URL cannot starve the table),
22
+ * but never more than the column's natural width and never less than
23
+ * MIN_COL when the budget allows it.
24
+ * 2. If those minimums fit, the leftover budget is shared out in proportion
25
+ * to each column's remaining demand (natural width minus its minimum),
26
+ * then any rounding remainder is handed round-robin to columns that are
27
+ * still short of natural.
28
+ * 3. If the minimums do NOT fit (a genuinely narrow terminal), they are
29
+ * scaled down toward HARD_MIN_COL and the cells simply wrap harder.
30
+ *
31
+ * When even HARD_MIN_COL per column plus the borders exceeds the terminal
32
+ * width, no allocation helps: the box drawing itself does not fit. That case
33
+ * used to write the overflowing columns past the end of the line buffer, where
34
+ * they were discarded — an eight-column table at width 40 lost its last
35
+ * columns, headers included, with nothing on screen to say so. The renderer now
36
+ * abandons the box at that point and stacks each row as `Header: value` records
37
+ * wrapped to the available width, so every column header and every cell is
38
+ * still emitted in full.
39
+ */
40
+
41
+ import { type Line, type Cell, createStyledCell } from '../types/grid.ts';
42
+ import { UIFactory } from './ui-factory.ts';
43
+ import { getDisplayWidth } from '../utils/terminal-width.ts';
44
+ import { activeTheme } from './theme.ts';
45
+ import { renderInlineMarkdown } from './markdown-inline.ts';
46
+
47
+ /** Width a column is given when the budget allows, even if its content is narrower. */
48
+ const MIN_COL = 6;
49
+ /** Absolute floor per column on a terminal too narrow for the real minimums. */
50
+ const HARD_MIN_COL = 3;
51
+ /** Most a single long word may claim as its column's minimum before the word
52
+ * is simply force-broken instead. Keeps one long URL from starving the rest. */
53
+ const WORD_MIN_CAP = 24;
54
+
55
+ function splitTableCells(row: string): string[] {
56
+ const cells = row.trim().split('|').map((c) => c.trim());
57
+ if (cells.length > 0 && cells[0] === '') cells.shift();
58
+ if (cells.length > 0 && cells[cells.length - 1] === '') cells.pop();
59
+ return cells;
60
+ }
61
+
62
+ export function isLikelyTableSeparatorRow(row: string): boolean {
63
+ if (!row.includes('|')) return false;
64
+ const cells = splitTableCells(row);
65
+ if (cells.length === 0) return false;
66
+ return cells.every((cell) => /:?-{3,}:?/.test(cell));
67
+ }
68
+
69
+ export function isLikelyTableHeaderRow(row: string): boolean {
70
+ return splitTableCells(row).length >= 2;
71
+ }
72
+
73
+ /**
74
+ * Strip markdown formatting from text for width measurement.
75
+ * Removes **, *, `, ~~ markers but keeps the inner text.
76
+ */
77
+ function stripMarkdown(text: string): string {
78
+ return text
79
+ .replace(/\*\*(.+?)\*\*/g, '$1') // bold
80
+ .replace(/\*(.+?)\*/g, '$1') // italic
81
+ .replace(/~~(.+?)~~/g, '$1') // strikethrough
82
+ .replace(/`(.+?)`/g, '$1'); // inline code
83
+ }
84
+
85
+ /** One display character with the style it inherited from its inline token. */
86
+ type StyledChar = { char: string; style: Partial<Cell> };
87
+
88
+ /** Display width of a character, floored at 1 so a zero-width oddity can never
89
+ * make a wrap loop spin without consuming input. */
90
+ function charWidth(ch: string): number {
91
+ return getDisplayWidth(ch) || 1;
92
+ }
93
+
94
+ /** Widest single whitespace-delimited word in a cell, after markdown stripping. */
95
+ function longestWordWidth(raw: string): number {
96
+ let widest = 0;
97
+ for (const word of stripMarkdown(raw).split(/\s+/)) {
98
+ if (word.length === 0) continue;
99
+ widest = Math.max(widest, getDisplayWidth(word));
100
+ }
101
+ return widest;
102
+ }
103
+
104
+ /**
105
+ * Flatten a cell's inline-markdown tokens into per-character styles, applying
106
+ * the same header/body styling the single-line renderer used.
107
+ */
108
+ function flattenCellChars(raw: string, isHdr: boolean): StyledChar[] {
109
+ const T = activeTheme();
110
+ const out: StyledChar[] = [];
111
+ for (const token of renderInlineMarkdown(raw)) {
112
+ let style: Partial<Cell>;
113
+ if (token.type === 'code') {
114
+ style = { fg: T.inlineCodeFg, bold: true };
115
+ } else if (token.type === 'link') {
116
+ style = { fg: T.link, underline: true };
117
+ } else {
118
+ style = { ...token.style };
119
+ }
120
+ if (isHdr) {
121
+ style.fg = style.fg || T.heading1;
122
+ style.bold = true;
123
+ } else {
124
+ style.fg = style.fg || '252';
125
+ }
126
+ for (const ch of token.text) out.push({ char: ch, style });
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /**
132
+ * Word-aware wrap of a styled character run into lines no wider than `maxW`
133
+ * display columns. A word wider than `maxW` is broken across lines character
134
+ * by character — the text is always emitted in full, never cut.
135
+ */
136
+ function wrapStyledChars(chars: readonly StyledChar[], maxW: number): StyledChar[][] {
137
+ if (maxW <= 0) return [[]];
138
+ const lines: StyledChar[][] = [];
139
+ let cur: StyledChar[] = [];
140
+ let curW = 0;
141
+ let word: StyledChar[] = [];
142
+ let wordW = 0;
143
+
144
+ const pushLine = (): void => {
145
+ lines.push(cur);
146
+ cur = [];
147
+ curW = 0;
148
+ };
149
+
150
+ const flushWord = (): void => {
151
+ if (word.length === 0) return;
152
+ if (wordW > maxW) {
153
+ // Longer than the whole column: force-break it rather than lose any of it.
154
+ for (const sc of word) {
155
+ const cw = charWidth(sc.char);
156
+ if (curW + cw > maxW && curW > 0) pushLine();
157
+ cur.push(sc);
158
+ curW += cw;
159
+ }
160
+ } else {
161
+ if (curW + wordW > maxW && curW > 0) pushLine();
162
+ cur.push(...word);
163
+ curW += wordW;
164
+ }
165
+ word = [];
166
+ wordW = 0;
167
+ };
168
+
169
+ for (const sc of chars) {
170
+ if (sc.char === ' ') {
171
+ flushWord();
172
+ // A space never starts a line; at a line boundary it becomes the break.
173
+ if (curW > 0 && curW + 1 <= maxW) {
174
+ cur.push(sc);
175
+ curW += 1;
176
+ } else if (curW > 0) {
177
+ pushLine();
178
+ }
179
+ continue;
180
+ }
181
+ word.push(sc);
182
+ wordW += charWidth(sc.char);
183
+ }
184
+ flushWord();
185
+ if (cur.length > 0 || lines.length === 0) lines.push(cur);
186
+ return lines;
187
+ }
188
+
189
+ /**
190
+ * Turn one wrapped line of styled characters into exactly `maxW` display
191
+ * columns of Cells, inserting wide-glyph placeholders and blank-padding the
192
+ * remainder so every column of every row is the same width.
193
+ */
194
+ function padLineToCells(lineChars: readonly StyledChar[], maxW: number, isHdr: boolean): Cell[] {
195
+ const T = activeTheme();
196
+ const cells: Cell[] = [];
197
+ let w = 0;
198
+ for (const sc of lineChars) {
199
+ const cw = charWidth(sc.char);
200
+ if (w + cw > maxW) break; // wrapping already guaranteed the fit; defensive only
201
+ cells.push(createStyledCell(sc.char, sc.style));
202
+ if (cw === 2) cells.push(createStyledCell('', sc.style)); // wide char placeholder
203
+ w += cw;
204
+ }
205
+ while (w < maxW) {
206
+ cells.push(createStyledCell(' ', isHdr ? { fg: T.heading1 } : { fg: '252' }));
207
+ w++;
208
+ }
209
+ return cells;
210
+ }
211
+
212
+ /**
213
+ * Allocate a display width to each column. See the module header for the
214
+ * three-stage policy; the contract callers rely on is only that every returned
215
+ * width is >= 1 and that no cell is ever asked to truncate.
216
+ */
217
+ function allocateColumnWidths(
218
+ naturalWidths: readonly number[],
219
+ minWordWidths: readonly number[],
220
+ contentBudget: number,
221
+ ): number[] {
222
+ const colCount = naturalWidths.length;
223
+ const totalNatural = naturalWidths.reduce((a, b) => a + b, 0);
224
+ if (totalNatural <= contentBudget) return [...naturalWidths];
225
+
226
+ const widths = naturalWidths.map((natural, c) =>
227
+ Math.min(natural, Math.max(MIN_COL, Math.min(minWordWidths[c], WORD_MIN_CAP))),
228
+ );
229
+ let used = widths.reduce((a, b) => a + b, 0);
230
+
231
+ if (used > contentBudget) {
232
+ // Narrow terminal: scale the minimums down, then shave the widest column
233
+ // repeatedly until the row fits or every column is at the hard floor.
234
+ const scale = contentBudget / used;
235
+ for (let c = 0; c < colCount; c++) {
236
+ widths[c] = Math.max(HARD_MIN_COL, Math.floor(widths[c] * scale));
237
+ }
238
+ used = widths.reduce((a, b) => a + b, 0);
239
+ while (used > contentBudget) {
240
+ let widest = -1;
241
+ let widestW = HARD_MIN_COL;
242
+ for (let c = 0; c < colCount; c++) {
243
+ if (widths[c] > widestW) {
244
+ widestW = widths[c];
245
+ widest = c;
246
+ }
247
+ }
248
+ if (widest === -1) break; // everything is at the floor; cannot shrink further
249
+ widths[widest]--;
250
+ used--;
251
+ }
252
+ return widths;
253
+ }
254
+
255
+ // Share the leftover budget in proportion to each column's unmet demand.
256
+ const demand = naturalWidths.map((natural, c) => Math.max(0, natural - widths[c]));
257
+ const totalDemand = demand.reduce((a, b) => a + b, 0);
258
+ if (totalDemand === 0) return widths;
259
+
260
+ const leftover = contentBudget - used;
261
+ for (let c = 0; c < colCount; c++) {
262
+ const share = Math.min(demand[c], Math.floor((leftover * demand[c]) / totalDemand));
263
+ widths[c] += share;
264
+ used += share;
265
+ }
266
+ // Rounding remainder: round-robin to whoever is still short of natural.
267
+ let cursor = 0;
268
+ let guard = contentBudget * 2 + colCount;
269
+ while (used < contentBudget && guard-- > 0) {
270
+ if (widths[cursor] < naturalWidths[cursor]) {
271
+ widths[cursor]++;
272
+ used++;
273
+ }
274
+ cursor = (cursor + 1) % colCount;
275
+ }
276
+ return widths;
277
+ }
278
+
279
+ /** Lay one wrapped run of styled characters into a full-width Line at `indent`. */
280
+ function emitStyledLines(
281
+ chars: readonly StyledChar[],
282
+ width: number,
283
+ indent: number,
284
+ maxW: number,
285
+ ): Line[] {
286
+ const out: Line[] = [];
287
+ for (const lineChars of wrapStyledChars(chars, maxW)) {
288
+ const line = new Array(width).fill(null).map(() => createStyledCell(' ')) as Cell[];
289
+ let x = indent;
290
+ for (const sc of lineChars) {
291
+ if (x < width) line[x] = createStyledCell(sc.char, sc.style);
292
+ x++;
293
+ if (charWidth(sc.char) === 2) {
294
+ if (x < width) line[x] = createStyledCell('', sc.style);
295
+ x++;
296
+ }
297
+ }
298
+ out.push(line);
299
+ }
300
+ return out;
301
+ }
302
+
303
+ /**
304
+ * The no-box fallback for a terminal too narrow to draw the table at all.
305
+ *
306
+ * Each data row becomes a block of `Header: value` lines wrapped to the
307
+ * available width, separated by a rule. Header text is styled as a header and
308
+ * force-broken rather than cut if it is somehow wider than the terminal, so the
309
+ * guarantee the box layout makes — every column present, nothing truncated —
310
+ * holds at any width.
311
+ */
312
+ function renderStackedTable(
313
+ parsedRows: readonly string[][],
314
+ colCount: number,
315
+ hasSeparator: boolean,
316
+ width: number,
317
+ indent: number,
318
+ ): Line[] {
319
+ const lines: Line[] = [];
320
+ const availW = Math.max(1, width - indent);
321
+ const headerRow = hasSeparator ? parsedRows[0] : undefined;
322
+ const bodyRows = hasSeparator ? parsedRows.slice(1) : [...parsedRows];
323
+ // A header-only table still has to show its headers, so it renders as one
324
+ // record with empty values rather than as nothing at all.
325
+ const records: readonly string[][] = bodyRows.length > 0 ? bodyRows : [[]];
326
+ const separatorStyle: Partial<Cell> = { fg: '240' };
327
+ const rule = UIFactory.stringToLine(' '.repeat(indent) + '─'.repeat(availW), width, { fg: '240' });
328
+
329
+ for (let r = 0; r < records.length; r++) {
330
+ if (r > 0) lines.push(rule);
331
+ const row = records[r];
332
+ for (let c = 0; c < colCount; c++) {
333
+ const label = headerRow ? (headerRow[c] ?? '') : `Column ${c + 1}`;
334
+ const value = c < row.length ? row[c] : '';
335
+ const chars: StyledChar[] = [
336
+ ...flattenCellChars(label, true),
337
+ { char: ':', style: separatorStyle },
338
+ { char: ' ', style: separatorStyle },
339
+ ...flattenCellChars(value, false),
340
+ ];
341
+ lines.push(...emitStyledLines(chars, width, indent, availW));
342
+ }
343
+ }
344
+ return lines;
345
+ }
346
+
347
+ /**
348
+ * Render a markdown table with box-drawing borders. Cells wrap to as many
349
+ * physical lines as their content needs; nothing is ever truncated.
350
+ */
351
+ export function renderTable(rows: string[], width: number, indent: number): Line[] {
352
+ const lines: Line[] = [];
353
+
354
+ // Parse rows into cells, skip separator
355
+ const parsedRows: string[][] = [];
356
+ let hasSeparator = false;
357
+ for (const row of rows) {
358
+ const trimmed = row.trim();
359
+ if (isLikelyTableSeparatorRow(trimmed)) {
360
+ hasSeparator = true;
361
+ continue;
362
+ }
363
+ const cells = splitTableCells(trimmed);
364
+ if (cells.length > 0) parsedRows.push(cells);
365
+ }
366
+
367
+ if (parsedRows.length === 0) return lines;
368
+
369
+ const colCount = Math.max(...parsedRows.map((r) => r.length));
370
+ const availW = width - indent;
371
+
372
+ // Measure using stripped text (no markdown markers), both the natural width
373
+ // and the widest single word — the latter is what a column needs before it
374
+ // has to start breaking words apart.
375
+ const naturalWidths: number[] = new Array(colCount).fill(0);
376
+ const minWordWidths: number[] = new Array(colCount).fill(0);
377
+ for (const row of parsedRows) {
378
+ for (let c = 0; c < row.length; c++) {
379
+ naturalWidths[c] = Math.max(naturalWidths[c], getDisplayWidth(stripMarkdown(row[c])));
380
+ minWordWidths[c] = Math.max(minWordWidths[c], longestWordWidth(row[c]));
381
+ }
382
+ }
383
+
384
+ // Budget: availW minus borders (colCount+1) minus padding (2 per col)
385
+ const overhead = (colCount + 1) + (colCount * 2);
386
+ const contentBudget = Math.max(colCount * HARD_MIN_COL, availW - overhead);
387
+ const colWidths = allocateColumnWidths(naturalWidths, minWordWidths, contentBudget);
388
+
389
+ // Left border + per column (leading pad, content, trailing pad, border).
390
+ // When that does not fit, the columns past the edge would be written beyond
391
+ // the end of the line buffer and silently dropped, so the box is abandoned
392
+ // for the stacked layout instead.
393
+ const boxWidth = indent + 1 + colWidths.reduce((sum, w) => sum + w + 3, 0);
394
+ if (boxWidth > width) {
395
+ return renderStackedTable(parsedRows, colCount, hasSeparator, width, indent);
396
+ }
397
+
398
+ const bc = '240'; // border color
399
+
400
+ const makeBorder = (left: string, mid: string, right: string, horiz: string): Line => {
401
+ let s = ' '.repeat(indent) + left;
402
+ for (let c = 0; c < colCount; c++) {
403
+ s += horiz.repeat(colWidths[c] + 2) + (c < colCount - 1 ? mid : right);
404
+ }
405
+ return UIFactory.stringToLine(s, width, { fg: bc });
406
+ };
407
+
408
+ lines.push(makeBorder('┌', '┬', '┐', '─'));
409
+
410
+ for (let r = 0; r < parsedRows.length; r++) {
411
+ const row = parsedRows[r];
412
+ const isHeader = hasSeparator && r === 0;
413
+
414
+ // Wrap every cell first; the row is as tall as its tallest cell.
415
+ const wrappedCells: StyledChar[][][] = [];
416
+ for (let c = 0; c < colCount; c++) {
417
+ const raw = c < row.length ? row[c] : '';
418
+ wrappedCells.push(wrapStyledChars(flattenCellChars(raw, isHeader), colWidths[c]));
419
+ }
420
+ const rowHeight = Math.max(1, ...wrappedCells.map((cell) => cell.length));
421
+
422
+ for (let physical = 0; physical < rowHeight; physical++) {
423
+ const line = new Array(width).fill(null).map(() => createStyledCell(' ')) as Cell[];
424
+ let x = indent;
425
+
426
+ if (x < width) line[x] = createStyledCell('│', { fg: bc });
427
+ x++;
428
+
429
+ for (let c = 0; c < colCount; c++) {
430
+ if (x < width) line[x] = createStyledCell(' ');
431
+ x++;
432
+
433
+ // Past the end of this cell's wrapped lines => blank padding, so the
434
+ // borders of a tall row stay aligned.
435
+ const cellLine = wrappedCells[c][physical] ?? [];
436
+ for (const cell of padLineToCells(cellLine, colWidths[c], isHeader)) {
437
+ if (x < width) line[x] = cell;
438
+ x++;
439
+ }
440
+
441
+ if (x < width) line[x] = createStyledCell(' ');
442
+ x++;
443
+
444
+ if (x < width) line[x] = createStyledCell('│', { fg: bc });
445
+ x++;
446
+ }
447
+
448
+ lines.push(line);
449
+ }
450
+
451
+ if (isHeader) {
452
+ lines.push(makeBorder('├', '┼', '┤', '─'));
453
+ }
454
+ }
455
+
456
+ lines.push(makeBorder('└', '┴', '┘', '─'));
457
+
458
+ return lines;
459
+ }