@brimveyn/aimux 1.22.8 → 1.22.9
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/package.json
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { runCli } from '../services/ai-usage/spawn'
|
|
2
|
+
|
|
3
|
+
export interface GhAccount {
|
|
4
|
+
host: string
|
|
5
|
+
user: string
|
|
6
|
+
active: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `gh auth status` has no `--json`, so its human output is the API: one
|
|
11
|
+
* "Logged in to <host> account <user>" line per account, each followed by the
|
|
12
|
+
* "Active account:" line that belongs to it.
|
|
13
|
+
*/
|
|
14
|
+
export function parseGhAuthStatus(output: string): GhAccount[] {
|
|
15
|
+
const accounts: GhAccount[] = []
|
|
16
|
+
for (const line of output.split('\n')) {
|
|
17
|
+
const login = /Logged in to (\S+) account (\S+)/.exec(line)
|
|
18
|
+
if (login !== null) {
|
|
19
|
+
accounts.push({ active: false, host: login[1] ?? '', user: login[2] ?? '' })
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
const current = accounts.at(-1)
|
|
23
|
+
if (current !== undefined && /Active account:\s*true/i.test(line)) current.active = true
|
|
24
|
+
}
|
|
25
|
+
return accounts
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function listGhAccounts(): Promise<GhAccount[]> {
|
|
29
|
+
const gh = Bun.which('gh')
|
|
30
|
+
if (gh === null) return []
|
|
31
|
+
// Non-zero exit when nothing is logged in, and the report has lived on both
|
|
32
|
+
// streams across gh versions — read both and let the parser decide.
|
|
33
|
+
const result = await runCli(gh, ['auth', 'status'], 10_000)
|
|
34
|
+
return parseGhAuthStatus(`${result.stdout}\n${result.stderr}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Where a "switch account" button goes next: the following account, cycling. */
|
|
38
|
+
export function nextGhAccount(accounts: readonly GhAccount[]): GhAccount | null {
|
|
39
|
+
if (accounts.length < 2) return null
|
|
40
|
+
const index = accounts.findIndex((account) => account.active)
|
|
41
|
+
return accounts[(index + 1) % accounts.length] ?? null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Returns an error message, or null when the switch landed. */
|
|
45
|
+
export async function switchGhAccount(account: GhAccount): Promise<string | null> {
|
|
46
|
+
const gh = Bun.which('gh')
|
|
47
|
+
if (gh === null) return 'gh CLI not found'
|
|
48
|
+
// --user is what keeps this non-interactive: with three accounts on a host,
|
|
49
|
+
// a bare `gh auth switch` opens a prompt no widget can answer.
|
|
50
|
+
const args = ['auth', 'switch', '--hostname', account.host, '--user', account.user]
|
|
51
|
+
const result = await runCli(gh, args, 15_000)
|
|
52
|
+
if (result.ok) return null
|
|
53
|
+
return result.stderr.trim().split('\n')[0] ?? 'gh auth switch failed'
|
|
54
|
+
}
|
package/src/git/pr-status.ts
CHANGED
|
@@ -231,6 +231,23 @@ export function clampPrBody(body: string, maxLines = 5, maxChars = 260): Clamped
|
|
|
231
231
|
return { text, truncated: text.length < full.length }
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* `gh`'s failure in one line. The raw spawn error is a binary path, an exit
|
|
236
|
+
* code and a GraphQL dump — accurate, unreadable, and far wider than the pane.
|
|
237
|
+
*/
|
|
238
|
+
export function ghErrorMessage(detail: string): string {
|
|
239
|
+
const repo = /could not resolve to a repository with the name '([^']+)'/i.exec(detail)
|
|
240
|
+
if (repo !== null) return `No access to ${repo[1]}`
|
|
241
|
+
if (/gh auth login|not logged in|authentication failed|bad credentials/i.test(detail)) {
|
|
242
|
+
return 'Not logged in to GitHub'
|
|
243
|
+
}
|
|
244
|
+
const first = detail
|
|
245
|
+
.split('\n')
|
|
246
|
+
.map((line) => line.trim())
|
|
247
|
+
.find((line) => line !== '')
|
|
248
|
+
return (first ?? 'gh failed').replace(/^(error|gh):\s*/i, '').slice(0, 160)
|
|
249
|
+
}
|
|
250
|
+
|
|
234
251
|
export async function collectPrStatus(cwd: string): Promise<PrStatusResult> {
|
|
235
252
|
const gh = Bun.which('gh')
|
|
236
253
|
if (gh === null) return { kind: 'no-gh' }
|
|
@@ -242,7 +259,9 @@ export async function collectPrStatus(cwd: string): Promise<PrStatusResult> {
|
|
|
242
259
|
// at all (aimux projects can point anywhere). None of those is an error.
|
|
243
260
|
const stderr = result.stderr.toLowerCase()
|
|
244
261
|
if (NOT_AN_ERROR.some((needle) => stderr.includes(needle))) return { kind: 'no-pr' }
|
|
245
|
-
|
|
262
|
+
// A spawn that never started (missing cwd) reports on `error`, not stderr.
|
|
263
|
+
const detail = result.stderr.trim() !== '' ? result.stderr : (result.error ?? '')
|
|
264
|
+
return { kind: 'error', message: ghErrorMessage(detail) }
|
|
246
265
|
}
|
|
247
266
|
|
|
248
267
|
try {
|
|
@@ -57,7 +57,7 @@ export const GitPaneWidget = memo(function GitPaneWidget({
|
|
|
57
57
|
<box flexDirection="column" flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden">
|
|
58
58
|
<GitPaneHeader gitPanel={display} onTabChange={setTab} projectPath={projectPath} tab={tab} />
|
|
59
59
|
{tab === 'github' ? (
|
|
60
|
-
<PrChecksPanel contentWidth={contentWidth} />
|
|
60
|
+
<PrChecksPanel contentWidth={contentWidth} projectPath={projectPath} />
|
|
61
61
|
) : (
|
|
62
62
|
<GitPanel
|
|
63
63
|
collapsedFolders={gitMode.collapsedFolders}
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import { memo, useCallback, useState } from 'react'
|
|
1
|
+
import { memo, useCallback, useEffect, useState } from 'react'
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
type GhAccount,
|
|
5
|
+
listGhAccounts,
|
|
6
|
+
nextGhAccount,
|
|
7
|
+
switchGhAccount,
|
|
8
|
+
} from '../../../../git/gh-auth'
|
|
3
9
|
import {
|
|
4
10
|
clampPrBody,
|
|
5
11
|
type PrCheck,
|
|
6
12
|
type PrCheckState,
|
|
7
13
|
type PrStatusResult,
|
|
8
14
|
} from '../../../../git/pr-status'
|
|
15
|
+
import { refreshPrStatus } from '../../../../git/pr-status-poller'
|
|
9
16
|
import { openUrl } from '../../../../platform/open-url'
|
|
10
17
|
import { usePrStatusStore } from '../../../../state/pr-status-store'
|
|
18
|
+
import { toast } from '../../../../state/toast-store'
|
|
11
19
|
import { useBusySpinner } from '../../../hooks/use-busy-spinner'
|
|
12
20
|
import { type ResolvedTuiTheme, useTheme, useTransparent } from '../../../theme'
|
|
13
21
|
|
|
@@ -47,10 +55,93 @@ function placeholder(
|
|
|
47
55
|
if (result === null) return { color: t.textMuted, label: '…' }
|
|
48
56
|
if (result.kind === 'no-gh') return { color: t.textMuted, label: 'gh CLI not found' }
|
|
49
57
|
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
58
|
return null
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Nearly every `gh` failure the pane can hit is really "the active account
|
|
63
|
+
* can't see this repo", so the error state carries the one fix worth a click:
|
|
64
|
+
* cycle to the next authenticated account and refetch.
|
|
65
|
+
*/
|
|
66
|
+
const GhErrorState = memo(function GhErrorState({
|
|
67
|
+
bg,
|
|
68
|
+
message,
|
|
69
|
+
projectPath,
|
|
70
|
+
}: {
|
|
71
|
+
bg: string | undefined
|
|
72
|
+
message: string
|
|
73
|
+
projectPath: string | undefined
|
|
74
|
+
}) {
|
|
75
|
+
const t = useTheme()
|
|
76
|
+
const [accounts, setAccounts] = useState<readonly GhAccount[]>([])
|
|
77
|
+
const [switching, setSwitching] = useState(false)
|
|
78
|
+
const spinner = useBusySpinner(switching)
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let cancelled = false
|
|
82
|
+
void (async () => {
|
|
83
|
+
const list = await listGhAccounts()
|
|
84
|
+
if (!cancelled) setAccounts(list)
|
|
85
|
+
})()
|
|
86
|
+
return () => {
|
|
87
|
+
cancelled = true
|
|
88
|
+
}
|
|
89
|
+
}, [])
|
|
90
|
+
|
|
91
|
+
const active = accounts.find((account) => account.active)
|
|
92
|
+
const next = nextGhAccount(accounts)
|
|
93
|
+
|
|
94
|
+
const onSwitch = useCallback(() => {
|
|
95
|
+
if (next === null || switching) return
|
|
96
|
+
setSwitching(true)
|
|
97
|
+
void (async () => {
|
|
98
|
+
const error = await switchGhAccount(next)
|
|
99
|
+
setSwitching(false)
|
|
100
|
+
if (error !== null) {
|
|
101
|
+
toast.error(error)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
setAccounts(await listGhAccounts())
|
|
105
|
+
// The poller backs off to two minutes on errors; don't make the user wait
|
|
106
|
+
// it out to find out whether the account they picked was the right one.
|
|
107
|
+
if (projectPath != null && projectPath !== '') await refreshPrStatus(projectPath)
|
|
108
|
+
})()
|
|
109
|
+
}, [next, projectPath, switching])
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<box
|
|
113
|
+
flexGrow={1}
|
|
114
|
+
flexDirection="column"
|
|
115
|
+
alignItems="center"
|
|
116
|
+
gap={1}
|
|
117
|
+
backgroundColor={bg}
|
|
118
|
+
padding={1}
|
|
119
|
+
>
|
|
120
|
+
<box flexDirection="column" alignItems="center">
|
|
121
|
+
<text selectable={false} fg={t.error} bg={bg}>
|
|
122
|
+
✗ {message}
|
|
123
|
+
</text>
|
|
124
|
+
{active !== undefined ? (
|
|
125
|
+
<text selectable={false} fg={t.textMuted} bg={bg}>
|
|
126
|
+
signed in as {active.user}
|
|
127
|
+
</text>
|
|
128
|
+
) : null}
|
|
129
|
+
</box>
|
|
130
|
+
{next !== null ? (
|
|
131
|
+
<text
|
|
132
|
+
selectable={false}
|
|
133
|
+
fg={switching ? t.warning : t.primary}
|
|
134
|
+
bg={bg}
|
|
135
|
+
wrapMode="none"
|
|
136
|
+
onMouseDown={onSwitch}
|
|
137
|
+
>
|
|
138
|
+
{switching ? `${spinner} switching…` : `↺ Switch to ${next.user}`}
|
|
139
|
+
</text>
|
|
140
|
+
) : null}
|
|
141
|
+
</box>
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
54
145
|
const CheckRow = memo(function CheckRow({
|
|
55
146
|
bg,
|
|
56
147
|
check,
|
|
@@ -97,8 +188,10 @@ const CheckRow = memo(function CheckRow({
|
|
|
97
188
|
|
|
98
189
|
export const PrChecksPanel = memo(function PrChecksPanel({
|
|
99
190
|
contentWidth,
|
|
191
|
+
projectPath,
|
|
100
192
|
}: {
|
|
101
193
|
contentWidth: number
|
|
194
|
+
projectPath: string | undefined
|
|
102
195
|
}) {
|
|
103
196
|
const t = useTheme()
|
|
104
197
|
// A tone apart from the PR state row above, so the two zones read as
|
|
@@ -113,6 +206,10 @@ export const PrChecksPanel = memo(function PrChecksPanel({
|
|
|
113
206
|
const checks = result?.kind === 'ok' ? result.checks : []
|
|
114
207
|
const spinner = useBusySpinner(checks.some((c) => c.state === 'pending'))
|
|
115
208
|
|
|
209
|
+
if (result?.kind === 'error') {
|
|
210
|
+
return <GhErrorState bg={bg} message={result.message} projectPath={projectPath} />
|
|
211
|
+
}
|
|
212
|
+
|
|
116
213
|
const status = placeholder(result, t)
|
|
117
214
|
if (status !== null || result?.kind !== 'ok') {
|
|
118
215
|
return (
|