openhorse 0.2.21 → 0.2.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.
Files changed (263) hide show
  1. package/dist/cli.js +25 -0
  2. package/dist/cli.js.map +1 -1
  3. package/dist/commands/index.d.ts.map +1 -1
  4. package/dist/commands/index.js +189 -49
  5. package/dist/commands/index.js.map +1 -1
  6. package/dist/commands/parser.d.ts.map +1 -1
  7. package/dist/commands/parser.js +8 -3
  8. package/dist/commands/parser.js.map +1 -1
  9. package/dist/commands/types.d.ts +2 -0
  10. package/dist/commands/types.d.ts.map +1 -1
  11. package/dist/commands/types.js.map +1 -1
  12. package/dist/core/cost-tracker.d.ts +45 -5
  13. package/dist/core/cost-tracker.d.ts.map +1 -1
  14. package/dist/core/cost-tracker.js +78 -13
  15. package/dist/core/cost-tracker.js.map +1 -1
  16. package/dist/framework/index.d.ts +1 -1
  17. package/dist/framework/index.d.ts.map +1 -1
  18. package/dist/framework/index.js.map +1 -1
  19. package/dist/framework/query.d.ts +25 -0
  20. package/dist/framework/query.d.ts.map +1 -1
  21. package/dist/framework/query.js +196 -49
  22. package/dist/framework/query.js.map +1 -1
  23. package/dist/framework/store.d.ts +6 -1
  24. package/dist/framework/store.d.ts.map +1 -1
  25. package/dist/framework/store.js +10 -1
  26. package/dist/framework/store.js.map +1 -1
  27. package/dist/harness/capsule.d.ts +1 -0
  28. package/dist/harness/capsule.d.ts.map +1 -1
  29. package/dist/harness/capsule.js +38 -9
  30. package/dist/harness/capsule.js.map +1 -1
  31. package/dist/harness/contract.d.ts +2 -0
  32. package/dist/harness/contract.d.ts.map +1 -1
  33. package/dist/harness/contract.js +78 -14
  34. package/dist/harness/contract.js.map +1 -1
  35. package/dist/harness/index.d.ts +2 -2
  36. package/dist/harness/index.d.ts.map +1 -1
  37. package/dist/harness/index.js +4 -1
  38. package/dist/harness/index.js.map +1 -1
  39. package/dist/harness/ledger.d.ts.map +1 -1
  40. package/dist/harness/ledger.js +6 -2
  41. package/dist/harness/ledger.js.map +1 -1
  42. package/dist/harness/state.d.ts.map +1 -1
  43. package/dist/harness/state.js +37 -7
  44. package/dist/harness/state.js.map +1 -1
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/ink-ui/components/StatusLine.d.ts.map +1 -1
  48. package/dist/ink-ui/components/StatusLine.js +0 -2
  49. package/dist/ink-ui/components/StatusLine.js.map +1 -1
  50. package/dist/ink-ui/screens/ReplScreen.d.ts.map +1 -1
  51. package/dist/ink-ui/screens/ReplScreen.js +12 -5
  52. package/dist/ink-ui/screens/ReplScreen.js.map +1 -1
  53. package/dist/runtime/agent-runtime-controller.d.ts.map +1 -1
  54. package/dist/runtime/agent-runtime-controller.js +11 -1
  55. package/dist/runtime/agent-runtime-controller.js.map +1 -1
  56. package/dist/runtime/aggregate-tool-presenter.d.ts +13 -0
  57. package/dist/runtime/aggregate-tool-presenter.d.ts.map +1 -0
  58. package/dist/runtime/aggregate-tool-presenter.js +44 -0
  59. package/dist/runtime/aggregate-tool-presenter.js.map +1 -0
  60. package/dist/runtime/chat-controller.d.ts +9 -2
  61. package/dist/runtime/chat-controller.d.ts.map +1 -1
  62. package/dist/runtime/chat-controller.js +345 -90
  63. package/dist/runtime/chat-controller.js.map +1 -1
  64. package/dist/runtime/rich-text/ansi-parser.d.ts +1 -1
  65. package/dist/runtime/rich-text/ansi-parser.d.ts.map +1 -1
  66. package/dist/runtime/rich-text/ansi-parser.js +93 -55
  67. package/dist/runtime/rich-text/ansi-parser.js.map +1 -1
  68. package/dist/runtime/rich-text/layout.d.ts +2 -11
  69. package/dist/runtime/rich-text/layout.d.ts.map +1 -1
  70. package/dist/runtime/rich-text/layout.js +280 -245
  71. package/dist/runtime/rich-text/layout.js.map +1 -1
  72. package/dist/runtime/rich-text/markdown-parser.js +31 -24
  73. package/dist/runtime/rich-text/markdown-parser.js.map +1 -1
  74. package/dist/runtime/rich-text/types.d.ts +1 -1
  75. package/dist/runtime/rich-text/types.d.ts.map +1 -1
  76. package/dist/runtime/subagents/production.d.ts.map +1 -1
  77. package/dist/runtime/subagents/production.js +28 -3
  78. package/dist/runtime/subagents/production.js.map +1 -1
  79. package/dist/runtime/subagents/result-parser.d.ts.map +1 -1
  80. package/dist/runtime/subagents/result-parser.js +54 -40
  81. package/dist/runtime/subagents/result-parser.js.map +1 -1
  82. package/dist/runtime/subagents/types.d.ts +2 -0
  83. package/dist/runtime/subagents/types.d.ts.map +1 -1
  84. package/dist/runtime/subagents/types.js +5 -1
  85. package/dist/runtime/subagents/types.js.map +1 -1
  86. package/dist/runtime/tool-detail-repository.d.ts +45 -0
  87. package/dist/runtime/tool-detail-repository.d.ts.map +1 -0
  88. package/dist/runtime/tool-detail-repository.js +131 -0
  89. package/dist/runtime/tool-detail-repository.js.map +1 -0
  90. package/dist/runtime/tool-output-presentation.d.ts +89 -0
  91. package/dist/runtime/tool-output-presentation.d.ts.map +1 -0
  92. package/dist/runtime/tool-output-presentation.js +329 -0
  93. package/dist/runtime/tool-output-presentation.js.map +1 -0
  94. package/dist/runtime/ui-events.d.ts +17 -0
  95. package/dist/runtime/ui-events.d.ts.map +1 -1
  96. package/dist/runtime/ui-events.js.map +1 -1
  97. package/dist/runtime/ui-view-model.d.ts +9 -0
  98. package/dist/runtime/ui-view-model.d.ts.map +1 -1
  99. package/dist/runtime/ui-view-model.js +59 -37
  100. package/dist/runtime/ui-view-model.js.map +1 -1
  101. package/dist/services/compact/auto-compact.d.ts +15 -2
  102. package/dist/services/compact/auto-compact.d.ts.map +1 -1
  103. package/dist/services/compact/auto-compact.js +56 -24
  104. package/dist/services/compact/auto-compact.js.map +1 -1
  105. package/dist/services/compact/compact.d.ts +2 -0
  106. package/dist/services/compact/compact.d.ts.map +1 -1
  107. package/dist/services/compact/compact.js +22 -5
  108. package/dist/services/compact/compact.js.map +1 -1
  109. package/dist/services/compact/coordinator.d.ts +26 -0
  110. package/dist/services/compact/coordinator.d.ts.map +1 -0
  111. package/dist/services/compact/coordinator.js +44 -0
  112. package/dist/services/compact/coordinator.js.map +1 -0
  113. package/dist/services/compact/index.d.ts +1 -0
  114. package/dist/services/compact/index.d.ts.map +1 -1
  115. package/dist/services/compact/index.js +3 -1
  116. package/dist/services/compact/index.js.map +1 -1
  117. package/dist/services/compact/summary-generator.d.ts +6 -2
  118. package/dist/services/compact/summary-generator.d.ts.map +1 -1
  119. package/dist/services/compact/summary-generator.js +73 -31
  120. package/dist/services/compact/summary-generator.js.map +1 -1
  121. package/dist/services/config-dir.d.ts +11 -1
  122. package/dist/services/config-dir.d.ts.map +1 -1
  123. package/dist/services/config-dir.js +19 -2
  124. package/dist/services/config-dir.js.map +1 -1
  125. package/dist/services/config.d.ts +4 -2
  126. package/dist/services/config.d.ts.map +1 -1
  127. package/dist/services/config.js +40 -0
  128. package/dist/services/config.js.map +1 -1
  129. package/dist/services/doctor.d.ts +2 -0
  130. package/dist/services/doctor.d.ts.map +1 -1
  131. package/dist/services/doctor.js +8 -1
  132. package/dist/services/doctor.js.map +1 -1
  133. package/dist/services/global-config.d.ts +10 -0
  134. package/dist/services/global-config.d.ts.map +1 -1
  135. package/dist/services/global-config.js.map +1 -1
  136. package/dist/services/llm.d.ts +21 -4
  137. package/dist/services/llm.d.ts.map +1 -1
  138. package/dist/services/llm.js +66 -8
  139. package/dist/services/llm.js.map +1 -1
  140. package/dist/services/model-context.d.ts +36 -0
  141. package/dist/services/model-context.d.ts.map +1 -1
  142. package/dist/services/model-context.js +245 -32
  143. package/dist/services/model-context.js.map +1 -1
  144. package/dist/services/redaction.js +1 -1
  145. package/dist/services/redaction.js.map +1 -1
  146. package/dist/services/session-storage.d.ts +42 -1
  147. package/dist/services/session-storage.d.ts.map +1 -1
  148. package/dist/services/session-storage.js +175 -54
  149. package/dist/services/session-storage.js.map +1 -1
  150. package/dist/services/storage-maintenance.d.ts.map +1 -1
  151. package/dist/services/storage-maintenance.js +3 -2
  152. package/dist/services/storage-maintenance.js.map +1 -1
  153. package/dist/services/usage-state.d.ts +55 -5
  154. package/dist/services/usage-state.d.ts.map +1 -1
  155. package/dist/services/usage-state.js +185 -59
  156. package/dist/services/usage-state.js.map +1 -1
  157. package/dist/services/verification-profile.d.ts.map +1 -1
  158. package/dist/services/verification-profile.js +3 -2
  159. package/dist/services/verification-profile.js.map +1 -1
  160. package/dist/services/workspace-diff.d.ts.map +1 -1
  161. package/dist/services/workspace-diff.js +34 -3
  162. package/dist/services/workspace-diff.js.map +1 -1
  163. package/dist/services/yaml-config.js +1 -1
  164. package/dist/skills/runtime.d.ts +9 -5
  165. package/dist/skills/runtime.d.ts.map +1 -1
  166. package/dist/skills/runtime.js +157 -12
  167. package/dist/skills/runtime.js.map +1 -1
  168. package/dist/terminal-ui/editor.d.ts.map +1 -1
  169. package/dist/terminal-ui/editor.js +14 -3
  170. package/dist/terminal-ui/editor.js.map +1 -1
  171. package/dist/terminal-ui/launch.d.ts +21 -0
  172. package/dist/terminal-ui/launch.d.ts.map +1 -1
  173. package/dist/terminal-ui/launch.js +203 -45
  174. package/dist/terminal-ui/launch.js.map +1 -1
  175. package/dist/terminal-ui/output-queue.d.ts +43 -0
  176. package/dist/terminal-ui/output-queue.d.ts.map +1 -0
  177. package/dist/terminal-ui/output-queue.js +139 -0
  178. package/dist/terminal-ui/output-queue.js.map +1 -0
  179. package/dist/terminal-ui/raw-editor.d.ts +42 -0
  180. package/dist/terminal-ui/raw-editor.d.ts.map +1 -1
  181. package/dist/terminal-ui/raw-editor.js +337 -41
  182. package/dist/terminal-ui/raw-editor.js.map +1 -1
  183. package/dist/tools/bash_security.d.ts.map +1 -1
  184. package/dist/tools/bash_security.js +14 -5
  185. package/dist/tools/bash_security.js.map +1 -1
  186. package/dist/tools/index.d.ts.map +1 -1
  187. package/dist/tools/index.js +176 -73
  188. package/dist/tools/index.js.map +1 -1
  189. package/dist/tools/plan.d.ts.map +1 -1
  190. package/dist/tools/plan.js +7 -0
  191. package/dist/tools/plan.js.map +1 -1
  192. package/dist/tools/web.d.ts +10 -0
  193. package/dist/tools/web.d.ts.map +1 -1
  194. package/dist/tools/web.js +81 -1
  195. package/dist/tools/web.js.map +1 -1
  196. package/dist/tui-core/input-parser.d.ts +9 -2
  197. package/dist/tui-core/input-parser.d.ts.map +1 -1
  198. package/dist/tui-core/input-parser.js +57 -3
  199. package/dist/tui-core/input-parser.js.map +1 -1
  200. package/dist/tui-core/style.d.ts +16 -0
  201. package/dist/tui-core/style.d.ts.map +1 -1
  202. package/dist/tui-core/style.js +22 -1
  203. package/dist/tui-core/style.js.map +1 -1
  204. package/dist/tui-ui/inline-surface.d.ts +69 -12
  205. package/dist/tui-ui/inline-surface.d.ts.map +1 -1
  206. package/dist/tui-ui/inline-surface.js +366 -94
  207. package/dist/tui-ui/inline-surface.js.map +1 -1
  208. package/dist/tui-ui/input-ownership.d.ts +46 -0
  209. package/dist/tui-ui/input-ownership.d.ts.map +1 -0
  210. package/dist/tui-ui/input-ownership.js +74 -0
  211. package/dist/tui-ui/input-ownership.js.map +1 -0
  212. package/dist/tui-ui/launch.d.ts +2 -0
  213. package/dist/tui-ui/launch.d.ts.map +1 -1
  214. package/dist/tui-ui/launch.js +289 -43
  215. package/dist/tui-ui/launch.js.map +1 -1
  216. package/dist/tui-ui/layout.d.ts +52 -2
  217. package/dist/tui-ui/layout.d.ts.map +1 -1
  218. package/dist/tui-ui/layout.js +230 -76
  219. package/dist/tui-ui/layout.js.map +1 -1
  220. package/dist/tui-ui/runner.d.ts +101 -8
  221. package/dist/tui-ui/runner.d.ts.map +1 -1
  222. package/dist/tui-ui/runner.js +843 -51
  223. package/dist/tui-ui/runner.js.map +1 -1
  224. package/dist/tui-ui/state.d.ts +70 -4
  225. package/dist/tui-ui/state.d.ts.map +1 -1
  226. package/dist/tui-ui/state.js +251 -20
  227. package/dist/tui-ui/state.js.map +1 -1
  228. package/dist/tui-ui/theme-profile.d.ts +25 -0
  229. package/dist/tui-ui/theme-profile.d.ts.map +1 -0
  230. package/dist/tui-ui/theme-profile.js +130 -0
  231. package/dist/tui-ui/theme-profile.js.map +1 -0
  232. package/dist/tui-ui/theme.d.ts +13 -0
  233. package/dist/tui-ui/theme.d.ts.map +1 -0
  234. package/dist/tui-ui/theme.js +20 -0
  235. package/dist/tui-ui/theme.js.map +1 -0
  236. package/dist/tui-ui/transcript-cache.d.ts +5 -3
  237. package/dist/tui-ui/transcript-cache.d.ts.map +1 -1
  238. package/dist/tui-ui/transcript-cache.js +16 -9
  239. package/dist/tui-ui/transcript-cache.js.map +1 -1
  240. package/dist/tui-ui/transcript-inspector-layout.d.ts +9 -0
  241. package/dist/tui-ui/transcript-inspector-layout.d.ts.map +1 -0
  242. package/dist/tui-ui/transcript-inspector-layout.js +97 -0
  243. package/dist/tui-ui/transcript-inspector-layout.js.map +1 -0
  244. package/dist/tui-ui/transcript-inspector-surface.d.ts +18 -0
  245. package/dist/tui-ui/transcript-inspector-surface.d.ts.map +1 -0
  246. package/dist/tui-ui/transcript-inspector-surface.js +88 -0
  247. package/dist/tui-ui/transcript-inspector-surface.js.map +1 -0
  248. package/dist/tui-ui/transcript-inspector.d.ts +35 -0
  249. package/dist/tui-ui/transcript-inspector.d.ts.map +1 -0
  250. package/dist/tui-ui/transcript-inspector.js +128 -0
  251. package/dist/tui-ui/transcript-inspector.js.map +1 -0
  252. package/dist/tui-ui/transcript-layout.d.ts +22 -0
  253. package/dist/tui-ui/transcript-layout.d.ts.map +1 -0
  254. package/dist/tui-ui/transcript-layout.js +439 -0
  255. package/dist/tui-ui/transcript-layout.js.map +1 -0
  256. package/dist/ui/status-bar.d.ts +1 -1
  257. package/dist/ui/status-bar.d.ts.map +1 -1
  258. package/dist/ui/status-bar.js +2 -15
  259. package/dist/ui/status-bar.js.map +1 -1
  260. package/dist/ui-v2/components/shell.d.ts.map +1 -1
  261. package/dist/ui-v2/components/shell.js +0 -8
  262. package/dist/ui-v2/components/shell.js.map +1 -1
  263. package/package.json +1 -1
@@ -38,6 +38,8 @@ const SHOW_CURSOR = '\x1b[?25h';
38
38
  const HIDE_CURSOR = '\x1b[?25l';
39
39
  const DISABLE_AUTOWRAP = '\x1b[?7l';
40
40
  const ENABLE_AUTOWRAP = '\x1b[?7h';
41
+ /** Fraction of the terminal height occupied by the bottom live band. */
42
+ const LIVE_BAND_RATIO = 0.75;
41
43
  function cursorUp(n) {
42
44
  return n > 0 ? `\x1b[${n}A` : '';
43
45
  }
@@ -49,10 +51,17 @@ class InlineTerminalSurface {
49
51
  this.phase = 'idle';
50
52
  this.width = 0;
51
53
  this.height = 0;
54
+ /** Height of the bottom live band (status + prompt + live tail). */
55
+ this.liveBandRows = 0;
52
56
  this.liveRegionCapacity = 0;
53
57
  this.cursorRow = 0;
54
58
  this.cursorColumn = 0;
55
59
  this.previousFrame = null;
60
+ /** Suppress stale-width live paints between SIGWINCH and the resize repaint. */
61
+ this.resizePending = false;
62
+ this.pendingResizeWidth = null;
63
+ this.resizeGeneration = 0;
64
+ this.resizeWaiters = [];
56
65
  /** Serialized FIFO queue of pending operations. */
57
66
  this.queue = [];
58
67
  this.processing = false;
@@ -60,17 +69,50 @@ class InlineTerminalSurface {
60
69
  this.now = options.now ?? (() => Date.now());
61
70
  this.suppressColor = (0, style_1.shouldSuppressColor)();
62
71
  }
72
+ /** Compute the live band height (~75% of screen, min 8, max height-1). */
73
+ static computeBandRows(height) {
74
+ const h = Math.max(1, Math.floor(height));
75
+ const minBand = 8;
76
+ const maxBand = Math.max(minBand, h - 1); // leave >=1 history row when possible
77
+ const desired = Math.round(h * LIVE_BAND_RATIO);
78
+ return Math.max(minBand, Math.min(maxBand, desired));
79
+ }
80
+ /** Live band height for the runner to size its live frame. */
81
+ getLiveBandRows() {
82
+ return this.liveBandRows;
83
+ }
84
+ /** History area rows above the band (recently scrolled committed lines). */
85
+ get historyAreaRows() {
86
+ return Math.max(0, this.height - this.liveBandRows);
87
+ }
63
88
  getState() {
64
89
  return {
65
90
  phase: this.phase,
66
91
  width: this.width,
67
92
  height: this.height,
93
+ liveBandRows: this.liveBandRows,
68
94
  liveRegionCapacity: this.liveRegionCapacity,
69
95
  cursorRow: this.cursorRow,
70
96
  cursorColumn: this.cursorColumn,
71
97
  previousFrame: this.previousFrame,
72
98
  };
73
99
  }
100
+ /** Mark the terminal as resizing before the debounced layout pass runs. */
101
+ beginResize(width = this.width) {
102
+ this.resizeGeneration += 1;
103
+ this.resizePending = true;
104
+ this.pendingResizeWidth = Math.max(1, Math.floor(width));
105
+ return this.resizeGeneration;
106
+ }
107
+ /**
108
+ * Await completion of all queued operations.
109
+ * Because the queue is strictly FIFO, enqueueing a no-op and awaiting it
110
+ * guarantees every operation enqueued before this call has finished. Used by
111
+ * tests to deterministically observe the terminal after a burst of renders.
112
+ */
113
+ whenIdle() {
114
+ return this.enqueue(async () => { });
115
+ }
74
116
  /** Safe content width: never write the last column (avoid pending-wrap). */
75
117
  get safeContentWidth() {
76
118
  return Math.max(1, this.width - 1);
@@ -112,121 +154,246 @@ class InlineTerminalSurface {
112
154
  return;
113
155
  this.width = Math.max(1, Math.floor(width));
114
156
  this.height = Math.max(1, Math.floor(height));
157
+ this.liveBandRows = InlineTerminalSurface.computeBandRows(this.height);
115
158
  this.phase = 'mounted';
116
159
  this.liveRegionCapacity = 0;
117
160
  this.cursorRow = 0;
118
161
  this.cursorColumn = 0;
119
- this.writeRaw(`${ENABLE_BRACKETED_PASTE}${HIDE_CURSOR}`);
162
+ await this.writeRaw(`${ENABLE_BRACKETED_PASTE}${HIDE_CURSOR}`);
120
163
  });
121
164
  }
122
165
  /**
123
- * Commit finalized transcript entries to scrollback (append-only).
124
- * Protocol: clear live region -> commit rows -> rebuild live frame.
166
+ * Commit finalized transcript entries to native scrollback.
167
+ *
168
+ * Protocol:
169
+ * 1. Erase only the live rows owned by this surface and return to their top.
170
+ * 2. Release that block and write finalized rows as ordinary line-oriented
171
+ * shell output. The terminal decides when those lines enter scrollback.
172
+ * 3. Allocate a fresh live block from the resulting cursor and repaint the
173
+ * latest live frame below the committed output.
125
174
  */
126
175
  async commit(batch, getLatestLiveFrame) {
127
176
  let output = '';
128
- await this.enqueue(async () => {
129
- const chunks = [];
130
- // 1. Move to live top and clear live region.
131
- chunks.push(this.clearLiveRegion());
132
- // 2. Commit each entry's rows with SGR0 after each row.
133
- for (const entry of batch.entries) {
134
- for (const row of entry.rows) {
135
- for (const span of row) {
136
- if (span.style) {
137
- const sgr = (0, style_1.encodeStyleToSgr)(span.style, this.suppressColor);
138
- if (sgr)
139
- chunks.push(sgr);
177
+ for (;;) {
178
+ let retryAfterResize = false;
179
+ await this.enqueue(async () => {
180
+ if (this.resizePending) {
181
+ retryAfterResize = true;
182
+ return;
183
+ }
184
+ // Committed output behaves like ordinary shell output. Keeping
185
+ // autowrap enabled preserves an already-queued old-width batch if the
186
+ // terminal changes size immediately before this operation executes.
187
+ const chunks = [this.clearOwnedLiveRegion(), ENABLE_AUTOWRAP];
188
+ this.releaseLiveRegion();
189
+ for (const entry of batch.entries) {
190
+ for (const row of entry.rows) {
191
+ chunks.push(CR);
192
+ for (const [index, span] of row.entries()) {
193
+ if (index > 0)
194
+ chunks.push(style_1.SGR_RESET);
195
+ if (span.style) {
196
+ const sgr = (0, style_1.encodeStyleToSgr)(span.style, this.suppressColor);
197
+ if (sgr)
198
+ chunks.push(sgr);
199
+ }
200
+ chunks.push(span.text);
140
201
  }
141
- chunks.push(span.text);
202
+ chunks.push(style_1.SGR_RESET);
203
+ chunks.push('\n');
142
204
  }
143
- chunks.push(style_1.SGR_RESET);
144
- chunks.push('\n'); // hard line boundary, never rely on pending wrap
145
205
  }
146
- }
147
- // 3. Reset live region capacity and rebuild live frame.
148
- this.liveRegionCapacity = 0;
149
- this.previousFrame = null;
150
- const liveFrame = getLatestLiveFrame();
151
- if (liveFrame) {
152
- chunks.push(this.renderLiveInternal(liveFrame));
153
- }
154
- output = chunks.join('');
155
- this.writeRaw(output);
156
- });
157
- return { output, committedEntries: batch.entries.length };
206
+ const liveFrame = getLatestLiveFrame();
207
+ if (liveFrame) {
208
+ chunks.push(this.renderLiveInternal(liveFrame));
209
+ }
210
+ output = chunks.join('');
211
+ await this.writeRaw(output);
212
+ });
213
+ if (!retryAfterResize)
214
+ break;
215
+ await this.waitForResizeCompletion();
216
+ }
217
+ return {
218
+ output,
219
+ committedEntries: batch.entries.length,
220
+ batchId: batch.batchId,
221
+ generation: batch.generation,
222
+ displayKeys: batch.entries.map(entry => entry.displayKey),
223
+ };
158
224
  }
159
225
  /** Render the live region frame (relative addressing, changed-row diff). */
160
226
  async renderLive(frame) {
227
+ let output = '';
228
+ const requestedDuringResize = this.resizePending;
229
+ await this.enqueue(async () => {
230
+ if (requestedDuringResize || this.resizePending)
231
+ return;
232
+ output = this.renderLiveInternal(frame);
233
+ await this.writeRaw(output);
234
+ });
235
+ return output;
236
+ }
237
+ /** Forget the diff baseline and repaint only the currently owned live region. */
238
+ async forceRedraw(frame) {
161
239
  let output = '';
162
240
  await this.enqueue(async () => {
241
+ if (this.phase !== 'mounted' || this.resizePending)
242
+ return;
243
+ this.previousFrame = null;
163
244
  output = this.renderLiveInternal(frame);
164
- this.writeRaw(output);
245
+ await this.writeRaw(output);
165
246
  });
166
247
  return output;
167
248
  }
168
249
  renderLiveInternal(frame) {
169
250
  const chunks = [];
170
- const requiredRows = Math.min(frame.height, Math.max(1, this.height - 1));
171
- // Ensure capacity for the live region.
251
+ const requiredRows = Math.min(frame.height, Math.max(1, this.liveBandRows));
252
+ // Allocate or resize the cursor-owned live block from the current cursor.
172
253
  chunks.push(this.ensureCapacity(requiredRows));
173
- // Move to live top.
174
- chunks.push(cursorUp(this.cursorRow));
175
- this.cursorRow = 0;
176
- // Clear all capacity rows, then write frame rows.
177
- for (let i = 0; i < this.liveRegionCapacity; i++) {
178
- chunks.push(CR, EL2);
179
- if (i < this.liveRegionCapacity - 1)
180
- chunks.push(cursorDown(1));
181
- }
182
- // Back to top.
183
- chunks.push(cursorUp(this.liveRegionCapacity - 1));
184
- // Write frame rows.
185
- for (let r = 0; r < Math.min(frame.height, this.liveRegionCapacity); r++) {
186
- const spans = (0, frame_1.renderStyledFrameRow)(frame.rows[r] ?? []);
187
- let emittedSgr = false;
188
- for (const span of spans) {
189
- const sgr = (0, style_1.encodeStyleToSgr)(span.style, this.suppressColor);
190
- if (sgr) {
191
- chunks.push(sgr);
192
- emittedSgr = true;
254
+ // Disable autowrap while painting band rows: a row that fills the last
255
+ // column (e.g. the full-width prompt border ┌─...─┐) would otherwise leave
256
+ // the terminal in a pending-wrap state that corrupts the next row's
257
+ // repaint. Re-enabled at the end of the batch.
258
+ chunks.push(DISABLE_AUTOWRAP);
259
+ const capacity = this.liveRegionCapacity;
260
+ const prev = this.previousFrame;
261
+ let row = this.cursorRow;
262
+ // Only diff when the previous frame shares the same geometry (height and
263
+ // capacity). Any geometry change (resize, first frame) triggers a full
264
+ // repaint so we never leave stale rows behind.
265
+ const canDiff = prev !== null && prev.height === capacity && frame.height === capacity;
266
+ if (!canDiff) {
267
+ // Full repaint: move to band top, clear every band row, then write every
268
+ // frame row. Track the real cursor row in `row` so the final positioning
269
+ // block below is exact.
270
+ chunks.push(cursorUp(row));
271
+ for (let i = 0; i < capacity; i++) {
272
+ chunks.push(CR, EL2);
273
+ if (i < capacity - 1) {
274
+ chunks.push(cursorDown(1));
275
+ row += 1;
193
276
  }
194
- chunks.push(span.text);
195
277
  }
196
- if (emittedSgr)
197
- chunks.push(style_1.SGR_RESET);
198
- if (r < Math.min(frame.height, this.liveRegionCapacity) - 1) {
199
- chunks.push(CR, cursorDown(1));
278
+ chunks.push(cursorUp(capacity - 1));
279
+ row = 0;
280
+ const frameRows = Math.min(frame.height, capacity);
281
+ for (let r = 0; r < frameRows; r++) {
282
+ chunks.push(...this.renderRowChunks(frame.rows[r] ?? []));
283
+ if (r < frameRows - 1) {
284
+ chunks.push(CR, cursorDown(1));
285
+ row += 1;
286
+ }
287
+ }
288
+ }
289
+ else {
290
+ // Changed-row diff: only repaint rows whose styled content differs from
291
+ // the previous frame. Navigation moves the real cursor to the changed
292
+ // row, clears it, and redraws — avoiding a full-band flicker on every
293
+ // tick (e.g. while a tool is running and only the status line changes).
294
+ for (let r = 0; r < capacity; r++) {
295
+ const newRow = r < frame.height ? frame.rows[r] : [];
296
+ const oldRow = r < prev.height ? prev.rows[r] : [];
297
+ if (this.renderRowString(newRow) === this.renderRowString(oldRow))
298
+ continue;
299
+ if (r < row) {
300
+ chunks.push(cursorUp(row - r));
301
+ row = r;
302
+ }
303
+ else if (r > row) {
304
+ chunks.push(cursorDown(r - row));
305
+ row = r;
306
+ }
307
+ chunks.push(CR, EL2);
308
+ chunks.push(...this.renderRowChunks(newRow));
200
309
  }
201
310
  }
202
- // Position cursor at frame cursor.
311
+ // Position cursor at frame cursor using the precisely-tracked `row`.
203
312
  const targetRow = Math.min(frame.cursor.row, this.liveRegionCapacity - 1);
204
313
  const targetCol = Math.min(frame.cursor.column, Math.max(0, this.width - 1));
205
- chunks.push(cursorUp(this.cursorRow));
206
- chunks.push(cursorDown(targetRow));
314
+ if (targetRow < row) {
315
+ chunks.push(cursorUp(row - targetRow));
316
+ row = targetRow;
317
+ }
318
+ else if (targetRow > row) {
319
+ chunks.push(cursorDown(targetRow - row));
320
+ row = targetRow;
321
+ }
207
322
  chunks.push(CR);
208
323
  if (targetCol > 0)
209
324
  chunks.push(`\x1b[${targetCol}C`);
210
- this.cursorRow = targetRow;
325
+ this.cursorRow = row;
211
326
  this.cursorColumn = targetCol;
212
327
  chunks.push(frame.cursor.visible ? SHOW_CURSOR : HIDE_CURSOR);
213
328
  chunks.push(ENABLE_AUTOWRAP);
214
329
  this.previousFrame = frame;
215
330
  return chunks.join('');
216
331
  }
217
- /** Ensure the live region has at least requiredRows capacity (grows only). */
332
+ /** Render a single styled frame row to ANSI chunks (text + SGR + reset). */
333
+ renderRowChunks(row) {
334
+ const chunks = [];
335
+ const spans = (0, frame_1.renderStyledFrameRow)(this.trimDefaultTrailingCells(row));
336
+ let emittedSgr = false;
337
+ for (const [index, span] of spans.entries()) {
338
+ if (index > 0)
339
+ chunks.push(style_1.SGR_RESET);
340
+ const sgr = (0, style_1.encodeStyleToSgr)(span.style, this.suppressColor);
341
+ if (sgr) {
342
+ chunks.push(sgr);
343
+ emittedSgr = true;
344
+ }
345
+ chunks.push(span.text);
346
+ }
347
+ if (emittedSgr || spans.length > 1)
348
+ chunks.push(style_1.SGR_RESET);
349
+ return chunks;
350
+ }
351
+ /** Stable string form of a styled row, used for changed-row comparison. */
352
+ renderRowString(row) {
353
+ return this.renderRowChunks(row).join('');
354
+ }
355
+ trimDefaultTrailingCells(row) {
356
+ let end = row.length;
357
+ while (end > 0) {
358
+ const cell = row[end - 1];
359
+ if (cell.char !== ' ' || (0, style_1.styleKey)(cell.style) !== '')
360
+ break;
361
+ end -= 1;
362
+ }
363
+ return row.slice(0, end);
364
+ }
365
+ renderedRowWidth(row) {
366
+ return this.trimDefaultTrailingCells(row)
367
+ .reduce((total, cell) => total + cell.width, 0);
368
+ }
369
+ /**
370
+ * Ensure the surface owns exactly `requiredRows` rows starting at the current
371
+ * cursor. Allocation uses ordinary newlines, so existing shell output is
372
+ * preserved and may naturally move into terminal scrollback.
373
+ */
218
374
  ensureCapacity(requiredRows) {
219
- const target = Math.min(requiredRows, Math.max(1, this.height - 1));
375
+ const target = Math.min(requiredRows, Math.max(1, this.liveBandRows));
220
376
  const chunks = [];
221
- while (this.liveRegionCapacity < target) {
222
- chunks.push('\n'); // hard line boundary
223
- this.liveRegionCapacity += 1;
224
- this.cursorRow = this.liveRegionCapacity;
377
+ if (this.liveRegionCapacity !== target) {
378
+ if (this.liveRegionCapacity > 0) {
379
+ chunks.push(this.clearLiveRegion());
380
+ this.releaseLiveRegion();
381
+ }
382
+ chunks.push(CR);
383
+ for (let i = 1; i < target; i++)
384
+ chunks.push('\n');
385
+ chunks.push(cursorUp(target - 1), CR);
386
+ this.liveRegionCapacity = target;
387
+ this.cursorRow = 0;
388
+ this.cursorColumn = 0;
389
+ this.previousFrame = null;
225
390
  }
226
391
  return chunks.join('');
227
392
  }
228
- /** Clear the live region rows (relative, no absolute addressing). */
393
+ /** Clear the live band rows (relative, no absolute addressing). */
229
394
  clearLiveRegion() {
395
+ if (this.liveRegionCapacity === 0)
396
+ return '';
230
397
  const chunks = [];
231
398
  chunks.push(cursorUp(this.cursorRow));
232
399
  for (let i = 0; i < this.liveRegionCapacity; i++) {
@@ -236,21 +403,83 @@ class InlineTerminalSurface {
236
403
  }
237
404
  chunks.push(cursorUp(this.liveRegionCapacity - 1));
238
405
  this.cursorRow = 0;
406
+ this.cursorColumn = 0;
407
+ return chunks.join('');
408
+ }
409
+ /**
410
+ * Clear the old live block after the terminal has already adopted a new
411
+ * width. A previously full row can occupy multiple physical rows after
412
+ * reflow, so tracked logical rows are not sufficient during resize.
413
+ */
414
+ clearReflowedLiveRegion(nextWidth) {
415
+ if (this.liveRegionCapacity === 0)
416
+ return '';
417
+ if (!this.previousFrame)
418
+ return this.clearLiveRegion();
419
+ const physicalWidth = Math.max(1, Math.floor(nextWidth));
420
+ const frame = this.previousFrame;
421
+ const rowPhysicalHeights = Array.from({ length: this.liveRegionCapacity }, (_, row) => {
422
+ const cells = frame.rows[row] ?? [];
423
+ const writtenWidth = this.renderedRowWidth(cells);
424
+ return Math.max(1, Math.ceil(Math.max(1, writtenWidth) / physicalWidth));
425
+ });
426
+ const logicalCursorRow = Math.min(this.cursorRow, rowPhysicalHeights.length - 1);
427
+ const cursorPhysicalRow = rowPhysicalHeights
428
+ .slice(0, logicalCursorRow)
429
+ .reduce((total, rows) => total + rows, 0)
430
+ + Math.floor(Math.max(0, this.cursorColumn) / physicalWidth);
431
+ const physicalRows = rowPhysicalHeights.reduce((total, rows) => total + rows, 0);
432
+ const chunks = [cursorUp(cursorPhysicalRow)];
433
+ for (let row = 0; row < physicalRows; row += 1) {
434
+ chunks.push(CR, EL2);
435
+ if (row < physicalRows - 1)
436
+ chunks.push(cursorDown(1));
437
+ }
438
+ chunks.push(cursorUp(physicalRows - 1));
439
+ this.cursorRow = 0;
440
+ this.cursorColumn = 0;
239
441
  return chunks.join('');
240
442
  }
241
- /** Resize: only handle live region; committed history goes to terminal reflow. */
242
- async resize(width, height, getLatestLiveFrame) {
443
+ clearOwnedLiveRegion() {
444
+ return this.resizePending && this.pendingResizeWidth !== null
445
+ ? this.clearReflowedLiveRegion(this.pendingResizeWidth)
446
+ : this.clearLiveRegion();
447
+ }
448
+ waitForResizeCompletion() {
449
+ if (!this.resizePending)
450
+ return Promise.resolve();
451
+ return new Promise(resolve => this.resizeWaiters.push(resolve));
452
+ }
453
+ completeResize() {
454
+ this.resizePending = false;
455
+ this.pendingResizeWidth = null;
456
+ const waiters = this.resizeWaiters.splice(0);
457
+ for (const resolve of waiters)
458
+ resolve();
459
+ }
460
+ releaseLiveRegion() {
461
+ this.liveRegionCapacity = 0;
462
+ this.cursorRow = 0;
463
+ this.cursorColumn = 0;
464
+ this.previousFrame = null;
465
+ }
466
+ /** Resize: erase only the owned block, then rebuild from the current cursor. */
467
+ async resize(width, height, getLatestLiveFrame, generation = this.resizePending ? this.resizeGeneration : this.beginResize(width)) {
243
468
  await this.enqueue(async () => {
469
+ if (generation !== this.resizeGeneration)
470
+ return;
471
+ const chunks = [this.clearReflowedLiveRegion(width)];
472
+ this.releaseLiveRegion();
244
473
  this.width = Math.max(1, Math.floor(width));
245
474
  this.height = Math.max(1, Math.floor(height));
246
- // Reset capacity and rebuild live frame.
247
- this.liveRegionCapacity = 0;
248
- this.cursorRow = 0;
249
- this.previousFrame = null;
475
+ this.liveBandRows = InlineTerminalSurface.computeBandRows(this.height);
250
476
  const liveFrame = getLatestLiveFrame();
251
477
  if (liveFrame) {
252
- this.writeRaw(this.renderLiveInternal(liveFrame));
478
+ chunks.push(this.renderLiveInternal(liveFrame));
253
479
  }
480
+ await this.writeRaw(chunks.join(''));
481
+ if (generation === this.resizeGeneration)
482
+ this.completeResize();
254
483
  });
255
484
  }
256
485
  /** Suspend for child process: clear live region, disable bracketed paste, show cursor. */
@@ -258,24 +487,27 @@ class InlineTerminalSurface {
258
487
  await this.enqueue(async () => {
259
488
  if (this.phase !== 'mounted')
260
489
  return;
261
- this.writeRaw(this.clearLiveRegion());
262
- this.writeRaw(`${style_1.SGR_RESET}${SHOW_CURSOR}${DISABLE_BRACKETED_PASTE}${ENABLE_AUTOWRAP}`);
490
+ await this.writeRaw(`${this.clearOwnedLiveRegion()}${style_1.SGR_RESET}${SHOW_CURSOR}${DISABLE_BRACKETED_PASTE}${ENABLE_AUTOWRAP}`);
263
491
  this.phase = 'suspended';
264
- this.liveRegionCapacity = 0;
265
- this.previousFrame = null;
492
+ this.releaseLiveRegion();
493
+ this.completeResize();
266
494
  });
267
495
  }
268
496
  /** Restore after child process: re-enable bracketed paste, rebuild live frame. */
269
- async restore(getLatestLiveFrame) {
497
+ async restore(getLatestLiveFrame, width = this.width, height = this.height) {
270
498
  await this.enqueue(async () => {
271
499
  if (this.phase !== 'suspended')
272
500
  return;
501
+ this.width = Math.max(1, Math.floor(width));
502
+ this.height = Math.max(1, Math.floor(height));
503
+ this.liveBandRows = InlineTerminalSurface.computeBandRows(this.height);
273
504
  this.phase = 'mounted';
274
- this.writeRaw(`${ENABLE_BRACKETED_PASTE}${HIDE_CURSOR}${DISABLE_AUTOWRAP}`);
505
+ const chunks = [`${ENABLE_BRACKETED_PASTE}${HIDE_CURSOR}`];
275
506
  const liveFrame = getLatestLiveFrame();
276
507
  if (liveFrame) {
277
- this.writeRaw(this.renderLiveInternal(liveFrame));
508
+ chunks.push(this.renderLiveInternal(liveFrame));
278
509
  }
510
+ await this.writeRaw(chunks.join(''));
279
511
  });
280
512
  }
281
513
  /** Unmount: clear live region, restore terminal state. Does NOT erase scrollback. */
@@ -283,13 +515,10 @@ class InlineTerminalSurface {
283
515
  await this.enqueue(async () => {
284
516
  if (this.phase === 'unmounted')
285
517
  return;
286
- this.writeRaw(this.clearLiveRegion());
287
- this.writeRaw(`${style_1.SGR_RESET}${SHOW_CURSOR}${DISABLE_BRACKETED_PASTE}${ENABLE_AUTOWRAP}`);
288
- // Final newline so shell prompt starts on a clean line.
289
- this.writeRaw('\n');
518
+ await this.writeRaw(`${this.clearOwnedLiveRegion()}${style_1.SGR_RESET}${SHOW_CURSOR}${DISABLE_BRACKETED_PASTE}${ENABLE_AUTOWRAP}\n`);
290
519
  this.phase = 'unmounted';
291
- this.liveRegionCapacity = 0;
292
- this.previousFrame = null;
520
+ this.releaseLiveRegion();
521
+ this.completeResize();
293
522
  });
294
523
  }
295
524
  /** Flush: wait for queue to drain. Yields to I/O between checks. */
@@ -298,23 +527,66 @@ class InlineTerminalSurface {
298
527
  await new Promise(resolve => setTimeout(resolve, 0));
299
528
  }
300
529
  }
301
- writeRaw(chunk) {
302
- if (this.phase === 'failed' || this.phase === 'unmounted')
530
+ async writeRaw(chunk) {
531
+ if (!chunk)
303
532
  return;
533
+ if (this.output.writable === false) {
534
+ throw new Error('terminal output is not writable');
535
+ }
536
+ let accepted;
304
537
  try {
305
- this.output.write(chunk);
538
+ accepted = this.output.write(chunk);
306
539
  }
307
- catch {
308
- this.phase = 'failed';
540
+ catch (error) {
541
+ throw error instanceof Error ? error : new Error(String(error));
309
542
  }
543
+ // Node streams return a boolean. A few compatible/test streams return
544
+ // void; only an explicit false means backpressure.
545
+ if (accepted !== false)
546
+ return;
547
+ await new Promise((resolve, reject) => {
548
+ let settled = false;
549
+ const cleanup = () => {
550
+ this.output.off('drain', onDrain);
551
+ this.output.off('error', onError);
552
+ this.output.off('close', onClose);
553
+ };
554
+ const settle = (error) => {
555
+ if (settled)
556
+ return;
557
+ settled = true;
558
+ cleanup();
559
+ if (error)
560
+ reject(error);
561
+ else
562
+ resolve();
563
+ };
564
+ const onDrain = () => {
565
+ if (this.output.writable === false) {
566
+ settle(new Error('terminal output closed before drain'));
567
+ return;
568
+ }
569
+ settle();
570
+ };
571
+ const onError = (error) => {
572
+ settle(error instanceof Error ? error : new Error(String(error ?? 'terminal output error')));
573
+ };
574
+ const onClose = () => settle(new Error('terminal output closed before drain'));
575
+ this.output.on('drain', onDrain);
576
+ this.output.on('error', onError);
577
+ this.output.on('close', onClose);
578
+ });
310
579
  }
311
580
  /** Reset for tests. */
312
581
  reset() {
313
582
  this.phase = 'idle';
583
+ this.liveBandRows = 0;
314
584
  this.liveRegionCapacity = 0;
315
585
  this.cursorRow = 0;
316
586
  this.cursorColumn = 0;
317
587
  this.previousFrame = null;
588
+ this.completeResize();
589
+ this.resizeGeneration = 0;
318
590
  this.queue = [];
319
591
  this.processing = false;
320
592
  }