Skip to content

Commit 12ed10d

Browse files
authored
refactor(tui): migrate to OpenTUI/Solid.js framework (#32)
* refactor(tui): migrate to OpenTUI/Solid.js framework Complete TUI rewrite from Ink/React to OpenTUI/Solid.js: - Replace React with Solid.js for reactive state management - Migrate all 20 screens to OpenTUI component primitives - Add monochromatic design with 14 color themes - Implement cinematic splash screen with ASCII animation - Add vi-key navigation (j/k/h/l) throughout - Create 32 reusable components with consistent styling - Add service layer for @skillkit/core API integration - Remove deprecated react-opentui.d.ts type definitions - Update build config for Solid.js JSX transform Features covered: - Browse, Marketplace, Recommend (discovery) - Installed, Sync, Translate (management) - Workflow, Execute, Plan, History (execution) - Team, Context, Memory, Mesh, Message (collaboration) - Plugins, Methodology, Settings (configuration) - Help screen with 25+ keyboard shortcuts * fix(tui): address CodeRabbit review feedback Critical fixes: - Remove non-existent module exports from index.tsx (context, themes, ui, hooks, services) - Simplify App.tsx to remove missing context provider imports Major fixes: - ProgressBar: Add defensive guards for width/progress (NaN, Infinity, negative) - Browse: Fix filtered index loading wrong repository - Context: Add section navigation (h/l keys) and async error handling - Methodology: Add error handling with try/catch/finally for all async ops - Plan: Fix division by zero when plan has no tasks, add error handling - Team: Add error handling for loadTeamConfig and handleInitialize - Translate: Guard against missing skill.path before read operations * fix(tui): address remaining CodeRabbit feedback - Memory: Add error handling for loadData, handleDelete, handleClearAll, handleInitialize - Team: Fix key conflict by blocking global keys when detail pane is open - animations: Fix springCurve to return 0 at progress=0 and 1 at progress=1 * fix(tui): add Solid.js OpenTUI type definitions Required for TypeScript to recognize OpenTUI JSX elements (box, text, span) with their custom props (fg, bg, flexDirection, etc.) * fix(tui): add stub services and fix type errors for CI - Add minimal stub service implementations for all TUI screens: - context.service.ts - project context loading - executor.service.ts - skill execution - memory.service.ts - memory management - methodology.service.ts - methodology packs - plan.service.ts - plan management - plugin.service.ts - plugin management - recommend.service.ts - skill recommendations - team.service.ts - team collaboration - translator.service.ts - skill translation - workflow.service.ts - workflow orchestration - Fix type errors in screens: - Plan.tsx: Add non-null assertions for optional issues array - Plugins.tsx: Cast getPluginInfo result to Plugin type These stubs provide the expected interfaces for screens while the full integration with @skillkit/core is completed later. * fix(tui): add missing component files for CI Add the component files that screens depend on: - StatusIndicator - loading/success/error status display - DetailPane - side panel for detailed view - EmptyState/ErrorState - empty and error state components - SelectList - selection list component - SplitPane - split view layout - AnimatedText - text animations - Breadcrumb - navigation breadcrumb - Button/Clickable - interactive elements - CodeBlock - code display - FormField - form input wrapper - HoverHighlight - hover effect wrapper - RightSidebar/BottomStatusBar - layout components - TabBar - tab navigation - ErrorBoundary - error handling wrapper These components are required by the screen files and were missing from the previous commit. * fix(tui): resolve context import and duplicate export issues - RightSidebar: inline sidebar state instead of importing from context - services/index.ts: fix duplicate AgentType export by using explicit exports for executor and translator services * fix(tui): use export type for type-only re-exports * refactor(tui): simplify components and remove redundant exports * refactor(tui): remove unused imports and variables Code simplification pass: - Remove unused onCleanup import from Workflow, Recommend, Sync screens - Remove unused contentWidth from Browse, Recommend, Marketplace, Sync - Remove unused cols from Browse, Recommend - Remove unused JSX import from EmptyState component - Remove unused translate import from Sync screen - Remove unused SelectList import and listItems function from Memory screen * fix(tui): wire up interactive handlers and fix type definitions - AnimatedText: fix reactive dependency tracking in createEffect - BottomStatusBar: remove unused width prop - Breadcrumb: wire onNavigate handlers, fix off-by-one in getPathAtIndex - Button: wire onClick with disabled check, apply paddingX, fix ButtonGroup spacing - Clickable: wire click/double-click/right-click handlers, fix InteractiveArea - DetailPane: make close button interactive - ErrorBoundary: use terminal keyboard handler, prevent duplicate onError calls - FormField: fix placeholder show condition for empty string - SelectList: add onSelect/onHover interactive behavior - SplitPane: preserve string sizes like "50%" in width/height helpers - TabBar: wire onSelect callbacks for both horizontal and vertical variants - solid-opentui.d.ts: add missing event handler types to BoxProps/TextProps * fix(tui): address Devin review issues - App: restore process.exit(0) fallback when onExit is undefined - Execute: pass skill path instead of content to executeSkillWithAgent - Memory: add useKeyboard handler for j/k/Enter/d/c/i/Esc shortcuts * fix(tui): address Devin and CodeRabbit review issues Security & Robustness: - App: use execFile instead of exec to prevent command injection in openUrl - App: clamp cols/rows to minimum 1 to prevent negative dimensions - index: await render() and handle failures with exitTUI(1) Execute screen: - Add error state for load failures - Prevent re-entrant execution with executing() guard - Use try/finally to always clear executing state Memory screen: - Add missing 'r' shortcut for retry/refresh Workflow screen: - Use try/finally to always clear executing state even on rejection Sync screen: - Capture selectedIndex and agent values before await to prevent drift * fix(tui): implement list windowing to keep selection visible Execute, Marketplace, Recommend screens now use windowed list rendering that follows the selection, preventing the selected item from being off-screen when navigating through long lists. - Add createMemo for computing visible window based on selectedIndex - Show ▲/▼ indicators when items are above/below the visible window - Navigate through full list instead of clamping to visible window size * fix(tui): add error handling and index clamping Marketplace.tsx: - Set error signal when all repos fail or no skills loaded - Add reactive effect to clamp selectedIndex when filteredSkills changes Recommend.tsx: - Wrap loadData in try/catch to surface errors in UI - Clamp selectedIndex after recommendations update * fix(tui): add error handling to Plugins and Workflow screens Plugins.tsx: - Wrap loadData in try/catch to prevent stuck loading state - Wrap handleToggle in try/catch/finally to always reset toggling flag - Wrap handleShowDetail in try/catch to surface errors gracefully Workflow.tsx: - Wrap loadData in try/catch to prevent stuck loading state - Add catch block to handleExecute to show execution errors in UI
1 parent eebaa71 commit 12ed10d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+8915
-1922
lines changed

packages/tui/package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@
2121
"test": "vitest run"
2222
},
2323
"dependencies": {
24-
"@skillkit/core": "workspace:*",
25-
"@skillkit/agents": "workspace:*",
2624
"@opentui/core": "^0.1.75",
27-
"@opentui/react": "^0.1.75",
28-
"react": "^19.0.0"
25+
"@opentui/solid": "^0.1.75",
26+
"@skillkit/agents": "workspace:*",
27+
"@skillkit/core": "workspace:*",
28+
"solid-js": "^1.9.0"
2929
},
3030
"devDependencies": {
3131
"@types/node": "^22.10.5",
32-
"@types/react": "^19.0.0",
32+
"esbuild-plugin-solid": "^0.6.0",
3333
"tsup": "^8.3.5",
3434
"typescript": "^5.7.2",
3535
"vitest": "^2.1.8"

packages/tui/src/App.tsx

Lines changed: 89 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { useState, useCallback, useEffect } from 'react';
2-
import { useKeyboard } from '@opentui/react';
3-
import { exec } from 'node:child_process';
1+
import { createSignal, createEffect, onCleanup, Show, Switch, Match } from 'solid-js';
2+
import { useKeyboard } from '@opentui/solid';
3+
import { execFile } from 'node:child_process';
44
import { type Screen, NAV_KEYS } from './state/types.js';
5-
import { Sidebar } from './components/Sidebar.js';
65
import { Splash } from './components/Splash.js';
6+
import { Sidebar } from './components/Sidebar.js';
7+
import { StatusBar } from './components/StatusBar.js';
78
import {
89
Home, Browse, Installed, Marketplace, Settings, Recommend,
910
Translate, Context, Memory, Team, Plugins, Methodology,
@@ -13,52 +14,67 @@ import {
1314
const DOCS_URL = 'https://agenstskills.com/docs';
1415

1516
function openUrl(url: string): void {
16-
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
17-
exec(`${cmd} ${url}`);
17+
if (process.platform === 'win32') {
18+
execFile('cmd', ['/c', 'start', '', url]);
19+
} else {
20+
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
21+
execFile(cmd, [url]);
22+
}
1823
}
1924

2025
interface AppProps {
2126
onExit?: (code?: number) => void;
2227
}
2328

24-
export function App({ onExit }: AppProps = {}) {
25-
const [showSplash, setShowSplash] = useState(true);
26-
const [screen, setScreen] = useState<Screen>('home');
27-
const [dimensions, setDimensions] = useState({
29+
export function App(props: AppProps) {
30+
const [showSplash, setShowSplash] = createSignal(true);
31+
const [currentScreen, setCurrentScreen] = createSignal<Screen>('home');
32+
const [showSidebar, setShowSidebar] = createSignal(true);
33+
const [dimensions, setDimensions] = createSignal({
2834
cols: process.stdout.columns || 80,
2935
rows: process.stdout.rows || 24,
3036
});
3137

32-
useEffect(() => {
38+
createEffect(() => {
3339
const handleResize = () => {
3440
setDimensions({
3541
cols: process.stdout.columns || 80,
3642
rows: process.stdout.rows || 24,
3743
});
3844
};
3945
process.stdout.on('resize', handleResize);
40-
return () => { process.stdout.off('resize', handleResize); };
41-
}, []);
46+
onCleanup(() => {
47+
process.stdout.off('resize', handleResize);
48+
});
49+
});
4250

43-
const { cols, rows } = dimensions;
44-
const showSidebar = cols >= 60;
51+
const cols = () => dimensions().cols;
52+
const rows = () => dimensions().rows;
53+
const sidebarVisible = () => showSidebar() && cols() >= 80;
54+
const sidebarWidth = () => {
55+
if (!sidebarVisible()) return 0;
56+
if (cols() >= 100) return 24;
57+
return 18;
58+
};
59+
const statusBarHeight = 2;
60+
const contentHeight = () => rows() - statusBarHeight;
4561

46-
const handleNavigate = useCallback((newScreen: Screen) => {
47-
setScreen(newScreen);
48-
}, []);
62+
const handleNavigate = (newScreen: Screen) => {
63+
setCurrentScreen(newScreen);
64+
};
4965

50-
const handleSplashComplete = useCallback(() => {
66+
const handleSplashComplete = () => {
5167
setShowSplash(false);
52-
}, []);
68+
};
5369

54-
useKeyboard((key: { name?: string; ctrl?: boolean }) => {
55-
if (showSplash) {
70+
useKeyboard((key: { name?: string; ctrl?: boolean; sequence?: string }) => {
71+
if (showSplash()) {
5672
setShowSplash(false);
5773
return;
5874
}
5975

6076
if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
61-
onExit ? onExit(0) : process.exit(0);
77+
props.onExit ? props.onExit(0) : process.exit(0);
6278
return;
6379
}
6480

@@ -67,59 +83,67 @@ export function App({ onExit }: AppProps = {}) {
6783
return;
6884
}
6985

86+
if (key.sequence === '\\') {
87+
setShowSidebar((v) => !v);
88+
return;
89+
}
90+
7091
const targetScreen = NAV_KEYS[key.name || ''];
7192
if (targetScreen) {
72-
setScreen(targetScreen);
93+
setCurrentScreen(targetScreen);
7394
}
7495

75-
if (key.name === 'escape' && screen !== 'home') {
76-
setScreen('home');
96+
if (key.name === 'escape' && currentScreen() !== 'home') {
97+
setCurrentScreen('home');
7798
}
7899
});
79100

80-
if (showSplash) {
81-
return <Splash onComplete={handleSplashComplete} duration={3000} />;
82-
}
83-
84-
const screenProps = {
101+
const screenProps = () => ({
85102
onNavigate: handleNavigate,
86-
cols: cols - (showSidebar ? 20 : 0),
87-
rows,
88-
};
89-
90-
const renderScreen = () => {
91-
switch (screen) {
92-
case 'home': return <Home {...screenProps} />;
93-
case 'browse': return <Browse {...screenProps} />;
94-
case 'installed': return <Installed {...screenProps} />;
95-
case 'marketplace': return <Marketplace {...screenProps} />;
96-
case 'settings': return <Settings {...screenProps} />;
97-
case 'recommend': return <Recommend {...screenProps} />;
98-
case 'translate': return <Translate {...screenProps} />;
99-
case 'context': return <Context {...screenProps} />;
100-
case 'memory': return <Memory {...screenProps} />;
101-
case 'team': return <Team {...screenProps} />;
102-
case 'plugins': return <Plugins {...screenProps} />;
103-
case 'methodology': return <Methodology {...screenProps} />;
104-
case 'plan': return <Plan {...screenProps} />;
105-
case 'workflow': return <Workflow {...screenProps} />;
106-
case 'execute': return <Execute {...screenProps} />;
107-
case 'history': return <History {...screenProps} />;
108-
case 'sync': return <Sync {...screenProps} />;
109-
case 'help': return <Help {...screenProps} />;
110-
case 'mesh': return <Mesh {...screenProps} />;
111-
case 'message': return <Message {...screenProps} />;
112-
default: return <Home {...screenProps} />;
113-
}
114-
};
103+
cols: Math.max(1, cols() - sidebarWidth() - 2),
104+
rows: Math.max(1, contentHeight() - 1),
105+
});
115106

116107
return (
117-
<box flexDirection="row" height={rows}>
118-
{showSidebar && <Sidebar screen={screen} onNavigate={handleNavigate} />}
119-
<box flexDirection="column" flexGrow={1} marginLeft={showSidebar ? 1 : 0} paddingRight={1}>
120-
{renderScreen()}
108+
<Show when={!showSplash()} fallback={<Splash onComplete={handleSplashComplete} duration={3000} />}>
109+
<box flexDirection="column" height={rows()}>
110+
<box flexDirection="row" height={contentHeight()}>
111+
<Show when={sidebarVisible()}>
112+
<Sidebar
113+
screen={currentScreen()}
114+
onNavigate={handleNavigate}
115+
/>
116+
</Show>
117+
118+
<box flexDirection="column" flexGrow={1} paddingX={1}>
119+
<Switch fallback={<Home {...screenProps()} />}>
120+
<Match when={currentScreen() === 'home'}><Home {...screenProps()} /></Match>
121+
<Match when={currentScreen() === 'browse'}><Browse {...screenProps()} /></Match>
122+
<Match when={currentScreen() === 'installed'}><Installed {...screenProps()} /></Match>
123+
<Match when={currentScreen() === 'marketplace'}><Marketplace {...screenProps()} /></Match>
124+
<Match when={currentScreen() === 'settings'}><Settings {...screenProps()} /></Match>
125+
<Match when={currentScreen() === 'recommend'}><Recommend {...screenProps()} /></Match>
126+
<Match when={currentScreen() === 'translate'}><Translate {...screenProps()} /></Match>
127+
<Match when={currentScreen() === 'context'}><Context {...screenProps()} /></Match>
128+
<Match when={currentScreen() === 'memory'}><Memory {...screenProps()} /></Match>
129+
<Match when={currentScreen() === 'team'}><Team {...screenProps()} /></Match>
130+
<Match when={currentScreen() === 'plugins'}><Plugins {...screenProps()} /></Match>
131+
<Match when={currentScreen() === 'methodology'}><Methodology {...screenProps()} /></Match>
132+
<Match when={currentScreen() === 'plan'}><Plan {...screenProps()} /></Match>
133+
<Match when={currentScreen() === 'workflow'}><Workflow {...screenProps()} /></Match>
134+
<Match when={currentScreen() === 'execute'}><Execute {...screenProps()} /></Match>
135+
<Match when={currentScreen() === 'history'}><History {...screenProps()} /></Match>
136+
<Match when={currentScreen() === 'sync'}><Sync {...screenProps()} /></Match>
137+
<Match when={currentScreen() === 'help'}><Help {...screenProps()} /></Match>
138+
<Match when={currentScreen() === 'mesh'}><Mesh {...screenProps()} /></Match>
139+
<Match when={currentScreen() === 'message'}><Message {...screenProps()} /></Match>
140+
</Switch>
141+
</box>
142+
</box>
143+
144+
<StatusBar />
121145
</box>
122-
</box>
146+
</Show>
123147
);
124148
}
125149

Lines changed: 46 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,69 @@
1-
/**
2-
* AgentGrid Component
3-
* Displays monochromatic agent logos in a grid layout
4-
*/
1+
import { Show, For, createMemo } from 'solid-js';
52
import { AGENT_LOGOS, type AgentLogo } from '../theme/symbols.js';
63
import { terminalColors } from '../theme/colors.js';
74

85
interface AgentGridProps {
9-
/** Maximum number of agents to display */
106
maxVisible?: number;
11-
/** Show status indicators for detected agents */
127
showStatus?: boolean;
13-
/** Agent types that are detected/active */
148
detectedAgents?: string[];
15-
/** Number of columns in the grid */
169
columns?: number;
1710
}
1811

19-
export function AgentGrid({
20-
maxVisible = 12,
21-
showStatus = true,
22-
detectedAgents = [],
23-
columns = 4,
24-
}: AgentGridProps) {
25-
const safeColumns = Math.max(1, columns);
12+
export function AgentGrid(props: AgentGridProps) {
13+
const maxVisible = () => props.maxVisible ?? 12;
14+
const showStatus = () => props.showStatus ?? true;
15+
const detectedAgents = () => props.detectedAgents ?? [];
16+
const columns = () => Math.max(1, props.columns ?? 4);
17+
2618
const allAgents = Object.entries(AGENT_LOGOS);
27-
const visibleAgents = allAgents.slice(0, maxVisible);
28-
const hiddenCount = allAgents.length - maxVisible;
19+
const visibleAgents = () => allAgents.slice(0, maxVisible());
20+
const hiddenCount = () => allAgents.length - maxVisible();
2921

30-
const rows: [string, AgentLogo][][] = [];
31-
for (let i = 0; i < visibleAgents.length; i += safeColumns) {
32-
rows.push(visibleAgents.slice(i, i + safeColumns));
33-
}
22+
const rows = createMemo((): [string, AgentLogo][][] => {
23+
const result: [string, AgentLogo][][] = [];
24+
const visible = visibleAgents();
25+
for (let i = 0; i < visible.length; i += columns()) {
26+
result.push(visible.slice(i, i + columns()));
27+
}
28+
return result;
29+
});
3430

3531
return (
3632
<box flexDirection="column">
3733
<text fg={terminalColors.text}>
3834
<b>Works with</b>
3935
</text>
4036
<text> </text>
41-
{rows.map((row, rowIndex) => (
42-
<box key={`row-${rowIndex}`} flexDirection="row">
43-
{row.map(([agentType, agent]) => {
44-
const isDetected = detectedAgents.includes(agentType);
45-
const statusIcon = showStatus ? (isDetected ? '●' : '○') : '';
46-
const fg = isDetected ? terminalColors.accent : terminalColors.text;
37+
<For each={rows()}>
38+
{(row, rowIndex) => (
39+
<box flexDirection="row">
40+
<For each={row}>
41+
{([agentType, agent]) => {
42+
const isDetected = () => detectedAgents().includes(agentType);
43+
const statusIcon = () => (showStatus() ? (isDetected() ? '●' : '○') : '');
44+
const fg = () => (isDetected() ? terminalColors.accent : terminalColors.text);
4745

48-
return (
49-
<box key={agentType} width={18}>
50-
<text fg={fg}>
51-
{agent.icon} {agent.name}
52-
{showStatus && (
53-
<span fg={isDetected ? terminalColors.success : terminalColors.textMuted}>
54-
{' '}{statusIcon}
55-
</span>
56-
)}
57-
</text>
58-
</box>
59-
);
60-
})}
61-
</box>
62-
))}
63-
{hiddenCount > 0 && (
64-
<text fg={terminalColors.textMuted}>+{hiddenCount} more agents</text>
65-
)}
46+
return (
47+
<box width={18}>
48+
<text fg={fg()}>
49+
{agent.icon} {agent.name}
50+
<Show when={showStatus()}>
51+
<span fg={isDetected() ? terminalColors.success : terminalColors.textMuted}>
52+
{' '}
53+
{statusIcon()}
54+
</span>
55+
</Show>
56+
</text>
57+
</box>
58+
);
59+
}}
60+
</For>
61+
</box>
62+
)}
63+
</For>
64+
<Show when={hiddenCount() > 0}>
65+
<text fg={terminalColors.textMuted}>+{hiddenCount()} more agents</text>
66+
</Show>
6667
</box>
6768
);
6869
}

0 commit comments

Comments
 (0)