@pipod/cli 0.1.21 → 0.1.23
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/cli.js +11 -2
- package/dist/cli.js.map +1 -1
- package/dist/key-encoding-guard-6UOKKTJQ.js +41 -0
- package/dist/key-encoding-guard-6UOKKTJQ.js.map +1 -0
- package/dist/{stdin-fragment-guard-SPIJC7GT.js → stdin-fragment-guard-DHHVSIXM.js} +3 -2
- package/dist/stdin-fragment-guard-DHHVSIXM.js.map +1 -0
- package/docs/fullscreen.md +57 -0
- package/docs/keyboard-input.md +48 -8
- package/man/pipod-config.5 +1 -1
- package/man/pipod.1 +4 -3
- package/man/pipod.1.md +3 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/stdin-fragment-guard-SPIJC7GT.js.map +0 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/client/runtime/key-encoding-guard.ts
|
|
2
|
+
import { decodeKittyPrintable, isKeyRelease } from "@earendil-works/pi-tui";
|
|
3
|
+
var CONTROL_DELETE = 127;
|
|
4
|
+
var DUPLICATE_WINDOW_MS = 50;
|
|
5
|
+
function rawTextChar(data) {
|
|
6
|
+
if (data.length === 0 || data.includes("\x1B")) return void 0;
|
|
7
|
+
if ([...data].length !== 1) return void 0;
|
|
8
|
+
const codepoint = data.codePointAt(0);
|
|
9
|
+
if (codepoint < 32 || codepoint === CONTROL_DELETE) return void 0;
|
|
10
|
+
return data;
|
|
11
|
+
}
|
|
12
|
+
function encodingGuardInput(state, data, now) {
|
|
13
|
+
if (isKeyRelease(data)) {
|
|
14
|
+
state.pending = void 0;
|
|
15
|
+
return void 0;
|
|
16
|
+
}
|
|
17
|
+
const char = rawTextChar(data);
|
|
18
|
+
if (char !== void 0) {
|
|
19
|
+
const pending = state.pending;
|
|
20
|
+
state.pending = void 0;
|
|
21
|
+
if (pending !== void 0 && pending.char === char && now - pending.at <= DUPLICATE_WINDOW_MS) {
|
|
22
|
+
return { consume: true };
|
|
23
|
+
}
|
|
24
|
+
return void 0;
|
|
25
|
+
}
|
|
26
|
+
const printable = decodeKittyPrintable(data);
|
|
27
|
+
state.pending = printable === void 0 ? void 0 : { char: printable, at: now };
|
|
28
|
+
return void 0;
|
|
29
|
+
}
|
|
30
|
+
function wireKeyEncodingGuard(ui) {
|
|
31
|
+
const subscribe = ui?.onTerminalInput;
|
|
32
|
+
if (!subscribe) return () => {
|
|
33
|
+
};
|
|
34
|
+
const state = { pending: void 0 };
|
|
35
|
+
return subscribe((data) => encodingGuardInput(state, data, Date.now()));
|
|
36
|
+
}
|
|
37
|
+
export {
|
|
38
|
+
encodingGuardInput,
|
|
39
|
+
wireKeyEncodingGuard
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=key-encoding-guard-6UOKKTJQ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client/runtime/key-encoding-guard.ts"],"sourcesContent":["/**\n * src/client/runtime/key-encoding-guard.ts — one press, one character, whichever encodings the\n * terminal reports it in.\n *\n * A terminal with the kitty keyboard protocol enabled describes a press on two paths: the key\n * event as a `CSI-u` sequence, and the text the key produced as raw bytes. Normally it uses one\n * or the other per press, and nothing here fires. Some terminals emit *both* for the same press\n * — the case upstream pi fixed for one shape and order in 0.70.4 (#3780, \"duplicate printable\n * characters from Kitty keyboard protocol CSI-u plus raw character input on layouts such as\n * Italian\"). pi-tui guards against it in `StdinBuffer.emitDataSequence`, which remembers the\n * codepoint of the last CSI-u printable and drops a raw character that repeats it.\n *\n * That recogniser is `parseUnmodifiedKittyPrintableCodepoint`, and it accepts only the bare\n * forms — `ESC[<cp>u`, `ESC[<cp>:<shifted>u`, `ESC[<cp>::<base>u`. Every form carrying a\n * *modifier parameter* is refused, while `decodeKittyPrintable` accepts those (it masks Caps\n * Lock and Num Lock: `KITTY_PRINTABLE_ALLOWED_MODIFIERS = shift | 64 | 128`). So the dedupe\n * silently stops working the moment the terminal puts a lock bit or a layout alternate in that\n * parameter — a Num Lock that is on by default, or the AltGr characters of a non-US layout —\n * and both copies insert: one press, two characters.\n *\n * The guard is fail-open by construction. A duplicate that escapes costs one character; a\n * keystroke spent on a wrong suppression costs the user's intent, and the second is the only\n * failure here that is not instantly visible and recoverable. So it drops a raw character only\n * when all of these hold: a `CSI-u` printable for exactly that character arrived immediately\n * before it; it is the one drop that `CSI-u` is owed (a second matching character is a second\n * press); and it lands within {@link DUPLICATE_WINDOW_MS}, which the two encodings of a single\n * press always meet because the terminal writes them together. A release rearms nothing: it\n * clears the memory instead, so the release event that follows a press can never make the next\n * deliberate press of the same key disappear.\n *\n * Reads and repeats are left alone on purpose. `key-repeat-guard.ts` owns repeats, and a\n * terminal that reports repeats as CSI-u event-type 2 is only deduped against its own text.\n */\n\nimport { decodeKittyPrintable, isKeyRelease } from \"@earendil-works/pi-tui\";\nimport type { HostUiContext, TerminalInputHandler } from \"./bridge.js\";\n\nconst CONTROL_DELETE = 0x7f;\n/**\n * How long the raw half of a dual-encoded press may trail its CSI-u half. Both halves are\n * written by the terminal as one keystroke, so they arrive within a read or two; the window\n * exists only to bound the damage of a terminal that mixes encodings per press — the one way a\n * deliberate press could sit close enough behind a CSI-u to be mistaken for its text.\n */\nconst DUPLICATE_WINDOW_MS = 50;\n\ninterface EncodingState {\n /** The printable a CSI-u just delivered, and when — still owed exactly one raw drop. */\n pending: { char: string; at: number } | undefined;\n}\n\n/** The single printable character this chunk carries as plain text, if it is that. */\nfunction rawTextChar(data: string): string | undefined {\n // An escape sequence is not text: the CSI-u half of the pair arrives in this shape.\n if (data.length === 0 || data.includes(\"\\x1b\")) return undefined;\n if ([...data].length !== 1) return undefined;\n const codepoint = data.codePointAt(0)!;\n if (codepoint < 32 || codepoint === CONTROL_DELETE) return undefined;\n return data;\n}\n\n/**\n * One terminal-input step ahead of the focused component. Consumes only the second encoding of\n * a press the terminal already reported as a key event.\n */\nexport function encodingGuardInput(\n state: EncodingState,\n data: string,\n now: number,\n): { consume: true } | undefined {\n // A release is not a press: it must never arm a drop, or the key's next press would vanish.\n if (isKeyRelease(data)) {\n state.pending = undefined;\n return undefined;\n }\n\n const char = rawTextChar(data);\n if (char !== undefined) {\n const pending = state.pending;\n state.pending = undefined;\n if (pending !== undefined && pending.char === char && now - pending.at <= DUPLICATE_WINDOW_MS) {\n return { consume: true };\n }\n return undefined;\n }\n\n // `decodeKittyPrintable` is the editor's own reading of a key event, including the modifier\n // combinations the stock dedupe refuses — which is exactly the gap this guard fills.\n const printable = decodeKittyPrintable(data);\n state.pending = printable === undefined ? undefined : { char: printable, at: now };\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 *\n * Registered ahead of the repeat guard: the repeat guard consumes CSI-u repeats, and this one\n * needs to see a repeat's key event to recognise the text copy that follows it.\n */\nexport function wireKeyEncodingGuard(ui: HostUiContext | null | undefined): () => void {\n const subscribe: ((handler: TerminalInputHandler) => () => void) | undefined = ui?.onTerminalInput;\n if (!subscribe) return () => {};\n const state: EncodingState = { pending: undefined };\n return subscribe((data) => encodingGuardInput(state, data, Date.now()));\n}\n"],"mappings":";AAkCA,SAAS,sBAAsB,oBAAoB;AAGnD,IAAM,iBAAiB;AAOvB,IAAM,sBAAsB;AAQ5B,SAAS,YAAY,MAAkC;AAErD,MAAI,KAAK,WAAW,KAAK,KAAK,SAAS,MAAM,EAAG,QAAO;AACvD,MAAI,CAAC,GAAG,IAAI,EAAE,WAAW,EAAG,QAAO;AACnC,QAAM,YAAY,KAAK,YAAY,CAAC;AACpC,MAAI,YAAY,MAAM,cAAc,eAAgB,QAAO;AAC3D,SAAO;AACT;AAMO,SAAS,mBACd,OACA,MACA,KAC+B;AAE/B,MAAI,aAAa,IAAI,GAAG;AACtB,UAAM,UAAU;AAChB,WAAO;AAAA,EACT;AAEA,QAAM,OAAO,YAAY,IAAI;AAC7B,MAAI,SAAS,QAAW;AACtB,UAAM,UAAU,MAAM;AACtB,UAAM,UAAU;AAChB,QAAI,YAAY,UAAa,QAAQ,SAAS,QAAQ,MAAM,QAAQ,MAAM,qBAAqB;AAC7F,aAAO,EAAE,SAAS,KAAK;AAAA,IACzB;AACA,WAAO;AAAA,EACT;AAIA,QAAM,YAAY,qBAAqB,IAAI;AAC3C,QAAM,UAAU,cAAc,SAAY,SAAY,EAAE,MAAM,WAAW,IAAI,IAAI;AACjF,SAAO;AACT;AASO,SAAS,qBAAqB,IAAkD;AACrF,QAAM,YAAyE,IAAI;AACnF,MAAI,CAAC,UAAW,QAAO,MAAM;AAAA,EAAC;AAC9B,QAAM,QAAuB,EAAE,SAAS,OAAU;AAClD,SAAO,UAAU,CAAC,SAAS,mBAAmB,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC;AACxE;","names":[]}
|
|
@@ -14,6 +14,7 @@ var LONE_ESCAPE = "\x1B";
|
|
|
14
14
|
var PASTE_START = "\x1B[200~";
|
|
15
15
|
var CSI_START = "\x1B[";
|
|
16
16
|
var NEGOTIATION_PREFIX = /^\x1b\[\?[\d;]*$/;
|
|
17
|
+
var SGR_MOUSE_REPORT = /^\x1b\[<\d+;\d+;\d+[Mm]/;
|
|
17
18
|
var PATCHED = /* @__PURE__ */ Symbol.for("pi-pod.stdin-fragment-guard");
|
|
18
19
|
var dropped = 0;
|
|
19
20
|
var held = /* @__PURE__ */ new WeakMap();
|
|
@@ -46,7 +47,7 @@ function guardStdinFragments() {
|
|
|
46
47
|
held.delete(this);
|
|
47
48
|
const text = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
|
|
48
49
|
const rejoined = fragment + text;
|
|
49
|
-
if (isKey(rejoined)) {
|
|
50
|
+
if (isKey(rejoined) || SGR_MOUSE_REPORT.test(rejoined)) {
|
|
50
51
|
process.call(this, rejoined);
|
|
51
52
|
return;
|
|
52
53
|
}
|
|
@@ -58,4 +59,4 @@ function guardStdinFragments() {
|
|
|
58
59
|
export {
|
|
59
60
|
guardStdinFragments
|
|
60
61
|
};
|
|
61
|
-
//# sourceMappingURL=stdin-fragment-guard-
|
|
62
|
+
//# sourceMappingURL=stdin-fragment-guard-DHHVSIXM.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 or fullscreen mouse report, the input is delivered as if it had never split.\n * Otherwise the fragment is discarded and those bytes are read on their own. Bounded by\n * 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/** A complete SGR mouse report; following reports/keystrokes belong to the same read. */\nconst SGR_MOUSE_REPORT = /^\\x1b\\[<\\d+;\\d+;\\d+[Mm]/;\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 // Fullscreen owns mouse input too. Dropping a timed-out report's head would type its\n // coordinates into the editor. Let StdinBuffer split any coalesced input after the report.\n if (isKey(rejoined) || SGR_MOUSE_REPORT.test(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":";;;;;AA0BA;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,mBAAmB;AAEzB,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;AAG5B,QAAI,MAAM,QAAQ,KAAK,iBAAiB,KAAK,QAAQ,GAAG;AACtD,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":[]}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Fullscreen Pi in pipod
|
|
2
|
+
|
|
3
|
+
Pipod uses its bundled Pi renderer locally; fullscreen does not need a different pod image
|
|
4
|
+
or a remote terminal. Launch or attach with an explicit layout:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pipod -- --tui-mode fullscreen
|
|
8
|
+
pipod attach <pod> -- --tui-mode fullscreen
|
|
9
|
+
pipod attach <pod> -- --tui-mode regular
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The override applies only to this client. Without it, Pi uses the derived pod/host display
|
|
13
|
+
settings. Fullscreen keeps the editor and footer fixed while the transcript scrolls in the
|
|
14
|
+
alternate screen; regular mode uses the terminal's scrollback. `/settings` can switch layouts
|
|
15
|
+
without disconnecting. Display changes made there are session-local, not saved account or
|
|
16
|
+
template configuration.
|
|
17
|
+
|
|
18
|
+
## Mouse preferences
|
|
19
|
+
|
|
20
|
+
The pod's display projection includes `tuiMode`, `fullscreenExitOutput`,
|
|
21
|
+
`fullscreenScrollbar`, `fullscreenCopyOnSelect` (boolean), and
|
|
22
|
+
`fullscreenWheelScrollLines` (`"auto"` or an integer from 1 through 100). Invalid pod values
|
|
23
|
+
are ignored; command-valued settings never cross this boundary. Host-only preferences remain
|
|
24
|
+
local. Both the server's manifest producer and the CLI validator need the new fields for pod
|
|
25
|
+
copy/scroll preferences to reach an attached client. An already-running shim keeps its old
|
|
26
|
+
projection until restarted; a server source update alone does not change it.
|
|
27
|
+
|
|
28
|
+
## Input compatibility
|
|
29
|
+
|
|
30
|
+
Fullscreen captures mouse reports. A wheel/click/drag/release report split across reads,
|
|
31
|
+
past pi-tui's sequence timeout, must still reach the renderer as one report—not type its
|
|
32
|
+
coordinates into the editor. The [stdin fragment guard](keyboard-input.md) rejoins a complete
|
|
33
|
+
SGR mouse report and preserves any following input in the same read. A malformed fragment
|
|
34
|
+
still cannot swallow the next ordinary keystroke. This retains the existing bound: recovery
|
|
35
|
+
covers the next read, not arbitrarily delayed multi-fragment streams.
|
|
36
|
+
|
|
37
|
+
## Manual acceptance
|
|
38
|
+
|
|
39
|
+
Use a real pod in an agent-owned tmux session. Build and invoke the checkout by absolute path;
|
|
40
|
+
do not accidentally verify a globally installed CLI instead. Keep and archive only the pod
|
|
41
|
+
you create. No model call is needed: `!` shell commands can generate transcript content.
|
|
42
|
+
|
|
43
|
+
Check the alternate-screen/mouse flags, not only visible text. Exercise:
|
|
44
|
+
|
|
45
|
+
- Startup and reattach with both explicit layouts and the default.
|
|
46
|
+
- Narrow/wide terminal resizes; editor/footer stay visible and input remains usable.
|
|
47
|
+
- Transcript keyboard and wheel scrolling, including split SGR reports ending in `M` and `m`.
|
|
48
|
+
- A completed report followed by printable input in the same read; only the printable input
|
|
49
|
+
enters the editor. Malformed fragments must not discard ordinary typing.
|
|
50
|
+
- `/settings` layout switching and the detach chord afterward.
|
|
51
|
+
- Detach/quit returns to the main screen with mouse reporting off, and leaves the pod running.
|
|
52
|
+
- Real generated shim → validated manifest → Pi's file-backed SettingsManager: both copy
|
|
53
|
+
booleans, `"auto"`, numeric wheel bounds, invalid values, and unchanged host-only settings.
|
|
54
|
+
|
|
55
|
+
When sending a partial mouse report through tmux, use `send-keys -H` for its hex bytes. A
|
|
56
|
+
trailing semicolon in a `send-keys -l` argument is a tmux command separator and can be removed,
|
|
57
|
+
turning a valid intended report into malformed diagnostic input.
|
package/docs/keyboard-input.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Keyboard input in the attached TUI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
gibberish.
|
|
5
|
-
fixed in `src/client/runtime/` rather than by pinning a different pi-tui,
|
|
6
|
-
version is what pods and the launcher share.
|
|
3
|
+
Three input defects turn one key press into several characters, the same character twice, or
|
|
4
|
+
into escape-sequence gibberish. All live in the local byte path — nothing typed crosses the
|
|
5
|
+
wire — and all are fixed in `src/client/runtime/` rather than by pinning a different pi-tui,
|
|
6
|
+
because the pinned version is what pods and the launcher share.
|
|
7
7
|
|
|
8
8
|
## The path
|
|
9
9
|
|
|
@@ -11,6 +11,7 @@ version is what pods and the launcher share.
|
|
|
11
11
|
terminal ──bytes──▶ ProcessTerminal ──▶ StdinBuffer ──▶ TUI input listeners ──▶ Editor
|
|
12
12
|
│
|
|
13
13
|
chords · disconnected-quit ·
|
|
14
|
+
key-encoding guard ·
|
|
14
15
|
key-repeat guard
|
|
15
16
|
```
|
|
16
17
|
|
|
@@ -57,9 +58,12 @@ reply is buffered.
|
|
|
57
58
|
|
|
58
59
|
At the flush the distinction is exact: the buffer is a lone ESC (a key) or an unterminated
|
|
59
60
|
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
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
rejoins it only if the two together parse as one key or start with a complete SGR mouse report
|
|
62
|
+
(`ESC [ < button ; column ; row M/m`). Fullscreen mode captures wheel, click, drag and release
|
|
63
|
+
reports: treating them as non-keys dropped the report's head and typed its coordinates into
|
|
64
|
+
the editor. Any input coalesced after the completed report goes through `StdinBuffer` normally.
|
|
65
|
+
Otherwise the fragment is discarded and the user's bytes are read on their own, so a stray
|
|
66
|
+
fragment cannot swallow the next keystroke.
|
|
63
67
|
|
|
64
68
|
Fragments shaped like pi-tui's own keyboard-protocol replies are left untouched: the terminal
|
|
65
69
|
reassembles those above the buffer already, and the guard must not starve that path. A split
|
|
@@ -69,6 +73,34 @@ reply may be missed and the theme falls back, which is cosmetic; the prompt stay
|
|
|
69
73
|
Upstream pi-tui 1.4.0 still flushes these fragments (it only rescues recognizable terminal
|
|
70
74
|
*replies*), so a version bump would not fix this.
|
|
71
75
|
|
|
76
|
+
## 3. A press reported in both encodings types once (`key-encoding-guard.ts`)
|
|
77
|
+
|
|
78
|
+
A terminal with the kitty keyboard protocol enabled may describe one press twice: the key
|
|
79
|
+
event as a `CSI-u` sequence **and** the text it produced as raw bytes. pi-tui already guards
|
|
80
|
+
this in `StdinBuffer.emitDataSequence` — it remembers the codepoint of the last CSI-u
|
|
81
|
+
printable and drops a raw character that repeats it — but its recogniser
|
|
82
|
+
(`parseUnmodifiedKittyPrintableCodepoint`) accepts only `ESC[<cp>u`, `ESC[<cp>:<shifted>u` and
|
|
83
|
+
`ESC[<cp>::<base>u`, and refuses every form carrying a *modifier parameter*. `decodeKittyPrintable`
|
|
84
|
+
accepts those, masking Caps Lock and Num Lock (`KITTY_PRINTABLE_ALLOWED_MODIFIERS = shift | 64
|
|
85
|
+
| 128`). So the stock dedupe stops working the moment the terminal puts a modifier bit in that
|
|
86
|
+
parameter — measured here with the lock bits a machine carries without anyone noticing, a Num
|
|
87
|
+
Lock that is on by default (`input:numlock_by_default = true`) or a Caps Lock — and both copies
|
|
88
|
+
insert. Upstream pi fixed the same class of report for one terminal order and shape in
|
|
89
|
+
0.70.4 (#3780, "duplicate printable characters from Kitty keyboard protocol CSI-u plus raw
|
|
90
|
+
character input on layouts such as Italian"); the pinned pi-tui still has the gap.
|
|
91
|
+
|
|
92
|
+
The guard consumes a raw character only when a CSI-u printable for exactly that character
|
|
93
|
+
arrived immediately before it, only once per CSI-u, and only within 50 ms — the two halves of
|
|
94
|
+
one press are written together, so they always meet that window. It is fail-open by
|
|
95
|
+
construction: an escaped duplicate costs one visible, recoverable character, while a wrong
|
|
96
|
+
suppression would silently eat a keystroke. A release clears the memory rather than arming it,
|
|
97
|
+
so the release event that follows a press can never make the next deliberate press of that key
|
|
98
|
+
disappear. Releases and repeats otherwise stay with `key-repeat-guard.ts`, which owns them.
|
|
99
|
+
|
|
100
|
+
Left open deliberately: the reversed order (raw text first, then the CSI-u). No observed
|
|
101
|
+
terminal writes it, and deduping it would mean dropping a key *event* on suspicion — the one
|
|
102
|
+
direction where a mistake loses input.
|
|
103
|
+
|
|
72
104
|
## Accepted rows
|
|
73
105
|
|
|
74
106
|
Measured with a pty harness against pi 1.0.2 + pi-tui 1.0.2 (the pinned pair), driving
|
|
@@ -81,6 +113,14 @@ inside pi's own `InteractiveMode`:
|
|
|
81
113
|
| release split past the flush (`\x1b[104;1:` … `3u`) | `h3u` | `h` |
|
|
82
114
|
| press split past the flush | `;1:1u` | that key |
|
|
83
115
|
| OSC reply fragment, then tap `j` | keystroke lost | `j` |
|
|
116
|
+
| Num Lock bit on the CSI-u (`\x1b[97;129u` then `a`) | `aa` | `a` |
|
|
117
|
+
| layout base + Num Lock (`\x1b[97:97:97;129u` then `a`) | `aa` | `a` |
|
|
118
|
+
| symbol press with Num Lock (`\x1b[64;129u` then `@`) | `@@` | `@` |
|
|
119
|
+
| Caps Lock bit (`\x1b[65;65u` then `A`) | `AA` | `A` |
|
|
120
|
+
| both halves in one read (`\x1b[104;129uh`) | `hh` | `h` |
|
|
121
|
+
| stock dedupe already covers it: `\x1b[64:64:50u` then `@` | `@` | `@` |
|
|
122
|
+
| CSI-u half lost, same key 200 ms later | `aa` | `aa` |
|
|
123
|
+
| release between the halves, then that key again | press kept | unchanged |
|
|
84
124
|
|
|
85
125
|
Everything else must stay exactly as it was, and does: a tap inserts one character, two
|
|
86
126
|
deliberate taps of the same key insert two, held Backspace and arrows still repeat, a
|
|
@@ -88,4 +128,4 @@ bracketed paste arrives verbatim, a legacy terminal without releases is untouche
|
|
|
88
128
|
stray fragment (`\x1b[1` followed later by another key) costs neither keystroke.
|
|
89
129
|
|
|
90
130
|
`PI_TUI_ESC_TIMEOUT` only widens the lone-ESC window, not the 50 ms sequence window, so it is
|
|
91
|
-
not a workaround for
|
|
131
|
+
not a workaround for any of these defects.
|
package/man/pipod-config.5
CHANGED
package/man/pipod.1
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.TH "PIPOD" "1" "November 2026" "0.1.
|
|
1
|
+
.TH "PIPOD" "1" "November 2026" "0.1.23"
|
|
2
2
|
.SH "NAME"
|
|
3
3
|
\fBpipod\fR \- run pi coding\-agent sessions in server\-managed pods
|
|
4
4
|
.SH SYNOPSIS
|
|
@@ -713,8 +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
|
|
717
|
-
Backspace, Tab and the arrow keys,
|
|
716
|
+
Holding a character key types it once, and a press the terminal reports in both of its
|
|
717
|
+
encodings types once too: key repeat stays available for Backspace, Tab and the arrow keys,
|
|
718
|
+
where it is useful while editing\.
|
|
718
719
|
|
|
719
720
|
After \-\-:
|
|
720
721
|
<prompt…> Words are sent to the session as a startup prompt
|
package/man/pipod.1.md
CHANGED
|
@@ -703,8 +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
|
|
707
|
-
Backspace, Tab and the arrow keys,
|
|
706
|
+
Holding a character key types it once, and a press the terminal reports in both of its
|
|
707
|
+
encodings types once too: key repeat stays available for Backspace, Tab and the arrow keys,
|
|
708
|
+
where it is useful while editing.
|
|
708
709
|
|
|
709
710
|
After --:
|
|
710
711
|
<prompt…> Words are sent to the session as a startup prompt
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipod/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
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.23",
|
|
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 +0,0 @@
|
|
|
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":[]}
|