@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.
- package/PLAN-messages.md +681 -0
- package/PLAN.md +47 -0
- package/README.md +25 -0
- package/bun.lock +1024 -0
- package/dev-docs/ARCHITECTURE.md +178 -0
- package/dev-docs/CODE_ORGANIZATION.md +232 -0
- package/dev-docs/STANDARDS.md +235 -0
- package/dev-docs/UI_DESIGN.md +425 -0
- package/eslint.config.cjs +194 -0
- package/images/nui.png +0 -0
- package/llxprt.png +0 -0
- package/llxprt.svg +128 -0
- package/package.json +66 -0
- package/scripts/check-limits.ts +177 -0
- package/scripts/start.js +71 -0
- package/src/app.tsx +599 -0
- package/src/bootstrap.tsx +23 -0
- package/src/commands/AuthCommand.tsx +80 -0
- package/src/commands/ModelCommand.tsx +102 -0
- package/src/commands/ProviderCommand.tsx +103 -0
- package/src/commands/ThemeCommand.tsx +71 -0
- package/src/features/chat/history.ts +178 -0
- package/src/features/chat/index.ts +3 -0
- package/src/features/chat/persistentHistory.ts +102 -0
- package/src/features/chat/responder.ts +217 -0
- package/src/features/completion/completions.ts +161 -0
- package/src/features/completion/index.ts +3 -0
- package/src/features/completion/slash.test.ts +82 -0
- package/src/features/completion/slash.ts +248 -0
- package/src/features/completion/suggestions.test.ts +51 -0
- package/src/features/completion/suggestions.ts +112 -0
- package/src/features/config/configSession.test.ts +189 -0
- package/src/features/config/configSession.ts +179 -0
- package/src/features/config/index.ts +4 -0
- package/src/features/config/llxprtAdapter.integration.test.ts +202 -0
- package/src/features/config/llxprtAdapter.test.ts +139 -0
- package/src/features/config/llxprtAdapter.ts +257 -0
- package/src/features/config/llxprtCommands.test.ts +40 -0
- package/src/features/config/llxprtCommands.ts +35 -0
- package/src/features/config/llxprtConfig.test.ts +261 -0
- package/src/features/config/llxprtConfig.ts +418 -0
- package/src/features/theme/index.ts +2 -0
- package/src/features/theme/theme.test.ts +51 -0
- package/src/features/theme/theme.ts +105 -0
- package/src/features/theme/themeManager.ts +84 -0
- package/src/hooks/useAppCommands.ts +129 -0
- package/src/hooks/useApprovalKeyboard.ts +156 -0
- package/src/hooks/useChatStore.test.ts +112 -0
- package/src/hooks/useChatStore.ts +252 -0
- package/src/hooks/useInputManager.ts +99 -0
- package/src/hooks/useKeyboardHandlers.ts +130 -0
- package/src/hooks/useListNavigation.test.ts +166 -0
- package/src/hooks/useListNavigation.ts +62 -0
- package/src/hooks/usePersistentHistory.ts +94 -0
- package/src/hooks/useScrollManagement.ts +107 -0
- package/src/hooks/useSelectionClipboard.ts +48 -0
- package/src/hooks/useSessionManager.test.ts +85 -0
- package/src/hooks/useSessionManager.ts +101 -0
- package/src/hooks/useStreamingLifecycle.ts +71 -0
- package/src/hooks/useStreamingResponder.ts +401 -0
- package/src/hooks/useSuggestionSetup.ts +23 -0
- package/src/hooks/useToolApproval.test.ts +140 -0
- package/src/hooks/useToolApproval.ts +264 -0
- package/src/hooks/useToolScheduler.ts +432 -0
- package/src/index.ts +3 -0
- package/src/jsx.d.ts +11 -0
- package/src/lib/clipboard.ts +18 -0
- package/src/lib/logger.ts +107 -0
- package/src/lib/random.ts +5 -0
- package/src/main.tsx +13 -0
- package/src/test/mockTheme.ts +51 -0
- package/src/types/events.ts +87 -0
- package/src/types.ts +13 -0
- package/src/ui/components/ChatLayout.tsx +694 -0
- package/src/ui/components/CommandComponents.tsx +74 -0
- package/src/ui/components/DiffViewer.tsx +306 -0
- package/src/ui/components/FilterInput.test.ts +69 -0
- package/src/ui/components/FilterInput.tsx +62 -0
- package/src/ui/components/HeaderBar.tsx +137 -0
- package/src/ui/components/RadioSelect.test.ts +140 -0
- package/src/ui/components/RadioSelect.tsx +88 -0
- package/src/ui/components/SelectableList.test.ts +83 -0
- package/src/ui/components/SelectableList.tsx +35 -0
- package/src/ui/components/StatusBar.tsx +45 -0
- package/src/ui/components/SuggestionPanel.tsx +102 -0
- package/src/ui/components/messages/ModelMessage.tsx +14 -0
- package/src/ui/components/messages/SystemMessage.tsx +29 -0
- package/src/ui/components/messages/ThinkingMessage.tsx +14 -0
- package/src/ui/components/messages/UserMessage.tsx +26 -0
- package/src/ui/components/messages/index.ts +15 -0
- package/src/ui/components/messages/renderMessage.test.ts +49 -0
- package/src/ui/components/messages/renderMessage.tsx +43 -0
- package/src/ui/components/messages/types.test.ts +24 -0
- package/src/ui/components/messages/types.ts +36 -0
- package/src/ui/modals/AuthModal.tsx +106 -0
- package/src/ui/modals/ModalShell.tsx +60 -0
- package/src/ui/modals/SearchSelectModal.tsx +236 -0
- package/src/ui/modals/ThemeModal.tsx +204 -0
- package/src/ui/modals/ToolApprovalModal.test.ts +206 -0
- package/src/ui/modals/ToolApprovalModal.tsx +282 -0
- package/src/ui/modals/index.ts +20 -0
- package/src/ui/modals/modals.test.ts +26 -0
- package/src/ui/modals/types.ts +19 -0
- package/src/uicontext/Command.tsx +102 -0
- package/src/uicontext/Dialog.tsx +65 -0
- package/src/uicontext/index.ts +2 -0
- package/themes/ansi-light.json +59 -0
- package/themes/ansi.json +59 -0
- package/themes/atom-one-dark.json +59 -0
- package/themes/ayu-light.json +59 -0
- package/themes/ayu.json +59 -0
- package/themes/default-light.json +59 -0
- package/themes/default.json +59 -0
- package/themes/dracula.json +59 -0
- package/themes/github-dark.json +59 -0
- package/themes/github-light.json +59 -0
- package/themes/googlecode.json +59 -0
- package/themes/green-screen.json +59 -0
- package/themes/no-color.json +59 -0
- package/themes/shades-of-purple.json +59 -0
- package/themes/xcode.json +59 -0
- package/tsconfig.json +28 -0
- 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
|
+
}
|