chat cli endpoint and base cli agent

This commit is contained in:
dal 2025-09-30 16:23:47 -06:00
parent 0febe2bd0a
commit 68ccbe1ba7
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
11 changed files with 458 additions and 226 deletions

View File

@ -1,5 +1,5 @@
import { Box, Text, useApp, useInput } from 'ink';
import { useRef, useState } from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import {
ChatFooter,
ChatHistory,
@ -10,8 +10,9 @@ import {
ChatVersionTagline,
VimStatus,
} from '../components/chat-layout';
import { Diff } from '../components/diff';
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 type { SlashCommand } from '../utils/slash-commands';
import type { VimMode } from '../utils/vim-mode';
@ -24,6 +25,12 @@ interface Message {
content: string;
messageType?: MessageType;
metadata?: string;
diffLines?: Array<{
lineNumber: number;
content: string;
type: 'add' | 'remove' | 'context';
}>;
fileName?: string;
}
export function Main() {
@ -62,66 +69,166 @@ export function Main() {
const getMockResponse = (userInput: string): Message[] => {
const responses: Message[] = [];
// Always send all message types for demo
responses.push(
{
if (userInput.toLowerCase().includes('plan')) {
responses.push({
id: ++messageCounter.current,
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',
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,
type: 'assistant',
content: 'Executing command: npm install',
content: 'Searching for relevant information across the web...',
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,
type: 'assistant',
content: 'Searching for documentation and best practices...',
messageType: 'WEB_SEARCH',
metadata: '"React hooks useState useEffect"'
},
{
content: `cd /Users/safzan/Development/insideim/jobai-backend && node --env-file=.env --input-type=module --import=axios`,
messageType: 'EXECUTE',
metadata: 'React hooks useState useEffect',
});
} else if (userInput.toLowerCase().includes('error')) {
responses.push({
id: ++messageCounter.current,
type: 'assistant',
content: 'Here is some general information about your request.',
messageType: 'INFO'
},
{
content: 'Writing new configuration file...',
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,
type: 'assistant',
content: 'Successfully completed all operations!',
messageType: 'SUCCESS'
content: 'Editing the file with the requested changes',
messageType: 'EDIT',
fileName: '/src/components/typed-message.tsx',
diffLines: [
{
lineNumber: 62,
content: 'const getMockResponse = (userInput: string): Message[] => {',
type: 'context',
},
{ lineNumber: 63, content: ' const responses: Message[] = [];', type: 'context' },
{ lineNumber: 64, content: '', type: 'context' },
{
lineNumber: 65,
content: " if (userInput.toLowerCase().includes('plan')) {",
type: 'remove',
},
{ lineNumber: 66, content: ' responses.push({', type: 'remove' },
{ lineNumber: 65, content: ' // Always send all message types for demo', type: 'add' },
{ lineNumber: 66, content: ' responses.push(', type: 'add' },
{ lineNumber: 67, content: ' {', type: 'add' },
{ 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',
},
{
id: ++messageCounter.current,
type: 'assistant',
content: 'Warning: This operation may take longer than expected.',
messageType: 'WARNING'
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',
},
{
id: ++messageCounter.current,
type: 'assistant',
content: 'Error: Failed to connect to the database.',
messageType: 'ERROR'
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',
},
{
id: ++messageCounter.current,
type: 'assistant',
content: 'Debug: Variable state = { isLoading: true, data: null }',
messageType: 'DEBUG',
metadata: 'Line 42 in main.tsx'
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;
};
const handleSubmit = () => {
const handleSubmit = useCallback(() => {
const trimmed = input.trim();
if (!trimmed) {
setInput('');
@ -139,9 +246,10 @@ export function Main() {
setMessages((prev) => [...prev, userMessage, ...mockResponses]);
setInput('');
};
}, [input]);
const handleCommandExecute = (command: SlashCommand) => {
const handleCommandExecute = useCallback(
(command: SlashCommand) => {
switch (command.action) {
case 'settings':
setShowSettings(true);
@ -163,42 +271,46 @@ export function Main() {
break;
}
}
};
},
[exit]
);
if (showSettings) {
return (
<Box flexDirection="column" paddingX={1} paddingY={2}>
<SettingsForm onClose={() => {
<SettingsForm
onClose={() => {
setShowSettings(false);
// Refresh vim enabled setting after settings close
setVimEnabled(getSetting('vimMode'));
}} />
}}
/>
</Box>
);
}
return (
<Box flexDirection="column" paddingX={1} paddingY={2} gap={1}>
<ChatTitle />
<ChatVersionTagline />
<ChatIntroText />
<Box flexDirection="column" marginTop={1}>
{messages.map((message) => {
// 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="#a855f7" bold>
{' '}
</Text>
<Text color="#e0e7ff">{message.content}</Text>
</Box>
);
} else if (message.messageType) {
return (
<Box key={message.id} flexDirection="column">
<TypedMessage
key={message.id}
type={message.messageType}
content={message.content}
metadata={message.metadata}
/>
{message.diffLines && <Diff lines={message.diffLines} fileName={message.fileName} />}
</Box>
);
} else {
return (
@ -207,7 +319,16 @@ export function Main() {
</Box>
);
}
})}
});
}, [messages]);
return (
<Box flexDirection="column" paddingX={1} paddingY={2} gap={1}>
<ChatTitle />
<ChatVersionTagline />
<ChatIntroText />
<Box flexDirection="column" marginTop={1}>
{messageList}
</Box>
<Box flexDirection="column">
<Box height={1}>

View File

@ -1,5 +1,5 @@
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 SlashCommand, searchCommands } from '../utils/slash-commands';
import { CommandAutocomplete } from './command-autocomplete';
@ -8,15 +8,15 @@ import { MultiLineTextInput, replaceMention } from './multi-line-text-input';
import { SettingsForm } from './settings-form';
import { SimpleBigText } from './simple-big-text';
export function ChatTitle() {
export const ChatTitle = memo(function ChatTitle() {
return (
<Box justifyContent="center">
<SimpleBigText text="Buster" color="#f5f3ff" />
</Box>
);
}
});
export function ChatVersionTagline() {
export const ChatVersionTagline = memo(function ChatVersionTagline() {
return (
<Box justifyContent="center" marginTop={1}>
<Text>
@ -25,9 +25,9 @@ export function ChatVersionTagline() {
</Text>
</Box>
);
}
});
export function ChatIntroText() {
export const ChatIntroText = memo(function ChatIntroText() {
const lines = useMemo(
() => [
'You are standing in an open terminal. An AI awaits your commands.',
@ -45,7 +45,7 @@ export function ChatIntroText() {
))}
</Box>
);
}
});
export function ChatStatusBar() {
return (

View File

@ -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>
);
}

View File

@ -30,9 +30,9 @@ export function MultiLineTextInput({
onVimModeChange,
}: MultiLineTextInputProps) {
const [cursorPosition, setCursorPosition] = useState(value.length);
const [showCursor, setShowCursor] = useState(true);
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
const [vimEnabled] = useState(() => getSetting('vimMode'));
@ -48,37 +48,16 @@ export function MultiLineTextInput({
}
}, [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)
useEffect(() => {
setCursorPosition(value.length);
}, [value]);
// Detect slash commands
// Detect slash commands with debounce
useEffect(() => {
if (!onSlashChange) return;
const timeoutId = setTimeout(() => {
// Check if we're at the beginning or after a newline
let slashStart = -1;
@ -101,12 +80,16 @@ export function MultiLineTextInput({
// No active slash command
onSlashChange(null, -1);
}, 50);
return () => clearTimeout(timeoutId);
}, [value, cursorPosition, onSlashChange]);
// Detect @ mentions
// 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--) {
@ -123,7 +106,12 @@ export function MultiLineTextInput({
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) {
if (
charBefore === ' ' ||
charBefore === '\n' ||
charBefore === '\t' ||
mentionStart === 0
) {
// Extract the mention query (text after @)
const mentionEnd = cursorPosition;
const mentionQuery = value.substring(mentionStart + 1, mentionEnd);
@ -138,6 +126,9 @@ export function MultiLineTextInput({
// No active mention
onMentionChange(null, -1);
}, 50);
return () => clearTimeout(timeoutId);
}, [value, cursorPosition, onMentionChange]);
useInput(
@ -409,14 +400,8 @@ export function MultiLineTextInput({
// Always reserve space for cursor to prevent shifting
let cursorChar = '█';
if (vimEnabled) {
// Different cursor styles for vim modes
if (vimState.mode === 'normal') {
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
}
// Use block cursor for all vim modes
cursorChar = '█';
}
return (
@ -429,17 +414,12 @@ export function MultiLineTextInput({
const beforeCursor = value.slice(0, cursorPosition);
const afterCursor = value.slice(cursorPosition);
// Always use block cursor
let cursorChar = showCursor && focus ? '█' : ' ';
// Show different cursor styles for vim modes
// Keep block cursor for all vim modes
if (vimEnabled && focus && showCursor) {
if (vimState.mode === 'normal') {
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
}
cursorChar = '█';
}
// Show special cursor when expecting newline

View File

@ -1,15 +1,7 @@
import { Box, Text } from 'ink';
import React from 'react';
export type MessageType =
| 'PLAN'
| 'EXECUTE'
| 'WEB_SEARCH'
| 'INFO'
| 'ERROR'
| 'SUCCESS'
| 'WARNING'
| 'DEBUG';
export type MessageType = 'PLAN' | 'EXECUTE' | 'WRITE' | 'EDIT';
interface TypedMessageProps {
type: MessageType;
@ -20,12 +12,8 @@ interface TypedMessageProps {
const typeStyles: Record<MessageType, { bg: string; fg: string; label: string }> = {
PLAN: { bg: '#fb923c', fg: '#000000', label: 'PLAN' },
EXECUTE: { bg: '#fb923c', fg: '#000000', label: 'EXECUTE' },
WEB_SEARCH: { bg: '#fb923c', fg: '#000000', label: 'WEB SEARCH' },
INFO: { bg: '#3b82f6', fg: '#ffffff', label: 'INFO' },
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' },
WRITE: { bg: '#3b82f6', fg: '#ffffff', label: 'WRITE' },
EDIT: { bg: '#22c55e', fg: '#ffffff', label: 'EDIT' },
};
export function TypedMessage({ type, content, metadata }: TypedMessageProps) {
@ -37,9 +25,7 @@ export function TypedMessage({ type, content, metadata }: TypedMessageProps) {
<Text backgroundColor={style.bg} color={style.fg} bold>
{` ${style.label} `}
</Text>
{metadata && (
<Text dimColor>{metadata}</Text>
)}
{metadata && <Text dimColor>{metadata}</Text>}
</Box>
<Box marginLeft={2} marginTop={0}>
<Text>{content}</Text>

View File

@ -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);
}
);

View File

@ -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,
};
}

View File

@ -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;

View File

@ -14,6 +14,7 @@ import { z } from 'zod';
import GET from './GET';
import chatById from './[id]';
import { cancelChatHandler } from './cancel-chat';
import cliChat from './cli';
import { createChatHandler } from './handler';
const app = new Hono()
@ -21,6 +22,7 @@ const app = new Hono()
.use('*', requireAuth)
.route('/', GET)
.route('/:id', chatById)
.route('/cli', cliChat)
// POST /chats - Create a new chat
.post('/', zValidator('json', ChatCreateRequestSchema), async (c) => {
const request = c.req.valid('json');

View File

@ -59,3 +59,12 @@ export type DuplicateChatRequest = z.infer<typeof DuplicateChatRequestSchema>;
// Logs list request (same as chats list)
export const GetLogsListRequestSchema = GetChatsListRequestSchema;
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>;

View File

@ -39,3 +39,7 @@ export type DeleteChatsResponse = z.infer<typeof DeleteChatsResponseSchema>;
export const ShareChatResponseSchema = ChatWithMessagesSchema;
export type ShareChatResponse = z.infer<typeof ShareChatResponseSchema>;
// Response for creating a CLI chat
export const CliChatCreateResponseSchema = ChatWithMessagesSchema;
export type CliChatCreateResponse = z.infer<typeof CliChatCreateResponseSchema>;