@vybestack/llxprt-ui 0.7.0-nightly.251211.5750c518a

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 (123) hide show
  1. package/PLAN-messages.md +681 -0
  2. package/PLAN.md +47 -0
  3. package/README.md +25 -0
  4. package/bun.lock +1024 -0
  5. package/dev-docs/ARCHITECTURE.md +178 -0
  6. package/dev-docs/CODE_ORGANIZATION.md +232 -0
  7. package/dev-docs/STANDARDS.md +235 -0
  8. package/dev-docs/UI_DESIGN.md +425 -0
  9. package/eslint.config.cjs +194 -0
  10. package/images/nui.png +0 -0
  11. package/llxprt.png +0 -0
  12. package/llxprt.svg +128 -0
  13. package/package.json +66 -0
  14. package/scripts/check-limits.ts +177 -0
  15. package/scripts/start.js +71 -0
  16. package/src/app.tsx +599 -0
  17. package/src/bootstrap.tsx +23 -0
  18. package/src/commands/AuthCommand.tsx +80 -0
  19. package/src/commands/ModelCommand.tsx +102 -0
  20. package/src/commands/ProviderCommand.tsx +103 -0
  21. package/src/commands/ThemeCommand.tsx +71 -0
  22. package/src/features/chat/history.ts +178 -0
  23. package/src/features/chat/index.ts +3 -0
  24. package/src/features/chat/persistentHistory.ts +102 -0
  25. package/src/features/chat/responder.ts +217 -0
  26. package/src/features/completion/completions.ts +161 -0
  27. package/src/features/completion/index.ts +3 -0
  28. package/src/features/completion/slash.test.ts +82 -0
  29. package/src/features/completion/slash.ts +248 -0
  30. package/src/features/completion/suggestions.test.ts +51 -0
  31. package/src/features/completion/suggestions.ts +112 -0
  32. package/src/features/config/configSession.test.ts +189 -0
  33. package/src/features/config/configSession.ts +179 -0
  34. package/src/features/config/index.ts +4 -0
  35. package/src/features/config/llxprtAdapter.integration.test.ts +202 -0
  36. package/src/features/config/llxprtAdapter.test.ts +139 -0
  37. package/src/features/config/llxprtAdapter.ts +257 -0
  38. package/src/features/config/llxprtCommands.test.ts +40 -0
  39. package/src/features/config/llxprtCommands.ts +35 -0
  40. package/src/features/config/llxprtConfig.test.ts +261 -0
  41. package/src/features/config/llxprtConfig.ts +418 -0
  42. package/src/features/theme/index.ts +2 -0
  43. package/src/features/theme/theme.test.ts +51 -0
  44. package/src/features/theme/theme.ts +105 -0
  45. package/src/features/theme/themeManager.ts +84 -0
  46. package/src/hooks/useAppCommands.ts +129 -0
  47. package/src/hooks/useApprovalKeyboard.ts +156 -0
  48. package/src/hooks/useChatStore.test.ts +112 -0
  49. package/src/hooks/useChatStore.ts +252 -0
  50. package/src/hooks/useInputManager.ts +99 -0
  51. package/src/hooks/useKeyboardHandlers.ts +130 -0
  52. package/src/hooks/useListNavigation.test.ts +166 -0
  53. package/src/hooks/useListNavigation.ts +62 -0
  54. package/src/hooks/usePersistentHistory.ts +94 -0
  55. package/src/hooks/useScrollManagement.ts +107 -0
  56. package/src/hooks/useSelectionClipboard.ts +48 -0
  57. package/src/hooks/useSessionManager.test.ts +85 -0
  58. package/src/hooks/useSessionManager.ts +101 -0
  59. package/src/hooks/useStreamingLifecycle.ts +71 -0
  60. package/src/hooks/useStreamingResponder.ts +401 -0
  61. package/src/hooks/useSuggestionSetup.ts +23 -0
  62. package/src/hooks/useToolApproval.test.ts +140 -0
  63. package/src/hooks/useToolApproval.ts +264 -0
  64. package/src/hooks/useToolScheduler.ts +432 -0
  65. package/src/index.ts +3 -0
  66. package/src/jsx.d.ts +11 -0
  67. package/src/lib/clipboard.ts +18 -0
  68. package/src/lib/logger.ts +107 -0
  69. package/src/lib/random.ts +5 -0
  70. package/src/main.tsx +13 -0
  71. package/src/test/mockTheme.ts +51 -0
  72. package/src/types/events.ts +87 -0
  73. package/src/types.ts +13 -0
  74. package/src/ui/components/ChatLayout.tsx +694 -0
  75. package/src/ui/components/CommandComponents.tsx +74 -0
  76. package/src/ui/components/DiffViewer.tsx +306 -0
  77. package/src/ui/components/FilterInput.test.ts +69 -0
  78. package/src/ui/components/FilterInput.tsx +62 -0
  79. package/src/ui/components/HeaderBar.tsx +137 -0
  80. package/src/ui/components/RadioSelect.test.ts +140 -0
  81. package/src/ui/components/RadioSelect.tsx +88 -0
  82. package/src/ui/components/SelectableList.test.ts +83 -0
  83. package/src/ui/components/SelectableList.tsx +35 -0
  84. package/src/ui/components/StatusBar.tsx +45 -0
  85. package/src/ui/components/SuggestionPanel.tsx +102 -0
  86. package/src/ui/components/messages/ModelMessage.tsx +14 -0
  87. package/src/ui/components/messages/SystemMessage.tsx +29 -0
  88. package/src/ui/components/messages/ThinkingMessage.tsx +14 -0
  89. package/src/ui/components/messages/UserMessage.tsx +26 -0
  90. package/src/ui/components/messages/index.ts +15 -0
  91. package/src/ui/components/messages/renderMessage.test.ts +49 -0
  92. package/src/ui/components/messages/renderMessage.tsx +43 -0
  93. package/src/ui/components/messages/types.test.ts +24 -0
  94. package/src/ui/components/messages/types.ts +36 -0
  95. package/src/ui/modals/AuthModal.tsx +106 -0
  96. package/src/ui/modals/ModalShell.tsx +60 -0
  97. package/src/ui/modals/SearchSelectModal.tsx +236 -0
  98. package/src/ui/modals/ThemeModal.tsx +204 -0
  99. package/src/ui/modals/ToolApprovalModal.test.ts +206 -0
  100. package/src/ui/modals/ToolApprovalModal.tsx +282 -0
  101. package/src/ui/modals/index.ts +20 -0
  102. package/src/ui/modals/modals.test.ts +26 -0
  103. package/src/ui/modals/types.ts +19 -0
  104. package/src/uicontext/Command.tsx +102 -0
  105. package/src/uicontext/Dialog.tsx +65 -0
  106. package/src/uicontext/index.ts +2 -0
  107. package/themes/ansi-light.json +59 -0
  108. package/themes/ansi.json +59 -0
  109. package/themes/atom-one-dark.json +59 -0
  110. package/themes/ayu-light.json +59 -0
  111. package/themes/ayu.json +59 -0
  112. package/themes/default-light.json +59 -0
  113. package/themes/default.json +59 -0
  114. package/themes/dracula.json +59 -0
  115. package/themes/github-dark.json +59 -0
  116. package/themes/github-light.json +59 -0
  117. package/themes/googlecode.json +59 -0
  118. package/themes/green-screen.json +59 -0
  119. package/themes/no-color.json +59 -0
  120. package/themes/shades-of-purple.json +59 -0
  121. package/themes/xcode.json +59 -0
  122. package/tsconfig.json +28 -0
  123. package/vitest.config.ts +10 -0
@@ -0,0 +1,694 @@
1
+ import type {
2
+ ScrollBoxRenderable,
3
+ TextareaRenderable,
4
+ TextareaAction,
5
+ } from '@vybestack/opentui-core';
6
+ import { parseColor, stringToStyledText } from '@vybestack/opentui-core';
7
+ import React, { type RefObject } from 'react';
8
+ import { useMemo } from 'react';
9
+ import type { CompletionSuggestion } from '../../features/completion';
10
+ import type { ThemeDefinition } from '../../features/theme';
11
+ import type { ToolStatus, ToolConfirmationType } from '../../types/events';
12
+ import type { ToolCallConfirmationDetails } from '@vybestack/llxprt-code-core';
13
+ import type { StreamState } from '../../hooks/useChatStore';
14
+ import { HeaderBar } from './HeaderBar';
15
+ import { StatusBar } from './StatusBar';
16
+ import { SuggestionPanel } from './SuggestionPanel';
17
+ import { renderMessage, type MessageRole } from './messages';
18
+ import { DiffViewer } from './DiffViewer';
19
+
20
+ export type ToolApprovalOutcome = 'allow_once' | 'allow_always' | 'cancel';
21
+
22
+ type Role = MessageRole;
23
+
24
+ interface ChatMessage {
25
+ id: string;
26
+ kind: 'message';
27
+ role: Role;
28
+ text: string;
29
+ }
30
+
31
+ interface ToolBlockLegacy {
32
+ id: string;
33
+ kind: 'tool';
34
+ lines: string[];
35
+ isBatch: boolean;
36
+ scrollable?: boolean;
37
+ maxHeight?: number;
38
+ streaming?: boolean;
39
+ }
40
+
41
+ interface ToolCall {
42
+ id: string;
43
+ kind: 'toolcall';
44
+ callId: string;
45
+ name: string;
46
+ params: Record<string, unknown>;
47
+ status: ToolStatus;
48
+ output?: string;
49
+ errorMessage?: string;
50
+ confirmation?: {
51
+ confirmationType: ToolConfirmationType;
52
+ question: string;
53
+ preview: string;
54
+ canAllowAlways: boolean;
55
+ coreDetails?: ToolCallConfirmationDetails;
56
+ };
57
+ }
58
+
59
+ /** Pending approval state passed from app to layout */
60
+ export interface PendingApprovalState {
61
+ readonly callId: string;
62
+ readonly selectedIndex: number;
63
+ }
64
+
65
+ type ToolBlock = ToolBlockLegacy | ToolCall;
66
+ type ChatEntry = ChatMessage | ToolBlock;
67
+
68
+ const MIN_INPUT_LINES = 1;
69
+ const MAX_INPUT_LINES = 10;
70
+ // Key bindings:
71
+ // - Return submits
72
+ // - Shift+Return sends linefeed (\n) which inserts newline
73
+ // - Option+Return (meta) inserts newline
74
+ // - Keypad enter (kpenter/kpplus) submits
75
+ const TEXTAREA_KEY_BINDINGS: {
76
+ name: string;
77
+ action: TextareaAction;
78
+ meta?: boolean;
79
+ shift?: boolean;
80
+ }[] = [
81
+ { name: 'return', action: 'submit' },
82
+ { name: 'return', meta: true, action: 'newline' },
83
+ { name: 'return', shift: true, action: 'newline' },
84
+ { name: 'linefeed', action: 'newline' },
85
+ { name: 'kpenter', action: 'submit' },
86
+ { name: 'kpplus', action: 'submit' },
87
+ ];
88
+
89
+ export interface ChatLayoutProps {
90
+ readonly headerText: string;
91
+ readonly entries: ChatEntry[];
92
+ readonly scrollRef: RefObject<ScrollBoxRenderable | null>;
93
+ readonly autoFollow: boolean;
94
+ readonly textareaRef: RefObject<TextareaRenderable | null>;
95
+ readonly inputLineCount: number;
96
+ readonly enforceInputLineBounds: () => void;
97
+ readonly handleSubmit: () => void;
98
+ readonly statusLabel: string;
99
+ readonly promptCount: number;
100
+ readonly responderWordCount: number;
101
+ readonly streamState: StreamState;
102
+ readonly onScroll: (event: { type: string }) => void;
103
+ readonly onMouseUp?: () => void;
104
+ readonly suggestions: CompletionSuggestion[];
105
+ readonly selectedSuggestion: number;
106
+ readonly theme: ThemeDefinition;
107
+ /** Pending approval state for inline tool approval */
108
+ readonly pendingApproval?: PendingApprovalState;
109
+ /** Callback when user selects an approval option */
110
+ readonly onApprovalSelect?: (
111
+ callId: string,
112
+ outcome: ToolApprovalOutcome,
113
+ ) => void;
114
+ /** Whether input is disabled (e.g., during approval) */
115
+ readonly inputDisabled?: boolean;
116
+ }
117
+
118
+ interface ScrollbackProps {
119
+ readonly entries: ChatEntry[];
120
+ readonly scrollRef: RefObject<ScrollBoxRenderable | null>;
121
+ readonly autoFollow: boolean;
122
+ readonly onScroll: (event: { type: string }) => void;
123
+ readonly theme: ThemeDefinition;
124
+ readonly pendingApproval?: PendingApprovalState;
125
+ }
126
+
127
+ interface InputAreaProps {
128
+ readonly textareaRef: RefObject<TextareaRenderable | null>;
129
+ readonly containerHeight: number;
130
+ readonly textareaHeight: number;
131
+ readonly handleSubmit: () => void;
132
+ readonly enforceInputLineBounds: () => void;
133
+ readonly theme: ThemeDefinition;
134
+ readonly disabled?: boolean;
135
+ }
136
+
137
+ export function renderChatMessage(
138
+ message: ChatMessage,
139
+ theme: ThemeDefinition,
140
+ ): React.ReactNode {
141
+ return renderMessage(message.role, message.id, message.text, theme);
142
+ }
143
+
144
+ /**
145
+ * Get status indicator symbol and color for tool status
146
+ */
147
+ function getStatusIndicator(
148
+ status: ToolStatus,
149
+ theme: ThemeDefinition,
150
+ ): { symbol: string; color: string } {
151
+ const successColor = theme.colors.accent.success ?? theme.colors.status.fg;
152
+ const errorColor = theme.colors.accent.error ?? theme.colors.text.primary;
153
+ const warningColor = theme.colors.accent.warning ?? theme.colors.status.fg;
154
+ const pendingColor = theme.colors.status.muted ?? theme.colors.text.muted;
155
+
156
+ switch (status) {
157
+ case 'pending':
158
+ return { symbol: '○', color: pendingColor };
159
+ case 'executing':
160
+ return { symbol: '◎', color: pendingColor };
161
+ case 'complete':
162
+ return { symbol: '✓', color: successColor };
163
+ case 'error':
164
+ return { symbol: '✗', color: errorColor };
165
+ case 'confirming':
166
+ return { symbol: '?', color: warningColor };
167
+ case 'cancelled':
168
+ return { symbol: '-', color: warningColor };
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Format tool parameters for display
174
+ */
175
+ function formatParams(params: Record<string, unknown> | string): string[] {
176
+ // Handle case where params might be a JSON string
177
+ let paramsObj: Record<string, unknown>;
178
+ if (typeof params === 'string') {
179
+ try {
180
+ paramsObj = JSON.parse(params) as Record<string, unknown>;
181
+ } catch {
182
+ // If parsing fails, just display the string as-is
183
+ const displayValue =
184
+ params.length > 80 ? params.slice(0, 77) + '...' : params;
185
+ return [` ${displayValue}`];
186
+ }
187
+ } else {
188
+ paramsObj = params;
189
+ }
190
+
191
+ const lines: string[] = [];
192
+ for (const [key, value] of Object.entries(paramsObj)) {
193
+ const valueStr = typeof value === 'string' ? value : JSON.stringify(value);
194
+ // Truncate long values
195
+ const displayValue =
196
+ valueStr.length > 80 ? valueStr.slice(0, 77) + '...' : valueStr;
197
+ lines.push(` ${key}: ${displayValue}`);
198
+ }
199
+ return lines;
200
+ }
201
+
202
+ // Maximum height for tool output scrollbox before requiring scroll
203
+ const TOOL_OUTPUT_MAX_HEIGHT = 10;
204
+
205
+ /** Approval option labels */
206
+ const APPROVAL_OPTIONS: { label: string; outcome: ToolApprovalOutcome }[] = [
207
+ { label: '[1] Yes, allow once', outcome: 'allow_once' },
208
+ { label: '[2] Yes, allow always', outcome: 'allow_always' },
209
+ { label: '[3] No, cancel (esc)', outcome: 'cancel' },
210
+ ];
211
+
212
+ interface InlineApprovalProps {
213
+ readonly tool: ToolCall;
214
+ readonly theme: ThemeDefinition;
215
+ readonly selectedIndex: number;
216
+ }
217
+
218
+ function renderInlineApproval({
219
+ tool,
220
+ theme,
221
+ selectedIndex,
222
+ }: InlineApprovalProps): React.ReactNode {
223
+ const confirmation = tool.confirmation;
224
+ if (!confirmation) {
225
+ return <></>;
226
+ }
227
+
228
+ const coreDetails = confirmation.coreDetails;
229
+
230
+ // Render diff for edit confirmations
231
+ const renderPreview = (): React.ReactNode => {
232
+ if (
233
+ confirmation.confirmationType === 'edit' &&
234
+ coreDetails?.type === 'edit'
235
+ ) {
236
+ return (
237
+ <DiffViewer
238
+ diffContent={coreDetails.fileDiff}
239
+ filename={coreDetails.fileName}
240
+ maxHeight={15}
241
+ theme={theme}
242
+ />
243
+ );
244
+ }
245
+
246
+ if (
247
+ confirmation.confirmationType === 'exec' &&
248
+ coreDetails?.type === 'exec'
249
+ ) {
250
+ return (
251
+ <box flexDirection="column" style={{ gap: 0 }}>
252
+ <text fg={theme.colors.text.muted}>Command:</text>
253
+ <text fg={theme.colors.accent.warning ?? theme.colors.text.primary}>
254
+ {coreDetails.command}
255
+ </text>
256
+ </box>
257
+ );
258
+ }
259
+
260
+ // Fallback: show raw preview
261
+ const previewLines = confirmation.preview.split('\n').slice(0, 5);
262
+ return (
263
+ <box flexDirection="column" style={{ gap: 0 }}>
264
+ {previewLines.map((line, idx) => (
265
+ <text key={`preview-${idx}`} fg={theme.colors.text.tool}>
266
+ {line}
267
+ </text>
268
+ ))}
269
+ </box>
270
+ );
271
+ };
272
+
273
+ // Build options (skip "allow always" if not available)
274
+ const options = confirmation.canAllowAlways
275
+ ? APPROVAL_OPTIONS
276
+ : APPROVAL_OPTIONS.filter((o) => o.outcome !== 'allow_always');
277
+
278
+ return (
279
+ <box flexDirection="column" style={{ gap: 0, marginTop: 1 }}>
280
+ <text fg={theme.colors.accent.warning ?? theme.colors.status.fg}>
281
+ <b>{confirmation.question}</b>
282
+ </text>
283
+ {renderPreview()}
284
+ <box flexDirection="column" style={{ gap: 0, marginTop: 1 }}>
285
+ {options.map((opt, idx) => (
286
+ <text
287
+ key={opt.outcome}
288
+ fg={
289
+ idx === selectedIndex
290
+ ? theme.colors.selection.fg
291
+ : theme.colors.text.primary
292
+ }
293
+ bg={idx === selectedIndex ? theme.colors.selection.bg : undefined}
294
+ >
295
+ {idx === selectedIndex ? '► ' : ' '}
296
+ {opt.label}
297
+ </text>
298
+ ))}
299
+ </box>
300
+ <text fg={theme.colors.text.muted} style={{ marginTop: 1 }}>
301
+ ↑/↓ to navigate, Enter to select, Esc to cancel
302
+ </text>
303
+ </box>
304
+ );
305
+ }
306
+
307
+ /**
308
+ * Render a ToolCall entry with status, params, and output in a scrollable container
309
+ */
310
+ export function renderToolCall(
311
+ tool: ToolCall,
312
+ theme: ThemeDefinition,
313
+ pendingApproval?: PendingApprovalState,
314
+ ): React.ReactNode {
315
+ const { symbol, color } = getStatusIndicator(tool.status, theme);
316
+ const paramLines = formatParams(tool.params);
317
+
318
+ // Build output lines
319
+ const outputLines: string[] = [];
320
+ if (tool.output) {
321
+ outputLines.push(...tool.output.split('\n'));
322
+ }
323
+ if (tool.errorMessage) {
324
+ outputLines.push(`Error: ${tool.errorMessage}`);
325
+ }
326
+
327
+ // Border always uses panel.border color
328
+ const borderColor = theme.colors.panel.border;
329
+
330
+ // Output needs scrollbox if it exceeds max height
331
+ const outputNeedsScroll = outputLines.length > TOOL_OUTPUT_MAX_HEIGHT;
332
+
333
+ // Check if this tool has pending approval
334
+ const isPendingApproval =
335
+ pendingApproval?.callId === tool.callId && tool.confirmation !== undefined;
336
+
337
+ return (
338
+ <box
339
+ key={tool.id}
340
+ border
341
+ style={{
342
+ paddingLeft: 1,
343
+ paddingRight: 1,
344
+ paddingTop: 0,
345
+ paddingBottom: 0,
346
+ marginTop: 0,
347
+ marginBottom: 0,
348
+ width: '100%',
349
+ flexDirection: 'column',
350
+ gap: 0,
351
+ borderStyle: 'rounded',
352
+ borderColor: isPendingApproval
353
+ ? (theme.colors.accent.warning ?? borderColor)
354
+ : borderColor,
355
+ backgroundColor: theme.colors.panel.bg,
356
+ overflow: 'hidden',
357
+ }}
358
+ >
359
+ {/* Header: status symbol + tool name - both use status color */}
360
+ <box key={`${tool.id}-header`} flexDirection="row" style={{ gap: 0 }}>
361
+ <text fg={color}>{symbol}</text>
362
+ <text fg={color}> {tool.name}</text>
363
+ </box>
364
+
365
+ {/* Parameters */}
366
+ {paramLines.map((line, idx) => (
367
+ <text
368
+ key={`${tool.id}-param-${idx}`}
369
+ fg={theme.colors.text.muted}
370
+ style={{ paddingLeft: 1 }}
371
+ >
372
+ {line}
373
+ </text>
374
+ ))}
375
+
376
+ {/* Inline approval UI if this tool is pending approval */}
377
+ {isPendingApproval &&
378
+ renderInlineApproval({
379
+ tool,
380
+ theme,
381
+ selectedIndex: pendingApproval.selectedIndex,
382
+ })}
383
+
384
+ {/* Output (shown after execution) - in scrollbox if large */}
385
+ {outputLines.length > 0 && (
386
+ <box
387
+ key={`${tool.id}-output`}
388
+ flexDirection="column"
389
+ style={{ gap: 0 }}
390
+ >
391
+ <text fg={theme.colors.text.muted} style={{ paddingLeft: 1 }}>
392
+ Output:
393
+ </text>
394
+ {outputNeedsScroll ? (
395
+ <scrollbox
396
+ style={{
397
+ height: TOOL_OUTPUT_MAX_HEIGHT,
398
+ maxHeight: TOOL_OUTPUT_MAX_HEIGHT,
399
+ paddingLeft: 0,
400
+ paddingRight: 0,
401
+ paddingTop: 0,
402
+ paddingBottom: 0,
403
+ overflow: 'hidden',
404
+ }}
405
+ contentOptions={{ paddingLeft: 1, paddingRight: 0 }}
406
+ scrollY
407
+ scrollX={false}
408
+ >
409
+ <box flexDirection="column" style={{ gap: 0, width: '100%' }}>
410
+ {outputLines.map((line, idx) => (
411
+ <text
412
+ key={`${tool.id}-output-${idx}`}
413
+ fg={
414
+ tool.errorMessage
415
+ ? (theme.colors.accent.error ??
416
+ theme.colors.text.primary)
417
+ : theme.colors.text.tool
418
+ }
419
+ >
420
+ {line}
421
+ </text>
422
+ ))}
423
+ </box>
424
+ </scrollbox>
425
+ ) : (
426
+ outputLines.map((line, idx) => (
427
+ <text
428
+ key={`${tool.id}-output-${idx}`}
429
+ fg={
430
+ tool.errorMessage
431
+ ? (theme.colors.accent.error ?? theme.colors.text.primary)
432
+ : theme.colors.text.tool
433
+ }
434
+ style={{ paddingLeft: 1 }}
435
+ >
436
+ {line}
437
+ </text>
438
+ ))
439
+ )}
440
+ </box>
441
+ )}
442
+
443
+ {/* Executing indicator */}
444
+ {tool.status === 'executing' && (
445
+ <text
446
+ key={`${tool.id}-executing`}
447
+ fg={theme.colors.text.muted}
448
+ style={{ paddingLeft: 1 }}
449
+ >
450
+ ...executing...
451
+ </text>
452
+ )}
453
+ </box>
454
+ );
455
+ }
456
+
457
+ export function renderToolBlock(
458
+ block: ToolBlockLegacy,
459
+ theme: ThemeDefinition,
460
+ ): React.ReactNode {
461
+ const content =
462
+ block.scrollable === true ? (
463
+ <scrollbox
464
+ style={{
465
+ paddingLeft: 0,
466
+ paddingRight: 0,
467
+ paddingTop: 0,
468
+ paddingBottom: 0,
469
+ height: Math.min(
470
+ block.lines.length + 1,
471
+ block.maxHeight ?? block.lines.length + 1,
472
+ ),
473
+ maxHeight: block.maxHeight,
474
+ overflow: 'hidden',
475
+ }}
476
+ contentOptions={{ paddingLeft: 0, paddingRight: 0 }}
477
+ scrollY
478
+ scrollX={false}
479
+ >
480
+ <box
481
+ flexDirection="column"
482
+ style={{ gap: 0, width: '100%', paddingLeft: 0, paddingRight: 0 }}
483
+ >
484
+ {block.lines.map((line, index) => (
485
+ <text key={`${block.id}-line-${index}`} fg={theme.colors.text.tool}>
486
+ {line}
487
+ </text>
488
+ ))}
489
+ </box>
490
+ </scrollbox>
491
+ ) : (
492
+ block.lines.map((line, index) => (
493
+ <text key={`${block.id}-line-${index}`} fg={theme.colors.text.tool}>
494
+ {line}
495
+ </text>
496
+ ))
497
+ );
498
+
499
+ return (
500
+ <box
501
+ key={block.id}
502
+ border
503
+ style={{
504
+ paddingLeft: 1,
505
+ paddingRight: 1,
506
+ paddingTop: 0,
507
+ paddingBottom: 0,
508
+ marginTop: 0,
509
+ marginBottom: 0,
510
+ width: '100%',
511
+ flexDirection: 'column',
512
+ gap: 0,
513
+ borderStyle: block.isBatch ? 'rounded' : 'single',
514
+ borderColor: theme.colors.panel.border,
515
+ backgroundColor: theme.colors.panel.bg,
516
+ overflow: 'hidden',
517
+ }}
518
+ >
519
+ {content}
520
+ {block.streaming === true ? (
521
+ <text fg={theme.colors.text.muted} key={`${block.id}-streaming`}>
522
+ ...streaming...
523
+ </text>
524
+ ) : null}
525
+ </box>
526
+ );
527
+ }
528
+
529
+ function ScrollbackView(props: ScrollbackProps): React.ReactNode {
530
+ return (
531
+ <scrollbox
532
+ ref={props.scrollRef}
533
+ style={{
534
+ flexGrow: 1,
535
+ border: true,
536
+ paddingTop: 0,
537
+ paddingBottom: 0,
538
+ paddingLeft: 0,
539
+ paddingRight: 0,
540
+ overflow: 'hidden',
541
+ borderColor: props.theme.colors.panel.border,
542
+ backgroundColor: props.theme.colors.panel.bg,
543
+ }}
544
+ contentOptions={{ paddingLeft: 2, paddingRight: 2 }}
545
+ verticalScrollbarOptions={{
546
+ trackOptions: {
547
+ backgroundColor: props.theme.colors.scrollbar?.track,
548
+ foregroundColor: props.theme.colors.scrollbar?.thumb,
549
+ },
550
+ }}
551
+ scrollX={false}
552
+ stickyScroll={props.autoFollow}
553
+ stickyStart="bottom"
554
+ scrollY
555
+ onMouse={props.onScroll}
556
+ focused
557
+ >
558
+ <box flexDirection="column" style={{ gap: 0, width: '100%' }}>
559
+ {props.entries.map((entry) => {
560
+ if (entry.kind === 'message') {
561
+ return renderChatMessage(entry, props.theme);
562
+ }
563
+ if (entry.kind === 'toolcall') {
564
+ return renderToolCall(entry, props.theme, props.pendingApproval);
565
+ }
566
+ // Legacy tool block
567
+ return renderToolBlock(entry, props.theme);
568
+ })}
569
+ </box>
570
+ </scrollbox>
571
+ );
572
+ }
573
+
574
+ function InputArea(props: InputAreaProps): React.ReactNode {
575
+ const isDisabled = props.disabled === true;
576
+ const placeholderText = useMemo(() => {
577
+ const text = isDisabled
578
+ ? 'Waiting for tool approval...'
579
+ : 'Type a thought, then submit with Enter';
580
+ const base = stringToStyledText(text);
581
+ const fg = parseColor(props.theme.colors.input.placeholder);
582
+ return { ...base, chunks: base.chunks.map((chunk) => ({ ...chunk, fg })) };
583
+ }, [props.theme.colors.input.placeholder, isDisabled]);
584
+
585
+ // When disabled, dim the colors to show input is inactive
586
+ const inputFg = isDisabled
587
+ ? props.theme.colors.text.muted
588
+ : props.theme.colors.input.fg;
589
+ const inputBg = isDisabled
590
+ ? props.theme.colors.panel.bg
591
+ : props.theme.colors.input.bg;
592
+
593
+ return (
594
+ <box
595
+ style={{
596
+ height: props.containerHeight,
597
+ minHeight: MIN_INPUT_LINES + 2,
598
+ maxHeight: MAX_INPUT_LINES + 2,
599
+ border: true,
600
+ paddingTop: 0,
601
+ paddingBottom: 0,
602
+ paddingLeft: 0,
603
+ paddingRight: 0,
604
+ flexDirection: 'column',
605
+ gap: 0,
606
+ borderColor: props.theme.colors.panel.border,
607
+ backgroundColor: props.theme.colors.panel.bg,
608
+ }}
609
+ >
610
+ <textarea
611
+ ref={props.textareaRef}
612
+ focused={!isDisabled}
613
+ placeholder={placeholderText}
614
+ keyBindings={TEXTAREA_KEY_BINDINGS}
615
+ onSubmit={props.handleSubmit}
616
+ onContentChange={props.enforceInputLineBounds}
617
+ onCursorChange={props.enforceInputLineBounds}
618
+ wrapMode="word"
619
+ cursorColor={props.theme.colors.input.fg}
620
+ style={{
621
+ height: props.textareaHeight,
622
+ minHeight: props.textareaHeight,
623
+ maxHeight: props.textareaHeight,
624
+ width: '100%',
625
+ }}
626
+ textColor={inputFg}
627
+ focusedTextColor={inputFg}
628
+ backgroundColor={inputBg}
629
+ focusedBackgroundColor={inputBg}
630
+ />
631
+ </box>
632
+ );
633
+ }
634
+
635
+ function clampInputLines(value: number): number {
636
+ return Math.min(MAX_INPUT_LINES, Math.max(MIN_INPUT_LINES, value));
637
+ }
638
+
639
+ export function ChatLayout(props: ChatLayoutProps): React.ReactNode {
640
+ const visibleInputLines = Math.min(
641
+ MAX_INPUT_LINES,
642
+ clampInputLines(props.inputLineCount),
643
+ );
644
+ const containerHeight = Math.min(
645
+ MAX_INPUT_LINES + 2,
646
+ Math.max(MIN_INPUT_LINES + 2, visibleInputLines + 2),
647
+ );
648
+ const textareaHeight = Math.max(3, containerHeight - 2);
649
+
650
+ return (
651
+ <box
652
+ flexDirection="column"
653
+ style={{
654
+ width: '100%',
655
+ height: '100%',
656
+ padding: 1,
657
+ gap: 1,
658
+ backgroundColor: props.theme.colors.background,
659
+ }}
660
+ onMouseUp={props.onMouseUp}
661
+ >
662
+ <HeaderBar text={props.headerText} theme={props.theme} />
663
+ <ScrollbackView
664
+ entries={props.entries}
665
+ scrollRef={props.scrollRef}
666
+ autoFollow={props.autoFollow}
667
+ onScroll={props.onScroll}
668
+ theme={props.theme}
669
+ pendingApproval={props.pendingApproval}
670
+ />
671
+ <InputArea
672
+ textareaRef={props.textareaRef}
673
+ containerHeight={containerHeight}
674
+ textareaHeight={textareaHeight}
675
+ handleSubmit={props.handleSubmit}
676
+ enforceInputLineBounds={props.enforceInputLineBounds}
677
+ theme={props.theme}
678
+ disabled={props.inputDisabled}
679
+ />
680
+ <SuggestionPanel
681
+ suggestions={props.suggestions}
682
+ selectedIndex={props.selectedSuggestion}
683
+ theme={props.theme}
684
+ />
685
+ <StatusBar
686
+ statusLabel={props.statusLabel}
687
+ promptCount={props.promptCount}
688
+ responderWordCount={props.responderWordCount}
689
+ streamState={props.streamState}
690
+ theme={props.theme}
691
+ />
692
+ </box>
693
+ );
694
+ }