@zhijiewang/openharness 0.10.1 → 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 (281) hide show
  1. package/README.md +79 -13
  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.map +1 -1
  151. package/dist/renderer/differ.js +1 -10
  152. package/dist/renderer/differ.js.map +1 -1
  153. package/dist/renderer/e2e.test.js +1 -0
  154. package/dist/renderer/e2e.test.js.map +1 -1
  155. package/dist/renderer/image.test.d.ts +5 -0
  156. package/dist/renderer/image.test.d.ts.map +1 -0
  157. package/dist/renderer/image.test.js +66 -0
  158. package/dist/renderer/image.test.js.map +1 -0
  159. package/dist/renderer/index.d.ts +21 -4
  160. package/dist/renderer/index.d.ts.map +1 -1
  161. package/dist/renderer/index.js +191 -116
  162. package/dist/renderer/index.js.map +1 -1
  163. package/dist/renderer/layout.d.ts +5 -1
  164. package/dist/renderer/layout.d.ts.map +1 -1
  165. package/dist/renderer/layout.js +504 -614
  166. package/dist/renderer/layout.js.map +1 -1
  167. package/dist/renderer/markdown.d.ts.map +1 -1
  168. package/dist/renderer/markdown.js +42 -36
  169. package/dist/renderer/markdown.js.map +1 -1
  170. package/dist/renderer/perf.test.js +1 -0
  171. package/dist/renderer/perf.test.js.map +1 -1
  172. package/dist/renderer/session-browser.test.d.ts +6 -0
  173. package/dist/renderer/session-browser.test.d.ts.map +1 -0
  174. package/dist/renderer/session-browser.test.js +95 -0
  175. package/dist/renderer/session-browser.test.js.map +1 -0
  176. package/dist/renderer/ui-ux.test.d.ts +15 -0
  177. package/dist/renderer/ui-ux.test.d.ts.map +1 -0
  178. package/dist/renderer/ui-ux.test.js +470 -0
  179. package/dist/renderer/ui-ux.test.js.map +1 -0
  180. package/dist/repl.d.ts.map +1 -1
  181. package/dist/repl.js +178 -14
  182. package/dist/repl.js.map +1 -1
  183. package/dist/services/StreamingToolExecutor.d.ts.map +1 -1
  184. package/dist/services/StreamingToolExecutor.js +4 -2
  185. package/dist/services/StreamingToolExecutor.js.map +1 -1
  186. package/dist/services/agent-messaging.d.ts +68 -0
  187. package/dist/services/agent-messaging.d.ts.map +1 -0
  188. package/dist/services/agent-messaging.js +121 -0
  189. package/dist/services/agent-messaging.js.map +1 -0
  190. package/dist/services/agent-messaging.test.d.ts +2 -0
  191. package/dist/services/agent-messaging.test.d.ts.map +1 -0
  192. package/dist/services/agent-messaging.test.js +88 -0
  193. package/dist/services/agent-messaging.test.js.map +1 -0
  194. package/dist/services/cron.d.ts +40 -0
  195. package/dist/services/cron.d.ts.map +1 -0
  196. package/dist/services/cron.js +90 -0
  197. package/dist/services/cron.js.map +1 -0
  198. package/dist/services/cron.test.d.ts +2 -0
  199. package/dist/services/cron.test.d.ts.map +1 -0
  200. package/dist/services/cron.test.js +49 -0
  201. package/dist/services/cron.test.js.map +1 -0
  202. package/dist/tools/AgentTool/index.d.ts +9 -0
  203. package/dist/tools/AgentTool/index.d.ts.map +1 -1
  204. package/dist/tools/AgentTool/index.js +89 -6
  205. package/dist/tools/AgentTool/index.js.map +1 -1
  206. package/dist/tools/BashTool/index.d.ts +6 -0
  207. package/dist/tools/BashTool/index.d.ts.map +1 -1
  208. package/dist/tools/BashTool/index.js +39 -1
  209. package/dist/tools/BashTool/index.js.map +1 -1
  210. package/dist/tools/FileEditTool/index.js +4 -4
  211. package/dist/tools/FileEditTool/index.js.map +1 -1
  212. package/dist/tools/FileReadTool/index.d.ts +3 -0
  213. package/dist/tools/FileReadTool/index.d.ts.map +1 -1
  214. package/dist/tools/FileReadTool/index.js +102 -4
  215. package/dist/tools/FileReadTool/index.js.map +1 -1
  216. package/dist/tools/FileWriteTool/index.d.ts.map +1 -1
  217. package/dist/tools/FileWriteTool/index.js +20 -5
  218. package/dist/tools/FileWriteTool/index.js.map +1 -1
  219. package/dist/tools/GlobTool/index.d.ts.map +1 -1
  220. package/dist/tools/GlobTool/index.js +4 -61
  221. package/dist/tools/GlobTool/index.js.map +1 -1
  222. package/dist/tools/GrepTool/index.d.ts +30 -0
  223. package/dist/tools/GrepTool/index.d.ts.map +1 -1
  224. package/dist/tools/GrepTool/index.js +153 -72
  225. package/dist/tools/GrepTool/index.js.map +1 -1
  226. package/dist/tools/LSTool/index.d.ts +3 -0
  227. package/dist/tools/LSTool/index.d.ts.map +1 -1
  228. package/dist/tools/LSTool/index.js +44 -29
  229. package/dist/tools/LSTool/index.js.map +1 -1
  230. package/dist/tools/TaskCreateTool/index.d.ts +6 -0
  231. package/dist/tools/TaskCreateTool/index.d.ts.map +1 -1
  232. package/dist/tools/TaskCreateTool/index.js +8 -2
  233. package/dist/tools/TaskCreateTool/index.js.map +1 -1
  234. package/dist/tools/TaskGetTool/index.d.ts +12 -0
  235. package/dist/tools/TaskGetTool/index.d.ts.map +1 -0
  236. package/dist/tools/TaskGetTool/index.js +50 -0
  237. package/dist/tools/TaskGetTool/index.js.map +1 -0
  238. package/dist/tools/TaskOutputTool/index.d.ts +15 -0
  239. package/dist/tools/TaskOutputTool/index.d.ts.map +1 -0
  240. package/dist/tools/TaskOutputTool/index.js +45 -0
  241. package/dist/tools/TaskOutputTool/index.js.map +1 -0
  242. package/dist/tools/TaskStopTool/index.d.ts +15 -0
  243. package/dist/tools/TaskStopTool/index.d.ts.map +1 -0
  244. package/dist/tools/TaskStopTool/index.js +51 -0
  245. package/dist/tools/TaskStopTool/index.js.map +1 -0
  246. package/dist/tools/TaskUpdateTool/index.d.ts +21 -3
  247. package/dist/tools/TaskUpdateTool/index.d.ts.map +1 -1
  248. package/dist/tools/TaskUpdateTool/index.js +48 -3
  249. package/dist/tools/TaskUpdateTool/index.js.map +1 -1
  250. package/dist/tools/tools-basic.test.js +191 -2
  251. package/dist/tools/tools-basic.test.js.map +1 -1
  252. package/dist/tools.d.ts.map +1 -1
  253. package/dist/tools.js +6 -0
  254. package/dist/tools.js.map +1 -1
  255. package/dist/types/permissions.d.ts +2 -2
  256. package/dist/types/permissions.d.ts.map +1 -1
  257. package/dist/types/permissions.js +59 -13
  258. package/dist/types/permissions.js.map +1 -1
  259. package/dist/types/permissions.test.js +57 -0
  260. package/dist/types/permissions.test.js.map +1 -1
  261. package/dist/utils/bash-safety.d.ts +18 -0
  262. package/dist/utils/bash-safety.d.ts.map +1 -0
  263. package/dist/utils/bash-safety.js +227 -0
  264. package/dist/utils/bash-safety.js.map +1 -0
  265. package/dist/utils/bash-safety.test.d.ts +2 -0
  266. package/dist/utils/bash-safety.test.d.ts.map +1 -0
  267. package/dist/utils/bash-safety.test.js +112 -0
  268. package/dist/utils/bash-safety.test.js.map +1 -0
  269. package/dist/utils/fs.d.ts +15 -0
  270. package/dist/utils/fs.d.ts.map +1 -0
  271. package/dist/utils/fs.js +64 -0
  272. package/dist/utils/fs.js.map +1 -0
  273. package/dist/utils/fs.test.d.ts +5 -0
  274. package/dist/utils/fs.test.d.ts.map +1 -0
  275. package/dist/utils/fs.test.js +82 -0
  276. package/dist/utils/fs.test.js.map +1 -0
  277. package/dist/utils/safe-env.d.ts +10 -0
  278. package/dist/utils/safe-env.d.ts.map +1 -0
  279. package/dist/utils/safe-env.js +40 -0
  280. package/dist/utils/safe-env.js.map +1 -0
  281. 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,272 +42,101 @@ function ensureStyles() {
37
42
  S_GREEN = s(t.success);
38
43
  }
39
44
  const SPINNER_CHARS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
40
- /**
41
- * Rasterize application state into the cell grid.
42
- * Returns cursor position for the input line.
43
- */
44
- export function rasterize(state, grid) {
45
- ensureStyles();
46
- const w = grid.width;
47
- const h = grid.height;
48
- // Footer height — capped at 50% of terminal to preserve message area
49
- const companionHeight = state.companionLines ? Math.min(state.companionLines.length + 1, 8) : 0;
50
- const maxDiffHeight = Math.min(15, Math.floor(h / 3));
51
- const diffHeight = (state.permissionDiffVisible && state.permissionDiffInfo) ? maxDiffHeight : 0;
52
- const permissionHeight = state.permissionBox ? 6 + diffHeight : 0;
53
- const questionHeight = state.questionPrompt ? 4 + (state.questionPrompt.options?.length ?? 0) : 0;
54
- const statusLineHeight = state.statusLine ? 1 : 0;
55
- const contextWarningHeight = state.contextWarning ? 1 : 0;
56
- const autocompleteHeight = state.autocomplete.length;
57
- const inputLineCount = Math.min(5, (state.inputText.match(/\n/g)?.length ?? 0) + 1);
58
- const rawFooterHeight = Math.max(2 + inputLineCount + statusLineHeight + autocompleteHeight, companionHeight + 1) + permissionHeight + questionHeight + contextWarningHeight;
59
- const footerHeight = Math.min(rawFooterHeight, Math.floor(h / 2));
60
- const msgAreaHeight = Math.max(1, h - footerHeight);
61
- // ── Session browser overlay ──
62
- if (state.sessionBrowser) {
63
- const browserRows = renderSessionBrowser(grid, 0, 0, state.sessionBrowser, w, msgAreaHeight);
64
- // Skip normal message rendering — show browser instead
65
- const footerStart = Math.min(browserRows, msgAreaHeight);
66
- // Render minimal footer (just input)
67
- for (let c = 0; c < w; c++)
68
- grid.setCell(footerStart, c, '─', S_BORDER);
69
- const inputRow = footerStart + 1;
70
- grid.writeText(inputRow, 0, '❯ ', S_USER);
71
- grid.writeText(inputRow + 1, 0, '↑/↓ navigate | Enter resume | Esc cancel', S_DIM);
72
- return { cursorRow: inputRow, cursorCol: 2 };
73
- }
74
- // ── Messages area (top) ──
75
- // Compute total height of all messages + streaming
76
- const allContent = [];
77
- for (const msg of state.messages) {
78
- if (msg.role === 'user') {
79
- allContent.push({ role: 'user', content: msg.content, style: { ...S_TEXT, bold: true }, prefixStyle: S_USER, prefix: '❯ ' });
80
- }
81
- else if (msg.role === 'assistant') {
82
- allContent.push({ role: 'assistant', content: msg.content, style: S_TEXT, prefixStyle: S_ASSISTANT, prefix: '◆ ' });
83
- }
84
- else if (msg.role === 'system') {
85
- allContent.push({ role: 'system', content: msg.content, style: S_DIM, prefixStyle: S_DIM, prefix: ' ' });
86
- }
87
- }
88
- // Add streaming text
89
- if (state.loading && state.streamingText) {
90
- allContent.push({ role: 'streaming', content: state.streamingText, style: S_TEXT, prefixStyle: S_ASSISTANT, prefix: '◆ ' });
91
- }
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
- if (state.errorText) {
96
- allContent.push({ role: 'error', content: state.errorText, style: S_ERROR, prefixStyle: S_ERROR, prefix: '✗ ' });
97
- }
98
- // Render messages top-down (scroll up when content exceeds area)
99
- const prefixLen = 2;
100
- const contentWidth = w - 1; // reserve rightmost column for scrollbar
101
- const textWidth = contentWidth - prefixLen;
102
- // Pre-compute total height to handle scrolling
103
- let totalRows = 0;
104
- // Banner height (compact on small terminals, hidden if very small)
105
- if (state.bannerLines && h >= 30) {
106
- const compact = h < 40;
107
- const visibleLines = compact ? Math.min(2, state.bannerLines.length) : state.bannerLines.length;
108
- totalRows += visibleLines + 1; // +1 blank line after
109
- }
110
- for (const item of allContent) {
111
- if (item.role === 'user' && totalRows > 0)
112
- totalRows++;
113
- if (item.role === 'assistant' || item.role === 'streaming') {
114
- totalRows += measureMarkdown(item.content, contentWidth);
115
- }
116
- else {
117
- const lines = item.content.split('\n');
118
- for (const line of lines) {
119
- totalRows += Math.max(1, Math.ceil((line.length || 1) / textWidth));
120
- }
121
- }
122
- }
123
- // Include non-message content in totalRows for accurate scroll indicator
124
- if (state.thinkingText) {
125
- totalRows += state.thinkingExpanded ? Math.min(state.thinkingText.split('\n').length, 10) : 1;
126
- }
127
- if (!state.loading && state.lastThinkingSummary)
128
- totalRows += 1;
129
- if (state.loading && !state.streamingText && !state.thinkingText)
130
- totalRows += 1; // spinner
131
- for (const [callId, tc] of state.toolCalls) {
132
- totalRows += 1; // tool header line
133
- if (tc.isAgent && tc.agentDescription)
134
- totalRows += 1; // agent description line
135
- if (tc.status === 'running' && tc.liveOutput)
136
- totalRows += Math.min(tc.liveOutput.length, 5);
137
- // Collapsed tools show 0 output lines; expanded show up to 20
138
- if (tc.output && tc.status !== 'running' && state.expandedToolCalls.has(callId)) {
139
- totalRows += Math.min(tc.output.split('\n').length, 20);
140
- }
141
- }
142
- if (state.contextWarning)
143
- 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
- const autoOffset = totalRows > msgAreaHeight ? totalRows - msgAreaHeight : 0;
147
- const scrollOffset = Math.max(0, autoOffset - state.manualScroll);
148
- // Scrollbar geometry (rightmost column of message area)
149
- const hasScrollbar = totalRows > msgAreaHeight;
150
- let thumbStart = 0;
151
- let thumbSize = msgAreaHeight;
152
- if (hasScrollbar) {
153
- thumbSize = Math.max(1, Math.round((msgAreaHeight / totalRows) * msgAreaHeight));
154
- thumbStart = Math.round((scrollOffset / Math.max(1, totalRows)) * (msgAreaHeight - thumbSize));
155
- }
156
- let r = 0;
157
- let virtualR = 0; // tracks position before scroll clipping
158
- let contentIdx = 0;
159
- // ── Banner (ASCII art at top) ──
160
- 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
- const compact = h < 40;
165
- const startLine = compact ? Math.max(0, state.bannerLines.length - 2) : 0;
166
- for (let i = startLine; i < state.bannerLines.length; i++) {
167
- if (virtualR >= scrollOffset && r < msgAreaHeight) {
168
- const line = state.bannerLines[i];
169
- const isBannerArt = i < state.bannerLines.length - 2;
170
- grid.writeText(r, 0, line, isBannerArt ? S_BANNER : S_BANNER_DIM);
171
- r++;
172
- }
173
- virtualR++;
174
- }
175
- // Blank line after banner
176
- if (virtualR >= scrollOffset && r < msgAreaHeight) {
177
- r++;
178
- }
179
- virtualR++;
180
- }
181
- for (const item of allContent) {
182
- if (r >= msgAreaHeight)
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)
183
53
  break;
184
- // Divider before user messages (except first)
185
- if (item.role === 'user' && contentIdx > 0) {
186
- if (virtualR >= scrollOffset) {
187
- for (let c = 0; c < w; c++) {
188
- grid.setCell(r, c, '─', S_BORDER);
189
- }
190
- r++;
191
- }
192
- virtualR++;
193
- }
194
- // Compute how many rows this content will take
195
- let itemRows;
196
- if (item.role === 'assistant' || item.role === 'streaming') {
197
- itemRows = measureMarkdown(item.content, contentWidth);
198
- }
199
- else {
200
- const lines = item.content.split('\n');
201
- itemRows = 0;
202
- for (const line of lines) {
203
- itemRows += Math.max(1, Math.ceil((line.length || 1) / textWidth));
204
- }
205
- }
206
- if (virtualR + itemRows <= scrollOffset) {
207
- // Entirely above viewport — skip
208
- virtualR += itemRows;
209
- contentIdx++;
210
- continue;
211
- }
212
- // Write prefix
213
- grid.writeText(r, 0, item.prefix, item.prefixStyle);
214
- // Write content — use markdown renderer for assistant messages
215
- let rows;
216
- if (item.role === 'assistant' || item.role === 'streaming') {
217
- rows = renderMarkdown(grid, r, prefixLen, item.content, contentWidth, state.codeBlocksExpanded, msgAreaHeight);
218
- }
219
- else {
220
- rows = grid.writeWrapped(r, prefixLen, item.content, item.style, contentWidth, msgAreaHeight);
221
- }
222
- r += rows;
223
- virtualR += itemRows;
224
- contentIdx++;
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++;
225
58
  }
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++;
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);
242
77
  }
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
78
  r++;
263
79
  }
264
80
  }
265
- // ── Shimmer spinner ──
266
- if (state.loading && !state.streamingText && !state.thinkingText && r < msgAreaHeight) {
267
- const thinkText = 'Thinking';
81
+ else {
82
+ const lineCount = state.thinkingText.split('\n').length;
268
83
  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);
84
+ const summary = `∴ Thinking${elapsed > 0 ? ` (${elapsed}s)` : ''} — ${lineCount} lines [Ctrl+O expand]`;
85
+ grid.writeText(r, 0, summary, S_DIM);
293
86
  r++;
294
87
  }
295
- // ── Tool calls (below messages, above footer) ──
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;
296
133
  for (const [callId, tc] of state.toolCalls) {
297
- if (r >= msgAreaHeight)
134
+ if (r >= limit)
298
135
  break;
299
- const spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
300
136
  const isAgent = tc.isAgent || tc.toolName === 'Agent' || tc.toolName === 'ParallelAgents';
301
- // Agent-specific icons and colors
302
137
  const icon = isAgent
303
138
  ? (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 };
139
+ : (tc.status === 'running' ? SPINNER_CHARS[state.spinnerFrame % SPINNER_CHARS.length] : tc.status === 'done' ? '✓' : '✗');
306
140
  const statusStyle = tc.status === 'error' ? S_ERROR : tc.status === 'done' ? S_GREEN : isAgent ? S_AGENT : S_YELLOW;
307
141
  const nameStyle = isAgent ? S_AGENT : { ...S_YELLOW, bold: true };
308
142
  const isExpanded = state.expandedToolCalls.has(callId);
@@ -313,10 +147,9 @@ export function rasterize(state, grid) {
313
147
  }
314
148
  grid.writeText(r, 2, `${icon} `, statusStyle);
315
149
  grid.writeText(r, 4, tc.toolName, nameStyle);
316
- // Show args + elapsed time on the same line
317
150
  let afterName = 4 + tc.toolName.length + 1;
318
151
  if (tc.args) {
319
- const maxArgs = w - afterName - 15; // leave room for elapsed
152
+ const maxArgs = w - afterName - 15;
320
153
  if (maxArgs > 5) {
321
154
  const argsText = tc.args.slice(0, maxArgs) + (tc.args.length > maxArgs ? '…' : '');
322
155
  grid.writeText(r, afterName, argsText, S_DIM);
@@ -332,7 +165,7 @@ export function rasterize(state, grid) {
332
165
  grid.writeText(r, Math.min(afterName, w - elapsedStr.length - 2), elapsedStr, S_DIM);
333
166
  }
334
167
  }
335
- // Result summary for completed tools (e.g., "42 lines", "exit 0")
168
+ // Result summary for completed tools
336
169
  if (tc.status !== 'running' && tc.resultSummary) {
337
170
  const elapsed = tc.startedAt ? Math.floor((Date.now() - tc.startedAt) / 1000) : 0;
338
171
  const suffix = elapsed > 0 ? `${tc.resultSummary} · ${elapsed}s` : tc.resultSummary;
@@ -340,28 +173,27 @@ export function rasterize(state, grid) {
340
173
  }
341
174
  r++;
342
175
  // Agent description line
343
- if (isAgent && tc.agentDescription && r < msgAreaHeight) {
176
+ if (isAgent && tc.agentDescription && r < limit) {
344
177
  grid.writeText(r, 6, tc.agentDescription.slice(0, w - 8), S_DIM);
345
178
  r++;
346
179
  }
347
180
  // Live streaming output while running
348
181
  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) {
182
+ const overflow = tc.liveOutput.length > opts.maxLiveLines ? tc.liveOutput.length - opts.maxLiveLines : 0;
183
+ if (opts.showOverflow && overflow > 0 && r < limit) {
352
184
  grid.writeText(r, 6, `… (${overflow} earlier lines)`, S_DIM);
353
185
  r++;
354
186
  }
355
- const visible = overflow > 0 ? tc.liveOutput.slice(-maxLines) : tc.liveOutput;
187
+ const visible = overflow > 0 ? tc.liveOutput.slice(-opts.maxLiveLines) : tc.liveOutput;
356
188
  for (const line of visible) {
357
- if (r >= msgAreaHeight)
189
+ if (r >= limit)
358
190
  break;
359
191
  grid.writeText(r, 6, line.slice(0, w - 8), S_DIM);
360
192
  r++;
361
193
  }
362
194
  }
363
195
  // Final output — collapsed by default (only show when expanded via Tab)
364
- if (tc.output && tc.status !== 'running' && isExpanded && r < msgAreaHeight) {
196
+ if (tc.output && tc.status !== 'running' && isExpanded && r < limit) {
365
197
  // Image results: show inline placeholder
366
198
  if (isImageOutput(tc.output)) {
367
199
  const label = renderImageInline(tc.output);
@@ -373,68 +205,40 @@ export function rasterize(state, grid) {
373
205
  const maxOut = 20;
374
206
  const showLines = outLines.slice(0, maxOut);
375
207
  for (const line of showLines) {
376
- if (r >= msgAreaHeight)
208
+ if (r >= limit)
377
209
  break;
378
210
  const lineStyle = tc.status === 'error' ? S_ERROR : S_DIM;
379
211
  grid.writeText(r, 6, line.slice(0, w - 8), lineStyle);
380
212
  r++;
381
213
  }
382
- if (outLines.length > maxOut && r < msgAreaHeight) {
214
+ if (outLines.length > maxOut && r < limit) {
383
215
  grid.writeText(r, 6, `… (${outLines.length} lines total)`, S_DIM);
384
216
  r++;
385
217
  }
386
218
  }
387
219
  }
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) ──
397
- if (hasScrollbar) {
398
- const S_TRACK = { fg: null, bg: null, bold: false, dim: true, underline: false };
399
- const S_THUMB = { fg: null, bg: null, bold: false, dim: false, underline: false };
400
- for (let sr = 0; sr < msgAreaHeight; sr++) {
401
- const isThumb = sr >= thumbStart && sr < thumbStart + thumbSize;
402
- grid.setCell(sr, w - 1, isThumb ? '' : '', isThumb ? S_THUMB : S_TRACK);
403
- }
404
- }
405
- // ── Footer — place right after content, or at bottom if content fills the screen ──
406
- const footerStart = Math.min(r, msgAreaHeight);
407
- // Border line with scroll indicator
408
- for (let c = 0; c < w; c++) {
409
- grid.setCell(footerStart, c, '─', S_BORDER);
410
- }
411
- // Connect scrollbar to footer border
412
- if (hasScrollbar) {
413
- grid.setCell(footerStart, w - 1, '┤', S_BORDER);
414
- }
415
- if (state.manualScroll > 0 && totalRows > msgAreaHeight) {
416
- // User scrolled up — show how many lines are hidden below
417
- const hiddenBelow = state.manualScroll;
418
- const indicator = ` ↓ ${hiddenBelow} more below `;
419
- const startCol = Math.max(0, Math.floor((w - indicator.length) / 2));
420
- grid.writeText(footerStart, startCol, indicator, S_DIM);
421
- }
422
- else if (totalRows > msgAreaHeight && scrollOffset > 0) {
423
- // Content overflows but auto-scrolled to bottom — show lines hidden above
424
- const indicator = ` ↑ ${scrollOffset} more above `;
425
- const startCol = Math.max(0, Math.floor((w - indicator.length) / 2));
426
- grid.writeText(footerStart, startCol, indicator, S_DIM);
427
- }
428
- 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 };
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;
435
239
  const riskDim = { fg: riskColor, bg: null, bold: false, dim: true, underline: false };
436
- // Top border
437
240
  const boxWidth = Math.max(15, Math.min(w - 2, 70));
241
+ // Top border
438
242
  grid.writeText(nextRow, 1, '╭' + '─'.repeat(boxWidth - 2) + '╮', riskDim);
439
243
  nextRow++;
440
244
  // Tool name + risk
@@ -446,18 +250,17 @@ export function rasterize(state, grid) {
446
250
  nextRow++;
447
251
  // Description (truncated)
448
252
  const rawDesc = state.permissionBox.suggestion || description.slice(0, boxWidth - 6);
449
- const descText = rawDesc.replace(/\|/g, ' ').replace(/\\/g, '/'); // sanitize pipe/backslash for display
253
+ const descText = rawDesc.replace(/\|/g, ' ').replace(/\\/g, '/');
450
254
  grid.writeText(nextRow, 1, '│ ', riskDim);
451
255
  grid.writeText(nextRow, 3, descText.slice(0, boxWidth - 4), S_DIM);
452
256
  grid.writeText(nextRow, boxWidth, '│', riskDim);
453
257
  nextRow++;
454
- // Inline diff (when toggled, capped to available space)
258
+ // Inline diff
455
259
  if (state.permissionDiffVisible && state.permissionDiffInfo && nextRow + 3 < h) {
456
260
  grid.writeText(nextRow, 1, '│', riskDim);
457
261
  nextRow++;
458
- const availDiffRows = Math.min(maxDiffHeight, h - nextRow - 3); // reserve 3 for keys + border + input
262
+ const availDiffRows = Math.min(opts.maxDiffHeight, h - nextRow - 3);
459
263
  const diffRows = renderDiff(grid, nextRow, 3, state.permissionDiffInfo, boxWidth - 2, availDiffRows);
460
- // Draw left border for diff rows
461
264
  for (let dr = 0; dr < diffRows; dr++) {
462
265
  if (nextRow + dr < grid.height) {
463
266
  grid.setCell(nextRow + dr, 1, '│', riskDim);
@@ -466,11 +269,7 @@ export function rasterize(state, grid) {
466
269
  }
467
270
  nextRow += diffRows;
468
271
  }
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 };
272
+ // Action keys
474
273
  grid.writeText(nextRow, 1, '│ ', riskDim);
475
274
  let kc = 3;
476
275
  grid.writeText(nextRow, kc, 'Y', S_KEY_GREEN);
@@ -483,7 +282,7 @@ export function rasterize(state, grid) {
483
282
  kc += 1;
484
283
  grid.writeText(nextRow, kc, 'o', S_DIM);
485
284
  kc += 1;
486
- if (hasDiff) {
285
+ if (state.permissionDiffInfo) {
487
286
  grid.writeText(nextRow, kc, ' ', S_DIM);
488
287
  kc += 2;
489
288
  grid.writeText(nextRow, kc, 'D', S_KEY_CYAN);
@@ -497,11 +296,37 @@ export function rasterize(state, grid) {
497
296
  grid.writeText(nextRow, 1, '╰' + '─'.repeat(boxWidth - 2) + '╯', riskDim);
498
297
  nextRow++;
499
298
  }
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 };
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) {
505
330
  const qBorder = { fg: 'yellow', bg: null, bold: false, dim: true, underline: false };
506
331
  const qBoxWidth = Math.max(15, Math.min(w - 2, 70));
507
332
  grid.writeText(nextRow, 1, '╭' + '─'.repeat(qBoxWidth - 2) + '╮', qBorder);
@@ -518,7 +343,7 @@ export function rasterize(state, grid) {
518
343
  nextRow++;
519
344
  }
520
345
  }
521
- questionInputRow = nextRow;
346
+ const questionInputRow = nextRow;
522
347
  grid.writeText(nextRow, 1, '│ ', qBorder);
523
348
  grid.writeText(nextRow, 3, '❯ ', qStyle);
524
349
  grid.writeText(nextRow, 5, input, S_TEXT);
@@ -526,343 +351,408 @@ export function rasterize(state, grid) {
526
351
  nextRow++;
527
352
  grid.writeText(nextRow, 1, '╰' + '─'.repeat(qBoxWidth - 2) + '╯', qBorder);
528
353
  nextRow++;
354
+ return { nextRow, questionInputRow };
529
355
  }
530
- // Status line (model | tokens | cost)
531
- if (state.statusLine) {
532
- grid.writeText(nextRow, 0, state.statusLine, S_DIM);
356
+ else {
357
+ // Compact mode (rasterizeLive)
358
+ if ((h - nextRow) < 3)
359
+ return { nextRow, questionInputRow: -1 };
360
+ grid.writeText(nextRow, 1, `❓ ${question}`, S_TEXT);
533
361
  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
555
- const inputRow = nextRow;
556
- let inputStart;
557
- {
558
- grid.writeText(inputRow, 0, promptText, S_USER);
559
- inputStart = promptWidth;
560
- // Multi-line input rendering
561
- const inputLines = state.inputText.split('\n');
562
- const maxInputLines = Math.min(inputLines.length, 5);
563
- for (let li = 0; li < maxInputLines; li++) {
564
- if (li === 0) {
565
- grid.writeText(inputRow, inputStart, inputLines[0], S_TEXT);
566
- }
567
- else {
568
- // Align continuation to prompt position
569
- grid.writeText(inputRow + li, inputStart, inputLines[li], S_TEXT);
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++;
570
368
  }
571
369
  }
572
- // Hints
573
- const hintsRow = inputRow + maxInputLines;
574
- const hintsText = inputLines.length > 1
575
- ? `${state.statusHints} | Alt+Enter newline`
576
- : state.statusHints;
577
- grid.writeText(hintsRow, 0, hintsText, S_DIM);
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 };
578
375
  }
579
- // Companion (right-aligned in footer, skipped if it would overlap input)
580
- if (state.companionLines && w >= 50) {
581
- const compWidth = Math.max(...state.companionLines.map(l => l.length), 0);
582
- const compStartCol = Math.max(0, w - compWidth - 1);
583
- const inputEndCol = promptWidth + (state.inputText.split('\n')[0]?.length ?? 0);
584
- // Only render if companion has horizontal clearance from input
585
- if (compStartCol > inputEndCol + 3) {
586
- const compStyle = { fg: state.companionColor || 'cyan', bg: null, bold: false, dim: false, underline: false };
587
- for (let i = 0; i < state.companionLines.length; i++) {
588
- const compRow = footerStart + i;
589
- // Skip rows that would overlap with input/status area
590
- if (compRow >= inputRow)
591
- break;
592
- if (compRow >= h)
593
- break;
594
- grid.writeText(compRow, compStartCol, state.companionLines[i], compStyle);
595
- }
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);
596
425
  }
597
426
  }
598
- // Position cursor: in question input if active, otherwise in main input
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) {
599
460
  if (state.questionPrompt && questionInputRow >= 0) {
600
- return {
601
- cursorRow: questionInputRow,
602
- cursorCol: 5 + state.questionPrompt.cursor,
603
- };
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 };
604
463
  }
605
- // 2D cursor positioning for multi-line input (all lines aligned to inputStart)
606
464
  const textBeforeCursor = state.inputText.slice(0, state.inputCursor);
607
465
  const cursorLines = textBeforeCursor.split('\n');
608
- const cursorLineIdx = Math.min(cursorLines.length - 1, 4); // capped at 5 visible lines
466
+ const cursorLineIdx = Math.min(cursorLines.length - 1, 4);
609
467
  const cursorColInLine = cursorLines[cursorLines.length - 1].length;
610
- return {
611
- cursorRow: inputRow + cursorLineIdx,
612
- cursorCol: inputStart + cursorColInLine,
613
- };
468
+ return { cursorRow: inputRow + cursorLineIdx, cursorCol: inputStart + cursorColInLine };
614
469
  }
615
- // extractSuggestion moved to shared utils/tool-summary.ts as summarizeToolArgs
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 ──
616
476
  /**
617
- * Rasterize only the "live area" streaming text, thinking, tool calls, and footer.
618
- * Used in hybrid mode where completed messages are flushed to terminal scrollback.
619
- * The grid should be sized to fit just the live content.
477
+ * Rasterize application state into the cell grid.
478
+ * Full-screen mode with message area + scrollbar + footer.
479
+ * Used by tests; production uses rasterizeLive().
620
480
  */
621
- export function rasterizeLive(state, grid) {
481
+ export function rasterize(state, grid) {
622
482
  ensureStyles();
623
483
  const w = grid.width;
624
484
  const h = grid.height;
625
- let r = 0;
626
- // ── Streaming text ──
485
+ // Footer height — capped at 50% of terminal to preserve message area
486
+ const companionHeight = state.companionLines ? Math.min(state.companionLines.length + 1, 8) : 0;
487
+ const maxDiffHeight = Math.min(15, Math.floor(h / 3));
488
+ const diffHeight = (state.permissionDiffVisible && state.permissionDiffInfo) ? maxDiffHeight : 0;
489
+ const permissionHeight = state.permissionBox ? 6 + diffHeight : 0;
490
+ const questionHeight = state.questionPrompt ? 4 + (state.questionPrompt.options?.length ?? 0) : 0;
491
+ const statusLineHeight = state.statusLine ? 1 : 0;
492
+ const contextWarningHeight = state.contextWarning ? 1 : 0;
493
+ const autocompleteHeight = state.autocomplete.length;
494
+ const inputLineCount = Math.min(5, (state.inputText.match(/\n/g)?.length ?? 0) + 1);
495
+ const rawFooterHeight = Math.max(2 + inputLineCount + statusLineHeight + autocompleteHeight, companionHeight + 1) + permissionHeight + questionHeight + contextWarningHeight;
496
+ const footerHeight = Math.min(rawFooterHeight, Math.floor(h / 2));
497
+ const msgAreaHeight = Math.max(1, h - footerHeight);
498
+ // ── Session browser overlay ──
499
+ if (state.sessionBrowser) {
500
+ const browserRows = renderSessionBrowser(grid, 0, 0, state.sessionBrowser, w, msgAreaHeight);
501
+ const footerStart = Math.min(browserRows, msgAreaHeight);
502
+ for (let c = 0; c < w; c++)
503
+ grid.setCell(footerStart, c, '─', S_BORDER);
504
+ const inputRow = footerStart + 1;
505
+ grid.writeText(inputRow, 0, '❯ ', S_USER);
506
+ grid.writeText(inputRow + 1, 0, '↑/↓ navigate | Enter resume | Esc cancel', S_DIM);
507
+ return { cursorRow: inputRow, cursorCol: 2 };
508
+ }
509
+ // ── Messages area (top) ──
510
+ const allContent = [];
511
+ for (const msg of state.messages) {
512
+ if (msg.role === 'user') {
513
+ allContent.push({ role: 'user', content: msg.content, style: { ...S_TEXT, bold: true }, prefixStyle: S_USER, prefix: '❯ ' });
514
+ }
515
+ else if (msg.role === 'assistant') {
516
+ allContent.push({ role: 'assistant', content: msg.content, style: S_TEXT, prefixStyle: S_ASSISTANT, prefix: '◆ ' });
517
+ }
518
+ else if (msg.role === 'system') {
519
+ allContent.push({ role: 'system', content: msg.content, style: S_DIM, prefixStyle: S_DIM, prefix: ' ' });
520
+ }
521
+ }
627
522
  if (state.loading && state.streamingText) {
628
- grid.writeText(r, 0, '◆ ', S_ASSISTANT);
629
- const rows = renderMarkdown(grid, r, 2, state.streamingText, w, state.codeBlocksExpanded, h);
630
- r += rows;
523
+ allContent.push({ role: 'streaming', content: state.streamingText, style: S_TEXT, prefixStyle: S_ASSISTANT, prefix: '◆ ' });
631
524
  }
632
- // ── Thinking (live shimmer) ──
633
- if (state.thinkingText && r < h) {
634
- if (state.thinkingExpanded) {
635
- const thinkLines = state.thinkingText.split('\n').slice(-10);
636
- const shimmerPos = state.spinnerFrame % 20;
637
- const S_BRIGHT = { fg: null, bg: null, bold: false, dim: false, underline: false };
638
- for (const tLine of thinkLines) {
639
- if (r >= h)
640
- break;
641
- grid.writeText(r, 0, '💭 ', S_DIM);
642
- const chars = [...tLine];
643
- for (let ci = 0; ci < chars.length && ci + 3 < w; ci++) {
644
- grid.setCell(r, 3 + ci, chars[ci], Math.abs(ci - shimmerPos) <= 2 ? S_BRIGHT : S_DIM);
645
- }
646
- r++;
647
- }
525
+ if (state.errorText) {
526
+ allContent.push({ role: 'error', content: state.errorText, style: S_ERROR, prefixStyle: S_ERROR, prefix: '✗ ' });
527
+ }
528
+ const prefixLen = 2;
529
+ const contentWidth = w - 1; // reserve rightmost column for scrollbar
530
+ const textWidth = contentWidth - prefixLen;
531
+ // Pre-compute total height to handle scrolling
532
+ let totalRows = 0;
533
+ if (state.bannerLines && h >= 30) {
534
+ const compact = h < 40;
535
+ const visibleLines = compact ? Math.min(2, state.bannerLines.length) : state.bannerLines.length;
536
+ totalRows += visibleLines + 1;
537
+ }
538
+ for (const item of allContent) {
539
+ if (item.role === 'user' && totalRows > 0)
540
+ totalRows++;
541
+ if (item.role === 'assistant' || item.role === 'streaming') {
542
+ totalRows += measureMarkdown(item.content, contentWidth);
648
543
  }
649
544
  else {
650
- const lineCount = state.thinkingText.split('\n').length;
651
- const elapsed = state.thinkingStartedAt ? Math.floor((Date.now() - state.thinkingStartedAt) / 1000) : 0;
652
- const summary = `∴ Thinking${elapsed > 0 ? ` (${elapsed}s)` : ''} — ${lineCount} lines [Ctrl+O expand]`;
653
- grid.writeText(r, 0, summary, S_DIM);
654
- r++;
545
+ const lines = item.content.split('\n');
546
+ for (const line of lines) {
547
+ totalRows += Math.max(1, Math.ceil((line.length || 1) / textWidth));
548
+ }
655
549
  }
656
550
  }
657
- // ── Thinking summary (after completion) ──
658
- if (!state.loading && state.lastThinkingSummary && r < h) {
659
- grid.writeText(r, 0, state.lastThinkingSummary, S_DIM);
660
- r++;
551
+ if (state.thinkingText) {
552
+ totalRows += state.thinkingExpanded ? Math.min(state.thinkingText.split('\n').length, 10) : 1;
661
553
  }
662
- // ── Spinner ──
663
- if (state.loading && !state.streamingText && !state.thinkingText && r < h) {
664
- const thinkText = 'Thinking';
665
- const elapsed = state.thinkingStartedAt ? Math.floor((Date.now() - state.thinkingStartedAt) / 1000) : 0;
666
- const t = getTheme();
667
- const baseColor = elapsed > 60 ? t.error : elapsed > 30 ? t.stall : t.primary;
668
- const shimmerColor = elapsed > 60 ? t.stallShimmer : elapsed > 30 ? t.warning : t.primaryShimmer;
669
- const baseStyle = { fg: baseColor, bg: null, bold: false, dim: false, underline: false };
670
- grid.writeText(r, 0, '', { ...baseStyle, bold: true });
671
- const shimmerPos = state.spinnerFrame % (thinkText.length + 6);
672
- const shimmerStyle = { fg: shimmerColor, bg: null, bold: true, dim: false, underline: false };
673
- for (let ci = 0; ci < thinkText.length; ci++) {
674
- grid.setCell(r, 2 + ci, thinkText[ci], Math.abs(ci - shimmerPos) <= 1 ? shimmerStyle : baseStyle);
675
- }
676
- let suffix = '';
677
- if (elapsed > 0)
678
- suffix += ` ${elapsed}s`;
679
- if (state.tokenCount > 0) {
680
- const tokStr = state.tokenCount >= 1000 ? `${(state.tokenCount / 1000).toFixed(1)}K` : `${state.tokenCount}`;
681
- suffix += ` | ${tokStr} tokens`;
682
- }
683
- suffix += '...';
684
- grid.writeText(r, 2 + thinkText.length, suffix, S_DIM);
685
- r++;
554
+ if (!state.loading && state.lastThinkingSummary)
555
+ totalRows += 1;
556
+ if (state.loading && !state.streamingText && !state.thinkingText)
557
+ totalRows += 1;
558
+ for (const [callId, tc] of state.toolCalls) {
559
+ totalRows += 1;
560
+ if (tc.isAgent && tc.agentDescription)
561
+ totalRows += 1;
562
+ if (tc.status === 'running' && tc.liveOutput)
563
+ totalRows += Math.min(tc.liveOutput.length, 5);
564
+ if (tc.output && tc.status !== 'running' && state.expandedToolCalls.has(callId)) {
565
+ totalRows += Math.min(tc.output.split('\n').length, 20);
566
+ }
686
567
  }
687
- // ── Error ──
688
- if (state.errorText && r < h) {
689
- grid.writeText(r, 0, '✗ ', S_ERROR);
690
- grid.writeText(r, 2, state.errorText.slice(0, w - 4), S_ERROR);
691
- r++;
568
+ if (state.contextWarning)
569
+ totalRows += 1;
570
+ const autoOffset = totalRows > msgAreaHeight ? totalRows - msgAreaHeight : 0;
571
+ const scrollOffset = Math.max(0, autoOffset - state.manualScroll);
572
+ // Scrollbar geometry
573
+ const hasScrollbar = totalRows > msgAreaHeight;
574
+ let thumbStart = 0;
575
+ let thumbSize = msgAreaHeight;
576
+ if (hasScrollbar) {
577
+ thumbSize = Math.max(1, Math.round((msgAreaHeight / totalRows) * msgAreaHeight));
578
+ thumbStart = Math.round((scrollOffset / Math.max(1, totalRows)) * (msgAreaHeight - thumbSize));
692
579
  }
693
- // ── Tool calls ──
694
- for (const [callId, tc] of state.toolCalls) {
695
- if (r >= h)
696
- break;
697
- const isAgent = tc.isAgent || tc.toolName === 'Agent' || tc.toolName === 'ParallelAgents';
698
- const icon = isAgent
699
- ? (tc.status === 'running' ? '⊕' : tc.status === 'done' ? '◈' : '◇')
700
- : (tc.status === 'running' ? SPINNER_CHARS[state.spinnerFrame % SPINNER_CHARS.length] : tc.status === 'done' ? '✓' : '✗');
701
- const S_AGENT = { fg: 'cyan', bg: null, bold: true, dim: false, underline: false };
702
- const statusStyle = tc.status === 'error' ? S_ERROR : tc.status === 'done' ? S_GREEN : isAgent ? S_AGENT : S_YELLOW;
703
- const nameStyle = isAgent ? S_AGENT : { ...S_YELLOW, bold: true };
704
- const isExpanded = state.expandedToolCalls.has(callId);
705
- grid.writeText(r, 0, isExpanded ? '▼' : '▶', S_DIM);
706
- grid.writeText(r, 2, `${icon} `, statusStyle);
707
- grid.writeText(r, 4, tc.toolName, nameStyle);
708
- let afterName = 4 + tc.toolName.length + 1;
709
- if (tc.args) {
710
- const maxArgs = w - afterName - 15;
711
- if (maxArgs > 5) {
712
- const argsText = tc.args.slice(0, maxArgs) + (tc.args.length > maxArgs ? '…' : '');
713
- grid.writeText(r, afterName, argsText, S_DIM);
714
- afterName += argsText.length + 1;
580
+ let r = 0;
581
+ let virtualR = 0;
582
+ let contentIdx = 0;
583
+ // ── Banner ──
584
+ if (state.bannerLines && h >= 30) {
585
+ const compact = h < 40;
586
+ const startLine = compact ? Math.max(0, state.bannerLines.length - 2) : 0;
587
+ for (let i = startLine; i < state.bannerLines.length; i++) {
588
+ if (virtualR >= scrollOffset && r < msgAreaHeight) {
589
+ const line = state.bannerLines[i];
590
+ const isBannerArt = i < state.bannerLines.length - 2;
591
+ grid.writeText(r, 0, line, isBannerArt ? S_BANNER : S_BANNER_DIM);
592
+ r++;
715
593
  }
594
+ virtualR++;
716
595
  }
717
- if (tc.resultSummary && tc.status !== 'running') {
718
- grid.writeText(r, Math.min(afterName, w - tc.resultSummary.length - 2), tc.resultSummary, S_DIM);
719
- }
720
- r++;
721
- if (isAgent && tc.agentDescription && r < h) {
722
- grid.writeText(r, 6, tc.agentDescription.slice(0, w - 8), S_DIM);
596
+ if (virtualR >= scrollOffset && r < msgAreaHeight) {
723
597
  r++;
724
598
  }
725
- // Live output while running
726
- if (tc.status === 'running' && tc.liveOutput && tc.liveOutput.length > 0) {
727
- const visible = tc.liveOutput.slice(-3);
728
- for (const line of visible) {
729
- if (r >= h)
730
- break;
731
- grid.writeText(r, 6, line.slice(0, w - 8), S_DIM);
599
+ virtualR++;
600
+ }
601
+ // ── Messages ──
602
+ for (const item of allContent) {
603
+ if (r >= msgAreaHeight)
604
+ break;
605
+ if (item.role === 'user' && contentIdx > 0) {
606
+ if (virtualR >= scrollOffset) {
607
+ for (let c = 0; c < w; c++) {
608
+ grid.setCell(r, c, '─', S_BORDER);
609
+ }
732
610
  r++;
733
611
  }
612
+ virtualR++;
734
613
  }
735
- // Expanded output
736
- if (tc.output && tc.status !== 'running' && isExpanded && r < h) {
737
- const outLines = tc.output.split('\n').slice(0, 20);
738
- for (const line of outLines) {
739
- if (r >= h)
740
- break;
741
- grid.writeText(r, 6, line.slice(0, w - 8), tc.status === 'error' ? S_ERROR : S_DIM);
742
- r++;
614
+ let itemRows;
615
+ if (item.role === 'assistant' || item.role === 'streaming') {
616
+ itemRows = measureMarkdown(item.content, contentWidth);
617
+ }
618
+ else {
619
+ const lines = item.content.split('\n');
620
+ itemRows = 0;
621
+ for (const line of lines) {
622
+ itemRows += Math.max(1, Math.ceil((line.length || 1) / textWidth));
743
623
  }
744
624
  }
745
- }
746
- // ── Context warning ──
747
- if (state.contextWarning && r < h) {
748
- const warnStyle = { fg: 'yellow', bg: null, bold: state.contextWarning.critical, dim: false, underline: false };
749
- grid.writeText(r, 0, state.contextWarning.text, warnStyle);
750
- r++;
751
- }
752
- // ── Footer border ──
753
- if (r < h) {
754
- for (let c = 0; c < w; c++)
755
- grid.setCell(r, c, '─', S_BORDER);
756
- r++;
757
- }
758
- let nextRow = r;
759
- // ── Permission box ──
760
- let questionInputRow = -1;
761
- if (state.permissionBox && w >= 20 && (h - nextRow) >= 4) {
762
- const { toolName, riskLevel } = state.permissionBox;
763
- const riskColor = riskLevel === 'high' ? 'red' : riskLevel === 'medium' ? 'yellow' : 'green';
764
- const riskStyle = { fg: riskColor, bg: null, bold: true, dim: false, underline: false };
765
- grid.writeText(nextRow, 1, `⚠ ${toolName} (${riskLevel} risk)`, riskStyle);
766
- nextRow++;
767
- const S_KEY_GREEN = { fg: 'green', bg: null, bold: true, dim: false, underline: false };
768
- const S_KEY_RED = { fg: 'red', bg: null, bold: true, dim: false, underline: false };
769
- grid.writeText(nextRow, 1, 'Y', S_KEY_GREEN);
770
- grid.writeText(nextRow, 2, 'es ', S_DIM);
771
- grid.writeText(nextRow, 6, 'N', S_KEY_RED);
772
- grid.writeText(nextRow, 7, 'o', S_DIM);
773
- if (state.permissionDiffInfo) {
774
- const S_KEY_CYAN = { fg: 'cyan', bg: null, bold: true, dim: false, underline: false };
775
- grid.writeText(nextRow, 10, 'D', S_KEY_CYAN);
776
- grid.writeText(nextRow, 11, 'iff', S_DIM);
625
+ if (virtualR + itemRows <= scrollOffset) {
626
+ virtualR += itemRows;
627
+ contentIdx++;
628
+ continue;
777
629
  }
778
- nextRow++;
779
- // Inline diff (when toggled)
780
- if (state.permissionDiffVisible && state.permissionDiffInfo && nextRow + 3 < h) {
781
- const availDiffRows = Math.min(15, h - nextRow - 3);
782
- const diffRows = renderDiff(grid, nextRow, 3, state.permissionDiffInfo, Math.min(w - 2, 70), availDiffRows);
783
- nextRow += diffRows;
630
+ grid.writeText(r, 0, item.prefix, item.prefixStyle);
631
+ let rows;
632
+ if (item.role === 'assistant' || item.role === 'streaming') {
633
+ rows = renderMarkdown(grid, r, prefixLen, item.content, contentWidth, state.codeBlocksExpanded, msgAreaHeight);
634
+ }
635
+ else {
636
+ rows = grid.writeWrapped(r, prefixLen, item.content, item.style, contentWidth, msgAreaHeight);
784
637
  }
638
+ r += rows;
639
+ virtualR += itemRows;
640
+ contentIdx++;
785
641
  }
786
- // ── Question prompt ──
787
- if (state.questionPrompt && w >= 20 && (h - nextRow) >= 3) {
788
- grid.writeText(nextRow, 1, `❓ ${state.questionPrompt.question}`, S_TEXT);
789
- nextRow++;
790
- if (state.questionPrompt.options) {
791
- for (const opt of state.questionPrompt.options) {
792
- if (nextRow >= h)
793
- break;
794
- grid.writeText(nextRow, 3, opt, S_DIM);
795
- nextRow++;
796
- }
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 ──
649
+ if (hasScrollbar) {
650
+ const S_TRACK = { fg: null, bg: null, bold: false, dim: true, underline: false };
651
+ const S_THUMB = { fg: null, bg: null, bold: false, dim: false, underline: false };
652
+ for (let sr = 0; sr < msgAreaHeight; sr++) {
653
+ const isThumb = sr >= thumbStart && sr < thumbStart + thumbSize;
654
+ grid.setCell(sr, w - 1, isThumb ? '█' : '░', isThumb ? S_THUMB : S_TRACK);
797
655
  }
798
- questionInputRow = nextRow;
799
- grid.writeText(nextRow, 1, '❯ ', S_USER);
800
- grid.writeText(nextRow, 3, state.questionPrompt.input, S_TEXT);
801
- nextRow++;
802
656
  }
803
- // ── Status line ──
804
- if (state.statusLine && nextRow < h) {
805
- grid.writeText(nextRow, 0, state.statusLine, S_DIM);
806
- nextRow++;
657
+ // ── Footer ──
658
+ const footerStart = Math.min(r, msgAreaHeight);
659
+ for (let c = 0; c < w; c++) {
660
+ grid.setCell(footerStart, c, '─', S_BORDER);
807
661
  }
808
- // ── Autocomplete ──
809
- const vimIndicator = state.vimMode ? (state.vimMode === 'normal' ? '[N] ' : '[I] ') : '';
810
- const promptText = vimIndicator + '❯ ';
811
- const promptWidth = promptText.length;
812
- if (state.autocomplete.length > 0) {
813
- for (let ai = 0; ai < state.autocomplete.length; ai++) {
814
- if (nextRow >= h)
815
- break;
816
- const cmd = state.autocomplete[ai];
817
- const desc = state.autocompleteDescriptions[ai] ?? '';
818
- const selected = ai === state.autocompleteIndex;
819
- const acStyle = selected ? s(getTheme().user, true) : s(null, false, true);
820
- grid.writeText(nextRow, promptWidth, `/${cmd.padEnd(12)}`, acStyle);
821
- if (desc && w > promptWidth + 15)
822
- grid.writeText(nextRow, promptWidth + 13, desc.slice(0, w - promptWidth - 15), S_DIM);
823
- nextRow++;
824
- }
662
+ if (hasScrollbar) {
663
+ grid.setCell(footerStart, w - 1, '', S_BORDER);
825
664
  }
826
- // ── Input line ──
827
- const inputRow = nextRow;
828
- let inputStart;
829
- {
830
- grid.writeText(inputRow, 0, promptText, S_USER);
831
- inputStart = promptWidth;
832
- const inputLines = state.inputText.split('\n');
833
- const maxInputLines = Math.min(inputLines.length, 5);
834
- for (let li = 0; li < maxInputLines; li++) {
835
- if (inputRow + li >= h)
836
- break;
837
- if (li === 0) {
838
- grid.writeText(inputRow, inputStart, inputLines[0], S_TEXT);
839
- }
840
- else {
841
- grid.writeText(inputRow + li, inputStart, inputLines[li], S_TEXT);
842
- }
843
- }
844
- const hintsRow = inputRow + maxInputLines;
845
- if (hintsRow < h) {
846
- const hintsText = inputLines.length > 1 ? `${state.statusHints} | Alt+Enter newline` : state.statusHints;
847
- grid.writeText(hintsRow, 0, hintsText, S_DIM);
848
- }
665
+ if (state.manualScroll > 0 && totalRows > msgAreaHeight) {
666
+ const hiddenBelow = state.manualScroll;
667
+ const indicator = ` ↓ ${hiddenBelow} more below `;
668
+ const startCol = Math.max(0, Math.floor((w - indicator.length) / 2));
669
+ grid.writeText(footerStart, startCol, indicator, S_DIM);
849
670
  }
850
- // ── Companion (right-aligned, anchored at footer border area) ──
671
+ else if (totalRows > msgAreaHeight && scrollOffset > 0) {
672
+ const indicator = ` ↑ ${scrollOffset} more above `;
673
+ const startCol = Math.max(0, Math.floor((w - indicator.length) / 2));
674
+ grid.writeText(footerStart, startCol, indicator, S_DIM);
675
+ }
676
+ let nextRow = footerStart + 1;
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);
686
+ const inputRow = nextRow;
687
+ renderInputSection(state, grid, inputRow, h, promptText, promptWidth);
688
+ // Companion (right-aligned in footer, skipped if it would overlap input)
851
689
  if (state.companionLines && w >= 50) {
852
690
  const compWidth = Math.max(...state.companionLines.map(l => l.length), 0);
853
691
  const compStartCol = Math.max(0, w - compWidth - 1);
854
- if (compStartCol > promptWidth + 20) {
692
+ const inputEndCol = promptWidth + (state.inputText.split('\n')[0]?.length ?? 0);
693
+ if (compStartCol > inputEndCol + 3) {
855
694
  const compStyle = { fg: state.companionColor || 'cyan', bg: null, bold: false, dim: false, underline: false };
856
- // Place companion starting at the border row, right-aligned
857
- const borderRow = r; // r is at the border line position
858
695
  for (let i = 0; i < state.companionLines.length; i++) {
859
- const compRow = borderRow + i;
696
+ const compRow = footerStart + i;
697
+ if (compRow >= inputRow)
698
+ break;
860
699
  if (compRow >= h)
861
700
  break;
862
701
  grid.writeText(compRow, compStartCol, state.companionLines[i], compStyle);
863
702
  }
864
703
  }
865
704
  }
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;
727
+ }
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);
866
756
  // ── Cursor position ──
867
757
  if (state.questionPrompt && questionInputRow >= 0) {
868
758
  return { cursorRow: questionInputRow, cursorCol: 3 + state.questionPrompt.cursor };
@@ -871,6 +761,6 @@ export function rasterizeLive(state, grid) {
871
761
  const cursorLines = textBeforeCursor.split('\n');
872
762
  const cursorLineIdx = Math.min(cursorLines.length - 1, 4);
873
763
  const cursorColInLine = cursorLines[cursorLines.length - 1].length;
874
- return { cursorRow: inputRow + cursorLineIdx, cursorCol: inputStart + cursorColInLine };
764
+ return { cursorRow: inputRow + cursorLineIdx, cursorCol: promptWidth + cursorColInLine };
875
765
  }
876
766
  //# sourceMappingURL=layout.js.map