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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -40
  3. package/docs/commands/clear.md +26 -0
  4. package/docs/commands/image.md +47 -0
  5. package/docs/commands/invite.md +23 -0
  6. package/docs/commands/kill.md +24 -0
  7. package/docs/commands/names.md +25 -0
  8. package/docs/commands/oper.md +24 -0
  9. package/docs/commands/preview.md +31 -0
  10. package/docs/commands/quote.md +29 -0
  11. package/docs/commands/server.md +6 -0
  12. package/docs/commands/stats.md +31 -0
  13. package/docs/commands/topic.md +12 -6
  14. package/docs/commands/version.md +23 -0
  15. package/docs/commands/wallops.md +24 -0
  16. package/package.json +46 -3
  17. package/src/app/App.tsx +11 -1
  18. package/src/core/commands/help-formatter.ts +1 -1
  19. package/src/core/commands/helpers.ts +3 -1
  20. package/src/core/commands/registry.ts +251 -6
  21. package/src/core/config/defaults.ts +11 -0
  22. package/src/core/config/loader.ts +5 -0
  23. package/src/core/constants.ts +3 -0
  24. package/src/core/image-preview/cache.ts +108 -0
  25. package/src/core/image-preview/detect.ts +105 -0
  26. package/src/core/image-preview/encode.ts +116 -0
  27. package/src/core/image-preview/fetch.ts +174 -0
  28. package/src/core/image-preview/index.ts +6 -0
  29. package/src/core/image-preview/render.ts +222 -0
  30. package/src/core/image-preview/stdin-guard.ts +33 -0
  31. package/src/core/init.ts +2 -1
  32. package/src/core/irc/antiflood.ts +2 -1
  33. package/src/core/irc/client.ts +3 -2
  34. package/src/core/irc/events.ts +121 -47
  35. package/src/core/irc/netsplit.ts +2 -1
  36. package/src/core/scripts/api.ts +3 -2
  37. package/src/core/state/store.ts +261 -3
  38. package/src/core/storage/index.ts +2 -2
  39. package/src/core/storage/writer.ts +12 -10
  40. package/src/core/theme/renderer.tsx +29 -1
  41. package/src/core/utils/id.ts +2 -0
  42. package/src/types/config.ts +14 -0
  43. package/src/types/index.ts +1 -2
  44. package/src/ui/chat/ChatView.tsx +11 -5
  45. package/src/ui/chat/MessageLine.tsx +18 -1
  46. package/src/ui/input/CommandInput.tsx +2 -1
  47. package/src/ui/layout/AppLayout.tsx +3 -1
  48. 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: crypto.randomUUID(),
81
+ id: nextMsgId(),
81
82
  timestamp: new Date(),
82
83
  type: "event",
83
84
  text,
@@ -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: crypto.randomUUID(),
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 ?? "kIRC")
125
+ client.quit(message ?? "kokoIRC — https://github.com/kofany/kokoIRC")
125
126
  clients.delete(id)
126
127
  }
127
128
  }