@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.12

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 (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.autoclose.test.tsx +24 -35
  3. package/src/App.diff.test.tsx +5 -18
  4. package/src/App.export.test.tsx +8 -21
  5. package/src/App.plan-review.test.tsx +21 -52
  6. package/src/App.readonly.test.tsx +3 -18
  7. package/src/App.rendering.test.tsx +5 -13
  8. package/src/App.submit-confirm.test.tsx +7 -25
  9. package/src/App.test.tsx +13 -52
  10. package/src/App.tsx +197 -910
  11. package/src/components/AnnotationCard.stories.tsx +75 -0
  12. package/src/components/AnnotationCard.tsx +136 -0
  13. package/src/components/Breadcrumb.stories.tsx +19 -0
  14. package/src/components/Breadcrumb.tsx +38 -0
  15. package/src/components/CodeBlock.stories.tsx +25 -0
  16. package/src/components/CodeBlock.tsx +68 -0
  17. package/src/components/CompletionOverlay.stories.tsx +23 -0
  18. package/src/components/CompletionOverlay.tsx +50 -0
  19. package/src/components/ComposeBar.stories.tsx +18 -0
  20. package/src/components/ComposeBar.tsx +54 -0
  21. package/src/components/ConfirmCard.stories.tsx +22 -0
  22. package/src/components/ConfirmCard.tsx +130 -0
  23. package/src/components/DiffSheet.stories.tsx +44 -0
  24. package/src/components/DiffSheet.tsx +243 -0
  25. package/src/components/InboxList.stories.tsx +18 -0
  26. package/src/components/InboxList.tsx +42 -0
  27. package/src/components/PlanSheet.stories.tsx +81 -0
  28. package/src/components/PlanSheet.tsx +309 -0
  29. package/src/components/ReviewRail.stories.tsx +103 -0
  30. package/src/components/ReviewRail.tsx +158 -0
  31. package/src/components/SettingsDialog.stories.tsx +63 -0
  32. package/src/components/SettingsDialog.tsx +132 -0
  33. package/src/components/SettingsRows.stories.tsx +18 -0
  34. package/src/components/SettingsRows.tsx +92 -0
  35. package/src/components/__snapshots__/stories.test.tsx.snap +1189 -0
  36. package/src/components/format.ts +6 -0
  37. package/src/components/primitives/Button.stories.tsx +32 -0
  38. package/src/components/primitives/Button.tsx +44 -0
  39. package/src/components/primitives/Card.stories.tsx +25 -0
  40. package/src/components/primitives/Card.tsx +58 -0
  41. package/src/components/primitives/Dialog.stories.tsx +24 -0
  42. package/src/components/primitives/Dialog.tsx +52 -0
  43. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  44. package/src/components/primitives/StatusBar.tsx +22 -0
  45. package/src/components/primitives/Tabs.stories.tsx +29 -0
  46. package/src/components/primitives/Tabs.tsx +97 -0
  47. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  48. package/src/components/primitives/Toolbar.tsx +14 -0
  49. package/src/components/stories-app.tsx +94 -0
  50. package/src/components/stories.test.tsx +51 -0
  51. package/src/components/story-fixtures.ts +90 -0
  52. package/src/components/story.ts +66 -0
  53. package/src/components/syntax-highlight.ts +57 -0
  54. package/src/components/theme-context.stories.tsx +41 -0
  55. package/src/components/theme-context.tsx +30 -0
  56. package/src/key-bindings.test.ts +103 -0
  57. package/src/key-bindings.ts +332 -0
  58. package/src/keymap.ts +8 -2
  59. package/src/run.ts +3 -1
  60. package/src/session-controller.ts +28 -7
  61. package/src/test-support.ts +87 -0
  62. package/src/theme.ts +3 -0
  63. package/src/view.test.ts +35 -13
  64. package/src/view.ts +41 -64
  65. package/src/syntax.test.ts +0 -41
  66. package/src/syntax.ts +0 -88
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The story contract (#86). Every component file in components/ ships a
3
+ * colocated *.stories.tsx that exports `meta` plus named Story objects.
4
+ * The catalog IS the regression suite: stories.test.tsx walks every story,
5
+ * snapshots its char frame, and asserts declared colors against the styled
6
+ * spans; the `bun run stories` TUI renders the same objects interactively.
7
+ */
8
+
9
+ import type React from "react";
10
+
11
+ export interface StoryMeta {
12
+ /** Catalog path, e.g. "primitives/Card". */
13
+ title: string;
14
+ }
15
+
16
+ export interface Story {
17
+ render: () => React.ReactNode;
18
+ /**
19
+ * Colors (hex tokens) that must appear among the rendered frame's styled
20
+ * spans - the color regression net for color-bearing stories.
21
+ */
22
+ expectedColors?: string[];
23
+ /** Virtual terminal size; defaults to 80x24. */
24
+ size?: { width: number; height: number };
25
+ }
26
+
27
+ export interface LoadedStory {
28
+ moduleTitle: string;
29
+ storyName: string;
30
+ story: Story;
31
+ }
32
+
33
+ export function isStory(value: unknown): value is Story {
34
+ return typeof value === "object" && value !== null && typeof (value as Story).render === "function";
35
+ }
36
+
37
+ /** Import every *.stories.tsx next to the components and flatten the exports. */
38
+ export async function loadStories(): Promise<LoadedStory[]> {
39
+ const glob = new Bun.Glob("**/*.stories.tsx");
40
+ const files = [...glob.scanSync({ cwd: import.meta.dir })].sort();
41
+ const loaded: LoadedStory[] = [];
42
+ for (const file of files) {
43
+ const moduleExports = (await import(`${import.meta.dir}/${file}`)) as Record<string, unknown>;
44
+ const meta = moduleExports["meta"] as StoryMeta | undefined;
45
+ const moduleTitle = meta?.title ?? file.replace(/\.stories\.tsx$/, "");
46
+ for (const [exportName, exported] of Object.entries(moduleExports)) {
47
+ if (exportName === "meta" || !isStory(exported)) continue;
48
+ loaded.push({ moduleTitle, storyName: exportName, story: exported });
49
+ }
50
+ }
51
+ return loaded;
52
+ }
53
+
54
+ /** Component files that must carry stories: every .tsx that is not a story/test. */
55
+ export function componentFilesMissingStories(): string[] {
56
+ const componentGlob = new Bun.Glob("**/*.tsx");
57
+ const files = [...componentGlob.scanSync({ cwd: import.meta.dir })];
58
+ const missing: string[] = [];
59
+ for (const file of files) {
60
+ if (file.endsWith(".stories.tsx") || file.endsWith(".test.tsx")) continue;
61
+ if (file === "stories-app.tsx") continue;
62
+ const storiesSibling = file.replace(/\.tsx$/, ".stories.tsx");
63
+ if (!files.includes(storiesSibling)) missing.push(file);
64
+ }
65
+ return missing;
66
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Code highlighting tokens (#86): the theme's named tokens projected onto
3
+ * tree-sitter capture names for the native code renderable. One SyntaxStyle
4
+ * per theme object, cached, so every code block on screen shares a handle.
5
+ * Unknown languages simply render unstyled - highlighting is an enhancement,
6
+ * never a blocker.
7
+ */
8
+
9
+ import { SyntaxStyle, infoStringToFiletype } from "@opentui/core";
10
+ import type { Theme } from "../theme";
11
+
12
+ /** Markdown fence info -> tree-sitter filetype, with the aliases we accept. */
13
+ export function filetypeFor(language?: string): string | undefined {
14
+ const info = (language ?? "").toLowerCase();
15
+ if (!info) return undefined;
16
+ const aliases: Record<string, string> = {
17
+ ts: "typescript",
18
+ js: "javascript",
19
+ py: "python",
20
+ rb: "ruby",
21
+ rs: "rust",
22
+ sh: "bash",
23
+ shell: "bash",
24
+ zsh: "bash",
25
+ yml: "yaml",
26
+ };
27
+ return infoStringToFiletype(aliases[info] ?? info) ?? aliases[info] ?? info;
28
+ }
29
+
30
+ const syntaxStyleCache = new WeakMap<Theme, SyntaxStyle>();
31
+
32
+ export function syntaxStyleFor(theme: Theme): SyntaxStyle {
33
+ const cached = syntaxStyleCache.get(theme);
34
+ if (cached) return cached;
35
+ const style = SyntaxStyle.fromStyles({
36
+ default: { fg: theme.textMuted },
37
+ comment: { fg: theme.textDim, italic: true },
38
+ string: { fg: theme.green },
39
+ number: { fg: theme.blue },
40
+ constant: { fg: theme.blue },
41
+ boolean: { fg: theme.blue },
42
+ keyword: { fg: theme.accent },
43
+ operator: { fg: theme.textDim },
44
+ punctuation: { fg: theme.textDim },
45
+ function: { fg: theme.text },
46
+ method: { fg: theme.text },
47
+ type: { fg: theme.blue },
48
+ constructor: { fg: theme.blue },
49
+ variable: { fg: theme.textMuted },
50
+ property: { fg: theme.textMuted },
51
+ parameter: { fg: theme.textMuted },
52
+ tag: { fg: theme.accent },
53
+ attribute: { fg: theme.blue },
54
+ });
55
+ syntaxStyleCache.set(theme, style);
56
+ return style;
57
+ }
@@ -0,0 +1,41 @@
1
+ import React from "react";
2
+ import { DARK, type Theme } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ThemeProvider } from "./theme-context";
5
+ import { Card } from "./primitives/Card";
6
+
7
+ export const meta: StoryMeta = { title: "theme-context" };
8
+
9
+ /** A high-contrast variant to prove tokens flow through the provider. */
10
+ const HIGH_CONTRAST: Theme = { ...DARK, accent: "#00ffff", elevated: "#000000", border: "#ffffff" };
11
+
12
+ export const ContextDefault: Story = {
13
+ render: () => (
14
+ <Card title=" default dark theme " contentRows={1}>
15
+ <text>no provider needed</text>
16
+ </Card>
17
+ ),
18
+ expectedColors: [DARK.elevated],
19
+ };
20
+
21
+ export const ProviderSwap: Story = {
22
+ render: () => (
23
+ <ThemeProvider theme={HIGH_CONTRAST}>
24
+ <Card title=" provider theme " contentRows={1} borderColor={HIGH_CONTRAST.accent}>
25
+ <text>tokens come from the provider</text>
26
+ </Card>
27
+ </ThemeProvider>
28
+ ),
29
+ expectedColors: ["#00ffff", "#000000"],
30
+ };
31
+
32
+ export const PropOverride: Story = {
33
+ render: () => (
34
+ <ThemeProvider theme={DARK}>
35
+ <Card title=" prop-overridden theme " contentRows={1} theme={HIGH_CONTRAST} borderColor={HIGH_CONTRAST.accent}>
36
+ <text>the theme prop beats the context</text>
37
+ </Card>
38
+ </ThemeProvider>
39
+ ),
40
+ expectedColors: ["#00ffff"],
41
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Provider theming for the component system (#86). The context default is the
3
+ * built-in dark theme, so embedding a component (or rendering a story) needs
4
+ * no provider at all. One ThemeProvider swap switches the live theme; every
5
+ * component also accepts an optional `theme` prop that overrides the context
6
+ * for explicit control. Components consume named tokens only - a hardcoded
7
+ * color inside components/ is a review rejection.
8
+ */
9
+
10
+ import React, { createContext, useContext } from "react";
11
+ import { DARK, type Theme } from "../theme";
12
+
13
+ const ThemeContext = createContext<Theme>(DARK);
14
+
15
+ export function ThemeProvider({ theme, children }: { theme: Theme; children: React.ReactNode }): React.ReactNode {
16
+ return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
17
+ }
18
+
19
+ export function useTheme(): Theme {
20
+ return useContext(ThemeContext);
21
+ }
22
+
23
+ /**
24
+ * Token resolution for a component: the explicit `theme` prop wins over the
25
+ * provider context, which defaults to the built-in dark theme.
26
+ */
27
+ export function useComponentTheme(override?: Theme): Theme {
28
+ const contextTheme = useContext(ThemeContext);
29
+ return override ?? contextTheme;
30
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Binding resolution and hint generation over @opentui/keymap: config keys
3
+ * resolve to action names per layer, and the status-line hints derive from
4
+ * getActiveKeys() so rebinds surface in the hint text.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { DEFAULT_KEYS } from "./config";
9
+ import { KeyBindings } from "./key-bindings";
10
+
11
+ function bindings(context: { overlay?: "none" | "compose" | "submit"; spanMode?: boolean } = {}): KeyBindings {
12
+ const resolver = new KeyBindings(DEFAULT_KEYS);
13
+ resolver.setContext({ overlay: context.overlay ?? "none", spanMode: context.spanMode ?? false });
14
+ return resolver;
15
+ }
16
+
17
+ describe("grammar-layer resolution", () => {
18
+ const table: [name: string, shift: boolean, action: string | undefined][] = [
19
+ ["j", false, "down"],
20
+ ["down", false, "down"],
21
+ ["k", false, "up"],
22
+ ["g", false, "top"],
23
+ ["g", true, "bottom"],
24
+ ["v", false, "span"],
25
+ ["c", false, "comment"],
26
+ ["s", false, "suggest"],
27
+ ["x", false, "cut"],
28
+ ["e", false, "edit"],
29
+ ["n", false, "next_annotation"],
30
+ ["p", false, "prev_annotation"],
31
+ ["backspace", false, "delete_annotation"],
32
+ ["return", false, "submit"],
33
+ ["enter", false, "submit"],
34
+ ["q", false, "quit"],
35
+ ["z", false, undefined],
36
+ ];
37
+ for (const [name, shift, action] of table) {
38
+ test(`${shift ? "shift+" : ""}${name} -> ${action}`, () => {
39
+ expect(bindings().resolveAction({ name, shift })).toBe(action!);
40
+ });
41
+ }
42
+ });
43
+
44
+ describe("mode layers own their keys", () => {
45
+ test("span mode resolves the span verbs, not the grammar", () => {
46
+ const resolver = bindings({ spanMode: true });
47
+ expect(resolver.resolveAction({ name: "l", shift: false })).toBe("span_grow");
48
+ expect(resolver.resolveAction({ name: "c", shift: false })).toBe("span_comment");
49
+ expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("span_cancel");
50
+ });
51
+
52
+ test("the compose overlay only knows save and cancel", () => {
53
+ const resolver = bindings({ overlay: "compose" });
54
+ expect(resolver.resolveAction({ name: "return", shift: false })).toBe("save_compose");
55
+ expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("cancel_overlay");
56
+ expect(resolver.resolveAction({ name: "j", shift: false })).toBeUndefined();
57
+ });
58
+
59
+ test("the submit overlay adds the verdict arrows", () => {
60
+ const resolver = bindings({ overlay: "submit" });
61
+ expect(resolver.resolveAction({ name: "left", shift: false })).toBe("cycle_verdict_left");
62
+ expect(resolver.resolveAction({ name: "right", shift: false })).toBe("cycle_verdict_right");
63
+ expect(resolver.resolveAction({ name: "return", shift: false })).toBe("submit_verdict");
64
+ });
65
+ });
66
+
67
+ describe("getActiveKeys-generated status hints", () => {
68
+ test("normal mode hint matches the locked string", () => {
69
+ expect(bindings().statusHint("normal")).toBe(
70
+ "j/k move · v span · drag selects · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit",
71
+ );
72
+ });
73
+
74
+ test("card-selected hint keeps the locked Cut label", () => {
75
+ expect(bindings().statusHint("card")).toBe("card · e edit · x Cut · n/p cards · esc deselect · ⏎ submit");
76
+ });
77
+
78
+ test("span hint", () => {
79
+ expect(bindings({ spanMode: true }).statusHint("span")).toBe(
80
+ "span · l/h grow/shrink · w/b slide · $ end · c comment · s suggest · esc",
81
+ );
82
+ });
83
+
84
+ test("compose hint", () => {
85
+ expect(bindings({ overlay: "compose" }).statusHint("compose")).toBe("typing · ⏎ save · esc cancel");
86
+ });
87
+
88
+ test("submit hint", () => {
89
+ expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe("verdict ←/→ · ⏎ submit · esc cancel");
90
+ });
91
+
92
+ test("read-only hint", () => {
93
+ expect(bindings().statusHint("read-only")).toBe("observer - read-only · j/k move · n/p annotations · q quit");
94
+ });
95
+
96
+ test("a rebound key shows its real binding in the hint", () => {
97
+ const resolver = new KeyBindings({ ...DEFAULT_KEYS, comment: ["m"] });
98
+ resolver.setContext({ overlay: "none", spanMode: false });
99
+ expect(resolver.resolveAction({ name: "m", shift: false })).toBe("comment");
100
+ expect(resolver.resolveAction({ name: "c", shift: false })).toBeUndefined();
101
+ expect(resolver.statusHint("normal")).toContain("m comment");
102
+ });
103
+ });
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Binding resolution over @opentui/keymap (#86): the loaded config keymap
3
+ * registers as layers (grammar, span, compose, submit, completion) on one
4
+ * Keymap instance, key events resolve to action names through its dispatch,
5
+ * and the status-line hints generate from getActiveKeys() - a rebound key
6
+ * shows its real binding in the hints. The pure intent reducer (keymap.ts)
7
+ * stays the dispatch layer beneath: this module answers "which action", the
8
+ * reducer answers "which intents".
9
+ */
10
+
11
+ import { Keymap, type KeymapEvent, type KeymapHost } from "@opentui/keymap";
12
+ import { registerDefaultKeys } from "@opentui/keymap/addons";
13
+ import type { KeyState } from "./keymap";
14
+
15
+ export interface ResolvableKey {
16
+ name: string;
17
+ shift: boolean;
18
+ }
19
+
20
+ /** The layer-activation slice of the view state. */
21
+ export interface KeyLayerContext {
22
+ overlay: KeyState["overlay"];
23
+ spanMode: boolean;
24
+ }
25
+
26
+ /** Hint templates keyed by the same view states the status line shows. */
27
+ export type HintMode = "normal" | "card" | "span" | "compose" | "submit" | "read-only";
28
+
29
+ type HintEntry =
30
+ | { text: string }
31
+ | { commands: string[]; label: string; labelFirst?: boolean };
32
+
33
+ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
34
+ normal: [
35
+ { commands: ["down", "up"], label: "move" },
36
+ { commands: ["span"], label: "span" },
37
+ { text: "drag selects" },
38
+ { commands: ["comment"], label: "comment" },
39
+ { commands: ["suggest"], label: "suggest" },
40
+ { commands: ["cut"], label: "cut" },
41
+ { commands: ["edit"], label: "edit" },
42
+ { commands: ["next_annotation", "prev_annotation"], label: "annotations" },
43
+ { commands: ["submit"], label: "submit" },
44
+ { commands: ["quit"], label: "quit" },
45
+ ],
46
+ card: [
47
+ { text: "card" },
48
+ { commands: ["edit"], label: "edit" },
49
+ { commands: ["cut"], label: "Cut" },
50
+ { commands: ["next_annotation", "prev_annotation"], label: "cards" },
51
+ { commands: ["deselect"], label: "deselect" },
52
+ { commands: ["submit"], label: "submit" },
53
+ ],
54
+ span: [
55
+ { text: "span" },
56
+ { commands: ["span_grow", "span_shrink"], label: "grow/shrink" },
57
+ { commands: ["span_slide_forward", "span_slide_back"], label: "slide" },
58
+ { commands: ["span_to_end"], label: "end" },
59
+ { commands: ["span_comment"], label: "comment" },
60
+ { commands: ["span_suggest"], label: "suggest" },
61
+ { commands: ["span_cancel"], label: "" },
62
+ ],
63
+ compose: [
64
+ { text: "typing" },
65
+ { commands: ["save_compose"], label: "save" },
66
+ { commands: ["cancel_overlay"], label: "cancel" },
67
+ ],
68
+ submit: [
69
+ { commands: ["cycle_verdict_left", "cycle_verdict_right"], label: "verdict", labelFirst: true },
70
+ { commands: ["submit_verdict"], label: "submit" },
71
+ { commands: ["cancel_submit"], label: "cancel" },
72
+ ],
73
+ "read-only": [
74
+ { text: "observer - read-only" },
75
+ { commands: ["down", "up"], label: "move" },
76
+ { commands: ["next_annotation", "prev_annotation"], label: "annotations" },
77
+ { commands: ["quit"], label: "quit" },
78
+ ],
79
+ };
80
+
81
+ /** Status-line glyphs for named keys. */
82
+ const KEY_GLYPHS: Record<string, string> = {
83
+ return: "⏎",
84
+ enter: "⏎",
85
+ escape: "esc",
86
+ left: "←",
87
+ right: "→",
88
+ backspace: "⌫",
89
+ down: "↓",
90
+ up: "↑",
91
+ };
92
+
93
+ function makeKeymapEvent(name: string, shift = false): KeymapEvent {
94
+ return {
95
+ name,
96
+ ctrl: false,
97
+ shift,
98
+ meta: false,
99
+ preventDefault: () => {},
100
+ stopPropagation: () => {},
101
+ propagationStopped: false,
102
+ };
103
+ }
104
+
105
+ class HeadlessKeymapHost implements KeymapHost<object, KeymapEvent> {
106
+ readonly metadata = {
107
+ platform: "unknown" as const,
108
+ primaryModifier: "unknown" as const,
109
+ modifiers: {
110
+ ctrl: "supported" as const,
111
+ shift: "supported" as const,
112
+ meta: "supported" as const,
113
+ super: "unknown" as const,
114
+ hyper: "unknown" as const,
115
+ },
116
+ };
117
+ readonly rootTarget = {};
118
+ readonly isDestroyed = false;
119
+ private readonly pressListeners = new Set<(event: KeymapEvent) => void>();
120
+
121
+ getFocusedTarget(): object | null {
122
+ return null;
123
+ }
124
+ getParentTarget(): object | null {
125
+ return null;
126
+ }
127
+ isTargetDestroyed(): boolean {
128
+ return false;
129
+ }
130
+ onKeyPress(listener: (event: KeymapEvent) => void): () => void {
131
+ this.pressListeners.add(listener);
132
+ return () => this.pressListeners.delete(listener);
133
+ }
134
+ onKeyRelease(): () => void {
135
+ return () => {};
136
+ }
137
+ onFocusChange(): () => void {
138
+ return () => {};
139
+ }
140
+ onTargetDestroy(): () => void {
141
+ return () => {};
142
+ }
143
+ createCommandEvent(): KeymapEvent {
144
+ return makeKeymapEvent("");
145
+ }
146
+ emitKeyPress(event: KeymapEvent): void {
147
+ for (const listener of this.pressListeners) listener(event);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Terminal key events name shifted letters either "g"+shift or "G"; the
153
+ * keymap's canonical form is lowercase + the shift modifier. Non-letter
154
+ * single characters ("$", "0") already encode their shift in the character.
155
+ */
156
+ function normalizedEvent(key: ResolvableKey): { name: string; shift: boolean } {
157
+ if (key.name.length === 1) {
158
+ if (/[a-zA-Z]/.test(key.name)) {
159
+ return { name: key.name.toLowerCase(), shift: key.shift || key.name !== key.name.toLowerCase() };
160
+ }
161
+ return { name: key.name, shift: false };
162
+ }
163
+ return { name: key.name, shift: key.shift };
164
+ }
165
+
166
+ /** Config combos write shifted letters as uppercase; the parser wants shift+. */
167
+ function bindingKeyFor(combo: string): string {
168
+ if (combo.length === 1 && /[A-Z]/.test(combo)) return `shift+${combo.toLowerCase()}`;
169
+ return combo;
170
+ }
171
+
172
+ /** The grammar actions live in the base layer; these live in mode layers. */
173
+ const SPAN_COMMANDS: [key: string, command: string][] = [
174
+ ["l", "span_grow"],
175
+ ["h", "span_shrink"],
176
+ ["w", "span_slide_forward"],
177
+ ["b", "span_slide_back"],
178
+ ["$", "span_to_end"],
179
+ ["0", "span_to_start"],
180
+ ["c", "span_comment"],
181
+ ["s", "span_suggest"],
182
+ ["escape", "span_cancel"],
183
+ ];
184
+
185
+ export class KeyBindings {
186
+ private readonly host = new HeadlessKeymapHost();
187
+ private readonly keymap = new Keymap<object, KeymapEvent>(this.host);
188
+ private context: KeyLayerContext = { overlay: "none", spanMode: false };
189
+ private resolvedCommand: string | undefined;
190
+ private unregisterGrammarLayer: (() => void) | null = null;
191
+
192
+ constructor(keys: Record<string, string[]>) {
193
+ // the standard binding-string parser and event matcher
194
+ registerDefaultKeys(this.keymap);
195
+ // binding/layer `when` fields register runtime matchers, so getActiveKeys
196
+ // and dispatch only see the layer that owns the current mode
197
+ this.keymap.registerLayerFields({
198
+ when: (value, fieldContext) => {
199
+ if (typeof value === "function") fieldContext.activeWhen(value as () => boolean);
200
+ },
201
+ });
202
+ this.registerModeLayers();
203
+ this.setKeys(keys);
204
+ }
205
+
206
+ /** Rebuild the grammar layer from a (re)loaded config keymap. */
207
+ setKeys(keys: Record<string, string[]>): void {
208
+ this.unregisterGrammarLayer?.();
209
+ const bindings: { key: string; cmd: string }[] = [];
210
+ const commandNames = new Set<string>();
211
+ for (const [action, combos] of Object.entries(keys)) {
212
+ commandNames.add(action);
213
+ for (const combo of combos) bindings.push({ key: bindingKeyFor(combo), cmd: action });
214
+ }
215
+ // deselect is a hardwired escape in the plan grammar; hints show it
216
+ commandNames.add("deselect");
217
+ bindings.push({ key: "escape", cmd: "deselect" });
218
+ this.unregisterGrammarLayer = this.keymap.registerLayer({
219
+ priority: 0,
220
+ when: () => this.context.overlay === "none" && !this.context.spanMode,
221
+ bindings,
222
+ commands: [...commandNames].map((name) => ({
223
+ name,
224
+ run: () => {
225
+ this.resolvedCommand = name;
226
+ return true;
227
+ },
228
+ })),
229
+ });
230
+ }
231
+
232
+ private registerModeLayers(): void {
233
+ const record = (name: string) => ({
234
+ name,
235
+ run: () => {
236
+ this.resolvedCommand = name;
237
+ return true;
238
+ },
239
+ });
240
+ this.keymap.registerLayer({
241
+ priority: 10,
242
+ when: () => this.context.overlay === "none" && this.context.spanMode,
243
+ bindings: SPAN_COMMANDS.map(([key, command]) => ({ key, cmd: command })),
244
+ commands: SPAN_COMMANDS.map(([, command]) => record(command)),
245
+ });
246
+ this.keymap.registerLayer({
247
+ priority: 20,
248
+ when: () => this.context.overlay === "compose",
249
+ bindings: [
250
+ { key: "return", cmd: "save_compose" },
251
+ { key: "enter", cmd: "save_compose" },
252
+ { key: "escape", cmd: "cancel_overlay" },
253
+ ],
254
+ commands: [record("save_compose"), record("cancel_overlay")],
255
+ });
256
+ this.keymap.registerLayer({
257
+ priority: 20,
258
+ when: () => this.context.overlay === "submit",
259
+ bindings: [
260
+ { key: "return", cmd: "submit_verdict" },
261
+ { key: "enter", cmd: "submit_verdict" },
262
+ { key: "left", cmd: "cycle_verdict_left" },
263
+ { key: "right", cmd: "cycle_verdict_right" },
264
+ { key: "escape", cmd: "cancel_submit" },
265
+ ],
266
+ commands: [
267
+ record("submit_verdict"),
268
+ record("cycle_verdict_left"),
269
+ record("cycle_verdict_right"),
270
+ record("cancel_submit"),
271
+ ],
272
+ });
273
+ this.keymap.registerLayer({
274
+ priority: 20,
275
+ when: () => this.context.overlay === "completion-prompt" || this.context.overlay === "completion-counting",
276
+ bindings: [
277
+ { key: "return", cmd: "finish_review" },
278
+ { key: "enter", cmd: "finish_review" },
279
+ { key: "q", cmd: "finish_review" },
280
+ { key: "a", cmd: "opt_in_auto_close" },
281
+ { key: "escape", cmd: "dismiss_completion" },
282
+ ],
283
+ commands: [record("finish_review"), record("opt_in_auto_close"), record("dismiss_completion")],
284
+ });
285
+ }
286
+
287
+ setContext(context: KeyLayerContext): void {
288
+ this.context = context;
289
+ }
290
+
291
+ /**
292
+ * Resolve a key event to its command name through the keymap's dispatch.
293
+ * In the base grammar layer the command IS the config action name, so the
294
+ * result feeds straight into the intent reducer.
295
+ */
296
+ resolveAction(key: ResolvableKey): string | undefined {
297
+ const normalized = normalizedEvent(key);
298
+ this.resolvedCommand = undefined;
299
+ this.host.emitKeyPress(makeKeymapEvent(normalized.name, normalized.shift));
300
+ return this.resolvedCommand;
301
+ }
302
+
303
+ /** First active key display for a command, mapped through the glyph table. */
304
+ private keyDisplayFor(command: string): string | null {
305
+ for (const activeKey of this.keymap.getActiveKeys({ includeBindings: true })) {
306
+ const bindingCommand =
307
+ typeof activeKey.command === "string" ? activeKey.command : activeKey.bindings?.[0]?.command;
308
+ if (bindingCommand !== command) continue;
309
+ return KEY_GLYPHS[activeKey.stroke.name] ?? activeKey.display;
310
+ }
311
+ return null;
312
+ }
313
+
314
+ /** The status-line hint for a view state, generated from the active keys. */
315
+ statusHint(mode: HintMode): string {
316
+ const fragments: string[] = [];
317
+ for (const entry of HINT_TEMPLATES[mode]) {
318
+ if ("text" in entry) {
319
+ fragments.push(entry.text);
320
+ continue;
321
+ }
322
+ const displays = entry.commands
323
+ .map((command) => this.keyDisplayFor(command))
324
+ .filter((display): display is string => display !== null);
325
+ if (!displays.length) continue;
326
+ const keyPart = displays.join("/");
327
+ if (!entry.label) fragments.push(keyPart);
328
+ else fragments.push(entry.labelFirst ? `${entry.label} ${keyPart}` : `${keyPart} ${entry.label}`);
329
+ }
330
+ return fragments.join(" · ");
331
+ }
332
+ }
package/src/keymap.ts CHANGED
@@ -64,7 +64,13 @@ function status(msg: string): Intent[] {
64
64
  return [{ t: "status", msg }];
65
65
  }
66
66
 
67
- export function reduceKey(state: KeyState, key: KeyInput): Intent[] {
67
+ /**
68
+ * Reduce one key event to intents. `resolvedAction` is the binding-layer
69
+ * resolution (key-bindings.ts over @opentui/keymap) when the caller has one;
70
+ * without it the reducer falls back to the plain reverse lookup, so the
71
+ * grammar stays testable as a pure function.
72
+ */
73
+ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: string): Intent[] {
68
74
  const name = key.name;
69
75
  // compose/submit overlays own the keys via focused inputs; only escape,
70
76
  // return, and verdict arrows route through the grammar
@@ -84,7 +90,7 @@ export function reduceKey(state: KeyState, key: KeyInput): Intent[] {
84
90
  if (name === "escape") return [{ t: "dismissCompletion" }];
85
91
  return [];
86
92
  }
87
- const action = actionFor(state.keys, name, key.shift);
93
+ const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
88
94
  if (action === "quit") return [{ t: "exit" }];
89
95
  // the ONE read-only rule: any mutating attempt answers instead of acting
90
96
  // (span-mode c/s are hardwired keys, so they gate by name as well)
package/src/run.ts CHANGED
@@ -22,7 +22,9 @@ export async function runClient(opts: RunClientOptions): Promise<number> {
22
22
  onExit: (code: number) => {
23
23
  renderer.destroy();
24
24
  resolve(code);
25
- process.exit(code);
25
+ // one microtask between destroy and exit lets the renderer flush
26
+ // its terminal-restore sequences before the process dies
27
+ queueMicrotask(() => process.exit(code));
26
28
  },
27
29
  }),
28
30
  );