@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
@@ -1,22 +1,28 @@
1
1
  /**
2
- * TerminalRenderer — cell-level diffing terminal renderer.
3
- * Replaces Ink for the REPL. Only writes changed characters to stdout.
2
+ * TerminalRenderer — sequential output terminal renderer.
3
+ * Flushed messages flow to scrollback; live area is rewritten in-place
4
+ * right after the scrollback content each frame (no absolute positioning gap).
4
5
  */
5
6
  import { CellGrid } from './cells.js';
6
- import { diff, syncWrite, enterAltScreen, leaveAltScreen, hideCursor, showCursor, moveCursor } from './differ.js';
7
- import { rasterize } from './layout.js';
7
+ import { styleToSGR, syncWrite, hideCursor, showCursor } from './differ.js';
8
+ import { rasterizeLive } from './layout.js';
9
+ import { getTheme } from '../utils/theme-data.js';
10
+ import { FG } from './colors.js';
8
11
  import { createSessionBrowser, browserUp, browserDown, browserSelectedId, browserLoadPreview, browserSearch } from './session-browser.js';
9
12
  import { summarizeToolArgs } from '../utils/tool-summary.js';
10
13
  import { extractDiffInfo } from './diff.js';
11
14
  import { startRawInput } from './input.js';
12
15
  export class TerminalRenderer {
13
16
  current;
14
- previous;
15
17
  state;
16
18
  stopInput = null;
19
+ notifyTimers = [];
17
20
  animationTimer = null;
18
21
  renderPending = false;
19
22
  started = false;
23
+ flushedMessageCount = 0;
24
+ flushedToolCallIds = new Set();
25
+ lastLiveLines = 0; // lines the live area occupied last frame (for relative cursor movement)
20
26
  // Callbacks
21
27
  keypressHandler = null;
22
28
  resizeHandler = null;
@@ -31,7 +37,6 @@ export class TerminalRenderer {
31
37
  const w = process.stdout.columns ?? 80;
32
38
  const h = process.stdout.rows ?? 24;
33
39
  this.current = new CellGrid(w, h);
34
- this.previous = new CellGrid(w, h);
35
40
  this.state = {
36
41
  messages: [],
37
42
  streamingText: '',
@@ -64,85 +69,21 @@ export class TerminalRenderer {
64
69
  bannerLines: null,
65
70
  thinkingExpanded: false,
66
71
  lastThinkingSummary: null,
67
- searchMode: false,
68
- searchQuery: '',
69
- searchMatchCount: 0,
70
- searchCurrentMatch: -1,
72
+ notifications: [],
71
73
  };
72
74
  }
73
75
  // ── Lifecycle ──
74
76
  start() {
75
77
  this.started = true;
76
- enterAltScreen();
77
78
  // Enable SGR mouse tracking (scroll wheel support)
78
79
  process.stdout.write('\x1b[?1000h\x1b[?1006h');
79
80
  hideCursor();
80
81
  // Raw input
81
82
  this.stopInput = startRawInput((key) => {
82
- // Permission prompt intercepts Y/N
83
- if (this.permissionResolve) {
84
- const k = key.char.toLowerCase();
85
- if (k === 'y') {
86
- const resolve = this.permissionResolve;
87
- this.permissionResolve = null;
88
- this.permissionPrompt = null;
89
- this.state.permissionBox = null;
90
- this.state.permissionDiffVisible = false;
91
- this.state.permissionDiffInfo = null;
92
- this.scheduleRender();
93
- resolve(true);
94
- }
95
- else if (k === 'n') {
96
- const resolve = this.permissionResolve;
97
- this.permissionResolve = null;
98
- this.permissionPrompt = null;
99
- this.state.permissionBox = null;
100
- this.state.permissionDiffVisible = false;
101
- this.state.permissionDiffInfo = null;
102
- this.scheduleRender();
103
- resolve(false);
104
- }
105
- else if (k === 'd' && this.state.permissionDiffInfo) {
106
- this.state.permissionDiffVisible = !this.state.permissionDiffVisible;
107
- this.scheduleRender();
108
- }
109
- return; // Swallow all other keys during permission prompt
110
- }
111
- // Question prompt intercepts text input
112
- if (this.questionResolve && this.state.questionPrompt) {
113
- const qp = this.state.questionPrompt;
114
- if (key.name === 'return' && qp.input.trim()) {
115
- const resolve = this.questionResolve;
116
- const answer = qp.input.trim();
117
- this.questionResolve = null;
118
- this.state.questionPrompt = null;
119
- this.scheduleRender();
120
- resolve(answer);
121
- }
122
- else if (key.name === 'backspace') {
123
- if (qp.cursor > 0) {
124
- this.state.questionPrompt = { ...qp, input: qp.input.slice(0, qp.cursor - 1) + qp.input.slice(qp.cursor), cursor: qp.cursor - 1 };
125
- this.scheduleRender();
126
- }
127
- }
128
- else if (key.name === 'left') {
129
- if (qp.cursor > 0) {
130
- this.state.questionPrompt = { ...qp, cursor: qp.cursor - 1 };
131
- this.scheduleRender();
132
- }
133
- }
134
- else if (key.name === 'right') {
135
- if (qp.cursor < qp.input.length) {
136
- this.state.questionPrompt = { ...qp, cursor: qp.cursor + 1 };
137
- this.scheduleRender();
138
- }
139
- }
140
- else if (key.char && key.char.length === 1 && !key.ctrl && !key.meta) {
141
- this.state.questionPrompt = { ...qp, input: qp.input.slice(0, qp.cursor) + key.char + qp.input.slice(qp.cursor), cursor: qp.cursor + 1 };
142
- this.scheduleRender();
143
- }
83
+ if (this.handlePermissionKey(key))
84
+ return;
85
+ if (this.handleQuestionKey(key))
144
86
  return;
145
- }
146
87
  if (this.keypressHandler)
147
88
  this.keypressHandler(key);
148
89
  });
@@ -175,16 +116,47 @@ export class TerminalRenderer {
175
116
  this.stopInput();
176
117
  this.stopInput = null;
177
118
  }
178
- // Restore terminal: disable mouse, leave alt screen, show cursor, reset attributes
119
+ for (const t of this.notifyTimers)
120
+ clearTimeout(t);
121
+ this.notifyTimers.length = 0;
122
+ // Restore terminal: disable mouse, show cursor, reset attributes
179
123
  process.stdout.write('\x1b[?1006l\x1b[?1000l\x1b[0m');
180
124
  showCursor();
181
- leaveAltScreen();
125
+ // Move past live area for clean shell prompt
126
+ process.stdout.write('\n');
182
127
  }
183
128
  // ── State updates ──
184
- setMessages(msgs) { this.state.messages = msgs; this.scheduleRender(); }
129
+ setMessages(msgs) {
130
+ // Reset flush counter if messages array was replaced (e.g., session resume)
131
+ if (msgs.length < this.flushedMessageCount)
132
+ this.flushedMessageCount = 0;
133
+ this.state.messages = msgs;
134
+ this.scheduleRender();
135
+ }
185
136
  setStreamingText(text) { this.state.streamingText = text; this.scheduleRender(); }
186
137
  setThinkingText(text) { this.state.thinkingText = text; this.scheduleRender(); }
187
138
  setError(text) { this.state.errorText = text; this.scheduleRender(); }
139
+ /** Show a toast notification above the input for 5 seconds */
140
+ notify(text) {
141
+ const entry = { text };
142
+ this.state.notifications.push(entry);
143
+ this.scheduleRender();
144
+ const timer = setTimeout(() => {
145
+ const idx = this.state.notifications.indexOf(entry);
146
+ if (idx >= 0) {
147
+ this.state.notifications.splice(idx, 1);
148
+ this.scheduleRender();
149
+ }
150
+ const ti = this.notifyTimers.indexOf(timer);
151
+ if (ti >= 0)
152
+ this.notifyTimers.splice(ti, 1);
153
+ }, 5000);
154
+ this.notifyTimers.push(timer);
155
+ }
156
+ scrollBy(delta) {
157
+ this.state.manualScroll = Math.max(0, Math.min(this.state.manualScroll + delta, 500));
158
+ this.scheduleRender();
159
+ }
188
160
  setLoading(loading) { this.state.loading = loading; this.scheduleRender(); }
189
161
  setInputText(text) { this.state.inputText = text; this.scheduleRender(); }
190
162
  setInputCursor(pos) { this.state.inputCursor = pos; this.scheduleRender(); }
@@ -193,8 +165,8 @@ export class TerminalRenderer {
193
165
  this.state.companionColor = color;
194
166
  this.scheduleRender();
195
167
  }
196
- setBanner(lines) { this.state.bannerLines = lines; this.scheduleRender(); }
197
168
  setStatusHints(text) { this.state.statusHints = text; this.scheduleRender(); }
169
+ setBannerLines(lines) { this.state.bannerLines = lines; this.scheduleRender(); }
198
170
  setAutocomplete(suggestions, index, descriptions) {
199
171
  this.state.autocomplete = suggestions;
200
172
  this.state.autocompleteDescriptions = descriptions ?? [];
@@ -208,125 +180,18 @@ export class TerminalRenderer {
208
180
  getThinkingStartedAt() { return this.state.thinkingStartedAt; }
209
181
  setLastThinkingSummary(summary) { this.state.lastThinkingSummary = summary; this.scheduleRender(); }
210
182
  toggleThinkingExpanded() { this.state.thinkingExpanded = !this.state.thinkingExpanded; this.scheduleRender(); }
211
- // Search mode
212
- enterSearchMode() {
213
- this.state.searchMode = true;
214
- this.state.searchQuery = '';
215
- this.state.searchMatchCount = 0;
216
- this.state.searchCurrentMatch = -1;
217
- this.scheduleRender();
218
- }
219
- exitSearchMode() {
220
- this.state.searchMode = false;
221
- this.state.searchQuery = '';
222
- this.state.searchMatchCount = 0;
223
- this.state.searchCurrentMatch = -1;
224
- this.scheduleRender();
225
- }
226
- setSearchQuery(query) {
227
- this.state.searchQuery = query;
228
- // Count matches across all messages
229
- if (query) {
230
- const lq = query.toLowerCase();
231
- let count = 0;
232
- for (const msg of this.state.messages) {
233
- const content = msg.content.toLowerCase();
234
- let idx = 0;
235
- while ((idx = content.indexOf(lq, idx)) !== -1) {
236
- count++;
237
- idx += lq.length;
238
- }
239
- }
240
- this.state.searchMatchCount = count;
241
- this.state.searchCurrentMatch = count > 0 ? 0 : -1;
242
- }
243
- else {
244
- this.state.searchMatchCount = 0;
245
- this.state.searchCurrentMatch = -1;
246
- }
247
- this.scheduleRender();
248
- }
249
- searchNext() {
250
- if (this.state.searchMatchCount > 0) {
251
- this.state.searchCurrentMatch = (this.state.searchCurrentMatch + 1) % this.state.searchMatchCount;
252
- this.scrollToSearchMatch();
253
- this.scheduleRender();
254
- }
255
- }
256
- searchPrev() {
257
- if (this.state.searchMatchCount > 0) {
258
- this.state.searchCurrentMatch = (this.state.searchCurrentMatch - 1 + this.state.searchMatchCount) % this.state.searchMatchCount;
259
- this.scrollToSearchMatch();
260
- this.scheduleRender();
261
- }
262
- }
263
- scrollToSearchMatch() {
264
- // Estimate which row the current match is on and scroll to it
265
- const lq = this.state.searchQuery.toLowerCase();
266
- const w = process.stdout.columns ?? 80;
267
- let matchIdx = 0;
268
- let rowEstimate = 0;
269
- for (const msg of this.state.messages) {
270
- const content = msg.content;
271
- const lines = content.split('\n');
272
- for (const line of lines) {
273
- const lineRows = Math.max(1, Math.ceil((line.length || 1) / (w - 2)));
274
- // Count matches in this line
275
- const ll = line.toLowerCase();
276
- let idx = 0;
277
- while ((idx = ll.indexOf(lq, idx)) !== -1) {
278
- if (matchIdx === this.state.searchCurrentMatch) {
279
- // Found it — scroll so this row is visible
280
- const h = process.stdout.rows ?? 24;
281
- const targetScroll = Math.max(0, rowEstimate - Math.floor(h / 3));
282
- // manualScroll is offset from bottom: convert
283
- const totalRows = this.estimateTotalRows();
284
- this.state.manualScroll = Math.max(0, totalRows - targetScroll - h + 15);
285
- return;
286
- }
287
- matchIdx++;
288
- idx += lq.length;
289
- }
290
- rowEstimate += lineRows;
291
- }
292
- rowEstimate++; // gap between messages
293
- }
294
- }
295
- estimateTotalRows() {
296
- const w = process.stdout.columns ?? 80;
297
- let total = 0;
298
- for (const msg of this.state.messages) {
299
- const lines = msg.content.split('\n');
300
- for (const line of lines) {
301
- total += Math.max(1, Math.ceil((line.length || 1) / (w - 2)));
302
- }
303
- total++;
304
- }
305
- return total;
306
- }
307
- isSearchMode() { return this.state.searchMode; }
308
- getSearchQuery() { return this.state.searchQuery; }
309
183
  setTokenCount(count) { this.state.tokenCount = count; this.scheduleRender(); }
310
- scrollUp(rows) {
311
- // Cap manualScroll to prevent scrolling past the top
312
- const h = process.stdout.rows ?? 24;
313
- const maxScroll = Math.max(0, this.estimateTotalRows() - Math.floor(h / 3));
314
- this.state.manualScroll = Math.min(this.state.manualScroll + rows, maxScroll);
315
- this.scheduleRender();
316
- }
317
- scrollDown(rows) {
318
- this.state.manualScroll = Math.max(0, this.state.manualScroll - rows);
319
- this.scheduleRender();
320
- }
321
- scrollToBottom() {
322
- this.state.manualScroll = 0;
323
- this.scheduleRender();
324
- }
325
184
  toggleCodeBlockExpansion() {
326
185
  this.state.codeBlocksExpanded = !this.state.codeBlocksExpanded;
327
186
  this.scheduleRender();
328
187
  }
329
188
  // Session browser
189
+ withSessionBrowser(fn) {
190
+ if (this.state.sessionBrowser) {
191
+ this.state.sessionBrowser = fn(this.state.sessionBrowser);
192
+ this.scheduleRender();
193
+ }
194
+ }
330
195
  openSessionBrowser() {
331
196
  this.state.sessionBrowser = createSessionBrowser();
332
197
  this.scheduleRender();
@@ -336,16 +201,10 @@ export class TerminalRenderer {
336
201
  this.scheduleRender();
337
202
  }
338
203
  sessionBrowserUp() {
339
- if (this.state.sessionBrowser) {
340
- this.state.sessionBrowser = browserLoadPreview(browserUp(this.state.sessionBrowser));
341
- this.scheduleRender();
342
- }
204
+ this.withSessionBrowser(sb => browserLoadPreview(browserUp(sb)));
343
205
  }
344
206
  sessionBrowserDown() {
345
- if (this.state.sessionBrowser) {
346
- this.state.sessionBrowser = browserLoadPreview(browserDown(this.state.sessionBrowser));
347
- this.scheduleRender();
348
- }
207
+ this.withSessionBrowser(sb => browserLoadPreview(browserDown(sb)));
349
208
  }
350
209
  sessionBrowserSelect() {
351
210
  if (!this.state.sessionBrowser)
@@ -356,15 +215,11 @@ export class TerminalRenderer {
356
215
  return id;
357
216
  }
358
217
  sessionBrowserType(char) {
359
- if (this.state.sessionBrowser) {
360
- this.state.sessionBrowser = browserSearch(this.state.sessionBrowser, this.state.sessionBrowser.searchQuery + char);
361
- this.scheduleRender();
362
- }
218
+ this.withSessionBrowser(sb => browserSearch(sb, sb.searchQuery + char));
363
219
  }
364
220
  sessionBrowserBackspace() {
365
221
  if (this.state.sessionBrowser && this.state.sessionBrowser.searchQuery.length > 0) {
366
- this.state.sessionBrowser = browserSearch(this.state.sessionBrowser, this.state.sessionBrowser.searchQuery.slice(0, -1));
367
- this.scheduleRender();
222
+ this.withSessionBrowser(sb => browserSearch(sb, sb.searchQuery.slice(0, -1)));
368
223
  }
369
224
  }
370
225
  isSessionBrowserOpen() {
@@ -375,7 +230,7 @@ export class TerminalRenderer {
375
230
  this.scheduleRender();
376
231
  }
377
232
  getToolCall(callId) { return this.state.toolCalls.get(callId); }
378
- clearToolCalls() { this.state.toolCalls.clear(); this.scheduleRender(); }
233
+ clearToolCalls() { this.state.toolCalls.clear(); this.flushedToolCallIds.clear(); this.scheduleRender(); }
379
234
  collapseAllToolCalls() { this.state.expandedToolCalls.clear(); this.scheduleRender(); }
380
235
  /** Show a question prompt and wait for text answer */
381
236
  askQuestion(question, options) {
@@ -422,20 +277,91 @@ export class TerminalRenderer {
422
277
  askPermission(toolName, description, riskLevel) {
423
278
  this.permissionPrompt = { toolName, description, riskLevel };
424
279
  this.state.permissionBox = { toolName, description, riskLevel, suggestion: summarizeToolArgs(toolName, description) };
425
- this.state.permissionDiffVisible = false;
426
280
  this.state.permissionDiffInfo = extractDiffInfo(toolName, description);
281
+ // Auto-show diffs for file-modifying tools
282
+ const isFileTool = /^(Edit|Write|FileEdit|FileWrite)/i.test(toolName);
283
+ this.state.permissionDiffVisible = isFileTool && this.state.permissionDiffInfo !== null;
427
284
  this.scheduleRender();
428
285
  return new Promise((resolve) => {
429
286
  this.permissionResolve = resolve;
430
287
  });
431
288
  }
432
289
  // ── Input ──
290
+ /** Clear the live area from screen using relative cursor movement */
291
+ clearLiveArea() {
292
+ if (!this.started)
293
+ return;
294
+ const cmd = this.lastLiveLines > 0
295
+ ? `\x1b[${this.lastLiveLines}A\r\x1b[J`
296
+ : '\r\x1b[J';
297
+ syncWrite(cmd);
298
+ this.lastLiveLines = 0;
299
+ }
433
300
  onKeypress(handler) {
434
301
  this.keypressHandler = handler;
435
302
  }
436
303
  onAnimation(handler) {
437
304
  this.animationCallback = handler;
438
305
  }
306
+ // ── Input routing ──
307
+ /** Handle permission prompt keys (Y/N/D). Returns true if key was consumed. */
308
+ handlePermissionKey(key) {
309
+ if (!this.permissionResolve)
310
+ return false;
311
+ const k = key.char.toLowerCase();
312
+ if (k === 'y' || k === 'n') {
313
+ const resolve = this.permissionResolve;
314
+ this.permissionResolve = null;
315
+ this.permissionPrompt = null;
316
+ this.state.permissionBox = null;
317
+ this.state.permissionDiffVisible = false;
318
+ this.state.permissionDiffInfo = null;
319
+ this.scheduleRender();
320
+ resolve(k === 'y');
321
+ }
322
+ else if (k === 'd' && this.state.permissionDiffInfo) {
323
+ this.state.permissionDiffVisible = !this.state.permissionDiffVisible;
324
+ this.scheduleRender();
325
+ }
326
+ return true; // Swallow all keys during permission prompt
327
+ }
328
+ /** Handle question prompt text input. Returns true if key was consumed. */
329
+ handleQuestionKey(key) {
330
+ if (!this.questionResolve || !this.state.questionPrompt)
331
+ return false;
332
+ const qp = this.state.questionPrompt;
333
+ if (key.name === 'return' && qp.input.trim()) {
334
+ const resolve = this.questionResolve;
335
+ const answer = qp.input.trim();
336
+ this.questionResolve = null;
337
+ this.state.questionPrompt = null;
338
+ this.scheduleRender();
339
+ resolve(answer);
340
+ }
341
+ else if (key.name === 'backspace') {
342
+ if (qp.cursor > 0) {
343
+ this.state.questionPrompt = { ...qp, input: qp.input.slice(0, qp.cursor - 1) + qp.input.slice(qp.cursor), cursor: qp.cursor - 1 };
344
+ this.scheduleRender();
345
+ }
346
+ }
347
+ else if (key.name === 'left') {
348
+ if (qp.cursor > 0) {
349
+ this.state.questionPrompt = { ...qp, cursor: qp.cursor - 1 };
350
+ this.scheduleRender();
351
+ }
352
+ }
353
+ else if (key.name === 'right') {
354
+ if (qp.cursor < qp.input.length) {
355
+ this.state.questionPrompt = { ...qp, cursor: qp.cursor + 1 };
356
+ this.scheduleRender();
357
+ }
358
+ }
359
+ else if (key.char && key.char.length === 1 && !key.ctrl && !key.meta) {
360
+ this.state.questionPrompt = { ...qp, input: qp.input.slice(0, qp.cursor) + key.char + qp.input.slice(qp.cursor), cursor: qp.cursor + 1 };
361
+ this.scheduleRender();
362
+ }
363
+ return true;
364
+ }
439
365
  // ── Rendering ──
440
366
  scheduleRender() {
441
367
  if (this.renderPending || !this.started)
@@ -447,29 +373,170 @@ export class TerminalRenderer {
447
373
  this.render();
448
374
  });
449
375
  }
376
+ /** Apply lightweight markdown styling to a line for scrollback output */
377
+ styleMarkdownLine(line) {
378
+ return line
379
+ .replace(/\*\*(.+?)\*\*/g, '\x1b[1m$1\x1b[0m') // bold → full reset after
380
+ .replace(/`([^`]+)`/g, '\x1b[2m$1\x1b[0m') // inline code → dim, full reset after
381
+ .replace(/^(#{1,3})\s+(.+)$/, '\x1b[1m\x1b[36m$1 $2\x1b[0m'); // headings → bold cyan
382
+ }
383
+ /** Calculate the max text width for flushed scrollback output, reserving space for companion */
384
+ flushTextWidth() {
385
+ const maxWidth = process.stdout.columns ?? 80;
386
+ if (!this.state.companionLines)
387
+ return maxWidth;
388
+ const compWidth = Math.max(...this.state.companionLines.map(l => l.length), 0) + 2;
389
+ return Math.max(40, maxWidth - compWidth);
390
+ }
391
+ /** Flush completed messages to terminal scrollback (native scrollbar) */
392
+ flushMessages() {
393
+ const messages = this.state.messages;
394
+ let didFlush = false;
395
+ const textWidth = this.flushTextWidth();
396
+ while (this.flushedMessageCount < messages.length) {
397
+ const msg = messages[this.flushedMessageCount];
398
+ // Don't flush the message currently being streamed
399
+ if (this.state.loading && this.flushedMessageCount === messages.length - 1 && msg.meta?.isStreaming)
400
+ break;
401
+ const t = getTheme();
402
+ const colorCode = msg.role === 'user' ? `\x1b[${FG(t.user)}m\x1b[1m` : msg.role === 'assistant' ? `\x1b[${FG(t.assistant)}m` : '\x1b[2m';
403
+ const prefixChar = msg.role === 'user' ? '❯ ' : msg.role === 'assistant' ? '◆ ' : ' ';
404
+ const lines = msg.content.split('\n');
405
+ for (let i = 0; i < lines.length; i++) {
406
+ const styledLine = msg.role === 'assistant' ? this.styleMarkdownLine(lines[i]) : lines[i];
407
+ const linePrefix = i === 0 ? prefixChar : ' ';
408
+ const fullLine = linePrefix + styledLine;
409
+ process.stdout.write(colorCode + fullLine.slice(0, textWidth) + '\x1b[0m\n');
410
+ }
411
+ // Divider after each message
412
+ process.stdout.write('\x1b[2m' + '─'.repeat(Math.min(60, textWidth)) + '\x1b[0m\n');
413
+ this.flushedMessageCount++;
414
+ didFlush = true;
415
+ }
416
+ // Flush completed tool calls as single-line summaries (once each)
417
+ if (didFlush) {
418
+ for (const [callId, tc] of this.state.toolCalls) {
419
+ if (tc.status === 'running')
420
+ continue;
421
+ if (this.flushedToolCallIds.has(callId))
422
+ continue;
423
+ this.flushedToolCallIds.add(callId);
424
+ const t = getTheme();
425
+ const icon = tc.status === 'done' ? `\x1b[${FG(t.success)}m✓` : `\x1b[${FG(t.error)}m✗`;
426
+ const summary = tc.resultSummary ? ` ${tc.resultSummary}` : '';
427
+ const elapsed = tc.startedAt ? ` · ${Math.floor((Date.now() - tc.startedAt) / 1000)}s` : '';
428
+ const toolLine = `${icon} ${tc.toolName}\x1b[0m \x1b[2m${tc.args ?? ''}${summary}${elapsed}\x1b[0m`;
429
+ process.stdout.write(toolLine.slice(0, textWidth) + '\n');
430
+ }
431
+ }
432
+ }
433
+ /** Convert a CellGrid to sequential ANSI output (line-by-line, no absolute positioning) */
434
+ renderGridToAnsi(grid) {
435
+ const parts = [];
436
+ for (let r = 0; r < grid.height; r++) {
437
+ // Find last non-space cell to avoid writing trailing whitespace
438
+ let lastCol = grid.width - 1;
439
+ while (lastCol >= 0 && grid.cells[r][lastCol].char === ' ' && !grid.cells[r][lastCol].style.bg) {
440
+ lastCol--;
441
+ }
442
+ let lastStyle = '';
443
+ for (let c = 0; c <= lastCol; c++) {
444
+ const cell = grid.cells[r][c];
445
+ const sgr = styleToSGR(cell.style);
446
+ if (sgr !== lastStyle) {
447
+ parts.push(sgr);
448
+ lastStyle = sgr;
449
+ }
450
+ parts.push(cell.char);
451
+ }
452
+ parts.push('\x1b[0m\x1b[K'); // reset + erase to end of line (clear stale content)
453
+ if (r < grid.height - 1)
454
+ parts.push('\n');
455
+ }
456
+ return parts.join('');
457
+ }
450
458
  render() {
451
459
  const w = process.stdout.columns ?? 80;
452
460
  const h = process.stdout.rows ?? 24;
453
- // Resize if needed
454
- if (w !== this.current.width || h !== this.current.height) {
455
- this.current = new CellGrid(w, h);
456
- this.previous = new CellGrid(w, h); // force full repaint
461
+ // 1. Move UP from cursor to start of old live area, then erase below.
462
+ // lastLiveLines = cursor.cursorRow from last frame (distance from cursor to live area start).
463
+ // Using relative movement so it works correctly even after terminal scroll.
464
+ let eraseCmd = '';
465
+ if (this.lastLiveLines > 0) {
466
+ eraseCmd += `\x1b[${this.lastLiveLines}A`;
457
467
  }
458
- this.current.clear();
459
- const cursor = rasterize(this.state, this.current);
460
- const output = diff(this.previous, this.current);
461
- if (output) {
462
- syncWrite(output);
468
+ eraseCmd += '\r\x1b[J'; // column 0, erase to end of screen
469
+ syncWrite(eraseCmd);
470
+ // 2. Flush completed messages to scrollback (sequential, cursor moves down)
471
+ this.flushMessages();
472
+ // 3. Calculate and render new live area
473
+ const liveHeight = Math.min(h, this.calculateLiveHeight());
474
+ if (w !== this.current.width || liveHeight !== this.current.height) {
475
+ this.current = new CellGrid(w, liveHeight);
463
476
  }
464
- // Show cursor at input position
465
- moveCursor(cursor.cursorRow, cursor.cursorCol);
477
+ this.current.clear();
478
+ const cursor = rasterizeLive(this.state, this.current);
479
+ // 4. Write live area sequentially, then position cursor using relative movement.
480
+ // After grid output, cursor may be in pending-wrap state at end of last row.
481
+ // \r resolves wrap and goes to column 0, then we move up to cursor row.
482
+ const liveOutput = this.renderGridToAnsi(this.current);
483
+ const upFromEnd = liveHeight - 1 - cursor.cursorRow;
484
+ syncWrite(liveOutput + '\r' +
485
+ (upFromEnd > 0 ? `\x1b[${upFromEnd}A` : '') +
486
+ `\x1b[${cursor.cursorCol + 1}G` // move to column (1-indexed)
487
+ );
466
488
  showCursor();
467
- // Swap buffers
468
- this.previous = this.current.clone();
489
+ // Track cursor's distance from live area start (NOT total height).
490
+ // Next frame moves up by this amount to get back to live area start.
491
+ this.lastLiveLines = cursor.cursorRow;
492
+ }
493
+ /** Estimate the height needed for the live area */
494
+ calculateLiveHeight() {
495
+ let rows = 3; // border + input + hints (minimum)
496
+ // Banner only shown when no messages and not loading (must match rasterizeLive condition)
497
+ if (this.state.bannerLines && this.state.messages.length === 0 && !this.state.loading) {
498
+ rows += this.state.bannerLines.length + 1;
499
+ }
500
+ if (this.state.loading && this.state.streamingText)
501
+ rows += Math.min(this.state.streamingText.split('\n').length, 10);
502
+ if (this.state.thinkingText)
503
+ rows += this.state.thinkingExpanded ? 10 : 1;
504
+ if (!this.state.loading && this.state.lastThinkingSummary)
505
+ rows += 1;
506
+ if (this.state.loading && !this.state.streamingText && !this.state.thinkingText)
507
+ rows += 1; // spinner
508
+ if (this.state.errorText)
509
+ rows += 1;
510
+ for (const [, tc] of this.state.toolCalls) {
511
+ rows += 2; // header + possible description/agent line
512
+ if (tc.status === 'running' && tc.liveOutput)
513
+ rows += Math.min(tc.liveOutput.length, 3);
514
+ }
515
+ if (this.state.contextWarning)
516
+ rows += 1;
517
+ rows += Math.min(this.state.notifications.length, 2); // toast notifications
518
+ if (this.state.statusLine)
519
+ rows += 1;
520
+ rows += this.state.autocomplete.length;
521
+ if (this.state.permissionBox) {
522
+ rows += 3;
523
+ if (this.state.permissionDiffVisible && this.state.permissionDiffInfo)
524
+ rows += 15;
525
+ }
526
+ if (this.state.questionPrompt)
527
+ rows += 3 + (this.state.questionPrompt.options?.length ?? 0);
528
+ if (this.state.companionLines)
529
+ rows = Math.max(rows, this.state.companionLines.length + 2);
530
+ const inputLineCount = Math.min(5, (this.state.inputText.match(/\n/g)?.length ?? 0) + 1);
531
+ rows += inputLineCount - 1;
532
+ const h = process.stdout.rows ?? 24;
533
+ // On initial screen with banner, fill the terminal
534
+ if (this.state.bannerLines && this.state.messages.length === 0 && !this.state.loading) {
535
+ return h;
536
+ }
537
+ return Math.min(rows, Math.floor(h * 0.7)); // never exceed 70% of terminal
469
538
  }
470
539
  handleResize() {
471
- // Force full repaint on resize
472
- this.previous = new CellGrid(1, 1);
473
540
  this.scheduleRender();
474
541
  }
475
542
  }