@pugi/cli 0.1.0-beta.22 → 0.1.0-beta.23

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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Leak L30 (2026-05-27) — TUI color theme presets.
3
+ *
4
+ * Sibling to `core/output-style/presets.ts` but addressing a different
5
+ * layer of the operator surface:
6
+ *
7
+ * - `output-style` steers the engine's PROSE register (terse /
8
+ * explanatory / russian-formal / casual). It compiles into a rule
9
+ * block appended to the system prompt; the model adjusts voice.
10
+ *
11
+ * - `theme` steers the LOCAL TUI's color palette. It never reaches
12
+ * the engine; only the Ink components in `tui/*.tsx` read it. The
13
+ * operator can run any output-style under any theme — the two
14
+ * surfaces are orthogonal.
15
+ *
16
+ * Design contract:
17
+ *
18
+ * - The catalogue is a closed set of 4 entries — `default`, `dark`,
19
+ * `light`, `colorblind`. The slug union is intentionally tight so
20
+ * the operator can hold the full surface in working memory and so
21
+ * Ink consumers can switch on every slug without a fall-through.
22
+ *
23
+ * - Each preset defines 7 semantic color tokens (foreground, muted,
24
+ * accent, success, warning, error, background). Ink components
25
+ * reference these tokens via `useTheme()` instead of inlining
26
+ * literal hex codes. The brand accent `#3da9fc` is preserved in
27
+ * `default` so the existing header / splash chrome reads
28
+ * identically when no override is active.
29
+ *
30
+ * - `colorblind` is tuned for deuteranopia (red-green color
31
+ * blindness, ~5% of male population). Status colors are mapped to
32
+ * a blue-yellow axis (`success` cyan, `warning` yellow, `error`
33
+ * bright-magenta) so the OK/WARN/ERROR triplet remains
34
+ * distinguishable without red/green discrimination. The accent is
35
+ * also shifted to bright-yellow `#ffd166` so it does not collide
36
+ * with the success cue.
37
+ *
38
+ * - `light` uses darker foreground + lighter accent values so the
39
+ * palette reads on a white terminal background. Most operators
40
+ * run dark terminals, so `dark` is closer to the default;
41
+ * `light` exists specifically for screen-share / projector demos
42
+ * where the room is bright.
43
+ *
44
+ * - All color values are 6-digit hex strings prefixed with `#`. Ink's
45
+ * `Text color` prop accepts hex strings directly; we deliberately
46
+ * do NOT use named colors like `green` / `red` so the palette is
47
+ * fully under operator control. The Ink-named fallback for OK/
48
+ * WARN/ERROR exists separately in `doctor-table.tsx` legacy code
49
+ * and gets superseded once the theme context is wired.
50
+ *
51
+ * Test surface: `test/commands/theme-presets.spec.ts` exercises
52
+ * catalogue invariants (4 entries, unique slugs, every preset has all
53
+ * 7 tokens, hex format, colorblind palette avoids red-green for
54
+ * status), the `isThemeSlug` predicate, and the `compileSampleRow`
55
+ * helper used by the preview table.
56
+ */
57
+ /**
58
+ * The closed list of theme slugs in catalogue order. Mirror used by
59
+ * the CLI surface (`/theme` table, `pugi theme --list`) so the
60
+ * operator sees themes in a stable order regardless of iteration
61
+ * order of the keyed catalogue.
62
+ */
63
+ export const THEME_SLUGS = Object.freeze([
64
+ 'default',
65
+ 'dark',
66
+ 'light',
67
+ 'colorblind',
68
+ ]);
69
+ /**
70
+ * Default slug used when no workspace-/user-level preference is set.
71
+ * Exported so `state.ts` and the CLI handler share one constant.
72
+ */
73
+ export const DEFAULT_THEME = 'default';
74
+ /**
75
+ * Catalogue keyed by slug. Frozen so callers cannot mutate the
76
+ * shared rows; the CLI handler returns slugs, not preset references,
77
+ * to keep the boundary clean.
78
+ *
79
+ * Color choices:
80
+ *
81
+ * - `default` accent `#3da9fc` is the existing Pugi blue baked into
82
+ * `repl.tsx` header + `/help` palette. Preserved verbatim so the
83
+ * default theme reads identically to pre-L30 chrome.
84
+ *
85
+ * - `dark` saturates the brand palette for deep-black terminals
86
+ * (true-black iTerm, kitty, alacritty). Accent cyan `#22d3ee`
87
+ * pops against `#0a0a0a`; foreground is a near-white `#f5f5f5`
88
+ * that does not glare.
89
+ *
90
+ * - `light` inverts the contrast: foreground is `#1a1a1a` (near-
91
+ * black), background `#fafafa` (off-white), accent `#1e40af`
92
+ * (deep blue, readable on white). Status colors are darkened
93
+ * equivalents — `#15803d` green, `#a16207` amber, `#b91c1c`
94
+ * deep-red — so they retain saturation on bright backgrounds.
95
+ *
96
+ * - `colorblind` shifts the status axis from red-green to
97
+ * blue-yellow. `#0ea5e9` cyan replaces green, `#facc15` yellow
98
+ * stays warning, `#d946ef` magenta replaces red. The
99
+ * deuteranopia community can distinguish blue from yellow from
100
+ * magenta even when red/green collapse. Accent moves to
101
+ * `#ffd166` bright-yellow so it does not collide with success
102
+ * cyan; we trade brand consistency for accessibility here, which
103
+ * is the explicit purpose of the preset.
104
+ */
105
+ export const THEMES = Object.freeze({
106
+ default: Object.freeze({
107
+ slug: 'default',
108
+ title: 'Default',
109
+ gloss: 'Current Pugi palette — blue accent on dark terminal.',
110
+ colors: Object.freeze({
111
+ foreground: '#e5e7eb',
112
+ background: '#0f172a',
113
+ muted: '#94a3b8',
114
+ accent: '#3da9fc',
115
+ success: '#22c55e',
116
+ warning: '#eab308',
117
+ error: '#ef4444',
118
+ }),
119
+ }),
120
+ dark: Object.freeze({
121
+ slug: 'dark',
122
+ title: 'Dark',
123
+ gloss: 'Saturated palette for deep-black terminals (iTerm, alacritty, kitty).',
124
+ colors: Object.freeze({
125
+ foreground: '#f5f5f5',
126
+ background: '#0a0a0a',
127
+ muted: '#737373',
128
+ accent: '#22d3ee',
129
+ success: '#4ade80',
130
+ warning: '#fbbf24',
131
+ error: '#f87171',
132
+ }),
133
+ }),
134
+ light: Object.freeze({
135
+ slug: 'light',
136
+ title: 'Light',
137
+ gloss: 'Inverted palette for projector demos + white-background terminals.',
138
+ colors: Object.freeze({
139
+ foreground: '#1a1a1a',
140
+ background: '#fafafa',
141
+ muted: '#525252',
142
+ accent: '#1e40af',
143
+ success: '#15803d',
144
+ warning: '#a16207',
145
+ error: '#b91c1c',
146
+ }),
147
+ }),
148
+ colorblind: Object.freeze({
149
+ slug: 'colorblind',
150
+ title: 'Colorblind',
151
+ gloss: 'High-contrast deuteranopia-safe — status on blue-yellow-magenta axis.',
152
+ colors: Object.freeze({
153
+ foreground: '#f5f5f5',
154
+ background: '#0f172a',
155
+ muted: '#9ca3af',
156
+ accent: '#ffd166',
157
+ success: '#0ea5e9',
158
+ warning: '#facc15',
159
+ error: '#d946ef',
160
+ }),
161
+ }),
162
+ });
163
+ /**
164
+ * Type-narrowing predicate. Used by the slash-command parser + state
165
+ * loader so an unknown string from operator input or a stale config
166
+ * file degrades to the default theme instead of crashing.
167
+ */
168
+ export function isThemeSlug(value) {
169
+ return (typeof value === 'string' && THEME_SLUGS.includes(value));
170
+ }
171
+ /**
172
+ * Resolve the colors for a slug. Pure lookup; never throws. Callers
173
+ * pass the slug from `resolveTheme()` so unknown values cannot reach
174
+ * this helper, but defensive isThemeSlug + DEFAULT_THEME fallback is
175
+ * still applied so future refactors cannot regress to a runtime
176
+ * crash on a stale config.
177
+ */
178
+ export function getThemeColors(slug) {
179
+ const preset = THEMES[slug] ?? THEMES[DEFAULT_THEME];
180
+ return preset.colors;
181
+ }
182
+ /**
183
+ * Render the theme catalogue as a plain-text table for the `/theme`
184
+ * + `pugi theme` surfaces. Marks the active slug with `*` so the
185
+ * operator can see at a glance which theme is in effect.
186
+ *
187
+ * Pure renderer (no fs, no env). Identical text is emitted from both
188
+ * the slash dispatcher and the top-level CLI command so operators
189
+ * trained on one surface read the same table on the other.
190
+ *
191
+ * The plain-text variant skips the color-sample preview row — Ink's
192
+ * `<ThemeTable>` in `tui/theme-table.tsx` renders the sample row
193
+ * inline using `Text color={preset.colors.accent}` so the operator
194
+ * can preview each palette before switching.
195
+ */
196
+ export function renderThemeTable(active) {
197
+ const slugWidth = Math.max('NAME'.length, ...THEME_SLUGS.map((slug) => slug.length));
198
+ const header = `${'NAME'.padEnd(slugWidth)} GLOSS`;
199
+ const rows = THEME_SLUGS.map((slug) => {
200
+ const preset = THEMES[slug];
201
+ const marker = slug === active ? '*' : ' ';
202
+ return `${marker} ${slug.padEnd(slugWidth)} ${preset.gloss}`;
203
+ });
204
+ return [header, ...rows].join('\n');
205
+ }
206
+ /**
207
+ * Build the per-row sample text used by `<ThemeTable>`. Pure helper
208
+ * so the spec can assert the canonical sample copy ("Aa 123 OK WARN
209
+ * ERROR") without mounting Ink. The TUI component colors each
210
+ * fragment with the matching token (`foreground`, `accent`, `success`,
211
+ * `warning`, `error`) — this helper only returns the source strings
212
+ * the renderer splices in.
213
+ */
214
+ export function compileSampleRow(slug) {
215
+ // The strings are slug-independent today; the helper exists so
216
+ // future presets can ship per-theme sample copy (e.g. localised
217
+ // OK/ERROR labels for a future `russian-tui` preset) without
218
+ // touching the consumer Ink component.
219
+ void slug;
220
+ return Object.freeze({
221
+ foreground: 'Aa',
222
+ accent: '123',
223
+ success: 'OK',
224
+ warning: 'WARN',
225
+ error: 'ERROR',
226
+ });
227
+ }
228
+ //# sourceMappingURL=presets.js.map
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Leak L30 (2026-05-27) — Theme state persistence.
3
+ *
4
+ * Mirror of `core/output-style/state.ts` — same two-tier ladder
5
+ * (workspace > user > default), same `~/.pugi/config.json` envelope,
6
+ * same malformed-config tolerance. The two modules write to disjoint
7
+ * keys (`outputStyle` vs `theme`) of the same JSON file so neighbour
8
+ * settings (`permissionMode`, `privacy`, `model`, `outputStyle`)
9
+ * survive a theme flip.
10
+ *
11
+ * Two-tier storage:
12
+ *
13
+ * 1. **Workspace** — `<workspaceRoot>/.pugi/config.json`. Set by
14
+ * `/theme <name>` or `pugi theme <name>` without `--persist`.
15
+ * Overrides the user default for the current workspace only.
16
+ *
17
+ * 2. **User default** — `~/.pugi/config.json` (PUGI_HOME-aware).
18
+ * Set by `pugi theme <name> --persist` or `/theme <name>
19
+ * --persist`. Applies to every workspace that has no
20
+ * workspace-level override.
21
+ *
22
+ * Precedence (highest → lowest):
23
+ *
24
+ * workspace value > user value > DEFAULT_THEME ('default')
25
+ *
26
+ * The reader tolerates:
27
+ * - missing file (returns the default slug),
28
+ * - empty file (returns the default slug),
29
+ * - malformed JSON (returns the default slug — DO NOT crash REPL
30
+ * boot because of a hand-edited config),
31
+ * - unknown slug (returns the default slug + emits no error; the
32
+ * operator can `/theme` to see the table and re-set).
33
+ *
34
+ * The writer is a read-modify-write to preserve neighbouring keys
35
+ * (`outputStyle`, `permissionMode`, etc.) — overwriting the whole
36
+ * file would clobber the other tier's settings AND any sibling
37
+ * module's slot in the same envelope.
38
+ *
39
+ * Test surface: `test/commands/theme-state.spec.ts` exercises
40
+ * precedence, malformed-config tolerance, persistence across reads,
41
+ * the `--persist` (user-default) path, reset semantics, and the
42
+ * coexistence contract with `outputStyle` in the same file.
43
+ */
44
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
45
+ import { homedir } from 'node:os';
46
+ import { dirname, resolve } from 'node:path';
47
+ import { DEFAULT_THEME, isThemeSlug } from './presets.js';
48
+ /**
49
+ * Env override for `~/.pugi` so the spec can sandbox both tiers
50
+ * without touching the developer's real config. Re-exported under a
51
+ * theme-specific alias so consumers in this module do not need to
52
+ * import the output-style constant; the underlying env key is shared
53
+ * (`PUGI_HOME`) because the two modules write to the same config
54
+ * envelope.
55
+ */
56
+ export const PUGI_HOME_ENV = 'PUGI_HOME';
57
+ /**
58
+ * Resolve the active theme for the workspace, applying the
59
+ * precedence ladder (workspace > user > default).
60
+ *
61
+ * Pure read. Never writes, never throws — every IO failure degrades
62
+ * to the default slug. The function returns the source label too so
63
+ * the CLI surface can show the operator where the value came from.
64
+ */
65
+ export function resolveTheme(io) {
66
+ const workspaceSlug = readSlugFromFile(workspaceConfigPath(io.workspaceRoot));
67
+ if (workspaceSlug)
68
+ return { slug: workspaceSlug, source: 'workspace' };
69
+ const userSlug = readSlugFromFile(userConfigPath(io.env ?? process.env));
70
+ if (userSlug)
71
+ return { slug: userSlug, source: 'user' };
72
+ return { slug: DEFAULT_THEME, source: 'default' };
73
+ }
74
+ /**
75
+ * Write `slug` to the workspace tier. Creates `<workspaceRoot>/.pugi/`
76
+ * if missing. Preserves neighbouring config keys via read-modify-write.
77
+ */
78
+ export function setWorkspaceTheme(slug, io) {
79
+ writeSlugToFile(workspaceConfigPath(io.workspaceRoot), slug);
80
+ }
81
+ /**
82
+ * Write `slug` to the user tier (`~/.pugi/config.json`).
83
+ *
84
+ * Mirrors the workspace writer's read-modify-write so the user's
85
+ * `outputStyle` / `permissionMode` / `privacy` / `model` keys survive
86
+ * a theme flip.
87
+ */
88
+ export function setUserTheme(slug, io) {
89
+ writeSlugToFile(userConfigPath(io.env ?? process.env), slug);
90
+ }
91
+ /**
92
+ * Clear the workspace tier's `theme` key. The user tier (and
93
+ * therefore the eventual resolved theme) is left untouched.
94
+ *
95
+ * Used by `/theme --reset` so the operator can revert a workspace
96
+ * override without nuking the rest of their workspace config (or the
97
+ * user default).
98
+ */
99
+ export function clearWorkspaceTheme(io) {
100
+ clearSlugInFile(workspaceConfigPath(io.workspaceRoot));
101
+ }
102
+ /**
103
+ * Clear the user tier's `theme` key. Lower-blast-radius reset for
104
+ * operators who want every workspace to fall back to `default` unless
105
+ * an explicit workspace value is set.
106
+ */
107
+ export function clearUserTheme(io) {
108
+ clearSlugInFile(userConfigPath(io.env ?? process.env));
109
+ }
110
+ /**
111
+ * Workspace config path. Exported for the spec; production callers
112
+ * should use the `setWorkspace…` / `resolveTheme` helpers.
113
+ */
114
+ export function workspaceConfigPath(workspaceRoot) {
115
+ return resolve(workspaceRoot, '.pugi', 'config.json');
116
+ }
117
+ /**
118
+ * User config path resolved against `PUGI_HOME` (or `~/.pugi`).
119
+ * Exported for the spec.
120
+ */
121
+ export function userConfigPath(env = process.env) {
122
+ const home = env[PUGI_HOME_ENV] ?? resolve(homedir(), '.pugi');
123
+ return resolve(home, 'config.json');
124
+ }
125
+ /**
126
+ * Read + parse a config file. Returns an empty object on any IO or
127
+ * parse error. Caller-provided JSON must be a plain object; arrays /
128
+ * scalars / null are treated as "no config" so a hand-edited file
129
+ * never crashes the REPL.
130
+ */
131
+ function readConfigFile(path) {
132
+ if (!existsSync(path))
133
+ return {};
134
+ let raw;
135
+ try {
136
+ raw = readFileSync(path, 'utf8');
137
+ }
138
+ catch {
139
+ return {};
140
+ }
141
+ if (raw.trim().length === 0)
142
+ return {};
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(raw);
146
+ }
147
+ catch {
148
+ return {};
149
+ }
150
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
151
+ return {};
152
+ return parsed;
153
+ }
154
+ function writeConfigFile(path, config) {
155
+ mkdirSync(dirname(path), { recursive: true });
156
+ // 0o600 mirrors `core/output-style/state.ts` + `runtime/commands/config.ts` —
157
+ // the config file may hold preferredEndpoint URLs etc. that should
158
+ // not be world-readable.
159
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, {
160
+ encoding: 'utf8',
161
+ mode: 0o600,
162
+ });
163
+ }
164
+ function readSlugFromFile(path) {
165
+ const config = readConfigFile(path);
166
+ const candidate = config.theme;
167
+ return isThemeSlug(candidate) ? candidate : null;
168
+ }
169
+ function writeSlugToFile(path, slug) {
170
+ const config = readConfigFile(path);
171
+ config.theme = slug;
172
+ writeConfigFile(path, config);
173
+ }
174
+ function clearSlugInFile(path) {
175
+ const config = readConfigFile(path);
176
+ if (!('theme' in config))
177
+ return;
178
+ delete config.theme;
179
+ writeConfigFile(path, config);
180
+ }
181
+ //# sourceMappingURL=state.js.map
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Leak L26 (2026-05-27) — Vim-style modal editing keymap.
3
+ *
4
+ * Pure, data-only motion + edit primitives. The TUI layer
5
+ * (`tui/vim-input.tsx`) maps Ink key events onto these helpers so the
6
+ * keymap stays trivially testable and the React component stays free
7
+ * of motion arithmetic.
8
+ *
9
+ * Scope (matches the leak research surface for Claude Code `/vim`):
10
+ *
11
+ * Normal-mode motions
12
+ * h / j / k / l → left / down / up / right (j/k are
13
+ * no-ops on a single-line buffer; we keep
14
+ * the kind in the discriminator so the
15
+ * multi-line buffer follow-up can extend
16
+ * without churning the call sites).
17
+ * 0 / $ → line start / line end
18
+ * w / b → word forward / word backward
19
+ *
20
+ * Normal-mode edits
21
+ * x → delete the char under the cursor
22
+ * dd → delete the entire line (clears the buffer
23
+ * for the single-line REPL prompt)
24
+ * i / a → enter insert mode (`a` advances cursor by
25
+ * one column first, capped at line length)
26
+ *
27
+ * Ex-line commands (typed after `:` in normal mode)
28
+ * :w → submit current buffer (same as Enter in
29
+ * insert mode)
30
+ * :q → clear buffer + return to prompt (no
31
+ * submission)
32
+ *
33
+ * Mode transitions
34
+ * Esc → return to normal mode
35
+ * i / a in normal → insert mode
36
+ *
37
+ * The motion helpers return both the new cursor position AND a
38
+ * structured discriminator so the TUI layer can react (e.g. animate
39
+ * the caret, log an undo entry, etc.) without re-parsing the key.
40
+ *
41
+ * The keymap is intentionally NOT a full vim implementation. We model
42
+ * just the subset that maps cleanly onto a single-line input buffer +
43
+ * the four REPL verbs (submit / cancel / accept-insert / leave-insert).
44
+ * Counts (`3dd`, `5j`), registers, marks, search (`/pattern`), visual
45
+ * mode, undo/redo, and macros are explicitly out of scope and left
46
+ * for a follow-up sprint once the single-line surface is shipped.
47
+ */
48
+ export const PENDING_NONE = { kind: 'none' };
49
+ /**
50
+ * Whitespace classifier used by `w` / `b`. Matches vim's default
51
+ * "word" classification loosely — treat any non-whitespace, non-
52
+ * punctuation byte as a word character; collapse runs.
53
+ *
54
+ * We DELIBERATELY do not split on punctuation the way vim's `w`/`b`
55
+ * do (`hello-world` is two words in vim but one here). The REPL
56
+ * brief is prose-heavy and operators expect `w` to skip whole tokens
57
+ * — splitting on punctuation surprised every internal tester.
58
+ */
59
+ function isWordChar(ch) {
60
+ return /[^\s]/.test(ch);
61
+ }
62
+ /**
63
+ * Move cursor forward by one word: skip the current run of word
64
+ * chars, then skip the run of whitespace, land on the first char of
65
+ * the next word. Clamps at `line.length` so end-of-line is safe.
66
+ */
67
+ export function wordForward(line, cursor) {
68
+ if (cursor >= line.length)
69
+ return line.length;
70
+ let i = cursor;
71
+ // Skip current word chars (if we're on one).
72
+ while (i < line.length && isWordChar(line[i] ?? ''))
73
+ i++;
74
+ // Skip whitespace gap.
75
+ while (i < line.length && !isWordChar(line[i] ?? ''))
76
+ i++;
77
+ return i;
78
+ }
79
+ /**
80
+ * Move cursor backward by one word: step back over whitespace, then
81
+ * step back over the previous word. Clamps at 0.
82
+ */
83
+ export function wordBackward(line, cursor) {
84
+ if (cursor <= 0)
85
+ return 0;
86
+ let i = cursor - 1;
87
+ // Skip whitespace behind cursor.
88
+ while (i > 0 && !isWordChar(line[i] ?? ''))
89
+ i--;
90
+ // Skip the word until we hit whitespace or start.
91
+ while (i > 0 && isWordChar(line[i - 1] ?? ''))
92
+ i--;
93
+ return i;
94
+ }
95
+ /**
96
+ * Single-character delete under the cursor (`x`). When the cursor sits
97
+ * past end-of-line (legal in insert mode) we no-op.
98
+ *
99
+ * The cursor stays at the same offset after the splice unless the
100
+ * deleted char was the trailing one — in which case we step back so
101
+ * the caret does not sit past EOL.
102
+ */
103
+ function deleteCharUnder(line, cursor) {
104
+ if (cursor < 0 || cursor >= line.length)
105
+ return { line, cursor };
106
+ const next = line.slice(0, cursor) + line.slice(cursor + 1);
107
+ const nextCursor = cursor >= next.length ? Math.max(0, next.length - (next.length === 0 ? 0 : 1)) : cursor;
108
+ // Subtle: when the deletion empties the buffer we want cursor=0,
109
+ // NOT length-1=−1. The Math.max above guards it but spell it out.
110
+ return { line: next, cursor: next.length === 0 ? 0 : nextCursor };
111
+ }
112
+ /**
113
+ * Apply a normal-mode keystroke and return the new buffer + cursor +
114
+ * mode + pending state. Pure — no side effects. The TUI is the only
115
+ * caller; tests exercise this directly.
116
+ */
117
+ export function handleNormalKey(input) {
118
+ const { line, cursor, pending, ch } = input;
119
+ // Esc resets any in-flight multi-key sequence and re-asserts normal
120
+ // mode (we stay in normal — the caller invoked us BECAUSE we are in
121
+ // normal, but a stale `pending=d` should not survive an Esc).
122
+ if (input.escape) {
123
+ return {
124
+ result: { kind: 'noop', cursor, line, mode: 'normal' },
125
+ pending: PENDING_NONE,
126
+ };
127
+ }
128
+ // ─── Ex-line composition ───────────────────────────────────────
129
+ // Once `:` has been pressed we accumulate keystrokes into the
130
+ // pending buffer until Enter (commit) or Esc (cancel, handled
131
+ // above). Backspace shortens the buffer; any other char extends.
132
+ if (pending.kind === 'ex') {
133
+ if (input.enter) {
134
+ const cmd = pending.buffer;
135
+ if (cmd === 'w') {
136
+ return {
137
+ result: { kind: 'submit', cursor, line, mode: 'normal', payload: line },
138
+ pending: PENDING_NONE,
139
+ };
140
+ }
141
+ if (cmd === 'q') {
142
+ return {
143
+ result: { kind: 'cancel', cursor: 0, line: '', mode: 'normal' },
144
+ pending: PENDING_NONE,
145
+ };
146
+ }
147
+ // Unknown ex command — drop silently. The TUI may surface a
148
+ // dim hint but the keymap stays narrow (only `:w` / `:q` are
149
+ // contractual today).
150
+ return {
151
+ result: { kind: 'noop', cursor, line, mode: 'normal' },
152
+ pending: PENDING_NONE,
153
+ };
154
+ }
155
+ if (input.backspace) {
156
+ const nextBuf = pending.buffer.slice(0, -1);
157
+ if (nextBuf.length === 0) {
158
+ // Operator backspaced past `:` — exit ex composition.
159
+ return {
160
+ result: { kind: 'noop', cursor, line, mode: 'normal' },
161
+ pending: PENDING_NONE,
162
+ };
163
+ }
164
+ return {
165
+ result: { kind: 'noop', cursor, line, mode: 'normal' },
166
+ pending: { kind: 'ex', buffer: nextBuf },
167
+ };
168
+ }
169
+ if (ch.length > 0) {
170
+ return {
171
+ result: { kind: 'noop', cursor, line, mode: 'normal' },
172
+ pending: { kind: 'ex', buffer: pending.buffer + ch },
173
+ };
174
+ }
175
+ return {
176
+ result: { kind: 'noop', cursor, line, mode: 'normal' },
177
+ pending,
178
+ };
179
+ }
180
+ // ─── Single-shot bindings ──────────────────────────────────────
181
+ switch (ch) {
182
+ case 'i':
183
+ return {
184
+ result: { kind: 'mode', cursor, line, mode: 'insert' },
185
+ pending: PENDING_NONE,
186
+ };
187
+ case 'a': {
188
+ // `a` = append: advance cursor by one (capped) then enter
189
+ // insert. Matches vim semantics for the single-line case.
190
+ const next = Math.min(line.length, cursor + 1);
191
+ return {
192
+ result: { kind: 'mode', cursor: next, line, mode: 'insert' },
193
+ pending: PENDING_NONE,
194
+ };
195
+ }
196
+ case 'h':
197
+ return {
198
+ result: { kind: 'move', cursor: Math.max(0, cursor - 1), line, mode: 'normal' },
199
+ pending: PENDING_NONE,
200
+ };
201
+ case 'l':
202
+ return {
203
+ result: { kind: 'move', cursor: Math.min(line.length, cursor + 1), line, mode: 'normal' },
204
+ pending: PENDING_NONE,
205
+ };
206
+ case 'j':
207
+ case 'k':
208
+ // Single-line buffer — j/k are inert today. Returning a `move`
209
+ // keeps the discriminator consistent; the multi-line follow-up
210
+ // will compute a real new offset here.
211
+ return {
212
+ result: { kind: 'move', cursor, line, mode: 'normal' },
213
+ pending: PENDING_NONE,
214
+ };
215
+ case '0':
216
+ return {
217
+ result: { kind: 'move', cursor: 0, line, mode: 'normal' },
218
+ pending: PENDING_NONE,
219
+ };
220
+ case '$':
221
+ return {
222
+ result: { kind: 'move', cursor: line.length, line, mode: 'normal' },
223
+ pending: PENDING_NONE,
224
+ };
225
+ case 'w':
226
+ return {
227
+ result: { kind: 'move', cursor: wordForward(line, cursor), line, mode: 'normal' },
228
+ pending: PENDING_NONE,
229
+ };
230
+ case 'b':
231
+ return {
232
+ result: { kind: 'move', cursor: wordBackward(line, cursor), line, mode: 'normal' },
233
+ pending: PENDING_NONE,
234
+ };
235
+ case 'x': {
236
+ const { line: nextLine, cursor: nextCursor } = deleteCharUnder(line, cursor);
237
+ return {
238
+ result: { kind: 'edit', cursor: nextCursor, line: nextLine, mode: 'normal' },
239
+ pending: PENDING_NONE,
240
+ };
241
+ }
242
+ case 'd': {
243
+ // First `d` arms the dd sequence; second `d` commits the line
244
+ // delete. Any other key intervenes the sequence collapses
245
+ // (handled below in the fall-through path).
246
+ if (pending.kind === 'd') {
247
+ return {
248
+ result: { kind: 'edit', cursor: 0, line: '', mode: 'normal' },
249
+ pending: PENDING_NONE,
250
+ };
251
+ }
252
+ return {
253
+ result: { kind: 'noop', cursor, line, mode: 'normal' },
254
+ pending: { kind: 'd' },
255
+ };
256
+ }
257
+ case ':':
258
+ return {
259
+ result: { kind: 'noop', cursor, line, mode: 'normal' },
260
+ pending: { kind: 'ex', buffer: '' },
261
+ };
262
+ default:
263
+ // Unknown binding in normal mode — drop the keystroke + reset
264
+ // any pending sequence so a stale `d` does not silently arm a
265
+ // future delete after the operator wandered through unbound
266
+ // keys.
267
+ return {
268
+ result: { kind: 'noop', cursor, line, mode: 'normal' },
269
+ pending: PENDING_NONE,
270
+ };
271
+ }
272
+ }
273
+ /**
274
+ * Tiny helper exposed for the TUI so the banner / status line can
275
+ * render the active pending state without re-implementing the
276
+ * discriminator switch.
277
+ */
278
+ export function describePending(pending) {
279
+ switch (pending.kind) {
280
+ case 'none':
281
+ return '';
282
+ case 'd':
283
+ return 'd';
284
+ case 'ex':
285
+ return `:${pending.buffer}`;
286
+ }
287
+ }
288
+ //# sourceMappingURL=keymap.js.map