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,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
+ }
@@ -0,0 +1,299 @@
1
+ import { Text } from './text.js';
2
+ import { Theme } from './theme.js';
3
+ export class ListScreen {
4
+ o;
5
+ category = 0;
6
+ focus = 'list';
7
+ cursor = 0;
8
+ offset = 0;
9
+ query = '';
10
+ searching = false;
11
+ notice;
12
+ checked;
13
+ constructor(o) {
14
+ this.o = o;
15
+ this.notice = o.notice;
16
+ this.checked = new Set(o.items.flatMap((item, i) => (item.checked ? [i] : [])));
17
+ const start = o.startAt === undefined ? -1 : o.items.findIndex((item) => item.value === o.startAt);
18
+ const startCategory = start >= 0 ? o.items[start].category : o.startCategory;
19
+ if (o.categories && startCategory)
20
+ this.category = Math.max(0, o.categories.findIndex((c) => c.id === startCategory));
21
+ if (start >= 0)
22
+ this.cursor = Math.max(0, this.visible().indexOf(start));
23
+ }
24
+ /** Indexes of the entries shown now: all matches of the search, else the current category's, else all. */
25
+ visible() {
26
+ const all = this.o.items.map((_, i) => i);
27
+ if (this.query.trim()) {
28
+ const words = this.query.toLowerCase().split(/\s+/).filter(Boolean);
29
+ const label = (id) => this.o.categories?.find((c) => c.id === id)?.label ?? '';
30
+ return all.filter((i) => {
31
+ const it = this.o.items[i];
32
+ const hay = [it.label, Text.strip(it.hint ?? ''), it.description, it.keywords, it.section, label(it.category)].join(' ').toLowerCase();
33
+ return words.every((w) => hay.includes(w));
34
+ });
35
+ }
36
+ const cat = this.o.categories?.[this.category];
37
+ return cat ? all.filter((i) => this.o.items[i].category === cat.id) : all;
38
+ }
39
+ rows(visible) {
40
+ const rows = [];
41
+ let section;
42
+ const searchAll = Boolean(this.query.trim() && this.o.categories);
43
+ for (const index of visible) {
44
+ const it = this.o.items[index];
45
+ const heading = searchAll ? [this.o.categories?.find((c) => c.id === it.category)?.label, it.section].filter(Boolean).join(' › ') : it.section;
46
+ if (heading && heading !== section)
47
+ rows.push({ kind: 'section', text: heading });
48
+ section = heading;
49
+ rows.push({ kind: 'item', index });
50
+ }
51
+ return rows;
52
+ }
53
+ render(size) {
54
+ const visible = this.visible();
55
+ this.cursor = Math.min(this.cursor, Math.max(0, visible.length - 1));
56
+ const sideWidth = this.o.categories ? Math.min(32, Math.max(...this.o.categories.map((c) => Text.width(c.label))) + 7) : 0;
57
+ const rest = size.width - 1 - (sideWidth ? sideWidth + 3 : 0);
58
+ const detailsRight = rest >= 100;
59
+ const detailsWidth = detailsRight ? Math.min(52, Math.floor(rest * 0.38)) : rest;
60
+ const listWidth = detailsRight ? rest - detailsWidth - 3 : rest;
61
+ const top = [];
62
+ if (this.o.tabs)
63
+ top.push(' ' + this.o.tabs.items.map((tab, i) => (i === this.o.tabs.active ? tab.paint(' ' + tab.badge + ' ' + tab.label + ' ') : Theme.muted(' ' + tab.label + ' '))).join(' ') + Theme.muted(' tab switches'), '');
64
+ const intro = typeof this.o.intro === 'string' ? [this.o.intro] : this.o.intro ?? [];
65
+ for (const line of intro)
66
+ top.push(...Text.wrap(line, size.width - 3).map((l) => ' ' + Theme.muted(l)));
67
+ if (this.searching || this.query)
68
+ top.push(' ' + Theme.accent(Theme.glyph.search) + ' ' + this.query + (this.searching ? Theme.accent('▏') : '') + Theme.muted(' ' + visible.length + ' match' + (visible.length === 1 ? '' : 'es')));
69
+ if (top.length)
70
+ top.push('');
71
+ const details = this.detailsLines(visible[this.cursor], detailsWidth);
72
+ const bottomDetails = !detailsRight && size.height - top.length >= 14 ? details.slice(0, 6) : [];
73
+ const rows = this.rows(visible);
74
+ const room = size.height - top.length - (bottomDetails.length ? bottomDetails.length + 1 : 0);
75
+ // A list longer than the room keeps one row for "▲ / ▼ more".
76
+ const listHeight = rows.length > room ? room - 1 : room;
77
+ const cursorRow = rows.findIndex((r) => r.kind === 'item' && r.index === visible[this.cursor]);
78
+ if (cursorRow >= 0) {
79
+ // Keep the heading above the first entry in view, and the cursor inside the window.
80
+ const want = cursorRow > 0 && rows[cursorRow - 1].kind === 'section' ? cursorRow - 1 : cursorRow;
81
+ if (want < this.offset)
82
+ this.offset = want;
83
+ if (cursorRow >= this.offset + listHeight)
84
+ this.offset = cursorRow - listHeight + 1;
85
+ }
86
+ this.offset = Math.max(0, Math.min(this.offset, Math.max(0, rows.length - listHeight)));
87
+ const listLines = rows.slice(this.offset, this.offset + listHeight).map((row) => this.rowLine(row, visible, listWidth));
88
+ if (!visible.length)
89
+ listLines.push('', ' ' + Theme.muted(this.query ? 'Nothing matches "' + this.query + '".' : this.o.empty ?? 'Nothing here yet.'));
90
+ if (rows.length > listHeight) {
91
+ const above = this.offset;
92
+ const below = rows.length - this.offset - listHeight;
93
+ listLines.push(Theme.muted(' ' + (above > 0 ? '▲ ' + above + ' above' : '') + (above > 0 && below > 0 ? ' ' : '') + (below > 0 ? '▼ ' + below + ' more' : '')));
94
+ }
95
+ const sideLines = this.sideLines(sideWidth);
96
+ const body = [...top];
97
+ const height = Math.max(listLines.length, detailsRight ? details.length : 0, sideLines.length);
98
+ for (let i = 0; i < Math.min(height, size.height - top.length); i++) {
99
+ const side = sideWidth ? Text.fit(sideLines[i] ?? '', sideWidth) + ' ' + Theme.dim(Theme.glyph.vline) + ' ' : '';
100
+ const main = Text.fit(listLines[i] ?? '', listWidth);
101
+ const right = detailsRight ? ' ' + Theme.dim(Theme.glyph.vline) + ' ' + (details[i] ?? '') : '';
102
+ body.push(side + main + right);
103
+ }
104
+ if (bottomDetails.length) {
105
+ // The details sit at the bottom, whatever the length of the list.
106
+ while (body.length < size.height - bottomDetails.length - 1)
107
+ body.push(sideWidth ? Text.fit(sideLines[body.length - top.length] ?? '', sideWidth) + ' ' + Theme.dim(Theme.glyph.vline) : '');
108
+ body.push(Theme.dim(' ' + Theme.glyph.line.repeat(size.width - 3)), ...bottomDetails.map((l) => ' ' + l));
109
+ }
110
+ return { path: this.o.path, status: this.o.status, body, keys: this.keys(), notice: this.notice };
111
+ }
112
+ rowLine(row, visible, width) {
113
+ if (row.kind === 'section')
114
+ return ' ' + Theme.accentBold(row.text);
115
+ const it = this.o.items[row.index];
116
+ const selected = visible[this.cursor] === row.index;
117
+ const box = this.o.multi ? (this.checked.has(row.index) ? Theme.ok(Theme.glyph.checked) : Theme.muted(Theme.glyph.unchecked)) + ' ' : '';
118
+ const icon = it.icon ? it.icon + ' ' : '';
119
+ const hint = it.hint ?? '';
120
+ const hintWidth = Math.min(Text.width(hint), Math.floor(width * 0.5));
121
+ const labelWidth = width - 4 - Text.width(box) - Text.width(icon) - (hintWidth ? hintWidth + 2 : 0);
122
+ const label = it.disabled ? Theme.muted(it.label) : it.label;
123
+ const line = (selected ? Theme.glyph.pointer : ' ') + ' ' + box + icon + Text.fit(label, labelWidth) + (hintWidth ? ' ' + Text.fit(hint, hintWidth, 'right') : '') + ' ';
124
+ if (!selected)
125
+ return ' ' + line;
126
+ return this.focus === 'list' ? ' ' + Theme.selected(Text.strip(line)) : ' ' + Theme.accent(Text.strip(line));
127
+ }
128
+ sideLines(width) {
129
+ if (!this.o.categories)
130
+ return [];
131
+ return this.o.categories.map((cat, i) => {
132
+ const count = this.o.items.filter((it) => it.category === cat.id).length;
133
+ const text = ' ' + Text.fit(cat.label, width - 6) + ' ' + Text.fit(String(count), 3, 'right') + ' ';
134
+ if (i !== this.category)
135
+ return Theme.muted(text);
136
+ return this.focus === 'side' ? Theme.selected(text) : Theme.accentBold(text);
137
+ });
138
+ }
139
+ detailsLines(index, width) {
140
+ const w = Math.max(10, width - 1);
141
+ if (this.focus === 'side' && this.o.categories) {
142
+ const cat = this.o.categories[this.category];
143
+ return [Theme.bold(cat.label), ...(cat.description ? Text.wrap(cat.description, w).map((l) => Theme.muted(l)) : [])];
144
+ }
145
+ if (index === undefined)
146
+ return [];
147
+ const it = this.o.items[index];
148
+ return [
149
+ Theme.bold(Text.truncate(it.label, w)),
150
+ ...(it.description ? Text.wrap(it.description, w).map((l) => Theme.muted(l)) : []),
151
+ ...(it.details?.length ? ['', ...it.details.flatMap((d) => Text.wrap(d, w))] : []),
152
+ ...(it.disabled ? ['', Theme.warn(it.disabled)] : []),
153
+ ];
154
+ }
155
+ keys() {
156
+ if (this.searching)
157
+ return [['type', 'to search'], ['↑↓', 'move'], ['↵', this.o.multi ? 'done' : 'open'], ['esc', 'clear search']];
158
+ return [
159
+ ['↑↓', 'move'],
160
+ ...(this.o.tabs ? [['tab', 'switch ' + this.o.tabs.items.map((t) => t.label.toLowerCase()).join(' / ')]] : []),
161
+ ...(this.o.categories ? [['←→', 'categories']] : []),
162
+ ...(this.o.multi ? [['space', 'tick'], ['a', 'tick all shown'], ['↵', 'done']] : [['↵', 'open']]),
163
+ ['/', 'search'],
164
+ ...(this.o.actions ?? []).map((a) => [a.key, a.label]),
165
+ ['esc', this.o.escapeLabel ?? 'back'],
166
+ ];
167
+ }
168
+ /** Programmatic notice (e.g. "Saved") shown until the next key. */
169
+ setNotice(text) { this.notice = text; }
170
+ handle(key) {
171
+ const visible = this.visible();
172
+ const current = visible[this.cursor];
173
+ this.notice = undefined;
174
+ const move = (by) => { this.cursor = Math.max(0, Math.min(visible.length - 1, this.cursor + by)); };
175
+ if (key.name === 'up') {
176
+ if (this.focus === 'side')
177
+ this.switchCategory(-1);
178
+ else
179
+ move(-1);
180
+ return;
181
+ }
182
+ if (key.name === 'down') {
183
+ if (this.focus === 'side')
184
+ this.switchCategory(1);
185
+ else
186
+ move(1);
187
+ return;
188
+ }
189
+ if (key.name === 'pageup') {
190
+ move(-10);
191
+ return;
192
+ }
193
+ if (key.name === 'pagedown') {
194
+ move(10);
195
+ return;
196
+ }
197
+ if (key.name === 'home') {
198
+ this.cursor = 0;
199
+ return;
200
+ }
201
+ if (key.name === 'end') {
202
+ this.cursor = visible.length - 1;
203
+ return;
204
+ }
205
+ if (this.searching) {
206
+ if (key.name === 'escape') {
207
+ this.searching = false;
208
+ this.query = '';
209
+ this.cursor = 0;
210
+ return;
211
+ }
212
+ if (key.name === 'backspace') {
213
+ if (this.query)
214
+ this.query = this.query.slice(0, -1);
215
+ else
216
+ this.searching = false;
217
+ this.cursor = 0;
218
+ return;
219
+ }
220
+ // In a tick list, space always ticks (even while searching).
221
+ if (key.name !== 'return' && key.text && !(this.o.multi && key.name === 'space')) {
222
+ this.query += key.text;
223
+ this.cursor = 0;
224
+ return;
225
+ }
226
+ }
227
+ if (this.o.tabs && key.name === 'tab') {
228
+ const n = this.o.tabs.items.length;
229
+ return { done: { kind: 'tab', index: (this.o.tabs.active + (key.shift ? n - 1 : 1)) % n } };
230
+ }
231
+ if (this.o.categories && !this.query) {
232
+ if (key.name === 'left' && this.focus === 'list') {
233
+ this.focus = 'side';
234
+ return;
235
+ }
236
+ if ((key.name === 'right' || key.name === 'return') && this.focus === 'side') {
237
+ this.focus = 'list';
238
+ return;
239
+ }
240
+ }
241
+ if (key.name === 'escape' || (key.name === 'backspace' && !this.searching)) {
242
+ if (this.query) {
243
+ this.query = '';
244
+ this.cursor = 0;
245
+ return;
246
+ }
247
+ return { done: { kind: 'back' } };
248
+ }
249
+ if (key.text === '/' || (key.name === 'f' && key.ctrl)) {
250
+ this.searching = true;
251
+ this.focus = 'list';
252
+ return;
253
+ }
254
+ if (this.o.multi) {
255
+ if (key.name === 'space' && current !== undefined) {
256
+ this.toggle(current);
257
+ return;
258
+ }
259
+ if (key.text === 'a') {
260
+ const all = visible.filter((i) => !this.o.items[i].disabled);
261
+ const tick = all.some((i) => !this.checked.has(i));
262
+ all.forEach((i) => (tick ? this.checked.add(i) : this.checked.delete(i)));
263
+ return;
264
+ }
265
+ if (key.name === 'return')
266
+ return { done: { kind: 'multi', values: this.o.items.filter((_, i) => this.checked.has(i)).map((it) => it.value) } };
267
+ }
268
+ if ((key.name === 'return' || key.name === 'right' || (key.name === 'space' && !this.searching)) && current !== undefined && !this.o.multi) {
269
+ const it = this.o.items[current];
270
+ if (it.disabled) {
271
+ this.notice = Theme.warn(it.disabled);
272
+ return;
273
+ }
274
+ return { done: { kind: 'pick', value: it.value } };
275
+ }
276
+ const action = !this.searching && key.text ? this.o.actions?.find((a) => a.key === key.text) : undefined;
277
+ if (action)
278
+ return { done: { kind: 'action', key: action.key, value: current === undefined ? undefined : this.o.items[current].value } };
279
+ return;
280
+ }
281
+ toggle(index) {
282
+ if (this.o.items[index].disabled) {
283
+ this.notice = Theme.warn(this.o.items[index].disabled);
284
+ return;
285
+ }
286
+ if (this.checked.has(index))
287
+ this.checked.delete(index);
288
+ else
289
+ this.checked.add(index);
290
+ }
291
+ switchCategory(by) {
292
+ const count = this.o.categories?.length ?? 0;
293
+ if (!count)
294
+ return;
295
+ this.category = (this.category + by + count) % count;
296
+ this.cursor = 0;
297
+ this.offset = 0;
298
+ }
299
+ }
@@ -0,0 +1,37 @@
1
+ /** One key press: `name` for special keys (up, return, escape, f1 ...), `text` for something typed. */
2
+ export interface Key {
3
+ name: string;
4
+ text: string;
5
+ ctrl: boolean;
6
+ meta: boolean;
7
+ shift: boolean;
8
+ }
9
+ /** Ctrl+C: leave the menu at once (callers let it propagate to the top). */
10
+ export declare class ExitRequested extends Error {
11
+ constructor();
12
+ }
13
+ export declare class Terminal {
14
+ private readonly stdin;
15
+ private readonly stdout;
16
+ private active;
17
+ private frame;
18
+ private queue;
19
+ private waiting;
20
+ private resizeHandler;
21
+ private readonly onKeypress;
22
+ private readonly onResize;
23
+ constructor(stdin?: NodeJS.ReadStream, stdout?: NodeJS.WriteStream);
24
+ get width(): number;
25
+ get height(): number;
26
+ /** Called after the window was resized (the screen draws itself again). */
27
+ onResized(handler: (() => void) | null): void;
28
+ start(): void;
29
+ /** Gives the terminal back exactly as it was (also on errors: callers use try/finally). */
30
+ stop(): void;
31
+ get running(): boolean;
32
+ /** Draws a whole screen: `lines` are cut to the width and padded to the height; unchanged rows are not rewritten. */
33
+ render(lines: string[]): void;
34
+ /** The next key press. Ctrl+C rejects with ExitRequested. */
35
+ key(): Promise<Key>;
36
+ private push;
37
+ }
@@ -0,0 +1,89 @@
1
+ // The terminal while the menu owns it: the alternate screen (your scrollback stays as it was), raw keys, a cursor that
2
+ // is hidden, and redraws that only rewrite the rows that changed (no flicker). suspend() hands the terminal back for
3
+ // a command's own output; resume() takes it again.
4
+ import readline from 'node:readline';
5
+ import { Text } from './text.js';
6
+ /** Ctrl+C: leave the menu at once (callers let it propagate to the top). */
7
+ export class ExitRequested extends Error {
8
+ constructor() { super('Ctrl+C'); this.name = 'ExitPromptError'; }
9
+ }
10
+ export class Terminal {
11
+ stdin;
12
+ stdout;
13
+ active = false;
14
+ frame = [];
15
+ queue = [];
16
+ waiting = null;
17
+ resizeHandler = null;
18
+ onKeypress = (str, key) => this.push(str, key);
19
+ onResize = () => { this.frame = []; this.resizeHandler?.(); };
20
+ constructor(stdin = process.stdin, stdout = process.stdout) {
21
+ this.stdin = stdin;
22
+ this.stdout = stdout;
23
+ }
24
+ get width() { return Math.max(40, this.stdout.columns ?? 80); }
25
+ get height() { return Math.max(12, this.stdout.rows ?? 30); }
26
+ /** Called after the window was resized (the screen draws itself again). */
27
+ onResized(handler) { this.resizeHandler = handler; }
28
+ start() {
29
+ if (this.active)
30
+ return;
31
+ this.active = true;
32
+ this.frame = [];
33
+ readline.emitKeypressEvents(this.stdin);
34
+ if (this.stdin.isTTY)
35
+ this.stdin.setRawMode(true);
36
+ this.stdin.on('keypress', this.onKeypress);
37
+ this.stdin.resume();
38
+ this.stdout.on('resize', this.onResize);
39
+ this.stdout.write('\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J');
40
+ }
41
+ /** Gives the terminal back exactly as it was (also on errors: callers use try/finally). */
42
+ stop() {
43
+ if (!this.active)
44
+ return;
45
+ this.active = false;
46
+ this.stdin.off('keypress', this.onKeypress);
47
+ this.stdout.off('resize', this.onResize);
48
+ if (this.stdin.isTTY)
49
+ this.stdin.setRawMode(false);
50
+ this.stdin.pause();
51
+ this.queue = [];
52
+ this.stdout.write('\x1b[0m\x1b[?25h\x1b[?1049l');
53
+ }
54
+ get running() { return this.active; }
55
+ /** Draws a whole screen: `lines` are cut to the width and padded to the height; unchanged rows are not rewritten. */
56
+ render(lines) {
57
+ const { width, height } = this;
58
+ // One column is left free: writing the last column can scroll older consoles.
59
+ const rows = Array.from({ length: height }, (_, i) => Text.fit(lines[i] ?? '', width - 1));
60
+ let out = '';
61
+ rows.forEach((row, i) => { if (this.frame[i] !== row)
62
+ out += '\x1b[' + (i + 1) + ';1H' + row + '\x1b[0m'; });
63
+ if (this.frame.length !== rows.length)
64
+ out = '\x1b[H\x1b[2J' + rows.map((r, i) => '\x1b[' + (i + 1) + ';1H' + r + '\x1b[0m').join('');
65
+ this.frame = rows;
66
+ if (out)
67
+ this.stdout.write(out);
68
+ }
69
+ /** The next key press. Ctrl+C rejects with ExitRequested. */
70
+ key() {
71
+ const next = this.queue.shift();
72
+ if (next)
73
+ return next.ctrl && next.name === 'c' ? Promise.reject(new ExitRequested()) : Promise.resolve(next);
74
+ return new Promise((resolve, reject) => { this.waiting = { resolve, reject }; });
75
+ }
76
+ push(str, key) {
77
+ const event = { name: key?.name ?? '', text: str && !key?.ctrl && !key?.meta && str.length >= 1 && str >= ' ' && str !== '\x7f' ? str : '', ctrl: Boolean(key?.ctrl), meta: Boolean(key?.meta), shift: Boolean(key?.shift) };
78
+ const waiting = this.waiting;
79
+ if (!waiting) {
80
+ this.queue.push(event);
81
+ return;
82
+ }
83
+ this.waiting = null;
84
+ if (event.ctrl && event.name === 'c')
85
+ waiting.reject(new ExitRequested());
86
+ else
87
+ waiting.resolve(event);
88
+ }
89
+ }
@@ -0,0 +1,20 @@
1
+ export declare class Text {
2
+ /** Removes colour and cursor codes. */
3
+ static strip(text: string): string;
4
+ /** Columns one character takes: 2 for East Asian wide characters and most emoji, 0 for combining marks. */
5
+ static charWidth(code: number): number;
6
+ /** Columns `text` takes on screen. */
7
+ static width(text: string): number;
8
+ /**
9
+ * The first `max` columns of `text`, keeping colour codes (and closing them), with an ellipsis when something was cut.
10
+ */
11
+ static truncate(text: string, max: number, ellipsis?: string): string;
12
+ /** `text` cut or padded with spaces to exactly `width` columns. */
13
+ static fit(text: string, width: number, align?: 'left' | 'right'): string;
14
+ /** Cuts the middle out of a long path so both ends stay readable (plain text). */
15
+ static middle(text: string, max: number): string;
16
+ /** Word-wraps plain text to `width` columns (long words are split); keeps the text's own line breaks. */
17
+ static wrap(text: string, width: number): string[];
18
+ /** "memoryMb" -> "Memory MB", "vGpu" -> "V GPU": a readable label from a setting's key. */
19
+ static humanize(key: string): string;
20
+ }
@@ -0,0 +1,105 @@
1
+ // Measuring and laying out terminal text that may hold colour codes: visible width, padding, cutting and wrapping.
2
+ // Pure (tested), so every screen lines up the same way.
3
+ const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
4
+ export class Text {
5
+ /** Removes colour and cursor codes. */
6
+ static strip(text) {
7
+ return text.replace(ANSI, '');
8
+ }
9
+ /** Columns one character takes: 2 for East Asian wide characters and most emoji, 0 for combining marks. */
10
+ static charWidth(code) {
11
+ if (code < 32 || (code >= 0x7f && code < 0xa0))
12
+ return 0;
13
+ if ((code >= 0x300 && code <= 0x36f) || (code >= 0x200b && code <= 0x200f) || code === 0xfe0f)
14
+ return 0;
15
+ const wide = (code >= 0x1100 && code <= 0x115f) || (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3)
16
+ || (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe4f) || (code >= 0xff00 && code <= 0xff60)
17
+ || (code >= 0xffe0 && code <= 0xffe6) || (code >= 0x1f300 && code <= 0x1faff) || (code >= 0x20000 && code <= 0x3fffd);
18
+ return wide ? 2 : 1;
19
+ }
20
+ /** Columns `text` takes on screen. */
21
+ static width(text) {
22
+ let width = 0;
23
+ for (const ch of Text.strip(text))
24
+ width += Text.charWidth(ch.codePointAt(0));
25
+ return width;
26
+ }
27
+ /**
28
+ * The first `max` columns of `text`, keeping colour codes (and closing them), with an ellipsis when something was cut.
29
+ */
30
+ static truncate(text, max, ellipsis = '…') {
31
+ if (max <= 0)
32
+ return '';
33
+ if (Text.width(text) <= max)
34
+ return text;
35
+ const room = max - Text.width(ellipsis);
36
+ let out = '';
37
+ let used = 0;
38
+ let coloured = false;
39
+ for (let i = 0; i < text.length;) {
40
+ const code = /^\x1b\[[0-9;?]*[A-Za-z]/.exec(text.slice(i));
41
+ if (code) {
42
+ out += code[0];
43
+ coloured = code[0] !== '\x1b[0m';
44
+ i += code[0].length;
45
+ continue;
46
+ }
47
+ const ch = String.fromCodePoint(text.codePointAt(i));
48
+ const w = Text.charWidth(ch.codePointAt(0));
49
+ if (used + w > room)
50
+ break;
51
+ out += ch;
52
+ used += w;
53
+ i += ch.length;
54
+ }
55
+ return out + (coloured ? '\x1b[0m' : '') + ellipsis;
56
+ }
57
+ /** `text` cut or padded with spaces to exactly `width` columns. */
58
+ static fit(text, width, align = 'left') {
59
+ const cut = Text.truncate(text, width);
60
+ const gap = ' '.repeat(Math.max(0, width - Text.width(cut)));
61
+ return align === 'left' ? cut + gap : gap + cut;
62
+ }
63
+ /** Cuts the middle out of a long path so both ends stay readable (plain text). */
64
+ static middle(text, max) {
65
+ if (max < 5 || text.length <= max)
66
+ return text;
67
+ const keep = max - 1;
68
+ const head = Math.ceil(keep / 2);
69
+ return text.slice(0, head) + '…' + text.slice(text.length - (keep - head));
70
+ }
71
+ /** Word-wraps plain text to `width` columns (long words are split); keeps the text's own line breaks. */
72
+ static wrap(text, width) {
73
+ const lines = [];
74
+ for (const paragraph of text.split(/\r?\n/)) {
75
+ let line = '';
76
+ for (const word of paragraph.split(/ +/)) {
77
+ let rest = word;
78
+ while (Text.width(rest) > width) {
79
+ if (line) {
80
+ lines.push(line);
81
+ line = '';
82
+ }
83
+ lines.push(rest.slice(0, width));
84
+ rest = rest.slice(width);
85
+ }
86
+ if (!line)
87
+ line = rest;
88
+ else if (Text.width(line) + 1 + Text.width(rest) <= width)
89
+ line += ' ' + rest;
90
+ else {
91
+ lines.push(line);
92
+ line = rest;
93
+ }
94
+ }
95
+ lines.push(line);
96
+ }
97
+ return lines;
98
+ }
99
+ /** "memoryMb" -> "Memory MB", "vGpu" -> "V GPU": a readable label from a setting's key. */
100
+ static humanize(key) {
101
+ const words = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').split(' ').filter(Boolean);
102
+ const upper = new Set(['mb', 'gb', 'cpu', 'gpu', 'jdk', 'sdk', 'id', 'url', 'ui', 'npm', 'msvc', 'uv', 'api', 'ssh', 'vm', 'io']);
103
+ return words.map((w, i) => (upper.has(w.toLowerCase()) ? w.toUpperCase() : i === 0 ? w[0].toUpperCase() + w.slice(1) : w.toLowerCase())).join(' ');
104
+ }
105
+ }