@zhijiewang/openharness 0.9.3 → 0.11.0

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 (282) hide show
  1. package/README.md +87 -24
  2. package/dist/Tool.d.ts.map +1 -1
  3. package/dist/Tool.js +7 -1
  4. package/dist/Tool.js.map +1 -1
  5. package/dist/Tool.test.js +8 -2
  6. package/dist/Tool.test.js.map +1 -1
  7. package/dist/agents/roles.d.ts +25 -0
  8. package/dist/agents/roles.d.ts.map +1 -0
  9. package/dist/agents/roles.js +116 -0
  10. package/dist/agents/roles.js.map +1 -0
  11. package/dist/agents/roles.test.d.ts +2 -0
  12. package/dist/agents/roles.test.d.ts.map +1 -0
  13. package/dist/agents/roles.test.js +38 -0
  14. package/dist/agents/roles.test.js.map +1 -0
  15. package/dist/commands/commands-new.test.d.ts +5 -0
  16. package/dist/commands/commands-new.test.d.ts.map +1 -0
  17. package/dist/commands/commands-new.test.js +132 -0
  18. package/dist/commands/commands-new.test.js.map +1 -0
  19. package/dist/commands/commands.test.js +31 -0
  20. package/dist/commands/commands.test.js.map +1 -1
  21. package/dist/commands/index.d.ts +2 -0
  22. package/dist/commands/index.d.ts.map +1 -1
  23. package/dist/commands/index.js +199 -6
  24. package/dist/commands/index.js.map +1 -1
  25. package/dist/components/REPL.js +1 -1
  26. package/dist/components/REPL.js.map +1 -1
  27. package/dist/git/git.test.js +33 -1
  28. package/dist/git/git.test.js.map +1 -1
  29. package/dist/git/index.d.ts +12 -0
  30. package/dist/git/index.d.ts.map +1 -1
  31. package/dist/git/index.js +47 -2
  32. package/dist/git/index.js.map +1 -1
  33. package/dist/harness/checkpoints.d.ts +36 -0
  34. package/dist/harness/checkpoints.d.ts.map +1 -0
  35. package/dist/harness/checkpoints.js +156 -0
  36. package/dist/harness/checkpoints.js.map +1 -0
  37. package/dist/harness/config.d.ts +3 -0
  38. package/dist/harness/config.d.ts.map +1 -1
  39. package/dist/harness/config.js +35 -2
  40. package/dist/harness/config.js.map +1 -1
  41. package/dist/harness/config.test.js +21 -1
  42. package/dist/harness/config.test.js.map +1 -1
  43. package/dist/harness/hooks-env.test.d.ts +5 -0
  44. package/dist/harness/hooks-env.test.d.ts.map +1 -0
  45. package/dist/harness/hooks-env.test.js +41 -0
  46. package/dist/harness/hooks-env.test.js.map +1 -0
  47. package/dist/harness/hooks.d.ts +7 -0
  48. package/dist/harness/hooks.d.ts.map +1 -1
  49. package/dist/harness/hooks.js +14 -0
  50. package/dist/harness/hooks.js.map +1 -1
  51. package/dist/harness/keybindings.d.ts.map +1 -1
  52. package/dist/harness/keybindings.js +4 -0
  53. package/dist/harness/keybindings.js.map +1 -1
  54. package/dist/harness/memory.d.ts +19 -0
  55. package/dist/harness/memory.d.ts.map +1 -1
  56. package/dist/harness/memory.js +85 -0
  57. package/dist/harness/memory.js.map +1 -1
  58. package/dist/harness/onboarding.d.ts +1 -1
  59. package/dist/harness/onboarding.d.ts.map +1 -1
  60. package/dist/harness/onboarding.js +59 -4
  61. package/dist/harness/onboarding.js.map +1 -1
  62. package/dist/harness/onboarding.test.d.ts +5 -0
  63. package/dist/harness/onboarding.test.d.ts.map +1 -0
  64. package/dist/harness/onboarding.test.js +93 -0
  65. package/dist/harness/onboarding.test.js.map +1 -0
  66. package/dist/harness/rules.d.ts +6 -1
  67. package/dist/harness/rules.d.ts.map +1 -1
  68. package/dist/harness/rules.js +52 -5
  69. package/dist/harness/rules.js.map +1 -1
  70. package/dist/harness/rules.test.js +30 -1
  71. package/dist/harness/rules.test.js.map +1 -1
  72. package/dist/harness/session.d.ts +8 -1
  73. package/dist/harness/session.d.ts.map +1 -1
  74. package/dist/harness/session.js +13 -5
  75. package/dist/harness/session.js.map +1 -1
  76. package/dist/harness/store.d.ts +46 -0
  77. package/dist/harness/store.d.ts.map +1 -0
  78. package/dist/harness/store.js +56 -0
  79. package/dist/harness/store.js.map +1 -0
  80. package/dist/harness/store.test.d.ts +2 -0
  81. package/dist/harness/store.test.d.ts.map +1 -0
  82. package/dist/harness/store.test.js +71 -0
  83. package/dist/harness/store.test.js.map +1 -0
  84. package/dist/harness/submit-handler.d.ts +2 -0
  85. package/dist/harness/submit-handler.d.ts.map +1 -1
  86. package/dist/harness/submit-handler.js +3 -0
  87. package/dist/harness/submit-handler.js.map +1 -1
  88. package/dist/main.js +153 -26
  89. package/dist/main.js.map +1 -1
  90. package/dist/mcp/client.d.ts +2 -0
  91. package/dist/mcp/client.d.ts.map +1 -1
  92. package/dist/mcp/client.js +10 -2
  93. package/dist/mcp/client.js.map +1 -1
  94. package/dist/mcp/loader.d.ts +2 -0
  95. package/dist/mcp/loader.d.ts.map +1 -1
  96. package/dist/mcp/loader.js +34 -18
  97. package/dist/mcp/loader.js.map +1 -1
  98. package/dist/mcp/loader.test.d.ts +7 -0
  99. package/dist/mcp/loader.test.d.ts.map +1 -0
  100. package/dist/mcp/loader.test.js +25 -0
  101. package/dist/mcp/loader.test.js.map +1 -0
  102. package/dist/providers/anthropic-convert.test.d.ts +5 -0
  103. package/dist/providers/anthropic-convert.test.d.ts.map +1 -0
  104. package/dist/providers/anthropic-convert.test.js +98 -0
  105. package/dist/providers/anthropic-convert.test.js.map +1 -0
  106. package/dist/providers/anthropic.d.ts.map +1 -1
  107. package/dist/providers/anthropic.js +23 -4
  108. package/dist/providers/anthropic.js.map +1 -1
  109. package/dist/providers/stream-parsing.test.d.ts +6 -0
  110. package/dist/providers/stream-parsing.test.d.ts.map +1 -0
  111. package/dist/providers/stream-parsing.test.js +174 -0
  112. package/dist/providers/stream-parsing.test.js.map +1 -0
  113. package/dist/query/compress.d.ts +17 -0
  114. package/dist/query/compress.d.ts.map +1 -0
  115. package/dist/query/compress.js +115 -0
  116. package/dist/query/compress.js.map +1 -0
  117. package/dist/query/errors.d.ts +10 -0
  118. package/dist/query/errors.d.ts.map +1 -0
  119. package/dist/query/errors.js +22 -0
  120. package/dist/query/errors.js.map +1 -0
  121. package/dist/query/index.d.ts +15 -0
  122. package/dist/query/index.d.ts.map +1 -0
  123. package/dist/query/index.js +199 -0
  124. package/dist/query/index.js.map +1 -0
  125. package/dist/query/tools.d.ts +17 -0
  126. package/dist/query/tools.d.ts.map +1 -0
  127. package/dist/query/tools.js +129 -0
  128. package/dist/query/tools.js.map +1 -0
  129. package/dist/query/types.d.ts +31 -0
  130. package/dist/query/types.d.ts.map +1 -0
  131. package/dist/query/types.js +5 -0
  132. package/dist/query/types.js.map +1 -0
  133. package/dist/query.d.ts +8 -38
  134. package/dist/query.d.ts.map +1 -1
  135. package/dist/query.js +7 -444
  136. package/dist/query.js.map +1 -1
  137. package/dist/query.test.js +1 -1
  138. package/dist/query.test.js.map +1 -1
  139. package/dist/renderer/cells.d.ts.map +1 -1
  140. package/dist/renderer/cells.js +15 -2
  141. package/dist/renderer/cells.js.map +1 -1
  142. package/dist/renderer/colors.d.ts +8 -0
  143. package/dist/renderer/colors.d.ts.map +1 -0
  144. package/dist/renderer/colors.js +18 -0
  145. package/dist/renderer/colors.js.map +1 -0
  146. package/dist/renderer/diff.test.d.ts +5 -0
  147. package/dist/renderer/diff.test.d.ts.map +1 -0
  148. package/dist/renderer/diff.test.js +140 -0
  149. package/dist/renderer/diff.test.js.map +1 -0
  150. package/dist/renderer/differ.d.ts +1 -5
  151. package/dist/renderer/differ.d.ts.map +1 -1
  152. package/dist/renderer/differ.js +3 -20
  153. package/dist/renderer/differ.js.map +1 -1
  154. package/dist/renderer/e2e.test.js +136 -53
  155. package/dist/renderer/e2e.test.js.map +1 -1
  156. package/dist/renderer/image.test.d.ts +5 -0
  157. package/dist/renderer/image.test.d.ts.map +1 -0
  158. package/dist/renderer/image.test.js +66 -0
  159. package/dist/renderer/image.test.js.map +1 -0
  160. package/dist/renderer/index.d.ts +28 -16
  161. package/dist/renderer/index.d.ts.map +1 -1
  162. package/dist/renderer/index.js +289 -222
  163. package/dist/renderer/index.js.map +1 -1
  164. package/dist/renderer/layout.d.ts +14 -5
  165. package/dist/renderer/layout.d.ts.map +1 -1
  166. package/dist/renderer/layout.js +522 -388
  167. package/dist/renderer/layout.js.map +1 -1
  168. package/dist/renderer/markdown.d.ts.map +1 -1
  169. package/dist/renderer/markdown.js +42 -36
  170. package/dist/renderer/markdown.js.map +1 -1
  171. package/dist/renderer/perf.test.js +1 -4
  172. package/dist/renderer/perf.test.js.map +1 -1
  173. package/dist/renderer/session-browser.test.d.ts +6 -0
  174. package/dist/renderer/session-browser.test.d.ts.map +1 -0
  175. package/dist/renderer/session-browser.test.js +95 -0
  176. package/dist/renderer/session-browser.test.js.map +1 -0
  177. package/dist/renderer/ui-ux.test.d.ts +15 -0
  178. package/dist/renderer/ui-ux.test.d.ts.map +1 -0
  179. package/dist/renderer/ui-ux.test.js +470 -0
  180. package/dist/renderer/ui-ux.test.js.map +1 -0
  181. package/dist/repl.d.ts.map +1 -1
  182. package/dist/repl.js +192 -67
  183. package/dist/repl.js.map +1 -1
  184. package/dist/services/StreamingToolExecutor.d.ts.map +1 -1
  185. package/dist/services/StreamingToolExecutor.js +4 -2
  186. package/dist/services/StreamingToolExecutor.js.map +1 -1
  187. package/dist/services/agent-messaging.d.ts +68 -0
  188. package/dist/services/agent-messaging.d.ts.map +1 -0
  189. package/dist/services/agent-messaging.js +121 -0
  190. package/dist/services/agent-messaging.js.map +1 -0
  191. package/dist/services/agent-messaging.test.d.ts +2 -0
  192. package/dist/services/agent-messaging.test.d.ts.map +1 -0
  193. package/dist/services/agent-messaging.test.js +88 -0
  194. package/dist/services/agent-messaging.test.js.map +1 -0
  195. package/dist/services/cron.d.ts +40 -0
  196. package/dist/services/cron.d.ts.map +1 -0
  197. package/dist/services/cron.js +90 -0
  198. package/dist/services/cron.js.map +1 -0
  199. package/dist/services/cron.test.d.ts +2 -0
  200. package/dist/services/cron.test.d.ts.map +1 -0
  201. package/dist/services/cron.test.js +49 -0
  202. package/dist/services/cron.test.js.map +1 -0
  203. package/dist/tools/AgentTool/index.d.ts +9 -0
  204. package/dist/tools/AgentTool/index.d.ts.map +1 -1
  205. package/dist/tools/AgentTool/index.js +89 -6
  206. package/dist/tools/AgentTool/index.js.map +1 -1
  207. package/dist/tools/BashTool/index.d.ts +6 -0
  208. package/dist/tools/BashTool/index.d.ts.map +1 -1
  209. package/dist/tools/BashTool/index.js +39 -1
  210. package/dist/tools/BashTool/index.js.map +1 -1
  211. package/dist/tools/FileEditTool/index.js +4 -4
  212. package/dist/tools/FileEditTool/index.js.map +1 -1
  213. package/dist/tools/FileReadTool/index.d.ts +3 -0
  214. package/dist/tools/FileReadTool/index.d.ts.map +1 -1
  215. package/dist/tools/FileReadTool/index.js +102 -4
  216. package/dist/tools/FileReadTool/index.js.map +1 -1
  217. package/dist/tools/FileWriteTool/index.d.ts.map +1 -1
  218. package/dist/tools/FileWriteTool/index.js +20 -5
  219. package/dist/tools/FileWriteTool/index.js.map +1 -1
  220. package/dist/tools/GlobTool/index.d.ts.map +1 -1
  221. package/dist/tools/GlobTool/index.js +4 -61
  222. package/dist/tools/GlobTool/index.js.map +1 -1
  223. package/dist/tools/GrepTool/index.d.ts +30 -0
  224. package/dist/tools/GrepTool/index.d.ts.map +1 -1
  225. package/dist/tools/GrepTool/index.js +153 -72
  226. package/dist/tools/GrepTool/index.js.map +1 -1
  227. package/dist/tools/LSTool/index.d.ts +3 -0
  228. package/dist/tools/LSTool/index.d.ts.map +1 -1
  229. package/dist/tools/LSTool/index.js +44 -29
  230. package/dist/tools/LSTool/index.js.map +1 -1
  231. package/dist/tools/TaskCreateTool/index.d.ts +6 -0
  232. package/dist/tools/TaskCreateTool/index.d.ts.map +1 -1
  233. package/dist/tools/TaskCreateTool/index.js +8 -2
  234. package/dist/tools/TaskCreateTool/index.js.map +1 -1
  235. package/dist/tools/TaskGetTool/index.d.ts +12 -0
  236. package/dist/tools/TaskGetTool/index.d.ts.map +1 -0
  237. package/dist/tools/TaskGetTool/index.js +50 -0
  238. package/dist/tools/TaskGetTool/index.js.map +1 -0
  239. package/dist/tools/TaskOutputTool/index.d.ts +15 -0
  240. package/dist/tools/TaskOutputTool/index.d.ts.map +1 -0
  241. package/dist/tools/TaskOutputTool/index.js +45 -0
  242. package/dist/tools/TaskOutputTool/index.js.map +1 -0
  243. package/dist/tools/TaskStopTool/index.d.ts +15 -0
  244. package/dist/tools/TaskStopTool/index.d.ts.map +1 -0
  245. package/dist/tools/TaskStopTool/index.js +51 -0
  246. package/dist/tools/TaskStopTool/index.js.map +1 -0
  247. package/dist/tools/TaskUpdateTool/index.d.ts +21 -3
  248. package/dist/tools/TaskUpdateTool/index.d.ts.map +1 -1
  249. package/dist/tools/TaskUpdateTool/index.js +48 -3
  250. package/dist/tools/TaskUpdateTool/index.js.map +1 -1
  251. package/dist/tools/tools-basic.test.js +191 -2
  252. package/dist/tools/tools-basic.test.js.map +1 -1
  253. package/dist/tools.d.ts.map +1 -1
  254. package/dist/tools.js +6 -0
  255. package/dist/tools.js.map +1 -1
  256. package/dist/types/permissions.d.ts +2 -2
  257. package/dist/types/permissions.d.ts.map +1 -1
  258. package/dist/types/permissions.js +59 -13
  259. package/dist/types/permissions.js.map +1 -1
  260. package/dist/types/permissions.test.js +57 -0
  261. package/dist/types/permissions.test.js.map +1 -1
  262. package/dist/utils/bash-safety.d.ts +18 -0
  263. package/dist/utils/bash-safety.d.ts.map +1 -0
  264. package/dist/utils/bash-safety.js +227 -0
  265. package/dist/utils/bash-safety.js.map +1 -0
  266. package/dist/utils/bash-safety.test.d.ts +2 -0
  267. package/dist/utils/bash-safety.test.d.ts.map +1 -0
  268. package/dist/utils/bash-safety.test.js +112 -0
  269. package/dist/utils/bash-safety.test.js.map +1 -0
  270. package/dist/utils/fs.d.ts +15 -0
  271. package/dist/utils/fs.d.ts.map +1 -0
  272. package/dist/utils/fs.js +64 -0
  273. package/dist/utils/fs.js.map +1 -0
  274. package/dist/utils/fs.test.d.ts +5 -0
  275. package/dist/utils/fs.test.d.ts.map +1 -0
  276. package/dist/utils/fs.test.js +82 -0
  277. package/dist/utils/fs.test.js.map +1 -0
  278. package/dist/utils/safe-env.d.ts +10 -0
  279. package/dist/utils/safe-env.d.ts.map +1 -0
  280. package/dist/utils/safe-env.js +40 -0
  281. package/dist/utils/safe-env.js.map +1 -0
  282. package/package.json +3 -1
@@ -7,13 +7,18 @@ import { renderDiff } from './diff.js';
7
7
  import { isImageOutput, renderImageInline } from './image.js';
8
8
  import { renderSessionBrowser } from './session-browser.js';
9
9
  import { getTheme } from '../utils/theme-data.js';
10
- // Styles
11
- // Style helper
10
+ // ── Style constants ──
12
11
  const s = (fg, bold = false, dim = false) => ({ fg, bg: null, bold, dim, underline: false });
13
- // Theme-independent styles
14
12
  const S_TEXT = s(null);
15
13
  const S_DIM = s(null, false, true);
16
14
  const S_BORDER = s(null, false, true);
15
+ const S_BRIGHT = s(null);
16
+ const S_BANNER = s('cyan');
17
+ const S_BANNER_DIM = s(null, false, true);
18
+ const S_AGENT = s('cyan', true);
19
+ const S_KEY_GREEN = s('green', true);
20
+ const S_KEY_RED = s('red', true);
21
+ const S_KEY_CYAN = s('cyan', true);
17
22
  // Theme-dependent styles — lazily initialized on first rasterize() call
18
23
  let S_USER;
19
24
  let S_ASSISTANT;
@@ -37,9 +42,441 @@ function ensureStyles() {
37
42
  S_GREEN = s(t.success);
38
43
  }
39
44
  const SPINNER_CHARS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
45
+ // ── Shared rendering helpers ──
46
+ // Each takes (state, grid, row, limit, ...options) and returns next row.
47
+ function renderBannerSection(state, grid, r, limit, opts) {
48
+ if (!state.bannerLines)
49
+ return r;
50
+ const startLine = opts.compact ? Math.max(0, state.bannerLines.length - 2) : 0;
51
+ for (let i = startLine; i < state.bannerLines.length; i++) {
52
+ if (r >= limit)
53
+ break;
54
+ const line = state.bannerLines[i];
55
+ const isBannerArt = i < state.bannerLines.length - 2;
56
+ grid.writeText(r, 0, line, isBannerArt ? S_BANNER : S_BANNER_DIM);
57
+ r++;
58
+ }
59
+ if (r < limit)
60
+ r++; // blank line after banner
61
+ return r;
62
+ }
63
+ function renderThinkingSection(state, grid, r, limit) {
64
+ if (!state.thinkingText || r >= limit)
65
+ return r;
66
+ const w = grid.width;
67
+ if (state.thinkingExpanded) {
68
+ const thinkLines = state.thinkingText.split('\n').slice(-10);
69
+ const shimmerPos = state.spinnerFrame % 20;
70
+ for (const tLine of thinkLines) {
71
+ if (r >= limit)
72
+ break;
73
+ grid.writeText(r, 0, '💭 ', S_DIM);
74
+ const chars = [...tLine];
75
+ for (let ci = 0; ci < chars.length && ci + 3 < w; ci++) {
76
+ grid.setCell(r, 3 + ci, chars[ci], Math.abs(ci - shimmerPos) <= 2 ? S_BRIGHT : S_DIM);
77
+ }
78
+ r++;
79
+ }
80
+ }
81
+ else {
82
+ const lineCount = state.thinkingText.split('\n').length;
83
+ const elapsed = state.thinkingStartedAt ? Math.floor((Date.now() - state.thinkingStartedAt) / 1000) : 0;
84
+ const summary = `∴ Thinking${elapsed > 0 ? ` (${elapsed}s)` : ''} — ${lineCount} lines [Ctrl+O expand]`;
85
+ grid.writeText(r, 0, summary, S_DIM);
86
+ r++;
87
+ }
88
+ return r;
89
+ }
90
+ function renderThinkingSummarySection(state, grid, r, limit) {
91
+ if (state.loading || !state.lastThinkingSummary || r >= limit)
92
+ return r;
93
+ grid.writeText(r, 0, state.lastThinkingSummary, S_DIM);
94
+ return r + 1;
95
+ }
96
+ function renderSpinnerSection(state, grid, r, limit) {
97
+ if (!state.loading || state.streamingText || state.thinkingText || r >= limit)
98
+ return r;
99
+ const w = grid.width;
100
+ const thinkText = 'Thinking';
101
+ const elapsed = state.thinkingStartedAt ? Math.floor((Date.now() - state.thinkingStartedAt) / 1000) : 0;
102
+ const t = getTheme();
103
+ const baseColor = elapsed > 60 ? t.error : elapsed > 30 ? t.stall : t.primary;
104
+ const shimmerColor = elapsed > 60 ? t.stallShimmer : elapsed > 30 ? t.warning : t.primaryShimmer;
105
+ const baseStyle = { fg: baseColor, bg: null, bold: false, dim: false, underline: false };
106
+ grid.writeText(r, 0, '◆ ', { ...baseStyle, bold: true });
107
+ const shimmerPos = state.spinnerFrame % (thinkText.length + 6);
108
+ const shimmerStyle = { fg: shimmerColor, bg: null, bold: true, dim: false, underline: false };
109
+ for (let ci = 0; ci < thinkText.length; ci++) {
110
+ grid.setCell(r, 2 + ci, thinkText[ci], Math.abs(ci - shimmerPos) <= 1 ? shimmerStyle : baseStyle);
111
+ }
112
+ let suffix = '';
113
+ if (elapsed > 0)
114
+ suffix += ` ${elapsed}s`;
115
+ if (state.tokenCount > 0) {
116
+ const tokStr = state.tokenCount >= 1000 ? `${(state.tokenCount / 1000).toFixed(1)}K` : `${state.tokenCount}`;
117
+ suffix += ` | ${tokStr} tokens`;
118
+ }
119
+ suffix += '...';
120
+ grid.writeText(r, 2 + thinkText.length, suffix, S_DIM);
121
+ return r + 1;
122
+ }
123
+ function renderErrorSection(state, grid, r, limit) {
124
+ if (!state.errorText || r >= limit)
125
+ return r;
126
+ const w = grid.width;
127
+ grid.writeText(r, 0, '✗ ', S_ERROR);
128
+ grid.writeText(r, 2, state.errorText.slice(0, w - 4), S_ERROR);
129
+ return r + 1;
130
+ }
131
+ function renderToolCallsSection(state, grid, r, limit, opts) {
132
+ const w = grid.width;
133
+ for (const [callId, tc] of state.toolCalls) {
134
+ if (r >= limit)
135
+ break;
136
+ const isAgent = tc.isAgent || tc.toolName === 'Agent' || tc.toolName === 'ParallelAgents';
137
+ const icon = isAgent
138
+ ? (tc.status === 'running' ? '⊕' : tc.status === 'done' ? '◈' : '◇')
139
+ : (tc.status === 'running' ? SPINNER_CHARS[state.spinnerFrame % SPINNER_CHARS.length] : tc.status === 'done' ? '✓' : '✗');
140
+ const statusStyle = tc.status === 'error' ? S_ERROR : tc.status === 'done' ? S_GREEN : isAgent ? S_AGENT : S_YELLOW;
141
+ const nameStyle = isAgent ? S_AGENT : { ...S_YELLOW, bold: true };
142
+ const isExpanded = state.expandedToolCalls.has(callId);
143
+ const canExpand = tc.status !== 'running' && tc.output;
144
+ // Collapse/expand indicator
145
+ if (canExpand) {
146
+ grid.writeText(r, 0, isExpanded ? '▼' : '▶', S_DIM);
147
+ }
148
+ grid.writeText(r, 2, `${icon} `, statusStyle);
149
+ grid.writeText(r, 4, tc.toolName, nameStyle);
150
+ let afterName = 4 + tc.toolName.length + 1;
151
+ if (tc.args) {
152
+ const maxArgs = w - afterName - 15;
153
+ if (maxArgs > 5) {
154
+ const argsText = tc.args.slice(0, maxArgs) + (tc.args.length > maxArgs ? '…' : '');
155
+ grid.writeText(r, afterName, argsText, S_DIM);
156
+ afterName += argsText.length + 1;
157
+ }
158
+ }
159
+ // Elapsed time for running tools
160
+ if (tc.status === 'running' && tc.startedAt) {
161
+ const elapsed = Math.floor((Date.now() - tc.startedAt) / 1000);
162
+ if (elapsed > 0) {
163
+ const lineCount = tc.liveOutput?.length ?? 0;
164
+ const elapsedStr = lineCount > 0 ? `${elapsed}s · ${lineCount} lines` : `${elapsed}s`;
165
+ grid.writeText(r, Math.min(afterName, w - elapsedStr.length - 2), elapsedStr, S_DIM);
166
+ }
167
+ }
168
+ // Result summary for completed tools
169
+ if (tc.status !== 'running' && tc.resultSummary) {
170
+ const elapsed = tc.startedAt ? Math.floor((Date.now() - tc.startedAt) / 1000) : 0;
171
+ const suffix = elapsed > 0 ? `${tc.resultSummary} · ${elapsed}s` : tc.resultSummary;
172
+ grid.writeText(r, Math.min(afterName, w - suffix.length - 2), suffix, S_DIM);
173
+ }
174
+ r++;
175
+ // Agent description line
176
+ if (isAgent && tc.agentDescription && r < limit) {
177
+ grid.writeText(r, 6, tc.agentDescription.slice(0, w - 8), S_DIM);
178
+ r++;
179
+ }
180
+ // Live streaming output while running
181
+ if (tc.status === 'running' && tc.liveOutput && tc.liveOutput.length > 0) {
182
+ const overflow = tc.liveOutput.length > opts.maxLiveLines ? tc.liveOutput.length - opts.maxLiveLines : 0;
183
+ if (opts.showOverflow && overflow > 0 && r < limit) {
184
+ grid.writeText(r, 6, `… (${overflow} earlier lines)`, S_DIM);
185
+ r++;
186
+ }
187
+ const visible = overflow > 0 ? tc.liveOutput.slice(-opts.maxLiveLines) : tc.liveOutput;
188
+ for (const line of visible) {
189
+ if (r >= limit)
190
+ break;
191
+ grid.writeText(r, 6, line.slice(0, w - 8), S_DIM);
192
+ r++;
193
+ }
194
+ }
195
+ // Final output — collapsed by default (only show when expanded via Tab)
196
+ if (tc.output && tc.status !== 'running' && isExpanded && r < limit) {
197
+ // Image results: show inline placeholder
198
+ if (isImageOutput(tc.output)) {
199
+ const label = renderImageInline(tc.output);
200
+ grid.writeText(r, 6, label.slice(0, w - 8), S_DIM);
201
+ r++;
202
+ continue;
203
+ }
204
+ const outLines = tc.output.split('\n');
205
+ const maxOut = 20;
206
+ const showLines = outLines.slice(0, maxOut);
207
+ for (const line of showLines) {
208
+ if (r >= limit)
209
+ break;
210
+ const lineStyle = tc.status === 'error' ? S_ERROR : S_DIM;
211
+ grid.writeText(r, 6, line.slice(0, w - 8), lineStyle);
212
+ r++;
213
+ }
214
+ if (outLines.length > maxOut && r < limit) {
215
+ grid.writeText(r, 6, `… (${outLines.length} lines total)`, S_DIM);
216
+ r++;
217
+ }
218
+ }
219
+ }
220
+ return r;
221
+ }
222
+ function renderContextWarningSection(state, grid, r, limit) {
223
+ if (!state.contextWarning || r >= limit)
224
+ return r;
225
+ const warnStyle = { fg: 'yellow', bg: null, bold: state.contextWarning.critical, dim: false, underline: false };
226
+ grid.writeText(r, 0, state.contextWarning.text, warnStyle);
227
+ return r + 1;
228
+ }
229
+ function renderPermissionBoxSection(state, grid, nextRow, h, opts) {
230
+ if (!state.permissionBox || grid.width < 20)
231
+ return nextRow;
232
+ const w = grid.width;
233
+ const { toolName, description, riskLevel } = state.permissionBox;
234
+ const riskColor = riskLevel === 'high' ? 'red' : riskLevel === 'medium' ? 'yellow' : 'green';
235
+ const riskStyle = { fg: riskColor, bg: null, bold: true, dim: false, underline: false };
236
+ if (opts.boxed) {
237
+ if ((h - nextRow) < 6)
238
+ return nextRow;
239
+ const riskDim = { fg: riskColor, bg: null, bold: false, dim: true, underline: false };
240
+ const boxWidth = Math.max(15, Math.min(w - 2, 70));
241
+ // Top border
242
+ grid.writeText(nextRow, 1, '╭' + '─'.repeat(boxWidth - 2) + '╮', riskDim);
243
+ nextRow++;
244
+ // Tool name + risk
245
+ grid.writeText(nextRow, 1, '│ ', riskDim);
246
+ grid.writeText(nextRow, 3, '⚠ ', riskStyle);
247
+ grid.writeText(nextRow, 5, toolName, { ...riskStyle });
248
+ grid.writeText(nextRow, 5 + toolName.length, ` ${riskLevel} risk`, S_DIM);
249
+ grid.writeText(nextRow, boxWidth, '│', riskDim);
250
+ nextRow++;
251
+ // Description (truncated)
252
+ const rawDesc = state.permissionBox.suggestion || description.slice(0, boxWidth - 6);
253
+ const descText = rawDesc.replace(/\|/g, ' ').replace(/\\/g, '/');
254
+ grid.writeText(nextRow, 1, '│ ', riskDim);
255
+ grid.writeText(nextRow, 3, descText.slice(0, boxWidth - 4), S_DIM);
256
+ grid.writeText(nextRow, boxWidth, '│', riskDim);
257
+ nextRow++;
258
+ // Inline diff
259
+ if (state.permissionDiffVisible && state.permissionDiffInfo && nextRow + 3 < h) {
260
+ grid.writeText(nextRow, 1, '│', riskDim);
261
+ nextRow++;
262
+ const availDiffRows = Math.min(opts.maxDiffHeight, h - nextRow - 3);
263
+ const diffRows = renderDiff(grid, nextRow, 3, state.permissionDiffInfo, boxWidth - 2, availDiffRows);
264
+ for (let dr = 0; dr < diffRows; dr++) {
265
+ if (nextRow + dr < grid.height) {
266
+ grid.setCell(nextRow + dr, 1, '│', riskDim);
267
+ grid.setCell(nextRow + dr, boxWidth, '│', riskDim);
268
+ }
269
+ }
270
+ nextRow += diffRows;
271
+ }
272
+ // Action keys
273
+ grid.writeText(nextRow, 1, '│ ', riskDim);
274
+ let kc = 3;
275
+ grid.writeText(nextRow, kc, 'Y', S_KEY_GREEN);
276
+ kc += 1;
277
+ grid.writeText(nextRow, kc, 'es', S_DIM);
278
+ kc += 2;
279
+ grid.writeText(nextRow, kc, ' ', S_DIM);
280
+ kc += 2;
281
+ grid.writeText(nextRow, kc, 'N', S_KEY_RED);
282
+ kc += 1;
283
+ grid.writeText(nextRow, kc, 'o', S_DIM);
284
+ kc += 1;
285
+ if (state.permissionDiffInfo) {
286
+ grid.writeText(nextRow, kc, ' ', S_DIM);
287
+ kc += 2;
288
+ grid.writeText(nextRow, kc, 'D', S_KEY_CYAN);
289
+ kc += 1;
290
+ grid.writeText(nextRow, kc, 'iff', S_DIM);
291
+ kc += 3;
292
+ }
293
+ grid.writeText(nextRow, boxWidth, '│', riskDim);
294
+ nextRow++;
295
+ // Bottom border
296
+ grid.writeText(nextRow, 1, '╰' + '─'.repeat(boxWidth - 2) + '╯', riskDim);
297
+ nextRow++;
298
+ }
299
+ else {
300
+ // Compact mode (rasterizeLive)
301
+ if ((h - nextRow) < 4)
302
+ return nextRow;
303
+ grid.writeText(nextRow, 1, `⚠ ${toolName} (${riskLevel} risk)`, riskStyle);
304
+ nextRow++;
305
+ grid.writeText(nextRow, 1, 'Y', S_KEY_GREEN);
306
+ grid.writeText(nextRow, 2, 'es ', S_DIM);
307
+ grid.writeText(nextRow, 6, 'N', S_KEY_RED);
308
+ grid.writeText(nextRow, 7, 'o', S_DIM);
309
+ if (state.permissionDiffInfo) {
310
+ grid.writeText(nextRow, 10, 'D', S_KEY_CYAN);
311
+ grid.writeText(nextRow, 11, 'iff', S_DIM);
312
+ }
313
+ nextRow++;
314
+ // Inline diff (when toggled)
315
+ if (state.permissionDiffVisible && state.permissionDiffInfo && nextRow + 3 < h) {
316
+ const availDiffRows = Math.min(15, h - nextRow - 3);
317
+ const diffRows = renderDiff(grid, nextRow, 3, state.permissionDiffInfo, Math.min(w - 2, 70), availDiffRows);
318
+ nextRow += diffRows;
319
+ }
320
+ }
321
+ return nextRow;
322
+ }
323
+ function renderQuestionPromptSection(state, grid, nextRow, h, opts) {
324
+ if (!state.questionPrompt || grid.width < 20)
325
+ return { nextRow, questionInputRow: -1 };
326
+ const w = grid.width;
327
+ const { question, options, input, cursor } = state.questionPrompt;
328
+ const qStyle = { fg: 'yellow', bg: null, bold: false, dim: false, underline: false };
329
+ if (opts.boxed) {
330
+ const qBorder = { fg: 'yellow', bg: null, bold: false, dim: true, underline: false };
331
+ const qBoxWidth = Math.max(15, Math.min(w - 2, 70));
332
+ grid.writeText(nextRow, 1, '╭' + '─'.repeat(qBoxWidth - 2) + '╮', qBorder);
333
+ nextRow++;
334
+ grid.writeText(nextRow, 1, '│ ', qBorder);
335
+ grid.writeText(nextRow, 3, `❓ ${question}`, qStyle);
336
+ grid.writeText(nextRow, qBoxWidth, '│', qBorder);
337
+ nextRow++;
338
+ if (options && options.length > 0) {
339
+ for (let oi = 0; oi < options.length; oi++) {
340
+ grid.writeText(nextRow, 1, '│ ', qBorder);
341
+ grid.writeText(nextRow, 5, `${oi + 1}. ${options[oi]}`, S_DIM);
342
+ grid.writeText(nextRow, qBoxWidth, '│', qBorder);
343
+ nextRow++;
344
+ }
345
+ }
346
+ const questionInputRow = nextRow;
347
+ grid.writeText(nextRow, 1, '│ ', qBorder);
348
+ grid.writeText(nextRow, 3, '❯ ', qStyle);
349
+ grid.writeText(nextRow, 5, input, S_TEXT);
350
+ grid.writeText(nextRow, qBoxWidth, '│', qBorder);
351
+ nextRow++;
352
+ grid.writeText(nextRow, 1, '╰' + '─'.repeat(qBoxWidth - 2) + '╯', qBorder);
353
+ nextRow++;
354
+ return { nextRow, questionInputRow };
355
+ }
356
+ else {
357
+ // Compact mode (rasterizeLive)
358
+ if ((h - nextRow) < 3)
359
+ return { nextRow, questionInputRow: -1 };
360
+ grid.writeText(nextRow, 1, `❓ ${question}`, S_TEXT);
361
+ nextRow++;
362
+ if (options) {
363
+ for (const opt of options) {
364
+ if (nextRow >= h)
365
+ break;
366
+ grid.writeText(nextRow, 3, opt, S_DIM);
367
+ nextRow++;
368
+ }
369
+ }
370
+ const questionInputRow = nextRow;
371
+ grid.writeText(nextRow, 1, '❯ ', S_USER);
372
+ grid.writeText(nextRow, 3, input, S_TEXT);
373
+ nextRow++;
374
+ return { nextRow, questionInputRow };
375
+ }
376
+ }
377
+ function renderStatusLineSection(state, grid, nextRow, limit) {
378
+ if (!state.statusLine || nextRow >= limit)
379
+ return nextRow;
380
+ grid.writeText(nextRow, 0, state.statusLine, S_DIM);
381
+ return nextRow + 1;
382
+ }
383
+ function renderAutocompleteSection(state, grid, nextRow, limit, promptWidth) {
384
+ if (state.autocomplete.length === 0)
385
+ return nextRow;
386
+ const w = grid.width;
387
+ for (let ai = 0; ai < state.autocomplete.length; ai++) {
388
+ if (nextRow >= limit)
389
+ break;
390
+ const cmd = state.autocomplete[ai];
391
+ const desc = state.autocompleteDescriptions[ai] ?? '';
392
+ const selected = ai === state.autocompleteIndex;
393
+ const acStyle = selected ? s(getTheme().user, true) : s(null, false, true);
394
+ grid.writeText(nextRow, promptWidth, `/${cmd.padEnd(12)}`, acStyle);
395
+ if (desc && w > promptWidth + 15)
396
+ grid.writeText(nextRow, promptWidth + 13, desc.slice(0, w - promptWidth - 15), S_DIM);
397
+ nextRow++;
398
+ }
399
+ return nextRow;
400
+ }
401
+ function renderNotificationsSection(state, grid, nextRow, limit) {
402
+ if (!state.notifications || state.notifications.length === 0)
403
+ return nextRow;
404
+ for (const note of state.notifications.slice(-2)) {
405
+ if (nextRow >= limit)
406
+ break;
407
+ grid.writeText(nextRow, 0, ` ⚡ ${note.text}`, S_YELLOW);
408
+ nextRow++;
409
+ }
410
+ return nextRow;
411
+ }
412
+ function renderInputSection(state, grid, inputRow, limit, promptText, promptWidth) {
413
+ grid.writeText(inputRow, 0, promptText, S_USER);
414
+ const inputStart = promptWidth;
415
+ const inputLines = state.inputText.split('\n');
416
+ const maxInputLines = Math.min(inputLines.length, 5);
417
+ for (let li = 0; li < maxInputLines; li++) {
418
+ if (inputRow + li >= limit)
419
+ break;
420
+ if (li === 0) {
421
+ grid.writeText(inputRow, inputStart, inputLines[0], S_TEXT);
422
+ }
423
+ else {
424
+ grid.writeText(inputRow + li, inputStart, inputLines[li], S_TEXT);
425
+ }
426
+ }
427
+ // Line count indicator for multi-line input
428
+ if (inputLines.length > 1) {
429
+ const lineCountStr = ` [${inputLines.length} lines]`;
430
+ const lineCountCol = Math.min(inputStart + (inputLines[0]?.length ?? 0) + 1, grid.width - lineCountStr.length - 1);
431
+ if (lineCountCol > inputStart)
432
+ grid.writeText(inputRow, lineCountCol, lineCountStr, S_DIM);
433
+ }
434
+ const hintsRow = inputRow + maxInputLines;
435
+ if (hintsRow < limit) {
436
+ const hintsText = inputLines.length > 1
437
+ ? `${state.statusHints} | Alt+Enter newline`
438
+ : state.statusHints;
439
+ grid.writeText(hintsRow, 0, hintsText, S_DIM);
440
+ }
441
+ return inputRow + maxInputLines + 1;
442
+ }
443
+ function renderCompanionSection(state, grid, anchorRow, limit, promptWidth) {
444
+ if (!state.companionLines || grid.width < 50)
445
+ return;
446
+ const w = grid.width;
447
+ const compWidth = Math.max(...state.companionLines.map(l => l.length), 0);
448
+ const compStartCol = Math.max(0, w - compWidth - 1);
449
+ if (compStartCol <= promptWidth + 20)
450
+ return;
451
+ const compStyle = { fg: state.companionColor || 'cyan', bg: null, bold: false, dim: false, underline: false };
452
+ for (let i = 0; i < state.companionLines.length; i++) {
453
+ const compRow = anchorRow + i;
454
+ if (compRow >= limit)
455
+ break;
456
+ grid.writeText(compRow, compStartCol, state.companionLines[i], compStyle);
457
+ }
458
+ }
459
+ function computeCursorPosition(state, inputRow, inputStart, questionInputRow) {
460
+ if (state.questionPrompt && questionInputRow >= 0) {
461
+ // In boxed mode cursor col is 5 (after "│ ❯ "), in compact mode it's 3 (after "❯ ")
462
+ return { cursorRow: questionInputRow, cursorCol: 5 + state.questionPrompt.cursor };
463
+ }
464
+ const textBeforeCursor = state.inputText.slice(0, state.inputCursor);
465
+ const cursorLines = textBeforeCursor.split('\n');
466
+ const cursorLineIdx = Math.min(cursorLines.length - 1, 4);
467
+ const cursorColInLine = cursorLines[cursorLines.length - 1].length;
468
+ return { cursorRow: inputRow + cursorLineIdx, cursorCol: inputStart + cursorColInLine };
469
+ }
470
+ function getPromptText(state) {
471
+ const vimIndicator = state.vimMode ? (state.vimMode === 'normal' ? '[N] ' : '[I] ') : '';
472
+ const promptText = vimIndicator + '❯ ';
473
+ return { promptText, promptWidth: promptText.length };
474
+ }
475
+ // ── Main rasterization functions ──
40
476
  /**
41
477
  * Rasterize application state into the cell grid.
42
- * Returns cursor position for the input line.
478
+ * Full-screen mode with message area + scrollbar + footer.
479
+ * Used by tests; production uses rasterizeLive().
43
480
  */
44
481
  export function rasterize(state, grid) {
45
482
  ensureStyles();
@@ -61,9 +498,7 @@ export function rasterize(state, grid) {
61
498
  // ── Session browser overlay ──
62
499
  if (state.sessionBrowser) {
63
500
  const browserRows = renderSessionBrowser(grid, 0, 0, state.sessionBrowser, w, msgAreaHeight);
64
- // Skip normal message rendering — show browser instead
65
501
  const footerStart = Math.min(browserRows, msgAreaHeight);
66
- // Render minimal footer (just input)
67
502
  for (let c = 0; c < w; c++)
68
503
  grid.setCell(footerStart, c, '─', S_BORDER);
69
504
  const inputRow = footerStart + 1;
@@ -72,7 +507,6 @@ export function rasterize(state, grid) {
72
507
  return { cursorRow: inputRow, cursorCol: 2 };
73
508
  }
74
509
  // ── Messages area (top) ──
75
- // Compute total height of all messages + streaming
76
510
  const allContent = [];
77
511
  for (const msg of state.messages) {
78
512
  if (msg.role === 'user') {
@@ -85,27 +519,21 @@ export function rasterize(state, grid) {
85
519
  allContent.push({ role: 'system', content: msg.content, style: S_DIM, prefixStyle: S_DIM, prefix: ' ' });
86
520
  }
87
521
  }
88
- // Add streaming text
89
522
  if (state.loading && state.streamingText) {
90
523
  allContent.push({ role: 'streaming', content: state.streamingText, style: S_TEXT, prefixStyle: S_ASSISTANT, prefix: '◆ ' });
91
524
  }
92
- // Thinking text rendered directly below (shimmer effect needs per-char styling)
93
- // Spinner rendered directly below (shimmer effect needs per-char styling)
94
- // Add error
95
525
  if (state.errorText) {
96
526
  allContent.push({ role: 'error', content: state.errorText, style: S_ERROR, prefixStyle: S_ERROR, prefix: '✗ ' });
97
527
  }
98
- // Render messages top-down (scroll up when content exceeds area)
99
528
  const prefixLen = 2;
100
529
  const contentWidth = w - 1; // reserve rightmost column for scrollbar
101
530
  const textWidth = contentWidth - prefixLen;
102
531
  // Pre-compute total height to handle scrolling
103
532
  let totalRows = 0;
104
- // Banner height (compact on small terminals, hidden if very small)
105
533
  if (state.bannerLines && h >= 30) {
106
534
  const compact = h < 40;
107
535
  const visibleLines = compact ? Math.min(2, state.bannerLines.length) : state.bannerLines.length;
108
- totalRows += visibleLines + 1; // +1 blank line after
536
+ totalRows += visibleLines + 1;
109
537
  }
110
538
  for (const item of allContent) {
111
539
  if (item.role === 'user' && totalRows > 0)
@@ -120,32 +548,28 @@ export function rasterize(state, grid) {
120
548
  }
121
549
  }
122
550
  }
123
- // Include non-message content in totalRows for accurate scroll indicator
124
551
  if (state.thinkingText) {
125
552
  totalRows += state.thinkingExpanded ? Math.min(state.thinkingText.split('\n').length, 10) : 1;
126
553
  }
127
554
  if (!state.loading && state.lastThinkingSummary)
128
555
  totalRows += 1;
129
556
  if (state.loading && !state.streamingText && !state.thinkingText)
130
- totalRows += 1; // spinner
557
+ totalRows += 1;
131
558
  for (const [callId, tc] of state.toolCalls) {
132
- totalRows += 1; // tool header line
559
+ totalRows += 1;
133
560
  if (tc.isAgent && tc.agentDescription)
134
- totalRows += 1; // agent description line
561
+ totalRows += 1;
135
562
  if (tc.status === 'running' && tc.liveOutput)
136
563
  totalRows += Math.min(tc.liveOutput.length, 5);
137
- // Collapsed tools show 0 output lines; expanded show up to 20
138
564
  if (tc.output && tc.status !== 'running' && state.expandedToolCalls.has(callId)) {
139
565
  totalRows += Math.min(tc.output.split('\n').length, 20);
140
566
  }
141
567
  }
142
568
  if (state.contextWarning)
143
569
  totalRows += 1;
144
- // If content exceeds area, scroll: offset so latest content is visible at bottom
145
- // manualScroll > 0 means user scrolled up by that many rows
146
570
  const autoOffset = totalRows > msgAreaHeight ? totalRows - msgAreaHeight : 0;
147
571
  const scrollOffset = Math.max(0, autoOffset - state.manualScroll);
148
- // Scrollbar geometry (rightmost column of message area)
572
+ // Scrollbar geometry
149
573
  const hasScrollbar = totalRows > msgAreaHeight;
150
574
  let thumbStart = 0;
151
575
  let thumbSize = msgAreaHeight;
@@ -154,13 +578,10 @@ export function rasterize(state, grid) {
154
578
  thumbStart = Math.round((scrollOffset / Math.max(1, totalRows)) * (msgAreaHeight - thumbSize));
155
579
  }
156
580
  let r = 0;
157
- let virtualR = 0; // tracks position before scroll clipping
581
+ let virtualR = 0;
158
582
  let contentIdx = 0;
159
- // ── Banner (ASCII art at top) ──
583
+ // ── Banner ──
160
584
  if (state.bannerLines && h >= 30) {
161
- const S_BANNER = s('cyan');
162
- const S_BANNER_DIM = s(null, false, true);
163
- // On small terminals, show only the last 2 lines (version + cwd info)
164
585
  const compact = h < 40;
165
586
  const startLine = compact ? Math.max(0, state.bannerLines.length - 2) : 0;
166
587
  for (let i = startLine; i < state.bannerLines.length; i++) {
@@ -172,16 +593,15 @@ export function rasterize(state, grid) {
172
593
  }
173
594
  virtualR++;
174
595
  }
175
- // Blank line after banner
176
596
  if (virtualR >= scrollOffset && r < msgAreaHeight) {
177
597
  r++;
178
598
  }
179
599
  virtualR++;
180
600
  }
601
+ // ── Messages ──
181
602
  for (const item of allContent) {
182
603
  if (r >= msgAreaHeight)
183
604
  break;
184
- // Divider before user messages (except first)
185
605
  if (item.role === 'user' && contentIdx > 0) {
186
606
  if (virtualR >= scrollOffset) {
187
607
  for (let c = 0; c < w; c++) {
@@ -191,7 +611,6 @@ export function rasterize(state, grid) {
191
611
  }
192
612
  virtualR++;
193
613
  }
194
- // Compute how many rows this content will take
195
614
  let itemRows;
196
615
  if (item.role === 'assistant' || item.role === 'streaming') {
197
616
  itemRows = measureMarkdown(item.content, contentWidth);
@@ -204,14 +623,11 @@ export function rasterize(state, grid) {
204
623
  }
205
624
  }
206
625
  if (virtualR + itemRows <= scrollOffset) {
207
- // Entirely above viewport — skip
208
626
  virtualR += itemRows;
209
627
  contentIdx++;
210
628
  continue;
211
629
  }
212
- // Write prefix
213
630
  grid.writeText(r, 0, item.prefix, item.prefixStyle);
214
- // Write content — use markdown renderer for assistant messages
215
631
  let rows;
216
632
  if (item.role === 'assistant' || item.role === 'streaming') {
217
633
  rows = renderMarkdown(grid, r, prefixLen, item.content, contentWidth, state.codeBlocksExpanded, msgAreaHeight);
@@ -223,177 +639,13 @@ export function rasterize(state, grid) {
223
639
  virtualR += itemRows;
224
640
  contentIdx++;
225
641
  }
226
- // ── Thinking text with shimmer (live) ──
227
- if (state.thinkingText && r < msgAreaHeight) {
228
- if (state.thinkingExpanded) {
229
- // Show full thinking text (last 10 lines)
230
- const thinkLines = state.thinkingText.split('\n').slice(-10);
231
- const shimmerPos = state.spinnerFrame % 20;
232
- const S_BRIGHT = { fg: null, bg: null, bold: false, dim: false, underline: false };
233
- for (const tLine of thinkLines) {
234
- if (r >= msgAreaHeight)
235
- break;
236
- grid.writeText(r, 0, '💭 ', S_DIM);
237
- const chars = [...tLine];
238
- for (let ci = 0; ci < chars.length && ci + 3 < w; ci++) {
239
- grid.setCell(r, 3 + ci, chars[ci], Math.abs(ci - shimmerPos) <= 2 ? S_BRIGHT : S_DIM);
240
- }
241
- r++;
242
- }
243
- }
244
- else {
245
- // Collapsed: single line with live indicator
246
- const lineCount = state.thinkingText.split('\n').length;
247
- const elapsed = state.thinkingStartedAt ? Math.floor((Date.now() - state.thinkingStartedAt) / 1000) : 0;
248
- const summary = `∴ Thinking${elapsed > 0 ? ` (${elapsed}s)` : ''} — ${lineCount} lines [Ctrl+O expand]`;
249
- grid.writeText(r, 0, summary, S_DIM);
250
- r++;
251
- }
252
- }
253
- // ── Collapsed thinking summary (after completion) ──
254
- if (!state.loading && state.lastThinkingSummary && r < msgAreaHeight) {
255
- if (state.thinkingExpanded) {
256
- // Expanded mode not applicable after completion since text was cleared
257
- grid.writeText(r, 0, state.lastThinkingSummary, S_DIM);
258
- r++;
259
- }
260
- else {
261
- grid.writeText(r, 0, state.lastThinkingSummary, S_DIM);
262
- r++;
263
- }
264
- }
265
- // ── Shimmer spinner ──
266
- if (state.loading && !state.streamingText && !state.thinkingText && r < msgAreaHeight) {
267
- const thinkText = 'Thinking';
268
- const elapsed = state.thinkingStartedAt ? Math.floor((Date.now() - state.thinkingStartedAt) / 1000) : 0;
269
- // Color transitions: magenta → yellow (30s+) → red (60s+)
270
- const t = getTheme();
271
- const baseColor = elapsed > 60 ? t.error : elapsed > 30 ? t.stall : t.primary;
272
- const shimmerColor = elapsed > 60 ? t.stallShimmer : elapsed > 30 ? t.warning : t.primaryShimmer;
273
- const baseStyle = { fg: baseColor, bg: null, bold: false, dim: false, underline: false };
274
- // Prefix
275
- const prefixStyle = { ...baseStyle, bold: true };
276
- grid.writeText(r, 0, '◆ ', prefixStyle);
277
- // Shimmer effect: bright color sweeps across text
278
- const shimmerPos = state.spinnerFrame % (thinkText.length + 6);
279
- const shimmerStyle = { fg: shimmerColor, bg: null, bold: true, dim: false, underline: false };
280
- for (let ci = 0; ci < thinkText.length; ci++) {
281
- grid.setCell(r, 2 + ci, thinkText[ci], Math.abs(ci - shimmerPos) <= 1 ? shimmerStyle : baseStyle);
282
- }
283
- // Suffix: elapsed + tokens
284
- let suffix = '';
285
- if (elapsed > 0)
286
- suffix += ` ${elapsed}s`;
287
- if (state.tokenCount > 0) {
288
- const tokStr = state.tokenCount >= 1000 ? `${(state.tokenCount / 1000).toFixed(1)}K` : `${state.tokenCount}`;
289
- suffix += ` | ${tokStr} tokens`;
290
- }
291
- suffix += '...';
292
- grid.writeText(r, 2 + thinkText.length, suffix, S_DIM);
293
- r++;
294
- }
295
- // ── Tool calls (below messages, above footer) ──
296
- for (const [callId, tc] of state.toolCalls) {
297
- if (r >= msgAreaHeight)
298
- break;
299
- const spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
300
- const isAgent = tc.isAgent || tc.toolName === 'Agent' || tc.toolName === 'ParallelAgents';
301
- // Agent-specific icons and colors
302
- const icon = isAgent
303
- ? (tc.status === 'running' ? '⊕' : tc.status === 'done' ? '◈' : '◇')
304
- : (tc.status === 'running' ? spinnerChars[state.spinnerFrame % spinnerChars.length] : tc.status === 'done' ? '✓' : '✗');
305
- const S_AGENT = { fg: 'cyan', bg: null, bold: true, dim: false, underline: false };
306
- const statusStyle = tc.status === 'error' ? S_ERROR : tc.status === 'done' ? S_GREEN : isAgent ? S_AGENT : S_YELLOW;
307
- const nameStyle = isAgent ? S_AGENT : { ...S_YELLOW, bold: true };
308
- const isExpanded = state.expandedToolCalls.has(callId);
309
- const canExpand = tc.status !== 'running' && tc.output;
310
- // Collapse/expand indicator
311
- if (canExpand) {
312
- grid.writeText(r, 0, isExpanded ? '▼' : '▶', S_DIM);
313
- }
314
- grid.writeText(r, 2, `${icon} `, statusStyle);
315
- grid.writeText(r, 4, tc.toolName, nameStyle);
316
- // Show args + elapsed time on the same line
317
- let afterName = 4 + tc.toolName.length + 1;
318
- if (tc.args) {
319
- const maxArgs = w - afterName - 15; // leave room for elapsed
320
- if (maxArgs > 5) {
321
- const argsText = tc.args.slice(0, maxArgs) + (tc.args.length > maxArgs ? '…' : '');
322
- grid.writeText(r, afterName, argsText, S_DIM);
323
- afterName += argsText.length + 1;
324
- }
325
- }
326
- // Elapsed time for running tools
327
- if (tc.status === 'running' && tc.startedAt) {
328
- const elapsed = Math.floor((Date.now() - tc.startedAt) / 1000);
329
- if (elapsed > 0) {
330
- const lineCount = tc.liveOutput?.length ?? 0;
331
- const elapsedStr = lineCount > 0 ? `${elapsed}s · ${lineCount} lines` : `${elapsed}s`;
332
- grid.writeText(r, Math.min(afterName, w - elapsedStr.length - 2), elapsedStr, S_DIM);
333
- }
334
- }
335
- // Result summary for completed tools (e.g., "42 lines", "exit 0")
336
- if (tc.status !== 'running' && tc.resultSummary) {
337
- const elapsed = tc.startedAt ? Math.floor((Date.now() - tc.startedAt) / 1000) : 0;
338
- const suffix = elapsed > 0 ? `${tc.resultSummary} · ${elapsed}s` : tc.resultSummary;
339
- grid.writeText(r, Math.min(afterName, w - suffix.length - 2), suffix, S_DIM);
340
- }
341
- r++;
342
- // Agent description line
343
- if (isAgent && tc.agentDescription && r < msgAreaHeight) {
344
- grid.writeText(r, 6, tc.agentDescription.slice(0, w - 8), S_DIM);
345
- r++;
346
- }
347
- // Live streaming output while running
348
- if (tc.status === 'running' && tc.liveOutput && tc.liveOutput.length > 0) {
349
- const maxLines = 5;
350
- const overflow = tc.liveOutput.length > maxLines ? tc.liveOutput.length - maxLines : 0;
351
- if (overflow > 0 && r < msgAreaHeight) {
352
- grid.writeText(r, 6, `… (${overflow} earlier lines)`, S_DIM);
353
- r++;
354
- }
355
- const visible = overflow > 0 ? tc.liveOutput.slice(-maxLines) : tc.liveOutput;
356
- for (const line of visible) {
357
- if (r >= msgAreaHeight)
358
- break;
359
- grid.writeText(r, 6, line.slice(0, w - 8), S_DIM);
360
- r++;
361
- }
362
- }
363
- // Final output — collapsed by default (only show when expanded via Tab)
364
- if (tc.output && tc.status !== 'running' && isExpanded && r < msgAreaHeight) {
365
- // Image results: show inline placeholder
366
- if (isImageOutput(tc.output)) {
367
- const label = renderImageInline(tc.output);
368
- grid.writeText(r, 6, label.slice(0, w - 8), S_DIM);
369
- r++;
370
- continue;
371
- }
372
- const outLines = tc.output.split('\n');
373
- const maxOut = 20;
374
- const showLines = outLines.slice(0, maxOut);
375
- for (const line of showLines) {
376
- if (r >= msgAreaHeight)
377
- break;
378
- const lineStyle = tc.status === 'error' ? S_ERROR : S_DIM;
379
- grid.writeText(r, 6, line.slice(0, w - 8), lineStyle);
380
- r++;
381
- }
382
- if (outLines.length > maxOut && r < msgAreaHeight) {
383
- grid.writeText(r, 6, `… (${outLines.length} lines total)`, S_DIM);
384
- r++;
385
- }
386
- }
387
- }
388
- // ── Context warning (above footer) ──
389
- if (state.contextWarning) {
390
- if (r < msgAreaHeight) {
391
- const warnStyle = { fg: 'yellow', bg: null, bold: state.contextWarning.critical, dim: false, underline: false };
392
- grid.writeText(r, 0, state.contextWarning.text, warnStyle);
393
- r++;
394
- }
395
- }
396
- // ── Scrollbar (right edge of message area) ──
642
+ // ── Thinking, spinner, tool calls, context warning (shared helpers) ──
643
+ r = renderThinkingSection(state, grid, r, msgAreaHeight);
644
+ r = renderThinkingSummarySection(state, grid, r, msgAreaHeight);
645
+ r = renderSpinnerSection(state, grid, r, msgAreaHeight);
646
+ r = renderToolCallsSection(state, grid, r, msgAreaHeight, { maxLiveLines: 5, showOverflow: true });
647
+ r = renderContextWarningSection(state, grid, r, msgAreaHeight);
648
+ // ── Scrollbar ──
397
649
  if (hasScrollbar) {
398
650
  const S_TRACK = { fg: null, bg: null, bold: false, dim: true, underline: false };
399
651
  const S_THUMB = { fg: null, bg: null, bold: false, dim: false, underline: false };
@@ -402,204 +654,46 @@ export function rasterize(state, grid) {
402
654
  grid.setCell(sr, w - 1, isThumb ? '█' : '░', isThumb ? S_THUMB : S_TRACK);
403
655
  }
404
656
  }
405
- // ── Footer — place right after content, or at bottom if content fills the screen ──
657
+ // ── Footer ──
406
658
  const footerStart = Math.min(r, msgAreaHeight);
407
- // Border line with scroll indicator
408
659
  for (let c = 0; c < w; c++) {
409
660
  grid.setCell(footerStart, c, '─', S_BORDER);
410
661
  }
411
- // Connect scrollbar to footer border
412
662
  if (hasScrollbar) {
413
663
  grid.setCell(footerStart, w - 1, '┤', S_BORDER);
414
664
  }
415
665
  if (state.manualScroll > 0 && totalRows > msgAreaHeight) {
416
- // User scrolled up — show how many lines are hidden below
417
666
  const hiddenBelow = state.manualScroll;
418
667
  const indicator = ` ↓ ${hiddenBelow} more below `;
419
668
  const startCol = Math.max(0, Math.floor((w - indicator.length) / 2));
420
669
  grid.writeText(footerStart, startCol, indicator, S_DIM);
421
670
  }
422
671
  else if (totalRows > msgAreaHeight && scrollOffset > 0) {
423
- // Content overflows but auto-scrolled to bottom — show lines hidden above
424
672
  const indicator = ` ↑ ${scrollOffset} more above `;
425
673
  const startCol = Math.max(0, Math.floor((w - indicator.length) / 2));
426
674
  grid.writeText(footerStart, startCol, indicator, S_DIM);
427
675
  }
428
676
  let nextRow = footerStart + 1;
429
- // Permission prompt box (if active, skip if terminal too small)
430
- // Ensure at least 6 rows available for the box (tool + desc + keys + borders)
431
- if (state.permissionBox && w >= 20 && (h - nextRow) >= 6) {
432
- const { toolName, description, riskLevel } = state.permissionBox;
433
- const riskColor = riskLevel === 'high' ? 'red' : riskLevel === 'medium' ? 'yellow' : 'green';
434
- const riskStyle = { fg: riskColor, bg: null, bold: true, dim: false, underline: false };
435
- const riskDim = { fg: riskColor, bg: null, bold: false, dim: true, underline: false };
436
- // Top border
437
- const boxWidth = Math.max(15, Math.min(w - 2, 70));
438
- grid.writeText(nextRow, 1, '╭' + '─'.repeat(boxWidth - 2) + '╮', riskDim);
439
- nextRow++;
440
- // Tool name + risk
441
- grid.writeText(nextRow, 1, '│ ', riskDim);
442
- grid.writeText(nextRow, 3, '⚠ ', riskStyle);
443
- grid.writeText(nextRow, 5, toolName, { ...riskStyle });
444
- grid.writeText(nextRow, 5 + toolName.length, ` ${riskLevel} risk`, S_DIM);
445
- grid.writeText(nextRow, boxWidth, '│', riskDim);
446
- nextRow++;
447
- // Description (truncated)
448
- const rawDesc = state.permissionBox.suggestion || description.slice(0, boxWidth - 6);
449
- const descText = rawDesc.replace(/\|/g, ' ').replace(/\\/g, '/'); // sanitize pipe/backslash for display
450
- grid.writeText(nextRow, 1, '│ ', riskDim);
451
- grid.writeText(nextRow, 3, descText.slice(0, boxWidth - 4), S_DIM);
452
- grid.writeText(nextRow, boxWidth, '│', riskDim);
453
- nextRow++;
454
- // Inline diff (when toggled, capped to available space)
455
- if (state.permissionDiffVisible && state.permissionDiffInfo && nextRow + 3 < h) {
456
- grid.writeText(nextRow, 1, '│', riskDim);
457
- nextRow++;
458
- const availDiffRows = Math.min(maxDiffHeight, h - nextRow - 3); // reserve 3 for keys + border + input
459
- const diffRows = renderDiff(grid, nextRow, 3, state.permissionDiffInfo, boxWidth - 2, availDiffRows);
460
- // Draw left border for diff rows
461
- for (let dr = 0; dr < diffRows; dr++) {
462
- if (nextRow + dr < grid.height) {
463
- grid.setCell(nextRow + dr, 1, '│', riskDim);
464
- grid.setCell(nextRow + dr, boxWidth, '│', riskDim);
465
- }
466
- }
467
- nextRow += diffRows;
468
- }
469
- // Action keys — prominent colored letters
470
- const hasDiff = state.permissionDiffInfo !== null;
471
- const S_KEY_GREEN = { fg: 'green', bg: null, bold: true, dim: false, underline: false };
472
- const S_KEY_RED = { fg: 'red', bg: null, bold: true, dim: false, underline: false };
473
- const S_KEY_CYAN = { fg: 'cyan', bg: null, bold: true, dim: false, underline: false };
474
- grid.writeText(nextRow, 1, '│ ', riskDim);
475
- let kc = 3;
476
- grid.writeText(nextRow, kc, 'Y', S_KEY_GREEN);
477
- kc += 1;
478
- grid.writeText(nextRow, kc, 'es', S_DIM);
479
- kc += 2;
480
- grid.writeText(nextRow, kc, ' ', S_DIM);
481
- kc += 2;
482
- grid.writeText(nextRow, kc, 'N', S_KEY_RED);
483
- kc += 1;
484
- grid.writeText(nextRow, kc, 'o', S_DIM);
485
- kc += 1;
486
- if (hasDiff) {
487
- grid.writeText(nextRow, kc, ' ', S_DIM);
488
- kc += 2;
489
- grid.writeText(nextRow, kc, 'D', S_KEY_CYAN);
490
- kc += 1;
491
- grid.writeText(nextRow, kc, 'iff', S_DIM);
492
- kc += 3;
493
- }
494
- grid.writeText(nextRow, boxWidth, '│', riskDim);
495
- nextRow++;
496
- // Bottom border
497
- grid.writeText(nextRow, 1, '╰' + '─'.repeat(boxWidth - 2) + '╯', riskDim);
498
- nextRow++;
499
- }
500
- // Question prompt (if active)
501
- let questionInputRow = -1;
502
- if (state.questionPrompt && w >= 20) {
503
- const { question, options, input, cursor } = state.questionPrompt;
504
- const qStyle = { fg: 'yellow', bg: null, bold: false, dim: false, underline: false };
505
- const qBorder = { fg: 'yellow', bg: null, bold: false, dim: true, underline: false };
506
- const qBoxWidth = Math.max(15, Math.min(w - 2, 70));
507
- grid.writeText(nextRow, 1, '╭' + '─'.repeat(qBoxWidth - 2) + '╮', qBorder);
508
- nextRow++;
509
- grid.writeText(nextRow, 1, '│ ', qBorder);
510
- grid.writeText(nextRow, 3, `❓ ${question}`, qStyle);
511
- grid.writeText(nextRow, qBoxWidth, '│', qBorder);
512
- nextRow++;
513
- if (options && options.length > 0) {
514
- for (let oi = 0; oi < options.length; oi++) {
515
- grid.writeText(nextRow, 1, '│ ', qBorder);
516
- grid.writeText(nextRow, 5, `${oi + 1}. ${options[oi]}`, S_DIM);
517
- grid.writeText(nextRow, qBoxWidth, '│', qBorder);
518
- nextRow++;
519
- }
520
- }
521
- questionInputRow = nextRow;
522
- grid.writeText(nextRow, 1, '│ ', qBorder);
523
- grid.writeText(nextRow, 3, '❯ ', qStyle);
524
- grid.writeText(nextRow, 5, input, S_TEXT);
525
- grid.writeText(nextRow, qBoxWidth, '│', qBorder);
526
- nextRow++;
527
- grid.writeText(nextRow, 1, '╰' + '─'.repeat(qBoxWidth - 2) + '╯', qBorder);
528
- nextRow++;
529
- }
530
- // Status line (model | tokens | cost)
531
- if (state.statusLine) {
532
- grid.writeText(nextRow, 0, state.statusLine, S_DIM);
533
- nextRow++;
534
- }
535
- // Pre-compute prompt width for alignment
536
- const vimIndicator = state.vimMode ? (state.vimMode === 'normal' ? '[N] ' : '[I] ') : '';
537
- const promptText = vimIndicator + '❯ ';
538
- const promptWidth = promptText.length;
539
- // Autocomplete suggestions (above input, aligned to prompt)
540
- if (state.autocomplete.length > 0) {
541
- for (let ai = 0; ai < state.autocomplete.length; ai++) {
542
- const cmd = state.autocomplete[ai];
543
- const desc = state.autocompleteDescriptions[ai] ?? '';
544
- const selected = ai === state.autocompleteIndex;
545
- const acStyle = selected
546
- ? s(getTheme().user, true)
547
- : s(null, false, true);
548
- grid.writeText(nextRow, promptWidth, `/${cmd.padEnd(12)}`, acStyle);
549
- if (desc && w > promptWidth + 15)
550
- grid.writeText(nextRow, promptWidth + 13, desc.slice(0, w - promptWidth - 15), S_DIM);
551
- nextRow++;
552
- }
553
- }
554
- // Input line (or search bar in search mode)
677
+ // ── Permission, question, status, autocomplete, notifications, input, companion (shared helpers) ──
678
+ nextRow = renderPermissionBoxSection(state, grid, nextRow, h, { boxed: true, maxDiffHeight });
679
+ const questionResult = renderQuestionPromptSection(state, grid, nextRow, h, { boxed: true });
680
+ nextRow = questionResult.nextRow;
681
+ const questionInputRow = questionResult.questionInputRow;
682
+ nextRow = renderStatusLineSection(state, grid, nextRow, h);
683
+ const { promptText, promptWidth } = getPromptText(state);
684
+ nextRow = renderAutocompleteSection(state, grid, nextRow, h, promptWidth);
685
+ nextRow = renderNotificationsSection(state, grid, nextRow, h);
555
686
  const inputRow = nextRow;
556
- let inputStart;
557
- if (state.searchMode) {
558
- const searchPrompt = '🔍 ';
559
- grid.writeText(inputRow, 0, searchPrompt, S_USER);
560
- inputStart = 3; // emoji + space
561
- grid.writeText(inputRow, inputStart, state.searchQuery, S_TEXT);
562
- // Match count
563
- const matchInfo = state.searchMatchCount > 0
564
- ? ` ${state.searchCurrentMatch + 1}/${state.searchMatchCount}`
565
- : state.searchQuery ? ' No matches' : '';
566
- grid.writeText(inputRow, inputStart + state.searchQuery.length, matchInfo, S_DIM);
567
- // Hints
568
- grid.writeText(inputRow + 1, 0, 'Enter/↓ next | ↑ prev | Esc close', S_DIM);
569
- }
570
- else {
571
- grid.writeText(inputRow, 0, promptText, S_USER);
572
- inputStart = promptWidth;
573
- // Multi-line input rendering
574
- const inputLines = state.inputText.split('\n');
575
- const maxInputLines = Math.min(inputLines.length, 5);
576
- for (let li = 0; li < maxInputLines; li++) {
577
- if (li === 0) {
578
- grid.writeText(inputRow, inputStart, inputLines[0], S_TEXT);
579
- }
580
- else {
581
- // Align continuation to prompt position
582
- grid.writeText(inputRow + li, inputStart, inputLines[li], S_TEXT);
583
- }
584
- }
585
- // Hints
586
- const hintsRow = inputRow + maxInputLines;
587
- const hintsText = inputLines.length > 1
588
- ? `${state.statusHints} | Alt+Enter newline`
589
- : state.statusHints;
590
- grid.writeText(hintsRow, 0, hintsText, S_DIM);
591
- }
687
+ renderInputSection(state, grid, inputRow, h, promptText, promptWidth);
592
688
  // Companion (right-aligned in footer, skipped if it would overlap input)
593
689
  if (state.companionLines && w >= 50) {
594
690
  const compWidth = Math.max(...state.companionLines.map(l => l.length), 0);
595
691
  const compStartCol = Math.max(0, w - compWidth - 1);
596
692
  const inputEndCol = promptWidth + (state.inputText.split('\n')[0]?.length ?? 0);
597
- // Only render if companion has horizontal clearance from input
598
693
  if (compStartCol > inputEndCol + 3) {
599
694
  const compStyle = { fg: state.companionColor || 'cyan', bg: null, bold: false, dim: false, underline: false };
600
695
  for (let i = 0; i < state.companionLines.length; i++) {
601
696
  const compRow = footerStart + i;
602
- // Skip rows that would overlap with input/status area
603
697
  if (compRow >= inputRow)
604
698
  break;
605
699
  if (compRow >= h)
@@ -608,25 +702,65 @@ export function rasterize(state, grid) {
608
702
  }
609
703
  }
610
704
  }
611
- // Position cursor: in question input if active, otherwise in main input
612
- if (state.questionPrompt && questionInputRow >= 0) {
613
- return {
614
- cursorRow: questionInputRow,
615
- cursorCol: 5 + state.questionPrompt.cursor,
616
- };
705
+ return computeCursorPosition(state, inputRow, promptWidth, questionInputRow);
706
+ }
707
+ // extractSuggestion moved to shared utils/tool-summary.ts as summarizeToolArgs
708
+ /**
709
+ * Rasterize only the "live area" — streaming text, thinking, tool calls, and footer.
710
+ * Used in hybrid mode where completed messages are flushed to terminal scrollback.
711
+ * The grid should be sized to fit just the live content.
712
+ */
713
+ export function rasterizeLive(state, grid) {
714
+ ensureStyles();
715
+ const w = grid.width;
716
+ const h = grid.height;
717
+ let r = 0;
718
+ // ── Banner (shown when no messages have been flushed yet) ──
719
+ if (state.bannerLines && state.messages.length === 0 && !state.loading) {
720
+ r = renderBannerSection(state, grid, r, h - 4, { compact: h < 15 });
721
+ }
722
+ // ── Streaming text ──
723
+ if (state.loading && state.streamingText) {
724
+ grid.writeText(r, 0, '◆ ', S_ASSISTANT);
725
+ const rows = renderMarkdown(grid, r, 2, state.streamingText, w, state.codeBlocksExpanded, h);
726
+ r += rows;
617
727
  }
618
- if (state.searchMode) {
619
- return { cursorRow: inputRow, cursorCol: inputStart + state.searchQuery.length };
728
+ // ── Thinking, spinner, error, tool calls, context warning (shared helpers) ──
729
+ r = renderThinkingSection(state, grid, r, h);
730
+ r = renderThinkingSummarySection(state, grid, r, h);
731
+ r = renderSpinnerSection(state, grid, r, h);
732
+ r = renderErrorSection(state, grid, r, h);
733
+ r = renderToolCallsSection(state, grid, r, h, { maxLiveLines: 3, showOverflow: false });
734
+ r = renderContextWarningSection(state, grid, r, h);
735
+ // ── Footer border ──
736
+ if (r < h) {
737
+ for (let c = 0; c < w; c++)
738
+ grid.setCell(r, c, '─', S_BORDER);
739
+ r++;
740
+ }
741
+ let nextRow = r;
742
+ const borderRow = r - 1; // for companion anchoring
743
+ // ── Permission, question, status, autocomplete, notifications, input (shared helpers) ──
744
+ nextRow = renderPermissionBoxSection(state, grid, nextRow, h, { boxed: false, maxDiffHeight: 15 });
745
+ const questionResult = renderQuestionPromptSection(state, grid, nextRow, h, { boxed: false });
746
+ nextRow = questionResult.nextRow;
747
+ const questionInputRow = questionResult.questionInputRow;
748
+ nextRow = renderStatusLineSection(state, grid, nextRow, h);
749
+ const { promptText, promptWidth } = getPromptText(state);
750
+ nextRow = renderAutocompleteSection(state, grid, nextRow, h, promptWidth);
751
+ nextRow = renderNotificationsSection(state, grid, nextRow, h);
752
+ const inputRow = nextRow;
753
+ renderInputSection(state, grid, inputRow, h, promptText, promptWidth);
754
+ // ── Companion (right-aligned, anchored at footer border) ──
755
+ renderCompanionSection(state, grid, borderRow, h, promptWidth);
756
+ // ── Cursor position ──
757
+ if (state.questionPrompt && questionInputRow >= 0) {
758
+ return { cursorRow: questionInputRow, cursorCol: 3 + state.questionPrompt.cursor };
620
759
  }
621
- // 2D cursor positioning for multi-line input (all lines aligned to inputStart)
622
760
  const textBeforeCursor = state.inputText.slice(0, state.inputCursor);
623
761
  const cursorLines = textBeforeCursor.split('\n');
624
- const cursorLineIdx = Math.min(cursorLines.length - 1, 4); // capped at 5 visible lines
762
+ const cursorLineIdx = Math.min(cursorLines.length - 1, 4);
625
763
  const cursorColInLine = cursorLines[cursorLines.length - 1].length;
626
- return {
627
- cursorRow: inputRow + cursorLineIdx,
628
- cursorCol: inputStart + cursorColInLine,
629
- };
764
+ return { cursorRow: inputRow + cursorLineIdx, cursorCol: promptWidth + cursorColInLine };
630
765
  }
631
- // extractSuggestion moved to shared utils/tool-summary.ts as summarizeToolArgs
632
766
  //# sourceMappingURL=layout.js.map