lumina-ai-cli 1.0.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.
package/src/index.js ADDED
@@ -0,0 +1,124 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { loadConfig, configExists } from './config.js';
4
+ import { runOnboarding } from './onboarding.js';
5
+ import { OpenAIProvider } from './api/openai.js';
6
+ import { AnthropicProvider } from './api/anthropic.js';
7
+ import { GoogleProvider } from './api/google.js';
8
+ import { GroqProvider } from './api/groq.js';
9
+ import { TogetherProvider } from './api/together.js';
10
+ import { CustomProvider } from './api/custom.js';
11
+ import App from './tui/app.js';
12
+
13
+ function getProviderInstance(providerName, config) {
14
+ const providers = {
15
+ openai: OpenAIProvider,
16
+ anthropic: AnthropicProvider,
17
+ google: GoogleProvider,
18
+ groq: GroqProvider,
19
+ together: TogetherProvider,
20
+ custom: CustomProvider,
21
+ };
22
+ const Klass = providers[providerName];
23
+ if (!Klass) throw new Error(`Unknown provider: ${providerName}`);
24
+ return new Klass(config);
25
+ }
26
+
27
+ export async function main(args) {
28
+ const flags = {};
29
+ for (let i = 0; i < args.length; i++) {
30
+ switch (args[i]) {
31
+ case '--config':
32
+ case '-c':
33
+ flags.reconfig = true;
34
+ break;
35
+ case '--model':
36
+ case '-m':
37
+ flags.model = args[++i];
38
+ break;
39
+ case '--persona':
40
+ case '-p':
41
+ flags.persona = args[++i];
42
+ break;
43
+ case '--budget':
44
+ case '-b':
45
+ flags.budget = args[++i];
46
+ break;
47
+ case '--help':
48
+ case '-h':
49
+ printHelp();
50
+ return;
51
+ case '--version':
52
+ case '-v':
53
+ console.log('lumina-ai-cli v1.0.0');
54
+ return;
55
+ default:
56
+ console.log(`Unknown flag: ${args[i]}`);
57
+ printHelp();
58
+ process.exit(1);
59
+ }
60
+ }
61
+
62
+ if (!configExists() || flags.reconfig) {
63
+ const existing = flags.reconfig ? loadConfig() : null;
64
+ await runOnboarding(existing);
65
+ if (flags.reconfig) {
66
+ const updated = loadConfig();
67
+ applySessionOverrides(updated, flags);
68
+ saveAndLaunch(updated);
69
+ }
70
+ return;
71
+ }
72
+
73
+ let config = loadConfig();
74
+
75
+ if (config.firstRun) {
76
+ await runOnboarding();
77
+ config = loadConfig();
78
+ }
79
+
80
+ applySessionOverrides(config, flags);
81
+ launchTUI(config);
82
+ }
83
+
84
+ function printHelp() {
85
+ console.log(`
86
+ lumina-ai-cli - Personal AI Assistant CLI
87
+
88
+ Usage:
89
+ lumina Start interactive TUI
90
+ lumina-ai-cli Same as above
91
+ lumina --config Re-run setup
92
+ lumina --model <model> Override model for session
93
+ lumina --persona <name> Override persona for session
94
+ lumina --budget <n> Override token budget (128|256|512|unlimited)
95
+ lumina --help Show this help
96
+ lumina --version Show version
97
+ `);
98
+ }
99
+
100
+ function applySessionOverrides(config, flags) {
101
+ if (flags.model) config.defaultModel = flags.model;
102
+ if (flags.persona) config.persona = flags.persona;
103
+ if (flags.budget) {
104
+ config.tokenBudget = flags.budget === 'unlimited' ? 'unlimited' : parseInt(flags.budget, 10);
105
+ }
106
+ }
107
+
108
+ function launchTUI(config) {
109
+ const provider = getProviderInstance(config.provider, config);
110
+
111
+ const { waitUntilExit } = render(
112
+ React.createElement(App, { provider, initialConfig: config })
113
+ );
114
+
115
+ process.on('SIGINT', () => {
116
+ process.exit(0);
117
+ });
118
+
119
+ waitUntilExit().catch(() => {});
120
+ }
121
+
122
+ function saveAndLaunch(config) {
123
+ launchTUI(config);
124
+ }
package/src/memory.js ADDED
@@ -0,0 +1,34 @@
1
+ import { countTokens } from './utils/tokens.js';
2
+
3
+ export function shouldSummarize(messages) {
4
+ if (!Array.isArray(messages)) return false;
5
+ const nonSystem = messages.filter(m => m.role !== 'system');
6
+ return nonSystem.length > 0 && nonSystem.length % 10 === 0;
7
+ }
8
+
9
+ export function buildMemoryBlock(summary) {
10
+ return {
11
+ role: 'system',
12
+ content: `[Memory: ${summary}]`,
13
+ isMemory: true
14
+ };
15
+ }
16
+
17
+ export function compressHistory(messages, summary) {
18
+ const memoryBlock = buildMemoryBlock(summary);
19
+ const lastTwo = messages.filter(m => m.role !== 'system').slice(-2);
20
+ const systemMessages = messages.filter(m => m.role === 'system' && !m.isMemory);
21
+ return [...systemMessages, memoryBlock, ...lastTwo];
22
+ }
23
+
24
+ export function generateSummaryPrompt(messages) {
25
+ const text = messages
26
+ .filter(m => m.role !== 'system')
27
+ .map(m => `${m.role}: ${m.content}`)
28
+ .join('\n')
29
+ .slice(0, 4000);
30
+ return {
31
+ role: 'user',
32
+ content: `Summarize this conversation in under 100 tokens, capturing the key topics and decisions:\n\n${text}`
33
+ };
34
+ }
@@ -0,0 +1,172 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import { loadConfig, saveConfig } from './config.js';
4
+ import { OpenAIProvider } from './api/openai.js';
5
+ import { AnthropicProvider } from './api/anthropic.js';
6
+ import { GoogleProvider } from './api/google.js';
7
+ import { GroqProvider } from './api/groq.js';
8
+ import { TogetherProvider } from './api/together.js';
9
+ import { CustomProvider } from './api/custom.js';
10
+
11
+ function getProvider(providerName, config) {
12
+ const providers = {
13
+ openai: OpenAIProvider,
14
+ anthropic: AnthropicProvider,
15
+ google: GoogleProvider,
16
+ groq: GroqProvider,
17
+ together: TogetherProvider,
18
+ custom: CustomProvider,
19
+ };
20
+ const Klass = providers[providerName];
21
+ if (!Klass) throw new Error(`Unknown provider: ${providerName}`);
22
+ return new Klass(config);
23
+ }
24
+
25
+ async function validateKeyWithRetry(provider, retries = 3) {
26
+ for (let i = 0; i < retries; i++) {
27
+ try {
28
+ await provider.validateKey();
29
+ return true;
30
+ } catch (err) {
31
+ if (i < retries - 1) {
32
+ console.log(chalk.red(`Validation failed: ${err.message}. Retrying... (${retries - i - 1} attempts left)`));
33
+ const { retry } = await inquirer.prompt([{
34
+ type: 'confirm',
35
+ name: 'retry',
36
+ message: 'Try again?',
37
+ default: true
38
+ }]);
39
+ if (!retry) return false;
40
+ } else {
41
+ console.log(chalk.red(`Validation failed after ${retries} attempts: ${err.message}`));
42
+ return false;
43
+ }
44
+ }
45
+ }
46
+ return false;
47
+ }
48
+
49
+ export async function runOnboarding(existingConfig = null) {
50
+ console.log(chalk.cyan.bold('\n✦ Welcome to Lumina AI. Let\'s set you up. ✦\n'));
51
+
52
+ const defaults = existingConfig || {};
53
+
54
+ const { provider } = await inquirer.prompt([{
55
+ type: 'list',
56
+ name: 'provider',
57
+ message: 'Which API provider?',
58
+ choices: [
59
+ { name: 'OpenAI', value: 'openai' },
60
+ { name: 'Anthropic', value: 'anthropic' },
61
+ { name: 'Google (Gemini)', value: 'google' },
62
+ { name: 'Groq', value: 'groq' },
63
+ { name: 'Together AI', value: 'together' },
64
+ { name: 'Custom (bring your own base URL)', value: 'custom' },
65
+ ],
66
+ default: defaults.provider || 'openai'
67
+ }]);
68
+
69
+ const { apiKey } = await inquirer.prompt([{
70
+ type: 'password',
71
+ name: 'apiKey',
72
+ message: 'Enter your API key:',
73
+ mask: '*',
74
+ default: defaults.apiKey || '',
75
+ validate: v => v.length > 0 || 'API key is required'
76
+ }]);
77
+
78
+ let baseUrl = defaults.baseUrl || null;
79
+ if (provider === 'custom') {
80
+ const { url } = await inquirer.prompt([{
81
+ type: 'input',
82
+ name: 'url',
83
+ message: 'Enter your custom base URL (e.g. https://api.example.com/v1):',
84
+ default: baseUrl || '',
85
+ validate: v => v.length > 0 || 'Base URL is required'
86
+ }]);
87
+ baseUrl = url;
88
+ }
89
+
90
+ const config = { provider, apiKey, baseUrl, ...defaults };
91
+ const instance = getProvider(provider, config);
92
+
93
+ console.log(chalk.yellow('\nValidating API key...'));
94
+ const valid = await validateKeyWithRetry(instance);
95
+ if (!valid) {
96
+ console.log(chalk.red('Setup failed. Please try again with a valid API key.'));
97
+ process.exit(1);
98
+ }
99
+ console.log(chalk.green('✓ API key validated\n'));
100
+
101
+ console.log(chalk.yellow('Fetching available models...'));
102
+ let models = [];
103
+ try {
104
+ models = await instance.listModels();
105
+ console.log(chalk.green(`✓ Found ${models.length} models\n`));
106
+ } catch (err) {
107
+ console.log(chalk.yellow(`Could not fetch models: ${err.message}`));
108
+ if (provider === 'custom') {
109
+ const { manual } = await inquirer.prompt([{
110
+ type: 'input',
111
+ name: 'manual',
112
+ message: 'Enter model names (comma-separated):',
113
+ default: 'custom-model',
114
+ validate: v => v.length > 0 || 'At least one model is required'
115
+ }]);
116
+ models = manual.split(',').map(m => m.trim());
117
+ } else {
118
+ models = defaults.availableModels || ['gpt-4o-mini'];
119
+ }
120
+ }
121
+
122
+ const { defaultModel } = await inquirer.prompt([{
123
+ type: 'list',
124
+ name: 'defaultModel',
125
+ message: 'Select your default model:',
126
+ choices: models.map(m => ({ name: m, value: m })),
127
+ default: defaults.defaultModel || models[0],
128
+ pageSize: 20
129
+ }]);
130
+
131
+ const { persona } = await inquirer.prompt([{
132
+ type: 'list',
133
+ name: 'persona',
134
+ message: 'Preferred persona?',
135
+ choices: [
136
+ { name: 'Planner', value: 'planner' },
137
+ { name: 'Coder', value: 'coder' },
138
+ { name: 'Reviewer', value: 'reviewer' },
139
+ { name: 'Fixer', value: 'fixer' },
140
+ { name: 'General', value: 'general' },
141
+ ],
142
+ default: defaults.persona || 'coder'
143
+ }]);
144
+
145
+ const { tokenBudget } = await inquirer.prompt([{
146
+ type: 'list',
147
+ name: 'tokenBudget',
148
+ message: 'Token budget preference?',
149
+ choices: [
150
+ { name: 'Ultra-efficient (128 tokens)', value: 128 },
151
+ { name: 'Efficient (256 tokens)', value: 256 },
152
+ { name: 'Balanced (512 tokens)', value: 512 },
153
+ { name: 'Unlimited', value: 'unlimited' },
154
+ ],
155
+ default: defaults.tokenBudget || 256
156
+ }]);
157
+
158
+ const finalConfig = {
159
+ provider,
160
+ apiKey,
161
+ baseUrl,
162
+ defaultModel,
163
+ availableModels: models,
164
+ persona,
165
+ tokenBudget,
166
+ firstRun: false,
167
+ version: '1.0.0'
168
+ };
169
+
170
+ saveConfig(finalConfig);
171
+ console.log(chalk.green.bold('\n✓ Setup complete. Run ' + chalk.cyan('lumina') + ' or ' + chalk.cyan('lumina-ai-cli') + ' to start.\n'));
172
+ }
@@ -0,0 +1,4 @@
1
+ export default {
2
+ name: 'coder',
3
+ systemPrompt: `You are a senior software engineer. Write clean, production-ready code with comments. No explanations unless asked. Use the most efficient algorithm. Include error handling. TOKEN_EFFICIENCY: Output code only. One-line docstring per function. No prose. Max 256 tokens unless user asks for explanation.`
4
+ };
@@ -0,0 +1,4 @@
1
+ export default {
2
+ name: 'fixer',
3
+ systemPrompt: `You are a debugger. Given broken code and/or error messages, output the minimal fix required. Explain the root cause in exactly 1 sentence. TOKEN_EFFICIENCY: Output corrected code only. One-line root cause explanation. No preamble. Max 256 tokens.`
4
+ };
@@ -0,0 +1,17 @@
1
+ import planner from './planner.js';
2
+ import coder from './coder.js';
3
+ import reviewer from './reviewer.js';
4
+ import fixer from './fixer.js';
5
+
6
+ const general = {
7
+ name: 'general',
8
+ systemPrompt: `You are a helpful and concise AI assistant. Answer questions directly and accurately. Be efficient with tokens. TOKEN_EFFICIENCY: Be concise. No filler. Max 256 tokens unless user requests more.`
9
+ };
10
+
11
+ export default {
12
+ planner,
13
+ coder,
14
+ reviewer,
15
+ fixer,
16
+ general
17
+ };
@@ -0,0 +1,4 @@
1
+ export default {
2
+ name: 'planner',
3
+ systemPrompt: `You are a technical architect and project planner. Break complex tasks into clear, actionable steps. Use bullet points. Be concise. Each step max 3 sentences. Prioritize by impact and effort. TOKEN_EFFICIENCY: Be extremely concise. Use abbreviations. No filler. Max 256 tokens unless user explicitly requests more.`
4
+ };
@@ -0,0 +1,4 @@
1
+ export default {
2
+ name: 'reviewer',
3
+ systemPrompt: `You are a principal code reviewer. Find bugs, security issues, performance problems, and style violations. Rate severity 1-10. Suggest specific fixes with line references. Be brutal but constructive. TOKEN_EFFICIENCY: Bullet points only. Severity + one-line issue + one-line fix. Max 256 tokens.`
4
+ };
package/src/tui/app.js ADDED
@@ -0,0 +1,244 @@
1
+ import React, { useState, useCallback, useEffect, useRef } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import MessageBlock from './chat.js';
5
+ import StatusBar from './statusbar.js';
6
+ import CommandInput from './input.js';
7
+ import { loadConfig, saveConfig } from '../config.js';
8
+ import { countTokens } from '../utils/tokens.js';
9
+ import { estimateCost } from '../utils/cost.js';
10
+ import { saveConversation } from '../utils/files.js';
11
+ import { shouldSummarize, compressHistory, generateSummaryPrompt } from '../memory.js';
12
+ import personas from '../personas/index.js';
13
+
14
+ const { createElement: h, Fragment } = React;
15
+
16
+ function App({ provider, initialConfig }) {
17
+ const [config, setConfig] = useState(initialConfig);
18
+ const [messages, setMessages] = useState([]);
19
+ const [input, setInput] = useState('');
20
+ const [isStreaming, setIsStreaming] = useState(false);
21
+ const [tokensUsed, setTokensUsed] = useState(0);
22
+ const [cost, setCost] = useState(0);
23
+ const [streamingContent, setStreamingContent] = useState('');
24
+ const [abortController, setAbortController] = useState(null);
25
+
26
+ const currentPersona = personas[config.persona] || personas.general;
27
+
28
+ useEffect(() => {
29
+ if (currentPersona) {
30
+ setMessages([{
31
+ role: 'system',
32
+ content: currentPersona.systemPrompt
33
+ }]);
34
+ }
35
+ }, []);
36
+
37
+ const handleSubmit = useCallback(async (value) => {
38
+ if (isStreaming || !value.trim()) return;
39
+
40
+ const trimmed = value.trim();
41
+
42
+ if (trimmed.startsWith(':')) {
43
+ handleCommand(trimmed);
44
+ setInput('');
45
+ return;
46
+ }
47
+
48
+ const userMsg = { role: 'user', content: trimmed };
49
+ const newMessages = [...messages, userMsg];
50
+ setMessages(newMessages);
51
+ setInput('');
52
+ setIsStreaming(true);
53
+ setStreamingContent('');
54
+
55
+ try {
56
+ let full = '';
57
+ const ac = new AbortController();
58
+ setAbortController(ac);
59
+
60
+ await provider.chat(newMessages, {
61
+ model: config.defaultModel,
62
+ maxTokens: config.tokenBudget === 'unlimited' ? 4096 : config.tokenBudget,
63
+ onToken: (token) => {
64
+ if (ac.signal.aborted) return;
65
+ full += token;
66
+ setStreamingContent(full);
67
+ const tokCount = countTokens(full, config.defaultModel);
68
+ setTokensUsed(prev => prev + 1);
69
+ const c = estimateCost(
70
+ countTokens(trimmed, config.defaultModel),
71
+ tokCount,
72
+ config.defaultModel
73
+ );
74
+ setCost(c);
75
+ },
76
+ signal: ac.signal
77
+ });
78
+
79
+ const assistantMsg = { role: 'assistant', content: full };
80
+ const finalMessages = [...newMessages, assistantMsg];
81
+ setMessages(finalMessages);
82
+ setStreamingContent('');
83
+ setIsStreaming(false);
84
+
85
+ const tokCount = countTokens(full, config.defaultModel);
86
+ setTokensUsed(prev => prev + tokCount);
87
+ const c = estimateCost(
88
+ countTokens(trimmed, config.defaultModel),
89
+ tokCount,
90
+ config.defaultModel
91
+ );
92
+ setCost(c);
93
+
94
+ if (shouldSummarize(finalMessages)) {
95
+ const summaryPrompt = generateSummaryPrompt(finalMessages);
96
+ try {
97
+ const summary = await provider.chat(
98
+ [...finalMessages.filter(m => m.role === 'system'), summaryPrompt],
99
+ { model: config.defaultModel, maxTokens: 150 }
100
+ );
101
+ const compressed = compressHistory(finalMessages, summary.slice(0, 500));
102
+ setMessages(compressed);
103
+ } catch {}
104
+ }
105
+ } catch (err) {
106
+ if (err.name === 'AbortError') return;
107
+ setMessages([...newMessages, {
108
+ role: 'system',
109
+ content: 'Error: ' + err.message
110
+ }]);
111
+ setIsStreaming(false);
112
+ setStreamingContent('');
113
+ }
114
+ }, [messages, isStreaming, config, provider]);
115
+
116
+ function handleCommand(cmd) {
117
+ const parts = cmd.slice(1).split(/\s+/);
118
+ const command = parts[0];
119
+ const arg = parts.slice(1).join(' ');
120
+
121
+ switch (command) {
122
+ case 'q':
123
+ case 'quit':
124
+ process.exit(0);
125
+ break;
126
+ case 'm':
127
+ case 'model':
128
+ if (arg) {
129
+ setConfig(prev => ({ ...prev, defaultModel: arg }));
130
+ setMessages(prev => [...prev, {
131
+ role: 'system',
132
+ content: 'Switched model to ' + arg
133
+ }]);
134
+ }
135
+ break;
136
+ case 'p':
137
+ case 'persona':
138
+ if (arg && personas[arg]) {
139
+ setConfig(prev => ({ ...prev, persona: arg }));
140
+ const newPersonaMsg = {
141
+ role: 'system',
142
+ content: personas[arg].systemPrompt
143
+ };
144
+ setMessages([newPersonaMsg]);
145
+ setTokensUsed(0);
146
+ setCost(0);
147
+ }
148
+ break;
149
+ case 'b':
150
+ case 'budget':
151
+ const budget = arg === 'unlimited' ? 'unlimited' : parseInt(arg, 10);
152
+ if (budget === 'unlimited' || [128, 256, 512].includes(budget)) {
153
+ setConfig(prev => ({ ...prev, tokenBudget: budget }));
154
+ setMessages(prev => [...prev, {
155
+ role: 'system',
156
+ content: 'Token budget set to ' + budget
157
+ }]);
158
+ }
159
+ break;
160
+ case 'clear':
161
+ setMessages([{
162
+ role: 'system',
163
+ content: currentPersona.systemPrompt
164
+ }]);
165
+ setTokensUsed(0);
166
+ setCost(0);
167
+ break;
168
+ case 'save':
169
+ if (arg) {
170
+ try {
171
+ const path = saveConversation(messages, arg);
172
+ setMessages(prev => [...prev, {
173
+ role: 'system',
174
+ content: 'Saved to ' + path
175
+ }]);
176
+ } catch (err) {
177
+ setMessages(prev => [...prev, {
178
+ role: 'system',
179
+ content: 'Save failed: ' + err.message
180
+ }]);
181
+ }
182
+ }
183
+ break;
184
+ default:
185
+ setMessages(prev => [...prev, {
186
+ role: 'system',
187
+ content: 'Unknown command: :' + command + '. Available: :q, :m <model>, :p <persona>, :b <budget>, :clear, :save <filename>'
188
+ }]);
189
+ }
190
+ }
191
+
192
+ const displayMessages = messages.filter(m => !(m.role === 'system' && !m.isMemory));
193
+ const emptyState = displayMessages.length === 0 || (displayMessages.length === 1 && displayMessages[0].role === 'system');
194
+
195
+ const children = [
196
+ h(Box, {
197
+ key: 'chat-area',
198
+ flexDirection: 'column',
199
+ flexGrow: 1,
200
+ overflowY: 'auto',
201
+ paddingX: 1,
202
+ paddingY: 1
203
+ },
204
+ emptyState
205
+ ? h(Box, { justifyContent: 'center', marginTop: 2 },
206
+ h(Text, { color: 'gray' }, 'Welcome to Lumina AI. Type a message or :q to exit.')
207
+ )
208
+ : displayMessages.map((msg, i) =>
209
+ h(MessageBlock, { key: i, message: msg })
210
+ ).concat(
211
+ isStreaming && streamingContent
212
+ ? h(MessageBlock, { key: 'streaming', message: { role: 'assistant', content: streamingContent } })
213
+ : null,
214
+ isStreaming && !streamingContent
215
+ ? h(Box, { key: 'spinner', marginY: 1 },
216
+ h(Text, { color: 'green' },
217
+ h(Spinner, { type: 'dots' }), ' Thinking...'
218
+ )
219
+ )
220
+ : null
221
+ ).filter(Boolean)
222
+ ),
223
+ h(StatusBar, {
224
+ key: 'statusbar',
225
+ model: config.defaultModel,
226
+ persona: config.persona,
227
+ tokensUsed,
228
+ tokenBudget: config.tokenBudget,
229
+ cost,
230
+ isStreaming
231
+ }),
232
+ h(CommandInput, {
233
+ key: 'input',
234
+ value: input,
235
+ onChange: setInput,
236
+ onSubmit: handleSubmit,
237
+ isStreaming
238
+ })
239
+ ];
240
+
241
+ return h(Box, { flexDirection: 'column', height: '100%' }, ...children);
242
+ }
243
+
244
+ export default App;
@@ -0,0 +1,52 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ const { createElement: h } = React;
5
+
6
+ function MessageBlock({ message }) {
7
+ const isUser = message.role === 'user';
8
+ const isSystem = message.isMemory || message.role === 'system';
9
+ const content = message.content || '';
10
+
11
+ if (isSystem) {
12
+ return h(Box, { justifyContent: 'center', marginY: 1 },
13
+ h(Text, { color: 'gray', dim: true }, '\u2500\u2500 ' + content + ' \u2500\u2500')
14
+ );
15
+ }
16
+
17
+ const segments = content.split(/(```[\s\S]*?```)/g);
18
+
19
+ return h(Box, {
20
+ flexDirection: 'column',
21
+ marginY: 1,
22
+ alignItems: isUser ? 'flex-end' : 'flex-start'
23
+ },
24
+ h(Text, { bold: true, color: isUser ? 'cyan' : 'white' },
25
+ isUser ? 'You' : 'Lumina'
26
+ ),
27
+ h(Box, {
28
+ flexDirection: 'column',
29
+ paddingX: 1,
30
+ borderStyle: 'single',
31
+ borderColor: isUser ? 'cyan' : 'gray'
32
+ },
33
+ ...segments.map((segment, i) => {
34
+ if (segment.startsWith('```')) {
35
+ const lines = segment.split('\n');
36
+ const lang = lines[0].replace('```', '').trim();
37
+ const code = lines.slice(1, -1).join('\n');
38
+ return h(Box, { key: i, flexDirection: 'column', marginY: 1, paddingX: 1 },
39
+ lang ? h(Text, { color: 'gray', dim: true }, lang) : null,
40
+ h(Text, null, code)
41
+ );
42
+ }
43
+ if (segment.trim()) {
44
+ return h(Text, { key: i }, segment);
45
+ }
46
+ return null;
47
+ }).filter(Boolean)
48
+ )
49
+ );
50
+ }
51
+
52
+ export default MessageBlock;
@@ -0,0 +1,24 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+
5
+ const { createElement: h } = React;
6
+
7
+ function CommandInput({ value, onChange, onSubmit, isStreaming }) {
8
+ return h(Box, null,
9
+ h(Box, { marginRight: 1 },
10
+ h(Text, { color: 'cyan' }, '>')
11
+ ),
12
+ h(Box, { flexGrow: 1 },
13
+ h(TextInput, {
14
+ value,
15
+ onChange,
16
+ onSubmit,
17
+ placeholder: isStreaming ? 'Waiting for response...' : 'Type a message or :q to quit',
18
+ focus: !isStreaming
19
+ })
20
+ )
21
+ );
22
+ }
23
+
24
+ export default CommandInput;