@pellux/goodvibes-tui 0.18.19 → 0.18.23

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 (42) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-rendering.ts +20 -6
  5. package/src/input/commands/session.ts +0 -1
  6. package/src/input/feed-context-factory.ts +236 -0
  7. package/src/input/handler-feed-routes.ts +10 -0
  8. package/src/input/handler-feed.ts +44 -6
  9. package/src/input/handler-shortcuts.ts +138 -125
  10. package/src/input/handler.ts +121 -119
  11. package/src/input/keybindings.ts +30 -0
  12. package/src/panels/approval-panel.ts +54 -74
  13. package/src/panels/automation-control-panel.ts +119 -161
  14. package/src/panels/base-panel.ts +71 -0
  15. package/src/panels/communication-panel.ts +68 -107
  16. package/src/panels/confirm-state.ts +61 -0
  17. package/src/panels/control-plane-panel.ts +116 -172
  18. package/src/panels/git-panel.ts +9 -0
  19. package/src/panels/hooks-panel.ts +101 -138
  20. package/src/panels/incident-review-panel.ts +55 -107
  21. package/src/panels/knowledge-panel.ts +63 -14
  22. package/src/panels/local-auth-panel.ts +76 -93
  23. package/src/panels/marketplace-panel.ts +19 -12
  24. package/src/panels/mcp-panel.ts +108 -155
  25. package/src/panels/ops-control-panel.ts +50 -85
  26. package/src/panels/panel-manager.ts +22 -2
  27. package/src/panels/plugins-panel.ts +36 -60
  28. package/src/panels/routes-panel.ts +89 -141
  29. package/src/panels/scrollable-list-panel.ts +71 -16
  30. package/src/panels/security-panel.ts +101 -137
  31. package/src/panels/services-panel.ts +58 -102
  32. package/src/panels/settings-sync-panel.ts +76 -122
  33. package/src/panels/skills-panel.ts +44 -0
  34. package/src/panels/subscription-panel.ts +69 -80
  35. package/src/panels/tasks-panel.ts +129 -179
  36. package/src/panels/watchers-panel.ts +88 -137
  37. package/src/renderer/buffer.ts +11 -0
  38. package/src/renderer/diff.ts +8 -0
  39. package/src/renderer/help-overlay.ts +37 -28
  40. package/src/renderer/markdown.ts +3 -145
  41. package/src/renderer/status-token.ts +71 -0
  42. package/src/version.ts +1 -1
@@ -2,17 +2,23 @@ import { type Line, type Cell, createEmptyLine, createEmptyCell } from '../types
2
2
 
3
3
  /**
4
4
  * TerminalBuffer - Represents a 2D grid of styled cells.
5
+ * Tracks a per-row dirty bitmap so the diff engine can skip rows that were
6
+ * never written in the current frame.
5
7
  */
6
8
  export class TerminalBuffer {
7
9
  public cells: Line[];
10
+ /** dirtyRows[y] is true if row y was written since the last reset(). */
11
+ public dirtyRows: boolean[];
8
12
 
9
13
  constructor(public width: number, public height: number) {
10
14
  this.cells = Array.from({ length: height }, () => createEmptyLine(width));
15
+ this.dirtyRows = new Array(height).fill(false);
11
16
  }
12
17
 
13
18
  public setCell(x: number, y: number, cell: Partial<Cell>): void {
14
19
  if (y >= 0 && y < this.height && x >= 0 && x < this.width) {
15
20
  this.cells[y][x] = { ...this.cells[y][x], ...cell };
21
+ this.dirtyRows[y] = true;
16
22
  }
17
23
  }
18
24
 
@@ -23,30 +29,35 @@ export class TerminalBuffer {
23
29
  public blitLine(row: number, line: Line): void {
24
30
  if (row >= 0 && row < this.height) {
25
31
  this.cells[row] = [...line];
32
+ this.dirtyRows[row] = true;
26
33
  }
27
34
  }
28
35
 
29
36
  public clone(): TerminalBuffer {
30
37
  const newBuf = new TerminalBuffer(this.width, this.height);
31
38
  newBuf.cells = this.cells.map(line => line.map(cell => ({ ...cell })));
39
+ newBuf.dirtyRows = [...this.dirtyRows];
32
40
  return newBuf;
33
41
  }
34
42
 
35
43
  /**
36
44
  * Reset all cells in-place to empty, reusing this buffer instance.
37
45
  * If dimensions changed, reallocates cells array.
46
+ * Always clears the dirty bitmap.
38
47
  */
39
48
  public reset(width: number, height: number): void {
40
49
  if (width !== this.width || height !== this.height) {
41
50
  this.width = width;
42
51
  this.height = height;
43
52
  this.cells = Array.from({ length: height }, () => createEmptyLine(width));
53
+ this.dirtyRows = new Array(height).fill(false);
44
54
  } else {
45
55
  for (let y = 0; y < this.height; y++) {
46
56
  const row = this.cells[y]!;
47
57
  for (let x = 0; x < this.width; x++) {
48
58
  row[x] = createEmptyCell();
49
59
  }
60
+ this.dirtyRows[y] = false;
50
61
  }
51
62
  }
52
63
  }
@@ -29,6 +29,14 @@ export class DiffEngine {
29
29
  let output = '';
30
30
 
31
31
  for (let y = 0; y < newBuffer.height; y++) {
32
+ // Skip rows that were not written in either the old or new buffer.
33
+ // If neither side touched the row, both must match the prior frame:
34
+ // old row was never written this frame (clean) and new row is also
35
+ // clean, so the on-screen content is still correct. No diff needed.
36
+ const newDirty = newBuffer.dirtyRows[y] ?? false;
37
+ const oldDirty = oldBuffer ? (oldBuffer.dirtyRows[y] ?? false) : true;
38
+ if (!newDirty && !oldDirty) continue;
39
+
32
40
  for (let x = 0; x < newBuffer.width; x++) {
33
41
  const oldCell = oldBuffer?.getCell(x, y);
34
42
  const newCell = newBuffer.cells[y]?.[x];
@@ -64,36 +64,45 @@ export function renderHelpOverlay(
64
64
  '',
65
65
  ];
66
66
 
67
- const commandRows: string[] = [
68
- ' Quick Start',
69
- ' ' + '\u2500'.repeat(40),
70
- ' /setup onboarding Guided first-run review and environment posture',
71
- ' /cockpit Unified runtime control room',
72
- ' /settings Settings and config browser',
73
- '',
74
- ' Build And Operate',
75
- ' ' + '\u2500'.repeat(40),
76
- ' /provider Choose provider or model family',
77
- ' /subscription Review provider logins and subscriptions',
78
- ' /marketplace open Browse plugins, skills, and packs',
79
- ' /remote setup Review remote, bridge, and tunnel flows',
80
- ' /sandbox review Inspect secure execution posture',
81
- '',
82
- ' Review And Govern',
83
- ' ' + '\u2500'.repeat(40),
84
- ' /security Security review workspace',
85
- ' /policy Simulation, lint, and preflight review',
86
- ' /incident Incident workspace and export flows',
87
- ' /knowledge Durable knowledge and review queue',
88
- '',
89
- ' Power Surfaces',
90
- ' ' + '\u2500'.repeat(40),
91
- ' /hooks Hook workbench and runtime activity',
92
- ' /orchestration Graph and recursive-agent control room',
93
- ' /communication Structured agent communication workspace',
94
- ' /tasks Task surface for list/show/pause/resume/output',
67
+ // Featured commands shown in the Quick Start section.
68
+ // Each entry is [commandName, subcommandOrArgHint, description].
69
+ // Commands not registered in the live registry are omitted at render time.
70
+ const FEATURED_COMMANDS: Array<[name: string, argHint: string, desc: string]> = [
71
+ ['setup', 'onboarding', 'Guided first-run review and environment posture'],
72
+ ['cockpit', '', 'Unified runtime control room'],
73
+ ['settings', '', 'Settings and config browser'],
74
+ ['provider', '', 'Choose provider or model family'],
75
+ ['subscription', '', 'Review provider logins and subscriptions'],
76
+ ['marketplace', 'open', 'Browse plugins, skills, and packs'],
77
+ ['remote', 'setup', 'Review remote, bridge, and tunnel flows'],
78
+ ['sandbox', 'review', 'Inspect secure execution posture'],
79
+ ['security', '', 'Security review workspace'],
80
+ ['policy', '', 'Simulation, lint, and preflight review'],
81
+ ['incident', '', 'Incident workspace and export flows'],
82
+ ['knowledge', '', 'Durable knowledge and review queue'],
83
+ ['hooks', '', 'Hook workbench and runtime activity'],
84
+ ['orchestration','', 'Graph and recursive-agent control room'],
85
+ ['communication','', 'Structured agent communication workspace'],
86
+ ['tasks', '', 'Task surface for list/show/pause/resume/output'],
95
87
  ];
96
88
 
89
+ // Build command rows from featured list, filtering out unregistered commands.
90
+ function featuredRow(name: string, argHint: string, desc: string): string {
91
+ const invocation = argHint ? `/${name} ${argHint}` : `/${name}`;
92
+ return ` ${invocation.padEnd(23)} ${desc}`;
93
+ }
94
+
95
+ const quickStartRows: string[] = [];
96
+ for (const [name, argHint, desc] of FEATURED_COMMANDS) {
97
+ if (!hasCommand(name)) continue; // omit if not in live registry
98
+ quickStartRows.push(featuredRow(name, argHint, desc));
99
+ }
100
+
101
+ const commandRows: string[] = [];
102
+ if (quickStartRows.length > 0) {
103
+ commandRows.push(' Quick Start', ' ' + '\u2500'.repeat(40), ...quickStartRows, '');
104
+ }
105
+
97
106
  if (commands && commands.length > 0) {
98
107
  commandRows.push('', ' Available Slash Commands', ' ' + '\u2500'.repeat(40));
99
108
  const preferred = ['setup', 'cockpit', 'settings', 'provider', 'subscription', 'marketplace', 'remote', 'sandbox', 'security', 'policy', 'incident', 'knowledge', 'hooks', 'orchestration', 'communication', 'tasks'];
@@ -30,153 +30,11 @@ function isLikelyTableHeaderRow(row: string): boolean {
30
30
  }
31
31
 
32
32
  /**
33
- * renderMarkdown - Parse markdown text into styled Line[] using a line-by-line state machine.
34
- * Supports headers, bold, italic, inline code, code blocks, lists, and links.
33
+ * renderMarkdown - Parse markdown text into styled Line[].
34
+ * Thin wrapper over renderMarkdownTracked for callers that don't need code-block metadata.
35
35
  */
36
36
  export function renderMarkdown(text: string, width: number, options: MarkdownRenderOptions = {}): Line[] {
37
- const lines: Line[] = [];
38
- const rawLines = text.split('\n');
39
-
40
- let inCodeBlock = false;
41
- let codeBlockLang = '';
42
- let codeBlockLines: string[] = [];
43
- const indent = LAYOUT.LEFT_MARGIN;
44
- const contentWidth = LAYOUT.contentWidth(width);
45
-
46
- for (let i = 0; i < rawLines.length; i++) {
47
- const raw = rawLines[i];
48
-
49
- // --- Code block fence ---
50
- const fenceMatch = raw.match(/^```(\w*)/);
51
- if (fenceMatch && !inCodeBlock) {
52
- inCodeBlock = true;
53
- codeBlockLang = fenceMatch[1] || '';
54
- codeBlockLines = [];
55
- continue;
56
- }
57
- if (inCodeBlock) {
58
- if (raw.trimStart().startsWith('```')) {
59
- // End of code block - delegate to code block renderer
60
- const rendered = renderCodeBlock(codeBlockLines, codeBlockLang, width, {
61
- showLineNumbers: options.codeBlockLineNumbers ?? true,
62
- });
63
- lines.push(...rendered);
64
- inCodeBlock = false;
65
- codeBlockLang = '';
66
- codeBlockLines = [];
67
- } else {
68
- codeBlockLines.push(raw);
69
- }
70
- continue;
71
- }
72
-
73
- // --- Empty line ---
74
- if (raw.trim() === '') {
75
- lines.push(UIFactory.stringToLine('', width));
76
- continue;
77
- }
78
-
79
- // --- Heading ---
80
- const h3 = raw.match(/^### (.+)/);
81
- const h2 = raw.match(/^## (.+)/);
82
- const h1 = raw.match(/^# (.+)/);
83
- if (h1) {
84
- lines.push(UIFactory.stringToLine(' '.repeat(indent) + h1[1].toUpperCase(), width, { fg: '#00ffff', bold: true }));
85
- lines.push(UIFactory.stringToLine(' '.repeat(indent) + '━'.repeat(Math.min(getDisplayWidth(h1[1]), contentWidth)), width, { fg: '244' }));
86
- continue;
87
- }
88
- if (h2) {
89
- lines.push(UIFactory.stringToLine(' '.repeat(indent) + h2[1], width, { fg: '#00ffff', bold: true }));
90
- lines.push(UIFactory.stringToLine(' '.repeat(indent) + '─'.repeat(Math.min(getDisplayWidth(h2[1]), contentWidth)), width, { fg: '240' }));
91
- continue;
92
- }
93
- if (h3) {
94
- lines.push(UIFactory.stringToLine(' '.repeat(indent) + h3[1], width, { fg: '111', bold: true }));
95
- continue;
96
- }
97
-
98
- // --- Task list ---
99
- const taskMatch = raw.match(/^(\s*)[-*] \[([ xX])\] (.+)/);
100
- if (taskMatch) {
101
- const listIndent = Math.floor(taskMatch[1].length / 2);
102
- const checked = taskMatch[2] !== ' ';
103
- const bulletX = indent + listIndent * 2;
104
- const textStartX = bulletX + 4;
105
- const checkbox = checked ? '\u2611 ' : '\u2610 '; // ☑ or ☐
106
- const rendered = renderInlineMarkdown(taskMatch[3]);
107
- const prefix = ' '.repeat(bulletX) + checkbox;
108
- const style = checked ? { fg: '244', strikethrough: true } : {};
109
- lines.push(...compositeInlineLine(prefix, rendered, width, { fg: checked ? '#22c55e' : '252', ...style }, textStartX));
110
- continue;
111
- }
112
-
113
- // --- Unordered list ---
114
- const ulMatch = raw.match(/^(\s*)[-*] (.+)/);
115
- if (ulMatch) {
116
- const listIndent = Math.floor(ulMatch[1].length / 2);
117
- const bulletX = indent + listIndent * 2;
118
- const textStartX = bulletX + 2;
119
- const rendered = renderInlineMarkdown(ulMatch[2]);
120
- const prefix = ' '.repeat(bulletX) + '• ';
121
- lines.push(...compositeInlineLine(prefix, rendered, width, { fg: '135', bold: false }, textStartX));
122
- continue;
123
- }
124
-
125
- // --- Ordered list ---
126
- const olMatch = raw.match(/^(\s*)(\d+)\. (.+)/);
127
- if (olMatch) {
128
- const listIndent = Math.floor(olMatch[1].length / 2);
129
- const numStr = olMatch[2] + '. ';
130
- const bulletX = indent + listIndent * 2;
131
- const textStartX = bulletX + numStr.length;
132
- const rendered = renderInlineMarkdown(olMatch[3]);
133
- const prefix = ' '.repeat(bulletX) + numStr;
134
- lines.push(...compositeInlineLine(prefix, rendered, width, { fg: '135', bold: false }, textStartX));
135
- continue;
136
- }
137
-
138
- // --- Horizontal rule ---
139
- if (/^[-*_]{3,}$/.test(raw.trim())) {
140
- lines.push(UIFactory.stringToLine(' '.repeat(indent) + '─'.repeat(contentWidth), width, { fg: '240' }));
141
- continue;
142
- }
143
-
144
- // --- Blockquote ---
145
- const bqMatch = raw.match(/^> (.*)/);
146
- if (bqMatch) {
147
- const rendered = renderInlineMarkdown(bqMatch[1]);
148
- const prefix = ' '.repeat(indent) + '┃ ';
149
- lines.push(...compositeInlineLine(prefix, rendered, width, { fg: '244', italic: true }, indent + 3));
150
- continue;
151
- }
152
-
153
- // --- Table ---
154
- if (raw.includes('|') && i + 1 < rawLines.length && isLikelyTableHeaderRow(raw) && isLikelyTableSeparatorRow(rawLines[i + 1])) {
155
- const tableRows: string[] = [];
156
- let j = i;
157
- while (j < rawLines.length && rawLines[j].includes('|')) {
158
- tableRows.push(rawLines[j]);
159
- j++;
160
- }
161
- i = j - 1;
162
- lines.push(...renderTable(tableRows, width, indent));
163
- continue;
164
- }
165
-
166
- // --- Normal paragraph ---
167
- const rendered = renderInlineMarkdown(raw);
168
- lines.push(...compositeInlineLine(' '.repeat(indent), rendered, width, {}, indent));
169
- }
170
-
171
- // Handle unclosed code block
172
- if (inCodeBlock && codeBlockLines.length > 0) {
173
- const rendered = renderCodeBlock(codeBlockLines, codeBlockLang, width, {
174
- showLineNumbers: options.codeBlockLineNumbers ?? true,
175
- });
176
- lines.push(...rendered);
177
- }
178
-
179
- return lines;
37
+ return renderMarkdownTracked(text, width, options).lines;
180
38
  }
181
39
 
182
40
  export interface CodeBlockSpan {
@@ -0,0 +1,71 @@
1
+ // ---------------------------------------------------------------------------
2
+ // buildStatusToken — always glyph + color, never color-only.
3
+ //
4
+ // Maps a semantic state to a Unicode glyph AND a palette color so that
5
+ // colorblind users and screen readers can distinguish states without color.
6
+ //
7
+ // Glyphs:
8
+ // good ✓ (CHECK MARK)
9
+ // warn ⚠ (WARNING SIGN)
10
+ // bad ✕ (MULTIPLICATION X)
11
+ // info ○ (WHITE CIRCLE)
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import type { Cell } from '../types/grid.ts';
15
+ import { DEFAULT_PANEL_PALETTE } from '../panels/polish.ts';
16
+
17
+ export type StatusState = 'good' | 'warn' | 'bad' | 'info';
18
+
19
+ const STATE_GLYPHS: Record<StatusState, string> = {
20
+ good: '\u2713', // ✓
21
+ warn: '\u26a0', // ⚠
22
+ bad: '\u2715', // ✕
23
+ info: '\u25cb', // ○
24
+ };
25
+
26
+ const STATE_COLORS: Record<StatusState, string> = {
27
+ good: DEFAULT_PANEL_PALETTE.good,
28
+ warn: DEFAULT_PANEL_PALETTE.warn,
29
+ bad: DEFAULT_PANEL_PALETTE.bad,
30
+ info: DEFAULT_PANEL_PALETTE.info,
31
+ };
32
+
33
+ export interface StatusTokenOpts {
34
+ /** Append a numeric count after the label, e.g. "label (3)". */
35
+ count?: number;
36
+ /** Override the default glyph for this state. */
37
+ glyph?: string;
38
+ }
39
+
40
+ /**
41
+ * Build a small sequence of styled cells: [glyph, space, label, optional count]
42
+ *
43
+ * Always prepends the glyph so colorblind users can parse the state.
44
+ * The color is applied to both glyph and label as a redundant cue.
45
+ *
46
+ * @param state Semantic state — controls default glyph and color.
47
+ * @param label Human-readable label string.
48
+ * @param opts Optional count suffix or glyph override.
49
+ * @returns Array of Cell objects ready to embed in a Line.
50
+ */
51
+ export function buildStatusToken(
52
+ state: StatusState,
53
+ label: string,
54
+ opts?: StatusTokenOpts,
55
+ ): Cell[] {
56
+ const glyph = opts?.glyph ?? STATE_GLYPHS[state];
57
+ const color = STATE_COLORS[state];
58
+ const suffix = opts?.count !== undefined ? ` (${opts.count})` : '';
59
+ const text = `${glyph} ${label}${suffix}`;
60
+
61
+ return text.split('').map((char): Cell => ({
62
+ char,
63
+ fg: color,
64
+ bg: '',
65
+ bold: false,
66
+ dim: false,
67
+ underline: false,
68
+ italic: false,
69
+ strikethrough: false,
70
+ }));
71
+ }
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 = '0.18.19';
9
+ let _version = '0.18.23';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;