@sigrun/tui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +9 -2
  4. package/dist/actions.d.ts +119 -0
  5. package/dist/actions.js +12 -0
  6. package/dist/api.d.ts +152 -0
  7. package/dist/api.js +189 -0
  8. package/dist/blocks/braille.d.ts +41 -0
  9. package/dist/blocks/braille.js +131 -0
  10. package/dist/blocks/chart.d.ts +20 -0
  11. package/dist/blocks/chart.js +397 -0
  12. package/dist/blocks/collection/calendar.d.ts +28 -0
  13. package/dist/blocks/collection/calendar.js +232 -0
  14. package/dist/blocks/collection/card.d.ts +36 -0
  15. package/dist/blocks/collection/card.js +128 -0
  16. package/dist/blocks/collection/collection.d.ts +9 -0
  17. package/dist/blocks/collection/collection.js +26 -0
  18. package/dist/blocks/collection/grid.d.ts +18 -0
  19. package/dist/blocks/collection/grid.js +72 -0
  20. package/dist/blocks/collection/lanes.d.ts +32 -0
  21. package/dist/blocks/collection/lanes.js +320 -0
  22. package/dist/blocks/collection/list.d.ts +30 -0
  23. package/dist/blocks/collection/list.js +273 -0
  24. package/dist/blocks/collection/move.d.ts +67 -0
  25. package/dist/blocks/collection/move.js +96 -0
  26. package/dist/blocks/collection/shared.d.ts +87 -0
  27. package/dist/blocks/collection/shared.js +177 -0
  28. package/dist/blocks/collection/table.d.ts +26 -0
  29. package/dist/blocks/collection/table.js +221 -0
  30. package/dist/blocks/content.d.ts +48 -0
  31. package/dist/blocks/content.js +253 -0
  32. package/dist/blocks/context.d.ts +96 -0
  33. package/dist/blocks/context.js +65 -0
  34. package/dist/blocks/diff.d.ts +21 -0
  35. package/dist/blocks/diff.js +25 -0
  36. package/dist/blocks/ext-transport.d.ts +70 -0
  37. package/dist/blocks/ext-transport.js +165 -0
  38. package/dist/blocks/form/choice.d.ts +54 -0
  39. package/dist/blocks/form/choice.js +175 -0
  40. package/dist/blocks/form/fields.d.ts +56 -0
  41. package/dist/blocks/form/fields.js +136 -0
  42. package/dist/blocks/form/file.d.ts +61 -0
  43. package/dist/blocks/form/file.js +121 -0
  44. package/dist/blocks/form/form.d.ts +53 -0
  45. package/dist/blocks/form/form.js +703 -0
  46. package/dist/blocks/form/list.d.ts +37 -0
  47. package/dist/blocks/form/list.js +70 -0
  48. package/dist/blocks/form/values.d.ts +20 -0
  49. package/dist/blocks/form/values.js +104 -0
  50. package/dist/blocks/icons.d.ts +14 -0
  51. package/dist/blocks/icons.js +74 -0
  52. package/dist/blocks/image.d.ts +39 -0
  53. package/dist/blocks/image.js +82 -0
  54. package/dist/blocks/layout.d.ts +31 -0
  55. package/dist/blocks/layout.js +155 -0
  56. package/dist/blocks/log.d.ts +24 -0
  57. package/dist/blocks/log.js +111 -0
  58. package/dist/blocks/meter.d.ts +16 -0
  59. package/dist/blocks/meter.js +50 -0
  60. package/dist/blocks/page.d.ts +54 -0
  61. package/dist/blocks/page.js +355 -0
  62. package/dist/blocks/render.d.ts +31 -0
  63. package/dist/blocks/render.js +145 -0
  64. package/dist/blocks/surface-view.d.ts +33 -0
  65. package/dist/blocks/surface-view.js +86 -0
  66. package/dist/blocks/surface.d.ts +75 -0
  67. package/dist/blocks/surface.js +468 -0
  68. package/dist/blocks/tones.d.ts +23 -0
  69. package/dist/blocks/tones.js +63 -0
  70. package/dist/blocks/treeview.d.ts +22 -0
  71. package/dist/blocks/treeview.js +121 -0
  72. package/dist/blocks/width.d.ts +32 -0
  73. package/dist/blocks/width.js +70 -0
  74. package/dist/clipboard.d.ts +20 -0
  75. package/dist/clipboard.js +19 -0
  76. package/dist/editor-launch.d.ts +34 -0
  77. package/dist/editor-launch.js +70 -0
  78. package/dist/keymaps/emacs.d.ts +18 -0
  79. package/dist/keymaps/emacs.js +172 -0
  80. package/dist/keymaps/standard.d.ts +17 -0
  81. package/dist/keymaps/standard.js +175 -0
  82. package/dist/keymaps/types.d.ts +43 -0
  83. package/dist/keymaps/types.js +28 -0
  84. package/dist/keymaps/vim.d.ts +48 -0
  85. package/dist/keymaps/vim.js +291 -0
  86. package/dist/launch.d.ts +149 -0
  87. package/dist/launch.js +446 -0
  88. package/dist/sse.d.ts +109 -0
  89. package/dist/sse.js +401 -0
  90. package/dist/theme.d.ts +51 -0
  91. package/dist/theme.js +119 -0
  92. package/dist/ui/ambient.d.ts +81 -0
  93. package/dist/ui/ambient.js +301 -0
  94. package/dist/ui/app.d.ts +245 -0
  95. package/dist/ui/app.js +1325 -0
  96. package/dist/ui/composer.d.ts +66 -0
  97. package/dist/ui/composer.js +125 -0
  98. package/dist/ui/conversation-pane.d.ts +123 -0
  99. package/dist/ui/conversation-pane.js +463 -0
  100. package/dist/ui/help.d.ts +15 -0
  101. package/dist/ui/help.js +80 -0
  102. package/dist/ui/interactions.d.ts +90 -0
  103. package/dist/ui/interactions.js +330 -0
  104. package/dist/ui/picker.d.ts +26 -0
  105. package/dist/ui/picker.js +130 -0
  106. package/dist/ui/sidebar.d.ts +99 -0
  107. package/dist/ui/sidebar.js +269 -0
  108. package/dist/ui/status-bar.d.ts +77 -0
  109. package/dist/ui/status-bar.js +146 -0
  110. package/package.json +49 -5
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Terminal width for block rendering (UI-blocks U2 Task 1): the width
3
+ * classes (spec §12.1), the `s`/`m`/`l` token scale (spec §5.2), and the
4
+ * clamp/pad helpers every block renderer funnels through.
5
+ *
6
+ * The display-width measure is pi-tui's `visibleWidth` (grapheme-aware,
7
+ * wcwidth-style, ANSI-safe) — re-exported here so block code has ONE
8
+ * width import site and the Review-Focus-2 invariant ("every rendered
9
+ * line's display width ≤ the pane") is enforced in one place:
10
+ * {@link truncate}.
11
+ */
12
+ import type { Size } from "@sigrun/shared/blocks";
13
+ /** The container width classes (spec §12.1, TUI column). */
14
+ export type WidthClass = "compact" | "regular" | "wide";
15
+ /** The compact/regular/wide boundary: cells (compact < 80 ≤ regular < 140 ≤ wide). */
16
+ export declare function widthClassOf(cells: number): WidthClass;
17
+ /** Width tokens in cells (spec §5.2: the TUI maps s/m/l to 30/45/60). */
18
+ export declare const SIZE_CELLS: Readonly<Record<Size, number>>;
19
+ /**
20
+ * Clamp one rendered line to `width` cells with an ellipsis (Review
21
+ * Focus 2): ANSI-aware, wide-character-aware, never a mid-sequence cut.
22
+ * Empty lines pass through.
23
+ */
24
+ export declare function truncate(line: string, width: number): string;
25
+ /** Pad a line to `width` cells by its DISPLAY width (for column joins). */
26
+ export declare function pad(line: string, width: number): string;
27
+ /**
28
+ * Fit a run of source lines into `width` cells: short runs stay one
29
+ * line (truncated); longer word-wraps fold at spaces, each folded line
30
+ * truncated. Used for plain-text values (markdown wraps itself).
31
+ */
32
+ export declare function wrapPlain(text: string, width: number): string[];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Terminal width for block rendering (UI-blocks U2 Task 1): the width
3
+ * classes (spec §12.1), the `s`/`m`/`l` token scale (spec §5.2), and the
4
+ * clamp/pad helpers every block renderer funnels through.
5
+ *
6
+ * The display-width measure is pi-tui's `visibleWidth` (grapheme-aware,
7
+ * wcwidth-style, ANSI-safe) — re-exported here so block code has ONE
8
+ * width import site and the Review-Focus-2 invariant ("every rendered
9
+ * line's display width ≤ the pane") is enforced in one place:
10
+ * {@link truncate}.
11
+ */
12
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+ /** The compact/regular/wide boundary: cells (compact < 80 ≤ regular < 140 ≤ wide). */
14
+ export function widthClassOf(cells) {
15
+ if (cells < 80)
16
+ return "compact";
17
+ if (cells < 140)
18
+ return "regular";
19
+ return "wide";
20
+ }
21
+ /** Width tokens in cells (spec §5.2: the TUI maps s/m/l to 30/45/60). */
22
+ export const SIZE_CELLS = {
23
+ s: 30,
24
+ m: 45,
25
+ l: 60,
26
+ };
27
+ /**
28
+ * Clamp one rendered line to `width` cells with an ellipsis (Review
29
+ * Focus 2): ANSI-aware, wide-character-aware, never a mid-sequence cut.
30
+ * Empty lines pass through.
31
+ */
32
+ export function truncate(line, width) {
33
+ if (line === "")
34
+ return "";
35
+ return truncateToWidth(line, width, "…");
36
+ }
37
+ /** Pad a line to `width` cells by its DISPLAY width (for column joins). */
38
+ export function pad(line, width) {
39
+ const rest = width - visibleWidth(line);
40
+ return rest > 0 ? line + " ".repeat(rest) : line;
41
+ }
42
+ /**
43
+ * Fit a run of source lines into `width` cells: short runs stay one
44
+ * line (truncated); longer word-wraps fold at spaces, each folded line
45
+ * truncated. Used for plain-text values (markdown wraps itself).
46
+ */
47
+ export function wrapPlain(text, width) {
48
+ if (width <= 0)
49
+ return [];
50
+ const words = text.split(/\s+/).filter((w) => w !== "");
51
+ if (words.length === 0)
52
+ return [""];
53
+ const lines = [];
54
+ let current = "";
55
+ for (const word of words) {
56
+ if (current === "") {
57
+ current = word;
58
+ }
59
+ else if (visibleWidth(current) + 1 + visibleWidth(word) <= width) {
60
+ current += ` ${word}`;
61
+ }
62
+ else {
63
+ lines.push(current);
64
+ current = word;
65
+ }
66
+ }
67
+ if (current !== "")
68
+ lines.push(current);
69
+ return lines.map((line) => truncate(line, width));
70
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Terminal clipboard (Task C3, UI refresh): OSC 52 writes the system
3
+ * clipboard through the terminal emulator. Inside tmux the sequence must
4
+ * be DCS-wrapped with every ESC doubled, or tmux eats it.
5
+ *
6
+ * `copyToTerminal` writes the sequence to the terminal; the app flashes
7
+ * "Copied" in the status bar on top of this.
8
+ */
9
+ /** The environment slice the tmux detection reads. */
10
+ export interface ClipboardEnv {
11
+ readonly TMUX?: string;
12
+ }
13
+ /** The OSC 52 sequence for `text` (base64 UTF-8), tmux-wrapped when needed. */
14
+ export declare function osc52(text: string, env?: ClipboardEnv): string;
15
+ /** The terminal surface the copy needs (pi-tui's `Terminal.write`). */
16
+ export interface ClipboardTerminal {
17
+ write(data: string): void;
18
+ }
19
+ /** Write `text` to the system clipboard via the terminal. */
20
+ export declare function copyToTerminal(terminal: ClipboardTerminal, text: string, env?: ClipboardEnv): void;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Terminal clipboard (Task C3, UI refresh): OSC 52 writes the system
3
+ * clipboard through the terminal emulator. Inside tmux the sequence must
4
+ * be DCS-wrapped with every ESC doubled, or tmux eats it.
5
+ *
6
+ * `copyToTerminal` writes the sequence to the terminal; the app flashes
7
+ * "Copied" in the status bar on top of this.
8
+ */
9
+ /** The OSC 52 sequence for `text` (base64 UTF-8), tmux-wrapped when needed. */
10
+ export function osc52(text, env = process.env) {
11
+ const seq = `\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`;
12
+ if (env.TMUX === undefined || env.TMUX === "")
13
+ return seq;
14
+ return `\x1bPtmux;\x1b${seq.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`;
15
+ }
16
+ /** Write `text` to the system clipboard via the terminal. */
17
+ export function copyToTerminal(terminal, text, env = process.env) {
18
+ terminal.write(osc52(text, env));
19
+ }
@@ -0,0 +1,34 @@
1
+ export declare const VIM_NAMES: readonly string[];
2
+ /** True when the basename of the first whitespace-separated token is a vim binary. */
3
+ export declare function isVimFamily(command: string): boolean;
4
+ /**
5
+ * Resolve a vim-family editor: configured → VISUAL → EDITOR (each used only
6
+ * when vim-family), then nvim/vim/vi on PATH. Undefined when nothing qualifies.
7
+ */
8
+ export declare function resolveVimEditor(opts: {
9
+ configured?: string;
10
+ env: NodeJS.ProcessEnv;
11
+ which(name: string): string | undefined;
12
+ }): string | undefined;
13
+ /**
14
+ * Edit `text` in a vim-family editor via a temp file, suspending the TUI for
15
+ * the duration. The temp file is always removed; on failure the draft is
16
+ * untouched and stop/start remain balanced.
17
+ */
18
+ export declare function editInVim(opts: {
19
+ text: string;
20
+ editor: string;
21
+ tui: {
22
+ stop(): void;
23
+ start(): void;
24
+ requestRender(force?: boolean): void;
25
+ };
26
+ spawnSync?: typeof import("node:child_process").spawnSync;
27
+ tmpDir?: string;
28
+ }): {
29
+ ok: true;
30
+ text: string;
31
+ } | {
32
+ ok: false;
33
+ reason: string;
34
+ };
@@ -0,0 +1,70 @@
1
+ import { spawnSync as realSpawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, join } from "node:path";
6
+ export const VIM_NAMES = ["vim", "nvim", "vi"];
7
+ /** True when the basename of the first whitespace-separated token is a vim binary. */
8
+ export function isVimFamily(command) {
9
+ const first = command.trim().split(/\s+/)[0];
10
+ if (!first)
11
+ return false;
12
+ return VIM_NAMES.includes(basename(first));
13
+ }
14
+ /**
15
+ * Resolve a vim-family editor: configured → VISUAL → EDITOR (each used only
16
+ * when vim-family), then nvim/vim/vi on PATH. Undefined when nothing qualifies.
17
+ */
18
+ export function resolveVimEditor(opts) {
19
+ for (const candidate of [opts.configured, opts.env.VISUAL, opts.env.EDITOR]) {
20
+ if (candidate && isVimFamily(candidate))
21
+ return candidate;
22
+ }
23
+ for (const name of ["nvim", "vim", "vi"]) {
24
+ const found = opts.which(name);
25
+ if (found)
26
+ return found;
27
+ }
28
+ return undefined;
29
+ }
30
+ /**
31
+ * Edit `text` in a vim-family editor via a temp file, suspending the TUI for
32
+ * the duration. The temp file is always removed; on failure the draft is
33
+ * untouched and stop/start remain balanced.
34
+ */
35
+ export function editInVim(opts) {
36
+ const spawn = opts.spawnSync ?? realSpawnSync;
37
+ const file = join(opts.tmpDir ?? tmpdir(), `sigrun-prompt-${randomBytes(8).toString("hex")}.md`);
38
+ writeFileSync(file, opts.text, { encoding: "utf8", mode: 0o600 });
39
+ // The editor string splits on whitespace — quoted paths with spaces in
40
+ // the command or its arguments are NOT supported (a known limitation).
41
+ const [command, ...args] = opts.editor.trim().split(/\s+/);
42
+ try {
43
+ opts.tui.stop();
44
+ const result = spawn(command, [...args, file], { stdio: "inherit" });
45
+ if (result.error) {
46
+ return { ok: false, reason: `editor failed to launch: ${result.error.message}` };
47
+ }
48
+ if (result.status !== 0) {
49
+ return { ok: false, reason: `editor exited with status ${result.status}` };
50
+ }
51
+ let text;
52
+ try {
53
+ text = readFileSync(file, "utf8");
54
+ }
55
+ catch {
56
+ return { ok: false, reason: "edited file missing or unreadable" };
57
+ }
58
+ return { ok: true, text };
59
+ }
60
+ finally {
61
+ opts.tui.start();
62
+ opts.tui.requestRender(true);
63
+ try {
64
+ unlinkSync(file);
65
+ }
66
+ catch {
67
+ // already gone (editor deleted it) — nothing to clean up
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The emacs keymap (Task C5, UI refresh): modeless, chord-driven. `C-x`
3
+ * ("\x18") arms a 1 000 ms chord: `C-x b` conversation picker, `C-x o`
4
+ * focus cycle, `C-x C-c` quit, `C-x k` a consumed no-op (a mismatched
5
+ * follow-up is the emacs undefined-key beep — consumed, never typed).
6
+ * `M-x` opens the command picker; `C-s` / `C-r` search forward / backward
7
+ * (the app's search overlay turns them into next / previous while open);
8
+ * `C-g` cancels a live run, closes the search/picker (it passes through
9
+ * to the open overlay), or focuses the composer. `M-p` / `M-n` jump
10
+ * between prompts; `M-w` copy, `M-f` fork, `M-e` edit are outside-
11
+ * composer actions (in the composer `M-f` is the editor's word-forward —
12
+ * all three pass). `C-n` / `C-p` move outside the composer; `C-o`
13
+ * answers an interaction, `C-l` redraws, `C-h` opens the key reference.
14
+ * Every {@link RESERVED_KEYS} chord passes in every focus — ctrl+b is
15
+ * NEVER bound (no `C-x C-b` either).
16
+ */
17
+ import { type Keymap } from "./types.js";
18
+ export declare function createEmacsKeymap(): Keymap;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The emacs keymap (Task C5, UI refresh): modeless, chord-driven. `C-x`
3
+ * ("\x18") arms a 1 000 ms chord: `C-x b` conversation picker, `C-x o`
4
+ * focus cycle, `C-x C-c` quit, `C-x k` a consumed no-op (a mismatched
5
+ * follow-up is the emacs undefined-key beep — consumed, never typed).
6
+ * `M-x` opens the command picker; `C-s` / `C-r` search forward / backward
7
+ * (the app's search overlay turns them into next / previous while open);
8
+ * `C-g` cancels a live run, closes the search/picker (it passes through
9
+ * to the open overlay), or focuses the composer. `M-p` / `M-n` jump
10
+ * between prompts; `M-w` copy, `M-f` fork, `M-e` edit are outside-
11
+ * composer actions (in the composer `M-f` is the editor's word-forward —
12
+ * all three pass). `C-n` / `C-p` move outside the composer; `C-o`
13
+ * answers an interaction, `C-l` redraws, `C-h` opens the key reference.
14
+ * Every {@link RESERVED_KEYS} chord passes in every focus — ctrl+b is
15
+ * NEVER bound (no `C-x C-b` either).
16
+ */
17
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
18
+ import { RESERVED_KEYS } from "./types.js";
19
+ /** The C-x chord window. */
20
+ const CHORD_MS = 1_000;
21
+ const BINDINGS = [
22
+ { keys: "C-x b", action: "conversation picker" },
23
+ { keys: "C-x o", action: "cycle focus" },
24
+ { keys: "C-x C-c", action: "quit" },
25
+ { keys: "M-x", action: "command picker" },
26
+ { keys: "C-s / C-r", action: "search forward / backward" },
27
+ { keys: "C-g", action: "cancel run / close / focus composer" },
28
+ { keys: "M-p / M-n", action: "previous / next prompt" },
29
+ { keys: "M-w", action: "copy turn" },
30
+ { keys: "M-f", action: "fork here" },
31
+ { keys: "M-e", action: "edit here" },
32
+ { keys: "C-n / C-p", action: "move down / up" },
33
+ { keys: "enter", action: "open / fold" },
34
+ { keys: "C-o", action: "answer interaction" },
35
+ { keys: "C-l", action: "redraw" },
36
+ { keys: "C-h", action: "key reference" },
37
+ { keys: "M-m", action: "pick up (block surface)" },
38
+ { keys: "enter", action: "drop (block surface)" },
39
+ { keys: "C-g", action: "cancel move (block surface)" },
40
+ { keys: "space", action: "select (block surface)" },
41
+ { keys: ".", action: "action menu (block surface)" },
42
+ { keys: "x", action: "check (block surface)" },
43
+ { keys: "[ / ]", action: "previous / next range (calendar)" },
44
+ { keys: "t", action: "today (calendar)" },
45
+ ];
46
+ /** Match `data` against any reserved chord (they always pass). */
47
+ function isReserved(data) {
48
+ return RESERVED_KEYS.some((id) => matchesKey(data, id));
49
+ }
50
+ export function createEmacsKeymap() {
51
+ /** The armed C-x prefix timestamp (undefined = no chord pending). */
52
+ let chordAt;
53
+ /**
54
+ * Resolve the C-x chord against `data`. Returns the chord's result,
55
+ * "consume" when `data` ARMS the prefix, or undefined to process
56
+ * `data` fresh (no prefix pending, or the window expired).
57
+ */
58
+ const chord = (data) => {
59
+ const now = Date.now();
60
+ if (chordAt !== undefined) {
61
+ const armed = chordAt;
62
+ chordAt = undefined;
63
+ if (now - armed <= CHORD_MS) {
64
+ if (data === "b")
65
+ return { type: "picker", kind: "conversation" };
66
+ if (data === "o")
67
+ return { type: "focus-cycle", direction: 1 };
68
+ if (data === "\x03")
69
+ return { type: "quit" }; // C-x C-c
70
+ if (data === "k")
71
+ return "consume"; // C-x k: a no-op, consumed
72
+ return "consume"; // undefined chord: the emacs beep
73
+ }
74
+ // Expired: `data` is processed fresh below.
75
+ }
76
+ if (data === "\x18") {
77
+ chordAt = now;
78
+ return "consume";
79
+ }
80
+ return undefined;
81
+ };
82
+ /** Block-surface keys (spec §12.4; emacs picks up with M-m). C-g is
83
+ * the cancel key — it cancels a move on a surface BEFORE the global
84
+ * C-g (cancel-run / focus composer) below sees it. */
85
+ const surface = (data) => {
86
+ if (data === "\x1bm")
87
+ return { type: "blocks.pickUp" }; // M-m
88
+ if (matchesKey(data, Key.enter) || data === "\r")
89
+ return { type: "blocks.drop" };
90
+ if (data === "\x07")
91
+ return { type: "blocks.cancelMove" }; // C-g
92
+ if (data === " ")
93
+ return { type: "blocks.select" };
94
+ if (data === ".")
95
+ return { type: "blocks.actionMenu" };
96
+ if (data === "x")
97
+ return { type: "blocks.check" };
98
+ if (data === "[")
99
+ return { type: "blocks.rangePrev" };
100
+ if (data === "]")
101
+ return { type: "blocks.rangeNext" };
102
+ if (data === "t")
103
+ return { type: "blocks.today" };
104
+ return undefined;
105
+ };
106
+ return {
107
+ name: "emacs",
108
+ modeLabel: () => undefined,
109
+ handle(data, ctx) {
110
+ // The composer's readline chords + the tmux prefix: NEVER bound.
111
+ // A reserved pass-through disarms a pending C-x — otherwise
112
+ // `C-x C-b b` would fire the picker with the user's "b".
113
+ if (isReserved(data)) {
114
+ chordAt = undefined;
115
+ return "pass";
116
+ }
117
+ // A modal overlay owns the keys; C-g passes too — the picker and
118
+ // the search overlay close themselves on it.
119
+ if (ctx.overlayOpen)
120
+ return "pass";
121
+ const chorded = chord(data);
122
+ if (chorded !== undefined)
123
+ return chorded;
124
+ // A block surface owns the §12.4 keys (its C-g is cancel-move,
125
+ // which must win over the global C-g cancel-run).
126
+ if (ctx.focus === "surface") {
127
+ const surfaceResult = surface(data);
128
+ if (surfaceResult !== undefined)
129
+ return surfaceResult;
130
+ }
131
+ if (data === "\x1bx")
132
+ return { type: "picker", kind: "command" };
133
+ if (data === "\x13")
134
+ return { type: "search", direction: "forward" };
135
+ if (data === "\x12")
136
+ return { type: "search", direction: "backward" };
137
+ if (data === "\x07") {
138
+ return ctx.busy
139
+ ? { type: "cancel-run" }
140
+ : { type: "focus", target: "composer" };
141
+ }
142
+ if (data === "\x1bp")
143
+ return { type: "prompt-jump", delta: -1 };
144
+ if (data === "\x1bn")
145
+ return { type: "prompt-jump", delta: 1 };
146
+ if (data === "\x0f")
147
+ return { type: "answer-interaction" };
148
+ if (data === "\x0c")
149
+ return { type: "redraw" };
150
+ if (data === "\x08")
151
+ return { type: "help" };
152
+ if (ctx.focus === "composer")
153
+ return "pass";
154
+ // Outside the composer: the transcript/list actions.
155
+ if (data === "\x1bw")
156
+ return { type: "copy" };
157
+ if (data === "\x1bf")
158
+ return { type: "fork-here" };
159
+ if (data === "\x1be")
160
+ return { type: "edit-here" };
161
+ if (data === "\x0e")
162
+ return { type: "move", delta: 1 };
163
+ if (data === "\x10")
164
+ return { type: "move", delta: -1 };
165
+ if (matchesKey(data, Key.enter) || data === "\r")
166
+ return { type: "open" };
167
+ return "pass";
168
+ },
169
+ hints: () => ["C-x b chats", "M-x commands", "C-h keys"],
170
+ describe: () => BINDINGS,
171
+ };
172
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The standard keymap (Task C3, UI refresh): readline-friendly globals
3
+ * plus vi-flavoured navigation OUTSIDE the composer. The composer owns
4
+ * every printable key and every {@link RESERVED_KEYS} chord — nothing
5
+ * here shadows a line-editing key (ctrl+d/ctrl+u included), and ctrl+b
6
+ * is never bound anywhere.
7
+ *
8
+ * Global (every focus): ctrl+p conversation picker, ctrl+n new
9
+ * conversation, ctrl+t cycle thinking, ctrl+o answer interaction,
10
+ * ctrl+l redraw, ctrl+c quit, tab/shift+tab focus cycle, Esc (abort a
11
+ * live run, else focus the composer; passes through while the
12
+ * composer's autocomplete is open). Outside the composer: arrows move,
13
+ * PgUp/PgDn page, Home/End jump, Enter open, y copy, f fork, e edit,
14
+ * / search, n/N next match, ? help.
15
+ */
16
+ import { type Keymap } from "./types.js";
17
+ export declare function createStandardKeymap(): Keymap;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The standard keymap (Task C3, UI refresh): readline-friendly globals
3
+ * plus vi-flavoured navigation OUTSIDE the composer. The composer owns
4
+ * every printable key and every {@link RESERVED_KEYS} chord — nothing
5
+ * here shadows a line-editing key (ctrl+d/ctrl+u included), and ctrl+b
6
+ * is never bound anywhere.
7
+ *
8
+ * Global (every focus): ctrl+p conversation picker, ctrl+n new
9
+ * conversation, ctrl+t cycle thinking, ctrl+o answer interaction,
10
+ * ctrl+l redraw, ctrl+c quit, tab/shift+tab focus cycle, Esc (abort a
11
+ * live run, else focus the composer; passes through while the
12
+ * composer's autocomplete is open). Outside the composer: arrows move,
13
+ * PgUp/PgDn page, Home/End jump, Enter open, y copy, f fork, e edit,
14
+ * / search, n/N next match, ? help.
15
+ */
16
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
17
+ import { RESERVED_KEYS } from "./types.js";
18
+ /** The help table (also the source of the status-bar hints). */
19
+ const BINDINGS = [
20
+ { keys: "ctrl+p", action: "conversation picker" },
21
+ { keys: "ctrl+n", action: "new conversation" },
22
+ { keys: "ctrl+t", action: "cycle thinking" },
23
+ { keys: "ctrl+o", action: "answer interaction" },
24
+ { keys: "ctrl+l", action: "redraw" },
25
+ { keys: "ctrl+c", action: "quit" },
26
+ { keys: "tab / shift+tab", action: "cycle focus" },
27
+ { keys: "esc", action: "abort run / focus composer" },
28
+ { keys: "↑ ↓", action: "move (list/transcript)" },
29
+ { keys: "pgup pgdn", action: "page (list/transcript)" },
30
+ { keys: "home end", action: "top / bottom" },
31
+ { keys: "enter", action: "open / select" },
32
+ { keys: "y", action: "copy turn" },
33
+ { keys: "f", action: "fork here" },
34
+ { keys: "e", action: "edit here" },
35
+ { keys: "/", action: "search transcript" },
36
+ { keys: "n / N", action: "next / previous match" },
37
+ { keys: "?", action: "help" },
38
+ { keys: "m", action: "pick up (block surface)" },
39
+ { keys: "enter", action: "drop (block surface)" },
40
+ { keys: "esc", action: "cancel move (block surface)" },
41
+ { keys: "space", action: "select (block surface)" },
42
+ { keys: ".", action: "action menu (block surface)" },
43
+ { keys: "x", action: "check (block surface)" },
44
+ { keys: "[ / ]", action: "previous / next range (calendar)" },
45
+ { keys: "t", action: "today (calendar)" },
46
+ ];
47
+ /** Match `data` against any reserved chord (they always pass). */
48
+ function isReserved(data) {
49
+ return RESERVED_KEYS.some((id) => matchesKey(data, id));
50
+ }
51
+ export function createStandardKeymap() {
52
+ const global = (data, ctx) => {
53
+ if (matchesKey(data, Key.ctrl("p")))
54
+ return { type: "picker", kind: "conversation" };
55
+ if (matchesKey(data, Key.ctrl("n")))
56
+ return { type: "new-conversation" };
57
+ if (matchesKey(data, Key.ctrl("t")))
58
+ return { type: "cycle-thinking" };
59
+ if (matchesKey(data, Key.ctrl("o")))
60
+ return { type: "answer-interaction" };
61
+ if (matchesKey(data, Key.ctrl("l")))
62
+ return { type: "redraw" };
63
+ if (matchesKey(data, Key.ctrl("c")))
64
+ return { type: "quit" };
65
+ if (matchesKey(data, Key.tab) || matchesKey(data, "tab")) {
66
+ return ctx.autocompleteOpen ? "pass" : { type: "focus-cycle", direction: 1 };
67
+ }
68
+ if (matchesKey(data, "shift+tab"))
69
+ return { type: "focus-cycle", direction: -1 };
70
+ if (matchesKey(data, Key.escape)) {
71
+ if (ctx.autocompleteOpen)
72
+ return "pass";
73
+ if (ctx.busy)
74
+ return { type: "cancel-run" };
75
+ // A block surface owns Esc: it cancels a pick-up
76
+ // (blocks.cancelMove) — the surface branch below handles it.
77
+ if (ctx.focus !== "surface")
78
+ return { type: "focus", target: "composer" };
79
+ return undefined;
80
+ }
81
+ return undefined;
82
+ };
83
+ const outsideComposer = (data) => {
84
+ if (matchesKey(data, Key.up) || matchesKey(data, "up"))
85
+ return { type: "move", delta: -1 };
86
+ if (matchesKey(data, Key.down) || matchesKey(data, "down"))
87
+ return { type: "move", delta: 1 };
88
+ if (matchesKey(data, Key.pageUp) || matchesKey(data, "pageUp"))
89
+ return { type: "page", direction: -1, half: false };
90
+ if (matchesKey(data, Key.pageDown) || matchesKey(data, "pageDown"))
91
+ return { type: "page", direction: 1, half: false };
92
+ if (matchesKey(data, Key.home) || matchesKey(data, "home"))
93
+ return { type: "edge", to: "top" };
94
+ if (matchesKey(data, Key.end) || matchesKey(data, "end"))
95
+ return { type: "edge", to: "bottom" };
96
+ if (matchesKey(data, Key.enter))
97
+ return { type: "open" };
98
+ if (data === "y")
99
+ return { type: "copy" };
100
+ if (data === "f")
101
+ return { type: "fork-here" };
102
+ if (data === "e")
103
+ return { type: "edit-here" };
104
+ if (data === "/")
105
+ return { type: "search", direction: "forward" };
106
+ if (data === "n")
107
+ return { type: "search-next", reverse: false };
108
+ if (data === "N")
109
+ return { type: "search-next", reverse: true };
110
+ if (data === "?")
111
+ return { type: "help" };
112
+ return undefined;
113
+ };
114
+ const surface = (data) => {
115
+ // Block-surface keys (spec §12.4): routed ONLY here; the app
116
+ // forwards the action to the focused block component.
117
+ if (data === "m")
118
+ return { type: "blocks.pickUp" };
119
+ if (matchesKey(data, Key.enter))
120
+ return { type: "blocks.drop" };
121
+ if (data === " ")
122
+ return { type: "blocks.select" };
123
+ if (data === ".")
124
+ return { type: "blocks.actionMenu" };
125
+ if (data === "x")
126
+ return { type: "blocks.check" };
127
+ if (data === "[")
128
+ return { type: "blocks.rangePrev" };
129
+ if (data === "]")
130
+ return { type: "blocks.rangeNext" };
131
+ if (data === "t")
132
+ return { type: "blocks.today" };
133
+ return undefined;
134
+ };
135
+ return {
136
+ name: "standard",
137
+ modeLabel: () => undefined,
138
+ handle(data, ctx) {
139
+ // The composer's readline chords + the tmux prefix: NEVER bound.
140
+ if (isReserved(data))
141
+ return "pass";
142
+ // A modal overlay owns the keys; only quit still works.
143
+ if (ctx.overlayOpen) {
144
+ return matchesKey(data, Key.ctrl("c")) ? { type: "quit" } : "pass";
145
+ }
146
+ const globalResult = global(data, ctx);
147
+ if (globalResult !== undefined)
148
+ return globalResult;
149
+ if (ctx.focus === "surface") {
150
+ const surfaceResult = surface(data);
151
+ if (surfaceResult !== undefined)
152
+ return surfaceResult;
153
+ // Esc on a surface: cancel a pick-up (§12.4). The host treats
154
+ // a cancel with NO pick-up in flight as focus-composer (the
155
+ // emacs C-g ordering rule, mirrored here).
156
+ if (matchesKey(data, Key.escape))
157
+ return { type: "blocks.cancelMove" };
158
+ return "pass";
159
+ }
160
+ if (ctx.focus !== "composer") {
161
+ const navResult = outsideComposer(data);
162
+ if (navResult !== undefined)
163
+ return navResult;
164
+ }
165
+ return "pass";
166
+ },
167
+ hints(ctx) {
168
+ if (ctx.focus === "composer") {
169
+ return ["Tab focus", "^P chats", "? keys"];
170
+ }
171
+ return ["enter open", "y copy", "f fork", "e edit", "/ search", "? help"];
172
+ },
173
+ describe: () => BINDINGS,
174
+ };
175
+ }