@kirosnn/mosaic 0.73.0 → 0.75.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.
@@ -1,858 +1,858 @@
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
- }
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
+ }