@pugi/cli 0.1.0-beta.44 → 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.44');
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.
@@ -238,7 +238,9 @@ export function InputBox(props) {
238
238
  setCursor(draftBeforeSearch.length);
239
239
  return;
240
240
  }
241
- if (key.return) {
241
+ // Bare LF accepts the focused match, same as CR (`key.return`).
242
+ // See the post-search block below for the rationale.
243
+ if (key.return || (input === '\n' && !key.meta && !key.ctrl && !key.shift)) {
242
244
  const picked = currentBrief(search);
243
245
  setSearch(undefined);
244
246
  if (picked !== null) {
@@ -257,6 +259,11 @@ export function InputBox(props) {
257
259
  return;
258
260
  }
259
261
  if (input && !key.meta && !key.ctrl) {
262
+ // Drop a bare LF from the search query — the Enter-accept
263
+ // branch above already handled it; falling through here would
264
+ // splice a newline into the search string.
265
+ if (input === '\n')
266
+ return;
260
267
  const nextQuery = search.query + input;
261
268
  setSearch(applyQuery(search, nextQuery, history));
262
269
  return;
@@ -279,6 +286,118 @@ export function InputBox(props) {
279
286
  setSearch(initialSearchState(history));
280
287
  return;
281
288
  }
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.
298
+ //
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
+ }
362
+ // Synthesise the same payload-shape the `key.return` branch
363
+ // below uses so palette completion + history dedup + onSubmit
364
+ // dispatch all run identically.
365
+ const paletteHere = !paletteSuppressed
366
+ ? filterPalette(mergedLine)
367
+ : { rows: [], totalBeforeLimit: 0 };
368
+ const paletteOpenHere = paletteHere.rows.length > 0;
369
+ const paletteFocusedIndexHere = paletteHere.rows.length === 0
370
+ ? 0
371
+ : Math.min(paletteIndex, paletteHere.rows.length - 1);
372
+ let payload = mergedLine;
373
+ if (paletteOpenHere) {
374
+ const completed = completePalette(mergedLine, paletteHere.rows, paletteFocusedIndexHere);
375
+ if (completed !== null)
376
+ payload = completed;
377
+ }
378
+ const trimmed = payload.trim();
379
+ if (trimmed.length > 0) {
380
+ setHistory((prev) => {
381
+ if (prev[prev.length - 1] === trimmed)
382
+ return prev;
383
+ return [...prev, trimmed];
384
+ });
385
+ setHistoryIndex(-1);
386
+ if (props.workspaceSlug) {
387
+ appendHistory({
388
+ home: props.historyHome,
389
+ workspaceSlug: props.workspaceSlug,
390
+ brief: trimmed,
391
+ });
392
+ }
393
+ props.onSubmit(trimmed);
394
+ }
395
+ setLine('');
396
+ setCursor(0);
397
+ setPaletteSuppressed(false);
398
+ setPaletteIndex(0);
399
+ return;
400
+ }
282
401
  // Readline-style kill ring shortcuts. All four kills push the
283
402
  // removed slice onto the ring; Ctrl+Y yanks the most recent.
284
403
  if (key.ctrl && input === 'u') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.44",
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.44"
58
+ "@pugi/sdk": "0.1.0-beta.46"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",