@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.
- package/AGENTS.md +203 -0
- package/CLAUDE.md +203 -0
- package/README.md +166 -0
- package/bun.lock +447 -0
- package/bunfig.toml +4 -0
- package/package.json +42 -0
- package/src/app.tsx +84 -0
- package/src/components/App.tsx +526 -0
- package/src/components/ConfirmDialog.tsx +88 -0
- package/src/components/Footer.tsx +50 -0
- package/src/components/HelpModal.tsx +136 -0
- package/src/components/IssueSelectorModal.tsx +701 -0
- package/src/components/ManualSessionModal.tsx +191 -0
- package/src/components/PhaseProgress.tsx +45 -0
- package/src/components/Preview.tsx +249 -0
- package/src/components/ProviderSwitcherModal.tsx +156 -0
- package/src/components/ScrollableText.tsx +120 -0
- package/src/components/SessionCard.tsx +60 -0
- package/src/components/SessionList.tsx +79 -0
- package/src/components/SessionTerminal.tsx +89 -0
- package/src/components/StatusBar.tsx +84 -0
- package/src/components/ThemeSwitcherModal.tsx +237 -0
- package/src/components/index.ts +58 -0
- package/src/components/session-utils.ts +337 -0
- package/src/components/theme.ts +206 -0
- package/src/components/types.ts +215 -0
- package/src/config/defaults.ts +44 -0
- package/src/config/env.ts +67 -0
- package/src/config/global.ts +252 -0
- package/src/config/index.ts +171 -0
- package/src/config/types.ts +131 -0
- package/src/core/.gitkeep +0 -0
- package/src/core/index.ts +5 -0
- package/src/core/parser.ts +62 -0
- package/src/core/process-manager.ts +52 -0
- package/src/core/session.ts +423 -0
- package/src/core/tmux.ts +206 -0
- package/src/git/.gitkeep +0 -0
- package/src/git/index.ts +8 -0
- package/src/git/repo.ts +443 -0
- package/src/git/worktree.ts +317 -0
- package/src/github/.gitkeep +0 -0
- package/src/github/client.ts +208 -0
- package/src/github/index.ts +8 -0
- package/src/github/issues.ts +351 -0
- package/src/index.ts +369 -0
- package/src/prompts/.gitkeep +0 -0
- package/src/prompts/index.ts +1 -0
- package/src/prompts/swe-system.ts +22 -0
- package/src/providers/claude.ts +103 -0
- package/src/providers/index.ts +21 -0
- package/src/providers/opencode.ts +98 -0
- package/src/providers/registry.ts +53 -0
- package/src/providers/types.ts +117 -0
- package/src/store/buffers.ts +234 -0
- package/src/store/db.test.ts +579 -0
- package/src/store/db.ts +249 -0
- package/src/store/index.ts +101 -0
- package/src/store/project.ts +119 -0
- package/src/store/schema.sql +71 -0
- package/src/store/sessions.ts +454 -0
- package/src/store/types.ts +194 -0
- package/src/theme/context.tsx +170 -0
- package/src/theme/custom.ts +134 -0
- package/src/theme/index.ts +58 -0
- package/src/theme/loader.ts +264 -0
- package/src/theme/themes/aura.json +69 -0
- package/src/theme/themes/ayu.json +80 -0
- package/src/theme/themes/carbonfox.json +248 -0
- package/src/theme/themes/catppuccin-frappe.json +233 -0
- package/src/theme/themes/catppuccin-macchiato.json +233 -0
- package/src/theme/themes/catppuccin.json +112 -0
- package/src/theme/themes/cobalt2.json +228 -0
- package/src/theme/themes/cursor.json +249 -0
- package/src/theme/themes/dracula.json +219 -0
- package/src/theme/themes/everforest.json +241 -0
- package/src/theme/themes/flexoki.json +237 -0
- package/src/theme/themes/github.json +233 -0
- package/src/theme/themes/gruvbox.json +242 -0
- package/src/theme/themes/kanagawa.json +77 -0
- package/src/theme/themes/lucent-orng.json +237 -0
- package/src/theme/themes/material.json +235 -0
- package/src/theme/themes/matrix.json +77 -0
- package/src/theme/themes/mercury.json +252 -0
- package/src/theme/themes/monokai.json +221 -0
- package/src/theme/themes/nightowl.json +221 -0
- package/src/theme/themes/nord.json +223 -0
- package/src/theme/themes/one-dark.json +84 -0
- package/src/theme/themes/opencode.json +245 -0
- package/src/theme/themes/orng.json +249 -0
- package/src/theme/themes/osaka-jade.json +93 -0
- package/src/theme/themes/palenight.json +222 -0
- package/src/theme/themes/rosepine.json +234 -0
- package/src/theme/themes/solarized.json +223 -0
- package/src/theme/themes/synthwave84.json +226 -0
- package/src/theme/themes/tokyonight.json +243 -0
- package/src/theme/themes/vercel.json +245 -0
- package/src/theme/themes/vesper.json +218 -0
- package/src/theme/themes/zenburn.json +223 -0
- package/src/theme/types.ts +225 -0
- package/src/types/sql.d.ts +4 -0
- package/src/utils/ansi-parser.ts +225 -0
- package/src/utils/format.ts +46 -0
- package/src/utils/id.ts +15 -0
- package/src/utils/logger.ts +112 -0
- package/src/utils/prerequisites.ts +118 -0
- package/src/utils/shell.ts +9 -0
- package/src/wizard/flows.ts +419 -0
- package/src/wizard/index.ts +37 -0
- package/src/wizard/prompts.ts +190 -0
- package/src/workspace/detect.test.ts +51 -0
- package/src/workspace/detect.ts +223 -0
- package/src/workspace/index.ts +71 -0
- package/src/workspace/init.ts +131 -0
- package/src/workspace/paths.ts +143 -0
- package/src/workspace/project.ts +164 -0
- 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
|
+
}
|