@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,701 @@
1
+ import { createSignal, onMount, For, Show, createEffect, onCleanup } from "solid-js"
2
+ import { useKeyboard } from "@opentui/solid"
3
+ import type { IssueSelectorModalProps } from "./types"
4
+ import type { Session } from "../store"
5
+ import { fetchIssues, formatRelativeTime, type GitHubIssue, type IssueState } from "../github"
6
+ import { createSessionFromIssue, findNextAvailableWorktreeName } from "./session-utils"
7
+ import { ScrollableText } from "./ScrollableText"
8
+ import { useColors, borders } from "./theme"
9
+ import { Footer } from "./Footer"
10
+ import { logger } from "../utils/logger"
11
+
12
+ // Bold attribute constant
13
+ const BOLD = 1
14
+ const ITEMS_PER_PAGE = 7 // Increased slightly since rows are more compact
15
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
16
+
17
+ /** State filter options */
18
+ const STATE_FILTERS: IssueState[] = ["open", "closed", "all"]
19
+
20
+ export function IssueSelectorModal(props: IssueSelectorModalProps) {
21
+ const colors = useColors()
22
+
23
+ // ============================================================================
24
+ // State
25
+ // ============================================================================
26
+
27
+ const [issues, setIssues] = createSignal<GitHubIssue[]>([])
28
+ const [selectedIndices, setSelectedIndices] = createSignal<Set<number>>(new Set())
29
+ const [focusedIndex, setFocusedIndex] = createSignal(0)
30
+ const [scrollOffset, setScrollOffset] = createSignal(0)
31
+ const [stateFilter, setStateFilter] = createSignal<IssueState>("open")
32
+ const [loading, setLoading] = createSignal(true)
33
+ const [error, setError] = createSignal<string | null>(null)
34
+ const [creating, setCreating] = createSignal(false)
35
+ const [spinnerFrame, setSpinnerFrame] = createSignal(0)
36
+ const [resolving, setResolving] = createSignal(false)
37
+
38
+ // Animation effect
39
+ createEffect(() => {
40
+ if (creating()) {
41
+ const interval = setInterval(() => {
42
+ setSpinnerFrame((f) => (f + 1) % SPINNER_FRAMES.length)
43
+ }, 80)
44
+ onCleanup(() => clearInterval(interval))
45
+ }
46
+ })
47
+
48
+ // Conflict resolution state
49
+ const [conflictIssue, setConflictIssue] = createSignal<GitHubIssue | null>(null)
50
+ const [suggestedName, setSuggestedName] = createSignal<string>("")
51
+ const [conflictChoice, setConflictChoice] = createSignal<0 | 1 | 2>(0) // 0: Use existing, 1: Create new, 2: Overwrite
52
+ const [pendingIndices, setPendingIndices] = createSignal<number[]>([])
53
+ const [successCount, setSuccessCount] = createSignal(0)
54
+ const [createdSessions, setCreatedSessions] = createSignal<Session[]>([])
55
+
56
+ // ============================================================================
57
+ // Data Loading
58
+ // ============================================================================
59
+
60
+ const loadIssues = async () => {
61
+ setLoading(true)
62
+ setError(null)
63
+
64
+ logger.debug("Loading issues", {
65
+ repo: props.ownerRepo,
66
+ state: stateFilter(),
67
+ })
68
+
69
+ const result = await fetchIssues(props.ownerRepo, {
70
+ state: stateFilter(),
71
+ limit: 500,
72
+ })
73
+
74
+ setLoading(false)
75
+
76
+ if (result.success) {
77
+ setIssues(result.issues)
78
+ setFocusedIndex(0)
79
+ setScrollOffset(0)
80
+ setSelectedIndices(new Set<number>())
81
+ logger.debug("Issues loaded", { count: result.issues.length })
82
+ } else {
83
+ setError(result.error ?? "Failed to fetch issues")
84
+ setIssues([])
85
+ logger.warn("Issue fetch failed", result.error)
86
+ }
87
+ }
88
+
89
+ onMount(() => {
90
+ loadIssues()
91
+ })
92
+
93
+ // ============================================================================
94
+ // Actions
95
+ // ============================================================================
96
+
97
+ const toggleSelection = (index: number) => {
98
+ setSelectedIndices((prev) => {
99
+ const newSet = new Set<number>(prev)
100
+ if (newSet.has(index)) {
101
+ newSet.delete(index)
102
+ } else {
103
+ newSet.add(index)
104
+ }
105
+ return newSet
106
+ })
107
+ }
108
+
109
+ const cycleStateFilter = () => {
110
+ const currentIndex = STATE_FILTERS.indexOf(stateFilter())
111
+ const nextIndex = (currentIndex + 1) % STATE_FILTERS.length
112
+ setStateFilter(STATE_FILTERS[nextIndex]!)
113
+ loadIssues()
114
+ }
115
+
116
+ // Recursive function to process the queue of selected issues
117
+ const processNextIssue = async () => {
118
+ // Check if cancelled
119
+ if (!creating()) return
120
+
121
+ const indices = pendingIndices()
122
+ if (indices.length === 0) {
123
+ // All done
124
+ finishCreation()
125
+ return
126
+ }
127
+
128
+ const index = indices[0]! // Get first
129
+ const issueList = issues()
130
+ const issue = issueList[index]
131
+
132
+ if (!issue) {
133
+ // Skip invalid index
134
+ setPendingIndices(indices.slice(1))
135
+ await processNextIssue()
136
+ return
137
+ }
138
+
139
+ // Try to create session normally first
140
+ const result = await createSessionFromIssue(props.projectRoot, issue, {
141
+ aiSessionData: { backend: props.currentBackend }
142
+ })
143
+
144
+ // Check if cancelled during await
145
+ if (!creating()) return
146
+
147
+ if (result.success && result.session) {
148
+ setSuccessCount((c) => c + 1)
149
+ setCreatedSessions((prev) => [...prev, result.session!])
150
+ logger.debug("Session created from issue", { issueNumber: issue.number })
151
+ // Remove from queue and continue
152
+ setPendingIndices(indices.slice(1))
153
+ await processNextIssue()
154
+ } else if (!result.success) {
155
+ // Check if it's a conflict
156
+ if (result.error?.toLowerCase().includes("already exists")) {
157
+ // Find suggested name
158
+ const suggestion = await findNextAvailableWorktreeName(props.projectRoot, issue.number)
159
+
160
+ // Check if cancelled during await
161
+ if (!creating()) return
162
+
163
+ // Pause and show conflict UI
164
+ setConflictIssue(issue)
165
+ setSuggestedName(suggestion)
166
+ setConflictChoice(0) // Default to "Continue"
167
+ // Don't remove from queue yet, we'll process it after resolution
168
+ } else {
169
+ // Other error, just log and continue
170
+ logger.warn("Failed to create session from issue", {
171
+ issueNumber: issue.number,
172
+ error: result.error,
173
+ })
174
+ setError(result.error ?? "Failed to create session")
175
+
176
+ // Remove from queue and continue
177
+ setPendingIndices(indices.slice(1))
178
+ await processNextIssue()
179
+ }
180
+ }
181
+ }
182
+
183
+ const resolveConflict = async () => {
184
+ if (resolving()) return
185
+ const issue = conflictIssue()
186
+ if (!issue) return
187
+
188
+ setResolving(true)
189
+ const choice = conflictChoice()
190
+ const suggestion = suggestedName()
191
+
192
+ // Create with chosen strategy
193
+ const result = await createSessionFromIssue(props.projectRoot, issue, {
194
+ worktreeNameOverride: choice === 1 ? suggestion : undefined,
195
+ overwriteWorktreeChoice: choice, // Maps directly to OverwriteWorktreeChoice enum
196
+ aiSessionData: { backend: props.currentBackend }
197
+ })
198
+
199
+ setResolving(false)
200
+
201
+ // Check if cancelled during await
202
+ if (!creating()) return
203
+
204
+ if (result.success && result.session) {
205
+ setSuccessCount((c) => c + 1)
206
+ setCreatedSessions((prev) => [...prev, result.session!])
207
+ logger.debug("Session created after conflict resolution", { issueNumber: issue.number })
208
+ } else {
209
+ logger.warn("Failed to create session after conflict resolution", {
210
+ issueNumber: issue.number,
211
+ error: result.error,
212
+ })
213
+ setError(result.error ?? "Failed to create session")
214
+ }
215
+
216
+ // Clear conflict state
217
+ setConflictIssue(null)
218
+ setSuggestedName("")
219
+
220
+ // Remove current issue from queue (it's done now)
221
+ setPendingIndices((prev) => prev.slice(1))
222
+
223
+ // Continue processing
224
+ await processNextIssue()
225
+ }
226
+
227
+ const finishCreation = () => {
228
+ setCreating(false)
229
+ const created = createdSessions()
230
+ if (created.length > 0) {
231
+ props.onSessionsCreated(created)
232
+ props.onClose()
233
+ }
234
+ }
235
+
236
+ const startCreation = async () => {
237
+ let selected = new Set(selectedIndices())
238
+
239
+ // Auto-select focused if nothing selected
240
+ if (selected.size === 0 && issues().length > 0) {
241
+ const focused = focusedIndex()
242
+ selected.add(focused)
243
+ toggleSelection(focused)
244
+ }
245
+
246
+ if (selected.size === 0) return
247
+
248
+ setCreating(true)
249
+ setSuccessCount(0)
250
+ setError(null)
251
+ setCreatedSessions([])
252
+
253
+ // Initialize queue
254
+ setPendingIndices(Array.from(selected))
255
+
256
+ logger.debug("Creating sessions from selected issues", {
257
+ selectedCount: selected.size,
258
+ })
259
+
260
+ // Start processing
261
+ await processNextIssue()
262
+ }
263
+
264
+ // ============================================================================
265
+ // Keyboard Handling
266
+ // ============================================================================
267
+
268
+ useKeyboard((event) => {
269
+ // If conflict UI is open, handle keys there
270
+ if (conflictIssue()) {
271
+ switch (event.name) {
272
+ case "up":
273
+ case "k":
274
+ if (conflictChoice() != 0) {
275
+ setConflictChoice((conflictChoice() - 1) as 0 | 1 | 2)
276
+ }
277
+ break
278
+ case "down":
279
+ case "j":
280
+ if (conflictChoice() != 2) {
281
+ setConflictChoice((conflictChoice() + 1) as 0 | 1 | 2)
282
+ }
283
+ break
284
+ case "enter":
285
+ case "return":
286
+ resolveConflict()
287
+ break
288
+ case "escape":
289
+ // Cancel everything
290
+ setConflictIssue(null)
291
+ setPendingIndices([])
292
+ setCreating(false)
293
+ break
294
+ }
295
+ return
296
+ }
297
+
298
+ const issueList = issues()
299
+
300
+ // Allow Escape to cancel creation or close modal
301
+ if (event.name === "escape") {
302
+ if (creating()) {
303
+ // Cancel creation
304
+ setPendingIndices([])
305
+ setCreating(false)
306
+ setConflictIssue(null)
307
+ setResolving(false)
308
+ } else {
309
+ props.onClose()
310
+ }
311
+ return
312
+ }
313
+
314
+ if (loading()) return
315
+
316
+ // If creating, only handle specific keys for conflict
317
+ if (creating()) {
318
+ if (conflictIssue()) {
319
+ switch (event.name) {
320
+ case "up":
321
+ case "k":
322
+ if (conflictChoice() != 0) {
323
+ setConflictChoice((conflictChoice() - 1) as 0 | 1 | 2)
324
+ }
325
+ break
326
+ case "down":
327
+ case "j":
328
+ if (conflictChoice() != 2) {
329
+ setConflictChoice((conflictChoice() + 1) as 0 | 1 | 2)
330
+ }
331
+ break
332
+ case "enter":
333
+ case "return":
334
+ resolveConflict()
335
+ break
336
+ }
337
+ }
338
+ return
339
+ }
340
+
341
+ switch (event.name) {
342
+ case "j":
343
+ case "down":
344
+ if (issueList.length > 0) {
345
+ const nextIndex = Math.min(focusedIndex() + 1, issueList.length - 1)
346
+ setFocusedIndex(nextIndex)
347
+ if (nextIndex >= scrollOffset() + ITEMS_PER_PAGE) {
348
+ setScrollOffset(nextIndex - ITEMS_PER_PAGE + 1)
349
+ }
350
+ }
351
+ break
352
+
353
+ case "k":
354
+ case "up":
355
+ if (issueList.length > 0) {
356
+ const prevIndex = Math.max(focusedIndex() - 1, 0)
357
+ setFocusedIndex(prevIndex)
358
+ if (prevIndex < scrollOffset()) {
359
+ setScrollOffset(prevIndex)
360
+ }
361
+ }
362
+ break
363
+
364
+ case "space":
365
+ if (issueList.length > 0) {
366
+ toggleSelection(focusedIndex())
367
+ }
368
+ break
369
+
370
+ case "tab":
371
+ cycleStateFilter()
372
+ break
373
+
374
+ case "return":
375
+ startCreation()
376
+ break
377
+
378
+ case "a":
379
+ // Select all
380
+ if (issueList.length > 0) {
381
+ const allIndices = new Set<number>(issueList.map((_, i) => i))
382
+ if (selectedIndices().size === issueList.length) {
383
+ setSelectedIndices(new Set<number>())
384
+ } else {
385
+ setSelectedIndices(allIndices)
386
+ }
387
+ }
388
+ break
389
+
390
+ case "r":
391
+ // Refresh
392
+ loadIssues()
393
+ break
394
+ }
395
+ })
396
+
397
+ // ============================================================================
398
+ // Render Helpers
399
+ // ============================================================================
400
+
401
+ const truncate = (text: string, maxLen: number): string => {
402
+ if (text.length <= maxLen) return text
403
+ return text.slice(0, maxLen - 1) + "…"
404
+ }
405
+
406
+ const formatLabels = (labels: { name: string }[]): string => {
407
+ if (labels.length === 0) return ""
408
+ const names = labels.slice(0, 2).map((l) => `[${l.name}]`)
409
+ if (labels.length > 2) {
410
+ names.push(`+${labels.length - 2}`)
411
+ }
412
+ return names.join(" ")
413
+ }
414
+
415
+ // ============================================================================
416
+ // Render
417
+ // ============================================================================
418
+
419
+ const modalWidth = 80
420
+ const modalHeight = 24
421
+ const selectedCount = () => selectedIndices().size
422
+
423
+ return (
424
+ <box
425
+ position="absolute"
426
+ top={0}
427
+ left={0}
428
+ width="100%"
429
+ height="100%"
430
+ justifyContent="center"
431
+ alignItems="center"
432
+ >
433
+ {/* Modal container */}
434
+ <box
435
+ flexDirection="column"
436
+ width={modalWidth}
437
+ height={modalHeight}
438
+ backgroundColor={colors().bg.secondary}
439
+ borderStyle="rounded"
440
+ borderColor={colors().border.accent}
441
+ overflow="hidden"
442
+ >
443
+ <Show when={creating()} fallback={
444
+ // Standard Issue List View
445
+ <>
446
+ {/* Header with Tabs */}
447
+ <box
448
+ height={1}
449
+ paddingLeft={1}
450
+ paddingRight={1}
451
+ justifyContent="space-between"
452
+ backgroundColor={colors().bg.secondary}
453
+ >
454
+ <box flexDirection="row" flexGrow={1} overflow="hidden" marginRight={2}>
455
+ <text fg={colors().text.primary} attributes={BOLD}>
456
+ {props.ownerRepo}
457
+ </text>
458
+ </box>
459
+
460
+ {/* Filter Tabs */}
461
+ <box flexDirection="row" gap={1}>
462
+ <For each={STATE_FILTERS}>
463
+ {(state) => (
464
+ <text
465
+ fg={stateFilter() === state ? colors().accent.primary : colors().text.muted}
466
+ attributes={stateFilter() === state ? BOLD : 0}
467
+ >
468
+ {stateFilter() === state ? `[ ${state} ]` : ` ${state} `}
469
+ </text>
470
+ )}
471
+ </For>
472
+ </box>
473
+ </box>
474
+
475
+ {/* Content area */}
476
+ <box
477
+ flexDirection="column"
478
+ flexGrow={1}
479
+ paddingTop={1}
480
+ overflow="hidden"
481
+ >
482
+ <Show when={loading()}>
483
+ <box justifyContent="center" alignItems="center" flexGrow={1}>
484
+ <text fg={colors().text.muted}>Loading issues...</text>
485
+ </box>
486
+ </Show>
487
+
488
+ <Show when={error() && !loading()}>
489
+ <box justifyContent="center" alignItems="center" flexGrow={1}>
490
+ <text fg={colors().accent.error}>{error()}</text>
491
+ </box>
492
+ </Show>
493
+
494
+ <Show when={!loading() && !error() && issues().length === 0}>
495
+ <box justifyContent="center" alignItems="center" flexGrow={1}>
496
+ <text fg={colors().text.muted}>No issues found</text>
497
+ </box>
498
+ </Show>
499
+
500
+ <Show when={!loading() && !error() && issues().length > 0}>
501
+ <box
502
+ flexDirection="column"
503
+ flexGrow={1}
504
+ width="100%"
505
+ >
506
+ <For each={issues().slice(scrollOffset(), scrollOffset() + ITEMS_PER_PAGE)}>
507
+ {(issue, i) => {
508
+ const index = () => scrollOffset() + i()
509
+ const isFocused = () => focusedIndex() === index()
510
+ const isSelected = () => selectedIndices().has(index())
511
+
512
+ const textColor = () => isFocused() ? colors().text.primary : colors().text.secondary
513
+ const mutedColor = () => isFocused() ? colors().text.primary : colors().text.muted
514
+
515
+ return (
516
+ <box
517
+ flexDirection="row"
518
+ height={1} // Single line rows
519
+ backgroundColor={isFocused() ? colors().bg.cardSelected : undefined}
520
+ paddingLeft={0}
521
+ paddingRight={1}
522
+ alignItems="center"
523
+ overflow="hidden"
524
+ >
525
+ {/* Focus Indicator */}
526
+ <box width={2}>
527
+ <text fg={colors().accent.primary}>
528
+ {isFocused() ? "▍" : " "}
529
+ </text>
530
+ </box>
531
+
532
+ {/* Selection Checkbox */}
533
+ <box width={4}>
534
+ <text
535
+ fg={isSelected()
536
+ ? (isFocused() ? colors().text.primary : colors().accent.success)
537
+ : mutedColor()
538
+ }
539
+ attributes={isSelected() ? BOLD : 0}
540
+ >
541
+ {isSelected() ? "[✓]" : "[ ]"}
542
+ </text>
543
+ </box>
544
+
545
+ {/* Issue ID (Fixed width, right alignedish) */}
546
+ <box width={6}>
547
+ <text fg={textColor()}>
548
+ {`#${issue.number}`.padEnd(5)}
549
+ </text>
550
+ </box>
551
+
552
+ {/* Title (Flex) */}
553
+ <box flexGrow={1} marginRight={2} overflow="hidden">
554
+ <ScrollableText
555
+ text={issue.title}
556
+ width={30}
557
+ isActive={isFocused()}
558
+ fg={isFocused() ? colors().text.primary : colors().text.secondary}
559
+ attributes={isFocused() ? BOLD : 0}
560
+ />
561
+ </box>
562
+
563
+ {/* Labels (Variable) */}
564
+ <box width={20} overflow="hidden">
565
+ <Show when={issue.labels.length > 0}>
566
+ <text fg={mutedColor()}>
567
+ {formatLabels(issue.labels)}
568
+ </text>
569
+ </Show>
570
+ </box>
571
+
572
+ {/* Time (Right aligned) */}
573
+ <box width={12} justifyContent="flex-end">
574
+ <text fg={mutedColor()}>
575
+ {formatRelativeTime(issue.updatedAt)}
576
+ </text>
577
+ </box>
578
+ </box>
579
+ )
580
+ }}
581
+ </For>
582
+ </box>
583
+ </Show>
584
+ </box>
585
+
586
+ {/* Footer */}
587
+ <Footer
588
+ bgColor={colors().bg.primary}
589
+ actions={[
590
+ { key: "Space", label: "Toggle" },
591
+ { key: "a", label: "All" },
592
+ { key: "Tab", label: "Filter" },
593
+ ...(selectedCount() > 0
594
+ ? [{ key: "Enter", label: `Create (${selectedCount()})` }]
595
+ : [{ key: "Enter", label: "Create" }]
596
+ ),
597
+ { key: "Esc", label: "Close" },
598
+ ]}
599
+ />
600
+ </>
601
+ }>
602
+ {/* Creating / Conflict State */}
603
+ <Show when={!!conflictIssue()} fallback={
604
+ // Progress View
605
+ <box flexDirection="column" width="100%" height="100%" justifyContent="center" alignItems="center">
606
+ <text fg={colors().accent.primary} attributes={BOLD}>Creating Sessions...</text>
607
+ <box height={1} />
608
+ <box flexDirection="row" gap={1}>
609
+ <text fg={colors().accent.primary}>{SPINNER_FRAMES[spinnerFrame()]}</text>
610
+ <text fg={colors().text.secondary}>
611
+ Processing {successCount() + 1} of {selectedCount() || 1}
612
+ </text>
613
+ </box>
614
+
615
+ {/* Current Issue being processed */}
616
+ <Show when={pendingIndices().length > 0}>
617
+ <box marginTop={1} padding={1} borderColor={colors().border.secondary} borderStyle="rounded">
618
+ <text fg={colors().text.primary}>
619
+ #{issues()[pendingIndices()[0]!]?.number} {truncate(issues()[pendingIndices()[0]!]?.title || "", 40)}
620
+ </text>
621
+ </box>
622
+ </Show>
623
+
624
+ <box height={2} />
625
+ <text fg={colors().text.muted}>Please wait...</text>
626
+ </box>
627
+ }>
628
+ {/* Conflict Resolution View */}
629
+ <box flexDirection="column" width="100%" height="100%">
630
+ <box flexDirection="column" flexGrow={1} padding={2} justifyContent="center" alignItems="center">
631
+ <text fg={colors().accent.warning} attributes={BOLD}>Worktree Conflict</text>
632
+
633
+ <box height={1} />
634
+
635
+ <text fg={colors().text.primary}>
636
+ Worktree for issue #{conflictIssue()?.number} already exists.
637
+ </text>
638
+ <text fg={colors().text.secondary}>
639
+ Please select an action:
640
+ </text>
641
+
642
+ <box height={2} />
643
+
644
+ {/* Options */}
645
+ <box flexDirection="column" gap={1} width={50}>
646
+ {/* Continue worktree option*/}
647
+ <box flexDirection="row" gap={1}>
648
+ <text fg={conflictChoice() === 0 ? colors().accent.primary : colors().text.muted}>
649
+ {conflictChoice() === 0 ? "●" : "○"}
650
+ </text>
651
+ <text
652
+ fg={conflictChoice() === 0 ? colors().text.primary : colors().text.secondary}
653
+ attributes={conflictChoice() === 0 ? BOLD : 0}
654
+ >
655
+ Continue with existing worktree
656
+ </text>
657
+ </box>
658
+
659
+ {/* Create new option */}
660
+ <box flexDirection="row" gap={1}>
661
+ <text fg={conflictChoice() === 1 ? colors().accent.primary : colors().text.muted}>
662
+ {conflictChoice() === 1 ? "●" : "○"}
663
+ </text>
664
+ <text
665
+ fg={conflictChoice() === 1 ? colors().text.primary : colors().text.secondary}
666
+ attributes={conflictChoice() === 1 ? BOLD : 0}
667
+ >
668
+ Create new worktree ({suggestedName()})
669
+ </text>
670
+ </box>
671
+
672
+ {/* Overwrite option */}
673
+ <box flexDirection="row" gap={1}>
674
+ <text fg={conflictChoice() === 2 ? colors().accent.primary : colors().text.muted}>
675
+ {conflictChoice() === 2 ? "●" : "○"}
676
+ </text>
677
+ <text
678
+ fg={conflictChoice() === 2 ? colors().text.primary : colors().text.secondary}
679
+ attributes={conflictChoice() === 2 ? BOLD : 0}
680
+ >
681
+ Overwrite worktree
682
+ </text>
683
+ </box>
684
+ </box>
685
+ </box>
686
+
687
+ <Footer
688
+ bgColor={colors().bg.primary}
689
+ actions={[
690
+ { key: "Enter", label: resolving() ? "Resolving..." : "Confirm" },
691
+ { key: "Esc", label: "Cancel Batch" },
692
+ ]}
693
+ />
694
+ </box>
695
+ </Show>
696
+ </Show>
697
+ </box>
698
+
699
+ </box>
700
+ )
701
+ }