@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,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ManualSessionModal component - Create a new manual session
|
|
3
|
+
*
|
|
4
|
+
* Collects a session name and creates a worktree + session entry.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createSignal, createEffect, onCleanup, Show } from "solid-js"
|
|
8
|
+
import { useKeyboard } from "@opentui/solid"
|
|
9
|
+
import type { ManualSessionModalProps } from "./types"
|
|
10
|
+
import { createManualSession } from "./session-utils"
|
|
11
|
+
import { Footer } from "./Footer"
|
|
12
|
+
import { useColors } from "./theme"
|
|
13
|
+
import { logger } from "../utils/logger"
|
|
14
|
+
|
|
15
|
+
// Bold attribute constant
|
|
16
|
+
const BOLD = 1
|
|
17
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
18
|
+
|
|
19
|
+
export function ManualSessionModal(props: ManualSessionModalProps) {
|
|
20
|
+
const colors = useColors()
|
|
21
|
+
const modalWidth = 50
|
|
22
|
+
const maxNameLength = 40
|
|
23
|
+
|
|
24
|
+
const [name, setName] = createSignal("")
|
|
25
|
+
const [error, setError] = createSignal<string | null>(null)
|
|
26
|
+
const [creating, setCreating] = createSignal(false)
|
|
27
|
+
const [spinnerFrame, setSpinnerFrame] = createSignal(0)
|
|
28
|
+
|
|
29
|
+
// Spinner animation
|
|
30
|
+
createEffect(() => {
|
|
31
|
+
if (creating()) {
|
|
32
|
+
const interval = setInterval(() => {
|
|
33
|
+
setSpinnerFrame((prev) => (prev + 1) % SPINNER_FRAMES.length)
|
|
34
|
+
}, 80)
|
|
35
|
+
onCleanup(() => clearInterval(interval))
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Dynamic height based on error/creating state
|
|
40
|
+
const modalHeight = () => {
|
|
41
|
+
let height = 8 // Base: header(1) + label(1) + input(1) + footer(1) + borders(2) + padding(2)
|
|
42
|
+
if (error()) height += 1
|
|
43
|
+
if (creating()) height += 1
|
|
44
|
+
return height
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const appendChar = (char: string) => {
|
|
48
|
+
setName((prev) => {
|
|
49
|
+
if (prev.length >= maxNameLength) return prev
|
|
50
|
+
return prev + char
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleSubmit = async () => {
|
|
55
|
+
const trimmed = name().trim()
|
|
56
|
+
if (!trimmed) {
|
|
57
|
+
setError("Session name is required")
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setCreating(true)
|
|
62
|
+
setError(null)
|
|
63
|
+
logger.debug("Creating manual session", { name: trimmed })
|
|
64
|
+
|
|
65
|
+
const result = await createManualSession(props.projectRoot, trimmed, {
|
|
66
|
+
aiSessionData: { backend: props.currentBackend }
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
if (!result.success || !result.session) {
|
|
70
|
+
const message = result.error ?? "Failed to create session"
|
|
71
|
+
logger.warn("Manual session creation failed", message)
|
|
72
|
+
setError(message)
|
|
73
|
+
setCreating(false)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
logger.debug("Manual session created", { sessionId: result.session.id })
|
|
78
|
+
|
|
79
|
+
// Start the session (spawns opencode in tmux)
|
|
80
|
+
try {
|
|
81
|
+
await props.sessionManager.startSession({
|
|
82
|
+
sessionId: result.session.id,
|
|
83
|
+
// No prompt - launches interactive opencode TUI
|
|
84
|
+
})
|
|
85
|
+
logger.debug("Manual session started", { sessionId: result.session.id })
|
|
86
|
+
} catch (err) {
|
|
87
|
+
// Session created but failed to start - still close modal
|
|
88
|
+
// User can try to start it manually by pressing Enter
|
|
89
|
+
logger.error("Failed to start session:", err)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
props.onSessionCreated()
|
|
93
|
+
props.onClose()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const extractChar = (event: { name?: string; sequence?: string }) => {
|
|
97
|
+
if (event.sequence && event.sequence.length === 1) {
|
|
98
|
+
return event.sequence
|
|
99
|
+
}
|
|
100
|
+
if (event.name === "space") return " "
|
|
101
|
+
if (event.name && event.name.length === 1) return event.name
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
useKeyboard((event) => {
|
|
106
|
+
if (creating()) return
|
|
107
|
+
|
|
108
|
+
switch (event.name) {
|
|
109
|
+
case "escape":
|
|
110
|
+
props.onClose()
|
|
111
|
+
return
|
|
112
|
+
case "backspace":
|
|
113
|
+
setName((prev) => prev.slice(0, -1))
|
|
114
|
+
return
|
|
115
|
+
case "return":
|
|
116
|
+
handleSubmit()
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const char = extractChar(event)
|
|
121
|
+
if (char) {
|
|
122
|
+
appendChar(char)
|
|
123
|
+
setError(null)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<box
|
|
129
|
+
position="absolute"
|
|
130
|
+
top={0}
|
|
131
|
+
left={0}
|
|
132
|
+
width="100%"
|
|
133
|
+
height="100%"
|
|
134
|
+
justifyContent="center"
|
|
135
|
+
alignItems="center"
|
|
136
|
+
>
|
|
137
|
+
<box
|
|
138
|
+
flexDirection="column"
|
|
139
|
+
width={modalWidth}
|
|
140
|
+
height={modalHeight()}
|
|
141
|
+
backgroundColor={colors().bg.secondary}
|
|
142
|
+
borderStyle="rounded"
|
|
143
|
+
borderColor={colors().border.accent}
|
|
144
|
+
overflow="hidden"
|
|
145
|
+
>
|
|
146
|
+
{/* Header */}
|
|
147
|
+
<box height={1} justifyContent="center" paddingLeft={1} paddingRight={1}>
|
|
148
|
+
<text fg={colors().text.primary} attributes={BOLD}>
|
|
149
|
+
New Manual Session
|
|
150
|
+
</text>
|
|
151
|
+
</box>
|
|
152
|
+
|
|
153
|
+
{/* Content area */}
|
|
154
|
+
<box flexDirection="column" flexGrow={1} paddingLeft={2} paddingRight={2} paddingTop={1}>
|
|
155
|
+
<box height={1}>
|
|
156
|
+
<text fg={colors().text.secondary}>Name:</text>
|
|
157
|
+
</box>
|
|
158
|
+
|
|
159
|
+
<box height={1}>
|
|
160
|
+
<text fg={colors().text.primary}>
|
|
161
|
+
{name() || "Type a session name"}
|
|
162
|
+
</text>
|
|
163
|
+
</box>
|
|
164
|
+
|
|
165
|
+
<Show when={error()}>
|
|
166
|
+
<box height={1}>
|
|
167
|
+
<text fg={colors().accent.error}>{error()}</text>
|
|
168
|
+
</box>
|
|
169
|
+
</Show>
|
|
170
|
+
|
|
171
|
+
<Show when={creating()}>
|
|
172
|
+
<box height={1} justifyContent="center">
|
|
173
|
+
<text fg={colors().accent.primary}>
|
|
174
|
+
{SPINNER_FRAMES[spinnerFrame()]} Creating session...
|
|
175
|
+
</text>
|
|
176
|
+
</box>
|
|
177
|
+
</Show>
|
|
178
|
+
</box>
|
|
179
|
+
|
|
180
|
+
{/* Footer */}
|
|
181
|
+
<Footer
|
|
182
|
+
actions={[
|
|
183
|
+
{ key: "Enter", label: "Create" },
|
|
184
|
+
{ key: "Esc", label: "Cancel" },
|
|
185
|
+
]}
|
|
186
|
+
bgColor={colors().bg.primary}
|
|
187
|
+
/>
|
|
188
|
+
</box>
|
|
189
|
+
</box>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PhaseProgress component - displays workflow phase progress
|
|
3
|
+
*
|
|
4
|
+
* Two variants:
|
|
5
|
+
* - inline: Just the phase name text (for session cards)
|
|
6
|
+
* - full: Progress bar + phase name (for preview pane)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Show } from "solid-js"
|
|
10
|
+
import type { PhaseProgressProps } from "./types"
|
|
11
|
+
import {
|
|
12
|
+
PHASE_DISPLAY_NAMES,
|
|
13
|
+
getPhaseProgress,
|
|
14
|
+
generateProgressBar,
|
|
15
|
+
} from "./types"
|
|
16
|
+
import { useColors } from "./theme"
|
|
17
|
+
|
|
18
|
+
export function PhaseProgress(props: PhaseProgressProps) {
|
|
19
|
+
const colors = useColors()
|
|
20
|
+
const displayName = () => PHASE_DISPLAY_NAMES[props.phase]
|
|
21
|
+
const progress = () => getPhaseProgress(props.phase)
|
|
22
|
+
const progressBar = () => generateProgressBar(progress(), 10)
|
|
23
|
+
|
|
24
|
+
// Color based on phase
|
|
25
|
+
const phaseColor = () => {
|
|
26
|
+
if (props.phase === "completed") return colors().accent.success
|
|
27
|
+
if (props.phase === "failed") return colors().accent.error
|
|
28
|
+
return colors().progress.text
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Show
|
|
33
|
+
when={props.variant === "full"}
|
|
34
|
+
fallback={
|
|
35
|
+
<text fg={phaseColor()}>{displayName()}</text>
|
|
36
|
+
}
|
|
37
|
+
>
|
|
38
|
+
<box flexDirection="row" gap={1}>
|
|
39
|
+
<text fg={colors().progress.filled}>{progressBar()}</text>
|
|
40
|
+
<text fg={phaseColor()}>{displayName()}</text>
|
|
41
|
+
<text fg={colors().text.muted}>({progress()}%)</text>
|
|
42
|
+
</box>
|
|
43
|
+
</Show>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview component - right pane showing session terminal output
|
|
3
|
+
*
|
|
4
|
+
* Layout:
|
|
5
|
+
* - Header: Session name + PhaseProgress (full variant)
|
|
6
|
+
* - Separator
|
|
7
|
+
* - Terminal Output
|
|
8
|
+
* - Footer: Session duration + attach hint
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Show, createMemo, For } from "solid-js"
|
|
12
|
+
import type { PreviewProps } from "./types"
|
|
13
|
+
import type { Session } from "../store"
|
|
14
|
+
import { getPhaseProgress } from "./types"
|
|
15
|
+
import { useColors, type Colors } from "./theme"
|
|
16
|
+
import { Footer } from "./Footer"
|
|
17
|
+
import { truncate } from "../utils/format"
|
|
18
|
+
import { parseAnsiLine } from "../utils/ansi-parser"
|
|
19
|
+
import { getProvider, isProviderSupported } from "../providers"
|
|
20
|
+
|
|
21
|
+
import { formatTokens } from "../utils/format"
|
|
22
|
+
|
|
23
|
+
// Bold attribute constant
|
|
24
|
+
const BOLD = 1
|
|
25
|
+
const VERSION = "v0.1.0" // TODO: Get from package.json or config
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Format duration in human-readable form
|
|
29
|
+
*/
|
|
30
|
+
function formatDuration(startedAt: Date | undefined): string {
|
|
31
|
+
if (!startedAt) return ""
|
|
32
|
+
|
|
33
|
+
const diff = Date.now() - startedAt.getTime()
|
|
34
|
+
const seconds = Math.floor(diff / 1000)
|
|
35
|
+
|
|
36
|
+
if (seconds < 60) return `${seconds}s`
|
|
37
|
+
|
|
38
|
+
const minutes = Math.floor(seconds / 60)
|
|
39
|
+
const remainingSeconds = seconds % 60
|
|
40
|
+
|
|
41
|
+
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`
|
|
42
|
+
|
|
43
|
+
const hours = Math.floor(minutes / 60)
|
|
44
|
+
const remainingMinutes = minutes % 60
|
|
45
|
+
|
|
46
|
+
return `${hours}h ${remainingMinutes}m`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* StatusBanner component - shows attention status with icon
|
|
51
|
+
* Displays colored banner when session needs attention due to questions/errors
|
|
52
|
+
*/
|
|
53
|
+
function StatusBanner(props: { session: Session }) {
|
|
54
|
+
const colors = useColors()
|
|
55
|
+
const bannerInfo = createMemo(() => {
|
|
56
|
+
// General needs_attention (e.g., push failed)
|
|
57
|
+
return {
|
|
58
|
+
icon: "!",
|
|
59
|
+
text: props.session.attentionReason || "Needs attention",
|
|
60
|
+
color: colors().accent.warning,
|
|
61
|
+
bgColor: colors().accent.warning,
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<box
|
|
67
|
+
width="100%"
|
|
68
|
+
paddingLeft={1}
|
|
69
|
+
paddingRight={1}
|
|
70
|
+
paddingTop={0}
|
|
71
|
+
paddingBottom={0}
|
|
72
|
+
>
|
|
73
|
+
<box
|
|
74
|
+
flexDirection="row"
|
|
75
|
+
gap={1}
|
|
76
|
+
alignItems="center"
|
|
77
|
+
>
|
|
78
|
+
<text fg={bannerInfo().color}>{bannerInfo().icon}</text>
|
|
79
|
+
<text fg={bannerInfo().color}>{bannerInfo().text}</text>
|
|
80
|
+
</box>
|
|
81
|
+
</box>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function Preview(props: PreviewProps) {
|
|
86
|
+
const colors = useColors()
|
|
87
|
+
const duration = createMemo(() => formatDuration(props.startedAt))
|
|
88
|
+
const isActive = createMemo(() => props.session?.status === "active")
|
|
89
|
+
|
|
90
|
+
// Resolve provider branding from session data if available, otherwise fall back to global prop
|
|
91
|
+
const resolvedBranding = createMemo(() => {
|
|
92
|
+
const sessionBackend = props.session?.aiSessionData?.backend
|
|
93
|
+
if (sessionBackend && isProviderSupported(sessionBackend)) {
|
|
94
|
+
try {
|
|
95
|
+
return getProvider(sessionBackend).branding
|
|
96
|
+
} catch {
|
|
97
|
+
// Fallback if provider lookup fails
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return props.providerBranding
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const borderColor = createMemo(() => resolvedBranding()?.accentColor ?? colors().border.primary)
|
|
104
|
+
const providerName = createMemo(() => resolvedBranding()?.displayName ?? "Terminal")
|
|
105
|
+
const terminalBackground = createMemo(() => resolvedBranding()?.terminalBackground)
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<box
|
|
109
|
+
flexDirection="column"
|
|
110
|
+
width="70%"
|
|
111
|
+
height="100%"
|
|
112
|
+
borderStyle="rounded"
|
|
113
|
+
borderColor={borderColor()}
|
|
114
|
+
title={` Preview [${providerName()}] `}
|
|
115
|
+
>
|
|
116
|
+
<Show
|
|
117
|
+
when={props.session}
|
|
118
|
+
fallback={<NoSelectionState />}
|
|
119
|
+
>
|
|
120
|
+
{(session) => (
|
|
121
|
+
/* Terminal View - Full Height */
|
|
122
|
+
<box flexDirection="column" width="100%" height="100%">
|
|
123
|
+
{/* Header with session info */}
|
|
124
|
+
<box
|
|
125
|
+
flexDirection="row"
|
|
126
|
+
width="100%"
|
|
127
|
+
paddingLeft={1}
|
|
128
|
+
paddingRight={1}
|
|
129
|
+
paddingTop={0}
|
|
130
|
+
paddingBottom={0}
|
|
131
|
+
justifyContent="space-between"
|
|
132
|
+
alignItems="flex-start"
|
|
133
|
+
overflow="hidden"
|
|
134
|
+
>
|
|
135
|
+
<box flexDirection="column" overflow="hidden" flexGrow={1} marginRight={1}>
|
|
136
|
+
<text fg={colors().text.primary} attributes={BOLD}>
|
|
137
|
+
{truncate(session().name, 60)}
|
|
138
|
+
</text>
|
|
139
|
+
<Show when={session().issueNumber}>
|
|
140
|
+
<text fg={colors().text.secondary}>
|
|
141
|
+
Issue #{session().issueNumber}
|
|
142
|
+
</text>
|
|
143
|
+
</Show>
|
|
144
|
+
</box>
|
|
145
|
+
|
|
146
|
+
{/* Right side metadata */}
|
|
147
|
+
<box flexDirection="column" alignItems="flex-end" marginRight={1}>
|
|
148
|
+
<box flexDirection="row" gap={1}>
|
|
149
|
+
<text fg={colors().text.muted}>
|
|
150
|
+
{session().tokensUsed ? session().tokensUsed.toLocaleString() : "0"}
|
|
151
|
+
</text>
|
|
152
|
+
<text fg={colors().text.muted}>
|
|
153
|
+
{getPhaseProgress(session().phase)}%
|
|
154
|
+
</text>
|
|
155
|
+
<text fg={colors().text.muted}>($0.00)</text>
|
|
156
|
+
<text fg={colors().text.muted}>{VERSION}</text>
|
|
157
|
+
</box>
|
|
158
|
+
</box>
|
|
159
|
+
</box>
|
|
160
|
+
|
|
161
|
+
{/* Status Banner for needs_attention */}
|
|
162
|
+
<Show when={session().status === "needs_attention"}>
|
|
163
|
+
<StatusBanner session={session()} />
|
|
164
|
+
</Show>
|
|
165
|
+
|
|
166
|
+
{/* Separator */}
|
|
167
|
+
<box width="100%" height={1} overflow="hidden">
|
|
168
|
+
<text fg={colors().border.secondary}>
|
|
169
|
+
{"─".repeat(500)}
|
|
170
|
+
</text>
|
|
171
|
+
</box>
|
|
172
|
+
|
|
173
|
+
<box
|
|
174
|
+
flexDirection="column"
|
|
175
|
+
flexGrow={1}
|
|
176
|
+
width="100%"
|
|
177
|
+
paddingLeft={1}
|
|
178
|
+
paddingRight={1}
|
|
179
|
+
paddingTop={0}
|
|
180
|
+
justifyContent="flex-end"
|
|
181
|
+
overflow="hidden"
|
|
182
|
+
backgroundColor={terminalBackground()}
|
|
183
|
+
>
|
|
184
|
+
<Show
|
|
185
|
+
when={props.session?.status === "active" && props.snapshotLines && props.snapshotLines.length > 0}
|
|
186
|
+
fallback={
|
|
187
|
+
<box height="100%" justifyContent="center" alignItems="center">
|
|
188
|
+
<text fg={colors().text.muted}>Waiting for output...</text>
|
|
189
|
+
</box>
|
|
190
|
+
}
|
|
191
|
+
>
|
|
192
|
+
<For each={props.snapshotLines}>
|
|
193
|
+
{(line) => {
|
|
194
|
+
const segments = parseAnsiLine(line)
|
|
195
|
+
return (
|
|
196
|
+
<box flexDirection="row">
|
|
197
|
+
<For each={segments}>
|
|
198
|
+
{(segment) => (
|
|
199
|
+
<text
|
|
200
|
+
fg={segment.fg || colors().text.primary}
|
|
201
|
+
bg={segment.bg}
|
|
202
|
+
attributes={segment.bold ? BOLD : undefined}
|
|
203
|
+
>
|
|
204
|
+
{segment.text}
|
|
205
|
+
</text>
|
|
206
|
+
)}
|
|
207
|
+
</For>
|
|
208
|
+
</box>
|
|
209
|
+
)
|
|
210
|
+
}}
|
|
211
|
+
</For>
|
|
212
|
+
</Show>
|
|
213
|
+
</box>
|
|
214
|
+
|
|
215
|
+
{/* Footer */}
|
|
216
|
+
<Show when={isActive()}>
|
|
217
|
+
<Footer
|
|
218
|
+
actions={[
|
|
219
|
+
{ key: "Enter", label: "Attach" },
|
|
220
|
+
{ key: "tmux-prefix+d", label: "Detach" }
|
|
221
|
+
]}
|
|
222
|
+
message={duration() ? `Running for ${duration()}` : undefined}
|
|
223
|
+
/>
|
|
224
|
+
</Show>
|
|
225
|
+
</box>
|
|
226
|
+
)}
|
|
227
|
+
</Show>
|
|
228
|
+
</box>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function NoSelectionState() {
|
|
233
|
+
const colors = useColors()
|
|
234
|
+
return (
|
|
235
|
+
<box
|
|
236
|
+
flexDirection="column"
|
|
237
|
+
width="100%"
|
|
238
|
+
height="100%"
|
|
239
|
+
justifyContent="center"
|
|
240
|
+
alignItems="center"
|
|
241
|
+
gap={1}
|
|
242
|
+
>
|
|
243
|
+
<text fg={colors().text.muted}>No session selected</text>
|
|
244
|
+
<text fg={colors().text.secondary}>
|
|
245
|
+
Select a session to view terminal output
|
|
246
|
+
</text>
|
|
247
|
+
</box>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProviderSwitcherModal component - Allows switching AI backends
|
|
3
|
+
*
|
|
4
|
+
* Displays a list of available AI providers and lets the user select one.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createSignal, onMount, For } from "solid-js"
|
|
8
|
+
import { useKeyboard } from "@opentui/solid"
|
|
9
|
+
import type { ProviderSwitcherModalProps } from "./types"
|
|
10
|
+
import { getAllProviders, type Provider } from "../providers"
|
|
11
|
+
import { Footer } from "./Footer"
|
|
12
|
+
import { useColors } from "./theme"
|
|
13
|
+
|
|
14
|
+
// Bold attribute constant
|
|
15
|
+
const BOLD = 1
|
|
16
|
+
|
|
17
|
+
export function ProviderSwitcherModal(props: ProviderSwitcherModalProps) {
|
|
18
|
+
const colors = useColors()
|
|
19
|
+
const [providers, setProviders] = createSignal<Provider[]>([])
|
|
20
|
+
const [focusedIndex, setFocusedIndex] = createSignal(0)
|
|
21
|
+
|
|
22
|
+
onMount(() => {
|
|
23
|
+
const all = getAllProviders()
|
|
24
|
+
setProviders(all)
|
|
25
|
+
|
|
26
|
+
// Set initial focus to current backend
|
|
27
|
+
const idx = all.findIndex(p => p.id === props.currentBackend)
|
|
28
|
+
if (idx !== -1) {
|
|
29
|
+
setFocusedIndex(idx)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
useKeyboard((event) => {
|
|
34
|
+
const list = providers()
|
|
35
|
+
|
|
36
|
+
switch (event.name) {
|
|
37
|
+
case "escape":
|
|
38
|
+
props.onClose()
|
|
39
|
+
break
|
|
40
|
+
|
|
41
|
+
case "j":
|
|
42
|
+
case "down":
|
|
43
|
+
setFocusedIndex(prev =>
|
|
44
|
+
Math.min(prev + 1, list.length - 1)
|
|
45
|
+
)
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
case "k":
|
|
49
|
+
case "up":
|
|
50
|
+
setFocusedIndex(prev =>
|
|
51
|
+
Math.max(prev - 1, 0)
|
|
52
|
+
)
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
case "enter":
|
|
56
|
+
case "return": {
|
|
57
|
+
const selected = list[focusedIndex()]
|
|
58
|
+
if (selected) {
|
|
59
|
+
props.onSelect(selected.id)
|
|
60
|
+
}
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const modalWidth = 50
|
|
67
|
+
const modalHeight = 12
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<box
|
|
71
|
+
position="absolute"
|
|
72
|
+
top={0}
|
|
73
|
+
left={0}
|
|
74
|
+
width="100%"
|
|
75
|
+
height="100%"
|
|
76
|
+
justifyContent="center"
|
|
77
|
+
alignItems="center"
|
|
78
|
+
>
|
|
79
|
+
<box
|
|
80
|
+
flexDirection="column"
|
|
81
|
+
width={modalWidth}
|
|
82
|
+
height={modalHeight}
|
|
83
|
+
backgroundColor={colors().bg.secondary}
|
|
84
|
+
borderStyle="rounded"
|
|
85
|
+
borderColor={colors().border.accent}
|
|
86
|
+
overflow="hidden"
|
|
87
|
+
>
|
|
88
|
+
{/* Header */}
|
|
89
|
+
<box
|
|
90
|
+
height={1}
|
|
91
|
+
justifyContent="center"
|
|
92
|
+
paddingLeft={1}
|
|
93
|
+
paddingRight={1}
|
|
94
|
+
marginBottom={1}
|
|
95
|
+
>
|
|
96
|
+
<text fg={colors().text.primary} attributes={BOLD}>
|
|
97
|
+
Select AI Provider
|
|
98
|
+
</text>
|
|
99
|
+
</box>
|
|
100
|
+
|
|
101
|
+
{/* List */}
|
|
102
|
+
<box flexDirection="column" flexGrow={1} paddingLeft={2} paddingRight={2}>
|
|
103
|
+
<For each={providers()}>
|
|
104
|
+
{(provider, i) => {
|
|
105
|
+
const isFocused = () => i() === focusedIndex()
|
|
106
|
+
const isCurrent = () => provider.id === props.currentBackend
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<box
|
|
110
|
+
height={1}
|
|
111
|
+
flexDirection="row"
|
|
112
|
+
alignItems="center"
|
|
113
|
+
backgroundColor={isFocused() ? colors().bg.cardSelected : undefined}
|
|
114
|
+
>
|
|
115
|
+
{/* Focus Indicator */}
|
|
116
|
+
<box width={2}>
|
|
117
|
+
<text fg={colors().accent.primary}>
|
|
118
|
+
{isFocused() ? "▍" : " "}
|
|
119
|
+
</text>
|
|
120
|
+
</box>
|
|
121
|
+
|
|
122
|
+
{/* Selection Indicator */}
|
|
123
|
+
<box width={4}>
|
|
124
|
+
<text
|
|
125
|
+
fg={isCurrent() ? colors().accent.success : colors().text.muted}
|
|
126
|
+
attributes={isCurrent() ? BOLD : 0}
|
|
127
|
+
>
|
|
128
|
+
{isCurrent() ? "[✓]" : "[ ]"}
|
|
129
|
+
</text>
|
|
130
|
+
</box>
|
|
131
|
+
|
|
132
|
+
{/* Provider Name */}
|
|
133
|
+
<text
|
|
134
|
+
fg={isFocused() ? colors().text.primary : colors().text.secondary}
|
|
135
|
+
attributes={isFocused() ? BOLD : 0}
|
|
136
|
+
>
|
|
137
|
+
{provider.branding.displayName}
|
|
138
|
+
</text>
|
|
139
|
+
</box>
|
|
140
|
+
)
|
|
141
|
+
}}
|
|
142
|
+
</For>
|
|
143
|
+
</box>
|
|
144
|
+
|
|
145
|
+
{/* Footer */}
|
|
146
|
+
<Footer
|
|
147
|
+
actions={[
|
|
148
|
+
{ key: "Enter", label: "Select" },
|
|
149
|
+
{ key: "Esc", label: "Cancel" }
|
|
150
|
+
]}
|
|
151
|
+
bgColor={colors().bg.primary}
|
|
152
|
+
/>
|
|
153
|
+
</box>
|
|
154
|
+
</box>
|
|
155
|
+
)
|
|
156
|
+
}
|