@brimveyn/aimux 1.20.3 → 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 (35) 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/clipboard.ts +140 -4
  13. package/src/platform/open-url.ts +39 -0
  14. package/src/services/ai-usage/spawn.ts +3 -1
  15. package/src/state/bars.ts +75 -0
  16. package/src/state/git-pane-sizing.ts +0 -9
  17. package/src/state/pr-status-store.ts +39 -0
  18. package/src/state/reducers/git-panel-state.ts +0 -47
  19. package/src/state/reducers/ui-state.ts +83 -13
  20. package/src/state/session-persistence.ts +5 -7
  21. package/src/state/store.ts +28 -35
  22. package/src/state/types.ts +24 -19
  23. package/src/state/workspace-save.ts +5 -7
  24. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +2 -1
  25. package/src/ui/components/git/pane/git-pane-header.tsx +105 -39
  26. package/src/ui/components/git/pane/git-pane-widget.tsx +30 -16
  27. package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
  28. package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
  29. package/src/ui/components/layout/bar.tsx +191 -0
  30. package/src/ui/components/layout/top-tab-bar.tsx +3 -2
  31. package/src/ui/root.tsx +25 -109
  32. package/src/ui/widgets/registry.tsx +18 -0
  33. package/src/ui/widgets/widget-context-menu.ts +69 -0
  34. package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
  35. package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
@@ -0,0 +1,57 @@
1
+ import { useEffect } from 'react'
2
+
3
+ import { prStatusStore } from '../state/pr-status-store'
4
+ import { collectPrStatus } from './pr-status'
5
+
6
+ /** A run in flight is worth watching closely; a settled one barely changes. */
7
+ const ACTIVE_INTERVAL_MS = 15_000
8
+ const IDLE_INTERVAL_MS = 60_000
9
+ const MAX_INTERVAL_MS = 120_000
10
+
11
+ interface Options {
12
+ enabled: boolean
13
+ projectPath: string | undefined
14
+ }
15
+
16
+ /** One-shot refetch, for when an action we took just invalidated the state. */
17
+ export async function refreshPrStatus(projectPath: string): Promise<void> {
18
+ prStatusStore.getState().setResult(await collectPrStatus(projectPath))
19
+ }
20
+
21
+ export function usePrStatusPolling({ enabled, projectPath }: Options): void {
22
+ useEffect(() => {
23
+ if (!enabled || !(projectPath != null && projectPath !== '')) return
24
+
25
+ prStatusStore.getState().reset()
26
+
27
+ let cancelled = false
28
+ let timer: ReturnType<typeof setTimeout> | null = null
29
+ let delay = ACTIVE_INTERVAL_MS
30
+
31
+ const schedule = () => {
32
+ if (cancelled) return
33
+ timer = setTimeout(() => void tick(), delay)
34
+ }
35
+
36
+ const tick = async () => {
37
+ const result = await collectPrStatus(projectPath)
38
+ if (cancelled) return
39
+ prStatusStore.getState().setResult(result)
40
+ if (result.kind === 'error') {
41
+ delay = Math.min(delay * 2, MAX_INTERVAL_MS)
42
+ } else if (result.kind === 'ok' && result.checks.some((c) => c.state === 'pending')) {
43
+ delay = ACTIVE_INTERVAL_MS
44
+ } else {
45
+ delay = IDLE_INTERVAL_MS
46
+ }
47
+ schedule()
48
+ }
49
+
50
+ void tick()
51
+
52
+ return () => {
53
+ cancelled = true
54
+ if (timer) clearTimeout(timer)
55
+ }
56
+ }, [enabled, projectPath])
57
+ }
@@ -0,0 +1,227 @@
1
+ import { runCli } from '../services/ai-usage/spawn'
2
+
3
+ export type PrCheckState = 'pass' | 'fail' | 'pending' | 'skipping' | 'cancel'
4
+
5
+ export interface PrCheck {
6
+ name: string
7
+ workflow: string
8
+ state: PrCheckState
9
+ url: string
10
+ durationMs: number | null
11
+ }
12
+
13
+ export interface PrSummary {
14
+ number: number
15
+ title: string
16
+ body: string
17
+ state: string
18
+ isDraft: boolean
19
+ base: string
20
+ head: string
21
+ reviewDecision: string
22
+ /** MERGEABLE | CONFLICTING | UNKNOWN */
23
+ mergeable: string
24
+ /** CLEAN | BLOCKED | BEHIND | UNSTABLE | DIRTY | DRAFT | HAS_HOOKS | UNKNOWN */
25
+ mergeStateStatus: string
26
+ additions: number
27
+ deletions: number
28
+ changedFiles: number
29
+ url: string
30
+ }
31
+
32
+ export type PrStatusResult =
33
+ | { kind: 'ok'; pr: PrSummary; checks: PrCheck[] }
34
+ | { kind: 'no-pr' }
35
+ | { kind: 'no-gh' }
36
+ | { kind: 'error'; message: string }
37
+
38
+ const PR_VIEW_FIELDS = [
39
+ 'number',
40
+ 'title',
41
+ 'body',
42
+ 'state',
43
+ 'isDraft',
44
+ 'url',
45
+ 'baseRefName',
46
+ 'headRefName',
47
+ 'reviewDecision',
48
+ 'mergeable',
49
+ 'mergeStateStatus',
50
+ 'additions',
51
+ 'deletions',
52
+ 'changedFiles',
53
+ 'statusCheckRollup',
54
+ ].join(',')
55
+
56
+ const NOT_AN_ERROR = [
57
+ 'no pull requests found',
58
+ 'no git remotes',
59
+ 'not a git repository',
60
+ 'none of the git remotes',
61
+ 'no default remote',
62
+ ]
63
+
64
+ function str(value: unknown): string {
65
+ return typeof value === 'string' ? value : ''
66
+ }
67
+
68
+ function num(value: unknown): number {
69
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
70
+ }
71
+
72
+ function duration(startedAt: unknown, completedAt: unknown): number | null {
73
+ const start = Date.parse(str(startedAt))
74
+ const end = Date.parse(str(completedAt))
75
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null
76
+ return end - start
77
+ }
78
+
79
+ // `gh` documents these buckets for `pr checks --json bucket`; we derive the same
80
+ // classification from the raw rollup so a single `pr view` call covers both the
81
+ // summary and the checks.
82
+ function checkRunState(status: string, conclusion: string): PrCheckState {
83
+ if (status !== 'COMPLETED') return 'pending'
84
+ if (conclusion === 'SUCCESS' || conclusion === 'NEUTRAL') return 'pass'
85
+ if (conclusion === 'SKIPPED') return 'skipping'
86
+ if (conclusion === 'CANCELLED') return 'cancel'
87
+ return 'fail'
88
+ }
89
+
90
+ function statusContextState(state: string): PrCheckState {
91
+ if (state === 'SUCCESS') return 'pass'
92
+ if (state === 'PENDING' || state === 'EXPECTED') return 'pending'
93
+ return 'fail'
94
+ }
95
+
96
+ function toCheck(raw: unknown): PrCheck | null {
97
+ if (typeof raw !== 'object' || raw === null) return null
98
+ const entry = raw as Record<string, unknown>
99
+ if (entry.__typename === 'StatusContext') {
100
+ const name = str(entry.context)
101
+ if (name === '') return null
102
+ return {
103
+ durationMs: null,
104
+ name,
105
+ state: statusContextState(str(entry.state)),
106
+ url: str(entry.targetUrl),
107
+ workflow: '',
108
+ }
109
+ }
110
+ const name = str(entry.name)
111
+ if (name === '') return null
112
+ return {
113
+ durationMs: duration(entry.startedAt, entry.completedAt),
114
+ name,
115
+ state: checkRunState(str(entry.status), str(entry.conclusion)),
116
+ url: str(entry.detailsUrl),
117
+ workflow: str(entry.workflowName),
118
+ }
119
+ }
120
+
121
+ export function parsePrView(raw: unknown): PrStatusResult {
122
+ if (typeof raw !== 'object' || raw === null) return { kind: 'no-pr' }
123
+ const pr = raw as Record<string, unknown>
124
+ if (typeof pr.number !== 'number') return { kind: 'no-pr' }
125
+ const rollup = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : []
126
+ return {
127
+ checks: rollup.map(toCheck).filter((c): c is PrCheck => c !== null),
128
+ kind: 'ok',
129
+ pr: {
130
+ additions: num(pr.additions),
131
+ base: str(pr.baseRefName),
132
+ body: str(pr.body).trim(),
133
+ changedFiles: num(pr.changedFiles),
134
+ deletions: num(pr.deletions),
135
+ head: str(pr.headRefName),
136
+ isDraft: pr.isDraft === true,
137
+ mergeable: str(pr.mergeable),
138
+ mergeStateStatus: str(pr.mergeStateStatus),
139
+ number: pr.number,
140
+ reviewDecision: str(pr.reviewDecision),
141
+ state: str(pr.state),
142
+ title: str(pr.title),
143
+ url: str(pr.url),
144
+ },
145
+ }
146
+ }
147
+
148
+ export type PrAction = 'merge' | null
149
+
150
+ export interface PrActionState {
151
+ label: string
152
+ action: PrAction
153
+ tone: 'ok' | 'blocked' | 'neutral'
154
+ }
155
+
156
+ /**
157
+ * The headline GitHub puts on the merge box, and the one action worth wiring to
158
+ * it. Order matters: a terminal state beats everything, then a hard blocker
159
+ * (conflicts, draft), then whatever the checks are doing. Anything we can't
160
+ * offer an action for still gets an honest label rather than a dead button.
161
+ */
162
+ export function prActionState(pr: PrSummary, checks: PrCheck[]): PrActionState {
163
+ if (pr.state === 'MERGED') return { action: null, label: 'Merged', tone: 'neutral' }
164
+ if (pr.state === 'CLOSED') return { action: null, label: 'Closed', tone: 'blocked' }
165
+ if (pr.isDraft) return { action: null, label: 'Draft', tone: 'neutral' }
166
+ if (pr.mergeable === 'CONFLICTING') {
167
+ return { action: null, label: 'Merge conflicts', tone: 'blocked' }
168
+ }
169
+ if (checks.some((c) => c.state === 'pending')) {
170
+ return { action: null, label: 'Checks running', tone: 'neutral' }
171
+ }
172
+ if (pr.mergeStateStatus === 'BLOCKED') return { action: null, label: 'Blocked', tone: 'blocked' }
173
+ if (pr.mergeStateStatus === 'BEHIND')
174
+ return { action: null, label: 'Out of date', tone: 'blocked' }
175
+ // UNSTABLE means a non-required check failed; GitHub still lets you merge.
176
+ if (pr.mergeStateStatus === 'UNSTABLE') {
177
+ return { action: 'merge', label: 'Checks failing', tone: 'blocked' }
178
+ }
179
+ if (pr.mergeStateStatus === 'CLEAN') {
180
+ return { action: 'merge', label: 'Ready to merge', tone: 'ok' }
181
+ }
182
+ return { action: null, label: 'Checking…', tone: 'neutral' }
183
+ }
184
+
185
+ export interface ClampedBody {
186
+ text: string
187
+ truncated: boolean
188
+ }
189
+
190
+ /**
191
+ * A PR body is a whole document; a bar widget gets a preview of it. Clamping on
192
+ * lines alone breaks on a wall-of-text paragraph and clamping on characters
193
+ * alone breaks on a bullet list, so whichever limit bites first wins.
194
+ */
195
+ export function clampPrBody(body: string, maxLines = 5, maxChars = 260): ClampedBody {
196
+ const full = body.trimEnd()
197
+ const lines = full.split('\n')
198
+ let text = lines.slice(0, maxLines).join('\n')
199
+ if (text.length > maxChars) {
200
+ const space = text.lastIndexOf(' ', maxChars)
201
+ // Only respect a word boundary that isn't absurdly early, else hard-cut.
202
+ text = text.slice(0, space > maxChars / 2 ? space : maxChars)
203
+ }
204
+ text = text.trimEnd()
205
+ return { text, truncated: text.length < full.length }
206
+ }
207
+
208
+ export async function collectPrStatus(cwd: string): Promise<PrStatusResult> {
209
+ const gh = Bun.which('gh')
210
+ if (gh === null) return { kind: 'no-gh' }
211
+
212
+ const result = await runCli(gh, ['pr', 'view', '--json', PR_VIEW_FIELDS], 15_000, cwd)
213
+ if (!result.ok) {
214
+ // `gh` exits non-zero for every "there is simply nothing to show" case too:
215
+ // no PR on the branch, no GitHub remote, or a directory that isn't a repo
216
+ // at all (aimux sessions can point anywhere). None of those is an error.
217
+ const stderr = result.stderr.toLowerCase()
218
+ if (NOT_AN_ERROR.some((needle) => stderr.includes(needle))) return { kind: 'no-pr' }
219
+ return { kind: 'error', message: (result.error ?? 'gh failed').slice(0, 200) }
220
+ }
221
+
222
+ try {
223
+ return parsePrView(JSON.parse(result.stdout))
224
+ } catch {
225
+ return { kind: 'no-pr' }
226
+ }
227
+ }
package/src/index.tsx CHANGED
@@ -72,8 +72,14 @@ if (command === '--help' || command === '-h' || command === 'help') {
72
72
  process.exit(await runCli([]))
73
73
  }
74
74
 
75
+ // Sequential ON PURPOSE: @opentui/react evaluates @opentui/core as part of its
76
+ // own module graph. Loading both concurrently races the two evaluations and
77
+ // react's chunk can hit `class X extends TextNodeRenderable` while core is
78
+ // still initializing (ReferenceError: cannot access before initialization).
79
+ // react has to wait on core either way, so this costs nothing.
80
+ const { createCliRenderer } = await import('@opentui/core')
81
+
75
82
  const [
76
- { createCliRenderer },
77
83
  { createRoot },
78
84
  { App },
79
85
  { loadUserConfig },
@@ -82,7 +88,6 @@ const [
82
88
  { createSessionBackend },
83
89
  { maybeAutoInstallCompletion },
84
90
  ] = await Promise.all([
85
- import('@opentui/core'),
86
91
  import('@opentui/react'),
87
92
  import('./app'),
88
93
  import('./config/loader'),
@@ -1,13 +1,135 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+
1
3
  import { logDebug } from '../debug/input-log'
2
4
  import { toast } from '../state/toast-store'
3
5
 
6
+ export interface ClipboardCandidate {
7
+ argv: string[]
8
+ // powershell's Get-Clipboard emits CRLF line endings and appends a trailing
9
+ // newline of its own; both have to be undone to get the copied text back.
10
+ normalizeWindowsOutput?: boolean
11
+ }
12
+
13
+ export interface ClipboardPlatform {
14
+ env: Record<string, string | undefined>
15
+ isWsl: boolean
16
+ platform: string
17
+ }
18
+
19
+ const POWERSHELL_PASTE: ClipboardCandidate = {
20
+ argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard'],
21
+ normalizeWindowsOutput: true,
22
+ }
23
+
24
+ function isSet(value: string | undefined): boolean {
25
+ return value !== undefined && value !== ''
26
+ }
27
+
28
+ export function detectClipboardPlatform(): ClipboardPlatform {
29
+ return {
30
+ env: process.env,
31
+ isWsl: detectWsl(),
32
+ platform: process.platform,
33
+ }
34
+ }
35
+
36
+ function detectWsl(): boolean {
37
+ if (process.platform !== 'linux') return false
38
+ if (isSet(process.env.WSL_DISTRO_NAME) || isSet(process.env.WSL_INTEROP)) return true
39
+ try {
40
+ return readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft')
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ export function copyCandidates({ env, isWsl, platform }: ClipboardPlatform): ClipboardCandidate[] {
47
+ if (platform === 'darwin') return [{ argv: ['pbcopy'] }]
48
+ if (platform === 'win32') return [{ argv: ['clip'] }]
49
+
50
+ const candidates: ClipboardCandidate[] = []
51
+ // On WSL the Windows clipboard is the one the user pastes from, and clip.exe
52
+ // always reaches it. The X/Wayland bridges only exist under WSLg.
53
+ if (isWsl) {
54
+ candidates.push({ argv: ['clip.exe'] }, { argv: ['/mnt/c/Windows/System32/clip.exe'] })
55
+ }
56
+ const wayland: ClipboardCandidate = { argv: ['wl-copy'] }
57
+ const xorg: ClipboardCandidate[] = [
58
+ { argv: ['xclip', '-selection', 'clipboard'] },
59
+ { argv: ['xsel', '--clipboard', '--input'] },
60
+ ]
61
+ candidates.push(...(isSet(env.WAYLAND_DISPLAY) ? [wayland, ...xorg] : [...xorg, wayland]))
62
+ return candidates
63
+ }
64
+
65
+ export function pasteCandidates({ env, isWsl, platform }: ClipboardPlatform): ClipboardCandidate[] {
66
+ if (platform === 'darwin') return [{ argv: ['pbpaste'] }]
67
+ if (platform === 'win32') return [POWERSHELL_PASTE]
68
+
69
+ const candidates: ClipboardCandidate[] = []
70
+ if (isWsl) candidates.push(POWERSHELL_PASTE)
71
+ const wayland: ClipboardCandidate = { argv: ['wl-paste', '--no-newline'] }
72
+ const xorg: ClipboardCandidate[] = [
73
+ { argv: ['xclip', '-selection', 'clipboard', '-o'] },
74
+ { argv: ['xsel', '--clipboard', '--output'] },
75
+ ]
76
+ candidates.push(...(isSet(env.WAYLAND_DISPLAY) ? [wayland, ...xorg] : [...xorg, wayland]))
77
+ return candidates
78
+ }
79
+
80
+ function resolveCandidate(candidates: ClipboardCandidate[]): ClipboardCandidate | null {
81
+ for (const candidate of candidates) {
82
+ const [bin, ...args] = candidate.argv
83
+ if (bin === undefined) continue
84
+ let resolved: string | null
85
+ if (bin.includes('/')) {
86
+ resolved = existsSync(bin) ? bin : null
87
+ } else {
88
+ resolved = Bun.which(bin)
89
+ }
90
+ if (resolved !== null) return { ...candidate, argv: [resolved, ...args] }
91
+ }
92
+ return null
93
+ }
94
+
95
+ let cachedCopy: ClipboardCandidate | null | undefined
96
+ let cachedPaste: ClipboardCandidate | null | undefined
97
+
98
+ function copyCommand(): ClipboardCandidate | null {
99
+ cachedCopy ??= resolveCandidate(copyCandidates(detectClipboardPlatform()))
100
+ return cachedCopy
101
+ }
102
+
103
+ function pasteCommand(): ClipboardCandidate | null {
104
+ cachedPaste ??= resolveCandidate(pasteCandidates(detectClipboardPlatform()))
105
+ return cachedPaste
106
+ }
107
+
108
+ const MISSING_TOOL_MESSAGE =
109
+ process.platform === 'darwin'
110
+ ? 'Copy failed: pbcopy not found'
111
+ : 'Copy failed: install xclip, wl-clipboard, or xsel'
112
+
4
113
  export function copyToSystemClipboard(text: string): void {
114
+ const command = copyCommand()
115
+ if (!command) {
116
+ logDebug('platform.clipboard.noCopyCommand', { platform: process.platform })
117
+ toast.error(MISSING_TOOL_MESSAGE)
118
+ return
119
+ }
5
120
  try {
6
- const proc = Bun.spawn(['pbcopy'], { stdin: 'pipe' })
121
+ const proc = Bun.spawn(command.argv, { stderr: 'pipe', stdin: 'pipe' })
7
122
  void proc.stdin.write(text)
8
123
  void proc.stdin.end()
124
+ void (async () => {
125
+ const code = await proc.exited
126
+ if (code === 0) return
127
+ logDebug('platform.clipboard.copyExit', { argv: command.argv, code })
128
+ toast.error('Copy failed')
129
+ })()
9
130
  } catch (error) {
10
131
  logDebug('platform.clipboard.copyError', {
132
+ argv: command.argv,
11
133
  error: error instanceof Error ? error.message : String(error),
12
134
  })
13
135
  toast.error('Copy failed')
@@ -15,15 +137,29 @@ export function copyToSystemClipboard(text: string): void {
15
137
  }
16
138
 
17
139
  export async function readFromSystemClipboard(): Promise<string> {
140
+ const command = pasteCommand()
141
+ if (!command) {
142
+ logDebug('platform.clipboard.noPasteCommand', { platform: process.platform })
143
+ return ''
144
+ }
18
145
  try {
19
- const proc = Bun.spawn(['pbpaste'], { stdout: 'pipe' })
146
+ const proc = Bun.spawn(command.argv, { stderr: 'pipe', stdout: 'pipe' })
20
147
  const text = await new Response(proc.stdout).text()
21
- await proc.exited
22
- return text
148
+ const code = await proc.exited
149
+ if (code !== 0) {
150
+ logDebug('platform.clipboard.readExit', { argv: command.argv, code })
151
+ return ''
152
+ }
153
+ return command.normalizeWindowsOutput === true ? normalizeWindowsClipboardText(text) : text
23
154
  } catch (error) {
24
155
  logDebug('platform.clipboard.readError', {
156
+ argv: command.argv,
25
157
  error: error instanceof Error ? error.message : String(error),
26
158
  })
27
159
  return ''
28
160
  }
29
161
  }
162
+
163
+ export function normalizeWindowsClipboardText(text: string): string {
164
+ return text.replaceAll('\r\n', '\n').replace(/\n$/, '')
165
+ }
@@ -0,0 +1,39 @@
1
+ import { logDebug } from '../debug/input-log'
2
+ import { toast } from '../state/toast-store'
3
+ import { detectClipboardPlatform } from './clipboard'
4
+
5
+ /**
6
+ * URLs here come from the GitHub API, i.e. outside the process. Handing an
7
+ * arbitrary scheme to the OS opener is a code-execution path (`file://`,
8
+ * `javascript:`, custom app handlers), so only plain https is allowed through.
9
+ */
10
+ function isSafeUrl(url: string): boolean {
11
+ return /^https:\/\/[^\s]+$/.test(url)
12
+ }
13
+
14
+ export function openUrlCommand(platform: string, isWsl: boolean, url: string): string[] | null {
15
+ if (!isSafeUrl(url)) return null
16
+ if (platform === 'darwin') return ['open', url]
17
+ // Under WSL the browser lives on the Windows side; explorer.exe is the one
18
+ // bridge present on every install (wslview/xdg-open often are not).
19
+ if (platform === 'win32' || isWsl) return ['explorer.exe', url]
20
+ return ['xdg-open', url]
21
+ }
22
+
23
+ export function openUrl(url: string): void {
24
+ const { isWsl, platform } = detectClipboardPlatform()
25
+ const argv = openUrlCommand(platform, isWsl, url)
26
+ if (!argv) {
27
+ logDebug('platform.openUrl.rejected', { url })
28
+ return
29
+ }
30
+ try {
31
+ Bun.spawn(argv, { stderr: 'ignore', stdin: 'ignore', stdout: 'ignore' })
32
+ } catch (error) {
33
+ logDebug('platform.openUrl.error', {
34
+ argv,
35
+ error: error instanceof Error ? error.message : String(error),
36
+ })
37
+ toast.error(`Could not open ${argv[0]}`)
38
+ }
39
+ }
@@ -10,9 +10,11 @@ const DEFAULT_TIMEOUT_MS = 15_000
10
10
  export async function runCli(
11
11
  command: string,
12
12
  args: string[],
13
- timeoutMs: number = DEFAULT_TIMEOUT_MS
13
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
14
+ cwd?: string
14
15
  ): Promise<CliResult> {
15
16
  const proc = Bun.spawn([command, ...args], {
17
+ cwd,
16
18
  stderr: 'pipe',
17
19
  stdin: 'ignore',
18
20
  stdout: 'pipe',
@@ -0,0 +1,75 @@
1
+ import type { BarSide, BarsState, BarState, BarWidget } from './types'
2
+
3
+ export const BAR_MIN_WIDTH = 18
4
+ export const BAR_MAX_WIDTH = 80
5
+
6
+ /** Widget ids the app knows how to render. Unknown ids are pruned on load. */
7
+ export const KNOWN_WIDGET_IDS = ['workspaces', 'git'] as const
8
+
9
+ /** Smallest share of a bar a single widget may shrink to, as a fraction. */
10
+ const MIN_WIDGET_SHARE = 0.1
11
+
12
+ export function clampBarWidth(width: number): number {
13
+ return Math.min(BAR_MAX_WIDTH, Math.max(BAR_MIN_WIDTH, Math.round(width)))
14
+ }
15
+
16
+ export function visibleWidgets(bar: BarState): BarWidget[] {
17
+ return bar.widgets.filter((widget) => widget.visible)
18
+ }
19
+
20
+ /**
21
+ * The single authority on how many columns a bar occupies. Both the `Bar`
22
+ * component and the terminal-size computation must call this — a mismatch
23
+ * silently corrupts PTY columns and mouse hit-testing.
24
+ */
25
+ export function getBarWidth(bar: BarState): number {
26
+ if (!bar.visible || visibleWidgets(bar).length === 0) return 0
27
+ return clampBarWidth(bar.width)
28
+ }
29
+
30
+ export function findWidgetBar(bars: BarsState, widgetId: string): BarSide | null {
31
+ if (bars.left.widgets.some((widget) => widget.id === widgetId)) return 'left'
32
+ if (bars.right.widgets.some((widget) => widget.id === widgetId)) return 'right'
33
+ return null
34
+ }
35
+
36
+ function totalGrow(widgets: BarWidget[]): number {
37
+ return widgets.reduce((sum, widget) => sum + widget.grow, 0)
38
+ }
39
+
40
+ /**
41
+ * Move the boundary between visible widgets `index` and `index + 1` by
42
+ * `deltaGrow`. Only the pair changes, so the bar's total grow is preserved and
43
+ * no renormalisation is ever needed — including when a widget is added.
44
+ */
45
+ export function shiftBoundary(bar: BarState, index: number, deltaGrow: number): BarWidget[] {
46
+ const visible = visibleWidgets(bar)
47
+ const above = visible[index]
48
+ const below = visible[index + 1]
49
+ if (!above || !below) return bar.widgets
50
+
51
+ const min = Math.max(1, Math.round(totalGrow(visible) * MIN_WIDGET_SHARE))
52
+ const pair = above.grow + below.grow
53
+ if (pair < min * 2) return bar.widgets
54
+
55
+ const nextAbove = Math.min(pair - min, Math.max(min, Math.round(above.grow + deltaGrow)))
56
+ if (nextAbove === above.grow) return bar.widgets
57
+
58
+ return bar.widgets.map((widget) => {
59
+ if (widget.id === above.id) return { ...widget, grow: nextAbove }
60
+ if (widget.id === below.id) return { ...widget, grow: pair - nextAbove }
61
+ return widget
62
+ })
63
+ }
64
+
65
+ /**
66
+ * Convert an absolute drag position (a 0..1 fraction of the bar's body) into
67
+ * the grow delta `shiftBoundary` expects for that boundary.
68
+ */
69
+ export function boundaryDeltaFromRatio(bar: BarState, index: number, ratio: number): number {
70
+ const visible = visibleWidgets(bar)
71
+ const target = visible[index]
72
+ if (!target) return 0
73
+ const above = visible.slice(0, index).reduce((sum, widget) => sum + widget.grow, 0)
74
+ return ratio * totalGrow(visible) - above - target.grow
75
+ }
@@ -1,15 +1,6 @@
1
1
  export const GIT_PANE_MIN_RATIO = 0.2
2
2
  export const GIT_PANE_MAX_RATIO = 0.8
3
- export const GIT_PANE_MIN_WIDTH = 20
4
- export const GIT_PANE_MAX_WIDTH = 80
5
3
 
6
4
  export function clampGitPaneRatio(value: number): number {
7
5
  return Math.max(GIT_PANE_MIN_RATIO, Math.min(GIT_PANE_MAX_RATIO, value))
8
6
  }
9
-
10
- export function getGitPaneWidthFromRatio(ratio: number): number {
11
- return Math.max(
12
- GIT_PANE_MIN_WIDTH,
13
- Math.min(GIT_PANE_MAX_WIDTH, Math.round(clampGitPaneRatio(ratio) * GIT_PANE_MAX_WIDTH))
14
- )
15
- }
@@ -0,0 +1,39 @@
1
+ import { useStore } from 'zustand'
2
+ import { createStore } from 'zustand/vanilla'
3
+
4
+ import type { PrStatusResult } from '../git/pr-status'
5
+
6
+ export interface PrStatusState {
7
+ result: PrStatusResult | null
8
+ /** True once a fetch failed but we are still showing the previous good result. */
9
+ stale: boolean
10
+ setResult: (result: PrStatusResult) => void
11
+ reset: () => void
12
+ }
13
+
14
+ export const prStatusStore = createStore<PrStatusState>((set) => ({
15
+ reset: () => set({ result: null, stale: false }),
16
+ result: null,
17
+ setResult: (result: PrStatusResult) =>
18
+ set((state) => {
19
+ // A transient `gh` failure shouldn't blank a PR we already resolved — keep
20
+ // the last good snapshot and mark it stale instead (same contract as
21
+ // ai-usage-store's setSnapshot).
22
+ if (result.kind === 'error' && state.result?.kind === 'ok') return { stale: true }
23
+ return { result, stale: false }
24
+ }),
25
+ stale: false,
26
+ }))
27
+
28
+ /**
29
+ * The PR state row occupies its band both while the first fetch is in flight
30
+ * and once it resolved to a PR — anything else (no PR, no gh, error) gives the
31
+ * row back. Shared so the header and the row itself can never disagree and
32
+ * shift the layout under the user.
33
+ */
34
+ export const selectPrRowVisible = (state: PrStatusState): boolean =>
35
+ state.result === null || state.result.kind === 'ok'
36
+
37
+ export function usePrStatusStore<T>(selector: (state: PrStatusState) => T): T {
38
+ return useStore(prStatusStore, selector)
39
+ }