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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
46
|
-
const
|
|
47
|
-
const imgWidth =
|
|
48
|
-
const imgHeight =
|
|
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
|
|
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
|
|
116
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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 =
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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/)
|
|
107
|
-
|
|
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 ?? ""
|