Skip to content

Commit d18bff3

Browse files
committed
refactor: interactive ui app
1 parent 0fa9e6f commit d18bff3

13 files changed

Lines changed: 491 additions & 291 deletions

src/components/ascii-logo.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Box, Text } from "ink";
22
import Gradient from "ink-gradient";
3+
import { memo } from "react";
34

4-
export const AsciiMCPCli = `
5+
export const asciiMcpCli = `
56
███╗ ███╗███████╗████████╗██╗ ██╗██████╗ ██╗ ██████╗
67
████╗ ████║██╔════╝╚══██╔══╝██║ ██║██╔══██╗██║██╔═══██╗
78
██╔████╔██║███████╗ ██║ ██║ ██║██║ ██║██║██║ ██║
@@ -12,12 +13,12 @@ export const AsciiMCPCli = `
1213
From Missing Studio(https://www.missing.studio)
1314
`;
1415

15-
export const AsciiLogo: React.FC = () => {
16+
export const AsciiLogo = memo(() => {
1617
return (
1718
<Box marginBottom={1} alignItems="flex-start" flexShrink={0}>
1819
<Gradient name="vice">
19-
<Text>{AsciiMCPCli}</Text>
20+
<Text>{asciiMcpCli}</Text>
2021
</Gradient>
2122
</Box>
2223
);
23-
};
24+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Box, Text } from "ink";
2+
import TextInput from "ink-text-input";
3+
import boxen from "boxen";
4+
import dedent from "dedent";
5+
6+
type ApiKeyPromptProps = {
7+
provider: string;
8+
model: string;
9+
value: string;
10+
onChange: (v: string) => void;
11+
onSubmit: (v: string) => void;
12+
};
13+
14+
export function ApiKeyPrompt({
15+
provider,
16+
model,
17+
value,
18+
onChange,
19+
onSubmit,
20+
}: ApiKeyPromptProps) {
21+
return (
22+
<>
23+
<Box justifyContent="flex-start" marginBottom={2}>
24+
<Text color="white">
25+
{boxen(
26+
dedent`
27+
🔑 API Key Required
28+
29+
To connect with ${provider} and enable chat, please enter your API key.
30+
The key should have the necessary permissions for ${model}.
31+
32+
👉 Paste your key below to continue.
33+
`,
34+
{
35+
padding: 1,
36+
borderColor: "yellow",
37+
borderStyle: "round",
38+
backgroundColor: "black",
39+
title: "Missing studio Requires",
40+
titleAlignment: "left",
41+
},
42+
)}
43+
</Text>
44+
</Box>
45+
46+
<Box flexDirection="column" marginTop={2}>
47+
<Box borderStyle="round" borderColor="gray" paddingX={1}>
48+
<Text color={"magenta"}>{"> "}</Text>
49+
<TextInput
50+
value={value}
51+
onChange={onChange}
52+
onSubmit={onSubmit}
53+
placeholder="Paste your API key and press Enter"
54+
focus
55+
mask="*"
56+
/>
57+
</Box>
58+
<Box marginTop={1}>
59+
<Text color="gray">Press Enter to submit, or Ctrl+C to quit.</Text>
60+
</Box>
61+
</Box>
62+
</>
63+
);
64+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Box, Text } from "ink";
2+
import { memo } from "react";
3+
4+
type HeaderProps = {
5+
provider?: string;
6+
model?: string;
7+
};
8+
9+
export const Header = memo(({ provider, model }: HeaderProps) => {
10+
return (
11+
<Box justifyContent="space-between" flexDirection="column" width="100%">
12+
<Text bold color="cyan">
13+
Missing studio AI Agent
14+
</Text>
15+
<Text color="gray">
16+
Selected Provider: {provider} | Model: {model}
17+
</Text>
18+
</Box>
19+
);
20+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Box, Text } from "ink";
2+
import TextInput from "ink-text-input";
3+
4+
type InputBoxProps = {
5+
value: string;
6+
onChange: (v: string) => void;
7+
onSubmit: (v: string) => void;
8+
};
9+
10+
export function InputBox({ value, onChange, onSubmit }: InputBoxProps) {
11+
return (
12+
<Box borderStyle="round" borderColor="gray" paddingLeft={1}>
13+
<Text color="white">{`> `} </Text>
14+
<TextInput
15+
value={value}
16+
onChange={onChange}
17+
onSubmit={onSubmit}
18+
placeholder="Try some command (help, clear, exit, or describe what you want to do)"
19+
focus
20+
/>
21+
</Box>
22+
);
23+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Box, Text } from "ink";
2+
import SelectInput from "ink-select-input";
3+
import { memo } from "react";
4+
5+
type Item<T = any> = { label: string; value: T };
6+
7+
const MenuItem = ({
8+
isSelected = false,
9+
label,
10+
}: {
11+
isSelected?: boolean;
12+
label: string;
13+
}) => (
14+
<Text color={isSelected ? "yellow" : "gray"} bold={isSelected}>
15+
{label}
16+
</Text>
17+
);
18+
const MemoMenuItem = memo(MenuItem);
19+
20+
type ItemSelectionProps<T> = {
21+
title: string;
22+
items: ReadonlyArray<Item<T>>;
23+
onSelect: (item: Item<T>) => void;
24+
};
25+
26+
export function ItemSelection<T>({
27+
title,
28+
items,
29+
onSelect,
30+
}: ItemSelectionProps<T>) {
31+
return (
32+
<>
33+
<Box justifyContent="flex-start" marginBottom={1}>
34+
<Text color="gray">{title}</Text>
35+
</Box>
36+
<SelectInput
37+
items={[...items] as any}
38+
onSelect={onSelect as any}
39+
itemComponent={MemoMenuItem as any}
40+
indicatorComponent={({ isSelected }) =>
41+
isSelected ? <Text color="yellow">👉 </Text> : <Text> </Text>
42+
}
43+
/>
44+
</>
45+
);
46+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Box, Text } from "ink";
2+
import Spinner from "ink-spinner";
3+
import { memo } from "react";
4+
import type { Message } from "../../agents/types.js";
5+
6+
type MessagesProps = {
7+
messages: ReadonlyArray<Message>;
8+
isProcessing: boolean;
9+
};
10+
11+
export const Messages = memo(({ messages, isProcessing }: MessagesProps) => {
12+
return (
13+
<Box
14+
flexDirection="column"
15+
flexGrow={1}
16+
marginTop={1}
17+
marginBottom={1}
18+
minHeight={12}
19+
>
20+
{messages.length === 0 && (
21+
<Text color="gray" dimColor>
22+
No messages yet. Say hi below!
23+
</Text>
24+
)}
25+
26+
{messages.map((msg, index) => (
27+
<Box key={index} marginBottom={0}>
28+
{msg.role === "user" && (
29+
<Box marginBottom={1}>
30+
<Text color="white">{`| `}</Text>
31+
<Text color="gray">{msg.content}</Text>
32+
</Box>
33+
)}
34+
35+
{msg.role === "assistant" && (
36+
<Box marginBottom={1} paddingLeft={2}>
37+
<Text wrap="wrap" color="white">
38+
{msg.content}
39+
</Text>
40+
</Box>
41+
)}
42+
43+
{msg.role === "tool" && (
44+
<Box marginBottom={1} paddingLeft={2}>
45+
<Text color="gray" dimColor>
46+
{msg.toolName && msg.content.includes("Executing")
47+
? `[${msg.toolName}] ${msg.content
48+
.replace(`Executing ${msg.toolName}...`, "")
49+
.replace(/^\(.*\)\.\.\./, "")}`
50+
: msg.content
51+
.replace("✅", "")
52+
.replace("❌ Error:", "ERROR:")
53+
.trim()}
54+
</Text>
55+
</Box>
56+
)}
57+
58+
{msg.role === "system" && (
59+
<Box marginBottom={1} paddingLeft={2}>
60+
<Text color="red">
61+
ERROR: {msg.content.replace("❌ Error:", "").trim()}
62+
</Text>
63+
</Box>
64+
)}
65+
</Box>
66+
))}
67+
68+
{isProcessing && (
69+
<Box marginBottom={1} gap={1}>
70+
<Text color="green">
71+
<Spinner type="star" />
72+
</Text>
73+
<Text>Working...</Text>
74+
</Box>
75+
)}
76+
</Box>
77+
);
78+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Box, Text } from "ink";
2+
import { memo } from "react";
3+
4+
type StatusBarProps = {
5+
messageCount: number;
6+
isProcessing: boolean;
7+
lastTimeLabel: string;
8+
};
9+
10+
export const StatusBar = memo(
11+
({ messageCount, isProcessing, lastTimeLabel }: StatusBarProps) => {
12+
return (
13+
<Box marginTop={1}>
14+
<Text color="gray" dimColor>
15+
{messageCount > 0 ? `${messageCount} messages` : "Ready"} |
16+
{isProcessing ? " Processing..." : " Idle"} |{lastTimeLabel}
17+
&nbsp;
18+
</Text>
19+
<Text color="gray">↑/↓ scroll • Ctrl+C to exit</Text>
20+
</Box>
21+
);
22+
},
23+
);

src/hooks/use-agent-chat.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useEffect, useMemo, useState } from "react";
2+
import type { Message, Provider } from "../agents/types.js";
3+
import { AIAgent } from "../agents/AIAgent.js";
4+
5+
export function useAgentChat(options: {
6+
provider?: Provider;
7+
model?: string;
8+
enabled: boolean;
9+
}) {
10+
const { provider, model, enabled } = options;
11+
const [isProcessing, setIsProcessing] = useState(false);
12+
const [messages, setMessages] = useState<Message[]>([]);
13+
const [agent, setAgent] = useState<AIAgent | null>(null);
14+
15+
useEffect(() => {
16+
if (provider && model && enabled) {
17+
const instance = new AIAgent(
18+
provider,
19+
model,
20+
[],
21+
setIsProcessing,
22+
setMessages,
23+
);
24+
setAgent(instance);
25+
}
26+
}, [provider, model, enabled]);
27+
28+
const messageCount = useMemo(() => messages.length, [messages]);
29+
30+
return { isProcessing, messages, setMessages, agent, messageCount } as const;
31+
}

src/hooks/use-api-key.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useEffect, useMemo, useState, useCallback } from "react";
2+
import type { Provider } from "../agents/types.js";
3+
import { providerEnvVar } from "../agents/constants.js";
4+
5+
export function useApiKey(selectedProvider?: Provider) {
6+
const [apiKeyInput, setApiKeyInput] = useState("");
7+
const [apiKeyConfigured, setApiKeyConfigured] = useState(false);
8+
9+
const envVarName = useMemo(() => {
10+
return selectedProvider ? providerEnvVar[selectedProvider] : undefined;
11+
}, [selectedProvider]);
12+
13+
const hasApiKey = useMemo(() => {
14+
if (!envVarName) return false;
15+
return Boolean(process.env[envVarName]);
16+
}, [envVarName]);
17+
18+
useEffect(() => {
19+
if (hasApiKey && !apiKeyConfigured) setApiKeyConfigured(true);
20+
}, [hasApiKey, apiKeyConfigured]);
21+
22+
const handleApiKeySubmit = useCallback(
23+
(value: string) => {
24+
if (!envVarName) return;
25+
process.env[envVarName] = value.trim();
26+
setApiKeyConfigured(true);
27+
},
28+
[envVarName],
29+
);
30+
31+
const readyForApi = hasApiKey || apiKeyConfigured;
32+
33+
return {
34+
envVarName,
35+
apiKeyInput,
36+
setApiKeyInput,
37+
apiKeyConfigured,
38+
hasApiKey,
39+
readyForApi,
40+
handleApiKeySubmit,
41+
} as const;
42+
}

src/hooks/use-global-shortcuts.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { useInput } from "ink";
2+
3+
type Options = {
4+
onExit: () => void;
5+
onToggleHelp: () => void;
6+
};
7+
8+
export function useGlobalShortcuts({ onExit, onToggleHelp }: Options) {
9+
useInput((input, key) => {
10+
if (key.ctrl && input === "c") onExit();
11+
if (key.ctrl && input === "h") onToggleHelp();
12+
});
13+
}

0 commit comments

Comments
 (0)