@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.10.3",
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.9",
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 { isAutoCommitEnabled } from '@brimveyn/aimux-config'
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: { destroy(): void }
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
  () => {
@@ -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
+ }
@@ -68,6 +68,7 @@ export type SideEffect =
68
68
  | { type: 'delete-session'; sessionId: string }
69
69
  | { type: 'toggle-transparent' }
70
70
  | { type: 'toggle-mode' }
71
+ | { type: 'open-file-in-editor'; path: string }
71
72
 
72
73
  export interface KeyResult {
73
74
  actions: AppAction[]
@@ -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
+ }