erosolar-cli 2.1.172 → 2.1.173

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 (213) hide show
  1. package/README.md +1 -1
  2. package/agents/erosolar-code.rules.json +2 -2
  3. package/agents/general.rules.json +21 -3
  4. package/dist/capabilities/askUserCapability.js +1 -1
  5. package/dist/capabilities/askUserCapability.js.map +1 -1
  6. package/dist/capabilities/statusCapability.js +2 -2
  7. package/dist/capabilities/statusCapability.js.map +1 -1
  8. package/dist/codex/capabilities/codexCoreCapability.d.ts +6 -0
  9. package/dist/codex/capabilities/codexCoreCapability.d.ts.map +1 -0
  10. package/dist/codex/capabilities/codexCoreCapability.js +516 -0
  11. package/dist/codex/capabilities/codexCoreCapability.js.map +1 -0
  12. package/dist/codex/fs.d.ts +4 -0
  13. package/dist/codex/fs.d.ts.map +1 -0
  14. package/dist/codex/fs.js +25 -0
  15. package/dist/codex/fs.js.map +1 -0
  16. package/dist/codex/persistence/planStore.d.ts +4 -0
  17. package/dist/codex/persistence/planStore.d.ts.map +1 -0
  18. package/dist/codex/persistence/planStore.js +59 -0
  19. package/dist/codex/persistence/planStore.js.map +1 -0
  20. package/dist/codex/pluginAllowlist.d.ts +4 -0
  21. package/dist/codex/pluginAllowlist.d.ts.map +1 -0
  22. package/dist/codex/pluginAllowlist.js +14 -0
  23. package/dist/codex/pluginAllowlist.js.map +1 -0
  24. package/dist/codex/types.d.ts +21 -0
  25. package/dist/codex/types.d.ts.map +1 -0
  26. package/dist/codex/types.js +62 -0
  27. package/dist/codex/types.js.map +1 -0
  28. package/dist/contracts/agent-schemas.json +5 -5
  29. package/dist/core/agent.d.ts +83 -24
  30. package/dist/core/agent.d.ts.map +1 -1
  31. package/dist/core/agent.js +499 -248
  32. package/dist/core/agent.js.map +1 -1
  33. package/dist/core/preferences.d.ts +1 -0
  34. package/dist/core/preferences.d.ts.map +1 -1
  35. package/dist/core/preferences.js +8 -1
  36. package/dist/core/preferences.js.map +1 -1
  37. package/dist/core/reliabilityPrompt.d.ts +9 -0
  38. package/dist/core/reliabilityPrompt.d.ts.map +1 -0
  39. package/dist/core/reliabilityPrompt.js +31 -0
  40. package/dist/core/reliabilityPrompt.js.map +1 -0
  41. package/dist/core/schemaValidator.js +3 -3
  42. package/dist/core/schemaValidator.js.map +1 -1
  43. package/dist/core/toolPreconditions.d.ts +0 -11
  44. package/dist/core/toolPreconditions.d.ts.map +1 -1
  45. package/dist/core/toolPreconditions.js +33 -164
  46. package/dist/core/toolPreconditions.js.map +1 -1
  47. package/dist/core/toolRuntime.d.ts.map +1 -1
  48. package/dist/core/toolRuntime.js +9 -114
  49. package/dist/core/toolRuntime.js.map +1 -1
  50. package/dist/core/updateChecker.d.ts +61 -1
  51. package/dist/core/updateChecker.d.ts.map +1 -1
  52. package/dist/core/updateChecker.js +147 -3
  53. package/dist/core/updateChecker.js.map +1 -1
  54. package/dist/headless/evalMode.d.ts.map +1 -1
  55. package/dist/headless/evalMode.js +6 -0
  56. package/dist/headless/evalMode.js.map +1 -1
  57. package/dist/headless/headlessApp.d.ts.map +1 -1
  58. package/dist/headless/headlessApp.js +6 -39
  59. package/dist/headless/headlessApp.js.map +1 -1
  60. package/dist/mcp/sseClient.d.ts +4 -1
  61. package/dist/mcp/sseClient.d.ts.map +1 -1
  62. package/dist/mcp/sseClient.js +36 -2
  63. package/dist/mcp/sseClient.js.map +1 -1
  64. package/dist/mcp/stdioClient.d.ts +4 -1
  65. package/dist/mcp/stdioClient.d.ts.map +1 -1
  66. package/dist/mcp/stdioClient.js +41 -1
  67. package/dist/mcp/stdioClient.js.map +1 -1
  68. package/dist/mcp/toolBridge.d.ts +3 -0
  69. package/dist/mcp/toolBridge.d.ts.map +1 -1
  70. package/dist/mcp/toolBridge.js +2 -2
  71. package/dist/mcp/toolBridge.js.map +1 -1
  72. package/dist/mcp/types.d.ts +18 -0
  73. package/dist/mcp/types.d.ts.map +1 -1
  74. package/dist/plugins/tools/nodeDefaults.d.ts.map +1 -1
  75. package/dist/plugins/tools/nodeDefaults.js +0 -2
  76. package/dist/plugins/tools/nodeDefaults.js.map +1 -1
  77. package/dist/providers/openaiResponsesProvider.d.ts.map +1 -1
  78. package/dist/providers/openaiResponsesProvider.js +79 -74
  79. package/dist/providers/openaiResponsesProvider.js.map +1 -1
  80. package/dist/runtime/agentController.d.ts.map +1 -1
  81. package/dist/runtime/agentController.js +6 -3
  82. package/dist/runtime/agentController.js.map +1 -1
  83. package/dist/runtime/agentSession.d.ts +0 -2
  84. package/dist/runtime/agentSession.d.ts.map +1 -1
  85. package/dist/runtime/agentSession.js +2 -2
  86. package/dist/runtime/agentSession.js.map +1 -1
  87. package/dist/shell/interactiveShell.d.ts +25 -18
  88. package/dist/shell/interactiveShell.d.ts.map +1 -1
  89. package/dist/shell/interactiveShell.js +345 -291
  90. package/dist/shell/interactiveShell.js.map +1 -1
  91. package/dist/shell/shellApp.d.ts.map +1 -1
  92. package/dist/shell/shellApp.js +15 -8
  93. package/dist/shell/shellApp.js.map +1 -1
  94. package/dist/shell/systemPrompt.d.ts.map +1 -1
  95. package/dist/shell/systemPrompt.js +4 -15
  96. package/dist/shell/systemPrompt.js.map +1 -1
  97. package/dist/subagents/taskRunner.js +2 -1
  98. package/dist/subagents/taskRunner.js.map +1 -1
  99. package/dist/tools/bashTools.d.ts.map +1 -1
  100. package/dist/tools/bashTools.js +101 -8
  101. package/dist/tools/bashTools.js.map +1 -1
  102. package/dist/tools/diffUtils.d.ts +8 -2
  103. package/dist/tools/diffUtils.d.ts.map +1 -1
  104. package/dist/tools/diffUtils.js +72 -13
  105. package/dist/tools/diffUtils.js.map +1 -1
  106. package/dist/tools/grepTools.d.ts.map +1 -1
  107. package/dist/tools/grepTools.js +10 -2
  108. package/dist/tools/grepTools.js.map +1 -1
  109. package/dist/tools/planningTools.d.ts +0 -10
  110. package/dist/tools/planningTools.d.ts.map +1 -1
  111. package/dist/tools/planningTools.js +0 -16
  112. package/dist/tools/planningTools.js.map +1 -1
  113. package/dist/tools/searchTools.d.ts.map +1 -1
  114. package/dist/tools/searchTools.js +4 -2
  115. package/dist/tools/searchTools.js.map +1 -1
  116. package/dist/ui/PromptController.d.ts +7 -4
  117. package/dist/ui/PromptController.d.ts.map +1 -1
  118. package/dist/ui/PromptController.js +4 -7
  119. package/dist/ui/PromptController.js.map +1 -1
  120. package/dist/ui/ShellUIAdapter.d.ts +286 -28
  121. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  122. package/dist/ui/ShellUIAdapter.js +1485 -121
  123. package/dist/ui/ShellUIAdapter.js.map +1 -1
  124. package/dist/ui/UnifiedUIController.d.ts +80 -0
  125. package/dist/ui/UnifiedUIController.d.ts.map +1 -0
  126. package/dist/ui/UnifiedUIController.js +211 -0
  127. package/dist/ui/UnifiedUIController.js.map +1 -0
  128. package/dist/ui/UnifiedUIRenderer.d.ts +102 -46
  129. package/dist/ui/UnifiedUIRenderer.d.ts.map +1 -1
  130. package/dist/ui/UnifiedUIRenderer.js +680 -610
  131. package/dist/ui/UnifiedUIRenderer.js.map +1 -1
  132. package/dist/ui/animatedStatus.d.ts +128 -6
  133. package/dist/ui/animatedStatus.d.ts.map +1 -1
  134. package/dist/ui/animatedStatus.js +383 -50
  135. package/dist/ui/animatedStatus.js.map +1 -1
  136. package/dist/ui/animation/AnimationScheduler.d.ts +192 -0
  137. package/dist/ui/animation/AnimationScheduler.d.ts.map +1 -0
  138. package/dist/ui/animation/AnimationScheduler.js +432 -0
  139. package/dist/ui/animation/AnimationScheduler.js.map +1 -0
  140. package/dist/ui/display.d.ts +179 -25
  141. package/dist/ui/display.d.ts.map +1 -1
  142. package/dist/ui/display.js +678 -96
  143. package/dist/ui/display.js.map +1 -1
  144. package/dist/ui/inPlaceUpdater.d.ts +181 -0
  145. package/dist/ui/inPlaceUpdater.d.ts.map +1 -0
  146. package/dist/ui/inPlaceUpdater.js +515 -0
  147. package/dist/ui/inPlaceUpdater.js.map +1 -0
  148. package/dist/ui/interrupts/InterruptManager.d.ts +142 -0
  149. package/dist/ui/interrupts/InterruptManager.d.ts.map +1 -0
  150. package/dist/ui/interrupts/InterruptManager.js +439 -0
  151. package/dist/ui/interrupts/InterruptManager.js.map +1 -0
  152. package/dist/ui/layout.d.ts +0 -1
  153. package/dist/ui/layout.d.ts.map +1 -1
  154. package/dist/ui/layout.js +0 -12
  155. package/dist/ui/layout.js.map +1 -1
  156. package/dist/ui/orchestration/StatusOrchestrator.d.ts +1 -1
  157. package/dist/ui/orchestration/StatusOrchestrator.js +1 -1
  158. package/dist/ui/orchestration/UIUpdateCoordinator.d.ts +61 -7
  159. package/dist/ui/orchestration/UIUpdateCoordinator.d.ts.map +1 -1
  160. package/dist/ui/orchestration/UIUpdateCoordinator.js +232 -20
  161. package/dist/ui/orchestration/UIUpdateCoordinator.js.map +1 -1
  162. package/dist/ui/shortcutsHelp.d.ts.map +1 -1
  163. package/dist/ui/shortcutsHelp.js +0 -1
  164. package/dist/ui/shortcutsHelp.js.map +1 -1
  165. package/dist/ui/telemetry/ResponseTracker.d.ts +22 -0
  166. package/dist/ui/telemetry/ResponseTracker.d.ts.map +1 -0
  167. package/dist/ui/telemetry/ResponseTracker.js +60 -0
  168. package/dist/ui/telemetry/ResponseTracker.js.map +1 -0
  169. package/dist/ui/telemetry/UITelemetry.d.ts +181 -0
  170. package/dist/ui/telemetry/UITelemetry.d.ts.map +1 -0
  171. package/dist/ui/telemetry/UITelemetry.js +446 -0
  172. package/dist/ui/telemetry/UITelemetry.js.map +1 -0
  173. package/dist/ui/unified/index.d.ts +28 -1
  174. package/dist/ui/unified/index.d.ts.map +1 -1
  175. package/dist/ui/unified/index.js +41 -2
  176. package/dist/ui/unified/index.js.map +1 -1
  177. package/dist/ui/unified/layout.d.ts +12 -0
  178. package/dist/ui/unified/layout.d.ts.map +1 -0
  179. package/dist/ui/unified/layout.js +96 -0
  180. package/dist/ui/unified/layout.js.map +1 -0
  181. package/package.json +1 -2
  182. package/dist/StringUtils.d.ts +0 -8
  183. package/dist/StringUtils.d.ts.map +0 -1
  184. package/dist/StringUtils.js +0 -11
  185. package/dist/StringUtils.js.map +0 -1
  186. package/dist/core/aiFlowSupervisor.d.ts +0 -44
  187. package/dist/core/aiFlowSupervisor.d.ts.map +0 -1
  188. package/dist/core/aiFlowSupervisor.js +0 -299
  189. package/dist/core/aiFlowSupervisor.js.map +0 -1
  190. package/dist/core/cliTestHarness.d.ts +0 -200
  191. package/dist/core/cliTestHarness.d.ts.map +0 -1
  192. package/dist/core/cliTestHarness.js +0 -549
  193. package/dist/core/cliTestHarness.js.map +0 -1
  194. package/dist/core/testUtils.d.ts +0 -121
  195. package/dist/core/testUtils.d.ts.map +0 -1
  196. package/dist/core/testUtils.js +0 -235
  197. package/dist/core/testUtils.js.map +0 -1
  198. package/dist/core/toolValidation.d.ts +0 -116
  199. package/dist/core/toolValidation.d.ts.map +0 -1
  200. package/dist/core/toolValidation.js +0 -282
  201. package/dist/core/toolValidation.js.map +0 -1
  202. package/dist/ui/planOverlay.d.ts +0 -28
  203. package/dist/ui/planOverlay.d.ts.map +0 -1
  204. package/dist/ui/planOverlay.js +0 -156
  205. package/dist/ui/planOverlay.js.map +0 -1
  206. package/dist/ui/streamingFormatter.d.ts +0 -30
  207. package/dist/ui/streamingFormatter.d.ts.map +0 -1
  208. package/dist/ui/streamingFormatter.js +0 -91
  209. package/dist/ui/streamingFormatter.js.map +0 -1
  210. package/dist/utils/errorUtils.d.ts +0 -16
  211. package/dist/utils/errorUtils.d.ts.map +0 -1
  212. package/dist/utils/errorUtils.js +0 -66
  213. package/dist/utils/errorUtils.js.map +0 -1
@@ -10,22 +10,13 @@
10
10
  */
11
11
  import * as readline from 'node:readline';
12
12
  import { EventEmitter } from 'node:events';
13
- import { homedir } from 'node:os';
14
- import { theme } from './theme.js';
13
+ import { theme, spinnerFrames } from './theme.js';
15
14
  import { isPlainOutputMode } from './outputMode.js';
16
- import { renderDivider } from './layout.js';
17
- import { colorizeActivity, createFrameTicker, formatElapsed, formatTinyProgressBar, formatTokenDelta, } from './animatedStatus.js';
18
15
  const ESC = {
19
- HIDE_CURSOR: '\x1b[?25l',
20
16
  SHOW_CURSOR: '\x1b[?25h',
21
- CLEAR_SCREEN: '\x1b[2J',
22
17
  CLEAR_LINE: '\x1b[2K',
23
- HOME: '\x1b[H',
24
18
  ENABLE_BRACKETED_PASTE: '\x1b[?2004h',
25
19
  DISABLE_BRACKETED_PASTE: '\x1b[?2004l',
26
- TO: (row, col) => `\x1b[${row};${col}H`,
27
- TO_COL: (col) => `\x1b[${col}G`,
28
- ERASE_DOWN: '\x1b[J',
29
20
  REVERSE: '\x1b[7m',
30
21
  RESET: '\x1b[0m',
31
22
  };
@@ -37,9 +28,7 @@ export class UnifiedUIRenderer extends EventEmitter {
37
28
  rl;
38
29
  plainMode;
39
30
  interactive;
40
- rows = 24;
41
31
  cols = 80;
42
- lastRenderWidth = null;
43
32
  eventQueue = [];
44
33
  isProcessingQueue = false;
45
34
  buffer = '';
@@ -49,37 +38,33 @@ export class UnifiedUIRenderer extends EventEmitter {
49
38
  suggestions = [];
50
39
  suggestionIndex = -1;
51
40
  availableCommands = [];
52
- hotkeysInToggleLine = new Set();
53
41
  collapsedPaste = null;
54
42
  mode = 'idle';
43
+ streamingStartTime = null;
55
44
  statusMessage = null;
56
45
  statusOverride = null;
57
46
  statusStreaming = null;
47
+ // Animated UI components
48
+ spinnerFrame = 0;
49
+ spinnerInterval = null;
50
+ // Activity/status tracking
51
+ activityMessage = null;
52
+ streamingTokens = 0;
58
53
  statusMeta = {};
59
54
  toggleState = {
60
55
  verificationEnabled: false,
61
- autoContinueEnabled: false,
62
56
  criticalApprovalMode: 'auto',
63
57
  };
64
58
  // ------------ Helpers ------------
65
- formatHotkey(combo) {
66
- if (!combo?.trim())
67
- return null;
68
- return combo.trim().toUpperCase();
69
- }
70
59
  lastPromptEvent = null;
71
60
  promptHeight = 0;
72
- lastOverlayHeight = 0;
73
- lastPromptIndex = 0;
74
- overlayBottomPadding = 1;
75
- inlinePanel = [];
76
- persistentPanel = [];
77
- overlayInvalidated = false;
78
- hasConversationContent = false;
79
61
  isPromptActive = false;
62
+ inputRenderOffset = 0;
80
63
  plainPasteIdleMs = 24;
81
64
  plainPasteWindowMs = 60;
82
65
  plainPasteTriggerChars = 24;
66
+ cursorVisibleColumn = 1;
67
+ cursorVisibleRow = 0;
83
68
  inBracketedPaste = false;
84
69
  pasteBuffer = '';
85
70
  inPlainPaste = false;
@@ -90,15 +75,6 @@ export class UnifiedUIRenderer extends EventEmitter {
90
75
  plainRecentChunks = [];
91
76
  lastRenderedEventKey = null;
92
77
  lastOutputEndedWithNewline = true;
93
- hasRenderedPrompt = false;
94
- hasEverRenderedOverlay = false; // Track if we've ever rendered to prevent first-render scrollback pollution
95
- lastOverlay = null;
96
- allowPromptRender = true;
97
- streamingStart = null;
98
- activityInterval = null;
99
- activityTicker = createFrameTicker('sparkle');
100
- renderedContextPercent = null;
101
- lastToolResult = null;
102
78
  inputCapture = null;
103
79
  constructor(output = process.stdout, input = process.stdin, options) {
104
80
  super();
@@ -116,7 +92,7 @@ export class UnifiedUIRenderer extends EventEmitter {
116
92
  this.rl.setPrompt('');
117
93
  this.updateTerminalSize();
118
94
  this.output.on('resize', () => {
119
- if (!this.plainMode) {
95
+ if (this.interactive) {
120
96
  this.updateTerminalSize();
121
97
  this.renderPrompt();
122
98
  }
@@ -127,47 +103,30 @@ export class UnifiedUIRenderer extends EventEmitter {
127
103
  if (!this.interactive) {
128
104
  return;
129
105
  }
130
- if (!this.plainMode) {
131
- // If an overlay was already rendered before initialization (e.g., banner emitted early),
132
- // clear it so initialize() doesn't stack a second control bar in scrollback.
133
- if (this.hasRenderedPrompt || this.lastOverlay) {
134
- this.clearPromptArea();
135
- }
136
- this.write(ESC.ENABLE_BRACKETED_PASTE);
137
- this.updateTerminalSize();
138
- this.hasRenderedPrompt = false;
139
- this.lastOutputEndedWithNewline = true;
140
- // Don't render prompt immediately - wait for banner/content to be added first.
141
- // The prompt will render after the event queue processes the welcome banner.
142
- // This prevents the prompt from appearing at the bottom before the banner shows.
143
- this.write(ESC.SHOW_CURSOR);
144
- return;
145
- }
146
- // Plain mode: minimal setup, still render a simple prompt line
106
+ this.write(ESC.ENABLE_BRACKETED_PASTE);
147
107
  this.updateTerminalSize();
148
- this.hasRenderedPrompt = false;
149
108
  this.lastOutputEndedWithNewline = true;
109
+ this.write(ESC.SHOW_CURSOR);
150
110
  this.renderPrompt();
151
111
  }
152
112
  cleanup() {
153
113
  this.cancelInputCapture(new Error('Renderer disposed'));
154
114
  this.cancelPlainPasteCapture();
155
- this.stopActivityTimer();
115
+ if (this.spinnerInterval) {
116
+ clearInterval(this.spinnerInterval);
117
+ this.spinnerInterval = null;
118
+ }
156
119
  if (!this.interactive) {
157
120
  this.rl.close();
158
121
  return;
159
122
  }
160
- if (!this.plainMode) {
161
- this.clearPromptArea();
162
- this.write(ESC.DISABLE_BRACKETED_PASTE);
163
- this.write(ESC.SHOW_CURSOR);
164
- this.write('\n');
165
- }
123
+ this.write(ESC.DISABLE_BRACKETED_PASTE);
124
+ this.write(ESC.SHOW_CURSOR);
125
+ this.write('\n');
166
126
  if (this.input.isTTY) {
167
127
  this.input.setRawMode(false);
168
128
  }
169
129
  this.rl.close();
170
- this.lastOverlay = null;
171
130
  }
172
131
  // ------------ Input handling ------------
173
132
  setupInputHandlers() {
@@ -208,15 +167,30 @@ export class UnifiedUIRenderer extends EventEmitter {
208
167
  this.emit('toggle-critical-approval');
209
168
  return;
210
169
  }
170
+ if (key.ctrl && key.shift && key.name?.toLowerCase() === 'n') {
171
+ this.emit('toggle-network');
172
+ return;
173
+ }
211
174
  if (key.ctrl && key.name === 'c') {
212
- const hadBuffer = this.buffer.length > 0;
213
- this.emit('ctrl-c', { hadBuffer });
214
- if (hadBuffer) {
215
- this.clearBuffer();
175
+ // Three-stage Ctrl+C behavior:
176
+ // 1. Clear chat box if it has text
177
+ // 2. Interrupt/pause the AI if streaming
178
+ // 3. Quit the CLI if already idle
179
+ if (this.buffer.length > 0) {
180
+ // Stage 1: Clear the input buffer
181
+ this.buffer = '';
182
+ this.cursor = 0;
183
+ this.renderPrompt();
184
+ this.emitInputChange();
216
185
  }
217
186
  else if (this.mode === 'streaming') {
187
+ // Stage 2: Interrupt the AI run
218
188
  this.emit('interrupt');
219
189
  }
190
+ else {
191
+ // Stage 3: Quit the CLI (emit exit signal)
192
+ this.emit('exit');
193
+ }
220
194
  return;
221
195
  }
222
196
  if (key.ctrl && key.name === 'd') {
@@ -225,12 +199,6 @@ export class UnifiedUIRenderer extends EventEmitter {
225
199
  }
226
200
  return;
227
201
  }
228
- if (key.ctrl && key.name === 'o') {
229
- if (!this.expandLastToolResult()) {
230
- this.emit('expand-tool-result');
231
- }
232
- return;
233
- }
234
202
  if (key.ctrl && key.name === 'u') {
235
203
  this.clearBuffer();
236
204
  return;
@@ -241,6 +209,11 @@ export class UnifiedUIRenderer extends EventEmitter {
241
209
  return;
242
210
  }
243
211
  }
212
+ // Ctrl+O: Expand last tool result
213
+ if (key.ctrl && key.name === 'o') {
214
+ this.emit('expand-tool-result');
215
+ return;
216
+ }
244
217
  if (key.name === 'return' || key.name === 'enter') {
245
218
  if (this.collapsedPaste) {
246
219
  this.expandCollapsedPaste();
@@ -250,9 +223,12 @@ export class UnifiedUIRenderer extends EventEmitter {
250
223
  // If a slash command suggestion is highlighted, pressing Enter submits it immediately
251
224
  if (this.applySuggestion(true))
252
225
  return;
253
- // If buffer starts with '/' and the first suggestion exists, submit it
226
+ // Fallback: if buffer starts with '/' and suggestions exist, use the selected/first one
254
227
  if (this.buffer.startsWith('/') && this.suggestions.length > 0) {
255
- this.buffer = this.suggestions[this.suggestionIndex >= 0 ? this.suggestionIndex : 0]?.command ?? this.buffer;
228
+ const safeIndex = this.suggestionIndex >= 0 && this.suggestionIndex < this.suggestions.length
229
+ ? this.suggestionIndex
230
+ : 0;
231
+ this.buffer = this.suggestions[safeIndex]?.command ?? this.buffer;
256
232
  }
257
233
  this.submitText(this.buffer);
258
234
  return;
@@ -298,18 +274,6 @@ export class UnifiedUIRenderer extends EventEmitter {
298
274
  }
299
275
  return;
300
276
  }
301
- if (key.name === 'home') {
302
- this.cursor = 0;
303
- this.renderPrompt();
304
- this.emitInputChange();
305
- return;
306
- }
307
- if (key.name === 'end') {
308
- this.cursor = this.buffer.length;
309
- this.renderPrompt();
310
- this.emitInputChange();
311
- return;
312
- }
313
277
  if (key.name === 'up') {
314
278
  if (this.navigateSuggestions(-1)) {
315
279
  return;
@@ -574,6 +538,7 @@ export class UnifiedUIRenderer extends EventEmitter {
574
538
  this.inputCapture = null;
575
539
  this.buffer = '';
576
540
  this.cursor = 0;
541
+ this.inputRenderOffset = 0;
577
542
  this.resetSuggestions();
578
543
  this.renderPrompt();
579
544
  this.emitInputChange();
@@ -640,7 +605,11 @@ export class UnifiedUIRenderer extends EventEmitter {
640
605
  if (!this.buffer.startsWith('/') || this.suggestions.length === 0) {
641
606
  return false;
642
607
  }
643
- const selected = this.suggestions[this.suggestionIndex] ?? this.suggestions[0];
608
+ // Ensure suggestionIndex is valid, default to first item
609
+ const safeIndex = this.suggestionIndex >= 0 && this.suggestionIndex < this.suggestions.length
610
+ ? this.suggestionIndex
611
+ : 0;
612
+ const selected = this.suggestions[safeIndex];
644
613
  if (!selected) {
645
614
  return false;
646
615
  }
@@ -673,34 +642,13 @@ export class UnifiedUIRenderer extends EventEmitter {
673
642
  this.renderPrompt();
674
643
  return true;
675
644
  }
676
- isGarbageOutput(content) {
677
- const text = content.replace(/\u001B\[[0-9;]*m/gu, '').replace(/\s+/g, '');
678
- if (!text)
679
- return true;
680
- if (text.length <= 2 && !/[a-zA-Z0-9]/.test(text))
681
- return true;
682
- if (text.length < 6 && /^[\.\-_,;:~`'"!@#$%^&*+=|\\/]+$/.test(text))
683
- return true;
684
- return false;
685
- }
686
645
  // ------------ Event queue ------------
687
- addEvent(type, content, options) {
646
+ addEvent(type, content) {
688
647
  if (!content)
689
648
  return;
690
- if (this.isGarbageOutput(content))
691
- return;
692
649
  const normalized = this.normalizeEventType(type);
693
650
  if (!normalized)
694
651
  return;
695
- if (normalized === 'prompt' ||
696
- normalized === 'response' ||
697
- normalized === 'thought' ||
698
- normalized === 'stream' ||
699
- normalized === 'tool' ||
700
- normalized === 'build' ||
701
- normalized === 'test') {
702
- this.hasConversationContent = true;
703
- }
704
652
  if (this.plainMode) {
705
653
  const formatted = this.formatContent({
706
654
  type: normalized,
@@ -720,7 +668,6 @@ export class UnifiedUIRenderer extends EventEmitter {
720
668
  rawType: type,
721
669
  content,
722
670
  timestamp: Date.now(),
723
- isCompacted: options?.compact === true,
724
671
  };
725
672
  // Priority queue: prompt events are inserted at the front to ensure immediate display
726
673
  // This guarantees user input is echoed before any async processing responses
@@ -759,23 +706,11 @@ export class UnifiedUIRenderer extends EventEmitter {
759
706
  const message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown renderer error';
760
707
  this.output.write(`\n[renderer] ${message}\n`);
761
708
  }
762
- // For prompt events, ensure the overlay is rendered immediately
763
- // This guarantees prompts are visible before async processing continues
764
- if (event.type === 'prompt') {
765
- if (this.output.isTTY) {
766
- this.allowPromptRender = true;
767
- this.renderPrompt();
768
- }
769
- // No delay for prompt events - render immediately
770
- }
771
- else {
709
+ if (event.type !== 'prompt') {
772
710
  await this.delay(1);
773
711
  }
774
712
  }
775
- // ALWAYS render prompt after queue completes to keep bottom UI persistent
776
- // This ensures status/toggles stay pinned and responses are fully rendered
777
- if (this.output.isTTY) {
778
- this.allowPromptRender = true;
713
+ if (this.output.isTTY && this.interactive) {
779
714
  this.renderPrompt();
780
715
  }
781
716
  }
@@ -790,28 +725,18 @@ export class UnifiedUIRenderer extends EventEmitter {
790
725
  */
791
726
  async flushEvents(timeoutMs = 250) {
792
727
  // Kick off processing if idle
793
- if (!this.plainMode && !this.isProcessingQueue && this.eventQueue.length > 0) {
728
+ if (!this.isProcessingQueue && this.eventQueue.length > 0) {
794
729
  void this.processQueue();
795
730
  }
796
731
  const start = Date.now();
797
732
  while ((this.isProcessingQueue || this.eventQueue.length > 0) && Date.now() - start < timeoutMs) {
798
733
  await this.delay(5);
799
734
  }
800
- if (!this.plainMode && this.output.isTTY) {
801
- this.allowPromptRender = true;
735
+ if (this.output.isTTY && this.interactive) {
802
736
  this.renderPrompt();
803
737
  }
804
738
  }
805
739
  async renderEvent(event) {
806
- if (this.plainMode) {
807
- const formattedPlain = this.formatContent(event);
808
- if (formattedPlain) {
809
- const text = formattedPlain.endsWith('\n') ? formattedPlain : `${formattedPlain}\n`;
810
- this.output.write(text);
811
- this.lastOutputEndedWithNewline = text.endsWith('\n');
812
- }
813
- return;
814
- }
815
740
  const formatted = this.formatContent(event);
816
741
  if (!formatted)
817
742
  return;
@@ -824,10 +749,9 @@ export class UnifiedUIRenderer extends EventEmitter {
824
749
  if (event.type !== 'prompt') {
825
750
  this.lastRenderedEventKey = signature;
826
751
  }
827
- if (this.promptHeight > 0 || this.lastOverlay) {
828
- this.clearPromptArea();
752
+ if (this.isPromptActive) {
753
+ this.clearPromptArea(true);
829
754
  }
830
- this.isPromptActive = false;
831
755
  if (event.type !== 'stream' && !this.lastOutputEndedWithNewline && formatted.trim()) {
832
756
  // Keep scrollback ordering predictable when previous output ended mid-line
833
757
  this.output.write('\n');
@@ -835,10 +759,9 @@ export class UnifiedUIRenderer extends EventEmitter {
835
759
  }
836
760
  this.output.write(formatted);
837
761
  this.lastOutputEndedWithNewline = formatted.endsWith('\n');
838
- // Overlay must be re-anchored after new scrollback is written
839
- this.overlayInvalidated = true;
840
- // Don't re-render prompt after every event - wait for queue to finish
841
- // This prevents premature prompt rendering that cuts off responses
762
+ if (this.interactive && !this.plainMode) {
763
+ this.renderPrompt();
764
+ }
842
765
  }
843
766
  normalizeEventType(type) {
844
767
  switch (type) {
@@ -851,8 +774,9 @@ export class UnifiedUIRenderer extends EventEmitter {
851
774
  return 'stream';
852
775
  case 'tool':
853
776
  case 'tool-call':
854
- case 'tool-result':
855
777
  return 'tool';
778
+ case 'tool-result':
779
+ return 'tool-result';
856
780
  case 'build':
857
781
  return 'build';
858
782
  case 'test':
@@ -868,83 +792,372 @@ export class UnifiedUIRenderer extends EventEmitter {
868
792
  }
869
793
  }
870
794
  formatContent(event) {
871
- const bullet = '⏺';
795
+ const bullet = '⏺'; // Claude Code uses plain bullet, no color
796
+ // Compacted blocks already have separator and formatting
872
797
  if (event.isCompacted) {
873
798
  return event.content;
874
799
  }
875
800
  if (event.rawType === 'banner') {
801
+ // Banners display without bullet prefix
876
802
  const lines = event.content.split('\n').map(line => line.trimEnd());
877
803
  return `${lines.join('\n')}\n`;
878
804
  }
805
+ // Compact, user-friendly formatting
879
806
  switch (event.type) {
880
807
  case 'prompt':
881
- return `\n> ${event.content}\n`;
808
+ // User prompt - just the text (prompt box handles styling)
809
+ return `${theme.primary('>')} ${event.content}\n`;
882
810
  case 'thought': {
883
- return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
884
- }
885
- case 'tool':
886
- if (event.rawType === 'tool-call') {
887
- return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
811
+ // Programmatic filter: reject content that looks like internal/garbage output
812
+ if (this.isGarbageOutput(event.content)) {
813
+ return '';
888
814
  }
889
- if (event.rawType === 'tool-result') {
890
- return this.formatToolResult(event.content, event.isCompacted);
891
- }
892
- return `\n${event.content}\n`;
815
+ // Strip any existing bullet prefix ( or ) and use consistent ⏺
816
+ const cleanContent = event.content.replace(/^[○⏺]\s*/, '');
817
+ return `⏺ ${cleanContent}\n`;
818
+ }
819
+ case 'tool': {
820
+ // Compact tool display: ⚡ToolName → result
821
+ const content = event.content.replace(/^[⏺⚙○]\s*/, '');
822
+ return this.formatCompactToolCall(content);
823
+ }
824
+ case 'tool-result': {
825
+ // Inline result: └─ summary
826
+ return this.formatCompactToolResult(event.content);
827
+ }
893
828
  case 'build':
894
- return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
829
+ return `${bullet} ${theme.warning('Build')} ${theme.ui.muted('→')} ${event.content}\n`;
895
830
  case 'test':
896
- return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
831
+ return `${bullet} ${theme.info('Test')} ${theme.ui.muted('→')} ${event.content}\n`;
897
832
  case 'stream':
898
833
  return event.content;
899
834
  case 'response':
900
835
  default: {
901
- return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
836
+ // Programmatic filter: reject content that looks like internal/garbage output
837
+ if (this.isGarbageOutput(event.content)) {
838
+ return '';
839
+ }
840
+ // Clean response without excessive bullets
841
+ return this.formatCompactResponse(event.content);
902
842
  }
903
843
  }
904
844
  }
905
- formatBulletBlock(content, bullet) {
906
- const width = Math.max(24, this.safeWidth() - 4);
907
- const wrapped = this.wrapText(content, width);
908
- return wrapped
909
- .map((line, index) => (index === 0 ? `${bullet} ${line}` : ` ${line}`))
910
- .join('\n');
911
- }
912
- formatToolResult(content, compacted) {
913
- const prefix = '';
914
- if (compacted) {
915
- return `\n${prefix} ${content}\n`;
845
+ /**
846
+ * Programmatic garbage detection - checks if content looks like internal/system output
847
+ * that shouldn't be shown to users. Uses structural checks, not pattern matching.
848
+ */
849
+ isGarbageOutput(content) {
850
+ if (!content || content.trim().length === 0)
851
+ return true;
852
+ // Structural check: content starting with < that isn't valid markdown/code
853
+ if (content.startsWith('<') && !content.startsWith('<http') && !content.startsWith('<!')) {
854
+ return true;
916
855
  }
917
- const width = Math.max(24, this.safeWidth() - 4);
918
- const wrapped = this.wrapText(content, width);
919
- const body = wrapped
920
- .map((line, index) => (index === 0 ? `${prefix} ${line}` : ` ${line}`))
921
- .join('\n');
922
- return `\n${body}\n`;
923
- }
924
- wrapText(content, width) {
925
- if (!content) {
926
- return [''];
856
+ // Structural check: contains "to=functions." or "to=tools." (internal routing)
857
+ if (content.includes('to=functions.') || content.includes('to=tools.')) {
858
+ return true;
927
859
  }
928
- const lines = [];
929
- const rawLines = content.split('\n');
930
- for (const rawLine of rawLines) {
931
- const words = rawLine.split(/(\s+)/);
932
- let current = '';
860
+ // Structural check: looks like internal instruction (quoted system text)
861
+ if (content.startsWith('"') && content.includes('block') && content.includes('tool')) {
862
+ return true;
863
+ }
864
+ // Structural check: very short content that's just timing info
865
+ if (content.length < 30 && /elapsed|seconds?|ms\b/i.test(content)) {
866
+ return true;
867
+ }
868
+ // Structural check: gibberish - high ratio of non-word characters
869
+ const alphaCount = (content.match(/[a-zA-Z]/g) || []).length;
870
+ const totalCount = content.replace(/\s/g, '').length;
871
+ if (totalCount > 20 && alphaCount / totalCount < 0.5) {
872
+ return true; // Less than 50% letters = likely garbage
873
+ }
874
+ return false;
875
+ }
876
+ /**
877
+ * Format text in Claude Code style: ⏺ prefix with wrapped continuation lines
878
+ * Example:
879
+ * ⏺ The AI ran tools but gave no response. Need to fix
880
+ * the response handling. Let me check where the AI's
881
+ * text response should be displayed:
882
+ */
883
+ formatClaudeCodeBlock(content) {
884
+ const bullet = '⏺';
885
+ const maxWidth = Math.min(this.cols - 4, 56); // Leave room for prefix and margins
886
+ const lines = content.split('\n');
887
+ const result = [];
888
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
889
+ const line = lines[lineIdx];
890
+ if (!line.trim()) {
891
+ result.push('');
892
+ continue;
893
+ }
894
+ // Word-wrap each line
895
+ const words = line.split(/(\s+)/);
896
+ let currentLine = '';
933
897
  for (const word of words) {
934
- if (!word)
935
- continue;
936
- const candidate = current ? `${current}${word}` : word;
937
- if (this.visibleLength(candidate) > width && current) {
938
- lines.push(current.trimEnd());
939
- current = word.trimStart();
898
+ if ((currentLine + word).length > maxWidth && currentLine.trim()) {
899
+ // First line of this paragraph gets ⏺, rest get indent
900
+ const prefix = result.length === 0 && lineIdx === 0 ? `${bullet} ` : ' ';
901
+ result.push(`${prefix}${currentLine.trimEnd()}`);
902
+ currentLine = word.trimStart();
940
903
  }
941
904
  else {
942
- current = candidate;
905
+ currentLine += word;
943
906
  }
944
907
  }
945
- lines.push(current.trimEnd());
908
+ if (currentLine.trim()) {
909
+ const prefix = result.length === 0 && lineIdx === 0 ? `${bullet} ` : ' ';
910
+ result.push(`${prefix}${currentLine.trimEnd()}`);
911
+ }
946
912
  }
947
- return lines;
913
+ return result.join('\n') + '\n';
914
+ }
915
+ /**
916
+ * Format a tool call in Claude Code style:
917
+ * ⏺ Search(pattern: "foo", path: "src",
918
+ * output_mode: "content", head_limit: 30)
919
+ */
920
+ formatToolCall(content) {
921
+ const bullet = '⏺';
922
+ // Parse tool name and arguments
923
+ const match = content.match(/^(\w+)\((.*)\)$/s);
924
+ if (!match) {
925
+ // Simple format without args
926
+ const nameMatch = content.match(/^(\w+)/);
927
+ if (nameMatch) {
928
+ return `${bullet} ${theme.info(nameMatch[1])}\n`;
929
+ }
930
+ return `${bullet} ${content}\n`;
931
+ }
932
+ const toolName = match[1];
933
+ const argsStr = match[2];
934
+ const maxWidth = Math.min(this.cols - 4, 56);
935
+ // Format: ⏺ ToolName(args...)
936
+ const prefix = `${bullet} ${theme.info(toolName)}(`;
937
+ const prefixLen = toolName.length + 3; // "⏺ ToolName(" visible length
938
+ const indent = ' '.repeat(prefixLen + 4); // Extra indent for wrapped args
939
+ // Parse and format arguments
940
+ const args = this.parseToolArgs(argsStr);
941
+ if (args.length === 0) {
942
+ return `${prefix})\n`;
943
+ }
944
+ const lines = [];
945
+ let currentLine = prefix;
946
+ for (let i = 0; i < args.length; i++) {
947
+ const arg = args[i];
948
+ const argText = `${theme.ui.muted(arg.key + ':')} ${this.formatArgValue(arg.value)}`;
949
+ const separator = i < args.length - 1 ? ', ' : ')';
950
+ // Check if this arg fits on current line
951
+ const testLine = currentLine + argText + separator;
952
+ if (this.stripAnsi(testLine).length > maxWidth && currentLine !== prefix) {
953
+ lines.push(currentLine.trimEnd());
954
+ currentLine = indent + argText + separator;
955
+ }
956
+ else {
957
+ currentLine += argText + separator;
958
+ }
959
+ }
960
+ if (currentLine.trim()) {
961
+ lines.push(currentLine.trimEnd());
962
+ }
963
+ return lines.join('\n') + '\n';
964
+ }
965
+ /**
966
+ * Parse tool arguments from string like: key: "value", key2: value2
967
+ */
968
+ parseToolArgs(argsStr) {
969
+ const args = [];
970
+ // Simple regex to extract key: value pairs
971
+ const regex = /(\w+):\s*("(?:[^"\\]|\\.)*"|[^,\)]+)/g;
972
+ let match;
973
+ while ((match = regex.exec(argsStr)) !== null) {
974
+ args.push({ key: match[1], value: match[2].trim() });
975
+ }
976
+ return args;
977
+ }
978
+ /**
979
+ * Format an argument value (truncate long strings)
980
+ */
981
+ formatArgValue(value) {
982
+ // Remove surrounding quotes if present
983
+ const isQuoted = value.startsWith('"') && value.endsWith('"');
984
+ const inner = isQuoted ? value.slice(1, -1) : value;
985
+ // Truncate long values
986
+ const maxLen = 40;
987
+ const truncated = inner.length > maxLen ? inner.slice(0, maxLen - 3) + '...' : inner;
988
+ return isQuoted ? `"${truncated}"` : truncated;
989
+ }
990
+ /**
991
+ * Format a tool result in Claude Code style:
992
+ * ⎿ Found 12 lines (ctrl+o to expand)
993
+ */
994
+ formatToolResult(content) {
995
+ // Check if this is a summary line (e.g., "Found X lines")
996
+ const summaryMatch = content.match(/^(Found \d+ (?:lines?|files?|matches?)|Read \d+ lines?|Wrote \d+ lines?|Edited|Created|Deleted)/i);
997
+ if (summaryMatch) {
998
+ return ` ${theme.ui.muted('⎿')} ${content} ${theme.ui.muted('(ctrl+o to expand)')}\n`;
999
+ }
1000
+ // For other results, show truncated preview
1001
+ const lines = content.split('\n');
1002
+ if (lines.length > 3) {
1003
+ const preview = lines.slice(0, 2).join('\n');
1004
+ return ` ${theme.ui.muted('⎿')} ${preview}\n ${theme.ui.muted(`... ${lines.length - 2} more lines (ctrl+o to expand)`)}\n`;
1005
+ }
1006
+ return ` ${theme.ui.muted('⎿')} ${content}\n`;
1007
+ }
1008
+ /**
1009
+ * Format a compact tool call: ⏺ Read → file.ts
1010
+ */
1011
+ formatCompactToolCall(content) {
1012
+ const bullet = '⏺';
1013
+ // Parse tool name and args
1014
+ const match = content.match(/^(\w+)\s*(?:\((.*)\))?$/s);
1015
+ if (!match) {
1016
+ return `${bullet} ${content}\n`;
1017
+ }
1018
+ const toolName = match[1];
1019
+ const argsStr = match[2]?.trim() || '';
1020
+ // If no args, just show tool name
1021
+ if (!argsStr) {
1022
+ return `${bullet} ${theme.info(toolName)}\n`;
1023
+ }
1024
+ // Format full params in Claude Code style with line wrapping
1025
+ // For long args, wrap them nicely with continuation indent
1026
+ const prefix = `${bullet} ${theme.info(toolName)}(`;
1027
+ const suffix = ')';
1028
+ const maxWidth = this.cols - 8; // Leave room for margins
1029
+ // Parse individual params
1030
+ const params = this.parseToolParams(argsStr);
1031
+ if (params.length === 0) {
1032
+ return `${prefix}${argsStr}${suffix}\n`;
1033
+ }
1034
+ // Format params with proper wrapping
1035
+ return this.formatToolParams(toolName, params, maxWidth);
1036
+ }
1037
+ /**
1038
+ * Parse tool params from args string
1039
+ */
1040
+ parseToolParams(argsStr) {
1041
+ const params = [];
1042
+ // Match key: "value" or key: value patterns
1043
+ const regex = /(\w+):\s*("(?:[^"\\]|\\.)*"|[^,\n]+)/g;
1044
+ let match;
1045
+ while ((match = regex.exec(argsStr)) !== null) {
1046
+ params.push({ key: match[1], value: match[2].trim() });
1047
+ }
1048
+ return params;
1049
+ }
1050
+ /**
1051
+ * Format tool params in Claude Code style with wrapping
1052
+ */
1053
+ formatToolParams(toolName, params, maxWidth) {
1054
+ const bullet = '⏺';
1055
+ const lines = [];
1056
+ const indent = ' '; // 8 spaces for continuation
1057
+ let currentLine = `${bullet} ${theme.info(toolName)}(`;
1058
+ let firstParam = true;
1059
+ for (const param of params) {
1060
+ const paramStr = firstParam
1061
+ ? `${param.key}: ${param.value}`
1062
+ : `, ${param.key}: ${param.value}`;
1063
+ // Check if adding this param would exceed width
1064
+ const testLine = currentLine + paramStr;
1065
+ const plainLength = testLine.replace(/\x1b\[[0-9;]*m/g, '').length;
1066
+ if (plainLength > maxWidth && !firstParam) {
1067
+ // Start new line
1068
+ lines.push(currentLine);
1069
+ currentLine = indent + `${param.key}: ${param.value}`;
1070
+ }
1071
+ else {
1072
+ currentLine += paramStr;
1073
+ }
1074
+ firstParam = false;
1075
+ }
1076
+ currentLine += ')';
1077
+ lines.push(currentLine);
1078
+ return lines.join('\n') + '\n';
1079
+ }
1080
+ /**
1081
+ * Extract a short summary from tool args
1082
+ */
1083
+ extractToolSummary(toolName, argsStr) {
1084
+ const tool = toolName.toLowerCase();
1085
+ // Extract path/file for file operations
1086
+ if (['read', 'write', 'edit', 'glob', 'grep', 'search'].includes(tool)) {
1087
+ const pathMatch = argsStr.match(/(?:path|file_path|pattern):\s*"([^"]+)"/);
1088
+ if (pathMatch) {
1089
+ const path = pathMatch[1];
1090
+ // Shorten long paths
1091
+ const short = path.length > 30 ? '…' + path.slice(-28) : path;
1092
+ return theme.ui.muted(short);
1093
+ }
1094
+ }
1095
+ // Extract command for bash
1096
+ if (tool === 'bash') {
1097
+ const cmdMatch = argsStr.match(/command:\s*"([^"]+)"/);
1098
+ if (cmdMatch) {
1099
+ const cmd = cmdMatch[1];
1100
+ const short = cmd.length > 40 ? cmd.slice(0, 37) + '…' : cmd;
1101
+ return theme.ui.muted(short);
1102
+ }
1103
+ }
1104
+ return null;
1105
+ }
1106
+ /**
1107
+ * Format a compact tool result: ⎿ Found X lines (ctrl+o to expand)
1108
+ */
1109
+ formatCompactToolResult(content) {
1110
+ // Parse common result patterns for summary
1111
+ const lineMatch = content.match(/(\d+)\s*lines?/i);
1112
+ const fileMatch = content.match(/(\d+)\s*(?:files?|matches?)/i);
1113
+ const readMatch = content.match(/read.*?(\d+)\s*lines?/i);
1114
+ let summary;
1115
+ if (readMatch) {
1116
+ summary = `Read ${readMatch[1]} lines`;
1117
+ }
1118
+ else if (lineMatch) {
1119
+ summary = `Found ${lineMatch[1]} line${lineMatch[1] === '1' ? '' : 's'}`;
1120
+ }
1121
+ else if (fileMatch) {
1122
+ summary = `Found ${fileMatch[1]} file${fileMatch[1] === '1' ? '' : 's'}`;
1123
+ }
1124
+ else if (content.match(/^(success|ok|done|completed|written|edited|created)/i)) {
1125
+ summary = '✓';
1126
+ }
1127
+ else {
1128
+ // Use content directly, truncated if needed
1129
+ summary = content.length > 40 ? content.slice(0, 37) + '…' : content;
1130
+ }
1131
+ return ` ${theme.ui.muted('⎿')} ${summary} ${theme.ui.muted('(ctrl+o to expand)')}\n`;
1132
+ }
1133
+ /**
1134
+ * Format a compact response with bullet on first line
1135
+ */
1136
+ formatCompactResponse(content) {
1137
+ const bullet = '⏺';
1138
+ const trimmed = content.trim();
1139
+ if (!trimmed)
1140
+ return '';
1141
+ // Single line responses - bullet prefix
1142
+ if (!trimmed.includes('\n') && trimmed.length < 80) {
1143
+ return `${bullet} ${trimmed}\n`;
1144
+ }
1145
+ // Multi-line: bullet on first, indent continuation
1146
+ const lines = trimmed.split('\n');
1147
+ const result = [];
1148
+ for (let i = 0; i < lines.length; i++) {
1149
+ const line = lines[i].trimEnd();
1150
+ if (!line) {
1151
+ result.push('');
1152
+ }
1153
+ else if (i === 0) {
1154
+ result.push(`${bullet} ${line}`);
1155
+ }
1156
+ else {
1157
+ result.push(` ${line}`);
1158
+ }
1159
+ }
1160
+ return result.join('\n') + '\n';
948
1161
  }
949
1162
  /**
950
1163
  * Format a compact conversation block (Claude Code style)
@@ -981,26 +1194,66 @@ export class UnifiedUIRenderer extends EventEmitter {
981
1194
  setMode(mode) {
982
1195
  const wasStreaming = this.mode === 'streaming';
983
1196
  this.mode = mode;
1197
+ // Track streaming start time for elapsed display
1198
+ if (mode === 'streaming' && !wasStreaming) {
1199
+ this.streamingStartTime = Date.now();
1200
+ this.streamingTokens = 0; // Reset token count
1201
+ this.startSpinnerAnimation();
1202
+ }
1203
+ else if (mode === 'idle' && wasStreaming) {
1204
+ this.streamingStartTime = null;
1205
+ this.stopSpinnerAnimation();
1206
+ }
984
1207
  if (wasStreaming && mode === 'idle' && !this.lastOutputEndedWithNewline) {
985
1208
  // Finish streaming on a fresh line so the next prompt/event doesn't collide
986
1209
  this.write('\n');
987
1210
  this.lastOutputEndedWithNewline = true;
988
1211
  }
989
- if (mode === 'streaming') {
990
- if (!this.streamingStart) {
991
- this.streamingStart = Date.now();
992
- }
993
- this.startActivityTimer();
1212
+ if (this.interactive) {
1213
+ this.renderPrompt();
994
1214
  }
995
- else {
996
- this.streamingStart = null;
997
- this.stopActivityTimer();
1215
+ }
1216
+ /**
1217
+ * Start the animated spinner for streaming status
1218
+ */
1219
+ startSpinnerAnimation() {
1220
+ if (this.spinnerInterval)
1221
+ return; // Already running
1222
+ this.spinnerFrame = 0;
1223
+ this.spinnerInterval = setInterval(() => {
1224
+ this.spinnerFrame = (this.spinnerFrame + 1) % spinnerFrames.braille.length;
1225
+ if (this.mode === 'streaming') {
1226
+ this.renderPrompt();
1227
+ }
1228
+ }, 120);
1229
+ }
1230
+ /**
1231
+ * Stop the animated spinner
1232
+ */
1233
+ stopSpinnerAnimation() {
1234
+ if (this.spinnerInterval) {
1235
+ clearInterval(this.spinnerInterval);
1236
+ this.spinnerInterval = null;
998
1237
  }
999
- if (!this.plainMode) {
1000
- // Always render prompt to keep bottom UI persistent (rich mode only)
1238
+ this.spinnerFrame = 0;
1239
+ this.activityMessage = null;
1240
+ }
1241
+ /**
1242
+ * Set the activity message displayed with animated star
1243
+ * Example: "Ruminating…" shows as "✳ Ruminating… (esc to interrupt · 34s · ↑1.2k)"
1244
+ */
1245
+ setActivity(message) {
1246
+ this.activityMessage = message;
1247
+ if (this.interactive) {
1001
1248
  this.renderPrompt();
1002
1249
  }
1003
1250
  }
1251
+ /**
1252
+ * Update the token count displayed in the activity line
1253
+ */
1254
+ updateStreamingTokens(tokens) {
1255
+ this.streamingTokens = tokens;
1256
+ }
1004
1257
  getMode() {
1005
1258
  return this.mode;
1006
1259
  }
@@ -1039,15 +1292,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1039
1292
  }
1040
1293
  }
1041
1294
  updateStatusMeta(meta, options = {}) {
1042
- const next = { ...this.statusMeta };
1043
- for (const [key, value] of Object.entries(meta)) {
1044
- if (value === null || value === undefined) {
1045
- delete next[key];
1046
- }
1047
- else {
1048
- next[key] = value;
1049
- }
1050
- }
1295
+ const next = { ...this.statusMeta, ...meta };
1051
1296
  const changed = JSON.stringify(next) !== JSON.stringify(this.statusMeta);
1052
1297
  this.statusMeta = next;
1053
1298
  const shouldRender = options.render !== false && changed;
@@ -1057,74 +1302,19 @@ export class UnifiedUIRenderer extends EventEmitter {
1057
1302
  }
1058
1303
  updateModeToggles(state) {
1059
1304
  this.toggleState = { ...this.toggleState, ...state };
1060
- if (!state.autoContinueHotkey &&
1061
- !state.verificationHotkey &&
1062
- !state.thinkingHotkey &&
1063
- !state.criticalApprovalHotkey) {
1064
- this.hotkeysInToggleLine.clear();
1065
- }
1066
1305
  this.renderPrompt();
1067
1306
  }
1068
1307
  setInlinePanel(lines) {
1069
1308
  const normalized = (lines ?? [])
1070
1309
  .map(line => line.replace(/\s+$/g, ''))
1071
1310
  .filter(line => line.trim().length > 0);
1072
- if (JSON.stringify(normalized) === JSON.stringify(this.inlinePanel)) {
1311
+ if (!normalized.length) {
1073
1312
  return;
1074
1313
  }
1075
- this.inlinePanel = normalized;
1076
- this.renderPrompt();
1314
+ this.addEvent('response', `${normalized.join('\n')}\n`);
1077
1315
  }
1078
1316
  clearInlinePanel() {
1079
- if (!this.inlinePanel.length)
1080
- return;
1081
- this.inlinePanel = [];
1082
- this.renderPrompt();
1083
- }
1084
- setPersistentPanel(lines) {
1085
- const normalized = (lines ?? [])
1086
- .map(line => line.replace(/\s+$/g, ''))
1087
- .filter(line => line.trim().length > 0);
1088
- if (JSON.stringify(normalized) === JSON.stringify(this.persistentPanel)) {
1089
- return;
1090
- }
1091
- this.persistentPanel = normalized;
1092
- this.renderPrompt();
1093
- }
1094
- clearPersistentPanel() {
1095
- if (!this.persistentPanel.length)
1096
- return;
1097
- this.persistentPanel = [];
1098
- this.renderPrompt();
1099
- }
1100
- startActivityTimer() {
1101
- if (this.activityInterval || this.plainMode || !this.interactive) {
1102
- return;
1103
- }
1104
- this.activityInterval = setInterval(() => {
1105
- if (this.allowPromptRender) {
1106
- this.renderPrompt();
1107
- }
1108
- }, 120);
1109
- }
1110
- stopActivityTimer() {
1111
- if (this.activityInterval) {
1112
- clearInterval(this.activityInterval);
1113
- this.activityInterval = null;
1114
- }
1115
- }
1116
- rememberToolResult(content, summary) {
1117
- const normalized = content?.trim();
1118
- if (!normalized)
1119
- return;
1120
- this.lastToolResult = { full: normalized, summary: summary?.trim() || undefined };
1121
- }
1122
- expandLastToolResult() {
1123
- if (!this.lastToolResult) {
1124
- return false;
1125
- }
1126
- this.addEvent('tool-result', this.lastToolResult.full);
1127
- return true;
1317
+ // No-op: inline panels render directly into scrollback
1128
1318
  }
1129
1319
  // ------------ Prompt rendering ------------
1130
1320
  renderPrompt() {
@@ -1132,343 +1322,203 @@ export class UnifiedUIRenderer extends EventEmitter {
1132
1322
  this.isPromptActive = false;
1133
1323
  return;
1134
1324
  }
1135
- if (this.plainMode) {
1136
- const line = `> ${this.buffer}`;
1137
- if (!this.isPromptActive && !this.lastOutputEndedWithNewline) {
1138
- this.write('\n');
1139
- this.lastOutputEndedWithNewline = true;
1140
- }
1141
- this.write(`\r${ESC.CLEAR_LINE}${line}`);
1142
- this.hasRenderedPrompt = true;
1143
- this.isPromptActive = true;
1144
- this.lastOutputEndedWithNewline = false; // prompt ends mid-line by design
1145
- this.promptHeight = 1;
1146
- return;
1147
- }
1148
- if (!this.allowPromptRender) {
1149
- return;
1150
- }
1151
- if (typeof this.statusMeta.contextPercent === 'number') {
1152
- this.renderedContextPercent =
1153
- this.renderedContextPercent === null
1154
- ? this.statusMeta.contextPercent
1155
- : this.renderedContextPercent +
1156
- (this.statusMeta.contextPercent - this.renderedContextPercent) * 0.35;
1157
- }
1158
- else {
1159
- this.renderedContextPercent = null;
1160
- }
1161
- // Rich mode: inline overlay anchored to current scrollback (no full-screen clear)
1162
1325
  this.updateTerminalSize();
1163
- const maxWidth = this.safeWidth();
1164
- if (this.lastRenderWidth !== null && maxWidth !== this.lastRenderWidth) {
1165
- // Terminal resized; force a clean anchor so the overlay doesn't jitter.
1166
- this.overlayInvalidated = true;
1167
- }
1168
- this.lastRenderWidth = maxWidth;
1169
- const overlay = this.buildOverlayLines();
1170
- if (!overlay.lines.length) {
1171
- return;
1172
- }
1173
- const renderedLines = overlay.lines.map(line => this.truncateLine(line, maxWidth));
1174
- if (!renderedLines.length) {
1175
- return;
1326
+ const status = this.composeStatusLabel();
1327
+ const inputLine = this.buildInputLine();
1328
+ const inputLines = inputLine.split('\n');
1329
+ const lines = [];
1330
+ if (status) {
1331
+ lines.push(this.applyTone(status.text, status.tone));
1176
1332
  }
1177
- let promptIndex = Math.max(0, Math.min(overlay.cursorRow, renderedLines.length - 1));
1178
- let height = renderedLines.length;
1179
- // Keep at least one free line below the overlay so typing always has breathing room
1180
- const bottomPadding = this.overlayBottomPadding;
1181
- const totalRows = this.rows || 24;
1182
- const availableRows = Math.max(1, totalRows - bottomPadding);
1183
- if (height > availableRows) {
1184
- renderedLines.splice(availableRows);
1185
- height = renderedLines.length;
1186
- promptIndex = Math.max(0, Math.min(promptIndex, height - 1));
1187
- }
1188
- const startRow = Math.max(1, availableRows - height + 1);
1189
- const promptRow = startRow + promptIndex;
1190
- const promptCol = Math.min(Math.max(1, overlay.cursorCol), this.cols || 80);
1191
- // Clear any previous overlay footprint (status, prompt, controls) to avoid leaking into scrollback
1192
- this.clearOverlayRows(height, startRow);
1193
- if (bottomPadding > 0 && startRow + height <= totalRows) {
1194
- this.write(ESC.TO(startRow + height, 1));
1195
- this.write(ESC.CLEAR_LINE);
1333
+ lines.push(...inputLines);
1334
+ const hadPrompt = this.isPromptActive;
1335
+ this.clearPromptArea();
1336
+ if (!hadPrompt && !this.lastOutputEndedWithNewline) {
1337
+ this.write('\n');
1338
+ this.lastOutputEndedWithNewline = true;
1196
1339
  }
1197
- // Render overlay lines in place without pushing scrollback
1198
- for (let idx = 0; idx < height; idx++) {
1199
- const row = startRow + idx;
1200
- const line = renderedLines[idx] ?? '';
1201
- this.write(ESC.TO(row, 1));
1340
+ for (let i = 0; i < lines.length; i++) {
1341
+ this.write('\r');
1202
1342
  this.write(ESC.CLEAR_LINE);
1203
- if (line) {
1204
- this.write(line);
1343
+ this.write(lines[i] || '');
1344
+ if (i < lines.length - 1) {
1345
+ this.write('\n');
1205
1346
  }
1206
1347
  }
1207
- // Position cursor at prompt row/col
1208
- this.write(ESC.TO(promptRow, promptCol));
1209
- this.hasRenderedPrompt = true;
1210
- this.hasEverRenderedOverlay = true; // Mark that we've rendered at least once
1348
+ const cursorRow = Math.min(lines.length - 1, this.cursorVisibleRow ?? lines.length - 1);
1349
+ const rowsToMoveUp = lines.length - 1 - cursorRow;
1350
+ if (rowsToMoveUp > 0) {
1351
+ this.write(`\x1b[${rowsToMoveUp}A`);
1352
+ }
1353
+ const cursorCol = Math.max(1, this.cursorVisibleColumn);
1354
+ this.write(`\x1b[${cursorCol}G`);
1211
1355
  this.isPromptActive = true;
1212
- this.lastOverlayHeight = height;
1213
- this.lastPromptIndex = promptIndex;
1214
- this.lastOverlay = { lines: renderedLines, promptIndex };
1215
- this.overlayInvalidated = false;
1216
- this.lastOutputEndedWithNewline = true;
1217
- this.promptHeight = height;
1356
+ this.promptHeight = lines.length;
1357
+ this.lastOutputEndedWithNewline = false;
1218
1358
  }
1219
- buildOverlayLines() {
1220
- const lines = [];
1221
- const maxWidth = this.safeWidth();
1222
- const activity = this.buildActivityLine(maxWidth);
1223
- if (activity) {
1224
- lines.push(activity);
1225
- }
1226
- lines.push(this.truncateLine(renderDivider(Math.min(maxWidth, 96)), maxWidth));
1227
- const inputOverlay = this.buildInputOverlay(maxWidth);
1228
- const promptRow = lines.length + inputOverlay.cursorRow;
1229
- const promptCol = inputOverlay.cursorCol;
1230
- for (const line of inputOverlay.lines) {
1231
- lines.push(this.truncateLine(line, maxWidth));
1232
- }
1233
- lines.push(this.truncateLine(renderDivider(Math.min(maxWidth, 96)), maxWidth));
1234
- if (this.inlinePanel.length > 0) {
1235
- for (const panelLine of this.inlinePanel) {
1236
- lines.push(this.truncateLine(panelLine, maxWidth));
1237
- }
1238
- }
1239
- if (this.persistentPanel.length > 0) {
1240
- for (const panelLine of this.persistentPanel) {
1241
- lines.push(this.truncateLine(panelLine, maxWidth));
1242
- }
1243
- }
1244
- if (this.suggestions.length > 0) {
1245
- for (let index = 0; index < this.suggestions.length; index++) {
1246
- const suggestion = this.suggestions[index];
1247
- const isActive = index === this.suggestionIndex;
1248
- const marker = isActive ? theme.primary('›') : theme.ui.muted('›');
1249
- const cmdText = isActive ? theme.primary(suggestion.command) : theme.ui.muted(suggestion.command);
1250
- const descText = isActive ? suggestion.description : theme.ui.muted(suggestion.description);
1251
- lines.push(this.truncateLine(`${marker} ${cmdText} — ${descText}`, maxWidth));
1252
- }
1253
- }
1254
- const modelLine = this.buildModelLine(maxWidth);
1255
- if (modelLine) {
1256
- lines.push(modelLine);
1257
- }
1258
- const toggleLine = this.buildToggleLine();
1259
- if (toggleLine) {
1260
- lines.push(toggleLine);
1261
- }
1262
- const shortcutLine = this.buildShortcutLine();
1263
- if (shortcutLine) {
1264
- lines.push(shortcutLine);
1265
- }
1266
- else {
1267
- lines.push(`${theme.ui.muted('?')} shortcuts`);
1268
- }
1269
- return { lines, cursorRow: promptRow, cursorCol: promptCol };
1270
- }
1271
- abbreviatePath(pathValue) {
1272
- const home = homedir();
1273
- if (home && pathValue.startsWith(home)) {
1274
- return pathValue.replace(home, '~');
1275
- }
1276
- return pathValue;
1277
- }
1278
- buildActivityLine(maxWidth) {
1279
- const base = this.statusStreaming || this.statusOverride || this.statusMessage || 'Ready';
1280
- const spinner = this.mode === 'streaming' ? colorizeActivity(this.activityTicker()) : theme.ui.muted('∙');
1281
- const elapsed = this.mode === 'streaming' ? formatElapsed(this.streamingStart) : null;
1282
- const tokenText = this.mode === 'streaming' ? formatTokenDelta(this.statusMeta.tokensUsed) : null;
1283
- const parts = [
1284
- `${spinner} ${base}`,
1285
- elapsed ? theme.ui.muted(elapsed) : null,
1286
- tokenText ? theme.ui.muted(tokenText) : null,
1287
- ].filter(Boolean);
1288
- if (!parts.length) {
1359
+ composeStatusLabel() {
1360
+ const statuses = [this.activityMessage, this.statusStreaming, this.statusOverride, this.statusMessage].filter((value, index, arr) => Boolean(value) && arr.indexOf(value) === index);
1361
+ const text = statuses.length > 0 ? statuses.join(' / ') : 'Ready for prompts';
1362
+ if (!text.trim()) {
1289
1363
  return null;
1290
1364
  }
1291
- return this.truncateLine(parts.join(theme.ui.muted(' • ')), maxWidth);
1292
- }
1293
- buildModelLine(maxWidth) {
1294
- const segments = [];
1295
- if (this.statusMeta.profile) {
1296
- segments.push(`${theme.ui.muted('profile')} ${theme.info(this.statusMeta.profile)}`);
1297
- }
1298
- const model = this.statusMeta.provider && this.statusMeta.model
1299
- ? `${this.statusMeta.provider} / ${this.statusMeta.model}`
1300
- : this.statusMeta.model || this.statusMeta.provider;
1301
- if (model) {
1302
- segments.push(`${theme.ui.muted('model')} ${theme.info(model)}`);
1303
- }
1304
- const workspace = this.statusMeta.workspace || this.statusMeta.directory;
1305
- if (workspace) {
1306
- segments.push(`${theme.ui.muted('dir')} ${theme.ui.muted(this.abbreviatePath(workspace))}`);
1307
- }
1308
- if (this.statusMeta.writes) {
1309
- segments.push(`${theme.ui.muted('writes')} ${theme.ui.muted(this.statusMeta.writes)}`);
1310
- }
1311
- if (this.statusMeta.toolSummary) {
1312
- segments.push(`${theme.ui.muted('tools')} ${theme.ui.muted(this.statusMeta.toolSummary)}`);
1313
- }
1314
- if (this.statusMeta.version) {
1315
- segments.push(`${theme.ui.muted('v')} ${theme.ui.muted(this.statusMeta.version)}`);
1316
- }
1317
- if (this.statusMeta.sessionLabel) {
1318
- segments.push(`${theme.ui.muted('session')} ${theme.ui.muted(this.statusMeta.sessionLabel)}`);
1319
- }
1320
- const ctxTarget = this.renderedContextPercent ?? this.statusMeta.contextPercent;
1321
- const contextBar = formatTinyProgressBar(ctxTarget, 10);
1322
- if (contextBar) {
1323
- segments.push(`${theme.ui.muted('ctx')} ${theme.ui.muted(contextBar)}`);
1324
- }
1325
- if (segments.length === 0) {
1326
- return null;
1365
+ const normalized = text.toLowerCase();
1366
+ const tone = normalized.includes('ready') ? 'success' : 'info';
1367
+ return { text, tone };
1368
+ }
1369
+ formatMetaSegment(label, value, tone) {
1370
+ const colorizer = tone === 'success'
1371
+ ? theme.success
1372
+ : tone === 'warn'
1373
+ ? theme.warning
1374
+ : tone === 'error'
1375
+ ? theme.error
1376
+ : tone === 'muted'
1377
+ ? theme.ui.muted
1378
+ : theme.info;
1379
+ return `${theme.ui.muted(label)} ${colorizer(value)}`;
1380
+ }
1381
+ applyTone(text, tone) {
1382
+ switch (tone) {
1383
+ case 'success':
1384
+ return theme.success(text);
1385
+ case 'warn':
1386
+ return theme.warning(text);
1387
+ case 'error':
1388
+ return theme.error(text);
1389
+ case 'info':
1390
+ default:
1391
+ return theme.info(text);
1327
1392
  }
1328
- return this.truncateLine(segments.join(theme.ui.muted(' • ')), maxWidth);
1329
1393
  }
1330
- buildControlLines() {
1394
+ wrapSegments(segments, maxWidth) {
1331
1395
  const lines = [];
1332
- const toggleLine = this.buildToggleLine();
1333
- if (toggleLine) {
1334
- lines.push(`${theme.ui.muted('modes')} ${theme.ui.muted('›')} ${toggleLine}`);
1335
- }
1336
- const shortcutLine = this.buildShortcutLine();
1337
- if (shortcutLine) {
1338
- lines.push(`${theme.ui.muted('keys')} ${shortcutLine}`);
1339
- }
1340
- return lines;
1341
- }
1342
- buildToggleLine() {
1343
- const toggles = [];
1344
- const addToggle = (label, on, hotkey, value) => {
1345
- toggles.push({ label, on, hotkey: this.formatHotkey(hotkey), value });
1346
- };
1347
- addToggle('Auto', this.toggleState.autoContinueEnabled, this.toggleState.autoContinueHotkey);
1348
- addToggle('Verify', this.toggleState.verificationEnabled, this.toggleState.verificationHotkey);
1349
- const approvalMode = this.toggleState.criticalApprovalMode || 'auto';
1350
- const approvalActive = approvalMode !== 'auto';
1351
- addToggle('Approvals', approvalActive, this.toggleState.criticalApprovalHotkey, approvalMode === 'auto' ? 'auto' : 'ask');
1352
- const thinkingLabel = (this.toggleState.thinkingModeLabel || 'off').trim();
1353
- const thinkingActive = thinkingLabel.toLowerCase() !== 'off';
1354
- addToggle('Thinking', thinkingActive, this.toggleState.thinkingHotkey, thinkingLabel);
1355
- const buildLine = (includeHotkeys) => {
1356
- return toggles
1357
- .map(toggle => {
1358
- const stateText = toggle.on ? theme.success(toggle.value || 'on') : theme.ui.muted(toggle.value || 'off');
1359
- const hotkeyText = includeHotkeys && toggle.hotkey ? theme.ui.muted(` [${toggle.hotkey}]`) : '';
1360
- return `${theme.ui.muted(`${toggle.label}:`)} ${stateText}${hotkeyText}`;
1361
- })
1362
- .join(theme.ui.muted(' '));
1363
- };
1364
- const maxWidth = this.safeWidth();
1365
- let line = buildLine(true);
1366
- // Record which hotkeys are actually shown so the shortcut line can avoid duplicates
1367
- this.hotkeysInToggleLine = new Set(toggles
1368
- .map(toggle => (toggle.hotkey ? toggle.hotkey : null))
1369
- .filter((key) => Boolean(key)));
1370
- // If the line is too wide, drop hotkey hints to preserve all toggle labels
1371
- if (this.visibleLength(line) > maxWidth) {
1372
- this.hotkeysInToggleLine.clear();
1373
- line = buildLine(false);
1374
- }
1375
- return line.trim() ? line : null;
1376
- }
1377
- buildShortcutLine() {
1378
- const parts = [];
1379
- const addHotkey = (label, combo) => {
1380
- const normalized = this.formatHotkey(combo);
1396
+ const separator = theme.ui.muted(' | ');
1397
+ let current = '';
1398
+ for (const segment of segments) {
1399
+ const normalized = segment.trim();
1381
1400
  if (!normalized)
1382
- return;
1383
- if (this.hotkeysInToggleLine.has(normalized)) {
1384
- return;
1401
+ continue;
1402
+ if (!current) {
1403
+ current = this.truncateLine(normalized, maxWidth);
1404
+ continue;
1405
+ }
1406
+ const candidate = `${current}${separator}${normalized}`;
1407
+ if (this.visibleLength(candidate) <= maxWidth) {
1408
+ current = candidate;
1409
+ }
1410
+ else {
1411
+ lines.push(this.truncateLine(current, maxWidth));
1412
+ current = this.truncateLine(normalized, maxWidth);
1385
1413
  }
1386
- parts.push(`${theme.info(normalized)} ${theme.ui.muted(label)}`);
1387
- };
1388
- // Core controls
1389
- addHotkey('interrupt', 'Ctrl+C');
1390
- addHotkey('clear input', 'Ctrl+U');
1391
- // Feature toggles (only if hotkeys are defined)
1392
- addHotkey('auto-run', this.toggleState.autoContinueHotkey);
1393
- addHotkey('verify', this.toggleState.verificationHotkey);
1394
- addHotkey('thinking', this.toggleState.thinkingHotkey);
1395
- if (parts.length === 0) {
1396
- return null;
1397
1414
  }
1398
- const body = parts.join(theme.ui.muted(' '));
1399
- return `${body}${theme.ui.muted(' ? shortcuts')}`;
1415
+ if (current) {
1416
+ lines.push(this.truncateLine(current, maxWidth));
1417
+ }
1418
+ return lines;
1400
1419
  }
1401
- buildInputOverlay(maxWidth) {
1402
- const prompt = theme.primary('› ');
1403
- const promptWidth = this.visibleLength(prompt);
1404
- const usableWidth = Math.max(8, maxWidth - promptWidth);
1420
+ buildInputLine() {
1405
1421
  if (this.collapsedPaste) {
1406
1422
  const summary = `[pasted ${this.collapsedPaste.lines} lines, ${this.collapsedPaste.chars} chars] (Ctrl+L insert, Backspace discard)`;
1407
- const line = `${prompt}${theme.ui.muted(summary)}`;
1408
- const cursorCol = Math.min(maxWidth, this.visibleLength(line) + 1);
1409
- return { lines: [this.truncateLine(line, maxWidth)], cursorRow: 0, cursorCol };
1423
+ return this.truncateLine(`${theme.primary('> ')}${theme.ui.muted(summary)}`, this.safeWidth());
1410
1424
  }
1425
+ // Claude Code uses simple '>' prompt
1426
+ const prompt = theme.primary('> ');
1427
+ const promptWidth = this.visibleLength(prompt);
1428
+ const maxWidth = this.safeWidth();
1429
+ const continuationIndent = ' '; // 2 spaces for continuation lines
1430
+ const continuationWidth = continuationIndent.length;
1431
+ // Handle multi-line input - split buffer on newlines first
1411
1432
  const normalized = this.buffer.replace(/\r/g, '\n');
1412
- const cursorIndex = Math.min(this.cursor, normalized.length);
1413
- const rawLines = [];
1414
- let current = '';
1415
- let row = 0;
1416
- const limitForRow = (r) => (r === 0 ? usableWidth : usableWidth);
1417
- let cursorRow = 0;
1433
+ const bufferLines = normalized.split('\n');
1434
+ // Wrap each logical line to fit terminal width, expanding vertically
1435
+ const result = [];
1436
+ let totalChars = 0;
1437
+ let cursorLine = 0;
1418
1438
  let cursorCol = 0;
1419
- for (let idx = 0; idx <= normalized.length; idx += 1) {
1420
- if (idx === cursorIndex) {
1421
- cursorRow = row;
1422
- cursorCol = current.length;
1423
- }
1424
- if (idx === normalized.length) {
1425
- break;
1426
- }
1427
- const char = normalized[idx];
1428
- if (char === '\n') {
1429
- current += NEWLINE_PLACEHOLDER;
1430
- rawLines.push(current);
1431
- row += 1;
1432
- current = '';
1433
- continue;
1434
- }
1435
- const limit = limitForRow(row);
1436
- if (current.length >= limit) {
1437
- rawLines.push(current);
1438
- row += 1;
1439
- current = '';
1439
+ let foundCursor = false;
1440
+ for (let lineIndex = 0; lineIndex < bufferLines.length; lineIndex++) {
1441
+ const line = bufferLines[lineIndex] ?? '';
1442
+ const isFirstLogicalLine = lineIndex === 0;
1443
+ const lineStartChar = totalChars;
1444
+ // Determine available width for this line
1445
+ const firstLineWidth = maxWidth - promptWidth;
1446
+ const contLineWidth = maxWidth - continuationWidth;
1447
+ // Wrap this logical line into display lines
1448
+ let remaining = line;
1449
+ let isFirstDisplayLine = true;
1450
+ while (remaining.length > 0 || isFirstDisplayLine) {
1451
+ const availableWidth = (isFirstLogicalLine && isFirstDisplayLine) ? firstLineWidth : contLineWidth;
1452
+ const chunk = remaining.slice(0, availableWidth);
1453
+ remaining = remaining.slice(availableWidth);
1454
+ // Build the display line
1455
+ let displayLine;
1456
+ if (isFirstLogicalLine && isFirstDisplayLine) {
1457
+ displayLine = `${prompt}${chunk}`;
1458
+ }
1459
+ else {
1460
+ displayLine = `${continuationIndent}${chunk}`;
1461
+ }
1462
+ // Track cursor position
1463
+ if (!foundCursor) {
1464
+ const chunkStart = lineStartChar + (line.length - remaining.length - chunk.length);
1465
+ const chunkEnd = chunkStart + chunk.length;
1466
+ if (this.cursor >= chunkStart && this.cursor <= chunkEnd) {
1467
+ cursorLine = result.length;
1468
+ const offsetInChunk = this.cursor - chunkStart;
1469
+ cursorCol = ((isFirstLogicalLine && isFirstDisplayLine) ? promptWidth : continuationWidth) + offsetInChunk;
1470
+ foundCursor = true;
1471
+ }
1472
+ }
1473
+ result.push(displayLine);
1474
+ isFirstDisplayLine = false;
1475
+ // If nothing left and this was an empty line, we already added it
1476
+ if (remaining.length === 0 && chunk.length === 0)
1477
+ break;
1440
1478
  }
1441
- current += char;
1479
+ totalChars += line.length + 1; // +1 for the newline separator
1442
1480
  }
1443
- if (current || rawLines.length === 0) {
1444
- rawLines.push(current);
1481
+ // Handle cursor at very end
1482
+ if (!foundCursor) {
1483
+ cursorLine = Math.max(0, result.length - 1);
1484
+ const lastLine = result[cursorLine] ?? '';
1485
+ cursorCol = this.visibleLength(lastLine);
1445
1486
  }
1446
- if (cursorIndex === normalized.length) {
1447
- cursorRow = rawLines.length - 1;
1448
- cursorCol = rawLines[cursorRow]?.length ?? 0;
1487
+ this.cursorVisibleRow = cursorLine;
1488
+ this.cursorVisibleColumn = cursorCol + 1;
1489
+ return result.join('\n');
1490
+ }
1491
+ buildInputWindow(available) {
1492
+ if (available <= 0) {
1493
+ return { text: '', cursor: 0 };
1449
1494
  }
1450
- const prefixPad = ' '.repeat(promptWidth);
1451
- const lines = [];
1452
- let cursorColumn = 1;
1453
- rawLines.forEach((line, index) => {
1454
- const prefix = index === 0 ? prompt : prefixPad;
1455
- const onCursorLine = index === cursorRow;
1456
- const text = line ?? '';
1457
- const col = Math.min(Math.max(cursorCol, 0), text.length);
1458
- let rendered = text;
1459
- if (onCursorLine) {
1460
- const before = rendered.slice(0, col);
1461
- const at = rendered.charAt(col) || ' ';
1462
- const after = rendered.slice(col + 1);
1463
- rendered = `${before}${ESC.REVERSE}${at}${ESC.RESET}${after}`;
1464
- cursorColumn = this.visibleLength(prefix) + col + 1;
1465
- }
1466
- lines.push(`${prefix}${rendered}`);
1467
- });
1495
+ if (this.collapsedPaste) {
1496
+ return { text: '', cursor: 0 };
1497
+ }
1498
+ const normalized = this.buffer.replace(/\r/g, '\n');
1499
+ const cursorIndex = Math.min(this.cursor, normalized.length);
1500
+ let offset = this.inputRenderOffset;
1501
+ if (cursorIndex < offset) {
1502
+ offset = cursorIndex;
1503
+ }
1504
+ const overflow = cursorIndex - offset - available + 1;
1505
+ if (overflow > 0) {
1506
+ offset += overflow;
1507
+ }
1508
+ const maxOffset = Math.max(0, normalized.length - available);
1509
+ if (offset > maxOffset) {
1510
+ offset = maxOffset;
1511
+ }
1512
+ this.inputRenderOffset = offset;
1513
+ const window = normalized.slice(offset, offset + available);
1514
+ const display = window.split('').map(char => (char === '\n' ? NEWLINE_PLACEHOLDER : char)).join('');
1515
+ const cursorInWindow = Math.min(display.length, Math.max(0, cursorIndex - offset));
1516
+ const before = display.slice(0, cursorInWindow);
1517
+ const at = display.charAt(cursorInWindow) || ' ';
1518
+ const after = display.slice(cursorInWindow + 1);
1468
1519
  return {
1469
- lines,
1470
- cursorRow,
1471
- cursorCol: cursorColumn,
1520
+ text: `${before}${ESC.REVERSE}${at}${ESC.RESET}${after}`,
1521
+ cursor: cursorInWindow,
1472
1522
  };
1473
1523
  }
1474
1524
  expandCollapsedPaste() {
@@ -1488,6 +1538,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1488
1538
  if (options.resetBuffer) {
1489
1539
  this.buffer = '';
1490
1540
  this.cursor = 0;
1541
+ this.inputRenderOffset = 0;
1491
1542
  this.resetSuggestions();
1492
1543
  this.renderPrompt();
1493
1544
  this.emitInputChange();
@@ -1566,17 +1617,6 @@ export class UnifiedUIRenderer extends EventEmitter {
1566
1617
  }
1567
1618
  return result;
1568
1619
  }
1569
- clearOverlayRows(rows, startRow) {
1570
- const totalRows = this.rows || 24;
1571
- const limit = Math.max(0, Math.min(rows, totalRows));
1572
- for (let idx = 0; idx < limit; idx++) {
1573
- const row = startRow + idx;
1574
- if (row < 1 || row > totalRows)
1575
- continue;
1576
- this.write(ESC.TO(row, 1));
1577
- this.write(ESC.CLEAR_LINE);
1578
- }
1579
- }
1580
1620
  getBuffer() {
1581
1621
  return this.buffer;
1582
1622
  }
@@ -1586,6 +1626,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1586
1626
  setBuffer(text, cursorPos) {
1587
1627
  this.buffer = text;
1588
1628
  this.cursor = cursorPos ?? text.length;
1629
+ this.inputRenderOffset = 0;
1589
1630
  this.updateSuggestions();
1590
1631
  this.renderPrompt();
1591
1632
  this.emitInputChange();
@@ -1594,6 +1635,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1594
1635
  this.cancelPlainPasteCapture();
1595
1636
  this.buffer = '';
1596
1637
  this.cursor = 0;
1638
+ this.inputRenderOffset = 0;
1597
1639
  this.suggestions = [];
1598
1640
  this.suggestionIndex = -1;
1599
1641
  this.renderPrompt();
@@ -1602,6 +1644,31 @@ export class UnifiedUIRenderer extends EventEmitter {
1602
1644
  setModeStatus(status) {
1603
1645
  this.updateStatus(status);
1604
1646
  }
1647
+ /**
1648
+ * Show a compacting status with animated spinner (Claude Code style)
1649
+ * Uses ✻ character with animation to indicate context compaction in progress
1650
+ */
1651
+ showCompactingStatus(message) {
1652
+ this.statusMessage = message;
1653
+ if (!this.spinnerInterval) {
1654
+ this.spinnerInterval = setInterval(() => {
1655
+ this.spinnerFrame++;
1656
+ this.renderPrompt();
1657
+ }, 120);
1658
+ }
1659
+ this.renderPrompt();
1660
+ }
1661
+ /**
1662
+ * Hide the compacting status and stop spinner animation
1663
+ */
1664
+ hideCompactingStatus() {
1665
+ if (this.spinnerInterval) {
1666
+ clearInterval(this.spinnerInterval);
1667
+ this.spinnerInterval = null;
1668
+ }
1669
+ this.statusMessage = null;
1670
+ this.renderPrompt();
1671
+ }
1605
1672
  emitPrompt(content) {
1606
1673
  this.pushPromptEvent(content);
1607
1674
  }
@@ -1641,31 +1708,34 @@ export class UnifiedUIRenderer extends EventEmitter {
1641
1708
  this.lastPromptEvent = { text: normalized, at: now };
1642
1709
  this.addEvent('prompt', normalized);
1643
1710
  }
1644
- clearPromptArea() {
1645
- const height = this.lastOverlay?.lines.length ?? this.promptHeight ?? 0;
1646
- if (height === 0)
1711
+ clearPromptArea(insertNewline = false) {
1712
+ if (!this.isPromptActive || this.promptHeight <= 0) {
1713
+ if (insertNewline && !this.lastOutputEndedWithNewline) {
1714
+ this.write('\n');
1715
+ this.lastOutputEndedWithNewline = true;
1716
+ }
1647
1717
  return;
1648
- this.updateTerminalSize();
1649
- const totalRows = this.rows || 24;
1650
- const startRow = Math.max(1, Math.max(1, totalRows - this.overlayBottomPadding) - height + 1);
1651
- this.clearOverlayRows(height, startRow);
1652
- // Keep the padding row clean as well
1653
- const paddingRow = startRow + height;
1654
- if (this.overlayBottomPadding > 0 && paddingRow <= totalRows) {
1655
- this.write(ESC.TO(paddingRow, 1));
1656
- this.write(ESC.CLEAR_LINE);
1657
1718
  }
1658
- // Move cursor to the bottom ready for new scrollback output
1659
- this.write(ESC.TO(totalRows, 1));
1660
- this.lastOverlayHeight = height;
1661
- this.lastPromptIndex = this.lastOverlay?.promptIndex ?? this.lastPromptIndex;
1662
- this.lastOverlay = null;
1663
- this.overlayInvalidated = true;
1719
+ if (this.promptHeight > 1) {
1720
+ readline.moveCursor(this.output, 0, -(this.promptHeight - 1));
1721
+ }
1722
+ for (let i = 0; i < this.promptHeight; i++) {
1723
+ readline.cursorTo(this.output, 0);
1724
+ readline.clearLine(this.output, 0);
1725
+ if (i < this.promptHeight - 1) {
1726
+ readline.moveCursor(this.output, 0, 1);
1727
+ }
1728
+ }
1729
+ readline.cursorTo(this.output, 0);
1730
+ if (insertNewline) {
1731
+ this.write('\n');
1732
+ this.lastOutputEndedWithNewline = true;
1733
+ }
1664
1734
  this.promptHeight = 0;
1735
+ this.isPromptActive = false;
1665
1736
  }
1666
1737
  updateTerminalSize() {
1667
1738
  if (this.output.isTTY) {
1668
- this.rows = this.output.rows || 24;
1669
1739
  this.cols = this.output.columns || 80;
1670
1740
  }
1671
1741
  }