ompchamber 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +30 -8
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/index-5yfre1kw.css +1 -0
  5. package/dist/client/static/js/{index-prbmx3yj.js → index-qhzsybkn.js} +643 -617
  6. package/package.json +1 -1
  7. package/src/cli/lib/args.js +4 -3
  8. package/src/cli/lib/commands/restart.js +7 -6
  9. package/src/cli/lib/commands/stop.js +38 -31
  10. package/src/cli/lib/commands/update.js +3 -2
  11. package/src/cli/lib/stop-scope.js +23 -0
  12. package/src/cli/lib/stop-scope.test.js +41 -0
  13. package/src/client/components/common/code-editor/handler.test.ts +274 -0
  14. package/src/client/components/common/code-editor/handler.ts +175 -0
  15. package/src/client/components/common/code-editor/index.tsx +185 -220
  16. package/src/client/components/common/code-surface/index.tsx +109 -12
  17. package/src/client/components/common/lazy-panels.tsx +5 -1
  18. package/src/client/components/common/viewport.ts +5 -3
  19. package/src/client/components/layout/RightActivityBar.tsx +2 -1
  20. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
  21. package/src/client/components/layout/desktop-layout/index.tsx +24 -4
  22. package/src/client/components/mobile/RightSidebar.tsx +11 -4
  23. package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
  24. package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
  25. package/src/client/components/workspace/browser-panel/index.tsx +6 -6
  26. package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
  27. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
  28. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
  29. package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
  30. package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
  31. package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
  32. package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
  33. package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
  34. package/src/client/components/workspace/editor/index.tsx +153 -53
  35. package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
  36. package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
  37. package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
  38. package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
  39. package/src/client/components/workspace/todo-panel/index.tsx +161 -0
  40. package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
  41. package/src/client/hooks/editor/scroll-container.test.ts +102 -0
  42. package/src/client/hooks/editor/scroll-container.ts +26 -0
  43. package/src/client/hooks/editor/use-code-window.ts +2 -20
  44. package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
  45. package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
  46. package/src/client/hooks/editor/use-editor-find.ts +321 -0
  47. package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
  48. package/src/client/hooks/editor/use-editor-history.ts +134 -0
  49. package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
  50. package/src/client/hooks/editor/use-occurrences.ts +98 -0
  51. package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
  52. package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
  53. package/src/client/hooks/workspace/session-todos.ts +102 -0
  54. package/src/client/tailwind.css +30 -0
  55. package/src/server/lib/bundler/css.test.ts +87 -0
  56. package/src/server/lib/bundler/css.ts +17 -2
  57. package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
  58. package/src/server/lib/bundler/nested-at-rules.ts +146 -0
  59. package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
  60. package/src/server/lib/lifecycle/launch-mode.ts +26 -0
  61. package/src/server/lib/lifecycle/restart.ts +9 -14
  62. package/src/server/lib/omp/session/todos.ts +100 -0
  63. package/src/server/routes/fs/action-create.test.ts +90 -0
  64. package/src/server/routes/fs/action.ts +23 -0
  65. package/src/server/routes/omp/index.ts +2 -0
  66. package/src/server/routes/omp/session-todos.ts +80 -0
  67. package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
  68. package/src/shared/lib/chat/todo/progress.ts +97 -0
  69. package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
  70. package/src/shared/lib/chat/todo/snapshot.ts +215 -0
  71. package/src/shared/lib/code/editor/commands.test.ts +155 -0
  72. package/src/shared/lib/code/editor/commands.ts +260 -0
  73. package/src/shared/lib/code/editor/edits.test.ts +100 -0
  74. package/src/shared/lib/code/editor/edits.ts +122 -0
  75. package/src/shared/lib/code/editor/find-status.test.ts +50 -0
  76. package/src/shared/lib/code/editor/find-status.ts +36 -0
  77. package/src/shared/lib/code/editor/find.test.ts +203 -0
  78. package/src/shared/lib/code/editor/find.ts +224 -0
  79. package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
  80. package/src/shared/lib/code/editor/keymap.test.ts +109 -0
  81. package/src/shared/lib/code/editor/keymap.ts +151 -0
  82. package/src/shared/lib/code/editor/layers.test.ts +77 -0
  83. package/src/shared/lib/code/editor/layers.ts +67 -0
  84. package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
  85. package/src/shared/lib/code/editor/line-edits.ts +159 -0
  86. package/src/shared/lib/code/editor/lines.test.ts +81 -0
  87. package/src/shared/lib/code/editor/lines.ts +83 -0
  88. package/src/shared/lib/code/editor/marks.test.ts +86 -0
  89. package/src/shared/lib/code/editor/marks.ts +106 -0
  90. package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
  91. package/src/shared/lib/code/editor/occurrences.ts +111 -0
  92. package/src/shared/lib/code/editor/palette.test.ts +91 -0
  93. package/src/shared/lib/code/editor/palette.ts +92 -0
  94. package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
  95. package/src/shared/lib/code/editor/reset-css.ts +32 -0
  96. package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
  97. package/src/shared/lib/code/editor/text-commands.ts +154 -0
  98. package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
  99. package/src/shared/lib/code/syntax-highlight.ts +29 -17
  100. package/src/shared/lib/code/windowed-highlight.ts +52 -1
  101. package/src/shared/lib/ui/key-binding.test.ts +115 -0
  102. package/src/shared/lib/ui/key-binding.ts +143 -0
  103. package/src/shared/lib/util/platform.test.ts +87 -0
  104. package/src/shared/lib/util/platform.ts +30 -0
  105. package/src/shared/lib/workspace/keymap.test.ts +82 -0
  106. package/src/shared/lib/workspace/keymap.ts +30 -0
  107. package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
  108. package/src/shared/lib/workspace/right-panels.ts +8 -0
  109. package/src/shared/lib/workspace/session-state/store.ts +0 -1
  110. package/src/shared/types/index.ts +1 -0
  111. package/src/shared/types/todo.ts +108 -0
  112. package/dist/client/static/js/index-ranztes9.css +0 -1
  113. package/src/server/lib/lifecycle/restart.test.ts +0 -21
  114. /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
@@ -188,7 +188,7 @@ USAGE:
188
188
  COMMANDS:
189
189
  serve Start the OMPChamber web server (default when no command is given)
190
190
  update Update OMPChamber to the latest GitHub release
191
- stop Stop running OMPChamber instances (every one, or --port <port>)
191
+ stop Stop the instances the CLI started (--port/--all to stop any)
192
192
  restart Stop and start again (every CLI-started instance, or --port <port>)
193
193
  status Show running instances (every one, or --port <port>)
194
194
  logs Print or follow server logs (every instance, or --port <port>)
@@ -201,7 +201,7 @@ OPTIONS:
201
201
  --prod Run the production build instead of the dev server
202
202
  --foreground Run the server in the foreground (no daemon)
203
203
  --no-daemon Alias for --foreground
204
- --all Explicit form of the default for stop/restart (every instance)
204
+ --all Every instance, including ones started outside the CLI
205
205
  -c, --check Report whether a newer release exists without installing it
206
206
  --force Reinstall even when already up to date
207
207
  --no-restart Do not restart a running instance after updating
@@ -226,7 +226,8 @@ EXAMPLES:
226
226
  ompchamber serve --prod # Serve the production build
227
227
  ompchamber update --check # Is a newer release available?
228
228
  ompchamber update # Update, then restart a running instance
229
- ompchamber stop # Stop the running instance
229
+ ompchamber stop # Stop the instances the CLI started
230
+ ompchamber stop --port 3000 # Stop the instance on port 3000, whoever started it
230
231
  ompchamber restart # Restart the server
231
232
  ompchamber status # Show whether the server is running
232
233
  ompchamber logs -f -n 200 # Follow the last 200 log lines
@@ -1,19 +1,20 @@
1
1
  // `ompchamber restart` — stop live instances, then serve again.
2
2
  //
3
3
  // Scoping matches `status` and `stop`: without `--port` every live instance is
4
- // restarted on its own recorded port/host/mode, and `--port <port>` restricts
5
- // the restart to that one. With nothing running, the command is simply `serve`.
4
+ // considered, and `--port <port>` restricts the restart to that one. With
5
+ // nothing running, the command is simply `serve`.
6
6
  //
7
7
  // Instances the CLI did not start are left alone, exactly as `ompchamber update`
8
8
  // leaves them: `bun run dev` and `bun run start` own their own process, so
9
- // replacing it would break a loop OMPChamber does not manage. `ompchamber stop`
10
- // is still the deliberate way to end one.
9
+ // replacing it would break a loop OMPChamber does not manage. `ompchamber stop
10
+ // --port <port>` (or `--all`) is the deliberate way to end one.
11
11
 
12
12
  import { findLiveInstance, listLiveInstances, stopInstance } from '@/cli/lib/runtime.js';
13
13
  import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
14
14
  import { log, isJson, isQuiet } from '@/cli/lib/output.js';
15
15
  import { run as runServe } from '@/cli/lib/commands/serve.js';
16
- import { isAutoRestartable, skipRestartNote } from '@/server/lib/lifecycle/restart';
16
+ import { skipRestartNote } from '@/server/lib/lifecycle/restart';
17
+ import { isCliManaged } from '@/server/lib/lifecycle/launch-mode';
17
18
 
18
19
  function explicitPort(options) {
19
20
  const raw = options?.port;
@@ -34,7 +35,7 @@ export async function run(options, ctx) {
34
35
  if (targets.length === 0) return runServe(options, ctx);
35
36
 
36
37
  for (const live of targets) {
37
- if (!isAutoRestartable(live.launchMode)) {
38
+ if (!isCliManaged(live.launchMode)) {
38
39
  if (!json && !quiet) log(`Skipping OMPChamber on port ${live.port}: ${skipRestartNote(live.launchMode)}.`);
39
40
  continue;
40
41
  }
@@ -1,22 +1,19 @@
1
- // `ompchamber stop` — stop one or every live OMPChamber instance.
1
+ // `ompchamber stop` — stop OMPChamber instances.
2
+ //
3
+ // Scope follows OWNERSHIP, the same rule `restart` and `update` apply. By
4
+ // default only the instances the CLI started are stopped, because a server
5
+ // running from source is supervised by a launcher the CLI does not own and
6
+ // that launcher exits with the child it spawned: `bun run dev` is a process
7
+ // whose whole job is to mirror the script under it, so signalling the server
8
+ // takes the user's dev loop down with it. `--port <port>` and `--all` are the
9
+ // explicit forms that do end one — naming an instance is a deliberate call,
10
+ // not a default.
2
11
 
3
12
  import { listLiveInstances, findLiveInstance, stopInstance } from '@/cli/lib/runtime.js';
4
13
  import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
5
- import { ok, warn, printJson, isJson } from '@/cli/lib/output.js';
6
-
7
- function resolvePort(options) {
8
- const raw = options?.port;
9
- if (raw !== null && raw !== undefined) {
10
- const parsed = Number(raw);
11
- if (Number.isFinite(parsed)) return parsed;
12
- }
13
- const env = Bun.env.OMPCHAMBER_PORT;
14
- if (typeof env === 'string' && env.trim().length > 0) {
15
- const parsed = Number(env.trim());
16
- if (Number.isFinite(parsed)) return parsed;
17
- }
18
- return 3000;
19
- }
14
+ import { ok, warn, log, printJson, isJson, isQuiet } from '@/cli/lib/output.js';
15
+ import { unmanagedReason } from '@/server/lib/lifecycle/launch-mode';
16
+ import { partitionStopTargets } from '@/cli/lib/stop-scope.js';
20
17
 
21
18
  function explicitPort(options) {
22
19
  const raw = options?.port;
@@ -25,30 +22,40 @@ function explicitPort(options) {
25
22
  return Number.isFinite(parsed) ? parsed : null;
26
23
  }
27
24
 
25
+ /** How to end an instance the default scope leaves alone. */
26
+ function stopHint(port) {
27
+ return `stop it with \`ompchamber stop --port ${port}\` or \`ompchamber stop --all\``;
28
+ }
29
+
28
30
  export async function run(options) {
29
31
  const json = isJson();
32
+ const quiet = isQuiet();
30
33
  const requested = explicitPort(options);
34
+ // `--all` is the explicit spelling of "every instance, whoever started it".
35
+ const explicitScope = requested !== null || Boolean(options?.all);
36
+
37
+ const live = requested !== null
38
+ ? [await findLiveInstance(requested)].filter(Boolean)
39
+ : await listLiveInstances();
40
+ const { targets, skipped } = partitionStopTargets(live, explicitScope);
41
+ const skippedReport = skipped.map((entry) => ({ port: entry.port, pid: entry.pid, launchMode: entry.launchMode }));
31
42
 
32
- // No `--port` means every live instance: a CLI server, a dev run and a
33
- // production server are three processes, and `stop` without a port is the
34
- // command that clears all of them. `--all` is the explicit spelling of the
35
- // same default, kept because it reads well in scripts.
36
- let targets;
37
- if (requested !== null) {
38
- const live = await findLiveInstance(requested);
39
- targets = live ? [live] : [];
40
- } else {
41
- targets = await listLiveInstances();
43
+ if (!json && !quiet) {
44
+ for (const entry of skipped) {
45
+ log(`Skipping OMPChamber on port ${entry.port} (pid ${entry.pid}): it ${unmanagedReason(entry.launchMode)} — ${stopHint(entry.port)}.`);
46
+ }
42
47
  }
43
48
 
44
49
  if (targets.length === 0) {
45
50
  if (json) {
46
- printJson({ stopped: [] });
51
+ printJson({ stopped: [], skipped: skippedReport });
47
52
  return;
48
53
  }
49
- warn(requested === null
50
- ? 'No running OMPChamber instances found.'
51
- : `No running OMPChamber instance on port ${requested}.`);
54
+ if (skipped.length === 0) {
55
+ warn(requested === null
56
+ ? 'No running OMPChamber instances found.'
57
+ : `No running OMPChamber instance on port ${requested}.`);
58
+ }
52
59
  return;
53
60
  }
54
61
 
@@ -64,5 +71,5 @@ export async function run(options) {
64
71
  }
65
72
  }
66
73
 
67
- if (json) printJson({ stopped });
74
+ if (json) printJson({ stopped, skipped: skippedReport });
68
75
  }
@@ -10,7 +10,8 @@ import { color, configure, error, log, ok, warn, printJson, isJson, isQuiet } fr
10
10
  import { listLiveInstances, findLiveInstance, stopInstance } from '@/cli/lib/runtime.js';
11
11
  import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
12
12
  import { run as runServe } from '@/cli/lib/commands/serve.js';
13
- import { isAutoRestartable, skipRestartNote } from '@/server/lib/lifecycle/restart';
13
+ import { skipRestartNote } from '@/server/lib/lifecycle/restart';
14
+ import { isCliManaged } from '@/server/lib/lifecycle/launch-mode';
14
15
  import { resolveInstallContext, resolveOmpChamberVersion, updateOmpChamber } from '@/server/lib/updates/install';
15
16
 
16
17
  /**
@@ -30,7 +31,7 @@ async function restartInstances(options, ctx) {
30
31
  const restarted = [];
31
32
  const skipped = [];
32
33
  for (const entry of await liveInstances(options)) {
33
- if (!isAutoRestartable(entry.launchMode)) {
34
+ if (!isCliManaged(entry.launchMode)) {
34
35
  skipped.push({ port: entry.port, pid: entry.pid, launchMode: entry.launchMode });
35
36
  log(`Skipping OMPChamber on port ${entry.port}: ${skipRestartNote(entry.launchMode)}.`);
36
37
  continue;
@@ -0,0 +1,23 @@
1
+ // Ownership scope for `ompchamber stop`.
2
+ //
3
+ // Kept separate from the command so the rule is testable without a live
4
+ // server: the default scope is the instances the CLI started, and only an
5
+ // explicit `--port <port>` / `--all` widens it to the ones it did not. That
6
+ // distinction is the whole reason this module exists — a server running from
7
+ // source sits under a launcher that exits with its child, so stopping it by
8
+ // default takes a `bun run dev` loop down with it.
9
+
10
+ import { isCliManaged } from '@/server/lib/lifecycle/launch-mode';
11
+
12
+ /**
13
+ * Split the live instances into the ones `stop` acts on and the ones it leaves
14
+ * alone. `explicit` is true when the user named a port or passed `--all`:
15
+ * naming an instance is a deliberate call, so ownership no longer gates it.
16
+ */
17
+ export function partitionStopTargets(instances, explicit) {
18
+ if (explicit) return { targets: instances, skipped: [] };
19
+ return {
20
+ targets: instances.filter((entry) => isCliManaged(entry.launchMode)),
21
+ skipped: instances.filter((entry) => !isCliManaged(entry.launchMode)),
22
+ };
23
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { partitionStopTargets } from '@/cli/lib/stop-scope.js';
9
+
10
+ const daemon = { port: 3000, pid: 11, launchMode: 'daemon' };
11
+ const foreground = { port: 3001, pid: 12, launchMode: 'foreground' };
12
+ const dev = { port: 3002, pid: 13, launchMode: 'direct' };
13
+ const unknown = { port: 3003, pid: 14, launchMode: 'unknown' };
14
+
15
+ describe('partitionStopTargets', () => {
16
+ // The default scope is ownership, not "everything that answers /api/health".
17
+ // `bun run dev` is a supervisor whose only job is to mirror the script under
18
+ // it, so stopping that server exits the user's dev loop too — the bug this
19
+ // rule exists to prevent.
20
+ test('stops only the instances the CLI started when the scope is implicit', () => {
21
+ const { targets, skipped } = partitionStopTargets([daemon, dev, foreground, unknown], false);
22
+ expect(targets).toEqual([daemon, foreground]);
23
+ expect(skipped).toEqual([dev, unknown]);
24
+ });
25
+
26
+ // Naming an instance — a port or `--all` — is a deliberate call, so the
27
+ // ownership rule no longer gates it.
28
+ test('widens to every instance when the scope is explicit', () => {
29
+ const { targets, skipped } = partitionStopTargets([daemon, dev, unknown], true);
30
+ expect(targets).toEqual([daemon, dev, unknown]);
31
+ expect(skipped).toEqual([]);
32
+ });
33
+
34
+ test('an unreadable launch mode is never stopped implicitly', () => {
35
+ // A record whose launchMode could not be read must not authorise a kill:
36
+ // the safe reading is "not ours".
37
+ const { targets, skipped } = partitionStopTargets([{ port: 4000, pid: 99, launchMode: undefined }], false);
38
+ expect(targets).toEqual([]);
39
+ expect(skipped.length).toBe(1);
40
+ });
41
+ });
@@ -0,0 +1,274 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor's keydown dispatcher, exercised with a fake textarea.
8
+ *
9
+ * These assertions are the routing contract: which layer owns a key, and what
10
+ * happens to the event when nobody does. The failures they guard against are
11
+ * all silent — a chord that reaches the browser's own find bar, an Escape that
12
+ * throws away a multi-selection instead of dropping it, a Tab that escapes the
13
+ * editor mid-line.
14
+ *
15
+ * The platform is PINNED rather than detected. The dispatcher resolves chords
16
+ * through `isMacPlatform()`, so a test written with ⌘ chords passes on a
17
+ * developer's Mac and fails on the Linux CI runner — which is exactly what
18
+ * happened. `usePlatform()` below makes each case state the platform it means.
19
+ */
20
+
21
+ import { describe, expect, test } from 'bun:test';
22
+
23
+ import { handleEditorKeydown, runEditorCommand, type EditorKeydownDeps } from '@/client/components/common/code-editor/handler';
24
+ import { createHistory, pushRecord } from '@/shared/lib/code/editor/history';
25
+ import type { EditorHistory } from '@/client/hooks/editor/use-editor-history';
26
+ import type { OccurrencesApi } from '@/client/hooks/editor/use-occurrences';
27
+ import type { EditorCommand } from '@/shared/lib/code/editor/keymap';
28
+ import type { TextRange } from '@/shared/lib/code/editor/commands';
29
+
30
+ /** A textarea stand-in: the dispatcher only reads value/selection and calls blur. */
31
+ function fakeInput(value: string, start: number, end = start) {
32
+ return {
33
+ value,
34
+ selectionStart: start,
35
+ selectionEnd: end,
36
+ blurred: false,
37
+ blur() {
38
+ this.blurred = true;
39
+ },
40
+ };
41
+ }
42
+
43
+ interface Harness {
44
+ deps: EditorKeydownDeps;
45
+ committed: Array<{ value: string; selectionStart: number; selectionEnd: number }>;
46
+ steps: string[];
47
+ delegated: EditorCommand[];
48
+ cleared: number;
49
+ selected: Array<[number, number]>;
50
+ ranges: TextRange[];
51
+ captureToggles: number;
52
+ }
53
+
54
+ function harness(options: { value: string; start?: number; end?: number; ranges?: TextRange[]; language?: string; delegate?: (c: EditorCommand) => boolean } = { value: '' }): Harness {
55
+ const committed: Harness['committed'] = [];
56
+ const steps: string[] = [];
57
+ const delegated: EditorCommand[] = [];
58
+ const selected: Array<[number, number]> = [];
59
+ const state = { ranges: options.ranges ? [...options.ranges] : [] as TextRange[] };
60
+ let cleared = 0;
61
+ let captureToggles = 0;
62
+
63
+ const history: EditorHistory = {
64
+ history: createHistory(),
65
+ buffer: options.value,
66
+ commit: (edit) => committed.push(edit),
67
+ commitEdit: (edit) => committed.push(edit),
68
+ step: (direction) => steps.push(direction),
69
+ markApplied: () => {},
70
+ };
71
+
72
+ const multi: OccurrencesApi = {
73
+ get ranges() {
74
+ return state.ranges;
75
+ },
76
+ set: (ranges) => {
77
+ state.ranges = [...ranges];
78
+ },
79
+ clear: () => {
80
+ cleared++;
81
+ state.ranges = [];
82
+ },
83
+ selectNext: (input, select) => select(input.selectionStart, input.selectionEnd + 1),
84
+ selectAll: (input, select) => select(0, input.value.length),
85
+ insertCursorBelow: () => {},
86
+ replicate: () => null,
87
+ };
88
+
89
+ const input = fakeInput(options.value, options.start ?? 0, options.end ?? options.start ?? 0);
90
+ const deps: EditorKeydownDeps = {
91
+ input: input as unknown as HTMLTextAreaElement,
92
+ language: options.language ?? 'typescript',
93
+ captureTab: true,
94
+ toggleCaptureTab: () => captureToggles++,
95
+ history,
96
+ multi,
97
+ select: (start, end) => selected.push([start, end]),
98
+ delegate: (command) => {
99
+ delegated.push(command);
100
+ options.delegate?.(command);
101
+ },
102
+ };
103
+
104
+ return {
105
+ deps,
106
+ committed,
107
+ steps,
108
+ delegated,
109
+ selected,
110
+ get cleared() {
111
+ return cleared;
112
+ },
113
+ get captureToggles() {
114
+ return captureToggles;
115
+ },
116
+ get ranges() {
117
+ return state.ranges;
118
+ },
119
+ } as Harness;
120
+ }
121
+
122
+ /** Run `body` with `navigator.platform` reporting `platform`. */
123
+ function usePlatform<T>(platform: string, body: () => T): T {
124
+ const globals = globalThis as Record<string, unknown>;
125
+ const previous = globals.navigator;
126
+ globals.navigator = { platform };
127
+ try {
128
+ return body();
129
+ } finally {
130
+ if (previous === undefined) delete globals.navigator;
131
+ else globals.navigator = previous;
132
+ }
133
+ }
134
+
135
+ /** A keydown event with only the fields the dispatcher reads. */
136
+ function press(key: string, code: string, modifiers: Partial<{ metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }> = {}) {
137
+ const prevented = { value: false };
138
+ const event = {
139
+ key,
140
+ code,
141
+ metaKey: false,
142
+ ctrlKey: false,
143
+ altKey: false,
144
+ shiftKey: false,
145
+ ...modifiers,
146
+ preventDefault: () => {
147
+ prevented.value = true;
148
+ },
149
+ };
150
+ return { event: event as never, prevented };
151
+ }
152
+
153
+ describe('handleEditorKeydown', () => {
154
+ test('Escape drops a live multi-selection before it leaves the editor', () => {
155
+ const first = harness({ value: 'abc', ranges: [{ start: 1, end: 2 }] });
156
+ const one = press('Escape', 'Escape');
157
+ handleEditorKeydown(one.event, first.deps);
158
+
159
+ expect(first.cleared).toBe(1);
160
+ expect(one.prevented.value).toBe(true);
161
+
162
+ // With no extra ranges left, Escape is a plain blur.
163
+ const input = first.deps.input as unknown as { blurred: boolean };
164
+ const two = press('Escape', 'Escape');
165
+ handleEditorKeydown(two.event, first.deps);
166
+
167
+ expect(input.blurred).toBe(true);
168
+ expect(two.prevented.value).toBe(false);
169
+ });
170
+
171
+ test('Tab indents through the history, not through a raw write', () => {
172
+ const h = harness({ value: 'one', start: 0 });
173
+ const { event, prevented } = press('Tab', 'Tab');
174
+ handleEditorKeydown(event, h.deps);
175
+
176
+ expect(prevented.value).toBe(true);
177
+ expect(h.committed[0].value).toBe(' one');
178
+ });
179
+
180
+ test('undo and redo step the history and drop the extra ranges', () => {
181
+ const h = harness({ value: 'abc', ranges: [{ start: 1, end: 2 }] });
182
+ usePlatform('MacIntel', () => handleEditorKeydown(press('z', 'KeyZ', { metaKey: true }).event, h.deps));
183
+
184
+ expect(h.steps).toEqual(['undo']);
185
+ expect(h.cleared).toBe(1);
186
+ });
187
+
188
+ test('a line command commits and clears the ranges it just invalidated', () => {
189
+ const h = harness({ value: 'one\ntwo', start: 0, end: 3, ranges: [{ start: 8, end: 9 }] });
190
+ handleEditorKeydown(press('ArrowDown', 'ArrowDown', { altKey: true }).event, h.deps);
191
+
192
+ expect(h.committed[0].value).toBe('two\none');
193
+ expect(h.cleared).toBe(1);
194
+ });
195
+
196
+ test('⌘D routes to the occurrence grower with the editor’s own select', () => {
197
+ const h = harness({ value: 'alpha', start: 0, end: 5 });
198
+ usePlatform('MacIntel', () => handleEditorKeydown(press('d', 'KeyD', { metaKey: true }).event, h.deps));
199
+
200
+ expect(h.selected).toEqual([[0, 6]]);
201
+ });
202
+
203
+ test('a find-bar chord is delegated and consumed', () => {
204
+ // The bar may not be open: nothing handles ⌘F, but the browser's own find
205
+ // bar must not open over the editor either — so the chord is consumed
206
+ // whatever the panel does with it.
207
+ const h = harness({ value: 'abc' });
208
+ const { event, prevented } = press('f', 'KeyF', { metaKey: true });
209
+ usePlatform('MacIntel', () => handleEditorKeydown(event, h.deps));
210
+
211
+ expect(h.delegated).toEqual(['find']);
212
+ expect(prevented.value).toBe(true);
213
+ });
214
+
215
+ test('a palette-style call runs a text command with no event at all', () => {
216
+ const h = harness({ value: 'one\ntwo', start: 0, end: 3 });
217
+ runEditorCommand('copyLineDown', h.deps);
218
+
219
+ expect(h.committed[0].value).toBe('one\none\ntwo');
220
+ });
221
+
222
+ test('an unbound chord is left to the browser', () => {
223
+ const h = harness({ value: 'abc' });
224
+ const { event, prevented } = press('a', 'KeyA', { metaKey: true });
225
+ handleEditorKeydown(event, h.deps);
226
+
227
+ expect(h.delegated).toEqual([]);
228
+ expect(prevented.value).toBe(false);
229
+ });
230
+
231
+ test('a chord built on Enter reaches the keymap instead of the plain-Enter branch', () => {
232
+ // ⌘⏎ is `insertLineBelow`; the plain-Enter auto-indent branch used to catch
233
+ // it first and do nothing on an unindented line, making the command dead.
234
+ const h = harness({ value: 'const a = 1;\nconst b = 2;', start: 20 });
235
+ usePlatform('MacIntel', () => handleEditorKeydown(press('Enter', 'Enter', { metaKey: true }).event, h.deps));
236
+
237
+ expect(h.committed[0].value).toBe('const a = 1;\nconst b = 2;\n');
238
+ });
239
+
240
+ test('plain Enter still auto-indents', () => {
241
+ const h = harness({ value: ' one', start: 5 });
242
+ handleEditorKeydown(press('Enter', 'Enter').event, h.deps);
243
+
244
+ expect(h.committed[0].value).toBe(' one\n ');
245
+ });
246
+
247
+ test('⌘[ outdents the line the caret is on, with nothing selected', () => {
248
+ const h = harness({ value: ' one', start: 2 });
249
+ usePlatform('MacIntel', () => handleEditorKeydown(press('[', 'BracketLeft', { metaKey: true }).event, h.deps));
250
+
251
+ expect(h.committed[0].value).toBe('one');
252
+ expect(h.committed[0].selectionStart).toBe(0);
253
+ });
254
+
255
+ test('Ctrl+M still toggles Tab capture', () => {
256
+ const h = harness({ value: 'abc' });
257
+ // On macOS the chord needs Shift (Ctrl+M alone is Return in the terminal's
258
+ // own vocabulary), which is what the handler encodes. Both platforms are
259
+ // exercised explicitly rather than detected.
260
+ usePlatform('Win32', () => handleEditorKeydown(press('m', 'KeyM', { ctrlKey: true }).event, h.deps));
261
+ expect(h.captureToggles).toBe(1);
262
+
263
+ usePlatform('MacIntel', () => handleEditorKeydown(press('m', 'KeyM', { ctrlKey: true, shiftKey: true }).event, h.deps));
264
+ expect(h.captureToggles).toBe(2);
265
+ });
266
+ });
267
+
268
+ describe('editor keymap ownership', () => {
269
+ test('the editor’s own history is seeded, not the browser’s', () => {
270
+ const history = createHistory();
271
+ pushRecord(history, { value: 'a', selectionStart: 1, selectionEnd: 1, timestamp: 1 });
272
+ expect(history.stack.length).toBe(1);
273
+ });
274
+ });