@pellux/goodvibes-tui 0.20.0 → 0.20.2
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 +14 -0
- package/README.md +7 -7
- package/package.json +1 -1
- package/src/input/commands/local-setup-review.ts +4 -6
- package/src/input/commands/local-setup.ts +1 -1
- package/src/input/commands/platform-sandbox-qemu.ts +143 -19
- package/src/input/commands/platform-sandbox-runtime.ts +4 -4
- 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.ts +53 -210
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to GoodVibes TUI.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [0.20.2] — 2026-05-12
|
|
8
|
+
|
|
9
|
+
### Changes
|
|
10
|
+
- Changed QEMU sandbox setup/bootstrap defaults to use `~/.goodvibes/tui/sandbox` instead of writing setup bundles into the current project.
|
|
11
|
+
- Made `/sandbox qemu bootstrap` perform the image build and guest runtime provisioning by default, with `--scaffold-only`, `--no-build`, and `--no-provision` escape hatches for manual workflows.
|
|
12
|
+
- Updated sandbox review guidance and QEMU documentation so first-run setup no longer points users at project-local sandbox paths.
|
|
13
|
+
|
|
14
|
+
## [0.20.1] — 2026-05-12
|
|
15
|
+
|
|
16
|
+
### Changes
|
|
17
|
+
- Added shared fullscreen rendering primitives for frame drawing, text writing, clipping, fills, rules, stable windows, and the workspace palette.
|
|
18
|
+
- Refactored `/config` and `/mcp` to use the same reusable fullscreen workspace base instead of maintaining separate one-off renderers.
|
|
19
|
+
- Updated onboarding to consume the shared primitive layer while preserving its wizard-specific layout and behavior.
|
|
20
|
+
|
|
7
21
|
## [0.20.0] — 2026-05-11
|
|
8
22
|
|
|
9
23
|
### 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
|
|
|
@@ -624,7 +624,7 @@ Isolation controls:
|
|
|
624
624
|
|
|
625
625
|
The QEMU path includes:
|
|
626
626
|
|
|
627
|
-
- setup bundle generation
|
|
627
|
+
- setup bundle generation under the user GoodVibes data directory by default
|
|
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
|
|
@@ -644,8 +644,8 @@ Key commands:
|
|
|
644
644
|
- `/sandbox recommend`
|
|
645
645
|
- `/sandbox doctor`
|
|
646
646
|
- `/sandbox probe`
|
|
647
|
-
- `/sandbox qemu setup
|
|
648
|
-
- `/sandbox qemu bootstrap
|
|
647
|
+
- `/sandbox qemu setup [dir]`
|
|
648
|
+
- `/sandbox qemu bootstrap [dir] [size-gb] [--scaffold-only|--no-build|--no-provision]`
|
|
649
649
|
- `/sandbox qemu inspect-setup <manifest>`
|
|
650
650
|
- `/sandbox qemu apply-setup <manifest>`
|
|
651
651
|
- `/sandbox session ...`
|
|
@@ -655,13 +655,13 @@ Key commands:
|
|
|
655
655
|
Typical first-run path:
|
|
656
656
|
|
|
657
657
|
```sh
|
|
658
|
-
/sandbox qemu bootstrap
|
|
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
|
|
658
|
+
/sandbox qemu bootstrap
|
|
661
659
|
/sandbox doctor
|
|
662
660
|
/sandbox guest-test eval-py
|
|
663
661
|
```
|
|
664
662
|
|
|
663
|
+
By default, QEMU setup files, the mutable qcow2 image, SSH keys, logs, and runtime state live in `~/.goodvibes/tui/sandbox`, not in the current project. Pass an explicit directory only when you intentionally want a non-default bundle location. Use `/sandbox qemu setup` or `/sandbox qemu bootstrap --scaffold-only` if you want to generate/review the bundle without building the image or provisioning guest runtimes.
|
|
664
|
+
|
|
665
665
|
See [QEMU sandbox bootstrapping](docs/qemu-sandbox.md) for host prerequisites, generated files, first-boot behavior, guest runtime packages, and troubleshooting logs.
|
|
666
666
|
|
|
667
667
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.2",
|
|
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",
|
|
@@ -154,14 +154,12 @@ export function renderSetupSandboxReview(ctx: CommandContext, snapshot: SetupRev
|
|
|
154
154
|
' next:',
|
|
155
155
|
];
|
|
156
156
|
if (backend === 'local') {
|
|
157
|
-
lines.push(' /sandbox qemu bootstrap
|
|
158
|
-
lines.push('
|
|
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');
|
|
157
|
+
lines.push(' /sandbox qemu bootstrap');
|
|
158
|
+
lines.push(' default bundle: ~/.goodvibes/tui/sandbox');
|
|
160
159
|
lines.push(' /sandbox doctor');
|
|
161
160
|
} else if (!image || !wrapper) {
|
|
162
|
-
lines.push(' /sandbox qemu
|
|
163
|
-
lines.push('
|
|
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');
|
|
161
|
+
lines.push(' /sandbox qemu bootstrap');
|
|
162
|
+
lines.push(' default bundle: ~/.goodvibes/tui/sandbox');
|
|
165
163
|
lines.push(' /sandbox doctor');
|
|
166
164
|
} else {
|
|
167
165
|
lines.push(' /sandbox guest-test eval-js');
|
|
@@ -73,7 +73,7 @@ export function registerLocalSetupCommands(registry: CommandRegistry): void {
|
|
|
73
73
|
...(`${ctx.platform.configManager.get('sandbox.vmBackend')}` === 'qemu' && !String(ctx.platform.configManager.get('sandbox.qemuImagePath')).trim()
|
|
74
74
|
? [' [WARN] sandbox: qemu backend selected without qemuImagePath'] : []),
|
|
75
75
|
...(`${ctx.platform.configManager.get('sandbox.vmBackend')}` === 'qemu' && !String(ctx.platform.configManager.get('sandbox.qemuExecWrapper')).trim()
|
|
76
|
-
? [' [WARN] sandbox: qemu backend selected without qemuExecWrapper', ' next: /sandbox
|
|
76
|
+
? [' [WARN] sandbox: qemu backend selected without qemuExecWrapper', ' next: /sandbox qemu setup'] : []),
|
|
77
77
|
...(`${ctx.platform.configManager.get('sandbox.vmBackend')}` === 'qemu' && String(ctx.platform.configManager.get('sandbox.qemuExecWrapper')).trim()
|
|
78
78
|
? [' [INFO] sandbox: wrapper bridge can be validated with GV_SANDBOX_WRAPPER_MODE=host-exec before wiring a real guest transport'] : []),
|
|
79
79
|
...(snapshot.serviceIssues.length > 0
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
3
|
import type { CommandContext } from '../command-registry.ts';
|
|
4
4
|
import {
|
|
5
5
|
applySandboxQemuSetupManifest,
|
|
@@ -11,6 +11,97 @@ import {
|
|
|
11
11
|
import { requireShellPaths } from './runtime-services.ts';
|
|
12
12
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
13
13
|
|
|
14
|
+
const DEFAULT_QEMU_SIZE_GB = 20;
|
|
15
|
+
|
|
16
|
+
interface ParsedQemuSetupArgs {
|
|
17
|
+
readonly directory: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ParsedQemuBootstrapArgs extends ParsedQemuSetupArgs {
|
|
21
|
+
readonly sizeGb: number;
|
|
22
|
+
readonly buildImage: boolean;
|
|
23
|
+
readonly provisionGuest: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function defaultQemuSandboxDirectory(shellPaths: ReturnType<typeof requireShellPaths>): string {
|
|
27
|
+
return shellPaths.resolveUserPath('tui', 'sandbox');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseSetupArgs(args: string[], shellPaths: ReturnType<typeof requireShellPaths>): ParsedQemuSetupArgs {
|
|
31
|
+
const directory = args[2] ?? defaultQemuSandboxDirectory(shellPaths);
|
|
32
|
+
return {
|
|
33
|
+
directory,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseBootstrapArgs(args: string[], shellPaths: ReturnType<typeof requireShellPaths>): ParsedQemuBootstrapArgs {
|
|
38
|
+
let directory = defaultQemuSandboxDirectory(shellPaths);
|
|
39
|
+
let sizeGb = DEFAULT_QEMU_SIZE_GB;
|
|
40
|
+
let buildImage = true;
|
|
41
|
+
let provisionGuest = true;
|
|
42
|
+
for (const arg of args.slice(2)) {
|
|
43
|
+
if (arg === '--scaffold-only') {
|
|
44
|
+
buildImage = false;
|
|
45
|
+
provisionGuest = false;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (arg === '--no-build') {
|
|
49
|
+
buildImage = false;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (arg === '--no-provision') {
|
|
53
|
+
provisionGuest = false;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (/^\d+$/.test(arg)) {
|
|
57
|
+
sizeGb = Number.parseInt(arg, 10);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
directory = arg;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
directory,
|
|
64
|
+
sizeGb,
|
|
65
|
+
buildImage,
|
|
66
|
+
provisionGuest,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function tailText(value: string, maxLines = 40): string {
|
|
71
|
+
const lines = value.trim().split(/\r?\n/).filter(Boolean);
|
|
72
|
+
return lines.slice(Math.max(0, lines.length - maxLines)).join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function runQemuBootstrapStep(
|
|
76
|
+
label: string,
|
|
77
|
+
command: string,
|
|
78
|
+
args: readonly string[],
|
|
79
|
+
options: {
|
|
80
|
+
readonly cwd: string;
|
|
81
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
82
|
+
readonly input?: string;
|
|
83
|
+
readonly timeoutMs: number;
|
|
84
|
+
},
|
|
85
|
+
): void {
|
|
86
|
+
const result = spawnSync(command, [...args], {
|
|
87
|
+
cwd: options.cwd,
|
|
88
|
+
env: { ...process.env, ...options.env },
|
|
89
|
+
input: options.input,
|
|
90
|
+
timeout: options.timeoutMs,
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
});
|
|
94
|
+
if (result.status === 0) return;
|
|
95
|
+
const stderr = tailText(result.stderr || '');
|
|
96
|
+
const stdout = tailText(result.stdout || '');
|
|
97
|
+
const reason = result.error instanceof Error ? result.error.message : `exit ${result.status ?? 'unknown'}`;
|
|
98
|
+
throw new Error([
|
|
99
|
+
`${label} failed (${reason}).`,
|
|
100
|
+
stderr ? `stderr:\n${stderr}` : '',
|
|
101
|
+
stdout ? `stdout:\n${stdout}` : '',
|
|
102
|
+
].filter(Boolean).join('\n'));
|
|
103
|
+
}
|
|
104
|
+
|
|
14
105
|
export async function handleSandboxQemuCommand(args: string[], ctx: CommandContext): Promise<boolean> {
|
|
15
106
|
const shellPaths = requireShellPaths(ctx);
|
|
16
107
|
const sub = (args[1] ?? '').toLowerCase();
|
|
@@ -20,12 +111,8 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
|
|
|
20
111
|
return true;
|
|
21
112
|
}
|
|
22
113
|
if (sub === 'setup') {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
ctx.print('Usage: /sandbox qemu setup <directory>');
|
|
26
|
-
return true;
|
|
27
|
-
}
|
|
28
|
-
const bundle = scaffoldSandboxQemuSetupBundle(ctx.platform.configManager, shellPaths.workingDirectory, dirArg, { surfaceRoot: 'tui' });
|
|
114
|
+
const parsed = parseSetupArgs(args, shellPaths);
|
|
115
|
+
const bundle = scaffoldSandboxQemuSetupBundle(ctx.platform.configManager, shellPaths.workingDirectory, parsed.directory, { surfaceRoot: 'tui' });
|
|
29
116
|
const manifest = loadSandboxQemuSetupManifest(shellPaths.workingDirectory, bundle.manifestPath);
|
|
30
117
|
applySandboxQemuSetupManifest(ctx.platform.configManager, manifest);
|
|
31
118
|
ctx.platform.configManager.setDynamic('sandbox.replIsolation', 'shared-vm');
|
|
@@ -45,19 +132,21 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
|
|
|
45
132
|
' applied: backend=qemu, qemu binary, wrapper path, image path, launch-per-command guest settings, shared VM isolation',
|
|
46
133
|
` next: ${bundle.imageCreateScriptPath} ${bundle.imagePath} 20G`,
|
|
47
134
|
` then provision runtimes: GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh ${bundle.wrapperPath} bash -s < ${bundle.guestBootstrapScriptPath}`,
|
|
135
|
+
'',
|
|
136
|
+
`Default setup location: ${defaultQemuSandboxDirectory(shellPaths)}`,
|
|
137
|
+
'Pass an explicit directory only when you intentionally want a non-default bundle location.',
|
|
48
138
|
].join('\n'));
|
|
49
139
|
return true;
|
|
50
140
|
}
|
|
51
141
|
if (sub === 'bootstrap') {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
ctx.print('Usage: /sandbox qemu bootstrap <directory> [size-gb]');
|
|
142
|
+
const parsed = parseBootstrapArgs(args, shellPaths);
|
|
143
|
+
if (!Number.isInteger(parsed.sizeGb) || parsed.sizeGb < 1) {
|
|
144
|
+
ctx.print('Usage: /sandbox qemu bootstrap [directory] [size-gb] [--scaffold-only|--no-build|--no-provision]');
|
|
56
145
|
return true;
|
|
57
146
|
}
|
|
58
147
|
try {
|
|
59
|
-
const bundle = bootstrapSandboxQemuSetupBundle(ctx.platform.configManager, shellPaths.workingDirectory,
|
|
60
|
-
|
|
148
|
+
const bundle = bootstrapSandboxQemuSetupBundle(ctx.platform.configManager, shellPaths.workingDirectory, parsed.directory, parsed.sizeGb, { surfaceRoot: 'tui' });
|
|
149
|
+
const lines = [
|
|
61
150
|
`Bootstrapped QEMU sandbox in ${bundle.directory}`,
|
|
62
151
|
` wrapper: ${bundle.wrapperPath}`,
|
|
63
152
|
` image path: ${bundle.imagePath}`,
|
|
@@ -67,10 +156,45 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
|
|
|
67
156
|
` projection policy: ${bundle.projectionPolicyPath}`,
|
|
68
157
|
` manifest: ${bundle.manifestPath}`,
|
|
69
158
|
' applied: backend=qemu, qemu binary, wrapper path, image path, launch-per-command guest settings, shared VM isolation',
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
159
|
+
];
|
|
160
|
+
if (parsed.buildImage) {
|
|
161
|
+
ctx.print(`Building QEMU image at ${bundle.imagePath} (${parsed.sizeGb}G). This can download the Debian cloud image on first run.`);
|
|
162
|
+
runQemuBootstrapStep('QEMU image build', bundle.imageCreateScriptPath, [bundle.imagePath, `${parsed.sizeGb}G`], {
|
|
163
|
+
cwd: bundle.directory,
|
|
164
|
+
timeoutMs: 30 * 60 * 1000,
|
|
165
|
+
});
|
|
166
|
+
lines.push(' image build: complete');
|
|
167
|
+
} else {
|
|
168
|
+
lines.push(` image build: skipped`);
|
|
169
|
+
lines.push(` next: ${bundle.imageCreateScriptPath} ${bundle.imagePath} ${parsed.sizeGb}G`);
|
|
170
|
+
}
|
|
171
|
+
if (parsed.provisionGuest) {
|
|
172
|
+
if (!existsSync(bundle.imagePath)) {
|
|
173
|
+
lines.push(' guest provisioning: skipped because image does not exist');
|
|
174
|
+
lines.push(` next: ${bundle.imageCreateScriptPath} ${bundle.imagePath} ${parsed.sizeGb}G`);
|
|
175
|
+
} else {
|
|
176
|
+
ctx.print(`Provisioning guest runtimes through ${bundle.wrapperPath}. First boot can take several minutes.`);
|
|
177
|
+
runQemuBootstrapStep('QEMU guest runtime provisioning', bundle.wrapperPath, ['bash', '-s'], {
|
|
178
|
+
cwd: bundle.directory,
|
|
179
|
+
env: {
|
|
180
|
+
GV_SANDBOX_SYNC_WORKSPACE: '0',
|
|
181
|
+
GV_SANDBOX_WRAPPER_MODE: 'launch-qemu-ssh',
|
|
182
|
+
},
|
|
183
|
+
input: readFileSync(bundle.guestBootstrapScriptPath, 'utf8'),
|
|
184
|
+
timeoutMs: 45 * 60 * 1000,
|
|
185
|
+
});
|
|
186
|
+
lines.push(' guest provisioning: complete');
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
lines.push(` guest provisioning: skipped`);
|
|
190
|
+
lines.push(` next: GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh ${bundle.wrapperPath} bash -s < ${bundle.guestBootstrapScriptPath}`);
|
|
191
|
+
}
|
|
192
|
+
lines.push(' verify: /sandbox doctor');
|
|
193
|
+
lines.push(' verify: /sandbox guest-test eval-py');
|
|
194
|
+
lines.push('');
|
|
195
|
+
lines.push(`Default setup location: ${defaultQemuSandboxDirectory(shellPaths)}`);
|
|
196
|
+
lines.push('Pass an explicit directory only when you intentionally want a non-default bundle location.');
|
|
197
|
+
ctx.print(lines.join('\n'));
|
|
74
198
|
} catch (error) {
|
|
75
199
|
ctx.print(summarizeError(error));
|
|
76
200
|
}
|
|
@@ -114,6 +238,6 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
|
|
|
114
238
|
ctx.print(`Applied QEMU sandbox setup from ${shellPaths.resolveWorkspacePath(pathArg)}.`);
|
|
115
239
|
return true;
|
|
116
240
|
}
|
|
117
|
-
ctx.print('Usage: /sandbox qemu <setup
|
|
241
|
+
ctx.print('Usage: /sandbox qemu <setup [directory]|bootstrap [directory] [size-gb] [--scaffold-only|--no-build|--no-provision]|recover <session-id>|inspect-setup <setup-manifest.json>|apply-setup <setup-manifest.json>>');
|
|
118
242
|
return true;
|
|
119
243
|
}
|
|
@@ -93,7 +93,7 @@ export function registerPlatformSandboxRuntimeCommands(registry: CommandRegistry
|
|
|
93
93
|
registry.register({
|
|
94
94
|
name: 'sandbox',
|
|
95
95
|
description: 'Review and configure VM isolation policy for MCP and evaluation runtimes',
|
|
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>]',
|
|
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 [dir]|bootstrap [dir] [size-gb]|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>]',
|
|
97
97
|
async handler(args, ctx) {
|
|
98
98
|
const shellPaths = requireShellPaths(ctx);
|
|
99
99
|
const sub = args[0] ?? 'open';
|
|
@@ -144,7 +144,7 @@ export function registerPlatformSandboxRuntimeCommands(registry: CommandRegistry
|
|
|
144
144
|
windowsMode: `${ctx.platform.configManager.get('sandbox.windowsMode')}`,
|
|
145
145
|
secureSandboxReady: renderSandboxReview(ctx.platform.configManager).includes('available'),
|
|
146
146
|
recommendedCommand: `${ctx.platform.configManager.get('sandbox.vmBackend')}` === 'local'
|
|
147
|
-
? '/sandbox qemu bootstrap
|
|
147
|
+
? '/sandbox qemu bootstrap'
|
|
148
148
|
: '/sandbox doctor',
|
|
149
149
|
};
|
|
150
150
|
ctx.print([inspectSandboxProbe(probe), ...backendProbe.warnings.map((warning: string) => ` warning: ${warning}`)].join('\n'));
|
|
@@ -207,7 +207,7 @@ export function registerPlatformSandboxRuntimeCommands(registry: CommandRegistry
|
|
|
207
207
|
` wrapper: ${bundle.wrapperPath}`,
|
|
208
208
|
` guest bundle: ${bundle.guestBundlePath}`,
|
|
209
209
|
` readme: ${bundle.readmePath}`,
|
|
210
|
-
' next: /sandbox qemu setup
|
|
210
|
+
' next: /sandbox qemu setup for the full default setup path',
|
|
211
211
|
].join('\n'));
|
|
212
212
|
return;
|
|
213
213
|
}
|
|
@@ -402,7 +402,7 @@ export function registerPlatformSandboxRuntimeCommands(registry: CommandRegistry
|
|
|
402
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'));
|
|
403
403
|
return;
|
|
404
404
|
}
|
|
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>]');
|
|
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 [dir]|bootstrap [dir] [size-gb]|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>]');
|
|
406
406
|
},
|
|
407
407
|
});
|
|
408
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
|
+
}
|