@pellux/goodvibes-tui 0.19.99 → 0.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.20.1] — 2026-05-12
8
+
9
+ ### Changes
10
+ - Added shared fullscreen rendering primitives for frame drawing, text writing, clipping, fills, rules, stable windows, and the workspace palette.
11
+ - Refactored `/config` and `/mcp` to use the same reusable fullscreen workspace base instead of maintaining separate one-off renderers.
12
+ - Updated onboarding to consume the shared primitive layer while preserving its wizard-specific layout and behavior.
13
+
14
+ ## [0.20.0] — 2026-05-11
15
+
16
+ ### Changes
17
+ - Updated `@pellux/goodvibes-sdk` to `0.33.30` for guest-side QEMU REPL execution and exec working-directory fixes.
18
+ - Added generated QEMU sandbox setup docs, runtime bootstrap scripts, cloud-init seed files, and guest runtime provisioning guidance.
19
+ - Wired `sandbox.replJavaScriptCommand` through generated QEMU setup manifests so JavaScript-family REPL snippets use the guest Bun runtime.
20
+ - Removed the obsolete `/sandbox qemu create-image` command in favor of the generated `create-image.sh` bootstrap workflow.
21
+ - Added focused coverage for QEMU setup scaffolding, command-level exec working directories, context auto-compaction thresholds, and retrospective setup-guide intent classification.
22
+
7
23
  ## [0.19.99] — 2026-05-12
8
24
 
9
25
  ### Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.19.99-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.20.1-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
8
 
@@ -626,13 +626,16 @@ The QEMU path includes:
626
626
 
627
627
  - setup bundle generation
628
628
  - first-run bootstrap scaffolding
629
- - `qemu-img` image creation helpers
629
+ - Debian cloud-image download, mutable qcow2 clone, resize, and NoCloud ISO creation through the generated `create-image.sh`
630
630
  - host-side wrapper generation
631
+ - guest cloud-init seed generation for the `goodvibes` sudo user, SSH key auth, `/workspace`, and `ens3` DHCP
631
632
  - guest-test and wrapper-test validation
632
633
  - session-backed command execution
633
634
  - guest bundle export / inspect flows
634
635
  - setup manifest export / apply flows
635
636
  - `attach` and `launch-per-command` execution modes
637
+ - REPL/MCP-friendly guest bootstrap packages for Python, JavaScript, TypeScript, SQL, GraphQL, Bun, Deno, DuckDB, Go, Rust, Ruby, and common CLI build/search tools
638
+ - guest JavaScript-family REPL execution through `sandbox.replJavaScriptCommand`, defaulting the generated QEMU setup to `/home/goodvibes/.bun/bin/bun`
636
639
 
637
640
  Key commands:
638
641
 
@@ -643,7 +646,6 @@ Key commands:
643
646
  - `/sandbox probe`
644
647
  - `/sandbox qemu setup <dir>`
645
648
  - `/sandbox qemu bootstrap <dir> [size-gb]`
646
- - `/sandbox qemu create-image <path> [size-gb]`
647
649
  - `/sandbox qemu inspect-setup <manifest>`
648
650
  - `/sandbox qemu apply-setup <manifest>`
649
651
  - `/sandbox session ...`
@@ -654,10 +656,14 @@ Typical first-run path:
654
656
 
655
657
  ```sh
656
658
  /sandbox qemu bootstrap .goodvibes/tui/sandbox 20
659
+ .goodvibes/tui/sandbox/create-image.sh .goodvibes/tui/sandbox/goodvibes-sandbox.qcow2 20G
660
+ GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh .goodvibes/tui/sandbox/qemu-wrapper.sh bash -s < .goodvibes/tui/sandbox/guest-bootstrap.sh
657
661
  /sandbox doctor
658
- /sandbox guest-test eval-js
662
+ /sandbox guest-test eval-py
659
663
  ```
660
664
 
665
+ See [QEMU sandbox bootstrapping](docs/qemu-sandbox.md) for host prerequisites, generated files, first-boot behavior, guest runtime packages, and troubleshooting logs.
666
+
661
667
  ---
662
668
 
663
669
  ## Remote, Local Services, And Integration Helpers
@@ -3,7 +3,7 @@
3
3
  "product": {
4
4
  "id": "goodvibes",
5
5
  "surface": "operator",
6
- "version": "0.33.27"
6
+ "version": "0.33.30"
7
7
  },
8
8
  "auth": {
9
9
  "modes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.19.99",
3
+ "version": "0.20.1",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -97,7 +97,7 @@
97
97
  "@anthropic-ai/vertex-sdk": "^0.16.0",
98
98
  "@ast-grep/napi": "^0.42.0",
99
99
  "@aws/bedrock-token-generator": "^1.1.0",
100
- "@pellux/goodvibes-sdk": "0.33.27",
100
+ "@pellux/goodvibes-sdk": "0.33.30",
101
101
  "bash-language-server": "^5.6.0",
102
102
  "fuse.js": "^7.1.0",
103
103
  "graphql": "^16.13.2",
@@ -155,10 +155,13 @@ export function renderSetupSandboxReview(ctx: CommandContext, snapshot: SetupRev
155
155
  ];
156
156
  if (backend === 'local') {
157
157
  lines.push(' /sandbox qemu bootstrap .goodvibes/tui/sandbox 20');
158
+ lines.push(' .goodvibes/tui/sandbox/create-image.sh .goodvibes/tui/sandbox/goodvibes-sandbox.qcow2 20G');
159
+ lines.push(' GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh .goodvibes/tui/sandbox/qemu-wrapper.sh bash -s < .goodvibes/tui/sandbox/guest-bootstrap.sh');
158
160
  lines.push(' /sandbox doctor');
159
161
  } else if (!image || !wrapper) {
160
162
  lines.push(' /sandbox qemu setup .goodvibes/tui/sandbox');
161
- lines.push(' /sandbox qemu create-image .goodvibes/tui/sandbox/images/goodvibes-sandbox.qcow2 20');
163
+ lines.push(' .goodvibes/tui/sandbox/create-image.sh .goodvibes/tui/sandbox/goodvibes-sandbox.qcow2 20G');
164
+ lines.push(' GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh .goodvibes/tui/sandbox/qemu-wrapper.sh bash -s < .goodvibes/tui/sandbox/guest-bootstrap.sh');
162
165
  lines.push(' /sandbox doctor');
163
166
  } else {
164
167
  lines.push(' /sandbox guest-test eval-js');
@@ -4,7 +4,6 @@ import type { CommandContext } from '../command-registry.ts';
4
4
  import {
5
5
  applySandboxQemuSetupManifest,
6
6
  bootstrapSandboxQemuSetupBundle,
7
- createSandboxQemuImage,
8
7
  inspectSandboxQemuSetupManifest,
9
8
  loadSandboxQemuSetupManifest,
10
9
  scaffoldSandboxQemuSetupBundle,
@@ -27,29 +26,25 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
27
26
  return true;
28
27
  }
29
28
  const bundle = scaffoldSandboxQemuSetupBundle(ctx.platform.configManager, shellPaths.workingDirectory, dirArg, { surfaceRoot: 'tui' });
30
- ctx.platform.configManager.setDynamic('sandbox.vmBackend', 'qemu');
31
- ctx.platform.configManager.setDynamic('sandbox.qemuExecWrapper', bundle.wrapperPath);
32
- ctx.platform.configManager.setDynamic('sandbox.qemuImagePath', bundle.imagePath);
33
- if (!`${ctx.platform.configManager.get('sandbox.qemuGuestHost') ?? ''}`.trim()) {
34
- ctx.platform.configManager.setDynamic('sandbox.qemuGuestHost', '127.0.0.1');
35
- }
36
- if (!`${ctx.platform.configManager.get('sandbox.qemuGuestUser') ?? ''}`.trim()) {
37
- ctx.platform.configManager.setDynamic('sandbox.qemuGuestUser', 'goodvibes');
38
- }
39
- if (!`${ctx.platform.configManager.get('sandbox.qemuWorkspacePath') ?? ''}`.trim()) {
40
- ctx.platform.configManager.setDynamic('sandbox.qemuWorkspacePath', '/workspace');
41
- }
29
+ const manifest = loadSandboxQemuSetupManifest(shellPaths.workingDirectory, bundle.manifestPath);
30
+ applySandboxQemuSetupManifest(ctx.platform.configManager, manifest);
31
+ ctx.platform.configManager.setDynamic('sandbox.replIsolation', 'shared-vm');
32
+ ctx.platform.configManager.setDynamic('sandbox.mcpIsolation', 'shared-vm');
42
33
  ctx.print([
43
34
  `Initialized QEMU sandbox setup bundle in ${bundle.directory}`,
44
35
  ` wrapper: ${bundle.wrapperPath}`,
45
36
  ` image path: ${bundle.imagePath}`,
46
37
  ` image create script: ${bundle.imageCreateScriptPath}`,
47
38
  ` guest bootstrap: ${bundle.guestBootstrapScriptPath}`,
39
+ ` seed directory: ${bundle.seedDirectory}`,
40
+ ` seed ISO: ${bundle.seedIsoPath}`,
41
+ ` ssh key: ${bundle.sshKeyPath}`,
48
42
  ` projection policy: ${bundle.projectionPolicyPath}`,
49
43
  ` ssh config: ${bundle.sshConfigPath}`,
50
44
  ` manifest: ${bundle.manifestPath}`,
51
- ' applied: backend=qemu, wrapper path, image path, and default guest settings',
52
- ` next: /sandbox qemu create-image ${bundle.imagePath} 20`,
45
+ ' applied: backend=qemu, qemu binary, wrapper path, image path, launch-per-command guest settings, shared VM isolation',
46
+ ` next: ${bundle.imageCreateScriptPath} ${bundle.imagePath} 20G`,
47
+ ` then provision runtimes: GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh ${bundle.wrapperPath} bash -s < ${bundle.guestBootstrapScriptPath}`,
53
48
  ].join('\n'));
54
49
  return true;
55
50
  }
@@ -67,33 +62,20 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
67
62
  ` wrapper: ${bundle.wrapperPath}`,
68
63
  ` image path: ${bundle.imagePath}`,
69
64
  ` guest bootstrap: ${bundle.guestBootstrapScriptPath}`,
65
+ ` seed ISO: ${bundle.seedIsoPath}`,
66
+ ` ssh key: ${bundle.sshKeyPath}`,
70
67
  ` projection policy: ${bundle.projectionPolicyPath}`,
71
68
  ` manifest: ${bundle.manifestPath}`,
72
- ' applied: backend=qemu, wrapper path, image path, and guest settings',
73
- ' next: boot the image, run the guest bootstrap script, then /sandbox guest-test eval-js',
69
+ ' applied: backend=qemu, qemu binary, wrapper path, image path, launch-per-command guest settings, shared VM isolation',
70
+ ` next: ${bundle.imageCreateScriptPath} ${bundle.imagePath} ${sizeGb}G`,
71
+ ` then provision runtimes: GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh ${bundle.wrapperPath} bash -s < ${bundle.guestBootstrapScriptPath}`,
72
+ ' then: /sandbox guest-test eval-py',
74
73
  ].join('\n'));
75
74
  } catch (error) {
76
75
  ctx.print(summarizeError(error));
77
76
  }
78
77
  return true;
79
78
  }
80
- if (sub === 'create-image') {
81
- const imagePath = args[2];
82
- const sizeGb = Number.parseInt(args[3] ?? '20', 10);
83
- if (!imagePath || !Number.isInteger(sizeGb) || sizeGb < 1) {
84
- ctx.print('Usage: /sandbox qemu create-image <path> [size-gb]');
85
- return true;
86
- }
87
- try {
88
- const created = createSandboxQemuImage(shellPaths.workingDirectory, imagePath, sizeGb);
89
- ctx.platform.configManager.setDynamic('sandbox.qemuImagePath', created.path);
90
- ctx.platform.configManager.setDynamic('sandbox.vmBackend', 'qemu');
91
- ctx.print(`Created QEMU image ${created.path} (${created.sizeGb}G).`);
92
- } catch (error) {
93
- ctx.print(summarizeError(error));
94
- }
95
- return true;
96
- }
97
79
  if (sub === 'recover') {
98
80
  const sessionId = args[2];
99
81
  if (!sessionId) {
@@ -132,6 +114,6 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
132
114
  ctx.print(`Applied QEMU sandbox setup from ${shellPaths.resolveWorkspacePath(pathArg)}.`);
133
115
  return true;
134
116
  }
135
- ctx.print('Usage: /sandbox qemu <setup <directory>|bootstrap <directory> [size-gb]|create-image <path> [size-gb]|recover <session-id>|inspect-setup <setup-manifest.json>|apply-setup <setup-manifest.json>>');
117
+ ctx.print('Usage: /sandbox qemu <setup <directory>|bootstrap <directory> [size-gb]|recover <session-id>|inspect-setup <setup-manifest.json>|apply-setup <setup-manifest.json>>');
136
118
  return true;
137
119
  }
@@ -61,6 +61,7 @@ function applySandboxPreset(
61
61
  configManager.setDynamic('sandbox.qemuGuestUser' as never, preset.config.qemuGuestUser);
62
62
  configManager.setDynamic('sandbox.qemuWorkspacePath' as never, preset.config.qemuWorkspacePath);
63
63
  configManager.setDynamic('sandbox.qemuSessionMode' as never, preset.config.qemuSessionMode);
64
+ configManager.setDynamic('sandbox.replJavaScriptCommand' as never, preset.config.replJavaScriptCommand);
64
65
  return true;
65
66
  }
66
67
 
@@ -83,6 +84,7 @@ function renderSandboxPresetDetail(presetId: string): string | null {
83
84
  ` qemu guest user: ${preset.config.qemuGuestUser || '(not configured)'}`,
84
85
  ` qemu workspace: ${preset.config.qemuWorkspacePath || '(not configured)'}`,
85
86
  ` qemu session mode: ${preset.config.qemuSessionMode}`,
87
+ ` repl JavaScript command: ${preset.config.replJavaScriptCommand || 'bun'}`,
86
88
  ...preset.notes.map((note) => ` note: ${note}`),
87
89
  ].join('\n');
88
90
  }
@@ -91,7 +93,7 @@ export function registerPlatformSandboxRuntimeCommands(registry: CommandRegistry
91
93
  registry.register({
92
94
  name: 'sandbox',
93
95
  description: 'Review and configure VM isolation policy for MCP and evaluation runtimes',
94
- usage: '[open|review|recommend|profiles|presets|preset <id>|apply-preset <id>|probe|doctor|wrapper-test <profile>|guest-test <profile>|init-qemu <dir>|qemu <setup|bootstrap|create-image|recover|inspect-setup|apply-setup> ...|session ...|bundle ...|guest-bundle <export|inspect> <path>|scaffold-qemu-wrapper <path>|set-mcp <mode>|set-repl <mode>|set-windows <mode>|set-backend <mode>|set-qemu-binary <path>|set-qemu-image <path>|set-qemu-wrapper <path>|set-qemu-guest-host <host>|set-qemu-guest-port <port>|set-qemu-guest-user <user>|set-qemu-workspace <path>|set-qemu-session-mode <attach|launch-per-command>]',
96
+ usage: '[open|review|recommend|profiles|presets|preset <id>|apply-preset <id>|probe|doctor|wrapper-test <profile>|guest-test <profile>|init-qemu <dir>|qemu <setup|bootstrap|recover|inspect-setup|apply-setup> ...|session ...|bundle ...|guest-bundle <export|inspect> <path>|scaffold-qemu-wrapper <path>|set-mcp <mode>|set-repl <mode>|set-windows <mode>|set-backend <mode>|set-qemu-binary <path>|set-qemu-image <path>|set-qemu-wrapper <path>|set-qemu-guest-host <host>|set-qemu-guest-port <port>|set-qemu-guest-user <user>|set-qemu-workspace <path>|set-qemu-session-mode <attach|launch-per-command>]',
95
97
  async handler(args, ctx) {
96
98
  const shellPaths = requireShellPaths(ctx);
97
99
  const sub = args[0] ?? 'open';
@@ -400,7 +402,7 @@ export function registerPlatformSandboxRuntimeCommands(registry: CommandRegistry
400
402
  ctx.print([`Scaffolded QEMU wrapper to ${targetPath}`, ' bridge test mode: GV_SANDBOX_WRAPPER_MODE=host-exec', ' next: /sandbox set-qemu-wrapper ' + targetPath].join('\n'));
401
403
  return;
402
404
  }
403
- ctx.print('Usage: /sandbox [open|review|recommend|profiles|presets|preset <id>|apply-preset <id>|probe|doctor|wrapper-test <profile>|guest-test <profile>|init-qemu <dir>|qemu <setup|bootstrap|create-image|recover|inspect-setup|apply-setup> ...|session ...|bundle export <path>|bundle inspect <path>|guest-bundle <export|inspect> <path>|scaffold-qemu-wrapper <path>|set-mcp <mode>|set-repl <mode>|set-windows <mode>|set-backend <mode>|set-qemu-binary <path>|set-qemu-image <path>|set-qemu-wrapper <path>|set-qemu-guest-host <host>|set-qemu-guest-port <port>|set-qemu-guest-user <user>|set-qemu-workspace <path>|set-qemu-session-mode <attach|launch-per-command>]');
405
+ ctx.print('Usage: /sandbox [open|review|recommend|profiles|presets|preset <id>|apply-preset <id>|probe|doctor|wrapper-test <profile>|guest-test <profile>|init-qemu <dir>|qemu <setup|bootstrap|recover|inspect-setup|apply-setup> ...|session ...|bundle export <path>|bundle inspect <path>|guest-bundle <export|inspect> <path>|scaffold-qemu-wrapper <path>|set-mcp <mode>|set-repl <mode>|set-windows <mode>|set-backend <mode>|set-qemu-binary <path>|set-qemu-image <path>|set-qemu-wrapper <path>|set-qemu-guest-host <host>|set-qemu-guest-port <port>|set-qemu-guest-user <user>|set-qemu-workspace <path>|set-qemu-session-mode <attach|launch-per-command>]');
404
406
  },
405
407
  });
406
408
  }
@@ -0,0 +1,130 @@
1
+ import type { Line } from '../types/grid.ts';
2
+ import { createEmptyLine, createStyledCell } from '../types/grid.ts';
3
+ import { getDisplayWidth } from '../utils/terminal-width.ts';
4
+ import { GLYPHS, UI_TONES } from './ui-primitives.ts';
5
+
6
+ export const FULLSCREEN_PALETTE = {
7
+ border: '#64748b',
8
+ title: '#67e8f9',
9
+ subtitle: '#93c5fd',
10
+ text: '#e2e8f0',
11
+ muted: '#94a3b8',
12
+ dim: '#64748b',
13
+ selectedBg: '#223049',
14
+ categoryBg: '#141b25',
15
+ contextBg: '#121923',
16
+ controlsBg: '#0f141d',
17
+ footerBg: '#111827',
18
+ good: UI_TONES.state.good,
19
+ warn: UI_TONES.state.warn,
20
+ bad: UI_TONES.state.bad,
21
+ info: UI_TONES.state.info,
22
+ } as const;
23
+
24
+ export type FullscreenTextStyle = Partial<Omit<Line[number], 'char'>>;
25
+
26
+ export function clamp(value: number, min: number, max: number): number {
27
+ return Math.max(min, Math.min(max, value));
28
+ }
29
+
30
+ export function fillRange(line: Line, startX: number, endX: number, bg: string): void {
31
+ for (let x = Math.max(0, startX); x <= Math.min(line.length - 1, endX); x += 1) {
32
+ const cell = line[x] ?? createStyledCell(' ');
33
+ line[x] = createStyledCell(cell.char, {
34
+ fg: cell.fg,
35
+ bg,
36
+ bold: cell.bold,
37
+ dim: cell.dim,
38
+ underline: cell.underline,
39
+ italic: cell.italic,
40
+ strikethrough: cell.strikethrough,
41
+ link: cell.link,
42
+ });
43
+ }
44
+ }
45
+
46
+ export function fillWidth(line: Line, startX: number, width: number, bg: string): void {
47
+ if (width <= 0) return;
48
+ fillRange(line, startX, startX + width - 1, bg);
49
+ }
50
+
51
+ export function writeText(
52
+ line: Line,
53
+ startX: number,
54
+ maxWidth: number,
55
+ text: string,
56
+ style: FullscreenTextStyle = {},
57
+ ): void {
58
+ let x = startX;
59
+ let used = 0;
60
+ for (const ch of text) {
61
+ const width = getDisplayWidth(ch);
62
+ if (width <= 0) continue;
63
+ if (used + width > maxWidth || x >= line.length) break;
64
+ line[x] = createStyledCell(ch, style);
65
+ if (width > 1 && x + 1 < line.length) line[x + 1] = createStyledCell(' ', style);
66
+ x += width;
67
+ used += width;
68
+ }
69
+ }
70
+
71
+ export function makeLine(width: number, bg = ''): Line {
72
+ const line = createEmptyLine(width);
73
+ if (bg) fillRange(line, 0, width - 1, bg);
74
+ return line;
75
+ }
76
+
77
+ export function borderLine(width: number, left: string, fill: string, right: string, fg: string = FULLSCREEN_PALETTE.border): Line {
78
+ const line = makeLine(width);
79
+ if (width <= 0) return line;
80
+ line[0] = createStyledCell(left, { fg });
81
+ for (let x = 1; x < width - 1; x += 1) line[x] = createStyledCell(fill, { fg });
82
+ if (width > 1) line[width - 1] = createStyledCell(right, { fg });
83
+ return line;
84
+ }
85
+
86
+ export function contentLine(width: number, bg: string, borderFg: string = FULLSCREEN_PALETTE.border): Line {
87
+ const line = makeLine(width, bg);
88
+ if (width > 0) line[0] = createStyledCell(GLYPHS.frame.vertical, { fg: borderFg });
89
+ if (width > 1) line[width - 1] = createStyledCell(GLYPHS.frame.vertical, { fg: borderFg });
90
+ return line;
91
+ }
92
+
93
+ export function drawVerticalRule(line: Line, x: number, fg: string = FULLSCREEN_PALETTE.border, bg = ''): void {
94
+ if (x < 0 || x >= line.length) return;
95
+ line[x] = createStyledCell(GLYPHS.frame.vertical, { fg, bg });
96
+ }
97
+
98
+ export function drawHorizontalRule(line: Line, startX: number, endX: number, fg: string = FULLSCREEN_PALETTE.border, bg = ''): void {
99
+ for (let x = Math.max(0, startX); x <= Math.min(line.length - 1, endX); x += 1) {
100
+ line[x] = createStyledCell(GLYPHS.frame.horizontal, { fg, bg });
101
+ }
102
+ }
103
+
104
+ export function clipDisplay(text: string, width: number): string {
105
+ if (width <= 0) return '';
106
+ let used = 0;
107
+ let output = '';
108
+ for (const ch of text) {
109
+ const chWidth = getDisplayWidth(ch);
110
+ if (chWidth <= 0) continue;
111
+ if (used + chWidth > width) break;
112
+ output += ch;
113
+ used += chWidth;
114
+ }
115
+ return output;
116
+ }
117
+
118
+ export function padDisplay(text: string, width: number): string {
119
+ const clipped = clipDisplay(text, width);
120
+ return `${clipped}${' '.repeat(Math.max(0, width - getDisplayWidth(clipped)))}`;
121
+ }
122
+
123
+ export function stableWindow(total: number, selectedIndex: number, visibleCount: number): { start: number; end: number } {
124
+ if (total <= 0 || visibleCount <= 0) return { start: 0, end: 0 };
125
+ if (total <= visibleCount) return { start: 0, end: total };
126
+ const selected = clamp(selectedIndex, 0, total - 1);
127
+ const half = Math.floor(visibleCount / 2);
128
+ const start = clamp(selected - half, 0, total - visibleCount);
129
+ return { start, end: start + visibleCount };
130
+ }
@@ -0,0 +1,199 @@
1
+ import type { Line } from '../types/grid.ts';
2
+ import { GLYPHS } from './ui-primitives.ts';
3
+ import {
4
+ borderLine,
5
+ clamp,
6
+ contentLine,
7
+ drawHorizontalRule,
8
+ drawVerticalRule,
9
+ fillRange,
10
+ FULLSCREEN_PALETTE,
11
+ makeLine,
12
+ writeText,
13
+ } from './fullscreen-primitives.ts';
14
+
15
+ export {
16
+ borderLine,
17
+ clamp,
18
+ contentLine,
19
+ fillRange,
20
+ makeLine,
21
+ padDisplay,
22
+ stableWindow,
23
+ writeText,
24
+ } from './fullscreen-primitives.ts';
25
+ export { FULLSCREEN_PALETTE as WORKSPACE_PALETTE } from './fullscreen-primitives.ts';
26
+
27
+ const WORKSPACE_PALETTE = FULLSCREEN_PALETTE;
28
+
29
+ export interface WorkspaceRow {
30
+ readonly text: string;
31
+ readonly selected?: boolean;
32
+ readonly bold?: boolean;
33
+ readonly dim?: boolean;
34
+ readonly fg?: string;
35
+ readonly bg?: string;
36
+ readonly kind?: 'group' | 'item' | 'more' | 'empty';
37
+ }
38
+
39
+ export interface FullscreenWorkspaceRenderOptions {
40
+ readonly width: number;
41
+ readonly height: number;
42
+ readonly title: string;
43
+ readonly stateLabel?: string;
44
+ readonly leftHeader: string;
45
+ readonly mainHeader: string;
46
+ readonly leftRows: readonly WorkspaceRow[];
47
+ readonly contextRows: readonly WorkspaceRow[];
48
+ readonly controlRows: readonly WorkspaceRow[];
49
+ readonly footer: string;
50
+ readonly leftWidth?: number;
51
+ readonly contextRatio?: number;
52
+ readonly minContextRows?: number;
53
+ }
54
+
55
+ export interface FullscreenWorkspaceMetrics {
56
+ readonly safeWidth: number;
57
+ readonly safeHeight: number;
58
+ readonly leftWidth: number;
59
+ readonly centerWidth: number;
60
+ readonly bodyRows: number;
61
+ readonly contextWidth: number;
62
+ readonly contextRows: number;
63
+ readonly controlRows: number;
64
+ }
65
+
66
+ export function drawVertical(line: Line, x: number, bg = ''): void {
67
+ if (x <= 0 || x >= line.length - 1) return;
68
+ drawVerticalRule(line, x, WORKSPACE_PALETTE.border, bg);
69
+ }
70
+
71
+ export function drawHorizontalRange(line: Line, startX: number, endX: number, bg = ''): void {
72
+ drawHorizontalRule(line, Math.max(1, startX), Math.min(line.length - 2, endX), WORKSPACE_PALETTE.border, bg);
73
+ }
74
+
75
+ function leftWidthFor(width: number, explicit?: number): number {
76
+ if (explicit !== undefined) return clamp(explicit, 14, Math.max(14, width - 24));
77
+ return width < 80
78
+ ? clamp(Math.round(width * 0.32), 14, Math.max(14, width - 24))
79
+ : clamp(Math.round(width * 0.22), 24, 34);
80
+ }
81
+
82
+ export function getFullscreenWorkspaceMetrics(options: Pick<
83
+ FullscreenWorkspaceRenderOptions,
84
+ 'width' | 'height' | 'leftWidth' | 'contextRatio' | 'minContextRows'
85
+ >): FullscreenWorkspaceMetrics {
86
+ const safeWidth = Math.max(1, options.width);
87
+ const safeHeight = Math.max(12, options.height);
88
+ const leftWidth = leftWidthFor(safeWidth, options.leftWidth);
89
+ const centerWidth = Math.max(20, safeWidth - leftWidth - 3);
90
+ const bodyTop = 3;
91
+ const footerY = safeHeight - 2;
92
+ const bodyRows = Math.max(4, footerY - bodyTop);
93
+ const contextWidth = Math.max(10, centerWidth - 2);
94
+ const maxContextRows = Math.max(3, bodyRows - 4);
95
+ const minContextRows = clamp(options.minContextRows ?? 10, 3, maxContextRows);
96
+ const contextRows = clamp(
97
+ Math.round(bodyRows * (options.contextRatio ?? 0.4)),
98
+ Math.min(minContextRows, maxContextRows),
99
+ maxContextRows,
100
+ );
101
+ const controlRows = Math.max(3, bodyRows - contextRows - 1);
102
+ return { safeWidth, safeHeight, leftWidth, centerWidth, bodyRows, contextWidth, contextRows, controlRows };
103
+ }
104
+
105
+ function rowFg(row: WorkspaceRow, fallback: string): string {
106
+ if (row.fg) return row.fg;
107
+ if (row.kind === 'group') return WORKSPACE_PALETTE.subtitle;
108
+ if (row.kind === 'more') return WORKSPACE_PALETTE.dim;
109
+ return fallback;
110
+ }
111
+
112
+ export function renderFullscreenWorkspace(options: FullscreenWorkspaceRenderOptions): Line[] {
113
+ const { safeWidth, safeHeight, leftWidth, centerWidth, bodyRows, contextWidth, contextRows } = getFullscreenWorkspaceMetrics(options);
114
+ const leftStart = 1;
115
+ const dividerX = leftWidth + 1;
116
+ const centerStart = dividerX + 1;
117
+ const centerEnd = safeWidth - 2;
118
+ const bodyTop = 3;
119
+ const separatorY = bodyTop + contextRows;
120
+ const lines: Line[] = [];
121
+
122
+ const top = borderLine(safeWidth, GLYPHS.frame.topLeft, GLYPHS.frame.horizontal, GLYPHS.frame.topRight);
123
+ writeText(top, 2, safeWidth - 4, ` ${options.title} `, { fg: WORKSPACE_PALETTE.title, bold: true });
124
+ if (options.stateLabel) {
125
+ writeText(top, Math.max(2, safeWidth - options.stateLabel.length - 4), options.stateLabel.length, options.stateLabel, {
126
+ fg: WORKSPACE_PALETTE.subtitle,
127
+ });
128
+ }
129
+ lines.push(top);
130
+
131
+ const header = contentLine(safeWidth, WORKSPACE_PALETTE.footerBg);
132
+ drawVertical(header, dividerX, WORKSPACE_PALETTE.footerBg);
133
+ writeText(header, leftStart + 1, leftWidth - 2, options.leftHeader, {
134
+ fg: WORKSPACE_PALETTE.subtitle,
135
+ bold: true,
136
+ bg: WORKSPACE_PALETTE.footerBg,
137
+ });
138
+ writeText(header, centerStart + 1, centerWidth - 2, options.mainHeader, {
139
+ fg: WORKSPACE_PALETTE.subtitle,
140
+ bold: true,
141
+ bg: WORKSPACE_PALETTE.footerBg,
142
+ });
143
+ lines.push(header);
144
+
145
+ const headerSep = contentLine(safeWidth, '');
146
+ drawVertical(headerSep, dividerX);
147
+ drawHorizontalRange(headerSep, 1, safeWidth - 2);
148
+ lines.push(headerSep);
149
+
150
+ for (let row = 0; row < bodyRows; row += 1) {
151
+ const y = bodyTop + row;
152
+ const inContext = y < separatorY;
153
+ const inSeparator = y === separatorY;
154
+ const bg = inSeparator ? '' : inContext ? WORKSPACE_PALETTE.contextBg : WORKSPACE_PALETTE.controlsBg;
155
+ const line = contentLine(safeWidth, bg);
156
+ fillRange(line, 1, dividerX - 1, WORKSPACE_PALETTE.categoryBg);
157
+ drawVertical(line, dividerX, bg);
158
+
159
+ const leftRow = options.leftRows[row] ?? { text: '', kind: 'empty' as const };
160
+ if (leftRow.selected) fillRange(line, leftStart, dividerX - 1, WORKSPACE_PALETTE.selectedBg);
161
+ writeText(line, leftStart + 1, leftWidth - 3, leftRow.text, {
162
+ fg: leftRow.selected ? WORKSPACE_PALETTE.text : rowFg(leftRow, WORKSPACE_PALETTE.muted),
163
+ bg: leftRow.selected ? WORKSPACE_PALETTE.selectedBg : WORKSPACE_PALETTE.categoryBg,
164
+ bold: leftRow.bold ?? (leftRow.selected || leftRow.kind === 'group'),
165
+ dim: leftRow.dim,
166
+ });
167
+
168
+ if (inSeparator) {
169
+ drawHorizontalRange(line, centerStart, centerEnd);
170
+ } else if (inContext) {
171
+ const contextRow = options.contextRows[row] ?? { text: '', kind: 'empty' as const };
172
+ writeText(line, centerStart + 1, contextWidth, contextRow.text, {
173
+ fg: rowFg(contextRow, WORKSPACE_PALETTE.text),
174
+ bg,
175
+ bold: contextRow.bold,
176
+ dim: contextRow.dim ?? contextRow.text.length === 0,
177
+ });
178
+ } else {
179
+ const controlRow = options.controlRows[row - contextRows - 1] ?? { text: '', kind: 'empty' as const };
180
+ if (controlRow.selected) fillRange(line, centerStart, centerEnd, WORKSPACE_PALETTE.selectedBg);
181
+ writeText(line, centerStart + 1, contextWidth, controlRow.text, {
182
+ fg: controlRow.selected ? WORKSPACE_PALETTE.text : rowFg(controlRow, WORKSPACE_PALETTE.text),
183
+ bg: controlRow.selected ? WORKSPACE_PALETTE.selectedBg : bg,
184
+ bold: controlRow.bold ?? controlRow.selected,
185
+ dim: controlRow.dim ?? controlRow.text.length === 0,
186
+ });
187
+ }
188
+
189
+ lines.push(line);
190
+ }
191
+
192
+ const footer = contentLine(safeWidth, WORKSPACE_PALETTE.footerBg);
193
+ writeText(footer, 2, safeWidth - 4, options.footer, { fg: WORKSPACE_PALETTE.muted, bg: WORKSPACE_PALETTE.footerBg });
194
+ lines.push(footer);
195
+ lines.push(borderLine(safeWidth, GLYPHS.frame.bottomLeft, GLYPHS.frame.horizontal, GLYPHS.frame.bottomRight));
196
+
197
+ while (lines.length < safeHeight) lines.unshift(makeLine(safeWidth));
198
+ return lines.slice(-safeHeight);
199
+ }