@vladimirven/openswe 0.1.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.
Files changed (117) hide show
  1. package/AGENTS.md +203 -0
  2. package/CLAUDE.md +203 -0
  3. package/README.md +166 -0
  4. package/bun.lock +447 -0
  5. package/bunfig.toml +4 -0
  6. package/package.json +42 -0
  7. package/src/app.tsx +84 -0
  8. package/src/components/App.tsx +526 -0
  9. package/src/components/ConfirmDialog.tsx +88 -0
  10. package/src/components/Footer.tsx +50 -0
  11. package/src/components/HelpModal.tsx +136 -0
  12. package/src/components/IssueSelectorModal.tsx +701 -0
  13. package/src/components/ManualSessionModal.tsx +191 -0
  14. package/src/components/PhaseProgress.tsx +45 -0
  15. package/src/components/Preview.tsx +249 -0
  16. package/src/components/ProviderSwitcherModal.tsx +156 -0
  17. package/src/components/ScrollableText.tsx +120 -0
  18. package/src/components/SessionCard.tsx +60 -0
  19. package/src/components/SessionList.tsx +79 -0
  20. package/src/components/SessionTerminal.tsx +89 -0
  21. package/src/components/StatusBar.tsx +84 -0
  22. package/src/components/ThemeSwitcherModal.tsx +237 -0
  23. package/src/components/index.ts +58 -0
  24. package/src/components/session-utils.ts +337 -0
  25. package/src/components/theme.ts +206 -0
  26. package/src/components/types.ts +215 -0
  27. package/src/config/defaults.ts +44 -0
  28. package/src/config/env.ts +67 -0
  29. package/src/config/global.ts +252 -0
  30. package/src/config/index.ts +171 -0
  31. package/src/config/types.ts +131 -0
  32. package/src/core/.gitkeep +0 -0
  33. package/src/core/index.ts +5 -0
  34. package/src/core/parser.ts +62 -0
  35. package/src/core/process-manager.ts +52 -0
  36. package/src/core/session.ts +423 -0
  37. package/src/core/tmux.ts +206 -0
  38. package/src/git/.gitkeep +0 -0
  39. package/src/git/index.ts +8 -0
  40. package/src/git/repo.ts +443 -0
  41. package/src/git/worktree.ts +317 -0
  42. package/src/github/.gitkeep +0 -0
  43. package/src/github/client.ts +208 -0
  44. package/src/github/index.ts +8 -0
  45. package/src/github/issues.ts +351 -0
  46. package/src/index.ts +369 -0
  47. package/src/prompts/.gitkeep +0 -0
  48. package/src/prompts/index.ts +1 -0
  49. package/src/prompts/swe-system.ts +22 -0
  50. package/src/providers/claude.ts +103 -0
  51. package/src/providers/index.ts +21 -0
  52. package/src/providers/opencode.ts +98 -0
  53. package/src/providers/registry.ts +53 -0
  54. package/src/providers/types.ts +117 -0
  55. package/src/store/buffers.ts +234 -0
  56. package/src/store/db.test.ts +579 -0
  57. package/src/store/db.ts +249 -0
  58. package/src/store/index.ts +101 -0
  59. package/src/store/project.ts +119 -0
  60. package/src/store/schema.sql +71 -0
  61. package/src/store/sessions.ts +454 -0
  62. package/src/store/types.ts +194 -0
  63. package/src/theme/context.tsx +170 -0
  64. package/src/theme/custom.ts +134 -0
  65. package/src/theme/index.ts +58 -0
  66. package/src/theme/loader.ts +264 -0
  67. package/src/theme/themes/aura.json +69 -0
  68. package/src/theme/themes/ayu.json +80 -0
  69. package/src/theme/themes/carbonfox.json +248 -0
  70. package/src/theme/themes/catppuccin-frappe.json +233 -0
  71. package/src/theme/themes/catppuccin-macchiato.json +233 -0
  72. package/src/theme/themes/catppuccin.json +112 -0
  73. package/src/theme/themes/cobalt2.json +228 -0
  74. package/src/theme/themes/cursor.json +249 -0
  75. package/src/theme/themes/dracula.json +219 -0
  76. package/src/theme/themes/everforest.json +241 -0
  77. package/src/theme/themes/flexoki.json +237 -0
  78. package/src/theme/themes/github.json +233 -0
  79. package/src/theme/themes/gruvbox.json +242 -0
  80. package/src/theme/themes/kanagawa.json +77 -0
  81. package/src/theme/themes/lucent-orng.json +237 -0
  82. package/src/theme/themes/material.json +235 -0
  83. package/src/theme/themes/matrix.json +77 -0
  84. package/src/theme/themes/mercury.json +252 -0
  85. package/src/theme/themes/monokai.json +221 -0
  86. package/src/theme/themes/nightowl.json +221 -0
  87. package/src/theme/themes/nord.json +223 -0
  88. package/src/theme/themes/one-dark.json +84 -0
  89. package/src/theme/themes/opencode.json +245 -0
  90. package/src/theme/themes/orng.json +249 -0
  91. package/src/theme/themes/osaka-jade.json +93 -0
  92. package/src/theme/themes/palenight.json +222 -0
  93. package/src/theme/themes/rosepine.json +234 -0
  94. package/src/theme/themes/solarized.json +223 -0
  95. package/src/theme/themes/synthwave84.json +226 -0
  96. package/src/theme/themes/tokyonight.json +243 -0
  97. package/src/theme/themes/vercel.json +245 -0
  98. package/src/theme/themes/vesper.json +218 -0
  99. package/src/theme/themes/zenburn.json +223 -0
  100. package/src/theme/types.ts +225 -0
  101. package/src/types/sql.d.ts +4 -0
  102. package/src/utils/ansi-parser.ts +225 -0
  103. package/src/utils/format.ts +46 -0
  104. package/src/utils/id.ts +15 -0
  105. package/src/utils/logger.ts +112 -0
  106. package/src/utils/prerequisites.ts +118 -0
  107. package/src/utils/shell.ts +9 -0
  108. package/src/wizard/flows.ts +419 -0
  109. package/src/wizard/index.ts +37 -0
  110. package/src/wizard/prompts.ts +190 -0
  111. package/src/workspace/detect.test.ts +51 -0
  112. package/src/workspace/detect.ts +223 -0
  113. package/src/workspace/index.ts +71 -0
  114. package/src/workspace/init.ts +131 -0
  115. package/src/workspace/paths.ts +143 -0
  116. package/src/workspace/project.ts +164 -0
  117. package/tsconfig.json +22 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * ScrollableText component - Displays text that scrolls horizontally if it exceeds width
3
+ *
4
+ * Behavior:
5
+ * - If text fits in width: Renders statically
6
+ * - If text exceeds width AND isActive is true: Scrolls horizontally (marquee)
7
+ * - If text exceeds width AND isActive is false: Truncates with ellipsis
8
+ */
9
+
10
+ import { createSignal, createEffect, onCleanup, mergeProps, Show } from "solid-js"
11
+ import { truncate } from "../utils/format"
12
+
13
+ export interface ScrollableTextProps {
14
+ text: string
15
+ width: number
16
+ isActive?: boolean
17
+ fg?: string
18
+ attributes?: number
19
+ align?: "left" | "right" | "center"
20
+ speed?: number
21
+ delay?: number
22
+ }
23
+
24
+ export function ScrollableText(props: ScrollableTextProps) {
25
+ const merged = mergeProps({
26
+ isActive: true,
27
+ speed: 200,
28
+ delay: 1500,
29
+ align: "left"
30
+ }, props)
31
+
32
+ const [offset, setOffset] = createSignal(0)
33
+ const [isScrolling, setIsScrolling] = createSignal(false)
34
+
35
+ // Reset offset when active state or text changes
36
+ createEffect(() => {
37
+ // Dependencies
38
+ const text = merged.text
39
+ const active = merged.isActive
40
+
41
+ if (!active) {
42
+ setOffset(0)
43
+ setIsScrolling(false)
44
+ return
45
+ }
46
+
47
+ // If text fits, don't scroll
48
+ if (text.length <= merged.width) {
49
+ setOffset(0)
50
+ setIsScrolling(false)
51
+ return
52
+ }
53
+
54
+ // Reset offset initially
55
+ setOffset(0)
56
+ setIsScrolling(false)
57
+
58
+ // Start delay timer
59
+ const delayTimer = setTimeout(() => {
60
+ setIsScrolling(true)
61
+ }, merged.delay)
62
+
63
+ onCleanup(() => clearTimeout(delayTimer))
64
+ })
65
+
66
+ // Scrolling interval
67
+ createEffect(() => {
68
+ if (!isScrolling() || !merged.isActive) return
69
+
70
+ const interval = setInterval(() => {
71
+ setOffset((prev) => prev + 1)
72
+ }, merged.speed)
73
+
74
+ onCleanup(() => clearInterval(interval))
75
+ })
76
+
77
+ const displayText = () => {
78
+ const { text, width, isActive } = merged
79
+
80
+ // Static case: Fits in width
81
+ if (text.length <= width) {
82
+ // Handle alignment
83
+ if (merged.align === "center") {
84
+ const pad = Math.floor((width - text.length) / 2)
85
+ return text.padStart(text.length + pad).padEnd(width)
86
+ }
87
+ if (merged.align === "right") {
88
+ return text.padStart(width)
89
+ }
90
+ return text.padEnd(width)
91
+ }
92
+
93
+ // Inactive case: Truncate
94
+ if (!isActive) {
95
+ return truncate(text, width)
96
+ }
97
+
98
+ // Active scrolling case
99
+ // We add spacing equal to width/3 (min 3 chars) between loops
100
+ const spacer = " "
101
+ const fullText = text + spacer
102
+ const currentOffset = offset() % fullText.length
103
+
104
+ // Create the window
105
+ let slice = fullText.slice(currentOffset, currentOffset + width)
106
+
107
+ // If slice is shorter than width (hit end of string), append start
108
+ if (slice.length < width) {
109
+ slice += fullText.slice(0, width - slice.length)
110
+ }
111
+
112
+ return slice
113
+ }
114
+
115
+ return (
116
+ <text fg={merged.fg} attributes={merged.attributes}>
117
+ {displayText()}
118
+ </text>
119
+ )
120
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * SessionCard component - displays a single session in the list
3
+ *
4
+ * Layout (1 line):
5
+ * - Status icon + session name
6
+ */
7
+
8
+ import type { SessionCardProps } from "./types"
9
+ import { STATUS_ICONS } from "./types"
10
+ import { ScrollableText } from "./ScrollableText"
11
+ import { useColors } from "./theme"
12
+
13
+ // Bold attribute constant
14
+ const BOLD = 1
15
+
16
+ export function SessionCard(props: SessionCardProps) {
17
+ const colors = useColors()
18
+ const statusIcon = () => STATUS_ICONS[props.session.status]
19
+ const statusColor = () => colors().status[props.session.status]
20
+
21
+ // Calculate width for the name scrolling area
22
+ // availableWidth is total inner width of the card
23
+ // Subtract:
24
+ // - Padding (L+R): 2
25
+ // - Status Icon + Gap: 2
26
+ const nameWidth = () => Math.max(10, props.availableWidth - 4)
27
+
28
+ return (
29
+ <box
30
+ flexDirection="row"
31
+ width="100%"
32
+ paddingLeft={1}
33
+ paddingRight={1}
34
+ paddingTop={0}
35
+ paddingBottom={0}
36
+ backgroundColor={
37
+ props.isSelected ? colors().bg.cardSelected : colors().bg.card
38
+ }
39
+ borderStyle={props.isSelected ? "rounded" : "single"}
40
+ borderColor={props.isSelected ? colors().accent.primary : colors().border.primary}
41
+ overflow="hidden"
42
+ justifyContent="space-between"
43
+ alignItems="center"
44
+ >
45
+ {/* Left: Status + Name */}
46
+ <box flexDirection="row" gap={1} overflow="hidden" flexShrink={1}>
47
+ <text fg={statusColor()} attributes={BOLD}>
48
+ {statusIcon()}
49
+ </text>
50
+ <ScrollableText
51
+ text={props.session.name}
52
+ width={nameWidth()}
53
+ isActive={props.isSelected}
54
+ fg={colors().text.primary}
55
+ attributes={props.isSelected ? BOLD : 0}
56
+ />
57
+ </box>
58
+ </box>
59
+ )
60
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * SessionList component - left pane with scrollable session list
3
+ *
4
+ * Displays a list of SessionCards with:
5
+ * - Title showing total and active session counts
6
+ * - Empty state when no sessions exist
7
+ * - Scrollbox for overflow
8
+ */
9
+
10
+ import { Show, For } from "solid-js"
11
+ import type { SessionListProps } from "./types"
12
+ import { SessionCard } from "./SessionCard"
13
+ import { useColors } from "./theme"
14
+
15
+ export function SessionList(props: SessionListProps) {
16
+ const colors = useColors()
17
+ const totalCount = () => props.sessions.length
18
+ const activeCount = () =>
19
+ props.sessions.filter((s) => s.status === "active").length
20
+
21
+ const title = () => ` Sessions (${activeCount()}/${totalCount()} active) `
22
+
23
+ return (
24
+ <box
25
+ flexDirection="column"
26
+ width="30%"
27
+ height="100%"
28
+ borderStyle="rounded"
29
+ borderColor={colors().border.primary}
30
+ title={title()}
31
+ >
32
+ <Show
33
+ when={props.sessions.length > 0}
34
+ fallback={<EmptyState />}
35
+ >
36
+ <scrollbox
37
+ flexDirection="column"
38
+ width="100%"
39
+ height="100%"
40
+ gap={1}
41
+ paddingTop={1}
42
+ paddingBottom={1}
43
+ >
44
+ <For each={props.sessions}>
45
+ {(session, index) => (
46
+ <SessionCard
47
+ session={session}
48
+ isSelected={index() === props.selectedIndex}
49
+ availableWidth={props.listWidth - 4}
50
+ />
51
+ )}
52
+ </For>
53
+ </scrollbox>
54
+ </Show>
55
+ </box>
56
+ )
57
+ }
58
+
59
+ function EmptyState() {
60
+ const colors = useColors()
61
+ return (
62
+ <box
63
+ flexDirection="column"
64
+ width="100%"
65
+ height="100%"
66
+ justifyContent="center"
67
+ alignItems="center"
68
+ gap={1}
69
+ >
70
+ <text fg={colors().text.muted}>No sessions yet</text>
71
+ <text fg={colors().text.secondary}>
72
+ Press 'n' to create a new session
73
+ </text>
74
+ <text fg={colors().text.secondary}>
75
+ or 'i' to browse issues
76
+ </text>
77
+ </box>
78
+ )
79
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * SessionTerminal component - Full screen interactive terminal view
3
+ */
4
+
5
+ import { For } from "solid-js"
6
+ import { useKeyboard } from "@opentui/solid"
7
+ import type { Session } from "../store"
8
+ import { useColors } from "./theme"
9
+
10
+ export interface SessionTerminalProps {
11
+ session: Session
12
+ lines: string[]
13
+ onInput: (data: string) => void
14
+ onExit: () => void
15
+ }
16
+
17
+ export function SessionTerminal(props: SessionTerminalProps) {
18
+ const colors = useColors()
19
+
20
+ // Handle keyboard input
21
+ useKeyboard((event) => {
22
+ // Handle exit shortcut (Ctrl+C or Esc)
23
+ if (event.name === "c" && event.ctrl) {
24
+ props.onExit()
25
+ return
26
+ }
27
+ if (event.name === "escape") {
28
+ props.onExit()
29
+ return
30
+ }
31
+
32
+ // Map keys to input
33
+ if (event.name === "return") {
34
+ props.onInput("\r")
35
+ } else if (event.name === "backspace") {
36
+ props.onInput("\x7f") // DEL
37
+ } else if (event.name === "tab") {
38
+ props.onInput("\t")
39
+ } else if (event.name === "space") {
40
+ props.onInput(" ")
41
+ } else if (event.name && event.name.length === 1 && !event.ctrl && !event.meta) {
42
+ // Regular characters
43
+ props.onInput(event.name)
44
+ }
45
+ })
46
+
47
+ return (
48
+ <box
49
+ flexDirection="column"
50
+ width="100%"
51
+ height="100%"
52
+ backgroundColor={colors().bg.primary}
53
+ >
54
+ {/* Header */}
55
+ <box
56
+ height={1}
57
+ width="100%"
58
+ backgroundColor={colors().bg.secondary}
59
+ paddingLeft={1}
60
+ paddingRight={1}
61
+ justifyContent="space-between"
62
+ >
63
+ <text fg={colors().text.primary} attributes={1}>
64
+ Terminal: {props.session.name}
65
+ </text>
66
+ <text fg={colors().text.muted}>
67
+ [Esc] Detach
68
+ </text>
69
+ </box>
70
+
71
+ {/* Terminal Output */}
72
+ <scrollbox
73
+ flexDirection="column"
74
+ flexGrow={1}
75
+ width="100%"
76
+ paddingLeft={1}
77
+ paddingRight={1}
78
+ stickyScroll
79
+ stickyStart="bottom"
80
+ >
81
+ <For each={props.lines}>
82
+ {(line) => (
83
+ <text fg={colors().text.primary}>{line || " "}</text>
84
+ )}
85
+ </For>
86
+ </scrollbox>
87
+ </box>
88
+ )
89
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * StatusBar component - displays header and footer bars
3
+ *
4
+ * Two variants:
5
+ * - header: "OpenSWE | repo/name | [task badge] | [Backend]"
6
+ * - footer: Keybinding hints
7
+ */
8
+
9
+ import { Show, For } from "solid-js"
10
+ import type { StatusBarProps } from "./types"
11
+ import type { ProviderBranding } from "../providers"
12
+ import { Footer } from "./Footer"
13
+ import { useColors, keybindings } from "./theme"
14
+
15
+ // Bold attribute constant (bit 0 in text attributes)
16
+ const BOLD = 1
17
+
18
+ export function StatusBar(props: StatusBarProps) {
19
+ return (
20
+ <Show when={props.variant === "header"} fallback={<FooterBar />}>
21
+ <HeaderBar
22
+ repoName={props.repoName}
23
+ backend={props.backend}
24
+ sessionId={props.sessionId}
25
+ providerBranding={props.providerBranding}
26
+ />
27
+ </Show>
28
+ )
29
+ }
30
+
31
+ function HeaderBar(props: {
32
+ repoName?: string
33
+ backend?: string
34
+ sessionId?: string
35
+ providerBranding?: ProviderBranding
36
+ }) {
37
+ const colors = useColors()
38
+ const headerBg = () => props.providerBranding?.headerBackground ?? props.providerBranding?.accentColor ?? colors().accent.primary
39
+ const backendDisplay = () => props.providerBranding?.displayName ?? props.backend
40
+
41
+ return (
42
+ <box
43
+ flexDirection="row"
44
+ width="100%"
45
+ height={1}
46
+ backgroundColor={headerBg()}
47
+ paddingLeft={1}
48
+ paddingRight={1}
49
+ justifyContent="space-between"
50
+ >
51
+ <box flexDirection="row" gap={1}>
52
+ <text fg={colors().text.inverse} attributes={BOLD}>
53
+ OpenSWE
54
+ </text>
55
+ <Show when={props.repoName}>
56
+ <text fg={colors().text.inverse}>|</text>
57
+ <text fg={colors().text.inverse}>{props.repoName}</text>
58
+ </Show>
59
+ </box>
60
+ <box flexDirection="row" gap={1}>
61
+ <Show when={props.sessionId}>
62
+ <text fg={colors().text.inverse}>ID: {props.sessionId}</text>
63
+ </Show>
64
+ <Show when={backendDisplay()}>
65
+ <text fg={colors().text.inverse}>[{backendDisplay()}]</text>
66
+ </Show>
67
+ </box>
68
+ </box>
69
+ )
70
+ }
71
+
72
+ function FooterBar() {
73
+ const actions = [
74
+ { key: keybindings.navigate, label: "Navigate" },
75
+ { key: keybindings.newSession, label: "New" },
76
+ { key: keybindings.issues, label: "Issues" },
77
+ { key: "a", label: "AI" },
78
+ { key: keybindings.theme, label: "Theme" },
79
+ { key: keybindings.help, label: "Help" },
80
+ { key: keybindings.quit, label: "Quit" },
81
+ ]
82
+
83
+ return <Footer actions={actions} />
84
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * ThemeSwitcherModal component - Allows switching UI themes
3
+ *
4
+ * Displays a scrollable list of available themes and lets the user select one.
5
+ */
6
+
7
+ import { createSignal, onMount, For, createMemo } from "solid-js"
8
+ import { useKeyboard } from "@opentui/solid"
9
+ import type { ThemeSwitcherModalProps } from "./types"
10
+ import { useTheme } from "../theme"
11
+ import { Footer } from "./Footer"
12
+ import { useColors } from "./theme"
13
+
14
+ // Bold attribute constant
15
+ const BOLD = 1
16
+
17
+ export function ThemeSwitcherModal(props: ThemeSwitcherModalProps) {
18
+ const { availableThemes } = useTheme()
19
+ const colors = useColors()
20
+ const [focusedIndex, setFocusedIndex] = createSignal(0)
21
+
22
+ // Viewport state for scrolling
23
+ const VIEWPORT_HEIGHT = 10
24
+ const [viewportStart, setViewportStart] = createSignal(0)
25
+
26
+ const themes = createMemo(() => availableThemes())
27
+
28
+ onMount(() => {
29
+ const all = themes()
30
+
31
+ // Set initial focus to current theme
32
+ const idx = all.findIndex(t => t === props.currentTheme)
33
+ if (idx !== -1) {
34
+ setFocusedIndex(idx)
35
+ // Center the initial selection in viewport if possible
36
+ const start = Math.max(0, Math.min(idx - Math.floor(VIEWPORT_HEIGHT / 2), all.length - VIEWPORT_HEIGHT))
37
+ setViewportStart(start)
38
+ }
39
+ })
40
+
41
+ // Ensure viewport follows focus
42
+ const updateViewport = (newIndex: number) => {
43
+ const currentStart = viewportStart()
44
+
45
+ if (newIndex < currentStart) {
46
+ // Scrolled up past top
47
+ setViewportStart(newIndex)
48
+ } else if (newIndex >= currentStart + VIEWPORT_HEIGHT) {
49
+ // Scrolled down past bottom
50
+ setViewportStart(newIndex - VIEWPORT_HEIGHT + 1)
51
+ }
52
+ }
53
+
54
+ useKeyboard((event) => {
55
+ const list = themes()
56
+
57
+ switch (event.name) {
58
+ case "escape":
59
+ props.onClose()
60
+ break
61
+
62
+ case "j":
63
+ case "down": {
64
+ const next = Math.min(focusedIndex() + 1, list.length - 1)
65
+ setFocusedIndex(next)
66
+ updateViewport(next)
67
+ break
68
+ }
69
+
70
+ case "k":
71
+ case "up": {
72
+ const prev = Math.max(focusedIndex() - 1, 0)
73
+ setFocusedIndex(prev)
74
+ updateViewport(prev)
75
+ break
76
+ }
77
+
78
+ case "pageup": {
79
+ const prev = Math.max(focusedIndex() - VIEWPORT_HEIGHT, 0)
80
+ setFocusedIndex(prev)
81
+ updateViewport(prev)
82
+ break
83
+ }
84
+
85
+ case "pagedown": {
86
+ const next = Math.min(focusedIndex() + VIEWPORT_HEIGHT, list.length - 1)
87
+ setFocusedIndex(next)
88
+ updateViewport(next)
89
+ break
90
+ }
91
+
92
+ case "enter":
93
+ case "return": {
94
+ const selected = list[focusedIndex()]
95
+ if (selected) {
96
+ props.onSelect(selected)
97
+ }
98
+ break
99
+ }
100
+ }
101
+ })
102
+
103
+ // Visible items based on viewport
104
+ const visibleThemes = createMemo(() => {
105
+ const all = themes()
106
+ const start = viewportStart()
107
+ return all.slice(start, start + VIEWPORT_HEIGHT).map((name, i) => ({
108
+ name,
109
+ originalIndex: start + i
110
+ }))
111
+ })
112
+
113
+ // Calculate scrollbar
114
+ const scrollbarInfo = createMemo(() => {
115
+ const total = themes().length
116
+ const start = viewportStart()
117
+ const height = VIEWPORT_HEIGHT
118
+
119
+ if (total <= height) return null
120
+
121
+ const trackHeight = height
122
+ const thumbHeight = Math.max(1, Math.floor((height / total) * trackHeight))
123
+ const thumbTop = Math.floor((start / (total - height)) * (trackHeight - thumbHeight))
124
+
125
+ return { thumbHeight, thumbTop }
126
+ })
127
+
128
+ const modalWidth = 50
129
+ // Header + List + Footer + Padding
130
+ const modalHeight = VIEWPORT_HEIGHT + 6
131
+
132
+ return (
133
+ <box
134
+ position="absolute"
135
+ top={0}
136
+ left={0}
137
+ width="100%"
138
+ height="100%"
139
+ justifyContent="center"
140
+ alignItems="center"
141
+ >
142
+ <box
143
+ flexDirection="column"
144
+ width={modalWidth}
145
+ height={modalHeight}
146
+ backgroundColor={colors().bg.secondary}
147
+ borderStyle="rounded"
148
+ borderColor={colors().border.accent}
149
+ overflow="hidden"
150
+ >
151
+ {/* Header */}
152
+ <box
153
+ height={1}
154
+ justifyContent="center"
155
+ paddingLeft={1}
156
+ paddingRight={1}
157
+ marginBottom={1}
158
+ >
159
+ <text fg={colors().text.primary} attributes={BOLD}>
160
+ Select Theme ({focusedIndex() + 1}/{themes().length})
161
+ </text>
162
+ </box>
163
+
164
+ {/* List Container with Scrollbar */}
165
+ <box flexDirection="row" flexGrow={1} paddingLeft={2} paddingRight={1} marginBottom={1}>
166
+ {/* List */}
167
+ <box flexDirection="column" flexGrow={1}>
168
+ <For each={visibleThemes()}>
169
+ {(item) => {
170
+ const isFocused = () => item.originalIndex === focusedIndex()
171
+ const isCurrent = () => item.name === props.currentTheme
172
+
173
+ return (
174
+ <box
175
+ height={1}
176
+ flexDirection="row"
177
+ alignItems="center"
178
+ backgroundColor={isFocused() ? colors().bg.cardSelected : undefined}
179
+ >
180
+ {/* Focus Indicator */}
181
+ <box width={2}>
182
+ <text fg={colors().accent.primary}>
183
+ {isFocused() ? "▍" : " "}
184
+ </text>
185
+ </box>
186
+
187
+ {/* Selection Indicator */}
188
+ <box width={4}>
189
+ <text
190
+ fg={isCurrent() ? colors().accent.success : colors().text.muted}
191
+ attributes={isCurrent() ? BOLD : 0}
192
+ >
193
+ {isCurrent() ? "[✓]" : "[ ]"}
194
+ </text>
195
+ </box>
196
+
197
+ {/* Theme Name */}
198
+ <text
199
+ fg={isFocused() ? colors().text.primary : colors().text.secondary}
200
+ attributes={isFocused() ? BOLD : 0}
201
+ >
202
+ {item.name}
203
+ </text>
204
+ </box>
205
+ )
206
+ }}
207
+ </For>
208
+ </box>
209
+
210
+ {/* Scrollbar */}
211
+ <box width={1} flexDirection="column" marginLeft={1}>
212
+ {scrollbarInfo() && (
213
+ <>
214
+ {/* Track above thumb */}
215
+ <box height={scrollbarInfo()!.thumbTop} />
216
+
217
+ {/* Thumb */}
218
+ <box height={scrollbarInfo()!.thumbHeight}>
219
+ <text fg={colors().border.accent}>|</text>
220
+ </box>
221
+ </>
222
+ )}
223
+ </box>
224
+ </box>
225
+
226
+ {/* Footer */}
227
+ <Footer
228
+ actions={[
229
+ { key: "Enter", label: "Select" },
230
+ { key: "Esc", label: "Cancel" }
231
+ ]}
232
+ bgColor={colors().bg.primary}
233
+ />
234
+ </box>
235
+ </box>
236
+ )
237
+ }