kokoirc 0.2.2 → 0.2.4
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/LICENSE +21 -0
- package/README.md +68 -40
- package/docs/commands/clear.md +26 -0
- package/docs/commands/image.md +47 -0
- package/docs/commands/invite.md +23 -0
- package/docs/commands/kill.md +24 -0
- package/docs/commands/names.md +25 -0
- package/docs/commands/oper.md +24 -0
- package/docs/commands/preview.md +31 -0
- package/docs/commands/quote.md +29 -0
- package/docs/commands/server.md +6 -0
- package/docs/commands/stats.md +31 -0
- package/docs/commands/topic.md +12 -6
- package/docs/commands/version.md +23 -0
- package/docs/commands/wallops.md +24 -0
- package/package.json +46 -3
- package/src/app/App.tsx +11 -1
- package/src/core/commands/help-formatter.ts +1 -1
- package/src/core/commands/helpers.ts +3 -1
- package/src/core/commands/registry.ts +251 -6
- package/src/core/config/defaults.ts +11 -0
- package/src/core/config/loader.ts +5 -0
- package/src/core/constants.ts +3 -0
- package/src/core/image-preview/cache.ts +108 -0
- package/src/core/image-preview/detect.ts +105 -0
- package/src/core/image-preview/encode.ts +116 -0
- package/src/core/image-preview/fetch.ts +174 -0
- package/src/core/image-preview/index.ts +6 -0
- package/src/core/image-preview/render.ts +222 -0
- package/src/core/image-preview/stdin-guard.ts +33 -0
- package/src/core/init.ts +2 -1
- package/src/core/irc/antiflood.ts +2 -1
- package/src/core/irc/client.ts +3 -2
- package/src/core/irc/events.ts +121 -47
- package/src/core/irc/netsplit.ts +2 -1
- package/src/core/scripts/api.ts +3 -2
- package/src/core/state/store.ts +261 -3
- package/src/core/storage/index.ts +2 -2
- package/src/core/storage/writer.ts +12 -10
- package/src/core/theme/renderer.tsx +29 -1
- package/src/core/utils/id.ts +2 -0
- package/src/types/config.ts +14 -0
- package/src/types/index.ts +1 -2
- package/src/ui/chat/ChatView.tsx +11 -5
- package/src/ui/chat/MessageLine.tsx +18 -1
- package/src/ui/input/CommandInput.tsx +2 -1
- package/src/ui/layout/AppLayout.tsx +3 -1
- package/src/ui/overlay/ImagePreview.tsx +77 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { image2sixel } from "sixel"
|
|
2
|
+
|
|
3
|
+
const ESC = "\x1b"
|
|
4
|
+
const ST = `${ESC}\\`
|
|
5
|
+
|
|
6
|
+
// ─── tmux DCS passthrough ────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
/** Wrap an escape sequence in tmux DCS passthrough */
|
|
9
|
+
export function wrapTmuxDCS(sequence: string): string {
|
|
10
|
+
// Escape all ESC bytes inside the payload
|
|
11
|
+
const escaped = sequence.replace(/\x1b/g, "\x1b\x1b")
|
|
12
|
+
return `${ESC}Ptmux;${escaped}${ST}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ─── Kitty Graphics Protocol ─────────────────────────────────
|
|
16
|
+
//
|
|
17
|
+
// Each chunk is a self-contained ESC_G...ESC\ sequence.
|
|
18
|
+
// First chunk carries metadata (a=T,f=,s=,v=,c=,r=) + payload.
|
|
19
|
+
// Continuation chunks carry m=1 + payload. Last chunk has m=0.
|
|
20
|
+
//
|
|
21
|
+
// q=2 suppresses terminal responses (prevents PTY echo issues).
|
|
22
|
+
// Each chunk MUST be written to stdout as a separate writeSync call
|
|
23
|
+
// (see render.ts) — subterm's async write pipeline can race when
|
|
24
|
+
// processing many sequences delivered in a single large buffer.
|
|
25
|
+
|
|
26
|
+
export type KittyFormat = "rgba" | "png"
|
|
27
|
+
|
|
28
|
+
/** Encode RGBA pixels as Kitty APC sequences using f=32 (raw RGBA). */
|
|
29
|
+
export function encodeKittyRGBA(rgbaBuffer: Buffer, width: number, height: number, cols: number, rows: number): string[] {
|
|
30
|
+
return encodeKittyChunked(rgbaBuffer.toString("base64"), `a=T,q=2,f=32,s=${width},v=${height},c=${cols},r=${rows}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Encode a PNG buffer as Kitty APC sequences using f=100 (PNG). */
|
|
34
|
+
export function encodeKittyPNG(pngBuffer: Buffer, cols: number, rows: number): string[] {
|
|
35
|
+
return encodeKittyChunked(pngBuffer.toString("base64"), `a=T,q=2,f=100,c=${cols},r=${rows}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Split base64 into Kitty APC chunks. First chunk includes metadata. */
|
|
39
|
+
function encodeKittyChunked(b64: string, firstChunkParams: string): string[] {
|
|
40
|
+
// tmux DCS passthrough buffer is 4096 bytes (tmux < 3.4).
|
|
41
|
+
// First chunk overhead: ~80 bytes params + 11 bytes framing = ~91.
|
|
42
|
+
// Continuation overhead: ~11 bytes. DCS wrap adds ~22.
|
|
43
|
+
// 3800 + 91 + 22 = 3913, safely under 4096.
|
|
44
|
+
const CHUNK_SIZE = 3800
|
|
45
|
+
const parts: string[] = []
|
|
46
|
+
|
|
47
|
+
for (let i = 0, idx = 0; i < b64.length; i += CHUNK_SIZE, idx++) {
|
|
48
|
+
const chunk = b64.slice(i, i + CHUNK_SIZE)
|
|
49
|
+
const isFirst = idx === 0
|
|
50
|
+
const isLast = i + CHUNK_SIZE >= b64.length
|
|
51
|
+
const m = isLast ? 0 : 1
|
|
52
|
+
|
|
53
|
+
if (isFirst) {
|
|
54
|
+
parts.push(`${ESC}_G${firstChunkParams},m=${m};${chunk}${ST}`)
|
|
55
|
+
} else {
|
|
56
|
+
parts.push(`${ESC}_Gm=${m};${chunk}${ST}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return parts
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── iTerm2 Inline Image Protocol ────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** Encode an image buffer as a raw iTerm2 inline image sequence */
|
|
66
|
+
export function encodeIterm2(imageBuffer: Buffer, cols: number, rows: number): string {
|
|
67
|
+
const b64 = imageBuffer.toString("base64")
|
|
68
|
+
// preserveAspectRatio=0: we already calculated correct dimensions in render.ts,
|
|
69
|
+
// so let iTerm2 stretch to fill the cell area. Using 1 causes double-correction.
|
|
70
|
+
return `${ESC}]1337;File=inline=1;width=${cols};height=${rows};preserveAspectRatio=0:${b64}\x07`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Sixel Encoding ──────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/** Encode raw RGBA pixels as a raw sixel sequence */
|
|
76
|
+
export function encodeSixel(rgbaBuffer: Buffer, width: number, height: number): string {
|
|
77
|
+
// Create a clean Uint8ClampedArray from a copy — Buffer.buffer is the shared Node pool
|
|
78
|
+
const copy = new Uint8ClampedArray(rgbaBuffer.length)
|
|
79
|
+
copy.set(rgbaBuffer)
|
|
80
|
+
return image2sixel(copy, width, height, 256)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Unicode Half-Block Fallback ─────────────────────────────
|
|
84
|
+
|
|
85
|
+
/** Encode RGBA pixels as Unicode half-block characters with 24-bit ANSI color */
|
|
86
|
+
export function encodeSymbols(rgbaBuffer: Buffer, width: number, height: number): string {
|
|
87
|
+
const lines: string[] = []
|
|
88
|
+
|
|
89
|
+
for (let y = 0; y < height; y += 2) {
|
|
90
|
+
let line = ""
|
|
91
|
+
for (let x = 0; x < width; x++) {
|
|
92
|
+
// Top pixel
|
|
93
|
+
const topIdx = (y * width + x) * 4
|
|
94
|
+
const tr = rgbaBuffer[topIdx]
|
|
95
|
+
const tg = rgbaBuffer[topIdx + 1]
|
|
96
|
+
const tb = rgbaBuffer[topIdx + 2]
|
|
97
|
+
|
|
98
|
+
// Bottom pixel (may be beyond height)
|
|
99
|
+
if (y + 1 < height) {
|
|
100
|
+
const botIdx = ((y + 1) * width + x) * 4
|
|
101
|
+
const br = rgbaBuffer[botIdx]
|
|
102
|
+
const bg = rgbaBuffer[botIdx + 1]
|
|
103
|
+
const bb = rgbaBuffer[botIdx + 2]
|
|
104
|
+
// ▀ = upper half block: fg=top, bg=bottom
|
|
105
|
+
line += `${ESC}[38;2;${tr};${tg};${tb}m${ESC}[48;2;${br};${bg};${bb}m▀`
|
|
106
|
+
} else {
|
|
107
|
+
// Last odd row: only top pixel
|
|
108
|
+
line += `${ESC}[38;2;${tr};${tg};${tb}m▀`
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
line += `${ESC}[0m`
|
|
112
|
+
lines.push(line)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return lines.join("\n")
|
|
116
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { ImagePreviewConfig } from "@/types/config"
|
|
2
|
+
|
|
3
|
+
export type UrlType = "direct_image" | "page_imgur" | "page_imgbb" | "page_generic"
|
|
4
|
+
|
|
5
|
+
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|bmp)(\?.*)?$/i
|
|
6
|
+
const DIRECT_HOST_RE = /^i\.(imgur\.com|ibb\.co)$/i
|
|
7
|
+
const IMGUR_PAGE_RE = /^(www\.)?imgur\.com\//i
|
|
8
|
+
const IMGBB_PAGE_RE = /^(www\.)?ibb\.co\//i
|
|
9
|
+
|
|
10
|
+
const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
11
|
+
|
|
12
|
+
/** Classify a URL — returns null only for non-HTTP URLs */
|
|
13
|
+
export function classifyUrl(url: string): UrlType | null {
|
|
14
|
+
let parsed: URL
|
|
15
|
+
try {
|
|
16
|
+
parsed = new URL(url)
|
|
17
|
+
} catch {
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null
|
|
22
|
+
|
|
23
|
+
// Direct image by extension
|
|
24
|
+
if (IMAGE_EXT_RE.test(parsed.pathname)) return "direct_image"
|
|
25
|
+
|
|
26
|
+
// Direct image hosts (i.imgur.com, i.ibb.co)
|
|
27
|
+
if (DIRECT_HOST_RE.test(parsed.host)) return "direct_image"
|
|
28
|
+
|
|
29
|
+
// Known image page hosts
|
|
30
|
+
if (IMGUR_PAGE_RE.test(parsed.host + parsed.pathname)) return "page_imgur"
|
|
31
|
+
if (IMGBB_PAGE_RE.test(parsed.host + parsed.pathname)) return "page_imgbb"
|
|
32
|
+
|
|
33
|
+
// Any other HTTP URL — try as generic page (will check content-type)
|
|
34
|
+
return "page_generic"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Quick check: is this URL likely an image we should try to preview?
|
|
38
|
+
* Used by click handler to avoid previewing every random link. */
|
|
39
|
+
export function isLikelyImageUrl(url: string): boolean {
|
|
40
|
+
let parsed: URL
|
|
41
|
+
try {
|
|
42
|
+
parsed = new URL(url)
|
|
43
|
+
} catch {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false
|
|
48
|
+
|
|
49
|
+
// Direct image by extension
|
|
50
|
+
if (IMAGE_EXT_RE.test(parsed.pathname)) return true
|
|
51
|
+
|
|
52
|
+
// Known image hosts
|
|
53
|
+
if (DIRECT_HOST_RE.test(parsed.host)) return true
|
|
54
|
+
if (IMGUR_PAGE_RE.test(parsed.host + parsed.pathname)) return true
|
|
55
|
+
if (IMGBB_PAGE_RE.test(parsed.host + parsed.pathname)) return true
|
|
56
|
+
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Extract og:image URL from HTML string */
|
|
61
|
+
function extractOgImage(html: string): string | null {
|
|
62
|
+
const match = html.match(
|
|
63
|
+
/<meta\s+(?:property="og:image"\s+content="([^"]+)"|content="([^"]+)"\s+property="og:image")/i
|
|
64
|
+
)
|
|
65
|
+
return match?.[1] ?? match?.[2] ?? null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Get file extension from Content-Type header */
|
|
69
|
+
function extFromContentType(ct: string | null): string {
|
|
70
|
+
if (!ct) return ".img"
|
|
71
|
+
if (ct.includes("jpeg") || ct.includes("jpg")) return ".jpg"
|
|
72
|
+
if (ct.includes("png")) return ".png"
|
|
73
|
+
if (ct.includes("gif")) return ".gif"
|
|
74
|
+
if (ct.includes("webp")) return ".webp"
|
|
75
|
+
if (ct.includes("bmp")) return ".bmp"
|
|
76
|
+
return ".img"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Check if a content-type header indicates an image */
|
|
80
|
+
function isImageContentType(ct: string | null): boolean {
|
|
81
|
+
if (!ct) return false
|
|
82
|
+
return ct.startsWith("image/")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Fetch a URL body with size limits, return raw Buffer + content-type */
|
|
86
|
+
async function fetchUrl(
|
|
87
|
+
url: string,
|
|
88
|
+
config: ImagePreviewConfig,
|
|
89
|
+
maxSize?: number
|
|
90
|
+
): Promise<{ data: Buffer; contentType: string | null }> {
|
|
91
|
+
const controller = new AbortController()
|
|
92
|
+
const timeout = setTimeout(() => controller.abort(), config.fetch_timeout * 1000)
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const resp = await fetch(url, {
|
|
96
|
+
signal: controller.signal,
|
|
97
|
+
headers: { "User-Agent": USER_AGENT },
|
|
98
|
+
redirect: "follow",
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
102
|
+
|
|
103
|
+
const limit = maxSize ?? config.max_file_size
|
|
104
|
+
const contentLength = parseInt(resp.headers.get("content-length") ?? "0", 10)
|
|
105
|
+
if (contentLength > limit) {
|
|
106
|
+
throw new Error(`Too large: ${(contentLength / 1024 / 1024).toFixed(1)}MB`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const reader = resp.body?.getReader()
|
|
110
|
+
if (!reader) throw new Error("No response body")
|
|
111
|
+
|
|
112
|
+
const chunks: Uint8Array[] = []
|
|
113
|
+
let totalSize = 0
|
|
114
|
+
|
|
115
|
+
while (true) {
|
|
116
|
+
const { done, value } = await reader.read()
|
|
117
|
+
if (done) break
|
|
118
|
+
chunks.push(value)
|
|
119
|
+
totalSize += value.length
|
|
120
|
+
if (totalSize > limit) {
|
|
121
|
+
reader.cancel()
|
|
122
|
+
throw new Error(`Too large: exceeded ${(limit / 1024 / 1024).toFixed(0)}MB limit`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { data: Buffer.concat(chunks), contentType: resp.headers.get("content-type") }
|
|
127
|
+
} finally {
|
|
128
|
+
clearTimeout(timeout)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Fetch an image from a URL using erssi-style two-phase detection:
|
|
134
|
+
* 1. Fetch the URL directly
|
|
135
|
+
* 2. If content-type is image/* → done, return the image data
|
|
136
|
+
* 3. If content-type is text/html → extract og:image, fetch that instead
|
|
137
|
+
*/
|
|
138
|
+
export async function fetchImage(
|
|
139
|
+
url: string,
|
|
140
|
+
config: ImagePreviewConfig
|
|
141
|
+
): Promise<{ data: Buffer; ext: string }> {
|
|
142
|
+
// Phase 1: Fetch the URL
|
|
143
|
+
const result = await fetchUrl(url, config)
|
|
144
|
+
|
|
145
|
+
// If it's already an image, return directly
|
|
146
|
+
if (isImageContentType(result.contentType)) {
|
|
147
|
+
return { data: result.data, ext: extFromContentType(result.contentType) }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Phase 2: If HTML, look for og:image
|
|
151
|
+
if (result.contentType?.includes("text/html")) {
|
|
152
|
+
const html = new TextDecoder().decode(result.data)
|
|
153
|
+
const ogImage = extractOgImage(html)
|
|
154
|
+
|
|
155
|
+
if (!ogImage) throw new Error("Page has no og:image")
|
|
156
|
+
|
|
157
|
+
// Resolve relative og:image URLs
|
|
158
|
+
let imageUrl: string
|
|
159
|
+
try {
|
|
160
|
+
imageUrl = new URL(ogImage, url).href
|
|
161
|
+
} catch {
|
|
162
|
+
imageUrl = ogImage
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Fetch the actual image
|
|
166
|
+
const imgResult = await fetchUrl(imageUrl, config)
|
|
167
|
+
if (!isImageContentType(imgResult.contentType)) {
|
|
168
|
+
throw new Error(`og:image URL returned ${imgResult.contentType}, not an image`)
|
|
169
|
+
}
|
|
170
|
+
return { data: imgResult.data, ext: extFromContentType(imgResult.contentType) }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
throw new Error(`URL returned ${result.contentType}, not an image`)
|
|
174
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { detectProtocol, isInsideTmux, getTmuxPaneOffset, type ImageProtocol } from "./detect"
|
|
2
|
+
export { isCached, writeCache, validateImage, cleanupCache } from "./cache"
|
|
3
|
+
export { classifyUrl, isLikelyImageUrl, fetchImage } from "./fetch"
|
|
4
|
+
export { encodeKittyRGBA, encodeKittyPNG, encodeIterm2, encodeSixel, encodeSymbols, wrapTmuxDCS, type KittyFormat } from "./encode"
|
|
5
|
+
export { preparePreview } from "./render"
|
|
6
|
+
export { flushStdin } from "./stdin-guard"
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import sharp from "sharp"
|
|
2
|
+
import { writeSync } from "node:fs"
|
|
3
|
+
import { useStore } from "@/core/state/store"
|
|
4
|
+
import { detectProtocol, isInsideTmux } from "./detect"
|
|
5
|
+
import { isCached, writeCache } from "./cache"
|
|
6
|
+
import { classifyUrl, fetchImage } from "./fetch"
|
|
7
|
+
import { encodeKittyRGBA, encodeKittyPNG, encodeIterm2, encodeSixel, encodeSymbols, wrapTmuxDCS, type KittyFormat } from "./encode"
|
|
8
|
+
import { flushStdin } from "./stdin-guard"
|
|
9
|
+
|
|
10
|
+
// Mouse tracking escape sequences — disable during image write to prevent
|
|
11
|
+
// accumulated stdin mouse events from crashing Bun's event loop (malloc double-free).
|
|
12
|
+
const MOUSE_DISABLE = "\x1b[?1003l\x1b[?1006l\x1b[?1002l\x1b[?1000l"
|
|
13
|
+
const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"
|
|
14
|
+
|
|
15
|
+
/** Main pipeline: fetch → cache → encode → write to stdout directly */
|
|
16
|
+
export async function preparePreview(url: string): Promise<void> {
|
|
17
|
+
const store = useStore.getState()
|
|
18
|
+
const config = store.config?.image_preview
|
|
19
|
+
|
|
20
|
+
if (!config?.enabled) {
|
|
21
|
+
store.updateImagePreview({ status: "error", error: "Image preview disabled" })
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
// 1. Classify URL
|
|
27
|
+
const urlType = classifyUrl(url)
|
|
28
|
+
if (!urlType) {
|
|
29
|
+
store.updateImagePreview({ status: "error", error: "Not an image URL" })
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. Check cache
|
|
34
|
+
let imagePath = await isCached(url)
|
|
35
|
+
|
|
36
|
+
// 3. Fetch if not cached
|
|
37
|
+
if (!imagePath) {
|
|
38
|
+
const { data, ext } = await fetchImage(url, config)
|
|
39
|
+
imagePath = await writeCache(url, data, ext)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Bail if preview was dismissed while we were fetching
|
|
43
|
+
if (!useStore.getState().imagePreview) return
|
|
44
|
+
|
|
45
|
+
// 4. Load with sharp and get metadata
|
|
46
|
+
const metadata = await sharp(imagePath).metadata()
|
|
47
|
+
const imgWidth = metadata.width ?? 0
|
|
48
|
+
const imgHeight = metadata.height ?? 0
|
|
49
|
+
|
|
50
|
+
if (imgWidth === 0 || imgHeight === 0) {
|
|
51
|
+
store.updateImagePreview({ status: "error", error: "Invalid image dimensions" })
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 5. Detect protocol
|
|
56
|
+
const [protocol] = detectProtocol(config.protocol)
|
|
57
|
+
const inTmux = isInsideTmux()
|
|
58
|
+
// Always use PNG for kitty protocol. Raw RGBA (f=32) produces hundreds of
|
|
59
|
+
// chunks (453 for a 492×656 image) — causes chunk misalignment in terminals
|
|
60
|
+
// with async parsers (subterm), and hundreds of writeSync calls trigger
|
|
61
|
+
// malloc double-free in Bun. PNG compresses to ~20-35 chunks, and terminals
|
|
62
|
+
// decode it natively via createImageBitmap/PNG decoder — more robust.
|
|
63
|
+
const kittyFmt: KittyFormat = (protocol === "kitty") ? "png" : (config.kitty_format ?? "rgba") as KittyFormat
|
|
64
|
+
|
|
65
|
+
// 6. Calculate display dimensions — match erssi's approach:
|
|
66
|
+
// max = 75% of terminal, aspect ratio with cellAspect=2, cell geometry 8×16
|
|
67
|
+
const termCols = process.stdout.columns || 80
|
|
68
|
+
const termRows = process.stdout.rows || 24
|
|
69
|
+
|
|
70
|
+
const maxCols = config.max_width || Math.floor(termCols * 0.75)
|
|
71
|
+
const maxRows = config.max_height || Math.floor(termRows * 0.75)
|
|
72
|
+
|
|
73
|
+
const innerCols = maxCols - 2
|
|
74
|
+
const innerRows = maxRows - 2
|
|
75
|
+
|
|
76
|
+
const cellAspect = 2
|
|
77
|
+
const imgAspect = imgWidth / imgHeight
|
|
78
|
+
|
|
79
|
+
let displayCols: number
|
|
80
|
+
let displayRows: number
|
|
81
|
+
|
|
82
|
+
if (imgAspect * cellAspect > innerCols / innerRows) {
|
|
83
|
+
displayCols = innerCols
|
|
84
|
+
displayRows = Math.max(1, Math.round(innerCols / (imgAspect * cellAspect)))
|
|
85
|
+
} else {
|
|
86
|
+
displayRows = innerRows
|
|
87
|
+
displayCols = Math.max(1, Math.round(innerRows * imgAspect * cellAspect))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let pixelWidth = protocol === "symbols" ? displayCols : displayCols * 8
|
|
91
|
+
let pixelHeight = protocol === "symbols" ? displayRows * 2 : displayRows * 16
|
|
92
|
+
|
|
93
|
+
// Byte limit like erssi (IMAGE_PREVIEW_MAX_BYTES = 2MB).
|
|
94
|
+
// Only applies to raw RGBA (f=32) — PNG is already compressed and much smaller.
|
|
95
|
+
if (protocol !== "symbols" && !(protocol === "kitty" && kittyFmt === "png")) {
|
|
96
|
+
const MAX_BYTES = 2_000_000
|
|
97
|
+
// Raw RGBA = W*H*4, base64 overhead = *1.37, DCS overhead = *1.05
|
|
98
|
+
const estimatedBytes = pixelWidth * pixelHeight * 4 * 1.4
|
|
99
|
+
if (estimatedBytes > MAX_BYTES) {
|
|
100
|
+
const scale = Math.sqrt(MAX_BYTES / estimatedBytes)
|
|
101
|
+
pixelWidth = Math.floor(pixelWidth * scale)
|
|
102
|
+
pixelHeight = Math.floor(pixelHeight * scale)
|
|
103
|
+
// Recalculate display cells to match
|
|
104
|
+
displayCols = Math.max(1, Math.round(pixelWidth / 8))
|
|
105
|
+
displayRows = Math.max(1, Math.round(pixelHeight / 16))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 7. Encode based on protocol — returns raw sequences (no DCS wrapping)
|
|
110
|
+
let rawChunks: string[]
|
|
111
|
+
|
|
112
|
+
if (protocol === "kitty") {
|
|
113
|
+
if (kittyFmt === "png") {
|
|
114
|
+
// PNG (f=100) — terminal decodes natively, more robust at chunk boundaries
|
|
115
|
+
const pngBuf = await sharp(imagePath)
|
|
116
|
+
.resize(pixelWidth, pixelHeight, { fit: "inside" })
|
|
117
|
+
.png()
|
|
118
|
+
.toBuffer()
|
|
119
|
+
rawChunks = encodeKittyPNG(pngBuf, displayCols, displayRows)
|
|
120
|
+
} else {
|
|
121
|
+
// Raw RGBA (f=32) — direct pixel data, matches erssi/chafa output
|
|
122
|
+
const { data, info } = await sharp(imagePath)
|
|
123
|
+
.resize(pixelWidth, pixelHeight, { fit: "inside" })
|
|
124
|
+
.ensureAlpha()
|
|
125
|
+
.raw()
|
|
126
|
+
.toBuffer({ resolveWithObject: true })
|
|
127
|
+
const rgbaCopy = Buffer.from(data)
|
|
128
|
+
rawChunks = encodeKittyRGBA(rgbaCopy, info.width, info.height, displayCols, displayRows)
|
|
129
|
+
}
|
|
130
|
+
} else if (protocol === "iterm2") {
|
|
131
|
+
const resized = await sharp(imagePath)
|
|
132
|
+
.resize(pixelWidth, pixelHeight, { fit: "inside" })
|
|
133
|
+
.png()
|
|
134
|
+
.toBuffer()
|
|
135
|
+
rawChunks = [encodeIterm2(resized, displayCols, displayRows)]
|
|
136
|
+
} else if (protocol === "sixel") {
|
|
137
|
+
const { data, info } = await sharp(imagePath)
|
|
138
|
+
.resize(pixelWidth, pixelHeight, { fit: "inside" })
|
|
139
|
+
.ensureAlpha()
|
|
140
|
+
.raw()
|
|
141
|
+
.toBuffer({ resolveWithObject: true })
|
|
142
|
+
const rgbaCopy = Buffer.from(data)
|
|
143
|
+
rawChunks = [encodeSixel(rgbaCopy, info.width, info.height)]
|
|
144
|
+
} else {
|
|
145
|
+
const { data, info } = await sharp(imagePath)
|
|
146
|
+
.resize(displayCols, displayRows * 2, { fit: "inside" })
|
|
147
|
+
.ensureAlpha()
|
|
148
|
+
.raw()
|
|
149
|
+
.toBuffer({ resolveWithObject: true })
|
|
150
|
+
const rgbaCopy = Buffer.from(data)
|
|
151
|
+
rawChunks = [encodeSymbols(rgbaCopy, info.width, info.height)]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Bail if preview was dismissed while we were encoding
|
|
155
|
+
if (!useStore.getState().imagePreview) return
|
|
156
|
+
|
|
157
|
+
const title = decodeURIComponent(url.split("/").pop()?.split("?")[0] ?? url).slice(0, 40)
|
|
158
|
+
|
|
159
|
+
// 8. Update store with dimensions (NO encoded data — it stays out of React)
|
|
160
|
+
store.updateImagePreview({
|
|
161
|
+
status: "ready",
|
|
162
|
+
width: displayCols + 2,
|
|
163
|
+
height: displayRows + 2,
|
|
164
|
+
title,
|
|
165
|
+
protocol,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// 9. Write image directly to stdout, bypassing React/OpenTUI
|
|
169
|
+
// Use setTimeout to let the overlay box render first.
|
|
170
|
+
// MUST be synchronous — async yields (Bun.sleep) let OpenTUI render frames
|
|
171
|
+
// between DCS chunks, corrupting tmux's virtual terminal state (ghostty+tmux).
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
if (!useStore.getState().imagePreview) return
|
|
174
|
+
|
|
175
|
+
const popupWidth = displayCols + 2
|
|
176
|
+
const popupHeight = displayRows + 2
|
|
177
|
+
const left = Math.max(0, Math.floor((termCols - popupWidth) / 2))
|
|
178
|
+
const top = Math.max(0, Math.floor((termRows - popupHeight) / 2))
|
|
179
|
+
|
|
180
|
+
// Interior position: skip border + title row
|
|
181
|
+
const interiorRow = top + 2
|
|
182
|
+
const interiorCol = left + 1
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
// Disable mouse tracking — terminal stops generating mouse events.
|
|
186
|
+
// Then flush any already-queued input from the kernel buffer.
|
|
187
|
+
// We intentionally do NOT call process.stdin.pause()/resume() — that
|
|
188
|
+
// triggers a buffer overwrite race in Bun's streams.zig (issue #8695)
|
|
189
|
+
// causing a malloc double-free crash on macOS.
|
|
190
|
+
writeSync(1, MOUSE_DISABLE)
|
|
191
|
+
flushStdin()
|
|
192
|
+
|
|
193
|
+
// Write strategy differs by environment:
|
|
194
|
+
// - Direct (no tmux): one write of all chunks — matches erssi's fwrite()
|
|
195
|
+
// - tmux: per-chunk writeSync — matches erssi's per-chunk fflush().
|
|
196
|
+
// tmux needs to process each DCS individually; one big write can cause
|
|
197
|
+
// tmux to split delivery at arbitrary boundaries → partial images.
|
|
198
|
+
// With PNG (~25 chunks), ~25 writeSync calls are safe for Bun.
|
|
199
|
+
writeSync(1, `\x1b7\x1b[${interiorRow};${interiorCol}H`)
|
|
200
|
+
if (inTmux && protocol !== "symbols") {
|
|
201
|
+
for (const chunk of rawChunks) {
|
|
202
|
+
writeSync(1, Buffer.from(wrapTmuxDCS(chunk)))
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
writeSync(1, Buffer.from(rawChunks.join("")))
|
|
206
|
+
}
|
|
207
|
+
writeSync(1, "\x1b8")
|
|
208
|
+
|
|
209
|
+
// Flush any stray input that arrived during writes, then re-enable mouse
|
|
210
|
+
flushStdin()
|
|
211
|
+
writeSync(1, MOUSE_ENABLE)
|
|
212
|
+
} catch {
|
|
213
|
+
try { writeSync(1, MOUSE_ENABLE) } catch {}
|
|
214
|
+
}
|
|
215
|
+
}, 50)
|
|
216
|
+
} catch (err: any) {
|
|
217
|
+
store.updateImagePreview({
|
|
218
|
+
status: "error",
|
|
219
|
+
error: err.message ?? "Unknown error",
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal I/O helpers for image write operations.
|
|
3
|
+
*
|
|
4
|
+
* Bun uses kqueue on macOS for stdin I/O. Calling process.stdin.pause()/resume()
|
|
5
|
+
* triggers a buffer overwrite race in Bun's streams.zig (issue #8695) that causes
|
|
6
|
+
* a malloc double-free crash. Instead of pause/resume, we:
|
|
7
|
+
*
|
|
8
|
+
* 1. Disable mouse tracking at the terminal level (caller's responsibility)
|
|
9
|
+
* — the terminal stops generating mouse events entirely
|
|
10
|
+
* 2. Call tcflush(TCIFLUSH) to discard any already-queued input at the kernel level
|
|
11
|
+
*
|
|
12
|
+
* This avoids touching Bun's stdin stream state while still preventing event
|
|
13
|
+
* accumulation during synchronous image writes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
let tcflushFn: ((fd: number) => void) | null = null
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const { dlopen, FFIType } = require("bun:ffi")
|
|
20
|
+
const TCIFLUSH = process.platform === "darwin" ? 1 : 0
|
|
21
|
+
const lib = dlopen(
|
|
22
|
+
process.platform === "darwin" ? "libSystem.B.dylib" : "libc.so.6",
|
|
23
|
+
{
|
|
24
|
+
tcflush: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
tcflushFn = (fd) => lib.symbols.tcflush(fd, TCIFLUSH)
|
|
28
|
+
} catch {}
|
|
29
|
+
|
|
30
|
+
/** Flush the kernel stdin buffer — discards any queued input data */
|
|
31
|
+
export function flushStdin() {
|
|
32
|
+
try { tcflushFn?.(0) } catch {}
|
|
33
|
+
}
|
package/src/core/init.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises"
|
|
2
2
|
import { join } from "node:path"
|
|
3
|
-
import { HOME_DIR, CONFIG_PATH, DEFAULT_THEMES_DIR, SCRIPTS_DIR } from "./constants"
|
|
3
|
+
import { HOME_DIR, CONFIG_PATH, DEFAULT_THEMES_DIR, SCRIPTS_DIR, IMAGE_CACHE_DIR } from "./constants"
|
|
4
4
|
import { DEFAULT_CONFIG } from "./config/defaults"
|
|
5
5
|
import { saveConfig } from "./config/loader"
|
|
6
6
|
|
|
@@ -11,6 +11,7 @@ export async function initHomeDir(): Promise<void> {
|
|
|
11
11
|
// Create directories
|
|
12
12
|
await mkdir(themesDir, { recursive: true })
|
|
13
13
|
await mkdir(SCRIPTS_DIR, { recursive: true })
|
|
14
|
+
await mkdir(IMAGE_CACHE_DIR, { recursive: true })
|
|
14
15
|
|
|
15
16
|
// Generate default config if missing
|
|
16
17
|
const configFile = Bun.file(CONFIG_PATH)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useStore } from "@/core/state/store"
|
|
2
2
|
import { makeBufferId } from "@/types"
|
|
3
3
|
import type { Message } from "@/types"
|
|
4
|
+
import { nextMsgId } from "@/core/utils/id"
|
|
4
5
|
import type { Client } from "kofany-irc-framework"
|
|
5
6
|
|
|
6
7
|
// ─── Constants (proven thresholds from erssi) ────────────────
|
|
@@ -77,7 +78,7 @@ function statusNotify(connId: string, text: string) {
|
|
|
77
78
|
|
|
78
79
|
function makeEventMessage(text: string): Message {
|
|
79
80
|
return {
|
|
80
|
-
id:
|
|
81
|
+
id: nextMsgId(),
|
|
81
82
|
timestamp: new Date(),
|
|
82
83
|
type: "event",
|
|
83
84
|
text,
|
package/src/core/irc/client.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ConnectOptions } from "kofany-irc-framework"
|
|
|
3
3
|
import type { ServerConfig } from "@/types/config"
|
|
4
4
|
import { useStore } from "@/core/state/store"
|
|
5
5
|
import { makeBufferId, BufferType, ActivityLevel } from "@/types"
|
|
6
|
+
import { nextMsgId } from "@/core/utils/id"
|
|
6
7
|
import { bindEvents } from "./events"
|
|
7
8
|
import { createAntiFloodMiddleware } from "./antiflood"
|
|
8
9
|
import { createIgnoreMiddleware } from "./ignore"
|
|
@@ -61,7 +62,7 @@ export function connectServer(id: string, config: ServerConfig): Client {
|
|
|
61
62
|
|
|
62
63
|
// Show connecting message in Status buffer
|
|
63
64
|
store.addMessage(statusBufferId, {
|
|
64
|
-
id:
|
|
65
|
+
id: nextMsgId(),
|
|
65
66
|
timestamp: new Date(),
|
|
66
67
|
type: "event" as const,
|
|
67
68
|
text: `%Ze0af68Connecting to ${config.address}:${config.port}${config.tls ? " (TLS)" : ""}...%N`,
|
|
@@ -121,7 +122,7 @@ export function connectServer(id: string, config: ServerConfig): Client {
|
|
|
121
122
|
export function disconnectServer(id: string, message?: string) {
|
|
122
123
|
const client = clients.get(id)
|
|
123
124
|
if (client) {
|
|
124
|
-
client.quit(message ?? "
|
|
125
|
+
client.quit(message ?? "kokoIRC — https://github.com/kofany/kokoIRC")
|
|
125
126
|
clients.delete(id)
|
|
126
127
|
}
|
|
127
128
|
}
|