kokoirc 0.2.4 → 0.2.6

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 CHANGED
@@ -60,7 +60,7 @@ bun run start
60
60
 
61
61
  # Or build a standalone binary
62
62
  bun run build
63
- ./openirc
63
+ ./kokoirc
64
64
  ```
65
65
 
66
66
  ## Configuration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kokoirc",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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": {
@@ -56,7 +56,7 @@
56
56
  "start": "bun run src/index.tsx",
57
57
  "dev": "bun --watch run src/index.tsx",
58
58
  "test": "bun test",
59
- "build": "bun build --compile src/index.tsx --outfile openirc",
59
+ "build": "bun build --compile src/index.tsx --outfile kokoirc",
60
60
  "docs:build": "bun run docs/build.ts"
61
61
  },
62
62
  "devDependencies": {
package/src/app/App.tsx CHANGED
@@ -35,7 +35,10 @@ export function App() {
35
35
 
36
36
  useKeyboard((key) => {
37
37
  if (key.name === "q" && key.ctrl) {
38
- shutdownStorage().finally(() => renderer.destroy())
38
+ shutdownStorage().finally(() => {
39
+ renderer.destroy()
40
+ process.exit(0)
41
+ })
39
42
  return
40
43
  }
41
44
 
@@ -121,7 +124,10 @@ export function App() {
121
124
  // Register shutdown handler so commands can close the app
122
125
  useEffect(() => {
123
126
  useStore.getState().setShutdownHandler(() => {
124
- shutdownStorage().finally(() => renderer.destroy())
127
+ shutdownStorage().finally(() => {
128
+ renderer.destroy()
129
+ process.exit(0)
130
+ })
125
131
  })
126
132
  }, [renderer])
127
133
 
@@ -140,6 +146,14 @@ export function App() {
140
146
  setTheme(theme)
141
147
 
142
148
  await loadAllDocs()
149
+
150
+ // Run image cache cleanup (fire-and-forget)
151
+ const imgConfig = config.image_preview
152
+ if (imgConfig?.enabled) {
153
+ import("@/core/image-preview/cache").then(({ cleanupCache }) => {
154
+ cleanupCache(imgConfig.cache_max_mb ?? 100, imgConfig.cache_max_days ?? 7).catch(() => {})
155
+ })
156
+ }
143
157
  }
144
158
  init().catch((err) => console.error("[init]", err))
145
159
  }, [])
@@ -379,6 +379,9 @@ export const commands: Record<string, CommandDef> = {
379
379
  const client = getClient(connId)
380
380
  if (client) {
381
381
  client.part(buf.name, args[0] ?? "Window closed")
382
+ } else {
383
+ // Already disconnected — just remove the buffer
384
+ s.removeBuffer(buf.id)
382
385
  }
383
386
  } else if (buf.type === BufferType.Query) {
384
387
  s.removeBuffer(buf.id)
@@ -48,8 +48,6 @@ export function mergeWithDefaults(partial: Record<string, any>): AppConfig {
48
48
  aliases: partial.aliases ?? {},
49
49
  ignores: (partial.ignores as IgnoreEntry[] | undefined) ?? [],
50
50
  scripts: {
51
- autoload: [],
52
- debug: false,
53
51
  ...DEFAULT_CONFIG.scripts,
54
52
  ...partial.scripts,
55
53
  },
@@ -169,9 +169,9 @@ export function createAntiFloodMiddleware(connId: string) {
169
169
  state.msgWindow.push({ text: message, time: now })
170
170
  // Prune old entries
171
171
  const cutoff = now - DUP_WINDOW
172
- while (state.msgWindow.length > 0 && state.msgWindow[0].time < cutoff) {
173
- state.msgWindow.shift()
174
- }
172
+ let pruneIdx = 0
173
+ while (pruneIdx < state.msgWindow.length && state.msgWindow[pruneIdx].time < cutoff) pruneIdx++
174
+ if (pruneIdx > 0) state.msgWindow.splice(0, pruneIdx)
175
175
 
176
176
  // Only analyze when enough messages in window
177
177
  if (state.msgWindow.length >= DUP_MIN_IN_WINDOW) {
@@ -123,8 +123,18 @@ export function disconnectServer(id: string, message?: string) {
123
123
  const client = clients.get(id)
124
124
  if (client) {
125
125
  client.quit(message ?? "kokoIRC — https://github.com/kofany/kokoIRC")
126
+ ;(client as any).removeAllListeners()
126
127
  clients.delete(id)
128
+ // Force-close the TCP socket after a brief delay so the QUIT message
129
+ // has time to flush. Without this, the half-open socket keeps the
130
+ // event loop alive and prevents clean process exit.
131
+ setTimeout(() => {
132
+ try { (client as any).connection?.disposeSocket?.() } catch {}
133
+ }, 200)
127
134
  }
135
+ // Update store even if client was already gone — removeAllListeners() prevents
136
+ // the "close" event handler from updating status, so we must do it here.
137
+ useStore.getState().updateConnection(id, { status: "disconnected" })
128
138
  }
129
139
 
130
140
  export function getClient(id: string): Client | undefined {
@@ -19,7 +19,8 @@ function isChannelTarget(target: string): boolean {
19
19
  function getListModes(connectionId: string): Set<string> {
20
20
  const conn = useStore.getState().connections.get(connectionId)
21
21
  const chanmodes = conn?.isupport?.CHANMODES
22
- if (chanmodes) return new Set(chanmodes.split(",")[0])
22
+ if (Array.isArray(chanmodes) && chanmodes[0]) return new Set(chanmodes[0].split(""))
23
+ if (typeof chanmodes === "string") return new Set(chanmodes.split(",")[0])
23
24
  return new Set(["b", "e", "I", "R"])
24
25
  }
25
26
 
@@ -178,6 +179,8 @@ export function bindEvents(client: Client, connectionId: string) {
178
179
  s.addMessage(bufferId, makeEventMessage(
179
180
  `%Zf7768eYou were kicked from ${event.channel} by %Za9b1d6${event.nick}%Zf7768e (${event.message || ""})%N`
180
181
  ))
182
+ // Auto-close the channel buffer after being kicked
183
+ getStore().removeBuffer(bufferId)
181
184
  } else {
182
185
  s.removeNick(bufferId, event.kicked)
183
186
  if (shouldIgnore(event.nick, event.ident, event.hostname, "KICKS", event.channel)) return
@@ -231,7 +234,13 @@ export function bindEvents(client: Client, connectionId: string) {
231
234
  ? event.message.toLowerCase().includes(conn.nick.toLowerCase())
232
235
  : false
233
236
 
234
- s.addMessage(bufferId, {
237
+ const activityLevel = (s.activeBufferId !== bufferId && !isOwnMsg)
238
+ ? (!isChannel ? ActivityLevel.Mention
239
+ : isMention ? ActivityLevel.Mention
240
+ : ActivityLevel.Activity)
241
+ : undefined
242
+
243
+ s.addMessageWithActivity(bufferId, {
235
244
  id: nextMsgId(),
236
245
  timestamp: new Date(event.time || Date.now()),
237
246
  type: "message",
@@ -239,14 +248,7 @@ export function bindEvents(client: Client, connectionId: string) {
239
248
  nickMode: getNickMode(s.buffers, bufferId, event.nick),
240
249
  text: event.message,
241
250
  highlight: isMention,
242
- })
243
-
244
- if (s.activeBufferId !== bufferId && !isOwnMsg) {
245
- const level = !isChannel ? ActivityLevel.Mention
246
- : isMention ? ActivityLevel.Mention
247
- : ActivityLevel.Activity
248
- s.updateBufferActivity(bufferId, level)
249
- }
251
+ }, activityLevel)
250
252
  })
251
253
 
252
254
  client.on("action", (event) => {
@@ -426,53 +428,55 @@ export function bindEvents(client: Client, connectionId: string) {
426
428
  const prefixMap = buildPrefixMap(conn?.isupport?.PREFIX)
427
429
  const modeOrder = buildModeOrder(conn?.isupport?.PREFIX)
428
430
 
429
- for (const mc of event.modes) {
430
- if (!mc.param) continue
431
- const isAdding = mc.mode.startsWith("+")
432
- const modeChar = mc.mode.replace(/[+-]/, "")
433
- if (!modeOrder.includes(modeChar)) continue // not a nick prefix mode
431
+ // Batch nick prefix updates (1 set() instead of N)
432
+ const nickUpdates: Array<{ bufferId: string; entry: NickEntry }> = []
433
+ const buf = getStore().buffers.get(bufferId)
434
+ if (buf) {
435
+ for (const mc of event.modes) {
436
+ if (!mc.param) continue
437
+ const isAdding = mc.mode.startsWith("+")
438
+ const modeChar = mc.mode.replace(/[+-]/, "")
439
+ if (!modeOrder.includes(modeChar)) continue
434
440
 
435
- const buf = getStore().buffers.get(bufferId)
436
- const entry = buf?.users.get(mc.param)
437
- if (!entry) continue
438
-
439
- // Add or remove this specific mode char from the user's modes string
440
- let modes = entry.modes ?? ""
441
- if (isAdding && !modes.includes(modeChar)) {
442
- modes += modeChar
443
- } else if (!isAdding) {
444
- modes = modes.replace(modeChar, "")
445
- }
441
+ const entry = buf.users.get(mc.param)
442
+ if (!entry) continue
446
443
 
447
- getStore().addNick(bufferId, {
448
- ...entry,
449
- modes,
450
- prefix: getHighestPrefix(modes, modeOrder, prefixMap),
451
- })
444
+ let modes = entry.modes ?? ""
445
+ if (isAdding && !modes.includes(modeChar)) {
446
+ modes += modeChar
447
+ } else if (!isAdding) {
448
+ modes = modes.replace(modeChar, "")
449
+ }
450
+
451
+ nickUpdates.push({
452
+ bufferId,
453
+ entry: { ...entry, modes, prefix: getHighestPrefix(modes, modeOrder, prefixMap) },
454
+ })
455
+ }
456
+ }
457
+ if (nickUpdates.length > 0) {
458
+ getStore().batchAddNick(nickUpdates)
452
459
  }
453
460
 
454
- // Update channel modes (non-nick-prefix, non-list modes)
455
- const listModes = getListModes(connectionId)
456
- const buf = getStore().buffers.get(bufferId)
457
- if (buf) {
458
- let chanModes = buf.modes ?? ""
459
- const params: Record<string, string> = { ...buf.modeParams }
461
+ // Batch list entry ops + update channel modes (non-nick-prefix, non-list modes)
462
+ const listModeSet = getListModes(connectionId)
463
+ const buf2 = getStore().buffers.get(bufferId)
464
+ if (buf2) {
465
+ let chanModes = buf2.modes ?? ""
466
+ const params: Record<string, string> = { ...buf2.modeParams }
467
+ const listOps: Array<{ action: "add" | "remove"; modeChar: string; entry?: { mask: string; setBy: string; setAt: number }; mask?: string }> = []
468
+
460
469
  for (const mc of event.modes) {
461
470
  const isAdding = mc.mode.startsWith("+")
462
471
  const modeChar = mc.mode.replace(/[+-]/, "")
463
- if (modeOrder.includes(modeChar)) continue // nick prefix mode
464
- if (listModes.has(modeChar)) {
465
- // Track list mode changes in store
472
+ if (modeOrder.includes(modeChar)) continue
473
+ if (listModeSet.has(modeChar)) {
466
474
  if (isAdding && mc.param) {
467
- getStore().addListEntry(bufferId, modeChar, {
468
- mask: mc.param,
469
- setBy: event.nick || "server",
470
- setAt: Date.now() / 1000,
471
- })
475
+ listOps.push({ action: "add", modeChar, entry: { mask: mc.param, setBy: event.nick || "server", setAt: Date.now() / 1000 } })
472
476
  } else if (!isAdding && mc.param) {
473
- getStore().removeListEntry(bufferId, modeChar, mc.param)
477
+ listOps.push({ action: "remove", modeChar, mask: mc.param })
474
478
  }
475
- continue // don't add to channel modes string
479
+ continue
476
480
  }
477
481
  if (isAdding && !chanModes.includes(modeChar)) {
478
482
  chanModes += modeChar
@@ -484,6 +488,9 @@ export function bindEvents(client: Client, connectionId: string) {
484
488
  params[modeChar] = mc.param
485
489
  }
486
490
  }
491
+ if (listOps.length > 0) {
492
+ getStore().batchListEntryOps(bufferId, listOps)
493
+ }
487
494
  getStore().updateBufferModes(bufferId, chanModes, params)
488
495
  }
489
496
  })
@@ -510,6 +517,7 @@ export function bindEvents(client: Client, connectionId: string) {
510
517
  clearInterval(lagPingInterval)
511
518
  destroyNetsplitState(connectionId)
512
519
  destroyAntifloodState(connectionId)
520
+ reopCollector.clear()
513
521
  getStore().updateConnection(connectionId, { status: "disconnected" })
514
522
  statusMsg("%Zf7768eDisconnected from server%N")
515
523
  })
@@ -592,8 +600,12 @@ export function bindEvents(client: Client, connectionId: string) {
592
600
  return
593
601
  }
594
602
  if (!event.motd) return
595
- for (const line of event.motd.split("\n")) {
596
- if (line.trim()) statusMsg(`%Z565f89${line}%N`)
603
+ const lines = event.motd.split("\n").filter((l: string) => l.trim())
604
+ if (lines.length > 0) {
605
+ getStore().batchAddMessage(lines.map((line: string) => ({
606
+ bufferId: statusId,
607
+ message: makeEventMessage(`%Z565f89${line}%N`),
608
+ })))
597
609
  }
598
610
  })
599
611
 
@@ -968,9 +980,10 @@ export function bindEvents(client: Client, connectionId: string) {
968
980
 
969
981
  lines.push(`%Z7aa2f7─────────────────────────────────────────────%N`)
970
982
 
971
- for (const line of lines) {
972
- getStore().addMessage(targetBuffer, makeEventMessage(line))
973
- }
983
+ getStore().batchAddMessage(lines.map(line => ({
984
+ bufferId: targetBuffer,
985
+ message: makeEventMessage(line),
986
+ })))
974
987
  })
975
988
 
976
989
  // ─── Whowas response ──────────────────────────────────────
@@ -998,9 +1011,10 @@ export function bindEvents(client: Client, connectionId: string) {
998
1011
 
999
1012
  lines.push(`%Z7aa2f7─────────────────────────────────────────────%N`)
1000
1013
 
1001
- for (const line of lines) {
1002
- getStore().addMessage(targetBuffer, makeEventMessage(line))
1003
- }
1014
+ getStore().batchAddMessage(lines.map(line => ({
1015
+ bufferId: targetBuffer,
1016
+ message: makeEventMessage(line),
1017
+ })))
1004
1018
  })
1005
1019
  }
1006
1020
 
@@ -1011,24 +1025,21 @@ function displayNumberedList(
1011
1025
  channel: string,
1012
1026
  entries: { mask: string; setBy: string; setAt: number }[],
1013
1027
  ) {
1014
- const s = useStore.getState()
1028
+ const msgs: Array<{ bufferId: string; message: Message }> = []
1015
1029
  if (entries.length === 0) {
1016
- s.addMessage(target, makeEventMessage(
1017
- `%Z565f89${channel}: ${label} is empty%N`
1018
- ))
1019
- return
1020
- }
1021
- s.addMessage(target, makeEventMessage(
1022
- `%Z7aa2f7───── ${label} for ${channel} ─────%N`
1023
- ))
1024
- for (let i = 0; i < entries.length; i++) {
1025
- const e = entries[i]
1026
- const by = e.setBy ? ` set by ${e.setBy}` : ""
1027
- const at = e.setAt ? ` [${formatDate(new Date(e.setAt * 1000))}]` : ""
1028
- s.addMessage(target, makeEventMessage(
1029
- `%Ze0af68${(i + 1).toString().padStart(2)}.%N %Za9b1d6${e.mask}%Z565f89${by}${at}%N`
1030
- ))
1030
+ msgs.push({ bufferId: target, message: makeEventMessage(`%Z565f89${channel}: ${label} is empty%N`) })
1031
+ } else {
1032
+ msgs.push({ bufferId: target, message: makeEventMessage(`%Z7aa2f7───── ${label} for ${channel} ─────%N`) })
1033
+ for (let i = 0; i < entries.length; i++) {
1034
+ const e = entries[i]
1035
+ const by = e.setBy ? ` set by ${e.setBy}` : ""
1036
+ const at = e.setAt ? ` [${formatDate(new Date(e.setAt * 1000))}]` : ""
1037
+ msgs.push({ bufferId: target, message: makeEventMessage(
1038
+ `%Ze0af68${(i + 1).toString().padStart(2)}.%N %Za9b1d6${e.mask}%Z565f89${by}${at}%N`
1039
+ ) })
1040
+ }
1031
1041
  }
1042
+ useStore.getState().batchAddMessage(msgs)
1032
1043
  }
1033
1044
 
1034
1045
  /** System/inline event — text may contain %Z color codes. */
@@ -25,6 +25,7 @@ export function createScriptAPI(meta: ScriptMeta, scriptDefaults: Record<string,
25
25
  } {
26
26
  const scriptName = meta.name
27
27
  const unsubs: Array<() => void> = []
28
+ const storeUnsubs: Array<() => void> = []
28
29
  const timers: Array<TimerHandle> = []
29
30
  const registeredCommands: string[] = []
30
31
 
@@ -37,7 +38,11 @@ export function createScriptAPI(meta: ScriptMeta, scriptDefaults: Record<string,
37
38
  getConfig: () => useStore.getState().config,
38
39
  getConnection: (id) => useStore.getState().connections.get(id),
39
40
  getBuffer: (id) => useStore.getState().buffers.get(id),
40
- subscribe: (listener) => useStore.subscribe(listener),
41
+ subscribe: (listener) => {
42
+ const unsub = useStore.subscribe(listener)
43
+ storeUnsubs.push(unsub)
44
+ return unsub
45
+ },
41
46
  }
42
47
 
43
48
  // ─── IRC Access ──────────────────────────────────────────
@@ -223,6 +228,10 @@ export function createScriptAPI(meta: ScriptMeta, scriptDefaults: Record<string,
223
228
  for (const unsub of unsubs) unsub()
224
229
  unsubs.length = 0
225
230
 
231
+ // Remove store subscriptions
232
+ for (const unsub of storeUnsubs) unsub()
233
+ storeUnsubs.length = 0
234
+
226
235
  // Clear all timers
227
236
  for (const t of timers) t.clear()
228
237
  timers.length = 0
@@ -26,8 +26,7 @@ export function useSortedBuffers(): Array<Buffer & { connectionLabel: string }>
26
26
  const EMPTY_NICKS: NickEntry[] = []
27
27
 
28
28
  export function useSortedNicks(bufferId: string, prefixOrder: string): NickEntry[] {
29
- const buffersMap = useStore((s) => s.buffers)
30
- const buffer = buffersMap.get(bufferId)
29
+ const buffer = useStore((s) => s.buffers.get(bufferId))
31
30
  return useMemo(() => {
32
31
  if (!buffer) return EMPTY_NICKS
33
32
  return sortNicks(Array.from(buffer.users.values()), prefixOrder)
@@ -45,6 +45,7 @@ interface AppState {
45
45
 
46
46
  // Message actions
47
47
  addMessage: (bufferId: string, message: Message) => void
48
+ addMessageWithActivity: (bufferId: string, message: Message, activity?: ActivityLevel) => void
48
49
  clearMessages: (bufferId: string) => void
49
50
 
50
51
  // Nicklist actions
@@ -66,6 +67,7 @@ interface AppState {
66
67
  batchAddNick: (entries: Array<{ bufferId: string; entry: NickEntry }>) => void
67
68
  batchUpdateNick: (entries: Array<{ bufferId: string; oldNick: string; newNick: string; prefix?: string }>) => void
68
69
  batchAddMessage: (entries: Array<{ bufferId: string; message: Message }>) => void
70
+ batchListEntryOps: (bufferId: string, ops: Array<{ action: "add" | "remove"; modeChar: ListModeKey; entry?: ListEntry; mask?: string }>) => void
69
71
 
70
72
  // Config/Theme
71
73
  setConfig: (config: AppConfig) => void
@@ -301,11 +303,11 @@ export const useStore = create<AppState>((set, get) => ({
301
303
  },
302
304
 
303
305
  updateBufferActivity: (id, level) => set((s) => {
306
+ const buf = s.buffers.get(id)
307
+ if (!buf || level <= buf.activity) return s
308
+ // Only trigger a store update when activity actually increases
304
309
  const buffers = new Map(s.buffers)
305
- const buf = buffers.get(id)
306
- if (buf && level > buf.activity) {
307
- buffers.set(id, { ...buf, activity: level, unreadCount: buf.unreadCount + 1 })
308
- }
310
+ buffers.set(id, { ...buf, activity: level, unreadCount: buf.unreadCount + 1 })
309
311
  return { buffers }
310
312
  }),
311
313
 
@@ -319,12 +321,60 @@ export const useStore = create<AppState>((set, get) => ({
319
321
  }
320
322
 
321
323
  return set((s) => {
324
+ const buf = s.buffers.get(bufferId)
325
+ if (!buf) return s
326
+ const maxLines = s.config?.display.scrollback_lines ?? 2000
327
+
328
+ // Inactive buffer — mutate in-place to avoid garbage (nobody is rendering these)
329
+ if (bufferId !== s.activeBufferId) {
330
+ buf.messages.push(message)
331
+ if (buf.messages.length > maxLines) buf.messages.splice(0, buf.messages.length - maxLines)
332
+ return s
333
+ }
334
+
335
+ // Active buffer — immutable update so ChatView re-renders
336
+ const messages = [...buf.messages, message]
337
+ if (messages.length > maxLines) messages.splice(0, messages.length - maxLines)
322
338
  const buffers = new Map(s.buffers)
323
- const buf = buffers.get(bufferId)
339
+ buffers.set(bufferId, { ...buf, messages })
340
+ return { buffers }
341
+ })
342
+ },
343
+
344
+ addMessageWithActivity: (bufferId, message, activity) => {
345
+ const slashIdx = bufferId.indexOf("/")
346
+ if (slashIdx > 0) {
347
+ const network = bufferId.slice(0, slashIdx)
348
+ const buffer = bufferId.slice(slashIdx + 1)
349
+ logMessage(network, buffer, message.id, message.type, message.text, message.nick ?? null, message.highlight, message.timestamp)
350
+ }
351
+
352
+ return set((s) => {
353
+ const buf = s.buffers.get(bufferId)
324
354
  if (!buf) return s
325
355
  const maxLines = s.config?.display.scrollback_lines ?? 2000
356
+
357
+ // Inactive buffer — mutate messages in-place to avoid garbage
358
+ if (bufferId !== s.activeBufferId) {
359
+ buf.messages.push(message)
360
+ if (buf.messages.length > maxLines) buf.messages.splice(0, buf.messages.length - maxLines)
361
+ buf.unreadCount++
362
+
363
+ // Only trigger a store update if activity level actually increases
364
+ // (first unread message sets the marker; subsequent messages are free)
365
+ if (activity != null && activity > buf.activity) {
366
+ buf.activity = activity
367
+ const buffers = new Map(s.buffers)
368
+ buffers.set(bufferId, { ...buf })
369
+ return { buffers }
370
+ }
371
+ return s // no state change — React won't re-render
372
+ }
373
+
374
+ // Active buffer — immutable update so ChatView re-renders
326
375
  const messages = [...buf.messages, message]
327
376
  if (messages.length > maxLines) messages.splice(0, messages.length - maxLines)
377
+ const buffers = new Map(s.buffers)
328
378
  buffers.set(bufferId, { ...buf, messages })
329
379
  return { buffers }
330
380
  })
@@ -339,9 +389,14 @@ export const useStore = create<AppState>((set, get) => ({
339
389
  }),
340
390
 
341
391
  addNick: (bufferId, entry) => set((s) => {
342
- const buffers = new Map(s.buffers)
343
- const buf = buffers.get(bufferId)
392
+ const buf = s.buffers.get(bufferId)
344
393
  if (!buf) return s
394
+ // Inactive buffer — mutate in-place (NickList only renders for active buffer)
395
+ if (bufferId !== s.activeBufferId) {
396
+ buf.users.set(entry.nick, entry)
397
+ return s
398
+ }
399
+ const buffers = new Map(s.buffers)
345
400
  const users = new Map(buf.users)
346
401
  users.set(entry.nick, entry)
347
402
  buffers.set(bufferId, { ...buf, users })
@@ -349,9 +404,13 @@ export const useStore = create<AppState>((set, get) => ({
349
404
  }),
350
405
 
351
406
  removeNick: (bufferId, nick) => set((s) => {
352
- const buffers = new Map(s.buffers)
353
- const buf = buffers.get(bufferId)
407
+ const buf = s.buffers.get(bufferId)
354
408
  if (!buf) return s
409
+ if (bufferId !== s.activeBufferId) {
410
+ buf.users.delete(nick)
411
+ return s
412
+ }
413
+ const buffers = new Map(s.buffers)
355
414
  const users = new Map(buf.users)
356
415
  users.delete(nick)
357
416
  buffers.set(bufferId, { ...buf, users })
@@ -359,15 +418,19 @@ export const useStore = create<AppState>((set, get) => ({
359
418
  }),
360
419
 
361
420
  updateNick: (bufferId, oldNick, newNick, prefix) => set((s) => {
362
- const buffers = new Map(s.buffers)
363
- const buf = buffers.get(bufferId)
421
+ const buf = s.buffers.get(bufferId)
364
422
  if (!buf) return s
365
- const users = new Map(buf.users)
366
- const existing = users.get(oldNick)
367
- if (existing) {
368
- users.delete(oldNick)
369
- users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
423
+ const existing = buf.users.get(oldNick)
424
+ if (!existing) return s
425
+ if (bufferId !== s.activeBufferId) {
426
+ buf.users.delete(oldNick)
427
+ buf.users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
428
+ return s
370
429
  }
430
+ const buffers = new Map(s.buffers)
431
+ const users = new Map(buf.users)
432
+ users.delete(oldNick)
433
+ users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
371
434
  buffers.set(bufferId, { ...buf, users })
372
435
  return { buffers }
373
436
  }),
@@ -424,43 +487,58 @@ export const useStore = create<AppState>((set, get) => ({
424
487
  }),
425
488
 
426
489
  batchRemoveNick: (entries) => set((s) => {
427
- const buffers = new Map(s.buffers)
490
+ let buffers: Map<string, Buffer> | null = null
428
491
  for (const { bufferId, nick } of entries) {
429
- const buf = buffers.get(bufferId)
492
+ const buf = (buffers ?? s.buffers).get(bufferId)
430
493
  if (!buf) continue
431
- const users = new Map(buf.users)
432
- users.delete(nick)
433
- buffers.set(bufferId, { ...buf, users })
494
+ if (bufferId !== s.activeBufferId) {
495
+ buf.users.delete(nick)
496
+ } else {
497
+ if (!buffers) buffers = new Map(s.buffers)
498
+ const users = new Map(buf.users)
499
+ users.delete(nick)
500
+ buffers.set(bufferId, { ...buf, users })
501
+ }
434
502
  }
435
- return { buffers }
503
+ return buffers ? { buffers } : s
436
504
  }),
437
505
 
438
506
  batchAddNick: (entries) => set((s) => {
439
- const buffers = new Map(s.buffers)
507
+ let buffers: Map<string, Buffer> | null = null
440
508
  for (const { bufferId, entry } of entries) {
441
- const buf = buffers.get(bufferId)
509
+ const buf = (buffers ?? s.buffers).get(bufferId)
442
510
  if (!buf) continue
443
- const users = new Map(buf.users)
444
- users.set(entry.nick, entry)
445
- buffers.set(bufferId, { ...buf, users })
511
+ if (bufferId !== s.activeBufferId) {
512
+ buf.users.set(entry.nick, entry)
513
+ } else {
514
+ if (!buffers) buffers = new Map(s.buffers)
515
+ const users = new Map(buf.users)
516
+ users.set(entry.nick, entry)
517
+ buffers.set(bufferId, { ...buf, users })
518
+ }
446
519
  }
447
- return { buffers }
520
+ return buffers ? { buffers } : s
448
521
  }),
449
522
 
450
523
  batchUpdateNick: (entries) => set((s) => {
451
- const buffers = new Map(s.buffers)
524
+ let buffers: Map<string, Buffer> | null = null
452
525
  for (const { bufferId, oldNick, newNick, prefix } of entries) {
453
- const buf = buffers.get(bufferId)
526
+ const buf = (buffers ?? s.buffers).get(bufferId)
454
527
  if (!buf) continue
455
- const users = new Map(buf.users)
456
- const existing = users.get(oldNick)
457
- if (existing) {
528
+ const existing = buf.users.get(oldNick)
529
+ if (!existing) continue
530
+ if (bufferId !== s.activeBufferId) {
531
+ buf.users.delete(oldNick)
532
+ buf.users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
533
+ } else {
534
+ if (!buffers) buffers = new Map(s.buffers)
535
+ const users = new Map(buf.users)
458
536
  users.delete(oldNick)
459
537
  users.set(newNick, { ...existing, nick: newNick, prefix: prefix ?? existing.prefix })
538
+ buffers.set(bufferId, { ...buf, users })
460
539
  }
461
- buffers.set(bufferId, { ...buf, users })
462
540
  }
463
- return { buffers }
541
+ return buffers ? { buffers } : s
464
542
  }),
465
543
 
466
544
  batchAddMessage: (entries) => {
@@ -475,19 +553,49 @@ export const useStore = create<AppState>((set, get) => ({
475
553
  }
476
554
 
477
555
  return set((s) => {
478
- const buffers = new Map(s.buffers)
479
556
  const maxLines = s.config?.display.scrollback_lines ?? 2000
557
+ let buffers: Map<string, Buffer> | null = null
558
+
480
559
  for (const { bufferId, message } of entries) {
481
- const buf = buffers.get(bufferId)
560
+ const buf = (buffers ?? s.buffers).get(bufferId)
482
561
  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 })
562
+
563
+ if (bufferId !== s.activeBufferId) {
564
+ // Inactive buffer mutate in-place
565
+ buf.messages.push(message)
566
+ if (buf.messages.length > maxLines) buf.messages.splice(0, buf.messages.length - maxLines)
567
+ } else {
568
+ // Active buffer — immutable update
569
+ if (!buffers) buffers = new Map(s.buffers)
570
+ const messages = [...buf.messages, message]
571
+ if (messages.length > maxLines) messages.splice(0, messages.length - maxLines)
572
+ buffers.set(bufferId, { ...buf, messages })
573
+ }
486
574
  }
487
- return { buffers }
575
+
576
+ return buffers ? { buffers } : s
488
577
  })
489
578
  },
490
579
 
580
+ batchListEntryOps: (bufferId, ops) => set((s) => {
581
+ const buffers = new Map(s.buffers)
582
+ const buf = buffers.get(bufferId)
583
+ if (!buf) return s
584
+ const listModes = new Map(buf.listModes)
585
+ for (const op of ops) {
586
+ const existing = listModes.get(op.modeChar) ?? []
587
+ if (op.action === "add" && op.entry) {
588
+ if (!existing.some((e) => e.mask === op.entry!.mask)) {
589
+ listModes.set(op.modeChar, [...existing, op.entry])
590
+ }
591
+ } else if (op.action === "remove" && op.mask) {
592
+ listModes.set(op.modeChar, existing.filter((e) => e.mask !== op.mask))
593
+ }
594
+ }
595
+ buffers.set(bufferId, { ...buf, listModes })
596
+ return { buffers }
597
+ }),
598
+
491
599
  setConfig: (config) => set({ config }),
492
600
  setTheme: (theme) => set({ theme }),
493
601
 
@@ -1,3 +1,3 @@
1
1
  export { parseFormatString, resolveAbstractions } from "./parser"
2
2
  export { loadTheme } from "./loader"
3
- export { StyledText } from "./renderer"
3
+ export { StyledText, renderStyledSpans } from "./renderer"
@@ -335,8 +335,10 @@ export function parseFormatString(input: string, params: string[] = []): StyledS
335
335
  continue
336
336
  }
337
337
 
338
- // %| — indent marker, skip
338
+ // %| — indent marker: emit a zero-width span so renderers can split here
339
339
  if (code === "|") {
340
+ flush()
341
+ spans.push({ text: "", bold: false, italic: false, underline: false, dim: false, indentMarker: true })
340
342
  i++
341
343
  continue
342
344
  }
@@ -30,24 +30,26 @@ function linkify(text: string): React.ReactNode[] {
30
30
  return parts.length > 0 ? parts : [text]
31
31
  }
32
32
 
33
+ /** Render StyledSpan[] as React elements (without wrapping <text>). */
34
+ export function renderStyledSpans(spans: StyledSpan[], keyOffset = 0): React.ReactNode[] {
35
+ return spans.map((span, i) => {
36
+ if (span.indentMarker) return null
37
+ let content: any = linkify(span.text)
38
+ if (content.length === 1 && typeof content[0] === "string") {
39
+ content = content[0]
40
+ }
41
+ if (span.bold) content = <strong>{content}</strong>
42
+ if (span.italic) content = <em>{content}</em>
43
+ if (span.underline) content = <u>{content}</u>
44
+ if (span.dim) content = <span attributes={TextAttributes.DIM}>{content}</span>
45
+ return (
46
+ <span key={keyOffset + i} fg={span.fg} bg={span.bg}>
47
+ {content}
48
+ </span>
49
+ )
50
+ })
51
+ }
52
+
33
53
  export function StyledText({ spans }: Props) {
34
- return (
35
- <text>
36
- {spans.map((span, i) => {
37
- let content: any = linkify(span.text)
38
- if (content.length === 1 && typeof content[0] === "string") {
39
- content = content[0]
40
- }
41
- if (span.bold) content = <strong>{content}</strong>
42
- if (span.italic) content = <em>{content}</em>
43
- if (span.underline) content = <u>{content}</u>
44
- if (span.dim) content = <span attributes={TextAttributes.DIM}>{content}</span>
45
- return (
46
- <span key={i} fg={span.fg} bg={span.bg}>
47
- {content}
48
- </span>
49
- )
50
- })}
51
- </text>
52
- )
54
+ return <text>{renderStyledSpans(spans)}</text>
53
55
  }
@@ -34,4 +34,5 @@ export interface StyledSpan {
34
34
  italic: boolean
35
35
  underline: boolean
36
36
  dim: boolean
37
+ indentMarker?: boolean
37
38
  }
@@ -1,21 +1,26 @@
1
1
  import { useRef, useEffect } from "react"
2
2
  import { useStore } from "@/core/state/store"
3
+ import { useShallow } from "zustand/react/shallow"
3
4
  import { MessageLine } from "./MessageLine"
5
+ import type { Message } from "@/types"
4
6
  import type { ScrollBoxRenderable } from "@opentui/core"
5
7
 
8
+ const NO_BUFFER = { messages: [] as Message[], activeBufferId: null as string | null, currentNick: "", hasBuffer: false }
9
+
6
10
  export function ChatView() {
7
- const buffer = useStore((s) => {
8
- const id = s.activeBufferId
9
- return id ? s.buffers.get(id) ?? null : null
10
- })
11
- const activeBufferId = useStore((s) => s.activeBufferId)
12
- const currentNick = useStore((s) => {
11
+ const data = useStore(useShallow((s) => {
13
12
  const id = s.activeBufferId
14
- if (!id) return ""
13
+ if (!id) return NO_BUFFER
15
14
  const buf = s.buffers.get(id)
16
- if (!buf) return ""
17
- return s.connections.get(buf.connectionId)?.nick ?? ""
18
- })
15
+ if (!buf) return NO_BUFFER
16
+ const conn = s.connections.get(buf.connectionId)
17
+ return {
18
+ messages: buf.messages,
19
+ activeBufferId: id,
20
+ currentNick: conn?.nick ?? "",
21
+ hasBuffer: true,
22
+ }
23
+ }))
19
24
  const colors = useStore((s) => s.theme?.colors)
20
25
  const scrollRef = useRef<ScrollBoxRenderable>(null)
21
26
 
@@ -25,9 +30,9 @@ export function ChatView() {
25
30
  scrollRef.current.stickyScroll = true
26
31
  scrollRef.current.scrollTo(scrollRef.current.scrollHeight)
27
32
  }
28
- }, [activeBufferId])
33
+ }, [data.activeBufferId])
29
34
 
30
- if (!buffer) {
35
+ if (!data.hasBuffer) {
31
36
  return (
32
37
  <box flexGrow={1} justifyContent="center" alignItems="center">
33
38
  <text><span fg={colors?.fg_dim ?? "#292e42"}>No active buffer</span></text>
@@ -37,8 +42,8 @@ export function ChatView() {
37
42
 
38
43
  return (
39
44
  <scrollbox ref={scrollRef} height="100%" stickyScroll stickyStart="bottom">
40
- {buffer.messages.map((msg) => (
41
- <MessageLine key={msg.id} message={msg} isOwnNick={msg.nick === currentNick} />
45
+ {data.messages.map((msg) => (
46
+ <MessageLine key={msg.id} message={msg} isOwnNick={msg.nick === data.currentNick} />
42
47
  ))}
43
48
  </scrollbox>
44
49
  )
@@ -1,5 +1,6 @@
1
+ import React from "react"
1
2
  import { useStore } from "@/core/state/store"
2
- import { resolveAbstractions, parseFormatString, StyledText } from "@/core/theme"
3
+ import { resolveAbstractions, parseFormatString, StyledText, renderStyledSpans } from "@/core/theme"
3
4
  import { formatTimestamp } from "@/core/irc/formatting"
4
5
  import { classifyUrl } from "@/core/image-preview/fetch"
5
6
  import type { Message } from "@/types"
@@ -12,7 +13,7 @@ interface Props {
12
13
  isOwnNick: boolean
13
14
  }
14
15
 
15
- export function MessageLine({ message, isOwnNick }: Props) {
16
+ export const MessageLine = React.memo(function MessageLine({ message, isOwnNick }: Props) {
16
17
  const theme = useStore((s) => s.theme)
17
18
  const config = useStore((s) => s.config)
18
19
  const abstracts = theme?.abstracts ?? {}
@@ -45,10 +46,10 @@ export function MessageLine({ message, isOwnNick }: Props) {
45
46
  const maxLen = config?.display.nick_max_length ?? nickWidth
46
47
  const truncate = config?.display.nick_truncation ?? true
47
48
 
48
- // Truncate nick if needed
49
+ // Truncate nick if needed — show "+" to indicate truncation
49
50
  let displayNick = nick
50
51
  if (truncate && displayNick.length > maxLen) {
51
- displayNick = displayNick.slice(0, maxLen)
52
+ displayNick = displayNick.slice(0, maxLen - 1) + "+"
52
53
  }
53
54
 
54
55
  // Pad the combined mode+nick so alignment covers the whole column
@@ -101,9 +102,29 @@ export function MessageLine({ message, isOwnNick }: Props) {
101
102
  }
102
103
  }
103
104
 
105
+ // Split at %| indent marker for wrap-indented two-column layout
106
+ const markerIdx = allSpans.findIndex((s) => s.indentMarker)
107
+ if (markerIdx !== -1) {
108
+ // Absorb whitespace-only spans after marker into prefix (for correct alignment)
109
+ let bodyStart = markerIdx + 1
110
+ while (bodyStart < allSpans.length && allSpans[bodyStart].text.trim() === "") {
111
+ bodyStart++
112
+ }
113
+ const prefixSpans = [...allSpans.slice(0, markerIdx), ...allSpans.slice(markerIdx + 1, bodyStart)]
114
+ const bodySpans = allSpans.slice(bodyStart)
115
+ const prefixWidth = prefixSpans.reduce((w, s) => w + s.text.length, 0)
116
+
117
+ return (
118
+ <box width="100%" flexDirection="row" onMouseDown={handleClick}>
119
+ <text width={prefixWidth}>{renderStyledSpans(prefixSpans)}</text>
120
+ <text flexGrow={1}>{renderStyledSpans(bodySpans, prefixSpans.length)}</text>
121
+ </box>
122
+ )
123
+ }
124
+
104
125
  return (
105
126
  <box width="100%" onMouseDown={handleClick}>
106
127
  <StyledText spans={allSpans} />
107
128
  </box>
108
129
  )
109
- }
130
+ })
@@ -50,6 +50,10 @@ export function CommandInput() {
50
50
  const addMessage = useStore((s) => s.addMessage)
51
51
  const sb = useStatusbarColors()
52
52
 
53
+ // ── Multiline paste handling ──────────────────────────────────
54
+ const pasteQueueRef = useRef<ReturnType<typeof setTimeout>[]>([])
55
+ const handleSubmitRef = useRef<(v: string) => void>(() => {})
56
+
53
57
  const handleSubmit = useCallback((submittedValue?: string | unknown) => {
54
58
  const text = typeof submittedValue === "string" ? submittedValue : value
55
59
  const trimmed = text.trim()
@@ -88,6 +92,56 @@ export function CommandInput() {
88
92
  }
89
93
  }, [value, buffer, addMessage])
90
94
 
95
+ // Keep ref in sync for paste queue callbacks
96
+ handleSubmitRef.current = handleSubmit
97
+
98
+ // Intercept multiline paste — split into lines and send with delay
99
+ useEffect(() => {
100
+ const PASTE_DELAY = 500 // ms between lines
101
+
102
+ const onPaste = (event: { text: string; preventDefault(): void }) => {
103
+ const text = event.text
104
+ if (!text) return
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
+
109
+ event.preventDefault()
110
+
111
+ // Prepend any existing input text to first pasted line
112
+ const currentInput = inputRef.current?.value ?? ""
113
+ if (currentInput.trim()) {
114
+ lines[0] = currentInput + lines[0]
115
+ }
116
+
117
+ // Clear input
118
+ setValue("")
119
+ if (inputRef.current) inputRef.current.value = ""
120
+
121
+ // Cancel any pending paste queue
122
+ for (const t of pasteQueueRef.current) clearTimeout(t)
123
+ pasteQueueRef.current = []
124
+
125
+ // Capture current submit for all queued lines
126
+ const submit = handleSubmitRef.current
127
+
128
+ // Send first line immediately, rest with delay to avoid excess flood
129
+ submit(lines[0])
130
+ for (let i = 1; i < lines.length; i++) {
131
+ const line = lines[i]
132
+ const timer = setTimeout(() => submit(line), PASTE_DELAY * i)
133
+ pasteQueueRef.current.push(timer)
134
+ }
135
+ }
136
+
137
+ renderer.keyInput.on("paste", onPaste)
138
+ return () => {
139
+ renderer.keyInput.off("paste", onPaste)
140
+ for (const t of pasteQueueRef.current) clearTimeout(t)
141
+ pasteQueueRef.current = []
142
+ }
143
+ }, [renderer])
144
+
91
145
  const tryNickCompletion = (currentValue: string) => {
92
146
  if (!buffer) return null
93
147
  const nicks = Array.from(buffer.users.keys())
@@ -35,7 +35,6 @@ export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline
35
35
  const [liveLeftWidth, setLiveLeftWidth] = useState(leftWidth)
36
36
  const [liveRightWidth, setLiveRightWidth] = useState(rightWidth)
37
37
  const dragRef = useRef<{ side: "left" | "right"; startX: number; startWidth: number; currentWidth: number } | null>(null)
38
- const store = useStore()
39
38
 
40
39
  useEffect(() => { setLiveLeftWidth(leftWidth) }, [leftWidth])
41
40
  useEffect(() => { setLiveRightWidth(rightWidth) }, [rightWidth])
@@ -59,10 +58,11 @@ export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline
59
58
  const d = dragRef.current
60
59
  if (!d) return
61
60
  dragRef.current = null
62
- const newConfig = cloneConfig(store.config!)
61
+ const s = useStore.getState()
62
+ const newConfig = cloneConfig(s.config!)
63
63
  if (d.side === "left") newConfig.sidepanel.left.width = d.currentWidth
64
64
  else newConfig.sidepanel.right.width = d.currentWidth
65
- store.setConfig(newConfig)
65
+ s.setConfig(newConfig)
66
66
  saveConfig(CONFIG_PATH, newConfig)
67
67
  }
68
68
 
@@ -21,26 +21,35 @@ export function BufferList() {
21
21
  // Connection header
22
22
  if (buf.connectionId !== lastConnectionId) {
23
23
  lastConnectionId = buf.connectionId
24
- const format = theme?.formats.sidepanel.header ?? "%B$0%N"
25
- const resolved = resolveAbstractions(format, theme?.abstracts ?? {})
26
- const spans = parseFormatString(resolved, [buf.connectionLabel])
24
+ const hdrFormat = theme?.formats.sidepanel.header ?? "%B$0%N"
25
+ const hdrResolved = resolveAbstractions(hdrFormat, theme?.abstracts ?? {})
26
+ // Measure visible overhead of header format (everything except $0)
27
+ const hdrOverhead = parseFormatString(hdrResolved, [""]).reduce((w, s) => w + s.text.length, 0)
28
+ const maxLabelLen = leftWidth - 3 - hdrOverhead
29
+ const displayLabel = maxLabelLen > 0 && buf.connectionLabel.length > maxLabelLen
30
+ ? buf.connectionLabel.slice(0, maxLabelLen - 1) + "+"
31
+ : buf.connectionLabel
32
+ const hdrSpans = parseFormatString(hdrResolved, [displayLabel])
27
33
  items.push(
28
34
  <box key={`h-${buf.connectionId}`} width="100%">
29
- <StyledText spans={spans} />
35
+ <StyledText spans={hdrSpans} />
30
36
  </box>
31
37
  )
32
38
  }
33
39
 
34
40
  refNum++
41
+ const refStr = String(refNum)
35
42
  const isActive = buf.id === activeBufferId
36
43
  const formatKey = isActive
37
44
  ? "item_selected"
38
45
  : `item_activity_${buf.activity}`
39
46
  const format = theme?.formats.sidepanel[formatKey] ?? "$0. $1"
40
47
  const resolved = resolveAbstractions(format, theme?.abstracts ?? {})
41
- const maxLen = leftWidth - 4
42
- const displayName = buf.name.length > maxLen ? buf.name.slice(0, maxLen - 1) + "\u2026" : buf.name
43
- const spans = parseFormatString(resolved, [String(refNum), displayName])
48
+ // Measure visible overhead of format (refnum + decoration, excluding channel name)
49
+ const formatOverhead = parseFormatString(resolved, [refStr, ""]).reduce((w, s) => w + s.text.length, 0)
50
+ const maxLen = leftWidth - 3 - formatOverhead
51
+ const displayName = maxLen > 0 && buf.name.length > maxLen ? buf.name.slice(0, maxLen - 1) + "+" : buf.name
52
+ const spans = parseFormatString(resolved, [refStr, displayName])
44
53
 
45
54
  items.push(
46
55
  <box key={buf.id} width="100%" onMouseDown={() => setActiveBuffer(buf.id)}>
@@ -8,15 +8,15 @@ const DEFAULT_PREFIX_ORDER = "~&@%+"
8
8
  const EMPTY_NICKS: import("@/types").NickEntry[] = []
9
9
 
10
10
  export function NickList() {
11
- const activeBufferId = useStore((s) => s.activeBufferId)
12
- const buffersMap = useStore((s) => s.buffers)
13
- const connectionsMap = useStore((s) => s.connections)
11
+ const buffer = useStore((s) => s.activeBufferId ? s.buffers.get(s.activeBufferId) ?? null : null)
12
+ const conn = useStore((s) => {
13
+ const buf = s.activeBufferId ? s.buffers.get(s.activeBufferId) : null
14
+ return buf ? s.connections.get(buf.connectionId) : undefined
15
+ })
14
16
  const theme = useStore((s) => s.theme)
17
+ const rightWidth = useStore((s) => s.config?.sidepanel.right.width ?? 18)
15
18
  const colors = theme?.colors
16
19
 
17
- const buffer = activeBufferId ? buffersMap.get(activeBufferId) ?? null : null
18
- const conn = buffer ? connectionsMap.get(buffer.connectionId) : undefined
19
-
20
20
  const prefixOrder = conn?.isupport?.PREFIX
21
21
  ? extractPrefixChars(conn.isupport.PREFIX)
22
22
  : DEFAULT_PREFIX_ORDER
@@ -46,7 +46,14 @@ export function NickList() {
46
46
  const formatKey = getFormatKey(entry.prefix)
47
47
  const format = formats[formatKey] ?? " $0"
48
48
  const resolved = resolveAbstractions(format, abstracts)
49
- const spans = parseFormatString(resolved, [entry.nick])
49
+ // Measure visible overhead of format (prefix char etc., excluding nick)
50
+ const formatOverhead = parseFormatString(resolved, [""]).reduce((w, s) => w + s.text.length, 0)
51
+ const maxNickLen = rightWidth - 3 - formatOverhead
52
+ let displayNick = entry.nick
53
+ if (maxNickLen > 0 && displayNick.length > maxNickLen) {
54
+ displayNick = displayNick.slice(0, maxNickLen - 1) + "+"
55
+ }
56
+ const spans = parseFormatString(resolved, [displayNick])
50
57
 
51
58
  return (
52
59
  <box key={entry.nick} width="100%"
@@ -65,6 +72,7 @@ export function NickList() {
65
72
  unreadCount: 0,
66
73
  lastRead: new Date(),
67
74
  users: new Map(),
75
+ listModes: new Map(),
68
76
  })
69
77
  }
70
78
  store.setActiveBuffer(queryId)
@@ -46,6 +46,7 @@ export function SplashScreen({ onDone }: { onDone: () => void }) {
46
46
  const [visibleLines, setVisibleLines] = useState(0)
47
47
  const [showLogo, setShowLogo] = useState(false)
48
48
  const doneRef = useRef(false)
49
+ const finishTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
49
50
 
50
51
  const finish = () => {
51
52
  if (doneRef.current) return
@@ -63,10 +64,13 @@ export function SplashScreen({ onDone }: { onDone: () => void }) {
63
64
  if (count >= BIRD.length) {
64
65
  clearInterval(timer)
65
66
  setShowLogo(true)
66
- setTimeout(finish, 2500)
67
+ finishTimer.current = setTimeout(finish, 2500)
67
68
  }
68
69
  }, 50)
69
- return () => clearInterval(timer)
70
+ return () => {
71
+ clearInterval(timer)
72
+ if (finishTimer.current) clearTimeout(finishTimer.current)
73
+ }
70
74
  }, [])
71
75
 
72
76
  return (
@@ -8,7 +8,10 @@ import { useStatusbarColors } from "@/ui/hooks/useStatusbarColors"
8
8
  export function StatusLine() {
9
9
  const config = useStore((s) => s.config)
10
10
  const buffer = useStore((s) => s.activeBufferId ? s.buffers.get(s.activeBufferId) : null)
11
- const connections = useStore((s) => s.connections)
11
+ const conn = useStore((s) => {
12
+ const buf = s.activeBufferId ? s.buffers.get(s.activeBufferId) : null
13
+ return buf ? s.connections.get(buf.connectionId) ?? null : null
14
+ })
12
15
  const activeBufferId = useStore((s) => s.activeBufferId)
13
16
  const setActiveBuffer = useStore((s) => s.setActiveBuffer)
14
17
 
@@ -24,7 +27,6 @@ export function StatusLine() {
24
27
 
25
28
  if (!config?.statusbar.enabled) return null
26
29
 
27
- const conn = buffer ? connections.get(buffer.connectionId) : null
28
30
  const items = config.statusbar.items
29
31
 
30
32
  function getItemFormat(item: StatusbarItem): string {