@brimveyn/aimux 1.19.3 → 1.19.5

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.
@@ -2,6 +2,7 @@ import { $ } from 'bun'
2
2
 
3
3
  import type { DiffData, DiffFileStatus, GitFileEntry } from '../state/types'
4
4
 
5
+ import { MAX_DIFF_BYTES } from './diff-limits'
5
6
  import { imageFormatLabel, imageMimeFromPath, isImagePath } from './image-detect'
6
7
 
7
8
  function resolveStatus(entry: GitFileEntry): { status: DiffFileStatus; oldPath?: string } {
@@ -15,19 +16,37 @@ function resolveStatus(entry: GitFileEntry): { status: DiffFileStatus; oldPath?:
15
16
  return { status: 'modified' }
16
17
  }
17
18
 
19
+ // numstat marks a binary file by replacing both line counts with a dash.
20
+ function numstatSaysBinary(text: string): boolean {
21
+ const first = text.trim().split('\n')[0] ?? ''
22
+ return first.startsWith('-\t-\t')
23
+ }
24
+
18
25
  async function isBinary(cwd: string, ref: string, path: string): Promise<boolean> {
19
- const result = await $`git -C ${cwd} diff ${ref} --numstat -- ${path}`.quiet().nothrow()
20
- if (result.exitCode !== 0) return false
21
- const text = result.text().trim()
26
+ const tracked = await $`git -C ${cwd} diff ${ref} --numstat -- ${path}`.quiet().nothrow()
27
+ if (tracked.exitCode === 0) {
28
+ const text = tracked.text().trim()
29
+ if (text) return numstatSaysBinary(text)
30
+ }
31
+ // An untracked path is absent from `git diff <ref>` altogether, so the probe above
32
+ // comes back empty and says nothing about it. Compare against /dev/null to classify
33
+ // it — without this, every untracked binary reads as text and gets slurped as one
34
+ // string.
35
+ const untracked = await $`git -C ${cwd} diff --no-index --numstat /dev/null -- ${path}`
36
+ .quiet()
37
+ .nothrow()
38
+ const text = untracked.text().trim()
22
39
  if (!text) return false
23
- const first = text.split('\n')[0] ?? ''
24
- return first.startsWith('-\t-\t')
40
+ return numstatSaysBinary(text)
25
41
  }
26
42
 
27
43
  async function readHeadSize(cwd: string, ref: string, path: string): Promise<number> {
28
- const result = await $`git -C ${cwd} show ${ref}:${path}`.quiet().nothrow()
44
+ // `cat-file -s` reads the blob header only. `git show` would materialise the entire
45
+ // blob as a string just to measure it — the very thing MAX_DIFF_BYTES exists to stop.
46
+ const result = await $`git -C ${cwd} cat-file -s ${ref}:${path}`.quiet().nothrow()
29
47
  if (result.exitCode !== 0) return 0
30
- return result.text().length
48
+ const size = Number.parseInt(result.text().trim(), 10)
49
+ return Number.isFinite(size) ? size : 0
31
50
  }
32
51
 
33
52
  async function readWorkingSize(cwd: string, path: string): Promise<number> {
@@ -91,6 +110,22 @@ function resolveCompareRef(headOffset: number, compareRef: string | undefined):
91
110
  return headOffset > 0 ? `HEAD~${headOffset}` : 'HEAD'
92
111
  }
93
112
 
113
+ // Both sides are measured from metadata only (blob header + stat), never by reading
114
+ // content — the sizes are what decide whether reading content is safe at all.
115
+ async function diffSizes(
116
+ cwd: string,
117
+ ref: string,
118
+ path: string,
119
+ headPath: string,
120
+ status: DiffFileStatus
121
+ ): Promise<{ after: number; before: number }> {
122
+ const [before, after] = await Promise.all([
123
+ status === 'new' ? Promise.resolve(0) : readHeadSize(cwd, ref, headPath),
124
+ status === 'deleted' ? Promise.resolve(0) : readWorkingSize(cwd, path),
125
+ ])
126
+ return { after, before }
127
+ }
128
+
94
129
  export async function fetchDiff(
95
130
  cwd: string,
96
131
  file: GitFileEntry,
@@ -99,9 +134,31 @@ export async function fetchDiff(
99
134
  ): Promise<DiffData> {
100
135
  const { oldPath, status } = resolveStatus(file)
101
136
  const ref = resolveCompareRef(headOffset, compareRef)
137
+ const headPath = oldPath ?? file.path
138
+
139
+ const { after: sizeAfter, before: sizeBefore } = await diffSizes(
140
+ cwd,
141
+ ref,
142
+ file.path,
143
+ headPath,
144
+ status
145
+ )
146
+
147
+ // Ahead of the image branch on purpose: a multi-gigabyte .png must not be read
148
+ // into memory either.
149
+ if (sizeBefore > MAX_DIFF_BYTES || sizeAfter > MAX_DIFF_BYTES) {
150
+ const data: DiffData = {
151
+ binarySizeAfter: sizeAfter,
152
+ binarySizeBefore: sizeBefore,
153
+ path: file.path,
154
+ rawDiff: '',
155
+ status: 'too-large',
156
+ }
157
+ if (oldPath != null && oldPath !== '') data.oldPath = oldPath
158
+ return data
159
+ }
102
160
 
103
161
  if (isImagePath(file.path)) {
104
- const headPath = oldPath ?? file.path
105
162
  const wantsBefore = status !== 'new'
106
163
  const wantsAfter = status !== 'deleted'
107
164
  const [imageBytesBefore, imageBytesAfter] = await Promise.all([
@@ -124,13 +181,9 @@ export async function fetchDiff(
124
181
  }
125
182
 
126
183
  if (await isBinary(cwd, ref, file.path)) {
127
- const [binarySizeBefore, binarySizeAfter] = await Promise.all([
128
- readHeadSize(cwd, ref, file.path),
129
- readWorkingSize(cwd, file.path),
130
- ])
131
184
  return {
132
- binarySizeAfter,
133
- binarySizeBefore,
185
+ binarySizeAfter: sizeAfter,
186
+ binarySizeBefore: sizeBefore,
134
187
  path: file.path,
135
188
  rawDiff: '',
136
189
  status: 'binary',
@@ -8,6 +8,8 @@ import type {
8
8
  GitRefreshPayload,
9
9
  } from '../state/types'
10
10
 
11
+ import { MAX_DIFF_BYTES } from './diff-limits'
12
+
11
13
  interface NumstatRow {
12
14
  added: number | null
13
15
  removed: number | null
@@ -163,6 +165,11 @@ async function countUntrackedLines(cwd: string, path: string): Promise<number |
163
165
  try {
164
166
  const file = Bun.file(`${cwd}/${path}`)
165
167
  if (!(await file.exists())) return null
168
+ // Size gate before the read, not after: `.text()` aborts the process on an
169
+ // oversized blob rather than throwing, so the catch below would never run. Callers
170
+ // treat null as "no count available", which is the honest answer for a 3 GB
171
+ // download sitting untracked in the working tree.
172
+ if (file.size > MAX_DIFF_BYTES) return null
166
173
  const text = await file.text()
167
174
  if (text.length === 0) return 0
168
175
  let count = 0
package/src/index.tsx CHANGED
@@ -83,7 +83,7 @@ if (command === '--help' || command === '-h' || command === 'help') {
83
83
  '',
84
84
  'Common CLI verbs at a glance',
85
85
  ' aimux tab list Enumerate tabs (+ activeTabId)',
86
- ' aimux tab create --assistant <id> [--title …] Spawn claude / codex / opencode / terminal / …',
86
+ ' aimux tab create --assistant <id> [--title …] Spawn claude / codex / opencode / grok / terminal / …',
87
87
  ' aimux tab send <tabId> [text] [--enter|--keys|--stdin] Type, chord, or paste into a tab',
88
88
  ' aimux tab focus <tabId> Bring a tab to the foreground',
89
89
  ' aimux tab close <tabId> Terminate a tab',
@@ -41,6 +41,7 @@ const PERMISSION_SIGNALS: readonly string[] = [
41
41
  '△ permission',
42
42
  'allow this',
43
43
  'approve',
44
+ 'pprove', // Grok stylized plan approval "[ a ] pprove ..."
44
45
  'grant',
45
46
  'press enter to confirm',
46
47
  ]
@@ -6,7 +6,7 @@
6
6
  * `src/detect.rs` in that repo.
7
7
  *
8
8
  * The detector classifies a terminal session as `working`, `waiting-input`,
9
- * or `idle`. Built-in CLIs (claude, codex, opencode) use per-CLI substring
9
+ * or `idle`. Built-in CLIs (claude, codex, opencode, grok) have dedicated classify* functions.
10
10
  * tables. Custom CLIs fall back to a generic heuristic that (a) recognises
11
11
  * common shells as always-idle and (b) uses pane-tail change velocity plus
12
12
  * generic y/n / confirm prompt patterns.
@@ -95,7 +95,7 @@ export class AssistantStatusDetector {
95
95
  export function extractTailLines(viewport: TerminalSnapshot, lineCount: number): string[] {
96
96
  const isScrolledToBottom = viewport.viewportY === viewport.baseY
97
97
  const lines = isScrolledToBottom ? viewport.lines : (viewport.tailLines ?? viewport.lines)
98
- // Full-screen TUIs (claude, opencode) paint in the alternate buffer and
98
+ // Full-screen TUIs (claude, opencode, grok) paint in the alternate buffer and
99
99
  // often leave the last rows blank, putting their status bar higher up.
100
100
  // Skip trailing blank rows before taking the last `lineCount`.
101
101
  let end = lines.length
@@ -138,6 +138,8 @@ function classifyBuiltin(
138
138
  return classifyCodex(haystack)
139
139
  case 'opencode':
140
140
  return classifyOpencode(haystack)
141
+ case 'grok':
142
+ return classifyGrok(haystack, rawTail)
141
143
  default:
142
144
  return null
143
145
  }
@@ -212,6 +214,47 @@ function classifyOpencode(haystack: string): TabActivity {
212
214
  return 'idle'
213
215
  }
214
216
 
217
+ function classifyGrok(haystack: string, _rawTail: string): TabActivity {
218
+ // Waiting for user decision / input (plan approval, Q&A, permissions, confirms).
219
+ // These are the distinctive Grok Build TUI states that must produce 'waiting-input'
220
+ // so the rest of the system (turn lifecycle, question events, UI chips, orchestrators)
221
+ // treats grok the same as claude/codex/opencode.
222
+ if (
223
+ haystack.includes('waiting on answers') ||
224
+ haystack.includes('pprove') || // stylized "[ a ] pprove [ c ] omment [ q ] uit plan"
225
+ haystack.includes('omment') ||
226
+ haystack.includes('uit plan') ||
227
+ (haystack.includes('approve') &&
228
+ (haystack.includes('comment') || haystack.includes('quit') || haystack.includes('plan'))) ||
229
+ haystack.includes('enter :select') ||
230
+ haystack.includes('enter to select') ||
231
+ haystack.includes('enter submit') ||
232
+ haystack.includes('do you want') ||
233
+ haystack.includes('would you like') ||
234
+ haystack.includes('permission required') ||
235
+ haystack.includes('permission to') ||
236
+ haystack.includes('approve?') ||
237
+ haystack.includes('allow?')
238
+ ) {
239
+ return 'waiting-input'
240
+ }
241
+
242
+ // Agent actively reasoning or executing (mirrors claude spinner + interrupt logic).
243
+ // "Thought for Xs" is the primary visible trace while Grok thinks/plans.
244
+ if (
245
+ haystack.includes('thought for') || // "Thought for 3.4s", etc.
246
+ haystack.includes('thinking…') ||
247
+ haystack.includes('thinking ...') ||
248
+ haystack.includes('esc to interrupt') ||
249
+ haystack.includes('esc interrupt') ||
250
+ haystack.includes('esc: interrupt')
251
+ ) {
252
+ return 'working'
253
+ }
254
+
255
+ return 'idle'
256
+ }
257
+
215
258
  const GENERIC_WAITING_PATTERNS: string[] = [
216
259
  '[y/n]',
217
260
  '(y/n)',
@@ -59,6 +59,17 @@ export const ASSISTANT_OPTIONS: AssistantOption[] = [
59
59
  buildModelArgs: (model) => ['--model', model],
60
60
  },
61
61
  },
62
+ {
63
+ command: 'grok',
64
+ description: 'xAI Grok Build CLI',
65
+ id: 'grok',
66
+ label: 'Grok',
67
+ model: {
68
+ // Grok supports -m (and likely --model) plus --effort (alias --reasoning-effort).
69
+ buildEffortArgs: (effort) => ['--effort', effort],
70
+ buildModelArgs: (model) => ['-m', model],
71
+ },
72
+ },
62
73
  {
63
74
  command: 'agy',
64
75
  description: 'Antigravity CLI',
@@ -4,7 +4,13 @@ import type { ThemedToken } from 'shiki'
4
4
  import type { WorktreeTemplate } from '../config'
5
5
  import type { LayoutNode, SplitDirection } from './layout-tree'
6
6
 
7
- export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal' | 'antigravity'
7
+ export type BuiltinAssistantId =
8
+ | 'claude'
9
+ | 'codex'
10
+ | 'opencode'
11
+ | 'grok'
12
+ | 'terminal'
13
+ | 'antigravity'
8
14
 
9
15
  export type AssistantId = BuiltinAssistantId | (string & {})
10
16
 
@@ -256,7 +262,14 @@ export interface GitPanelState {
256
262
  error: GitPanelError | null
257
263
  }
258
264
 
259
- export type DiffFileStatus = 'modified' | 'new' | 'deleted' | 'binary' | 'renamed' | 'image'
265
+ export type DiffFileStatus =
266
+ | 'modified'
267
+ | 'new'
268
+ | 'deleted'
269
+ | 'binary'
270
+ | 'renamed'
271
+ | 'image'
272
+ | 'too-large'
260
273
 
261
274
  export interface DiffData {
262
275
  path: string
@@ -7,6 +7,7 @@ import type { DiffData, GitDiffView } from '../../../state/types'
7
7
  import type { ThemeId } from '../../themes'
8
8
 
9
9
  import { diffHash } from '../../../git/diff-hash'
10
+ import { MAX_DIFF_BYTES } from '../../../git/diff-limits'
10
11
  import { getMergeBase } from '../../../git/divergence'
11
12
  import { fetchDiff } from '../../../git/git-diff'
12
13
  import { useGitPanelPolling } from '../../../git/git-poller'
@@ -21,6 +22,7 @@ import { PierreDiff, type PierreDiffHandle } from './diff-renderer'
21
22
  import { useDiffPrefetch } from './diff-renderer/use-diff-prefetch'
22
23
  import { GitPanel } from './git-panel'
23
24
  import { ImageDiffView } from './image-diff'
25
+ import { formatBytes } from './image-diff/dimensions'
24
26
  import { GitPaneHeader } from './pane/git-pane-header'
25
27
 
26
28
  interface DiffStageProps {
@@ -33,6 +35,12 @@ interface DiffStageProps {
33
35
  }
34
36
 
35
37
  function placeholderText(diff: DiffData): string | null {
38
+ if (diff.status === 'too-large') {
39
+ const before = diff.binarySizeBefore ?? 0
40
+ const after = diff.binarySizeAfter ?? 0
41
+ const largest = Math.max(before, after)
42
+ return `(file too large to diff — ${formatBytes(largest)}, limit ${formatBytes(MAX_DIFF_BYTES)})`
43
+ }
36
44
  if (diff.status === 'binary') {
37
45
  const before = diff.binarySizeBefore ?? 0
38
46
  const after = diff.binarySizeAfter ?? 0
@@ -92,5 +92,6 @@ export function readImageDimensions(bytes: Uint8Array): ImageDimensions | null {
92
92
  export function formatBytes(n: number): string {
93
93
  if (n < 1024) return `${n} B`
94
94
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
95
- return `${(n / (1024 * 1024)).toFixed(2)} MB`
95
+ if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(2)} MB`
96
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`
96
97
  }