@brimveyn/aimux 1.10.3 → 1.12.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.
- package/package.json +3 -2
- package/src/app-runtime/side-effects.ts +209 -2
- package/src/app.tsx +2 -0
- package/src/git/git-diff.ts +47 -0
- package/src/git/image-detect.ts +34 -0
- package/src/input/modes/types.ts +1 -0
- package/src/state/types.ts +5 -1
- package/src/ui/components/git/git-view.tsx +5 -0
- package/src/ui/components/git/image-diff/dimensions.ts +96 -0
- package/src/ui/components/git/image-diff/image-diff-view.tsx +113 -0
- package/src/ui/components/git/image-diff/index.ts +1 -0
- package/src/ui/components/git/image-diff/terminal-image-pane.tsx +99 -0
- package/src/ui/terminal-graphics/capabilities.ts +32 -0
- package/src/ui/terminal-graphics/format-fallback.ts +143 -0
- package/src/ui/terminal-graphics/kitty.ts +79 -0
- package/src/ui/terminal-graphics/svg-render.ts +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -60,9 +60,10 @@
|
|
|
60
60
|
"bump": "bun run scripts/bump.ts"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@brimveyn/aimux-config": "0.5.
|
|
63
|
+
"@brimveyn/aimux-config": "0.5.12",
|
|
64
64
|
"@opentui/core": "^0.1.90",
|
|
65
65
|
"@opentui/react": "^0.1.90",
|
|
66
|
+
"@resvg/resvg-wasm": "^2.6.2",
|
|
66
67
|
"@xterm/headless": "^6.0.0",
|
|
67
68
|
"bun-pty": "^0.4.8",
|
|
68
69
|
"react": "^19.2.4",
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_EDITOR_ARGS,
|
|
3
|
+
getExternalEditorConfig,
|
|
4
|
+
isAutoCommitEnabled,
|
|
5
|
+
KNOWN_GUI_EDITORS,
|
|
6
|
+
} from '@brimveyn/aimux-config'
|
|
7
|
+
import { type CliRenderer } from '@opentui/core'
|
|
2
8
|
import { $ } from 'bun'
|
|
9
|
+
import { resolve as resolvePath } from 'node:path'
|
|
3
10
|
|
|
4
11
|
import type { SideEffect } from '../input/modes/types'
|
|
5
12
|
import type { SessionBackend } from '../session-backend/types'
|
|
@@ -59,7 +66,7 @@ export interface SideEffectContext {
|
|
|
59
66
|
state: AppState
|
|
60
67
|
dispatch: (action: AppAction) => void
|
|
61
68
|
backend: SessionBackend
|
|
62
|
-
renderer:
|
|
69
|
+
renderer: CliRenderer
|
|
63
70
|
themeId: ThemeId
|
|
64
71
|
setThemeId: (id: ThemeId) => void
|
|
65
72
|
activeTab: TabSession | undefined
|
|
@@ -606,11 +613,211 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
606
613
|
saveConfig({ ...loadConfig(), themeMode: next })
|
|
607
614
|
return
|
|
608
615
|
}
|
|
616
|
+
case 'open-file-in-editor': {
|
|
617
|
+
openFileInEditor(ctx, effect.path)
|
|
618
|
+
return
|
|
619
|
+
}
|
|
609
620
|
default:
|
|
610
621
|
effect satisfies never
|
|
611
622
|
}
|
|
612
623
|
}
|
|
613
624
|
|
|
625
|
+
function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
|
|
626
|
+
const config = getExternalEditorConfig()
|
|
627
|
+
const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
|
|
628
|
+
if (!rawCommand || rawCommand.trim() === '') {
|
|
629
|
+
ctx.dispatch({
|
|
630
|
+
message: 'no $EDITOR/$VISUAL set — configure externalEditor in aimux.config.ts',
|
|
631
|
+
type: 'git-mode-set-message',
|
|
632
|
+
})
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const fileEntry = ctx.state.gitPanel.files.find((f) => f.path === relPath)
|
|
637
|
+
const cwd = fileEntry?.repoPath ?? ctx.getCurrentSessionProjectPath()
|
|
638
|
+
if (!cwd) {
|
|
639
|
+
ctx.dispatch({ message: 'no working directory', type: 'git-mode-set-message' })
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
const absolutePath = resolvePath(cwd, relPath)
|
|
643
|
+
|
|
644
|
+
const cmdParts = shellSplit(rawCommand)
|
|
645
|
+
const executable = cmdParts[0]
|
|
646
|
+
if (!executable) {
|
|
647
|
+
ctx.dispatch({ message: 'invalid editor command', type: 'git-mode-set-message' })
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
const baseName = executable.split('/').pop() ?? executable
|
|
651
|
+
const extraCmdArgs = cmdParts.slice(1)
|
|
652
|
+
|
|
653
|
+
const kind: 'gui' | 'tui' = config.kind ?? (KNOWN_GUI_EDITORS.has(baseName) ? 'gui' : 'tui')
|
|
654
|
+
|
|
655
|
+
const templateArgs = config.args ?? DEFAULT_EDITOR_ARGS[baseName] ?? ['{file}']
|
|
656
|
+
// git mode does not track a cursor line within the diff yet, so pass undefined
|
|
657
|
+
// and let the substitution strip `{line}` placeholders cleanly — preserves
|
|
658
|
+
// editor-side "restore last cursor position" behavior (e.g. vscode).
|
|
659
|
+
const resolvedArgs = [...extraCmdArgs, ...substituteEditorArgs(templateArgs, absolutePath)]
|
|
660
|
+
|
|
661
|
+
if (!isCommandAvailable(executable)) {
|
|
662
|
+
ctx.dispatch({
|
|
663
|
+
message: `editor not found in PATH: ${executable}`,
|
|
664
|
+
type: 'git-mode-set-message',
|
|
665
|
+
})
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (kind === 'gui') {
|
|
670
|
+
spawnDetached(ctx, [executable, ...resolvedArgs], cwd)
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// TUI editor — if the user explicitly configured `terminal`, spawn a new
|
|
675
|
+
// terminal window with their template. Otherwise default to inline shellout:
|
|
676
|
+
// suspend the renderer, hand the TTY to the editor, resume on exit.
|
|
677
|
+
if (config.terminal && config.terminal.length > 0) {
|
|
678
|
+
const shellCmd = buildShellCmd(cwd, executable, resolvedArgs)
|
|
679
|
+
const argv = config.terminal.map((a) =>
|
|
680
|
+
a.replaceAll('{cmd}', shellCmd).replaceAll('{cwd}', cwd)
|
|
681
|
+
)
|
|
682
|
+
spawnDetached(ctx, argv, cwd)
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
void openEditorInline(ctx, executable, resolvedArgs, cwd)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Substitute `{file}` and `{line}` placeholders in an editor-arg template.
|
|
691
|
+
*
|
|
692
|
+
* When `line` is `undefined` we drop the line bits cleanly so we don't pass a
|
|
693
|
+
* misleading `:1` / `+1` that would defeat the editor's "restore last cursor
|
|
694
|
+
* position" feature:
|
|
695
|
+
* `['--line', '{line}', '{file}']` → `['{file}']`
|
|
696
|
+
* `['+{line}', '{file}']` → `['{file}']`
|
|
697
|
+
* `['-g', '{file}:{line}']` → `['-g', '{file}']`
|
|
698
|
+
* `['{file}:{line}']` → `['{file}']`
|
|
699
|
+
*/
|
|
700
|
+
function substituteEditorArgs(template: string[], file: string, line?: string): string[] {
|
|
701
|
+
if (line !== undefined) {
|
|
702
|
+
return template.map((a) => a.replaceAll('{file}', file).replaceAll('{line}', line))
|
|
703
|
+
}
|
|
704
|
+
const out: string[] = []
|
|
705
|
+
for (let i = 0; i < template.length; i++) {
|
|
706
|
+
const arg = template[i] ?? ''
|
|
707
|
+
// Drop a flag immediately followed by a bare `{line}` arg (--line, -line, etc.).
|
|
708
|
+
if (template[i + 1] === '{line}') {
|
|
709
|
+
i++
|
|
710
|
+
continue
|
|
711
|
+
}
|
|
712
|
+
// Drop standalone line tokens like `{line}`, `+{line}`, `:{line}`.
|
|
713
|
+
if (/^[+:]?\{line\}$/.test(arg)) continue
|
|
714
|
+
// Strip trailing `:{line}` or `+{line}` from compound tokens like `{file}:{line}`.
|
|
715
|
+
out.push(arg.replace(/[:+]\{line\}/g, '').replaceAll('{file}', file))
|
|
716
|
+
}
|
|
717
|
+
return out
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function shellQuote(s: string): string {
|
|
721
|
+
return `'${s.replaceAll("'", `'\\''`)}'`
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Minimal POSIX shell-word splitter — respects single/double quotes and
|
|
726
|
+
* backslash escapes so values like `EDITOR='/Applications/My Editor/bin/code'`
|
|
727
|
+
* or `EDITOR="code --user-data-dir \"/tmp/foo bar\""` tokenize correctly.
|
|
728
|
+
* Does not expand variables or globs.
|
|
729
|
+
*/
|
|
730
|
+
function shellSplit(input: string): string[] {
|
|
731
|
+
const out: string[] = []
|
|
732
|
+
let current = ''
|
|
733
|
+
let inSingle = false
|
|
734
|
+
let inDouble = false
|
|
735
|
+
let hasToken = false
|
|
736
|
+
for (let i = 0; i < input.length; i++) {
|
|
737
|
+
const c = input[i] ?? ''
|
|
738
|
+
if (!inSingle && !inDouble && /\s/.test(c)) {
|
|
739
|
+
if (hasToken) {
|
|
740
|
+
out.push(current)
|
|
741
|
+
current = ''
|
|
742
|
+
hasToken = false
|
|
743
|
+
}
|
|
744
|
+
continue
|
|
745
|
+
}
|
|
746
|
+
hasToken = true
|
|
747
|
+
if (c === "'" && !inDouble) {
|
|
748
|
+
inSingle = !inSingle
|
|
749
|
+
} else if (c === '"' && !inSingle) {
|
|
750
|
+
inDouble = !inDouble
|
|
751
|
+
} else if (c === '\\' && !inSingle && i + 1 < input.length) {
|
|
752
|
+
current += input[++i]
|
|
753
|
+
} else {
|
|
754
|
+
current += c
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (hasToken) out.push(current)
|
|
758
|
+
return out
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function buildShellCmd(cwd: string, executable: string, args: string[]): string {
|
|
762
|
+
const quoted = [executable, ...args].map(shellQuote).join(' ')
|
|
763
|
+
return `cd ${shellQuote(cwd)} && ${quoted}`
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function spawnDetached(ctx: SideEffectContext, argv: string[], cwd?: string): void {
|
|
767
|
+
try {
|
|
768
|
+
const child = Bun.spawn(argv, {
|
|
769
|
+
cwd,
|
|
770
|
+
stderr: 'pipe',
|
|
771
|
+
stdin: 'ignore',
|
|
772
|
+
stdout: 'ignore',
|
|
773
|
+
})
|
|
774
|
+
void (async () => {
|
|
775
|
+
const stderr = await new Response(child.stderr).text()
|
|
776
|
+
const code = await child.exited
|
|
777
|
+
if (code !== 0) {
|
|
778
|
+
const firstLine = stderr.trim().split('\n')[0] || `exit ${code}`
|
|
779
|
+
ctx.dispatch({ message: `editor: ${firstLine}`, type: 'git-mode-set-message' })
|
|
780
|
+
}
|
|
781
|
+
})()
|
|
782
|
+
child.unref()
|
|
783
|
+
} catch (err) {
|
|
784
|
+
const msg = err instanceof Error ? err.message : 'failed to spawn'
|
|
785
|
+
ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Suspend the opentui renderer, hand the TTY to the editor (inheriting
|
|
791
|
+
* stdin/stdout/stderr), then resume and force a redraw on exit. Matches the
|
|
792
|
+
* shellout pattern used by opencode (packages/opencode/src/cli/cmd/tui/util/editor.ts).
|
|
793
|
+
*/
|
|
794
|
+
async function openEditorInline(
|
|
795
|
+
ctx: SideEffectContext,
|
|
796
|
+
executable: string,
|
|
797
|
+
args: string[],
|
|
798
|
+
cwd: string
|
|
799
|
+
): Promise<void> {
|
|
800
|
+
const { renderer } = ctx
|
|
801
|
+
try {
|
|
802
|
+
renderer.suspend()
|
|
803
|
+
renderer.currentRenderBuffer.clear()
|
|
804
|
+
const proc = Bun.spawn([executable, ...args], {
|
|
805
|
+
cwd,
|
|
806
|
+
stderr: 'inherit',
|
|
807
|
+
stdin: 'inherit',
|
|
808
|
+
stdout: 'inherit',
|
|
809
|
+
})
|
|
810
|
+
await proc.exited
|
|
811
|
+
} catch (err) {
|
|
812
|
+
const msg = err instanceof Error ? err.message : 'failed to spawn editor'
|
|
813
|
+
ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
|
|
814
|
+
} finally {
|
|
815
|
+
renderer.currentRenderBuffer.clear()
|
|
816
|
+
renderer.resume()
|
|
817
|
+
renderer.requestRender()
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
614
821
|
function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
|
|
615
822
|
const { backend, dispatch, state } = ctx
|
|
616
823
|
const ordered = state.sessions
|
package/src/app.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type ResolvedConfig,
|
|
3
3
|
setAutoCommitEnabled,
|
|
4
|
+
setExternalEditorConfig,
|
|
4
5
|
setMultiRepoConfig,
|
|
5
6
|
} from '@brimveyn/aimux-config'
|
|
6
7
|
import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
|
|
@@ -65,6 +66,7 @@ export function App({
|
|
|
65
66
|
// actions (which live outside React) can read it synchronously.
|
|
66
67
|
setAutoCommitEnabled(resolvedConfig.autoCommit.enabled)
|
|
67
68
|
setMultiRepoConfig(resolvedConfig.multiRepo)
|
|
69
|
+
setExternalEditorConfig(resolvedConfig.externalEditor)
|
|
68
70
|
|
|
69
71
|
const keymapHandlers = useMemo(
|
|
70
72
|
() => {
|
package/src/git/git-diff.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { $ } from 'bun'
|
|
|
2
2
|
|
|
3
3
|
import type { DiffData, DiffFileStatus, GitFileEntry } from '../state/types'
|
|
4
4
|
|
|
5
|
+
import { imageFormatLabel, imageMimeFromPath, isImagePath } from './image-detect'
|
|
6
|
+
|
|
5
7
|
function resolveStatus(entry: GitFileEntry): { status: DiffFileStatus; oldPath?: string } {
|
|
6
8
|
if (entry.renamedFrom) return { oldPath: entry.renamedFrom, status: 'renamed' }
|
|
7
9
|
if (entry.section === 'untracked' || entry.status === '?') return { status: 'new' }
|
|
@@ -35,6 +37,28 @@ async function readWorkingSize(cwd: string, path: string): Promise<number> {
|
|
|
35
37
|
return 0
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
async function readHeadBlob(
|
|
41
|
+
cwd: string,
|
|
42
|
+
ref: string,
|
|
43
|
+
path: string
|
|
44
|
+
): Promise<Uint8Array | undefined> {
|
|
45
|
+
// `git cat-file blob` writes raw bytes to stdout — binary-safe, unlike `git show`.
|
|
46
|
+
const result = await $`git -C ${cwd} cat-file blob ${ref}:${path}`.quiet().nothrow()
|
|
47
|
+
if (result.exitCode !== 0) return undefined
|
|
48
|
+
const bytes = result.stdout
|
|
49
|
+
return bytes.byteLength > 0 ? new Uint8Array(bytes) : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readWorkingBytes(cwd: string, path: string): Promise<Uint8Array | undefined> {
|
|
53
|
+
try {
|
|
54
|
+
const file = Bun.file(`${cwd}/${path}`)
|
|
55
|
+
if (!(await file.exists())) return undefined
|
|
56
|
+
return await file.bytes()
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
async function rawUnifiedDiff(
|
|
39
63
|
cwd: string,
|
|
40
64
|
ref: string,
|
|
@@ -69,6 +93,29 @@ export async function fetchDiff(
|
|
|
69
93
|
const { oldPath, status } = resolveStatus(file)
|
|
70
94
|
const ref = headOffset > 0 ? `HEAD~${headOffset}` : 'HEAD'
|
|
71
95
|
|
|
96
|
+
if (isImagePath(file.path)) {
|
|
97
|
+
const headPath = oldPath ?? file.path
|
|
98
|
+
const wantsBefore = status !== 'new'
|
|
99
|
+
const wantsAfter = status !== 'deleted'
|
|
100
|
+
const [imageBytesBefore, imageBytesAfter] = await Promise.all([
|
|
101
|
+
wantsBefore ? readHeadBlob(cwd, ref, headPath) : Promise.resolve(undefined),
|
|
102
|
+
wantsAfter ? readWorkingBytes(cwd, file.path) : Promise.resolve(undefined),
|
|
103
|
+
])
|
|
104
|
+
const data: DiffData = {
|
|
105
|
+
binarySizeAfter: imageBytesAfter?.byteLength ?? 0,
|
|
106
|
+
binarySizeBefore: imageBytesBefore?.byteLength ?? 0,
|
|
107
|
+
imageFormatLabel: imageFormatLabel(file.path),
|
|
108
|
+
imageMime: imageMimeFromPath(file.path),
|
|
109
|
+
path: file.path,
|
|
110
|
+
rawDiff: '',
|
|
111
|
+
status: 'image',
|
|
112
|
+
}
|
|
113
|
+
if (imageBytesBefore) data.imageBytesBefore = imageBytesBefore
|
|
114
|
+
if (imageBytesAfter) data.imageBytesAfter = imageBytesAfter
|
|
115
|
+
if (oldPath) data.oldPath = oldPath
|
|
116
|
+
return data
|
|
117
|
+
}
|
|
118
|
+
|
|
72
119
|
if (await isBinary(cwd, ref, file.path)) {
|
|
73
120
|
const [binarySizeBefore, binarySizeAfter] = await Promise.all([
|
|
74
121
|
readHeadSize(cwd, ref, file.path),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
2
|
+
avif: 'image/avif',
|
|
3
|
+
bmp: 'image/bmp',
|
|
4
|
+
gif: 'image/gif',
|
|
5
|
+
ico: 'image/x-icon',
|
|
6
|
+
jpeg: 'image/jpeg',
|
|
7
|
+
jpg: 'image/jpeg',
|
|
8
|
+
png: 'image/png',
|
|
9
|
+
svg: 'image/svg+xml',
|
|
10
|
+
tif: 'image/tiff',
|
|
11
|
+
tiff: 'image/tiff',
|
|
12
|
+
webp: 'image/webp',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function extOf(path: string): string {
|
|
16
|
+
const slash = path.lastIndexOf('/')
|
|
17
|
+
const dot = path.lastIndexOf('.')
|
|
18
|
+
if (dot < 0 || dot < slash) return ''
|
|
19
|
+
return path.slice(dot + 1).toLowerCase()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isImagePath(path: string): boolean {
|
|
23
|
+
return extOf(path) in MIME_BY_EXT
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function imageMimeFromPath(path: string): string {
|
|
27
|
+
return MIME_BY_EXT[extOf(path)] ?? 'application/octet-stream'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function imageFormatLabel(path: string): string {
|
|
31
|
+
const ext = extOf(path)
|
|
32
|
+
if (ext === 'jpg') return 'jpeg'
|
|
33
|
+
return ext || 'image'
|
|
34
|
+
}
|
package/src/input/modes/types.ts
CHANGED
package/src/state/types.ts
CHANGED
|
@@ -202,7 +202,7 @@ export interface GitPanelState {
|
|
|
202
202
|
error: GitPanelError | null
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
export type DiffFileStatus = 'modified' | 'new' | 'deleted' | 'binary' | 'renamed'
|
|
205
|
+
export type DiffFileStatus = 'modified' | 'new' | 'deleted' | 'binary' | 'renamed' | 'image'
|
|
206
206
|
|
|
207
207
|
export interface DiffData {
|
|
208
208
|
path: string
|
|
@@ -212,6 +212,10 @@ export interface DiffData {
|
|
|
212
212
|
binarySizeBefore?: number
|
|
213
213
|
binarySizeAfter?: number
|
|
214
214
|
errorMessage?: string
|
|
215
|
+
imageBytesBefore?: Uint8Array
|
|
216
|
+
imageBytesAfter?: Uint8Array
|
|
217
|
+
imageMime?: string
|
|
218
|
+
imageFormatLabel?: string
|
|
215
219
|
}
|
|
216
220
|
|
|
217
221
|
export type GitDiffView = 'split' | 'stacked'
|
|
@@ -16,6 +16,7 @@ import { useTheme } from '../../theme'
|
|
|
16
16
|
import { PierreDiff, type PierreDiffHandle } from './diff-renderer'
|
|
17
17
|
import { useDiffPrefetch } from './diff-renderer/use-diff-prefetch'
|
|
18
18
|
import { GitPanel } from './git-panel'
|
|
19
|
+
import { ImageDiffView } from './image-diff'
|
|
19
20
|
|
|
20
21
|
interface DiffStageProps {
|
|
21
22
|
diff: DiffData | undefined
|
|
@@ -71,6 +72,10 @@ const DiffStage = memo(function DiffStage({
|
|
|
71
72
|
)
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
if (diff.status === 'image') {
|
|
76
|
+
return <ImageDiffView diff={diff} />
|
|
77
|
+
}
|
|
78
|
+
|
|
74
79
|
const placeholder = placeholderText(diff)
|
|
75
80
|
if (placeholder) {
|
|
76
81
|
return (
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Lightweight pure-JS dimension extraction. Handles only the headers we care
|
|
2
|
+
// about (PNG, JPEG, GIF, WebP, BMP); other formats return null and the UI
|
|
3
|
+
// shows just the byte size.
|
|
4
|
+
|
|
5
|
+
export interface ImageDimensions {
|
|
6
|
+
height: number
|
|
7
|
+
width: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readPng(b: Uint8Array): ImageDimensions | null {
|
|
11
|
+
if (b.length < 24) return null
|
|
12
|
+
// PNG signature + IHDR chunk type at offset 12.
|
|
13
|
+
if (b[12] !== 0x49 || b[13] !== 0x48 || b[14] !== 0x44 || b[15] !== 0x52) return null
|
|
14
|
+
const view = new DataView(b.buffer, b.byteOffset)
|
|
15
|
+
return { height: view.getUint32(20, false), width: view.getUint32(16, false) }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readGif(b: Uint8Array): ImageDimensions | null {
|
|
19
|
+
if (b.length < 10) return null
|
|
20
|
+
if (b[0] !== 0x47 || b[1] !== 0x49 || b[2] !== 0x46) return null
|
|
21
|
+
const view = new DataView(b.buffer, b.byteOffset)
|
|
22
|
+
return { height: view.getUint16(8, true), width: view.getUint16(6, true) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readBmp(b: Uint8Array): ImageDimensions | null {
|
|
26
|
+
if (b.length < 26) return null
|
|
27
|
+
if (b[0] !== 0x42 || b[1] !== 0x4d) return null
|
|
28
|
+
const view = new DataView(b.buffer, b.byteOffset)
|
|
29
|
+
return { height: Math.abs(view.getInt32(22, true)), width: view.getInt32(18, true) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readWebp(b: Uint8Array): ImageDimensions | null {
|
|
33
|
+
if (b.length < 30) return null
|
|
34
|
+
if (b[0] !== 0x52 || b[1] !== 0x49 || b[2] !== 0x46 || b[3] !== 0x46) return null
|
|
35
|
+
if (b[8] !== 0x57 || b[9] !== 0x45 || b[10] !== 0x42 || b[11] !== 0x50) return null
|
|
36
|
+
// VP8X chunk
|
|
37
|
+
if (b[12] === 0x56 && b[13] === 0x50 && b[14] === 0x38 && b[15] === 0x58) {
|
|
38
|
+
const view = new DataView(b.buffer, b.byteOffset)
|
|
39
|
+
const w = (view.getUint32(24, true) & 0xffffff) + 1
|
|
40
|
+
const h = ((view.getUint32(27, true) >> 8) & 0xffffff) + 1
|
|
41
|
+
return { height: h, width: w }
|
|
42
|
+
}
|
|
43
|
+
// VP8L (lossless)
|
|
44
|
+
if (b[12] === 0x56 && b[13] === 0x50 && b[14] === 0x38 && b[15] === 0x4c) {
|
|
45
|
+
const view = new DataView(b.buffer, b.byteOffset)
|
|
46
|
+
const bits = view.getUint32(21, true)
|
|
47
|
+
return { height: ((bits >> 14) & 0x3fff) + 1, width: (bits & 0x3fff) + 1 }
|
|
48
|
+
}
|
|
49
|
+
// VP8 (lossy)
|
|
50
|
+
if (b[12] === 0x56 && b[13] === 0x50 && b[14] === 0x38 && b[15] === 0x20) {
|
|
51
|
+
const view = new DataView(b.buffer, b.byteOffset)
|
|
52
|
+
return { height: view.getUint16(28, true) & 0x3fff, width: view.getUint16(26, true) & 0x3fff }
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readJpeg(b: Uint8Array): ImageDimensions | null {
|
|
58
|
+
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null
|
|
59
|
+
let i = 2
|
|
60
|
+
while (i + 9 < b.length) {
|
|
61
|
+
if (b[i] !== 0xff) {
|
|
62
|
+
i++
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
// Skip padding 0xFF bytes
|
|
66
|
+
while (i < b.length && b[i] === 0xff) i++
|
|
67
|
+
if (i >= b.length) return null
|
|
68
|
+
const marker = b[i] ?? 0
|
|
69
|
+
i++
|
|
70
|
+
// SOF markers: 0xC0..0xCF except 0xC4, 0xC8, 0xCC
|
|
71
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
72
|
+
if (i + 7 >= b.length) return null
|
|
73
|
+
const view = new DataView(b.buffer, b.byteOffset)
|
|
74
|
+
const height = view.getUint16(i + 3, false)
|
|
75
|
+
const width = view.getUint16(i + 5, false)
|
|
76
|
+
return { height, width }
|
|
77
|
+
}
|
|
78
|
+
// Standalone markers without length
|
|
79
|
+
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) continue
|
|
80
|
+
if (i + 1 >= b.length) return null
|
|
81
|
+
const view = new DataView(b.buffer, b.byteOffset)
|
|
82
|
+
const len = view.getUint16(i, false)
|
|
83
|
+
i += len
|
|
84
|
+
}
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function readImageDimensions(bytes: Uint8Array): ImageDimensions | null {
|
|
89
|
+
return readPng(bytes) ?? readJpeg(bytes) ?? readGif(bytes) ?? readWebp(bytes) ?? readBmp(bytes)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatBytes(n: number): string {
|
|
93
|
+
if (n < 1024) return `${n} B`
|
|
94
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
95
|
+
return `${(n / (1024 * 1024)).toFixed(2)} MB`
|
|
96
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useRenderer } from '@opentui/react'
|
|
2
|
+
import { memo } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { DiffData } from '../../../../state/types'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
detectGraphicsProtocol,
|
|
8
|
+
isInsideTmux,
|
|
9
|
+
terminalLabel,
|
|
10
|
+
} from '../../../terminal-graphics/capabilities'
|
|
11
|
+
import { useTheme } from '../../../theme'
|
|
12
|
+
import { formatBytes, readImageDimensions } from './dimensions'
|
|
13
|
+
import { TerminalImagePane } from './terminal-image-pane'
|
|
14
|
+
|
|
15
|
+
interface ImageDiffViewProps {
|
|
16
|
+
diff: DiffData
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PaneProps {
|
|
20
|
+
bytes: Uint8Array | undefined
|
|
21
|
+
formatLabel: string
|
|
22
|
+
label: string
|
|
23
|
+
mime: string
|
|
24
|
+
protocol: 'kitty' | 'iterm' | 'none'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const Pane = memo(function Pane({ bytes, formatLabel, label, mime, protocol }: PaneProps) {
|
|
28
|
+
const t = useTheme()
|
|
29
|
+
if (!bytes) {
|
|
30
|
+
return (
|
|
31
|
+
<box flexDirection="column" flexGrow={1} padding={1}>
|
|
32
|
+
<text fg={t.textMuted}>{label}</text>
|
|
33
|
+
<text fg={t.textMuted}>(absent)</text>
|
|
34
|
+
</box>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
const dims = readImageDimensions(bytes)
|
|
38
|
+
const meta = [
|
|
39
|
+
formatLabel,
|
|
40
|
+
formatBytes(bytes.byteLength),
|
|
41
|
+
dims ? `${dims.width}×${dims.height}` : null,
|
|
42
|
+
]
|
|
43
|
+
.filter((s) => s !== null)
|
|
44
|
+
.join(' · ')
|
|
45
|
+
return (
|
|
46
|
+
<box flexDirection="column" flexGrow={1} padding={1}>
|
|
47
|
+
<text fg={t.text}>{label}</text>
|
|
48
|
+
{protocol === 'kitty' ? (
|
|
49
|
+
<TerminalImagePane bytes={bytes} mime={mime} />
|
|
50
|
+
) : (
|
|
51
|
+
<box flexGrow={1} alignItems="center" justifyContent="center">
|
|
52
|
+
<text fg={t.textMuted}>(no preview)</text>
|
|
53
|
+
</box>
|
|
54
|
+
)}
|
|
55
|
+
<text fg={t.textMuted}>{meta}</text>
|
|
56
|
+
</box>
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
export const ImageDiffView = memo(function ImageDiffView({ diff }: ImageDiffViewProps) {
|
|
61
|
+
const t = useTheme()
|
|
62
|
+
const renderer = useRenderer()
|
|
63
|
+
const protocol = detectGraphicsProtocol(renderer)
|
|
64
|
+
const before = diff.imageBytesBefore
|
|
65
|
+
const after = diff.imageBytesAfter
|
|
66
|
+
const mime = diff.imageMime ?? 'application/octet-stream'
|
|
67
|
+
const formatLabel = diff.imageFormatLabel ?? 'image'
|
|
68
|
+
|
|
69
|
+
const banner = (() => {
|
|
70
|
+
if (protocol === 'kitty') return isInsideTmux() ? 'tmux: requires allow-passthrough on' : null
|
|
71
|
+
if (protocol === 'iterm')
|
|
72
|
+
return 'Image preview unavailable in iTerm2 (open externally to view).'
|
|
73
|
+
return `Image preview requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm). Detected: ${terminalLabel()}.`
|
|
74
|
+
})()
|
|
75
|
+
|
|
76
|
+
const showBoth = before && after
|
|
77
|
+
return (
|
|
78
|
+
<box flexDirection="column" flexGrow={1} overflow="hidden" backgroundColor={t.background}>
|
|
79
|
+
{diff.oldPath ? (
|
|
80
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
81
|
+
<text fg={t.textMuted}>
|
|
82
|
+
renamed: {diff.oldPath} → {diff.path}
|
|
83
|
+
</text>
|
|
84
|
+
</box>
|
|
85
|
+
) : null}
|
|
86
|
+
{banner ? (
|
|
87
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
88
|
+
<text fg={protocol === 'kitty' ? t.textMuted : t.warning}>{banner}</text>
|
|
89
|
+
</box>
|
|
90
|
+
) : null}
|
|
91
|
+
<box flexDirection="row" flexGrow={1}>
|
|
92
|
+
{showBoth || before ? (
|
|
93
|
+
<Pane
|
|
94
|
+
bytes={before}
|
|
95
|
+
formatLabel={formatLabel}
|
|
96
|
+
label="old (HEAD)"
|
|
97
|
+
mime={mime}
|
|
98
|
+
protocol={protocol}
|
|
99
|
+
/>
|
|
100
|
+
) : null}
|
|
101
|
+
{showBoth || after ? (
|
|
102
|
+
<Pane
|
|
103
|
+
bytes={after}
|
|
104
|
+
formatLabel={formatLabel}
|
|
105
|
+
label="new (working)"
|
|
106
|
+
mime={mime}
|
|
107
|
+
protocol={protocol}
|
|
108
|
+
/>
|
|
109
|
+
) : null}
|
|
110
|
+
</box>
|
|
111
|
+
</box>
|
|
112
|
+
)
|
|
113
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ImageDiffView } from './image-diff-view'
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type BoxRenderable } from '@opentui/core'
|
|
2
|
+
import { memo, useEffect, useRef, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { convertToPng, isPng } from '../../../terminal-graphics/format-fallback'
|
|
5
|
+
import {
|
|
6
|
+
deleteImageEscape,
|
|
7
|
+
imageIdToRgb,
|
|
8
|
+
nextImageId,
|
|
9
|
+
uploadPngEscape,
|
|
10
|
+
writeRaw,
|
|
11
|
+
} from '../../../terminal-graphics/kitty'
|
|
12
|
+
import { useTheme } from '../../../theme'
|
|
13
|
+
|
|
14
|
+
interface TerminalImagePaneProps {
|
|
15
|
+
bytes: Uint8Array
|
|
16
|
+
mime: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PaneState {
|
|
20
|
+
imageId: number | null
|
|
21
|
+
lastKey: string | null
|
|
22
|
+
uploaded: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Move-cursor + Kitty placement escape, sent via process.nextTick so it lands
|
|
26
|
+
// AFTER opentui's native cell flush for the same frame. Otherwise the cell
|
|
27
|
+
// writes overwrite the image overlay.
|
|
28
|
+
function buildPlacement(id: number, screenX: number, screenY: number): string {
|
|
29
|
+
const [r, g, b] = imageIdToRgb(id)
|
|
30
|
+
const move = `\x1b[${screenY + 1};${screenX + 1}H`
|
|
31
|
+
const color = `\x1b[38;2;${r};${g};${b}m`
|
|
32
|
+
const reset = `\x1b[39m`
|
|
33
|
+
// a=p (put placement), C=1 (cursor stays put), q=2 (quiet).
|
|
34
|
+
const place = `\x1b_Ga=p,i=${id},C=1,q=2;\x1b\\`
|
|
35
|
+
return `${move}${color}${place}${reset}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const TerminalImagePane = memo(function TerminalImagePane({
|
|
39
|
+
bytes,
|
|
40
|
+
mime,
|
|
41
|
+
}: TerminalImagePaneProps) {
|
|
42
|
+
const t = useTheme()
|
|
43
|
+
const stateRef = useRef<PaneState>({ imageId: null, lastKey: null, uploaded: false })
|
|
44
|
+
const [error, setError] = useState<string | null>(null)
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
let cancelled = false
|
|
48
|
+
stateRef.current = { imageId: null, lastKey: null, uploaded: false }
|
|
49
|
+
setError(null)
|
|
50
|
+
;(async () => {
|
|
51
|
+
let pngBytes: Uint8Array | null = null
|
|
52
|
+
if (mime === 'image/png' || isPng(bytes)) {
|
|
53
|
+
pngBytes = bytes
|
|
54
|
+
} else {
|
|
55
|
+
const result = await convertToPng(bytes, mime)
|
|
56
|
+
if (cancelled) return
|
|
57
|
+
if (result.kind === 'error') {
|
|
58
|
+
setError(result.reason)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
pngBytes = result.png
|
|
62
|
+
}
|
|
63
|
+
if (cancelled || !pngBytes) return
|
|
64
|
+
const id = nextImageId()
|
|
65
|
+
writeRaw(uploadPngEscape(pngBytes, id))
|
|
66
|
+
stateRef.current.imageId = id
|
|
67
|
+
stateRef.current.uploaded = true
|
|
68
|
+
// Force re-placement on the next render.
|
|
69
|
+
stateRef.current.lastKey = null
|
|
70
|
+
})()
|
|
71
|
+
return () => {
|
|
72
|
+
cancelled = true
|
|
73
|
+
const id = stateRef.current.imageId
|
|
74
|
+
if (id !== null) writeRaw(deleteImageEscape(id))
|
|
75
|
+
stateRef.current = { imageId: null, lastKey: null, uploaded: false }
|
|
76
|
+
}
|
|
77
|
+
}, [bytes, mime])
|
|
78
|
+
|
|
79
|
+
if (error) {
|
|
80
|
+
return (
|
|
81
|
+
<box flexGrow={1} alignItems="center" justifyContent="center" padding={1}>
|
|
82
|
+
<text fg={t.warning}>({error})</text>
|
|
83
|
+
</box>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderAfter(this: BoxRenderable): void {
|
|
88
|
+
const state = stateRef.current
|
|
89
|
+
if (!state.uploaded || state.imageId === null) return
|
|
90
|
+
const key = `${this.screenX},${this.screenY},${this.width},${this.height}`
|
|
91
|
+
if (state.lastKey === key) return
|
|
92
|
+
state.lastKey = key
|
|
93
|
+
const seq = buildPlacement(state.imageId, this.screenX, this.screenY)
|
|
94
|
+
// Queue write to land AFTER opentui's native cell flush in this frame.
|
|
95
|
+
process.nextTick(() => writeRaw(seq))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return <box flexGrow={1} renderAfter={renderAfter} />
|
|
99
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CliRenderer } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
export type GraphicsProtocol = 'kitty' | 'iterm' | 'none'
|
|
4
|
+
|
|
5
|
+
interface RendererWithCapabilities {
|
|
6
|
+
capabilities?: { kitty_graphics?: boolean } | null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let cached: GraphicsProtocol | null = null
|
|
10
|
+
|
|
11
|
+
export function detectGraphicsProtocol(renderer: CliRenderer): GraphicsProtocol {
|
|
12
|
+
if (cached !== null) return cached
|
|
13
|
+
const caps = (renderer as RendererWithCapabilities).capabilities
|
|
14
|
+
if (caps?.kitty_graphics) {
|
|
15
|
+
cached = 'kitty'
|
|
16
|
+
return cached
|
|
17
|
+
}
|
|
18
|
+
if (Bun.env.TERM_PROGRAM === 'iTerm.app') {
|
|
19
|
+
cached = 'iterm'
|
|
20
|
+
return cached
|
|
21
|
+
}
|
|
22
|
+
cached = 'none'
|
|
23
|
+
return cached
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isInsideTmux(): boolean {
|
|
27
|
+
return Boolean(Bun.env.TMUX)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function terminalLabel(): string {
|
|
31
|
+
return Bun.env.TERM_PROGRAM ?? Bun.env.TERM ?? 'unknown terminal'
|
|
32
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Convert non-PNG image bytes to PNG. SVGs go through @resvg/resvg-wasm
|
|
2
|
+
// (zero-install). Everything else tries the system converter chain. Results
|
|
3
|
+
// are cached per byte-source so we don't retry on every render.
|
|
4
|
+
|
|
5
|
+
import { isSvg, renderSvgToPng } from './svg-render'
|
|
6
|
+
|
|
7
|
+
export type ConvertResult = { kind: 'ok'; png: Uint8Array } | { kind: 'error'; reason: string }
|
|
8
|
+
|
|
9
|
+
const cache = new Map<string, ConvertResult>()
|
|
10
|
+
|
|
11
|
+
function cacheKey(bytes: Uint8Array): string {
|
|
12
|
+
return new Bun.CryptoHasher('sha1').update(bytes).digest('hex')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function tryConverter(
|
|
16
|
+
cmd: string[],
|
|
17
|
+
bytes: Uint8Array,
|
|
18
|
+
timeoutMs: number
|
|
19
|
+
): Promise<Uint8Array | null> {
|
|
20
|
+
try {
|
|
21
|
+
const proc = Bun.spawn(cmd, {
|
|
22
|
+
stderr: 'ignore',
|
|
23
|
+
stdin: 'pipe',
|
|
24
|
+
stdout: 'pipe',
|
|
25
|
+
})
|
|
26
|
+
const writer = proc.stdin
|
|
27
|
+
if (writer) {
|
|
28
|
+
writer.write(bytes)
|
|
29
|
+
await writer.end()
|
|
30
|
+
}
|
|
31
|
+
const timer = setTimeout(() => proc.kill(), timeoutMs)
|
|
32
|
+
const [out, code] = await Promise.all([new Response(proc.stdout).bytes(), proc.exited])
|
|
33
|
+
clearTimeout(timer)
|
|
34
|
+
if (code !== 0 || out.byteLength === 0) return null
|
|
35
|
+
return out
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// For sips (macOS) we must write to a temp file because it doesn't accept stdin.
|
|
42
|
+
async function trySips(bytes: Uint8Array): Promise<Uint8Array | null> {
|
|
43
|
+
const tmpIn = `${(Bun.env.TMPDIR ?? '/tmp').replace(/\/$/, '')}/aimux-img-in-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
44
|
+
const tmpOut = `${tmpIn}.png`
|
|
45
|
+
try {
|
|
46
|
+
await Bun.write(tmpIn, bytes)
|
|
47
|
+
const proc = Bun.spawn(['sips', '-s', 'format', 'png', tmpIn, '--out', tmpOut], {
|
|
48
|
+
stderr: 'ignore',
|
|
49
|
+
stdout: 'ignore',
|
|
50
|
+
})
|
|
51
|
+
const timer = setTimeout(() => proc.kill(), 1500)
|
|
52
|
+
const code = await proc.exited
|
|
53
|
+
clearTimeout(timer)
|
|
54
|
+
if (code !== 0) return null
|
|
55
|
+
const file = Bun.file(tmpOut)
|
|
56
|
+
if (!(await file.exists())) return null
|
|
57
|
+
return await file.bytes()
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
} finally {
|
|
61
|
+
await Promise.all([
|
|
62
|
+
Bun.file(tmpIn)
|
|
63
|
+
.delete()
|
|
64
|
+
.catch(() => {}),
|
|
65
|
+
Bun.file(tmpOut)
|
|
66
|
+
.delete()
|
|
67
|
+
.catch(() => {}),
|
|
68
|
+
])
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mimeLabel(mime: string): string {
|
|
73
|
+
const slash = mime.indexOf('/')
|
|
74
|
+
if (slash < 0) return mime
|
|
75
|
+
return mime.slice(slash + 1).toUpperCase()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function convertToPng(bytes: Uint8Array, mime: string): Promise<ConvertResult> {
|
|
79
|
+
const key = cacheKey(bytes)
|
|
80
|
+
const hit = cache.get(key)
|
|
81
|
+
if (hit !== undefined) return hit
|
|
82
|
+
|
|
83
|
+
let result: ConvertResult
|
|
84
|
+
if (isSvg(bytes)) {
|
|
85
|
+
const png = await renderSvgToPng(bytes)
|
|
86
|
+
result = png ? { kind: 'ok', png } : { kind: 'error', reason: 'failed to render SVG' }
|
|
87
|
+
} else {
|
|
88
|
+
const attempts: Array<() => Promise<Uint8Array | null>> = [
|
|
89
|
+
() => tryConverter(['magick', '-', 'png:-'], bytes, 1500),
|
|
90
|
+
() => tryConverter(['convert', '-', 'png:-'], bytes, 1500),
|
|
91
|
+
() => trySips(bytes),
|
|
92
|
+
() =>
|
|
93
|
+
tryConverter(
|
|
94
|
+
[
|
|
95
|
+
'ffmpeg',
|
|
96
|
+
'-loglevel',
|
|
97
|
+
'error',
|
|
98
|
+
'-i',
|
|
99
|
+
'pipe:0',
|
|
100
|
+
'-f',
|
|
101
|
+
'image2',
|
|
102
|
+
'-vcodec',
|
|
103
|
+
'png',
|
|
104
|
+
'pipe:1',
|
|
105
|
+
],
|
|
106
|
+
bytes,
|
|
107
|
+
2500
|
|
108
|
+
),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
let converted: Uint8Array | null = null
|
|
112
|
+
for (const attempt of attempts) {
|
|
113
|
+
const out = await attempt()
|
|
114
|
+
if (out && out.byteLength > 0) {
|
|
115
|
+
converted = out
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
result = converted
|
|
120
|
+
? { kind: 'ok', png: converted }
|
|
121
|
+
: {
|
|
122
|
+
kind: 'error',
|
|
123
|
+
reason: `no converter could decode this ${mimeLabel(mime)} (install ImageMagick or cwebp)`,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
cache.set(key, result)
|
|
128
|
+
return result
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function isPng(bytes: Uint8Array): boolean {
|
|
132
|
+
return (
|
|
133
|
+
bytes.length >= 8 &&
|
|
134
|
+
bytes[0] === 0x89 &&
|
|
135
|
+
bytes[1] === 0x50 &&
|
|
136
|
+
bytes[2] === 0x4e &&
|
|
137
|
+
bytes[3] === 0x47 &&
|
|
138
|
+
bytes[4] === 0x0d &&
|
|
139
|
+
bytes[5] === 0x0a &&
|
|
140
|
+
bytes[6] === 0x1a &&
|
|
141
|
+
bytes[7] === 0x0a
|
|
142
|
+
)
|
|
143
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Kitty graphics protocol — pixel placement (a=p) with quiet, cursor-preserving
|
|
2
|
+
// placements. The image is uploaded once (a=t) with `f=100` (PNG), then placed
|
|
3
|
+
// at the cursor's current position.
|
|
4
|
+
// Spec: https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
|
5
|
+
|
|
6
|
+
import { isInsideTmux } from './capabilities'
|
|
7
|
+
|
|
8
|
+
const ESC = '\x1b'
|
|
9
|
+
const ST = `${ESC}\\`
|
|
10
|
+
const MAX_BASE64_CHUNK = 4096
|
|
11
|
+
|
|
12
|
+
// Image IDs use the 24-bit RGB foreground color of placeholder cells. We start
|
|
13
|
+
// from 0x100000 to avoid collisions with embedded PTYs that may also emit
|
|
14
|
+
// graphics commands (Kitty namespaces images per-window but the spec is loose).
|
|
15
|
+
let nextId = 0x100000
|
|
16
|
+
|
|
17
|
+
export function nextImageId(): number {
|
|
18
|
+
const id = nextId++
|
|
19
|
+
// Wrap at 24-bit; we don't expect to leak more than ~16M IDs per session but
|
|
20
|
+
// be safe in case of a long-running daemon.
|
|
21
|
+
if (nextId > 0xffffff) nextId = 0x100000
|
|
22
|
+
return id
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function idToRgb(id: number): [number, number, number] {
|
|
26
|
+
return [(id >> 16) & 0xff, (id >> 8) & 0xff, id & 0xff]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function wrapForTmux(seq: string): string {
|
|
30
|
+
if (!isInsideTmux()) return seq
|
|
31
|
+
// tmux passthrough: wrap in DCS tmux; ... ST, and double every ESC inside.
|
|
32
|
+
return `${ESC}Ptmux;${seq.split(ESC).join(`${ESC}${ESC}`)}${ESC}\\`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function encodeBase64(bytes: Uint8Array): string {
|
|
36
|
+
// Bun supports btoa for binary strings, but Buffer is faster for large blobs.
|
|
37
|
+
return Buffer.from(bytes).toString('base64')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function chunkString(s: string, size: number): string[] {
|
|
41
|
+
if (s.length <= size) return [s]
|
|
42
|
+
const out: string[] = []
|
|
43
|
+
for (let i = 0; i < s.length; i += size) out.push(s.slice(i, i + size))
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build the upload escape(s) for a PNG (f=100) image. Chunked per Kitty spec
|
|
48
|
+
// recommendation (≤ 4096 base64 bytes per escape).
|
|
49
|
+
export function uploadPngEscape(pngBytes: Uint8Array, id: number): string {
|
|
50
|
+
const b64 = encodeBase64(pngBytes)
|
|
51
|
+
const chunks = chunkString(b64, MAX_BASE64_CHUNK)
|
|
52
|
+
const parts: string[] = []
|
|
53
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
54
|
+
const isLast = i === chunks.length - 1
|
|
55
|
+
const m = isLast ? 0 : 1
|
|
56
|
+
let header: string
|
|
57
|
+
if (i === 0) {
|
|
58
|
+
// a=t (transmit), t=d (direct), f=100 (PNG), q=2 (quiet).
|
|
59
|
+
header = `q=2,a=t,t=d,f=100,i=${id},m=${m}`
|
|
60
|
+
} else {
|
|
61
|
+
header = `m=${m},q=2`
|
|
62
|
+
}
|
|
63
|
+
parts.push(`${ESC}_G${header};${chunks[i]}${ST}`)
|
|
64
|
+
}
|
|
65
|
+
return wrapForTmux(parts.join(''))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function deleteImageEscape(id: number): string {
|
|
69
|
+
return wrapForTmux(`${ESC}_Ga=d,d=I,i=${id},q=2;${ST}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function imageIdToRgb(id: number): [number, number, number] {
|
|
73
|
+
return idToRgb(id)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function writeRaw(seq: string): void {
|
|
77
|
+
// Synchronous write so we don't interleave with opentui frames.
|
|
78
|
+
process.stdout.write(seq)
|
|
79
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SVG → PNG rendering via @resvg/resvg-wasm. WASM is loaded once per process
|
|
2
|
+
// from the package's bundled `index_bg.wasm`; subsequent renders reuse it.
|
|
3
|
+
|
|
4
|
+
import { initWasm, Resvg } from '@resvg/resvg-wasm'
|
|
5
|
+
|
|
6
|
+
let initPromise: Promise<void> | null = null
|
|
7
|
+
|
|
8
|
+
async function ensureInit(): Promise<void> {
|
|
9
|
+
if (!initPromise) {
|
|
10
|
+
initPromise = (async () => {
|
|
11
|
+
const wasmUrl = import.meta.resolve('@resvg/resvg-wasm/index_bg.wasm')
|
|
12
|
+
const bytes = await Bun.file(new URL(wasmUrl)).bytes()
|
|
13
|
+
await initWasm(bytes)
|
|
14
|
+
})()
|
|
15
|
+
}
|
|
16
|
+
await initPromise
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function renderSvgToPng(svgBytes: Uint8Array): Promise<Uint8Array | null> {
|
|
20
|
+
try {
|
|
21
|
+
await ensureInit()
|
|
22
|
+
// 1024px wide is plenty for any TUI cell grid; Kitty scales to fit.
|
|
23
|
+
const resvg = new Resvg(svgBytes, { fitTo: { mode: 'width', value: 1024 } })
|
|
24
|
+
const rendered = resvg.render()
|
|
25
|
+
const png = rendered.asPng()
|
|
26
|
+
rendered.free()
|
|
27
|
+
resvg.free()
|
|
28
|
+
return png
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isSvg(bytes: Uint8Array): boolean {
|
|
35
|
+
// Accept files that open with an XML prolog or whitespace before <svg.
|
|
36
|
+
const max = Math.min(bytes.length, 256)
|
|
37
|
+
for (let i = 0; i + 3 < max; i++) {
|
|
38
|
+
if (
|
|
39
|
+
bytes[i] === 0x3c && // <
|
|
40
|
+
bytes[i + 1] === 0x73 && // s
|
|
41
|
+
bytes[i + 2] === 0x76 && // v
|
|
42
|
+
bytes[i + 3] === 0x67 // g
|
|
43
|
+
) {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return false
|
|
48
|
+
}
|