erosolar-cli 2.1.167 → 2.1.168

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