ompchamber 3.0.0 → 3.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 (100) hide show
  1. package/README.md +24 -5
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/{index-prbmx3yj.js → index-8t70jhyj.js} +643 -617
  5. package/dist/client/static/js/index-n4wkqj7t.css +1 -0
  6. package/package.json +1 -1
  7. package/src/client/components/common/code-editor/handler.test.ts +274 -0
  8. package/src/client/components/common/code-editor/handler.ts +175 -0
  9. package/src/client/components/common/code-editor/index.tsx +185 -220
  10. package/src/client/components/common/code-surface/index.tsx +109 -12
  11. package/src/client/components/common/lazy-panels.tsx +5 -1
  12. package/src/client/components/common/viewport.ts +5 -3
  13. package/src/client/components/layout/RightActivityBar.tsx +2 -1
  14. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
  15. package/src/client/components/layout/desktop-layout/index.tsx +24 -4
  16. package/src/client/components/mobile/RightSidebar.tsx +11 -4
  17. package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
  18. package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
  19. package/src/client/components/workspace/browser-panel/index.tsx +6 -6
  20. package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
  21. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
  22. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
  23. package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
  24. package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
  25. package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
  26. package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
  27. package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
  28. package/src/client/components/workspace/editor/index.tsx +153 -53
  29. package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
  30. package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
  31. package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
  32. package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
  33. package/src/client/components/workspace/todo-panel/index.tsx +161 -0
  34. package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
  35. package/src/client/hooks/editor/scroll-container.test.ts +102 -0
  36. package/src/client/hooks/editor/scroll-container.ts +26 -0
  37. package/src/client/hooks/editor/use-code-window.ts +2 -20
  38. package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
  39. package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
  40. package/src/client/hooks/editor/use-editor-find.ts +321 -0
  41. package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
  42. package/src/client/hooks/editor/use-editor-history.ts +134 -0
  43. package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
  44. package/src/client/hooks/editor/use-occurrences.ts +98 -0
  45. package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
  46. package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
  47. package/src/client/hooks/workspace/session-todos.ts +102 -0
  48. package/src/client/tailwind.css +30 -0
  49. package/src/server/lib/omp/session/todos.ts +100 -0
  50. package/src/server/routes/fs/action-create.test.ts +90 -0
  51. package/src/server/routes/fs/action.ts +23 -0
  52. package/src/server/routes/omp/index.ts +2 -0
  53. package/src/server/routes/omp/session-todos.ts +80 -0
  54. package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
  55. package/src/shared/lib/chat/todo/progress.ts +97 -0
  56. package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
  57. package/src/shared/lib/chat/todo/snapshot.ts +215 -0
  58. package/src/shared/lib/code/editor/commands.test.ts +155 -0
  59. package/src/shared/lib/code/editor/commands.ts +260 -0
  60. package/src/shared/lib/code/editor/edits.test.ts +100 -0
  61. package/src/shared/lib/code/editor/edits.ts +122 -0
  62. package/src/shared/lib/code/editor/find-status.test.ts +50 -0
  63. package/src/shared/lib/code/editor/find-status.ts +36 -0
  64. package/src/shared/lib/code/editor/find.test.ts +203 -0
  65. package/src/shared/lib/code/editor/find.ts +224 -0
  66. package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
  67. package/src/shared/lib/code/editor/keymap.test.ts +109 -0
  68. package/src/shared/lib/code/editor/keymap.ts +151 -0
  69. package/src/shared/lib/code/editor/layers.test.ts +77 -0
  70. package/src/shared/lib/code/editor/layers.ts +67 -0
  71. package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
  72. package/src/shared/lib/code/editor/line-edits.ts +159 -0
  73. package/src/shared/lib/code/editor/lines.test.ts +81 -0
  74. package/src/shared/lib/code/editor/lines.ts +83 -0
  75. package/src/shared/lib/code/editor/marks.test.ts +86 -0
  76. package/src/shared/lib/code/editor/marks.ts +106 -0
  77. package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
  78. package/src/shared/lib/code/editor/occurrences.ts +111 -0
  79. package/src/shared/lib/code/editor/palette.test.ts +91 -0
  80. package/src/shared/lib/code/editor/palette.ts +92 -0
  81. package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
  82. package/src/shared/lib/code/editor/reset-css.ts +32 -0
  83. package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
  84. package/src/shared/lib/code/editor/text-commands.ts +154 -0
  85. package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
  86. package/src/shared/lib/code/syntax-highlight.ts +29 -17
  87. package/src/shared/lib/code/windowed-highlight.ts +52 -1
  88. package/src/shared/lib/ui/key-binding.test.ts +115 -0
  89. package/src/shared/lib/ui/key-binding.ts +143 -0
  90. package/src/shared/lib/util/platform.test.ts +87 -0
  91. package/src/shared/lib/util/platform.ts +30 -0
  92. package/src/shared/lib/workspace/keymap.test.ts +82 -0
  93. package/src/shared/lib/workspace/keymap.ts +30 -0
  94. package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
  95. package/src/shared/lib/workspace/right-panels.ts +8 -0
  96. package/src/shared/lib/workspace/session-state/store.ts +0 -1
  97. package/src/shared/types/index.ts +1 -0
  98. package/src/shared/types/todo.ts +108 -0
  99. package/dist/client/static/js/index-ranztes9.css +0 -1
  100. /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The chord matcher every shortcut in the app shares.
8
+ *
9
+ * Two failures are silent and both are covered here: matching on `event.key`
10
+ * (which never fires on macOS, where Option produces a different character), and
11
+ * a label that disagrees with the stroke it describes. The matcher is also
12
+ * platform-guarded, so a chord from the other platform must not fire.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+
17
+ import {
18
+ bindingFor,
19
+ describeStroke,
20
+ matchesStroke,
21
+ printableKey,
22
+ resolveBinding,
23
+ strokeOf,
24
+ type KeyBinding,
25
+ type KeyboardEventLike,
26
+ } from '@/shared/lib/ui/key-binding';
27
+
28
+ type Command = 'open' | 'save' | 'redo';
29
+
30
+ const BINDINGS: readonly KeyBinding<Command>[] = [
31
+ { command: 'open', label: 'Open', mac: { code: 'KeyO', meta: true }, other: { code: 'KeyO', ctrl: true } },
32
+ { command: 'save', label: 'Save', mac: { code: 'KeyS', meta: true, shift: true }, other: { code: 'KeyS', ctrl: true, shift: true } },
33
+ {
34
+ command: 'redo',
35
+ label: 'Redo',
36
+ mac: { code: 'KeyZ', meta: true, shift: true },
37
+ other: { code: 'KeyZ', ctrl: true, shift: true },
38
+ windows: { code: 'KeyY', ctrl: true },
39
+ },
40
+ ];
41
+
42
+ function key(code: string, keyName: string, modifiers: Partial<KeyboardEventLike> = {}): KeyboardEventLike {
43
+ return { code, key: keyName, metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, ...modifiers };
44
+ }
45
+
46
+ describe('matchesStroke', () => {
47
+ test('requires the exact modifier set', () => {
48
+ const stroke = { code: 'KeyF', meta: true };
49
+ expect(matchesStroke(stroke, key('KeyF', 'f', { metaKey: true }))).toBe(true);
50
+ expect(matchesStroke(stroke, key('KeyF', 'f', { metaKey: true, shiftKey: true }))).toBe(false);
51
+ expect(matchesStroke(stroke, key('KeyF', 'f', { ctrlKey: true }))).toBe(false);
52
+ });
53
+
54
+ test('reads the physical key, not the character it produces', () => {
55
+ // macOS reports ⌥⌘C as `Ç`; a `key`-based test would never fire there.
56
+ expect(matchesStroke({ code: 'KeyC', meta: true, alt: true }, key('KeyC', 'Ç', { metaKey: true, altKey: true }))).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe('strokeOf', () => {
61
+ test('picks the platform’s own chord', () => {
62
+ expect(strokeOf(BINDINGS[0], true)).toEqual({ code: 'KeyO', meta: true });
63
+ expect(strokeOf(BINDINGS[0], false)).toEqual({ code: 'KeyO', ctrl: true });
64
+ });
65
+
66
+ test('uses the Windows override only where it exists', () => {
67
+ // Windows redoes with Ctrl+Y while Linux keeps Ctrl+Shift+Z; one `other`
68
+ // stroke cannot express both.
69
+ expect(strokeOf(BINDINGS[2], false)).toEqual({ code: 'KeyZ', ctrl: true, shift: true });
70
+ });
71
+ });
72
+
73
+ describe('resolveBinding', () => {
74
+ test('returns the command a chord names', () => {
75
+ expect(resolveBinding(BINDINGS, key('KeyO', 'o', { metaKey: true }), true)).toBe('open');
76
+ expect(resolveBinding(BINDINGS, key('KeyO', 'o', { ctrlKey: true }), false)).toBe('open');
77
+ });
78
+
79
+ test('a chord from the other platform never fires', () => {
80
+ expect(resolveBinding(BINDINGS, key('KeyO', 'o', { ctrlKey: true }), true)).toBeNull();
81
+ expect(resolveBinding(BINDINGS, key('KeyO', 'o', { metaKey: true }), false)).toBeNull();
82
+ });
83
+
84
+ test('an unbound chord resolves to nothing', () => {
85
+ expect(resolveBinding(BINDINGS, key('KeyQ', 'q', { metaKey: true }), true)).toBeNull();
86
+ });
87
+ });
88
+
89
+ describe('bindingFor', () => {
90
+ test('finds the binding a tooltip needs to label', () => {
91
+ expect(bindingFor(BINDINGS, 'save')?.label).toBe('Save');
92
+ expect(bindingFor(BINDINGS, 'open')?.mac).toEqual({ code: 'KeyO', meta: true });
93
+ });
94
+ });
95
+
96
+ describe('printableKey and describeStroke', () => {
97
+ test('prints the physical key the way a reader expects', () => {
98
+ expect(printableKey('KeyC')).toBe('C');
99
+ expect(printableKey('Digit1')).toBe('1');
100
+ expect(printableKey('Slash')).toBe('/');
101
+ expect(printableKey('BracketLeft')).toBe('[');
102
+ expect(printableKey('ArrowUp')).toBe('ArrowUp');
103
+ expect(printableKey('F3')).toBe('F3');
104
+ });
105
+
106
+ test('mac chords render without separators, others with plus signs', () => {
107
+ expect(describeStroke({ code: 'KeyC', meta: true, alt: true }, true)).toBe('⌥⌘C');
108
+ expect(describeStroke({ code: 'KeyC', alt: true }, false)).toBe('Alt+C');
109
+ expect(describeStroke({ code: 'Slash', ctrl: true }, false)).toBe('Ctrl+/');
110
+ });
111
+
112
+ test('modifier order is stable, so two labels for one chord cannot differ', () => {
113
+ expect(describeStroke({ code: 'ArrowDown', meta: true, alt: true, shift: true }, true)).toBe('⌥⇧⌘ArrowDown');
114
+ });
115
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One chord matcher for every shortcut in the app.
8
+ *
9
+ * The editor's find bar, the editor's own commands and the panel shortcuts all
10
+ * have to answer the same question — "is this event this chord?" — and they
11
+ * must answer it identically or the same physical key means different things
12
+ * depending on which surface has focus. They also have to *describe* it
13
+ * identically: a tooltip that reads `⌥⌘C` while the binding listens for
14
+ * something else is worse than an unlabeled button, and the command palette
15
+ * lists the very same strings.
16
+ *
17
+ * Matching is on `event.code`, not `event.key`: with Option held, macOS reports
18
+ * the character the combination produces (`⌥⌘C` arrives as `Ç`), so a
19
+ * `key`-based test silently never fires on the platform most of these
20
+ * shortcuts are copied from.
21
+ */
22
+
23
+ import { isMacPlatform, isWindowsPlatform } from '@/shared/lib/util/platform';
24
+
25
+ export interface KeyStroke {
26
+ /** `KeyboardEvent.code`, e.g. `KeyC`, `ArrowUp`, `Slash`, `Digit1`. */
27
+ code: string;
28
+ meta?: boolean;
29
+ ctrl?: boolean;
30
+ alt?: boolean;
31
+ shift?: boolean;
32
+ }
33
+
34
+ /** The subset of a keyboard event the matcher reads, so a test can fake one. */
35
+ export interface KeyboardEventLike {
36
+ code: string;
37
+ key: string;
38
+ metaKey: boolean;
39
+ ctrlKey: boolean;
40
+ altKey: boolean;
41
+ shiftKey: boolean;
42
+ }
43
+
44
+ export interface KeyBinding<Command extends string> {
45
+ command: Command;
46
+ /** What the palette and the tooltips call this command. */
47
+ label: string;
48
+ /** Chord on macOS. */
49
+ mac: KeyStroke;
50
+ /** Chord on Windows and Linux. */
51
+ other: KeyStroke;
52
+ /**
53
+ * Windows-only override. VS Code redoes with `Ctrl+Y` on Windows while Linux
54
+ * keeps `Ctrl+Shift+Z`, which one `other` stroke cannot express.
55
+ */
56
+ windows?: KeyStroke;
57
+ }
58
+
59
+ export function matchesStroke(stroke: KeyStroke, event: KeyboardEventLike): boolean {
60
+ return (
61
+ stroke.code === event.code &&
62
+ Boolean(stroke.meta) === event.metaKey &&
63
+ Boolean(stroke.ctrl) === event.ctrlKey &&
64
+ Boolean(stroke.alt) === event.altKey &&
65
+ Boolean(stroke.shift) === event.shiftKey
66
+ );
67
+ }
68
+
69
+ /** The chord a binding resolves to on the current platform. */
70
+ export function strokeOf<C extends string>(binding: KeyBinding<C>, isMac: boolean): KeyStroke {
71
+ if (isMac) return binding.mac;
72
+ if (binding.windows && isWindowsPlatform()) return binding.windows;
73
+ return binding.other;
74
+ }
75
+
76
+ /**
77
+ * Codes whose printed name is not their `KeyboardEvent.code` suffix. Kept as a
78
+ * table because the alternative — a chain of `if`s — is what makes a label
79
+ * drift from the key it names.
80
+ */
81
+ const KEY_LABEL_BY_CODE: Record<string, string> = {
82
+ Slash: '/',
83
+ Backslash: '\\',
84
+ Comma: ',',
85
+ Period: '.',
86
+ Semicolon: ';',
87
+ Quote: "'",
88
+ Backquote: '`',
89
+ Minus: '-',
90
+ Equal: '=',
91
+ BracketLeft: '[',
92
+ BracketRight: ']',
93
+ Space: 'Space',
94
+ Enter: 'Enter',
95
+ Escape: 'Esc',
96
+ Backspace: 'Backspace',
97
+ Delete: 'Del',
98
+ Tab: 'Tab',
99
+ };
100
+
101
+ /** `⌥⌘C` on macOS, `Alt+C` elsewhere. */
102
+ export function describeStroke(stroke: KeyStroke, isMac: boolean): string {
103
+ const parts: string[] = [];
104
+ if (stroke.ctrl) parts.push(isMac ? '⌃' : 'Ctrl');
105
+ if (stroke.alt) parts.push(isMac ? '⌥' : 'Alt');
106
+ if (stroke.shift) parts.push(isMac ? '⇧' : 'Shift');
107
+ if (stroke.meta) parts.push(isMac ? '⌘' : 'Win');
108
+ parts.push(printableKey(stroke.code));
109
+ return isMac ? parts.join('') : parts.join('+');
110
+ }
111
+
112
+ /** The printed name of a physical key: `KeyC` → `C`, `Slash` → `/`, `ArrowUp` → `ArrowUp`. */
113
+ export function printableKey(code: string): string {
114
+ if (KEY_LABEL_BY_CODE[code]) return KEY_LABEL_BY_CODE[code];
115
+ if (code.startsWith('Key')) return code.slice(3);
116
+ if (code.startsWith('Digit')) return code.slice(5);
117
+ return code;
118
+ }
119
+
120
+ /** The label a binding's shortcut gets in a tooltip or the palette. */
121
+ export function describeBinding<C extends string>(binding: KeyBinding<C>, isMac: boolean = isMacPlatform()): string {
122
+ return describeStroke(strokeOf(binding, isMac), isMac);
123
+ }
124
+
125
+ /** The command `event` invokes, or null when no binding claims it. */
126
+ export function resolveBinding<C extends string>(
127
+ bindings: readonly KeyBinding<C>[],
128
+ event: KeyboardEventLike,
129
+ isMac: boolean = isMacPlatform(),
130
+ ): C | null {
131
+ for (const binding of bindings) {
132
+ if (matchesStroke(strokeOf(binding, isMac), event)) return binding.command;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /** The binding for a command, for a call site that knows what it wants to label. */
138
+ export function bindingFor<C extends string>(
139
+ bindings: readonly KeyBinding<C>[],
140
+ command: C,
141
+ ): KeyBinding<C> | undefined {
142
+ return bindings.find((binding) => binding.command === command);
143
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The platform sniffing the shortcut labels depend on.
8
+ *
9
+ * A wrong answer here is not cosmetic: the label and the binding are derived
10
+ * from the same predicate, so a mis-detected platform names a chord that does
11
+ * not fire. The server case matters too — these modules are imported by both
12
+ * bundles, and touching `navigator` unguarded would throw during SSR.
13
+ */
14
+
15
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
16
+
17
+ import { isMacPlatform, isWindowsPlatform } from '@/shared/lib/util/platform';
18
+
19
+ const displaced = (globalThis as Record<string, unknown>).navigator;
20
+
21
+ function withPlatform(platform: string) {
22
+ (globalThis as Record<string, unknown>).navigator = { platform };
23
+ }
24
+
25
+ afterAll(() => {
26
+ if (displaced === undefined) delete (globalThis as Record<string, unknown>).navigator;
27
+ else (globalThis as Record<string, unknown>).navigator = displaced;
28
+ });
29
+
30
+ describe('isMacPlatform', () => {
31
+ test('recognises every Apple platform the app runs on', () => {
32
+ for (const platform of ['MacIntel', 'Macintosh', 'iPhone', 'iPad', 'iPod']) {
33
+ withPlatform(platform);
34
+ expect(isMacPlatform()).toBe(true);
35
+ }
36
+ });
37
+
38
+ test('is false elsewhere', () => {
39
+ for (const platform of ['Win32', 'Win64', 'Linux x86_64', 'Linux armv8l']) {
40
+ withPlatform(platform);
41
+ expect(isMacPlatform()).toBe(false);
42
+ }
43
+ });
44
+
45
+ test('reports false rather than throwing without a navigator', () => {
46
+ // Shared modules are imported by the server bundle too.
47
+ delete (globalThis as Record<string, unknown>).navigator;
48
+ expect(isMacPlatform()).toBe(false);
49
+ expect(isWindowsPlatform()).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe('isWindowsPlatform', () => {
54
+ test('recognises the Windows spellings', () => {
55
+ for (const platform of ['Win32', 'Win64', 'Windows']) {
56
+ withPlatform(platform);
57
+ expect(isWindowsPlatform()).toBe(true);
58
+ }
59
+ });
60
+
61
+ test('is false on mac and Linux', () => {
62
+ for (const platform of ['MacIntel', 'Linux x86_64']) {
63
+ withPlatform(platform);
64
+ expect(isWindowsPlatform()).toBe(false);
65
+ }
66
+ });
67
+
68
+ test('does not match a platform that merely contains “win”', () => {
69
+ // `Darwin` contains `win`; a substring test without the case rule would
70
+ // report every macOS machine as Windows and pick the Ctrl+Y redo chord.
71
+ withPlatform('Darwin');
72
+ expect(isWindowsPlatform()).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe('mac and Windows are mutually exclusive', () => {
77
+ beforeAll(() => {
78
+ withPlatform('MacIntel');
79
+ });
80
+
81
+ test('no platform reports both', () => {
82
+ for (const platform of ['MacIntel', 'Win32', 'Linux x86_64', 'iPhone']) {
83
+ withPlatform(platform);
84
+ expect(isMacPlatform() && isWindowsPlatform()).toBe(false);
85
+ }
86
+ });
87
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Platform sniffing for the surfaces that label a keyboard shortcut.
8
+ *
9
+ * The labels ("⌘F" vs "Ctrl+F") and the modifiers they match have to come from
10
+ * one place: a shortcut that is documented as ⌘F but only bound to Ctrl+F is
11
+ * worse than an unlabeled one. `navigator.platform` is deprecated in favour of
12
+ * `userAgentData.platform`, but the replacement is Chromium-only — and these
13
+ * checks only choose a modifier label, so the deprecated source is the one that
14
+ * answers on every engine. Guarded for the server, where `navigator` does not
15
+ * exist (shared modules are imported by both bundles).
16
+ */
17
+
18
+ export function isMacPlatform(): boolean {
19
+ return typeof navigator !== 'undefined' && /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
20
+ }
21
+
22
+ /**
23
+ * Anchored, because the unanchored form is wrong: `/Win/i` matches `Darwin`,
24
+ * which would report every macOS machine as Windows. (Measured: the redo chord
25
+ * then resolves to Ctrl+Y on a Mac — latent only because every call site checks
26
+ * `isMacPlatform` first.)
27
+ */
28
+ export function isWindowsPlatform(): boolean {
29
+ return typeof navigator !== 'undefined' && /^Win/i.test(navigator.platform);
30
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The workspace shortcuts.
8
+ *
9
+ * The table is what the panel tooltips read, so what matters is that every
10
+ * binding is unique on a platform (a duplicate silently shadows one of them) and
11
+ * that the chords are ones a browser or a text field does not already own —
12
+ * binding ⌘W or ⌘T here would break the tab, and ⌘A would break every selection
13
+ * in the app.
14
+ */
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+
18
+ import { WORKSPACE_KEY_BINDINGS } from '@/shared/lib/workspace/keymap';
19
+ import { resolveBinding, strokeOf, type KeyboardEventLike } from '@/shared/lib/ui/key-binding';
20
+
21
+ function key(code: string, modifiers: Partial<KeyboardEventLike> = {}): KeyboardEventLike {
22
+ return { code, key: code, metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, ...modifiers };
23
+ }
24
+
25
+ /** Chords the browser or the OS owns; binding one would break the app. */
26
+ const RESERVED: Record<string, true> = {
27
+ KeyW: true,
28
+ KeyT: true,
29
+ KeyN: true,
30
+ KeyQ: true,
31
+ KeyA: true,
32
+ KeyR: true,
33
+ KeyP: true,
34
+ };
35
+
36
+ describe('WORKSPACE_KEY_BINDINGS', () => {
37
+ test('every binding carries a label and both platforms', () => {
38
+ for (const binding of WORKSPACE_KEY_BINDINGS) {
39
+ expect(binding.label.length).toBeGreaterThan(0);
40
+ expect(binding.mac.code.length).toBeGreaterThan(0);
41
+ expect(binding.other.code.length).toBeGreaterThan(0);
42
+ }
43
+ });
44
+
45
+ test('no binding claims a chord the browser already owns', () => {
46
+ for (const binding of WORKSPACE_KEY_BINDINGS) {
47
+ expect(RESERVED[binding.mac.code]).toBeUndefined();
48
+ }
49
+ });
50
+
51
+ test('no two commands claim the same chord on a platform', () => {
52
+ for (const isMac of [true, false]) {
53
+ const seen: Record<string, string> = {};
54
+ for (const binding of WORKSPACE_KEY_BINDINGS) {
55
+ const stroke = strokeOf(binding, isMac);
56
+ const id = `${stroke.code}|${stroke.meta ? 'm' : ''}${stroke.ctrl ? 'c' : ''}${stroke.alt ? 'a' : ''}${stroke.shift ? 's' : ''}`;
57
+ expect(`${binding.command} vs ${seen[id]}`).toBe(`${binding.command} vs undefined`);
58
+ seen[id] = binding.command;
59
+ }
60
+ }
61
+ });
62
+
63
+ test('the panel toggles resolve from their own chords', () => {
64
+ expect(resolveBinding(WORKSPACE_KEY_BINDINGS, key('KeyB', { metaKey: true }), true)).toBe('toggleSidebar');
65
+ expect(resolveBinding(WORKSPACE_KEY_BINDINGS, key('KeyJ', { metaKey: true }), true)).toBe('toggleEditorPanel');
66
+ expect(resolveBinding(WORKSPACE_KEY_BINDINGS, key('KeyJ', { metaKey: true, altKey: true }), true)).toBe('toggleRightPanel');
67
+ });
68
+
69
+ test('⌘J and ⌥⌘J are different commands, not the same one twice', () => {
70
+ // The alt modifier is the only thing separating the editor and right panels.
71
+ const plain = resolveBinding(WORKSPACE_KEY_BINDINGS, key('KeyJ', { metaKey: true }), true);
72
+ const withAlt = resolveBinding(WORKSPACE_KEY_BINDINGS, key('KeyJ', { metaKey: true, altKey: true }), true);
73
+
74
+ expect(plain).not.toBe(withAlt);
75
+ });
76
+
77
+ test('save is bound here but deliberately not handled by the layout', () => {
78
+ // The editor panel owns ⌘S (it is the only surface with a buffer); this
79
+ // binding exists so a tooltip can name the chord.
80
+ expect(resolveBinding(WORKSPACE_KEY_BINDINGS, key('KeyS', { metaKey: true }), true)).toBe('saveFile');
81
+ });
82
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The workspace's own shortcuts — the ones that belong to a PANEL rather than
8
+ * to the text inside it. Kept in a table beside the editor's keymap for the
9
+ * same reason: the panel toggles carry their chord in a tooltip, and a tooltip
10
+ * that names a key nobody listens for is a lie.
11
+ *
12
+ * These are deliberately few. A global table is where a shortcut goes to
13
+ * collide with the browser's own (⌘W closes a tab, ⌘T opens one, ⌘N opens a
14
+ * window) or with a text field the user is typing in — so only chords that are
15
+ * inert in a text field and unambiguous in this app are bound here.
16
+ */
17
+
18
+ import type { KeyBinding } from '@/shared/lib/ui/key-binding';
19
+
20
+ export type WorkspaceCommand = 'toggleEditorPanel' | 'toggleRightPanel' | 'toggleSidebar' | 'openSettings' | 'saveFile';
21
+
22
+ export const WORKSPACE_KEY_BINDINGS: readonly KeyBinding<WorkspaceCommand>[] = [
23
+ // ⌘B / ⌘J are VS Code's own panel toggles.
24
+ { command: 'toggleSidebar', label: 'Toggle sidebar', mac: { code: 'KeyB', meta: true }, other: { code: 'KeyB', ctrl: true } },
25
+ { command: 'toggleEditorPanel', label: 'Toggle editor panel', mac: { code: 'KeyJ', meta: true }, other: { code: 'KeyJ', ctrl: true } },
26
+ { command: 'toggleRightPanel', label: 'Toggle right panel', mac: { code: 'KeyJ', meta: true, alt: true }, other: { code: 'KeyJ', ctrl: true, alt: true } },
27
+ // ⌘, is the platform's own settings chord (the app already binds it).
28
+ { command: 'openSettings', label: 'Open settings', mac: { code: 'Comma', meta: true }, other: { code: 'Comma', ctrl: true } },
29
+ { command: 'saveFile', label: 'Save file', mac: { code: 'KeyS', meta: true }, other: { code: 'KeyS', ctrl: true } },
30
+ ];
@@ -93,6 +93,15 @@ export const GIT_STATUS_EVENT_THROTTLE_MS = 1_000;
93
93
  */
94
94
  export const REPO_DISCOVERY_POLL_MS = 1_500;
95
95
 
96
+ /**
97
+ * Coalescing window for the todo panel's event-driven re-read. A run emits
98
+ * `omp:session-updated` at every turn boundary (which is what follows each tool
99
+ * call), so this collapses the several signals around one `todo` call into one
100
+ * request. Tighter than the git window on purpose: the server answers an
101
+ * unchanged read from a stat, and the panel is watched while the agent works.
102
+ */
103
+ export const TODO_REFRESH_EVENT_THROTTLE_MS = 400;
104
+
96
105
  /**
97
106
  * CDP state/screencast poll. Latency-sensitive — the browser panel streams a
98
107
  * live page, so this one stays tight on purpose.
@@ -18,6 +18,7 @@ export const RIGHT_PANEL_TYPES = [
18
18
  'user-browser',
19
19
  'browser',
20
20
  'usage',
21
+ 'todo',
21
22
  ] as const;
22
23
 
23
24
  export type RightPanelType = (typeof RIGHT_PANEL_TYPES)[number];
@@ -37,6 +38,7 @@ export type RightPanelType = (typeof RIGHT_PANEL_TYPES)[number];
37
38
  * - `search` matches `files`: both are single narrow columns of rows.
38
39
  * - `usage` has no OpenChamber counterpart; 0.48 keeps the pixel default it
39
40
  * shipped with.
41
+ * - `todo` has no OpenChamber counterpart either; 0.3 keeps its pixel default.
40
42
  * - `context`, `git`, `terminal` and both browsers take OpenChamber's values.
41
43
  */
42
44
  export const DEFAULT_RIGHT_PANEL_FRACTIONS: Record<RightPanelType, number> = {
@@ -49,6 +51,9 @@ export const DEFAULT_RIGHT_PANEL_FRACTIONS: Record<RightPanelType, number> = {
49
51
  'user-browser': 0.45,
50
52
  browser: 0.45,
51
53
  usage: 0.48,
54
+ // A todo list is one column of short rows — narrower than usage, wider than
55
+ // a file tree, because a task line is a sentence.
56
+ todo: 0.3,
52
57
  };
53
58
 
54
59
  /**
@@ -65,6 +70,7 @@ export const DEFAULT_RIGHT_PANEL_WIDTHS: Record<RightPanelType, number> = {
65
70
  'user-browser': 804,
66
71
  browser: 804,
67
72
  usage: 536,
73
+ todo: 384,
68
74
  };
69
75
 
70
76
  /**
@@ -88,6 +94,8 @@ export const MIN_RIGHT_PANEL_WIDTHS: Record<RightPanelType, number> = {
88
94
  'user-browser': 320,
89
95
  browser: 320,
90
96
  usage: 420,
97
+ // Below this a task line wraps to three words a row.
98
+ todo: 280,
91
99
  };
92
100
 
93
101
  /** Guards a persisted or URL-provided view id before it keys a width. */
@@ -23,7 +23,6 @@
23
23
  * - files.expandedPaths string[]
24
24
  * - files.searchQuery string
25
25
  * - files.activeRepo string
26
- * - browser.viewportMode string
27
26
  * - browser.zoomLevel number
28
27
  * - userBrowser.history string[]
29
28
  * - userBrowser.historyIndex number
@@ -22,4 +22,5 @@ export * from '@/shared/types/omp/instructions';
22
22
  export * from '@/shared/types/omp/subagent';
23
23
  export * from '@/shared/types/browser';
24
24
  export * from '@/shared/types/updates';
25
+ export * from '@/shared/types/todo';
25
26
  export type { RightPanelType } from '@/shared/lib/workspace/right-panels';
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * oh-my-pi's `todo` tool state, as the chamber reads it.
8
+ *
9
+ * omp owns this list, not the chamber: the `todo` tool writes a structured
10
+ * snapshot into the session JSONL (a `toolResult` entry's `details.phases`, or
11
+ * a `user_todo_edit` custom entry when the operator edits the list by hand), and
12
+ * every later branch rehydration — resume, rewind, fork, `/btw` — reads the
13
+ * DEEPEST such snapshot on the active branch back out of it
14
+ * (`getLatestTodoPhasesFromEntries`). The chamber mirrors exactly that read, so
15
+ * the panel and the agent's own HUD cannot disagree about what is open.
16
+ *
17
+ * The status vocabulary is omp's, including the two states the chamber's chat
18
+ * timeline never rendered before: `blocked` (waiting on something, with an
19
+ * optional note) and `abandoned` (deliberately dropped). Both are settled-but-
20
+ * not-done, which is why `isClosedTodo` — not `status === 'completed'` — is
21
+ * what any progress counter must use.
22
+ */
23
+
24
+ /** Lifecycle state of one todo item (omp's `TodoStatus`). */
25
+ export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'abandoned' | 'blocked';
26
+
27
+ /** One task inside a phase. */
28
+ export interface TodoItem {
29
+ content: string;
30
+ status: TodoStatus;
31
+ /** Present on a `blocked` task: what it is waiting for. */
32
+ blocker?: string;
33
+ details?: string;
34
+ notes?: string[];
35
+ }
36
+
37
+ /** A named group of tasks. */
38
+ export interface TodoPhase {
39
+ name: string;
40
+ tasks: TodoItem[];
41
+ }
42
+
43
+ /** The `todo` op that produced a snapshot; absent on legacy transcript entries. */
44
+ export type TodoOperation =
45
+ | 'init'
46
+ | 'start'
47
+ | 'done'
48
+ | 'rm'
49
+ | 'drop'
50
+ | 'block'
51
+ | 'unblock'
52
+ | 'append'
53
+ | 'view';
54
+
55
+ /**
56
+ * Where the snapshot that resolved the list came from. A `toolResult` entry
57
+ * only exists when the `todo` tool actually ran in this transcript; the
58
+ * `custom` variant is omp's own durable rehydration record (a hand edit from
59
+ * the `/todo` command, a HUD reveal, or the auto-complete omp performs when a
60
+ * subagent matching a task description finishes). The distinction is
61
+ * diagnostic: "the agent never used todos here" and "the agent used them and
62
+ * then edited them by hand" look identical without it.
63
+ */
64
+ export type TodoSnapshotSource = 'toolResult' | 'custom';
65
+
66
+ /** The resolved todo list for a session, plus the entry that carried it. */
67
+ export interface TodoSnapshot {
68
+ phases: TodoPhase[];
69
+ /** Entry id of the snapshot — what a later snapshot has to beat. */
70
+ sourceEntryId: string;
71
+ source: TodoSnapshotSource;
72
+ /** ISO timestamp of the snapshot entry, when it carries one. */
73
+ updatedAt?: string;
74
+ /** Op that produced it, when the entry records one. */
75
+ op?: TodoOperation;
76
+ /** omp's own storage mode: `session` (durable) or `memory` (no session file). */
77
+ storage?: 'session' | 'memory';
78
+ }
79
+
80
+ /** Rolled-up counters over a snapshot's tasks. */
81
+ export interface TodoProgress {
82
+ total: number;
83
+ completed: number;
84
+ /** `completed` + `abandoned` — the pair omp's HUD hides as settled. */
85
+ closed: number;
86
+ inProgress: number;
87
+ pending: number;
88
+ blocked: number;
89
+ abandoned: number;
90
+ }
91
+
92
+ /** One session's todo state as the panel consumes it. */
93
+ export interface SessionTodoState {
94
+ /** null when this session's transcript holds no committed snapshot. */
95
+ snapshot: TodoSnapshot | null;
96
+ /** Roll-up over the snapshot's tasks; all zeros when there is none. */
97
+ progress: TodoProgress;
98
+ }
99
+
100
+ /** Wire payload of `GET /api/omp/session-todos`. */
101
+ export interface SessionTodosPayload extends SessionTodoState {
102
+ sessionId: string;
103
+ /** ISO timestamp of the read. */
104
+ generatedAt: string;
105
+ isMock: boolean;
106
+ /** Set when the session has no readable transcript (never written, or gone). */
107
+ error?: string;
108
+ }