@astralyn/pi 0.82.3 → 0.82.5

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.
Files changed (158) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli/config-selector.d.ts.map +1 -1
  3. package/dist/cli/config-selector.js +3 -2
  4. package/dist/cli/config-selector.js.map +1 -1
  5. package/dist/cli/startup-ui.d.ts.map +1 -1
  6. package/dist/cli/startup-ui.js +3 -2
  7. package/dist/cli/startup-ui.js.map +1 -1
  8. package/dist/core/agent-session.d.ts +2 -0
  9. package/dist/core/agent-session.d.ts.map +1 -1
  10. package/dist/core/agent-session.js +33 -14
  11. package/dist/core/agent-session.js.map +1 -1
  12. package/dist/core/extensions/index.d.ts +2 -2
  13. package/dist/core/extensions/index.d.ts.map +1 -1
  14. package/dist/core/extensions/index.js +1 -1
  15. package/dist/core/extensions/index.js.map +1 -1
  16. package/dist/core/extensions/loader.d.ts +1 -1
  17. package/dist/core/extensions/loader.d.ts.map +1 -1
  18. package/dist/core/extensions/loader.js +2 -3
  19. package/dist/core/extensions/loader.js.map +1 -1
  20. package/dist/core/extensions/types.d.ts +18 -0
  21. package/dist/core/extensions/types.d.ts.map +1 -1
  22. package/dist/core/extensions/types.js +11 -0
  23. package/dist/core/extensions/types.js.map +1 -1
  24. package/dist/extensions/index.d.ts.map +1 -1
  25. package/dist/extensions/index.js +2 -0
  26. package/dist/extensions/index.js.map +1 -1
  27. package/dist/extensions/plan/constants.d.ts +31 -0
  28. package/dist/extensions/plan/constants.d.ts.map +1 -0
  29. package/dist/extensions/plan/constants.js +57 -0
  30. package/dist/extensions/plan/constants.js.map +1 -0
  31. package/dist/extensions/plan/index.d.ts +21 -0
  32. package/dist/extensions/plan/index.d.ts.map +1 -0
  33. package/dist/extensions/plan/index.js +291 -0
  34. package/dist/extensions/plan/index.js.map +1 -0
  35. package/dist/extensions/plan/schema.d.ts +15 -0
  36. package/dist/extensions/plan/schema.d.ts.map +1 -0
  37. package/dist/extensions/plan/schema.js +13 -0
  38. package/dist/extensions/plan/schema.js.map +1 -0
  39. package/dist/extensions/plan/state.d.ts +29 -0
  40. package/dist/extensions/plan/state.d.ts.map +1 -0
  41. package/dist/extensions/plan/state.js +74 -0
  42. package/dist/extensions/plan/state.js.map +1 -0
  43. package/dist/extensions/plan/storage.d.ts +18 -0
  44. package/dist/extensions/plan/storage.d.ts.map +1 -0
  45. package/dist/extensions/plan/storage.js +58 -0
  46. package/dist/extensions/plan/storage.js.map +1 -0
  47. package/dist/extensions/question/dialog.d.ts +3 -1
  48. package/dist/extensions/question/dialog.d.ts.map +1 -1
  49. package/dist/extensions/question/dialog.js +106 -52
  50. package/dist/extensions/question/dialog.js.map +1 -1
  51. package/dist/extensions/question/index.d.ts.map +1 -1
  52. package/dist/extensions/question/index.js +2 -2
  53. package/dist/extensions/question/index.js.map +1 -1
  54. package/dist/extensions/question/render-cache.d.ts +9 -0
  55. package/dist/extensions/question/render-cache.d.ts.map +1 -1
  56. package/dist/extensions/question/render-cache.js +22 -0
  57. package/dist/extensions/question/render-cache.js.map +1 -1
  58. package/dist/extensions/question/results.d.ts.map +1 -1
  59. package/dist/extensions/question/results.js +6 -1
  60. package/dist/extensions/question/results.js.map +1 -1
  61. package/dist/extensions/question/schema.d.ts +4 -0
  62. package/dist/extensions/question/schema.d.ts.map +1 -1
  63. package/dist/extensions/question/schema.js +19 -28
  64. package/dist/extensions/question/schema.js.map +1 -1
  65. package/dist/extensions/question/types.d.ts +1 -1
  66. package/dist/extensions/question/types.d.ts.map +1 -1
  67. package/dist/extensions/question/types.js.map +1 -1
  68. package/dist/extensions/rewind/anchor.d.ts +29 -0
  69. package/dist/extensions/rewind/anchor.d.ts.map +1 -0
  70. package/dist/extensions/rewind/anchor.js +35 -0
  71. package/dist/extensions/rewind/anchor.js.map +1 -0
  72. package/dist/extensions/rewind/engine.d.ts +2 -6
  73. package/dist/extensions/rewind/engine.d.ts.map +1 -1
  74. package/dist/extensions/rewind/engine.js +41 -10
  75. package/dist/extensions/rewind/engine.js.map +1 -1
  76. package/dist/extensions/rewind/index.d.ts.map +1 -1
  77. package/dist/extensions/rewind/index.js +55 -21
  78. package/dist/extensions/rewind/index.js.map +1 -1
  79. package/dist/extensions/rewind/menu.d.ts.map +1 -1
  80. package/dist/extensions/rewind/menu.js +7 -2
  81. package/dist/extensions/rewind/menu.js.map +1 -1
  82. package/dist/extensions/rewind/restore.d.ts +26 -9
  83. package/dist/extensions/rewind/restore.d.ts.map +1 -1
  84. package/dist/extensions/rewind/restore.js +58 -16
  85. package/dist/extensions/rewind/restore.js.map +1 -1
  86. package/dist/extensions/subagent/activity.d.ts +1 -0
  87. package/dist/extensions/subagent/activity.d.ts.map +1 -1
  88. package/dist/extensions/subagent/activity.js +20 -4
  89. package/dist/extensions/subagent/activity.js.map +1 -1
  90. package/dist/extensions/subagent/agents.d.ts.map +1 -1
  91. package/dist/extensions/subagent/agents.js +13 -18
  92. package/dist/extensions/subagent/agents.js.map +1 -1
  93. package/dist/extensions/subagent/constants.d.ts +5 -4
  94. package/dist/extensions/subagent/constants.d.ts.map +1 -1
  95. package/dist/extensions/subagent/constants.js +12 -5
  96. package/dist/extensions/subagent/constants.js.map +1 -1
  97. package/dist/extensions/subagent/index.d.ts.map +1 -1
  98. package/dist/extensions/subagent/index.js +12 -5
  99. package/dist/extensions/subagent/index.js.map +1 -1
  100. package/dist/extensions/subagent/render.d.ts.map +1 -1
  101. package/dist/extensions/subagent/render.js +166 -41
  102. package/dist/extensions/subagent/render.js.map +1 -1
  103. package/dist/extensions/subagent/resolve.d.ts +2 -5
  104. package/dist/extensions/subagent/resolve.d.ts.map +1 -1
  105. package/dist/extensions/subagent/resolve.js +8 -13
  106. package/dist/extensions/subagent/resolve.js.map +1 -1
  107. package/dist/extensions/subagent/runner.d.ts +3 -1
  108. package/dist/extensions/subagent/runner.d.ts.map +1 -1
  109. package/dist/extensions/subagent/runner.js +115 -66
  110. package/dist/extensions/subagent/runner.js.map +1 -1
  111. package/dist/extensions/subagent/schema.d.ts +0 -6
  112. package/dist/extensions/subagent/schema.d.ts.map +1 -1
  113. package/dist/extensions/subagent/schema.js +3 -4
  114. package/dist/extensions/subagent/schema.js.map +1 -1
  115. package/dist/extensions/subagent/sdk-runner.d.ts.map +1 -1
  116. package/dist/extensions/subagent/sdk-runner.js +37 -16
  117. package/dist/extensions/subagent/sdk-runner.js.map +1 -1
  118. package/dist/extensions/subagent/types.d.ts +10 -2
  119. package/dist/extensions/subagent/types.d.ts.map +1 -1
  120. package/dist/extensions/subagent/types.js.map +1 -1
  121. package/dist/extensions/todo/constants.d.ts +1 -1
  122. package/dist/extensions/todo/constants.d.ts.map +1 -1
  123. package/dist/extensions/todo/constants.js +29 -4
  124. package/dist/extensions/todo/constants.js.map +1 -1
  125. package/dist/extensions/todo/index.d.ts +1 -1
  126. package/dist/extensions/todo/index.d.ts.map +1 -1
  127. package/dist/extensions/todo/index.js +22 -3
  128. package/dist/extensions/todo/index.js.map +1 -1
  129. package/dist/extensions/todo/schema.d.ts +6 -5
  130. package/dist/extensions/todo/schema.d.ts.map +1 -1
  131. package/dist/extensions/todo/schema.js +20 -9
  132. package/dist/extensions/todo/schema.js.map +1 -1
  133. package/dist/extensions/todo/state.d.ts +5 -1
  134. package/dist/extensions/todo/state.d.ts.map +1 -1
  135. package/dist/extensions/todo/state.js +184 -38
  136. package/dist/extensions/todo/state.js.map +1 -1
  137. package/dist/extensions/todo/view.d.ts.map +1 -1
  138. package/dist/extensions/todo/view.js +10 -6
  139. package/dist/extensions/todo/view.js.map +1 -1
  140. package/dist/modes/interactive/interactive-mode.d.ts +2 -0
  141. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  142. package/dist/modes/interactive/interactive-mode.js +36 -2
  143. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  144. package/dist/utils/coalescing-terminal.d.ts +116 -0
  145. package/dist/utils/coalescing-terminal.d.ts.map +1 -0
  146. package/dist/utils/coalescing-terminal.js +256 -0
  147. package/dist/utils/coalescing-terminal.js.map +1 -0
  148. package/docs/bundled/extensions/README.md +1 -0
  149. package/docs/bundled/extensions/plan.md +31 -0
  150. package/docs/bundled/extensions/question.md +9 -7
  151. package/docs/bundled/extensions/rewind.md +19 -8
  152. package/docs/bundled/extensions/subagent.md +16 -16
  153. package/docs/bundled/extensions/todo.md +5 -3
  154. package/docs/compaction.md +6 -2
  155. package/docs/docs.json +4 -0
  156. package/docs/extensions.md +4 -2
  157. package/npm-shrinkwrap.json +2 -2
  158. package/package.json +1 -1
@@ -0,0 +1,116 @@
1
+ import { ProcessTerminal } from "@earendil-works/pi-tui";
2
+ /**
3
+ * ProcessTerminal that (1) coalesces every write issued within one synchronous
4
+ * task into a single stdout write and (2) keeps content-driven full redraws
5
+ * from destroying the terminal's scrollback.
6
+ *
7
+ * Coalescing: pi-tui emits each render pass as several separate stdout
8
+ * writes: the frame wrapped in synchronized output (CSI ?2026h/l), then a
9
+ * relative cursor move that parks the hardware cursor at the focused
10
+ * component's caret for IME composition, then a cursor visibility toggle.
11
+ * Terminals that anchor the IME candidate window to the hardware cursor
12
+ * (Windows Terminal and other ConPTY hosts, WezTerm) can process the frame
13
+ * write before the reposition write arrives, briefly leaving the cursor at
14
+ * the end of the last repainted line. With an IME active this makes the
15
+ * candidate window jump between the caret and the right edge of the input
16
+ * line on every keystroke. Merging the writes makes each frame atomic, so the
17
+ * only cursor position a terminal can observe is the final one.
18
+ *
19
+ * Scrollback preservation: pi-tui's fullRender(clear) opens with
20
+ * `2J H 3J`, wiping the terminal's scrollback, and then replays the entire
21
+ * transcript. During streaming, content-driven triggers (a line changing
22
+ * above the viewport during markdown reflow) fire this routinely, which — in
23
+ * terminals that clamp the scroll offset when scrollback vanishes, notably
24
+ * Windows Terminal — yanks a scrolled-up reader to the top of the buffer on
25
+ * every reflow (upstream #6502, #5576, #6050; rejected upstream fix #4204).
26
+ *
27
+ * Preservation is therefore windowed, not permanent: the host enables it via
28
+ * setScrollbackPreservation while an agent run is mutating the transcript
29
+ * (and keeps it on until the user's next input after the run settles, since
30
+ * they may still be scrolled up reading), and leaves it off otherwise. An
31
+ * idle-session full redraw — Ctrl+O toggles, mode switches — passes through
32
+ * with upstream's wipe-and-replay, whose result is byte-perfect: scrollback
33
+ * becomes the clean new transcript. Mid-run user toggles can arm
34
+ * passNextFullRedraw for the same clean replay on their own frame.
35
+ *
36
+ * While preservation is active, a matching frame is rewritten to repaint
37
+ * without the wipe. With setViewportTopProvider wired, the rewrite knows
38
+ * which transcript line the visible screen starts at, so a frame that grew
39
+ * the transcript repaints the on-screen rows with the new content at those
40
+ * same positions and then scrolls the extra lines in at the bottom row.
41
+ * Each bottom-row newline pushes the top row — already repainted with the
42
+ * correct new content — into scrollback, exactly like ordinary streaming
43
+ * output, so scrollback ends up byte-equivalent to the upstream full replay
44
+ * without ever clearing it. Without the provider, or when the transcript
45
+ * shrank (lines cannot be pulled back out of scrollback), the rewrite falls
46
+ * back to overwriting the visible screen in place with the bottom
47
+ * viewport-height rows, at the cost of a stale seam above the screen. ED 2
48
+ * is avoided entirely in rewritten frames because conhost/Windows Terminal
49
+ * implement it by scrolling the screen contents into scrollback rather than
50
+ * erasing in place.
51
+ *
52
+ * Width- and height-change redraws keep the upstream wipe because re-wrapped
53
+ * content genuinely invalidates old scrollback, and frames containing kitty
54
+ * graphics pass through untouched. A frame that does not match the expected
55
+ * shape is passed through unchanged, so if upstream reshapes its render
56
+ * output the wrapper degrades back to stock behavior.
57
+ */
58
+ export declare class CoalescingTerminal extends ProcessTerminal {
59
+ private pending;
60
+ private flushQueued;
61
+ private lastWriteColumns;
62
+ private lastWriteRows;
63
+ private viewportTopProvider;
64
+ private preserve;
65
+ private passArmed;
66
+ private flushOnExit;
67
+ /**
68
+ * Enable or disable the scrollback-preserving rewrite. Preservation is
69
+ * only worth its imperfections (stale renderings above the screen, seams
70
+ * on shrink) while the transcript is mutating on its own and the reader
71
+ * may be scrolled up — i.e. during an agent run and until the user's next
72
+ * input after it settles. Outside that window every full redraw passes
73
+ * through with upstream's wipe-and-replay, whose result is byte-perfect:
74
+ * scrollback becomes the clean new transcript. Default: disabled.
75
+ */
76
+ setScrollbackPreservation(enabled: boolean): void;
77
+ /**
78
+ * Arm a one-shot exemption for an explicit user action (Ctrl+O tool
79
+ * expansion, thinking visibility) so its own full redraw keeps the
80
+ * upstream wipe-and-replay even while preservation is active mid-run.
81
+ * The user just pressed a key, so they are interacting at the bottom;
82
+ * a clean replay beats preservation's stale-seam trade-offs there. The
83
+ * flag is cleared by the next write so it cannot leak onto a later
84
+ * streaming redraw.
85
+ */
86
+ passNextFullRedraw(): void;
87
+ /**
88
+ * Wire the renderer's notion of which transcript line the visible screen
89
+ * currently starts at (pi-tui's `previousViewportTop`). Full-redraw frames
90
+ * that grew the transcript then scroll the new above-screen lines into
91
+ * scrollback instead of dropping them. The provider is read while the
92
+ * frame is being written, i.e. before the renderer updates its own
93
+ * bookkeeping for that frame; returning undefined falls back to the
94
+ * bottom-anchored in-place repaint.
95
+ */
96
+ setViewportTopProvider(provider: () => number | undefined): void;
97
+ constructor();
98
+ write(data: string): void;
99
+ moveBy(lines: number): void;
100
+ hideCursor(): void;
101
+ showCursor(): void;
102
+ clearLine(): void;
103
+ clearFromCursor(): void;
104
+ clearScreen(): void;
105
+ stop(): void;
106
+ /**
107
+ * Rewrite a full-redraw frame (`?2026h 2J H 3J <lines> ?2026l`) so it
108
+ * repaints the screen without wiping scrollback. Anything that does not
109
+ * match this exact shape — including width-change redraws and frames
110
+ * touching kitty images — passes through unchanged.
111
+ */
112
+ private preserveScrollback;
113
+ private readViewportTop;
114
+ private flush;
115
+ }
116
+ //# sourceMappingURL=coalescing-terminal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coalescing-terminal.d.ts","sourceRoot":"","sources":["../../src/utils/coalescing-terminal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AASzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH,qBAAa,kBAAmB,SAAQ,eAAe;IACtD,OAAO,CAAC,OAAO,CAAM;IACrB,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,gBAAgB,CAAqB;IAC7C,OAAO,CAAC,aAAa,CAAqB;IAC1C,OAAO,CAAC,mBAAmB,CAAyC;IACpE,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,WAAW,CAAsB;IAEzC;;;;;;;;OAQG;IACH,yBAAyB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAEhD;IAED;;;;;;;;OAQG;IACH,kBAAkB,IAAI,IAAI,CAEzB;IAED;;;;;;;;OAQG;IACH,sBAAsB,CAAC,QAAQ,EAAE,MAAM,MAAM,GAAG,SAAS,GAAG,IAAI,CAE/D;IAED,cAKC;IAEQ,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAMjC;IAIQ,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAMnC;IAEQ,UAAU,IAAI,IAAI,CAE1B;IAEQ,UAAU,IAAI,IAAI,CAE1B;IAEQ,SAAS,IAAI,IAAI,CAEzB;IAEQ,eAAe,IAAI,IAAI,CAE/B;IAEQ,WAAW,IAAI,IAAI,CAE3B;IAEQ,IAAI,IAAI,IAAI,CAMpB;IAED;;;;;OAKG;IACH,OAAO,CAAC,kBAAkB;IA2E1B,OAAO,CAAC,eAAe;IAUvB,OAAO,CAAC,KAAK;CASb","sourcesContent":["import { ProcessTerminal } from \"@earendil-works/pi-tui\";\n\nconst SYNC_START = \"\\x1b[?2026h\";\nconst SYNC_END = \"\\x1b[?2026l\";\n/** Clear screen, home, clear scrollback — pi-tui's fullRender(clear) preamble. */\nconst CLEAR_WITH_SCROLLBACK = \"\\x1b[2J\\x1b[H\\x1b[3J\";\n/** Kitty graphics APC prefix; frames touching images interleave cursor moves. */\nconst KITTY_APC_PREFIX = \"\\x1b_G\";\n\n/**\n * ProcessTerminal that (1) coalesces every write issued within one synchronous\n * task into a single stdout write and (2) keeps content-driven full redraws\n * from destroying the terminal's scrollback.\n *\n * Coalescing: pi-tui emits each render pass as several separate stdout\n * writes: the frame wrapped in synchronized output (CSI ?2026h/l), then a\n * relative cursor move that parks the hardware cursor at the focused\n * component's caret for IME composition, then a cursor visibility toggle.\n * Terminals that anchor the IME candidate window to the hardware cursor\n * (Windows Terminal and other ConPTY hosts, WezTerm) can process the frame\n * write before the reposition write arrives, briefly leaving the cursor at\n * the end of the last repainted line. With an IME active this makes the\n * candidate window jump between the caret and the right edge of the input\n * line on every keystroke. Merging the writes makes each frame atomic, so the\n * only cursor position a terminal can observe is the final one.\n *\n * Scrollback preservation: pi-tui's fullRender(clear) opens with\n * `2J H 3J`, wiping the terminal's scrollback, and then replays the entire\n * transcript. During streaming, content-driven triggers (a line changing\n * above the viewport during markdown reflow) fire this routinely, which — in\n * terminals that clamp the scroll offset when scrollback vanishes, notably\n * Windows Terminal — yanks a scrolled-up reader to the top of the buffer on\n * every reflow (upstream #6502, #5576, #6050; rejected upstream fix #4204).\n *\n * Preservation is therefore windowed, not permanent: the host enables it via\n * setScrollbackPreservation while an agent run is mutating the transcript\n * (and keeps it on until the user's next input after the run settles, since\n * they may still be scrolled up reading), and leaves it off otherwise. An\n * idle-session full redraw — Ctrl+O toggles, mode switches — passes through\n * with upstream's wipe-and-replay, whose result is byte-perfect: scrollback\n * becomes the clean new transcript. Mid-run user toggles can arm\n * passNextFullRedraw for the same clean replay on their own frame.\n *\n * While preservation is active, a matching frame is rewritten to repaint\n * without the wipe. With setViewportTopProvider wired, the rewrite knows\n * which transcript line the visible screen starts at, so a frame that grew\n * the transcript repaints the on-screen rows with the new content at those\n * same positions and then scrolls the extra lines in at the bottom row.\n * Each bottom-row newline pushes the top row — already repainted with the\n * correct new content — into scrollback, exactly like ordinary streaming\n * output, so scrollback ends up byte-equivalent to the upstream full replay\n * without ever clearing it. Without the provider, or when the transcript\n * shrank (lines cannot be pulled back out of scrollback), the rewrite falls\n * back to overwriting the visible screen in place with the bottom\n * viewport-height rows, at the cost of a stale seam above the screen. ED 2\n * is avoided entirely in rewritten frames because conhost/Windows Terminal\n * implement it by scrolling the screen contents into scrollback rather than\n * erasing in place.\n *\n * Width- and height-change redraws keep the upstream wipe because re-wrapped\n * content genuinely invalidates old scrollback, and frames containing kitty\n * graphics pass through untouched. A frame that does not match the expected\n * shape is passed through unchanged, so if upstream reshapes its render\n * output the wrapper degrades back to stock behavior.\n */\nexport class CoalescingTerminal extends ProcessTerminal {\n\tprivate pending = \"\";\n\tprivate flushQueued = false;\n\tprivate lastWriteColumns: number | undefined;\n\tprivate lastWriteRows: number | undefined;\n\tprivate viewportTopProvider: (() => number | undefined) | undefined;\n\tprivate preserve = false;\n\tprivate passArmed = false;\n\tprivate flushOnExit = () => this.flush();\n\n\t/**\n\t * Enable or disable the scrollback-preserving rewrite. Preservation is\n\t * only worth its imperfections (stale renderings above the screen, seams\n\t * on shrink) while the transcript is mutating on its own and the reader\n\t * may be scrolled up — i.e. during an agent run and until the user's next\n\t * input after it settles. Outside that window every full redraw passes\n\t * through with upstream's wipe-and-replay, whose result is byte-perfect:\n\t * scrollback becomes the clean new transcript. Default: disabled.\n\t */\n\tsetScrollbackPreservation(enabled: boolean): void {\n\t\tthis.preserve = enabled;\n\t}\n\n\t/**\n\t * Arm a one-shot exemption for an explicit user action (Ctrl+O tool\n\t * expansion, thinking visibility) so its own full redraw keeps the\n\t * upstream wipe-and-replay even while preservation is active mid-run.\n\t * The user just pressed a key, so they are interacting at the bottom;\n\t * a clean replay beats preservation's stale-seam trade-offs there. The\n\t * flag is cleared by the next write so it cannot leak onto a later\n\t * streaming redraw.\n\t */\n\tpassNextFullRedraw(): void {\n\t\tthis.passArmed = true;\n\t}\n\n\t/**\n\t * Wire the renderer's notion of which transcript line the visible screen\n\t * currently starts at (pi-tui's `previousViewportTop`). Full-redraw frames\n\t * that grew the transcript then scroll the new above-screen lines into\n\t * scrollback instead of dropping them. The provider is read while the\n\t * frame is being written, i.e. before the renderer updates its own\n\t * bookkeeping for that frame; returning undefined falls back to the\n\t * bottom-anchored in-place repaint.\n\t */\n\tsetViewportTopProvider(provider: () => number | undefined): void {\n\t\tthis.viewportTopProvider = provider;\n\t}\n\n\tconstructor() {\n\t\tsuper();\n\t\t// A synchronous process.exit() before the queued microtask runs would\n\t\t// otherwise drop the final frame.\n\t\tprocess.on(\"exit\", this.flushOnExit);\n\t}\n\n\toverride write(data: string): void {\n\t\tif (data.length === 0) return;\n\t\tthis.pending += this.preserveScrollback(data);\n\t\tif (this.flushQueued) return;\n\t\tthis.flushQueued = true;\n\t\tqueueMicrotask(() => this.flush());\n\t}\n\n\t// Route every escape-sequence helper through the coalescing buffer so a\n\t// caller mixing helpers with write() keeps its output ordered.\n\toverride moveBy(lines: number): void {\n\t\tif (lines > 0) {\n\t\t\tthis.write(`\\x1b[${lines}B`);\n\t\t} else if (lines < 0) {\n\t\t\tthis.write(`\\x1b[${-lines}A`);\n\t\t}\n\t}\n\n\toverride hideCursor(): void {\n\t\tthis.write(\"\\x1b[?25l\");\n\t}\n\n\toverride showCursor(): void {\n\t\tthis.write(\"\\x1b[?25h\");\n\t}\n\n\toverride clearLine(): void {\n\t\tthis.write(\"\\x1b[K\");\n\t}\n\n\toverride clearFromCursor(): void {\n\t\tthis.write(\"\\x1b[J\");\n\t}\n\n\toverride clearScreen(): void {\n\t\tthis.write(\"\\x1b[2J\\x1b[H\");\n\t}\n\n\toverride stop(): void {\n\t\t// ProcessTerminal.stop() writes its teardown sequences directly to\n\t\t// stdout, so pending frame data must go out first.\n\t\tthis.flush();\n\t\tprocess.removeListener(\"exit\", this.flushOnExit);\n\t\tsuper.stop();\n\t}\n\n\t/**\n\t * Rewrite a full-redraw frame (`?2026h 2J H 3J <lines> ?2026l`) so it\n\t * repaints the screen without wiping scrollback. Anything that does not\n\t * match this exact shape — including width-change redraws and frames\n\t * touching kitty images — passes through unchanged.\n\t */\n\tprivate preserveScrollback(data: string): string {\n\t\tconst columns = this.columns;\n\t\tconst rows = this.rows;\n\t\tconst widthChanged = this.lastWriteColumns !== undefined && this.lastWriteColumns !== columns;\n\t\tconst heightChanged = this.lastWriteRows !== undefined && this.lastWriteRows !== rows;\n\t\tthis.lastWriteColumns = columns;\n\t\tthis.lastWriteRows = rows;\n\t\t// One-shot: the very next write is the armed toggle's own frame (or a\n\t\t// differential frame when the toggle stayed inside the viewport, in\n\t\t// which case no exemption is needed and the flag must not linger).\n\t\tconst passAllowed = this.passArmed;\n\t\tthis.passArmed = false;\n\n\t\tconst prefix = SYNC_START + CLEAR_WITH_SCROLLBACK;\n\t\tif (!data.startsWith(prefix) || !data.endsWith(SYNC_END)) {\n\t\t\treturn data;\n\t\t}\n\t\t// Outside the preservation window (idle sessions, pre-session UI) and\n\t\t// for armed user toggles, upstream's wipe-and-replay is byte-perfect;\n\t\t// let it through.\n\t\tif (!this.preserve || passAllowed) {\n\t\t\treturn data;\n\t\t}\n\t\t// Re-wrapped/re-flowed content invalidates what scrollback holds and\n\t\t// where the screen sits in it; keep the wipe.\n\t\tif (widthChanged || heightChanged) {\n\t\t\treturn data;\n\t\t}\n\t\tif (data.includes(KITTY_APC_PREFIX)) {\n\t\t\treturn data;\n\t\t}\n\t\tconst body = data.slice(prefix.length, data.length - SYNC_END.length);\n\t\t// A second clear inside one write means the frame shape changed\n\t\t// upstream; bail out rather than guess.\n\t\tif (body.includes(CLEAR_WITH_SCROLLBACK)) {\n\t\t\treturn data;\n\t\t}\n\t\t// ED 2 (`2J`) is deliberately avoided in both rewrites below: conhost/\n\t\t// Windows Terminal implement it by scrolling the current screen into\n\t\t// scrollback (cls compatibility), so without the `3J` wipe each redraw\n\t\t// would stack another screenful of duplicates into history.\n\t\tconst lines = body.split(\"\\r\\n\");\n\t\tconst newViewportTop = Math.max(0, lines.length - rows);\n\t\tconst viewportTop = this.readViewportTop();\n\t\tif (viewportTop !== undefined && viewportTop <= newViewportTop) {\n\t\t\t// The transcript grew (or held steady): repaint the rows currently\n\t\t\t// on screen with the new content at those same transcript\n\t\t\t// positions, then scroll the remaining lines in at the bottom row.\n\t\t\t// Each bottom-row newline pushes the already-repainted top row into\n\t\t\t// scrollback, so scrollback receives exactly the new lines the\n\t\t\t// upstream replay would have put there — no wipe, no loss.\n\t\t\tconst onScreen = lines.slice(viewportTop, viewportTop + rows);\n\t\t\tconst scrolled = lines.slice(viewportTop + rows);\n\t\t\tlet repaint = onScreen.map((line) => `\\x1b[2K${line}`).join(\"\\r\\n\");\n\t\t\tfor (const line of scrolled) {\n\t\t\t\trepaint += `\\r\\n\\x1b[2K${line}`;\n\t\t\t}\n\t\t\treturn `${SYNC_START}\\x1b[H${repaint}\\x1b[J${SYNC_END}`;\n\t\t}\n\t\t// Fallback (no provider, or the transcript shrank — lines cannot be\n\t\t// pulled back out of scrollback): repaint only the bottom\n\t\t// viewport-height rows, overwriting the screen in place: home,\n\t\t// erase-and-rewrite each row, erase whatever remains below. Writing the\n\t\t// full transcript after a screen clear would push a duplicate copy of\n\t\t// everything above the viewport into scrollback; the truncated\n\t\t// overwrite produces the exact same final screen and cursor position as\n\t\t// the full replay (rows beyond the screen would have scrolled out\n\t\t// anyway).\n\t\tconst kept = lines.length > rows ? lines.slice(lines.length - rows) : lines;\n\t\tconst repaint = kept.map((line) => `\\x1b[2K${line}`).join(\"\\r\\n\");\n\t\treturn `${SYNC_START}\\x1b[H${repaint}\\x1b[J${SYNC_END}`;\n\t}\n\n\t// The provider reaches into the renderer's bookkeeping; treat anything\n\t// unexpected as \"unknown\" and fall back rather than corrupt the screen.\n\tprivate readViewportTop(): number | undefined {\n\t\tif (!this.viewportTopProvider) return undefined;\n\t\ttry {\n\t\t\tconst value = this.viewportTopProvider();\n\t\t\treturn typeof value === \"number\" && Number.isInteger(value) && value >= 0 ? value : undefined;\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate flush(): void {\n\t\tthis.flushQueued = false;\n\t\tif (this.pending.length === 0) {\n\t\t\treturn;\n\t\t}\n\t\tconst data = this.pending;\n\t\tthis.pending = \"\";\n\t\tsuper.write(data);\n\t}\n}\n"]}
@@ -0,0 +1,256 @@
1
+ import { ProcessTerminal } from "@earendil-works/pi-tui";
2
+ const SYNC_START = "\x1b[?2026h";
3
+ const SYNC_END = "\x1b[?2026l";
4
+ /** Clear screen, home, clear scrollback — pi-tui's fullRender(clear) preamble. */
5
+ const CLEAR_WITH_SCROLLBACK = "\x1b[2J\x1b[H\x1b[3J";
6
+ /** Kitty graphics APC prefix; frames touching images interleave cursor moves. */
7
+ const KITTY_APC_PREFIX = "\x1b_G";
8
+ /**
9
+ * ProcessTerminal that (1) coalesces every write issued within one synchronous
10
+ * task into a single stdout write and (2) keeps content-driven full redraws
11
+ * from destroying the terminal's scrollback.
12
+ *
13
+ * Coalescing: pi-tui emits each render pass as several separate stdout
14
+ * writes: the frame wrapped in synchronized output (CSI ?2026h/l), then a
15
+ * relative cursor move that parks the hardware cursor at the focused
16
+ * component's caret for IME composition, then a cursor visibility toggle.
17
+ * Terminals that anchor the IME candidate window to the hardware cursor
18
+ * (Windows Terminal and other ConPTY hosts, WezTerm) can process the frame
19
+ * write before the reposition write arrives, briefly leaving the cursor at
20
+ * the end of the last repainted line. With an IME active this makes the
21
+ * candidate window jump between the caret and the right edge of the input
22
+ * line on every keystroke. Merging the writes makes each frame atomic, so the
23
+ * only cursor position a terminal can observe is the final one.
24
+ *
25
+ * Scrollback preservation: pi-tui's fullRender(clear) opens with
26
+ * `2J H 3J`, wiping the terminal's scrollback, and then replays the entire
27
+ * transcript. During streaming, content-driven triggers (a line changing
28
+ * above the viewport during markdown reflow) fire this routinely, which — in
29
+ * terminals that clamp the scroll offset when scrollback vanishes, notably
30
+ * Windows Terminal — yanks a scrolled-up reader to the top of the buffer on
31
+ * every reflow (upstream #6502, #5576, #6050; rejected upstream fix #4204).
32
+ *
33
+ * Preservation is therefore windowed, not permanent: the host enables it via
34
+ * setScrollbackPreservation while an agent run is mutating the transcript
35
+ * (and keeps it on until the user's next input after the run settles, since
36
+ * they may still be scrolled up reading), and leaves it off otherwise. An
37
+ * idle-session full redraw — Ctrl+O toggles, mode switches — passes through
38
+ * with upstream's wipe-and-replay, whose result is byte-perfect: scrollback
39
+ * becomes the clean new transcript. Mid-run user toggles can arm
40
+ * passNextFullRedraw for the same clean replay on their own frame.
41
+ *
42
+ * While preservation is active, a matching frame is rewritten to repaint
43
+ * without the wipe. With setViewportTopProvider wired, the rewrite knows
44
+ * which transcript line the visible screen starts at, so a frame that grew
45
+ * the transcript repaints the on-screen rows with the new content at those
46
+ * same positions and then scrolls the extra lines in at the bottom row.
47
+ * Each bottom-row newline pushes the top row — already repainted with the
48
+ * correct new content — into scrollback, exactly like ordinary streaming
49
+ * output, so scrollback ends up byte-equivalent to the upstream full replay
50
+ * without ever clearing it. Without the provider, or when the transcript
51
+ * shrank (lines cannot be pulled back out of scrollback), the rewrite falls
52
+ * back to overwriting the visible screen in place with the bottom
53
+ * viewport-height rows, at the cost of a stale seam above the screen. ED 2
54
+ * is avoided entirely in rewritten frames because conhost/Windows Terminal
55
+ * implement it by scrolling the screen contents into scrollback rather than
56
+ * erasing in place.
57
+ *
58
+ * Width- and height-change redraws keep the upstream wipe because re-wrapped
59
+ * content genuinely invalidates old scrollback, and frames containing kitty
60
+ * graphics pass through untouched. A frame that does not match the expected
61
+ * shape is passed through unchanged, so if upstream reshapes its render
62
+ * output the wrapper degrades back to stock behavior.
63
+ */
64
+ export class CoalescingTerminal extends ProcessTerminal {
65
+ pending = "";
66
+ flushQueued = false;
67
+ lastWriteColumns;
68
+ lastWriteRows;
69
+ viewportTopProvider;
70
+ preserve = false;
71
+ passArmed = false;
72
+ flushOnExit = () => this.flush();
73
+ /**
74
+ * Enable or disable the scrollback-preserving rewrite. Preservation is
75
+ * only worth its imperfections (stale renderings above the screen, seams
76
+ * on shrink) while the transcript is mutating on its own and the reader
77
+ * may be scrolled up — i.e. during an agent run and until the user's next
78
+ * input after it settles. Outside that window every full redraw passes
79
+ * through with upstream's wipe-and-replay, whose result is byte-perfect:
80
+ * scrollback becomes the clean new transcript. Default: disabled.
81
+ */
82
+ setScrollbackPreservation(enabled) {
83
+ this.preserve = enabled;
84
+ }
85
+ /**
86
+ * Arm a one-shot exemption for an explicit user action (Ctrl+O tool
87
+ * expansion, thinking visibility) so its own full redraw keeps the
88
+ * upstream wipe-and-replay even while preservation is active mid-run.
89
+ * The user just pressed a key, so they are interacting at the bottom;
90
+ * a clean replay beats preservation's stale-seam trade-offs there. The
91
+ * flag is cleared by the next write so it cannot leak onto a later
92
+ * streaming redraw.
93
+ */
94
+ passNextFullRedraw() {
95
+ this.passArmed = true;
96
+ }
97
+ /**
98
+ * Wire the renderer's notion of which transcript line the visible screen
99
+ * currently starts at (pi-tui's `previousViewportTop`). Full-redraw frames
100
+ * that grew the transcript then scroll the new above-screen lines into
101
+ * scrollback instead of dropping them. The provider is read while the
102
+ * frame is being written, i.e. before the renderer updates its own
103
+ * bookkeeping for that frame; returning undefined falls back to the
104
+ * bottom-anchored in-place repaint.
105
+ */
106
+ setViewportTopProvider(provider) {
107
+ this.viewportTopProvider = provider;
108
+ }
109
+ constructor() {
110
+ super();
111
+ // A synchronous process.exit() before the queued microtask runs would
112
+ // otherwise drop the final frame.
113
+ process.on("exit", this.flushOnExit);
114
+ }
115
+ write(data) {
116
+ if (data.length === 0)
117
+ return;
118
+ this.pending += this.preserveScrollback(data);
119
+ if (this.flushQueued)
120
+ return;
121
+ this.flushQueued = true;
122
+ queueMicrotask(() => this.flush());
123
+ }
124
+ // Route every escape-sequence helper through the coalescing buffer so a
125
+ // caller mixing helpers with write() keeps its output ordered.
126
+ moveBy(lines) {
127
+ if (lines > 0) {
128
+ this.write(`\x1b[${lines}B`);
129
+ }
130
+ else if (lines < 0) {
131
+ this.write(`\x1b[${-lines}A`);
132
+ }
133
+ }
134
+ hideCursor() {
135
+ this.write("\x1b[?25l");
136
+ }
137
+ showCursor() {
138
+ this.write("\x1b[?25h");
139
+ }
140
+ clearLine() {
141
+ this.write("\x1b[K");
142
+ }
143
+ clearFromCursor() {
144
+ this.write("\x1b[J");
145
+ }
146
+ clearScreen() {
147
+ this.write("\x1b[2J\x1b[H");
148
+ }
149
+ stop() {
150
+ // ProcessTerminal.stop() writes its teardown sequences directly to
151
+ // stdout, so pending frame data must go out first.
152
+ this.flush();
153
+ process.removeListener("exit", this.flushOnExit);
154
+ super.stop();
155
+ }
156
+ /**
157
+ * Rewrite a full-redraw frame (`?2026h 2J H 3J <lines> ?2026l`) so it
158
+ * repaints the screen without wiping scrollback. Anything that does not
159
+ * match this exact shape — including width-change redraws and frames
160
+ * touching kitty images — passes through unchanged.
161
+ */
162
+ preserveScrollback(data) {
163
+ const columns = this.columns;
164
+ const rows = this.rows;
165
+ const widthChanged = this.lastWriteColumns !== undefined && this.lastWriteColumns !== columns;
166
+ const heightChanged = this.lastWriteRows !== undefined && this.lastWriteRows !== rows;
167
+ this.lastWriteColumns = columns;
168
+ this.lastWriteRows = rows;
169
+ // One-shot: the very next write is the armed toggle's own frame (or a
170
+ // differential frame when the toggle stayed inside the viewport, in
171
+ // which case no exemption is needed and the flag must not linger).
172
+ const passAllowed = this.passArmed;
173
+ this.passArmed = false;
174
+ const prefix = SYNC_START + CLEAR_WITH_SCROLLBACK;
175
+ if (!data.startsWith(prefix) || !data.endsWith(SYNC_END)) {
176
+ return data;
177
+ }
178
+ // Outside the preservation window (idle sessions, pre-session UI) and
179
+ // for armed user toggles, upstream's wipe-and-replay is byte-perfect;
180
+ // let it through.
181
+ if (!this.preserve || passAllowed) {
182
+ return data;
183
+ }
184
+ // Re-wrapped/re-flowed content invalidates what scrollback holds and
185
+ // where the screen sits in it; keep the wipe.
186
+ if (widthChanged || heightChanged) {
187
+ return data;
188
+ }
189
+ if (data.includes(KITTY_APC_PREFIX)) {
190
+ return data;
191
+ }
192
+ const body = data.slice(prefix.length, data.length - SYNC_END.length);
193
+ // A second clear inside one write means the frame shape changed
194
+ // upstream; bail out rather than guess.
195
+ if (body.includes(CLEAR_WITH_SCROLLBACK)) {
196
+ return data;
197
+ }
198
+ // ED 2 (`2J`) is deliberately avoided in both rewrites below: conhost/
199
+ // Windows Terminal implement it by scrolling the current screen into
200
+ // scrollback (cls compatibility), so without the `3J` wipe each redraw
201
+ // would stack another screenful of duplicates into history.
202
+ const lines = body.split("\r\n");
203
+ const newViewportTop = Math.max(0, lines.length - rows);
204
+ const viewportTop = this.readViewportTop();
205
+ if (viewportTop !== undefined && viewportTop <= newViewportTop) {
206
+ // The transcript grew (or held steady): repaint the rows currently
207
+ // on screen with the new content at those same transcript
208
+ // positions, then scroll the remaining lines in at the bottom row.
209
+ // Each bottom-row newline pushes the already-repainted top row into
210
+ // scrollback, so scrollback receives exactly the new lines the
211
+ // upstream replay would have put there — no wipe, no loss.
212
+ const onScreen = lines.slice(viewportTop, viewportTop + rows);
213
+ const scrolled = lines.slice(viewportTop + rows);
214
+ let repaint = onScreen.map((line) => `\x1b[2K${line}`).join("\r\n");
215
+ for (const line of scrolled) {
216
+ repaint += `\r\n\x1b[2K${line}`;
217
+ }
218
+ return `${SYNC_START}\x1b[H${repaint}\x1b[J${SYNC_END}`;
219
+ }
220
+ // Fallback (no provider, or the transcript shrank — lines cannot be
221
+ // pulled back out of scrollback): repaint only the bottom
222
+ // viewport-height rows, overwriting the screen in place: home,
223
+ // erase-and-rewrite each row, erase whatever remains below. Writing the
224
+ // full transcript after a screen clear would push a duplicate copy of
225
+ // everything above the viewport into scrollback; the truncated
226
+ // overwrite produces the exact same final screen and cursor position as
227
+ // the full replay (rows beyond the screen would have scrolled out
228
+ // anyway).
229
+ const kept = lines.length > rows ? lines.slice(lines.length - rows) : lines;
230
+ const repaint = kept.map((line) => `\x1b[2K${line}`).join("\r\n");
231
+ return `${SYNC_START}\x1b[H${repaint}\x1b[J${SYNC_END}`;
232
+ }
233
+ // The provider reaches into the renderer's bookkeeping; treat anything
234
+ // unexpected as "unknown" and fall back rather than corrupt the screen.
235
+ readViewportTop() {
236
+ if (!this.viewportTopProvider)
237
+ return undefined;
238
+ try {
239
+ const value = this.viewportTopProvider();
240
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
241
+ }
242
+ catch {
243
+ return undefined;
244
+ }
245
+ }
246
+ flush() {
247
+ this.flushQueued = false;
248
+ if (this.pending.length === 0) {
249
+ return;
250
+ }
251
+ const data = this.pending;
252
+ this.pending = "";
253
+ super.write(data);
254
+ }
255
+ }
256
+ //# sourceMappingURL=coalescing-terminal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coalescing-terminal.js","sourceRoot":"","sources":["../../src/utils/coalescing-terminal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAEzD,MAAM,UAAU,GAAG,aAAa,CAAC;AACjC,MAAM,QAAQ,GAAG,aAAa,CAAC;AAC/B,oFAAkF;AAClF,MAAM,qBAAqB,GAAG,sBAAsB,CAAC;AACrD,iFAAiF;AACjF,MAAM,gBAAgB,GAAG,QAAQ,CAAC;AAElC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH,MAAM,OAAO,kBAAmB,SAAQ,eAAe;IAC9C,OAAO,GAAG,EAAE,CAAC;IACb,WAAW,GAAG,KAAK,CAAC;IACpB,gBAAgB,CAAqB;IACrC,aAAa,CAAqB;IAClC,mBAAmB,CAAyC;IAC5D,QAAQ,GAAG,KAAK,CAAC;IACjB,SAAS,GAAG,KAAK,CAAC;IAClB,WAAW,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;IAEzC;;;;;;;;OAQG;IACH,yBAAyB,CAAC,OAAgB,EAAQ;QACjD,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;IAAA,CACxB;IAED;;;;;;;;OAQG;IACH,kBAAkB,GAAS;QAC1B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;IAAA,CACtB;IAED;;;;;;;;OAQG;IACH,sBAAsB,CAAC,QAAkC,EAAQ;QAChE,IAAI,CAAC,mBAAmB,GAAG,QAAQ,CAAC;IAAA,CACpC;IAED,cAAc;QACb,KAAK,EAAE,CAAC;QACR,sEAAsE;QACtE,kCAAkC;QAClC,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC;IAAA,CACrC;IAEQ,KAAK,CAAC,IAAY,EAAQ;QAClC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO;QAC9B,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,CAAC;QAC9C,IAAI,IAAI,CAAC,WAAW;YAAE,OAAO;QAC7B,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC;QACxB,cAAc,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;IAAA,CACnC;IAED,wEAAwE;IACxE,+DAA+D;IACtD,MAAM,CAAC,KAAa,EAAQ;QACpC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;YACf,IAAI,CAAC,KAAK,CAAC,QAAQ,KAAK,GAAG,CAAC,CAAC;QAC9B,CAAC;aAAM,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;YACtB,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,GAAG,CAAC,CAAC;QAC/B,CAAC;IAAA,CACD;IAEQ,UAAU,GAAS;QAC3B,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;IAAA,CACxB;IAEQ,UAAU,GAAS;QAC3B,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;IAAA,CACxB;IAEQ,SAAS,GAAS;QAC1B,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAAA,CACrB;IAEQ,eAAe,GAAS;QAChC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAAA,CACrB;IAEQ,WAAW,GAAS;QAC5B,IAAI,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;IAAA,CAC5B;IAEQ,IAAI,GAAS;QACrB,mEAAmE;QACnE,mDAAmD;QACnD,IAAI,CAAC,KAAK,EAAE,CAAC;QACb,OAAO,CAAC,cAAc,CAAC,MAAM,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC;QACjD,KAAK,CAAC,IAAI,EAAE,CAAC;IAAA,CACb;IAED;;;;;OAKG;IACK,kBAAkB,CAAC,IAAY,EAAU;QAChD,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;QAC7B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;QACvB,MAAM,YAAY,GAAG,IAAI,CAAC,gBAAgB,KAAK,SAAS,IAAI,IAAI,CAAC,gBAAgB,KAAK,OAAO,CAAC;QAC9F,MAAM,aAAa,GAAG,IAAI,CAAC,aAAa,KAAK,SAAS,IAAI,IAAI,CAAC,aAAa,KAAK,IAAI,CAAC;QACtF,IAAI,CAAC,gBAAgB,GAAG,OAAO,CAAC;QAChC,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC;QAC1B,sEAAsE;QACtE,oEAAoE;QACpE,mEAAmE;QACnE,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC;QACnC,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC;QAEvB,MAAM,MAAM,GAAG,UAAU,GAAG,qBAAqB,CAAC;QAClD,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC1D,OAAO,IAAI,CAAC;QACb,CAAC;QACD,sEAAsE;QACtE,sEAAsE;QACtE,kBAAkB;QAClB,IAAI,CAAC,IAAI,CAAC,QAAQ,IAAI,WAAW,EAAE,CAAC;YACnC,OAAO,IAAI,CAAC;QACb,CAAC;QACD,qEAAqE;QACrE,8CAA8C;QAC9C,IAAI,YAAY,IAAI,aAAa,EAAE,CAAC;YACnC,OAAO,IAAI,CAAC;QACb,CAAC;QACD,IAAI,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,CAAC;YACrC,OAAO,IAAI,CAAC;QACb,CAAC;QACD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;QACtE,gEAAgE;QAChE,wCAAwC;QACxC,IAAI,IAAI,CAAC,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAAC;YAC1C,OAAO,IAAI,CAAC;QACb,CAAC;QACD,uEAAuE;QACvE,qEAAqE;QACrE,uEAAuE;QACvE,4DAA4D;QAC5D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QACjC,MAAM,cAAc,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;QACxD,MAAM,WAAW,GAAG,IAAI,CAAC,eAAe,EAAE,CAAC;QAC3C,IAAI,WAAW,KAAK,SAAS,IAAI,WAAW,IAAI,cAAc,EAAE,CAAC;YAChE,mEAAmE;YACnE,0DAA0D;YAC1D,mEAAmE;YACnE,oEAAoE;YACpE,+DAA+D;YAC/D,6DAA2D;YAC3D,MAAM,QAAQ,GAAG,KAAK,CAAC,KAAK,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAAC,CAAC;YAC9D,MAAM,QAAQ,GAAG,KAAK,CAAC,KAAK,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;YACjD,IAAI,OAAO,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACpE,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;gBAC7B,OAAO,IAAI,cAAc,IAAI,EAAE,CAAC;YACjC,CAAC;YACD,OAAO,GAAG,UAAU,SAAS,OAAO,SAAS,QAAQ,EAAE,CAAC;QACzD,CAAC;QACD,sEAAoE;QACpE,0DAA0D;QAC1D,+DAA+D;QAC/D,wEAAwE;QACxE,sEAAsE;QACtE,+DAA+D;QAC/D,wEAAwE;QACxE,kEAAkE;QAClE,WAAW;QACX,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;QAC5E,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAClE,OAAO,GAAG,UAAU,SAAS,OAAO,SAAS,QAAQ,EAAE,CAAC;IAAA,CACxD;IAED,uEAAuE;IACvE,wEAAwE;IAChE,eAAe,GAAuB;QAC7C,IAAI,CAAC,IAAI,CAAC,mBAAmB;YAAE,OAAO,SAAS,CAAC;QAChD,IAAI,CAAC;YACJ,MAAM,KAAK,GAAG,IAAI,CAAC,mBAAmB,EAAE,CAAC;YACzC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;QAC/F,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,SAAS,CAAC;QAClB,CAAC;IAAA,CACD;IAEO,KAAK,GAAS;QACrB,IAAI,CAAC,WAAW,GAAG,KAAK,CAAC;QACzB,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC/B,OAAO;QACR,CAAC;QACD,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC;QAC1B,IAAI,CAAC,OAAO,GAAG,EAAE,CAAC;QAClB,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAAA,CAClB;CACD","sourcesContent":["import { ProcessTerminal } from \"@earendil-works/pi-tui\";\n\nconst SYNC_START = \"\\x1b[?2026h\";\nconst SYNC_END = \"\\x1b[?2026l\";\n/** Clear screen, home, clear scrollback — pi-tui's fullRender(clear) preamble. */\nconst CLEAR_WITH_SCROLLBACK = \"\\x1b[2J\\x1b[H\\x1b[3J\";\n/** Kitty graphics APC prefix; frames touching images interleave cursor moves. */\nconst KITTY_APC_PREFIX = \"\\x1b_G\";\n\n/**\n * ProcessTerminal that (1) coalesces every write issued within one synchronous\n * task into a single stdout write and (2) keeps content-driven full redraws\n * from destroying the terminal's scrollback.\n *\n * Coalescing: pi-tui emits each render pass as several separate stdout\n * writes: the frame wrapped in synchronized output (CSI ?2026h/l), then a\n * relative cursor move that parks the hardware cursor at the focused\n * component's caret for IME composition, then a cursor visibility toggle.\n * Terminals that anchor the IME candidate window to the hardware cursor\n * (Windows Terminal and other ConPTY hosts, WezTerm) can process the frame\n * write before the reposition write arrives, briefly leaving the cursor at\n * the end of the last repainted line. With an IME active this makes the\n * candidate window jump between the caret and the right edge of the input\n * line on every keystroke. Merging the writes makes each frame atomic, so the\n * only cursor position a terminal can observe is the final one.\n *\n * Scrollback preservation: pi-tui's fullRender(clear) opens with\n * `2J H 3J`, wiping the terminal's scrollback, and then replays the entire\n * transcript. During streaming, content-driven triggers (a line changing\n * above the viewport during markdown reflow) fire this routinely, which — in\n * terminals that clamp the scroll offset when scrollback vanishes, notably\n * Windows Terminal — yanks a scrolled-up reader to the top of the buffer on\n * every reflow (upstream #6502, #5576, #6050; rejected upstream fix #4204).\n *\n * Preservation is therefore windowed, not permanent: the host enables it via\n * setScrollbackPreservation while an agent run is mutating the transcript\n * (and keeps it on until the user's next input after the run settles, since\n * they may still be scrolled up reading), and leaves it off otherwise. An\n * idle-session full redraw — Ctrl+O toggles, mode switches — passes through\n * with upstream's wipe-and-replay, whose result is byte-perfect: scrollback\n * becomes the clean new transcript. Mid-run user toggles can arm\n * passNextFullRedraw for the same clean replay on their own frame.\n *\n * While preservation is active, a matching frame is rewritten to repaint\n * without the wipe. With setViewportTopProvider wired, the rewrite knows\n * which transcript line the visible screen starts at, so a frame that grew\n * the transcript repaints the on-screen rows with the new content at those\n * same positions and then scrolls the extra lines in at the bottom row.\n * Each bottom-row newline pushes the top row — already repainted with the\n * correct new content — into scrollback, exactly like ordinary streaming\n * output, so scrollback ends up byte-equivalent to the upstream full replay\n * without ever clearing it. Without the provider, or when the transcript\n * shrank (lines cannot be pulled back out of scrollback), the rewrite falls\n * back to overwriting the visible screen in place with the bottom\n * viewport-height rows, at the cost of a stale seam above the screen. ED 2\n * is avoided entirely in rewritten frames because conhost/Windows Terminal\n * implement it by scrolling the screen contents into scrollback rather than\n * erasing in place.\n *\n * Width- and height-change redraws keep the upstream wipe because re-wrapped\n * content genuinely invalidates old scrollback, and frames containing kitty\n * graphics pass through untouched. A frame that does not match the expected\n * shape is passed through unchanged, so if upstream reshapes its render\n * output the wrapper degrades back to stock behavior.\n */\nexport class CoalescingTerminal extends ProcessTerminal {\n\tprivate pending = \"\";\n\tprivate flushQueued = false;\n\tprivate lastWriteColumns: number | undefined;\n\tprivate lastWriteRows: number | undefined;\n\tprivate viewportTopProvider: (() => number | undefined) | undefined;\n\tprivate preserve = false;\n\tprivate passArmed = false;\n\tprivate flushOnExit = () => this.flush();\n\n\t/**\n\t * Enable or disable the scrollback-preserving rewrite. Preservation is\n\t * only worth its imperfections (stale renderings above the screen, seams\n\t * on shrink) while the transcript is mutating on its own and the reader\n\t * may be scrolled up — i.e. during an agent run and until the user's next\n\t * input after it settles. Outside that window every full redraw passes\n\t * through with upstream's wipe-and-replay, whose result is byte-perfect:\n\t * scrollback becomes the clean new transcript. Default: disabled.\n\t */\n\tsetScrollbackPreservation(enabled: boolean): void {\n\t\tthis.preserve = enabled;\n\t}\n\n\t/**\n\t * Arm a one-shot exemption for an explicit user action (Ctrl+O tool\n\t * expansion, thinking visibility) so its own full redraw keeps the\n\t * upstream wipe-and-replay even while preservation is active mid-run.\n\t * The user just pressed a key, so they are interacting at the bottom;\n\t * a clean replay beats preservation's stale-seam trade-offs there. The\n\t * flag is cleared by the next write so it cannot leak onto a later\n\t * streaming redraw.\n\t */\n\tpassNextFullRedraw(): void {\n\t\tthis.passArmed = true;\n\t}\n\n\t/**\n\t * Wire the renderer's notion of which transcript line the visible screen\n\t * currently starts at (pi-tui's `previousViewportTop`). Full-redraw frames\n\t * that grew the transcript then scroll the new above-screen lines into\n\t * scrollback instead of dropping them. The provider is read while the\n\t * frame is being written, i.e. before the renderer updates its own\n\t * bookkeeping for that frame; returning undefined falls back to the\n\t * bottom-anchored in-place repaint.\n\t */\n\tsetViewportTopProvider(provider: () => number | undefined): void {\n\t\tthis.viewportTopProvider = provider;\n\t}\n\n\tconstructor() {\n\t\tsuper();\n\t\t// A synchronous process.exit() before the queued microtask runs would\n\t\t// otherwise drop the final frame.\n\t\tprocess.on(\"exit\", this.flushOnExit);\n\t}\n\n\toverride write(data: string): void {\n\t\tif (data.length === 0) return;\n\t\tthis.pending += this.preserveScrollback(data);\n\t\tif (this.flushQueued) return;\n\t\tthis.flushQueued = true;\n\t\tqueueMicrotask(() => this.flush());\n\t}\n\n\t// Route every escape-sequence helper through the coalescing buffer so a\n\t// caller mixing helpers with write() keeps its output ordered.\n\toverride moveBy(lines: number): void {\n\t\tif (lines > 0) {\n\t\t\tthis.write(`\\x1b[${lines}B`);\n\t\t} else if (lines < 0) {\n\t\t\tthis.write(`\\x1b[${-lines}A`);\n\t\t}\n\t}\n\n\toverride hideCursor(): void {\n\t\tthis.write(\"\\x1b[?25l\");\n\t}\n\n\toverride showCursor(): void {\n\t\tthis.write(\"\\x1b[?25h\");\n\t}\n\n\toverride clearLine(): void {\n\t\tthis.write(\"\\x1b[K\");\n\t}\n\n\toverride clearFromCursor(): void {\n\t\tthis.write(\"\\x1b[J\");\n\t}\n\n\toverride clearScreen(): void {\n\t\tthis.write(\"\\x1b[2J\\x1b[H\");\n\t}\n\n\toverride stop(): void {\n\t\t// ProcessTerminal.stop() writes its teardown sequences directly to\n\t\t// stdout, so pending frame data must go out first.\n\t\tthis.flush();\n\t\tprocess.removeListener(\"exit\", this.flushOnExit);\n\t\tsuper.stop();\n\t}\n\n\t/**\n\t * Rewrite a full-redraw frame (`?2026h 2J H 3J <lines> ?2026l`) so it\n\t * repaints the screen without wiping scrollback. Anything that does not\n\t * match this exact shape — including width-change redraws and frames\n\t * touching kitty images — passes through unchanged.\n\t */\n\tprivate preserveScrollback(data: string): string {\n\t\tconst columns = this.columns;\n\t\tconst rows = this.rows;\n\t\tconst widthChanged = this.lastWriteColumns !== undefined && this.lastWriteColumns !== columns;\n\t\tconst heightChanged = this.lastWriteRows !== undefined && this.lastWriteRows !== rows;\n\t\tthis.lastWriteColumns = columns;\n\t\tthis.lastWriteRows = rows;\n\t\t// One-shot: the very next write is the armed toggle's own frame (or a\n\t\t// differential frame when the toggle stayed inside the viewport, in\n\t\t// which case no exemption is needed and the flag must not linger).\n\t\tconst passAllowed = this.passArmed;\n\t\tthis.passArmed = false;\n\n\t\tconst prefix = SYNC_START + CLEAR_WITH_SCROLLBACK;\n\t\tif (!data.startsWith(prefix) || !data.endsWith(SYNC_END)) {\n\t\t\treturn data;\n\t\t}\n\t\t// Outside the preservation window (idle sessions, pre-session UI) and\n\t\t// for armed user toggles, upstream's wipe-and-replay is byte-perfect;\n\t\t// let it through.\n\t\tif (!this.preserve || passAllowed) {\n\t\t\treturn data;\n\t\t}\n\t\t// Re-wrapped/re-flowed content invalidates what scrollback holds and\n\t\t// where the screen sits in it; keep the wipe.\n\t\tif (widthChanged || heightChanged) {\n\t\t\treturn data;\n\t\t}\n\t\tif (data.includes(KITTY_APC_PREFIX)) {\n\t\t\treturn data;\n\t\t}\n\t\tconst body = data.slice(prefix.length, data.length - SYNC_END.length);\n\t\t// A second clear inside one write means the frame shape changed\n\t\t// upstream; bail out rather than guess.\n\t\tif (body.includes(CLEAR_WITH_SCROLLBACK)) {\n\t\t\treturn data;\n\t\t}\n\t\t// ED 2 (`2J`) is deliberately avoided in both rewrites below: conhost/\n\t\t// Windows Terminal implement it by scrolling the current screen into\n\t\t// scrollback (cls compatibility), so without the `3J` wipe each redraw\n\t\t// would stack another screenful of duplicates into history.\n\t\tconst lines = body.split(\"\\r\\n\");\n\t\tconst newViewportTop = Math.max(0, lines.length - rows);\n\t\tconst viewportTop = this.readViewportTop();\n\t\tif (viewportTop !== undefined && viewportTop <= newViewportTop) {\n\t\t\t// The transcript grew (or held steady): repaint the rows currently\n\t\t\t// on screen with the new content at those same transcript\n\t\t\t// positions, then scroll the remaining lines in at the bottom row.\n\t\t\t// Each bottom-row newline pushes the already-repainted top row into\n\t\t\t// scrollback, so scrollback receives exactly the new lines the\n\t\t\t// upstream replay would have put there — no wipe, no loss.\n\t\t\tconst onScreen = lines.slice(viewportTop, viewportTop + rows);\n\t\t\tconst scrolled = lines.slice(viewportTop + rows);\n\t\t\tlet repaint = onScreen.map((line) => `\\x1b[2K${line}`).join(\"\\r\\n\");\n\t\t\tfor (const line of scrolled) {\n\t\t\t\trepaint += `\\r\\n\\x1b[2K${line}`;\n\t\t\t}\n\t\t\treturn `${SYNC_START}\\x1b[H${repaint}\\x1b[J${SYNC_END}`;\n\t\t}\n\t\t// Fallback (no provider, or the transcript shrank — lines cannot be\n\t\t// pulled back out of scrollback): repaint only the bottom\n\t\t// viewport-height rows, overwriting the screen in place: home,\n\t\t// erase-and-rewrite each row, erase whatever remains below. Writing the\n\t\t// full transcript after a screen clear would push a duplicate copy of\n\t\t// everything above the viewport into scrollback; the truncated\n\t\t// overwrite produces the exact same final screen and cursor position as\n\t\t// the full replay (rows beyond the screen would have scrolled out\n\t\t// anyway).\n\t\tconst kept = lines.length > rows ? lines.slice(lines.length - rows) : lines;\n\t\tconst repaint = kept.map((line) => `\\x1b[2K${line}`).join(\"\\r\\n\");\n\t\treturn `${SYNC_START}\\x1b[H${repaint}\\x1b[J${SYNC_END}`;\n\t}\n\n\t// The provider reaches into the renderer's bookkeeping; treat anything\n\t// unexpected as \"unknown\" and fall back rather than corrupt the screen.\n\tprivate readViewportTop(): number | undefined {\n\t\tif (!this.viewportTopProvider) return undefined;\n\t\ttry {\n\t\t\tconst value = this.viewportTopProvider();\n\t\t\treturn typeof value === \"number\" && Number.isInteger(value) && value >= 0 ? value : undefined;\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate flush(): void {\n\t\tthis.flushQueued = false;\n\t\tif (this.pending.length === 0) {\n\t\t\treturn;\n\t\t}\n\t\tconst data = this.pending;\n\t\tthis.pending = \"\";\n\t\tsuper.write(data);\n\t}\n}\n"]}
@@ -8,6 +8,7 @@ They are registered as hidden built-in extensions and use the same public Extens
8
8
  |---|---|---|
9
9
  | `llama.cpp` | `/llama` | [llama.cpp](../../llama-cpp.md) |
10
10
  | `deepwiki` | `deepwiki` | [deepwiki.md](deepwiki.md) |
11
+ | `plan` | `exit_plan`, `/plan` | [plan.md](plan.md) |
11
12
  | `question` | `question` | [question.md](question.md) |
12
13
  | `todo` | `todo`, `/todos` | [todo.md](todo.md) |
13
14
  | `rewind` | `/rewind`, `/tree` lifecycle | [rewind.md](rewind.md) |
@@ -0,0 +1,31 @@
1
+ # plan — read-only plan mode
2
+
3
+ Adds `/plan` for a Claude-Code-style planning phase: the model explores read-only, interviews the user, and leaves the mode through a user-approved `exit_plan` tool call that saves the plan to disk and optionally compacts the context before execution starts.
4
+
5
+ ## Behavior
6
+
7
+ - `/plan` (or the `--plan` CLI flag) toggles plan mode. On entry the built-in `edit`, `write`, and `bash` tools and the bundled `subagent` tool are deactivated — `subagent` because child sessions do not inherit the restricted tool set. Read-only built-ins and other extension tools (`question`, `todo`, `deepwiki`, …) stay available. A `tool_call` guard also blocks the four tools for requests already in flight when the mode toggles.
8
+ - While planning, the system prompt gains a plan-mode block: explore before asking, interview one decision at a time with the `question` tool (with a recommendation and trade-offs), skip the interview for small clear tasks, then present the full plan as response text before calling `exit_plan`.
9
+ - `exit_plan(title, plan, revises?)` opens the approval menu:
10
+ - **Start executing (keep full context)** — saves the plan, restores tools, and execution continues in the same run.
11
+ - **Compact context, then execute** — saves the plan, ends the run (`terminate`), compacts once the run has settled, and only then restores tools; execution restarts via a kickoff message that embeds the plan text, so continuation never depends on the compaction summary. If compaction fails, tools are restored and execution proceeds with full context.
12
+ - **Keep planning** — optionally collects feedback (multi-line editor in the TUI, input dialog in RPC) and returns it to the model; plan mode stays active. `Esc` and dialog timeouts map here, never to an executing option.
13
+ - Both executing options save the plan file. If the session has no name yet, the plan title becomes the session name.
14
+ - `/plan` while planning exits directly without a menu or a file — the plan text is still in context. During a pending compaction the toggle is refused until the compaction settles.
15
+ - Plan files live under `<agentDir>/plans/<sessionId>/NN-<slug>.md` (`~/.pi/agent/plans/…` by default, `PI_CODING_AGENT_DIR` respected) with `title`, `created`, `cwd`, `session`, and optional `revises` frontmatter. Numbering is append-only; revisions are new files that point back via `revises`.
16
+ - To revise a plan mid-execution, re-enter with `/plan`: the system prompt lists the branch's saved plans so the model reads the latest one, accounts for completed work, and produces a revision.
17
+
18
+ ## Limits
19
+
20
+ - The kickoff message embeds at most 24 000 characters of plan markdown; longer plans are truncated with a pointer to the full file.
21
+ - In `json`/`print` modes `exit_plan` cannot ask, so it saves the plan and exits with context kept (write access is restored without a menu).
22
+ - Approving with **Compact context, then execute** and then interrupting before compaction completes (quit, resume, branch switch) degrades to the keep-context exit on the next session start.
23
+
24
+ ## Implementation notes
25
+
26
+ - **Conversation-backed state.** Every transition appends a `plan-mode` custom entry (never sent to the LLM); lifecycle handlers replay the current branch tail → head, so `/reload`, `/tree`, resume, and fork restore the mode. Fork and clone copy entries, so the recorded plan-file paths follow the new session even though it gets a fresh session id — the `<sessionId>` directory is only the initial storage location, not the index.
27
+ - **State is keyed per session id** (todo pattern): resume and `/tree` can switch sessions within one process, and handlers re-point the active bucket before touching state.
28
+ - **Tool restore is an inverse operation, not a snapshot.** Entry records which tools were actually removed; exit re-adds exactly those, so tools other extensions activate during planning are not revoked and tools the user had disabled are not resurrected.
29
+ - **No write access is ever needed for the plan itself** — the plan text travels as an `exit_plan` parameter and the extension writes the file.
30
+ - **The compact-then-execute window is closed by construction**: plan mode (and its tool restrictions) persists until compaction completes; the run is terminated by the tool result and the compaction starts on `agent_settled`, which fires once per fully settled run.
31
+ - The `exit_plan` result text authoritatively announces mode changes because the per-run system prompt override is not rebuilt mid-run.
@@ -7,13 +7,15 @@ Adds a `question` tool for asking one to four multiple-choice questions when the
7
7
  - Each option requires a concise description of its consequence or trade-off. Questions, labels, descriptions, and previews have input-length limits so the dialog stays usable.
8
8
  - A single single-select question submits as soon as the user selects an option.
9
9
  - A multi-question flow advances after each single-select answer; multi-select uses `Space` to toggle choices and Enter to continue. Once every question is answered, a **Review answers** view requires explicit submission.
10
+ - Options are numbered; pressing `1`–`9` jumps to the matching option — selecting it in single-select, toggling it in multi-select. The custom-answer row's number opens its input.
10
11
  - `←` / `→` switch between adjacent questions without wrapping. The tab bar marks answered questions and the Review state.
11
12
  - `Chat about this` is available after the choices. It returns a `needs_clarification` outcome so the model explains or reformulates instead of treating the user as having declined.
12
- - `Type something.` is appended automatically for custom answers. Authored options may not use reserved labels (`Other`, `Type something.`, `Chat about this`, `Next`).
13
- - `Tab` opens a note editor for the focused option; in multi-select, the option must be selected first. On the custom-answer row, it opens custom-answer input.
14
- - Multi-select custom answers stay on the `Type something.` row, are selected when saved, and can be toggled with `Space` without losing text.
15
- - `preview` shows focused single-select content beside the choices on wide terminals and beneath them on narrow terminals. Preview height is capped and reports hidden lines.
13
+ - `Type something.` is appended automatically for custom answers. Authored options may not use reserved labels (`Other`, `Type something.`, `Chat about this`); the comparison is case-insensitive and ignores surrounding whitespace.
14
+ - `Tab` opens a note editor for the focused option; in multi-select, the option must be selected first. In single-select it also selects the option, and cancelling the note editor with `Esc` restores the previous selection. On the custom-answer row, it opens custom-answer input.
15
+ - Multi-select custom answers stay on the `Type something.` row, are selected when saved, and can be toggled with `Space` without losing text. Enter on that row opens the input while no custom answer exists yet.
16
+ - `preview` shows focused single-select content beside the choices on wide terminals and beneath them on narrow terminals. Preview height is capped and reports hidden lines. Previews on multi-select questions are rejected with a `preview_multiselect` error.
16
17
  - The dialog is available only in TUI mode. RPC, JSON, and print calls return a structured `no_ui` error rather than attempting a custom component.
18
+ - Aborting the turn closes the dialog and resolves the call as cancelled with the answers given so far.
17
19
 
18
20
  ## Result contract
19
21
 
@@ -28,7 +30,7 @@ Adds a `question` tool for asking one to four multiple-choice questions when the
28
30
  }
29
31
  ```
30
32
 
31
- Only `content` reaches the model. Successful results are numbered, clearly identify single/custom/multi answers, retain notes, and state when a preview was selected without echoing its full source. Model-facing output is capped at 12,000 characters; if it is truncated, the result instructs the model to ask a focused follow-up question. Notes and custom answers are capped at 4,000 characters in the dialog.
33
+ Only `content` reaches the model. Successful results are numbered, clearly identify single/custom/multi answers, retain notes, and state when a preview was selected without echoing its full source. Cancelling — via `Esc` or a turn abort — lists any answers already given as partial answers alongside the decline message. Model-facing output is capped at 12,000 characters; if it is truncated, the result instructs the model to ask a focused follow-up question. Notes and custom answers are capped at 4,000 characters in the dialog.
32
34
 
33
35
  The transcript uses a private `renderCall` / `renderResult` only to replace raw question JSON and model-oriented result text with concise user summaries. Pi retains its native tool shell, pending/error state, and collapsed/expanded behavior.
34
36
 
@@ -42,7 +44,7 @@ The transcript uses a private `renderCall` / `renderResult` only to replace raw
42
44
  ## Implementation notes
43
45
 
44
46
  - Uses Pi's native `ctx.ui.custom()` lifecycle; no state is shared with another extension.
45
- - The render cache is keyed by terminal width **and height**, because preview height depends on available rows and Pi resize only requests a render.
46
- - `validateQuestions` enforces uniqueness, reserved-label rejection, and text budgets beyond what the JSON schema can express.
47
+ - The render cache is keyed by terminal width **and height**, because preview height depends on available rows and Pi resize only requests a render. Preview markdown rendering is additionally memoized per text and dimensions so editor keystrokes don't re-parse previews.
48
+ - `validateQuestions` enforces only what the JSON schema cannot express: case-insensitive uniqueness, reserved-label rejection, and the preview/multi-select conflict. Length and count limits are enforced against the schema before the tool executes.
47
49
  - Dialog navigation follows Pi's injected select/input keybindings where applicable; custom actions such as Space-to-toggle remain explicit in the footer.
48
50
 
@@ -13,7 +13,11 @@ Per turn: `before_agent_start` opens a snapshot frame (re-recording every tracke
13
13
  file at its turn-start state, reusing the latest backup when unchanged);
14
14
  `tool_call(edit|write)` backs up each newly edited file *before* it lands;
15
15
  `agent_settled` persists the frame to the session JSONL as a `pi-rewind-snapshot`
16
- custom entry **only when files changed**. Using `agent_settled` (not `agent_end`)
16
+ custom entry **only when files changed**. The frame anchors to the **first** user
17
+ entry the run appended — the message whose start the frame recorded; steering and
18
+ follow-up messages consumed inside the same run append later user entries and do
19
+ not steal the anchor (a run that appends no user entry, e.g. custom-triggered,
20
+ records no frame). Using `agent_settled` (not `agent_end`)
17
21
  keeps auto-retry, overflow compaction-retry, and queued follow-ups in one logical
18
22
  turn — `agent_end` can fire while Pi still continues. Requires **Pi ≥ 0.80.4**
19
23
  (when `agent_settled` was added); older hosts never fire that event, so frames
@@ -24,18 +28,25 @@ rebuilt from them on `session_start`.
24
28
  `edit` and `write` tools. Files written by `bash` (redirects, codegen, `mv`) or
25
29
  edited by hand outside Pi are **not** tracked — same boundary as Claude Code's
26
30
  file-history. Rewind undoes *Pi's edits*, not arbitrary filesystem state.
27
- - **Time-travel is via `/tree`.** Navigating to a node whose turn changed files
28
- prompts to restore them, listing the affected files (cwd-relative, up to 8
29
- then *"+N more"*) under *"Restore 3 files to this point? (+120 / −40)"*
30
- when coarse line stats are available; choosing yes restores the work tree to
31
- that turn's start state. Nodes with no file changes navigate silently. Only
32
- files that actually differ are rewritten. Line totals are a bag-of-lines
31
+ - **Time-travel is via `/tree`.** Navigating to a node whose recorded state
32
+ differs from the work tree prompts to restore, listing the affected files
33
+ (cwd-relative, up to 8 then *"+N more"*) under *"Restore 3 files to this
34
+ point? (+120 / −40)"* when coarse line stats are available. The restore
35
+ target mirrors `/tree`'s own semantics: a **user message** target (whose turn
36
+ is removed and returned to the editor) restores to the state *before* that
37
+ turn ran; any **other** target (whose turn stays in the conversation)
38
+ restores to the state *after* that turn — the state recorded when the next
39
+ run began. When nothing was recorded after the target (e.g. the last turn's
40
+ assistant message), navigation is silent rather than rolling back edits the
41
+ conversation still contains. Nodes with no file changes navigate silently.
42
+ Only files that actually differ are rewritten. Line totals are a bag-of-lines
33
43
  estimate (capped at 1 MB per file), not a full Myers diff.
34
44
  - **`/rewind` is a settings + storage menu**, not a restore picker:
35
45
  - toggle rewind on/off,
36
46
  - set the auto-clean retention window,
37
47
  - inspect storage and prune (clean aged + orphaned / remove orphaned / remove
38
- all except current).
48
+ all except current — the latter also keeps backups written to in the last
49
+ 10 minutes, which may belong to sessions running in another Pi process).
39
50
  - **New files** created by `write` are tracked with a "did not exist" marker, so
40
51
  rewinding deletes them.
41
52
  - **Resume/fork** hard-links the prior session's backup blobs into the new