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

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,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
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Leak L26 (2026-05-27) — Vim mode preference persistence.
3
+ *
4
+ * Single-tier storage (user-only, `~/.pugi/config.json`):
5
+ *
6
+ * `{ ..., "vimMode": true }`
7
+ *
8
+ * The leak research shows Claude Code persists `/vim` as a user-level
9
+ * preference (not per-workspace) — vim-style editing is a body-memory
10
+ * trait of the operator, not a per-repo concern. We match that.
11
+ *
12
+ * Reader tolerances mirror `core/output-style/state.ts`:
13
+ * - missing file → returns `false` (vim mode off, the default)
14
+ * - empty file → returns `false`
15
+ * - malformed JSON → returns `false` (REPL must not crash on a
16
+ * hand-edited config)
17
+ * - non-boolean → returns `false`
18
+ *
19
+ * Writer is a read-modify-write — neighbouring keys (`outputStyle`,
20
+ * `permissionMode`, …) survive a vim-mode flip.
21
+ *
22
+ * File mode 0o600 mirrors `core/output-style/state.ts` so the same
23
+ * config does not silently downgrade its permission bits depending on
24
+ * which helper last touched it.
25
+ */
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
27
+ import { homedir } from 'node:os';
28
+ import { dirname, resolve } from 'node:path';
29
+ /**
30
+ * Env override for `~/.pugi`. Re-uses the same `PUGI_HOME` knob as
31
+ * the output-style helpers so test sandboxes are uniform.
32
+ */
33
+ export const PUGI_HOME_ENV = 'PUGI_HOME';
34
+ /** Default when the file is missing / malformed / does not set the key. */
35
+ export const VIM_MODE_DEFAULT = false;
36
+ /**
37
+ * Read the persisted vim-mode preference. Pure read, never throws —
38
+ * every IO failure path degrades to `VIM_MODE_DEFAULT`.
39
+ */
40
+ export function resolveVimMode(io = {}) {
41
+ const config = readConfigFile(userConfigPath(io.env ?? process.env));
42
+ return typeof config.vimMode === 'boolean' ? config.vimMode : VIM_MODE_DEFAULT;
43
+ }
44
+ /**
45
+ * Write the vim-mode preference. Read-modify-write so neighbouring
46
+ * config keys survive (`outputStyle`, `permissionMode`, …).
47
+ */
48
+ export function setVimMode(value, io = {}) {
49
+ const path = userConfigPath(io.env ?? process.env);
50
+ const config = readConfigFile(path);
51
+ config.vimMode = value;
52
+ writeConfigFile(path, config);
53
+ }
54
+ /**
55
+ * Path resolver exported for the spec; production callers should use
56
+ * `resolveVimMode` / `setVimMode`.
57
+ */
58
+ export function userConfigPath(env = process.env) {
59
+ const home = env[PUGI_HOME_ENV] ?? resolve(homedir(), '.pugi');
60
+ return resolve(home, 'config.json');
61
+ }
62
+ function readConfigFile(path) {
63
+ if (!existsSync(path))
64
+ return {};
65
+ let raw;
66
+ try {
67
+ raw = readFileSync(path, 'utf8');
68
+ }
69
+ catch {
70
+ return {};
71
+ }
72
+ if (raw.trim().length === 0)
73
+ return {};
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(raw);
77
+ }
78
+ catch {
79
+ return {};
80
+ }
81
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
82
+ return {};
83
+ return parsed;
84
+ }
85
+ function writeConfigFile(path, config) {
86
+ mkdirSync(dirname(path), { recursive: true });
87
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, {
88
+ encoding: 'utf8',
89
+ mode: 0o600,
90
+ });
91
+ }
92
+ //# sourceMappingURL=state.js.map