@pipod/cli 0.1.22 → 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.
@@ -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-SPIJC7GT.js.map
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.
@@ -58,9 +58,12 @@ reply is buffered.
58
58
 
59
59
  At the flush the distinction is exact: the buffer is a lone ESC (a key) or an unterminated
60
60
  escape sequence (never a key). The guard holds the fragment for exactly the next read and
61
- rejoins it only if the two together parse as one key — a split release then behaves as if it
62
- had never split. Otherwise the fragment is discarded and the user's bytes are read on their
63
- own, so a stray fragment cannot swallow the next keystroke.
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.
64
67
 
65
68
  Fragments shaped like pi-tui's own keyboard-protocol replies are left untouched: the terminal
66
69
  reassembles those above the buffer already, and the guard must not starve that path. A split
@@ -1,4 +1,4 @@
1
- .TH "PIPOD\-CONFIG" "5" "November 2026" "0.1.22"
1
+ .TH "PIPOD\-CONFIG" "5" "November 2026" "0.1.23"
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" "November 2026" "0.1.22"
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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@pipod/cli",
3
- "version": "0.1.22",
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.22",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipod/cli",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Run pi coding-agent sessions in server-managed pods",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -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":[]}