kokoirc 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kokoirc",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Modern terminal IRC client with inline image preview, SASL, scripting, encrypted logging, and theming — built with OpenTUI, React, and Bun",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -62,7 +62,6 @@
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.9",
64
64
  "@types/react": "^19.2.14",
65
- "@types/sharp": "^0.32.0",
66
65
  "typescript": "^5.9.3"
67
66
  },
68
67
  "imports": {
@@ -71,10 +70,9 @@
71
70
  "dependencies": {
72
71
  "@opentui/core": "^0.1.83",
73
72
  "@opentui/react": "^0.1.83",
73
+ "jimp": "^1.6.0",
74
74
  "kofany-irc-framework": "^4.14.1",
75
75
  "react": "^19.2.4",
76
- "sharp": "^0.34.5",
77
- "sixel": "^0.16.0",
78
76
  "smol-toml": "^1.6.0",
79
77
  "zustand": "^5.0.11"
80
78
  }
@@ -1,4 +1,4 @@
1
- import { image2sixel } from "sixel"
1
+ import { encodeSixel as encodeSixelRaw } from "./sixel"
2
2
 
3
3
  const ESC = "\x1b"
4
4
  const ST = `${ESC}\\`
@@ -74,10 +74,7 @@ export function encodeIterm2(imageBuffer: Buffer, cols: number, rows: number): s
74
74
 
75
75
  /** Encode raw RGBA pixels as a raw sixel sequence */
76
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)
77
+ return encodeSixelRaw(new Uint8Array(rgbaBuffer), width, height)
81
78
  }
82
79
 
83
80
  // ─── Unicode Half-Block Fallback ─────────────────────────────
@@ -1,4 +1,4 @@
1
- import sharp from "sharp"
1
+ import { Jimp } from "jimp"
2
2
  import { writeSync } from "node:fs"
3
3
  import { useStore } from "@/core/state/store"
4
4
  import { detectProtocol, isInsideTmux } from "./detect"
@@ -12,6 +12,31 @@ import { flushStdin } from "./stdin-guard"
12
12
  const MOUSE_DISABLE = "\x1b[?1003l\x1b[?1006l\x1b[?1002l\x1b[?1000l"
13
13
  const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"
14
14
 
15
+ /** Resize image to fit within maxW x maxH while preserving aspect ratio (fit: "inside"). */
16
+ function resizeContain(image: InstanceType<typeof Jimp>, maxW: number, maxH: number): InstanceType<typeof Jimp> {
17
+ const { width, height } = image
18
+ if (width <= maxW && height <= maxH) return image
19
+
20
+ const scale = Math.min(maxW / width, maxH / height)
21
+ const newW = Math.max(1, Math.round(width * scale))
22
+ const newH = Math.max(1, Math.round(height * scale))
23
+ return image.resize({ w: newW, h: newH })
24
+ }
25
+
26
+ /** Get raw RGBA buffer from a Jimp image. */
27
+ function getRGBA(image: InstanceType<typeof Jimp>): { data: Buffer; width: number; height: number } {
28
+ return {
29
+ data: Buffer.from(image.bitmap.data),
30
+ width: image.bitmap.width,
31
+ height: image.bitmap.height,
32
+ }
33
+ }
34
+
35
+ /** Get PNG buffer from a Jimp image. */
36
+ async function toPNG(image: InstanceType<typeof Jimp>): Promise<Buffer> {
37
+ return Buffer.from(await image.getBuffer("image/png"))
38
+ }
39
+
15
40
  /** Main pipeline: fetch → cache → encode → write to stdout directly */
16
41
  export async function preparePreview(url: string): Promise<void> {
17
42
  const store = useStore.getState()
@@ -42,10 +67,10 @@ export async function preparePreview(url: string): Promise<void> {
42
67
  // Bail if preview was dismissed while we were fetching
43
68
  if (!useStore.getState().imagePreview) return
44
69
 
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
70
+ // 4. Load with jimp and get metadata
71
+ const image = await Jimp.read(imagePath)
72
+ const imgWidth = image.width
73
+ const imgHeight = image.height
49
74
 
50
75
  if (imgWidth === 0 || imgHeight === 0) {
51
76
  store.updateImagePreview({ status: "error", error: "Invalid image dimensions" })
@@ -106,49 +131,33 @@ export async function preparePreview(url: string): Promise<void> {
106
131
  }
107
132
  }
108
133
 
109
- // 7. Encode based on protocol — returns raw sequences (no DCS wrapping)
134
+ // 7. Encode based on protocol
110
135
  let rawChunks: string[]
111
136
 
112
137
  if (protocol === "kitty") {
113
138
  if (kittyFmt === "png") {
114
139
  // 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()
140
+ const resized = resizeContain(image.clone(), pixelWidth, pixelHeight)
141
+ const pngBuf = await toPNG(resized)
119
142
  rawChunks = encodeKittyPNG(pngBuf, displayCols, displayRows)
120
143
  } else {
121
144
  // 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)
145
+ const resized = resizeContain(image.clone(), pixelWidth, pixelHeight)
146
+ const { data, width, height } = getRGBA(resized)
147
+ rawChunks = encodeKittyRGBA(data, width, height, displayCols, displayRows)
129
148
  }
130
149
  } 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)]
150
+ const resized = resizeContain(image.clone(), pixelWidth, pixelHeight)
151
+ const pngBuf = await toPNG(resized)
152
+ rawChunks = [encodeIterm2(pngBuf, displayCols, displayRows)]
136
153
  } 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)]
154
+ const resized = resizeContain(image.clone(), pixelWidth, pixelHeight)
155
+ const { data, width, height } = getRGBA(resized)
156
+ rawChunks = [encodeSixel(data, width, height)]
144
157
  } 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)]
158
+ const resized = resizeContain(image.clone(), displayCols, displayRows * 2)
159
+ const { data, width, height } = getRGBA(resized)
160
+ rawChunks = [encodeSymbols(data, width, height)]
152
161
  }
153
162
 
154
163
  // Bail if preview was dismissed while we were encoding
@@ -214,9 +223,22 @@ export async function preparePreview(url: string): Promise<void> {
214
223
  }
215
224
  }, 50)
216
225
  } catch (err: any) {
217
- store.updateImagePreview({
218
- status: "error",
219
- error: err.message ?? "Unknown error",
220
- })
226
+ const errDetail = err.stack
227
+ ? `${err.message}\n${err.stack.split("\n").slice(1, 4).join("\n")}`
228
+ : (err.message ?? "Unknown error")
229
+ store.updateImagePreview({ status: "error", error: err.message ?? "Unknown error" })
230
+
231
+ // Also show full error in the active buffer for debugging
232
+ const buf = useStore.getState().activeBufferId
233
+ if (buf) {
234
+ const { nextMsgId } = await import("@/core/utils/id")
235
+ useStore.getState().addMessage(buf, {
236
+ id: nextMsgId(),
237
+ timestamp: new Date(),
238
+ type: "event",
239
+ text: `%Zf7768e[img] ${errDetail}%N`,
240
+ highlight: false,
241
+ })
242
+ }
221
243
  }
222
244
  }
@@ -0,0 +1,193 @@
1
+ /** Pure TypeScript sixel encoder — no native dependencies.
2
+ * Converts RGBA pixel data to sixel format for terminal display.
3
+ *
4
+ * Sixel format: each "band" is 6 rows of pixels. For each color in the palette,
5
+ * we scan across the band outputting characters whose 6-bit value (+ 63) indicates
6
+ * which of the 6 rows contain that color at that x position. */
7
+
8
+ // ─── Color Quantization (Median Cut) ─────────────────────────────────────
9
+
10
+ interface ColorBox {
11
+ pixels: Uint32Array
12
+ rMin: number; rMax: number
13
+ gMin: number; gMax: number
14
+ bMin: number; bMax: number
15
+ }
16
+
17
+ function buildBox(pixels: Uint32Array): ColorBox {
18
+ let rMin = 255, rMax = 0, gMin = 255, gMax = 0, bMin = 255, bMax = 0
19
+ for (let i = 0; i < pixels.length; i++) {
20
+ const c = pixels[i]
21
+ const r = c & 0xFF
22
+ const g = (c >> 8) & 0xFF
23
+ const b = (c >> 16) & 0xFF
24
+ if (r < rMin) rMin = r; if (r > rMax) rMax = r
25
+ if (g < gMin) gMin = g; if (g > gMax) gMax = g
26
+ if (b < bMin) bMin = b; if (b > bMax) bMax = b
27
+ }
28
+ return { pixels, rMin, rMax, gMin, gMax, bMin, bMax }
29
+ }
30
+
31
+ function medianCut(rgba: Uint8Array, width: number, height: number, maxColors: number): { palette: number[][]; indexed: Uint8Array } {
32
+ // Pack pixels into uint32 (ignore alpha)
33
+ const count = width * height
34
+ const packed = new Uint32Array(count)
35
+ for (let i = 0; i < count; i++) {
36
+ const off = i * 4
37
+ packed[i] = rgba[off] | (rgba[off + 1] << 8) | (rgba[off + 2] << 16)
38
+ }
39
+
40
+ // Build initial box and split
41
+ const boxes: ColorBox[] = [buildBox(packed)]
42
+
43
+ while (boxes.length < maxColors) {
44
+ // Find box with largest range
45
+ let bestIdx = 0
46
+ let bestRange = 0
47
+ for (let i = 0; i < boxes.length; i++) {
48
+ const b = boxes[i]
49
+ if (b.pixels.length < 2) continue
50
+ const range = Math.max(b.rMax - b.rMin, b.gMax - b.gMin, b.bMax - b.bMin)
51
+ if (range > bestRange) { bestRange = range; bestIdx = i }
52
+ }
53
+
54
+ if (bestRange === 0) break
55
+
56
+ const box = boxes[bestIdx]
57
+ const rRange = box.rMax - box.rMin
58
+ const gRange = box.gMax - box.gMin
59
+ const bRange = box.bMax - box.bMin
60
+
61
+ // Sort along the longest axis
62
+ let channel: number
63
+ if (rRange >= gRange && rRange >= bRange) channel = 0
64
+ else if (gRange >= bRange) channel = 8
65
+ else channel = 16
66
+
67
+ const sorted = box.pixels.slice().sort((a, b) =>
68
+ ((a >> channel) & 0xFF) - ((b >> channel) & 0xFF)
69
+ )
70
+
71
+ const mid = sorted.length >> 1
72
+ boxes.splice(bestIdx, 1,
73
+ buildBox(sorted.slice(0, mid)),
74
+ buildBox(sorted.slice(mid))
75
+ )
76
+ }
77
+
78
+ // Build palette from box averages
79
+ const palette: number[][] = []
80
+ for (const box of boxes) {
81
+ let rSum = 0, gSum = 0, bSum = 0
82
+ for (let i = 0; i < box.pixels.length; i++) {
83
+ const c = box.pixels[i]
84
+ rSum += c & 0xFF
85
+ gSum += (c >> 8) & 0xFF
86
+ bSum += (c >> 16) & 0xFF
87
+ }
88
+ const n = box.pixels.length || 1
89
+ palette.push([Math.round(rSum / n), Math.round(gSum / n), Math.round(bSum / n)])
90
+ }
91
+
92
+ // Map each pixel to nearest palette entry
93
+ const indexed = new Uint8Array(count)
94
+ for (let i = 0; i < count; i++) {
95
+ const off = i * 4
96
+ const r = rgba[off], g = rgba[off + 1], b = rgba[off + 2]
97
+ let bestDist = Infinity
98
+ let bestColor = 0
99
+ for (let c = 0; c < palette.length; c++) {
100
+ const dr = r - palette[c][0]
101
+ const dg = g - palette[c][1]
102
+ const db = b - palette[c][2]
103
+ const dist = dr * dr + dg * dg + db * db
104
+ if (dist < bestDist) { bestDist = dist; bestColor = c }
105
+ }
106
+ indexed[i] = bestColor
107
+ }
108
+
109
+ return { palette, indexed }
110
+ }
111
+
112
+ // ─── Sixel Encoding ──────────────────────────────────────────────────────
113
+
114
+ /** Encode RGBA pixel data as a sixel string.
115
+ * @param rgba Raw RGBA pixel buffer
116
+ * @param width Image width in pixels
117
+ * @param height Image height in pixels
118
+ * @param maxColors Max palette size (default 256, sixel max) */
119
+ export function encodeSixel(rgba: Uint8Array, width: number, height: number, maxColors = 256): string {
120
+ const { palette, indexed } = medianCut(rgba, width, height, Math.min(maxColors, 256))
121
+
122
+ const parts: string[] = []
123
+
124
+ // DCS q — sixel header. P2=1 means background stays transparent.
125
+ // "0;0;0" = aspect ratio params (let terminal decide)
126
+ parts.push("\x1bPq")
127
+
128
+ // Palette definitions: #N;2;R;G;B (percentages 0-100)
129
+ for (let i = 0; i < palette.length; i++) {
130
+ const [r, g, b] = palette[i]
131
+ parts.push(`#${i};2;${Math.round(r * 100 / 255)};${Math.round(g * 100 / 255)};${Math.round(b * 100 / 255)}`)
132
+ }
133
+
134
+ // Encode bands of 6 rows
135
+ const bandCount = Math.ceil(height / 6)
136
+
137
+ for (let band = 0; band < bandCount; band++) {
138
+ const y0 = band * 6
139
+
140
+ for (let color = 0; color < palette.length; color++) {
141
+ // Build sixel line for this color in this band
142
+ let hasPixel = false
143
+ const chars: number[] = new Array(width)
144
+
145
+ for (let x = 0; x < width; x++) {
146
+ let bits = 0
147
+ for (let row = 0; row < 6; row++) {
148
+ const y = y0 + row
149
+ if (y >= height) break
150
+ if (indexed[y * width + x] === color) {
151
+ bits |= (1 << row)
152
+ }
153
+ }
154
+ chars[x] = bits
155
+ if (bits !== 0) hasPixel = true
156
+ }
157
+
158
+ if (!hasPixel) continue
159
+
160
+ // Select this color
161
+ parts.push(`#${color}`)
162
+
163
+ // Emit with run-length encoding
164
+ let i = 0
165
+ while (i < width) {
166
+ const ch = chars[i]
167
+ let run = 1
168
+ while (i + run < width && chars[i + run] === ch) run++
169
+
170
+ const sixelChar = String.fromCharCode(ch + 63)
171
+ if (run >= 4) {
172
+ parts.push(`!${run}${sixelChar}`)
173
+ } else {
174
+ for (let r = 0; r < run; r++) parts.push(sixelChar)
175
+ }
176
+ i += run
177
+ }
178
+
179
+ // Carriage return (back to column 0, same band)
180
+ parts.push("$")
181
+ }
182
+
183
+ // Next band (move down 6 rows)
184
+ if (band < bandCount - 1) {
185
+ parts.push("-")
186
+ }
187
+ }
188
+
189
+ // ST — string terminator
190
+ parts.push("\x1b\\")
191
+
192
+ return parts.join("")
193
+ }
@@ -103,10 +103,20 @@ export function CommandInput() {
103
103
  const text = event.text
104
104
  if (!text) return
105
105
 
106
- const lines = text.split(/\r?\n/).filter((l) => l.trim())
107
- if (lines.length <= 1) return // single-line paste: let input handle normally
108
-
106
+ const lines = text.split(/\r?\n/)
107
+ const nonEmptyLines = lines.filter((l) => l.trim())
108
+
109
+ // Always prevent default to stop OpenTUI input from stripping newlines
109
110
  event.preventDefault()
111
+
112
+ // Single line: insert at cursor position
113
+ if (nonEmptyLines.length <= 1) {
114
+ const currentValue = inputRef.current?.value ?? value
115
+ const newValue = currentValue + text
116
+ setValue(newValue)
117
+ if (inputRef.current) inputRef.current.value = newValue
118
+ return
119
+ }
110
120
 
111
121
  // Prepend any existing input text to first pasted line
112
122
  const currentInput = inputRef.current?.value ?? ""