@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
package/src/app.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main TUI application entry point
|
|
3
|
+
*
|
|
4
|
+
* Provides startTUI function to launch the OpenSWE terminal interface.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { render } from "@opentui/solid"
|
|
8
|
+
import { createCliRenderer } from "@opentui/core"
|
|
9
|
+
import { App } from "./components"
|
|
10
|
+
import { ThemeProvider } from "./theme"
|
|
11
|
+
import type { GlobalConfig } from "./config"
|
|
12
|
+
import { createProject, initDatabaseWithPath, projectExists } from "./store"
|
|
13
|
+
import { getStateDatabasePath } from "./workspace/paths"
|
|
14
|
+
import { loadProjectConfig } from "./workspace/project"
|
|
15
|
+
import { logger } from "./utils/logger"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Start the TUI application
|
|
19
|
+
*
|
|
20
|
+
* @param config - Global configuration
|
|
21
|
+
* @param projectRoot - Absolute path to the project root directory
|
|
22
|
+
*/
|
|
23
|
+
export async function startTUI(config: GlobalConfig, projectRoot: string) {
|
|
24
|
+
// Initialize database with project-specific path
|
|
25
|
+
const dbPath = getStateDatabasePath(projectRoot)
|
|
26
|
+
await initDatabaseWithPath(dbPath)
|
|
27
|
+
|
|
28
|
+
// Ensure project metadata exists in the database
|
|
29
|
+
try {
|
|
30
|
+
const projectConfig = await loadProjectConfig(projectRoot)
|
|
31
|
+
if (!projectConfig) {
|
|
32
|
+
logger.warn("Project config not found; skipping project DB initialization")
|
|
33
|
+
} else if (!projectExists()) {
|
|
34
|
+
createProject({
|
|
35
|
+
repoFullName: projectConfig.repoFullName,
|
|
36
|
+
repoUrl: projectConfig.repoUrl,
|
|
37
|
+
})
|
|
38
|
+
logger.debug("Initialized project state in database", projectConfig)
|
|
39
|
+
} else {
|
|
40
|
+
logger.debug("Project state already present in database")
|
|
41
|
+
}
|
|
42
|
+
} catch (error) {
|
|
43
|
+
logger.error("Failed to initialize project state in database", error)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Render the TUI
|
|
47
|
+
const rendererConfig = {
|
|
48
|
+
stdin: process.stdin,
|
|
49
|
+
stdout: process.stdout,
|
|
50
|
+
useAlternateScreen: true,
|
|
51
|
+
useMouse: false,
|
|
52
|
+
useKittyKeyboard:
|
|
53
|
+
process.env.OPENTUI_KITTY === "1"
|
|
54
|
+
? {
|
|
55
|
+
disambiguate: true,
|
|
56
|
+
alternateKeys: true,
|
|
57
|
+
events: true,
|
|
58
|
+
}
|
|
59
|
+
: null,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
process.stdin.resume()
|
|
63
|
+
|
|
64
|
+
const renderer = await createCliRenderer(rendererConfig)
|
|
65
|
+
|
|
66
|
+
// Wait for destroy event to know when to exit
|
|
67
|
+
const exitPromise = new Promise<void>((resolve) => {
|
|
68
|
+
renderer.on("destroy", () => resolve())
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
await render(
|
|
72
|
+
() => (
|
|
73
|
+
<ThemeProvider
|
|
74
|
+
initialTheme={config.ui?.theme}
|
|
75
|
+
initialMode={config.ui?.themeMode}
|
|
76
|
+
>
|
|
77
|
+
<App config={config} projectRoot={projectRoot} />
|
|
78
|
+
</ThemeProvider>
|
|
79
|
+
),
|
|
80
|
+
renderer,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
await exitPromise
|
|
84
|
+
}
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App component - root component for OpenSWE TUI
|
|
3
|
+
*
|
|
4
|
+
* Manages:
|
|
5
|
+
* - State signals for sessions, selection, project info
|
|
6
|
+
* - Data loading from store
|
|
7
|
+
* - Keyboard navigation
|
|
8
|
+
* - Layout structure
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createSignal, createEffect, onMount, onCleanup, Show } from "solid-js"
|
|
12
|
+
import { useKeyboard, useRenderer } from "@opentui/solid"
|
|
13
|
+
import type { Session } from "../store"
|
|
14
|
+
import {
|
|
15
|
+
getAllSessions,
|
|
16
|
+
getProject,
|
|
17
|
+
updateSessionStatus,
|
|
18
|
+
getSession,
|
|
19
|
+
} from "../store"
|
|
20
|
+
import { SessionManager } from "../core"
|
|
21
|
+
import { generateSWEPrompt } from "../prompts"
|
|
22
|
+
import type { AppProps, ModalType, ProjectInfo, PendingAction } from "./types"
|
|
23
|
+
import type { ProviderBranding } from "../providers"
|
|
24
|
+
import type { AIBackend } from "../config"
|
|
25
|
+
import { saveGlobalConfig } from "../config"
|
|
26
|
+
import { StatusBar } from "./StatusBar"
|
|
27
|
+
import { SessionList } from "./SessionList"
|
|
28
|
+
import { Preview } from "./Preview"
|
|
29
|
+
import { HelpModal } from "./HelpModal"
|
|
30
|
+
import { ConfirmDialog } from "./ConfirmDialog"
|
|
31
|
+
import { IssueSelectorModal } from "./IssueSelectorModal"
|
|
32
|
+
import { ManualSessionModal } from "./ManualSessionModal"
|
|
33
|
+
import { ProviderSwitcherModal } from "./ProviderSwitcherModal"
|
|
34
|
+
import { ThemeSwitcherModal } from "./ThemeSwitcherModal"
|
|
35
|
+
import { deleteSessionWithWorktree } from "./session-utils"
|
|
36
|
+
import { useColors } from "./theme"
|
|
37
|
+
import { useTheme } from "../theme"
|
|
38
|
+
import { logger } from "../utils/logger"
|
|
39
|
+
|
|
40
|
+
/** Refresh interval for polling data (24 fps) */
|
|
41
|
+
const REFRESH_INTERVAL = (1/60) * 1000
|
|
42
|
+
|
|
43
|
+
export function App(props: AppProps) {
|
|
44
|
+
const sessionManager = new SessionManager(props.config, props.projectRoot)
|
|
45
|
+
const renderer = useRenderer()
|
|
46
|
+
const { themeName, setTheme } = useTheme()
|
|
47
|
+
const colors = useColors()
|
|
48
|
+
|
|
49
|
+
// Get provider branding from session manager (reactive)
|
|
50
|
+
const [providerBranding, setProviderBranding] = createSignal<ProviderBranding>(
|
|
51
|
+
sessionManager.getProvider().branding
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// State
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
const [sessions, setSessions] = createSignal<Session[]>([])
|
|
59
|
+
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
|
60
|
+
const [projectInfo, setProjectInfo] = createSignal<ProjectInfo | null>(null)
|
|
61
|
+
const [snapshotLines, setSnapshotLines] = createSignal<string[]>([])
|
|
62
|
+
const [sessionStartedAt, setSessionStartedAt] = createSignal<Date | undefined>(undefined)
|
|
63
|
+
const [activeModal, setActiveModal] = createSignal<ModalType>("none")
|
|
64
|
+
const [pendingAction, setPendingAction] = createSignal<PendingAction | null>(null)
|
|
65
|
+
const [isAttaching, setIsAttaching] = createSignal(false)
|
|
66
|
+
|
|
67
|
+
// Terminal size tracking
|
|
68
|
+
const [termSize, setTermSize] = createSignal({ cols: process.stdout.columns, rows: process.stdout.rows })
|
|
69
|
+
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// Derived State
|
|
72
|
+
// ============================================================================
|
|
73
|
+
|
|
74
|
+
const selectedSession = (): Session | null => {
|
|
75
|
+
const sessionList = sessions()
|
|
76
|
+
const index = selectedIndex()
|
|
77
|
+
if (index >= 0 && index < sessionList.length) {
|
|
78
|
+
return sessionList[index] ?? null
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sessionListWidth = () => Math.floor(termSize().cols * 0.3)
|
|
84
|
+
|
|
85
|
+
// Calculate optimal terminal size for preview
|
|
86
|
+
const previewSize = () => {
|
|
87
|
+
const { cols, rows } = termSize()
|
|
88
|
+
// Preview is 70% width. Subtract 4 for borders/padding.
|
|
89
|
+
// Height is full minus header/footer status bars (2 lines) and borders (2 lines)
|
|
90
|
+
// We use rows - 2 to be aggressive and fill the space, allowing slight scroll if needed
|
|
91
|
+
// rather than showing a gap.
|
|
92
|
+
return {
|
|
93
|
+
cols: Math.max(20, Math.floor(cols * 0.7) - 4),
|
|
94
|
+
rows: Math.max(10, rows - 2)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Data Loading
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
const loadSessions = () => {
|
|
103
|
+
try {
|
|
104
|
+
const loadedSessions = getAllSessions()
|
|
105
|
+
setSessions(loadedSessions)
|
|
106
|
+
// Clamp selectedIndex to valid range
|
|
107
|
+
if (loadedSessions.length > 0) {
|
|
108
|
+
setSelectedIndex((prev) =>
|
|
109
|
+
Math.min(prev, loadedSessions.length - 1)
|
|
110
|
+
)
|
|
111
|
+
} else {
|
|
112
|
+
setSelectedIndex(0)
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Database might not be initialized
|
|
116
|
+
setSessions([])
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const loadProjectInfo = () => {
|
|
121
|
+
try {
|
|
122
|
+
const project = getProject()
|
|
123
|
+
if (project) {
|
|
124
|
+
setProjectInfo({
|
|
125
|
+
repoFullName: project.repoFullName,
|
|
126
|
+
repoUrl: project.repoUrl,
|
|
127
|
+
})
|
|
128
|
+
logger.debug("Loaded project info", project)
|
|
129
|
+
} else {
|
|
130
|
+
logger.warn("Project info missing from database")
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
setProjectInfo(null)
|
|
134
|
+
logger.warn("Failed to load project info")
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ============================================================================
|
|
139
|
+
// Effects
|
|
140
|
+
// ============================================================================
|
|
141
|
+
|
|
142
|
+
// Load initial data on mount and set up periodic refresh
|
|
143
|
+
onMount(() => {
|
|
144
|
+
// Recover any orphaned sessions from previous runs
|
|
145
|
+
sessionManager.recoverSessions()
|
|
146
|
+
|
|
147
|
+
loadSessions()
|
|
148
|
+
loadProjectInfo()
|
|
149
|
+
|
|
150
|
+
// Set up periodic refresh for dashboard data
|
|
151
|
+
const refreshId = setInterval(() => {
|
|
152
|
+
loadSessions()
|
|
153
|
+
}, REFRESH_INTERVAL)
|
|
154
|
+
|
|
155
|
+
// Set up faster refresh for preview activities
|
|
156
|
+
const previewId = setInterval(() => {
|
|
157
|
+
const session = selectedSession()
|
|
158
|
+
if (session && session.status === "active") {
|
|
159
|
+
sessionManager.getSnapshot(session.id).then(lines => {
|
|
160
|
+
setSnapshotLines(lines)
|
|
161
|
+
}).catch(() => setSnapshotLines([]))
|
|
162
|
+
|
|
163
|
+
// Sync terminal size if not currently attaching/attached
|
|
164
|
+
if (!isAttaching()) {
|
|
165
|
+
const size = previewSize()
|
|
166
|
+
sessionManager.resizeSession(session.id, size.cols, size.rows).catch(() => {})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}, REFRESH_INTERVAL)
|
|
170
|
+
|
|
171
|
+
// Handle Window Resize
|
|
172
|
+
const onResize = () => {
|
|
173
|
+
setTermSize({ cols: process.stdout.columns, rows: process.stdout.rows })
|
|
174
|
+
}
|
|
175
|
+
process.on('SIGWINCH', onResize)
|
|
176
|
+
|
|
177
|
+
onCleanup(() => {
|
|
178
|
+
clearInterval(refreshId)
|
|
179
|
+
clearInterval(previewId)
|
|
180
|
+
process.off('SIGWINCH', onResize)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// Load preview activities when selection changes
|
|
185
|
+
createEffect(() => {
|
|
186
|
+
const session = selectedSession()
|
|
187
|
+
if (session) {
|
|
188
|
+
// Track session start time for duration display
|
|
189
|
+
if (session.status === "active" && session.createdAt) {
|
|
190
|
+
setSessionStartedAt(new Date(session.createdAt))
|
|
191
|
+
|
|
192
|
+
// Resize immediately on selection, but only if not attaching
|
|
193
|
+
if (!isAttaching()) {
|
|
194
|
+
const size = previewSize()
|
|
195
|
+
sessionManager.resizeSession(session.id, size.cols, size.rows).catch(() => {})
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
setSessionStartedAt(undefined)
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
setSessionStartedAt(undefined)
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// Keyboard Handling
|
|
207
|
+
// ============================================================================
|
|
208
|
+
|
|
209
|
+
useKeyboard((event) => {
|
|
210
|
+
const modal = activeModal()
|
|
211
|
+
|
|
212
|
+
// Handle modal-specific keys
|
|
213
|
+
if (modal === "confirm-delete") {
|
|
214
|
+
switch (event.name) {
|
|
215
|
+
case "escape":
|
|
216
|
+
setPendingAction(null)
|
|
217
|
+
setActiveModal("none")
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
case "return": {
|
|
221
|
+
const action = pendingAction()
|
|
222
|
+
if (action && action.type === "delete") {
|
|
223
|
+
deleteSessionWithWorktree(props.projectRoot, action.sessionId).then(() => {
|
|
224
|
+
setPendingAction(null)
|
|
225
|
+
setActiveModal("none")
|
|
226
|
+
loadSessions()
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
break
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (modal !== "none") {
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const sessionList = sessions()
|
|
240
|
+
|
|
241
|
+
switch (event.name) {
|
|
242
|
+
// Navigation
|
|
243
|
+
case "j":
|
|
244
|
+
case "down":
|
|
245
|
+
if (sessionList.length > 0) {
|
|
246
|
+
setSelectedIndex((prev) =>
|
|
247
|
+
Math.min(prev + 1, sessionList.length - 1)
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
break
|
|
251
|
+
|
|
252
|
+
case "k":
|
|
253
|
+
case "up":
|
|
254
|
+
if (sessionList.length > 0) {
|
|
255
|
+
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
|
256
|
+
}
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
// Session actions
|
|
260
|
+
case "n":
|
|
261
|
+
setActiveModal("manual")
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
case "d": {
|
|
265
|
+
const session = selectedSession()
|
|
266
|
+
if (session) {
|
|
267
|
+
setPendingAction({
|
|
268
|
+
type: "delete",
|
|
269
|
+
sessionId: session.id,
|
|
270
|
+
sessionName: session.name,
|
|
271
|
+
})
|
|
272
|
+
setActiveModal("confirm-delete")
|
|
273
|
+
}
|
|
274
|
+
break
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
case "p": {
|
|
278
|
+
const session = selectedSession()
|
|
279
|
+
if (session) {
|
|
280
|
+
if (session.status === "paused") {
|
|
281
|
+
updateSessionStatus(session.id, "queued")
|
|
282
|
+
} else if (session.status === "active" || session.status === "queued") {
|
|
283
|
+
updateSessionStatus(session.id, "paused")
|
|
284
|
+
}
|
|
285
|
+
loadSessions()
|
|
286
|
+
}
|
|
287
|
+
break
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
case "r":
|
|
291
|
+
loadSessions()
|
|
292
|
+
break
|
|
293
|
+
|
|
294
|
+
case "return":
|
|
295
|
+
// Full takeover mode - start session if needed, then attach
|
|
296
|
+
(async () => {
|
|
297
|
+
const session = selectedSession()
|
|
298
|
+
if (!session) return
|
|
299
|
+
|
|
300
|
+
setIsAttaching(true)
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
// If session needs starting, start it first and await completion
|
|
304
|
+
if (session.status === "queued" || session.status === "paused") {
|
|
305
|
+
await sessionManager.startSession({
|
|
306
|
+
sessionId: session.id,
|
|
307
|
+
prompt: session.issueNumber ? generateSWEPrompt(session) : undefined,
|
|
308
|
+
resumeSessionId: session.aiSessionData?.sessionId,
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Re-fetch session to get updated status after start
|
|
313
|
+
const updatedSession = getSession(session.id)
|
|
314
|
+
if (updatedSession?.status === "active") {
|
|
315
|
+
// Resize to full terminal size for interactive use
|
|
316
|
+
await sessionManager.resizeSession(session.id, termSize().cols, termSize().rows)
|
|
317
|
+
|
|
318
|
+
const cmd = sessionManager.getAttachCommand(session.id)
|
|
319
|
+
|
|
320
|
+
// Suspend OpenTUI before yielding terminal to tmux
|
|
321
|
+
renderer.suspend()
|
|
322
|
+
|
|
323
|
+
Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] })
|
|
324
|
+
|
|
325
|
+
// Resume OpenTUI - this properly re-establishes terminal state
|
|
326
|
+
renderer.resume()
|
|
327
|
+
|
|
328
|
+
// Resize back to preview size
|
|
329
|
+
const size = previewSize()
|
|
330
|
+
await sessionManager.resizeSession(session.id, size.cols, size.rows)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
loadSessions()
|
|
334
|
+
} catch (error) {
|
|
335
|
+
logger.error("Session start/attach failed:", error)
|
|
336
|
+
loadSessions()
|
|
337
|
+
} finally {
|
|
338
|
+
setIsAttaching(false)
|
|
339
|
+
}
|
|
340
|
+
})()
|
|
341
|
+
break
|
|
342
|
+
|
|
343
|
+
// Modal triggers
|
|
344
|
+
case "i":
|
|
345
|
+
if (projectInfo()) {
|
|
346
|
+
setActiveModal("issues")
|
|
347
|
+
} else {
|
|
348
|
+
logger.warn("Cannot open issues modal: project info not loaded")
|
|
349
|
+
}
|
|
350
|
+
break
|
|
351
|
+
|
|
352
|
+
case "?":
|
|
353
|
+
setActiveModal("help")
|
|
354
|
+
break
|
|
355
|
+
|
|
356
|
+
case "a":
|
|
357
|
+
setActiveModal("provider")
|
|
358
|
+
break
|
|
359
|
+
|
|
360
|
+
case "t":
|
|
361
|
+
setActiveModal("theme")
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
// Quit
|
|
365
|
+
case "q":
|
|
366
|
+
renderer.destroy()
|
|
367
|
+
break
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// ============================================================================
|
|
372
|
+
// Selection Handler
|
|
373
|
+
// ============================================================================
|
|
374
|
+
|
|
375
|
+
const handleSelect = (index: number) => {
|
|
376
|
+
setSelectedIndex(index)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ============================================================================
|
|
380
|
+
// Provider Change Handler
|
|
381
|
+
// ============================================================================
|
|
382
|
+
|
|
383
|
+
const handleProviderChange = async (backend: AIBackend) => {
|
|
384
|
+
sessionManager.setProvider(backend)
|
|
385
|
+
setProviderBranding(sessionManager.getProvider().branding)
|
|
386
|
+
setActiveModal("none")
|
|
387
|
+
|
|
388
|
+
// Persist to config file
|
|
389
|
+
try {
|
|
390
|
+
await saveGlobalConfig({ ai: { backend } })
|
|
391
|
+
} catch (error) {
|
|
392
|
+
logger.error("Failed to save provider preference:", error)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ============================================================================
|
|
397
|
+
// Theme Change Handler
|
|
398
|
+
// ============================================================================
|
|
399
|
+
|
|
400
|
+
const handleThemeChange = async (name: string) => {
|
|
401
|
+
setTheme(name)
|
|
402
|
+
setActiveModal("none")
|
|
403
|
+
|
|
404
|
+
// Persist to config file
|
|
405
|
+
try {
|
|
406
|
+
await saveGlobalConfig({ ui: { theme: name } })
|
|
407
|
+
} catch (error) {
|
|
408
|
+
logger.error("Failed to save theme preference:", error)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ============================================================================
|
|
413
|
+
// Render
|
|
414
|
+
// ============================================================================
|
|
415
|
+
|
|
416
|
+
return (
|
|
417
|
+
<box
|
|
418
|
+
flexDirection="column"
|
|
419
|
+
width="100%"
|
|
420
|
+
height="100%"
|
|
421
|
+
backgroundColor={colors().bg.primary}
|
|
422
|
+
>
|
|
423
|
+
{/* Header Status Bar */}
|
|
424
|
+
<StatusBar
|
|
425
|
+
variant="header"
|
|
426
|
+
repoName={projectInfo()?.repoFullName}
|
|
427
|
+
backend={props.config.ai.backend}
|
|
428
|
+
sessionId={selectedSession()?.id}
|
|
429
|
+
providerBranding={providerBranding()}
|
|
430
|
+
/>
|
|
431
|
+
|
|
432
|
+
{/* Main Content Area */}
|
|
433
|
+
<box flexDirection="row" flexGrow={1}>
|
|
434
|
+
<SessionList
|
|
435
|
+
sessions={sessions()}
|
|
436
|
+
selectedIndex={selectedIndex()}
|
|
437
|
+
onSelect={handleSelect}
|
|
438
|
+
listWidth={sessionListWidth()}
|
|
439
|
+
/>
|
|
440
|
+
<Preview
|
|
441
|
+
session={selectedSession()}
|
|
442
|
+
startedAt={sessionStartedAt()}
|
|
443
|
+
snapshotLines={snapshotLines()}
|
|
444
|
+
providerBranding={providerBranding()}
|
|
445
|
+
/>
|
|
446
|
+
</box>
|
|
447
|
+
|
|
448
|
+
{/* Footer Status Bar */}
|
|
449
|
+
<StatusBar variant="footer" />
|
|
450
|
+
|
|
451
|
+
{/* Modal Overlays */}
|
|
452
|
+
<Show when={activeModal() === "help"}>
|
|
453
|
+
<HelpModal onClose={() => setActiveModal("none")} />
|
|
454
|
+
</Show>
|
|
455
|
+
|
|
456
|
+
<Show when={activeModal() === "confirm-delete" && pendingAction()}>
|
|
457
|
+
<ConfirmDialog
|
|
458
|
+
title="Delete Session?"
|
|
459
|
+
message={`Are you sure you want to delete\n"${pendingAction()!.sessionName}"?\n\nThis will also remove the worktree.`}
|
|
460
|
+
onConfirm={async () => {
|
|
461
|
+
const action = pendingAction()
|
|
462
|
+
if (action) {
|
|
463
|
+
await deleteSessionWithWorktree(props.projectRoot, action.sessionId)
|
|
464
|
+
}
|
|
465
|
+
setPendingAction(null)
|
|
466
|
+
setActiveModal("none")
|
|
467
|
+
loadSessions()
|
|
468
|
+
}}
|
|
469
|
+
onCancel={() => {
|
|
470
|
+
setPendingAction(null)
|
|
471
|
+
setActiveModal("none")
|
|
472
|
+
}}
|
|
473
|
+
/>
|
|
474
|
+
</Show>
|
|
475
|
+
|
|
476
|
+
<Show when={activeModal() === "issues" && projectInfo()}>
|
|
477
|
+
<IssueSelectorModal
|
|
478
|
+
ownerRepo={projectInfo()!.repoFullName}
|
|
479
|
+
projectRoot={props.projectRoot}
|
|
480
|
+
currentBackend={props.config.ai.backend}
|
|
481
|
+
onClose={() => setActiveModal("none")}
|
|
482
|
+
onSessionsCreated={async (sessions) => {
|
|
483
|
+
// Auto-start all created sessions
|
|
484
|
+
for (const session of sessions) {
|
|
485
|
+
try {
|
|
486
|
+
await sessionManager.startSession({
|
|
487
|
+
sessionId: session.id,
|
|
488
|
+
prompt: generateSWEPrompt(session),
|
|
489
|
+
})
|
|
490
|
+
} catch (error) {
|
|
491
|
+
logger.error("Failed to auto-start session", { sessionId: session.id, error })
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
loadSessions()
|
|
495
|
+
}}
|
|
496
|
+
/>
|
|
497
|
+
</Show>
|
|
498
|
+
|
|
499
|
+
<Show when={activeModal() === "manual"}>
|
|
500
|
+
<ManualSessionModal
|
|
501
|
+
projectRoot={props.projectRoot}
|
|
502
|
+
currentBackend={props.config.ai.backend}
|
|
503
|
+
sessionManager={sessionManager}
|
|
504
|
+
onClose={() => setActiveModal("none")}
|
|
505
|
+
onSessionCreated={() => loadSessions()}
|
|
506
|
+
/>
|
|
507
|
+
</Show>
|
|
508
|
+
|
|
509
|
+
<Show when={activeModal() === "provider"}>
|
|
510
|
+
<ProviderSwitcherModal
|
|
511
|
+
currentBackend={props.config.ai.backend}
|
|
512
|
+
onSelect={handleProviderChange}
|
|
513
|
+
onClose={() => setActiveModal("none")}
|
|
514
|
+
/>
|
|
515
|
+
</Show>
|
|
516
|
+
|
|
517
|
+
<Show when={activeModal() === "theme"}>
|
|
518
|
+
<ThemeSwitcherModal
|
|
519
|
+
currentTheme={themeName()}
|
|
520
|
+
onSelect={handleThemeChange}
|
|
521
|
+
onClose={() => setActiveModal("none")}
|
|
522
|
+
/>
|
|
523
|
+
</Show>
|
|
524
|
+
</box>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConfirmDialog component - Reusable confirmation dialog
|
|
3
|
+
*
|
|
4
|
+
* Used for destructive actions like deleting sessions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { For } from "solid-js"
|
|
8
|
+
import { Footer } from "./Footer"
|
|
9
|
+
import { ScrollableText } from "./ScrollableText"
|
|
10
|
+
import type { ConfirmDialogProps } from "./types"
|
|
11
|
+
import { useColors } from "./theme"
|
|
12
|
+
|
|
13
|
+
// Bold attribute constant
|
|
14
|
+
const BOLD = 1
|
|
15
|
+
|
|
16
|
+
export function ConfirmDialog(props: ConfirmDialogProps) {
|
|
17
|
+
const colors = useColors()
|
|
18
|
+
const modalWidth = 45
|
|
19
|
+
|
|
20
|
+
// Split message into lines
|
|
21
|
+
const messageLines = () => props.message.split("\n")
|
|
22
|
+
// Height: title(1) + message lines + padding(2) + footer(1) + borders(2)
|
|
23
|
+
const modalHeight = () => messageLines().length + 6
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<box
|
|
27
|
+
position="absolute"
|
|
28
|
+
top={0}
|
|
29
|
+
left={0}
|
|
30
|
+
width="100%"
|
|
31
|
+
height="100%"
|
|
32
|
+
justifyContent="center"
|
|
33
|
+
alignItems="center"
|
|
34
|
+
>
|
|
35
|
+
{/* Modal container */}
|
|
36
|
+
<box
|
|
37
|
+
flexDirection="column"
|
|
38
|
+
width={modalWidth}
|
|
39
|
+
height={modalHeight()}
|
|
40
|
+
backgroundColor={colors().bg.secondary}
|
|
41
|
+
borderStyle="rounded"
|
|
42
|
+
borderColor={colors().border.accent}
|
|
43
|
+
overflow="hidden"
|
|
44
|
+
>
|
|
45
|
+
{/* Header / Title */}
|
|
46
|
+
<box
|
|
47
|
+
height={1}
|
|
48
|
+
justifyContent="center"
|
|
49
|
+
paddingLeft={1}
|
|
50
|
+
paddingRight={1}
|
|
51
|
+
>
|
|
52
|
+
<text fg={colors().text.primary} attributes={BOLD}>
|
|
53
|
+
{props.title}
|
|
54
|
+
</text>
|
|
55
|
+
</box>
|
|
56
|
+
|
|
57
|
+
{/* Message content */}
|
|
58
|
+
<box
|
|
59
|
+
flexDirection="column"
|
|
60
|
+
flexGrow={1}
|
|
61
|
+
paddingLeft={2}
|
|
62
|
+
paddingRight={2}
|
|
63
|
+
paddingTop={1}
|
|
64
|
+
paddingBottom={1}
|
|
65
|
+
>
|
|
66
|
+
<For each={messageLines()}>
|
|
67
|
+
{(line) => (
|
|
68
|
+
<ScrollableText
|
|
69
|
+
text={line || " "}
|
|
70
|
+
width={38}
|
|
71
|
+
fg={colors().text.primary}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
</For>
|
|
75
|
+
</box>
|
|
76
|
+
|
|
77
|
+
{/* Footer with action hints */}
|
|
78
|
+
<Footer
|
|
79
|
+
actions={[
|
|
80
|
+
{ key: "Enter", label: "Confirm" },
|
|
81
|
+
{ key: "Esc", label: "Cancel" },
|
|
82
|
+
]}
|
|
83
|
+
bgColor={colors().bg.primary}
|
|
84
|
+
/>
|
|
85
|
+
</box>
|
|
86
|
+
</box>
|
|
87
|
+
)
|
|
88
|
+
}
|