@kirosnn/mosaic 0.0.91 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -6
  3. package/package.json +55 -48
  4. package/src/agent/Agent.ts +353 -131
  5. package/src/agent/context.ts +4 -4
  6. package/src/agent/prompts/systemPrompt.ts +209 -70
  7. package/src/agent/prompts/toolsPrompt.ts +285 -138
  8. package/src/agent/provider/anthropic.ts +109 -105
  9. package/src/agent/provider/google.ts +111 -107
  10. package/src/agent/provider/mistral.ts +95 -95
  11. package/src/agent/provider/ollama.ts +73 -17
  12. package/src/agent/provider/openai.ts +146 -102
  13. package/src/agent/provider/rateLimit.ts +178 -0
  14. package/src/agent/provider/reasoning.ts +29 -0
  15. package/src/agent/provider/xai.ts +108 -104
  16. package/src/agent/tools/definitions.ts +15 -1
  17. package/src/agent/tools/executor.ts +717 -98
  18. package/src/agent/tools/exploreExecutor.ts +20 -22
  19. package/src/agent/tools/fetch.ts +58 -0
  20. package/src/agent/tools/glob.ts +20 -4
  21. package/src/agent/tools/grep.ts +64 -9
  22. package/src/agent/tools/plan.ts +27 -0
  23. package/src/agent/tools/question.ts +7 -1
  24. package/src/agent/tools/read.ts +2 -0
  25. package/src/agent/types.ts +15 -14
  26. package/src/components/App.tsx +50 -8
  27. package/src/components/CustomInput.tsx +461 -77
  28. package/src/components/Main.tsx +1459 -1112
  29. package/src/components/Setup.tsx +1 -1
  30. package/src/components/ShortcutsModal.tsx +11 -8
  31. package/src/components/Welcome.tsx +1 -1
  32. package/src/components/main/ApprovalPanel.tsx +4 -3
  33. package/src/components/main/ChatPage.tsx +858 -516
  34. package/src/components/main/HomePage.tsx +58 -39
  35. package/src/components/main/QuestionPanel.tsx +52 -7
  36. package/src/components/main/ThinkingIndicator.tsx +13 -2
  37. package/src/components/main/types.ts +11 -10
  38. package/src/index.tsx +53 -25
  39. package/src/mcp/approvalPolicy.ts +148 -0
  40. package/src/mcp/cli/add.ts +185 -0
  41. package/src/mcp/cli/doctor.ts +77 -0
  42. package/src/mcp/cli/index.ts +85 -0
  43. package/src/mcp/cli/list.ts +50 -0
  44. package/src/mcp/cli/logs.ts +24 -0
  45. package/src/mcp/cli/manage.ts +99 -0
  46. package/src/mcp/cli/show.ts +53 -0
  47. package/src/mcp/cli/tools.ts +77 -0
  48. package/src/mcp/config.ts +223 -0
  49. package/src/mcp/index.ts +80 -0
  50. package/src/mcp/processManager.ts +299 -0
  51. package/src/mcp/rateLimiter.ts +50 -0
  52. package/src/mcp/registry.ts +151 -0
  53. package/src/mcp/schemaConverter.ts +100 -0
  54. package/src/mcp/servers/navigation.ts +854 -0
  55. package/src/mcp/toolCatalog.ts +169 -0
  56. package/src/mcp/types.ts +95 -0
  57. package/src/utils/approvalBridge.ts +45 -12
  58. package/src/utils/approvalModeBridge.ts +17 -0
  59. package/src/utils/commands/approvals.ts +48 -0
  60. package/src/utils/commands/compact.ts +30 -0
  61. package/src/utils/commands/echo.ts +1 -1
  62. package/src/utils/commands/image.ts +109 -0
  63. package/src/utils/commands/index.ts +9 -7
  64. package/src/utils/commands/new.ts +15 -0
  65. package/src/utils/commands/types.ts +3 -0
  66. package/src/utils/config.ts +3 -1
  67. package/src/utils/diffRendering.tsx +13 -16
  68. package/src/utils/exploreBridge.ts +10 -0
  69. package/src/utils/history.ts +82 -40
  70. package/src/utils/imageBridge.ts +28 -0
  71. package/src/utils/images.ts +31 -0
  72. package/src/utils/markdown.tsx +163 -99
  73. package/src/utils/models.ts +31 -16
  74. package/src/utils/notificationBridge.ts +23 -0
  75. package/src/utils/questionBridge.ts +36 -1
  76. package/src/utils/tokenEstimator.ts +32 -0
  77. package/src/utils/toolFormatting.ts +428 -48
  78. package/src/web/app.tsx +65 -5
  79. package/src/web/assets/css/ChatPage.css +102 -30
  80. package/src/web/assets/css/MessageItem.css +26 -29
  81. package/src/web/assets/css/ThinkingIndicator.css +44 -6
  82. package/src/web/assets/css/ToolMessage.css +36 -14
  83. package/src/web/components/ChatPage.tsx +228 -105
  84. package/src/web/components/HomePage.tsx +3 -3
  85. package/src/web/components/MessageItem.tsx +80 -81
  86. package/src/web/components/QuestionPanel.tsx +72 -12
  87. package/src/web/components/Setup.tsx +1 -1
  88. package/src/web/components/Sidebar.tsx +1 -3
  89. package/src/web/components/ThinkingIndicator.tsx +41 -21
  90. package/src/web/router.ts +1 -1
  91. package/src/web/server.tsx +894 -662
  92. package/src/web/storage.ts +23 -1
  93. package/src/web/types.ts +7 -6
  94. package/src/utils/commands/redo.ts +0 -74
  95. package/src/utils/commands/sessions.ts +0 -129
  96. package/src/utils/commands/undo.ts +0 -75
  97. package/src/utils/undoRedo.ts +0 -429
  98. package/src/utils/undoRedoBridge.ts +0 -45
  99. package/src/utils/undoRedoDb.ts +0 -338
@@ -1,516 +1,858 @@
1
- import { useEffect, useState } from "react";
2
- import { TextAttributes } from "@opentui/core";
3
- import { renderMarkdownSegment, parseAndWrapMarkdown } from "../../utils/markdown";
4
- import { getToolParagraphIndent, getToolWrapTarget, getToolWrapWidth } from "../../utils/toolFormatting";
5
- import { subscribeQuestion, answerQuestion, type QuestionRequest } from "../../utils/questionBridge";
6
- import { subscribeApproval, respondApproval, type ApprovalRequest } from "../../utils/approvalBridge";
7
- import { subscribeFileChanges } from "../../utils/fileChangesBridge";
8
- import type { FileChanges } from "../../utils/fileChangeTracker";
9
- import { CustomInput } from "../CustomInput";
10
- import type { Message } from "./types";
11
- import { wrapText } from "./wrapText";
12
- import { QuestionPanel } from "./QuestionPanel";
13
- import { ApprovalPanel } from "./ApprovalPanel";
14
- import { ThinkingIndicatorBlock, getBottomReservedLinesForInputBar, getInputBarBaseLines, getInputAreaTotalLines, formatElapsedTime } from "./ThinkingIndicator";
15
- import { renderInlineDiffLine, getDiffLineBackground } from "../../utils/diffRendering";
16
-
17
- function renderToolText(content: string, paragraphIndex: number, indent: number) {
18
- if (paragraphIndex === 0) {
19
- const match = content.match(/^(.+?)\s*\((.+)\)$/);
20
- if (match) {
21
- const [, toolName, toolInfo] = match;
22
- return (
23
- <>
24
- <text fg="white">{toolName}</text>
25
- <text fg="white" attributes={TextAttributes.DIM}> ({toolInfo})</text>
26
- </>
27
- );
28
- }
29
- }
30
-
31
- const diffLineRender = renderInlineDiffLine(content);
32
- if (diffLineRender) {
33
- return diffLineRender;
34
- }
35
-
36
- return <text fg="white">{`${' '.repeat(indent)}${content || ' '}`}</text>;
37
- }
38
-
39
- interface ChatPageProps {
40
- messages: Message[];
41
- isProcessing: boolean;
42
- processingStartTime: number | null;
43
- currentTokens: number;
44
- scrollOffset: number;
45
- terminalHeight: number;
46
- terminalWidth: number;
47
- pasteRequestId: number;
48
- shortcutsOpen: boolean;
49
- onSubmit: (value: string, meta?: import("../CustomInput").InputSubmitMeta) => void;
50
- }
51
-
52
- export function ChatPage({
53
- messages,
54
- isProcessing,
55
- processingStartTime,
56
- currentTokens,
57
- scrollOffset,
58
- terminalHeight,
59
- terminalWidth,
60
- pasteRequestId,
61
- shortcutsOpen,
62
- onSubmit,
63
- }: ChatPageProps) {
64
- const maxWidth = Math.max(20, terminalWidth - 6);
65
- const [questionRequest, setQuestionRequest] = useState<QuestionRequest | null>(null);
66
- const [approvalRequest, setApprovalRequest] = useState<ApprovalRequest | null>(null);
67
- const [fileChanges, setFileChanges] = useState<FileChanges>({ linesAdded: 0, linesRemoved: 0, filesModified: 0 });
68
- const [, setTimerTick] = useState(0);
69
-
70
- useEffect(() => {
71
- return subscribeQuestion(setQuestionRequest);
72
- }, []);
73
-
74
- useEffect(() => {
75
- return subscribeApproval(setApprovalRequest);
76
- }, []);
77
-
78
- useEffect(() => {
79
- return subscribeFileChanges(setFileChanges);
80
- }, []);
81
-
82
- useEffect(() => {
83
- const interval = setInterval(() => {
84
- const hasRunning = messages.some(m => m.isRunning);
85
- if (hasRunning) {
86
- setTimerTick(tick => tick + 1);
87
- }
88
- }, 1000);
89
- return () => clearInterval(interval);
90
- }, [messages]);
91
-
92
- const bottomReservedLines = getBottomReservedLinesForInputBar({
93
- isProcessing,
94
- hasQuestion: Boolean(questionRequest) || Boolean(approvalRequest),
95
- });
96
- const viewportHeight = Math.max(5, terminalHeight - (bottomReservedLines + 2));
97
-
98
- interface RenderItem {
99
- key: string;
100
- type: 'line' | 'question' | 'approval' | 'blend';
101
- content?: string;
102
- role: "user" | "assistant" | "tool" | "slash";
103
- isFirst: boolean;
104
- indent?: number;
105
- paragraphIndex?: number;
106
- segments?: import("../../utils/markdown").MarkdownSegment[];
107
- success?: boolean;
108
- isError?: boolean;
109
- isSpacer?: boolean;
110
- questionRequest?: QuestionRequest;
111
- approvalRequest?: ApprovalRequest;
112
- visualLines: number;
113
- blendDuration?: number;
114
- blendWord?: string;
115
- isRunning?: boolean;
116
- runningStartTime?: number;
117
- }
118
-
119
- const allItems: RenderItem[] = [];
120
- let pendingBlend: { key: string; blendDuration: number; blendWord: string } | null = null;
121
-
122
- for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
123
- const message = messages[messageIndex]!;
124
- const messageKey = message.id || `m-${messageIndex}`;
125
- const messageRole = message.displayRole ?? message.role;
126
-
127
- if (messageRole === 'user' && pendingBlend) {
128
- allItems.push({
129
- key: pendingBlend.key,
130
- type: 'blend',
131
- role: 'assistant',
132
- isFirst: false,
133
- visualLines: 1,
134
- blendDuration: pendingBlend.blendDuration,
135
- blendWord: pendingBlend.blendWord
136
- });
137
- pendingBlend = null;
138
- }
139
-
140
- if (messageRole === 'assistant') {
141
- const blocks = parseAndWrapMarkdown(message.content, maxWidth);
142
- let isFirstContent = true;
143
-
144
- for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
145
- const block = blocks[blockIndex]!;
146
- if (block.type !== 'line' || !block.wrappedLines) continue;
147
-
148
- for (let j = 0; j < block.wrappedLines.length; j++) {
149
- const wrapped = block.wrappedLines[j];
150
- if (wrapped) {
151
- allItems.push({
152
- key: `${messageKey}-line-${blockIndex}-${j}`,
153
- type: 'line',
154
- content: wrapped.text || '',
155
- role: messageRole,
156
- isFirst: isFirstContent && j === 0,
157
- segments: wrapped.segments,
158
- isError: message.isError,
159
- visualLines: 1
160
- });
161
- if (wrapped.text && wrapped.text.trim()) {
162
- isFirstContent = false;
163
- }
164
- }
165
- }
166
- }
167
- } else {
168
- const messageText = message.displayContent ?? message.content;
169
- const paragraphs = messageText.split('\n');
170
- let isFirstContent = true;
171
-
172
- for (let i = 0; i < paragraphs.length; i++) {
173
- const paragraph = paragraphs[i] ?? '';
174
- if (paragraph === '') {
175
- allItems.push({
176
- key: `${messageKey}-paragraph-${i}-empty`,
177
- type: 'line',
178
- content: '',
179
- role: messageRole,
180
- isFirst: false,
181
- indent: messageRole === 'tool' ? getToolParagraphIndent(i) : 0,
182
- paragraphIndex: i,
183
- success: (messageRole === 'tool' || messageRole === 'slash') ? message.success : undefined,
184
- isSpacer: messageRole !== 'tool' && messageRole !== 'slash',
185
- visualLines: 1,
186
- isRunning: message.isRunning,
187
- runningStartTime: message.runningStartTime
188
- });
189
- } else {
190
- const indent = messageRole === 'tool' ? getToolParagraphIndent(i) : 0;
191
- const wrapTarget = messageRole === 'tool' ? getToolWrapTarget(paragraph, i) : paragraph;
192
- const wrapWidth = messageRole === 'tool' ? getToolWrapWidth(maxWidth, i) : maxWidth;
193
- const wrappedLines = wrapText(wrapTarget, wrapWidth);
194
- for (let j = 0; j < wrappedLines.length; j++) {
195
- allItems.push({
196
- key: `${messageKey}-paragraph-${i}-line-${j}`,
197
- type: 'line',
198
- content: wrappedLines[j] || '',
199
- role: messageRole,
200
- isFirst: isFirstContent && i === 0 && j === 0,
201
- indent,
202
- paragraphIndex: i,
203
- success: (messageRole === 'tool' || messageRole === 'slash') ? message.success : undefined,
204
- isSpacer: false,
205
- visualLines: 1,
206
- isRunning: message.isRunning,
207
- runningStartTime: message.runningStartTime
208
- });
209
- }
210
- isFirstContent = false;
211
- }
212
- }
213
- }
214
-
215
- if (message.isRunning && message.runningStartTime && messageRole === 'tool' && message.toolName !== 'explore') {
216
- allItems.push({
217
- key: `${messageKey}-running`,
218
- type: 'line',
219
- content: '',
220
- role: messageRole,
221
- isFirst: false,
222
- indent: 2,
223
- paragraphIndex: 1,
224
- success: message.success,
225
- isSpacer: false,
226
- visualLines: 1,
227
- isRunning: true,
228
- runningStartTime: message.runningStartTime
229
- });
230
- }
231
-
232
- if (message.responseDuration && messageRole === 'assistant' && message.responseDuration > 60000) {
233
- pendingBlend = {
234
- key: `${messageKey}-blend`,
235
- blendDuration: message.responseDuration,
236
- blendWord: message.blendWord || 'Blended'
237
- };
238
- }
239
-
240
- allItems.push({
241
- key: `${messageKey}-spacer`,
242
- type: 'line',
243
- content: '',
244
- role: messageRole,
245
- isFirst: false,
246
- isSpacer: true,
247
- visualLines: 1
248
- });
249
- }
250
-
251
- if (pendingBlend) {
252
- allItems.push({
253
- key: pendingBlend.key,
254
- type: 'blend',
255
- role: 'assistant',
256
- isFirst: false,
257
- visualLines: 1,
258
- blendDuration: pendingBlend.blendDuration,
259
- blendWord: pendingBlend.blendWord
260
- });
261
- }
262
-
263
- if (questionRequest) {
264
- const questionPanelLines = Math.max(6, 5 + questionRequest.options.length);
265
- const currentTotalLines = allItems.reduce((sum, item) => sum + item.visualLines, 0);
266
- const linesFromBottom = currentTotalLines % viewportHeight;
267
- const spaceNeeded = viewportHeight - linesFromBottom;
268
-
269
- if (linesFromBottom > 0 && questionPanelLines + 2 > spaceNeeded) {
270
- allItems.push({
271
- key: `question-${questionRequest.id}-pagebreak`,
272
- type: 'line',
273
- content: '',
274
- role: 'assistant',
275
- isFirst: false,
276
- isSpacer: true,
277
- visualLines: spaceNeeded,
278
- });
279
- }
280
-
281
- allItems.push({
282
- key: `question-${questionRequest.id}`,
283
- type: 'question',
284
- role: 'assistant',
285
- isFirst: true,
286
- questionRequest,
287
- visualLines: questionPanelLines,
288
- });
289
- allItems.push({
290
- key: `question-${questionRequest.id}-spacer`,
291
- type: 'line',
292
- content: '',
293
- role: 'assistant',
294
- isFirst: false,
295
- isSpacer: true,
296
- visualLines: 1,
297
- });
298
- }
299
-
300
- if (approvalRequest) {
301
- const previewLines = approvalRequest.preview.content.split('\n').length;
302
- const maxVisibleLines = Math.min(previewLines, viewportHeight - 10);
303
- const approvalPanelLines = Math.max(8, 6 + maxVisibleLines);
304
- const currentTotalLines = allItems.reduce((sum, item) => sum + item.visualLines, 0);
305
- const linesFromBottom = currentTotalLines % viewportHeight;
306
- const spaceNeeded = viewportHeight - linesFromBottom;
307
-
308
- if (linesFromBottom > 0) {
309
- allItems.push({
310
- key: `approval-${approvalRequest.id}-pagebreak`,
311
- type: 'line',
312
- content: '',
313
- role: 'assistant',
314
- isFirst: false,
315
- isSpacer: true,
316
- visualLines: spaceNeeded,
317
- });
318
- }
319
-
320
- allItems.push({
321
- key: `approval-${approvalRequest.id}`,
322
- type: 'approval',
323
- role: 'assistant',
324
- isFirst: true,
325
- approvalRequest,
326
- visualLines: approvalPanelLines,
327
- });
328
- allItems.push({
329
- key: `approval-${approvalRequest.id}-spacer`,
330
- type: 'line',
331
- content: '',
332
- role: 'assistant',
333
- isFirst: false,
334
- isSpacer: true,
335
- visualLines: 1,
336
- });
337
- }
338
-
339
- const totalVisualLines = allItems.reduce((sum, item) => sum + item.visualLines, 0);
340
- const maxScrollOffset = Math.max(0, totalVisualLines - viewportHeight);
341
- const clampedScrollOffset = Math.max(0, Math.min(scrollOffset, maxScrollOffset));
342
-
343
- let visibleLines: RenderItem[] = [];
344
-
345
- if (allItems.length === 0) {
346
- visibleLines = [];
347
- } else {
348
- const targetEndLine = totalVisualLines - clampedScrollOffset;
349
- const targetStartLine = Math.max(0, targetEndLine - viewportHeight);
350
-
351
- let currentLine = 0;
352
- let startIdx = -1;
353
- let endIdx = -1;
354
-
355
- for (let i = 0; i < allItems.length; i++) {
356
- const item = allItems[i]!;
357
- const itemEndLine = currentLine + item.visualLines;
358
-
359
- if (startIdx === -1 && itemEndLine > targetStartLine) {
360
- startIdx = i;
361
- }
362
-
363
- if (endIdx === -1 && itemEndLine >= targetEndLine) {
364
- endIdx = i + 1;
365
- break;
366
- }
367
-
368
- currentLine = itemEndLine;
369
- }
370
-
371
- if (startIdx === -1) startIdx = 0;
372
- if (endIdx === -1) endIdx = allItems.length;
373
-
374
- visibleLines = allItems.slice(startIdx, endIdx);
375
- }
376
-
377
- return (
378
- <box flexDirection="column" width="100%" height="100%" position="relative">
379
- <box flexGrow={1} flexDirection="column" width="100%" paddingLeft={1} paddingRight={1} paddingTop={1} paddingBottom={bottomReservedLines}>
380
- {visibleLines.map((item) => {
381
- if (item.type === 'question') {
382
- const req = item.questionRequest;
383
- if (!req) return null;
384
- return (
385
- <box key={item.key} flexDirection="column" width="100%">
386
- <QuestionPanel
387
- request={req}
388
- disabled={shortcutsOpen}
389
- onAnswer={(index, customText) => answerQuestion(index, customText)}
390
- />
391
- </box>
392
- );
393
- }
394
-
395
- if (item.type === 'approval') {
396
- const req = item.approvalRequest;
397
- if (!req) return null;
398
- return (
399
- <box key={item.key} flexDirection="column" width="100%">
400
- <ApprovalPanel
401
- request={req}
402
- disabled={shortcutsOpen}
403
- onRespond={(approved, customResponse) => respondApproval(approved, customResponse)}
404
- />
405
- </box>
406
- );
407
- }
408
-
409
- if (item.type === 'blend') {
410
- if (item.blendDuration && item.blendDuration > 60000) {
411
- const timeStr = formatElapsedTime(item.blendDuration, false);
412
- return (
413
- <box key={item.key} flexDirection="row" width="100%" paddingLeft={1}>
414
- <text attributes={TextAttributes.DIM}>⁘ {item.blendWord} for {timeStr}</text>
415
- </box>
416
- );
417
- }
418
- return null;
419
- }
420
-
421
- const showErrorBar = item.role === "assistant" && item.isError && item.isFirst && item.content;
422
- const showToolBar = item.role === "tool" && item.isSpacer === false;
423
- const showSlashBar = item.role === "slash" && item.isSpacer === false;
424
- const showToolBackground = item.role === "tool" && item.isSpacer === false;
425
- const showSlashBackground = item.role === "slash" && item.isSpacer === false;
426
- const isRunningTool = item.isRunning && item.runningStartTime;
427
-
428
- const diffBackground = getDiffLineBackground(item.content || '');
429
-
430
- const runningBackground = isRunningTool ? "#2a2a2a" : (diffBackground || (((item.role === "user" && item.content) || showToolBackground || showSlashBackground || showErrorBar) ? "#1a1a1a" : "transparent"));
431
-
432
- return (
433
- <box
434
- key={item.key}
435
- flexDirection="row"
436
- width="100%"
437
- backgroundColor={runningBackground}
438
- paddingRight={((item.role === "user" && item.content) || showToolBackground || showSlashBackground || showErrorBar || isRunningTool) ? 1 : 0}
439
- >
440
- {item.role === "user" && item.content && (
441
- <text fg="#ffca38">▎ </text>
442
- )}
443
- {showToolBar && !isRunningTool && (
444
- <text fg={item.success ? "#1a3a1a" : "#3a1a1a"}>▎ </text>
445
- )}
446
- {showToolBar && isRunningTool && (
447
- <text fg="#808080">▎ </text>
448
- )}
449
- {showSlashBar && (
450
- <text fg="white">▎ </text>
451
- )}
452
- {showErrorBar && (
453
- <text fg="#ff3838">▎ </text>
454
- )}
455
- {item.role === "tool" ? (
456
- isRunningTool && item.runningStartTime && item.paragraphIndex === 1 ? (
457
- <text fg="#ffffff" attributes={TextAttributes.DIM}> Running... {Math.floor((Date.now() - item.runningStartTime) / 1000)}s</text>
458
- ) : (
459
- renderToolText(item.content || ' ', item.paragraphIndex || 0, item.indent || 0)
460
- )
461
- ) : item.role === "user" || item.role === "slash" ? (
462
- <text fg="white">{`${' '.repeat(item.indent || 0)}${item.content || ' '}`}</text>
463
- ) : item.segments && item.segments.length > 0 ? (
464
- <>
465
- {item.segments.map((segment, segIndex) => renderMarkdownSegment(segment, segIndex))}
466
- </>
467
- ) : (
468
- <text fg={item.isError ? "#ff3838" : "white"}>{item.content || ' '}</text>
469
- )}
470
- </box>
471
- );
472
- })}
473
- </box>
474
-
475
- <box
476
- position="absolute"
477
- bottom={1.4}
478
- left={0}
479
- right={0}
480
- flexDirection="column"
481
- backgroundColor="#1a1a1a"
482
- paddingLeft={1}
483
- paddingRight={1}
484
- paddingTop={0}
485
- paddingBottom={0}
486
- flexShrink={0}
487
- minHeight={getInputBarBaseLines()}
488
- minWidth="100%"
489
- >
490
- <box flexDirection="row" alignItems="center" width="100%" flexGrow={1} minWidth={0}>
491
- <box flexGrow={1} flexShrink={1} minWidth={0}>
492
- <CustomInput
493
- onSubmit={onSubmit}
494
- placeholder="Type your message..."
495
- focused={!isProcessing && !shortcutsOpen && !questionRequest && !approvalRequest}
496
- pasteRequestId={shortcutsOpen ? 0 : pasteRequestId}
497
- />
498
- </box>
499
- </box>
500
- </box>
501
-
502
- <box position="absolute" bottom={0} left={0} right={0} flexDirection="row" paddingLeft={1} paddingRight={1} justifyContent="space-between">
503
- <box flexDirection="row" gap={1}>
504
- <text>—</text>
505
- <text fg="#4d8f29">+{fileChanges.linesAdded}</text>
506
- <text fg="#d73a49">-{fileChanges.linesRemoved}</text>
507
- </box>
508
- <text attributes={TextAttributes.DIM}>ctrl+o to see commands — ctrl+p to view shortcuts</text>
509
- </box>
510
-
511
- <box position="absolute" bottom={getInputBarBaseLines() + 1} left={0} right={0} flexDirection="column" paddingLeft={1} paddingRight={1}>
512
- <ThinkingIndicatorBlock isProcessing={isProcessing} hasQuestion={Boolean(questionRequest) || Boolean(approvalRequest)} startTime={processingStartTime} tokens={currentTokens} />
513
- </box>
514
- </box>
515
- );
516
- }
1
+ import { useEffect, useState, useRef } from "react";
2
+ import { TextAttributes } from "@opentui/core";
3
+ import { renderMarkdownSegment, parseAndWrapMarkdown } from "../../utils/markdown";
4
+ import { getToolParagraphIndent, getToolWrapTarget, getToolWrapWidth } from "../../utils/toolFormatting";
5
+ import { subscribeQuestion, answerQuestion, type QuestionRequest } from "../../utils/questionBridge";
6
+ import { subscribeApproval, respondApproval, type ApprovalRequest } from "../../utils/approvalBridge";
7
+ import { subscribeApprovalMode } from "../../utils/approvalModeBridge";
8
+ import { shouldRequireApprovals } from "../../utils/config";
9
+ import { subscribeFileChanges } from "../../utils/fileChangesBridge";
10
+ import type { FileChanges } from "../../utils/fileChangeTracker";
11
+ import { CustomInput } from "../CustomInput";
12
+ import type { Message } from "./types";
13
+ import type { ImageAttachment } from "../../utils/images";
14
+ import { wrapText } from "./wrapText";
15
+ import { QuestionPanel } from "./QuestionPanel";
16
+ import { ApprovalPanel } from "./ApprovalPanel";
17
+ import { ThinkingIndicatorBlock, getBottomReservedLinesForInputBar, getInputBarBaseLines, formatElapsedTime } from "./ThinkingIndicator";
18
+ import { renderInlineDiffLine, getDiffLineBackground } from "../../utils/diffRendering";
19
+
20
+ type CodeToken = { text: string; color: string };
21
+
22
+ const CODE_THEME = {
23
+ text: "#d4d4d4",
24
+ keyword: "#569cd6",
25
+ string: "#ce9178",
26
+ number: "#b5cea8",
27
+ comment: "#6a9955",
28
+ type: "#4ec9b0",
29
+ builtin: "#9cdcfe",
30
+ function: "#dcdcaa",
31
+ header: "#d7ba7d",
32
+ };
33
+
34
+ const JS_KEYWORDS = new Set([
35
+ "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete",
36
+ "do", "else", "export", "extends", "finally", "for", "function", "if", "import", "in",
37
+ "instanceof", "let", "new", "return", "super", "switch", "this", "throw", "try", "typeof",
38
+ "var", "void", "while", "with", "yield", "await", "async", "of", "as"
39
+ ]);
40
+
41
+ const JS_TYPES = new Set([
42
+ "string", "number", "boolean", "any", "unknown", "never", "void", "object", "symbol",
43
+ "bigint", "null", "undefined", "Array", "Record", "Map", "Set", "Promise"
44
+ ]);
45
+
46
+ const JS_BUILTINS = new Set([
47
+ "console", "Math", "JSON", "Date", "Intl", "Number", "String", "Boolean", "Object",
48
+ "Array", "Set", "Map", "WeakMap", "WeakSet", "RegExp", "Error", "Promise", "Reflect",
49
+ "Proxy", "Symbol", "BigInt", "URL", "URLSearchParams"
50
+ ]);
51
+
52
+ function isIdentifierStart(char: string) {
53
+ return /[A-Za-z_$]/.test(char);
54
+ }
55
+
56
+ function isIdentifierChar(char: string) {
57
+ return /[A-Za-z0-9_$]/.test(char);
58
+ }
59
+
60
+ function tokenizeCodeLine(line: string, language?: string): CodeToken[] {
61
+ if (!line) return [{ text: "", color: CODE_THEME.text }];
62
+ const lang = (language || "").toLowerCase();
63
+ const isJs = lang === "js" || lang === "jsx" || lang === "ts" || lang === "tsx";
64
+ if (!isJs) return [{ text: line, color: CODE_THEME.text }];
65
+
66
+ const tokens: CodeToken[] = [];
67
+ let i = 0;
68
+
69
+ const push = (text: string, color: string) => {
70
+ if (text.length === 0) return;
71
+ tokens.push({ text, color });
72
+ };
73
+
74
+ while (i < line.length) {
75
+ const ch = line[i]!;
76
+
77
+ if (ch === "/" && line[i + 1] === "/") {
78
+ push(line.slice(i), CODE_THEME.comment);
79
+ break;
80
+ }
81
+
82
+ if (ch === "'" || ch === '"' || ch === "`") {
83
+ const quote = ch;
84
+ let j = i + 1;
85
+ while (j < line.length) {
86
+ const c = line[j]!;
87
+ if (c === "\\" && j + 1 < line.length) {
88
+ j += 2;
89
+ continue;
90
+ }
91
+ if (c === quote) {
92
+ j += 1;
93
+ break;
94
+ }
95
+ j += 1;
96
+ }
97
+ push(line.slice(i, j), CODE_THEME.string);
98
+ i = j;
99
+ continue;
100
+ }
101
+
102
+ if (/\d/.test(ch)) {
103
+ let j = i + 1;
104
+ while (j < line.length && /[\d._xXa-fA-F]/.test(line[j]!)) {
105
+ j += 1;
106
+ }
107
+ push(line.slice(i, j), CODE_THEME.number);
108
+ i = j;
109
+ continue;
110
+ }
111
+
112
+ if (isIdentifierStart(ch)) {
113
+ let j = i + 1;
114
+ while (j < line.length && isIdentifierChar(line[j]!)) {
115
+ j += 1;
116
+ }
117
+ const word = line.slice(i, j);
118
+ let color = CODE_THEME.text;
119
+ if (JS_KEYWORDS.has(word)) color = CODE_THEME.keyword;
120
+ else if (JS_TYPES.has(word)) color = CODE_THEME.type;
121
+ else if (JS_BUILTINS.has(word)) color = CODE_THEME.builtin;
122
+ else {
123
+ let k = j;
124
+ while (k < line.length && line[k] === " ") k += 1;
125
+ if (line[k] === "(") color = CODE_THEME.function;
126
+ }
127
+ push(word, color);
128
+ i = j;
129
+ continue;
130
+ }
131
+
132
+ push(ch, CODE_THEME.text);
133
+ i += 1;
134
+ }
135
+
136
+ return tokens.length > 0 ? tokens : [{ text: line, color: CODE_THEME.text }];
137
+ }
138
+
139
+ function renderToolText(content: string, paragraphIndex: number, indent: number, wrappedLineIndex: number) {
140
+ if (paragraphIndex === 0) {
141
+ if (wrappedLineIndex === 0) {
142
+ const match = content.match(/^(.+?)\s*(\(.*)$/);
143
+ if (match) {
144
+ const [, toolName, toolInfo] = match;
145
+ return (
146
+ <>
147
+ <text fg="white">{toolName} </text>
148
+ <text fg="white" attributes={TextAttributes.DIM}>{toolInfo}</text>
149
+ </>
150
+ );
151
+ }
152
+ } else {
153
+ return <text fg="white" attributes={TextAttributes.DIM}>{` ${content || ' '}`}</text>;
154
+ }
155
+ }
156
+
157
+ const planMatch = content.match(/^(\s*)>\s*(\[[~x ]\])?\s*(.*)$/);
158
+ if (planMatch) {
159
+ const [, leading, bracket, rest] = planMatch;
160
+ const bracketColor = bracket === '[~]' ? '#ffca38' : 'white';
161
+ return (
162
+ <>
163
+ <text fg="white">{leading || ''}</text>
164
+ <text fg="#ffca38">{'>'}</text>
165
+ <text fg="white"> </text>
166
+ {bracket ? <text fg={bracketColor}>{bracket}</text> : null}
167
+ {bracket ? <text fg="white"> </text> : null}
168
+ <text fg="white">{rest || ' '}</text>
169
+ </>
170
+ );
171
+ }
172
+
173
+
174
+ const diffLineRender = renderInlineDiffLine(content);
175
+ if (diffLineRender) {
176
+ return diffLineRender;
177
+ }
178
+
179
+ return <text fg="white">{`${' '.repeat(indent)}${content || ' '}`}</text>;
180
+ }
181
+
182
+ function getPlanProgress(messages: Message[]): { inProgressStep?: string; nextStep?: string } {
183
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
184
+ const message = messages[i];
185
+ if (!message || message.role !== 'tool' || message.toolName !== 'plan') continue;
186
+ const result = message.toolResult;
187
+ if (!result || typeof result !== 'object') continue;
188
+ const obj = result as Record<string, unknown>;
189
+ const planItems = Array.isArray(obj.plan) ? obj.plan : [];
190
+ const normalized = planItems
191
+ .map((item) => {
192
+ if (!item || typeof item !== 'object') return null;
193
+ const entry = item as Record<string, unknown>;
194
+ const step = typeof entry.step === 'string' ? entry.step.trim() : '';
195
+ const status = typeof entry.status === 'string' ? entry.status : 'pending';
196
+ if (!step) return null;
197
+ return { step, status };
198
+ })
199
+ .filter((item): item is { step: string; status: string } => !!item);
200
+
201
+ if (normalized.length === 0) return {};
202
+
203
+ const inProgressIndex = normalized.findIndex(item => item.status === 'in_progress');
204
+ const inProgressStep = inProgressIndex >= 0 ? normalized[inProgressIndex]?.step : undefined;
205
+ let nextStep: string | undefined;
206
+
207
+ if (inProgressIndex >= 0) {
208
+ const after = normalized.slice(inProgressIndex + 1).find(item => item.status === 'pending');
209
+ nextStep = after?.step;
210
+ }
211
+
212
+ if (!nextStep) {
213
+ nextStep = normalized.find(item => item.status === 'pending')?.step;
214
+ }
215
+
216
+ return { inProgressStep, nextStep };
217
+ }
218
+
219
+ return {};
220
+ }
221
+
222
+ interface ChatPageProps {
223
+ messages: Message[];
224
+ isProcessing: boolean;
225
+ processingStartTime: number | null;
226
+ currentTokens: number;
227
+ scrollOffset: number;
228
+ terminalHeight: number;
229
+ terminalWidth: number;
230
+ pasteRequestId: number;
231
+ shortcutsOpen: boolean;
232
+ onSubmit: (value: string, meta?: import("../CustomInput").InputSubmitMeta) => void;
233
+ pendingImages: ImageAttachment[];
234
+ }
235
+
236
+ export function ChatPage({
237
+ messages,
238
+ isProcessing,
239
+ processingStartTime,
240
+ currentTokens,
241
+ scrollOffset,
242
+ terminalHeight,
243
+ terminalWidth,
244
+ pasteRequestId,
245
+ shortcutsOpen,
246
+ onSubmit,
247
+ pendingImages,
248
+ }: ChatPageProps) {
249
+ const maxWidth = Math.max(20, terminalWidth - 6);
250
+ const [questionRequest, setQuestionRequest] = useState<QuestionRequest | null>(null);
251
+ const [approvalRequest, setApprovalRequest] = useState<ApprovalRequest | null>(null);
252
+ const [fileChanges, setFileChanges] = useState<FileChanges>({ linesAdded: 0, linesRemoved: 0, filesModified: 0 });
253
+ const [, setTimerTick] = useState(0);
254
+ const [requireApprovals, setRequireApprovals] = useState(shouldRequireApprovals());
255
+ const scrollboxRef = useRef<any>(null);
256
+
257
+ useEffect(() => {
258
+ return subscribeQuestion(setQuestionRequest);
259
+ }, []);
260
+
261
+ useEffect(() => {
262
+ return subscribeApproval(setApprovalRequest);
263
+ }, []);
264
+
265
+ useEffect(() => {
266
+ return subscribeApprovalMode((require) => {
267
+ setRequireApprovals(require);
268
+ });
269
+ }, []);
270
+
271
+ useEffect(() => {
272
+ return subscribeFileChanges(setFileChanges);
273
+ }, []);
274
+
275
+ useEffect(() => {
276
+ const interval = setInterval(() => {
277
+ const hasRunning = messages.some(m => m.isRunning);
278
+ if (hasRunning) {
279
+ setTimerTick(tick => tick + 1);
280
+ }
281
+ }, 1000);
282
+ return () => clearInterval(interval);
283
+ }, [messages]);
284
+ useEffect(() => {
285
+ const sb = scrollboxRef.current;
286
+ if (sb?.verticalScrollBar) {
287
+ sb.verticalScrollBar.visible = false;
288
+ }
289
+ }, []);
290
+
291
+ const planProgress = getPlanProgress(messages);
292
+ const extraInputLines = pendingImages.length > 0 ? 1 : 0;
293
+ const inputBarBaseLines = getInputBarBaseLines() + extraInputLines;
294
+ const bottomReservedLines = getBottomReservedLinesForInputBar({
295
+ isProcessing,
296
+ hasQuestion: Boolean(questionRequest) || Boolean(approvalRequest),
297
+ inProgressStep: planProgress.inProgressStep,
298
+ nextStep: planProgress.nextStep,
299
+ }) + extraInputLines;
300
+ const viewportHeight = Math.max(5, terminalHeight - (bottomReservedLines + 2));
301
+
302
+ interface RenderItem {
303
+ key: string;
304
+ type: 'line' | 'question' | 'approval' | 'blend';
305
+ content?: string;
306
+ role: "user" | "assistant" | "tool" | "slash";
307
+ toolName?: string;
308
+ isFirst: boolean;
309
+ indent?: number;
310
+ paragraphIndex?: number;
311
+ wrappedLineIndex?: number;
312
+ segments?: import("../../utils/markdown").MarkdownSegment[];
313
+ success?: boolean;
314
+ isError?: boolean;
315
+ isSpacer?: boolean;
316
+ questionRequest?: QuestionRequest;
317
+ approvalRequest?: ApprovalRequest;
318
+ visualLines: number;
319
+ blendDuration?: number;
320
+ blendWord?: string;
321
+ isRunning?: boolean;
322
+ runningStartTime?: number;
323
+ isThinking?: boolean;
324
+ isCodeBlock?: boolean;
325
+ isCodeHeader?: boolean;
326
+ codeLanguage?: string;
327
+ codeTokens?: CodeToken[];
328
+ }
329
+
330
+ const allItems: RenderItem[] = [];
331
+ let pendingBlend: { key: string; blendDuration: number; blendWord: string } | null = null;
332
+
333
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
334
+ const message = messages[messageIndex]!;
335
+ const messageKey = message.id || `m-${messageIndex}`;
336
+ const messageRole = message.displayRole ?? message.role;
337
+
338
+ if (messageRole === 'user' && pendingBlend) {
339
+ allItems.push({
340
+ key: pendingBlend.key,
341
+ type: 'blend',
342
+ role: 'assistant',
343
+ isFirst: false,
344
+ visualLines: 1,
345
+ blendDuration: pendingBlend.blendDuration,
346
+ blendWord: pendingBlend.blendWord
347
+ });
348
+ pendingBlend = null;
349
+ }
350
+
351
+ if (messageRole === 'assistant') {
352
+ if (message.thinkingContent) {
353
+ const headerLines = wrapText('Thinking:', maxWidth);
354
+ for (let i = 0; i < headerLines.length; i++) {
355
+ allItems.push({
356
+ key: `${messageKey}-thinking-header-${i}`,
357
+ type: 'line',
358
+ content: headerLines[i] || '',
359
+ role: messageRole,
360
+ isFirst: false,
361
+ visualLines: 1,
362
+ isThinking: true
363
+ });
364
+ }
365
+
366
+ const thinkingLines = message.thinkingContent.split('\n');
367
+ for (let i = 0; i < thinkingLines.length; i++) {
368
+ const wrapped = wrapText(thinkingLines[i] || '', Math.max(10, maxWidth - 2));
369
+ for (let j = 0; j < wrapped.length; j++) {
370
+ allItems.push({
371
+ key: `${messageKey}-thinking-${i}-${j}`,
372
+ type: 'line',
373
+ content: wrapped[j] || '',
374
+ role: messageRole,
375
+ isFirst: false,
376
+ indent: 2,
377
+ visualLines: 1,
378
+ isThinking: true
379
+ });
380
+ }
381
+ }
382
+
383
+ allItems.push({
384
+ key: `${messageKey}-thinking-spacer`,
385
+ type: 'line',
386
+ content: '',
387
+ role: messageRole,
388
+ isFirst: false,
389
+ isSpacer: true,
390
+ visualLines: 1,
391
+ isThinking: true
392
+ });
393
+ }
394
+
395
+ const blocks = parseAndWrapMarkdown(message.content, maxWidth);
396
+ let isFirstContent = true;
397
+
398
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
399
+ const block = blocks[blockIndex]!;
400
+ if (block.type === 'code' && block.codeLines) {
401
+ const langLabel = (block.language || 'code').trim();
402
+ allItems.push({
403
+ key: `${messageKey}-code-${blockIndex}-header`,
404
+ type: 'line',
405
+ content: `${langLabel}`,
406
+ role: messageRole,
407
+ toolName: message.toolName,
408
+ isFirst: isFirstContent,
409
+ isCodeBlock: true,
410
+ isCodeHeader: true,
411
+ codeLanguage: block.language,
412
+ visualLines: 1
413
+ });
414
+ for (let j = 0; j < block.codeLines.length; j++) {
415
+ allItems.push({
416
+ key: `${messageKey}-code-${blockIndex}-${j}`,
417
+ type: 'line',
418
+ content: block.codeLines[j] || '',
419
+ role: messageRole,
420
+ toolName: message.toolName,
421
+ isFirst: isFirstContent && j === 0,
422
+ isCodeBlock: true,
423
+ codeLanguage: block.language,
424
+ codeTokens: tokenizeCodeLine(block.codeLines[j] || '', block.language),
425
+ visualLines: 1
426
+ });
427
+ }
428
+ if (block.codeLines.some(line => line.trim().length > 0)) {
429
+ isFirstContent = false;
430
+ }
431
+ continue;
432
+ }
433
+
434
+ if (block.type !== 'line' || !block.wrappedLines) continue;
435
+
436
+ for (let j = 0; j < block.wrappedLines.length; j++) {
437
+ const wrapped = block.wrappedLines[j];
438
+ if (wrapped) {
439
+ allItems.push({
440
+ key: `${messageKey}-line-${blockIndex}-${j}`,
441
+ type: 'line',
442
+ content: wrapped.text || '',
443
+ role: messageRole,
444
+ toolName: message.toolName,
445
+ isFirst: isFirstContent && j === 0,
446
+ segments: wrapped.segments,
447
+ isError: message.isError,
448
+ visualLines: 1
449
+ });
450
+ if (wrapped.text && wrapped.text.trim()) {
451
+ isFirstContent = false;
452
+ }
453
+ }
454
+ }
455
+ }
456
+ } else {
457
+ if (messageRole === "user" && message.images && message.images.length > 0) {
458
+ for (let i = 0; i < message.images.length; i++) {
459
+ const image = message.images[i]!;
460
+ allItems.push({
461
+ key: `${messageKey}-image-${i}`,
462
+ type: "line",
463
+ content: `[image] ${image.name}`,
464
+ role: messageRole,
465
+ toolName: message.toolName,
466
+ isFirst: i === 0,
467
+ indent: 0,
468
+ paragraphIndex: 0,
469
+ wrappedLineIndex: 0,
470
+ success: undefined,
471
+ isSpacer: false,
472
+ visualLines: 1
473
+ });
474
+ }
475
+ }
476
+
477
+ const messageText = message.displayContent ?? message.content;
478
+ const paragraphs = messageText.split('\n');
479
+ let isFirstContent = true;
480
+
481
+ for (let i = 0; i < paragraphs.length; i++) {
482
+ const paragraph = paragraphs[i] ?? '';
483
+ if (paragraph === '') {
484
+ allItems.push({
485
+ key: `${messageKey}-paragraph-${i}-empty`,
486
+ type: 'line',
487
+ content: '',
488
+ role: messageRole,
489
+ toolName: message.toolName,
490
+ isFirst: false,
491
+ indent: messageRole === 'tool' ? getToolParagraphIndent(i) : 0,
492
+ paragraphIndex: i,
493
+ wrappedLineIndex: 0,
494
+ success: (messageRole === 'tool' || messageRole === 'slash') ? message.success : undefined,
495
+ isSpacer: messageRole !== 'tool' && messageRole !== 'slash',
496
+ visualLines: 1,
497
+ isRunning: message.isRunning,
498
+ runningStartTime: message.runningStartTime
499
+ });
500
+ } else {
501
+ const indent = messageRole === 'tool' ? getToolParagraphIndent(i) : 0;
502
+ const wrapTarget = messageRole === 'tool' ? getToolWrapTarget(paragraph, i) : paragraph;
503
+ const wrapWidth = messageRole === 'tool' ? getToolWrapWidth(maxWidth, i) : maxWidth;
504
+ const wrappedLines = wrapText(wrapTarget, wrapWidth);
505
+ for (let j = 0; j < wrappedLines.length; j++) {
506
+ allItems.push({
507
+ key: `${messageKey}-paragraph-${i}-line-${j}`,
508
+ type: 'line',
509
+ content: wrappedLines[j] || '',
510
+ role: messageRole,
511
+ toolName: message.toolName,
512
+ isFirst: isFirstContent && i === 0 && j === 0,
513
+ indent,
514
+ paragraphIndex: i,
515
+ wrappedLineIndex: j,
516
+ success: (messageRole === 'tool' || messageRole === 'slash') ? message.success : undefined,
517
+ isSpacer: false,
518
+ visualLines: 1,
519
+ isRunning: message.isRunning,
520
+ runningStartTime: message.runningStartTime
521
+ });
522
+ }
523
+ isFirstContent = false;
524
+ }
525
+ }
526
+ }
527
+
528
+ if (message.isRunning && message.runningStartTime && messageRole === 'tool' && message.toolName !== 'explore') {
529
+ allItems.push({
530
+ key: `${messageKey}-running`,
531
+ type: 'line',
532
+ content: '',
533
+ role: messageRole,
534
+ toolName: message.toolName,
535
+ isFirst: false,
536
+ indent: 2,
537
+ paragraphIndex: 1,
538
+ success: message.success,
539
+ isSpacer: false,
540
+ visualLines: 1,
541
+ isRunning: true,
542
+ runningStartTime: message.runningStartTime
543
+ });
544
+ }
545
+
546
+ if (message.responseDuration && messageRole === 'assistant' && message.responseDuration > 60000) {
547
+ pendingBlend = {
548
+ key: `${messageKey}-blend`,
549
+ blendDuration: message.responseDuration,
550
+ blendWord: message.blendWord || 'Blended'
551
+ };
552
+ }
553
+
554
+ allItems.push({
555
+ key: `${messageKey}-spacer`,
556
+ type: 'line',
557
+ content: '',
558
+ role: messageRole,
559
+ toolName: message.toolName,
560
+ isFirst: false,
561
+ isSpacer: true,
562
+ visualLines: 1
563
+ });
564
+ }
565
+
566
+ if (pendingBlend) {
567
+ allItems.push({
568
+ key: pendingBlend.key,
569
+ type: 'blend',
570
+ role: 'assistant',
571
+ isFirst: false,
572
+ visualLines: 1,
573
+ blendDuration: pendingBlend.blendDuration,
574
+ blendWord: pendingBlend.blendWord
575
+ });
576
+ }
577
+
578
+ if (questionRequest) {
579
+ const questionPanelLines = Math.max(6, 5 + questionRequest.options.length);
580
+ const currentTotalLines = allItems.reduce((sum, item) => sum + item.visualLines, 0);
581
+ const linesFromBottom = currentTotalLines % viewportHeight;
582
+ const spaceNeeded = viewportHeight - linesFromBottom;
583
+
584
+ if (linesFromBottom > 0 && questionPanelLines + 2 > spaceNeeded) {
585
+ allItems.push({
586
+ key: `question-${questionRequest.id}-pagebreak`,
587
+ type: 'line',
588
+ content: '',
589
+ role: 'assistant',
590
+ isFirst: false,
591
+ isSpacer: true,
592
+ visualLines: spaceNeeded,
593
+ });
594
+ }
595
+
596
+ allItems.push({
597
+ key: `question-${questionRequest.id}`,
598
+ type: 'question',
599
+ role: 'assistant',
600
+ isFirst: true,
601
+ questionRequest,
602
+ visualLines: questionPanelLines,
603
+ });
604
+ allItems.push({
605
+ key: `question-${questionRequest.id}-spacer`,
606
+ type: 'line',
607
+ content: '',
608
+ role: 'assistant',
609
+ isFirst: false,
610
+ isSpacer: true,
611
+ visualLines: 1,
612
+ });
613
+ }
614
+
615
+ if (approvalRequest) {
616
+ const previewLines = approvalRequest.preview.content.split('\n').length;
617
+ const maxVisibleLines = Math.min(previewLines, viewportHeight - 10);
618
+ const approvalPanelLines = Math.max(8, 6 + maxVisibleLines);
619
+ const currentTotalLines = allItems.reduce((sum, item) => sum + item.visualLines, 0);
620
+ const linesFromBottom = currentTotalLines % viewportHeight;
621
+ const spaceNeeded = viewportHeight - linesFromBottom;
622
+
623
+ if (linesFromBottom > 0) {
624
+ allItems.push({
625
+ key: `approval-${approvalRequest.id}-pagebreak`,
626
+ type: 'line',
627
+ content: '',
628
+ role: 'assistant',
629
+ isFirst: false,
630
+ isSpacer: true,
631
+ visualLines: spaceNeeded,
632
+ });
633
+ }
634
+
635
+ allItems.push({
636
+ key: `approval-${approvalRequest.id}`,
637
+ type: 'approval',
638
+ role: 'assistant',
639
+ isFirst: true,
640
+ approvalRequest,
641
+ visualLines: approvalPanelLines,
642
+ });
643
+ allItems.push({
644
+ key: `approval-${approvalRequest.id}-spacer`,
645
+ type: 'line',
646
+ content: '',
647
+ role: 'assistant',
648
+ isFirst: false,
649
+ isSpacer: true,
650
+ visualLines: 1,
651
+ });
652
+ }
653
+
654
+ const totalVisualLines = allItems.reduce((sum, item) => sum + item.visualLines, 0);
655
+ const maxScrollOffset = Math.max(0, totalVisualLines - viewportHeight);
656
+ const clampedScrollOffset = Math.max(0, Math.min(scrollOffset, maxScrollOffset));
657
+ const scrollYPosition = Math.max(0, totalVisualLines - viewportHeight - clampedScrollOffset);
658
+
659
+ useEffect(() => {
660
+ if (scrollboxRef.current && typeof scrollboxRef.current.scrollTop === 'number') {
661
+ scrollboxRef.current.scrollTop = scrollYPosition;
662
+ }
663
+ }, [scrollYPosition]);
664
+
665
+ return (
666
+ <box flexDirection="column" width="100%" height="100%" position="relative">
667
+ <scrollbox
668
+ ref={scrollboxRef}
669
+ scrollY
670
+ stickyScroll={scrollOffset === 0}
671
+ stickyStart="bottom"
672
+ viewportCulling
673
+ width="100%"
674
+ height={viewportHeight}
675
+ paddingLeft={1}
676
+ paddingRight={1}
677
+ paddingTop={1}
678
+ >
679
+ {allItems.map((item) => {
680
+ if (item.type === 'question') {
681
+ const req = item.questionRequest;
682
+ if (!req) return null;
683
+ return (
684
+ <box key={item.key} flexDirection="column" width="100%">
685
+ <QuestionPanel
686
+ request={req}
687
+ disabled={shortcutsOpen}
688
+ onAnswer={(index, customText) => answerQuestion(index, customText)}
689
+ maxWidth={Math.max(10, terminalWidth - 4)}
690
+ />
691
+ </box>
692
+ );
693
+ }
694
+
695
+ if (item.type === 'approval') {
696
+ const req = item.approvalRequest;
697
+ if (!req) return null;
698
+ return (
699
+ <box key={item.key} flexDirection="column" width="100%">
700
+ <ApprovalPanel
701
+ request={req}
702
+ disabled={shortcutsOpen}
703
+ onRespond={(approved, customResponse) => respondApproval(approved, customResponse)}
704
+ maxWidth={Math.max(10, terminalWidth - 4)}
705
+ />
706
+ </box>
707
+ );
708
+ }
709
+
710
+ if (item.type === 'blend') {
711
+ if (item.blendDuration && item.blendDuration > 60000) {
712
+ const timeStr = formatElapsedTime(item.blendDuration, false);
713
+ const label = `${item.blendWord} for ${timeStr}`;
714
+ const innerWidth = Math.max(10, terminalWidth - 2);
715
+ const leftSegment = `─ `;
716
+ const rightCount = Math.max(0, innerWidth - (leftSegment.length + label.length + 1));
717
+ return (
718
+ <box key={item.key} flexDirection="row" width="100%" marginBottom={1}>
719
+ <text attributes={TextAttributes.DIM}>{leftSegment}</text>
720
+ <text attributes={TextAttributes.DIM}>{label} </text>
721
+ <text attributes={TextAttributes.DIM}>{'─'.repeat(rightCount)}</text>
722
+ </box>
723
+ );
724
+ }
725
+ return null;
726
+ }
727
+
728
+ const showErrorBar = item.role === "assistant" && item.isError && item.isFirst && item.content;
729
+ const showToolBar = item.role === "tool" && item.isSpacer === false && item.toolName !== "plan";
730
+ const showSlashBar = item.role === "slash" && item.isSpacer === false;
731
+ const showToolBackground = item.role === "tool" && item.isSpacer === false;
732
+ const showSlashBackground = item.role === "slash" && item.isSpacer === false;
733
+ const isRunningTool = item.isRunning && item.runningStartTime;
734
+
735
+ const diffBackground = getDiffLineBackground(item.content || '');
736
+
737
+ const codeBackground = item.isCodeBlock ? "#1e1e1e" : null;
738
+ const runningBackground = isRunningTool
739
+ ? "#2a2a2a"
740
+ : (codeBackground || diffBackground || (((item.role === "user" && item.content) || showToolBackground || showSlashBackground || showErrorBar) ? "#1a1a1a" : "transparent"));
741
+
742
+ return (
743
+ <box
744
+ key={item.key}
745
+ flexDirection="row"
746
+ width="100%"
747
+ backgroundColor={runningBackground}
748
+ paddingRight={((item.role === "user" && item.content) || showToolBackground || showSlashBackground || showErrorBar || isRunningTool) ? 1 : 0}
749
+ >
750
+ {item.role === "user" && item.content && (
751
+ <text fg="#ffca38">▎ </text>
752
+ )}
753
+ {showToolBar && !isRunningTool && (
754
+ <text fg={item.success ? "#1a3a1a" : "#3a1a1a"}>▎ </text>
755
+ )}
756
+ {showToolBar && isRunningTool && (
757
+ <text fg="#808080">▎ </text>
758
+ )}
759
+ {showSlashBar && (
760
+ <text fg="white">▎ </text>
761
+ )}
762
+ {showErrorBar && (
763
+ <text fg="#ff3838">▎ </text>
764
+ )}
765
+ {item.isThinking ? (
766
+ <text fg="#9a9a9a" attributes={TextAttributes.DIM}>{`${' '.repeat(item.indent || 0)}${item.content || ' '}`}</text>
767
+ ) : item.isCodeBlock ? (
768
+ item.isCodeHeader ? (
769
+ <text fg={CODE_THEME.header} attributes={TextAttributes.DIM}>{item.content || ' '}</text>
770
+ ) : (
771
+ (item.codeTokens && item.codeTokens.length > 0)
772
+ ? (
773
+ <>
774
+ {item.codeTokens.map((token, tokenIndex) => (
775
+ <text key={tokenIndex} fg={token.color}>{token.text}</text>
776
+ ))}
777
+ </>
778
+ )
779
+ : <text fg={CODE_THEME.text}>{item.content || ' '}</text>
780
+ )
781
+ ) : item.role === "tool" ? (
782
+ isRunningTool && item.runningStartTime && item.paragraphIndex === 1 ? (
783
+ <text fg="#ffffff" attributes={TextAttributes.DIM}> Running... {Math.floor((Date.now() - item.runningStartTime) / 1000)}s</text>
784
+ ) : (
785
+ renderToolText(item.content || ' ', item.paragraphIndex || 0, item.indent || 0, item.wrappedLineIndex || 0)
786
+ )
787
+ ) : item.role === "user" || item.role === "slash" ? (
788
+ <text fg="white">{`${' '.repeat(item.indent || 0)}${item.content || ' '}`}</text>
789
+ ) : item.segments && item.segments.length > 0 ? (
790
+ <>
791
+ {item.segments.map((segment, segIndex) => renderMarkdownSegment(segment, segIndex))}
792
+ </>
793
+ ) : (
794
+ <text fg={item.isError ? "#ff3838" : "white"}>{item.content || ' '}</text>
795
+ )}
796
+ </box>
797
+ );
798
+ })}
799
+ </scrollbox>
800
+
801
+ <box
802
+ position="absolute"
803
+ bottom={1.4}
804
+ left={0}
805
+ right={0}
806
+ flexDirection="column"
807
+ backgroundColor="#1a1a1a"
808
+ paddingLeft={1}
809
+ paddingRight={1}
810
+ paddingTop={0}
811
+ paddingBottom={0}
812
+ flexShrink={0}
813
+ minHeight={inputBarBaseLines}
814
+ minWidth="100%"
815
+ >
816
+ {pendingImages.length > 0 && (
817
+ <box flexDirection="row" width="100%" marginBottom={1}>
818
+ <text fg="#ffca38">Images: </text>
819
+ <text fg="gray">{pendingImages.map((img) => img.name).join(", ")}</text>
820
+ </box>
821
+ )}
822
+ <box flexDirection="row" alignItems="center" width="100%" flexGrow={1} minWidth={0}>
823
+ <box flexGrow={1} flexShrink={1} minWidth={0}>
824
+ <CustomInput
825
+ onSubmit={onSubmit}
826
+ placeholder="Type your message..."
827
+ focused={!shortcutsOpen && !questionRequest && !approvalRequest}
828
+ pasteRequestId={shortcutsOpen ? 0 : pasteRequestId}
829
+ submitDisabled={isProcessing || shortcutsOpen || Boolean(questionRequest) || Boolean(approvalRequest)}
830
+ maxWidth={Math.max(10, terminalWidth - 6)}
831
+ />
832
+ </box>
833
+ </box>
834
+ </box>
835
+
836
+ <box position="absolute" bottom={0} left={0} right={0} flexDirection="row" paddingLeft={1} paddingRight={1} justifyContent="space-between">
837
+ <box flexDirection="row" gap={1}>
838
+ <text fg="#ffca38">{requireApprovals ? '' : '⏵⏵ auto-accept edits on'}</text>
839
+ <text attributes={TextAttributes.DIM}>{requireApprovals ? '' : ' — '}</text>
840
+ <text fg="#4d8f29">+{fileChanges.linesAdded}</text>
841
+ <text fg="#d73a49">-{fileChanges.linesRemoved}</text>
842
+ </box>
843
+ <text attributes={TextAttributes.DIM}>ctrl+o to see commands — ctrl+p to view shortcuts</text>
844
+ </box>
845
+
846
+ <box position="absolute" bottom={inputBarBaseLines + 1} left={0} right={0} flexDirection="column" paddingLeft={1} paddingRight={1}>
847
+ <ThinkingIndicatorBlock
848
+ isProcessing={isProcessing}
849
+ hasQuestion={Boolean(questionRequest) || Boolean(approvalRequest)}
850
+ startTime={processingStartTime}
851
+ tokens={currentTokens}
852
+ inProgressStep={planProgress.inProgressStep}
853
+ nextStep={planProgress.nextStep}
854
+ />
855
+ </box>
856
+ </box>
857
+ );
858
+ }