mirror of https://github.com/buster-so/buster.git
chat cli endpoint and base cli agent
This commit is contained in:
parent
0febe2bd0a
commit
68ccbe1ba7
|
@ -1,5 +1,5 @@
|
||||||
import { Box, Text, useApp, useInput } from 'ink';
|
import { Box, Text, useApp, useInput } from 'ink';
|
||||||
import { useRef, useState } from 'react';
|
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ChatFooter,
|
ChatFooter,
|
||||||
ChatHistory,
|
ChatHistory,
|
||||||
|
@ -10,8 +10,9 @@ import {
|
||||||
ChatVersionTagline,
|
ChatVersionTagline,
|
||||||
VimStatus,
|
VimStatus,
|
||||||
} from '../components/chat-layout';
|
} from '../components/chat-layout';
|
||||||
|
import { Diff } from '../components/diff';
|
||||||
import { SettingsForm } from '../components/settings-form';
|
import { SettingsForm } from '../components/settings-form';
|
||||||
import { TypedMessage, type MessageType } from '../components/typed-message';
|
import { type MessageType, TypedMessage } from '../components/typed-message';
|
||||||
import { getSetting } from '../utils/settings';
|
import { getSetting } from '../utils/settings';
|
||||||
import type { SlashCommand } from '../utils/slash-commands';
|
import type { SlashCommand } from '../utils/slash-commands';
|
||||||
import type { VimMode } from '../utils/vim-mode';
|
import type { VimMode } from '../utils/vim-mode';
|
||||||
|
@ -24,6 +25,12 @@ interface Message {
|
||||||
content: string;
|
content: string;
|
||||||
messageType?: MessageType;
|
messageType?: MessageType;
|
||||||
metadata?: string;
|
metadata?: string;
|
||||||
|
diffLines?: Array<{
|
||||||
|
lineNumber: number;
|
||||||
|
content: string;
|
||||||
|
type: 'add' | 'remove' | 'context';
|
||||||
|
}>;
|
||||||
|
fileName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Main() {
|
export function Main() {
|
||||||
|
@ -43,7 +50,7 @@ export function Main() {
|
||||||
if (key.ctrl && value === 'c') {
|
if (key.ctrl && value === 'c') {
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cycle through modes with shift+tab
|
// Cycle through modes with shift+tab
|
||||||
if (key.shift && key.tab) {
|
if (key.shift && key.tab) {
|
||||||
setAppMode((current) => {
|
setAppMode((current) => {
|
||||||
|
@ -61,67 +68,167 @@ export function Main() {
|
||||||
|
|
||||||
const getMockResponse = (userInput: string): Message[] => {
|
const getMockResponse = (userInput: string): Message[] => {
|
||||||
const responses: Message[] = [];
|
const responses: Message[] = [];
|
||||||
|
|
||||||
// Always send all message types for demo
|
if (userInput.toLowerCase().includes('plan')) {
|
||||||
responses.push(
|
responses.push({
|
||||||
{
|
|
||||||
id: ++messageCounter.current,
|
id: ++messageCounter.current,
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
content: 'Creating a comprehensive plan for your request.',
|
content: `I'll create a detailed plan for your request. Let me analyze the requirements and break down the implementation steps.`,
|
||||||
messageType: 'PLAN',
|
messageType: 'PLAN',
|
||||||
metadata: 'Updated: 3 total (3 pending, 0 in progress, 0 completed)'
|
metadata: 'Creating a comprehensive plan for your request.',
|
||||||
},
|
});
|
||||||
{
|
} else if (userInput.toLowerCase().includes('search')) {
|
||||||
|
responses.push({
|
||||||
id: ++messageCounter.current,
|
id: ++messageCounter.current,
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
content: 'Executing command: npm install',
|
content: 'Searching for relevant information across the web...',
|
||||||
messageType: 'EXECUTE',
|
messageType: 'EXECUTE',
|
||||||
metadata: 'cd /project && npm install, impact: low'
|
metadata: 'Executing command: npm install',
|
||||||
},
|
});
|
||||||
{
|
} else if (userInput.toLowerCase().includes('run')) {
|
||||||
|
responses.push({
|
||||||
id: ++messageCounter.current,
|
id: ++messageCounter.current,
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
content: 'Searching for documentation and best practices...',
|
content: `cd /Users/safzan/Development/insideim/jobai-backend && node --env-file=.env --input-type=module --import=axios`,
|
||||||
messageType: 'WEB_SEARCH',
|
messageType: 'EXECUTE',
|
||||||
metadata: '"React hooks useState useEffect"'
|
metadata: 'React hooks useState useEffect',
|
||||||
},
|
});
|
||||||
{
|
} else if (userInput.toLowerCase().includes('error')) {
|
||||||
|
responses.push({
|
||||||
id: ++messageCounter.current,
|
id: ++messageCounter.current,
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
content: 'Here is some general information about your request.',
|
content: 'Writing new configuration file...',
|
||||||
messageType: 'INFO'
|
messageType: 'WRITE',
|
||||||
},
|
metadata:
|
||||||
{
|
'/Users/safzan/Development/insideim/cheating-daddy/node_modules/@google/genai, impact: medium',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Default response with EDIT and diff visualization
|
||||||
|
responses.push({
|
||||||
id: ++messageCounter.current,
|
id: ++messageCounter.current,
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
content: 'Successfully completed all operations!',
|
content: 'Editing the file with the requested changes',
|
||||||
messageType: 'SUCCESS'
|
messageType: 'EDIT',
|
||||||
},
|
fileName: '/src/components/typed-message.tsx',
|
||||||
{
|
diffLines: [
|
||||||
id: ++messageCounter.current,
|
{
|
||||||
type: 'assistant',
|
lineNumber: 62,
|
||||||
content: 'Warning: This operation may take longer than expected.',
|
content: 'const getMockResponse = (userInput: string): Message[] => {',
|
||||||
messageType: 'WARNING'
|
type: 'context',
|
||||||
},
|
},
|
||||||
{
|
{ lineNumber: 63, content: ' const responses: Message[] = [];', type: 'context' },
|
||||||
id: ++messageCounter.current,
|
{ lineNumber: 64, content: '', type: 'context' },
|
||||||
type: 'assistant',
|
{
|
||||||
content: 'Error: Failed to connect to the database.',
|
lineNumber: 65,
|
||||||
messageType: 'ERROR'
|
content: " if (userInput.toLowerCase().includes('plan')) {",
|
||||||
},
|
type: 'remove',
|
||||||
{
|
},
|
||||||
id: ++messageCounter.current,
|
{ lineNumber: 66, content: ' responses.push({', type: 'remove' },
|
||||||
type: 'assistant',
|
{ lineNumber: 65, content: ' // Always send all message types for demo', type: 'add' },
|
||||||
content: 'Debug: Variable state = { isLoading: true, data: null }',
|
{ lineNumber: 66, content: ' responses.push(', type: 'add' },
|
||||||
messageType: 'DEBUG',
|
{ lineNumber: 67, content: ' {', type: 'add' },
|
||||||
metadata: 'Line 42 in main.tsx'
|
{ lineNumber: 68, content: ' id: ++messageCounter.current,', type: 'context' },
|
||||||
}
|
{ lineNumber: 69, content: " type: 'assistant',", type: 'context' },
|
||||||
);
|
{
|
||||||
|
lineNumber: 70,
|
||||||
|
content:
|
||||||
|
" content: 'I\\'ll create a detailed plan for your request. Let me analyze the requirements and break down the implementation steps.',",
|
||||||
|
type: 'remove',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lineNumber: 70,
|
||||||
|
content: " content: 'Creating a comprehensive plan for your request.',",
|
||||||
|
type: 'add',
|
||||||
|
},
|
||||||
|
{ lineNumber: 71, content: " messageType: 'PLAN',", type: 'context' },
|
||||||
|
{
|
||||||
|
lineNumber: 72,
|
||||||
|
content: " metadata: 'Updated: 3 total (3 pending, 0 in progress, 0 completed)'",
|
||||||
|
type: 'context',
|
||||||
|
},
|
||||||
|
{ lineNumber: 73, content: ' })', type: 'remove' },
|
||||||
|
{ lineNumber: 73, content: ' },', type: 'add' },
|
||||||
|
{
|
||||||
|
lineNumber: 74,
|
||||||
|
content: " } else if (userInput.toLowerCase().includes('search')) {",
|
||||||
|
type: 'remove',
|
||||||
|
},
|
||||||
|
{ lineNumber: 75, content: ' responses.push({', type: 'remove' },
|
||||||
|
{ lineNumber: 74, content: ' {', type: 'add' },
|
||||||
|
{ lineNumber: 75, content: ' id: ++messageCounter.current,', type: 'add' },
|
||||||
|
{ lineNumber: 76, content: " type: 'assistant',", type: 'add' },
|
||||||
|
{
|
||||||
|
lineNumber: 77,
|
||||||
|
content: " content: 'Searching for relevant information across the web...',",
|
||||||
|
type: 'remove',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lineNumber: 77,
|
||||||
|
content: " content: 'Executing command: npm install',",
|
||||||
|
type: 'add',
|
||||||
|
},
|
||||||
|
{ lineNumber: 78, content: " messageType: 'EXECUTE',", type: 'add' },
|
||||||
|
{
|
||||||
|
lineNumber: 79,
|
||||||
|
content: " metadata: 'cd /project && npm install, impact: low'",
|
||||||
|
type: 'add',
|
||||||
|
},
|
||||||
|
{ lineNumber: 80, content: ' },', type: 'add' },
|
||||||
|
{ lineNumber: 81, content: ' {', type: 'add' },
|
||||||
|
{ lineNumber: 82, content: ' id: ++messageCounter.current,', type: 'add' },
|
||||||
|
{ lineNumber: 83, content: " type: 'assistant',", type: 'add' },
|
||||||
|
{
|
||||||
|
lineNumber: 84,
|
||||||
|
content: " content: 'Searching for documentation and best practices...',",
|
||||||
|
type: 'add',
|
||||||
|
},
|
||||||
|
{ lineNumber: 85, content: " messageType: 'WEB_SEARCH',", type: 'remove' },
|
||||||
|
{
|
||||||
|
lineNumber: 86,
|
||||||
|
content: ' metadata: \'"site:github.com generativelanguage auth_tokens.proto"\'',
|
||||||
|
type: 'remove',
|
||||||
|
},
|
||||||
|
{ lineNumber: 87, content: ' });', type: 'remove' },
|
||||||
|
{
|
||||||
|
lineNumber: 88,
|
||||||
|
content: " } else if (userInput.toLowerCase().includes('run')) {",
|
||||||
|
type: 'remove',
|
||||||
|
},
|
||||||
|
{ lineNumber: 88, content: ' } else {', type: 'add' },
|
||||||
|
{ lineNumber: 89, content: '', type: 'context' },
|
||||||
|
{ lineNumber: 90, content: '', type: 'context' },
|
||||||
|
{
|
||||||
|
lineNumber: 91,
|
||||||
|
content: " metadata: 'React hooks useState useEffect'",
|
||||||
|
type: 'remove',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lineNumber: 91,
|
||||||
|
content:
|
||||||
|
" content: 'cd /Users/safzan/Development/insideim/jobai-backend && node --env-file=.env --input-type=module --import=axios',",
|
||||||
|
type: 'add',
|
||||||
|
},
|
||||||
|
{ lineNumber: 92, content: " messageType: 'EXECUTE',", type: 'remove' },
|
||||||
|
{
|
||||||
|
lineNumber: 93,
|
||||||
|
content:
|
||||||
|
" metadata: 'ls /Users/safzan/Development/insideim/cheating-daddy/node_modules/@google/genai, impact: medium'",
|
||||||
|
type: 'remove',
|
||||||
|
},
|
||||||
|
{ lineNumber: 94, content: ' }));', type: 'remove' },
|
||||||
|
{
|
||||||
|
lineNumber: 95,
|
||||||
|
content: " } else if (userInput.toLowerCase().includes('error')) {",
|
||||||
|
type: 'remove',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return responses;
|
return responses;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = useCallback(() => {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setInput('');
|
setInput('');
|
||||||
|
@ -136,78 +243,92 @@ export function Main() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockResponses = getMockResponse(trimmed);
|
const mockResponses = getMockResponse(trimmed);
|
||||||
|
|
||||||
setMessages((prev) => [...prev, userMessage, ...mockResponses]);
|
setMessages((prev) => [...prev, userMessage, ...mockResponses]);
|
||||||
setInput('');
|
setInput('');
|
||||||
};
|
}, [input]);
|
||||||
|
|
||||||
const handleCommandExecute = (command: SlashCommand) => {
|
const handleCommandExecute = useCallback(
|
||||||
switch (command.action) {
|
(command: SlashCommand) => {
|
||||||
case 'settings':
|
switch (command.action) {
|
||||||
setShowSettings(true);
|
case 'settings':
|
||||||
break;
|
setShowSettings(true);
|
||||||
case 'clear':
|
break;
|
||||||
setHistory([]);
|
case 'clear':
|
||||||
break;
|
setHistory([]);
|
||||||
case 'exit':
|
break;
|
||||||
exit();
|
case 'exit':
|
||||||
break;
|
exit();
|
||||||
case 'help': {
|
break;
|
||||||
historyCounter.current += 1;
|
case 'help': {
|
||||||
const helpEntry: ChatHistoryEntry = {
|
historyCounter.current += 1;
|
||||||
id: historyCounter.current,
|
const helpEntry: ChatHistoryEntry = {
|
||||||
value:
|
id: historyCounter.current,
|
||||||
'Available commands:\n/settings - Configure app settings\n/clear - Clear chat history\n/exit - Exit the app\n/help - Show this help',
|
value:
|
||||||
};
|
'Available commands:\n/settings - Configure app settings\n/clear - Clear chat history\n/exit - Exit the app\n/help - Show this help',
|
||||||
setHistory((prev) => [...prev, helpEntry].slice(-5));
|
};
|
||||||
break;
|
setHistory((prev) => [...prev, helpEntry].slice(-5));
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
[exit]
|
||||||
|
);
|
||||||
|
|
||||||
if (showSettings) {
|
if (showSettings) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1} paddingY={2}>
|
<Box flexDirection="column" paddingX={1} paddingY={2}>
|
||||||
<SettingsForm onClose={() => {
|
<SettingsForm
|
||||||
setShowSettings(false);
|
onClose={() => {
|
||||||
// Refresh vim enabled setting after settings close
|
setShowSettings(false);
|
||||||
setVimEnabled(getSetting('vimMode'));
|
// Refresh vim enabled setting after settings close
|
||||||
}} />
|
setVimEnabled(getSetting('vimMode'));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memoize message list to prevent re-renders from cursor blinking
|
||||||
|
const messageList = useMemo(() => {
|
||||||
|
return messages.map((message) => {
|
||||||
|
if (message.type === 'user') {
|
||||||
|
return (
|
||||||
|
<Box key={message.id} marginBottom={1}>
|
||||||
|
<Text color="#a855f7" bold>
|
||||||
|
❯{' '}
|
||||||
|
</Text>
|
||||||
|
<Text color="#e0e7ff">{message.content}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else if (message.messageType) {
|
||||||
|
return (
|
||||||
|
<Box key={message.id} flexDirection="column">
|
||||||
|
<TypedMessage
|
||||||
|
type={message.messageType}
|
||||||
|
content={message.content}
|
||||||
|
metadata={message.metadata}
|
||||||
|
/>
|
||||||
|
{message.diffLines && <Diff lines={message.diffLines} fileName={message.fileName} />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Box key={message.id} marginBottom={1}>
|
||||||
|
<Text color="#e0e7ff">{message.content}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1} paddingY={2} gap={1}>
|
<Box flexDirection="column" paddingX={1} paddingY={2} gap={1}>
|
||||||
<ChatTitle />
|
<ChatTitle />
|
||||||
<ChatVersionTagline />
|
<ChatVersionTagline />
|
||||||
<ChatIntroText />
|
<ChatIntroText />
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
{messages.map((message) => {
|
{messageList}
|
||||||
if (message.type === 'user') {
|
|
||||||
return (
|
|
||||||
<Box key={message.id} marginBottom={1}>
|
|
||||||
<Text color="#a855f7" bold>❯ </Text>
|
|
||||||
<Text color="#e0e7ff">{message.content}</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
} else if (message.messageType) {
|
|
||||||
return (
|
|
||||||
<TypedMessage
|
|
||||||
key={message.id}
|
|
||||||
type={message.messageType}
|
|
||||||
content={message.content}
|
|
||||||
metadata={message.metadata}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Box key={message.id} marginBottom={1}>
|
|
||||||
<Text color="#e0e7ff">{message.content}</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Box height={1}>
|
<Box height={1}>
|
||||||
|
@ -227,9 +348,9 @@ export function Main() {
|
||||||
onAutocompleteStateChange={setIsAutocompleteOpen}
|
onAutocompleteStateChange={setIsAutocompleteOpen}
|
||||||
/>
|
/>
|
||||||
<Box justifyContent="space-between">
|
<Box justifyContent="space-between">
|
||||||
<VimStatus
|
<VimStatus
|
||||||
vimMode={currentVimMode}
|
vimMode={currentVimMode}
|
||||||
vimEnabled={vimEnabled}
|
vimEnabled={vimEnabled}
|
||||||
hideWhenAutocomplete={isAutocompleteOpen}
|
hideWhenAutocomplete={isAutocompleteOpen}
|
||||||
/>
|
/>
|
||||||
<ChatFooter />
|
<ChatFooter />
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { memo, useEffect, useMemo, useState } from 'react';
|
||||||
import { type FileSearchResult, searchFiles } from '../utils/file-search';
|
import { type FileSearchResult, searchFiles } from '../utils/file-search';
|
||||||
import { type SlashCommand, searchCommands } from '../utils/slash-commands';
|
import { type SlashCommand, searchCommands } from '../utils/slash-commands';
|
||||||
import { CommandAutocomplete } from './command-autocomplete';
|
import { CommandAutocomplete } from './command-autocomplete';
|
||||||
|
@ -8,15 +8,15 @@ import { MultiLineTextInput, replaceMention } from './multi-line-text-input';
|
||||||
import { SettingsForm } from './settings-form';
|
import { SettingsForm } from './settings-form';
|
||||||
import { SimpleBigText } from './simple-big-text';
|
import { SimpleBigText } from './simple-big-text';
|
||||||
|
|
||||||
export function ChatTitle() {
|
export const ChatTitle = memo(function ChatTitle() {
|
||||||
return (
|
return (
|
||||||
<Box justifyContent="center">
|
<Box justifyContent="center">
|
||||||
<SimpleBigText text="Buster" color="#f5f3ff" />
|
<SimpleBigText text="Buster" color="#f5f3ff" />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function ChatVersionTagline() {
|
export const ChatVersionTagline = memo(function ChatVersionTagline() {
|
||||||
return (
|
return (
|
||||||
<Box justifyContent="center" marginTop={1}>
|
<Box justifyContent="center" marginTop={1}>
|
||||||
<Text>
|
<Text>
|
||||||
|
@ -25,9 +25,9 @@ export function ChatVersionTagline() {
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function ChatIntroText() {
|
export const ChatIntroText = memo(function ChatIntroText() {
|
||||||
const lines = useMemo(
|
const lines = useMemo(
|
||||||
() => [
|
() => [
|
||||||
'You are standing in an open terminal. An AI awaits your commands.',
|
'You are standing in an open terminal. An AI awaits your commands.',
|
||||||
|
@ -45,7 +45,7 @@ export function ChatIntroText() {
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function ChatStatusBar() {
|
export function ChatStatusBar() {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface DiffLine {
|
||||||
|
lineNumber: number;
|
||||||
|
content: string;
|
||||||
|
type: 'add' | 'remove' | 'context';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffProps {
|
||||||
|
lines: DiffLine[];
|
||||||
|
fileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Diff({ lines, fileName }: DiffProps) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" marginY={1}>
|
||||||
|
{fileName && (
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text color="#64748b">{fileName}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{lines.map((line, index) => (
|
||||||
|
<Box key={`${line.lineNumber}-${index}`} gap={1}>
|
||||||
|
<Text color="#475569" dimColor>
|
||||||
|
{String(line.lineNumber).padStart(3, ' ')}
|
||||||
|
</Text>
|
||||||
|
{line.type === 'add' && <Text color="#22c55e">+</Text>}
|
||||||
|
{line.type === 'remove' && <Text color="#ef4444">-</Text>}
|
||||||
|
{line.type === 'context' && <Text> </Text>}
|
||||||
|
<Text
|
||||||
|
backgroundColor={
|
||||||
|
line.type === 'add' ? '#166534' : line.type === 'remove' ? '#7f1d1d' : undefined
|
||||||
|
}
|
||||||
|
color={line.type === 'add' || line.type === 'remove' ? '#ffffff' : '#e2e8f0'}
|
||||||
|
>
|
||||||
|
{line.content}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
|
@ -30,9 +30,9 @@ export function MultiLineTextInput({
|
||||||
onVimModeChange,
|
onVimModeChange,
|
||||||
}: MultiLineTextInputProps) {
|
}: MultiLineTextInputProps) {
|
||||||
const [cursorPosition, setCursorPosition] = useState(value.length);
|
const [cursorPosition, setCursorPosition] = useState(value.length);
|
||||||
const [showCursor, setShowCursor] = useState(true);
|
|
||||||
const [expectingNewline, setExpectingNewline] = useState(false);
|
const [expectingNewline, setExpectingNewline] = useState(false);
|
||||||
const cursorBlinkTimer = useRef<NodeJS.Timeout>();
|
// Always show cursor - no blinking to prevent re-renders
|
||||||
|
const showCursor = true;
|
||||||
|
|
||||||
// Vim mode state
|
// Vim mode state
|
||||||
const [vimEnabled] = useState(() => getSetting('vimMode'));
|
const [vimEnabled] = useState(() => getSetting('vimMode'));
|
||||||
|
@ -48,96 +48,87 @@ export function MultiLineTextInput({
|
||||||
}
|
}
|
||||||
}, [vimState.mode, vimEnabled, onVimModeChange]);
|
}, [vimState.mode, vimEnabled, onVimModeChange]);
|
||||||
|
|
||||||
// Cursor blinking effect
|
|
||||||
useEffect(() => {
|
|
||||||
if (focus) {
|
|
||||||
// In vim normal mode, cursor should be solid
|
|
||||||
if (vimEnabled && vimState.mode === 'normal') {
|
|
||||||
setShowCursor(true);
|
|
||||||
} else {
|
|
||||||
cursorBlinkTimer.current = setInterval(() => {
|
|
||||||
setShowCursor((prev) => !prev);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setShowCursor(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (cursorBlinkTimer.current) {
|
|
||||||
clearInterval(cursorBlinkTimer.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [focus, vimEnabled, vimState.mode]);
|
|
||||||
|
|
||||||
// Update cursor position when value changes externally (e.g., when cleared after submit)
|
// Update cursor position when value changes externally (e.g., when cleared after submit)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCursorPosition(value.length);
|
setCursorPosition(value.length);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
// Detect slash commands
|
// Detect slash commands with debounce
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onSlashChange) return;
|
if (!onSlashChange) return;
|
||||||
|
|
||||||
// Check if we're at the beginning or after a newline
|
const timeoutId = setTimeout(() => {
|
||||||
let slashStart = -1;
|
// Check if we're at the beginning or after a newline
|
||||||
|
let slashStart = -1;
|
||||||
|
|
||||||
// Look for a slash at the start of the current line
|
// Look for a slash at the start of the current line
|
||||||
const lines = value.substring(0, cursorPosition).split('\n');
|
const lines = value.substring(0, cursorPosition).split('\n');
|
||||||
const currentLine = lines[lines.length - 1];
|
const currentLine = lines[lines.length - 1];
|
||||||
const currentLineStart = cursorPosition - currentLine.length;
|
const currentLineStart = cursorPosition - currentLine.length;
|
||||||
|
|
||||||
if (currentLine.startsWith('/')) {
|
if (currentLine.startsWith('/')) {
|
||||||
slashStart = currentLineStart;
|
slashStart = currentLineStart;
|
||||||
const slashEnd = cursorPosition;
|
const slashEnd = cursorPosition;
|
||||||
const slashQuery = value.substring(slashStart + 1, slashEnd);
|
const slashQuery = value.substring(slashStart + 1, slashEnd);
|
||||||
|
|
||||||
// Only trigger if we're still in the command (no spaces)
|
// Only trigger if we're still in the command (no spaces)
|
||||||
if (!slashQuery.includes(' ') && !slashQuery.includes('\n')) {
|
if (!slashQuery.includes(' ') && !slashQuery.includes('\n')) {
|
||||||
onSlashChange(slashQuery, slashStart);
|
onSlashChange(slashQuery, slashStart);
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No active slash command
|
|
||||||
onSlashChange(null, -1);
|
|
||||||
}, [value, cursorPosition, onSlashChange]);
|
|
||||||
|
|
||||||
// Detect @ mentions
|
|
||||||
useEffect(() => {
|
|
||||||
if (!onMentionChange) return;
|
|
||||||
|
|
||||||
// Find the last @ before cursor position
|
|
||||||
let mentionStart = -1;
|
|
||||||
for (let i = cursorPosition - 1; i >= 0; i--) {
|
|
||||||
if (value[i] === '@') {
|
|
||||||
mentionStart = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Stop if we hit whitespace or newline (mention ended)
|
|
||||||
if (value[i] === ' ' || value[i] === '\n' || value[i] === '\t') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mentionStart !== -1) {
|
|
||||||
// Check if there's a space or newline before @ (or it's at the start)
|
|
||||||
const charBefore = mentionStart > 0 ? value[mentionStart - 1] : ' ';
|
|
||||||
if (charBefore === ' ' || charBefore === '\n' || charBefore === '\t' || mentionStart === 0) {
|
|
||||||
// Extract the mention query (text after @)
|
|
||||||
const mentionEnd = cursorPosition;
|
|
||||||
const mentionQuery = value.substring(mentionStart + 1, mentionEnd);
|
|
||||||
|
|
||||||
// Only trigger if we're still in the mention (no spaces)
|
|
||||||
if (!mentionQuery.includes(' ') && !mentionQuery.includes('\n')) {
|
|
||||||
onMentionChange(mentionQuery, mentionStart);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// No active mention
|
// No active slash command
|
||||||
onMentionChange(null, -1);
|
onSlashChange(null, -1);
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [value, cursorPosition, onSlashChange]);
|
||||||
|
|
||||||
|
// Detect @ mentions with debounce
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onMentionChange) return;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
// Find the last @ before cursor position
|
||||||
|
let mentionStart = -1;
|
||||||
|
for (let i = cursorPosition - 1; i >= 0; i--) {
|
||||||
|
if (value[i] === '@') {
|
||||||
|
mentionStart = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Stop if we hit whitespace or newline (mention ended)
|
||||||
|
if (value[i] === ' ' || value[i] === '\n' || value[i] === '\t') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mentionStart !== -1) {
|
||||||
|
// Check if there's a space or newline before @ (or it's at the start)
|
||||||
|
const charBefore = mentionStart > 0 ? value[mentionStart - 1] : ' ';
|
||||||
|
if (
|
||||||
|
charBefore === ' ' ||
|
||||||
|
charBefore === '\n' ||
|
||||||
|
charBefore === '\t' ||
|
||||||
|
mentionStart === 0
|
||||||
|
) {
|
||||||
|
// Extract the mention query (text after @)
|
||||||
|
const mentionEnd = cursorPosition;
|
||||||
|
const mentionQuery = value.substring(mentionStart + 1, mentionEnd);
|
||||||
|
|
||||||
|
// Only trigger if we're still in the mention (no spaces)
|
||||||
|
if (!mentionQuery.includes(' ') && !mentionQuery.includes('\n')) {
|
||||||
|
onMentionChange(mentionQuery, mentionStart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active mention
|
||||||
|
onMentionChange(null, -1);
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
}, [value, cursorPosition, onMentionChange]);
|
}, [value, cursorPosition, onMentionChange]);
|
||||||
|
|
||||||
useInput(
|
useInput(
|
||||||
|
@ -409,14 +400,8 @@ export function MultiLineTextInput({
|
||||||
// Always reserve space for cursor to prevent shifting
|
// Always reserve space for cursor to prevent shifting
|
||||||
let cursorChar = '█';
|
let cursorChar = '█';
|
||||||
if (vimEnabled) {
|
if (vimEnabled) {
|
||||||
// Different cursor styles for vim modes
|
// Use block cursor for all vim modes
|
||||||
if (vimState.mode === 'normal') {
|
cursorChar = '█';
|
||||||
cursorChar = '▮'; // Block cursor for normal mode
|
|
||||||
} else if (vimState.mode === 'insert') {
|
|
||||||
cursorChar = '│'; // Line cursor for insert mode
|
|
||||||
} else if (vimState.mode === 'visual') {
|
|
||||||
cursorChar = '▬'; // Underscore cursor for visual mode
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -429,17 +414,12 @@ export function MultiLineTextInput({
|
||||||
|
|
||||||
const beforeCursor = value.slice(0, cursorPosition);
|
const beforeCursor = value.slice(0, cursorPosition);
|
||||||
const afterCursor = value.slice(cursorPosition);
|
const afterCursor = value.slice(cursorPosition);
|
||||||
|
// Always use block cursor
|
||||||
let cursorChar = showCursor && focus ? '█' : ' ';
|
let cursorChar = showCursor && focus ? '█' : ' ';
|
||||||
|
|
||||||
// Show different cursor styles for vim modes
|
// Keep block cursor for all vim modes
|
||||||
if (vimEnabled && focus && showCursor) {
|
if (vimEnabled && focus && showCursor) {
|
||||||
if (vimState.mode === 'normal') {
|
cursorChar = '█';
|
||||||
cursorChar = '▮'; // Block cursor for normal mode
|
|
||||||
} else if (vimState.mode === 'insert') {
|
|
||||||
cursorChar = '│'; // Line cursor for insert mode
|
|
||||||
} else if (vimState.mode === 'visual') {
|
|
||||||
cursorChar = '▬'; // Underscore cursor for visual mode
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show special cursor when expecting newline
|
// Show special cursor when expecting newline
|
||||||
|
|
|
@ -1,15 +1,7 @@
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export type MessageType =
|
export type MessageType = 'PLAN' | 'EXECUTE' | 'WRITE' | 'EDIT';
|
||||||
| 'PLAN'
|
|
||||||
| 'EXECUTE'
|
|
||||||
| 'WEB_SEARCH'
|
|
||||||
| 'INFO'
|
|
||||||
| 'ERROR'
|
|
||||||
| 'SUCCESS'
|
|
||||||
| 'WARNING'
|
|
||||||
| 'DEBUG';
|
|
||||||
|
|
||||||
interface TypedMessageProps {
|
interface TypedMessageProps {
|
||||||
type: MessageType;
|
type: MessageType;
|
||||||
|
@ -20,30 +12,24 @@ interface TypedMessageProps {
|
||||||
const typeStyles: Record<MessageType, { bg: string; fg: string; label: string }> = {
|
const typeStyles: Record<MessageType, { bg: string; fg: string; label: string }> = {
|
||||||
PLAN: { bg: '#fb923c', fg: '#000000', label: 'PLAN' },
|
PLAN: { bg: '#fb923c', fg: '#000000', label: 'PLAN' },
|
||||||
EXECUTE: { bg: '#fb923c', fg: '#000000', label: 'EXECUTE' },
|
EXECUTE: { bg: '#fb923c', fg: '#000000', label: 'EXECUTE' },
|
||||||
WEB_SEARCH: { bg: '#fb923c', fg: '#000000', label: 'WEB SEARCH' },
|
WRITE: { bg: '#3b82f6', fg: '#ffffff', label: 'WRITE' },
|
||||||
INFO: { bg: '#3b82f6', fg: '#ffffff', label: 'INFO' },
|
EDIT: { bg: '#22c55e', fg: '#ffffff', label: 'EDIT' },
|
||||||
ERROR: { bg: '#ef4444', fg: '#ffffff', label: 'ERROR' },
|
|
||||||
SUCCESS: { bg: '#22c55e', fg: '#ffffff', label: 'SUCCESS' },
|
|
||||||
WARNING: { bg: '#eab308', fg: '#000000', label: 'WARNING' },
|
|
||||||
DEBUG: { bg: '#8b5cf6', fg: '#ffffff', label: 'DEBUG' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TypedMessage({ type, content, metadata }: TypedMessageProps) {
|
export function TypedMessage({ type, content, metadata }: TypedMessageProps) {
|
||||||
const style = typeStyles[type];
|
const style = typeStyles[type];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Box gap={1}>
|
<Box gap={1}>
|
||||||
<Text backgroundColor={style.bg} color={style.fg} bold>
|
<Text backgroundColor={style.bg} color={style.fg} bold>
|
||||||
{` ${style.label} `}
|
{` ${style.label} `}
|
||||||
</Text>
|
</Text>
|
||||||
{metadata && (
|
{metadata && <Text dimColor>{metadata}</Text>}
|
||||||
<Text dimColor>{metadata}</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box marginLeft={2} marginTop={0}>
|
<Box marginLeft={2} marginTop={0}>
|
||||||
<Text>{content}</Text>
|
<Text>{content}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
import {
|
||||||
|
CliChatCreateRequestSchema,
|
||||||
|
type CliChatCreateResponse,
|
||||||
|
} from '@buster/server-shared/chats';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { createCliChatHandler } from './handler';
|
||||||
|
|
||||||
|
export const POST = new Hono().post(
|
||||||
|
'/',
|
||||||
|
zValidator('json', CliChatCreateRequestSchema),
|
||||||
|
async (c) => {
|
||||||
|
const request = c.req.valid('json');
|
||||||
|
const user = c.get('busterUser');
|
||||||
|
|
||||||
|
const response: CliChatCreateResponse = await createCliChatHandler(request, user);
|
||||||
|
|
||||||
|
return c.json(response);
|
||||||
|
}
|
||||||
|
);
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { User } from '@buster/database/queries';
|
||||||
|
import type { CliChatCreateRequest, CliChatCreateResponse } from '@buster/server-shared/chats';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler function for creating a CLI chat.
|
||||||
|
*
|
||||||
|
* TODO: Implement CLI-specific chat logic
|
||||||
|
*/
|
||||||
|
export async function createCliChatHandler(
|
||||||
|
request: CliChatCreateRequest,
|
||||||
|
user: User
|
||||||
|
): Promise<CliChatCreateResponse> {
|
||||||
|
// Placeholder - just acknowledge the request
|
||||||
|
console.log('CLI chat request received:', {
|
||||||
|
userId: user.id,
|
||||||
|
prompt: request.prompt,
|
||||||
|
chatId: request.chat_id,
|
||||||
|
});
|
||||||
|
// Return a minimal response
|
||||||
|
return {
|
||||||
|
id: 'temp-id',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
title: 'CLI Chat',
|
||||||
|
messages: {},
|
||||||
|
created_by: user.id,
|
||||||
|
created_by_id: user.id,
|
||||||
|
created_by_name: user.email || 'Unknown',
|
||||||
|
created_by_avatar: null,
|
||||||
|
individual_permissions: [],
|
||||||
|
publicly_accessible: false,
|
||||||
|
public_expiry_date: null,
|
||||||
|
public_enabled_by: null,
|
||||||
|
public_password: null,
|
||||||
|
permission: 'owner',
|
||||||
|
workspace_sharing: 'none',
|
||||||
|
workspace_member_count: 0,
|
||||||
|
message_ids: [],
|
||||||
|
is_favorited: false,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { ChatError } from '@buster/server-shared/chats';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import { requireAuth } from '../../../../middleware/auth';
|
||||||
|
import { POST } from './POST';
|
||||||
|
|
||||||
|
const app = new Hono()
|
||||||
|
// Apply authentication middleware
|
||||||
|
.use('*', requireAuth)
|
||||||
|
.route('/', POST)
|
||||||
|
.onError((e, c) => {
|
||||||
|
if (e instanceof ChatError) {
|
||||||
|
return c.json(e.toResponse(), e.statusCode);
|
||||||
|
}
|
||||||
|
if (e instanceof HTTPException) {
|
||||||
|
return e.getResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HTTPException(500, {
|
||||||
|
message: 'Internal server error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
|
@ -14,6 +14,7 @@ import { z } from 'zod';
|
||||||
import GET from './GET';
|
import GET from './GET';
|
||||||
import chatById from './[id]';
|
import chatById from './[id]';
|
||||||
import { cancelChatHandler } from './cancel-chat';
|
import { cancelChatHandler } from './cancel-chat';
|
||||||
|
import cliChat from './cli';
|
||||||
import { createChatHandler } from './handler';
|
import { createChatHandler } from './handler';
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
@ -21,6 +22,7 @@ const app = new Hono()
|
||||||
.use('*', requireAuth)
|
.use('*', requireAuth)
|
||||||
.route('/', GET)
|
.route('/', GET)
|
||||||
.route('/:id', chatById)
|
.route('/:id', chatById)
|
||||||
|
.route('/cli', cliChat)
|
||||||
// POST /chats - Create a new chat
|
// POST /chats - Create a new chat
|
||||||
.post('/', zValidator('json', ChatCreateRequestSchema), async (c) => {
|
.post('/', zValidator('json', ChatCreateRequestSchema), async (c) => {
|
||||||
const request = c.req.valid('json');
|
const request = c.req.valid('json');
|
||||||
|
|
|
@ -59,3 +59,12 @@ export type DuplicateChatRequest = z.infer<typeof DuplicateChatRequestSchema>;
|
||||||
// Logs list request (same as chats list)
|
// Logs list request (same as chats list)
|
||||||
export const GetLogsListRequestSchema = GetChatsListRequestSchema;
|
export const GetLogsListRequestSchema = GetChatsListRequestSchema;
|
||||||
export type GetLogsListRequest = z.infer<typeof GetLogsListRequestSchema>;
|
export type GetLogsListRequest = z.infer<typeof GetLogsListRequestSchema>;
|
||||||
|
|
||||||
|
// Request for creating a CLI chat
|
||||||
|
export const CliChatCreateRequestSchema = z.object({
|
||||||
|
prompt: z.string().describe('User prompt for the CLI chat'),
|
||||||
|
chat_id: z.string().optional().describe('Optional existing chat ID to continue'),
|
||||||
|
message_id: z.string().optional().describe('Optional message ID to continue from'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CliChatCreateRequest = z.infer<typeof CliChatCreateRequestSchema>;
|
||||||
|
|
|
@ -39,3 +39,7 @@ export type DeleteChatsResponse = z.infer<typeof DeleteChatsResponseSchema>;
|
||||||
|
|
||||||
export const ShareChatResponseSchema = ChatWithMessagesSchema;
|
export const ShareChatResponseSchema = ChatWithMessagesSchema;
|
||||||
export type ShareChatResponse = z.infer<typeof ShareChatResponseSchema>;
|
export type ShareChatResponse = z.infer<typeof ShareChatResponseSchema>;
|
||||||
|
|
||||||
|
// Response for creating a CLI chat
|
||||||
|
export const CliChatCreateResponseSchema = ChatWithMessagesSchema;
|
||||||
|
export type CliChatCreateResponse = z.infer<typeof CliChatCreateResponseSchema>;
|
||||||
|
|
Loading…
Reference in New Issue