@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.22.8",
3
+ "version": "1.22.9",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -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
+ }
@@ -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
- return { kind: 'error', message: (result.error ?? 'gh failed').slice(0, 200) }
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 (