@orderful/droid 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1102 @@
1
+ import { render, Box, Text, useInput, useApp } from 'ink';
2
+ import TextInput from 'ink-text-input';
3
+ import { useState, useMemo } from 'react';
4
+ import { execSync } from 'child_process';
5
+ import { existsSync, readdirSync, readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import {
8
+ getBundledSkills,
9
+ getBundledSkillsDir,
10
+ isSkillInstalled,
11
+ getInstalledSkill,
12
+ installSkill,
13
+ uninstallSkill,
14
+ } from '../lib/skills.js';
15
+ import { configExists, loadConfig, saveConfig, loadSkillOverrides, saveSkillOverrides } from '../lib/config.js';
16
+ import { configureAIToolPermissions } from './setup.js';
17
+ import { AITool, BuiltInOutput, ConfigOptionType, type DroidConfig, type OutputPreference, type SkillManifest, type ConfigOption, type SkillOverrides } from '../lib/types.js';
18
+ import { getVersion } from '../lib/version.js';
19
+
20
+ type Tab = 'skills' | 'commands' | 'agents' | 'settings';
21
+ type View = 'welcome' | 'setup' | 'menu' | 'detail' | 'configure';
22
+ type SetupStep = 'ai_tool' | 'user_mention' | 'output_preference' | 'confirm';
23
+
24
+ const colors = {
25
+ primary: '#6366f1',
26
+ bgSelected: '#2d2d2d',
27
+ border: '#3a3a3a',
28
+ text: '#e8e8e8',
29
+ textMuted: '#999999',
30
+ textDim: '#6a6a6a',
31
+ success: '#4ade80',
32
+ error: '#f87171',
33
+ };
34
+
35
+ interface Command {
36
+ name: string;
37
+ description: string;
38
+ skillName: string;
39
+ usage: string[];
40
+ }
41
+
42
+ function getCommandsFromSkills(): Command[] {
43
+ const skillsDir = getBundledSkillsDir();
44
+ const commands: Command[] = [];
45
+
46
+ if (!existsSync(skillsDir)) return commands;
47
+
48
+ const skillDirs = readdirSync(skillsDir, { withFileTypes: true })
49
+ .filter((d) => d.isDirectory())
50
+ .map((d) => d.name);
51
+
52
+ for (const skillName of skillDirs) {
53
+ const commandsDir = join(skillsDir, skillName, 'commands');
54
+ if (!existsSync(commandsDir)) continue;
55
+
56
+ const cmdFiles = readdirSync(commandsDir).filter((f) => f.endsWith('.md'));
57
+ for (const file of cmdFiles) {
58
+ const content = readFileSync(join(commandsDir, file), 'utf-8');
59
+ const lines = content.split('\n');
60
+
61
+ const headerMatch = lines[0]?.match(/^#\s+\/(\S+)\s*-?\s*(.*)/);
62
+ if (!headerMatch) continue;
63
+
64
+ const usage: string[] = [];
65
+ let inUsage = false;
66
+ for (const line of lines) {
67
+ if (line.startsWith('## Usage')) inUsage = true;
68
+ else if (line.startsWith('## ') && inUsage) break;
69
+ else if (inUsage && line.startsWith('/')) usage.push(line.trim());
70
+ }
71
+
72
+ commands.push({
73
+ name: headerMatch[1],
74
+ description: headerMatch[2] || '',
75
+ skillName,
76
+ usage,
77
+ });
78
+ }
79
+ }
80
+
81
+ return commands;
82
+ }
83
+
84
+ function detectAITool(): AITool | null {
85
+ try {
86
+ execSync('claude --version', { stdio: 'ignore' });
87
+ return AITool.ClaudeCode;
88
+ } catch {
89
+ // Claude Code not found
90
+ }
91
+ try {
92
+ execSync('opencode --version', { stdio: 'ignore' });
93
+ return AITool.OpenCode;
94
+ } catch {
95
+ // OpenCode not found
96
+ }
97
+ return null;
98
+ }
99
+
100
+ function getOutputOptions(): Array<{ label: string; value: OutputPreference }> {
101
+ const options: Array<{ label: string; value: OutputPreference }> = [
102
+ { label: 'Terminal (display in CLI)', value: BuiltInOutput.Terminal },
103
+ { label: 'Editor ($EDITOR)', value: BuiltInOutput.Editor },
104
+ ];
105
+ const skills = getBundledSkills();
106
+ for (const skill of skills) {
107
+ if (skill.provides_output) {
108
+ options.push({ label: `${skill.name} (${skill.description})`, value: skill.name });
109
+ }
110
+ }
111
+ return options;
112
+ }
113
+
114
+ function WelcomeScreen({ onContinue }: { onContinue: () => void }) {
115
+ useInput((input, key) => {
116
+ if (key.return || input === 'q') {
117
+ onContinue();
118
+ }
119
+ });
120
+
121
+ return (
122
+ <Box flexDirection="column" alignItems="center" justifyContent="center" height={16}>
123
+ <Box
124
+ flexDirection="column"
125
+ alignItems="center"
126
+ borderStyle="single"
127
+ borderColor={colors.border}
128
+ paddingX={4}
129
+ paddingY={1}
130
+ >
131
+ <Box flexDirection="column">
132
+ <Text>
133
+ <Text color={colors.textDim}>╔═════╗ </Text>
134
+ <Text color={colors.text}>droid</Text>
135
+ <Text color={colors.textDim}> v{getVersion()}</Text>
136
+ </Text>
137
+ <Text>
138
+ <Text color={colors.textDim}>║ </Text>
139
+ <Text color={colors.primary}>●</Text>
140
+ <Text color={colors.textDim}> </Text>
141
+ <Text color={colors.primary}>●</Text>
142
+ <Text color={colors.textDim}> ║ </Text>
143
+ <Text color={colors.textMuted}>Teaching your droid new tricks</Text>
144
+ </Text>
145
+ <Text>
146
+ <Text color={colors.textDim}>╚═╦═╦═╝ </Text>
147
+ <Text color={colors.textDim}>github.com/Orderful/droid</Text>
148
+ </Text>
149
+ </Box>
150
+
151
+ <Box marginTop={2} marginBottom={1}>
152
+ <Text backgroundColor={colors.primary} color="#ffffff" bold>
153
+ {' '}I'm probably the droid you are looking for.{' '}
154
+ </Text>
155
+ </Box>
156
+
157
+ <Text color={colors.textDim}>press enter</Text>
158
+ </Box>
159
+ </Box>
160
+ );
161
+ }
162
+
163
+ interface SetupScreenProps {
164
+ onComplete: () => void;
165
+ onSkip: () => void;
166
+ initialConfig?: DroidConfig;
167
+ }
168
+
169
+ function SetupScreen({ onComplete, onSkip, initialConfig }: SetupScreenProps) {
170
+ const [step, setStep] = useState<SetupStep>('ai_tool');
171
+ const [aiTool, setAITool] = useState<AITool>(
172
+ initialConfig?.ai_tool || detectAITool() || AITool.ClaudeCode
173
+ );
174
+ const [userMention, setUserMention] = useState(initialConfig?.user_mention || '@user');
175
+ const [outputPreference, setOutputPreference] = useState<OutputPreference>(
176
+ initialConfig?.output_preference || BuiltInOutput.Terminal
177
+ );
178
+ const [selectedIndex, setSelectedIndex] = useState(0);
179
+ const [error, setError] = useState<string | null>(null);
180
+
181
+ const aiToolOptions = useMemo(() => [
182
+ { label: 'Claude Code', value: AITool.ClaudeCode },
183
+ { label: 'OpenCode', value: AITool.OpenCode },
184
+ ], []);
185
+ const outputOptions = useMemo(() => getOutputOptions(), []);
186
+
187
+ const steps: SetupStep[] = ['ai_tool', 'user_mention', 'output_preference', 'confirm'];
188
+ const stepIndex = steps.indexOf(step);
189
+ const totalSteps = steps.length - 1; // Don't count confirm as a step
190
+
191
+ const handleUserMentionSubmit = () => {
192
+ if (!userMention.startsWith('@')) {
193
+ setError('Mention should start with @');
194
+ return;
195
+ }
196
+ setError(null);
197
+ setStep('output_preference');
198
+ setSelectedIndex(0);
199
+ };
200
+
201
+ // Handle escape during text input (only intercept escape, nothing else)
202
+ useInput((input, key) => {
203
+ if (key.escape) {
204
+ setStep('ai_tool');
205
+ setSelectedIndex(0);
206
+ }
207
+ }, { isActive: step === 'user_mention' });
208
+
209
+ // Handle all input for non-text-input steps
210
+ useInput((input, key) => {
211
+ if (key.escape) {
212
+ if (step === 'ai_tool') {
213
+ onSkip();
214
+ } else if (step === 'output_preference') {
215
+ setStep('user_mention');
216
+ } else if (step === 'confirm') {
217
+ setStep('output_preference');
218
+ setSelectedIndex(0);
219
+ }
220
+ return;
221
+ }
222
+
223
+ if (step === 'ai_tool') {
224
+ if (key.upArrow) setSelectedIndex((prev) => Math.max(0, prev - 1));
225
+ if (key.downArrow) setSelectedIndex((prev) => Math.min(aiToolOptions.length - 1, prev + 1));
226
+ if (key.return) {
227
+ setAITool(aiToolOptions[selectedIndex].value);
228
+ setStep('user_mention');
229
+ }
230
+ } else if (step === 'output_preference') {
231
+ if (key.upArrow) setSelectedIndex((prev) => Math.max(0, prev - 1));
232
+ if (key.downArrow) setSelectedIndex((prev) => Math.min(outputOptions.length - 1, prev + 1));
233
+ if (key.return) {
234
+ setOutputPreference(outputOptions[selectedIndex].value);
235
+ setStep('confirm');
236
+ }
237
+ } else if (step === 'confirm') {
238
+ if (key.return) {
239
+ const config: DroidConfig = {
240
+ ...loadConfig(),
241
+ ai_tool: aiTool,
242
+ user_mention: userMention,
243
+ output_preference: outputPreference,
244
+ };
245
+ saveConfig(config);
246
+ configureAIToolPermissions(aiTool);
247
+ onComplete();
248
+ }
249
+ }
250
+ }, { isActive: step !== 'user_mention' });
251
+
252
+ const renderHeader = () => (
253
+ <Box flexDirection="column" marginBottom={1}>
254
+ <Text>
255
+ <Text color={colors.textDim}>[</Text>
256
+ <Text color={colors.primary}>●</Text>
257
+ <Text color={colors.textDim}> </Text>
258
+ <Text color={colors.primary}>●</Text>
259
+ <Text color={colors.textDim}>] </Text>
260
+ <Text color={colors.text} bold>droid setup</Text>
261
+ <Text color={colors.textDim}> · Step {Math.min(stepIndex + 1, totalSteps)} of {totalSteps}</Text>
262
+ </Text>
263
+ </Box>
264
+ );
265
+
266
+ if (step === 'ai_tool') {
267
+ return (
268
+ <Box flexDirection="column" padding={1}>
269
+ {renderHeader()}
270
+ <Text color={colors.text}>Which AI tool are you using?</Text>
271
+ <Box flexDirection="column" marginTop={1}>
272
+ {aiToolOptions.map((option, index) => (
273
+ <Text key={option.value}>
274
+ <Text color={colors.textDim}>{index === selectedIndex ? '>' : ' '} </Text>
275
+ <Text color={index === selectedIndex ? colors.text : colors.textMuted}>{option.label}</Text>
276
+ {option.value === detectAITool() && <Text color={colors.success}> (detected)</Text>}
277
+ </Text>
278
+ ))}
279
+ </Box>
280
+ <Box marginTop={1}>
281
+ <Text color={colors.textDim}>↑↓ select · enter next · esc skip</Text>
282
+ </Box>
283
+ </Box>
284
+ );
285
+ }
286
+
287
+ if (step === 'user_mention') {
288
+ return (
289
+ <Box flexDirection="column" padding={1}>
290
+ {renderHeader()}
291
+ <Text color={colors.text}>What @mention should be used for you?</Text>
292
+ <Box marginTop={1}>
293
+ <Text color={colors.textDim}>{'> '}</Text>
294
+ <TextInput
295
+ value={userMention}
296
+ onChange={setUserMention}
297
+ onSubmit={handleUserMentionSubmit}
298
+ placeholder="@user"
299
+ />
300
+ </Box>
301
+ {error && (
302
+ <Box marginTop={1}>
303
+ <Text color={colors.error}>{error}</Text>
304
+ </Box>
305
+ )}
306
+ <Box marginTop={1}>
307
+ <Text color={colors.textDim}>enter next · esc back</Text>
308
+ </Box>
309
+ </Box>
310
+ );
311
+ }
312
+
313
+ if (step === 'output_preference') {
314
+ return (
315
+ <Box flexDirection="column" padding={1}>
316
+ {renderHeader()}
317
+ <Text color={colors.text}>Default output preference for skill results?</Text>
318
+ <Box flexDirection="column" marginTop={1}>
319
+ {outputOptions.map((option, index) => (
320
+ <Text key={option.value}>
321
+ <Text color={colors.textDim}>{index === selectedIndex ? '>' : ' '} </Text>
322
+ <Text color={index === selectedIndex ? colors.text : colors.textMuted}>{option.label}</Text>
323
+ </Text>
324
+ ))}
325
+ </Box>
326
+ <Box marginTop={1}>
327
+ <Text color={colors.textDim}>↑↓ select · enter next · esc back</Text>
328
+ </Box>
329
+ </Box>
330
+ );
331
+ }
332
+
333
+ // Confirm step
334
+ return (
335
+ <Box flexDirection="column" padding={1}>
336
+ {renderHeader()}
337
+ <Text color={colors.text} bold>Review your settings</Text>
338
+ <Box flexDirection="column" marginTop={1}>
339
+ <Text>
340
+ <Text color={colors.textDim}>AI Tool: </Text>
341
+ <Text color={colors.text}>{aiTool === AITool.ClaudeCode ? 'Claude Code' : 'OpenCode'}</Text>
342
+ </Text>
343
+ <Text>
344
+ <Text color={colors.textDim}>Your @mention: </Text>
345
+ <Text color={colors.text}>{userMention}</Text>
346
+ </Text>
347
+ <Text>
348
+ <Text color={colors.textDim}>Output: </Text>
349
+ <Text color={colors.text}>{outputOptions.find((o) => o.value === outputPreference)?.label || outputPreference}</Text>
350
+ </Text>
351
+ </Box>
352
+ <Box marginTop={2}>
353
+ <Text backgroundColor={colors.primary} color="#ffffff" bold>
354
+ {' '}Save{' '}
355
+ </Text>
356
+ </Box>
357
+ <Box marginTop={1}>
358
+ <Text color={colors.textDim}>enter save · esc back</Text>
359
+ </Box>
360
+ </Box>
361
+ );
362
+ }
363
+
364
+ function TabBar({ tabs, activeTab }: { tabs: { id: Tab; label: string }[]; activeTab: Tab }) {
365
+ return (
366
+ <Box flexDirection="row">
367
+ {tabs.map((tab) => (
368
+ <Text
369
+ key={tab.id}
370
+ backgroundColor={tab.id === activeTab ? colors.primary : undefined}
371
+ color={tab.id === activeTab ? '#ffffff' : colors.textMuted}
372
+ bold={tab.id === activeTab}
373
+ >
374
+ {' '}{tab.label}{' '}
375
+ </Text>
376
+ ))}
377
+ </Box>
378
+ );
379
+ }
380
+
381
+ function SkillItem({
382
+ skill,
383
+ isSelected,
384
+ isActive,
385
+ }: {
386
+ skill: SkillManifest;
387
+ isSelected: boolean;
388
+ isActive: boolean;
389
+ }) {
390
+ const installed = isSkillInstalled(skill.name);
391
+ const installedInfo = getInstalledSkill(skill.name);
392
+
393
+ return (
394
+ <Box paddingX={1} backgroundColor={isActive ? colors.bgSelected : undefined}>
395
+ <Text>
396
+ <Text color={colors.textDim}>{isSelected ? '>' : ' '} </Text>
397
+ <Text color={isSelected || isActive ? colors.text : colors.textMuted}>{skill.name}</Text>
398
+ {installed && installedInfo && <Text color={colors.textDim}> v{installedInfo.version}</Text>}
399
+ {installed && <Text color={colors.success}> *</Text>}
400
+ </Text>
401
+ </Box>
402
+ );
403
+ }
404
+
405
+ function CommandItem({
406
+ command,
407
+ isSelected,
408
+ }: {
409
+ command: Command;
410
+ isSelected: boolean;
411
+ }) {
412
+ const installed = isSkillInstalled(command.skillName);
413
+
414
+ return (
415
+ <Box paddingX={1}>
416
+ <Text>
417
+ <Text color={colors.textDim}>{isSelected ? '>' : ' '} </Text>
418
+ <Text color={isSelected ? colors.text : colors.textMuted}>/{command.name}</Text>
419
+ {installed && <Text color={colors.success}> *</Text>}
420
+ </Text>
421
+ </Box>
422
+ );
423
+ }
424
+
425
+ function SkillDetails({
426
+ skill,
427
+ isFocused,
428
+ selectedAction,
429
+ }: {
430
+ skill: SkillManifest | null;
431
+ isFocused: boolean;
432
+ selectedAction: number;
433
+ }) {
434
+ if (!skill) {
435
+ return (
436
+ <Box paddingLeft={2} flexGrow={1}>
437
+ <Text color={colors.textDim}>Select a skill</Text>
438
+ </Box>
439
+ );
440
+ }
441
+
442
+ const installed = isSkillInstalled(skill.name);
443
+ const skillCommands = getCommandsFromSkills().filter((c) => c.skillName === skill.name);
444
+
445
+ const actions = installed
446
+ ? [
447
+ { id: 'uninstall', label: 'Uninstall', variant: 'danger' },
448
+ { id: 'configure', label: 'Configure', variant: 'primary' },
449
+ ]
450
+ : [{ id: 'install', label: 'Install', variant: 'primary' }];
451
+
452
+ return (
453
+ <Box flexDirection="column" paddingLeft={2} flexGrow={1}>
454
+ <Text color={colors.text} bold>{skill.name}</Text>
455
+
456
+ <Box marginTop={1}>
457
+ <Text color={colors.textDim}>
458
+ {skill.version}
459
+ {skill.status && ` · ${skill.status}`}
460
+ {installed && <Text color={colors.success}> · installed</Text>}
461
+ </Text>
462
+ </Box>
463
+
464
+ <Box marginTop={1}>
465
+ <Text color={colors.textMuted}>{skill.description}</Text>
466
+ </Box>
467
+
468
+ {skillCommands.length > 0 && (
469
+ <Box marginTop={1}>
470
+ <Text color={colors.textDim}>
471
+ Commands: {skillCommands.map((c) => `/${c.name}`).join(', ')}
472
+ </Text>
473
+ </Box>
474
+ )}
475
+
476
+ {skill.examples && skill.examples.length > 0 && (
477
+ <Box flexDirection="column" marginTop={1}>
478
+ <Text color={colors.textDim}>
479
+ Examples{skill.examples.length > 2 ? ` (showing 2 of ${skill.examples.length})` : ''}:
480
+ </Text>
481
+ {skill.examples.slice(0, 2).map((example, i) => (
482
+ <Box key={i} flexDirection="column" marginTop={i > 0 ? 1 : 0}>
483
+ <Text color={colors.textMuted}> {example.title}</Text>
484
+ {example.code
485
+ .trim()
486
+ .split('\n')
487
+ .slice(0, 3)
488
+ .map((line, j) => (
489
+ <Text key={j} color={colors.textDim}>
490
+ {' '}{line}
491
+ </Text>
492
+ ))}
493
+ </Box>
494
+ ))}
495
+ </Box>
496
+ )}
497
+
498
+ {isFocused && (
499
+ <Box flexDirection="row" marginTop={1}>
500
+ {actions.map((action, index) => (
501
+ <Text
502
+ key={action.id}
503
+ backgroundColor={
504
+ selectedAction === index
505
+ ? action.variant === 'danger'
506
+ ? colors.error
507
+ : colors.primary
508
+ : colors.bgSelected
509
+ }
510
+ color={selectedAction === index ? '#ffffff' : colors.textMuted}
511
+ bold={selectedAction === index}
512
+ >
513
+ {' '}{action.label}{' '}
514
+ </Text>
515
+ ))}
516
+ </Box>
517
+ )}
518
+ </Box>
519
+ );
520
+ }
521
+
522
+ function CommandDetails({ command }: { command: Command | null }) {
523
+ if (!command) {
524
+ return (
525
+ <Box paddingLeft={2} flexGrow={1}>
526
+ <Text color={colors.textDim}>Select a command</Text>
527
+ </Box>
528
+ );
529
+ }
530
+
531
+ const installed = isSkillInstalled(command.skillName);
532
+
533
+ return (
534
+ <Box flexDirection="column" paddingLeft={2} flexGrow={1}>
535
+ <Text color={colors.text} bold>/{command.name}</Text>
536
+
537
+ <Box marginTop={1}>
538
+ <Text color={colors.textDim}>
539
+ from {command.skillName}
540
+ {installed && <Text color={colors.success}> · installed</Text>}
541
+ </Text>
542
+ </Box>
543
+
544
+ <Box marginTop={1}>
545
+ <Text color={colors.textMuted}>{command.description}</Text>
546
+ </Box>
547
+
548
+ {command.usage.length > 0 && (
549
+ <Box flexDirection="column" marginTop={1}>
550
+ <Text color={colors.textDim}>Usage:</Text>
551
+ {command.usage.map((u, i) => (
552
+ <Text key={i} color={colors.textMuted}>
553
+ {' '}{u}
554
+ </Text>
555
+ ))}
556
+ </Box>
557
+ )}
558
+ </Box>
559
+ );
560
+ }
561
+
562
+ function SettingsDetails({
563
+ onEditSettings,
564
+ isFocused,
565
+ }: {
566
+ onEditSettings: () => void;
567
+ isFocused: boolean;
568
+ }) {
569
+ const config = loadConfig();
570
+ const outputOptions = getOutputOptions();
571
+
572
+ return (
573
+ <Box flexDirection="column" paddingLeft={2} flexGrow={1}>
574
+ <Text color={colors.text} bold>Settings</Text>
575
+
576
+ <Box flexDirection="column" marginTop={1}>
577
+ <Text>
578
+ <Text color={colors.textDim}>AI Tool: </Text>
579
+ <Text color={colors.text}>
580
+ {config.ai_tool === AITool.ClaudeCode ? 'Claude Code' : 'OpenCode'}
581
+ </Text>
582
+ </Text>
583
+ <Text>
584
+ <Text color={colors.textDim}>Your @mention: </Text>
585
+ <Text color={colors.text}>{config.user_mention}</Text>
586
+ </Text>
587
+ <Text>
588
+ <Text color={colors.textDim}>Output: </Text>
589
+ <Text color={colors.text}>
590
+ {outputOptions.find((o) => o.value === config.output_preference)?.label || config.output_preference}
591
+ </Text>
592
+ </Text>
593
+ </Box>
594
+
595
+ <Box marginTop={1}>
596
+ <Text color={colors.textDim}>Config: ~/.droid/config.yaml</Text>
597
+ </Box>
598
+
599
+ {isFocused && (
600
+ <Box marginTop={2}>
601
+ <Text backgroundColor={colors.primary} color="#ffffff" bold>
602
+ {' '}Edit Settings{' '}
603
+ </Text>
604
+ </Box>
605
+ )}
606
+
607
+ {!isFocused && (
608
+ <Box marginTop={2}>
609
+ <Text color={colors.textDim}>press enter to edit</Text>
610
+ </Box>
611
+ )}
612
+ </Box>
613
+ );
614
+ }
615
+
616
+ interface SkillConfigScreenProps {
617
+ skill: SkillManifest;
618
+ onComplete: () => void;
619
+ onCancel: () => void;
620
+ }
621
+
622
+ function SkillConfigScreen({ skill, onComplete, onCancel }: SkillConfigScreenProps) {
623
+ const configSchema = skill.config_schema || {};
624
+ const configKeys = Object.keys(configSchema);
625
+
626
+ const initialOverrides = useMemo(() => loadSkillOverrides(skill.name), [skill.name]);
627
+
628
+ // Initialize values from saved overrides or defaults
629
+ const [values, setValues] = useState<SkillOverrides>(() => {
630
+ const initial: SkillOverrides = {};
631
+ for (const key of configKeys) {
632
+ const option = configSchema[key];
633
+ initial[key] = initialOverrides[key] ?? option.default ?? (option.type === ConfigOptionType.Boolean ? false : '');
634
+ }
635
+ return initial;
636
+ });
637
+
638
+ const [selectedIndex, setSelectedIndex] = useState(0);
639
+ const [editingField, setEditingField] = useState<string | null>(null);
640
+ const [editValue, setEditValue] = useState('');
641
+
642
+ const handleSave = () => {
643
+ saveSkillOverrides(skill.name, values);
644
+ onComplete();
645
+ };
646
+
647
+ const handleSubmitEdit = () => {
648
+ if (editingField) {
649
+ setValues((prev) => ({ ...prev, [editingField]: editValue }));
650
+ setEditingField(null);
651
+ setEditValue('');
652
+ }
653
+ };
654
+
655
+ // Handle text input for string fields
656
+ useInput((input, key) => {
657
+ if (key.escape) {
658
+ setEditingField(null);
659
+ setEditValue('');
660
+ }
661
+ }, { isActive: editingField !== null });
662
+
663
+ // Handle navigation and actions when not editing
664
+ useInput((input, key) => {
665
+ if (key.escape) {
666
+ onCancel();
667
+ return;
668
+ }
669
+
670
+ if (key.upArrow) {
671
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
672
+ }
673
+ if (key.downArrow) {
674
+ // +1 for the Save button at the end
675
+ setSelectedIndex((prev) => Math.min(configKeys.length, prev + 1));
676
+ }
677
+
678
+ if (key.return) {
679
+ // Save button is at index === configKeys.length
680
+ if (selectedIndex === configKeys.length) {
681
+ handleSave();
682
+ return;
683
+ }
684
+
685
+ const key = configKeys[selectedIndex];
686
+ const option = configSchema[key];
687
+
688
+ if (option.type === ConfigOptionType.Boolean) {
689
+ // Toggle boolean
690
+ setValues((prev) => ({ ...prev, [key]: !prev[key] }));
691
+ } else if (option.type === ConfigOptionType.String) {
692
+ // Enter edit mode for string
693
+ setEditingField(key);
694
+ setEditValue(String(values[key] || ''));
695
+ }
696
+ }
697
+ }, { isActive: editingField === null });
698
+
699
+ if (configKeys.length === 0) {
700
+ return (
701
+ <Box flexDirection="column" padding={1}>
702
+ <Box marginBottom={1}>
703
+ <Text>
704
+ <Text color={colors.textDim}>[</Text>
705
+ <Text color={colors.primary}>●</Text>
706
+ <Text color={colors.textDim}> </Text>
707
+ <Text color={colors.primary}>●</Text>
708
+ <Text color={colors.textDim}>] </Text>
709
+ <Text color={colors.text} bold>configure {skill.name}</Text>
710
+ </Text>
711
+ </Box>
712
+ <Text color={colors.textMuted}>This skill has no configuration options.</Text>
713
+ <Box marginTop={1}>
714
+ <Text color={colors.textDim}>esc to go back</Text>
715
+ </Box>
716
+ </Box>
717
+ );
718
+ }
719
+
720
+ return (
721
+ <Box flexDirection="column" padding={1}>
722
+ <Box marginBottom={1}>
723
+ <Text>
724
+ <Text color={colors.textDim}>[</Text>
725
+ <Text color={colors.primary}>●</Text>
726
+ <Text color={colors.textDim}> </Text>
727
+ <Text color={colors.primary}>●</Text>
728
+ <Text color={colors.textDim}>] </Text>
729
+ <Text color={colors.text} bold>configure {skill.name}</Text>
730
+ </Text>
731
+ </Box>
732
+
733
+ <Box flexDirection="column">
734
+ {configKeys.map((key, index) => {
735
+ const option = configSchema[key];
736
+ const isSelected = selectedIndex === index;
737
+ const isEditing = editingField === key;
738
+
739
+ return (
740
+ <Box key={key} flexDirection="column" marginBottom={1}>
741
+ <Text>
742
+ <Text color={colors.textDim}>{isSelected ? '>' : ' '} </Text>
743
+ <Text color={isSelected ? colors.text : colors.textMuted}>{key}</Text>
744
+ </Text>
745
+ <Text color={colors.textDim}> {option.description}</Text>
746
+ <Box>
747
+ <Text color={colors.textDim}> </Text>
748
+ {option.type === ConfigOptionType.Boolean ? (
749
+ <Text color={colors.text}>
750
+ [{values[key] ? 'x' : ' '}] {values[key] ? 'enabled' : 'disabled'}
751
+ </Text>
752
+ ) : isEditing ? (
753
+ <Box>
754
+ <Text color={colors.textDim}>{'> '}</Text>
755
+ <TextInput
756
+ value={editValue}
757
+ onChange={setEditValue}
758
+ onSubmit={handleSubmitEdit}
759
+ />
760
+ </Box>
761
+ ) : (
762
+ <Text color={colors.text}>{String(values[key]) || '(not set)'}</Text>
763
+ )}
764
+ </Box>
765
+ </Box>
766
+ );
767
+ })}
768
+
769
+ {/* Save button */}
770
+ <Box marginTop={1}>
771
+ <Text>
772
+ <Text color={colors.textDim}>{selectedIndex === configKeys.length ? '>' : ' '} </Text>
773
+ <Text
774
+ backgroundColor={selectedIndex === configKeys.length ? colors.primary : undefined}
775
+ color={selectedIndex === configKeys.length ? '#ffffff' : colors.textMuted}
776
+ bold={selectedIndex === configKeys.length}
777
+ >
778
+ {' '}Save{' '}
779
+ </Text>
780
+ </Text>
781
+ </Box>
782
+ </Box>
783
+
784
+ <Box marginTop={1}>
785
+ <Text color={colors.textDim}>
786
+ {editingField ? 'enter save · esc cancel' : '↑↓ select · enter toggle/edit · esc back'}
787
+ </Text>
788
+ </Box>
789
+ </Box>
790
+ );
791
+ }
792
+
793
+ function App() {
794
+ const { exit } = useApp();
795
+ const tabs: { id: Tab; label: string }[] = [
796
+ { id: 'skills', label: 'Skills' },
797
+ { id: 'commands', label: 'Commands' },
798
+ { id: 'agents', label: 'Agents' },
799
+ { id: 'settings', label: 'Settings' },
800
+ ];
801
+
802
+ const [activeTab, setActiveTab] = useState<Tab>('skills');
803
+ const [tabIndex, setTabIndex] = useState(0);
804
+ const [view, setView] = useState<View>('welcome');
805
+ const [selectedIndex, setSelectedIndex] = useState(0);
806
+ const [selectedAction, setSelectedAction] = useState(0);
807
+ const [scrollOffset, setScrollOffset] = useState(0);
808
+ const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
809
+ const [isEditingSettings, setIsEditingSettings] = useState(false);
810
+
811
+ const MAX_VISIBLE_ITEMS = 6;
812
+
813
+ const skills = getBundledSkills();
814
+ const commands = getCommandsFromSkills();
815
+
816
+ useInput((input, key) => {
817
+ if (message) setMessage(null);
818
+
819
+ if (view === 'welcome') return;
820
+
821
+ if (input === 'q') {
822
+ exit();
823
+ return;
824
+ }
825
+
826
+ if (view === 'menu') {
827
+ if (key.leftArrow) {
828
+ const newIndex = Math.max(0, tabIndex - 1);
829
+ setTabIndex(newIndex);
830
+ setActiveTab(tabs[newIndex].id);
831
+ setSelectedIndex(0);
832
+ setScrollOffset(0);
833
+ }
834
+ if (key.rightArrow) {
835
+ const newIndex = Math.min(tabs.length - 1, tabIndex + 1);
836
+ setTabIndex(newIndex);
837
+ setActiveTab(tabs[newIndex].id);
838
+ setSelectedIndex(0);
839
+ setScrollOffset(0);
840
+ }
841
+ if (key.upArrow) {
842
+ setSelectedIndex((prev) => {
843
+ const newIndex = Math.max(0, prev - 1);
844
+ // Scroll up if needed
845
+ if (newIndex < scrollOffset) {
846
+ setScrollOffset(newIndex);
847
+ }
848
+ return newIndex;
849
+ });
850
+ setSelectedAction(0);
851
+ }
852
+ if (key.downArrow) {
853
+ const maxIndex =
854
+ activeTab === 'skills' ? skills.length - 1 : activeTab === 'commands' ? commands.length - 1 : 0;
855
+ setSelectedIndex((prev) => {
856
+ const newIndex = Math.min(maxIndex, prev + 1);
857
+ // Scroll down if needed
858
+ if (newIndex >= scrollOffset + MAX_VISIBLE_ITEMS) {
859
+ setScrollOffset(newIndex - MAX_VISIBLE_ITEMS + 1);
860
+ }
861
+ return newIndex;
862
+ });
863
+ setSelectedAction(0);
864
+ }
865
+ if (key.return) {
866
+ if (activeTab === 'skills' && skills.length > 0) {
867
+ setView('detail');
868
+ } else if (activeTab === 'settings') {
869
+ setIsEditingSettings(true);
870
+ setView('setup');
871
+ }
872
+ }
873
+ } else if (view === 'detail') {
874
+ if (key.escape || key.backspace) {
875
+ setView('menu');
876
+ setSelectedAction(0);
877
+ }
878
+ if (key.leftArrow) {
879
+ setSelectedAction((prev) => Math.max(0, prev - 1));
880
+ }
881
+ if (key.rightArrow) {
882
+ const skill = skills[selectedIndex];
883
+ const installed = skill ? isSkillInstalled(skill.name) : false;
884
+ const maxActions = installed ? 1 : 0;
885
+ setSelectedAction((prev) => Math.min(maxActions, prev + 1));
886
+ }
887
+ if (key.return && activeTab === 'skills') {
888
+ const skill = skills[selectedIndex];
889
+ if (skill) {
890
+ const installed = isSkillInstalled(skill.name);
891
+ if (installed && selectedAction === 0) {
892
+ const result = uninstallSkill(skill.name);
893
+ setMessage({
894
+ text: result.success ? `✓ Uninstalled ${skill.name}` : `✗ ${result.message}`,
895
+ type: result.success ? 'success' : 'error',
896
+ });
897
+ if (result.success) {
898
+ setView('menu');
899
+ setSelectedAction(0);
900
+ }
901
+ } else if (installed && selectedAction === 1) {
902
+ // Configure
903
+ setView('configure');
904
+ } else if (!installed) {
905
+ const result = installSkill(skill.name);
906
+ setMessage({
907
+ text: result.success ? `✓ Installed ${skill.name}` : `✗ ${result.message}`,
908
+ type: result.success ? 'success' : 'error',
909
+ });
910
+ if (result.success) {
911
+ setView('menu');
912
+ setSelectedAction(0);
913
+ }
914
+ }
915
+ }
916
+ }
917
+ }
918
+ });
919
+
920
+ const selectedSkill = activeTab === 'skills' ? skills[selectedIndex] ?? null : null;
921
+ const selectedCommand = activeTab === 'commands' ? commands[selectedIndex] ?? null : null;
922
+
923
+ if (view === 'welcome') {
924
+ return (
925
+ <WelcomeScreen
926
+ onContinue={() => {
927
+ // If no config exists, show setup first
928
+ if (!configExists()) {
929
+ setView('setup');
930
+ } else {
931
+ setView('menu');
932
+ }
933
+ }}
934
+ />
935
+ );
936
+ }
937
+
938
+ if (view === 'setup') {
939
+ return (
940
+ <SetupScreen
941
+ onComplete={() => {
942
+ setIsEditingSettings(false);
943
+ setView('menu');
944
+ }}
945
+ onSkip={() => {
946
+ setIsEditingSettings(false);
947
+ setView('menu');
948
+ }}
949
+ initialConfig={isEditingSettings ? loadConfig() : undefined}
950
+ />
951
+ );
952
+ }
953
+
954
+ if (view === 'configure' && selectedSkill) {
955
+ return (
956
+ <SkillConfigScreen
957
+ skill={selectedSkill}
958
+ onComplete={() => {
959
+ setMessage({ text: `✓ Configuration saved for ${selectedSkill.name}`, type: 'success' });
960
+ setView('detail');
961
+ }}
962
+ onCancel={() => {
963
+ setView('detail');
964
+ }}
965
+ />
966
+ );
967
+ }
968
+
969
+ return (
970
+ <Box flexDirection="row" padding={1}>
971
+ {/* Left column */}
972
+ <Box
973
+ flexDirection="column"
974
+ width={44}
975
+ borderStyle="single"
976
+ borderColor={colors.border}
977
+ >
978
+ {/* Header */}
979
+ <Box paddingX={1}>
980
+ <Text>
981
+ <Text color={colors.textDim}>[</Text>
982
+ <Text color={colors.primary}>●</Text>
983
+ <Text color={colors.textDim}> </Text>
984
+ <Text color={colors.primary}>●</Text>
985
+ <Text color={colors.textDim}>] </Text>
986
+ <Text color={colors.textMuted}>droid</Text>
987
+ </Text>
988
+ </Box>
989
+
990
+ {/* Tabs */}
991
+ <Box paddingX={1} marginTop={1}>
992
+ <TabBar tabs={tabs} activeTab={activeTab} />
993
+ </Box>
994
+
995
+ {/* List */}
996
+ <Box flexDirection="column" marginTop={1}>
997
+ {activeTab === 'skills' && (
998
+ <>
999
+ {scrollOffset > 0 && (
1000
+ <Box paddingX={1}>
1001
+ <Text color={colors.textDim}>↑ {scrollOffset} more</Text>
1002
+ </Box>
1003
+ )}
1004
+ {skills.slice(scrollOffset, scrollOffset + MAX_VISIBLE_ITEMS).map((skill, index) => (
1005
+ <SkillItem
1006
+ key={skill.name}
1007
+ skill={skill}
1008
+ isSelected={scrollOffset + index === selectedIndex}
1009
+ isActive={scrollOffset + index === selectedIndex && view === 'detail'}
1010
+ />
1011
+ ))}
1012
+ {scrollOffset + MAX_VISIBLE_ITEMS < skills.length && (
1013
+ <Box paddingX={1}>
1014
+ <Text color={colors.textDim}>↓ {skills.length - scrollOffset - MAX_VISIBLE_ITEMS} more</Text>
1015
+ </Box>
1016
+ )}
1017
+ {skills.length > MAX_VISIBLE_ITEMS && (
1018
+ <Box paddingX={1} marginTop={1}>
1019
+ <Text color={colors.textDim}>{skills.length} skills total</Text>
1020
+ </Box>
1021
+ )}
1022
+ </>
1023
+ )}
1024
+
1025
+ {activeTab === 'commands' && (
1026
+ <>
1027
+ {scrollOffset > 0 && (
1028
+ <Box paddingX={1}>
1029
+ <Text color={colors.textDim}>↑ {scrollOffset} more</Text>
1030
+ </Box>
1031
+ )}
1032
+ {commands.slice(scrollOffset, scrollOffset + MAX_VISIBLE_ITEMS).map((cmd, index) => (
1033
+ <CommandItem key={cmd.name} command={cmd} isSelected={scrollOffset + index === selectedIndex} />
1034
+ ))}
1035
+ {scrollOffset + MAX_VISIBLE_ITEMS < commands.length && (
1036
+ <Box paddingX={1}>
1037
+ <Text color={colors.textDim}>↓ {commands.length - scrollOffset - MAX_VISIBLE_ITEMS} more</Text>
1038
+ </Box>
1039
+ )}
1040
+ </>
1041
+ )}
1042
+
1043
+ {activeTab === 'agents' && (
1044
+ <Box paddingX={1}>
1045
+ <Text color={colors.textDim}>Coming soon</Text>
1046
+ </Box>
1047
+ )}
1048
+
1049
+ {activeTab === 'settings' && (
1050
+ <Box paddingX={1}>
1051
+ <Text color={colors.textDim}>View and edit config</Text>
1052
+ </Box>
1053
+ )}
1054
+ </Box>
1055
+
1056
+ {/* Footer */}
1057
+ <Box paddingX={1} marginTop={1}>
1058
+ <Text color={colors.textDim}>
1059
+ {view === 'menu' ? '←→ ↑↓ enter q' : '←→ enter esc q'}
1060
+ </Text>
1061
+ </Box>
1062
+ </Box>
1063
+
1064
+ {/* Right column */}
1065
+ {activeTab === 'skills' && (
1066
+ <SkillDetails skill={selectedSkill} isFocused={view === 'detail'} selectedAction={selectedAction} />
1067
+ )}
1068
+
1069
+ {activeTab === 'commands' && <CommandDetails command={selectedCommand} />}
1070
+
1071
+ {activeTab === 'settings' && (
1072
+ <SettingsDetails onEditSettings={() => setView('setup')} isFocused={false} />
1073
+ )}
1074
+
1075
+ {activeTab === 'agents' && (
1076
+ <Box paddingLeft={2} flexGrow={1}>
1077
+ <Text color={colors.textDim}>Coming soon</Text>
1078
+ </Box>
1079
+ )}
1080
+
1081
+ {/* Message */}
1082
+ {message && (
1083
+ <Box position="absolute" marginTop={12}>
1084
+ <Text color={message.type === 'success' ? colors.success : colors.error}>{message.text}</Text>
1085
+ </Box>
1086
+ )}
1087
+ </Box>
1088
+ );
1089
+ }
1090
+
1091
+ export async function tuiCommand(): Promise<void> {
1092
+ // Enter alternate screen (fullscreen mode)
1093
+ process.stdout.write('\x1b[?1049h');
1094
+ process.stdout.write('\x1b[H'); // Move cursor to top-left
1095
+
1096
+ const { unmount, waitUntilExit } = render(<App />);
1097
+
1098
+ await waitUntilExit();
1099
+
1100
+ // Leave alternate screen
1101
+ process.stdout.write('\x1b[?1049l');
1102
+ }