@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.90
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.
- package/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +42 -34
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +77 -51
- package/src/App.diff.test.tsx +259 -45
- package/src/App.export.test.tsx +58 -41
- package/src/App.focus.test.ts +89 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +330 -161
- package/src/App.prototype-markdown.test.tsx +128 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +129 -67
- package/src/App.regression.test.tsx +94 -0
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +264 -0
- package/src/App.test.tsx +402 -126
- package/src/App.toast.test.tsx +98 -0
- package/src/App.tsx +1402 -1060
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +774 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +62 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +577 -0
- package/src/app-view-model.ts +168 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/client-extension-registry.test.tsx +61 -0
- package/src/client-extension-registry.ts +159 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +134 -0
- package/src/components/AnnotationCards.stories.tsx +86 -0
- package/src/components/AnnotationCards.tsx +341 -0
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.extensions.test.tsx +191 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +362 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +44 -0
- package/src/components/ChangesColumn.tsx +199 -0
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +37 -0
- package/src/components/CompletionOverlay.tsx +77 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +150 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +72 -0
- package/src/components/DiffContentView.scroll.test.tsx +149 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +906 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +186 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +426 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/ExtensionZone.stories.tsx +28 -0
- package/src/components/ExtensionZone.tsx +145 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +182 -0
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +98 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +75 -0
- package/src/components/ProjectTreeView.tsx +125 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +99 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +45 -0
- package/src/components/RootOverlay.tsx +48 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +186 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +47 -0
- package/src/components/ThreadFooter.tsx +76 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +255 -0
- package/src/components/ThreadView.selection.test.tsx +951 -0
- package/src/components/ThreadView.slash.test.tsx +146 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +525 -0
- package/src/components/Toast.stories.tsx +19 -0
- package/src/components/Toast.tsx +52 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +150 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +49 -0
- package/src/components/WelcomePlayground.tsx +139 -0
- package/src/components/WorkspacePanels.stories.tsx +36 -0
- package/src/components/WorkspacePanels.tsx +116 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +59 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +63 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +71 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +100 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +17 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +58 -0
- package/src/components/quick-actions-editor.test.tsx +117 -0
- package/src/components/quick-actions-editor.tsx +226 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +193 -0
- package/src/components/stories.test.tsx +74 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +108 -0
- package/src/components/story.ts +159 -0
- package/src/components/syntax-highlight.ts +133 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/thread-tree.test.ts +134 -0
- package/src/components/thread-tree.ts +109 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config-identity.test.ts +78 -0
- package/src/config.test.ts +579 -34
- package/src/config.ts +533 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +238 -0
- package/src/diff-intraline.ts +236 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +28 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +724 -0
- package/src/intent-dispatch.ts +690 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +459 -0
- package/src/keymap.test.ts +456 -115
- package/src/keymap.ts +315 -81
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/launch-layout.test.ts +67 -0
- package/src/launch-layout.ts +47 -0
- package/src/mark-runs.ts +121 -0
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/prototype-browser.test.ts +81 -0
- package/src/prototype-browser.ts +329 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +85 -12
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +52 -10
- package/src/share.ts +237 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +75 -0
- package/src/slash-palette.test.ts +103 -0
- package/src/slash-palette.ts +129 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +369 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +219 -0
- package/src/theme.ts +88 -19
- package/src/thread-chords.test.ts +79 -0
- package/src/thread-chords.ts +163 -0
- package/src/thread-code-syntax.ts +45 -0
- package/src/thread-controller.curate.test.ts +476 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +322 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +561 -0
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +261 -0
- package/src/thread-controller.ts +2050 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +153 -0
- package/src/use-annotation-surface.tsx +975 -0
- package/src/use-changes-workbench.ts +190 -0
- package/src/use-frame-measure.ts +41 -0
- package/src/use-remember-layout.ts +31 -0
- package/src/use-repo-changes.ts +87 -0
- package/src/use-settings-dialog.tsx +364 -0
- package/src/use-terminal-virtualizer.ts +259 -0
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +279 -9
- package/src/view-diff.ts +460 -24
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +545 -0
- package/src/view-plan.ts +1019 -0
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/workbench-snapshot.test.ts +99 -0
- package/src/workbench-snapshot.ts +59 -0
- package/src/session-controller.ts +0 -398
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -364
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** Config keys resolve to per-layer actions, and status hints derive from getActiveKeys() so rebinds surface. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { DEFAULT_KEYS } from "./config";
|
|
5
|
+
import { KeyBindings } from "./key-bindings";
|
|
6
|
+
|
|
7
|
+
function bindings(
|
|
8
|
+
context: { overlay?: "none" | "walk" | "compose" | "submit"; spanMode?: boolean } = {},
|
|
9
|
+
): KeyBindings {
|
|
10
|
+
const resolver = new KeyBindings(DEFAULT_KEYS);
|
|
11
|
+
|
|
12
|
+
resolver.setContext({ overlay: context.overlay ?? "none", spanMode: context.spanMode ?? false });
|
|
13
|
+
|
|
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, "split_diff"],
|
|
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
|
+
|
|
38
|
+
for (const [name, shift, action] of table) {
|
|
39
|
+
test(`${shift ? "shift+" : ""}${name} -> ${action}`, () => {
|
|
40
|
+
expect(bindings().resolveAction({ name, shift })).toBe(action!);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("mode layers own their keys", () => {
|
|
46
|
+
test("span mode resolves the span primitives, not the grammar", () => {
|
|
47
|
+
// Arrange
|
|
48
|
+
const resolver = bindings({ spanMode: true });
|
|
49
|
+
|
|
50
|
+
// Assert
|
|
51
|
+
expect(resolver.resolveAction({ name: "l", shift: false })).toBe("span_grow");
|
|
52
|
+
expect(resolver.resolveAction({ name: "c", shift: false })).toBe("span_comment");
|
|
53
|
+
expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("span_cancel");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("the compose overlay only knows save and cancel", () => {
|
|
57
|
+
// Arrange
|
|
58
|
+
const resolver = bindings({ overlay: "compose" });
|
|
59
|
+
|
|
60
|
+
// Assert
|
|
61
|
+
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("save_compose");
|
|
62
|
+
expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("cancel_overlay");
|
|
63
|
+
expect(resolver.resolveAction({ name: "j", shift: false })).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("the submit overlay adds the message arrows", () => {
|
|
67
|
+
// Arrange
|
|
68
|
+
const resolver = bindings({ overlay: "submit" });
|
|
69
|
+
|
|
70
|
+
// Assert
|
|
71
|
+
expect(resolver.resolveAction({ name: "left", shift: false })).toBe("cycle_outcome_left");
|
|
72
|
+
expect(resolver.resolveAction({ name: "right", shift: false })).toBe("cycle_outcome_right");
|
|
73
|
+
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("send_message");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the walk overlay owns the bracket stepping and leaves the grammar keys dead", () => {
|
|
77
|
+
// Arrange
|
|
78
|
+
const resolver = bindings({ overlay: "walk" });
|
|
79
|
+
|
|
80
|
+
// Assert
|
|
81
|
+
expect(resolver.resolveAction({ name: "]", shift: false })).toBe("walk_next");
|
|
82
|
+
expect(resolver.resolveAction({ name: "[", shift: false })).toBe("walk_prev");
|
|
83
|
+
expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("walk_leave");
|
|
84
|
+
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("walk_submit");
|
|
85
|
+
expect(resolver.resolveAction({ name: "q", shift: false })).toBe("quit");
|
|
86
|
+
expect(resolver.resolveAction({ name: "j", shift: false })).toBeUndefined();
|
|
87
|
+
expect(resolver.resolveAction({ name: "c", shift: false })).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("getActiveKeys-generated status hints", () => {
|
|
92
|
+
test("normal mode hint matches the locked string", () => {
|
|
93
|
+
expect(bindings().statusHint("normal")).toBe(
|
|
94
|
+
"j/k move · v span · drag selects · c comment · x cut · e edit · n/p annotations · enter submit · q quit",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("card-selected hint keeps the locked Cut label", () => {
|
|
99
|
+
expect(bindings().statusHint("card")).toBe(
|
|
100
|
+
"card · e edit · x Cut · n/p cards · esc deselect · enter submit",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("span hint", () => {
|
|
105
|
+
expect(bindings({ spanMode: true }).statusHint("span")).toBe(
|
|
106
|
+
"span · l/h grow/shrink · w/b slide · $ end · c comment · esc",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("compose hint", () => {
|
|
111
|
+
expect(bindings({ overlay: "compose" }).statusHint("compose")).toBe(
|
|
112
|
+
"typing · enter save · esc cancel",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("submit hint", () => {
|
|
117
|
+
expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe(
|
|
118
|
+
"decision ←/→ · enter submit · esc cancel",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("walk hint", () => {
|
|
123
|
+
expect(bindings({ overlay: "walk" }).statusHint("walk")).toBe(
|
|
124
|
+
"walk · ] next (marks viewed) · [ back · esc leave walk · q quit",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("read-only hint", () => {
|
|
129
|
+
expect(bindings().statusHint("read-only")).toBe(
|
|
130
|
+
"observer - read-only · j/k move · n/p annotations · q quit",
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("collaborator hint annotates but shows no plan edit or submit", () => {
|
|
135
|
+
// Arrange / Act
|
|
136
|
+
const hint = bindings().statusHint("collaborator");
|
|
137
|
+
|
|
138
|
+
// Assert
|
|
139
|
+
expect(hint).toBe("j/k move · v span · drag selects · c comment · n/p annotations · q quit");
|
|
140
|
+
expect(hint).not.toContain("cut");
|
|
141
|
+
expect(hint).not.toContain("edit");
|
|
142
|
+
expect(hint).not.toContain("submit");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a rebound key shows its real binding in the hint", () => {
|
|
146
|
+
// Arrange
|
|
147
|
+
const resolver = new KeyBindings({ ...DEFAULT_KEYS, comment: ["m"] });
|
|
148
|
+
|
|
149
|
+
resolver.setContext({ overlay: "none", spanMode: false });
|
|
150
|
+
|
|
151
|
+
// Assert
|
|
152
|
+
expect(resolver.resolveAction({ name: "m", shift: false })).toBe("comment");
|
|
153
|
+
expect(resolver.resolveAction({ name: "c", shift: false })).toBeUndefined();
|
|
154
|
+
expect(resolver.statusHint("normal")).toContain("m comment");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding resolution over @opentui/keymap: 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 * as v from "valibot";
|
|
14
|
+
import type { KeyState } from "./keymap";
|
|
15
|
+
|
|
16
|
+
export interface ResolvableKey {
|
|
17
|
+
name: string;
|
|
18
|
+
shift: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The layer-activation slice of the view state. */
|
|
22
|
+
export interface KeyLayerContext {
|
|
23
|
+
overlay: KeyState["overlay"];
|
|
24
|
+
spanMode: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Hint templates keyed by the same view states the status line shows. */
|
|
28
|
+
export type HintMode =
|
|
29
|
+
| "normal"
|
|
30
|
+
| "collaborator"
|
|
31
|
+
| "card"
|
|
32
|
+
| "span"
|
|
33
|
+
| "compose"
|
|
34
|
+
| "submit"
|
|
35
|
+
| "walk"
|
|
36
|
+
| "read-only";
|
|
37
|
+
|
|
38
|
+
export interface CheatsheetEntry {
|
|
39
|
+
keys: string;
|
|
40
|
+
label: string;
|
|
41
|
+
}
|
|
42
|
+
export interface CheatsheetSection {
|
|
43
|
+
title: string;
|
|
44
|
+
entries: CheatsheetEntry[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type HintEntry = { text: string } | { commands: string[]; label: string; labelFirst?: boolean };
|
|
48
|
+
|
|
49
|
+
const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
|
|
50
|
+
normal: [
|
|
51
|
+
{ commands: ["down", "up"], label: "move" },
|
|
52
|
+
{ commands: ["span"], label: "span" },
|
|
53
|
+
{ text: "drag selects" },
|
|
54
|
+
{ commands: ["comment"], label: "comment" },
|
|
55
|
+
{ commands: ["cut"], label: "cut" },
|
|
56
|
+
{ commands: ["edit"], label: "edit" },
|
|
57
|
+
{ commands: ["next_annotation", "prev_annotation"], label: "annotations" },
|
|
58
|
+
{ commands: ["submit"], label: "submit" },
|
|
59
|
+
{ commands: ["quit"], label: "quit" },
|
|
60
|
+
],
|
|
61
|
+
// A share viewer: annotate and navigate, no plan edit / message submit.
|
|
62
|
+
collaborator: [
|
|
63
|
+
{ commands: ["down", "up"], label: "move" },
|
|
64
|
+
{ commands: ["span"], label: "span" },
|
|
65
|
+
{ text: "drag selects" },
|
|
66
|
+
{ commands: ["comment"], label: "comment" },
|
|
67
|
+
{ commands: ["next_annotation", "prev_annotation"], label: "annotations" },
|
|
68
|
+
{ commands: ["quit"], label: "quit" },
|
|
69
|
+
],
|
|
70
|
+
card: [
|
|
71
|
+
{ text: "card" },
|
|
72
|
+
{ commands: ["edit"], label: "edit" },
|
|
73
|
+
{ commands: ["cut"], label: "Cut" },
|
|
74
|
+
{ commands: ["next_annotation", "prev_annotation"], label: "cards" },
|
|
75
|
+
{ commands: ["deselect"], label: "deselect" },
|
|
76
|
+
{ commands: ["submit"], label: "submit" },
|
|
77
|
+
],
|
|
78
|
+
span: [
|
|
79
|
+
{ text: "span" },
|
|
80
|
+
{ commands: ["span_grow", "span_shrink"], label: "grow/shrink" },
|
|
81
|
+
{ commands: ["span_slide_forward", "span_slide_back"], label: "slide" },
|
|
82
|
+
{ commands: ["span_to_end"], label: "end" },
|
|
83
|
+
{ commands: ["span_comment"], label: "comment" },
|
|
84
|
+
{ commands: ["span_cancel"], label: "" },
|
|
85
|
+
],
|
|
86
|
+
compose: [
|
|
87
|
+
{ text: "typing" },
|
|
88
|
+
{ commands: ["save_compose"], label: "save" },
|
|
89
|
+
{ commands: ["cancel_overlay"], label: "cancel" },
|
|
90
|
+
],
|
|
91
|
+
submit: [
|
|
92
|
+
{
|
|
93
|
+
commands: ["cycle_outcome_left", "cycle_outcome_right"],
|
|
94
|
+
label: "decision",
|
|
95
|
+
labelFirst: true,
|
|
96
|
+
},
|
|
97
|
+
{ commands: ["send_message"], label: "submit" },
|
|
98
|
+
{ commands: ["cancel_submit"], label: "cancel" },
|
|
99
|
+
],
|
|
100
|
+
walk: [
|
|
101
|
+
{ text: "walk" },
|
|
102
|
+
{ commands: ["walk_next"], label: "next (marks viewed)" },
|
|
103
|
+
{ commands: ["walk_prev"], label: "back" },
|
|
104
|
+
{ commands: ["walk_leave"], label: "leave walk" },
|
|
105
|
+
{ commands: ["quit"], label: "quit" },
|
|
106
|
+
],
|
|
107
|
+
"read-only": [
|
|
108
|
+
{ text: "observer - read-only" },
|
|
109
|
+
{ commands: ["down", "up"], label: "move" },
|
|
110
|
+
{ commands: ["next_annotation", "prev_annotation"], label: "annotations" },
|
|
111
|
+
{ commands: ["quit"], label: "quit" },
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Status-line glyphs for named keys. */
|
|
116
|
+
interface KeyGlyphs {
|
|
117
|
+
[key: string]: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const KEY_GLYPHS: KeyGlyphs = {
|
|
121
|
+
return: "enter",
|
|
122
|
+
enter: "enter",
|
|
123
|
+
escape: "esc",
|
|
124
|
+
left: "←",
|
|
125
|
+
right: "→",
|
|
126
|
+
backspace: "⌫",
|
|
127
|
+
down: "↓",
|
|
128
|
+
up: "↑",
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function makeKeymapEvent(name: string, shift = false): KeymapEvent {
|
|
132
|
+
return {
|
|
133
|
+
name,
|
|
134
|
+
ctrl: false,
|
|
135
|
+
shift,
|
|
136
|
+
meta: false,
|
|
137
|
+
preventDefault: () => {},
|
|
138
|
+
stopPropagation: () => {},
|
|
139
|
+
propagationStopped: false,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
class HeadlessKeymapHost implements KeymapHost<object, KeymapEvent> {
|
|
144
|
+
readonly metadata = {
|
|
145
|
+
platform: "unknown" as const,
|
|
146
|
+
primaryModifier: "unknown" as const,
|
|
147
|
+
modifiers: {
|
|
148
|
+
ctrl: "supported" as const,
|
|
149
|
+
shift: "supported" as const,
|
|
150
|
+
meta: "supported" as const,
|
|
151
|
+
super: "unknown" as const,
|
|
152
|
+
hyper: "unknown" as const,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
readonly rootTarget = {};
|
|
156
|
+
readonly isDestroyed = false;
|
|
157
|
+
private readonly pressListeners = new Set<(event: KeymapEvent) => void>();
|
|
158
|
+
|
|
159
|
+
getFocusedTarget(): object | null {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
getParentTarget(): object | null {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
isTargetDestroyed(): boolean {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
onKeyPress(listener: (event: KeymapEvent) => void): () => void {
|
|
169
|
+
this.pressListeners.add(listener);
|
|
170
|
+
|
|
171
|
+
return () => this.pressListeners.delete(listener);
|
|
172
|
+
}
|
|
173
|
+
onKeyRelease(): () => void {
|
|
174
|
+
return () => {};
|
|
175
|
+
}
|
|
176
|
+
onFocusChange(): () => void {
|
|
177
|
+
return () => {};
|
|
178
|
+
}
|
|
179
|
+
onTargetDestroy(): () => void {
|
|
180
|
+
return () => {};
|
|
181
|
+
}
|
|
182
|
+
createCommandEvent(): KeymapEvent {
|
|
183
|
+
return makeKeymapEvent("");
|
|
184
|
+
}
|
|
185
|
+
emitKeyPress(event: KeymapEvent): void {
|
|
186
|
+
for (const listener of this.pressListeners) listener(event);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Terminal key events name shifted letters either "g"+shift or "G"; the
|
|
192
|
+
* keymap's canonical form is lowercase + the shift modifier. Non-letter
|
|
193
|
+
* single characters ("$", "0") already encode their shift in the character.
|
|
194
|
+
*/
|
|
195
|
+
function normalizedEvent(key: ResolvableKey) {
|
|
196
|
+
if (key.name.length === 1) {
|
|
197
|
+
if (/[a-zA-Z]/.test(key.name)) {
|
|
198
|
+
return {
|
|
199
|
+
name: key.name.toLowerCase(),
|
|
200
|
+
shift: key.shift || key.name !== key.name.toLowerCase(),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { name: key.name, shift: false };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { name: key.name, shift: key.shift };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Config combos write shifted letters as uppercase; the parser wants shift+. */
|
|
211
|
+
function bindingKeyFor(combo: string): string {
|
|
212
|
+
if (combo.length === 1 && /[A-Z]/.test(combo)) return `shift+${combo.toLowerCase()}`;
|
|
213
|
+
|
|
214
|
+
return combo;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The grammar actions live in the base layer; these live in mode layers. */
|
|
218
|
+
const SPAN_COMMANDS: [key: string, command: string][] = [
|
|
219
|
+
["l", "span_grow"],
|
|
220
|
+
["h", "span_shrink"],
|
|
221
|
+
["w", "span_slide_forward"],
|
|
222
|
+
["b", "span_slide_back"],
|
|
223
|
+
["$", "span_to_end"],
|
|
224
|
+
["0", "span_to_start"],
|
|
225
|
+
["c", "span_comment"],
|
|
226
|
+
["escape", "span_cancel"],
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
function isConditionMatcher(value: unknown): value is () => boolean {
|
|
230
|
+
return typeof value === "function";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export class KeyBindings {
|
|
234
|
+
private readonly host = new HeadlessKeymapHost();
|
|
235
|
+
private readonly keymap = new Keymap<object, KeymapEvent>(this.host);
|
|
236
|
+
private context: KeyLayerContext = { overlay: "none", spanMode: false };
|
|
237
|
+
private resolvedCommand: string | undefined;
|
|
238
|
+
private unregisterGrammarLayer: (() => void) | null = null;
|
|
239
|
+
|
|
240
|
+
constructor(keys: Record<string, string[]>) {
|
|
241
|
+
// the standard binding-string parser and event matcher
|
|
242
|
+
registerDefaultKeys(this.keymap);
|
|
243
|
+
// binding/layer `when` fields register runtime matchers, so getActiveKeys
|
|
244
|
+
// and dispatch only see the layer that owns the current mode
|
|
245
|
+
this.keymap.registerLayerFields({
|
|
246
|
+
when: (value, fieldContext) => {
|
|
247
|
+
if (isConditionMatcher(value)) fieldContext.activeWhen(value);
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
this.registerModeLayers();
|
|
251
|
+
this.setKeys(keys);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Rebuild the grammar layer from a (re)loaded config keymap. */
|
|
255
|
+
setKeys(keys: Record<string, string[]>): void {
|
|
256
|
+
this.unregisterGrammarLayer?.();
|
|
257
|
+
const bindings: { key: string; cmd: string }[] = [];
|
|
258
|
+
const commandNames = new Set<string>();
|
|
259
|
+
|
|
260
|
+
for (const [action, combos] of Object.entries(keys)) {
|
|
261
|
+
commandNames.add(action);
|
|
262
|
+
for (const combo of combos) bindings.push({ key: bindingKeyFor(combo), cmd: action });
|
|
263
|
+
}
|
|
264
|
+
// deselect is a hardwired escape in the plan grammar; hints show it
|
|
265
|
+
commandNames.add("deselect");
|
|
266
|
+
bindings.push({ key: "escape", cmd: "deselect" });
|
|
267
|
+
this.unregisterGrammarLayer = this.keymap.registerLayer({
|
|
268
|
+
priority: 0,
|
|
269
|
+
when: () => this.context.overlay === "none" && !this.context.spanMode,
|
|
270
|
+
bindings,
|
|
271
|
+
commands: [...commandNames].map((name) => ({
|
|
272
|
+
name,
|
|
273
|
+
run: () => {
|
|
274
|
+
this.resolvedCommand = name;
|
|
275
|
+
|
|
276
|
+
return true;
|
|
277
|
+
},
|
|
278
|
+
})),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private registerModeLayers(): void {
|
|
283
|
+
const record = (name: string) => ({
|
|
284
|
+
name,
|
|
285
|
+
run: () => {
|
|
286
|
+
this.resolvedCommand = name;
|
|
287
|
+
|
|
288
|
+
return true;
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
this.keymap.registerLayer({
|
|
293
|
+
priority: 10,
|
|
294
|
+
when: () => this.context.overlay === "none" && this.context.spanMode,
|
|
295
|
+
bindings: SPAN_COMMANDS.map(([key, command]) => ({ key, cmd: command })),
|
|
296
|
+
commands: SPAN_COMMANDS.map(([, command]) => record(command)),
|
|
297
|
+
});
|
|
298
|
+
// the walk wizard: prev/next stepping, leave, and submit from the end card
|
|
299
|
+
this.keymap.registerLayer({
|
|
300
|
+
priority: 20,
|
|
301
|
+
when: () => this.context.overlay === "walk",
|
|
302
|
+
bindings: [
|
|
303
|
+
{ key: "]", cmd: "walk_next" },
|
|
304
|
+
{ key: "[", cmd: "walk_prev" },
|
|
305
|
+
{ key: "escape", cmd: "walk_leave" },
|
|
306
|
+
{ key: "return", cmd: "walk_submit" },
|
|
307
|
+
{ key: "enter", cmd: "walk_submit" },
|
|
308
|
+
{ key: "q", cmd: "quit" },
|
|
309
|
+
],
|
|
310
|
+
commands: [
|
|
311
|
+
record("walk_next"),
|
|
312
|
+
record("walk_prev"),
|
|
313
|
+
record("walk_leave"),
|
|
314
|
+
record("walk_submit"),
|
|
315
|
+
record("quit"),
|
|
316
|
+
],
|
|
317
|
+
});
|
|
318
|
+
this.keymap.registerLayer({
|
|
319
|
+
priority: 20,
|
|
320
|
+
when: () => this.context.overlay === "compose",
|
|
321
|
+
bindings: [
|
|
322
|
+
{ key: "return", cmd: "save_compose" },
|
|
323
|
+
{ key: "enter", cmd: "save_compose" },
|
|
324
|
+
{ key: "escape", cmd: "cancel_overlay" },
|
|
325
|
+
],
|
|
326
|
+
commands: [record("save_compose"), record("cancel_overlay")],
|
|
327
|
+
});
|
|
328
|
+
this.keymap.registerLayer({
|
|
329
|
+
priority: 20,
|
|
330
|
+
when: () => this.context.overlay === "submit",
|
|
331
|
+
bindings: [
|
|
332
|
+
{ key: "return", cmd: "send_message" },
|
|
333
|
+
{ key: "enter", cmd: "send_message" },
|
|
334
|
+
{ key: "left", cmd: "cycle_outcome_left" },
|
|
335
|
+
{ key: "right", cmd: "cycle_outcome_right" },
|
|
336
|
+
{ key: "escape", cmd: "cancel_submit" },
|
|
337
|
+
],
|
|
338
|
+
commands: [
|
|
339
|
+
record("send_message"),
|
|
340
|
+
record("cycle_outcome_left"),
|
|
341
|
+
record("cycle_outcome_right"),
|
|
342
|
+
record("cancel_submit"),
|
|
343
|
+
],
|
|
344
|
+
});
|
|
345
|
+
this.keymap.registerLayer({
|
|
346
|
+
priority: 20,
|
|
347
|
+
when: () =>
|
|
348
|
+
this.context.overlay === "completion-prompt" ||
|
|
349
|
+
this.context.overlay === "completion-counting",
|
|
350
|
+
bindings: [
|
|
351
|
+
{ key: "return", cmd: "finish_review" },
|
|
352
|
+
{ key: "enter", cmd: "finish_review" },
|
|
353
|
+
{ key: "q", cmd: "finish_review" },
|
|
354
|
+
{ key: "a", cmd: "opt_in_auto_close" },
|
|
355
|
+
{ key: "escape", cmd: "dismiss_completion" },
|
|
356
|
+
],
|
|
357
|
+
commands: [
|
|
358
|
+
record("finish_review"),
|
|
359
|
+
record("opt_in_auto_close"),
|
|
360
|
+
record("dismiss_completion"),
|
|
361
|
+
],
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
setContext(context: KeyLayerContext): void {
|
|
366
|
+
this.context = context;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Resolve a key event to its command name through the keymap's dispatch.
|
|
371
|
+
* In the base grammar layer the command IS the config action name, so the
|
|
372
|
+
* result feeds straight into the intent reducer.
|
|
373
|
+
*/
|
|
374
|
+
resolveAction(key: ResolvableKey): string | undefined {
|
|
375
|
+
const normalized = normalizedEvent(key);
|
|
376
|
+
|
|
377
|
+
this.resolvedCommand = undefined;
|
|
378
|
+
this.host.emitKeyPress(makeKeymapEvent(normalized.name, normalized.shift));
|
|
379
|
+
|
|
380
|
+
return this.resolvedCommand;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** First active key display for a command, mapped through the glyph table. */
|
|
384
|
+
private keyDisplayFor(command: string): string | null {
|
|
385
|
+
for (const activeKey of this.keymap.getActiveKeys({ includeBindings: true })) {
|
|
386
|
+
const parsedCommand = v.safeParse(v.string(), activeKey.command);
|
|
387
|
+
const bindingCommand = parsedCommand.success
|
|
388
|
+
? parsedCommand.output
|
|
389
|
+
: activeKey.bindings?.[0]?.command;
|
|
390
|
+
|
|
391
|
+
if (bindingCommand !== command) continue;
|
|
392
|
+
|
|
393
|
+
return KEY_GLYPHS[activeKey.stroke.name] ?? activeKey.display;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The status-line hint for a view state, generated from the active keys. */
|
|
400
|
+
statusHint(mode: HintMode): string {
|
|
401
|
+
const fragments: string[] = [];
|
|
402
|
+
|
|
403
|
+
for (const entry of HINT_TEMPLATES[mode]) {
|
|
404
|
+
if ("text" in entry) {
|
|
405
|
+
fragments.push(entry.text);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const displays = entry.commands
|
|
409
|
+
.map((command) => this.keyDisplayFor(command))
|
|
410
|
+
.filter((display): display is string => display !== null);
|
|
411
|
+
|
|
412
|
+
if (!displays.length) continue;
|
|
413
|
+
const keyPart = displays.join("/");
|
|
414
|
+
|
|
415
|
+
if (!entry.label) fragments.push(keyPart);
|
|
416
|
+
else
|
|
417
|
+
fragments.push(
|
|
418
|
+
entry.labelFirst ? `${entry.label} ${keyPart}` : `${keyPart} ${entry.label}`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return fragments.join(" · ");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The full keybinding cheatsheet, grouped by mode. Each section sets the
|
|
427
|
+
* layer context so getActiveKeys resolves that mode's real bindings, then
|
|
428
|
+
* restores it - a rebound key shows its live binding here too.
|
|
429
|
+
*/
|
|
430
|
+
cheatsheet(): CheatsheetSection[] {
|
|
431
|
+
const saved = this.context;
|
|
432
|
+
const build = (context: KeyLayerContext, mode: HintMode, title: string): CheatsheetSection => {
|
|
433
|
+
this.context = context;
|
|
434
|
+
const entries: CheatsheetEntry[] = [];
|
|
435
|
+
|
|
436
|
+
for (const entry of HINT_TEMPLATES[mode]) {
|
|
437
|
+
if ("text" in entry || !entry.label) continue;
|
|
438
|
+
const keys = entry.commands
|
|
439
|
+
.map((command) => this.keyDisplayFor(command))
|
|
440
|
+
.filter((display): display is string => display !== null)
|
|
441
|
+
.join(" / ");
|
|
442
|
+
|
|
443
|
+
if (keys) entries.push({ keys, label: entry.label });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { title, entries };
|
|
447
|
+
};
|
|
448
|
+
const sections = [
|
|
449
|
+
build({ overlay: "none", spanMode: false }, "normal", "Review"),
|
|
450
|
+
build({ overlay: "none", spanMode: true }, "span", "Selection"),
|
|
451
|
+
build({ overlay: "submit", spanMode: false }, "submit", "Submit"),
|
|
452
|
+
build({ overlay: "walk", spanMode: false }, "walk", "Walk"),
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
this.context = saved;
|
|
456
|
+
|
|
457
|
+
return sections.filter((section) => section.entries.length > 0);
|
|
458
|
+
}
|
|
459
|
+
}
|