dsh-oc-tui 0.1.4 → 0.2.0-pre.13
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/README.md +179 -28
- package/bin/apply-update.cjs +187 -0
- package/bin/mcp-sse-bridge.mjs +499 -0
- package/cordis.patch.yml +46 -32
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +560 -424
- package/dsh-plugin.json +65 -65
- package/lib/caps.js +83 -0
- package/lib/i18n.js +283 -0
- package/lib/image.js +326 -0
- package/lib/index.js +980 -185
- package/lib/locales/ar.js +561 -0
- package/lib/locales/de.js +530 -0
- package/lib/locales/en.js +548 -0
- package/lib/locales/es.js +530 -0
- package/lib/locales/fr.js +530 -0
- package/lib/locales/id.js +532 -0
- package/lib/locales/it.js +530 -0
- package/lib/locales/ja.js +530 -0
- package/lib/locales/ko.js +530 -0
- package/lib/locales/nl.js +530 -0
- package/lib/locales/pl.js +544 -0
- package/lib/locales/pt-BR.js +530 -0
- package/lib/locales/ru.js +544 -0
- package/lib/locales/tr.js +530 -0
- package/lib/locales/vi.js +532 -0
- package/lib/locales/zh-CN.js +531 -0
- package/lib/locales/zh-TW.js +531 -0
- package/lib/markdown.js +560 -297
- package/lib/mcp-skill.js +93 -0
- package/lib/mcp.js +838 -0
- package/lib/mermaid-ascii.js +138 -0
- package/lib/mermaid.js +137 -0
- package/lib/rewind.js +15 -14
- package/lib/splash-opencode.js +108 -0
- package/lib/startup.js +16 -10
- package/lib/term.js +242 -10
- package/lib/ui.js +1265 -406
- package/lib/updates.js +1028 -697
- package/lib/util.js +85 -27
- package/lib/web-settings.js +846 -454
- package/package.json +145 -100
- package/skills/mcp-import/SKILL.md +216 -0
package/lib/image.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Image pipeline: decode, scale to terminal cells, and encode for the best
|
|
2
|
+
// graphics protocol the terminal supports (kitty graphics, iTerm2 inline
|
|
3
|
+
// images, sixel) or ANSI halfblock characters as the universal fallback.
|
|
4
|
+
// sharp is injected by the caller (lazy import) so the module stays loadable
|
|
5
|
+
// in environments without it (tests, degraded installs).
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
|
|
8
|
+
export function sha16(bytes) {
|
|
9
|
+
return createHash('sha1').update(bytes).digest('hex').slice(0, 16)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Small LRU for rendered payloads keyed by content hash + size + protocol:
|
|
13
|
+
// a scrolling transcript re-emits the same image many times, and sixel/iTerm2
|
|
14
|
+
// payloads are large enough that regenerating them per frame would stall.
|
|
15
|
+
export class PayloadCache {
|
|
16
|
+
constructor(max = 64) {
|
|
17
|
+
this.max = max
|
|
18
|
+
this.map = new Map()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(key) {
|
|
22
|
+
if (!this.map.has(key)) return undefined
|
|
23
|
+
const value = this.map.get(key)
|
|
24
|
+
this.map.delete(key)
|
|
25
|
+
this.map.set(key, value)
|
|
26
|
+
return value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
set(key, value) {
|
|
30
|
+
if (this.map.has(key)) this.map.delete(key)
|
|
31
|
+
else if (this.map.size >= this.max) this.map.delete(this.map.keys().next().value)
|
|
32
|
+
this.map.set(key, value)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Fit an image of pxW x pxH into maxCellsW x maxCellsH cells without
|
|
37
|
+
// upscaling past its natural cell size. Cells are what the renderer reserves,
|
|
38
|
+
// so fractional cells round to whole ones (never zero).
|
|
39
|
+
export function scaleToCells(pxW, pxH, maxCellsW, maxCellsH, cellW, cellH) {
|
|
40
|
+
let w = pxW / cellW
|
|
41
|
+
let h = pxH / cellH
|
|
42
|
+
const factor = Math.min(maxCellsW / w, maxCellsH / h, 1)
|
|
43
|
+
w *= factor
|
|
44
|
+
h *= factor
|
|
45
|
+
return { cellsW: Math.max(1, Math.round(w)), cellsH: Math.max(1, Math.round(h)) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hex2 = (n) => n.toString(16).padStart(2, '0')
|
|
49
|
+
|
|
50
|
+
// Compose a source pixel over the background color and return the hex pair
|
|
51
|
+
// the terminal truecolor SGR expects.
|
|
52
|
+
function blendHex(r, g, b, a, bg) {
|
|
53
|
+
const f = a / 255
|
|
54
|
+
const rr = Math.round(r * f + bg[0] * (1 - f))
|
|
55
|
+
const gg = Math.round(g * f + bg[1] * (1 - f))
|
|
56
|
+
const bb = Math.round(b * f + bg[2] * (1 - f))
|
|
57
|
+
return hex2(rr) + hex2(gg) + hex2(bb)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ANSI halfblock renderer: each cell shows two stacked pixel rows through the
|
|
61
|
+
// foreground/background pair of '▀' (a plain space when both rows match).
|
|
62
|
+
// Every glyph is a basic block present in every terminal font, which is what
|
|
63
|
+
// makes this the universal fallback. A cell averages the pixel block it covers
|
|
64
|
+
// (pxW/cellsW wide, pxH/cellsH tall split at half height), so downsampling is
|
|
65
|
+
// done here rather than by sampling a single pixel.
|
|
66
|
+
export function renderHalfblock(rgba, pxW, pxH, cellsW, cellsH, { bgHex = '000000' } = {}) {
|
|
67
|
+
const bg = [0, 0, 0]
|
|
68
|
+
if (/^[0-9a-fA-F]{6}$/.test(bgHex)) {
|
|
69
|
+
bg[0] = parseInt(bgHex.slice(0, 2), 16)
|
|
70
|
+
bg[1] = parseInt(bgHex.slice(2, 4), 16)
|
|
71
|
+
bg[2] = parseInt(bgHex.slice(4, 6), 16)
|
|
72
|
+
}
|
|
73
|
+
const cellPxW = pxW / cellsW
|
|
74
|
+
const cellPxH = pxH / cellsH
|
|
75
|
+
const halfH = Math.max(1, Math.floor(cellPxH / 2))
|
|
76
|
+
// Average an axis-aligned pixel block, alpha-composited over the background.
|
|
77
|
+
const blockColor = (x0, y0, w, h) => {
|
|
78
|
+
let r = 0, g = 0, b = 0, a = 0, n = 0
|
|
79
|
+
const xa = Math.max(0, Math.floor(x0))
|
|
80
|
+
const xb = Math.min(pxW, Math.ceil(x0 + w))
|
|
81
|
+
const ya = Math.max(0, Math.floor(y0))
|
|
82
|
+
const yb = Math.min(pxH, Math.ceil(y0 + h))
|
|
83
|
+
for (let y = ya; y < yb; y++) {
|
|
84
|
+
for (let x = xa; x < xb; x++) {
|
|
85
|
+
const i = (y * pxW + x) * 4
|
|
86
|
+
r += rgba[i]; g += rgba[i + 1]; b += rgba[i + 2]; a += rgba[i + 3]
|
|
87
|
+
n++
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (n === 0) return blendHex(0, 0, 0, 0, bg)
|
|
91
|
+
return blendHex(r / n, g / n, b / n, a / n, bg)
|
|
92
|
+
}
|
|
93
|
+
const lines = []
|
|
94
|
+
for (let cy = 0; cy < cellsH; cy++) {
|
|
95
|
+
const row = []
|
|
96
|
+
let last = null
|
|
97
|
+
for (let cx = 0; cx < cellsW; cx++) {
|
|
98
|
+
const x0 = cx * cellPxW
|
|
99
|
+
const y0 = cy * cellPxH
|
|
100
|
+
const top = blockColor(x0, y0, cellPxW, halfH)
|
|
101
|
+
const bottom = blockColor(x0, y0 + halfH, cellPxW, halfH)
|
|
102
|
+
let seg
|
|
103
|
+
if (top === bottom) seg = { text: ' ', style: { fg: top, bg: top } }
|
|
104
|
+
else seg = { text: '▀', style: { fg: top, bg: bottom } }
|
|
105
|
+
if (last && last.style.fg === seg.style.fg && last.style.bg === seg.style.bg) {
|
|
106
|
+
last.text += seg.text
|
|
107
|
+
} else {
|
|
108
|
+
row.push(seg)
|
|
109
|
+
last = seg
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
lines.push(row)
|
|
113
|
+
}
|
|
114
|
+
return lines
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Median-cut color quantization for sixel (which carries at most 256 palette
|
|
118
|
+
// entries). The histogram is sampled and looked up through a coarse 15-bit
|
|
119
|
+
// key so per-pixel palette matching costs one Map read, not a color scan.
|
|
120
|
+
function quantize(rgba, pxW, pxH, maxColors) {
|
|
121
|
+
const indices = new Uint8Array(pxW * pxH)
|
|
122
|
+
const total = pxW * pxH
|
|
123
|
+
const step = Math.max(1, Math.floor(total / 16384))
|
|
124
|
+
const histogram = new Map()
|
|
125
|
+
for (let i = 0; i < total; i += step) {
|
|
126
|
+
const o = i * 4
|
|
127
|
+
const key = ((rgba[o] >> 3) << 10) | ((rgba[o + 1] >> 3) << 5) | (rgba[o + 2] >> 3)
|
|
128
|
+
if (!histogram.has(key)) histogram.set(key, [rgba[o], rgba[o + 1], rgba[o + 2], 1])
|
|
129
|
+
else histogram.get(key)[3]++
|
|
130
|
+
}
|
|
131
|
+
let buckets = [[...histogram.values()]]
|
|
132
|
+
while (buckets.length < maxColors) {
|
|
133
|
+
let bi = -1
|
|
134
|
+
let range = 0
|
|
135
|
+
let chan = 0
|
|
136
|
+
buckets.forEach((b, i) => {
|
|
137
|
+
if (b.length < 2) return
|
|
138
|
+
for (let c = 0; c < 3; c++) {
|
|
139
|
+
let min = 255
|
|
140
|
+
let max = 0
|
|
141
|
+
for (const p of b) {
|
|
142
|
+
if (p[c] < min) min = p[c]
|
|
143
|
+
if (p[c] > max) max = p[c]
|
|
144
|
+
}
|
|
145
|
+
if (max - min > range) { range = max - min; bi = i; chan = c }
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
if (bi < 0) break
|
|
149
|
+
const b = buckets[bi]
|
|
150
|
+
b.sort((x, y) => x[chan] - y[chan])
|
|
151
|
+
const mid = b.length >> 1
|
|
152
|
+
buckets.splice(bi, 1, b.slice(0, mid), b.slice(mid))
|
|
153
|
+
}
|
|
154
|
+
const palette = buckets
|
|
155
|
+
.filter((b) => b.length > 0)
|
|
156
|
+
.map((b) => {
|
|
157
|
+
let r = 0, g = 0, bl = 0, n = 0
|
|
158
|
+
for (const p of b) { r += p[0]; g += p[1]; bl += p[2]; n++ }
|
|
159
|
+
return [Math.round(r / n), Math.round(g / n), Math.round(bl / n)]
|
|
160
|
+
})
|
|
161
|
+
// Coarse-key cache: every source color inside one 5-bit cube maps to the
|
|
162
|
+
// same palette entry, which keeps banding visible but the encoder fast.
|
|
163
|
+
const lookup = new Map()
|
|
164
|
+
const nearest = (r, g, b) => {
|
|
165
|
+
const key = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3)
|
|
166
|
+
let idx = lookup.get(key)
|
|
167
|
+
if (idx !== undefined) return idx
|
|
168
|
+
let best = 0
|
|
169
|
+
let bestD = Infinity
|
|
170
|
+
for (let i = 0; i < palette.length; i++) {
|
|
171
|
+
const p = palette[i]
|
|
172
|
+
const dr = r - p[0], dg = g - p[1], db = b - p[2]
|
|
173
|
+
const d = dr * dr + dg * dg + db * db
|
|
174
|
+
if (d < bestD) { bestD = d; best = i }
|
|
175
|
+
}
|
|
176
|
+
lookup.set(key, best)
|
|
177
|
+
return best
|
|
178
|
+
}
|
|
179
|
+
for (let i = 0; i < total; i++) {
|
|
180
|
+
const o = i * 4
|
|
181
|
+
indices[i] = nearest(rgba[o], rgba[o + 1], rgba[o + 2])
|
|
182
|
+
}
|
|
183
|
+
return { palette, indices }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Sixel encoder (DEC graphics). The raster attributes ("1;1;<H>;<W>") force
|
|
187
|
+
// square pixels — the bare DCS P1=0 means 2:1 on some terminals and would
|
|
188
|
+
// stretch every image to twice its reserved rows. P2=1 leaves unset pixels
|
|
189
|
+
// transparent so the text grid behind the image (the bubble/canvas background
|
|
190
|
+
// the renderer already painted) shows through instead of a painted rectangle.
|
|
191
|
+
//
|
|
192
|
+
// Within a color pass, EVERY column emits a data character — blank columns
|
|
193
|
+
// send '?' (bits 0). The sixel cursor only advances on data characters, so
|
|
194
|
+
// skipping gaps would shift everything after the first hole sideways.
|
|
195
|
+
export function encodeSixel(rgba, pxW, pxH) {
|
|
196
|
+
const { palette, indices } = quantize(rgba, pxW, pxH, 256)
|
|
197
|
+
const parts = [`\x1bP0;1;1q"1;1;${pxH};${pxW}`]
|
|
198
|
+
const bands = Math.ceil(pxH / 6)
|
|
199
|
+
for (let band = 0; band < bands; band++) {
|
|
200
|
+
if (band > 0) parts.push('-')
|
|
201
|
+
// Which palette entries appear in this band, most-used first so the
|
|
202
|
+
// dominant color switches cost the least.
|
|
203
|
+
const used = new Map()
|
|
204
|
+
for (let y = band * 6; y < Math.min(pxH, band * 6 + 6); y++) {
|
|
205
|
+
for (let x = 0; x < pxW; x++) {
|
|
206
|
+
const idx = indices[y * pxW + x]
|
|
207
|
+
used.set(idx, (used.get(idx) ?? 0) + 1)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const order = [...used.entries()].sort((a, b) => b[1] - a[1]).map(([i]) => i)
|
|
211
|
+
for (const idx of order) {
|
|
212
|
+
const p = palette[idx]
|
|
213
|
+
parts.push(`#${idx};2;${Math.round(p[0] / 2.55)};${Math.round(p[1] / 2.55)};${Math.round(p[2] / 2.55)}`)
|
|
214
|
+
let runChar = ''
|
|
215
|
+
let run = 0
|
|
216
|
+
const flush = () => {
|
|
217
|
+
if (run === 0) return
|
|
218
|
+
parts.push(run > 3 ? `!${run}${runChar}` : runChar.repeat(run))
|
|
219
|
+
run = 0
|
|
220
|
+
}
|
|
221
|
+
for (let x = 0; x < pxW; x++) {
|
|
222
|
+
let bits = 0
|
|
223
|
+
for (let r = 0; r < 6; r++) {
|
|
224
|
+
const y = band * 6 + r
|
|
225
|
+
if (y < pxH && indices[y * pxW + x] === idx) bits |= 1 << r
|
|
226
|
+
}
|
|
227
|
+
const ch = String.fromCharCode(0x3F + bits)
|
|
228
|
+
if (ch === runChar) run++
|
|
229
|
+
else { flush(); runChar = ch; run = 1 }
|
|
230
|
+
}
|
|
231
|
+
flush()
|
|
232
|
+
parts.push('$')
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
parts.push('\x1b\\')
|
|
236
|
+
return parts.join('')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Kitty graphics protocol. Transmission: base64 PNG in 4096-char chunks with
|
|
240
|
+
// the continuation flag m; the first chunk carries the full control params.
|
|
241
|
+
// Placement happens separately (kittyPlacement) so a scrolled transcript moves
|
|
242
|
+
// an already-transmitted image for free instead of resending pixel data.
|
|
243
|
+
export function encodeKittyTransmission(id, b64) {
|
|
244
|
+
const parts = []
|
|
245
|
+
for (let i = 0; i < b64.length; i += 4096) {
|
|
246
|
+
const chunk = b64.slice(i, i + 4096)
|
|
247
|
+
const last = i + 4096 >= b64.length
|
|
248
|
+
const control = i === 0
|
|
249
|
+
? `a=T,f=100,q=2,i=${id},m=${last ? 0 : 1}`
|
|
250
|
+
: `m=${last ? 0 : 1}`
|
|
251
|
+
parts.push(`\x1b_G${control};${chunk}\x1b\\`)
|
|
252
|
+
}
|
|
253
|
+
if (parts.length === 0) parts.push(`\x1b_Ga=T,f=100,q=2,i=${id},m=0;\x1b\\`)
|
|
254
|
+
return parts.join('')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Place a previously transmitted image with its top-left cell at (x, y).
|
|
258
|
+
// The CSI prefix moves the (hidden) cursor there; C=1 keeps the placement
|
|
259
|
+
// from advancing it, and q=2 suppresses the terminal's response frames.
|
|
260
|
+
export function kittyPlacement(id, x, y) {
|
|
261
|
+
return `\x1b[${y + 1};${x + 1}H\x1b_Ga=p,i=${id},C=1,q=2\x1b\\`
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function kittyDelete(id) {
|
|
265
|
+
return `\x1b_Ga=d,d=i,i=${id},q=2\x1b\\`
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function kittyDeleteAll() {
|
|
269
|
+
return '\x1b_Ga=d,d=a,q=2\x1b\\'
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// iTerm2 inline image (also WezTerm, recent Konsole, mintty). There is no
|
|
273
|
+
// image registry on the terminal side, so moving an image means re-emitting
|
|
274
|
+
// the payload at the new cursor position; callers cache the payload string.
|
|
275
|
+
export function encodeITerm2(b64, pxW, pxH, byteLength = b64.length) {
|
|
276
|
+
return `\x1b]1337;File=inline=1;size=${byteLength};width=${pxW}px;height=${pxH}px:name=x.png:${b64}\x07`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Copy the pixel rows [top, height) out of an RGBA raster. A cropped variant
|
|
280
|
+
// carries exactly the band that is still on screen; re-encoding the full
|
|
281
|
+
// raster would draw the rows that scrolled away on top of the ones below.
|
|
282
|
+
function cropRows(rgba, width, height, top) {
|
|
283
|
+
const out = Buffer.allocUnsafe(width * (height - top) * 4)
|
|
284
|
+
rgba.copy(out, 0, top * width * 4)
|
|
285
|
+
return out
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Decode + scale + encode one image for the requested protocol. `cropCells`
|
|
289
|
+
// drops that many cell rows off the TOP before encoding, for the image whose
|
|
290
|
+
// first reserved row has scrolled above the transcript window: what is left is
|
|
291
|
+
// the band the window still shows, and it has to be encoded at that height or
|
|
292
|
+
// it would cover the rows underneath it.
|
|
293
|
+
// Returns { protocol, cellsW, cellsH, hash, payload? | segLines? } on success
|
|
294
|
+
// or { protocol: 'error', error } — never throws.
|
|
295
|
+
export async function renderImage(source, { protocol, maxCellsW, maxCellsH, cellW = 8, cellH = 16, bgHex = '000000', cropCells = 0, sharp: injectedSharp }) {
|
|
296
|
+
const bytes = source?.bytes
|
|
297
|
+
const hash = sha16(bytes ?? Buffer.alloc(0))
|
|
298
|
+
if (!bytes) return { protocol: 'error', error: 'no image bytes', hash }
|
|
299
|
+
try {
|
|
300
|
+
const sharp = injectedSharp ?? (await import('sharp')).default
|
|
301
|
+
const meta = await sharp(bytes, { animated: false }).metadata()
|
|
302
|
+
if (!meta.width || !meta.height) throw new Error('image has no dimensions')
|
|
303
|
+
const { cellsW, cellsH } = scaleToCells(meta.width, meta.height, maxCellsW, maxCellsH, cellW, cellH)
|
|
304
|
+
const clip = Math.max(0, Math.min(Math.floor(cropCells), cellsH - 1))
|
|
305
|
+
const pxW = cellsW * cellW
|
|
306
|
+
const pxH = cellsH * cellH
|
|
307
|
+
const rows = cellsH - clip
|
|
308
|
+
const bandH = pxH - clip * cellH
|
|
309
|
+
const scaled = await sharp(bytes, { animated: false }).resize(pxW, pxH, { fit: 'fill' }).ensureAlpha()
|
|
310
|
+
.raw().toBuffer({ resolveWithObject: true })
|
|
311
|
+
const data = clip > 0 ? cropRows(scaled.data, pxW, pxH, clip * cellH) : scaled.data
|
|
312
|
+
if (protocol === 'kitty' || protocol === 'iterm2') {
|
|
313
|
+
const png = await sharp(data, { raw: { width: pxW, height: bandH, channels: 4 } }).png().toBuffer()
|
|
314
|
+
const b64 = png.toString('base64')
|
|
315
|
+
if (protocol === 'kitty') return { protocol, cellsW, cellsH: rows, hash, payload: { kind: 'kitty', b64, bytes: png.length } }
|
|
316
|
+
return { protocol, cellsW, cellsH: rows, hash, payload: { kind: 'iterm2', b64, bytes: png.length, pxW, pxH: bandH } }
|
|
317
|
+
}
|
|
318
|
+
if (protocol === 'sixel') {
|
|
319
|
+
return { protocol, cellsW, cellsH: rows, hash, payload: { kind: 'sixel', s: encodeSixel(data, pxW, bandH) } }
|
|
320
|
+
}
|
|
321
|
+
const segLines = renderHalfblock(data, pxW, bandH, cellsW, rows, { bgHex })
|
|
322
|
+
return { protocol: 'halfblock', cellsW, cellsH: rows, hash, segLines }
|
|
323
|
+
} catch (e) {
|
|
324
|
+
return { protocol: 'error', error: String(e?.message ?? e), hash }
|
|
325
|
+
}
|
|
326
|
+
}
|