@brimveyn/aimux 1.20.4 → 1.21.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 (34) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +3 -48
  3. package/src/app-runtime/split-drag-controller.ts +4 -8
  4. package/src/app-runtime/use-mouse-handlers.ts +27 -42
  5. package/src/app-runtime/use-terminal-resize.ts +11 -20
  6. package/src/app.tsx +9 -37
  7. package/src/config.ts +98 -8
  8. package/src/git/pr-merge.ts +64 -0
  9. package/src/git/pr-status-poller.ts +57 -0
  10. package/src/git/pr-status.ts +227 -0
  11. package/src/index.tsx +7 -2
  12. package/src/platform/open-url.ts +39 -0
  13. package/src/services/ai-usage/spawn.ts +3 -1
  14. package/src/state/bars.ts +75 -0
  15. package/src/state/git-pane-sizing.ts +0 -9
  16. package/src/state/pr-status-store.ts +39 -0
  17. package/src/state/reducers/git-panel-state.ts +0 -47
  18. package/src/state/reducers/ui-state.ts +83 -13
  19. package/src/state/session-persistence.ts +5 -7
  20. package/src/state/store.ts +28 -35
  21. package/src/state/types.ts +24 -19
  22. package/src/state/workspace-save.ts +5 -7
  23. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +2 -1
  24. package/src/ui/components/git/pane/git-pane-header.tsx +105 -39
  25. package/src/ui/components/git/pane/git-pane-widget.tsx +30 -16
  26. package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
  27. package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
  28. package/src/ui/components/layout/bar.tsx +191 -0
  29. package/src/ui/components/layout/top-tab-bar.tsx +3 -2
  30. package/src/ui/root.tsx +25 -109
  31. package/src/ui/widgets/registry.tsx +18 -0
  32. package/src/ui/widgets/widget-context-menu.ts +69 -0
  33. package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
  34. package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
@@ -0,0 +1,197 @@
1
+ import { memo, useCallback, useState } from 'react'
2
+
3
+ import {
4
+ clampPrBody,
5
+ type PrCheck,
6
+ type PrCheckState,
7
+ type PrStatusResult,
8
+ } from '../../../../git/pr-status'
9
+ import { openUrl } from '../../../../platform/open-url'
10
+ import { usePrStatusStore } from '../../../../state/pr-status-store'
11
+ import { useBusySpinner } from '../../../hooks/use-busy-spinner'
12
+ import { type ResolvedTuiTheme, useTheme, useTransparent } from '../../../theme'
13
+
14
+ /** Below this the workflow column crowds out the check name. */
15
+ const WORKFLOW_MIN_WIDTH = 34
16
+
17
+ const HIDDEN_SCROLLBAR_OPTIONS = { visible: false }
18
+ /** gap 1 separates title / body / checks; the rows inside stay tight. */
19
+ const AIRY_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 1 }
20
+
21
+ const STATE_GLYPH: Record<Exclude<PrCheckState, 'pending'>, string> = {
22
+ cancel: '⊘',
23
+ fail: '✗',
24
+ pass: '✓',
25
+ skipping: '○',
26
+ }
27
+
28
+ function stateColor(state: PrCheckState, t: ResolvedTuiTheme): string {
29
+ if (state === 'pass') return t.success
30
+ if (state === 'fail') return t.error
31
+ if (state === 'pending') return t.warning
32
+ return t.textMuted
33
+ }
34
+
35
+ function formatDuration(ms: number | null): string {
36
+ if (ms === null) return '—'
37
+ const seconds = Math.round(ms / 1000)
38
+ if (seconds < 60) return `${seconds}s`
39
+ const minutes = Math.floor(seconds / 60)
40
+ return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
41
+ }
42
+
43
+ function placeholder(
44
+ result: PrStatusResult | null,
45
+ t: ResolvedTuiTheme
46
+ ): { label: string; color: string } | null {
47
+ if (result === null) return { color: t.textMuted, label: '…' }
48
+ if (result.kind === 'no-gh') return { color: t.textMuted, label: 'gh CLI not found' }
49
+ if (result.kind === 'no-pr') return { color: t.textMuted, label: 'No pull request' }
50
+ if (result.kind === 'error') return { color: t.error, label: result.message }
51
+ return null
52
+ }
53
+
54
+ const CheckRow = memo(function CheckRow({
55
+ bg,
56
+ check,
57
+ showWorkflow,
58
+ spinner,
59
+ }: {
60
+ check: PrCheck
61
+ showWorkflow: boolean
62
+ spinner: string
63
+ bg: string | undefined
64
+ }) {
65
+ const t = useTheme()
66
+ const onOpen = useCallback(() => {
67
+ openUrl(check.url)
68
+ }, [check.url])
69
+ const glyph = check.state === 'pending' ? spinner : STATE_GLYPH[check.state]
70
+ return (
71
+ <box flexDirection="row" gap={1} onMouseDown={onOpen}>
72
+ <box width={1} flexShrink={0}>
73
+ <text selectable={false} fg={stateColor(check.state, t)} bg={bg}>
74
+ {glyph}
75
+ </text>
76
+ </box>
77
+ <box flexGrow={1} overflow="hidden">
78
+ <text selectable={false} fg={t.text} bg={bg} wrapMode="none">
79
+ {check.name}
80
+ </text>
81
+ </box>
82
+ {showWorkflow && check.workflow !== '' ? (
83
+ <box flexShrink={0}>
84
+ <text selectable={false} fg={t.textMuted} bg={bg} wrapMode="none">
85
+ {check.workflow}
86
+ </text>
87
+ </box>
88
+ ) : null}
89
+ <box flexShrink={0}>
90
+ <text selectable={false} fg={t.textMuted} bg={bg} wrapMode="none">
91
+ {formatDuration(check.durationMs)}
92
+ </text>
93
+ </box>
94
+ </box>
95
+ )
96
+ })
97
+
98
+ export const PrChecksPanel = memo(function PrChecksPanel({
99
+ contentWidth,
100
+ }: {
101
+ contentWidth: number
102
+ }) {
103
+ const t = useTheme()
104
+ // A tone apart from the PR state row above, so the two zones read as
105
+ // separate bands. Transparent mode paints neither.
106
+ const transparent = useTransparent()
107
+ const bg = transparent ? undefined : t.backgroundPanel
108
+ const result = usePrStatusStore((s) => s.result)
109
+ const stale = usePrStatusStore((s) => s.stale)
110
+ const [expanded, setExpanded] = useState(false)
111
+ const toggleBody = useCallback(() => setExpanded((prev) => !prev), [])
112
+
113
+ const checks = result?.kind === 'ok' ? result.checks : []
114
+ const spinner = useBusySpinner(checks.some((c) => c.state === 'pending'))
115
+
116
+ const status = placeholder(result, t)
117
+ if (status !== null || result?.kind !== 'ok') {
118
+ return (
119
+ <box
120
+ flexGrow={1}
121
+ flexDirection="column"
122
+ alignItems="center"
123
+ backgroundColor={bg}
124
+ paddingTop={1}
125
+ >
126
+ <text selectable={false} fg={status?.color ?? t.textMuted} bg={bg}>
127
+ {status?.label ?? '…'}
128
+ </text>
129
+ </box>
130
+ )
131
+ }
132
+
133
+ const pr = result.pr
134
+ // The padding below costs a column on each side.
135
+ const innerWidth = contentWidth - 2
136
+ const clamped = clampPrBody(pr.body)
137
+ const body = expanded ? pr.body.trimEnd() : clamped.text
138
+ // The body is raw markdown on purpose: rendering it would cost a parser and
139
+ // the source is what a PR author actually wrote.
140
+ return (
141
+ <box
142
+ flexDirection="column"
143
+ flexGrow={1}
144
+ flexShrink={1}
145
+ flexBasis={0}
146
+ overflow="hidden"
147
+ backgroundColor={bg}
148
+ padding={1}
149
+ >
150
+ {/* No viewportCulling here — the wrapped body is one tall child, which
151
+ culling measures badly, and the content is a description plus a
152
+ handful of rows either way. */}
153
+ <scrollbox
154
+ flexGrow={1}
155
+ scrollY
156
+ scrollbarOptions={HIDDEN_SCROLLBAR_OPTIONS}
157
+ contentOptions={AIRY_CONTENT_OPTIONS}
158
+ >
159
+ <text selectable={false} fg={stale ? t.textMuted : t.text} bg={bg}>
160
+ <strong>{pr.title}</strong>
161
+ </text>
162
+ {body !== '' ? (
163
+ <box flexDirection="column">
164
+ <text selectable={false} fg={t.textMuted} bg={bg}>
165
+ {body}
166
+ </text>
167
+ {clamped.truncated ? (
168
+ <text selectable={false} fg={t.primary} bg={bg} onMouseDown={toggleBody}>
169
+ {expanded ? '▴ less' : '▾ more'}
170
+ </text>
171
+ ) : null}
172
+ </box>
173
+ ) : null}
174
+ <box flexDirection="column">
175
+ <text selectable={false} fg={t.textMuted} bg={bg}>
176
+ Checks
177
+ </text>
178
+ {checks.length === 0 ? (
179
+ <text selectable={false} fg={t.textMuted} bg={bg}>
180
+ No checks
181
+ </text>
182
+ ) : (
183
+ checks.map((check) => (
184
+ <CheckRow
185
+ key={`${check.workflow}/${check.name}`}
186
+ bg={bg}
187
+ check={check}
188
+ showWorkflow={innerWidth >= WORKFLOW_MIN_WIDTH}
189
+ spinner={spinner}
190
+ />
191
+ ))
192
+ )}
193
+ </box>
194
+ </scrollbox>
195
+ </box>
196
+ )
197
+ })
@@ -0,0 +1,103 @@
1
+ import { memo, useCallback, useState } from 'react'
2
+
3
+ import { approveAndMergePr } from '../../../../git/pr-merge'
4
+ import { type PrActionState, prActionState } from '../../../../git/pr-status'
5
+ import { refreshPrStatus } from '../../../../git/pr-status-poller'
6
+ import { openUrl } from '../../../../platform/open-url'
7
+ import { usePrStatusStore } from '../../../../state/pr-status-store'
8
+ import { toast } from '../../../../state/toast-store'
9
+ import { useBusySpinner } from '../../../hooks/use-busy-spinner'
10
+ import { type ResolvedTuiTheme, useTheme, useTransparent } from '../../../theme'
11
+
12
+ function toneColor(tone: PrActionState['tone'], t: ResolvedTuiTheme): string {
13
+ if (tone === 'ok') return t.success
14
+ if (tone === 'blocked') return t.error
15
+ return t.textMuted
16
+ }
17
+
18
+ export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPath: string }) {
19
+ const t = useTheme()
20
+ // Transparent mode drops every painted background rather than punching a hole
21
+ // in whatever the terminal is showing behind aimux.
22
+ const transparent = useTransparent()
23
+ const bg = transparent ? undefined : t.backgroundElement
24
+ const result = usePrStatusStore((s) => s.result)
25
+ const [confirming, setConfirming] = useState(false)
26
+ const [merging, setMerging] = useState(false)
27
+ const spinner = useBusySpinner(merging)
28
+
29
+ const pr = result?.kind === 'ok' ? result.pr : null
30
+ const prUrl = pr?.url ?? ''
31
+
32
+ const openPr = useCallback(() => openUrl(prUrl), [prUrl])
33
+ const askConfirm = useCallback(() => setConfirming(true), [])
34
+ const cancel = useCallback(() => setConfirming(false), [])
35
+ const confirm = useCallback(() => {
36
+ setConfirming(false)
37
+ setMerging(true)
38
+ void (async () => {
39
+ const merged = await approveAndMergePr(projectPath)
40
+ setMerging(false)
41
+ if (merged.ok) toast.success('Pull request merged')
42
+ else toast.error(merged.message)
43
+ await refreshPrStatus(projectPath)
44
+ })()
45
+ }, [projectPath])
46
+
47
+ // First fetch still in flight: hold the band empty so the tabs below don't
48
+ // jump a row once the PR lands.
49
+ if (result === null) {
50
+ return (
51
+ <box backgroundColor={bg} paddingLeft={1} paddingRight={1}>
52
+ <text selectable={false} bg={bg} wrapMode="none">
53
+ {' '}
54
+ </text>
55
+ </box>
56
+ )
57
+ }
58
+ if (result.kind !== 'ok' || pr === null) return null
59
+ const status = prActionState(pr, result.checks)
60
+ let label = status.label
61
+ if (confirming) label = 'Merge this PR?'
62
+ if (merging) label = `${spinner} merging…`
63
+
64
+ return (
65
+ <box flexDirection="row" gap={1} backgroundColor={bg} paddingLeft={1} paddingRight={1}>
66
+ <box flexDirection="row" flexShrink={0} gap={1} onMouseDown={openPr}>
67
+ <text selectable={false} fg={t.textMuted} bg={bg} wrapMode="none">
68
+ #{pr.number}
69
+ </text>
70
+ <text selectable={false} fg={t.primary} bg={bg} wrapMode="none">
71
+
72
+ </text>
73
+ </box>
74
+ <box flexGrow={1} flexShrink={1} overflow="hidden">
75
+ <text
76
+ selectable={false}
77
+ fg={merging || confirming ? t.warning : toneColor(status.tone, t)}
78
+ bg={bg}
79
+ wrapMode="none"
80
+ >
81
+ {label}
82
+ </text>
83
+ </box>
84
+ {confirming ? (
85
+ <box flexDirection="row" flexShrink={0} gap={1}>
86
+ <text selectable={false} fg={t.success} bg={bg} wrapMode="none" onMouseDown={confirm}>
87
+ <strong>yes</strong>
88
+ </text>
89
+ <text selectable={false} fg={t.textMuted} bg={bg} wrapMode="none" onMouseDown={cancel}>
90
+ no
91
+ </text>
92
+ </box>
93
+ ) : null}
94
+ {status.action === 'merge' && !confirming && !merging ? (
95
+ <box flexShrink={0}>
96
+ <text selectable={false} fg={t.primary} bg={bg} wrapMode="none" onMouseDown={askConfirm}>
97
+ <strong>Merge</strong>
98
+ </text>
99
+ </box>
100
+ ) : null}
101
+ </box>
102
+ )
103
+ })
@@ -0,0 +1,191 @@
1
+ import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
3
+ import { useCallback, useRef } from 'react'
4
+
5
+ import type { BarSide } from '../../../state/types'
6
+
7
+ import { useAppStore } from '../../../state/app-store'
8
+ import { getBarWidth, visibleWidgets } from '../../../state/bars'
9
+ import { dispatchGlobal } from '../../../state/dispatch-ref'
10
+ import { useTheme } from '../../theme'
11
+ import { WIDGET_RENDERERS } from '../../widgets/registry'
12
+ import { buildBarContextMenu, buildWidgetContextMenu } from '../../widgets/widget-context-menu'
13
+ import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
14
+
15
+ export interface BarBoundaryResizeInfo {
16
+ containerStart: number
17
+ index: number
18
+ side: BarSide
19
+ totalSize: number
20
+ }
21
+
22
+ interface BarProps {
23
+ side: BarSide
24
+ onResizeDrag?: (event: OtuiMouseEvent) => boolean
25
+ onResizeDragEnd?: () => void
26
+ onEdgeResizeStart?: (info: { initialWidth: number; screenStart: number; side: BarSide }) => void
27
+ onBoundaryResizeStart?: (info: BarBoundaryResizeInfo) => void
28
+ }
29
+
30
+ const RESIZE_HANDLE = '─'
31
+
32
+ /**
33
+ * One edge bar hosting a vertical stack of widgets. Both bars are this
34
+ * component; the only asymmetry is which side the resize handle sits on.
35
+ */
36
+ export function Bar({
37
+ onBoundaryResizeStart,
38
+ onEdgeResizeStart,
39
+ onResizeDrag,
40
+ onResizeDragEnd,
41
+ side,
42
+ }: BarProps) {
43
+ const t = useTheme()
44
+ const bars = useAppStore((s) => s.bars)
45
+ const focusMode = useAppStore((s) => s.focusMode)
46
+ const bodyRef = useRef<BoxRenderable | null>(null)
47
+
48
+ const bar = bars[side]
49
+ const width = getBarWidth(bar)
50
+
51
+ const handleMouseDown = useCallback(() => {
52
+ if (focusMode === 'terminal-input') {
53
+ dispatchGlobal({ focusMode: 'navigation', type: 'set-focus-mode' })
54
+ }
55
+ }, [focusMode])
56
+ const handleMouseDrag = useCallback(
57
+ (event: OtuiMouseEvent) => {
58
+ if (onResizeDrag?.(event) === true) {
59
+ event.preventDefault()
60
+ event.stopPropagation()
61
+ }
62
+ },
63
+ [onResizeDrag]
64
+ )
65
+ const handleMouseUp = useCallback(() => {
66
+ onResizeDragEnd?.()
67
+ }, [onResizeDragEnd])
68
+ const handleEdgeMouseDown = useCallback(
69
+ (event: OtuiMouseEvent) => {
70
+ event.preventDefault()
71
+ event.stopPropagation()
72
+ onEdgeResizeStart?.({ initialWidth: width, screenStart: event.x, side })
73
+ },
74
+ [onEdgeResizeStart, side, width]
75
+ )
76
+
77
+ if (width === 0) return null
78
+
79
+ const visible = visibleWidgets(bar)
80
+ const contentWidth = Math.max(1, width - 1)
81
+
82
+ // Drag handle on the side facing the terminal.
83
+ const edge = (
84
+ <box width={1} flexShrink={0} backgroundColor={t.border} onMouseDown={handleEdgeMouseDown} />
85
+ )
86
+
87
+ const body = (
88
+ <box ref={bodyRef} flexDirection="column" flexGrow={1} overflow="hidden">
89
+ {visible.map((widget, index) => (
90
+ <BarWidgetSlot
91
+ key={widget.id}
92
+ bodyRef={bodyRef}
93
+ contentWidth={contentWidth}
94
+ grow={widget.grow}
95
+ handleColor={t.border}
96
+ index={index}
97
+ isLast={index === visible.length - 1}
98
+ onBoundaryResizeStart={onBoundaryResizeStart}
99
+ side={side}
100
+ widgetId={widget.id}
101
+ />
102
+ ))}
103
+ </box>
104
+ )
105
+
106
+ return (
107
+ <ContextMenuBox
108
+ width={width}
109
+ padding={0}
110
+ flexDirection="row"
111
+ backgroundColor={t.background}
112
+ gap={0}
113
+ overflow="hidden"
114
+ rightClickMenu={buildBarContextMenu(bars, side)}
115
+ onMouseDown={handleMouseDown}
116
+ onMouseDrag={handleMouseDrag}
117
+ onMouseUp={handleMouseUp}
118
+ >
119
+ {side === 'right' ? edge : null}
120
+ <box width={contentWidth} flexGrow={1} flexDirection="column" overflow="hidden">
121
+ {body}
122
+ </box>
123
+ {side === 'left' ? edge : null}
124
+ </ContextMenuBox>
125
+ )
126
+ }
127
+
128
+ function BarWidgetSlot({
129
+ bodyRef,
130
+ contentWidth,
131
+ grow,
132
+ handleColor,
133
+ index,
134
+ isLast,
135
+ onBoundaryResizeStart,
136
+ side,
137
+ widgetId,
138
+ }: {
139
+ bodyRef: React.RefObject<BoxRenderable | null>
140
+ contentWidth: number
141
+ grow: number
142
+ handleColor: string
143
+ index: number
144
+ isLast: boolean
145
+ side: BarSide
146
+ widgetId: string
147
+ onBoundaryResizeStart?: (info: BarBoundaryResizeInfo) => void
148
+ }) {
149
+ const bars = useAppStore((s) => s.bars)
150
+ const render = WIDGET_RENDERERS[widgetId]
151
+
152
+ const handleBoundaryMouseDown = useCallback(
153
+ (event: OtuiMouseEvent) => {
154
+ const body = bodyRef.current
155
+ if (!body) return
156
+ event.preventDefault()
157
+ event.stopPropagation()
158
+ onBoundaryResizeStart?.({
159
+ containerStart: body.y,
160
+ index,
161
+ side,
162
+ totalSize: Math.max(1, body.height),
163
+ })
164
+ },
165
+ [bodyRef, index, onBoundaryResizeStart, side]
166
+ )
167
+
168
+ if (!render) return null
169
+
170
+ return (
171
+ <>
172
+ <ContextMenuBox
173
+ flexDirection="column"
174
+ flexGrow={grow}
175
+ flexShrink={1}
176
+ flexBasis={0}
177
+ overflow="hidden"
178
+ rightClickMenu={buildWidgetContextMenu(bars, side, widgetId)}
179
+ >
180
+ {render(contentWidth)}
181
+ </ContextMenuBox>
182
+ {isLast ? null : (
183
+ <box minHeight={1} flexShrink={0} onMouseDown={handleBoundaryMouseDown}>
184
+ <text fg={handleColor} selectable={false}>
185
+ {RESIZE_HANDLE.repeat(Math.max(1, contentWidth))}
186
+ </text>
187
+ </box>
188
+ )}
189
+ </>
190
+ )
191
+ }
@@ -10,6 +10,7 @@ import type { FocusMode, TabSession } from '../../../state/types'
10
10
 
11
11
  import { useWorktreeDivergencePolling } from '../../../git/worktree-divergence-poller'
12
12
  import { useAppStore } from '../../../state/app-store'
13
+ import { getBarWidth } from '../../../state/bars'
13
14
  import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
14
15
  import { filterTabsForActiveWorktree } from '../../../state/session-worktrees'
15
16
  import { buildTabEntries, type GroupEntry, type TabEntry } from '../../../state/tab-entries'
@@ -197,7 +198,7 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
197
198
  const tabs = useAppStore((s) => s.tabs)
198
199
  const activeTabId = useAppStore((s) => s.activeTabId)
199
200
  const bar = useAppStore((s) => s.sessionBar)
200
- const sidebar = useAppStore((s) => s.sidebar)
201
+ const leftBarVisible = useAppStore((s) => getBarWidth(s.bars.left) > 0)
201
202
  const currentSessionId = useAppStore((s) => s.currentSessionId)
202
203
  const sessions = useAppStore((s) => s.sessions)
203
204
  const focusMode: FocusMode = useAppStore((s) => s.focusMode)
@@ -206,7 +207,7 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
206
207
 
207
208
  // Sidebar now also shows worktree chips with divergence — poll whenever
208
209
  // either surface is visible.
209
- useWorktreeDivergencePolling(bar.visible || sidebar.visible || forceVisible)
210
+ useWorktreeDivergencePolling(bar.visible || leftBarVisible || forceVisible)
210
211
 
211
212
  const currentSession = useMemo(
212
213
  () =>