@jmoyers/harness 0.1.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 (214) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/native/ptyd/Cargo.lock +16 -0
  4. package/native/ptyd/Cargo.toml +7 -0
  5. package/native/ptyd/src/main.rs +257 -0
  6. package/package.json +90 -0
  7. package/scripts/build-ptyd.sh +73 -0
  8. package/scripts/control-plane-daemon.ts +277 -0
  9. package/scripts/cursor-hook-relay.ts +82 -0
  10. package/scripts/harness-animate.ts +469 -0
  11. package/scripts/harness-bin.js +77 -0
  12. package/scripts/harness-core.ts +1 -0
  13. package/scripts/harness-inspector.ts +439 -0
  14. package/scripts/harness.ts +2493 -0
  15. package/src/adapters/agent-session-state.ts +390 -0
  16. package/src/cli/gateway-record.ts +173 -0
  17. package/src/codex/live-session.ts +872 -0
  18. package/src/config/config-core.ts +1359 -0
  19. package/src/config/secrets-core.ts +170 -0
  20. package/src/control-plane/agent-realtime-api.ts +2441 -0
  21. package/src/control-plane/codex-session-stream.ts +392 -0
  22. package/src/control-plane/codex-telemetry.ts +1325 -0
  23. package/src/control-plane/lifecycle-hooks.ts +706 -0
  24. package/src/control-plane/session-summary.ts +380 -0
  25. package/src/control-plane/status/agent-status-reducer.ts +21 -0
  26. package/src/control-plane/status/reducer-base.ts +170 -0
  27. package/src/control-plane/status/reducers/claude-status-reducer.ts +37 -0
  28. package/src/control-plane/status/reducers/codex-status-reducer.ts +48 -0
  29. package/src/control-plane/status/reducers/critique-status-reducer.ts +15 -0
  30. package/src/control-plane/status/reducers/cursor-status-reducer.ts +37 -0
  31. package/src/control-plane/status/reducers/terminal-status-reducer.ts +15 -0
  32. package/src/control-plane/status/session-status-engine.ts +76 -0
  33. package/src/control-plane/stream-client.ts +396 -0
  34. package/src/control-plane/stream-command-parser.ts +1673 -0
  35. package/src/control-plane/stream-protocol.ts +1808 -0
  36. package/src/control-plane/stream-server-background.ts +486 -0
  37. package/src/control-plane/stream-server-command.ts +2557 -0
  38. package/src/control-plane/stream-server-connection.ts +234 -0
  39. package/src/control-plane/stream-server-observed-filter.ts +112 -0
  40. package/src/control-plane/stream-server-session-runtime.ts +566 -0
  41. package/src/control-plane/stream-server-state-store.ts +15 -0
  42. package/src/control-plane/stream-server.ts +3192 -0
  43. package/src/cursor/managed-hooks.ts +282 -0
  44. package/src/domain/conversations.ts +414 -0
  45. package/src/domain/directories.ts +78 -0
  46. package/src/domain/repositories.ts +123 -0
  47. package/src/domain/tasks.ts +148 -0
  48. package/src/domain/workspace.ts +156 -0
  49. package/src/events/normalized-events.ts +124 -0
  50. package/src/mux/ansi-integrity.ts +103 -0
  51. package/src/mux/control-plane-op-queue.ts +212 -0
  52. package/src/mux/conversation-rail.ts +339 -0
  53. package/src/mux/double-click.ts +78 -0
  54. package/src/mux/dual-pane-core.ts +435 -0
  55. package/src/mux/harness-core-ui.ts +817 -0
  56. package/src/mux/input-shortcuts.ts +667 -0
  57. package/src/mux/live-mux/actions-conversation.ts +344 -0
  58. package/src/mux/live-mux/actions-repository.ts +246 -0
  59. package/src/mux/live-mux/actions-task.ts +115 -0
  60. package/src/mux/live-mux/args.ts +142 -0
  61. package/src/mux/live-mux/command-menu.ts +298 -0
  62. package/src/mux/live-mux/control-plane-records.ts +546 -0
  63. package/src/mux/live-mux/conversation-state.ts +188 -0
  64. package/src/mux/live-mux/directory-resolution.ts +34 -0
  65. package/src/mux/live-mux/event-mapping.ts +96 -0
  66. package/src/mux/live-mux/gateway-profiler.ts +152 -0
  67. package/src/mux/live-mux/gateway-render-trace.ts +177 -0
  68. package/src/mux/live-mux/gateway-status-timeline.ts +166 -0
  69. package/src/mux/live-mux/git-parsing.ts +131 -0
  70. package/src/mux/live-mux/git-snapshot.ts +263 -0
  71. package/src/mux/live-mux/git-state.ts +136 -0
  72. package/src/mux/live-mux/global-shortcut-handlers.ts +143 -0
  73. package/src/mux/live-mux/home-pane-actions.ts +58 -0
  74. package/src/mux/live-mux/home-pane-drop.ts +44 -0
  75. package/src/mux/live-mux/home-pane-entity-click.ts +96 -0
  76. package/src/mux/live-mux/home-pane-pointer.ts +96 -0
  77. package/src/mux/live-mux/input-forwarding.ts +112 -0
  78. package/src/mux/live-mux/layout.ts +30 -0
  79. package/src/mux/live-mux/left-nav-activation.ts +103 -0
  80. package/src/mux/live-mux/left-nav.ts +85 -0
  81. package/src/mux/live-mux/left-rail-actions.ts +118 -0
  82. package/src/mux/live-mux/left-rail-conversation-click.ts +82 -0
  83. package/src/mux/live-mux/left-rail-pointer.ts +74 -0
  84. package/src/mux/live-mux/modal-command-menu-handler.ts +101 -0
  85. package/src/mux/live-mux/modal-conversation-handlers.ts +217 -0
  86. package/src/mux/live-mux/modal-input-reducers.ts +94 -0
  87. package/src/mux/live-mux/modal-overlays.ts +287 -0
  88. package/src/mux/live-mux/modal-pointer.ts +70 -0
  89. package/src/mux/live-mux/modal-prompt-handlers.ts +187 -0
  90. package/src/mux/live-mux/modal-task-editor-handler.ts +156 -0
  91. package/src/mux/live-mux/observed-stream.ts +87 -0
  92. package/src/mux/live-mux/palette-parsing.ts +128 -0
  93. package/src/mux/live-mux/pointer-routing.ts +108 -0
  94. package/src/mux/live-mux/process-usage.ts +53 -0
  95. package/src/mux/live-mux/project-pane-pointer.ts +44 -0
  96. package/src/mux/live-mux/rail-layout.ts +244 -0
  97. package/src/mux/live-mux/render-trace-analysis.ts +213 -0
  98. package/src/mux/live-mux/render-trace-state.ts +84 -0
  99. package/src/mux/live-mux/repository-folding.ts +207 -0
  100. package/src/mux/live-mux/runtime-shutdown.ts +51 -0
  101. package/src/mux/live-mux/selection.ts +411 -0
  102. package/src/mux/live-mux/startup-utils.ts +187 -0
  103. package/src/mux/live-mux/status-timeline-state.ts +82 -0
  104. package/src/mux/live-mux/task-pane-shortcuts.ts +206 -0
  105. package/src/mux/live-mux/terminal-palette.ts +79 -0
  106. package/src/mux/new-thread-prompt.ts +165 -0
  107. package/src/mux/project-tree.ts +295 -0
  108. package/src/mux/render-frame.ts +113 -0
  109. package/src/mux/runtime-wiring.ts +185 -0
  110. package/src/mux/selector-index.ts +160 -0
  111. package/src/mux/startup-sequencer.ts +238 -0
  112. package/src/mux/task-composer.ts +289 -0
  113. package/src/mux/task-focused-pane.ts +417 -0
  114. package/src/mux/task-screen-keybindings.ts +539 -0
  115. package/src/mux/terminal-input-modes.ts +35 -0
  116. package/src/mux/workspace-path.ts +55 -0
  117. package/src/mux/workspace-rail-model.ts +701 -0
  118. package/src/mux/workspace-rail.ts +247 -0
  119. package/src/perf/perf-core.ts +307 -0
  120. package/src/pty/pty_host.ts +217 -0
  121. package/src/pty/session-broker.ts +158 -0
  122. package/src/recording/terminal-recording.ts +383 -0
  123. package/src/services/control-plane.ts +567 -0
  124. package/src/services/conversation-lifecycle.ts +176 -0
  125. package/src/services/conversation-startup-hydration.ts +47 -0
  126. package/src/services/directory-hydration.ts +49 -0
  127. package/src/services/event-persistence.ts +104 -0
  128. package/src/services/mux-ui-state-persistence.ts +82 -0
  129. package/src/services/output-load-sampler.ts +231 -0
  130. package/src/services/process-usage-refresh.ts +88 -0
  131. package/src/services/recording.ts +75 -0
  132. package/src/services/render-trace-recorder.ts +177 -0
  133. package/src/services/runtime-control-actions.ts +123 -0
  134. package/src/services/runtime-control-plane-ops.ts +131 -0
  135. package/src/services/runtime-conversation-actions.ts +113 -0
  136. package/src/services/runtime-conversation-activation.ts +78 -0
  137. package/src/services/runtime-conversation-starter.ts +171 -0
  138. package/src/services/runtime-conversation-title-edit.ts +149 -0
  139. package/src/services/runtime-directory-actions.ts +164 -0
  140. package/src/services/runtime-envelope-handler.ts +198 -0
  141. package/src/services/runtime-git-state.ts +92 -0
  142. package/src/services/runtime-input-pipeline.ts +50 -0
  143. package/src/services/runtime-input-router.ts +202 -0
  144. package/src/services/runtime-layout-resize.ts +236 -0
  145. package/src/services/runtime-left-rail-render.ts +159 -0
  146. package/src/services/runtime-main-pane-input.ts +230 -0
  147. package/src/services/runtime-modal-input.ts +119 -0
  148. package/src/services/runtime-navigation-input.ts +207 -0
  149. package/src/services/runtime-process-wiring.ts +68 -0
  150. package/src/services/runtime-rail-input.ts +287 -0
  151. package/src/services/runtime-render-flush.ts +146 -0
  152. package/src/services/runtime-render-lifecycle.ts +104 -0
  153. package/src/services/runtime-render-orchestrator.ts +108 -0
  154. package/src/services/runtime-render-pipeline.ts +167 -0
  155. package/src/services/runtime-render-state.ts +72 -0
  156. package/src/services/runtime-repository-actions.ts +197 -0
  157. package/src/services/runtime-right-pane-render.ts +132 -0
  158. package/src/services/runtime-shutdown.ts +79 -0
  159. package/src/services/runtime-stream-subscriptions.ts +56 -0
  160. package/src/services/runtime-task-composer-persistence.ts +139 -0
  161. package/src/services/runtime-task-editor-actions.ts +83 -0
  162. package/src/services/runtime-task-pane-actions.ts +198 -0
  163. package/src/services/runtime-task-pane-shortcuts.ts +189 -0
  164. package/src/services/runtime-task-pane.ts +62 -0
  165. package/src/services/runtime-workspace-actions.ts +153 -0
  166. package/src/services/runtime-workspace-observed-events.ts +190 -0
  167. package/src/services/session-projection-instrumentation.ts +190 -0
  168. package/src/services/startup-background-probe.ts +91 -0
  169. package/src/services/startup-background-resume.ts +65 -0
  170. package/src/services/startup-orchestrator.ts +166 -0
  171. package/src/services/startup-output-tracker.ts +54 -0
  172. package/src/services/startup-paint-tracker.ts +115 -0
  173. package/src/services/startup-persisted-conversation-queue.ts +45 -0
  174. package/src/services/startup-settled-gate.ts +67 -0
  175. package/src/services/startup-shutdown.ts +53 -0
  176. package/src/services/startup-span-tracker.ts +77 -0
  177. package/src/services/startup-state-hydration.ts +94 -0
  178. package/src/services/startup-visibility.ts +35 -0
  179. package/src/services/status-timeline-recorder.ts +144 -0
  180. package/src/services/task-pane-selection-actions.ts +153 -0
  181. package/src/services/task-planning-hydration.ts +58 -0
  182. package/src/services/task-planning-observed-events.ts +89 -0
  183. package/src/services/workspace-observed-events.ts +113 -0
  184. package/src/store/control-plane-store-normalize.ts +760 -0
  185. package/src/store/control-plane-store-types.ts +224 -0
  186. package/src/store/control-plane-store.ts +2951 -0
  187. package/src/store/event-store.ts +253 -0
  188. package/src/store/sqlite.ts +81 -0
  189. package/src/terminal/compat-matrix.ts +345 -0
  190. package/src/terminal/differential-checkpoints.ts +132 -0
  191. package/src/terminal/parity-suite.ts +441 -0
  192. package/src/terminal/snapshot-oracle.ts +1840 -0
  193. package/src/ui/conversation-input-forwarder.ts +114 -0
  194. package/src/ui/conversation-selection-input.ts +103 -0
  195. package/src/ui/debug-footer-notice.ts +39 -0
  196. package/src/ui/global-shortcut-input.ts +126 -0
  197. package/src/ui/input-preflight.ts +68 -0
  198. package/src/ui/input-token-router.ts +312 -0
  199. package/src/ui/input.ts +238 -0
  200. package/src/ui/kit.ts +509 -0
  201. package/src/ui/left-nav-input.ts +80 -0
  202. package/src/ui/left-rail-pointer-input.ts +148 -0
  203. package/src/ui/main-pane-pointer-input.ts +150 -0
  204. package/src/ui/modals/manager.ts +192 -0
  205. package/src/ui/mux-theme.ts +529 -0
  206. package/src/ui/panes/conversation.ts +19 -0
  207. package/src/ui/panes/home-gridfire.ts +302 -0
  208. package/src/ui/panes/home.ts +109 -0
  209. package/src/ui/panes/left-rail.ts +12 -0
  210. package/src/ui/panes/project.ts +44 -0
  211. package/src/ui/pointer-routing-input.ts +158 -0
  212. package/src/ui/repository-fold-input.ts +91 -0
  213. package/src/ui/screen.ts +210 -0
  214. package/src/ui/surface.ts +224 -0
@@ -0,0 +1,142 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import type { EventScope } from '../../events/normalized-events.ts';
4
+
5
+ const DEFAULT_RECORDING_FPS = 30;
6
+ const RECORDINGS_DIR_RELATIVE_PATH = '.harness/recordings';
7
+
8
+ interface MuxOptions {
9
+ codexArgs: string[];
10
+ storePath: string;
11
+ initialConversationId: string;
12
+ invocationDirectory: string;
13
+ controlPlaneHost: string | null;
14
+ controlPlanePort: number | null;
15
+ controlPlaneAuthToken: string | null;
16
+ recordingPath: string | null;
17
+ recordingGifOutputPath: string | null;
18
+ recordingFps: number;
19
+ scope: EventScope;
20
+ }
21
+
22
+ interface ParseMuxArgsOptions {
23
+ readonly env?: NodeJS.ProcessEnv;
24
+ readonly cwd?: string;
25
+ readonly randomId?: () => string;
26
+ readonly nowIso?: () => string;
27
+ }
28
+
29
+ function sanitizeFileToken(value: string): string {
30
+ const trimmed = value.trim();
31
+ if (trimmed.length === 0) {
32
+ return 'recording';
33
+ }
34
+ const normalized = trimmed.replace(/[^A-Za-z0-9._-]/g, '-');
35
+ return normalized.length > 0 ? normalized : 'recording';
36
+ }
37
+
38
+ export function parseMuxArgs(argv: string[], options: ParseMuxArgsOptions = {}): MuxOptions {
39
+ const env = options.env ?? process.env;
40
+ const cwd = options.cwd ?? process.cwd();
41
+ const randomId = options.randomId ?? randomUUID;
42
+ const nowIso = options.nowIso ?? (() => new Date().toISOString());
43
+
44
+ const codexArgs: string[] = [];
45
+ let controlPlaneHost = env.HARNESS_CONTROL_PLANE_HOST ?? null;
46
+ let controlPlanePortRaw = env.HARNESS_CONTROL_PLANE_PORT ?? null;
47
+ let controlPlaneAuthToken = env.HARNESS_CONTROL_PLANE_AUTH_TOKEN ?? null;
48
+ let recordEnabled = false;
49
+ const invocationDirectory = env.HARNESS_INVOKE_CWD ?? env.INIT_CWD ?? cwd;
50
+
51
+ for (let idx = 0; idx < argv.length; idx += 1) {
52
+ const arg = argv[idx]!;
53
+ if (arg === '--harness-server-host') {
54
+ const value = argv[idx + 1];
55
+ if (value === undefined) {
56
+ throw new Error('missing value for --harness-server-host');
57
+ }
58
+ controlPlaneHost = value;
59
+ idx += 1;
60
+ continue;
61
+ }
62
+
63
+ if (arg === '--harness-server-port') {
64
+ const value = argv[idx + 1];
65
+ if (value === undefined) {
66
+ throw new Error('missing value for --harness-server-port');
67
+ }
68
+ controlPlanePortRaw = value;
69
+ idx += 1;
70
+ continue;
71
+ }
72
+
73
+ if (arg === '--harness-server-token') {
74
+ const value = argv[idx + 1];
75
+ if (value === undefined) {
76
+ throw new Error('missing value for --harness-server-token');
77
+ }
78
+ controlPlaneAuthToken = value;
79
+ idx += 1;
80
+ continue;
81
+ }
82
+
83
+ if (arg === '--record') {
84
+ recordEnabled = true;
85
+ continue;
86
+ }
87
+
88
+ if (arg === '--record-path' || arg === '--record-output' || arg === '--record-fps') {
89
+ throw new Error(`${arg} is no longer supported; use --record`);
90
+ }
91
+
92
+ codexArgs.push(arg);
93
+ }
94
+
95
+ let controlPlanePort: number | null = null;
96
+ if (controlPlanePortRaw !== null) {
97
+ const parsed = Number.parseInt(controlPlanePortRaw, 10);
98
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) {
99
+ throw new Error(`invalid --harness-server-port value: ${controlPlanePortRaw}`);
100
+ }
101
+ controlPlanePort = parsed;
102
+ }
103
+
104
+ if ((controlPlaneHost === null) !== (controlPlanePort === null)) {
105
+ throw new Error('both control-plane host and port must be set together');
106
+ }
107
+
108
+ let recordingPath: string | null = null;
109
+ let recordingGifOutputPath: string | null = null;
110
+ if (recordEnabled) {
111
+ const recordingsDirectoryPath = resolve(invocationDirectory, RECORDINGS_DIR_RELATIVE_PATH);
112
+ const nowToken = sanitizeFileToken(nowIso().replaceAll(':', '-').replaceAll('.', '-'));
113
+ const randomToken = sanitizeFileToken(randomId());
114
+ const stem = `${nowToken}-${randomToken}`;
115
+ recordingGifOutputPath = join(recordingsDirectoryPath, `${stem}.gif`);
116
+ recordingPath = join(recordingsDirectoryPath, `${stem}.jsonl`);
117
+ }
118
+
119
+ const initialConversationId = env.HARNESS_CONVERSATION_ID ?? `conversation-${randomId()}`;
120
+ const turnId = env.HARNESS_TURN_ID ?? `turn-${randomId()}`;
121
+
122
+ return {
123
+ codexArgs,
124
+ storePath: env.HARNESS_EVENTS_DB_PATH ?? '.harness/events.sqlite',
125
+ initialConversationId,
126
+ invocationDirectory,
127
+ controlPlaneHost,
128
+ controlPlanePort,
129
+ controlPlaneAuthToken,
130
+ recordingPath,
131
+ recordingGifOutputPath,
132
+ recordingFps: DEFAULT_RECORDING_FPS,
133
+ scope: {
134
+ tenantId: env.HARNESS_TENANT_ID ?? 'tenant-local',
135
+ userId: env.HARNESS_USER_ID ?? 'user-local',
136
+ workspaceId: env.HARNESS_WORKSPACE_ID ?? basename(cwd),
137
+ worktreeId: env.HARNESS_WORKTREE_ID ?? 'worktree-local',
138
+ conversationId: initialConversationId,
139
+ turnId,
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,298 @@
1
+ export const COMMAND_MENU_MAX_RESULTS = 8;
2
+
3
+ export interface CommandMenuState {
4
+ readonly query: string;
5
+ readonly selectedIndex: number;
6
+ }
7
+
8
+ export interface CommandMenuActionDescriptor {
9
+ readonly id: string;
10
+ readonly title: string;
11
+ readonly aliases?: readonly string[];
12
+ readonly keywords?: readonly string[];
13
+ readonly detail?: string;
14
+ }
15
+
16
+ export interface RegisteredCommandMenuAction<TContext> extends CommandMenuActionDescriptor {
17
+ readonly when?: (context: TContext) => boolean;
18
+ readonly run: (context: TContext) => Promise<void> | void;
19
+ }
20
+
21
+ interface CommandMenuMatch<TAction extends CommandMenuActionDescriptor> {
22
+ readonly action: TAction;
23
+ readonly score: number;
24
+ }
25
+
26
+ type CommandMenuActionProvider<TContext> = (
27
+ context: TContext,
28
+ ) => readonly RegisteredCommandMenuAction<TContext>[];
29
+
30
+ interface CommandMenuInputReduction {
31
+ readonly nextState: CommandMenuState;
32
+ readonly submit: boolean;
33
+ }
34
+
35
+ function normalizeQuery(value: string): string {
36
+ return value.trim().toLowerCase().replace(/\s+/g, ' ');
37
+ }
38
+
39
+ function normalizedTokens(query: string): readonly string[] {
40
+ const normalized = normalizeQuery(query);
41
+ if (normalized.length === 0) {
42
+ return [];
43
+ }
44
+ return normalized.split(' ');
45
+ }
46
+
47
+ function normalizedAliases(action: CommandMenuActionDescriptor): readonly string[] {
48
+ return (action.aliases ?? [])
49
+ .map((value) => value.trim().toLowerCase())
50
+ .filter((value) => value.length > 0);
51
+ }
52
+
53
+ function normalizedKeywords(action: CommandMenuActionDescriptor): readonly string[] {
54
+ return (action.keywords ?? [])
55
+ .map((value) => value.trim().toLowerCase())
56
+ .filter((value) => value.length > 0);
57
+ }
58
+
59
+ function searchableParts(action: CommandMenuActionDescriptor): readonly string[] {
60
+ const parts = [
61
+ action.title.trim().toLowerCase(),
62
+ ...normalizedAliases(action),
63
+ ...normalizedKeywords(action),
64
+ ].filter((value) => value.length > 0);
65
+ return parts;
66
+ }
67
+
68
+ function actionScore(action: CommandMenuActionDescriptor, query: string): number | null {
69
+ const tokens = normalizedTokens(query);
70
+ if (tokens.length === 0) {
71
+ return 0;
72
+ }
73
+ const title = action.title.trim().toLowerCase();
74
+ const aliases = normalizedAliases(action);
75
+ const keywords = normalizedKeywords(action);
76
+ const haystack = searchableParts(action).join(' ');
77
+
78
+ for (const token of tokens) {
79
+ if (!haystack.includes(token)) {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ const normalizedQuery = tokens.join(' ');
85
+ if (title.startsWith(normalizedQuery)) {
86
+ return 0;
87
+ }
88
+ const aliasPrefix = aliases.findIndex((alias) => alias.startsWith(normalizedQuery));
89
+ if (aliasPrefix >= 0) {
90
+ return 10 + aliasPrefix;
91
+ }
92
+
93
+ const titleContains = title.indexOf(normalizedQuery);
94
+ if (titleContains >= 0) {
95
+ return 100 + titleContains;
96
+ }
97
+ const aliasContains = aliases
98
+ .map((alias) => alias.indexOf(normalizedQuery))
99
+ .find((index) => index >= 0);
100
+ if (aliasContains !== undefined) {
101
+ return 200 + aliasContains;
102
+ }
103
+ const keywordContains = keywords
104
+ .map((keyword) => keyword.indexOf(normalizedQuery))
105
+ .find((index) => index >= 0);
106
+ if (keywordContains !== undefined) {
107
+ return 300 + keywordContains;
108
+ }
109
+ return 1000;
110
+ }
111
+
112
+ function clampSelectedIndex(selectedIndex: number, resultCount: number): number {
113
+ if (resultCount <= 0) {
114
+ return 0;
115
+ }
116
+ if (selectedIndex < 0) {
117
+ return 0;
118
+ }
119
+ if (selectedIndex >= resultCount) {
120
+ return resultCount - 1;
121
+ }
122
+ return selectedIndex;
123
+ }
124
+
125
+ function moveSelectionByDelta(selectedIndex: number, resultCount: number, delta: number): number {
126
+ if (resultCount <= 0) {
127
+ return 0;
128
+ }
129
+ const normalized = (selectedIndex + delta + resultCount * 4) % resultCount;
130
+ return normalized;
131
+ }
132
+
133
+ function isCsiArrowSequence(text: string, final: 'A' | 'B'): boolean {
134
+ if (!text.startsWith('\u001b[') || !text.endsWith(final)) {
135
+ return false;
136
+ }
137
+ const payload = text.slice(2, -1);
138
+ for (const char of payload) {
139
+ const isDigit = char >= '0' && char <= '9';
140
+ if (!isDigit && char !== ';') {
141
+ return false;
142
+ }
143
+ }
144
+ return true;
145
+ }
146
+
147
+ function isUpArrowSequence(text: string): boolean {
148
+ return isCsiArrowSequence(text, 'A') || text === '\u001bOA';
149
+ }
150
+
151
+ function isDownArrowSequence(text: string): boolean {
152
+ return isCsiArrowSequence(text, 'B') || text === '\u001bOB';
153
+ }
154
+
155
+ export function createCommandMenuState(): CommandMenuState {
156
+ return {
157
+ query: '',
158
+ selectedIndex: 0,
159
+ };
160
+ }
161
+
162
+ export function resolveCommandMenuMatches<TAction extends CommandMenuActionDescriptor>(
163
+ actions: readonly TAction[],
164
+ query: string,
165
+ limit = COMMAND_MENU_MAX_RESULTS,
166
+ ): readonly CommandMenuMatch<TAction>[] {
167
+ const scored = actions
168
+ .flatMap((action) => {
169
+ const score = actionScore(action, query);
170
+ return score === null ? [] : [{ action, score }];
171
+ })
172
+ .sort((left, right) => {
173
+ if (left.score !== right.score) {
174
+ return left.score - right.score;
175
+ }
176
+ return left.action.title.localeCompare(right.action.title);
177
+ });
178
+ return scored.slice(0, Math.max(0, limit));
179
+ }
180
+
181
+ export function reduceCommandMenuInput(
182
+ state: CommandMenuState,
183
+ input: Buffer,
184
+ visibleResultCount: number,
185
+ ): CommandMenuInputReduction {
186
+ const text = input.toString('utf8');
187
+ if (isUpArrowSequence(text)) {
188
+ return {
189
+ nextState: {
190
+ query: state.query,
191
+ selectedIndex: moveSelectionByDelta(state.selectedIndex, visibleResultCount, -1),
192
+ },
193
+ submit: false,
194
+ };
195
+ }
196
+ if (isDownArrowSequence(text)) {
197
+ return {
198
+ nextState: {
199
+ query: state.query,
200
+ selectedIndex: moveSelectionByDelta(state.selectedIndex, visibleResultCount, 1),
201
+ },
202
+ submit: false,
203
+ };
204
+ }
205
+
206
+ let query = state.query;
207
+ let selectedIndex = clampSelectedIndex(state.selectedIndex, visibleResultCount);
208
+ let submit = false;
209
+ for (const byte of input) {
210
+ if (byte === 0x0d || byte === 0x0a) {
211
+ submit = true;
212
+ break;
213
+ }
214
+ if (byte === 0x09 || byte === 0x0e) {
215
+ selectedIndex = moveSelectionByDelta(selectedIndex, visibleResultCount, 1);
216
+ continue;
217
+ }
218
+ if (byte === 0x10) {
219
+ selectedIndex = moveSelectionByDelta(selectedIndex, visibleResultCount, -1);
220
+ continue;
221
+ }
222
+ if (byte === 0x7f || byte === 0x08) {
223
+ if (query.length > 0) {
224
+ query = query.slice(0, -1);
225
+ selectedIndex = 0;
226
+ }
227
+ continue;
228
+ }
229
+ if (byte >= 32 && byte <= 126) {
230
+ query += String.fromCharCode(byte);
231
+ selectedIndex = 0;
232
+ }
233
+ }
234
+
235
+ return {
236
+ nextState: {
237
+ query,
238
+ selectedIndex: clampSelectedIndex(selectedIndex, visibleResultCount),
239
+ },
240
+ submit,
241
+ };
242
+ }
243
+
244
+ export function clampCommandMenuState(
245
+ state: CommandMenuState,
246
+ visibleResultCount: number,
247
+ ): CommandMenuState {
248
+ return {
249
+ query: state.query,
250
+ selectedIndex: clampSelectedIndex(state.selectedIndex, visibleResultCount),
251
+ };
252
+ }
253
+
254
+ export class CommandMenuRegistry<TContext> {
255
+ private readonly staticActions = new Map<string, RegisteredCommandMenuAction<TContext>>();
256
+ private readonly providers = new Map<string, CommandMenuActionProvider<TContext>>();
257
+
258
+ constructor() {}
259
+
260
+ registerAction(action: RegisteredCommandMenuAction<TContext>): () => void {
261
+ this.staticActions.set(action.id, action);
262
+ return () => {
263
+ this.staticActions.delete(action.id);
264
+ };
265
+ }
266
+
267
+ registerProvider(providerId: string, provider: CommandMenuActionProvider<TContext>): () => void {
268
+ this.providers.set(providerId, provider);
269
+ return () => {
270
+ this.providers.delete(providerId);
271
+ };
272
+ }
273
+
274
+ resolveActions(context: TContext): readonly RegisteredCommandMenuAction<TContext>[] {
275
+ const resolved: RegisteredCommandMenuAction<TContext>[] = [];
276
+ const seenIds = new Set<string>();
277
+ for (const action of this.staticActions.values()) {
278
+ if (action.when !== undefined && !action.when(context)) {
279
+ continue;
280
+ }
281
+ resolved.push(action);
282
+ seenIds.add(action.id);
283
+ }
284
+ for (const provider of this.providers.values()) {
285
+ for (const action of provider(context)) {
286
+ if (seenIds.has(action.id)) {
287
+ continue;
288
+ }
289
+ if (action.when !== undefined && !action.when(context)) {
290
+ continue;
291
+ }
292
+ resolved.push(action);
293
+ seenIds.add(action.id);
294
+ }
295
+ }
296
+ return resolved;
297
+ }
298
+ }