windows-development-sandbox 1.1.0 → 1.4.0

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 (187) hide show
  1. package/README.md +3 -2
  2. package/config.example.json +38 -5
  3. package/dist/cli/args.d.ts +3 -0
  4. package/dist/cli/args.js +3 -1
  5. package/dist/cli/commands/accounts.js +2 -2
  6. package/dist/cli/commands/agent-commands.js +1 -1
  7. package/dist/cli/commands/context.js +2 -1
  8. package/dist/cli/commands/data/clean.js +6 -1
  9. package/dist/cli/commands/data/collect.js +3 -3
  10. package/dist/cli/commands/index.d.ts +1 -1
  11. package/dist/cli/commands/index.js +1 -1
  12. package/dist/cli/commands/launch.js +36 -3
  13. package/dist/cli/commands/maintenance.d.ts +2 -0
  14. package/dist/cli/commands/maintenance.js +35 -2
  15. package/dist/cli/commands/sessions.js +3 -3
  16. package/dist/cli/commands/user-config.js +1 -1
  17. package/dist/cli/index.js +6 -4
  18. package/dist/cli/manifest.js +5 -0
  19. package/dist/cli/menu/apps.d.ts +21 -0
  20. package/dist/cli/menu/apps.js +122 -0
  21. package/dist/cli/menu/command-form.d.ts +28 -0
  22. package/dist/cli/menu/command-form.js +219 -0
  23. package/dist/cli/menu/index.d.ts +26 -0
  24. package/dist/cli/menu/index.js +180 -0
  25. package/dist/cli/menu/settings/categories.d.ts +9 -0
  26. package/dist/cli/menu/settings/categories.js +28 -0
  27. package/dist/cli/menu/settings/config-file.d.ts +40 -0
  28. package/dist/cli/menu/settings/config-file.js +71 -0
  29. package/dist/cli/menu/settings/editors.d.ts +30 -0
  30. package/dist/cli/menu/settings/editors.js +305 -0
  31. package/dist/cli/menu/settings/index.d.ts +32 -0
  32. package/dist/cli/menu/settings/index.js +136 -0
  33. package/dist/cli/menu/settings/schema-node.d.ts +62 -0
  34. package/dist/cli/menu/settings/schema-node.js +104 -0
  35. package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
  36. package/dist/cli/menu/several.js +102 -0
  37. package/dist/cli/tui/app.d.ts +39 -0
  38. package/dist/cli/tui/app.js +52 -0
  39. package/dist/cli/tui/dialogs.d.ts +57 -0
  40. package/dist/cli/tui/dialogs.js +162 -0
  41. package/dist/cli/tui/frame.d.ts +18 -0
  42. package/dist/cli/tui/frame.js +27 -0
  43. package/dist/cli/tui/index.d.ts +7 -0
  44. package/dist/cli/tui/index.js +8 -0
  45. package/dist/cli/tui/list-screen.d.ts +101 -0
  46. package/dist/cli/tui/list-screen.js +299 -0
  47. package/dist/cli/tui/terminal.d.ts +37 -0
  48. package/dist/cli/tui/terminal.js +89 -0
  49. package/dist/cli/tui/text.d.ts +20 -0
  50. package/dist/cli/tui/text.js +105 -0
  51. package/dist/cli/tui/theme.d.ts +45 -0
  52. package/dist/cli/tui/theme.js +30 -0
  53. package/dist/core/config.js +9 -6
  54. package/dist/core/local-config.d.ts +15 -0
  55. package/dist/core/local-config.js +63 -4
  56. package/dist/core/schema/apps.d.ts +40 -0
  57. package/dist/core/schema/apps.js +27 -0
  58. package/dist/core/schema/config.d.ts +66 -1
  59. package/dist/core/schema/config.js +18 -6
  60. package/dist/core/schema/profile.d.ts +5 -0
  61. package/dist/core/schema/profile.js +4 -2
  62. package/dist/core/schema/sandbox.d.ts +28 -0
  63. package/dist/core/schema/sandbox.js +28 -0
  64. package/dist/core/schema/toolchains.d.ts +3 -0
  65. package/dist/core/schema/toolchains.js +6 -1
  66. package/dist/core/types.d.ts +6 -0
  67. package/dist/core/util.d.ts +2 -0
  68. package/dist/core/util.js +2 -0
  69. package/dist/core/workspace.js +22 -5
  70. package/dist/guest/apps/index.d.ts +10 -0
  71. package/dist/guest/apps/index.js +206 -0
  72. package/dist/guest/apps/resolvers.d.ts +8 -0
  73. package/dist/guest/apps/resolvers.js +90 -0
  74. package/dist/guest/audit-parse.d.ts +7 -0
  75. package/dist/guest/audit-parse.js +24 -0
  76. package/dist/guest/audit.d.ts +4 -0
  77. package/dist/guest/audit.js +47 -0
  78. package/dist/guest/daemon/relays.d.ts +1 -0
  79. package/dist/guest/daemon/relays.js +18 -0
  80. package/dist/guest/daemon.js +4 -0
  81. package/dist/guest/env.d.ts +1 -0
  82. package/dist/guest/env.js +3 -1
  83. package/dist/guest/helpers.d.ts +1 -1
  84. package/dist/guest/main.js +15 -2
  85. package/dist/guest/netproxy.d.ts +1 -0
  86. package/dist/guest/netproxy.js +100 -0
  87. package/dist/guest/ptyhost.js +14 -4
  88. package/dist/guest/setup/claude.d.ts +0 -4
  89. package/dist/guest/setup/claude.js +48 -2
  90. package/dist/guest/setup/network.d.ts +9 -0
  91. package/dist/guest/setup/network.js +97 -4
  92. package/dist/guest/setup/user.js +4 -0
  93. package/dist/guest/toolchains/native/mesa.js +13 -3
  94. package/dist/guest/toolchains/plan.d.ts +3 -1
  95. package/dist/guest/toolchains/plan.js +53 -9
  96. package/dist/guest/toolchains/python.js +26 -2
  97. package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
  98. package/dist/guest/toolchains/vscode-profile.js +69 -0
  99. package/dist/guest/toolchains/vscode.d.ts +17 -0
  100. package/dist/guest/toolchains/vscode.js +87 -1
  101. package/dist/host/attach.js +2 -1
  102. package/dist/host/audit/collect.d.ts +34 -0
  103. package/dist/host/audit/collect.js +126 -0
  104. package/dist/host/audit/log.d.ts +46 -0
  105. package/dist/host/audit/log.js +143 -0
  106. package/dist/host/generate.d.ts +6 -0
  107. package/dist/host/generate.js +10 -0
  108. package/dist/host/launch/mappings.js +5 -1
  109. package/dist/host/launch/stage.js +1 -1
  110. package/dist/host/motw.d.ts +17 -0
  111. package/dist/host/motw.js +74 -0
  112. package/dist/host/platform.js +3 -0
  113. package/dist/host/port-forward.d.ts +8 -0
  114. package/dist/host/port-forward.js +37 -0
  115. package/dist/host/repo-guard.d.ts +10 -3
  116. package/dist/host/repo-guard.js +142 -14
  117. package/dist/host/stops.d.ts +67 -0
  118. package/dist/host/stops.js +126 -0
  119. package/dist/host/terminal.d.ts +8 -0
  120. package/dist/host/terminal.js +45 -1
  121. package/dist/host/workspace-view/index.d.ts +20 -0
  122. package/dist/host/workspace-view/index.js +53 -0
  123. package/dist/host/workspace-view/rules.d.ts +26 -0
  124. package/dist/host/workspace-view/rules.js +93 -0
  125. package/dist/host/workspace-view/view.d.ts +50 -0
  126. package/dist/host/workspace-view/view.js +281 -0
  127. package/dist/runtime/daemon.js +1319 -552
  128. package/dist/runtime/db.js +168 -13
  129. package/dist/runtime/main.js +2898 -608
  130. package/dist/runtime/netproxy.js +25936 -0
  131. package/dist/runtime/view.js +272 -23
  132. package/dist/selftest/audit.test.d.ts +2 -0
  133. package/dist/selftest/audit.test.js +57 -0
  134. package/dist/selftest/channel.test.js +7 -0
  135. package/dist/selftest/cli.test.js +47 -17
  136. package/dist/selftest/fixtures.d.ts +45 -0
  137. package/dist/selftest/fixtures.js +3 -1
  138. package/dist/selftest/generate.test.js +7 -2
  139. package/dist/selftest/index.js +5 -2
  140. package/dist/selftest/live-network.test.d.ts +2 -0
  141. package/dist/selftest/live-network.test.js +63 -0
  142. package/dist/selftest/local-config.test.js +16 -3
  143. package/dist/selftest/network.test.js +12 -0
  144. package/dist/selftest/projects.test.js +1 -1
  145. package/dist/selftest/repo-guard.test.js +59 -1
  146. package/dist/selftest/sync.test.js +50 -10
  147. package/dist/selftest/toolchains.test.js +30 -0
  148. package/dist/selftest/workspace-view.test.d.ts +2 -0
  149. package/dist/selftest/workspace-view.test.js +89 -0
  150. package/dist/shared/apps.d.ts +49 -0
  151. package/dist/shared/apps.js +114 -0
  152. package/dist/shared/netpolicy.d.ts +27 -0
  153. package/dist/shared/netpolicy.js +51 -0
  154. package/dist/shared/pty-client.d.ts +9 -4
  155. package/dist/shared/pty-client.js +139 -17
  156. package/dist/shared/rundir.d.ts +2 -0
  157. package/dist/shared/rundir.js +2 -0
  158. package/dist/shared/schema.d.ts +10 -0
  159. package/dist/shared/schema.js +2 -0
  160. package/dist/shared/system.d.ts +1 -0
  161. package/dist/shared/system.js +1 -0
  162. package/dist/sync/claude-mem.js +3 -2
  163. package/dist/sync/history.d.ts +6 -0
  164. package/dist/sync/history.js +26 -11
  165. package/dist/sync/watcher.js +78 -6
  166. package/package.json +2 -1
  167. package/sandbox/cargoshim.cs +56 -0
  168. package/sandbox/cpucap.cs +33 -0
  169. package/sandbox/cpucap.ps1 +4 -35
  170. package/sandbox/logon.cmd +6 -0
  171. package/sandbox/memlog.ps1 +34 -0
  172. package/sandbox/pagefile.cs +31 -0
  173. package/sandbox/pagefile.ps1 +21 -0
  174. package/sandbox/ptyhost.cs +105 -7
  175. package/schemas/config.schema.json +303 -2
  176. package/schemas/sandbox-config.schema.json +126 -12
  177. package/dist/cli/menu-multi.js +0 -82
  178. package/dist/cli/menu.d.ts +0 -14
  179. package/dist/cli/menu.js +0 -290
  180. package/dist/cli/settings/edit.d.ts +0 -13
  181. package/dist/cli/settings/edit.js +0 -19
  182. package/dist/cli/settings/index.d.ts +0 -8
  183. package/dist/cli/settings/index.js +0 -131
  184. package/dist/cli/settings/prompts.d.ts +0 -7
  185. package/dist/cli/settings/prompts.js +0 -48
  186. package/dist/cli/settings/schema.d.ts +0 -33
  187. package/dist/cli/settings/schema.js +0 -29
@@ -0,0 +1,62 @@
1
+ import type { z } from 'zod';
2
+ type Def = {
3
+ type: string;
4
+ options?: ZodLike[];
5
+ values?: unknown[];
6
+ entries?: Record<string, string>;
7
+ element?: ZodLike;
8
+ innerType?: ZodLike;
9
+ keyType?: ZodLike;
10
+ valueType?: ZodLike;
11
+ defaultValue?: unknown;
12
+ in?: ZodLike;
13
+ out?: ZodLike;
14
+ };
15
+ type ZodLike = z.ZodType & {
16
+ _zod: {
17
+ def: Def;
18
+ };
19
+ shape?: Record<string, ZodLike>;
20
+ description?: string;
21
+ minValue?: number | null;
22
+ maxValue?: number | null;
23
+ };
24
+ /** The editor a setting gets. */
25
+ export type EditorKind = 'boolean' | 'choice' | 'union' | 'number' | 'string' | 'string-list' | 'choice-list' | 'object-list' | 'string-map' | 'object-map' | 'object' | 'json';
26
+ export declare class SchemaNode {
27
+ readonly schema: ZodLike;
28
+ constructor(schema: unknown);
29
+ /** The real type under optional() / default() / nullable() / transforms. */
30
+ get inner(): ZodLike;
31
+ get type(): string;
32
+ /** A group of settings (an object with known fields), opened rather than edited. */
33
+ get isSection(): boolean;
34
+ /** The description, which .partial() / .optional() wrappers may carry on the inner schema instead. */
35
+ get description(): string | undefined;
36
+ get min(): number | undefined;
37
+ get max(): number | undefined;
38
+ /** The fields of an object, in schema order. */
39
+ children(): [string, SchemaNode][];
40
+ child(name: string): SchemaNode | undefined;
41
+ /** Every setting below this one that has an editor of its own (not a section), with its path. */
42
+ leaves(key?: string[]): {
43
+ key: string[];
44
+ node: SchemaNode;
45
+ }[];
46
+ /** Array items, or a map's values. */
47
+ get element(): SchemaNode | undefined;
48
+ /** Union members. */
49
+ get options(): SchemaNode[];
50
+ /** The values an enum or literal allows. */
51
+ get choices(): unknown[];
52
+ get editor(): EditorKind;
53
+ /** Every value a choice allows (booleans of a union included). */
54
+ get choiceValues(): unknown[];
55
+ /** The member of a union that is not a fixed value (a number, a string ...), edited after "Custom...". */
56
+ get openOption(): SchemaNode | undefined;
57
+ /** What a new entry (in a list or a map) starts as: the default if there is one, otherwise the simplest valid shape. */
58
+ initial(): unknown;
59
+ /** Is `value` acceptable here on its own? (The whole file is checked again when it is saved.) */
60
+ check(value: unknown): true | string;
61
+ }
62
+ export {};
@@ -0,0 +1,104 @@
1
+ /** Settings never shown (the link to the JSON Schema). */
2
+ const HIDDEN = new Set(['$schema']);
3
+ export class SchemaNode {
4
+ schema;
5
+ constructor(schema) { this.schema = schema; }
6
+ /** The real type under optional() / default() / nullable() / transforms. */
7
+ get inner() {
8
+ let s = this.schema;
9
+ for (;;) {
10
+ const def = s._zod.def;
11
+ if (['optional', 'default', 'nullable', 'prefault', 'readonly'].includes(def.type) && def.innerType)
12
+ s = def.innerType;
13
+ else if (def.type === 'pipe' && def.in)
14
+ s = def.in;
15
+ else
16
+ return s;
17
+ }
18
+ }
19
+ get type() { return this.inner._zod.def.type; }
20
+ /** A group of settings (an object with known fields), opened rather than edited. */
21
+ get isSection() { return this.type === 'object' && Boolean(this.inner.shape); }
22
+ /** The description, which .partial() / .optional() wrappers may carry on the inner schema instead. */
23
+ get description() { return this.schema.description ?? this.inner.description; }
24
+ get min() { return this.inner.minValue ?? undefined; }
25
+ get max() { return this.inner.maxValue ?? undefined; }
26
+ /** The fields of an object, in schema order. */
27
+ children() {
28
+ return Object.entries(this.inner.shape ?? {}).filter(([name]) => !HIDDEN.has(name)).map(([name, s]) => [name, new SchemaNode(s)]);
29
+ }
30
+ child(name) {
31
+ const s = this.inner.shape?.[name];
32
+ return s ? new SchemaNode(s) : undefined;
33
+ }
34
+ /** Every setting below this one that has an editor of its own (not a section), with its path. */
35
+ leaves(key = []) {
36
+ return this.children().flatMap(([name, child]) => (child.isSection ? child.leaves([...key, name]) : [{ key: [...key, name], node: child }]));
37
+ }
38
+ /** Array items, or a map's values. */
39
+ get element() {
40
+ const def = this.inner._zod.def;
41
+ return def.type === 'array' && def.element ? new SchemaNode(def.element) : def.type === 'record' && def.valueType ? new SchemaNode(def.valueType) : undefined;
42
+ }
43
+ /** Union members. */
44
+ get options() { return (this.inner._zod.def.options ?? []).map((o) => new SchemaNode(o)); }
45
+ /** The values an enum or literal allows. */
46
+ get choices() {
47
+ const def = this.inner._zod.def;
48
+ if (def.type === 'enum')
49
+ return Object.values(def.entries ?? {});
50
+ if (def.type === 'literal')
51
+ return def.values ?? [];
52
+ return [];
53
+ }
54
+ get editor() {
55
+ const type = this.type;
56
+ if (type === 'boolean')
57
+ return 'boolean';
58
+ if (type === 'enum' || type === 'literal')
59
+ return 'choice';
60
+ if (type === 'union')
61
+ return this.options.every((o) => ['literal', 'enum', 'boolean'].includes(o.type)) ? 'choice' : 'union';
62
+ if (type === 'number' || type === 'string')
63
+ return type;
64
+ const element = this.element;
65
+ if (type === 'array' && element)
66
+ return element.type === 'string' ? 'string-list' : element.type === 'enum' ? 'choice-list' : element.type === 'object' ? 'object-list' : 'json';
67
+ if (type === 'record' && element)
68
+ return ['string', 'number', 'boolean'].includes(element.type) ? 'string-map' : element.type === 'object' ? 'object-map' : 'json';
69
+ if (type === 'object')
70
+ return 'object';
71
+ return 'json';
72
+ }
73
+ /** Every value a choice allows (booleans of a union included). */
74
+ get choiceValues() {
75
+ if (this.type !== 'union')
76
+ return this.choices;
77
+ return this.options.flatMap((o) => (o.type === 'boolean' ? [true, false] : o.choices));
78
+ }
79
+ /** The member of a union that is not a fixed value (a number, a string ...), edited after "Custom...". */
80
+ get openOption() { return this.options.find((o) => !['literal', 'enum', 'boolean'].includes(o.type)); }
81
+ /** What a new entry (in a list or a map) starts as: the default if there is one, otherwise the simplest valid shape. */
82
+ initial() {
83
+ const def = this.schema._zod.def;
84
+ if (def.type === 'default')
85
+ return structuredClone(def.defaultValue);
86
+ switch (this.type) {
87
+ case 'boolean': return false;
88
+ case 'number': return this.min ?? 0;
89
+ case 'string': return '';
90
+ case 'enum':
91
+ case 'literal': return this.choices[0];
92
+ case 'union': return this.options[0]?.initial();
93
+ case 'array': return [];
94
+ case 'record': return {};
95
+ case 'object': return Object.fromEntries(this.children().filter(([, c]) => !['optional', 'default'].includes(c.schema._zod.def.type)).map(([name, c]) => [name, c.initial()]));
96
+ default: return null;
97
+ }
98
+ }
99
+ /** Is `value` acceptable here on its own? (The whole file is checked again when it is saved.) */
100
+ check(value) {
101
+ const result = this.schema.safeParse(value);
102
+ return result.success ? true : result.error.issues.map((i) => (i.path.length ? i.path.join('.') + ': ' : '') + i.message).join('; ');
103
+ }
104
+ }
@@ -1,4 +1,5 @@
1
- import type { Context } from './commands/context.js';
1
+ import type { Context } from '../commands/context.js';
2
+ import { App } from '../tui/index.js';
2
3
  /** Folders worth offering: this one, the projects started before, and the folders inside your roots. */
3
4
  export declare const candidateFolders: (ctx: Context, cwd?: string) => string[];
4
5
  /** The command line for several sessions: one --session per (agent, folder), with the shared options. */
@@ -9,4 +10,11 @@ export declare const severalAgentsArgv: (sessions: {
9
10
  headless: boolean;
10
11
  attachFirst: boolean;
11
12
  }) => string[];
12
- export declare const askSeveralAgents: (ctx: Context) => Promise<string[] | null>;
13
+ export declare class SeveralAgents {
14
+ private readonly app;
15
+ private readonly ctx;
16
+ private readonly path;
17
+ constructor(app: App, ctx: Context);
18
+ run(): Promise<string[] | null>;
19
+ private folders;
20
+ }
@@ -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
+ }
@@ -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';