@pipod/cli 0.1.19 → 0.1.21

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,64 @@
1
+ // src/client/runtime/key-repeat-guard.ts
2
+ import {
3
+ decodeKittyPrintable,
4
+ isKeyRelease,
5
+ isKeyRepeat,
6
+ isKittyProtocolActive
7
+ } from "@earendil-works/pi-tui";
8
+ var PASTE_START = "\x1B[200~";
9
+ var LONE_ESCAPE = "\x1B";
10
+ var CONTROL_DELETE = 127;
11
+ var KITTY_KEY_EVENT = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/;
12
+ var STALE_DOWN_MS = 1500;
13
+ var MAX_DOWN_KEYS = 16;
14
+ function textPressCodepoint(data) {
15
+ if (data.length === 0 || data.includes(LONE_ESCAPE)) return void 0;
16
+ const codepoints = [...data];
17
+ if (codepoints.length !== 1) return void 0;
18
+ const codepoint = codepoints[0].codePointAt(0);
19
+ if (codepoint < 32 || codepoint === CONTROL_DELETE) return void 0;
20
+ return codepoint;
21
+ }
22
+ function releaseCodepoints(data) {
23
+ const match = KITTY_KEY_EVENT.exec(data);
24
+ if (!match || match[5] !== "3") return [];
25
+ return [match[1], match[2]].map((group) => group === void 0 || group === "" ? Number.NaN : Number(group)).filter((codepoint) => Number.isFinite(codepoint) && codepoint >= 32);
26
+ }
27
+ function repeatGuardInput(state, data, now) {
28
+ if (data.includes(PASTE_START)) return void 0;
29
+ if (isKeyRelease(data)) {
30
+ state.releasesSeen = true;
31
+ for (const codepoint2 of releaseCodepoints(data)) state.down.delete(codepoint2);
32
+ return void 0;
33
+ }
34
+ if (isKeyRepeat(data)) {
35
+ return decodeKittyPrintable(data) !== void 0 ? { consume: true } : void 0;
36
+ }
37
+ const codepoint = textPressCodepoint(data);
38
+ if (codepoint === void 0) return void 0;
39
+ const seenAt = state.down.get(codepoint);
40
+ if (seenAt !== void 0 && now - seenAt <= STALE_DOWN_MS) {
41
+ state.down.set(codepoint, now);
42
+ const releasesReported = state.releasesSeen || isKittyProtocolActive();
43
+ return releasesReported ? { consume: true } : void 0;
44
+ }
45
+ state.down.set(codepoint, now);
46
+ if (state.down.size > MAX_DOWN_KEYS) {
47
+ for (const [key, at] of state.down) {
48
+ if (now - at > STALE_DOWN_MS) state.down.delete(key);
49
+ }
50
+ }
51
+ return void 0;
52
+ }
53
+ function wireKeyRepeatGuard(ui) {
54
+ const subscribe = ui?.onTerminalInput;
55
+ if (!subscribe) return () => {
56
+ };
57
+ const state = { down: /* @__PURE__ */ new Map(), releasesSeen: false };
58
+ return subscribe((data) => repeatGuardInput(state, data, Date.now()));
59
+ }
60
+ export {
61
+ repeatGuardInput,
62
+ wireKeyRepeatGuard
63
+ };
64
+ //# sourceMappingURL=key-repeat-guard-7OVKW5KD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client/runtime/key-repeat-guard.ts"],"sourcesContent":["/**\n * src/client/runtime/key-repeat-guard.ts — one physical key press, one character.\n *\n * Holding a key makes the terminal repeat, and those repeats arrive as ordinary presses.\n * Ghostty's encoder sends every plain-text key — press *and* repeat — as bare UTF-8 and only\n * marks the release (src/input/key_encode.zig: releases are CSI-u, text is written as-is), so\n * `isKeyRepeat` never fires while typing and nothing downstream can tell a repeat from a\n * deliberate second press. In a laggy session that becomes \"one press, several characters\":\n * the prompt echoes behind the keystrokes, the key stays down past the compositor's repeat\n * delay (Hyprland: 250 ms, then 40/s), and every repeat inserts.\n *\n * The release is the signal that does survive. pi-tui asks for kitty event types, so a key\n * that is still down has not sent its release yet; a text press for a key with no release\n * since is a repeat, and a repeat must not insert. Navigation and deletion keep repeating —\n * this only covers characters that would be inserted, which is the half of key repeat a\n * prompt never needs and a laggy session turns into gibberish.\n *\n * Inert when the terminal reports no releases (legacy encoding): there is nothing to tell a\n * repeat from a press, and guessing would swallow real typing. pi-tui's protocol flag is the\n * activation signal — the terminal answered with event reporting — so the guard covers the\n * session's first held key too, not just ones typed after a release was observed.\n */\nimport {\n decodeKittyPrintable,\n isKeyRelease,\n isKeyRepeat,\n isKittyProtocolActive,\n} from \"@earendil-works/pi-tui\";\nimport type { HostUiContext, TerminalInputHandler } from \"./bridge.js\";\n\n/** pi-tui's bracketed-paste opener: pasted runs are text, never key repeat. */\nconst PASTE_START = \"\\x1b[200~\";\nconst LONE_ESCAPE = \"\\x1b\";\nconst CONTROL_DELETE = 0x7f;\n/**\n * A kitty key event: `<codepoint>[:<shifted>[:<base>]];<modifier>[:<event>]u`. Releases are read\n * straight from the sequence rather than through `decodeKittyPrintable`, which drops the\n * modifier combinations (caps lock, super) a release still has to clear.\n */\nconst KITTY_KEY_EVENT = /^\\x1b\\[(\\d+)(?::(\\d*))?(?::(\\d+))?(?:;(\\d+))?(?::(\\d+))?u$/;\n\n/**\n * A key we still believe is down after this long lost its release (focus loss, suspend, a\n * terminal that changed encoding). The next press is a fresh one — better one repeated\n * character than a prompt that ignores a key for the rest of the session.\n */\nconst STALE_DOWN_MS = 1_500;\n/** A keyboard cannot hold more distinct keys than this; bound the map when releases go missing. */\nconst MAX_DOWN_KEYS = 16;\n\ninterface RepeatState {\n /** Codepoint → the last instant we saw that key down. */\n down: Map<number, number>;\n /** Set once a release arrives: proof this terminal reports them, so suppression is safe. */\n releasesSeen: boolean;\n}\n\n/** The codepoint of a plain-text press, or undefined when the chunk is not one. */\nfunction textPressCodepoint(data: string): number | undefined {\n // An escape sequence is not text. Exactly one codepoint: an IME commit or a pasted run\n // arrives as several and carries its own semantics — never filter those.\n if (data.length === 0 || data.includes(LONE_ESCAPE)) return undefined;\n const codepoints = [...data];\n if (codepoints.length !== 1) return undefined;\n const codepoint = codepoints[0]!.codePointAt(0)!;\n if (codepoint < 32 || codepoint === CONTROL_DELETE) return undefined;\n return codepoint;\n}\n\n/** Every codepoint a release can name: the key itself and, for shifted keys, its unshifted form. */\nfunction releaseCodepoints(data: string): number[] {\n const match = KITTY_KEY_EVENT.exec(data);\n // Group 5 is the event type: 3 = release. Presses and repeats are not releases.\n if (!match || match[5] !== \"3\") return [];\n return [match[1], match[2]]\n .map((group) => (group === undefined || group === \"\" ? Number.NaN : Number(group)))\n .filter((codepoint) => Number.isFinite(codepoint) && codepoint >= 32);\n}\n\n/**\n * One terminal-input step ahead of the focused component. Returns `{ consume: true }` only for\n * a press the terminal repeated; every other chunk — including releases, which extension\n * surfaces may opt into — continues untouched.\n */\nexport function repeatGuardInput(\n state: RepeatState,\n data: string,\n now: number,\n): { consume: true } | undefined {\n if (data.includes(PASTE_START)) return undefined;\n\n if (isKeyRelease(data)) {\n state.releasesSeen = true;\n for (const codepoint of releaseCodepoints(data)) state.down.delete(codepoint);\n return undefined;\n }\n\n // A terminal that marks repeats itself (kitty's \"report all keys\") saves the inference —\n // but only for inserted text. Backspace and arrows must keep repeating.\n if (isKeyRepeat(data)) {\n return decodeKittyPrintable(data) !== undefined ? { consume: true } : undefined;\n }\n\n const codepoint = textPressCodepoint(data);\n if (codepoint === undefined) return undefined;\n\n const seenAt = state.down.get(codepoint);\n if (seenAt !== undefined && now - seenAt <= STALE_DOWN_MS) {\n // Keep the hold alive: a long press must stay suppressed for as long as it lasts.\n state.down.set(codepoint, now);\n const releasesReported = state.releasesSeen || isKittyProtocolActive();\n return releasesReported ? { consume: true } : undefined;\n }\n\n state.down.set(codepoint, now);\n if (state.down.size > MAX_DOWN_KEYS) {\n for (const [key, at] of state.down) {\n if (now - at > STALE_DOWN_MS) state.down.delete(key);\n }\n }\n return undefined;\n}\n\n/**\n * Wire the guard into the bound TUI. Returns an unsubscribe; call it before re-wiring, since\n * `clearExtensionTerminalInputListeners()` drops the listener on every session rebind.\n */\nexport function wireKeyRepeatGuard(ui: HostUiContext | null | undefined): () => void {\n const subscribe: ((handler: TerminalInputHandler) => () => void) | undefined = ui?.onTerminalInput;\n if (!subscribe) return () => {};\n const state: RepeatState = { down: new Map(), releasesSeen: false };\n return subscribe((data) => repeatGuardInput(state, data, Date.now()));\n}\n"],"mappings":";AAsBA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAIP,IAAM,cAAc;AACpB,IAAM,cAAc;AACpB,IAAM,iBAAiB;AAMvB,IAAM,kBAAkB;AAOxB,IAAM,gBAAgB;AAEtB,IAAM,gBAAgB;AAUtB,SAAS,mBAAmB,MAAkC;AAG5D,MAAI,KAAK,WAAW,KAAK,KAAK,SAAS,WAAW,EAAG,QAAO;AAC5D,QAAM,aAAa,CAAC,GAAG,IAAI;AAC3B,MAAI,WAAW,WAAW,EAAG,QAAO;AACpC,QAAM,YAAY,WAAW,CAAC,EAAG,YAAY,CAAC;AAC9C,MAAI,YAAY,MAAM,cAAc,eAAgB,QAAO;AAC3D,SAAO;AACT;AAGA,SAAS,kBAAkB,MAAwB;AACjD,QAAM,QAAQ,gBAAgB,KAAK,IAAI;AAEvC,MAAI,CAAC,SAAS,MAAM,CAAC,MAAM,IAAK,QAAO,CAAC;AACxC,SAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,EACvB,IAAI,CAAC,UAAW,UAAU,UAAa,UAAU,KAAK,OAAO,MAAM,OAAO,KAAK,CAAE,EACjF,OAAO,CAAC,cAAc,OAAO,SAAS,SAAS,KAAK,aAAa,EAAE;AACxE;AAOO,SAAS,iBACd,OACA,MACA,KAC+B;AAC/B,MAAI,KAAK,SAAS,WAAW,EAAG,QAAO;AAEvC,MAAI,aAAa,IAAI,GAAG;AACtB,UAAM,eAAe;AACrB,eAAWA,cAAa,kBAAkB,IAAI,EAAG,OAAM,KAAK,OAAOA,UAAS;AAC5E,WAAO;AAAA,EACT;AAIA,MAAI,YAAY,IAAI,GAAG;AACrB,WAAO,qBAAqB,IAAI,MAAM,SAAY,EAAE,SAAS,KAAK,IAAI;AAAA,EACxE;AAEA,QAAM,YAAY,mBAAmB,IAAI;AACzC,MAAI,cAAc,OAAW,QAAO;AAEpC,QAAM,SAAS,MAAM,KAAK,IAAI,SAAS;AACvC,MAAI,WAAW,UAAa,MAAM,UAAU,eAAe;AAEzD,UAAM,KAAK,IAAI,WAAW,GAAG;AAC7B,UAAM,mBAAmB,MAAM,gBAAgB,sBAAsB;AACrE,WAAO,mBAAmB,EAAE,SAAS,KAAK,IAAI;AAAA,EAChD;AAEA,QAAM,KAAK,IAAI,WAAW,GAAG;AAC7B,MAAI,MAAM,KAAK,OAAO,eAAe;AACnC,eAAW,CAAC,KAAK,EAAE,KAAK,MAAM,MAAM;AAClC,UAAI,MAAM,KAAK,cAAe,OAAM,KAAK,OAAO,GAAG;AAAA,IACrD;AAAA,EACF;AACA,SAAO;AACT;AAMO,SAAS,mBAAmB,IAAkD;AACnF,QAAM,YAAyE,IAAI;AACnF,MAAI,CAAC,UAAW,QAAO,MAAM;AAAA,EAAC;AAC9B,QAAM,QAAqB,EAAE,MAAM,oBAAI,IAAI,GAAG,cAAc,MAAM;AAClE,SAAO,UAAU,CAAC,SAAS,iBAAiB,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC;AACtE;","names":["codepoint"]}
@@ -0,0 +1,61 @@
1
+ import {
2
+ debug
3
+ } from "./chunk-7TJWBDVX.js";
4
+
5
+ // src/client/runtime/stdin-fragment-guard.ts
6
+ import {
7
+ decodeKittyPrintable,
8
+ isKeyRelease,
9
+ isKeyRepeat,
10
+ parseKey,
11
+ StdinBuffer
12
+ } from "@earendil-works/pi-tui";
13
+ var LONE_ESCAPE = "\x1B";
14
+ var PASTE_START = "\x1B[200~";
15
+ var CSI_START = "\x1B[";
16
+ var NEGOTIATION_PREFIX = /^\x1b\[\?[\d;]*$/;
17
+ var PATCHED = /* @__PURE__ */ Symbol.for("pi-pod.stdin-fragment-guard");
18
+ var dropped = 0;
19
+ var held = /* @__PURE__ */ new WeakMap();
20
+ function isKey(data) {
21
+ return parseKey(data) !== void 0 || decodeKittyPrintable(data) !== void 0 || isKeyRelease(data) || isKeyRepeat(data);
22
+ }
23
+ function guardStdinFragments() {
24
+ const prototype = StdinBuffer.prototype;
25
+ if (prototype[PATCHED] === true) return;
26
+ const flush = prototype.flush;
27
+ const process = prototype.process;
28
+ if (typeof flush !== "function" || typeof process !== "function") return;
29
+ prototype[PATCHED] = true;
30
+ prototype.flush = function guardedFlush() {
31
+ return flush.call(this).filter((sequence) => {
32
+ if (!sequence.startsWith(LONE_ESCAPE) || sequence === LONE_ESCAPE) return true;
33
+ if (sequence.includes(PASTE_START)) return true;
34
+ if (sequence === CSI_START || NEGOTIATION_PREFIX.test(sequence)) return true;
35
+ if (parseKey(sequence) !== void 0) return true;
36
+ held.set(this, sequence);
37
+ return false;
38
+ });
39
+ };
40
+ prototype.process = function guardedProcess(data) {
41
+ const fragment = held.get(this);
42
+ if (fragment === void 0) {
43
+ process.call(this, data);
44
+ return;
45
+ }
46
+ held.delete(this);
47
+ const text = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
48
+ const rejoined = fragment + text;
49
+ if (isKey(rejoined)) {
50
+ process.call(this, rejoined);
51
+ return;
52
+ }
53
+ dropped += 1;
54
+ debug(`stdin fragment guard: dropped ${JSON.stringify(fragment)} (${dropped} this session)`);
55
+ process.call(this, data);
56
+ };
57
+ }
58
+ export {
59
+ guardStdinFragments
60
+ };
61
+ //# sourceMappingURL=stdin-fragment-guard-SPIJC7GT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client/runtime/stdin-fragment-guard.ts"],"sourcesContent":["/**\n * src/client/runtime/stdin-fragment-guard.ts — an incomplete escape fragment is never typed.\n *\n * pi-tui's StdinBuffer splits stdin into whole key sequences; whatever it cannot complete after\n * a short wait is flushed as keyboard input (50 ms for a partial sequence, 10 ms for a lone\n * ESC). That timeout exists so a real Escape key press is not held forever, but it also turns a\n * *fragment* into text: a key release arriving in two pieces types its tail — \"3\", \"u\" — at the\n * cursor, and behind a multiplexer a split is routine (measured: tmux forwards a split\n * verbatim). The same window swallows keystrokes that land while an unterminated terminal reply\n * is buffered.\n *\n * At the moment of the flush the distinction is exact: the buffer is either a lone ESC (a key)\n * or an unterminated escape sequence (never a key). Dropping the latter would leave its tail to\n * be typed instead, so the fragment is held for exactly one more read: if the bytes that follow\n * complete a key, the key is delivered as if it had never been split; if they do not, the\n * fragment is discarded and those bytes are read on their own. Bounded by construction — a held\n * fragment never survives the next chunk, so a stray fragment cannot swallow typing. Upstream\n * pi-tui still flushes these fragments (1.4.0 only rescues recognizable terminal *replies*), so\n * a version bump cannot fix it for us.\n *\n * This patches a pinned dependency's class, not our own code: it is deliberately narrow (only\n * the timeout flush and the read that may complete it), feature-detected, applied once, and\n * coupled to pi-tui 1.0.2's StdinBuffer contract. Re-check it on a pi-tui bump (the pin is\n * enforced by `npm run lint`).\n */\nimport {\n decodeKittyPrintable,\n isKeyRelease,\n isKeyRepeat,\n parseKey,\n StdinBuffer,\n} from \"@earendil-works/pi-tui\";\nimport { debug } from \"../../log.js\";\n\nconst LONE_ESCAPE = \"\\x1b\";\n/** Pasted runs arrive complete; never drop a chunk that carries one. */\nconst PASTE_START = \"\\x1b[200~\";\n/**\n * pi-tui's terminal reassembles these *above* the buffer: a split kitty-flags or DA1 reply is\n * held there for its own window and joined to the next bytes. Leaving them untouched keeps that\n * working — the fragment guard only owns what nothing else would interpret.\n */\nconst CSI_START = \"\\x1b[\";\nconst NEGOTIATION_PREFIX = /^\\x1b\\[\\?[\\d;]*$/;\n/** Set on the patched method so a second import (or a re-wire) cannot double-wrap it. */\nconst PATCHED = Symbol.for(\"pi-pod.stdin-fragment-guard\");\n/** Count of fragments dropped this process — reported only through debug logging. */\nlet dropped = 0;\n\n/** The fragment a timed-out flush released, waiting for the read that may complete it. */\nconst held = new WeakMap<StdinBuffer, string>();\n\ntype Flush = (this: StdinBuffer) => string[];\ntype Process = (this: StdinBuffer, data: string | Uint8Array) => void;\n\n/** True when `data` is one complete key, whatever the terminal encoded it as. */\nfunction isKey(data: string): boolean {\n return (\n parseKey(data) !== undefined ||\n decodeKittyPrintable(data) !== undefined ||\n isKeyRelease(data) ||\n isKeyRepeat(data)\n );\n}\n\n/**\n * Install the guard once per process. Safe to call from every session start: the prototype is\n * shared by the TUI pi builds and by this launcher, so one patch covers both.\n */\nexport function guardStdinFragments(): void {\n const prototype = StdinBuffer.prototype as unknown as Record<PropertyKey, unknown> & {\n flush: Flush;\n };\n if (prototype[PATCHED] === true) return;\n\n const flush = prototype.flush;\n const process = prototype.process;\n if (typeof flush !== \"function\" || typeof process !== \"function\") return;\n prototype[PATCHED] = true;\n prototype.flush = function guardedFlush(this: StdinBuffer): string[] {\n return flush.call(this).filter((sequence) => {\n if (!sequence.startsWith(LONE_ESCAPE) || sequence === LONE_ESCAPE) return true;\n if (sequence.includes(PASTE_START)) return true;\n if (sequence === CSI_START || NEGOTIATION_PREFIX.test(sequence)) return true;\n // A complete sequence would have been emitted already; anything here that does not parse\n // is the unterminated remainder the timeout released.\n if (parseKey(sequence) !== undefined) return true;\n held.set(this, sequence);\n return false;\n });\n };\n\n prototype.process = function guardedProcess(this: StdinBuffer, data: string | Uint8Array): void {\n const fragment = held.get(this);\n if (fragment === undefined) {\n process.call(this, data);\n return;\n }\n held.delete(this);\n const text = typeof data === \"string\" ? data : Buffer.from(data).toString(\"utf8\");\n const rejoined = fragment + text;\n if (isKey(rejoined)) {\n process.call(this, rejoined);\n return;\n }\n // The tail never came: drop the fragment, keep the user's bytes intact.\n dropped += 1;\n debug(`stdin fragment guard: dropped ${JSON.stringify(fragment)} (${dropped} this session)`);\n process.call(this, data);\n };\n}\n"],"mappings":";;;;;AAyBA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP,IAAM,cAAc;AAEpB,IAAM,cAAc;AAMpB,IAAM,YAAY;AAClB,IAAM,qBAAqB;AAE3B,IAAM,UAAU,uBAAO,IAAI,6BAA6B;AAExD,IAAI,UAAU;AAGd,IAAM,OAAO,oBAAI,QAA6B;AAM9C,SAAS,MAAM,MAAuB;AACpC,SACE,SAAS,IAAI,MAAM,UACnB,qBAAqB,IAAI,MAAM,UAC/B,aAAa,IAAI,KACjB,YAAY,IAAI;AAEpB;AAMO,SAAS,sBAA4B;AAC1C,QAAM,YAAY,YAAY;AAG9B,MAAI,UAAU,OAAO,MAAM,KAAM;AAEjC,QAAM,QAAQ,UAAU;AACxB,QAAM,UAAU,UAAU;AAC1B,MAAI,OAAO,UAAU,cAAc,OAAO,YAAY,WAAY;AAClE,YAAU,OAAO,IAAI;AACrB,YAAU,QAAQ,SAAS,eAA0C;AACnE,WAAO,MAAM,KAAK,IAAI,EAAE,OAAO,CAAC,aAAa;AAC3C,UAAI,CAAC,SAAS,WAAW,WAAW,KAAK,aAAa,YAAa,QAAO;AAC1E,UAAI,SAAS,SAAS,WAAW,EAAG,QAAO;AAC3C,UAAI,aAAa,aAAa,mBAAmB,KAAK,QAAQ,EAAG,QAAO;AAGxE,UAAI,SAAS,QAAQ,MAAM,OAAW,QAAO;AAC7C,WAAK,IAAI,MAAM,QAAQ;AACvB,aAAO;AAAA,IACT,CAAC;AAAA,EACH;AAEA,YAAU,UAAU,SAAS,eAAkC,MAAiC;AAC9F,UAAM,WAAW,KAAK,IAAI,IAAI;AAC9B,QAAI,aAAa,QAAW;AAC1B,cAAQ,KAAK,MAAM,IAAI;AACvB;AAAA,IACF;AACA,SAAK,OAAO,IAAI;AAChB,UAAM,OAAO,OAAO,SAAS,WAAW,OAAO,OAAO,KAAK,IAAI,EAAE,SAAS,MAAM;AAChF,UAAM,WAAW,WAAW;AAC5B,QAAI,MAAM,QAAQ,GAAG;AACnB,cAAQ,KAAK,MAAM,QAAQ;AAC3B;AAAA,IACF;AAEA,eAAW;AACX,UAAM,iCAAiC,KAAK,UAAU,QAAQ,CAAC,KAAK,OAAO,gBAAgB;AAC3F,YAAQ,KAAK,MAAM,IAAI;AAAA,EACzB;AACF;","names":[]}
@@ -104,3 +104,15 @@ Only these modules may monkey-patch a live `InteractiveMode` instance:
104
104
 
105
105
  - `client/pod-title.ts`
106
106
  - `client/runtime/pod-command-overrides.ts`
107
+
108
+ ## pi-tui patch sites
109
+
110
+ Only this module may patch a pi-tui class. Both pins are exact and must stay that way: the
111
+ launcher and pi load the same copy (enforced by `npm run lint`), so a patch here is a patch to
112
+ the TUI pi itself runs.
113
+
114
+ - `client/runtime/stdin-fragment-guard.ts` — `StdinBuffer.prototype.flush`/`process`, so a
115
+ sequence fragment that timed out is never typed as input (see
116
+ [keyboard input](keyboard-input.md)). Narrow by construction: only the timeout flush, only
117
+ ESC-led remainders that do not parse as a key, feature-detected, applied once. Re-check it
118
+ on every pi-tui bump — the guard is written against 1.0.2's `buffer`/`flush` contract.
@@ -0,0 +1,91 @@
1
+ # Keyboard input in the attached TUI
2
+
3
+ Two input defects turn one key press into several characters or into escape-sequence
4
+ gibberish. Both live in the local byte path — nothing typed crosses the wire — and both are
5
+ fixed in `src/client/runtime/` rather than by pinning a different pi-tui, because the pinned
6
+ version is what pods and the launcher share.
7
+
8
+ ## The path
9
+
10
+ ```
11
+ terminal ──bytes──▶ ProcessTerminal ──▶ StdinBuffer ──▶ TUI input listeners ──▶ Editor
12
+ │
13
+ chords · disconnected-quit ·
14
+ key-repeat guard
15
+ ```
16
+
17
+ Extensions (and this launcher) register listeners through `ExtensionUIContext.onTerminalInput`;
18
+ they run ahead of the focused component and may consume a keystroke. pi-tui filters key
19
+ *releases* for the focused component but not repeats, and it has no way to see that a repeat
20
+ is a repeat for plain text.
21
+
22
+ ## 1. Auto-repeat inserts characters (`key-repeat-guard.ts`)
23
+
24
+ Ghostty encodes plain-text keys on the text path: press **and** repeat are written as bare
25
+ UTF-8, and only the release is a marked `CSI-u` event
26
+ (`ghostty: src/input/key_encode.zig` — "Send plain-text non-modified text directly to the
27
+ terminal … We don't send release events because those are specially encoded"). So
28
+ `isKeyRepeat()` never fires while typing, and a repeat is byte-identical to a fresh press.
29
+
30
+ A compositor repeats a held key (Hyprland default here: 250 ms delay, then 40/s), and a laggy
31
+ prompt — a long session re-rendering, the agent streaming — makes presses last longer than on
32
+ an idle session. Every repeat then inserts: one press, several characters.
33
+
34
+ The release is the surviving signal. pi-tui requests kitty event types, so a key that is still
35
+ down has not sent its release; a text press for a key with no release since is a repeat. The
36
+ guard consumes exactly those, and nothing else:
37
+
38
+ - stays inert until the terminal reports event types (`isKittyProtocolActive()` or a release
39
+ observed), so legacy encodings behave exactly as before;
40
+ - leaves backspace, Tab, arrows and every other non-inserting key repeating;
41
+ - treats a key as fresh once 1.5 s pass without hearing from it, so a lost release cannot
42
+ swallow typing for the rest of a session;
43
+ - passes releases through, since extension surfaces may opt into them (`wantsKeyRelease`).
44
+
45
+ Accepted trade-off: holding a *printable* key types it once. Hold-to-repeat exists for
46
+ navigation and deletion, not for text entry.
47
+
48
+ ## 2. A timed-out escape fragment is typed (`stdin-fragment-guard.ts`)
49
+
50
+ pi-tui's `StdinBuffer` splits stdin into whole sequences and flushes anything it cannot
51
+ complete: 50 ms for a partial sequence (`DEFAULT_SEQUENCE_TIMEOUT_MS`), 10 ms for a lone ESC,
52
+ 100 ms when `SSH_CONNECTION`/`SSH_TTY` is set. Flushing the remainder is how a real Escape key
53
+ is delivered, but it also types a *fragment*: a release split across two reads leaves `3u` at
54
+ the cursor, and behind a multiplexer a split is routine (tmux forwards a split verbatim,
55
+ measured). The same window swallows keystrokes that arrive while an unterminated terminal
56
+ reply is buffered.
57
+
58
+ At the flush the distinction is exact: the buffer is a lone ESC (a key) or an unterminated
59
+ escape sequence (never a key). The guard holds the fragment for exactly the next read and
60
+ rejoins it only if the two together parse as one key — a split release then behaves as if it
61
+ had never split. Otherwise the fragment is discarded and the user's bytes are read on their
62
+ own, so a stray fragment cannot swallow the next keystroke.
63
+
64
+ Fragments shaped like pi-tui's own keyboard-protocol replies are left untouched: the terminal
65
+ reassembles those above the buffer already, and the guard must not starve that path. A split
66
+ *colour* reply has no such reassembly anywhere, so its head is dropped instead of typed — the
67
+ reply may be missed and the theme falls back, which is cosmetic; the prompt stays clean.
68
+
69
+ Upstream pi-tui 1.4.0 still flushes these fragments (it only rescues recognizable terminal
70
+ *replies*), so a version bump would not fix this.
71
+
72
+ ## Accepted rows
73
+
74
+ Measured with a pty harness against pi 1.0.2 + pi-tui 1.0.2 (the pinned pair), driving
75
+ Ghostty-accurate bytes (`press` = bare UTF-8, `release` = `CSI-u`), and repeated end-to-end
76
+ inside pi's own `InteractiveMode`:
77
+
78
+ | Input | Before | After |
79
+ | --- | --- | --- |
80
+ | press `g` + 8 repeats + release | `ggggggggg` | `g` |
81
+ | release split past the flush (`\x1b[104;1:` … `3u`) | `h3u` | `h` |
82
+ | press split past the flush | `;1:1u` | that key |
83
+ | OSC reply fragment, then tap `j` | keystroke lost | `j` |
84
+
85
+ Everything else must stay exactly as it was, and does: a tap inserts one character, two
86
+ deliberate taps of the same key insert two, held Backspace and arrows still repeat, a
87
+ bracketed paste arrives verbatim, a legacy terminal without releases is untouched, and a
88
+ stray fragment (`\x1b[1` followed later by another key) costs neither keystroke.
89
+
90
+ `PI_TUI_ESC_TIMEOUT` only widens the lone-ESC window, not the 50 ms sequence window, so it is
91
+ not a workaround for either defect.
@@ -3,7 +3,8 @@
3
3
  `pipod ls` (also `pipod list`) reads every page before applying display filters or
4
4
  rendering the oldest-activity-first tree. Quiet output contains each full pod ID
5
5
  once. Project, template, account and machine-group scopes use the same page walk;
6
- so do short-ref/name resolution and bulk archive/restore/GC selection.
6
+ so does bulk archive/restore/GC selection. Short-ref/name resolution uses the
7
+ bounded lookup below; only an older server without that route uses pagination.
7
8
 
8
9
  ## API contract
9
10
 
@@ -29,6 +30,32 @@ pod ahead of an already-read boundary. The CLI deduplicates IDs if it sees a pod
29
30
  again and fails explicitly on a repeated cursor rather than reporting a silently
30
31
  truncated roster. Rerun a listing after concurrent activity for a fresh view.
31
32
 
33
+ ## Pod-reference lookup
34
+
35
+ `GET /v1/pods/by-ref/:ref` resolves a short reference or exact name within the
36
+ caller's own pods, including archived pods but excluding gone rows. Pod tokens
37
+ are additionally limited to their own subtree. Matching uses the same shared
38
+ parser as the CLI: case-sensitive exact names precede hexadecimal references;
39
+ `p-`/`j-` references match UUID tails, and bare hex matches legacy leading prefixes
40
+ or tails. Short hex references require at least four characters. Exact names can
41
+ still contain text that would otherwise be an invalid short reference.
42
+
43
+ The response is `{ pod, matches, total }`. A unique result includes one full pod;
44
+ no match returns `pod: null, matches: [], total: 0`. Ambiguity returns no full pod,
45
+ the exact total, and at most twenty `{ id, name, state }` choices. It never sends
46
+ the account's launch reports just to resolve one reference. All values are bound
47
+ SQL parameters, and lookup does not grant permission to perform an action.
48
+
49
+ A full UUID continues to use the ordinary single-pod route. Only a 404 from the
50
+ new route triggers the CLI's old-server pagination fallback; auth, validation,
51
+ network and server failures do not. Missing matches on a supporting server are
52
+ 200 responses, not capability failures.
53
+
54
+ Attach also shares one in-flight semantic model catalog between catalog and
55
+ thinking-level consumers and overlaps independent auth/thinking initialization.
56
+ It still validates readiness, settings and extension trust before accepting input;
57
+ there is no persistent model cache or new classic-RPC passthrough.
58
+
32
59
  ## Manual acceptance (no unit tests)
33
60
 
34
61
  Use an explicit disposable migrated database, never a production fixture insert.
@@ -45,6 +72,14 @@ Run the actual pod routes over loopback HTTP and the built CLI by absolute path:
45
72
  hide active pods on later pages. Check quiet and rendered output.
46
73
  - Delete the last pod of one page, then follow its cursor successfully.
47
74
  - Malformed cursor and simultaneous `cursor`/`before` return 400.
75
+ - Resolve short refs and names against more than 800 history rows without any
76
+ roster requests. Check exact-name precedence, case-insensitive hex, minimum
77
+ length, prefix/tail collisions, archived/gone rows, owner/org isolation and
78
+ pod-token lineage. Ambiguity must return the exact count but at most twenty
79
+ metadata choices, never a full report for each match.
80
+ - A missing lookup route falls back to the old paginated selection. A missing
81
+ match, invalid reference, 401/403, network failure or 5xx must not trigger that
82
+ fallback. Full UUIDs still go directly to the single-pod route.
48
83
  - A legacy server omitting `nextCursor` is still traversed with `before`.
49
84
  A server repeating a cursor fails without printing an incomplete quiet roster.
50
85
 
@@ -1,4 +1,4 @@
1
- .TH "PIPOD\-CONFIG" "5" "October 2026" "0.1.19"
1
+ .TH "PIPOD\-CONFIG" "5" "November 2026" "0.1.21"
2
2
  .SH "NAME"
3
3
  \fBpipod-config\fR \- pi pod configuration files
4
4
  .SH SYNOPSIS
package/man/pipod.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH "PIPOD" "1" "October 2026" "0.1.19"
1
+ .TH "PIPOD" "1" "November 2026" "0.1.21"
2
2
  .SH "NAME"
3
3
  \fBpipod\fR \- run pi coding\-agent sessions in server\-managed pods
4
4
  .SH SYNOPSIS
@@ -713,6 +713,9 @@ Quitting detaches: the pod keeps running until the server's idle policy reaps
713
713
  it or `pipod stop` stops it\. Raw pod shells are not available\.
714
714
  Attaching to an archived pod offers to restore it first\.
715
715
 
716
+ Holding a character key types it once: key repeat stays available for
717
+ Backspace, Tab and the arrow keys, where it is useful while editing\.
718
+
716
719
  After \-\-:
717
720
  <prompt…> Words are sent to the session as a startup prompt
718
721
  \-\-tui\-mode <mode> Local TUI layout for this attach: regular or fullscreen
package/man/pipod.1.md CHANGED
@@ -703,6 +703,9 @@ Quitting detaches: the pod keeps running until the server's idle policy reaps
703
703
  it or `pipod stop` stops it. Raw pod shells are not available.
704
704
  Attaching to an archived pod offers to restore it first.
705
705
 
706
+ Holding a character key types it once: key repeat stays available for
707
+ Backspace, Tab and the arrow keys, where it is useful while editing.
708
+
706
709
  After --:
707
710
  <prompt…> Words are sent to the session as a startup prompt
708
711
  --tui-mode <mode> Local TUI layout for this attach: regular or fullscreen
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@pipod/cli",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@pipod/cli",
9
- "version": "0.1.19",
9
+ "version": "0.1.21",
10
10
  "license": "AGPL-3.0-only",
11
11
  "dependencies": {
12
12
  "@earendil-works/pi-coding-agent": "1.0.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipod/cli",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Run pi coding-agent sessions in server-managed pods",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",