@brimveyn/aimux 1.19.4 → 1.19.6
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/README.md +1 -1
- package/package.json +4 -3
- package/src/auto-commit/headless-commands.ts +9 -1
- package/src/cli/commands/tab/create.ts +1 -1
- package/src/git/diff-limits.ts +8 -0
- package/src/git/git-diff.ts +67 -14
- package/src/git/git-status.ts +7 -0
- package/src/index.tsx +1 -1
- package/src/pty/assistant-question-extractor.ts +7 -0
- package/src/pty/assistant-status-detector.ts +68 -4
- package/src/pty/command-registry.ts +10 -0
- package/src/state/types.ts +9 -1
- package/src/ui/components/git/git-view.tsx +8 -0
- package/src/ui/components/git/image-diff/dimensions.ts +2 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ snippets, themes, and fully configurable keymaps.
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
15
|
- multi-workspace workflow with a dedicated workspace picker
|
|
16
|
-
- tabs for `claude`, `codex`, `opencode`, `grok`, and `terminal`
|
|
16
|
+
- tabs for `claude`, `codex`, `opencode`, `grok`, `kimi`, and `terminal`
|
|
17
17
|
- split panes with pane focus and resize shortcuts
|
|
18
18
|
- persistent workspaces with saved layout and tab state
|
|
19
19
|
- profile-isolated config, catalogs, daemon sockets, and runtime state
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.19.
|
|
4
|
-
"description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
|
|
3
|
+
"version": "1.19.6",
|
|
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",
|
|
7
7
|
"aimux",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"claude",
|
|
10
10
|
"cli",
|
|
11
11
|
"codex",
|
|
12
|
+
"kimi",
|
|
12
13
|
"multiplexer",
|
|
13
14
|
"opencode",
|
|
14
15
|
"pty",
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
"bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
|
|
65
66
|
},
|
|
66
67
|
"dependencies": {
|
|
67
|
-
"@brimveyn/aimux-config": "0.8.
|
|
68
|
+
"@brimveyn/aimux-config": "0.8.3",
|
|
68
69
|
"@opentui/core": "^0.1.90",
|
|
69
70
|
"@opentui/react": "^0.1.90",
|
|
70
71
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
@@ -3,13 +3,14 @@ export interface HeadlessInvocation {
|
|
|
3
3
|
args: string[]
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
export type SupportedProvider = 'claude' | 'codex' | 'opencode' | 'grok'
|
|
6
|
+
export type SupportedProvider = 'claude' | 'codex' | 'opencode' | 'grok' | 'kimi'
|
|
7
7
|
|
|
8
8
|
const SUPPORTED: ReadonlySet<string> = new Set<SupportedProvider>([
|
|
9
9
|
'claude',
|
|
10
10
|
'codex',
|
|
11
11
|
'opencode',
|
|
12
12
|
'grok',
|
|
13
|
+
'kimi',
|
|
13
14
|
])
|
|
14
15
|
|
|
15
16
|
export function isSupportedProvider(id: string): id is SupportedProvider {
|
|
@@ -44,6 +45,13 @@ export function buildHeadlessInvocation(
|
|
|
44
45
|
if (model != null && model !== '') args.push('-m', model)
|
|
45
46
|
return { args, executable: 'grok' }
|
|
46
47
|
}
|
|
48
|
+
case 'kimi': {
|
|
49
|
+
// Headless: kimi -p "<prompt>" [--model <model>]. Non-interactive mode uses
|
|
50
|
+
// auto permission by default; --output-format text is the default.
|
|
51
|
+
const args: string[] = ['-p', prompt]
|
|
52
|
+
if (model != null && model !== '') args.push('--model', model)
|
|
53
|
+
return { args, executable: 'kimi' }
|
|
54
|
+
}
|
|
47
55
|
default:
|
|
48
56
|
return null
|
|
49
57
|
}
|
|
@@ -55,7 +55,7 @@ export const tabCreate: CliCommand = {
|
|
|
55
55
|
flags: [
|
|
56
56
|
...SHARED_FLAGS,
|
|
57
57
|
{
|
|
58
|
-
description: 'assistant id (claude, codex, opencode, grok, terminal, ...)',
|
|
58
|
+
description: 'assistant id (claude, codex, opencode, grok, kimi, terminal, ...)',
|
|
59
59
|
kind: 'string',
|
|
60
60
|
name: 'assistant',
|
|
61
61
|
},
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Git plumbing and Bun's file API both hand a blob back as a single JS string. Past
|
|
2
|
+
// JavaScriptCore's string cap the allocation does not throw — it aborts the process
|
|
3
|
+
// with SIGTRAP, which no try/catch can intercept and which takes the whole TUI down
|
|
4
|
+
// with it. Every read of file content in the git layer is gated on this.
|
|
5
|
+
//
|
|
6
|
+
// 5 MB covers source files and mid-sized CSVs. Anything past it is a build artefact
|
|
7
|
+
// or a download, and nobody reviews those line-by-line.
|
|
8
|
+
export const MAX_DIFF_BYTES = 5 * 1024 * 1024
|
package/src/git/git-diff.ts
CHANGED
|
@@ -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
|
|
20
|
-
if (
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
package/src/git/git-status.ts
CHANGED
|
@@ -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 / grok / terminal / …',
|
|
86
|
+
' aimux tab create --assistant <id> [--title …] Spawn claude / codex / opencode / grok / kimi / 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',
|
|
@@ -44,6 +44,13 @@ const PERMISSION_SIGNALS: readonly string[] = [
|
|
|
44
44
|
'pprove', // Grok stylized plan approval "[ a ] pprove ..."
|
|
45
45
|
'grant',
|
|
46
46
|
'press enter to confirm',
|
|
47
|
+
// Kimi Code CLI approval panel titles
|
|
48
|
+
'run this command?',
|
|
49
|
+
'write this file?',
|
|
50
|
+
'apply these edits?',
|
|
51
|
+
'ready to build with this plan?',
|
|
52
|
+
'approve for session',
|
|
53
|
+
'stop this task?',
|
|
47
54
|
]
|
|
48
55
|
|
|
49
56
|
/**
|
|
@@ -6,10 +6,10 @@
|
|
|
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, grok) have dedicated
|
|
10
|
-
*
|
|
11
|
-
* common shells as always-idle and (b) uses pane-tail change
|
|
12
|
-
* generic y/n / confirm prompt patterns.
|
|
9
|
+
* or `idle`. Built-in CLIs (claude, codex, opencode, grok, kimi) have dedicated
|
|
10
|
+
* classify* functions. Custom CLIs fall back to a generic heuristic that
|
|
11
|
+
* (a) recognises common shells as always-idle and (b) uses pane-tail change
|
|
12
|
+
* velocity plus generic y/n / confirm prompt patterns.
|
|
13
13
|
*/
|
|
14
14
|
import type { AssistantId, TabActivity, TerminalSnapshot } from '../state/types'
|
|
15
15
|
|
|
@@ -140,6 +140,8 @@ function classifyBuiltin(
|
|
|
140
140
|
return classifyOpencode(haystack)
|
|
141
141
|
case 'grok':
|
|
142
142
|
return classifyGrok(haystack, rawTail)
|
|
143
|
+
case 'kimi':
|
|
144
|
+
return classifyKimi(haystack, rawTail)
|
|
143
145
|
default:
|
|
144
146
|
return null
|
|
145
147
|
}
|
|
@@ -255,6 +257,68 @@ function classifyGrok(haystack: string, _rawTail: string): TabActivity {
|
|
|
255
257
|
return 'idle'
|
|
256
258
|
}
|
|
257
259
|
|
|
260
|
+
/** Braille spinner frames used by Kimi Code CLI (`BRAILLE_SPINNER_FRAMES`). */
|
|
261
|
+
const KIMI_BRAILLE_SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
|
262
|
+
/** Moon-phase spinner frames used by Kimi Code CLI (`MOON_SPINNER_FRAMES`). */
|
|
263
|
+
const KIMI_MOON_SPINNER = '🌑🌒🌓🌔🌕🌖🌗🌘'
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Kimi Code CLI status heuristics from the official TUI:
|
|
267
|
+
* - Approval panel titles/footers → waiting-input
|
|
268
|
+
* - Braille/moon spinners, `working...`, rotating tips → working
|
|
269
|
+
*/
|
|
270
|
+
function classifyKimi(haystack: string, rawTail: string): TabActivity {
|
|
271
|
+
// Tool / plan approval panel (apps/kimi-code approval-panel.ts headers + footer).
|
|
272
|
+
if (
|
|
273
|
+
haystack.includes('run this command?') ||
|
|
274
|
+
haystack.includes('write this file?') ||
|
|
275
|
+
haystack.includes('apply these edits?') ||
|
|
276
|
+
haystack.includes('stop this task?') ||
|
|
277
|
+
haystack.includes('ready to build with this plan?') ||
|
|
278
|
+
haystack.includes('approve for session') ||
|
|
279
|
+
haystack.includes('approve for this session') ||
|
|
280
|
+
haystack.includes('type feedback') ||
|
|
281
|
+
haystack.includes('waiting for authorization') ||
|
|
282
|
+
haystack.includes('sign in to kimi') ||
|
|
283
|
+
haystack.includes('do you want') ||
|
|
284
|
+
haystack.includes('would you like') ||
|
|
285
|
+
haystack.includes('permission required') ||
|
|
286
|
+
haystack.includes('permission to') ||
|
|
287
|
+
haystack.includes('approve?') ||
|
|
288
|
+
haystack.includes('allow?') ||
|
|
289
|
+
// Generic "Approve <Tool>?" header, e.g. "▶ Approve Bash?"
|
|
290
|
+
(haystack.includes('approve ') && haystack.includes('?')) ||
|
|
291
|
+
// Distinctive panel chrome: "↑/↓ select · 1/2 choose · ↵ confirm"
|
|
292
|
+
haystack.includes('↑/↓ select') ||
|
|
293
|
+
haystack.includes('↵ confirm')
|
|
294
|
+
) {
|
|
295
|
+
return 'waiting-input'
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Agent actively streaming / calling tools.
|
|
299
|
+
// Anchor thinking on ellipsis so the footer model suffix "thinking" alone
|
|
300
|
+
// (e.g. "kimi-k2 thinking") does not mark an idle session as working.
|
|
301
|
+
if (
|
|
302
|
+
haystack.includes('working...') ||
|
|
303
|
+
haystack.includes('working…') ||
|
|
304
|
+
haystack.includes(' · tip:') ||
|
|
305
|
+
haystack.includes('thinking…') ||
|
|
306
|
+
haystack.includes('thinking...') ||
|
|
307
|
+
hasKimiSpinner(rawTail)
|
|
308
|
+
) {
|
|
309
|
+
return 'working'
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return 'idle'
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function hasKimiSpinner(rawTail: string): boolean {
|
|
316
|
+
for (const ch of rawTail) {
|
|
317
|
+
if (KIMI_BRAILLE_SPINNER.includes(ch) || KIMI_MOON_SPINNER.includes(ch)) return true
|
|
318
|
+
}
|
|
319
|
+
return false
|
|
320
|
+
}
|
|
321
|
+
|
|
258
322
|
const GENERIC_WAITING_PATTERNS: string[] = [
|
|
259
323
|
'[y/n]',
|
|
260
324
|
'(y/n)',
|
|
@@ -70,6 +70,16 @@ export const ASSISTANT_OPTIONS: AssistantOption[] = [
|
|
|
70
70
|
buildModelArgs: (model) => ['-m', model],
|
|
71
71
|
},
|
|
72
72
|
},
|
|
73
|
+
{
|
|
74
|
+
command: 'kimi',
|
|
75
|
+
description: 'Moonshot Kimi Code CLI',
|
|
76
|
+
id: 'kimi',
|
|
77
|
+
label: 'Kimi',
|
|
78
|
+
// Kimi supports --model / -m; no reasoning-effort flag.
|
|
79
|
+
model: {
|
|
80
|
+
buildModelArgs: (model) => ['--model', model],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
73
83
|
{
|
|
74
84
|
command: 'agy',
|
|
75
85
|
description: 'Antigravity CLI',
|
package/src/state/types.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type BuiltinAssistantId =
|
|
|
9
9
|
| 'codex'
|
|
10
10
|
| 'opencode'
|
|
11
11
|
| 'grok'
|
|
12
|
+
| 'kimi'
|
|
12
13
|
| 'terminal'
|
|
13
14
|
| 'antigravity'
|
|
14
15
|
|
|
@@ -262,7 +263,14 @@ export interface GitPanelState {
|
|
|
262
263
|
error: GitPanelError | null
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
export type DiffFileStatus =
|
|
266
|
+
export type DiffFileStatus =
|
|
267
|
+
| 'modified'
|
|
268
|
+
| 'new'
|
|
269
|
+
| 'deleted'
|
|
270
|
+
| 'binary'
|
|
271
|
+
| 'renamed'
|
|
272
|
+
| 'image'
|
|
273
|
+
| 'too-large'
|
|
266
274
|
|
|
267
275
|
export interface DiffData {
|
|
268
276
|
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
|
}
|