@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 +3 -2
- package/src/app-runtime/multi-click-clipboard-guard.ts +21 -0
- package/src/app-runtime/use-mouse-handlers.ts +2 -0
- package/src/app-runtime/use-renderer-bindings.ts +11 -0
- package/src/git/git-diff.ts +47 -0
- package/src/git/image-detect.ts +34 -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.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.
|
|
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
|
}
|
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/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
|
+
}
|