@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 +16 -0
- package/README.md +10 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/input/commands/local-setup-review.ts +4 -1
- package/src/input/commands/platform-sandbox-qemu.ts +17 -35
- package/src/input/commands/platform-sandbox-runtime.ts +4 -2
- package/src/renderer/fullscreen-primitives.ts +130 -0
- package/src/renderer/fullscreen-workspace.ts +199 -0
- package/src/renderer/mcp-workspace.ts +176 -236
- package/src/renderer/onboarding/onboarding-wizard.ts +16 -42
- package/src/renderer/overlay-box.ts +12 -31
- package/src/renderer/settings-modal-helpers.ts +1 -0
- package/src/renderer/settings-modal.ts +53 -210
- package/src/runtime/sandbox-public-gaps.ts +158 -38
- package/src/runtime/sandbox-qemu-templates.ts +340 -0
- package/src/version.ts +1 -1
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
|
[](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](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
|
-
-
|
|
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-
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
|
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
|
-
|
|
31
|
-
ctx.platform.configManager
|
|
32
|
-
ctx.platform.configManager.setDynamic('sandbox.
|
|
33
|
-
|
|
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,
|
|
52
|
-
` next:
|
|
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,
|
|
73
|
-
|
|
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]|
|
|
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|
|
|
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|
|
|
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
|
+
}
|