@pugi/cli 0.1.0-beta.45 → 0.1.0-beta.46

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.
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.45');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.46');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
@@ -286,42 +286,92 @@ export function InputBox(props) {
286
286
  setSearch(initialSearchState(history));
287
287
  return;
288
288
  }
289
- // P0 fix (CEO 2026-05-29 dogfood): bare LF (`\n`) MUST submit the
290
- // brief, same as bare CR (`\r`). Ink's parseKeypress maps `\r` to
291
- // `key.return` and `\n` to `key.name === 'enter'` WITHOUT setting
292
- // `key.return`. Most real terminals deliver CR for Enter (ICRNL on
293
- // by default), so the `key.return` branch below catches them. But
294
- // when stdin is a PTY whose parent writes raw `\n` (Python's
295
- // `pty.fork` + `os.write(fd, b"\n")`, automation harnesses,
296
- // certain SSH multiplexers), the LF arrives as a printable char
297
- // and lands in the buffer as a literal newline. The result: the
298
- // input box shows a multi-line composer, the brief never
299
- // dispatches, status stays "idle". CEO PTY repro 2026-05-29.
289
+ // P0 fix (CEO 2026-05-29 dogfood, second iteration): bare LF (`\n`)
290
+ // MUST submit the brief, same as bare CR (`\r`). Ink's parseKeypress
291
+ // maps `\r` to `key.return` and `\n` to `key.name === 'enter'`
292
+ // WITHOUT setting `key.return`. Most real terminals deliver CR for
293
+ // Enter (ICRNL on by default), so the `key.return` branch below
294
+ // catches them. But when stdin is a PTY whose parent writes raw
295
+ // `\n` (Python's `pty.fork` + `os.write(fd, b"\n")`, automation
296
+ // harnesses, certain SSH multiplexers), the LF arrives as a
297
+ // printable char.
300
298
  //
301
- // Detection contract: the chunk is a SINGLE bare `\n` with no
302
- // modifiers and not inside a bracketed-paste burst. Multi-char
303
- // chunks containing `\n` (multi-line pastes, terminal-pasted
304
- // markdown blocks) preserve interior newlines so the operator
305
- // sees the full text in the buffer those are handled by the
306
- // printable-char branch below.
307
- if (input === '\n' && !key.meta && !key.ctrl && !key.shift) {
299
+ // PR #697 (beta.45) fixed the case where `input === '\n'` exactly.
300
+ // CEO PTY smoke 2026-05-29 surfaced the REAL shape: when the parent
301
+ // writes the brief AND the Enter as separate `os.write` calls (or
302
+ // even when it doesn't), Node's stdin buffer COALESCES them into
303
+ // ONE chunk before Ink delivers the `useInput` event. The repro
304
+ // confirmed via stderr instrumentation: typing `hi\n` arrives in
305
+ // input-box as `bytes=[68 69 0a] len=3 flags=-` a SINGLE 3-char
306
+ // chunk "hi\n" with no key flags. The PR #697 branch (`input ===
307
+ // '\n'`) does not match, so `hi\n` falls through to the printable-
308
+ // char branch and the literal newline lands in the buffer as
309
+ // `› hi\n █` (multi-line composer, brief never dispatches, status
310
+ // stays `idle` forever).
311
+ //
312
+ // Fix: detect a TRAILING `\n` in a printable chunk with no
313
+ // modifiers — type the prefix into the buffer, then submit. The
314
+ // discriminator that keeps multi-line paste working: the chunk
315
+ // must contain EXACTLY ONE `\n` (the trailing one) and no other
316
+ // newlines. Multi-line pastes have ≥2 `\n` characters (or arrive
317
+ // wrapped in bracketed-paste markers handled below), so they
318
+ // still preserve interior newlines via the printable-char branch.
319
+ //
320
+ // Detection contract:
321
+ // - `input` ends with `\n`
322
+ // - no Ctrl / Meta / Shift modifiers
323
+ // - exactly ONE `\n` in the chunk (the trailing one)
324
+ // - chunk is not bracketed-paste wrapped (markers stripped below)
325
+ //
326
+ // Edge cases covered by `test/input-box-lf-submit.spec.tsx`:
327
+ // - bare `\n` → submit empty (no-op on empty buf)
328
+ // - `hi\n` → splice `hi` + submit
329
+ // - `hi\nthere\n` (multi-line) → printable branch, preserves \n
330
+ // - `\r` (CR) → key.return branch unchanged
331
+ // - `hi\r\n` (CRLF) → key.return branch (CR wins first)
332
+ const endsWithLf = input.length > 0 && input.charCodeAt(input.length - 1) === 0x0a;
333
+ const newlineCount = (input.match(/\n/g) || []).length;
334
+ if (endsWithLf
335
+ && newlineCount === 1
336
+ && !key.meta
337
+ && !key.ctrl
338
+ && !key.shift) {
339
+ // Splice the prefix (everything before the trailing `\n`) into
340
+ // the buffer at the cursor, then run the canonical submit path.
341
+ // Refs (cursorRef / lineRef) hold the latest committed values so
342
+ // the splice runs against the operator's most recent edits even
343
+ // if a previous async paste / setState is still mid-flight.
344
+ const prefix = input.slice(0, -1);
345
+ let mergedLine = lineRef.current;
346
+ let mergedCursor = cursorRef.current;
347
+ if (prefix.length > 0) {
348
+ // Same sanitisation as the printable-char branch below — strip
349
+ // bracketed-paste markers so a stray escape sequence never
350
+ // lands in the submitted brief.
351
+ const stripped = prefix
352
+ .replace(/\x1b\[200~/g, '')
353
+ .replace(/\x1b\[201~/g, '')
354
+ .replace(/\[200~/g, '')
355
+ .replace(/\[201~/g, '');
356
+ if (stripped.length > 0) {
357
+ mergedLine =
358
+ mergedLine.slice(0, mergedCursor) + stripped + mergedLine.slice(mergedCursor);
359
+ mergedCursor = mergedCursor + stripped.length;
360
+ }
361
+ }
308
362
  // Synthesise the same payload-shape the `key.return` branch
309
363
  // below uses so palette completion + history dedup + onSubmit
310
- // dispatch all run identically. We re-enter the handler with a
311
- // synthetic key.return = true would require a refactor; the
312
- // cheap fix is to inline the submit logic here in a way that
313
- // mirrors the canonical branch exactly. Kept tight + obvious so
314
- // future edits to one path get mirrored to the other.
364
+ // dispatch all run identically.
315
365
  const paletteHere = !paletteSuppressed
316
- ? filterPalette(line)
366
+ ? filterPalette(mergedLine)
317
367
  : { rows: [], totalBeforeLimit: 0 };
318
368
  const paletteOpenHere = paletteHere.rows.length > 0;
319
369
  const paletteFocusedIndexHere = paletteHere.rows.length === 0
320
370
  ? 0
321
371
  : Math.min(paletteIndex, paletteHere.rows.length - 1);
322
- let payload = line;
372
+ let payload = mergedLine;
323
373
  if (paletteOpenHere) {
324
- const completed = completePalette(line, paletteHere.rows, paletteFocusedIndexHere);
374
+ const completed = completePalette(mergedLine, paletteHere.rows, paletteFocusedIndexHere);
325
375
  if (completed !== null)
326
376
  payload = completed;
327
377
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.45",
3
+ "version": "0.1.0-beta.46",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  "undici": "^8.3.0",
56
56
  "zod": "^3.23.0",
57
57
  "@pugi/personas": "0.1.2",
58
- "@pugi/sdk": "0.1.0-beta.45"
58
+ "@pugi/sdk": "0.1.0-beta.46"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",