erosolar-cli 2.1.168 → 2.1.169

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 (133) 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/statusCapability.js +2 -2
  5. package/dist/capabilities/statusCapability.js.map +1 -1
  6. package/dist/contracts/agent-schemas.json +5 -5
  7. package/dist/core/agent.d.ts +70 -24
  8. package/dist/core/agent.d.ts.map +1 -1
  9. package/dist/core/agent.js +424 -248
  10. package/dist/core/agent.js.map +1 -1
  11. package/dist/core/preferences.d.ts +1 -0
  12. package/dist/core/preferences.d.ts.map +1 -1
  13. package/dist/core/preferences.js +8 -1
  14. package/dist/core/preferences.js.map +1 -1
  15. package/dist/core/reliabilityPrompt.d.ts +9 -0
  16. package/dist/core/reliabilityPrompt.d.ts.map +1 -0
  17. package/dist/core/reliabilityPrompt.js +31 -0
  18. package/dist/core/reliabilityPrompt.js.map +1 -0
  19. package/dist/core/schemaValidator.js +3 -3
  20. package/dist/core/schemaValidator.js.map +1 -1
  21. package/dist/core/toolPreconditions.d.ts +0 -11
  22. package/dist/core/toolPreconditions.d.ts.map +1 -1
  23. package/dist/core/toolPreconditions.js +33 -164
  24. package/dist/core/toolPreconditions.js.map +1 -1
  25. package/dist/core/toolRuntime.d.ts.map +1 -1
  26. package/dist/core/toolRuntime.js +9 -114
  27. package/dist/core/toolRuntime.js.map +1 -1
  28. package/dist/core/updateChecker.d.ts +61 -1
  29. package/dist/core/updateChecker.d.ts.map +1 -1
  30. package/dist/core/updateChecker.js +147 -3
  31. package/dist/core/updateChecker.js.map +1 -1
  32. package/dist/headless/headlessApp.d.ts.map +1 -1
  33. package/dist/headless/headlessApp.js +0 -39
  34. package/dist/headless/headlessApp.js.map +1 -1
  35. package/dist/plugins/tools/nodeDefaults.d.ts.map +1 -1
  36. package/dist/plugins/tools/nodeDefaults.js +0 -2
  37. package/dist/plugins/tools/nodeDefaults.js.map +1 -1
  38. package/dist/providers/openaiResponsesProvider.d.ts.map +1 -1
  39. package/dist/providers/openaiResponsesProvider.js +79 -74
  40. package/dist/providers/openaiResponsesProvider.js.map +1 -1
  41. package/dist/runtime/agentController.d.ts.map +1 -1
  42. package/dist/runtime/agentController.js +6 -3
  43. package/dist/runtime/agentController.js.map +1 -1
  44. package/dist/runtime/agentSession.d.ts +0 -2
  45. package/dist/runtime/agentSession.d.ts.map +1 -1
  46. package/dist/runtime/agentSession.js +2 -2
  47. package/dist/runtime/agentSession.js.map +1 -1
  48. package/dist/shell/interactiveShell.d.ts +11 -12
  49. package/dist/shell/interactiveShell.d.ts.map +1 -1
  50. package/dist/shell/interactiveShell.js +269 -193
  51. package/dist/shell/interactiveShell.js.map +1 -1
  52. package/dist/shell/systemPrompt.d.ts.map +1 -1
  53. package/dist/shell/systemPrompt.js +4 -15
  54. package/dist/shell/systemPrompt.js.map +1 -1
  55. package/dist/subagents/taskRunner.js +2 -1
  56. package/dist/subagents/taskRunner.js.map +1 -1
  57. package/dist/tools/bashTools.d.ts.map +1 -1
  58. package/dist/tools/bashTools.js +101 -8
  59. package/dist/tools/bashTools.js.map +1 -1
  60. package/dist/tools/diffUtils.d.ts +8 -2
  61. package/dist/tools/diffUtils.d.ts.map +1 -1
  62. package/dist/tools/diffUtils.js +72 -13
  63. package/dist/tools/diffUtils.js.map +1 -1
  64. package/dist/tools/grepTools.d.ts.map +1 -1
  65. package/dist/tools/grepTools.js +10 -2
  66. package/dist/tools/grepTools.js.map +1 -1
  67. package/dist/tools/searchTools.d.ts.map +1 -1
  68. package/dist/tools/searchTools.js +4 -2
  69. package/dist/tools/searchTools.js.map +1 -1
  70. package/dist/ui/PromptController.d.ts +2 -3
  71. package/dist/ui/PromptController.d.ts.map +1 -1
  72. package/dist/ui/PromptController.js +2 -3
  73. package/dist/ui/PromptController.js.map +1 -1
  74. package/dist/ui/ShellUIAdapter.d.ts +71 -18
  75. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  76. package/dist/ui/ShellUIAdapter.js +237 -139
  77. package/dist/ui/ShellUIAdapter.js.map +1 -1
  78. package/dist/ui/UnifiedUIController.d.ts +0 -1
  79. package/dist/ui/UnifiedUIController.d.ts.map +1 -1
  80. package/dist/ui/UnifiedUIController.js +0 -1
  81. package/dist/ui/UnifiedUIController.js.map +1 -1
  82. package/dist/ui/UnifiedUIRenderer.d.ts +122 -7
  83. package/dist/ui/UnifiedUIRenderer.d.ts.map +1 -1
  84. package/dist/ui/UnifiedUIRenderer.js +823 -130
  85. package/dist/ui/UnifiedUIRenderer.js.map +1 -1
  86. package/dist/ui/animatedStatus.d.ts +129 -0
  87. package/dist/ui/animatedStatus.d.ts.map +1 -0
  88. package/dist/ui/animatedStatus.js +384 -0
  89. package/dist/ui/animatedStatus.js.map +1 -0
  90. package/dist/ui/display.d.ts +13 -48
  91. package/dist/ui/display.d.ts.map +1 -1
  92. package/dist/ui/display.js +22 -105
  93. package/dist/ui/display.js.map +1 -1
  94. package/dist/ui/shortcutsHelp.d.ts.map +1 -1
  95. package/dist/ui/shortcutsHelp.js +0 -1
  96. package/dist/ui/shortcutsHelp.js.map +1 -1
  97. package/dist/ui/unified/index.d.ts +1 -1
  98. package/dist/ui/unified/index.d.ts.map +1 -1
  99. package/dist/ui/unified/index.js +0 -2
  100. package/dist/ui/unified/index.js.map +1 -1
  101. package/package.json +1 -2
  102. package/dist/StringUtils.d.ts +0 -8
  103. package/dist/StringUtils.d.ts.map +0 -1
  104. package/dist/StringUtils.js +0 -11
  105. package/dist/StringUtils.js.map +0 -1
  106. package/dist/core/aiFlowSupervisor.d.ts +0 -44
  107. package/dist/core/aiFlowSupervisor.d.ts.map +0 -1
  108. package/dist/core/aiFlowSupervisor.js +0 -299
  109. package/dist/core/aiFlowSupervisor.js.map +0 -1
  110. package/dist/core/cliTestHarness.d.ts +0 -200
  111. package/dist/core/cliTestHarness.d.ts.map +0 -1
  112. package/dist/core/cliTestHarness.js +0 -549
  113. package/dist/core/cliTestHarness.js.map +0 -1
  114. package/dist/core/testUtils.d.ts +0 -121
  115. package/dist/core/testUtils.d.ts.map +0 -1
  116. package/dist/core/testUtils.js +0 -235
  117. package/dist/core/testUtils.js.map +0 -1
  118. package/dist/core/toolValidation.d.ts +0 -116
  119. package/dist/core/toolValidation.d.ts.map +0 -1
  120. package/dist/core/toolValidation.js +0 -282
  121. package/dist/core/toolValidation.js.map +0 -1
  122. package/dist/ui/compactRenderer.d.ts +0 -139
  123. package/dist/ui/compactRenderer.d.ts.map +0 -1
  124. package/dist/ui/compactRenderer.js +0 -398
  125. package/dist/ui/compactRenderer.js.map +0 -1
  126. package/dist/ui/streamingFormatter.d.ts +0 -30
  127. package/dist/ui/streamingFormatter.d.ts.map +0 -1
  128. package/dist/ui/streamingFormatter.js +0 -91
  129. package/dist/ui/streamingFormatter.js.map +0 -1
  130. package/dist/utils/errorUtils.d.ts +0 -16
  131. package/dist/utils/errorUtils.d.ts.map +0 -1
  132. package/dist/utils/errorUtils.js +0 -66
  133. package/dist/utils/errorUtils.js.map +0 -1
@@ -11,9 +11,9 @@
11
11
  import * as readline from 'node:readline';
12
12
  import { EventEmitter } from 'node:events';
13
13
  import { homedir } from 'node:os';
14
- import { theme } from './theme.js';
14
+ import { theme, spinnerFrames } from './theme.js';
15
15
  import { isPlainOutputMode } from './outputMode.js';
16
- import { renderDivider } from './unified/layout.js';
16
+ import { ContextMeter, disposeAnimations } from './animatedStatus.js';
17
17
  const ESC = {
18
18
  HIDE_CURSOR: '\x1b[?25l',
19
19
  SHOW_CURSOR: '\x1b[?25h',
@@ -27,6 +27,11 @@ const ESC = {
27
27
  ERASE_DOWN: '\x1b[J',
28
28
  REVERSE: '\x1b[7m',
29
29
  RESET: '\x1b[0m',
30
+ // Scroll region control - CRITICAL for fixed bottom overlay
31
+ SET_SCROLL_REGION: (top, bottom) => `\x1b[${top};${bottom}r`,
32
+ RESET_SCROLL_REGION: '\x1b[r',
33
+ SAVE_CURSOR: '\x1b[s',
34
+ RESTORE_CURSOR: '\x1b[u',
30
35
  };
31
36
  const COLLAPSE_HINT = theme.ui.muted('…'); // subtle ellipsis, no key hint
32
37
  const NEWLINE_PLACEHOLDER = '↵';
@@ -51,13 +56,38 @@ export class UnifiedUIRenderer extends EventEmitter {
51
56
  hotkeysInToggleLine = new Set();
52
57
  collapsedPaste = null;
53
58
  mode = 'idle';
59
+ streamingStartTime = null;
54
60
  statusMessage = null;
55
61
  statusOverride = null;
56
62
  statusStreaming = null;
63
+ // Animated UI components
64
+ streamingSpinner = null;
65
+ thinkingIndicator = null;
66
+ contextMeter;
67
+ spinnerFrame = 0;
68
+ spinnerInterval = null;
69
+ // Compacting status animation
70
+ compactingStatusMessage = '';
71
+ compactingStatusFrame = 0;
72
+ compactingStatusInterval = null;
73
+ compactingSpinnerFrames = ['✻', '✼', '✻', '✺'];
74
+ // Animated activity line (e.g., "✳ Ruminating… (esc to interrupt · 34s · ↑1.2k)")
75
+ activityMessage = null;
76
+ activityPhraseIndex = 0;
77
+ activityStarFrame = 0;
78
+ activityStarFrames = ['✳', '✴', '✵', '✶', '✷', '✸'];
79
+ // Token count during streaming
80
+ streamingTokens = 0;
81
+ // Fun phrases to cycle through when no specific activity is provided
82
+ funActivityPhrases = [
83
+ 'Moseying', 'Ruminating', 'Pondering', 'Cogitating', 'Mulling',
84
+ 'Contemplating', 'Deliberating', 'Noodling', 'Percolating', 'Stewing',
85
+ 'Brewing', 'Simmering', 'Churning', 'Puzzling', 'Meandering',
86
+ 'Wandering', 'Musing', 'Daydreaming', 'Woolgathering', 'Chewing',
87
+ ];
57
88
  statusMeta = {};
58
89
  toggleState = {
59
90
  verificationEnabled: false,
60
- autoContinueEnabled: false,
61
91
  criticalApprovalMode: 'auto',
62
92
  };
63
93
  // ------------ Helpers ------------
@@ -69,10 +99,7 @@ export class UnifiedUIRenderer extends EventEmitter {
69
99
  lastPromptEvent = null;
70
100
  promptHeight = 0;
71
101
  lastOverlayHeight = 0;
72
- lastPromptIndex = 0;
73
- overlayBottomPadding = 1;
74
102
  inlinePanel = [];
75
- overlayInvalidated = false;
76
103
  hasConversationContent = false;
77
104
  isPromptActive = false;
78
105
  inputRenderOffset = 0;
@@ -91,7 +118,7 @@ export class UnifiedUIRenderer extends EventEmitter {
91
118
  lastRenderedEventKey = null;
92
119
  lastOutputEndedWithNewline = true;
93
120
  hasRenderedPrompt = false;
94
- hasEverRenderedOverlay = false; // Track if we've ever rendered to prevent first-render scrollback pollution
121
+ hasEverRenderedOverlay = false; // Track if we've ever rendered for inline clearing
95
122
  lastOverlay = null;
96
123
  allowPromptRender = true;
97
124
  inputCapture = null;
@@ -101,6 +128,8 @@ export class UnifiedUIRenderer extends EventEmitter {
101
128
  this.input = input;
102
129
  this.interactive = Boolean(this.output.isTTY && this.input.isTTY && !process.env['CI']);
103
130
  this.plainMode = isPlainOutputMode() || !this.interactive;
131
+ // Initialize animated components
132
+ this.contextMeter = new ContextMeter();
104
133
  this.rl = readline.createInterface({
105
134
  input: this.input,
106
135
  output: this.output,
@@ -132,9 +161,6 @@ export class UnifiedUIRenderer extends EventEmitter {
132
161
  this.updateTerminalSize();
133
162
  this.hasRenderedPrompt = false;
134
163
  this.lastOutputEndedWithNewline = true;
135
- // Don't render prompt immediately - wait for banner/content to be added first.
136
- // The prompt will render after the event queue processes the welcome banner.
137
- // This prevents the prompt from appearing at the bottom before the banner shows.
138
164
  this.write(ESC.SHOW_CURSOR);
139
165
  return;
140
166
  }
@@ -147,11 +173,27 @@ export class UnifiedUIRenderer extends EventEmitter {
147
173
  cleanup() {
148
174
  this.cancelInputCapture(new Error('Renderer disposed'));
149
175
  this.cancelPlainPasteCapture();
176
+ // Stop any running animations
177
+ if (this.spinnerInterval) {
178
+ clearInterval(this.spinnerInterval);
179
+ this.spinnerInterval = null;
180
+ }
181
+ if (this.streamingSpinner) {
182
+ this.streamingSpinner.stop();
183
+ this.streamingSpinner = null;
184
+ }
185
+ if (this.thinkingIndicator) {
186
+ this.thinkingIndicator.stop();
187
+ this.thinkingIndicator = null;
188
+ }
189
+ this.contextMeter.dispose();
190
+ disposeAnimations();
150
191
  if (!this.interactive) {
151
192
  this.rl.close();
152
193
  return;
153
194
  }
154
195
  if (!this.plainMode) {
196
+ // Clear the prompt area so it doesn't remain in scrollback history
155
197
  this.clearPromptArea();
156
198
  this.write(ESC.DISABLE_BRACKETED_PASTE);
157
199
  this.write(ESC.SHOW_CURSOR);
@@ -203,15 +245,25 @@ export class UnifiedUIRenderer extends EventEmitter {
203
245
  return;
204
246
  }
205
247
  if (key.ctrl && key.name === 'c') {
206
- if (this.buffer.length === 0) {
207
- this.emit('interrupt');
208
- }
209
- else {
248
+ // Three-stage Ctrl+C behavior:
249
+ // 1. Clear chat box if it has text
250
+ // 2. Interrupt/pause the AI if streaming
251
+ // 3. Quit the CLI if already idle
252
+ if (this.buffer.length > 0) {
253
+ // Stage 1: Clear the input buffer
210
254
  this.buffer = '';
211
255
  this.cursor = 0;
212
256
  this.renderPrompt();
213
257
  this.emitInputChange();
214
258
  }
259
+ else if (this.mode === 'streaming') {
260
+ // Stage 2: Interrupt the AI run
261
+ this.emit('interrupt');
262
+ }
263
+ else {
264
+ // Stage 3: Quit the CLI (emit exit signal)
265
+ this.emit('exit');
266
+ }
215
267
  return;
216
268
  }
217
269
  if (key.ctrl && key.name === 'd') {
@@ -230,6 +282,11 @@ export class UnifiedUIRenderer extends EventEmitter {
230
282
  return;
231
283
  }
232
284
  }
285
+ // Ctrl+O: Expand last tool result
286
+ if (key.ctrl && key.name === 'o') {
287
+ this.emit('expand-tool-result');
288
+ return;
289
+ }
233
290
  if (key.name === 'return' || key.name === 'enter') {
234
291
  if (this.collapsedPaste) {
235
292
  this.expandCollapsedPaste();
@@ -239,9 +296,12 @@ export class UnifiedUIRenderer extends EventEmitter {
239
296
  // If a slash command suggestion is highlighted, pressing Enter submits it immediately
240
297
  if (this.applySuggestion(true))
241
298
  return;
242
- // If buffer starts with '/' and the first suggestion exists, submit it
299
+ // Fallback: if buffer starts with '/' and suggestions exist, use the selected/first one
243
300
  if (this.buffer.startsWith('/') && this.suggestions.length > 0) {
244
- this.buffer = this.suggestions[this.suggestionIndex >= 0 ? this.suggestionIndex : 0]?.command ?? this.buffer;
301
+ const safeIndex = this.suggestionIndex >= 0 && this.suggestionIndex < this.suggestions.length
302
+ ? this.suggestionIndex
303
+ : 0;
304
+ this.buffer = this.suggestions[safeIndex]?.command ?? this.buffer;
245
305
  }
246
306
  this.submitText(this.buffer);
247
307
  return;
@@ -618,7 +678,11 @@ export class UnifiedUIRenderer extends EventEmitter {
618
678
  if (!this.buffer.startsWith('/') || this.suggestions.length === 0) {
619
679
  return false;
620
680
  }
621
- const selected = this.suggestions[this.suggestionIndex] ?? this.suggestions[0];
681
+ // Ensure suggestionIndex is valid, default to first item
682
+ const safeIndex = this.suggestionIndex >= 0 && this.suggestionIndex < this.suggestions.length
683
+ ? this.suggestionIndex
684
+ : 0;
685
+ const selected = this.suggestions[safeIndex];
622
686
  if (!selected) {
623
687
  return false;
624
688
  }
@@ -663,6 +727,7 @@ export class UnifiedUIRenderer extends EventEmitter {
663
727
  normalized === 'thought' ||
664
728
  normalized === 'stream' ||
665
729
  normalized === 'tool' ||
730
+ normalized === 'tool-result' ||
666
731
  normalized === 'build' ||
667
732
  normalized === 'test') {
668
733
  this.hasConversationContent = true;
@@ -789,6 +854,7 @@ export class UnifiedUIRenderer extends EventEmitter {
789
854
  if (event.type !== 'prompt') {
790
855
  this.lastRenderedEventKey = signature;
791
856
  }
857
+ // Clear the prompt area before writing new content
792
858
  if (this.promptHeight > 0 || this.lastOverlay) {
793
859
  this.clearPromptArea();
794
860
  }
@@ -800,10 +866,6 @@ export class UnifiedUIRenderer extends EventEmitter {
800
866
  }
801
867
  this.output.write(formatted);
802
868
  this.lastOutputEndedWithNewline = formatted.endsWith('\n');
803
- // Overlay must be re-anchored after new scrollback is written
804
- this.overlayInvalidated = true;
805
- // Don't re-render prompt after every event - wait for queue to finish
806
- // This prevents premature prompt rendering that cuts off responses
807
869
  }
808
870
  normalizeEventType(type) {
809
871
  switch (type) {
@@ -816,8 +878,9 @@ export class UnifiedUIRenderer extends EventEmitter {
816
878
  return 'stream';
817
879
  case 'tool':
818
880
  case 'tool-call':
819
- case 'tool-result':
820
881
  return 'tool';
882
+ case 'tool-result':
883
+ return 'tool-result';
821
884
  case 'build':
822
885
  return 'build';
823
886
  case 'test':
@@ -843,35 +906,378 @@ export class UnifiedUIRenderer extends EventEmitter {
843
906
  const lines = event.content.split('\n').map(line => line.trimEnd());
844
907
  return `${lines.join('\n')}\n`;
845
908
  }
909
+ // Compact, user-friendly formatting
846
910
  switch (event.type) {
847
911
  case 'prompt':
848
- return `\n> ${event.content}\n`; // Plain > like Claude Code
912
+ // User prompt - just the text (prompt box handles styling)
913
+ return `${theme.primary('>')} ${event.content}\n`;
849
914
  case 'thought': {
850
- // Claude Code style: first line, then indented continuation
851
- const lines = event.content.split('\n');
852
- const formatted = lines
853
- .map((line, i) => (i === 0 ? `${bullet} ${line}` : ` ${line}`))
854
- .join('\n');
855
- return `\n${formatted}\n`;
915
+ // Programmatic filter: reject content that looks like internal/garbage output
916
+ if (this.isGarbageOutput(event.content)) {
917
+ return '';
918
+ }
919
+ // Strip any existing bullet prefix (○ or ⏺) and use consistent ⏺
920
+ const cleanContent = event.content.replace(/^[○⏺]\s*/, '');
921
+ return `⏺ ${cleanContent}\n`;
922
+ }
923
+ case 'tool': {
924
+ // Compact tool display: ⚡ToolName → result
925
+ const content = event.content.replace(/^[⏺⚙○]\s*/, '');
926
+ return this.formatCompactToolCall(content);
927
+ }
928
+ case 'tool-result': {
929
+ // Inline result: └─ summary
930
+ return this.formatCompactToolResult(event.content);
856
931
  }
857
- case 'tool':
858
- // Tool calls don't have bullet in Claude Code - just the name
859
- return `\n${event.content}\n`;
860
932
  case 'build':
861
- return `\n${event.content}\n`;
933
+ return `${bullet} ${theme.warning('Build')} ${theme.ui.muted('→')} ${event.content}\n`;
862
934
  case 'test':
863
- return `\n${event.content}\n`;
935
+ return `${bullet} ${theme.info('Test')} ${theme.ui.muted('→')} ${event.content}\n`;
864
936
  case 'stream':
865
937
  return event.content;
866
938
  case 'response':
867
939
  default: {
868
- // Claude Code style: for first line, indent rest
869
- const lines = event.content.split('\n');
870
- return (`\n${lines
871
- .map((line, i) => (i === 0 ? `${bullet} ${line}` : ` ${line}`))
872
- .join('\n')}\n`);
940
+ // Programmatic filter: reject content that looks like internal/garbage output
941
+ if (this.isGarbageOutput(event.content)) {
942
+ return '';
943
+ }
944
+ // Clean response without excessive bullets
945
+ return this.formatCompactResponse(event.content);
946
+ }
947
+ }
948
+ }
949
+ /**
950
+ * Programmatic garbage detection - checks if content looks like internal/system output
951
+ * that shouldn't be shown to users. Uses structural checks, not pattern matching.
952
+ */
953
+ isGarbageOutput(content) {
954
+ if (!content || content.trim().length === 0)
955
+ return true;
956
+ // Structural check: content starting with < that isn't valid markdown/code
957
+ if (content.startsWith('<') && !content.startsWith('<http') && !content.startsWith('<!')) {
958
+ return true;
959
+ }
960
+ // Structural check: contains "to=functions." or "to=tools." (internal routing)
961
+ if (content.includes('to=functions.') || content.includes('to=tools.')) {
962
+ return true;
963
+ }
964
+ // Structural check: looks like internal instruction (quoted system text)
965
+ if (content.startsWith('"') && content.includes('block') && content.includes('tool')) {
966
+ return true;
967
+ }
968
+ // Structural check: very short content that's just timing info
969
+ if (content.length < 30 && /elapsed|seconds?|ms\b/i.test(content)) {
970
+ return true;
971
+ }
972
+ // Structural check: gibberish - high ratio of non-word characters
973
+ const alphaCount = (content.match(/[a-zA-Z]/g) || []).length;
974
+ const totalCount = content.replace(/\s/g, '').length;
975
+ if (totalCount > 20 && alphaCount / totalCount < 0.5) {
976
+ return true; // Less than 50% letters = likely garbage
977
+ }
978
+ return false;
979
+ }
980
+ /**
981
+ * Format text in Claude Code style: ⏺ prefix with wrapped continuation lines
982
+ * Example:
983
+ * ⏺ The AI ran tools but gave no response. Need to fix
984
+ * the response handling. Let me check where the AI's
985
+ * text response should be displayed:
986
+ */
987
+ formatClaudeCodeBlock(content) {
988
+ const bullet = '⏺';
989
+ const maxWidth = Math.min(this.cols - 4, 56); // Leave room for prefix and margins
990
+ const lines = content.split('\n');
991
+ const result = [];
992
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
993
+ const line = lines[lineIdx];
994
+ if (!line.trim()) {
995
+ result.push('');
996
+ continue;
997
+ }
998
+ // Word-wrap each line
999
+ const words = line.split(/(\s+)/);
1000
+ let currentLine = '';
1001
+ for (const word of words) {
1002
+ if ((currentLine + word).length > maxWidth && currentLine.trim()) {
1003
+ // First line of this paragraph gets ⏺, rest get indent
1004
+ const prefix = result.length === 0 && lineIdx === 0 ? `${bullet} ` : ' ';
1005
+ result.push(`${prefix}${currentLine.trimEnd()}`);
1006
+ currentLine = word.trimStart();
1007
+ }
1008
+ else {
1009
+ currentLine += word;
1010
+ }
1011
+ }
1012
+ if (currentLine.trim()) {
1013
+ const prefix = result.length === 0 && lineIdx === 0 ? `${bullet} ` : ' ';
1014
+ result.push(`${prefix}${currentLine.trimEnd()}`);
1015
+ }
1016
+ }
1017
+ return result.join('\n') + '\n';
1018
+ }
1019
+ /**
1020
+ * Format a tool call in Claude Code style:
1021
+ * ⏺ Search(pattern: "foo", path: "src",
1022
+ * output_mode: "content", head_limit: 30)
1023
+ */
1024
+ formatToolCall(content) {
1025
+ const bullet = '⏺';
1026
+ // Parse tool name and arguments
1027
+ const match = content.match(/^(\w+)\((.*)\)$/s);
1028
+ if (!match) {
1029
+ // Simple format without args
1030
+ const nameMatch = content.match(/^(\w+)/);
1031
+ if (nameMatch) {
1032
+ return `${bullet} ${theme.info(nameMatch[1])}\n`;
1033
+ }
1034
+ return `${bullet} ${content}\n`;
1035
+ }
1036
+ const toolName = match[1];
1037
+ const argsStr = match[2];
1038
+ const maxWidth = Math.min(this.cols - 4, 56);
1039
+ // Format: ⏺ ToolName(args...)
1040
+ const prefix = `${bullet} ${theme.info(toolName)}(`;
1041
+ const prefixLen = toolName.length + 3; // "⏺ ToolName(" visible length
1042
+ const indent = ' '.repeat(prefixLen + 4); // Extra indent for wrapped args
1043
+ // Parse and format arguments
1044
+ const args = this.parseToolArgs(argsStr);
1045
+ if (args.length === 0) {
1046
+ return `${prefix})\n`;
1047
+ }
1048
+ const lines = [];
1049
+ let currentLine = prefix;
1050
+ for (let i = 0; i < args.length; i++) {
1051
+ const arg = args[i];
1052
+ const argText = `${theme.ui.muted(arg.key + ':')} ${this.formatArgValue(arg.value)}`;
1053
+ const separator = i < args.length - 1 ? ', ' : ')';
1054
+ // Check if this arg fits on current line
1055
+ const testLine = currentLine + argText + separator;
1056
+ if (this.stripAnsi(testLine).length > maxWidth && currentLine !== prefix) {
1057
+ lines.push(currentLine.trimEnd());
1058
+ currentLine = indent + argText + separator;
1059
+ }
1060
+ else {
1061
+ currentLine += argText + separator;
873
1062
  }
874
1063
  }
1064
+ if (currentLine.trim()) {
1065
+ lines.push(currentLine.trimEnd());
1066
+ }
1067
+ return lines.join('\n') + '\n';
1068
+ }
1069
+ /**
1070
+ * Parse tool arguments from string like: key: "value", key2: value2
1071
+ */
1072
+ parseToolArgs(argsStr) {
1073
+ const args = [];
1074
+ // Simple regex to extract key: value pairs
1075
+ const regex = /(\w+):\s*("(?:[^"\\]|\\.)*"|[^,\)]+)/g;
1076
+ let match;
1077
+ while ((match = regex.exec(argsStr)) !== null) {
1078
+ args.push({ key: match[1], value: match[2].trim() });
1079
+ }
1080
+ return args;
1081
+ }
1082
+ /**
1083
+ * Format an argument value (truncate long strings)
1084
+ */
1085
+ formatArgValue(value) {
1086
+ // Remove surrounding quotes if present
1087
+ const isQuoted = value.startsWith('"') && value.endsWith('"');
1088
+ const inner = isQuoted ? value.slice(1, -1) : value;
1089
+ // Truncate long values
1090
+ const maxLen = 40;
1091
+ const truncated = inner.length > maxLen ? inner.slice(0, maxLen - 3) + '...' : inner;
1092
+ return isQuoted ? `"${truncated}"` : truncated;
1093
+ }
1094
+ /**
1095
+ * Format a tool result in Claude Code style:
1096
+ * ⎿ Found 12 lines (ctrl+o to expand)
1097
+ */
1098
+ formatToolResult(content) {
1099
+ // Check if this is a summary line (e.g., "Found X lines")
1100
+ const summaryMatch = content.match(/^(Found \d+ (?:lines?|files?|matches?)|Read \d+ lines?|Wrote \d+ lines?|Edited|Created|Deleted)/i);
1101
+ if (summaryMatch) {
1102
+ return ` ${theme.ui.muted('⎿')} ${content} ${theme.ui.muted('(ctrl+o to expand)')}\n`;
1103
+ }
1104
+ // For other results, show truncated preview
1105
+ const lines = content.split('\n');
1106
+ if (lines.length > 3) {
1107
+ const preview = lines.slice(0, 2).join('\n');
1108
+ return ` ${theme.ui.muted('⎿')} ${preview}\n ${theme.ui.muted(`... ${lines.length - 2} more lines (ctrl+o to expand)`)}\n`;
1109
+ }
1110
+ return ` ${theme.ui.muted('⎿')} ${content}\n`;
1111
+ }
1112
+ /**
1113
+ * Format a compact tool call: ⏺ Read → file.ts
1114
+ */
1115
+ formatCompactToolCall(content) {
1116
+ const bullet = '⏺';
1117
+ // Parse tool name and args
1118
+ const match = content.match(/^(\w+)\s*(?:\((.*)\))?$/s);
1119
+ if (!match) {
1120
+ return `${bullet} ${content}\n`;
1121
+ }
1122
+ const toolName = match[1];
1123
+ const argsStr = match[2]?.trim() || '';
1124
+ // If no args, just show tool name
1125
+ if (!argsStr) {
1126
+ return `${bullet} ${theme.info(toolName)}\n`;
1127
+ }
1128
+ // Format full params in Claude Code style with line wrapping
1129
+ // For long args, wrap them nicely with continuation indent
1130
+ const prefix = `${bullet} ${theme.info(toolName)}(`;
1131
+ const suffix = ')';
1132
+ const maxWidth = this.cols - 8; // Leave room for margins
1133
+ // Parse individual params
1134
+ const params = this.parseToolParams(argsStr);
1135
+ if (params.length === 0) {
1136
+ return `${prefix}${argsStr}${suffix}\n`;
1137
+ }
1138
+ // Format params with proper wrapping
1139
+ return this.formatToolParams(toolName, params, maxWidth);
1140
+ }
1141
+ /**
1142
+ * Parse tool params from args string
1143
+ */
1144
+ parseToolParams(argsStr) {
1145
+ const params = [];
1146
+ // Match key: "value" or key: value patterns
1147
+ const regex = /(\w+):\s*("(?:[^"\\]|\\.)*"|[^,\n]+)/g;
1148
+ let match;
1149
+ while ((match = regex.exec(argsStr)) !== null) {
1150
+ params.push({ key: match[1], value: match[2].trim() });
1151
+ }
1152
+ return params;
1153
+ }
1154
+ /**
1155
+ * Format tool params in Claude Code style with wrapping
1156
+ */
1157
+ formatToolParams(toolName, params, maxWidth) {
1158
+ const bullet = '⏺';
1159
+ const lines = [];
1160
+ const indent = ' '; // 8 spaces for continuation
1161
+ let currentLine = `${bullet} ${theme.info(toolName)}(`;
1162
+ let firstParam = true;
1163
+ for (const param of params) {
1164
+ const paramStr = firstParam
1165
+ ? `${param.key}: ${param.value}`
1166
+ : `, ${param.key}: ${param.value}`;
1167
+ // Check if adding this param would exceed width
1168
+ const testLine = currentLine + paramStr;
1169
+ const plainLength = testLine.replace(/\x1b\[[0-9;]*m/g, '').length;
1170
+ if (plainLength > maxWidth && !firstParam) {
1171
+ // Start new line
1172
+ lines.push(currentLine);
1173
+ currentLine = indent + `${param.key}: ${param.value}`;
1174
+ }
1175
+ else {
1176
+ currentLine += paramStr;
1177
+ }
1178
+ firstParam = false;
1179
+ }
1180
+ currentLine += ')';
1181
+ lines.push(currentLine);
1182
+ return lines.join('\n') + '\n';
1183
+ }
1184
+ /**
1185
+ * Extract a short summary from tool args
1186
+ */
1187
+ extractToolSummary(toolName, argsStr) {
1188
+ const tool = toolName.toLowerCase();
1189
+ // Extract path/file for file operations
1190
+ if (['read', 'write', 'edit', 'glob', 'grep', 'search'].includes(tool)) {
1191
+ const pathMatch = argsStr.match(/(?:path|file_path|pattern):\s*"([^"]+)"/);
1192
+ if (pathMatch) {
1193
+ const path = pathMatch[1];
1194
+ // Shorten long paths
1195
+ const short = path.length > 30 ? '…' + path.slice(-28) : path;
1196
+ return theme.ui.muted(short);
1197
+ }
1198
+ }
1199
+ // Extract command for bash
1200
+ if (tool === 'bash') {
1201
+ const cmdMatch = argsStr.match(/command:\s*"([^"]+)"/);
1202
+ if (cmdMatch) {
1203
+ const cmd = cmdMatch[1];
1204
+ const short = cmd.length > 40 ? cmd.slice(0, 37) + '…' : cmd;
1205
+ return theme.ui.muted(short);
1206
+ }
1207
+ }
1208
+ return null;
1209
+ }
1210
+ /**
1211
+ * Format a compact tool result: ⎿ Found X lines (ctrl+o to expand)
1212
+ */
1213
+ formatCompactToolResult(content) {
1214
+ // Parse common result patterns for summary
1215
+ const lineMatch = content.match(/(\d+)\s*lines?/i);
1216
+ const fileMatch = content.match(/(\d+)\s*(?:files?|matches?)/i);
1217
+ const readMatch = content.match(/read.*?(\d+)\s*lines?/i);
1218
+ let summary;
1219
+ if (readMatch) {
1220
+ summary = `Read ${readMatch[1]} lines`;
1221
+ }
1222
+ else if (lineMatch) {
1223
+ summary = `Found ${lineMatch[1]} line${lineMatch[1] === '1' ? '' : 's'}`;
1224
+ }
1225
+ else if (fileMatch) {
1226
+ summary = `Found ${fileMatch[1]} file${fileMatch[1] === '1' ? '' : 's'}`;
1227
+ }
1228
+ else if (content.match(/^(success|ok|done|completed|written|edited|created)/i)) {
1229
+ summary = '✓';
1230
+ }
1231
+ else {
1232
+ // Use content directly, truncated if needed
1233
+ summary = content.length > 40 ? content.slice(0, 37) + '…' : content;
1234
+ }
1235
+ return ` ${theme.ui.muted('⎿')} ${summary} ${theme.ui.muted('(ctrl+o to expand)')}\n`;
1236
+ }
1237
+ /**
1238
+ * Format a compact response with bullet on first line
1239
+ */
1240
+ formatCompactResponse(content) {
1241
+ const bullet = '⏺';
1242
+ const trimmed = content.trim();
1243
+ if (!trimmed)
1244
+ return '';
1245
+ // Single line responses - bullet prefix
1246
+ if (!trimmed.includes('\n') && trimmed.length < 80) {
1247
+ return `${bullet} ${trimmed}\n`;
1248
+ }
1249
+ // Multi-line: bullet on first, indent continuation
1250
+ const lines = trimmed.split('\n');
1251
+ const result = [];
1252
+ for (let i = 0; i < lines.length; i++) {
1253
+ const line = lines[i].trimEnd();
1254
+ if (!line) {
1255
+ result.push('');
1256
+ }
1257
+ else if (i === 0) {
1258
+ result.push(`${bullet} ${line}`);
1259
+ }
1260
+ else {
1261
+ result.push(` ${line}`);
1262
+ }
1263
+ }
1264
+ return result.join('\n') + '\n';
1265
+ }
1266
+ /**
1267
+ * Format streaming elapsed time in Claude Code style: 3m 30s
1268
+ */
1269
+ formatStreamingElapsed() {
1270
+ if (!this.streamingStartTime)
1271
+ return null;
1272
+ const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
1273
+ if (elapsed < 5)
1274
+ return null; // Don't show for very short durations
1275
+ const mins = Math.floor(elapsed / 60);
1276
+ const secs = elapsed % 60;
1277
+ if (mins > 0) {
1278
+ return `${mins}m ${secs}s`;
1279
+ }
1280
+ return `${secs}s`;
875
1281
  }
876
1282
  /**
877
1283
  * Format a compact conversation block (Claude Code style)
@@ -908,6 +1314,16 @@ export class UnifiedUIRenderer extends EventEmitter {
908
1314
  setMode(mode) {
909
1315
  const wasStreaming = this.mode === 'streaming';
910
1316
  this.mode = mode;
1317
+ // Track streaming start time for elapsed display
1318
+ if (mode === 'streaming' && !wasStreaming) {
1319
+ this.streamingStartTime = Date.now();
1320
+ this.streamingTokens = 0; // Reset token count
1321
+ this.startSpinnerAnimation();
1322
+ }
1323
+ else if (mode === 'idle' && wasStreaming) {
1324
+ this.streamingStartTime = null;
1325
+ this.stopSpinnerAnimation();
1326
+ }
911
1327
  if (wasStreaming && mode === 'idle' && !this.lastOutputEndedWithNewline) {
912
1328
  // Finish streaming on a fresh line so the next prompt/event doesn't collide
913
1329
  this.write('\n');
@@ -918,6 +1334,61 @@ export class UnifiedUIRenderer extends EventEmitter {
918
1334
  this.renderPrompt();
919
1335
  }
920
1336
  }
1337
+ /**
1338
+ * Start the animated spinner for streaming status
1339
+ */
1340
+ startSpinnerAnimation() {
1341
+ if (this.spinnerInterval)
1342
+ return; // Already running
1343
+ this.spinnerFrame = 0;
1344
+ this.activityStarFrame = 0;
1345
+ this.spinnerInterval = setInterval(() => {
1346
+ this.spinnerFrame = (this.spinnerFrame + 1) % spinnerFrames.braille.length;
1347
+ this.activityStarFrame = (this.activityStarFrame + 1) % this.activityStarFrames.length;
1348
+ // Re-render to show updated spinner/star frame
1349
+ if (!this.plainMode && this.mode === 'streaming') {
1350
+ this.renderPrompt();
1351
+ }
1352
+ }, 80); // ~12 FPS for smooth spinner animation
1353
+ }
1354
+ /**
1355
+ * Stop the animated spinner
1356
+ */
1357
+ stopSpinnerAnimation() {
1358
+ if (this.spinnerInterval) {
1359
+ clearInterval(this.spinnerInterval);
1360
+ this.spinnerInterval = null;
1361
+ }
1362
+ this.spinnerFrame = 0;
1363
+ this.activityStarFrame = 0;
1364
+ this.activityMessage = null;
1365
+ }
1366
+ /**
1367
+ * Set the activity message displayed with animated star
1368
+ * Example: "Ruminating…" shows as "✳ Ruminating… (esc to interrupt · 34s · ↑1.2k)"
1369
+ */
1370
+ setActivity(message) {
1371
+ this.activityMessage = message;
1372
+ if (!this.plainMode) {
1373
+ this.renderPrompt();
1374
+ }
1375
+ }
1376
+ /**
1377
+ * Update the token count displayed in the activity line
1378
+ */
1379
+ updateStreamingTokens(tokens) {
1380
+ this.streamingTokens = tokens;
1381
+ }
1382
+ /**
1383
+ * Format token count as compact string (e.g., 1.2k, 24k, 128k)
1384
+ */
1385
+ formatTokenCount(tokens) {
1386
+ if (tokens < 1000)
1387
+ return String(tokens);
1388
+ if (tokens < 10000)
1389
+ return `${(tokens / 1000).toFixed(1)}k`;
1390
+ return `${Math.round(tokens / 1000)}k`;
1391
+ }
921
1392
  getMode() {
922
1393
  return this.mode;
923
1394
  }
@@ -966,8 +1437,7 @@ export class UnifiedUIRenderer extends EventEmitter {
966
1437
  }
967
1438
  updateModeToggles(state) {
968
1439
  this.toggleState = { ...this.toggleState, ...state };
969
- if (!state.autoContinueHotkey &&
970
- !state.verificationHotkey &&
1440
+ if (!state.verificationHotkey &&
971
1441
  !state.thinkingHotkey &&
972
1442
  !state.criticalApprovalHotkey) {
973
1443
  this.hotkeysInToggleLine.clear();
@@ -1013,13 +1483,9 @@ export class UnifiedUIRenderer extends EventEmitter {
1013
1483
  if (!this.allowPromptRender) {
1014
1484
  return;
1015
1485
  }
1016
- // Rich mode: inline overlay anchored to current scrollback (no full-screen clear)
1486
+ // Rich inline mode: prompt flows naturally with content
1017
1487
  this.updateTerminalSize();
1018
1488
  const maxWidth = this.safeWidth();
1019
- if (this.lastRenderWidth !== null && maxWidth !== this.lastRenderWidth) {
1020
- // Terminal resized; force a clean anchor so the overlay doesn't jitter.
1021
- this.overlayInvalidated = true;
1022
- }
1023
1489
  this.lastRenderWidth = maxWidth;
1024
1490
  const overlay = this.buildOverlayLines();
1025
1491
  if (!overlay.lines.length) {
@@ -1029,81 +1495,183 @@ export class UnifiedUIRenderer extends EventEmitter {
1029
1495
  if (!renderedLines.length) {
1030
1496
  return;
1031
1497
  }
1032
- let promptIndex = Math.max(0, Math.min(overlay.promptIndex, renderedLines.length - 1));
1033
- let height = renderedLines.length;
1034
- // Keep at least one free line below the overlay so typing always has breathing room
1035
- const bottomPadding = this.overlayBottomPadding;
1036
- const totalRows = this.rows || 24;
1037
- const availableRows = Math.max(1, totalRows - bottomPadding);
1038
- if (height > availableRows) {
1039
- renderedLines.splice(availableRows);
1040
- height = renderedLines.length;
1041
- promptIndex = Math.max(0, Math.min(promptIndex, height - 1));
1042
- }
1043
- const startRow = Math.max(1, availableRows - height + 1);
1044
- const promptRow = startRow + promptIndex;
1045
- const promptCol = Math.min(Math.max(1, 3 + this.cursor), this.cols || 80);
1046
- // Clear any previous overlay footprint (status, prompt, controls) to avoid leaking into scrollback
1047
- this.clearOverlayRows(height, startRow);
1048
- if (bottomPadding > 0 && startRow + height <= totalRows) {
1049
- this.write(ESC.TO(startRow + height, 1));
1050
- this.write(ESC.CLEAR_LINE);
1498
+ const promptIndex = Math.max(0, Math.min(overlay.promptIndex, renderedLines.length - 1));
1499
+ const height = renderedLines.length;
1500
+ // Clear previous prompt and handle height changes
1501
+ if (this.hasEverRenderedOverlay && this.lastOverlayHeight > 0 && this.lastOverlay) {
1502
+ // Move up from prompt row to top of overlay
1503
+ const linesToTop = this.lastOverlay.promptIndex;
1504
+ if (linesToTop > 0) {
1505
+ this.write(`\x1b[${linesToTop}A`);
1506
+ }
1507
+ // Clear all previous lines
1508
+ for (let i = 0; i < this.lastOverlayHeight; i++) {
1509
+ this.write('\r');
1510
+ this.write(ESC.CLEAR_LINE);
1511
+ if (i < this.lastOverlayHeight - 1) {
1512
+ this.write('\x1b[B');
1513
+ }
1514
+ }
1515
+ // If new height is greater, we need to add blank lines
1516
+ const extraLines = height - this.lastOverlayHeight;
1517
+ if (extraLines > 0) {
1518
+ for (let i = 0; i < extraLines; i++) {
1519
+ this.write('\n');
1520
+ }
1521
+ }
1522
+ // Move back to top of where overlay should start
1523
+ const moveBackUp = Math.max(0, height - 1);
1524
+ if (moveBackUp > 0) {
1525
+ this.write(`\x1b[${moveBackUp}A`);
1526
+ }
1051
1527
  }
1052
- // Render overlay lines in place without pushing scrollback
1053
- for (let idx = 0; idx < height; idx++) {
1054
- const row = startRow + idx;
1055
- const line = renderedLines[idx] ?? '';
1056
- this.write(ESC.TO(row, 1));
1528
+ // Write prompt lines (no trailing newline on last line)
1529
+ for (let i = 0; i < renderedLines.length; i++) {
1530
+ this.write('\r');
1057
1531
  this.write(ESC.CLEAR_LINE);
1058
- if (line) {
1059
- this.write(line);
1532
+ this.write(renderedLines[i] || '');
1533
+ if (i < renderedLines.length - 1) {
1534
+ this.write('\n');
1060
1535
  }
1061
1536
  }
1062
- // Position cursor at prompt row/col
1063
- this.write(ESC.TO(promptRow, promptCol));
1537
+ // Position cursor at prompt input line
1538
+ const promptCol = Math.min(Math.max(1, 3 + this.cursor), this.cols || 80);
1539
+ // Cursor is now at the last line. Move up to the prompt row.
1540
+ const linesToMoveUp = height - 1 - promptIndex;
1541
+ if (linesToMoveUp > 0) {
1542
+ this.write(`\x1b[${linesToMoveUp}A`);
1543
+ }
1544
+ this.write(`\x1b[${promptCol}G`);
1064
1545
  this.cursorVisibleColumn = promptCol;
1065
1546
  this.hasRenderedPrompt = true;
1066
- this.hasEverRenderedOverlay = true; // Mark that we've rendered at least once
1547
+ this.hasEverRenderedOverlay = true;
1067
1548
  this.isPromptActive = true;
1068
1549
  this.lastOverlayHeight = height;
1069
- this.lastPromptIndex = promptIndex;
1070
1550
  this.lastOverlay = { lines: renderedLines, promptIndex };
1071
- this.overlayInvalidated = false;
1072
- this.lastOutputEndedWithNewline = true;
1551
+ this.lastOutputEndedWithNewline = false;
1073
1552
  this.promptHeight = height;
1074
1553
  }
1075
1554
  buildOverlayLines() {
1076
1555
  const lines = [];
1077
1556
  const maxWidth = this.safeWidth();
1078
- const chromeLines = this.buildChromeLines();
1079
- for (const line of chromeLines) {
1080
- lines.push(this.truncateLine(line, maxWidth));
1557
+ // Simple horizontal divider - clean and reliable
1558
+ const divider = theme.ui.muted('─'.repeat(Math.min(maxWidth, 56)));
1559
+ // Activity line (only when streaming) - shows: ✽ Moseying… (esc to interrupt · 34s)
1560
+ if (this.mode === 'streaming' && this.activityMessage) {
1561
+ // Animated sparkle
1562
+ const spinnerChars = ['✽', '✾', '✿', '❀', '❁', '❂', '❃', '✻'];
1563
+ const spinnerChar = spinnerChars[this.spinnerFrame % spinnerChars.length] ?? '✽';
1564
+ const elapsed = this.formatStreamingElapsed();
1565
+ // Use fun phrases for generic activity, otherwise show specific activity
1566
+ const genericActivities = ['Streaming', 'Thinking', 'Processing'];
1567
+ const displayActivity = genericActivities.includes(this.activityMessage)
1568
+ ? this.funActivityPhrases[this.activityPhraseIndex % this.funActivityPhrases.length]
1569
+ : this.activityMessage;
1570
+ // Format: ✽ Moseying… (esc to interrupt · 1m 19s · ↑1.2k tokens)
1571
+ const parts = ['esc to interrupt'];
1572
+ if (elapsed)
1573
+ parts.push(elapsed);
1574
+ if (this.streamingTokens > 0) {
1575
+ parts.push(`↑${this.formatTokenCount(this.streamingTokens)} tokens`);
1576
+ }
1577
+ const activityLine = `${theme.info(spinnerChar)} ${displayActivity}… ${theme.ui.muted(`(${parts.join(' · ')})`)}`;
1578
+ lines.push(this.truncateLine(activityLine, maxWidth));
1081
1579
  }
1082
- const divider = renderDivider(Math.min(maxWidth, 96), 'prompt');
1083
- lines.push(this.truncateLine(divider, maxWidth));
1580
+ // Top divider
1581
+ lines.push(divider);
1582
+ // Input prompt line
1084
1583
  const promptIndex = lines.length;
1085
- lines.push(this.truncateLine(this.buildInputLine(), maxWidth));
1584
+ const inputLine = this.buildInputLine();
1585
+ // Handle multi-line input by splitting on newlines
1586
+ const inputLines = inputLine.split('\n');
1587
+ for (const line of inputLines) {
1588
+ lines.push(this.truncateLine(line, maxWidth));
1589
+ }
1590
+ // Bottom divider
1591
+ lines.push(divider);
1592
+ // Inline panel (pinned scroll box for live output/menus)
1593
+ if (this.inlinePanel.length > 0) {
1594
+ for (const panelLine of this.inlinePanel) {
1595
+ lines.push(this.truncateLine(` ${panelLine}`, maxWidth));
1596
+ }
1597
+ // Separate inline content from suggestions/toggles
1598
+ lines.push(divider);
1599
+ }
1600
+ // Slash command suggestions
1086
1601
  if (this.suggestions.length > 0) {
1087
1602
  for (let index = 0; index < this.suggestions.length; index++) {
1088
1603
  const suggestion = this.suggestions[index];
1089
1604
  const isActive = index === this.suggestionIndex;
1090
- const marker = isActive ? theme.primary('') : theme.ui.muted('');
1605
+ const marker = isActive ? theme.primary('') : theme.ui.muted(' ');
1091
1606
  const cmdText = isActive ? theme.primary(suggestion.command) : theme.ui.muted(suggestion.command);
1092
1607
  const descText = isActive ? suggestion.description : theme.ui.muted(suggestion.description);
1093
- lines.push(this.truncateLine(`${marker} ${cmdText} — ${descText}`, maxWidth));
1608
+ lines.push(this.truncateLine(` ${marker} ${cmdText} — ${descText}`, maxWidth));
1094
1609
  }
1095
1610
  }
1096
- if (this.inlinePanel.length > 0) {
1097
- for (const panelLine of this.inlinePanel) {
1098
- lines.push(this.truncateLine(panelLine, maxWidth));
1099
- }
1611
+ // Model and context info
1612
+ const modelContextLine = this.buildModelContextLine();
1613
+ if (modelContextLine) {
1614
+ lines.push(this.truncateLine(` ${modelContextLine}`, maxWidth));
1100
1615
  }
1101
- const controlLines = this.buildControlLines();
1102
- for (const control of controlLines) {
1103
- lines.push(this.truncateLine(control, maxWidth));
1616
+ // Mode toggles
1617
+ const toggleLine = this.buildInlineToggleLine();
1618
+ if (toggleLine) {
1619
+ lines.push(this.truncateLine(` ${toggleLine}`, maxWidth));
1104
1620
  }
1621
+ // Help hint
1622
+ lines.push(this.truncateLine(` ${theme.ui.muted('? for shortcuts')}`, maxWidth));
1105
1623
  return { lines, promptIndex };
1106
1624
  }
1625
+ /**
1626
+ * Build model name and context usage line with mini progress bar
1627
+ * Format: gpt-4 · ████░░ 85% context
1628
+ */
1629
+ buildModelContextLine() {
1630
+ const parts = [];
1631
+ // Model name (provider / model or just model)
1632
+ const model = this.statusMeta.provider && this.statusMeta.model
1633
+ ? `${this.statusMeta.provider} · ${this.statusMeta.model}`
1634
+ : this.statusMeta.model || this.statusMeta.provider;
1635
+ if (model) {
1636
+ parts.push(theme.info(model));
1637
+ }
1638
+ // Context meter with mini progress bar
1639
+ if (this.statusMeta.contextPercent !== undefined) {
1640
+ const remaining = Math.max(0, 100 - this.statusMeta.contextPercent);
1641
+ const barWidth = 6;
1642
+ const filled = Math.round((remaining / 100) * barWidth);
1643
+ const empty = barWidth - filled;
1644
+ const barColor = remaining > 50 ? theme.success : remaining > 20 ? theme.warning : theme.error;
1645
+ const bar = barColor('█'.repeat(filled)) + theme.ui.muted('░'.repeat(empty));
1646
+ parts.push(`${bar} ${barColor(`${remaining}%`)} ${theme.ui.muted('ctx')}`);
1647
+ }
1648
+ return parts.length > 0 ? parts.join(theme.ui.muted(' · ')) : null;
1649
+ }
1650
+ /**
1651
+ * Build inline toggle controls - Claude Code style
1652
+ * Format: ⏵⏵ accept edits on (shift+tab to cycle)
1653
+ */
1654
+ buildInlineToggleLine() {
1655
+ const parts = [];
1656
+ // Edit acceptance mode - Claude Code style with ⏵⏵
1657
+ const editIcon = '⏵⏵';
1658
+ const editState = this.toggleState.verificationEnabled ? 'verify edits' : 'accept edits';
1659
+ const editStatus = this.toggleState.verificationEnabled ? theme.warning('on') : theme.success('on');
1660
+ parts.push(`${theme.ui.muted(editIcon)} ${editState} ${editStatus}`);
1661
+ // Thinking mode (if not default)
1662
+ const thinkingLabel = (this.toggleState.thinkingModeLabel || 'balanced').trim().toLowerCase();
1663
+ if (thinkingLabel === 'extended') {
1664
+ parts.push(`${theme.ui.muted('thinking')} ${theme.info('extended')}`);
1665
+ }
1666
+ // Approval mode (if not auto)
1667
+ const approvalMode = this.toggleState.criticalApprovalMode || 'auto';
1668
+ if (approvalMode === 'approval') {
1669
+ parts.push(`${theme.ui.muted('approvals')} ${theme.warning('ask')}`);
1670
+ }
1671
+ // Cycle hint
1672
+ const cycleHint = theme.ui.muted('(shift+tab to cycle)');
1673
+ return parts.length > 0 ? `${parts.join(theme.ui.muted(' · '))} ${cycleHint}` : null;
1674
+ }
1107
1675
  buildChromeLines() {
1108
1676
  const maxWidth = this.safeWidth();
1109
1677
  const statusLines = this.buildStatusBlock(maxWidth);
@@ -1123,15 +1691,22 @@ export class UnifiedUIRenderer extends EventEmitter {
1123
1691
  return [];
1124
1692
  }
1125
1693
  const segments = [];
1126
- segments.push(`${theme.ui.muted('status')} ${this.applyTone(statusLabel.text, statusLabel.tone)}`);
1694
+ // Add animated spinner when streaming for dynamic visual feedback
1695
+ if (this.mode === 'streaming') {
1696
+ const spinnerChars = spinnerFrames.braille;
1697
+ const spinnerChar = spinnerChars[this.spinnerFrame % spinnerChars.length] ?? '⠋';
1698
+ segments.push(`${theme.info(spinnerChar)} ${this.applyTone(statusLabel.text, statusLabel.tone)}`);
1699
+ }
1700
+ else {
1701
+ segments.push(`${theme.ui.muted('status')} ${this.applyTone(statusLabel.text, statusLabel.tone)}`);
1702
+ }
1127
1703
  if (this.statusMeta.sessionTime) {
1128
1704
  segments.push(`${theme.ui.muted('runtime')} ${theme.ui.muted(this.statusMeta.sessionTime)}`);
1129
1705
  }
1130
1706
  if (this.statusMeta.contextPercent !== undefined) {
1131
- const ctx = this.statusMeta.contextPercent;
1132
- const tone = ctx > 90 ? 'error' : ctx > 70 ? 'warn' : 'muted';
1133
- const color = tone === 'error' ? theme.error : tone === 'warn' ? theme.warning : theme.ui.muted;
1134
- segments.push(`${theme.ui.muted('ctx')} ${color(`${ctx}%`)}`);
1707
+ // Use animated context meter for smooth color transitions
1708
+ this.contextMeter.update(this.statusMeta.contextPercent);
1709
+ segments.push(this.contextMeter.render());
1135
1710
  }
1136
1711
  return this.wrapSegments(segments, maxWidth);
1137
1712
  }
@@ -1240,12 +1815,34 @@ export class UnifiedUIRenderer extends EventEmitter {
1240
1815
  }
1241
1816
  return lines;
1242
1817
  }
1818
+ /**
1819
+ * Build a compact toggle line like Claude Code:
1820
+ * "⏵⏵ accept edits on (shift+tab to cycle)"
1821
+ */
1822
+ buildCompactToggleLine() {
1823
+ // Show the most relevant mode based on current state
1824
+ const parts = [];
1825
+ // Edit mode indicator
1826
+ const editIcon = '⏵⏵';
1827
+ const editState = this.toggleState.verificationEnabled ? 'approval required' : 'accept edits';
1828
+ parts.push(`${theme.ui.muted(editIcon)} ${editState} ${theme.success('on')}`);
1829
+ // Thinking mode (if active)
1830
+ const thinkingLabel = (this.toggleState.thinkingModeLabel || '').trim().toLowerCase();
1831
+ if (thinkingLabel && thinkingLabel !== 'off') {
1832
+ parts.push(`${theme.ui.muted('thinking')} ${theme.info(thinkingLabel)}`);
1833
+ }
1834
+ // Cycle hint
1835
+ const cycleHint = theme.ui.muted('(shift+tab to cycle)');
1836
+ if (parts.length === 0) {
1837
+ return null;
1838
+ }
1839
+ return ` ${parts.join(theme.ui.muted(' · '))} ${cycleHint}`;
1840
+ }
1243
1841
  buildToggleLine() {
1244
1842
  const toggles = [];
1245
1843
  const addToggle = (label, on, hotkey, value) => {
1246
1844
  toggles.push({ label, on, hotkey: this.formatHotkey(hotkey), value });
1247
1845
  };
1248
- addToggle('Auto', this.toggleState.autoContinueEnabled, this.toggleState.autoContinueHotkey);
1249
1846
  addToggle('Verify', this.toggleState.verificationEnabled, this.toggleState.verificationHotkey);
1250
1847
  const approvalMode = this.toggleState.criticalApprovalMode || 'auto';
1251
1848
  const approvalActive = approvalMode !== 'auto';
@@ -1290,7 +1887,6 @@ export class UnifiedUIRenderer extends EventEmitter {
1290
1887
  addHotkey('interrupt', 'Ctrl+C');
1291
1888
  addHotkey('clear input', 'Ctrl+U');
1292
1889
  // Feature toggles (only if hotkeys are defined)
1293
- addHotkey('auto-run', this.toggleState.autoContinueHotkey);
1294
1890
  addHotkey('verify', this.toggleState.verificationHotkey);
1295
1891
  addHotkey('thinking', this.toggleState.thinkingHotkey);
1296
1892
  if (parts.length === 0) {
@@ -1301,15 +1897,87 @@ export class UnifiedUIRenderer extends EventEmitter {
1301
1897
  buildInputLine() {
1302
1898
  if (this.collapsedPaste) {
1303
1899
  const summary = `[pasted ${this.collapsedPaste.lines} lines, ${this.collapsedPaste.chars} chars] (Ctrl+L insert, Backspace discard)`;
1304
- return this.truncateLine(`${theme.primary(' ')}${theme.ui.muted(summary)}`, this.safeWidth());
1900
+ return this.truncateLine(`${theme.primary('> ')}${theme.ui.muted(summary)}`, this.safeWidth());
1305
1901
  }
1306
- const prompt = theme.primary(' ');
1902
+ // Claude Code uses simple '>' prompt
1903
+ const prompt = theme.primary('> ');
1307
1904
  const promptWidth = this.visibleLength(prompt);
1308
1905
  const maxWidth = this.safeWidth();
1309
- const available = Math.max(1, maxWidth - promptWidth);
1310
- const window = this.buildInputWindow(available);
1311
- this.cursorVisibleColumn = Math.min(maxWidth, promptWidth + window.cursor + 1);
1312
- return this.truncateLine(`${prompt}${window.text}`, maxWidth);
1906
+ const continuationIndent = ' '; // 2 spaces for continuation lines
1907
+ const continuationWidth = continuationIndent.length;
1908
+ // Handle multi-line input - split buffer on newlines first
1909
+ const normalized = this.buffer.replace(/\r/g, '\n');
1910
+ const bufferLines = normalized.split('\n');
1911
+ // Wrap each logical line to fit terminal width, expanding vertically
1912
+ const result = [];
1913
+ let totalChars = 0;
1914
+ let cursorLine = 0;
1915
+ let cursorCol = 0;
1916
+ let foundCursor = false;
1917
+ for (let lineIndex = 0; lineIndex < bufferLines.length; lineIndex++) {
1918
+ const line = bufferLines[lineIndex] ?? '';
1919
+ const isFirstLogicalLine = lineIndex === 0;
1920
+ const lineStartChar = totalChars;
1921
+ // Determine available width for this line
1922
+ const firstLineWidth = maxWidth - promptWidth;
1923
+ const contLineWidth = maxWidth - continuationWidth;
1924
+ // Wrap this logical line into display lines
1925
+ let remaining = line;
1926
+ let isFirstDisplayLine = true;
1927
+ while (remaining.length > 0 || isFirstDisplayLine) {
1928
+ const availableWidth = (isFirstLogicalLine && isFirstDisplayLine) ? firstLineWidth : contLineWidth;
1929
+ const chunk = remaining.slice(0, availableWidth);
1930
+ remaining = remaining.slice(availableWidth);
1931
+ // Build the display line
1932
+ let displayLine;
1933
+ if (isFirstLogicalLine && isFirstDisplayLine) {
1934
+ displayLine = `${prompt}${chunk}`;
1935
+ }
1936
+ else {
1937
+ displayLine = `${continuationIndent}${chunk}`;
1938
+ }
1939
+ // Track cursor position
1940
+ if (!foundCursor) {
1941
+ const chunkStart = lineStartChar + (line.length - remaining.length - chunk.length);
1942
+ const chunkEnd = chunkStart + chunk.length;
1943
+ if (this.cursor >= chunkStart && this.cursor <= chunkEnd) {
1944
+ cursorLine = result.length;
1945
+ const offsetInChunk = this.cursor - chunkStart;
1946
+ cursorCol = ((isFirstLogicalLine && isFirstDisplayLine) ? promptWidth : continuationWidth) + offsetInChunk;
1947
+ foundCursor = true;
1948
+ }
1949
+ }
1950
+ result.push(displayLine);
1951
+ isFirstDisplayLine = false;
1952
+ // If nothing left and this was an empty line, we already added it
1953
+ if (remaining.length === 0 && chunk.length === 0)
1954
+ break;
1955
+ }
1956
+ totalChars += line.length + 1; // +1 for the newline separator
1957
+ }
1958
+ // Handle cursor at very end
1959
+ if (!foundCursor) {
1960
+ cursorLine = Math.max(0, result.length - 1);
1961
+ const lastLine = result[cursorLine] ?? '';
1962
+ cursorCol = this.visibleLength(lastLine);
1963
+ }
1964
+ // Add cursor highlight to the appropriate position
1965
+ if (result.length > 0) {
1966
+ const targetLine = result[cursorLine] ?? '';
1967
+ const visiblePart = this.stripAnsi(targetLine);
1968
+ const cursorPos = Math.min(cursorCol, visiblePart.length);
1969
+ // Rebuild the line with cursor highlight
1970
+ const before = visiblePart.slice(0, cursorPos);
1971
+ const at = visiblePart.charAt(cursorPos) || ' ';
1972
+ const after = visiblePart.slice(cursorPos + 1);
1973
+ // Preserve the prompt/indent styling
1974
+ const prefix = cursorLine === 0 ? prompt : continuationIndent;
1975
+ const textPart = cursorLine === 0 ? before.slice(promptWidth) : before.slice(continuationWidth);
1976
+ result[cursorLine] = `${prefix}${textPart}${ESC.REVERSE}${at}${ESC.RESET}${after}`;
1977
+ }
1978
+ // Store cursor column for terminal positioning
1979
+ this.cursorVisibleColumn = cursorCol + 1;
1980
+ return result.join('\n');
1313
1981
  }
1314
1982
  buildInputWindow(available) {
1315
1983
  if (available <= 0) {
@@ -1440,17 +2108,6 @@ export class UnifiedUIRenderer extends EventEmitter {
1440
2108
  }
1441
2109
  return result;
1442
2110
  }
1443
- clearOverlayRows(rows, startRow) {
1444
- const totalRows = this.rows || 24;
1445
- const limit = Math.max(0, Math.min(rows, totalRows));
1446
- for (let idx = 0; idx < limit; idx++) {
1447
- const row = startRow + idx;
1448
- if (row < 1 || row > totalRows)
1449
- continue;
1450
- this.write(ESC.TO(row, 1));
1451
- this.write(ESC.CLEAR_LINE);
1452
- }
1453
- }
1454
2111
  getBuffer() {
1455
2112
  return this.buffer;
1456
2113
  }
@@ -1478,6 +2135,35 @@ export class UnifiedUIRenderer extends EventEmitter {
1478
2135
  setModeStatus(status) {
1479
2136
  this.updateStatus(status);
1480
2137
  }
2138
+ /**
2139
+ * Show a compacting status with animated spinner (Claude Code style)
2140
+ * Uses ✻ character with animation to indicate context compaction in progress
2141
+ */
2142
+ showCompactingStatus(message) {
2143
+ this.statusMessage = message;
2144
+ if (!this.spinnerInterval) {
2145
+ this.spinnerInterval = setInterval(() => {
2146
+ this.spinnerFrame++;
2147
+ // Cycle activity phrase every ~4 seconds (50 frames at 80ms)
2148
+ if (this.spinnerFrame % 50 === 0) {
2149
+ this.activityPhraseIndex++;
2150
+ }
2151
+ this.renderPrompt();
2152
+ }, 80);
2153
+ }
2154
+ this.renderPrompt();
2155
+ }
2156
+ /**
2157
+ * Hide the compacting status and stop spinner animation
2158
+ */
2159
+ hideCompactingStatus() {
2160
+ if (this.spinnerInterval) {
2161
+ clearInterval(this.spinnerInterval);
2162
+ this.spinnerInterval = null;
2163
+ }
2164
+ this.statusMessage = null;
2165
+ this.renderPrompt();
2166
+ }
1481
2167
  emitPrompt(content) {
1482
2168
  this.pushPromptEvent(content);
1483
2169
  }
@@ -1521,23 +2207,30 @@ export class UnifiedUIRenderer extends EventEmitter {
1521
2207
  const height = this.lastOverlay?.lines.length ?? this.promptHeight ?? 0;
1522
2208
  if (height === 0)
1523
2209
  return;
1524
- this.updateTerminalSize();
1525
- const totalRows = this.rows || 24;
1526
- const startRow = Math.max(1, Math.max(1, totalRows - this.overlayBottomPadding) - height + 1);
1527
- this.clearOverlayRows(height, startRow);
1528
- // Keep the padding row clean as well
1529
- const paddingRow = startRow + height;
1530
- if (this.overlayBottomPadding > 0 && paddingRow <= totalRows) {
1531
- this.write(ESC.TO(paddingRow, 1));
2210
+ // Cursor is at prompt row. Move up to top of overlay first.
2211
+ if (this.lastOverlay) {
2212
+ const linesToTop = this.lastOverlay.promptIndex;
2213
+ if (linesToTop > 0) {
2214
+ this.write(`\x1b[${linesToTop}A`);
2215
+ }
2216
+ }
2217
+ // Now at top, clear each line downward
2218
+ for (let i = 0; i < height; i++) {
2219
+ this.write('\r');
1532
2220
  this.write(ESC.CLEAR_LINE);
2221
+ if (i < height - 1) {
2222
+ this.write('\x1b[B');
2223
+ }
1533
2224
  }
1534
- // Move cursor to the bottom ready for new scrollback output
1535
- this.write(ESC.TO(totalRows, 1));
1536
- this.lastOverlayHeight = height;
1537
- this.lastPromptIndex = this.lastOverlay?.promptIndex ?? this.lastPromptIndex;
2225
+ // Move back to top (where content should continue from)
2226
+ if (height > 1) {
2227
+ this.write(`\x1b[${height - 1}A`);
2228
+ }
2229
+ this.write('\r');
1538
2230
  this.lastOverlay = null;
1539
- this.overlayInvalidated = true;
1540
2231
  this.promptHeight = 0;
2232
+ this.lastOverlayHeight = 0;
2233
+ this.isPromptActive = false;
1541
2234
  }
1542
2235
  updateTerminalSize() {
1543
2236
  if (this.output.isTTY) {