@pipod/cli 0.1.19 → 0.1.20
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.
- package/dist/chunk-7TJWBDVX.js +100 -0
- package/dist/chunk-7TJWBDVX.js.map +1 -0
- package/dist/cli.js +35 -95
- package/dist/cli.js.map +1 -1
- package/dist/key-repeat-guard-7OVKW5KD.js +64 -0
- package/dist/key-repeat-guard-7OVKW5KD.js.map +1 -0
- package/dist/stdin-fragment-guard-SPIJC7GT.js +61 -0
- package/dist/stdin-fragment-guard-SPIJC7GT.js.map +1 -0
- package/docs/classic-runtime-coupling.md +12 -0
- package/docs/keyboard-input.md +91 -0
- package/man/pipod-config.5 +1 -1
- package/man/pipod.1 +4 -1
- package/man/pipod.1.md +3 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -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.
|
package/man/pipod-config.5
CHANGED
package/man/pipod.1
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.TH "PIPOD" "1" "October 2026" "0.1.
|
|
1
|
+
.TH "PIPOD" "1" "October 2026" "0.1.20"
|
|
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
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipod/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@pipod/cli",
|
|
9
|
-
"version": "0.1.
|
|
9
|
+
"version": "0.1.20",
|
|
10
10
|
"license": "AGPL-3.0-only",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@earendil-works/pi-coding-agent": "1.0.2",
|