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
@@ -1,8 +1,20 @@
1
1
  import { create } from "zustand"
2
- import type { Connection, Buffer, Message, NickEntry, ActivityLevel, ListEntry, ListModeKey } from "@/types"
2
+ import { BufferType, ActivityLevel, makeBufferId } from "@/types"
3
+ import type { Connection, Buffer, Message, NickEntry, ListEntry, ListModeKey } from "@/types"
3
4
  import type { AppConfig } from "@/types/config"
4
5
  import type { ThemeFile } from "@/types/theme"
5
6
  import { logMessage, updateReadMarker } from "@/core/storage"
7
+ import { nextMsgId } from "@/core/utils/id"
8
+
9
+ export interface ImagePreviewState {
10
+ url: string
11
+ status: "loading" | "ready" | "error"
12
+ error?: string
13
+ width: number
14
+ height: number
15
+ title?: string
16
+ protocol?: string // needed for cleanup on dismiss
17
+ }
6
18
 
7
19
  interface AppState {
8
20
  // Data
@@ -13,6 +25,12 @@ interface AppState {
13
25
  config: AppConfig | null
14
26
  theme: ThemeFile | null
15
27
 
28
+ // Image preview
29
+ imagePreview: ImagePreviewState | null
30
+ showImagePreview: (url: string) => void
31
+ updateImagePreview: (updates: Partial<ImagePreviewState>) => void
32
+ hideImagePreview: () => void
33
+
16
34
  // Connection actions
17
35
  addConnection: (conn: Connection) => void
18
36
  updateConnection: (id: string, updates: Partial<Connection>) => void
@@ -21,11 +39,13 @@ interface AppState {
21
39
  // Buffer actions
22
40
  addBuffer: (buffer: Buffer) => void
23
41
  removeBuffer: (id: string) => void
42
+ closeConnection: (connectionId: string) => void
24
43
  setActiveBuffer: (id: string) => void
25
44
  updateBufferActivity: (id: string, level: ActivityLevel) => void
26
45
 
27
46
  // Message actions
28
47
  addMessage: (bufferId: string, message: Message) => void
48
+ clearMessages: (bufferId: string) => void
29
49
 
30
50
  // Nicklist actions
31
51
  addNick: (bufferId: string, entry: NickEntry) => void
@@ -41,6 +61,12 @@ interface AppState {
41
61
  addListEntry: (bufferId: string, modeChar: ListModeKey, entry: ListEntry) => void
42
62
  removeListEntry: (bufferId: string, modeChar: ListModeKey, mask: string) => void
43
63
 
64
+ // Batch actions (single set() for N mutations)
65
+ batchRemoveNick: (entries: Array<{ bufferId: string; nick: string }>) => void
66
+ batchAddNick: (entries: Array<{ bufferId: string; entry: NickEntry }>) => void
67
+ batchUpdateNick: (entries: Array<{ bufferId: string; oldNick: string; newNick: string; prefix?: string }>) => void
68
+ batchAddMessage: (entries: Array<{ bufferId: string; message: Message }>) => void
69
+
44
70
  // Config/Theme
45
71
  setConfig: (config: AppConfig) => void
46
72
  setTheme: (theme: ThemeFile) => void
@@ -59,6 +85,96 @@ export const useStore = create<AppState>((set, get) => ({
59
85
  config: null,
60
86
  theme: null,
61
87
 
88
+ // Image preview
89
+ imagePreview: null,
90
+ showImagePreview: (url) => {
91
+ // Concurrency guard — ignore if already loading
92
+ const current = get().imagePreview
93
+ if (current?.status === "loading") return
94
+
95
+ set({
96
+ imagePreview: { url, status: "loading", width: 0, height: 0 },
97
+ })
98
+ // Kick off the async render pipeline
99
+ import("@/core/image-preview/render").then(({ preparePreview }) => {
100
+ preparePreview(url)
101
+ }).catch((err) => {
102
+ const s = get()
103
+ const buf = s.activeBufferId
104
+ if (buf) {
105
+ s.addMessage(buf, {
106
+ id: nextMsgId(),
107
+ timestamp: new Date(),
108
+ type: "event",
109
+ text: `%Zf7768e[img] import failed: ${err.message}%N`,
110
+ highlight: false,
111
+ })
112
+ }
113
+ set({ imagePreview: null })
114
+ })
115
+ },
116
+ updateImagePreview: (updates) => set((s) => {
117
+ if (!s.imagePreview) return s
118
+ return { imagePreview: { ...s.imagePreview, ...updates } }
119
+ }),
120
+ hideImagePreview: () => {
121
+ const prev = get().imagePreview
122
+ if (!prev) { set({ imagePreview: null }); return }
123
+
124
+ try {
125
+ const { writeSync } = require("node:fs")
126
+ const { flushStdin } = require("@/core/image-preview/stdin-guard")
127
+ const inTmux = !!process.env.TMUX
128
+
129
+ // Disable mouse tracking + flush kernel input buffer.
130
+ // Do NOT call process.stdin.pause()/resume() — triggers Bun malloc crash.
131
+ writeSync(1, "\x1b[?1003l\x1b[?1006l\x1b[?1002l\x1b[?1000l")
132
+ flushStdin()
133
+
134
+ if (prev.protocol === "kitty") {
135
+ // Kitty: image is on separate graphics layer — delete command removes it
136
+ const deleteCmd = "\x1b_Ga=d,q=2\x1b\\"
137
+ if (inTmux) {
138
+ const escaped = deleteCmd.replace(/\x1b/g, "\x1b\x1b")
139
+ writeSync(1, `\x1bPtmux;${escaped}\x1b\\`)
140
+ } else {
141
+ writeSync(1, deleteCmd)
142
+ }
143
+ } else {
144
+ // iTerm2/Sixel/Symbols: image is in the cell buffer — overwrite with
145
+ // spaces using theme bg color. OpenTUI's diff renderer skips empty
146
+ // cells (they look "unchanged"), so we must fill them with the correct
147
+ // bg to avoid a default-bg hole where the image was.
148
+ const termCols = process.stdout.columns || 80
149
+ const termRows = process.stdout.rows || 24
150
+ const popupW = prev.width || 0
151
+ const popupH = prev.height || 0
152
+ if (popupW > 0 && popupH > 0) {
153
+ const left = Math.max(0, Math.floor((termCols - popupW) / 2))
154
+ const top = Math.max(0, Math.floor((termRows - popupH) / 2))
155
+ // Use theme bg color for spaces so empty cells match the UI
156
+ const theme = get().theme
157
+ const hex = theme?.colors?.bg ?? "#1a1b26"
158
+ const r = parseInt(hex.slice(1, 3), 16)
159
+ const g = parseInt(hex.slice(3, 5), 16)
160
+ const b = parseInt(hex.slice(5, 7), 16)
161
+ const bgSeq = `\x1b[48;2;${r};${g};${b}m`
162
+ const blankLine = bgSeq + " ".repeat(popupW) + "\x1b[0m"
163
+ writeSync(1, "\x1b7") // save cursor
164
+ for (let row = 0; row < popupH; row++) {
165
+ writeSync(1, `\x1b[${top + row + 1};${left + 1}H${blankLine}`)
166
+ }
167
+ writeSync(1, "\x1b8") // restore cursor
168
+ }
169
+ }
170
+
171
+ flushStdin()
172
+ writeSync(1, "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h")
173
+ } catch {}
174
+
175
+ set({ imagePreview: null })
176
+ },
177
+
62
178
  addConnection: (conn) => set((s) => {
63
179
  const connections = new Map(s.connections)
64
180
  connections.set(conn.id, conn)
@@ -87,13 +203,82 @@ export const useStore = create<AppState>((set, get) => ({
87
203
  removeBuffer: (id) => set((s) => {
88
204
  const buffers = new Map(s.buffers)
89
205
  buffers.delete(id)
206
+
207
+ // If no buffers left, recreate the default welcome state
208
+ if (buffers.size === 0) {
209
+ const defaultId = makeBufferId("_default", "Status")
210
+ buffers.set(defaultId, {
211
+ id: defaultId,
212
+ connectionId: "_default",
213
+ type: BufferType.Server,
214
+ name: "Status",
215
+ messages: [{
216
+ id: nextMsgId(),
217
+ timestamp: new Date(),
218
+ type: "event" as const,
219
+ text: "Welcome to kokoIRC. Type /connect to connect to a server.",
220
+ highlight: false,
221
+ }],
222
+ activity: ActivityLevel.None,
223
+ unreadCount: 0,
224
+ lastRead: new Date(),
225
+ users: new Map(),
226
+ listModes: new Map(),
227
+ })
228
+ return { buffers, activeBufferId: defaultId, previousActiveBufferId: null }
229
+ }
230
+
90
231
  if (s.activeBufferId !== id) return { buffers }
91
- // Fall back to previous buffer if it still exists, otherwise null
232
+ // Fall back to previous buffer if it still exists, otherwise first available
92
233
  const fallback = s.previousActiveBufferId && buffers.has(s.previousActiveBufferId)
93
- ? s.previousActiveBufferId : null
234
+ ? s.previousActiveBufferId : buffers.keys().next().value ?? null
94
235
  return { buffers, activeBufferId: fallback }
95
236
  }),
96
237
 
238
+ closeConnection: (connectionId) => set((s) => {
239
+ const buffers = new Map(s.buffers)
240
+ const connections = new Map(s.connections)
241
+
242
+ // Remove all buffers for this connection
243
+ for (const [id, buf] of buffers) {
244
+ if (buf.connectionId === connectionId) buffers.delete(id)
245
+ }
246
+ // Remove the connection entry
247
+ connections.delete(connectionId)
248
+
249
+ // If no buffers left, recreate the default welcome state
250
+ if (buffers.size === 0) {
251
+ const defaultId = makeBufferId("_default", "Status")
252
+ buffers.set(defaultId, {
253
+ id: defaultId,
254
+ connectionId: "_default",
255
+ type: BufferType.Server,
256
+ name: "Status",
257
+ messages: [{
258
+ id: nextMsgId(),
259
+ timestamp: new Date(),
260
+ type: "event" as const,
261
+ text: "Welcome to kokoIRC. Type /connect to connect to a server.",
262
+ highlight: false,
263
+ }],
264
+ activity: ActivityLevel.None,
265
+ unreadCount: 0,
266
+ lastRead: new Date(),
267
+ users: new Map(),
268
+ listModes: new Map(),
269
+ })
270
+ return { buffers, connections, activeBufferId: defaultId, previousActiveBufferId: null }
271
+ }
272
+
273
+ // If active buffer was removed, fall back
274
+ const needsFallback = !buffers.has(s.activeBufferId ?? "")
275
+ const fallback = needsFallback
276
+ ? (s.previousActiveBufferId && buffers.has(s.previousActiveBufferId)
277
+ ? s.previousActiveBufferId : buffers.keys().next().value ?? null)
278
+ : s.activeBufferId
279
+ return { buffers, connections, activeBufferId: fallback }
280
+ }),
281
+
97
282
  setActiveBuffer: (id) => {
98
283
  // Persist read marker for TUI client
99
284
  const slashIdx = id.indexOf("/")
@@ -145,6 +330,14 @@ export const useStore = create<AppState>((set, get) => ({
145
330
  })
146
331
  },
147
332
 
333
+ clearMessages: (bufferId) => set((s) => {
334
+ const buffers = new Map(s.buffers)
335
+ const buf = buffers.get(bufferId)
336
+ if (!buf) return s
337
+ buffers.set(bufferId, { ...buf, messages: [] })
338
+ return { buffers }
339
+ }),
340
+
148
341
  addNick: (bufferId, entry) => set((s) => {
149
342
  const buffers = new Map(s.buffers)
150
343
  const buf = buffers.get(bufferId)
@@ -230,6 +423,71 @@ export const useStore = create<AppState>((set, get) => ({
230
423
  return { buffers }
231
424
  }),
232
425
 
426
+ batchRemoveNick: (entries) => set((s) => {
427
+ const buffers = new Map(s.buffers)
428
+ for (const { bufferId, nick } of entries) {
429
+ const buf = buffers.get(bufferId)
430
+ if (!buf) continue
431
+ const users = new Map(buf.users)
432
+ users.delete(nick)
433
+ buffers.set(bufferId, { ...buf, users })
434
+ }
435
+ return { buffers }
436
+ }),
437
+
438
+ batchAddNick: (entries) => set((s) => {
439
+ const buffers = new Map(s.buffers)
440
+ for (const { bufferId, entry } of entries) {
441
+ const buf = buffers.get(bufferId)
442
+ if (!buf) continue
443
+ const users = new Map(buf.users)
444
+ users.set(entry.nick, entry)
445
+ buffers.set(bufferId, { ...buf, users })
446
+ }
447
+ return { buffers }
448
+ }),
449
+
450
+ batchUpdateNick: (entries) => set((s) => {
451
+ const buffers = new Map(s.buffers)
452
+ for (const { bufferId, oldNick, newNick, prefix } of entries) {
453
+ const buf = buffers.get(bufferId)
454
+ if (!buf) continue
455
+ const users = new Map(buf.users)
456
+ const existing = users.get(oldNick)
457
+ if (existing) {
458
+ users.delete(oldNick)
459
+ users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
460
+ }
461
+ buffers.set(bufferId, { ...buf, users })
462
+ }
463
+ return { buffers }
464
+ }),
465
+
466
+ batchAddMessage: (entries) => {
467
+ // Log each message to persistent storage before the set() call
468
+ for (const { bufferId, message } of entries) {
469
+ const slashIdx = bufferId.indexOf("/")
470
+ if (slashIdx > 0) {
471
+ const network = bufferId.slice(0, slashIdx)
472
+ const buffer = bufferId.slice(slashIdx + 1)
473
+ logMessage(network, buffer, message.id, message.type, message.text, message.nick ?? null, message.highlight, message.timestamp)
474
+ }
475
+ }
476
+
477
+ return set((s) => {
478
+ const buffers = new Map(s.buffers)
479
+ const maxLines = s.config?.display.scrollback_lines ?? 2000
480
+ for (const { bufferId, message } of entries) {
481
+ const buf = buffers.get(bufferId)
482
+ if (!buf) continue
483
+ const messages = [...buf.messages, message]
484
+ if (messages.length > maxLines) messages.splice(0, messages.length - maxLines)
485
+ buffers.set(bufferId, { ...buf, messages })
486
+ }
487
+ return { buffers }
488
+ })
489
+ },
490
+
233
491
  setConfig: (config) => set({ config }),
234
492
  setTheme: (theme) => set({ theme }),
235
493
 
@@ -35,7 +35,7 @@ export async function initStorage(config: LoggingConfig): Promise<void> {
35
35
  export function logMessage(
36
36
  network: string,
37
37
  buffer: string,
38
- msgId: string,
38
+ msgId: string | number,
39
39
  type: MessageType,
40
40
  text: string,
41
41
  nick: string | null,
@@ -45,7 +45,7 @@ export function logMessage(
45
45
  if (!writer) return
46
46
 
47
47
  const row: LogRow = {
48
- msg_id: msgId,
48
+ msg_id: String(msgId),
49
49
  network,
50
50
  buffer,
51
51
  timestamp: timestamp.getTime(),
@@ -14,6 +14,8 @@ export class LogWriter {
14
14
  private cryptoKey: CryptoKey | null = null
15
15
  private hasFts: boolean
16
16
  private listeners: MessageListener[] = []
17
+ private insertStmt: ReturnType<Database["prepare"]> | null = null
18
+ private insertFtsStmt: ReturnType<Database["prepare"]> | null = null
17
19
 
18
20
  constructor(db: Database, config: LoggingConfig) {
19
21
  this.db = db
@@ -25,6 +27,14 @@ export class LogWriter {
25
27
  if (this.config.encrypt) {
26
28
  this.cryptoKey = await loadOrCreateKey()
27
29
  }
30
+ this.insertStmt = this.db.prepare(
31
+ "INSERT INTO messages (msg_id, network, buffer, timestamp, type, nick, text, highlight, iv) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
32
+ )
33
+ if (this.hasFts) {
34
+ this.insertFtsStmt = this.db.prepare(
35
+ "INSERT INTO messages_fts (rowid, nick, text) VALUES (?, ?, ?)"
36
+ )
37
+ }
28
38
  }
29
39
 
30
40
  /** Subscribe to new messages (for WebSocket real-time push). */
@@ -70,16 +80,8 @@ export class LogWriter {
70
80
  this.flushing = true
71
81
  const batch = this.queue.splice(0)
72
82
 
73
- const insert = this.db.prepare(
74
- "INSERT INTO messages (msg_id, network, buffer, timestamp, type, nick, text, highlight, iv) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
75
- )
76
-
77
- // Prepare FTS insert if available
78
- const insertFts = this.hasFts
79
- ? this.db.prepare(
80
- "INSERT INTO messages_fts (rowid, nick, text) VALUES (?, ?, ?)"
81
- )
82
- : null
83
+ const insert = this.insertStmt!
84
+ const insertFts = this.insertFtsStmt
83
85
 
84
86
  try {
85
87
  // bun:sqlite transactions are sync, but encrypt is async — handle both modes
@@ -1,15 +1,43 @@
1
1
  import { TextAttributes } from "@opentui/core"
2
2
  import type { StyledSpan } from "@/types/theme"
3
3
 
4
+ const URL_RE = /https?:\/\/[^\s<>"')\]]+/gi
5
+
4
6
  interface Props {
5
7
  spans: StyledSpan[]
6
8
  }
7
9
 
10
+ /** Split text into segments, wrapping URLs in <a href> for OSC 8 hyperlinks */
11
+ function linkify(text: string): React.ReactNode[] {
12
+ const parts: React.ReactNode[] = []
13
+ let lastIndex = 0
14
+ URL_RE.lastIndex = 0
15
+
16
+ let match: RegExpExecArray | null
17
+ while ((match = URL_RE.exec(text)) !== null) {
18
+ if (match.index > lastIndex) {
19
+ parts.push(text.slice(lastIndex, match.index))
20
+ }
21
+ const url = match[0]
22
+ parts.push(<a key={match.index} href={url}>{url}</a>)
23
+ lastIndex = match.index + url.length
24
+ }
25
+
26
+ if (lastIndex < text.length) {
27
+ parts.push(text.slice(lastIndex))
28
+ }
29
+
30
+ return parts.length > 0 ? parts : [text]
31
+ }
32
+
8
33
  export function StyledText({ spans }: Props) {
9
34
  return (
10
35
  <text>
11
36
  {spans.map((span, i) => {
12
- let content: any = span.text
37
+ let content: any = linkify(span.text)
38
+ if (content.length === 1 && typeof content[0] === "string") {
39
+ content = content[0]
40
+ }
13
41
  if (span.bold) content = <strong>{content}</strong>
14
42
  if (span.italic) content = <em>{content}</em>
15
43
  if (span.underline) content = <u>{content}</u>
@@ -0,0 +1,2 @@
1
+ let counter = 0
2
+ export function nextMsgId(): number { return ++counter }
@@ -63,11 +63,24 @@ export interface ScriptsConfig {
63
63
  [scriptName: string]: any // per-script config: [scripts.my-script]
64
64
  }
65
65
 
66
+ export interface ImagePreviewConfig {
67
+ enabled: boolean
68
+ max_width: number // max popup width in columns (0 = auto ~60% of terminal)
69
+ max_height: number // max popup height in rows (0 = auto ~60% of terminal)
70
+ cache_max_mb: number // disk cache limit (default: 100)
71
+ cache_max_days: number // max age before cleanup (default: 7)
72
+ fetch_timeout: number // seconds (default: 30)
73
+ max_file_size: number // max download bytes (default: 10MB)
74
+ protocol: string // "auto" | "kitty" | "iterm2" | "sixel" | "symbols"
75
+ kitty_format: string // "rgba" | "png" — kitty protocol pixel format (default: "rgba")
76
+ }
77
+
66
78
  export interface AppConfig {
67
79
  general: GeneralConfig
68
80
  display: DisplayConfig
69
81
  sidepanel: SidepanelConfig
70
82
  statusbar: StatusbarConfig
83
+ image_preview: ImagePreviewConfig
71
84
  servers: Record<string, ServerConfig>
72
85
  aliases: Record<string, string>
73
86
  ignores: IgnoreEntry[]
@@ -123,4 +136,5 @@ export interface ServerConfig {
123
136
  auto_reconnect?: boolean // auto reconnect on disconnect (default: true)
124
137
  reconnect_delay?: number // seconds between reconnect attempts (default: 30)
125
138
  reconnect_max_retries?: number // max reconnect attempts (default: 10)
139
+ autosendcmd?: string // commands to run on connect, before autojoin (;-separated, WAIT <ms> for delays)
126
140
  }
@@ -59,14 +59,13 @@ export interface Buffer {
59
59
  export type MessageType = 'message' | 'action' | 'event' | 'notice' | 'ctcp'
60
60
 
61
61
  export interface Message {
62
- id: string
62
+ id: number
63
63
  timestamp: Date
64
64
  type: MessageType
65
65
  nick?: string
66
66
  nickMode?: string
67
67
  text: string
68
68
  highlight: boolean
69
- tags?: Record<string, string>
70
69
  eventKey?: string
71
70
  eventParams?: string[]
72
71
  }
@@ -4,15 +4,21 @@ import { MessageLine } from "./MessageLine"
4
4
  import type { ScrollBoxRenderable } from "@opentui/core"
5
5
 
6
6
  export function ChatView() {
7
+ const buffer = useStore((s) => {
8
+ const id = s.activeBufferId
9
+ return id ? s.buffers.get(id) ?? null : null
10
+ })
7
11
  const activeBufferId = useStore((s) => s.activeBufferId)
8
- const buffersMap = useStore((s) => s.buffers)
9
- const connectionsMap = useStore((s) => s.connections)
12
+ const currentNick = useStore((s) => {
13
+ const id = s.activeBufferId
14
+ if (!id) return ""
15
+ const buf = s.buffers.get(id)
16
+ if (!buf) return ""
17
+ return s.connections.get(buf.connectionId)?.nick ?? ""
18
+ })
10
19
  const colors = useStore((s) => s.theme?.colors)
11
20
  const scrollRef = useRef<ScrollBoxRenderable>(null)
12
21
 
13
- const buffer = activeBufferId ? buffersMap.get(activeBufferId) ?? null : null
14
- const currentNick = buffer ? connectionsMap.get(buffer.connectionId)?.nick ?? "" : ""
15
-
16
22
  // Snap to bottom when switching buffers
17
23
  useEffect(() => {
18
24
  if (scrollRef.current) {
@@ -1,9 +1,12 @@
1
1
  import { useStore } from "@/core/state/store"
2
2
  import { resolveAbstractions, parseFormatString, StyledText } from "@/core/theme"
3
3
  import { formatTimestamp } from "@/core/irc/formatting"
4
+ import { classifyUrl } from "@/core/image-preview/fetch"
4
5
  import type { Message } from "@/types"
5
6
  import type { StyledSpan } from "@/types/theme"
6
7
 
8
+ const URL_RE = /https?:\/\/[^\s<>"')\]]+/gi
9
+
7
10
  interface Props {
8
11
  message: Message
9
12
  isOwnNick: boolean
@@ -84,8 +87,22 @@ export function MessageLine({ message, isOwnNick }: Props) {
84
87
  const separator: StyledSpan = { text: " ", bold: false, italic: false, underline: false, dim: false }
85
88
  const allSpans = [...tsSpans, separator, ...msgSpans]
86
89
 
90
+ // Click any URL in the message to attempt image preview (erssi-style content-type sniffing)
91
+ const handleClick = () => {
92
+ const text = message.text
93
+ const urls = text.match(URL_RE)
94
+ if (!urls) return
95
+
96
+ for (const url of urls) {
97
+ if (classifyUrl(url)) {
98
+ useStore.getState().showImagePreview(url)
99
+ return
100
+ }
101
+ }
102
+ }
103
+
87
104
  return (
88
- <box width="100%">
105
+ <box width="100%" onMouseDown={handleClick}>
89
106
  <StyledText spans={allSpans} />
90
107
  </box>
91
108
  )
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from "react"
2
2
  import { useStore } from "@/core/state/store"
3
3
  import { parseCommand, executeCommand, getCommandNames, getSubcommands } from "@/core/commands"
4
4
  import { getClient } from "@/core/irc"
5
+ import { nextMsgId } from "@/core/utils/id"
5
6
  import { useKeyboard, useRenderer } from "@opentui/react"
6
7
  import { useStatusbarColors } from "@/ui/hooks/useStatusbarColors"
7
8
  import type { InputRenderable } from "@opentui/core"
@@ -75,7 +76,7 @@ export function CommandInput() {
75
76
  client.say(buffer.name, trimmed)
76
77
  const conn = useStore.getState().connections.get(buffer.connectionId)
77
78
  addMessage(buffer.id, {
78
- id: crypto.randomUUID(),
79
+ id: nextMsgId(),
79
80
  timestamp: new Date(),
80
81
  type: "message",
81
82
  nick: conn?.nick ?? "",
@@ -15,9 +15,10 @@ interface Props {
15
15
  input: React.ReactNode
16
16
  topicbar: React.ReactNode
17
17
  statusline?: React.ReactNode
18
+ overlay?: React.ReactNode
18
19
  }
19
20
 
20
- export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline }: Props) {
21
+ export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline, overlay }: Props) {
21
22
  const config = useStore((s) => s.config)
22
23
  const colors = useStore((s) => s.theme?.colors)
23
24
  const buffer = useStore((s) => s.activeBufferId ? s.buffers.get(s.activeBufferId) : null)
@@ -114,6 +115,7 @@ export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline
114
115
  onMouseDragEnd={endDrag}
115
116
  />
116
117
  )}
118
+ {overlay}
117
119
  </box>
118
120
 
119
121
  {/* Status line + Input area — shared background from config */}
@@ -0,0 +1,77 @@
1
+ import { useMemo } from "react"
2
+ import { useStore } from "@/core/state/store"
3
+
4
+ export function ImagePreview() {
5
+ const preview = useStore((s) => s.imagePreview)
6
+ const hideImagePreview = useStore((s) => s.hideImagePreview)
7
+ const theme = useStore((s) => s.theme?.colors)
8
+
9
+ const termCols = process.stdout.columns || 80
10
+ const termRows = process.stdout.rows || 24
11
+
12
+ const layout = useMemo(() => {
13
+ if (!preview) return null
14
+ const popupWidth = Math.max(preview.width, 20)
15
+ const popupHeight = Math.max(preview.height, 5)
16
+ const left = Math.max(0, Math.floor((termCols - popupWidth) / 2))
17
+ const top = Math.max(0, Math.floor((termRows - popupHeight) / 2))
18
+ return { popupWidth, popupHeight, left, top }
19
+ }, [preview?.width, preview?.height, termCols, termRows])
20
+
21
+ if (!preview || !layout) return null
22
+
23
+ const bg = theme?.bg ?? "#1a1b26"
24
+ const accent = theme?.accent ?? "#7aa2f7"
25
+ const muted = theme?.fg_muted ?? "#565f89"
26
+
27
+ const title = preview.title
28
+ ? ` ${preview.title.slice(0, layout.popupWidth - 4)} `
29
+ : " Preview "
30
+
31
+ let statusText: React.ReactNode = null
32
+ if (preview.status === "loading") {
33
+ statusText = <text><span fg={muted}>Loading image...</span></text>
34
+ } else if (preview.status === "error") {
35
+ statusText = <text><span fg="#f7768e">{preview.error ?? "Error"}</span></text>
36
+ }
37
+
38
+ return (
39
+ <>
40
+ {/* Full-screen transparent backdrop — click anywhere to dismiss */}
41
+ <box
42
+ position="absolute"
43
+ left={0}
44
+ top={0}
45
+ width="100%"
46
+ height="100%"
47
+ onMouseDown={() => hideImagePreview()}
48
+ />
49
+ {/* Centered popup */}
50
+ <box
51
+ position="absolute"
52
+ left={layout.left}
53
+ top={layout.top}
54
+ width={layout.popupWidth}
55
+ height={layout.popupHeight}
56
+ border={["top", "bottom", "left", "right"]}
57
+ borderStyle="single"
58
+ borderColor={accent}
59
+ backgroundColor={bg}
60
+ onMouseDown={() => hideImagePreview()}
61
+ >
62
+ <box height={1} width="100%">
63
+ <text>
64
+ <span fg={accent}>{title}</span>
65
+ <span fg={muted}> [click/key to close]</span>
66
+ </text>
67
+ </box>
68
+
69
+ {statusText && (
70
+ <box width="100%" flexGrow={1} justifyContent="center" alignItems="center">
71
+ {statusText}
72
+ </box>
73
+ )}
74
+ </box>
75
+ </>
76
+ )
77
+ }