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
@@ -7,7 +7,7 @@ import { commands, findByAlias, getCanonicalName } from "./registry"
7
7
 
8
8
  const CATEGORY_ORDER = [
9
9
  "Connection", "Channel", "Messaging", "Moderation",
10
- "Configuration", "Statusbar", "Info",
10
+ "Media", "Configuration", "Statusbar", "Info",
11
11
  ]
12
12
 
13
13
  // ─── /help (no args) — categorized command list ─────────────
@@ -1,6 +1,7 @@
1
1
  import { useStore } from "@/core/state/store"
2
2
  import { makeBufferId, BufferType } from "@/types"
3
3
  import type { AppConfig } from "@/types/config"
4
+ import { nextMsgId } from "@/core/utils/id"
4
5
  import { CREDENTIAL_FIELDS } from "./types"
5
6
  import type { ResolvedConfig } from "./types"
6
7
 
@@ -10,7 +11,7 @@ export function addLocalEvent(text: string) {
10
11
  const buf = s.activeBufferId
11
12
  if (!buf) return
12
13
  s.addMessage(buf, {
13
- id: crypto.randomUUID(),
14
+ id: nextMsgId(),
14
15
  timestamp: new Date(),
15
16
  type: "event",
16
17
  text,
@@ -155,6 +156,7 @@ export function listAllSettings(config: AppConfig): void {
155
156
  showSection("sidepanel.left", "sidepanel.left", config.sidepanel.left)
156
157
  showSection("sidepanel.right", "sidepanel.right", config.sidepanel.right)
157
158
  showSection("statusbar", "statusbar", config.statusbar)
159
+ showSection("image_preview", "image_preview", config.image_preview)
158
160
 
159
161
  if (Object.keys(config.aliases).length > 0) {
160
162
  showSection("aliases", "aliases", config.aliases)
@@ -1,5 +1,6 @@
1
1
  import { getClient, connectServer, disconnectServer, getAllClientIds } from "@/core/irc"
2
2
  import { useStore } from "@/core/state/store"
3
+ import { nextMsgId } from "@/core/utils/id"
3
4
  import { loadConfig, saveConfig, saveCredentialsToEnv, cloneConfig } from "@/core/config/loader"
4
5
  import { loadTheme } from "@/core/theme/loader"
5
6
  import { BufferType, makeBufferId, ActivityLevel } from "@/types"
@@ -192,7 +193,7 @@ export const commands: Record<string, CommandDef> = {
192
193
  // Show the sent message in the target buffer
193
194
  if (s.buffers.has(bufferId)) {
194
195
  s.addMessage(bufferId, {
195
- id: crypto.randomUUID(),
196
+ id: nextMsgId(),
196
197
  timestamp: new Date(),
197
198
  type: "message",
198
199
  nick: conn?.nick ?? "",
@@ -220,7 +221,7 @@ export const commands: Record<string, CommandDef> = {
220
221
  client.action(buf.name, args[0])
221
222
  const conn = s.connections.get(connId)
222
223
  s.addMessage(buf.id, {
223
- id: crypto.randomUUID(),
224
+ id: nextMsgId(),
224
225
  timestamp: new Date(),
225
226
  type: "action",
226
227
  nick: conn?.nick ?? "",
@@ -248,7 +249,7 @@ export const commands: Record<string, CommandDef> = {
248
249
 
249
250
  quit: {
250
251
  handler(args) {
251
- const reason = args.join(" ") || "kIRC"
252
+ const reason = args.join(" ") || "kokoIRC — https://github.com/kofany/kokoIRC"
252
253
  const ids = getAllClientIds()
253
254
  for (const id of ids) {
254
255
  disconnectServer(id, reason)
@@ -267,13 +268,92 @@ export const commands: Record<string, CommandDef> = {
267
268
  const s = useStore.getState()
268
269
  const buf = s.activeBufferId ? s.buffers.get(s.activeBufferId) : null
269
270
  if (args.length >= 2) {
271
+ // /topic #channel new topic text
270
272
  client.setTopic(args[0], args[1])
273
+ } else if (args.length === 1 && args[0].startsWith("#")) {
274
+ // /topic #channel — request topic from server
275
+ client.raw(`TOPIC ${args[0]}`)
271
276
  } else if (buf && args[0]) {
277
+ // /topic new topic text — set on current channel
272
278
  client.setTopic(buf.name, args[0])
279
+ } else if (buf) {
280
+ // /topic — display current topic locally
281
+ if (buf.topic) {
282
+ const setBy = buf.topicSetBy ? ` %Z565f89(set by ${buf.topicSetBy})%N` : ""
283
+ addLocalEvent(`%Z7aa2f7Topic for ${buf.name}:%N ${buf.topic}${setBy}`)
284
+ } else {
285
+ addLocalEvent(`%Z565f89No topic set for ${buf.name}%N`)
286
+ }
273
287
  }
274
288
  },
275
289
  description: "Set or view channel topic",
276
- usage: "/topic [channel] <text>",
290
+ usage: "/topic [channel] [text]",
291
+ },
292
+
293
+ names: {
294
+ handler(args, connId) {
295
+ const client = getClient(connId)
296
+ if (!client) return
297
+ const s = useStore.getState()
298
+ const channel = args[0] || getActiveChannel()
299
+ if (!channel) {
300
+ addLocalEvent(`%Zf7768eUsage: /names [channel]%N`)
301
+ return
302
+ }
303
+ // Display current nick list from buffer state
304
+ const bufferId = s.activeBufferId?.split("/")[0]
305
+ ? makeBufferId(s.activeBufferId.split("/")[0], channel)
306
+ : null
307
+ const buf = bufferId ? s.buffers.get(bufferId) : null
308
+ if (buf && buf.users.size > 0) {
309
+ const nicks: string[] = []
310
+ for (const [, entry] of buf.users) {
311
+ nicks.push(`${entry.prefix}${entry.nick}`)
312
+ }
313
+ nicks.sort((a, b) => a.replace(/^[@+%~&!]/, "").localeCompare(b.replace(/^[@+%~&!]/, "")))
314
+ addLocalEvent(`%Z7aa2f7Users on ${channel}%N [${buf.users.size}]: ${nicks.join(" ")}`)
315
+ }
316
+ // Also request fresh NAMES from server (updates nick list panel)
317
+ client.raw(`NAMES ${channel}`)
318
+ },
319
+ description: "List users in a channel",
320
+ usage: "/names [channel]",
321
+ },
322
+
323
+ invite: {
324
+ handler(args, connId) {
325
+ const client = getClient(connId)
326
+ if (!client || !args[0]) {
327
+ addLocalEvent(`%Zf7768eUsage: /invite <nick> [channel]%N`)
328
+ return
329
+ }
330
+ const nick = args[0]
331
+ const channel = args[1] || getActiveChannel()
332
+ if (!channel) {
333
+ addLocalEvent(`%Zf7768eNo active channel — specify a channel: /invite ${nick} #channel%N`)
334
+ return
335
+ }
336
+ client.raw(`INVITE ${nick} ${channel}`)
337
+ },
338
+ description: "Invite a user to a channel",
339
+ usage: "/invite <nick> [channel]",
340
+ },
341
+
342
+ version: {
343
+ handler(args, connId) {
344
+ const client = getClient(connId)
345
+ if (!client) return
346
+ if (args[0]) {
347
+ // /version <nick> — send CTCP VERSION query
348
+ client.ctcpRequest(args[0], "VERSION")
349
+ } else {
350
+ // /version — query server version
351
+ client.raw("VERSION")
352
+ }
353
+ },
354
+ description: "Query server or user client version",
355
+ usage: "/version [nick]",
356
+ aliases: ["ver"],
277
357
  },
278
358
 
279
359
  notice: {
@@ -303,13 +383,34 @@ export const commands: Record<string, CommandDef> = {
303
383
  } else if (buf.type === BufferType.Query) {
304
384
  s.removeBuffer(buf.id)
305
385
  } else if (buf.type === BufferType.Server) {
306
- addLocalEvent(`%Ze0af68Cannot close server buffer%N`)
386
+ const conn = s.connections.get(buf.connectionId)
387
+ const isDefault = buf.connectionId === "_default"
388
+ const isDisconnected = !conn || conn.status === "disconnected" || conn.status === "error"
389
+
390
+ if (!isDefault && !isDisconnected) {
391
+ addLocalEvent(`%Ze0af68Cannot close server buffer while connected. /disconnect first%N`)
392
+ return
393
+ }
394
+
395
+ // Atomically remove all buffers + connection for this server
396
+ s.closeConnection(buf.connectionId)
307
397
  }
308
398
  },
309
399
  description: "Close current buffer",
310
400
  usage: "/close [reason]",
311
401
  },
312
402
 
403
+ clear: {
404
+ handler() {
405
+ const s = useStore.getState()
406
+ if (!s.activeBufferId) return
407
+ s.clearMessages(s.activeBufferId)
408
+ addLocalEvent(`%Z6e738dBuffer cleared%N`)
409
+ },
410
+ description: "Clear current buffer's messages",
411
+ usage: "/clear",
412
+ },
413
+
313
414
  whois: {
314
415
  handler(args, connId) {
315
416
  const client = getClient(connId)
@@ -411,7 +512,7 @@ export const commands: Record<string, CommandDef> = {
411
512
  const id = args[1]?.toLowerCase()
412
513
  const addrArg = args[2]
413
514
  if (!id || !addrArg) {
414
- addLocalEvent(`%Zf7768eUsage: /server add <id> <address>[:<port>] [-tls] [-noauto] [-bind=<ip>] [-label=<name>] [-password=<pass>] [-sasl=<user>:<pass>]%N`)
515
+ addLocalEvent(`%Zf7768eUsage: /server add <id> <address>[:<port>] [-tls] [-noauto] [-bind=<ip>] [-label=<name>] [-password=<pass>] [-sasl=<user>:<pass>] [-autosendcmd=<cmds>]%N`)
415
516
  return
416
517
  }
417
518
 
@@ -435,6 +536,7 @@ export const commands: Record<string, CommandDef> = {
435
536
  let sasl_pass: string | undefined
436
537
  let label = id
437
538
  let tls_verify = true
539
+ let autosendcmd: string | undefined
438
540
 
439
541
  for (let i = 3; i < args.length; i++) {
440
542
  const a = args[i]
@@ -450,6 +552,11 @@ export const commands: Record<string, CommandDef> = {
450
552
  sasl_user = saslParts[0]
451
553
  sasl_pass = saslParts.slice(1).join(":")
452
554
  }
555
+ else if (a.startsWith("-autosendcmd=")) {
556
+ // Consumes rest of args since the value contains spaces
557
+ autosendcmd = [a.slice(13), ...args.slice(i + 1)].join(" ").replace(/^"|"$/g, "")
558
+ break
559
+ }
453
560
  }
454
561
 
455
562
  const serverConfig: ServerConfig = {
@@ -463,6 +570,7 @@ export const commands: Record<string, CommandDef> = {
463
570
  nick,
464
571
  bind_ip,
465
572
  sasl_user,
573
+ autosendcmd,
466
574
  }
467
575
 
468
576
  const s = useStore.getState()
@@ -1343,6 +1451,79 @@ export const commands: Record<string, CommandDef> = {
1343
1451
  usage: "/log [status|search] [query]",
1344
1452
  },
1345
1453
 
1454
+ preview: {
1455
+ handler(args) {
1456
+ if (!args[0]) {
1457
+ addLocalEvent(`%Zf7768eUsage: /preview <url>%N`)
1458
+ return
1459
+ }
1460
+ useStore.getState().showImagePreview(args[0])
1461
+ },
1462
+ description: "Preview an image URL in the terminal",
1463
+ usage: "/preview <url>",
1464
+ },
1465
+
1466
+ image: {
1467
+ async handler(args) {
1468
+ const sub = args[0]?.toLowerCase()
1469
+ const s = useStore.getState()
1470
+ const config = s.config?.image_preview
1471
+
1472
+ if (!sub) {
1473
+ addLocalEvent(`%Z7aa2f7───── Image Preview ─────────────────────%N`)
1474
+ addLocalEvent(` %Z565f89Status:%N ${config?.enabled ? "%Z9ece6aenabled%N" : "%Zf7768edisabled%N"}`)
1475
+ addLocalEvent(` %Z565f89Protocol:%N %Zc0caf5${config?.protocol ?? "auto"}%N`)
1476
+ addLocalEvent(` %Z565f89Cache limit:%N %Zc0caf5${config?.cache_max_mb ?? 100}MB%N`)
1477
+ addLocalEvent(`%Z7aa2f7─────────────────────────────────────────────%N`)
1478
+ addLocalEvent(` %Z565f89Commands: stats, clear%N`)
1479
+ addLocalEvent(` %Z565f89Toggle: /set image_preview.enabled true|false%N`)
1480
+ return
1481
+ }
1482
+
1483
+ if (sub === "stats") {
1484
+ const { readdir, stat } = await import("node:fs/promises")
1485
+ const { IMAGE_CACHE_DIR } = await import("@/core/constants")
1486
+ try {
1487
+ const files = await readdir(IMAGE_CACHE_DIR)
1488
+ let totalSize = 0
1489
+ for (const file of files) {
1490
+ const { join } = await import("node:path")
1491
+ const s = await stat(join(IMAGE_CACHE_DIR, file))
1492
+ totalSize += s.size
1493
+ }
1494
+ const sizeMb = (totalSize / 1024 / 1024).toFixed(1)
1495
+ addLocalEvent(`%Z7aa2f7Image cache:%N %Zc0caf5${files.length}%N files, %Zc0caf5${sizeMb}%N MB`)
1496
+ } catch {
1497
+ addLocalEvent(`%Z565f89Cache empty or not initialized%N`)
1498
+ }
1499
+ return
1500
+ }
1501
+
1502
+ if (sub === "clear") {
1503
+ const { readdir, unlink } = await import("node:fs/promises")
1504
+ const { IMAGE_CACHE_DIR } = await import("@/core/constants")
1505
+ const { join } = await import("node:path")
1506
+ try {
1507
+ const files = await readdir(IMAGE_CACHE_DIR)
1508
+ let count = 0
1509
+ for (const file of files) {
1510
+ await unlink(join(IMAGE_CACHE_DIR, file))
1511
+ count++
1512
+ }
1513
+ addLocalEvent(`%Z9ece6aCleared ${count} cached images%N`)
1514
+ } catch {
1515
+ addLocalEvent(`%Z565f89Cache already empty%N`)
1516
+ }
1517
+ return
1518
+ }
1519
+
1520
+ addLocalEvent(`%Zf7768eUnknown subcommand: /image ${sub}. Use: stats, clear%N`)
1521
+ addLocalEvent(`%Z565f89Settings via /set image_preview.<field> — enabled, protocol, max_width, etc.%N`)
1522
+ },
1523
+ description: "Image cache management (settings via /set image_preview.*)",
1524
+ usage: "/image [stats|clear]",
1525
+ },
1526
+
1346
1527
  disconnect: {
1347
1528
  handler(args, connId) {
1348
1529
  const target = args[0]?.toLowerCase()
@@ -1371,6 +1552,70 @@ export const commands: Record<string, CommandDef> = {
1371
1552
  description: "Disconnect from a server",
1372
1553
  usage: "/disconnect [server-id] [message]",
1373
1554
  },
1555
+
1556
+ quote: {
1557
+ handler(args, connId) {
1558
+ const client = getClient(connId)
1559
+ if (!client || args.length === 0) {
1560
+ addLocalEvent(`%Zf7768eUsage: /quote <raw command>%N`)
1561
+ return
1562
+ }
1563
+ client.raw(args.join(" "))
1564
+ },
1565
+ aliases: ["raw"],
1566
+ description: "Send a raw IRC command to the server",
1567
+ usage: "/quote <raw command>",
1568
+ },
1569
+
1570
+ stats: {
1571
+ handler(args, connId) {
1572
+ const client = getClient(connId)
1573
+ if (!client) return
1574
+ client.raw("STATS" + (args.length ? " " + args.join(" ") : ""))
1575
+ },
1576
+ description: "Request server statistics",
1577
+ usage: "/stats [type] [server]",
1578
+ },
1579
+
1580
+ oper: {
1581
+ handler(args, connId) {
1582
+ const client = getClient(connId)
1583
+ if (!client || args.length < 2) {
1584
+ addLocalEvent(`%Zf7768eUsage: /oper <name> <password>%N`)
1585
+ return
1586
+ }
1587
+ client.raw(`OPER ${args[0]} ${args[1]}`)
1588
+ },
1589
+ description: "Authenticate as an IRC operator",
1590
+ usage: "/oper <name> <password>",
1591
+ },
1592
+
1593
+ kill: {
1594
+ handler(args, connId) {
1595
+ const client = getClient(connId)
1596
+ if (!client || args.length === 0) {
1597
+ addLocalEvent(`%Zf7768eUsage: /kill <nick> [reason]%N`)
1598
+ return
1599
+ }
1600
+ const reason = args.slice(1).join(" ") || args[0]
1601
+ client.raw(`KILL ${args[0]} :${reason}`)
1602
+ },
1603
+ description: "Disconnect a user from the network (oper only)",
1604
+ usage: "/kill <nick> [reason]",
1605
+ },
1606
+
1607
+ wallops: {
1608
+ handler(args, connId) {
1609
+ const client = getClient(connId)
1610
+ if (!client || args.length === 0) {
1611
+ addLocalEvent(`%Zf7768eUsage: /wallops <message>%N`)
1612
+ return
1613
+ }
1614
+ client.raw(`WALLOPS :${args.join(" ")}`)
1615
+ },
1616
+ description: "Send a message to all opers (oper only)",
1617
+ usage: "/wallops <message>",
1618
+ },
1374
1619
  }
1375
1620
 
1376
1621
  // ─── Built-in Alias Resolution ────────────────────────────────
@@ -40,6 +40,17 @@ export const DEFAULT_CONFIG: AppConfig = {
40
40
  input_color: "",
41
41
  cursor_color: "",
42
42
  },
43
+ image_preview: {
44
+ enabled: true,
45
+ max_width: 0,
46
+ max_height: 0,
47
+ cache_max_mb: 100,
48
+ cache_max_days: 7,
49
+ fetch_timeout: 30,
50
+ max_file_size: 10485760,
51
+ protocol: "auto",
52
+ kitty_format: "rgba",
53
+ },
43
54
  servers: {
44
55
  ircnet: {
45
56
  label: "IRCnet",
@@ -13,6 +13,7 @@ export function cloneConfig(config: AppConfig): AppConfig {
13
13
  right: { ...config.sidepanel.right },
14
14
  },
15
15
  statusbar: { ...config.statusbar, items: [...config.statusbar.items], item_formats: { ...config.statusbar.item_formats } },
16
+ image_preview: { ...config.image_preview },
16
17
  servers: Object.fromEntries(
17
18
  Object.entries(config.servers).map(([id, srv]) => [id, { ...srv, channels: [...srv.channels] }])
18
19
  ),
@@ -42,6 +43,7 @@ export function mergeWithDefaults(partial: Record<string, any>): AppConfig {
42
43
  right: { ...DEFAULT_CONFIG.sidepanel.right, ...partial.sidepanel?.right },
43
44
  },
44
45
  statusbar: { ...DEFAULT_CONFIG.statusbar, ...partial.statusbar },
46
+ image_preview: { ...DEFAULT_CONFIG.image_preview, ...partial.image_preview },
45
47
  servers: partial.servers ?? {},
46
48
  aliases: partial.aliases ?? {},
47
49
  ignores: (partial.ignores as IgnoreEntry[] | undefined) ?? [],
@@ -120,6 +122,7 @@ function cleanServerForTOML(server: ServerConfig): Record<string, any> {
120
122
  if (server.auto_reconnect === false) obj.auto_reconnect = false
121
123
  if (server.reconnect_delay && server.reconnect_delay !== 30) obj.reconnect_delay = server.reconnect_delay
122
124
  if (server.reconnect_max_retries != null) obj.reconnect_max_retries = server.reconnect_max_retries
125
+ if (server.autosendcmd) obj.autosendcmd = server.autosendcmd
123
126
  // SASL and password stored in config only if NOT in .env — sasl_user kept, pass stripped
124
127
  if (server.sasl_user) obj.sasl_user = server.sasl_user
125
128
  // password and sasl_pass NOT saved to TOML — they go to .env
@@ -163,6 +166,8 @@ export async function saveConfig(configPath: string, config: AppConfig): Promise
163
166
  tomlObj.servers[id] = cleanServerForTOML(server)
164
167
  }
165
168
 
169
+ tomlObj.image_preview = config.image_preview
170
+
166
171
  if (config.ignores.length > 0) {
167
172
  tomlObj.ignores = config.ignores.map((e) => {
168
173
  const obj: Record<string, any> = { mask: e.mask, levels: e.levels }
@@ -16,5 +16,8 @@ export const SCRIPTS_DIR = join(HOME_DIR, "scripts")
16
16
 
17
17
  export const LOG_DB_PATH = join(HOME_DIR, "logs.db")
18
18
 
19
+ // Image cache
20
+ export const IMAGE_CACHE_DIR = join(HOME_DIR, "image_cache")
21
+
19
22
  // Default assets bundled with the package
20
23
  export const DEFAULT_THEMES_DIR = join(PKG_DIR, "themes")
@@ -0,0 +1,108 @@
1
+ import { createHash } from "crypto"
2
+ import { readdir, stat, unlink } from "node:fs/promises"
3
+ import { join, extname } from "node:path"
4
+ import { IMAGE_CACHE_DIR } from "@/core/constants"
5
+
6
+ // Magic bytes for common image formats
7
+ const MAGIC = {
8
+ jpeg: [0xff, 0xd8, 0xff],
9
+ png: [0x89, 0x50, 0x4e, 0x47],
10
+ gif: [0x47, 0x49, 0x46],
11
+ webp: [0x52, 0x49, 0x46, 0x46], // RIFF header (check WEBP at offset 8)
12
+ } as const
13
+
14
+ /** Get the cache file path for a URL */
15
+ export function getCachePath(url: string, ext?: string): string {
16
+ const hash = createHash("sha256").update(url).digest("hex")
17
+ const suffix = ext || extname(new URL(url).pathname) || ".img"
18
+ return join(IMAGE_CACHE_DIR, hash + suffix)
19
+ }
20
+
21
+ /** Check if a URL is already cached and valid. Returns path or null. */
22
+ export async function isCached(url: string): Promise<string | null> {
23
+ // Try common extensions since we may not know the original
24
+ const hash = createHash("sha256").update(url).digest("hex")
25
+ const glob = new Bun.Glob(hash + ".*")
26
+
27
+ for await (const file of glob.scan(IMAGE_CACHE_DIR)) {
28
+ const path = join(IMAGE_CACHE_DIR, file)
29
+ if (await validateImage(path)) return path
30
+ }
31
+
32
+ return null
33
+ }
34
+
35
+ /** Write image data to cache, return the path */
36
+ export async function writeCache(url: string, data: Buffer, ext?: string): Promise<string> {
37
+ const path = getCachePath(url, ext)
38
+ await Bun.write(path, data)
39
+ return path
40
+ }
41
+
42
+ /** Validate an image file by checking magic bytes */
43
+ export async function validateImage(path: string): Promise<boolean> {
44
+ const file = Bun.file(path)
45
+ if (!(await file.exists())) return false
46
+
47
+ const header = new Uint8Array(await file.slice(0, 12).arrayBuffer())
48
+ if (header.length < 3) return false
49
+
50
+ // JPEG
51
+ if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) return true
52
+ // PNG
53
+ if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4e && header[3] === 0x47) return true
54
+ // GIF
55
+ if (header[0] === 0x47 && header[1] === 0x49 && header[2] === 0x46) return true
56
+ // WEBP (RIFF....WEBP)
57
+ if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46 &&
58
+ header[8] === 0x57 && header[9] === 0x45 && header[10] === 0x42 && header[11] === 0x50) return true
59
+
60
+ return false
61
+ }
62
+
63
+ /** Clean up old cache files based on size and age limits */
64
+ export async function cleanupCache(maxMb: number, maxDays: number): Promise<void> {
65
+ const entries: Array<{ path: string; size: number; mtimeMs: number }> = []
66
+ const maxAgeMs = maxDays * 24 * 60 * 60 * 1000
67
+ const now = Date.now()
68
+
69
+ let files: string[]
70
+ try {
71
+ files = await readdir(IMAGE_CACHE_DIR)
72
+ } catch {
73
+ return
74
+ }
75
+
76
+ for (const file of files) {
77
+ const path = join(IMAGE_CACHE_DIR, file)
78
+ try {
79
+ const s = await stat(path)
80
+ if (!s.isFile()) continue
81
+
82
+ // Remove files older than maxDays
83
+ if (now - s.mtimeMs > maxAgeMs) {
84
+ await unlink(path)
85
+ continue
86
+ }
87
+
88
+ entries.push({ path, size: s.size, mtimeMs: s.mtimeMs })
89
+ } catch {
90
+ // Skip files we can't stat
91
+ }
92
+ }
93
+
94
+ // Sort oldest first, remove until under size limit
95
+ entries.sort((a, b) => a.mtimeMs - b.mtimeMs)
96
+ const maxBytes = maxMb * 1024 * 1024
97
+ let totalSize = entries.reduce((sum, e) => sum + e.size, 0)
98
+
99
+ for (const entry of entries) {
100
+ if (totalSize <= maxBytes) break
101
+ try {
102
+ await unlink(entry.path)
103
+ totalSize -= entry.size
104
+ } catch {
105
+ // Skip
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,105 @@
1
+ export type ImageProtocol = "kitty" | "iterm2" | "sixel" | "symbols"
2
+
3
+ export function isInsideTmux(): boolean {
4
+ return !!process.env.TMUX
5
+ }
6
+
7
+ /** Get the tmux pane's absolute position on the outer terminal screen */
8
+ export function getTmuxPaneOffset(): { top: number; left: number } {
9
+ try {
10
+ const result = Bun.spawnSync(["tmux", "display-message", "-p", "#{pane_top}:#{pane_left}"])
11
+ const output = new TextDecoder().decode(result.stdout).trim()
12
+ const [top, left] = output.split(":").map(Number)
13
+ if (!isNaN(top) && !isNaN(left)) return { top, left }
14
+ } catch {}
15
+ return { top: 0, left: 0 }
16
+ }
17
+
18
+ /** Match a terminal identifier string to a protocol.
19
+ * Works with both #{client_termtype} (e.g. "iTerm2 3.6.8", "ghostty 1.3.0")
20
+ * and #{client_termname} (e.g. "xterm-ghostty", "xterm-kitty").
21
+ * iterm FIRST, then kitty family, then sixel-capable terminals. */
22
+ function matchTermName(name: string): ImageProtocol | null {
23
+ const t = name.toLowerCase()
24
+
25
+ // iTerm2 — "iTerm2 3.6.8" from termtype, or "iterm2" from termname
26
+ if (t.includes("iterm")) return "iterm2"
27
+
28
+ // Kitty protocol family
29
+ if (t.includes("kitty")) return "kitty"
30
+ if (t.includes("ghostty")) return "kitty"
31
+ if (t.includes("wezterm")) return "kitty"
32
+ if (t.includes("rio")) return "kitty"
33
+ // Subterm: "subterm x.x.x" from termtype, LC_TERMINAL=Subterm without tmux
34
+ if (t.includes("subterm")) return "kitty"
35
+
36
+ // Sixel-capable terminals
37
+ if (t.includes("foot")) return "sixel"
38
+ if (t.includes("contour")) return "sixel"
39
+ if (t.includes("konsole")) return "sixel"
40
+ if (t.includes("mintty")) return "sixel"
41
+ if (t.includes("mlterm")) return "sixel"
42
+ if (t.includes("xterm")) return "sixel"
43
+
44
+ return null
45
+ }
46
+
47
+ /** Detect the best image display protocol for the current terminal.
48
+ * Returns [protocol, detectedName] — detectedName is the raw string that matched.
49
+ * In tmux: queries #{client_termtype} first (returns real terminal identity
50
+ * like "iTerm2 3.6.8", "ghostty 1.3.0"), then #{client_termname} as fallback.
51
+ * Outside tmux: checks env vars, then generic TERM. */
52
+ export function detectProtocol(configOverride?: string): [ImageProtocol, string] {
53
+ if (configOverride && configOverride !== "auto") {
54
+ return [configOverride as ImageProtocol, `config:${configOverride}`]
55
+ }
56
+
57
+ const inTmux = isInsideTmux()
58
+
59
+ // ─── tmux: query the REAL outer terminal ───────────────────
60
+ if (inTmux) {
61
+ // #{client_termtype} returns the actual terminal identity
62
+ // (e.g. "iTerm2 3.6.8", "ghostty 1.3.0-main+...", "subterm 1.0")
63
+ // unlike #{client_termname} which returns generic "xterm-256color" for iTerm2
64
+ try {
65
+ const result = Bun.spawnSync(["tmux", "display-message", "-p", "#{client_termtype}"])
66
+ const termType = new TextDecoder().decode(result.stdout).trim()
67
+ if (termType) {
68
+ const match = matchTermName(termType)
69
+ if (match) return [match, termType]
70
+ }
71
+ } catch {}
72
+
73
+ // Fallback: #{client_termname} (works for ghostty "xterm-ghostty", kitty "xterm-kitty")
74
+ try {
75
+ const result = Bun.spawnSync(["tmux", "display-message", "-p", "#{client_termname}"])
76
+ const termName = new TextDecoder().decode(result.stdout).trim()
77
+ if (termName) {
78
+ const match = matchTermName(termName)
79
+ if (match) return [match, termName]
80
+ }
81
+ } catch {}
82
+ }
83
+
84
+ // ─── env var detection (non-tmux, or tmux query returned unknown) ──
85
+ const termProgram = (process.env.TERM_PROGRAM ?? "").toLowerCase()
86
+ const lcTerminal = (process.env.LC_TERMINAL ?? "").toLowerCase()
87
+
88
+ if (termProgram === "iterm.app" || termProgram === "iterm2" || lcTerminal === "iterm2") {
89
+ return ["iterm2", `TERM_PROGRAM=${process.env.TERM_PROGRAM ?? lcTerminal}`]
90
+ }
91
+ if (lcTerminal === "subterm") return ["kitty", `LC_TERMINAL=${process.env.LC_TERMINAL}`]
92
+ if (termProgram === "wezterm") return ["kitty", `TERM_PROGRAM=${process.env.TERM_PROGRAM}`]
93
+ if (termProgram === "rio") return ["kitty", `TERM_PROGRAM=${process.env.TERM_PROGRAM}`]
94
+ if (termProgram === "mintty") return ["sixel", `TERM_PROGRAM=${process.env.TERM_PROGRAM}`]
95
+ if (process.env.KITTY_PID) return ["kitty", `KITTY_PID=${process.env.KITTY_PID}`]
96
+ if (process.env.GHOSTTY_RESOURCES_DIR) return ["kitty", "GHOSTTY_RESOURCES_DIR"]
97
+ if (process.env.WT_SESSION) return ["sixel", "WT_SESSION"]
98
+
99
+ // Generic TERM value — last resort
100
+ const term = (process.env.TERM ?? "").toLowerCase()
101
+ const termMatch = matchTermName(term)
102
+ if (termMatch) return [termMatch, `TERM=${process.env.TERM}`]
103
+
104
+ return ["symbols", "unknown"]
105
+ }