windows-development-sandbox 1.1.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/.sandbox-config.example.json +2 -1
  2. package/README.md +3 -2
  3. package/config.example.json +39 -5
  4. package/dist/cli/args.d.ts +3 -0
  5. package/dist/cli/args.js +3 -1
  6. package/dist/cli/boot-progress.d.ts +22 -0
  7. package/dist/cli/boot-progress.js +59 -0
  8. package/dist/cli/commands/accounts.js +2 -2
  9. package/dist/cli/commands/agent-commands.js +1 -1
  10. package/dist/cli/commands/context.js +2 -1
  11. package/dist/cli/commands/data/clean.js +6 -1
  12. package/dist/cli/commands/data/collect.js +3 -3
  13. package/dist/cli/commands/index.d.ts +1 -1
  14. package/dist/cli/commands/index.js +1 -1
  15. package/dist/cli/commands/launch.d.ts +5 -2
  16. package/dist/cli/commands/launch.js +113 -25
  17. package/dist/cli/commands/maintenance.d.ts +2 -0
  18. package/dist/cli/commands/maintenance.js +70 -4
  19. package/dist/cli/commands/project.d.ts +10 -6
  20. package/dist/cli/commands/project.js +42 -3
  21. package/dist/cli/commands/restart.js +17 -7
  22. package/dist/cli/commands/sessions.js +5 -4
  23. package/dist/cli/commands/user-config.js +1 -1
  24. package/dist/cli/index.js +6 -4
  25. package/dist/cli/manifest.js +6 -1
  26. package/dist/cli/menu/apps.d.ts +21 -0
  27. package/dist/cli/menu/apps.js +122 -0
  28. package/dist/cli/menu/command-form.d.ts +28 -0
  29. package/dist/cli/menu/command-form.js +219 -0
  30. package/dist/cli/menu/index.d.ts +26 -0
  31. package/dist/cli/menu/index.js +180 -0
  32. package/dist/cli/menu/settings/categories.d.ts +9 -0
  33. package/dist/cli/menu/settings/categories.js +28 -0
  34. package/dist/cli/menu/settings/config-file.d.ts +40 -0
  35. package/dist/cli/menu/settings/config-file.js +71 -0
  36. package/dist/cli/menu/settings/editors.d.ts +30 -0
  37. package/dist/cli/menu/settings/editors.js +305 -0
  38. package/dist/cli/menu/settings/index.d.ts +32 -0
  39. package/dist/cli/menu/settings/index.js +136 -0
  40. package/dist/cli/menu/settings/schema-node.d.ts +62 -0
  41. package/dist/cli/menu/settings/schema-node.js +104 -0
  42. package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
  43. package/dist/cli/menu/several.js +102 -0
  44. package/dist/cli/progress.d.ts +15 -0
  45. package/dist/cli/progress.js +52 -0
  46. package/dist/cli/tui/app.d.ts +39 -0
  47. package/dist/cli/tui/app.js +52 -0
  48. package/dist/cli/tui/dialogs.d.ts +57 -0
  49. package/dist/cli/tui/dialogs.js +162 -0
  50. package/dist/cli/tui/frame.d.ts +18 -0
  51. package/dist/cli/tui/frame.js +27 -0
  52. package/dist/cli/tui/index.d.ts +7 -0
  53. package/dist/cli/tui/index.js +8 -0
  54. package/dist/cli/tui/list-screen.d.ts +101 -0
  55. package/dist/cli/tui/list-screen.js +299 -0
  56. package/dist/cli/tui/terminal.d.ts +37 -0
  57. package/dist/cli/tui/terminal.js +89 -0
  58. package/dist/cli/tui/text.d.ts +20 -0
  59. package/dist/cli/tui/text.js +105 -0
  60. package/dist/cli/tui/theme.d.ts +45 -0
  61. package/dist/cli/tui/theme.js +30 -0
  62. package/dist/core/config.js +10 -7
  63. package/dist/core/local-config.d.ts +31 -1
  64. package/dist/core/local-config.js +72 -7
  65. package/dist/core/project-policy.d.ts +50 -3
  66. package/dist/core/project-policy.js +90 -4
  67. package/dist/core/schema/apps.d.ts +40 -0
  68. package/dist/core/schema/apps.js +27 -0
  69. package/dist/core/schema/config.d.ts +69 -3
  70. package/dist/core/schema/config.js +18 -6
  71. package/dist/core/schema/profile.d.ts +5 -0
  72. package/dist/core/schema/profile.js +4 -2
  73. package/dist/core/schema/projects.d.ts +1 -0
  74. package/dist/core/schema/projects.js +1 -0
  75. package/dist/core/schema/sandbox.d.ts +29 -1
  76. package/dist/core/schema/sandbox.js +29 -1
  77. package/dist/core/schema/toolchains.d.ts +3 -0
  78. package/dist/core/schema/toolchains.js +6 -1
  79. package/dist/core/types.d.ts +24 -1
  80. package/dist/core/util.d.ts +2 -0
  81. package/dist/core/util.js +2 -0
  82. package/dist/core/workspace.d.ts +21 -4
  83. package/dist/core/workspace.js +99 -21
  84. package/dist/guest/apps/index.d.ts +10 -0
  85. package/dist/guest/apps/index.js +206 -0
  86. package/dist/guest/apps/resolvers.d.ts +8 -0
  87. package/dist/guest/apps/resolvers.js +90 -0
  88. package/dist/guest/audit-parse.d.ts +7 -0
  89. package/dist/guest/audit-parse.js +24 -0
  90. package/dist/guest/audit.d.ts +4 -0
  91. package/dist/guest/audit.js +47 -0
  92. package/dist/guest/daemon/relays.d.ts +1 -0
  93. package/dist/guest/daemon/relays.js +18 -0
  94. package/dist/guest/daemon.js +4 -0
  95. package/dist/guest/env.d.ts +7 -2
  96. package/dist/guest/env.js +17 -3
  97. package/dist/guest/helpers.d.ts +1 -1
  98. package/dist/guest/main.js +20 -3
  99. package/dist/guest/netproxy.d.ts +1 -0
  100. package/dist/guest/netproxy.js +100 -0
  101. package/dist/guest/ptyhost.js +14 -4
  102. package/dist/guest/setup/claude.d.ts +0 -4
  103. package/dist/guest/setup/claude.js +48 -2
  104. package/dist/guest/setup/network.d.ts +9 -0
  105. package/dist/guest/setup/network.js +97 -4
  106. package/dist/guest/setup/user.js +4 -0
  107. package/dist/guest/toolchains/index.js +29 -3
  108. package/dist/guest/toolchains/native/mesa.js +13 -3
  109. package/dist/guest/toolchains/plan.d.ts +3 -1
  110. package/dist/guest/toolchains/plan.js +53 -9
  111. package/dist/guest/toolchains/python.js +26 -2
  112. package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
  113. package/dist/guest/toolchains/vscode-profile.js +69 -0
  114. package/dist/guest/toolchains/vscode.d.ts +17 -0
  115. package/dist/guest/toolchains/vscode.js +87 -1
  116. package/dist/host/attach.js +2 -1
  117. package/dist/host/audit/collect.d.ts +34 -0
  118. package/dist/host/audit/collect.js +126 -0
  119. package/dist/host/audit/log.d.ts +46 -0
  120. package/dist/host/audit/log.js +143 -0
  121. package/dist/host/channel.d.ts +2 -2
  122. package/dist/host/channel.js +3 -2
  123. package/dist/host/generate.d.ts +7 -1
  124. package/dist/host/generate.js +23 -6
  125. package/dist/host/join.d.ts +7 -0
  126. package/dist/host/join.js +23 -9
  127. package/dist/host/launch/mappings.d.ts +7 -2
  128. package/dist/host/launch/mappings.js +68 -18
  129. package/dist/host/launch/projects.d.ts +2 -0
  130. package/dist/host/launch/projects.js +11 -5
  131. package/dist/host/launch/stage.js +1 -1
  132. package/dist/host/motw.d.ts +17 -0
  133. package/dist/host/motw.js +74 -0
  134. package/dist/host/platform.js +3 -0
  135. package/dist/host/port-forward.d.ts +8 -0
  136. package/dist/host/port-forward.js +37 -0
  137. package/dist/host/projects.d.ts +25 -2
  138. package/dist/host/projects.js +54 -5
  139. package/dist/host/repo-guard.d.ts +10 -3
  140. package/dist/host/repo-guard.js +142 -14
  141. package/dist/host/stops.d.ts +67 -0
  142. package/dist/host/stops.js +126 -0
  143. package/dist/host/terminal.d.ts +8 -0
  144. package/dist/host/terminal.js +45 -1
  145. package/dist/host/workspace-view/index.d.ts +20 -0
  146. package/dist/host/workspace-view/index.js +53 -0
  147. package/dist/host/workspace-view/rules.d.ts +26 -0
  148. package/dist/host/workspace-view/rules.js +93 -0
  149. package/dist/host/workspace-view/view.d.ts +50 -0
  150. package/dist/host/workspace-view/view.js +281 -0
  151. package/dist/runtime/daemon.js +1387 -558
  152. package/dist/runtime/db.js +199 -14
  153. package/dist/runtime/main.js +3082 -631
  154. package/dist/runtime/netproxy.js +25948 -0
  155. package/dist/runtime/view.js +531 -148
  156. package/dist/selftest/audit.test.d.ts +2 -0
  157. package/dist/selftest/audit.test.js +57 -0
  158. package/dist/selftest/channel.test.js +45 -2
  159. package/dist/selftest/cli.test.js +47 -17
  160. package/dist/selftest/fixtures.d.ts +47 -1
  161. package/dist/selftest/fixtures.js +3 -1
  162. package/dist/selftest/generate.test.js +7 -2
  163. package/dist/selftest/index.js +6 -2
  164. package/dist/selftest/live-network.test.d.ts +2 -0
  165. package/dist/selftest/live-network.test.js +63 -0
  166. package/dist/selftest/live-terminal.test.d.ts +2 -0
  167. package/dist/selftest/live-terminal.test.js +169 -0
  168. package/dist/selftest/local-config.test.js +42 -4
  169. package/dist/selftest/mirror.test.js +36 -7
  170. package/dist/selftest/network.test.js +12 -0
  171. package/dist/selftest/projects.test.js +77 -8
  172. package/dist/selftest/repo-guard.test.js +59 -1
  173. package/dist/selftest/sync.test.js +50 -10
  174. package/dist/selftest/toolchains.test.js +30 -0
  175. package/dist/selftest/workspace-view.test.d.ts +2 -0
  176. package/dist/selftest/workspace-view.test.js +89 -0
  177. package/dist/shared/apps.d.ts +49 -0
  178. package/dist/shared/apps.js +114 -0
  179. package/dist/shared/console-input.d.ts +11 -0
  180. package/dist/shared/console-input.js +61 -0
  181. package/dist/shared/download.d.ts +3 -0
  182. package/dist/shared/download.js +25 -2
  183. package/dist/shared/guest-paths.d.ts +27 -0
  184. package/dist/shared/guest-paths.js +68 -0
  185. package/dist/shared/netpolicy.d.ts +27 -0
  186. package/dist/shared/netpolicy.js +51 -0
  187. package/dist/shared/projects.d.ts +27 -4
  188. package/dist/shared/projects.js +72 -13
  189. package/dist/shared/pty-client.d.ts +26 -4
  190. package/dist/shared/pty-client.js +204 -25
  191. package/dist/shared/rundir.d.ts +2 -0
  192. package/dist/shared/rundir.js +2 -0
  193. package/dist/shared/schema.d.ts +34 -0
  194. package/dist/shared/schema.js +14 -0
  195. package/dist/shared/system.d.ts +1 -0
  196. package/dist/shared/system.js +1 -0
  197. package/dist/sync/claude-mem.js +3 -2
  198. package/dist/sync/history.d.ts +6 -0
  199. package/dist/sync/history.js +26 -11
  200. package/dist/sync/watcher.js +78 -6
  201. package/package.json +2 -1
  202. package/sandbox/cargoshim.cs +56 -0
  203. package/sandbox/cpucap.cs +33 -0
  204. package/sandbox/cpucap.ps1 +4 -35
  205. package/sandbox/logon.cmd +6 -0
  206. package/sandbox/memlog.ps1 +34 -0
  207. package/sandbox/pagefile.cs +31 -0
  208. package/sandbox/pagefile.ps1 +21 -0
  209. package/sandbox/ptyhost.cs +162 -7
  210. package/schemas/config.schema.json +318 -3
  211. package/schemas/sandbox-config.schema.json +129 -14
  212. package/dist/cli/menu-multi.js +0 -82
  213. package/dist/cli/menu.d.ts +0 -14
  214. package/dist/cli/menu.js +0 -290
  215. package/dist/cli/settings/edit.d.ts +0 -13
  216. package/dist/cli/settings/edit.js +0 -19
  217. package/dist/cli/settings/index.d.ts +0 -8
  218. package/dist/cli/settings/index.js +0 -131
  219. package/dist/cli/settings/prompts.d.ts +0 -7
  220. package/dist/cli/settings/prompts.js +0 -48
  221. package/dist/cli/settings/schema.d.ts +0 -33
  222. package/dist/cli/settings/schema.js +0 -29
@@ -0,0 +1,102 @@
1
+ // "Start several agents": projects, then agents for each, then where to attach. It only builds a command line
2
+ // (sandbox launch --session <agent>@<folder> ...), so "repeat last" works and typing it does the same.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { assertSafeWorkspace } from '../../core/safe-paths.js';
6
+ import { errMsg, expandPath } from '../../core/util.js';
7
+ import { readKnown } from '../../host/projects.js';
8
+ import { isWithin } from '../../shared/projects.js';
9
+ import { commandLine } from '../history.js';
10
+ import { Text, Theme } from '../tui/index.js';
11
+ /** Folders worth offering: this one, the projects started before, and the folders inside your roots. */
12
+ export const candidateFolders = (ctx, cwd = process.cwd()) => {
13
+ const roots = ctx.loaded.config.projects.roots.map((r) => expandPath(r, ctx.loaded.base));
14
+ const inRoots = roots.flatMap((r) => { try {
15
+ return fs.readdirSync(r, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.isSymbolicLink() && !e.name.startsWith('.')).slice(0, 200).map((e) => path.join(r, e.name));
16
+ }
17
+ catch {
18
+ return [];
19
+ } });
20
+ const all = [path.resolve(cwd), ...readKnown(ctx.storageRoot).map((k) => k.host), ...inRoots];
21
+ return all.filter((f, i) => all.findIndex((g) => g.toLowerCase() === f.toLowerCase()) === i && fs.existsSync(f));
22
+ };
23
+ /** The command line for several sessions: one --session per (agent, folder), with the shared options. */
24
+ export const severalAgentsArgv = (sessions, o) => ['launch', ...sessions.flatMap((s) => ['--session', s.harness + '@' + s.folder]), ...(o.headless ? ['--headless'] : []), ...(o.attachFirst ? ['-t'] : [])];
25
+ const ADD = '@add';
26
+ export class SeveralAgents {
27
+ app;
28
+ ctx;
29
+ path = ['Start several agents'];
30
+ constructor(app, ctx) {
31
+ this.app = app;
32
+ this.ctx = ctx;
33
+ }
34
+ async run() {
35
+ const folders = await this.folders();
36
+ if (!folders?.length)
37
+ return null;
38
+ const agents = Object.keys(this.ctx.loaded.config.harnesses);
39
+ const sessions = [];
40
+ for (const folder of folders) {
41
+ const picked = await this.app.list({
42
+ path: [...this.path, path.basename(folder)], intro: 'Agents for ' + folder + ' (one session each). Space ticks, Enter continues.', multi: true,
43
+ items: agents.map((a) => ({ value: a, label: a, checked: a === this.ctx.loaded.config.defaultHarness })),
44
+ });
45
+ if (picked.kind !== 'multi')
46
+ return null;
47
+ sessions.push(...picked.values.map((harness) => ({ harness, folder })));
48
+ }
49
+ if (!sessions.length)
50
+ return null;
51
+ const headless = await this.app.confirm({ path: this.path, question: 'Hide the sandbox window (headless)?', about: ['sandbox show brings it back.'], initial: this.ctx.loaded.config.sandbox.headless });
52
+ const attach = await this.app.choose(this.path, 'Attach this terminal to one of them? Each session also has a tab inside the sandbox; sandbox attach shows any of them later.', [
53
+ { value: -1, label: 'None' },
54
+ ...sessions.map((s, i) => ({ value: i, label: s.harness + ' in ' + Text.middle(s.folder, 60) })),
55
+ ], 0);
56
+ if (attach === undefined)
57
+ return null;
58
+ // The one to attach goes first: -t attaches the first session.
59
+ const ordered = attach >= 0 ? [sessions[attach], ...sessions.filter((_s, i) => i !== attach)] : sessions;
60
+ const argv = severalAgentsArgv(ordered, { headless, attachFirst: attach >= 0 });
61
+ const projects = [...new Set(sessions.map((s) => path.basename(s.folder)))];
62
+ const go = await this.app.confirm({
63
+ path: this.path, question: 'Start them?',
64
+ about: ['One sandbox with ' + sessions.length + ' session' + (sessions.length === 1 ? '' : 's') + ': ' + ordered.map((s) => s.harness + ' in ' + path.basename(s.folder)).join(', ') + '. Maps ' + projects.join(' and ') + '.', '', '$ ' + commandLine(argv)],
65
+ });
66
+ return go ? argv : null;
67
+ }
68
+ async folders() {
69
+ const here = path.resolve(process.cwd());
70
+ const candidates = candidateFolders(this.ctx);
71
+ const picked = await this.app.list({
72
+ path: this.path, intro: 'Which projects? Each gets its own sessions; all of them are mapped into one sandbox. Space ticks, Enter continues.', multi: true,
73
+ items: [
74
+ ...candidates.map((f) => ({ value: f, label: Text.middle(f, 90), hint: isWithin(f, here) && isWithin(here, f) ? Theme.muted('this folder') : undefined, checked: f.toLowerCase() === here.toLowerCase() })),
75
+ { value: ADD, label: '+ Another folder…' },
76
+ ],
77
+ });
78
+ if (picked.kind !== 'multi')
79
+ return null;
80
+ const folders = picked.values.filter((f) => f !== ADD);
81
+ if (picked.values.includes(ADD)) {
82
+ const extra = await this.app.text({
83
+ path: this.path, label: 'Folder to add',
84
+ validate: (v) => {
85
+ const dir = path.resolve(v.trim());
86
+ if (!v.trim() || !fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
87
+ return 'Not a folder: ' + dir;
88
+ try {
89
+ assertSafeWorkspace(dir, this.ctx.packageRoot, this.ctx.storageRoot);
90
+ return true;
91
+ }
92
+ catch (e) {
93
+ return errMsg(e);
94
+ }
95
+ },
96
+ });
97
+ if (extra)
98
+ folders.push(path.resolve(extra.trim()));
99
+ }
100
+ return folders;
101
+ }
102
+ }
@@ -2,6 +2,21 @@
2
2
  export declare const canAnimate: (env: NodeJS.ProcessEnv, isTTY: boolean) => boolean;
3
3
  /** "12 s", "2 min 5 s". */
4
4
  export declare const elapsed: (ms: number) => string;
5
+ /** One line of progress: `line` is shown, `key` changes when it is worth a new plain line, `what` names what runs. */
6
+ export interface Progressing {
7
+ line: string;
8
+ key: string;
9
+ what: string;
10
+ }
11
+ /**
12
+ * Follows long work (a sandbox's boot): on a terminal one line redrawn in place with the time so far; without one a
13
+ * plain line each time `key` changes. When nothing changes for `stallMs` it says so (stall(what, ms)). A failure first
14
+ * prints where it stopped, then rethrows.
15
+ */
16
+ export declare const follow: <T>(show: () => Progressing, work: () => Promise<T>, o: {
17
+ stallMs?: number;
18
+ stall: (what: string, ms: number) => string;
19
+ }) => Promise<T>;
5
20
  /**
6
21
  * Shows `message` while work runs and removes the line afterwards. The spinner only moves while the event loop is free
7
22
  * (waiting on the sandbox); for long synchronous work (measuring folders) the static line is what you see.
@@ -9,6 +9,58 @@ export const elapsed = (ms) => {
9
9
  const s = Math.round(ms / 1000);
10
10
  return s < 60 ? s + ' s' : Math.floor(s / 60) + ' min ' + (s % 60) + ' s';
11
11
  };
12
+ /**
13
+ * Follows long work (a sandbox's boot): on a terminal one line redrawn in place with the time so far; without one a
14
+ * plain line each time `key` changes. When nothing changes for `stallMs` it says so (stall(what, ms)). A failure first
15
+ * prints where it stopped, then rethrows.
16
+ */
17
+ export const follow = async (show, work, o) => {
18
+ const err = process.stderr;
19
+ const animate = canAnimate(process.env, Boolean(err.isTTY));
20
+ const colour = colorEnabled(process.env, true);
21
+ const start = Date.now();
22
+ const stallMs = o.stallMs ?? 3 * 60 * 1000;
23
+ let shown = show();
24
+ let lastKey = '';
25
+ let changedAt = start;
26
+ let noticedAt = start;
27
+ let frame = 0;
28
+ const clear = () => { if (animate)
29
+ err.write('\r\x1b[2K'); };
30
+ const tick = () => {
31
+ const now = show();
32
+ if (now.line !== shown.line)
33
+ changedAt = noticedAt = Date.now();
34
+ shown = now;
35
+ if (Date.now() - noticedAt >= stallMs) {
36
+ clear();
37
+ err.write(o.stall(shown.what, Date.now() - changedAt) + '\n');
38
+ noticedAt = Date.now();
39
+ }
40
+ if (animate) {
41
+ const time = Date.now() - start > 3000 ? ' (' + elapsed(Date.now() - start) + ')' : '';
42
+ err.write('\r\x1b[2K' + (colour ? '\x1b[36m' : '') + FRAMES[frame++ % FRAMES.length] + (colour ? '\x1b[0m' : '') + ' ' + shorten(shown.line, termWidth() - 4 - time.length) + time);
43
+ }
44
+ else if (shown.key !== lastKey) {
45
+ lastKey = shown.key;
46
+ err.write(shown.line + (Date.now() - start > 3000 ? ' (' + elapsed(Date.now() - start) + ')' : '') + '\n');
47
+ }
48
+ };
49
+ tick();
50
+ const timer = setInterval(tick, animate ? 150 : 1000);
51
+ try {
52
+ return await work();
53
+ }
54
+ catch (e) {
55
+ clear();
56
+ err.write('It stopped at: ' + shown.line + '\n');
57
+ throw e;
58
+ }
59
+ finally {
60
+ clearInterval(timer);
61
+ clear();
62
+ }
63
+ };
12
64
  /**
13
65
  * Shows `message` while work runs and removes the line afterwards. The spinner only moves while the event loop is free
14
66
  * (waiting on the sandbox); for long synchronous work (measuring folders) the static line is what you see.
@@ -0,0 +1,39 @@
1
+ import { MessageScreen, type ConfirmOptions, type TextOptions } from './dialogs.js';
2
+ import { type FrameParts } from './frame.js';
3
+ import { type ListOptions, type ListResult } from './list-screen.js';
4
+ import { Terminal, type Key } from './terminal.js';
5
+ export interface Size {
6
+ width: number;
7
+ height: number;
8
+ }
9
+ /** What a screen's key handler returns: `{ done: value }` ends the screen, nothing keeps it open (and redraws it). */
10
+ export type Step<T> = {
11
+ done: T;
12
+ } | undefined | void;
13
+ export interface Screen<T> {
14
+ render(size: Size): FrameParts;
15
+ handle(key: Key): Step<T> | Promise<Step<T>>;
16
+ }
17
+ export declare class App {
18
+ readonly terminal: Terminal;
19
+ /** The status line for screens that do not set their own (refreshed on every draw). */
20
+ status: () => string;
21
+ constructor(terminal?: Terminal);
22
+ /** Shows `screen` until its handler says it is done, and returns what it returned. */
23
+ show<T>(screen: Screen<T>): Promise<T>;
24
+ list<T>(o: ListOptions<T>): Promise<ListResult<T>>;
25
+ text(o: TextOptions): Promise<string | undefined>;
26
+ confirm(o: ConfirmOptions): Promise<boolean>;
27
+ message(o: ConstructorParameters<typeof MessageScreen>[0]): Promise<void>;
28
+ /** One of a few values (undefined = back). */
29
+ choose<T>(path: string[], intro: string, choices: {
30
+ value: T;
31
+ label: string;
32
+ description?: string;
33
+ hint?: string;
34
+ }[], current?: T): Promise<T | undefined>;
35
+ /** Runs `work` on the normal screen (command output, interactive commands); the next show() takes the screen back. */
36
+ suspend<T>(work: () => Promise<T>): Promise<T>;
37
+ /** Leaves the alternate screen for good. */
38
+ close(): void;
39
+ }
@@ -0,0 +1,52 @@
1
+ // The menu as an application: screens are shown one at a time in the frame, each until it is done. Commands that print
2
+ // (or take over the terminal) run with the terminal handed back, and the next screen takes it again.
3
+ import { ConfirmScreen, MessageScreen, TextScreen } from './dialogs.js';
4
+ import { Frame } from './frame.js';
5
+ import { ListScreen } from './list-screen.js';
6
+ import { Terminal } from './terminal.js';
7
+ import { Theme } from './theme.js';
8
+ export class App {
9
+ terminal;
10
+ /** The status line for screens that do not set their own (refreshed on every draw). */
11
+ status = () => '';
12
+ constructor(terminal = new Terminal()) {
13
+ this.terminal = terminal;
14
+ }
15
+ /** Shows `screen` until its handler says it is done, and returns what it returned. */
16
+ async show(screen) {
17
+ this.terminal.start();
18
+ const draw = () => {
19
+ const { width, height } = this.terminal;
20
+ const parts = screen.render({ width, height: Frame.bodyHeight(height) });
21
+ this.terminal.render(Frame.render({ ...parts, status: parts.status ?? this.status() }, width, height));
22
+ };
23
+ this.terminal.onResized(draw);
24
+ try {
25
+ for (;;) {
26
+ draw();
27
+ const step = await screen.handle(await this.terminal.key());
28
+ if (step)
29
+ return step.done;
30
+ }
31
+ }
32
+ finally {
33
+ this.terminal.onResized(null);
34
+ }
35
+ }
36
+ list(o) { return this.show(new ListScreen(o)); }
37
+ text(o) { return this.show(new TextScreen(o)); }
38
+ confirm(o) { return this.show(new ConfirmScreen(o)); }
39
+ message(o) { return this.show(new MessageScreen(o)); }
40
+ /** One of a few values (undefined = back). */
41
+ async choose(path, intro, choices, current) {
42
+ const picked = await this.list({ path, intro, items: choices.map((c) => ({ ...c, icon: c.value === current ? Theme.ok(Theme.glyph.on) : Theme.muted(Theme.glyph.off) })), startAt: current });
43
+ return picked.kind === 'pick' ? picked.value : undefined;
44
+ }
45
+ /** Runs `work` on the normal screen (command output, interactive commands); the next show() takes the screen back. */
46
+ async suspend(work) {
47
+ this.terminal.stop();
48
+ return work();
49
+ }
50
+ /** Leaves the alternate screen for good. */
51
+ close() { this.terminal.stop(); }
52
+ }
@@ -0,0 +1,57 @@
1
+ import type { FrameParts } from './frame.js';
2
+ import type { Screen, Size, Step } from './app.js';
3
+ import type { Key } from './terminal.js';
4
+ export interface TextOptions {
5
+ path: string[];
6
+ label: string;
7
+ /** Explanation above the field (plain lines). */
8
+ about?: string[];
9
+ initial?: string;
10
+ placeholder?: string;
11
+ /** true = fine, a string = what is wrong (shown under the field). */
12
+ validate?: (value: string) => true | string;
13
+ /** Allow an empty answer (otherwise Enter on an empty field cancels). */
14
+ allowEmpty?: boolean;
15
+ }
16
+ /** One line of text. Resolves to the text, or undefined when you press Esc. */
17
+ export declare class TextScreen implements Screen<string | undefined> {
18
+ private readonly o;
19
+ private value;
20
+ private cursor;
21
+ private problem;
22
+ constructor(o: TextOptions);
23
+ render(size: Size): FrameParts;
24
+ handle(key: Key): Step<string | undefined>;
25
+ private check;
26
+ }
27
+ export interface ConfirmOptions {
28
+ path: string[];
29
+ question: string;
30
+ about?: string[];
31
+ yes?: string;
32
+ no?: string;
33
+ initial?: boolean;
34
+ danger?: boolean;
35
+ }
36
+ /** Yes or no. Esc answers no. */
37
+ export declare class ConfirmScreen implements Screen<boolean> {
38
+ private readonly o;
39
+ private yes;
40
+ constructor(o: ConfirmOptions);
41
+ render(size: Size): FrameParts;
42
+ handle(key: Key): Step<boolean>;
43
+ }
44
+ /** Text to read (scrolls); any key but the arrows closes it. */
45
+ export declare class MessageScreen implements Screen<void> {
46
+ private readonly o;
47
+ private offset;
48
+ private height;
49
+ constructor(o: {
50
+ path: string[];
51
+ title?: string;
52
+ lines: string[];
53
+ tone?: 'ok' | 'warn' | 'error';
54
+ });
55
+ render(size: Size): FrameParts;
56
+ handle(key: Key): Step<void>;
57
+ }
@@ -0,0 +1,162 @@
1
+ import { Text } from './text.js';
2
+ import { Theme } from './theme.js';
3
+ /** Plain lines are wrapped to the width; coloured ones are kept as they are (the frame cuts them). */
4
+ const paragraphs = (lines, width) => lines.flatMap((l) => (Text.strip(l) === l ? Text.wrap(l, width - 4) : [l]).map((x) => ' ' + x));
5
+ /** One line of text. Resolves to the text, or undefined when you press Esc. */
6
+ export class TextScreen {
7
+ o;
8
+ value;
9
+ cursor;
10
+ problem;
11
+ constructor(o) {
12
+ this.o = o;
13
+ this.value = o.initial ?? '';
14
+ this.cursor = [...this.value].length;
15
+ }
16
+ render(size) {
17
+ const width = Math.min(size.width - 4, 100);
18
+ const chars = [...this.value];
19
+ // Scroll the field so the cursor stays visible.
20
+ const inner = width - 4;
21
+ const start = Math.max(0, this.cursor - inner + 1);
22
+ const shown = chars.slice(start, start + inner);
23
+ const before = shown.slice(0, this.cursor - start).join('');
24
+ const at = shown[this.cursor - start] ?? ' ';
25
+ const after = shown.slice(this.cursor - start + 1).join('');
26
+ const text = this.value === '' && this.o.placeholder ? Theme.muted(this.o.placeholder) : before + (Theme.colour ? '\x1b[7m' + at + '\x1b[27m' : '_') + after;
27
+ const body = [
28
+ '',
29
+ ' ' + Theme.bold(this.o.label),
30
+ ...(this.o.about?.length ? paragraphs(this.o.about, size.width).map((l) => Theme.muted(l)) : []),
31
+ '',
32
+ ' ' + Theme.accent('╭' + '─'.repeat(width - 2) + '╮'),
33
+ ' ' + Theme.accent('│') + ' ' + Text.fit(text, inner) + ' ' + Theme.accent('│'),
34
+ ' ' + Theme.accent('╰' + '─'.repeat(width - 2) + '╯'),
35
+ this.problem ? ' ' + Theme.error(Theme.glyph.cross + ' ' + this.problem) : '',
36
+ ];
37
+ return { path: this.o.path, body, keys: [['↵', 'save'], ['esc', 'cancel'], ['ctrl+u', 'clear'], ['←→', 'move']] };
38
+ }
39
+ handle(key) {
40
+ const chars = [...this.value];
41
+ const set = (next, cursor) => { this.value = next.join(''); this.cursor = Math.max(0, Math.min(next.length, cursor)); this.check(); };
42
+ if (key.name === 'escape')
43
+ return { done: undefined };
44
+ if (key.name === 'return') {
45
+ if (this.value === '' && !this.o.allowEmpty)
46
+ return { done: undefined };
47
+ const verdict = this.o.validate?.(this.value) ?? true;
48
+ if (verdict !== true) {
49
+ this.problem = verdict;
50
+ return;
51
+ }
52
+ return { done: this.value };
53
+ }
54
+ if (key.name === 'left') {
55
+ this.cursor = Math.max(0, this.cursor - 1);
56
+ return;
57
+ }
58
+ if (key.name === 'right') {
59
+ this.cursor = Math.min(chars.length, this.cursor + 1);
60
+ return;
61
+ }
62
+ if (key.name === 'home') {
63
+ this.cursor = 0;
64
+ return;
65
+ }
66
+ if (key.name === 'end') {
67
+ this.cursor = chars.length;
68
+ return;
69
+ }
70
+ if (key.name === 'backspace') {
71
+ if (this.cursor > 0)
72
+ set([...chars.slice(0, this.cursor - 1), ...chars.slice(this.cursor)], this.cursor - 1);
73
+ return;
74
+ }
75
+ if (key.name === 'delete') {
76
+ set([...chars.slice(0, this.cursor), ...chars.slice(this.cursor + 1)], this.cursor);
77
+ return;
78
+ }
79
+ if (key.ctrl && key.name === 'u') {
80
+ set([], 0);
81
+ return;
82
+ }
83
+ if (key.text) {
84
+ const add = [...key.text.replace(/[\r\n]/g, '')];
85
+ set([...chars.slice(0, this.cursor), ...add, ...chars.slice(this.cursor)], this.cursor + add.length);
86
+ }
87
+ return;
88
+ }
89
+ check() {
90
+ const verdict = this.value === '' ? true : this.o.validate?.(this.value) ?? true;
91
+ this.problem = verdict === true ? undefined : verdict;
92
+ }
93
+ }
94
+ /** Yes or no. Esc answers no. */
95
+ export class ConfirmScreen {
96
+ o;
97
+ yes;
98
+ constructor(o) {
99
+ this.o = o;
100
+ this.yes = o.initial ?? true;
101
+ }
102
+ render(size) {
103
+ const button = (label, on, danger = false) => (on ? (danger ? Theme.colour ? '\x1b[48;5;160m\x1b[97m ' + label + ' \x1b[0m' : '[' + label + ']' : Theme.selected(' ' + label + ' ')) : Theme.muted(' ' + label + ' '));
104
+ const body = [
105
+ '',
106
+ ' ' + Theme.bold(this.o.question),
107
+ ...(this.o.about?.length ? ['', ...paragraphs(this.o.about, size.width)] : []),
108
+ '',
109
+ ' ' + button(this.o.yes ?? 'Yes', this.yes, this.o.danger) + ' ' + button(this.o.no ?? 'No', !this.yes),
110
+ ];
111
+ return { path: this.o.path, body, keys: [['←→', 'choose'], ['↵', 'confirm'], ['y/n', 'answer'], ['esc', 'no']] };
112
+ }
113
+ handle(key) {
114
+ if (key.name === 'left' || key.name === 'right' || key.name === 'tab') {
115
+ this.yes = !this.yes;
116
+ return;
117
+ }
118
+ if (key.text?.toLowerCase() === 'y')
119
+ return { done: true };
120
+ if (key.text?.toLowerCase() === 'n' || key.name === 'escape')
121
+ return { done: false };
122
+ if (key.name === 'return')
123
+ return { done: this.yes };
124
+ return;
125
+ }
126
+ }
127
+ /** Text to read (scrolls); any key but the arrows closes it. */
128
+ export class MessageScreen {
129
+ o;
130
+ offset = 0;
131
+ height = 10;
132
+ constructor(o) {
133
+ this.o = o;
134
+ }
135
+ render(size) {
136
+ const paint = this.o.tone === 'ok' ? Theme.ok : this.o.tone === 'warn' ? Theme.warn : this.o.tone === 'error' ? Theme.error : Theme.bold;
137
+ const head = this.o.title ? ['', ' ' + paint(this.o.title), ''] : [''];
138
+ const lines = this.o.lines.flatMap((l) => (Text.width(l) > size.width - 4 && Text.strip(l) === l ? Text.wrap(l, size.width - 4) : [l])).map((l) => ' ' + l);
139
+ this.height = size.height - head.length;
140
+ this.offset = Math.max(0, Math.min(this.offset, lines.length - this.height));
141
+ return { path: this.o.path, body: [...head, ...lines.slice(this.offset, this.offset + this.height)], keys: lines.length > this.height ? [['↑↓', 'scroll'], ['↵', 'close']] : [['any key', 'close']] };
142
+ }
143
+ handle(key) {
144
+ if (key.name === 'up') {
145
+ this.offset--;
146
+ return;
147
+ }
148
+ if (key.name === 'down') {
149
+ this.offset++;
150
+ return;
151
+ }
152
+ if (key.name === 'pageup') {
153
+ this.offset -= this.height;
154
+ return;
155
+ }
156
+ if (key.name === 'pagedown') {
157
+ this.offset += this.height;
158
+ return;
159
+ }
160
+ return { done: undefined };
161
+ }
162
+ }
@@ -0,0 +1,18 @@
1
+ export interface FrameParts {
2
+ /** Where you are, e.g. ['Settings', 'Languages and tools']. */
3
+ path: string[];
4
+ /** Short line under the title (what is running, which folder). */
5
+ status?: string;
6
+ body: string[];
7
+ /** [key, what it does] pairs, e.g. [['↵', 'open'], ['esc', 'back']]. */
8
+ keys: [string, string][];
9
+ /** A message for the last action (saved, invalid ...), shown above the footer. */
10
+ notice?: string;
11
+ }
12
+ export declare class Frame {
13
+ static title: string;
14
+ static version: string;
15
+ /** Rows the body may use on a terminal `height` rows high (title, status, two rules, notice and keys take 6). */
16
+ static bodyHeight(height: number): number;
17
+ static render(parts: FrameParts, width: number, height: number): string[];
18
+ }
@@ -0,0 +1,27 @@
1
+ // The frame every screen is drawn in: a title bar with where you are, a status line, the screen's own body and a footer
2
+ // with the keys that work here.
3
+ import { Text } from './text.js';
4
+ import { Theme } from './theme.js';
5
+ export class Frame {
6
+ static title = 'Windows Development Sandbox';
7
+ static version = '';
8
+ /** Rows the body may use on a terminal `height` rows high (title, status, two rules, notice and keys take 6). */
9
+ static bodyHeight(height) {
10
+ return Math.max(3, height - 6);
11
+ }
12
+ static render(parts, width, height) {
13
+ const w = width - 1;
14
+ const crumbs = [Frame.title, ...parts.path].map((p, i, all) => (i === all.length - 1 ? Theme.bold(p) : p)).join(Theme.dim(' › '));
15
+ const right = Frame.version ? 'v' + Frame.version + ' ' : '';
16
+ const title = Theme.bar(Text.fit(' ' + Theme.accent('◆') + ' ' + crumbs, w - Text.width(right)) + right);
17
+ const status = ' ' + Text.truncate(parts.status ?? '', w - 2);
18
+ const rule = Theme.dim(Theme.glyph.line.repeat(w));
19
+ const bodyRows = Frame.bodyHeight(height);
20
+ const body = parts.body.slice(0, bodyRows);
21
+ while (body.length < bodyRows)
22
+ body.push('');
23
+ const notice = parts.notice ? ' ' + Text.truncate(parts.notice, w - 2) : '';
24
+ const keys = ' ' + parts.keys.map(([k, what]) => Theme.key(k) + ' ' + Theme.muted(what)).join(' ');
25
+ return [title, status, rule, ...body, notice, rule, Text.truncate(keys, w)];
26
+ }
27
+ }
@@ -0,0 +1,7 @@
1
+ export { App, type Screen, type Size, type Step } from './app.js';
2
+ export { ConfirmScreen, MessageScreen, TextScreen } from './dialogs.js';
3
+ export { Frame } from './frame.js';
4
+ export { ListScreen, type Category, type ListItem, type ListOptions, type ListResult, type Tab } from './list-screen.js';
5
+ export { ExitRequested, Terminal, type Key } from './terminal.js';
6
+ export { Text } from './text.js';
7
+ export { Theme } from './theme.js';
@@ -0,0 +1,8 @@
1
+ // The menu's screen toolkit: a full-screen app (App) showing one screen at a time in a common frame.
2
+ export { App } from './app.js';
3
+ export { ConfirmScreen, MessageScreen, TextScreen } from './dialogs.js';
4
+ export { Frame } from './frame.js';
5
+ export { ListScreen } from './list-screen.js';
6
+ export { ExitRequested, Terminal } from './terminal.js';
7
+ export { Text } from './text.js';
8
+ export { Theme } from './theme.js';
@@ -0,0 +1,101 @@
1
+ import type { FrameParts } from './frame.js';
2
+ import type { Screen, Size, Step } from './app.js';
3
+ import type { Key } from './terminal.js';
4
+ export interface ListItem<T> {
5
+ value: T;
6
+ label: string;
7
+ /** Right-hand column: a setting's value, a size, a state. */
8
+ hint?: string;
9
+ description?: string;
10
+ /** Extra lines for the details pane (already formatted). */
11
+ details?: string[];
12
+ /** Heading the entry is listed under (entries with the same section should be next to each other). */
13
+ section?: string;
14
+ /** Sidebar category id. */
15
+ category?: string;
16
+ /** More words search should find it by. */
17
+ keywords?: string;
18
+ /** Why it cannot be picked (shown dim). */
19
+ disabled?: string;
20
+ /** Multi-select: ticked. */
21
+ checked?: boolean;
22
+ /** A glyph before the label. */
23
+ icon?: string;
24
+ }
25
+ export interface Category {
26
+ id: string;
27
+ label: string;
28
+ description?: string;
29
+ }
30
+ export interface ListAction {
31
+ key: string;
32
+ label: string;
33
+ }
34
+ /** Tabs above the list (e.g. which settings file): Tab switches, the screen reopens on the other one. */
35
+ export interface Tab {
36
+ label: string;
37
+ badge: string;
38
+ paint: (text: string) => string;
39
+ }
40
+ export type ListResult<T> = {
41
+ kind: 'pick';
42
+ value: T;
43
+ } | {
44
+ kind: 'back';
45
+ } | {
46
+ kind: 'action';
47
+ key: string;
48
+ value: T | undefined;
49
+ } | {
50
+ kind: 'multi';
51
+ values: T[];
52
+ } | {
53
+ kind: 'tab';
54
+ index: number;
55
+ };
56
+ export interface ListOptions<T> {
57
+ path: string[];
58
+ status?: string;
59
+ /** A line or two above the list. */
60
+ intro?: string | string[];
61
+ items: ListItem<T>[];
62
+ categories?: Category[];
63
+ multi?: boolean;
64
+ /** Start on this entry (and its category). */
65
+ startAt?: T;
66
+ startCategory?: string;
67
+ actions?: ListAction[];
68
+ notice?: string;
69
+ empty?: string;
70
+ tabs?: {
71
+ items: Tab[];
72
+ active: number;
73
+ };
74
+ /** What Esc does, for the footer ("back", "quit"). */
75
+ escapeLabel?: string;
76
+ }
77
+ export declare class ListScreen<T> implements Screen<ListResult<T>> {
78
+ private readonly o;
79
+ private category;
80
+ private focus;
81
+ private cursor;
82
+ private offset;
83
+ private query;
84
+ private searching;
85
+ private notice;
86
+ private readonly checked;
87
+ constructor(o: ListOptions<T>);
88
+ /** Indexes of the entries shown now: all matches of the search, else the current category's, else all. */
89
+ private visible;
90
+ private rows;
91
+ render(size: Size): FrameParts;
92
+ private rowLine;
93
+ private sideLines;
94
+ private detailsLines;
95
+ private keys;
96
+ /** Programmatic notice (e.g. "Saved") shown until the next key. */
97
+ setNotice(text: string | undefined): void;
98
+ handle(key: Key): Step<ListResult<T>>;
99
+ private toggle;
100
+ private switchCategory;
101
+ }