kokoirc 0.2.3 → 0.2.5

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -39
  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/names.md +25 -0
  7. package/docs/commands/preview.md +31 -0
  8. package/docs/commands/topic.md +12 -6
  9. package/docs/commands/version.md +23 -0
  10. package/package.json +46 -3
  11. package/src/app/App.tsx +27 -3
  12. package/src/core/commands/help-formatter.ts +1 -1
  13. package/src/core/commands/helpers.ts +3 -1
  14. package/src/core/commands/registry.ts +182 -5
  15. package/src/core/config/defaults.ts +11 -0
  16. package/src/core/config/loader.ts +4 -2
  17. package/src/core/constants.ts +3 -0
  18. package/src/core/image-preview/cache.ts +108 -0
  19. package/src/core/image-preview/detect.ts +105 -0
  20. package/src/core/image-preview/encode.ts +116 -0
  21. package/src/core/image-preview/fetch.ts +174 -0
  22. package/src/core/image-preview/index.ts +6 -0
  23. package/src/core/image-preview/render.ts +222 -0
  24. package/src/core/image-preview/stdin-guard.ts +33 -0
  25. package/src/core/init.ts +2 -1
  26. package/src/core/irc/antiflood.ts +5 -4
  27. package/src/core/irc/client.ts +13 -2
  28. package/src/core/irc/events.ts +140 -109
  29. package/src/core/irc/netsplit.ts +2 -1
  30. package/src/core/scripts/api.ts +13 -3
  31. package/src/core/state/selectors.ts +1 -2
  32. package/src/core/state/store.ts +384 -18
  33. package/src/core/storage/index.ts +2 -2
  34. package/src/core/storage/writer.ts +12 -10
  35. package/src/core/theme/index.ts +1 -1
  36. package/src/core/theme/parser.ts +3 -1
  37. package/src/core/theme/renderer.tsx +46 -16
  38. package/src/core/utils/id.ts +2 -0
  39. package/src/types/config.ts +13 -0
  40. package/src/types/index.ts +1 -2
  41. package/src/types/theme.ts +1 -0
  42. package/src/ui/chat/ChatView.tsx +21 -10
  43. package/src/ui/chat/MessageLine.tsx +44 -6
  44. package/src/ui/input/CommandInput.tsx +56 -1
  45. package/src/ui/layout/AppLayout.tsx +6 -4
  46. package/src/ui/overlay/ImagePreview.tsx +77 -0
  47. package/src/ui/sidebar/BufferList.tsx +16 -7
  48. package/src/ui/sidebar/NickList.tsx +15 -7
  49. package/src/ui/splash/SplashScreen.tsx +6 -2
  50. package/src/ui/statusbar/StatusLine.tsx +4 -2
@@ -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
  }
@@ -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,27 +1,38 @@
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 activeBufferId = useStore((s) => s.activeBufferId)
8
- const buffersMap = useStore((s) => s.buffers)
9
- const connectionsMap = useStore((s) => s.connections)
11
+ const data = useStore(useShallow((s) => {
12
+ const id = s.activeBufferId
13
+ if (!id) return NO_BUFFER
14
+ const buf = s.buffers.get(id)
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
+ }))
10
24
  const colors = useStore((s) => s.theme?.colors)
11
25
  const scrollRef = useRef<ScrollBoxRenderable>(null)
12
26
 
13
- const buffer = activeBufferId ? buffersMap.get(activeBufferId) ?? null : null
14
- const currentNick = buffer ? connectionsMap.get(buffer.connectionId)?.nick ?? "" : ""
15
-
16
27
  // Snap to bottom when switching buffers
17
28
  useEffect(() => {
18
29
  if (scrollRef.current) {
19
30
  scrollRef.current.stickyScroll = true
20
31
  scrollRef.current.scrollTo(scrollRef.current.scrollHeight)
21
32
  }
22
- }, [activeBufferId])
33
+ }, [data.activeBufferId])
23
34
 
24
- if (!buffer) {
35
+ if (!data.hasBuffer) {
25
36
  return (
26
37
  <box flexGrow={1} justifyContent="center" alignItems="center">
27
38
  <text><span fg={colors?.fg_dim ?? "#292e42"}>No active buffer</span></text>
@@ -31,8 +42,8 @@ export function ChatView() {
31
42
 
32
43
  return (
33
44
  <scrollbox ref={scrollRef} height="100%" stickyScroll stickyStart="bottom">
34
- {buffer.messages.map((msg) => (
35
- <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} />
36
47
  ))}
37
48
  </scrollbox>
38
49
  )
@@ -1,15 +1,19 @@
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"
5
+ import { classifyUrl } from "@/core/image-preview/fetch"
4
6
  import type { Message } from "@/types"
5
7
  import type { StyledSpan } from "@/types/theme"
6
8
 
9
+ const URL_RE = /https?:\/\/[^\s<>"')\]]+/gi
10
+
7
11
  interface Props {
8
12
  message: Message
9
13
  isOwnNick: boolean
10
14
  }
11
15
 
12
- export function MessageLine({ message, isOwnNick }: Props) {
16
+ export const MessageLine = React.memo(function MessageLine({ message, isOwnNick }: Props) {
13
17
  const theme = useStore((s) => s.theme)
14
18
  const config = useStore((s) => s.config)
15
19
  const abstracts = theme?.abstracts ?? {}
@@ -42,10 +46,10 @@ export function MessageLine({ message, isOwnNick }: Props) {
42
46
  const maxLen = config?.display.nick_max_length ?? nickWidth
43
47
  const truncate = config?.display.nick_truncation ?? true
44
48
 
45
- // Truncate nick if needed
49
+ // Truncate nick if needed — show "+" to indicate truncation
46
50
  let displayNick = nick
47
51
  if (truncate && displayNick.length > maxLen) {
48
- displayNick = displayNick.slice(0, maxLen)
52
+ displayNick = displayNick.slice(0, maxLen - 1) + "+"
49
53
  }
50
54
 
51
55
  // Pad the combined mode+nick so alignment covers the whole column
@@ -84,9 +88,43 @@ export function MessageLine({ message, isOwnNick }: Props) {
84
88
  const separator: StyledSpan = { text: " ", bold: false, italic: false, underline: false, dim: false }
85
89
  const allSpans = [...tsSpans, separator, ...msgSpans]
86
90
 
91
+ // Click any URL in the message to attempt image preview (erssi-style content-type sniffing)
92
+ const handleClick = () => {
93
+ const text = message.text
94
+ const urls = text.match(URL_RE)
95
+ if (!urls) return
96
+
97
+ for (const url of urls) {
98
+ if (classifyUrl(url)) {
99
+ useStore.getState().showImagePreview(url)
100
+ return
101
+ }
102
+ }
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
+
87
125
  return (
88
- <box width="100%">
126
+ <box width="100%" onMouseDown={handleClick}>
89
127
  <StyledText spans={allSpans} />
90
128
  </box>
91
129
  )
92
- }
130
+ })
@@ -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"
@@ -49,6 +50,10 @@ export function CommandInput() {
49
50
  const addMessage = useStore((s) => s.addMessage)
50
51
  const sb = useStatusbarColors()
51
52
 
53
+ // ── Multiline paste handling ──────────────────────────────────
54
+ const pasteQueueRef = useRef<ReturnType<typeof setTimeout>[]>([])
55
+ const handleSubmitRef = useRef<(v: string) => void>(() => {})
56
+
52
57
  const handleSubmit = useCallback((submittedValue?: string | unknown) => {
53
58
  const text = typeof submittedValue === "string" ? submittedValue : value
54
59
  const trimmed = text.trim()
@@ -75,7 +80,7 @@ export function CommandInput() {
75
80
  client.say(buffer.name, trimmed)
76
81
  const conn = useStore.getState().connections.get(buffer.connectionId)
77
82
  addMessage(buffer.id, {
78
- id: crypto.randomUUID(),
83
+ id: nextMsgId(),
79
84
  timestamp: new Date(),
80
85
  type: "message",
81
86
  nick: conn?.nick ?? "",
@@ -87,6 +92,56 @@ export function CommandInput() {
87
92
  }
88
93
  }, [value, buffer, addMessage])
89
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
+
90
145
  const tryNickCompletion = (currentValue: string) => {
91
146
  if (!buffer) return null
92
147
  const nicks = Array.from(buffer.users.keys())
@@ -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)
@@ -34,7 +35,6 @@ export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline
34
35
  const [liveLeftWidth, setLiveLeftWidth] = useState(leftWidth)
35
36
  const [liveRightWidth, setLiveRightWidth] = useState(rightWidth)
36
37
  const dragRef = useRef<{ side: "left" | "right"; startX: number; startWidth: number; currentWidth: number } | null>(null)
37
- const store = useStore()
38
38
 
39
39
  useEffect(() => { setLiveLeftWidth(leftWidth) }, [leftWidth])
40
40
  useEffect(() => { setLiveRightWidth(rightWidth) }, [rightWidth])
@@ -58,10 +58,11 @@ export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline
58
58
  const d = dragRef.current
59
59
  if (!d) return
60
60
  dragRef.current = null
61
- const newConfig = cloneConfig(store.config!)
61
+ const s = useStore.getState()
62
+ const newConfig = cloneConfig(s.config!)
62
63
  if (d.side === "left") newConfig.sidepanel.left.width = d.currentWidth
63
64
  else newConfig.sidepanel.right.width = d.currentWidth
64
- store.setConfig(newConfig)
65
+ s.setConfig(newConfig)
65
66
  saveConfig(CONFIG_PATH, newConfig)
66
67
  }
67
68
 
@@ -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
+ }
@@ -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 {