@brimveyn/aimux 1.10.4 → 1.12.1

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.4",
3
+ "version": "1.12.1",
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.10",
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",
@@ -0,0 +1,21 @@
1
+ // On multi-click selection mouseUp opentui's finishSelection() fires the
2
+ // 'selection' event *after* our handleTerminalMouseUp has already written
3
+ // the clipboard from drag.capturedLines (anchored at click time). The event
4
+ // handler would then call computeStreamSelectedText against the *current*
5
+ // tab.viewport.lines and overwrite our write with a value that can diverge
6
+ // by a few rows when output landed between mouseDown and mouseUp.
7
+ //
8
+ // To avoid that, handleTerminalMouseUp pings the guard right after it
9
+ // copies; handleSelection consults the guard and skips its own copy for a
10
+ // short window.
11
+
12
+ const SUPPRESS_WINDOW_MS = 100
13
+ let lastMultiClickWriteAt = 0
14
+
15
+ export function recordMultiClickClipboardWrite(): void {
16
+ lastMultiClickWriteAt = Date.now()
17
+ }
18
+
19
+ export function shouldSuppressSelectionCopy(): boolean {
20
+ return Date.now() - lastMultiClickWriteAt < SUPPRESS_WINDOW_MS
21
+ }
@@ -19,6 +19,7 @@ import {
19
19
  type MultiClickMode,
20
20
  resolveClickSelection,
21
21
  } from './click-selection-resolver'
22
+ import { recordMultiClickClipboardWrite } from './multi-click-clipboard-guard'
22
23
  import { requestRenderUpTree } from './render-invalidation'
23
24
  import {
24
25
  type AnchoredRatioDragState,
@@ -540,6 +541,7 @@ export function useMouseHandlers({
540
541
  const text = extractStreamText(lines, anchorIdx, anchorCol, focusIdx, drag.focusCol)
541
542
  if (text.length > 0) {
542
543
  copyToSystemClipboard(text)
544
+ recordMultiClickClipboardWrite()
543
545
  }
544
546
 
545
547
  multiClickDragRef.current = null
@@ -8,6 +8,7 @@ import type { AppAction, FocusMode, TabSession } from '../state/types'
8
8
  import { INPUT_DEBUG_LOG_PATH, logInputDebug } from '../debug/input-log'
9
9
  import { createRawInputHandler } from '../input/raw-input-handler'
10
10
  import { copyToSystemClipboard } from '../platform/clipboard'
11
+ import { shouldSuppressSelectionCopy } from './multi-click-clipboard-guard'
11
12
  import { writePasteToTab, writeToTab } from './pty-write'
12
13
  import { type OtuiSelection, resolveSelectionClipboardText } from './selection-clipboard'
13
14
  import { applyViewportObservation, type ViewportObservation } from './selection-scroll'
@@ -118,6 +119,16 @@ export function useRendererBindings({
118
119
  return
119
120
  }
120
121
 
122
+ // After a multi-click drag, handleTerminalMouseUp has just copied the
123
+ // authoritative text built from drag.capturedLines (anchored at click
124
+ // time). opentui's finishSelection() then re-fires this 'selection'
125
+ // event, but the recomputed text can drift by a few rows when output
126
+ // landed between mouseDown and mouseUp. Skip the redundant write.
127
+ if (shouldSuppressSelectionCopy()) {
128
+ logInputDebug('app.selection.suppressed', { textLength: selectedText.length })
129
+ return
130
+ }
131
+
121
132
  renderer.copyToClipboardOSC52(selectedText)
122
133
  copyToSystemClipboard(selectedText)
123
134
  }
@@ -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
+ }
@@ -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
+ }