@skillsgate/tui 0.1.1

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.
Files changed (41) hide show
  1. package/bin/skillsgate-tui +28 -0
  2. package/bunfig.toml +3 -0
  3. package/package.json +24 -0
  4. package/src/app.tsx +18 -0
  5. package/src/components/agent-filter.tsx +162 -0
  6. package/src/components/confirm-dialog.tsx +56 -0
  7. package/src/components/help-overlay.tsx +101 -0
  8. package/src/components/layout.tsx +272 -0
  9. package/src/components/search-input.tsx +48 -0
  10. package/src/components/skill-list-item.tsx +45 -0
  11. package/src/components/skill-list.tsx +245 -0
  12. package/src/components/status-bar.tsx +34 -0
  13. package/src/data/api-client.ts +151 -0
  14. package/src/data/use-agents.ts +41 -0
  15. package/src/data/use-auth.ts +136 -0
  16. package/src/data/use-favorites.ts +147 -0
  17. package/src/data/use-installed-skills.ts +128 -0
  18. package/src/data/use-search.ts +118 -0
  19. package/src/data/use-skill-actions.ts +333 -0
  20. package/src/db/context.tsx +38 -0
  21. package/src/db/index.ts +19 -0
  22. package/src/db/migrations.ts +72 -0
  23. package/src/db/servers.ts +154 -0
  24. package/src/db/settings.ts +43 -0
  25. package/src/db/skills.ts +138 -0
  26. package/src/db/ssh.ts +319 -0
  27. package/src/index.tsx +37 -0
  28. package/src/store/context.tsx +26 -0
  29. package/src/store/reducers.ts +126 -0
  30. package/src/store/types.ts +124 -0
  31. package/src/utils/colors.ts +42 -0
  32. package/src/views/add-server.tsx +240 -0
  33. package/src/views/discover.tsx +419 -0
  34. package/src/views/favorites.tsx +358 -0
  35. package/src/views/home.tsx +218 -0
  36. package/src/views/login.tsx +202 -0
  37. package/src/views/server-skills.tsx +269 -0
  38. package/src/views/servers.tsx +449 -0
  39. package/src/views/settings.tsx +185 -0
  40. package/src/views/skill-detail.tsx +497 -0
  41. package/tsconfig.json +18 -0
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ const { execFileSync } = require("child_process");
3
+ const path = require("path");
4
+ const os = require("os");
5
+
6
+ const platform = os.platform();
7
+ const arch = os.arch();
8
+
9
+ // Map to npm package names
10
+ const platformMap = {
11
+ darwin: { arm64: "tui-darwin-arm64", x64: "tui-darwin-x64" },
12
+ linux: { arm64: "tui-linux-arm64", x64: "tui-linux-x64" },
13
+ win32: { x64: "tui-win32-x64" },
14
+ };
15
+
16
+ const pkg = platformMap[platform]?.[arch];
17
+ if (!pkg) {
18
+ console.error(`Unsupported platform: ${platform}-${arch}`);
19
+ process.exit(1);
20
+ }
21
+
22
+ try {
23
+ const binPath = require.resolve(`@skillsgate/${pkg}/skillsgate-tui`);
24
+ execFileSync(binPath, process.argv.slice(2), { stdio: "inherit" });
25
+ } catch {
26
+ console.error("Platform binary not found. Try reinstalling: npm install -g @skillsgate/tui");
27
+ process.exit(1);
28
+ }
package/bunfig.toml ADDED
@@ -0,0 +1,3 @@
1
+ [install]
2
+ # Treat this package as standalone - do not walk up to the monorepo root
3
+ scopes = {}
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@skillsgate/tui",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "bin": {
6
+ "skillsgate-tui": "bin/skillsgate-tui"
7
+ },
8
+ "scripts": {
9
+ "dev": "bun --watch run src/index.tsx",
10
+ "start": "bun run src/index.tsx"
11
+ },
12
+ "dependencies": {
13
+ "@opentui/core": "0.1.90",
14
+ "@opentui/react": "0.1.90",
15
+ "gray-matter": "^4.0.3"
16
+ },
17
+ "peerDependencies": {
18
+ "react": ">=19.0.0"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/skillsgate/skillsgate"
23
+ }
24
+ }
package/src/app.tsx ADDED
@@ -0,0 +1,18 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { StoreProvider } from "./store/context.js"
3
+ import { DbProvider } from "./db/context.js"
4
+ import { Layout } from "./components/layout.js"
5
+
6
+ interface AppProps {
7
+ db: Database
8
+ }
9
+
10
+ export function App({ db }: AppProps) {
11
+ return (
12
+ <DbProvider db={db}>
13
+ <StoreProvider>
14
+ <Layout />
15
+ </StoreProvider>
16
+ </DbProvider>
17
+ )
18
+ }
@@ -0,0 +1,162 @@
1
+ import { useState } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import { useStore, useDispatch } from "../store/context.js"
4
+ import { useDb } from "../db/context.js"
5
+ import { colors, agentBadges } from "../utils/colors.js"
6
+
7
+ /**
8
+ * Vertical sidebar showing the Library section (All Skills, Favorites)
9
+ * and the Tools section (detected agents with skill counts).
10
+ * Rendered as the left panel in the three-panel home layout.
11
+ */
12
+ export function AgentFilter() {
13
+ const state = useStore()
14
+ const dispatch = useDispatch()
15
+ const { servers } = useDb()
16
+
17
+ const allCount = state.installedSkills.length
18
+
19
+ // Remote servers with skill counts
20
+ const serverList = servers.list()
21
+ const serverEntries = serverList.map((srv) => ({
22
+ id: srv.id,
23
+ label: srv.label,
24
+ count: servers.skillCount(srv.id),
25
+ }))
26
+
27
+ // Build the list of filter options
28
+ const agentOptions = state.detectedAgents.map((a) => ({
29
+ name: a.displayName,
30
+ value: a.name,
31
+ count: a.skillCount,
32
+ badge: agentBadges[a.name],
33
+ }))
34
+
35
+ // Navigate agent filters with keyboard when agents pane is focused
36
+ useKeyboard((key) => {
37
+ if (state.activeView !== "home") return
38
+ if (state.focusedPane !== "agents") return
39
+ if (state.showHelp) return
40
+
41
+ const allOptions = ["all", ...agentOptions.map((o) => o.value)]
42
+ const currentIdx = allOptions.indexOf(state.selectedAgentFilter)
43
+
44
+ if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
45
+ const prev = Math.max(0, currentIdx - 1)
46
+ dispatch({ type: "SET_AGENT_FILTER", filter: allOptions[prev] })
47
+ }
48
+ if (key.name === "down" || (key.name === "j" && !key.ctrl)) {
49
+ const next = Math.min(allOptions.length - 1, currentIdx + 1)
50
+ dispatch({ type: "SET_AGENT_FILTER", filter: allOptions[next] })
51
+ }
52
+ })
53
+
54
+ const isFocused = state.focusedPane === "agents"
55
+
56
+ return (
57
+ <box
58
+ style={{
59
+ flexDirection: "column",
60
+ width: 22,
61
+ borderRight: true,
62
+ borderColor: isFocused ? colors.primary : colors.border,
63
+ backgroundColor: colors.bg,
64
+ paddingTop: 0,
65
+ }}
66
+ >
67
+ {/* Library section header */}
68
+ <box style={{ paddingLeft: 1, height: 1, backgroundColor: colors.bgAlt }}>
69
+ <text fg={colors.textDim}>LIBRARY</text>
70
+ </box>
71
+
72
+ {/* All Skills */}
73
+ <box
74
+ style={{
75
+ paddingLeft: 1,
76
+ paddingRight: 1,
77
+ height: 1,
78
+ backgroundColor: state.selectedAgentFilter === "all" ? colors.bgAlt : "transparent",
79
+ }}
80
+ >
81
+ <text fg={state.selectedAgentFilter === "all" ? colors.primary : colors.text}>
82
+ All Skills
83
+ </text>
84
+ <text fg={colors.textDim}> ({allCount})</text>
85
+ </box>
86
+
87
+ {/* Spacer */}
88
+ <box style={{ height: 1 }}>
89
+ <text>{" "}</text>
90
+ </box>
91
+
92
+ {/* Tools section header */}
93
+ <box style={{ paddingLeft: 1, height: 1, backgroundColor: colors.bgAlt }}>
94
+ <text fg={colors.textDim}>TOOLS</text>
95
+ </box>
96
+
97
+ {/* Agent entries */}
98
+ {agentOptions.length === 0 ? (
99
+ <box style={{ paddingLeft: 1, height: 1 }}>
100
+ <text fg={colors.textDim}>(none)</text>
101
+ </box>
102
+ ) : (
103
+ agentOptions.map((opt) => {
104
+ const isActive = state.selectedAgentFilter === opt.value
105
+ return (
106
+ <box
107
+ key={opt.value}
108
+ style={{
109
+ paddingLeft: 1,
110
+ paddingRight: 1,
111
+ height: 1,
112
+ flexDirection: "row",
113
+ backgroundColor: isActive ? colors.bgAlt : "transparent",
114
+ }}
115
+ >
116
+ <text fg={opt.badge?.color ?? colors.agent}>
117
+ {opt.badge?.label ?? opt.name.slice(0, 2)}
118
+ </text>
119
+ <text fg={isActive ? colors.primary : colors.text}>
120
+ {" "}{opt.name}
121
+ </text>
122
+ <text fg={colors.textDim}> {opt.count}</text>
123
+ </box>
124
+ )
125
+ })
126
+ )}
127
+
128
+ {/* Spacer */}
129
+ <box style={{ height: 1 }}>
130
+ <text>{" "}</text>
131
+ </box>
132
+
133
+ {/* Servers section header */}
134
+ <box style={{ paddingLeft: 1, height: 1, backgroundColor: colors.bgAlt }}>
135
+ <text fg={colors.textDim}>SERVERS</text>
136
+ </box>
137
+
138
+ {/* Server entries */}
139
+ {serverEntries.length === 0 ? (
140
+ <box style={{ paddingLeft: 1, height: 1 }}>
141
+ <text fg={colors.textDim}>(none)</text>
142
+ </box>
143
+ ) : (
144
+ serverEntries.map((srv) => (
145
+ <box
146
+ key={srv.id}
147
+ style={{
148
+ paddingLeft: 1,
149
+ paddingRight: 1,
150
+ height: 1,
151
+ flexDirection: "row",
152
+ }}
153
+ >
154
+ <text fg={colors.secondary}>S </text>
155
+ <text fg={colors.text}>{srv.label}</text>
156
+ <text fg={colors.textDim}> {srv.count}</text>
157
+ </box>
158
+ ))
159
+ )}
160
+ </box>
161
+ )
162
+ }
@@ -0,0 +1,56 @@
1
+ import { useKeyboard } from "@opentui/react"
2
+ import { colors } from "../utils/colors.js"
3
+
4
+ interface ConfirmDialogProps {
5
+ message: string
6
+ onConfirm: () => void
7
+ onCancel: () => void
8
+ }
9
+
10
+ /**
11
+ * Overlay confirmation dialog that captures y/n keypress.
12
+ * Renders as a centered box with the message and (y/n) hint.
13
+ */
14
+ export function ConfirmDialog({ message, onConfirm, onCancel }: ConfirmDialogProps) {
15
+ useKeyboard((key) => {
16
+ if (key.name === "y") {
17
+ onConfirm()
18
+ } else if (key.name === "n" || key.name === "escape") {
19
+ onCancel()
20
+ }
21
+ })
22
+
23
+ return (
24
+ <box
25
+ style={{
26
+ width: "100%",
27
+ height: "100%",
28
+ justifyContent: "center",
29
+ alignItems: "center",
30
+ backgroundColor: colors.bg,
31
+ }}
32
+ >
33
+ <box
34
+ style={{
35
+ width: 60,
36
+ border: true,
37
+ borderColor: colors.primary,
38
+ backgroundColor: "#1a1a2e",
39
+ paddingLeft: 2,
40
+ paddingRight: 2,
41
+ paddingTop: 1,
42
+ paddingBottom: 1,
43
+ flexDirection: "column",
44
+ alignItems: "center",
45
+ }}
46
+ title="Confirm"
47
+ >
48
+ <text fg={colors.text}>{message}</text>
49
+ <text>{" "}</text>
50
+ <text fg={colors.textDim}>
51
+ Press <span fg={colors.success}>y</span> to confirm, <span fg={colors.error}>n</span> to cancel
52
+ </text>
53
+ </box>
54
+ </box>
55
+ )
56
+ }
@@ -0,0 +1,101 @@
1
+ import { colors } from "../utils/colors.js"
2
+
3
+ interface ShortcutEntry {
4
+ key: string
5
+ description: string
6
+ }
7
+
8
+ const SHORTCUTS_LEFT: ShortcutEntry[] = [
9
+ { key: "j/k", description: "Navigate list up/down" },
10
+ { key: "g", description: "Jump to first item" },
11
+ { key: "G", description: "Jump to last item" },
12
+ { key: "v", description: "View skill detail" },
13
+ { key: "/", description: "Focus search input" },
14
+ { key: "Tab", description: "Cycle focus: agents > search > list" },
15
+ { key: "Esc", description: "Clear search / go back" },
16
+ ]
17
+
18
+ const SHORTCUTS_RIGHT: ShortcutEntry[] = [
19
+ { key: "1/2/3/4", description: "Switch tabs" },
20
+ { key: "s", description: "Open settings" },
21
+ { key: "r", description: "Refresh installed skills" },
22
+ { key: "l", description: "Login / auth status" },
23
+ { key: "i", description: "Install selected skill" },
24
+ { key: "d", description: "Remove selected skill" },
25
+ { key: "x", description: "Unfavorite (favorites view)" },
26
+ { key: "m", description: "Toggle keyword/AI search" },
27
+ { key: "?", description: "Toggle this help" },
28
+ { key: "Ctrl+Q", description: "Quit" },
29
+ { key: "", description: "" },
30
+ { key: "-- Servers View --", description: "" },
31
+ { key: "S", description: "Sync selected server" },
32
+ { key: "a", description: "Add new server" },
33
+ { key: "e", description: "Edit server" },
34
+ { key: "t", description: "Test connection" },
35
+ { key: "", description: "" },
36
+ { key: "-- Detail View --", description: "" },
37
+ { key: "q/Esc", description: "Go back" },
38
+ { key: "e", description: "Edit skill in $EDITOR" },
39
+ { key: "o", description: "Open folder / source URL" },
40
+ { key: "d", description: "Remove (per-agent if multiple)" },
41
+ ]
42
+
43
+ const KEY_COL_WIDTH = 18
44
+ const DESC_COL_WIDTH = 34
45
+
46
+ function formatShortcutLine(entry: ShortcutEntry): string {
47
+ if (!entry.key && !entry.description) return ""
48
+ if (entry.key.startsWith("--")) {
49
+ return entry.key
50
+ }
51
+ const keyPadded = entry.key.padEnd(KEY_COL_WIDTH)
52
+ return `${keyPadded}${entry.description}`
53
+ }
54
+
55
+ export function HelpOverlay() {
56
+ const maxRows = Math.max(SHORTCUTS_LEFT.length, SHORTCUTS_RIGHT.length)
57
+ const lines: string[] = []
58
+
59
+ // Title
60
+ lines.push("")
61
+ lines.push(" Keyboard Shortcuts")
62
+ lines.push(" " + "-".repeat(KEY_COL_WIDTH + DESC_COL_WIDTH + 4 + KEY_COL_WIDTH + DESC_COL_WIDTH))
63
+ lines.push("")
64
+
65
+ for (let i = 0; i < maxRows; i++) {
66
+ const left = SHORTCUTS_LEFT[i]
67
+ const right = SHORTCUTS_RIGHT[i]
68
+
69
+ const leftStr = left ? formatShortcutLine(left) : ""
70
+ const rightStr = right ? formatShortcutLine(right) : ""
71
+
72
+ const leftPadded = leftStr.padEnd(KEY_COL_WIDTH + DESC_COL_WIDTH + 2)
73
+ lines.push(` ${leftPadded} ${rightStr}`)
74
+ }
75
+
76
+ lines.push("")
77
+ lines.push(" Press ? or Esc to close")
78
+ lines.push("")
79
+
80
+ return (
81
+ <box
82
+ style={{
83
+ width: "100%",
84
+ flexGrow: 1,
85
+ backgroundColor: "#1a1a2e",
86
+ border: true,
87
+ borderColor: colors.primary,
88
+ flexDirection: "column",
89
+ paddingLeft: 1,
90
+ paddingRight: 1,
91
+ }}
92
+ title="Help"
93
+ >
94
+ {lines.map((line, i) => (
95
+ <text key={i} fg={line.includes("--") && !line.includes("Keyboard") ? colors.primary : colors.text}>
96
+ {line}
97
+ </text>
98
+ ))}
99
+ </box>
100
+ )
101
+ }
@@ -0,0 +1,272 @@
1
+ import { useState } from "react"
2
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react"
3
+ import { useStore, useDispatch } from "../store/context.js"
4
+ import { useDb } from "../db/context.js"
5
+ import { useDetectedAgents } from "../data/use-agents.js"
6
+ import { useInstalledSkills } from "../data/use-installed-skills.js"
7
+ import { useAuth } from "../data/use-auth.js"
8
+ import { StatusBar } from "./status-bar.js"
9
+ import { HelpOverlay } from "./help-overlay.js"
10
+ import { HomeView } from "../views/home.js"
11
+ import { SkillDetailView } from "../views/skill-detail.js"
12
+ import { DiscoverView } from "../views/discover.js"
13
+ import { FavoritesView } from "../views/favorites.js"
14
+ import { ServersView } from "../views/servers.js"
15
+ import { AddServerView } from "../views/add-server.js"
16
+ import { ServerSkillsView } from "../views/server-skills.js"
17
+ import { SettingsView } from "../views/settings.js"
18
+ import { LoginView } from "../views/login.js"
19
+ import { colors } from "../utils/colors.js"
20
+ import type { ViewName } from "../store/types.js"
21
+
22
+ function getTabOptions(favCount: number, serverCount: number) {
23
+ return [
24
+ { name: "Installed", description: "Locally installed skills", value: "home" },
25
+ { name: "Discover", description: "Search the registry", value: "discover" },
26
+ {
27
+ name: favCount > 0 ? `Favorites (${favCount})` : "Favorites",
28
+ description: "Your starred skills",
29
+ value: "favorites",
30
+ },
31
+ {
32
+ name: serverCount > 0 ? `Servers (${serverCount})` : "Servers",
33
+ description: "Remote SSH servers",
34
+ value: "servers",
35
+ },
36
+ ]
37
+ }
38
+
39
+ export function Layout() {
40
+ const state = useStore()
41
+ const dispatch = useDispatch()
42
+ const { width, height } = useTerminalDimensions()
43
+ const { servers } = useDb()
44
+ const [serverCount, setServerCount] = useState(() => servers.list().length)
45
+
46
+ // Load auth, agent + skill data on mount
47
+ useAuth()
48
+ useDetectedAgents()
49
+ useInstalledSkills()
50
+
51
+ // Global keyboard shortcuts
52
+ useKeyboard((key) => {
53
+ // Ctrl+Q always works -- clean exit
54
+ if (key.name === "q" && key.ctrl) {
55
+ const exit = (globalThis as any).__skillsgateTuiCleanExit
56
+ if (exit) exit()
57
+ else process.exit(0)
58
+ }
59
+
60
+ // When search input is focused, only handle Escape, Tab, and Ctrl shortcuts
61
+ // All other keys pass through to the input component
62
+ if (state.focusedPane === "search") {
63
+ if (key.name === "escape") {
64
+ dispatch({ type: "SET_FOCUSED_PANE", pane: "list" })
65
+ return
66
+ }
67
+ if (key.name === "tab" && !key.shift) {
68
+ dispatch({ type: "CYCLE_FOCUS" })
69
+ return
70
+ }
71
+ return
72
+ }
73
+
74
+ // When on login view, only handle Escape -- let input keys pass through
75
+ if (state.activeView === "login") {
76
+ if (key.name === "escape") {
77
+ dispatch({ type: "GO_BACK" })
78
+ }
79
+ return
80
+ }
81
+
82
+ // Help overlay toggle
83
+ if (key.name === "?" || (key.shift && key.name === "/")) {
84
+ dispatch({ type: "TOGGLE_HELP" })
85
+ return
86
+ }
87
+
88
+ // Dismiss help with Esc
89
+ if (state.showHelp && key.name === "escape") {
90
+ dispatch({ type: "TOGGLE_HELP" })
91
+ return
92
+ }
93
+
94
+ // When help is shown, block other shortcuts
95
+ if (state.showHelp) return
96
+
97
+ // Tab switching (only when not in detail/form views)
98
+ const inFormView = state.activeView === "detail" || state.activeView === "add-server"
99
+ || state.activeView === "edit-server" || state.activeView === "settings"
100
+ || state.activeView === "server-skills" || state.activeView === "login"
101
+ if (!inFormView) {
102
+ if (key.name === "1") dispatch({ type: "NAVIGATE", view: "home" })
103
+ if (key.name === "2") dispatch({ type: "NAVIGATE", view: "discover" })
104
+ if (key.name === "3") dispatch({ type: "NAVIGATE", view: "favorites" })
105
+ if (key.name === "4") {
106
+ setServerCount(servers.list().length)
107
+ dispatch({ type: "NAVIGATE", view: "servers" })
108
+ }
109
+ }
110
+
111
+ // "s" to open settings (only from home/favorites views when not in search)
112
+ if (key.name === "s" && state.focusedPane !== "search"
113
+ && state.activeView !== "discover" && state.activeView !== "detail"
114
+ && !inFormView) {
115
+ dispatch({ type: "NAVIGATE", view: "settings" })
116
+ return
117
+ }
118
+
119
+ // Tab to cycle focus (only on home/discover views)
120
+ if (key.name === "tab" && !key.shift && (state.activeView === "home" || state.activeView === "discover")) {
121
+ dispatch({ type: "CYCLE_FOCUS" })
122
+ return
123
+ }
124
+
125
+ // "/" to focus search from anywhere
126
+ if (key.name === "/" && state.activeView !== "detail") {
127
+ dispatch({ type: "SET_FOCUSED_PANE", pane: "search" })
128
+ return
129
+ }
130
+
131
+ // Esc: go back from sub-views, clear search, etc.
132
+ if (key.name === "escape") {
133
+ if (state.activeView === "detail" || state.activeView === "add-server"
134
+ || state.activeView === "edit-server" || state.activeView === "settings"
135
+ || state.activeView === "server-skills") {
136
+ dispatch({ type: "GO_BACK" })
137
+ return
138
+ }
139
+ if (state.installedFilter) {
140
+ dispatch({ type: "SET_INSTALLED_FILTER", filter: "" })
141
+ }
142
+ if (state.focusedPane === "search") {
143
+ dispatch({ type: "SET_FOCUSED_PANE", pane: "list" })
144
+ }
145
+ return
146
+ }
147
+
148
+ // "l" to navigate to login view (always -- allows re-login if token expired)
149
+ if (key.name === "l" && state.focusedPane !== "search" && state.activeView !== "detail" && state.activeView !== "login") {
150
+ dispatch({ type: "NAVIGATE", view: "login" })
151
+ return
152
+ }
153
+
154
+ // "r" to refresh installed skills (when not typing in search, not on login view)
155
+ if (key.name === "r" && state.focusedPane !== "search" && state.activeView !== "detail" && state.activeView !== "login") {
156
+ dispatch({ type: "REFRESH_SKILLS" })
157
+ return
158
+ }
159
+ })
160
+
161
+ const TAB_OPTIONS = getTabOptions(state.favorites.length, serverCount)
162
+
163
+ const activeTabIndex = TAB_OPTIONS.findIndex(
164
+ (t) => t.value === state.activeView
165
+ )
166
+
167
+ return (
168
+ <box
169
+ style={{
170
+ width: "100%",
171
+ height: "100%",
172
+ flexDirection: "column",
173
+ backgroundColor: colors.bg,
174
+ }}
175
+ >
176
+ {/* Header */}
177
+ <box
178
+ style={{
179
+ height: 1,
180
+ width: "100%",
181
+ backgroundColor: colors.header,
182
+ flexDirection: "row",
183
+ paddingLeft: 1,
184
+ paddingRight: 1,
185
+ justifyContent: "space-between",
186
+ }}
187
+ >
188
+ <text fg={colors.primary}>
189
+ <strong>SkillsGate TUI</strong> <span fg={colors.textDim}>v0.1.0</span>
190
+ </text>
191
+ </box>
192
+
193
+ {/* Tab navigation */}
194
+ <tab-select
195
+ options={TAB_OPTIONS}
196
+ focused={state.activeView !== "detail" && !state.showHelp}
197
+ selectedIndex={activeTabIndex >= 0 ? activeTabIndex : 0}
198
+ selectedBackgroundColor={colors.tabActive}
199
+ selectedTextColor={colors.tabText}
200
+ textColor={colors.textDim}
201
+ backgroundColor={colors.bg}
202
+ showDescription={false}
203
+ showUnderline={true}
204
+ wrapSelection={true}
205
+ onChange={(index: number) => {
206
+ const view = TAB_OPTIONS[index]?.value as ViewName | undefined
207
+ if (view) dispatch({ type: "NAVIGATE", view })
208
+ }}
209
+ />
210
+
211
+ {/* Content area */}
212
+ <box style={{ flexGrow: 1, width: "100%" }}>
213
+ {state.showHelp ? (
214
+ <HelpOverlay />
215
+ ) : (
216
+ <>
217
+ {state.activeView === "home" && <HomeView />}
218
+ {state.activeView === "discover" && <DiscoverView />}
219
+ {state.activeView === "favorites" && <FavoritesView />}
220
+ {state.activeView === "servers" && <ServersView onServerCountChange={setServerCount} />}
221
+ {(state.activeView === "add-server" || state.activeView === "edit-server") && (
222
+ <AddServerView
223
+ editServerId={state.activeView === "edit-server" ? state.selectedServerId : null}
224
+ onServerCountChange={setServerCount}
225
+ />
226
+ )}
227
+ {state.activeView === "server-skills" && state.selectedServerId && (
228
+ <ServerSkillsView serverId={state.selectedServerId} />
229
+ )}
230
+ {state.activeView === "settings" && <SettingsView />}
231
+ {state.activeView === "login" && <LoginView />}
232
+ {state.activeView === "detail" && state.selectedSkill && (
233
+ <SkillDetailView />
234
+ )}
235
+ </>
236
+ )}
237
+ </box>
238
+
239
+ {/* Notification bar (conditional) */}
240
+ {state.notification && (
241
+ <box
242
+ style={{
243
+ height: 1,
244
+ width: "100%",
245
+ backgroundColor:
246
+ state.notification.type === "error"
247
+ ? "#331111"
248
+ : state.notification.type === "success"
249
+ ? "#113311"
250
+ : "#111133",
251
+ paddingLeft: 1,
252
+ }}
253
+ >
254
+ <text
255
+ fg={
256
+ state.notification.type === "error"
257
+ ? colors.error
258
+ : state.notification.type === "success"
259
+ ? colors.success
260
+ : colors.primary
261
+ }
262
+ >
263
+ {state.notification.message}
264
+ </text>
265
+ </box>
266
+ )}
267
+
268
+ {/* Status bar */}
269
+ <StatusBar />
270
+ </box>
271
+ )
272
+ }