@fastino-ai/pioneer-cli 0.2.10 → 0.3.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,1028 +0,0 @@
1
- /**
2
- * ChatApp - Main interactive chat interface using Ink
3
- * Uses Pioneer backend via WebSocket for agent, executes CLI tools locally
4
- */
5
-
6
- import React, { useState, useEffect, useCallback, useRef } from "react";
7
- import { Box, Text, useApp, useInput, useStdin } from "ink";
8
- import TextInput from "ink-text-input";
9
- import Spinner from "ink-spinner";
10
- import Markdown from "@inkkit/ink-markdown";
11
-
12
- import { WebSocketClient, type HistoryMessage } from "../client/WebSocketClient.js";
13
- import { ToolExecutor } from "../client/ToolExecutor.js";
14
- import type { FileReference } from "../utils/FileResolver.js";
15
- import { FileResolver } from "../utils/FileResolver.js";
16
- import { executeBashStream } from "../tools/bash.js";
17
- import { trackChatSessionStart, trackChatSessionEnd } from "../telemetry.js";
18
- import { getMleModel, setMleModel, AVAILABLE_MLE_MODELS, DEFAULT_MLE_MODEL } from "../config.js";
19
-
20
- // ─────────────────────────────────────────────────────────────────────────────
21
- // Types
22
- // ─────────────────────────────────────────────────────────────────────────────
23
-
24
- interface ChatMessage {
25
- role: "user" | "assistant" | "system" | "tool";
26
- content: string;
27
- timestamp: Date;
28
- toolName?: string;
29
- toolArgs?: Record<string, unknown>;
30
- isStreaming?: boolean;
31
- isUserBash?: boolean; // For !command - show raw output
32
- }
33
-
34
- interface ToolCallInfo {
35
- name: string;
36
- args: Record<string, unknown>;
37
- }
38
-
39
- interface ChatState {
40
- messages: ChatMessage[];
41
- isProcessing: boolean;
42
- streamingContent: string;
43
- currentToolCall: ToolCallInfo | null;
44
- fileReferences: FileReference[];
45
- error: string | null;
46
- }
47
-
48
- // ─────────────────────────────────────────────────────────────────────────────
49
- // Tool Output Formatting
50
- // ─────────────────────────────────────────────────────────────────────────────
51
-
52
- interface FormattedToolOutput {
53
- header: string;
54
- summary: string;
55
- showContent: boolean;
56
- content?: string;
57
- }
58
-
59
- function formatToolOutput(
60
- toolName: string,
61
- args: Record<string, unknown>,
62
- result: string
63
- ): FormattedToolOutput {
64
- const lineCount = result.split("\n").length;
65
-
66
- switch (toolName) {
67
- case "read_file": {
68
- const filePath = args.path as string || args.file_path as string || "unknown";
69
- const shortPath = filePath.split("/").slice(-2).join("/");
70
- return {
71
- header: `Read(${shortPath})`,
72
- summary: `Read ${lineCount} lines`,
73
- showContent: false,
74
- };
75
- }
76
-
77
- case "write_file": {
78
- const filePath = args.path as string || args.file_path as string || "unknown";
79
- const shortPath = filePath.split("/").slice(-2).join("/");
80
- const content = args.content as string || "";
81
- const writtenLines = content.split("\n").length;
82
- return {
83
- header: `Write(${shortPath})`,
84
- summary: `Wrote ${writtenLines} lines`,
85
- showContent: false,
86
- };
87
- }
88
-
89
- case "list_directory": {
90
- const dirPath = args.path as string || args.directory as string || ".";
91
- const shortPath = dirPath === "." ? "." : dirPath.split("/").slice(-2).join("/");
92
- const entries = result.trim().split("\n").filter(Boolean).length;
93
- return {
94
- header: `ListDir(${shortPath})`,
95
- summary: `${entries} entries`,
96
- showContent: true,
97
- content: result.length > 500 ? result.slice(0, 500) + "\n..." : result,
98
- };
99
- }
100
-
101
- case "bash": {
102
- const command = args.command as string || "";
103
- const shortCommand = command.length > 60 ? command.slice(0, 60) + "..." : command;
104
- const hasOutput = Boolean(result && result !== "(no output)");
105
- return {
106
- header: `Bash: ${shortCommand}`,
107
- summary: hasOutput ? `${lineCount} lines` : "(no output)",
108
- showContent: hasOutput && lineCount <= 30,
109
- content: hasOutput ? (result.length > 1000 ? result.slice(0, 1000) + "\n..." : result) : undefined,
110
- };
111
- }
112
-
113
- case "search_files":
114
- case "grep": {
115
- const pattern = args.pattern as string || "";
116
- const matches = result.trim().split("\n").filter(Boolean).length;
117
- return {
118
- header: `Search: "${pattern}"`,
119
- summary: `${matches} matches`,
120
- showContent: matches <= 20,
121
- content: matches <= 20 ? result : result.split("\n").slice(0, 20).join("\n") + `\n... (${matches - 20} more)`,
122
- };
123
- }
124
-
125
- default: {
126
- // For unknown tools, show truncated content
127
- return {
128
- header: toolName,
129
- summary: `${lineCount} lines`,
130
- showContent: lineCount <= 20,
131
- content: result.length > 1000 ? result.slice(0, 1000) + "\n..." : result,
132
- };
133
- }
134
- }
135
- }
136
-
137
- // ─────────────────────────────────────────────────────────────────────────────
138
- // Message Display Components
139
- // ─────────────────────────────────────────────────────────────────────────────
140
-
141
- const MessageBubble: React.FC<{ message: ChatMessage }> = React.memo(({ message }) => {
142
- const roleColors: Record<string, string> = {
143
- user: "cyan",
144
- assistant: "green",
145
- system: "yellow",
146
- tool: "magenta",
147
- };
148
-
149
- // Handle user-initiated bash commands (!command) - show raw output
150
- if (message.role === "tool" && message.isUserBash) {
151
- return (
152
- <Box flexDirection="column" marginBottom={1}>
153
- <Text wrap="wrap">{message.content}</Text>
154
- </Box>
155
- );
156
- }
157
-
158
- // Handle agent tool messages with pretty formatting
159
- if (message.role === "tool" && message.toolName && message.toolArgs) {
160
- const formatted = formatToolOutput(message.toolName, message.toolArgs, message.content);
161
- return (
162
- <Box flexDirection="column" marginBottom={1}>
163
- <Text color="magenta">
164
- {formatted.header}
165
- </Text>
166
- <Box marginLeft={2}>
167
- <Text color="gray">⎿ {formatted.summary}</Text>
168
- </Box>
169
- {formatted.showContent && formatted.content && (
170
- <Box marginLeft={4} flexDirection="column">
171
- <Text wrap="wrap" dimColor>{formatted.content}</Text>
172
- </Box>
173
- )}
174
- </Box>
175
- );
176
- }
177
-
178
- // Handle tool messages without args (fallback)
179
- if (message.role === "tool") {
180
- const label = message.toolName || "Tool";
181
- const content = message.content.length > 1000
182
- ? message.content.slice(0, 1000) + "\n... (truncated)"
183
- : message.content;
184
- return (
185
- <Box flexDirection="column" marginBottom={1}>
186
- <Text color="magenta">{label}:</Text>
187
- <Box marginLeft={2}>
188
- <Text wrap="wrap" dimColor>{content}</Text>
189
- </Box>
190
- </Box>
191
- );
192
- }
193
-
194
- const roleLabels: Record<string, string> = {
195
- user: "You",
196
- assistant: "Agent",
197
- system: "System",
198
- };
199
-
200
- const color = roleColors[message.role] || "white";
201
- const label = roleLabels[message.role] || message.role;
202
-
203
- return (
204
- <Box flexDirection="column" marginBottom={1}>
205
- <Text color={color} bold>
206
- {label}:
207
- </Text>
208
- <Box marginLeft={2}>
209
- {message.role === "assistant" ? (
210
- <Markdown>{message.content}</Markdown>
211
- ) : (
212
- <Text wrap="wrap">{message.content}</Text>
213
- )}
214
- </Box>
215
- </Box>
216
- );
217
- });
218
- MessageBubble.displayName = 'MessageBubble';
219
-
220
- const StreamingMessage: React.FC<{ content: string }> = ({ content }) => (
221
- <Box flexDirection="column" marginBottom={1}>
222
- <Text color="green" bold>
223
- Agent: <Text color="gray">(streaming...)</Text>
224
- </Text>
225
- <Box marginLeft={2}>
226
- {content ? <Markdown>{content}</Markdown> : <Text>...</Text>}
227
- </Box>
228
- </Box>
229
- );
230
-
231
- const ToolCallIndicator: React.FC<{ toolCall: ToolCallInfo }> = React.memo(({ toolCall }) => {
232
- // Show a nice summary of what tool is being called
233
- let displayText = toolCall.name;
234
- if (toolCall.name === "bash" && toolCall.args.command) {
235
- const cmd = toolCall.args.command as string;
236
- displayText = `bash: ${cmd.length > 50 ? cmd.slice(0, 50) + "..." : cmd}`;
237
- } else if ((toolCall.name === "read_file" || toolCall.name === "write_file") && (toolCall.args.path || toolCall.args.file_path)) {
238
- const path = (toolCall.args.path || toolCall.args.file_path) as string;
239
- displayText = `${toolCall.name}: ${path.split("/").slice(-2).join("/")}`;
240
- } else if (toolCall.name === "list_directory" && (toolCall.args.path || toolCall.args.directory)) {
241
- const path = (toolCall.args.path || toolCall.args.directory) as string;
242
- displayText = `list_directory: ${path}`;
243
- }
244
-
245
- return (
246
- <Box marginBottom={1}>
247
- <Text color="magenta">
248
- <Spinner type="dots" /> {displayText}
249
- </Text>
250
- </Box>
251
- );
252
- });
253
- ToolCallIndicator.displayName = 'ToolCallIndicator';
254
-
255
- const FileReferencesIndicator: React.FC<{ refs: FileReference[] }> = React.memo(({ refs }) => {
256
- if (refs.length === 0) return null;
257
-
258
- return (
259
- <Box flexDirection="column" marginBottom={1}>
260
- <Text color="blue" bold>📎 Referenced files:</Text>
261
- {refs.map((ref, i) => (
262
- <Box key={i} marginLeft={2}>
263
- <Text color={ref.exists ? "blue" : "red"}>
264
- {ref.exists ? "✓" : "✗"} {ref.relativePath}
265
- {ref.isDirectory ? "/" : ""}
266
- {ref.error ? ` (${ref.error})` : ""}
267
- </Text>
268
- </Box>
269
- ))}
270
- </Box>
271
- );
272
- });
273
- FileReferencesIndicator.displayName = 'FileReferencesIndicator';
274
-
275
- // File autocomplete dropdown
276
- const FileSuggestions: React.FC<{
277
- suggestions: string[];
278
- selectedIndex: number;
279
- searchPath: string;
280
- }> = ({ suggestions, selectedIndex, searchPath }) => {
281
- if (suggestions.length === 0) return null;
282
-
283
- return (
284
- <Box flexDirection="column" borderStyle="round" borderColor="blue" paddingX={1} marginBottom={1}>
285
- <Text color="blue" dimColor>
286
- 📁 {searchPath || "./"} <Text color="gray">(↑↓ navigate, Tab select, Esc close)</Text>
287
- </Text>
288
- {suggestions.slice(0, 10).map((suggestion, i) => (
289
- <Box key={i}>
290
- <Text
291
- color={i === selectedIndex ? "black" : "white"}
292
- backgroundColor={i === selectedIndex ? "blue" : undefined}
293
- >
294
- {" "}{suggestion.endsWith("/") ? "📁" : "📄"} {suggestion}{" "}
295
- </Text>
296
- </Box>
297
- ))}
298
- {suggestions.length > 10 && (
299
- <Text dimColor> ... and {suggestions.length - 10} more</Text>
300
- )}
301
- </Box>
302
- );
303
- };
304
-
305
- // Extract @path from end of input
306
- function extractAtPath(input: string): { path: string; start: number } | null {
307
- const match = input.match(/@([\w\-.\/]*)$/);
308
- if (!match) return null;
309
- return { path: match[1], start: input.length - match[0].length };
310
- }
311
-
312
- // Model selection component
313
- const ModelSelector: React.FC<{
314
- selectedIndex: number;
315
- currentModel: string | undefined;
316
- }> = ({ selectedIndex, currentModel }) => {
317
- const effectiveModel = currentModel || DEFAULT_MLE_MODEL;
318
-
319
- return (
320
- <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1}>
321
- <Text color="cyan" bold>
322
- Select a model: <Text color="gray">(↑/↓ navigate, Enter select, Esc cancel)</Text>
323
- </Text>
324
- <Box marginTop={1} flexDirection="column">
325
- {AVAILABLE_MLE_MODELS.map((model, i) => {
326
- const isSelected = i === selectedIndex;
327
- const isCurrent = model.id === effectiveModel;
328
- return (
329
- <Box key={model.id}>
330
- <Text
331
- color={isSelected ? "black" : isCurrent ? "cyan" : "white"}
332
- backgroundColor={isSelected ? "cyan" : undefined}
333
- bold={isSelected}
334
- >
335
- {" "}{isSelected ? "❯" : " "} {model.name}
336
- <Text dimColor={!isSelected}> - {model.description}</Text>
337
- {isCurrent && <Text color={isSelected ? "black" : "green"}> (current)</Text>}
338
- {" "}
339
- </Text>
340
- </Box>
341
- );
342
- })}
343
- </Box>
344
- </Box>
345
- );
346
- };
347
-
348
- const StatusBar: React.FC<{ serverStatus: string; isProcessing: boolean }> = ({
349
- serverStatus,
350
- isProcessing,
351
- }) => (
352
- <Box
353
- borderStyle="single"
354
- borderColor="gray"
355
- paddingX={1}
356
- marginTop={1}
357
- >
358
- <Text dimColor>
359
- {isProcessing ? "Processing... | " : ""}
360
- {serverStatus}
361
- </Text>
362
- </Box>
363
- );
364
-
365
- // ─────────────────────────────────────────────────────────────────────────────
366
- // Main Chat Component
367
- // ─────────────────────────────────────────────────────────────────────────────
368
-
369
- export interface ChatAppProps {
370
- initialMessage?: string;
371
- }
372
-
373
- export const ChatApp: React.FC<ChatAppProps> = ({ initialMessage }) => {
374
- const { exit } = useApp();
375
- const { isRawModeSupported } = useStdin();
376
-
377
- const [state, setState] = useState<ChatState>({
378
- messages: [],
379
- isProcessing: false,
380
- streamingContent: "",
381
- currentToolCall: null,
382
- fileReferences: [],
383
- error: null,
384
- });
385
-
386
- const [input, setInput] = useState("");
387
- const [client, setClient] = useState<WebSocketClient | null>(null);
388
- const [toolExecutor, setToolExecutor] = useState<ToolExecutor | null>(null);
389
- const [fileResolver, setFileResolver] = useState<FileResolver | null>(null);
390
- const [serverStatus, setServerStatus] = useState("Connecting...");
391
- const [history, setHistory] = useState<HistoryMessage[]>([]);
392
-
393
- // Autocomplete state
394
- const [suggestions, setSuggestions] = useState<string[]>([]);
395
- const [selectedIndex, setSelectedIndex] = useState(0);
396
- const [showSuggestions, setShowSuggestions] = useState(false);
397
- const [currentAtPath, setCurrentAtPath] = useState<{ path: string; start: number } | null>(null);
398
-
399
- // Model selection state
400
- const [showModelSelect, setShowModelSelect] = useState(false);
401
- const [modelSelectedIndex, setModelSelectedIndex] = useState(0);
402
- const [currentMleModel, setCurrentMleModel] = useState<string | undefined>(getMleModel());
403
-
404
- // Streaming content refs for throttled updates
405
- const streamBufferRef = useRef("");
406
- const streamUpdateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
407
-
408
- // Session tracking refs
409
- const sessionStartTimeRef = useRef<number>(Date.now());
410
- const toolCallCountRef = useRef<number>(0);
411
- const messageCountRef = useRef<number>(0);
412
-
413
- // Initialize client, tool executor, and file resolver
414
- useEffect(() => {
415
- // Track session start
416
- sessionStartTimeRef.current = Date.now();
417
- toolCallCountRef.current = 0;
418
- const model = getMleModel() || DEFAULT_MLE_MODEL;
419
- trackChatSessionStart("mle-agent", model);
420
-
421
- const newClient = new WebSocketClient();
422
- setClient(newClient);
423
-
424
- const executor = new ToolExecutor({ workingDirectory: process.cwd() });
425
- setToolExecutor(executor);
426
-
427
- const resolver = new FileResolver(process.cwd());
428
- setFileResolver(resolver);
429
-
430
- // Check server health and connect
431
- const serverUrl = newClient.getWebSocketUrl();
432
- newClient.health().then(async (healthy) => {
433
- if (healthy) {
434
- try {
435
- await newClient.connect();
436
- setServerStatus("Connected to Pioneer server (WebSocket)");
437
- } catch (err) {
438
- const errorMsg = err instanceof Error ? err.message : "Unknown error";
439
- setServerStatus(`⚠️ WebSocket failed (${serverUrl}): ${errorMsg}`);
440
- }
441
- } else {
442
- setServerStatus(`⚠️ Server not reachable at ${serverUrl}`);
443
- }
444
- });
445
-
446
- return () => {
447
- // Track session end
448
- const durationMs = Date.now() - sessionStartTimeRef.current;
449
- trackChatSessionEnd(durationMs, messageCountRef.current, toolCallCountRef.current);
450
- newClient.disconnect();
451
- };
452
- }, []);
453
-
454
- // Keep message count ref in sync for telemetry
455
- useEffect(() => {
456
- messageCountRef.current = state.messages.filter(m => m.role === "user").length;
457
- }, [state.messages]);
458
-
459
- // Update file suggestions when input changes
460
- useEffect(() => {
461
- if (!fileResolver || state.isProcessing) {
462
- if (showSuggestions) {
463
- setSuggestions([]);
464
- setShowSuggestions(false);
465
- }
466
- return;
467
- }
468
-
469
- const atPath = extractAtPath(input);
470
-
471
- if (JSON.stringify(atPath) !== JSON.stringify(currentAtPath)) {
472
- setCurrentAtPath(atPath);
473
- }
474
-
475
- if (atPath) {
476
- const newSuggestions = fileResolver.getSuggestions(atPath.path);
477
- setSuggestions(newSuggestions);
478
- setShowSuggestions(newSuggestions.length > 0);
479
- setSelectedIndex(0);
480
- } else if (showSuggestions) {
481
- setSuggestions([]);
482
- setShowSuggestions(false);
483
- }
484
- }, [input, fileResolver, state.isProcessing]);
485
-
486
- // Handle initial message if provided
487
- useEffect(() => {
488
- if (client && initialMessage && state.messages.length === 0) {
489
- handleSubmit(initialMessage);
490
- }
491
- }, [client, initialMessage]);
492
-
493
- const handleSubmit = useCallback(
494
- async (value: string) => {
495
- if (!client || !value.trim() || state.isProcessing) return;
496
-
497
- const userMessage = value.trim();
498
- setInput("");
499
-
500
- // Handle special commands
501
- if (userMessage === "/exit" || userMessage === "/quit") {
502
- exit();
503
- return;
504
- }
505
-
506
- if (userMessage === "/clear") {
507
- streamBufferRef.current = "";
508
- setHistory([]);
509
- setState({
510
- messages: [],
511
- isProcessing: false,
512
- streamingContent: "",
513
- currentToolCall: null,
514
- fileReferences: [],
515
- error: null,
516
- });
517
- return;
518
- }
519
-
520
- if (userMessage === "/help") {
521
- setState((s) => ({
522
- ...s,
523
- messages: [
524
- ...s.messages,
525
- {
526
- role: "system",
527
- content: `Available commands:
528
- /clear - Clear conversation history
529
- /help - Show this help
530
- /exit - Exit the chat
531
- /tools - List available tools
532
- /status - Check server connection
533
- /model - Select AI model (Haiku/Sonnet/Opus)
534
-
535
- Direct bash mode:
536
- !<command> - Execute command directly (no agent)
537
- !ls -la - Example: list files
538
- !pwd - Example: print working directory
539
-
540
- File references:
541
- @file.ts - Include file contents in message
542
- @src/ - Include directory listing
543
- @package.json - Include any file by path`,
544
- timestamp: new Date(),
545
- },
546
- ],
547
- }));
548
- return;
549
- }
550
-
551
- if (userMessage === "/tools") {
552
- try {
553
- const tools = await client.listTools();
554
- const toolList = tools
555
- .map((t) => ` • ${t.name}: ${t.description}`)
556
- .join("\n");
557
- setState((s) => ({
558
- ...s,
559
- messages: [
560
- ...s.messages,
561
- {
562
- role: "system",
563
- content: `Available tools:\n${toolList}`,
564
- timestamp: new Date(),
565
- },
566
- ],
567
- }));
568
- } catch (error) {
569
- setState((s) => ({
570
- ...s,
571
- messages: [
572
- ...s.messages,
573
- {
574
- role: "system",
575
- content: `Error fetching tools: ${error}`,
576
- timestamp: new Date(),
577
- },
578
- ],
579
- }));
580
- }
581
- return;
582
- }
583
-
584
- if (userMessage === "/status") {
585
- const healthy = await client.health();
586
- setState((s) => ({
587
- ...s,
588
- messages: [
589
- ...s.messages,
590
- {
591
- role: "system",
592
- content: healthy
593
- ? "✅ Connected to Pioneer server"
594
- : "❌ Cannot reach Pioneer server",
595
- timestamp: new Date(),
596
- },
597
- ],
598
- }));
599
- return;
600
- }
601
-
602
- if (userMessage === "/model") {
603
- // Find the index of the current model
604
- const currentModel = currentMleModel || DEFAULT_MLE_MODEL;
605
- const currentIndex = AVAILABLE_MLE_MODELS.findIndex(m => m.id === currentModel);
606
- setModelSelectedIndex(currentIndex >= 0 ? currentIndex : 0);
607
- setShowModelSelect(true);
608
- return;
609
- }
610
-
611
- // Direct bash mode: commands starting with ! execute directly without agent
612
- if (userMessage.startsWith("!")) {
613
- const bashCommand = userMessage.slice(1).trim();
614
- if (!bashCommand) return;
615
-
616
- setState((s) => ({
617
- ...s,
618
- messages: [...s.messages, { role: "user", content: userMessage, timestamp: new Date() }],
619
- isProcessing: true,
620
- }));
621
-
622
- let output = "";
623
- try {
624
- for await (const chunk of executeBashStream(bashCommand, { cwd: process.cwd() })) {
625
- if (chunk.type === "stdout" || chunk.type === "stderr") {
626
- output += chunk.data;
627
- }
628
- }
629
- } catch (err) {
630
- output = `Error: ${err instanceof Error ? err.message : String(err)}`;
631
- }
632
-
633
- setState((s) => ({
634
- ...s,
635
- messages: [...s.messages, {
636
- role: "tool",
637
- content: output || "(no output)",
638
- timestamp: new Date(),
639
- toolName: "bash",
640
- isUserBash: true,
641
- }],
642
- isProcessing: false,
643
- }));
644
- return;
645
- }
646
-
647
- // Process file references (@file syntax) using FileResolver
648
- let processedMessage = userMessage;
649
- let fileRefs: FileReference[] = [];
650
- if (fileResolver) {
651
- const resolved = fileResolver.resolve(userMessage);
652
- fileRefs = resolved.references;
653
- // Prepend the context block to the message if there are resolved files
654
- if (resolved.contextBlock) {
655
- processedMessage = resolved.contextBlock + "\n" + userMessage;
656
- }
657
- }
658
-
659
- // Update file references display
660
- setState((s) => ({
661
- ...s,
662
- fileReferences: fileRefs,
663
- }));
664
-
665
- // Add user message
666
- streamBufferRef.current = "";
667
- if (streamUpdateTimerRef.current) {
668
- clearTimeout(streamUpdateTimerRef.current);
669
- streamUpdateTimerRef.current = null;
670
- }
671
- setState((s) => ({
672
- ...s,
673
- messages: [
674
- ...s.messages,
675
- { role: "user", content: userMessage, timestamp: new Date() },
676
- ],
677
- isProcessing: true,
678
- streamingContent: "",
679
- error: null,
680
- }));
681
-
682
- // Track final response for history
683
- let finalResponse = "";
684
-
685
- try {
686
- await client.chat(
687
- processedMessage,
688
- {
689
- onStream: (chunk) => {
690
- streamBufferRef.current += chunk;
691
- if (!streamUpdateTimerRef.current) {
692
- streamUpdateTimerRef.current = setTimeout(() => {
693
- setState((s) => ({
694
- ...s,
695
- streamingContent: streamBufferRef.current,
696
- }));
697
- streamUpdateTimerRef.current = null;
698
- }, 50);
699
- }
700
- },
701
- onToolStart: (name, callId, args) => {
702
- toolCallCountRef.current++;
703
- setState((s) => ({
704
- ...s,
705
- currentToolCall: { name, args: args as Record<string, unknown> },
706
- }));
707
- },
708
- onToolCall: async (call) => {
709
- // Execute tool locally using ToolExecutor
710
- // NOTE: Don't add message here - server will send tool_result event
711
- // which triggers onToolResult and adds the message there
712
- if (!toolExecutor) {
713
- throw new Error("Tool executor not initialized");
714
- }
715
- setState((s) => ({
716
- ...s,
717
- currentToolCall: { name: call.tool, args: call.args },
718
- }));
719
- const result = await toolExecutor.execute(call.tool, call.args);
720
- // Keep currentToolCall for onToolResult to use
721
- return result;
722
- },
723
- onToolResult: (name, callId, result) => {
724
- // Tool result from server (both Felix tools AND client-side tool results echoed back)
725
- setState((s) => {
726
- // Get args from currentToolCall if available
727
- const toolArgs = s.currentToolCall?.name === name ? s.currentToolCall.args : {};
728
- return {
729
- ...s,
730
- currentToolCall: null,
731
- messages: [
732
- ...s.messages,
733
- {
734
- role: "tool",
735
- content: typeof result === "string" ? result : JSON.stringify(result, null, 2),
736
- timestamp: new Date(),
737
- toolName: name,
738
- toolArgs,
739
- },
740
- ],
741
- };
742
- });
743
- },
744
- onToolError: (name, callId, error) => {
745
- setState((s) => ({
746
- ...s,
747
- currentToolCall: null,
748
- messages: [
749
- ...s.messages,
750
- {
751
- role: "tool",
752
- content: `Error: ${error}`,
753
- timestamp: new Date(),
754
- toolName: name,
755
- },
756
- ],
757
- }));
758
- },
759
- onAssistantMessage: (content) => {
760
- finalResponse = content;
761
- },
762
- onError: (error) => {
763
- setState((s) => ({
764
- ...s,
765
- error: error.message,
766
- isProcessing: false,
767
- }));
768
- },
769
- onDone: (backendMessages) => {
770
- // Clear any pending stream update
771
- if (streamUpdateTimerRef.current) {
772
- clearTimeout(streamUpdateTimerRef.current);
773
- streamUpdateTimerRef.current = null;
774
- }
775
- streamBufferRef.current = "";
776
-
777
- console.log("[DEBUG] ChatApp onDone received messages:", backendMessages?.length, "roles:", backendMessages?.map(m => m.role));
778
-
779
- // Update history with full message history from backend (includes tool calls)
780
- if (backendMessages && backendMessages.length > 0) {
781
- // Backend sends the complete history including this turn's messages
782
- console.log("[DEBUG] Setting history to backendMessages");
783
- setHistory(backendMessages);
784
- } else if (finalResponse) {
785
- // Fallback if backend doesn't send messages (legacy)
786
- setHistory((h) => [
787
- ...h,
788
- { role: "user", content: processedMessage },
789
- { role: "assistant", content: finalResponse },
790
- ]);
791
- }
792
-
793
- // Update UI state
794
- if (finalResponse) {
795
- setState((s) => ({
796
- ...s,
797
- messages: [
798
- ...s.messages,
799
- { role: "assistant", content: finalResponse, timestamp: new Date() },
800
- ],
801
- isProcessing: false,
802
- streamingContent: "",
803
- }));
804
- } else {
805
- setState((s) => ({
806
- ...s,
807
- isProcessing: false,
808
- streamingContent: "",
809
- }));
810
- }
811
- },
812
- },
813
- {
814
- history: history,
815
- fileReferences: fileRefs.filter(r => r.exists).map(r => r.path),
816
- config: currentMleModel ? { model: currentMleModel } : undefined,
817
- }
818
- );
819
- } catch (error) {
820
- streamBufferRef.current = "";
821
- setState((s) => ({
822
- ...s,
823
- error: error instanceof Error ? error.message : String(error),
824
- isProcessing: false,
825
- }));
826
- }
827
- },
828
- [client, toolExecutor, fileResolver, state.isProcessing, history, exit, currentMleModel]
829
- );
830
-
831
- // Insert selected suggestion into input
832
- const insertSuggestion = useCallback((suggestion: string) => {
833
- if (!currentAtPath) return;
834
- const before = input.slice(0, currentAtPath.start);
835
- const newInput = before + "@" + suggestion;
836
- setInput(newInput);
837
- setShowSuggestions(false);
838
- setSuggestions([]);
839
- }, [input, currentAtPath]);
840
-
841
- // Stop the request (disconnect and reconnect)
842
- const handleStop = useCallback(() => {
843
- if (client && state.isProcessing) {
844
- client.disconnect();
845
- // Reconnect for future messages
846
- client.connect().catch(() => {
847
- setServerStatus("⚠️ Reconnection failed");
848
- });
849
- setState((s) => ({
850
- ...s,
851
- isProcessing: false,
852
- currentToolCall: null,
853
- }));
854
- }
855
- }, [client, state.isProcessing]);
856
-
857
- // Handle keyboard shortcuts
858
- useInput(
859
- (char, key) => {
860
- if (key.ctrl && char === "c") {
861
- exit();
862
- return;
863
- }
864
-
865
- if (key.escape && state.isProcessing) {
866
- handleStop();
867
- return;
868
- }
869
-
870
- // Handle model selection navigation
871
- if (showModelSelect) {
872
- if (key.downArrow) {
873
- setModelSelectedIndex((i) => Math.min(i + 1, AVAILABLE_MLE_MODELS.length - 1));
874
- return;
875
- }
876
- if (key.upArrow) {
877
- setModelSelectedIndex((i) => Math.max(i - 1, 0));
878
- return;
879
- }
880
- if (key.return) {
881
- const selectedModel = AVAILABLE_MLE_MODELS[modelSelectedIndex];
882
- setMleModel(selectedModel.id);
883
- setCurrentMleModel(selectedModel.id);
884
- setShowModelSelect(false);
885
- setState((s) => ({
886
- ...s,
887
- messages: [
888
- ...s.messages,
889
- {
890
- role: "system",
891
- content: `✓ Model set to: ${selectedModel.name} (${selectedModel.id})`,
892
- timestamp: new Date(),
893
- },
894
- ],
895
- }));
896
- return;
897
- }
898
- if (key.escape) {
899
- setShowModelSelect(false);
900
- return;
901
- }
902
- return; // Consume all other input when model select is shown
903
- }
904
-
905
- // Handle suggestion navigation when suggestions are shown
906
- if (showSuggestions && suggestions.length > 0) {
907
- if (key.downArrow) {
908
- setSelectedIndex((i) => Math.min(i + 1, suggestions.length - 1));
909
- return;
910
- }
911
- if (key.upArrow) {
912
- setSelectedIndex((i) => Math.max(i - 1, 0));
913
- return;
914
- }
915
- if (key.tab) {
916
- insertSuggestion(suggestions[selectedIndex]);
917
- return;
918
- }
919
- if (key.escape) {
920
- setShowSuggestions(false);
921
- return;
922
- }
923
- }
924
- },
925
- { isActive: isRawModeSupported }
926
- );
927
-
928
- if (!client) {
929
- return (
930
- <Box>
931
- <Text color="yellow">
932
- <Spinner type="dots" />
933
- </Text>
934
- <Text> Initializing...</Text>
935
- </Box>
936
- );
937
- }
938
-
939
- return (
940
- <Box flexDirection="column" paddingX={1}>
941
- {/* Header */}
942
- <Box marginBottom={1}>
943
- <Text color="cyan" bold>
944
- Pioneer Agent
945
- </Text>
946
- <Text dimColor> — /help for commands, /model to change AI, @ for files, Ctrl+C to exit</Text>
947
- </Box>
948
-
949
- {/* Messages */}
950
- <Box flexDirection="column" flexGrow={1}>
951
- {state.messages.map((msg, i) => (
952
- <MessageBubble key={i} message={msg} />
953
- ))}
954
-
955
- {/* Streaming content */}
956
- {state.isProcessing && state.streamingContent && (
957
- <StreamingMessage content={state.streamingContent} />
958
- )}
959
-
960
- {/* File references indicator */}
961
- {state.fileReferences.length > 0 && (
962
- <FileReferencesIndicator refs={state.fileReferences} />
963
- )}
964
-
965
- {/* Tool call indicator */}
966
- {state.currentToolCall && (
967
- <ToolCallIndicator toolCall={state.currentToolCall} />
968
- )}
969
-
970
- {/* Error display */}
971
- {state.error && (
972
- <Box marginBottom={1}>
973
- <Text color="red">Error: {state.error}</Text>
974
- </Box>
975
- )}
976
- </Box>
977
-
978
- {/* File suggestions dropdown */}
979
- {showSuggestions && !state.isProcessing && (
980
- <FileSuggestions
981
- suggestions={suggestions}
982
- selectedIndex={selectedIndex}
983
- searchPath={currentAtPath?.path || ""}
984
- />
985
- )}
986
-
987
- {/* Model selection overlay */}
988
- {showModelSelect && (
989
- <ModelSelector
990
- selectedIndex={modelSelectedIndex}
991
- currentModel={currentMleModel}
992
- />
993
- )}
994
-
995
- {/* Input area */}
996
- <Box marginTop={1}>
997
- {state.isProcessing ? (
998
- <Box>
999
- <Text color="yellow">
1000
- <Spinner type="dots" />
1001
- </Text>
1002
- <Text> Processing... </Text>
1003
- <Text dimColor>(Esc to stop)</Text>
1004
- </Box>
1005
- ) : (
1006
- <Box>
1007
- <Text color="cyan" bold>
1008
- {">"}{" "}
1009
- </Text>
1010
- {isRawModeSupported ? (
1011
- <TextInput
1012
- value={input}
1013
- onChange={setInput}
1014
- onSubmit={handleSubmit}
1015
- placeholder="Type a message... (@ for files, ! for bash)"
1016
- />
1017
- ) : (
1018
- <Text dimColor>Raw mode not supported in this terminal</Text>
1019
- )}
1020
- </Box>
1021
- )}
1022
- </Box>
1023
-
1024
- {/* Status bar */}
1025
- <StatusBar serverStatus={serverStatus} isProcessing={state.isProcessing} />
1026
- </Box>
1027
- );
1028
- };