kokoirc 0.2.0

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 (92) hide show
  1. package/README.md +227 -0
  2. package/docs/commands/alias.md +42 -0
  3. package/docs/commands/ban.md +26 -0
  4. package/docs/commands/close.md +25 -0
  5. package/docs/commands/connect.md +26 -0
  6. package/docs/commands/deop.md +24 -0
  7. package/docs/commands/devoice.md +24 -0
  8. package/docs/commands/disconnect.md +26 -0
  9. package/docs/commands/help.md +28 -0
  10. package/docs/commands/ignore.md +47 -0
  11. package/docs/commands/items.md +95 -0
  12. package/docs/commands/join.md +25 -0
  13. package/docs/commands/kb.md +26 -0
  14. package/docs/commands/kick.md +25 -0
  15. package/docs/commands/log.md +82 -0
  16. package/docs/commands/me.md +24 -0
  17. package/docs/commands/mode.md +29 -0
  18. package/docs/commands/msg.md +26 -0
  19. package/docs/commands/nick.md +24 -0
  20. package/docs/commands/notice.md +24 -0
  21. package/docs/commands/op.md +24 -0
  22. package/docs/commands/part.md +25 -0
  23. package/docs/commands/quit.md +24 -0
  24. package/docs/commands/reload.md +19 -0
  25. package/docs/commands/script.md +126 -0
  26. package/docs/commands/server.md +61 -0
  27. package/docs/commands/set.md +37 -0
  28. package/docs/commands/topic.md +24 -0
  29. package/docs/commands/unalias.md +22 -0
  30. package/docs/commands/unban.md +25 -0
  31. package/docs/commands/unignore.md +25 -0
  32. package/docs/commands/voice.md +25 -0
  33. package/docs/commands/whois.md +24 -0
  34. package/docs/commands/wii.md +23 -0
  35. package/package.json +38 -0
  36. package/src/app/App.tsx +205 -0
  37. package/src/core/commands/docs.ts +183 -0
  38. package/src/core/commands/execution.ts +114 -0
  39. package/src/core/commands/help-formatter.ts +185 -0
  40. package/src/core/commands/helpers.ts +168 -0
  41. package/src/core/commands/index.ts +7 -0
  42. package/src/core/commands/parser.ts +33 -0
  43. package/src/core/commands/registry.ts +1394 -0
  44. package/src/core/commands/types.ts +19 -0
  45. package/src/core/config/defaults.ts +66 -0
  46. package/src/core/config/loader.ts +209 -0
  47. package/src/core/constants.ts +20 -0
  48. package/src/core/init.ts +32 -0
  49. package/src/core/irc/antiflood.ts +244 -0
  50. package/src/core/irc/client.ts +145 -0
  51. package/src/core/irc/events.ts +1031 -0
  52. package/src/core/irc/formatting.ts +132 -0
  53. package/src/core/irc/ignore.ts +84 -0
  54. package/src/core/irc/index.ts +2 -0
  55. package/src/core/irc/netsplit.ts +292 -0
  56. package/src/core/scripts/api.ts +240 -0
  57. package/src/core/scripts/event-bus.ts +82 -0
  58. package/src/core/scripts/index.ts +26 -0
  59. package/src/core/scripts/manager.ts +154 -0
  60. package/src/core/scripts/types.ts +256 -0
  61. package/src/core/state/selectors.ts +39 -0
  62. package/src/core/state/sorting.ts +30 -0
  63. package/src/core/state/store.ts +242 -0
  64. package/src/core/storage/crypto.ts +78 -0
  65. package/src/core/storage/db.ts +107 -0
  66. package/src/core/storage/index.ts +80 -0
  67. package/src/core/storage/query.ts +204 -0
  68. package/src/core/storage/types.ts +37 -0
  69. package/src/core/storage/writer.ts +130 -0
  70. package/src/core/theme/index.ts +3 -0
  71. package/src/core/theme/loader.ts +45 -0
  72. package/src/core/theme/parser.ts +518 -0
  73. package/src/core/theme/renderer.tsx +25 -0
  74. package/src/index.tsx +17 -0
  75. package/src/types/config.ts +126 -0
  76. package/src/types/index.ts +107 -0
  77. package/src/types/irc-framework.d.ts +569 -0
  78. package/src/types/theme.ts +37 -0
  79. package/src/ui/ErrorBoundary.tsx +42 -0
  80. package/src/ui/chat/ChatView.tsx +39 -0
  81. package/src/ui/chat/MessageLine.tsx +92 -0
  82. package/src/ui/hooks/useStatusbarColors.ts +23 -0
  83. package/src/ui/input/CommandInput.tsx +273 -0
  84. package/src/ui/layout/AppLayout.tsx +126 -0
  85. package/src/ui/layout/TopicBar.tsx +46 -0
  86. package/src/ui/sidebar/BufferList.tsx +55 -0
  87. package/src/ui/sidebar/NickList.tsx +96 -0
  88. package/src/ui/splash/SplashScreen.tsx +100 -0
  89. package/src/ui/statusbar/StatusLine.tsx +205 -0
  90. package/themes/.gitkeep +0 -0
  91. package/themes/default.theme +57 -0
  92. package/tsconfig.json +19 -0
@@ -0,0 +1,42 @@
1
+ import React, { createElement } from "react"
2
+
3
+ interface Props {
4
+ children: React.ReactNode
5
+ }
6
+
7
+ interface State {
8
+ error: Error | null
9
+ }
10
+
11
+ class ErrorBoundaryImpl extends React.Component<Props, State> {
12
+ state: State = { error: null }
13
+
14
+ static getDerivedStateFromError(error: Error): State {
15
+ return { error }
16
+ }
17
+
18
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
19
+ console.error("[ErrorBoundary] Uncaught error:", error)
20
+ if (info.componentStack) {
21
+ console.error("[ErrorBoundary] Component stack:", info.componentStack)
22
+ }
23
+ }
24
+
25
+ render() {
26
+ if (this.state.error) {
27
+ return (
28
+ <box width="100%" height="100%" flexDirection="column" justifyContent="center" alignItems="center" backgroundColor="#1a1b26">
29
+ <text><span fg="#f7768e"><strong>kIRC crashed</strong></span></text>
30
+ <text><span fg="#a9b1d6">{this.state.error.message}</span></text>
31
+ <text><span fg="#565f89">Check console for details. Press Ctrl+C to exit.</span></text>
32
+ </box>
33
+ )
34
+ }
35
+ return this.props.children
36
+ }
37
+ }
38
+
39
+ /** Error boundary wrapper — uses createElement to bypass OpenTUI's JSX class component types. */
40
+ export function ErrorBoundary({ children }: Props): React.ReactNode {
41
+ return createElement(ErrorBoundaryImpl, null, children)
42
+ }
@@ -0,0 +1,39 @@
1
+ import { useRef, useEffect } from "react"
2
+ import { useStore } from "@/core/state/store"
3
+ import { MessageLine } from "./MessageLine"
4
+ import type { ScrollBoxRenderable } from "@opentui/core"
5
+
6
+ export function ChatView() {
7
+ const activeBufferId = useStore((s) => s.activeBufferId)
8
+ const buffersMap = useStore((s) => s.buffers)
9
+ const connectionsMap = useStore((s) => s.connections)
10
+ const colors = useStore((s) => s.theme?.colors)
11
+ const scrollRef = useRef<ScrollBoxRenderable>(null)
12
+
13
+ const buffer = activeBufferId ? buffersMap.get(activeBufferId) ?? null : null
14
+ const currentNick = buffer ? connectionsMap.get(buffer.connectionId)?.nick ?? "" : ""
15
+
16
+ // Snap to bottom when switching buffers
17
+ useEffect(() => {
18
+ if (scrollRef.current) {
19
+ scrollRef.current.stickyScroll = true
20
+ scrollRef.current.scrollTo(scrollRef.current.scrollHeight)
21
+ }
22
+ }, [activeBufferId])
23
+
24
+ if (!buffer) {
25
+ return (
26
+ <box flexGrow={1} justifyContent="center" alignItems="center">
27
+ <text><span fg={colors?.fg_dim ?? "#292e42"}>No active buffer</span></text>
28
+ </box>
29
+ )
30
+ }
31
+
32
+ return (
33
+ <scrollbox ref={scrollRef} height="100%" stickyScroll stickyStart="bottom">
34
+ {buffer.messages.map((msg) => (
35
+ <MessageLine key={msg.id} message={msg} isOwnNick={msg.nick === currentNick} />
36
+ ))}
37
+ </scrollbox>
38
+ )
39
+ }
@@ -0,0 +1,92 @@
1
+ import { useStore } from "@/core/state/store"
2
+ import { resolveAbstractions, parseFormatString, StyledText } from "@/core/theme"
3
+ import { formatTimestamp } from "@/core/irc/formatting"
4
+ import type { Message } from "@/types"
5
+ import type { StyledSpan } from "@/types/theme"
6
+
7
+ interface Props {
8
+ message: Message
9
+ isOwnNick: boolean
10
+ }
11
+
12
+ export function MessageLine({ message, isOwnNick }: Props) {
13
+ const theme = useStore((s) => s.theme)
14
+ const config = useStore((s) => s.config)
15
+ const abstracts = theme?.abstracts ?? {}
16
+ const messages: Record<string, string> = theme?.formats.messages ?? {}
17
+
18
+ // Timestamp
19
+ const ts = formatTimestamp(message.timestamp, config?.general.timestamp_format ?? "%H:%M:%S")
20
+ const tsFormat = abstracts.timestamp ?? "$*"
21
+ const tsResolved = resolveAbstractions(tsFormat, abstracts)
22
+ const tsSpans = parseFormatString(tsResolved, [ts])
23
+
24
+ let msgSpans: StyledSpan[]
25
+
26
+ if (message.type === "event") {
27
+ const events = theme?.formats.events ?? {}
28
+ if (message.eventKey && events[message.eventKey]) {
29
+ const format = events[message.eventKey]
30
+ const resolved = resolveAbstractions(format, abstracts)
31
+ msgSpans = parseFormatString(resolved, message.eventParams ?? [])
32
+ } else {
33
+ // System events (whois, help, status) — text may contain inline %Z codes
34
+ msgSpans = parseFormatString(message.text, [])
35
+ }
36
+ } else {
37
+ // Message/action/notice
38
+ const nickWidth = config?.display.nick_column_width ?? 8
39
+ const alignment = config?.display.nick_alignment ?? "right"
40
+ const rawNickMode = message.nickMode ?? ""
41
+ const nick = message.nick ?? ""
42
+ const maxLen = config?.display.nick_max_length ?? nickWidth
43
+ const truncate = config?.display.nick_truncation ?? true
44
+
45
+ // Truncate nick if needed
46
+ let displayNick = nick
47
+ if (truncate && displayNick.length > maxLen) {
48
+ displayNick = displayNick.slice(0, maxLen)
49
+ }
50
+
51
+ // Pad the combined mode+nick so alignment covers the whole column
52
+ // e.g., width=8, mode="@", nick="brudny" → " @brudny" (right-aligned)
53
+ const totalLen = rawNickMode.length + displayNick.length
54
+ const padSize = Math.max(0, nickWidth - totalLen)
55
+ let nickMode: string
56
+ if (alignment === "right") {
57
+ nickMode = " ".repeat(padSize) + rawNickMode
58
+ } else if (alignment === "center") {
59
+ const left = Math.floor(padSize / 2)
60
+ nickMode = " ".repeat(left) + rawNickMode
61
+ displayNick = displayNick + " ".repeat(padSize - left)
62
+ } else {
63
+ nickMode = rawNickMode
64
+ displayNick = displayNick + " ".repeat(padSize)
65
+ }
66
+
67
+ let msgFormatKey: string
68
+ if (message.type === "action") {
69
+ msgFormatKey = "action"
70
+ } else if (isOwnNick) {
71
+ msgFormatKey = "own_msg"
72
+ } else if (message.highlight) {
73
+ msgFormatKey = "pubmsg_mention"
74
+ } else {
75
+ msgFormatKey = "pubmsg"
76
+ }
77
+
78
+ const msgFormat = messages[msgFormatKey] ?? "$0 $1"
79
+ const resolved = resolveAbstractions(msgFormat, abstracts)
80
+ msgSpans = parseFormatString(resolved, [displayNick, message.text, nickMode])
81
+ }
82
+
83
+ // Combine timestamp + space + message into single text element
84
+ const separator: StyledSpan = { text: " ", bold: false, italic: false, underline: false, dim: false }
85
+ const allSpans = [...tsSpans, separator, ...msgSpans]
86
+
87
+ return (
88
+ <box width="100%">
89
+ <StyledText spans={allSpans} />
90
+ </box>
91
+ )
92
+ }
@@ -0,0 +1,23 @@
1
+ import { useStore } from "@/core/state/store"
2
+
3
+ /** Resolve statusbar color: config override → theme fallback */
4
+ function c(configVal: string, themeFallback: string | undefined, hardFallback: string): string {
5
+ return configVal || themeFallback || hardFallback
6
+ }
7
+
8
+ export function useStatusbarColors() {
9
+ const sb = useStore((s) => s.config?.statusbar)
10
+ const colors = useStore((s) => s.theme?.colors)
11
+ return {
12
+ bg: c(sb?.background ?? "", colors?.bg_alt, "#16161e"),
13
+ accent: c(sb?.accent_color ?? "", colors?.accent, "#7aa2f7"),
14
+ text: c(sb?.text_color ?? "", colors?.fg, "#a9b1d6"),
15
+ muted: c(sb?.muted_color ?? "", colors?.fg_muted, "#565f89"),
16
+ dim: c(sb?.dim_color ?? "", colors?.fg_dim, "#292e42"),
17
+ promptColor: c(sb?.prompt_color ?? "", colors?.accent, "#7aa2f7"),
18
+ inputColor: c(sb?.input_color ?? "", colors?.fg, "#c0caf5"),
19
+ cursorColor: c(sb?.cursor_color ?? "", colors?.cursor, "#7aa2f7"),
20
+ prompt: sb?.prompt ?? "[$channel] > ",
21
+ separator: sb?.separator ?? " | ",
22
+ }
23
+ }
@@ -0,0 +1,273 @@
1
+ import { useState, useRef, useCallback, useEffect } from "react"
2
+ import { useStore } from "@/core/state/store"
3
+ import { parseCommand, executeCommand, getCommandNames, getSubcommands } from "@/core/commands"
4
+ import { getClient } from "@/core/irc"
5
+ import { useKeyboard, useRenderer } from "@opentui/react"
6
+ import { useStatusbarColors } from "@/ui/hooks/useStatusbarColors"
7
+ import type { InputRenderable } from "@opentui/core"
8
+
9
+ export function CommandInput() {
10
+ const renderer = useRenderer()
11
+ const [value, setValue] = useState("")
12
+ const [history, setHistory] = useState<string[]>([])
13
+ const [historyIndex, setHistoryIndex] = useState(-1)
14
+ const inputRef = useRef<InputRenderable>(null)
15
+
16
+ // Auto-copy selected text to clipboard, then clear selection and refocus input
17
+ useEffect(() => {
18
+ const handleSelection = (selection: any) => {
19
+ if (!selection || selection.isDragging) return
20
+ const text = selection.getSelectedText()
21
+ if (text) {
22
+ renderer.copyToClipboardOSC52(text)
23
+ }
24
+ // Clear selection highlight and return focus to input for immediate Cmd+V
25
+ renderer.clearSelection()
26
+ if (inputRef.current) inputRef.current.focus()
27
+ }
28
+ renderer.on("selection", handleSelection)
29
+ return () => { renderer.off("selection", handleSelection) }
30
+ }, [renderer])
31
+
32
+ // Tab completion state
33
+ const tabState = useRef<{
34
+ prefix: string // the partial text typed by user
35
+ matches: string[] // sorted matching candidates
36
+ index: number // current position in matches cycle
37
+ textBefore: string // text before the word being completed
38
+ isStartOfLine: boolean
39
+ isCommand: boolean // true = completing /command, false = completing nick
40
+ isSubcommand: boolean // true = completing subcommand after /command
41
+ } | null>(null)
42
+ const isTabCompleting = useRef(false)
43
+
44
+ const buffer = useStore((s) => s.activeBufferId ? s.buffers.get(s.activeBufferId) : null)
45
+ const conn = useStore((s) => {
46
+ const buf = s.activeBufferId ? s.buffers.get(s.activeBufferId) : null
47
+ return buf ? s.connections.get(buf.connectionId) : null
48
+ })
49
+ const addMessage = useStore((s) => s.addMessage)
50
+ const sb = useStatusbarColors()
51
+
52
+ const handleSubmit = useCallback((submittedValue?: string | unknown) => {
53
+ const text = typeof submittedValue === "string" ? submittedValue : value
54
+ const trimmed = text.trim()
55
+ if (!trimmed) return
56
+
57
+ setHistory((h) => [trimmed, ...h].slice(0, 100))
58
+ setHistoryIndex(-1)
59
+ setValue("")
60
+ // Force clear via ref in case React state sync is delayed
61
+ if (inputRef.current) {
62
+ inputRef.current.value = ""
63
+ inputRef.current.focus()
64
+ }
65
+
66
+ if (!buffer) return
67
+
68
+ const parsed = parseCommand(trimmed)
69
+
70
+ if (parsed) {
71
+ executeCommand(parsed, buffer.connectionId)
72
+ } else {
73
+ const client = getClient(buffer.connectionId)
74
+ if (client) {
75
+ client.say(buffer.name, trimmed)
76
+ const conn = useStore.getState().connections.get(buffer.connectionId)
77
+ addMessage(buffer.id, {
78
+ id: crypto.randomUUID(),
79
+ timestamp: new Date(),
80
+ type: "message",
81
+ nick: conn?.nick ?? "",
82
+ nickMode: "",
83
+ text: trimmed,
84
+ highlight: false,
85
+ })
86
+ }
87
+ }
88
+ }, [value, buffer, addMessage])
89
+
90
+ const tryNickCompletion = (currentValue: string) => {
91
+ if (!buffer) return null
92
+ const nicks = Array.from(buffer.users.keys())
93
+ if (nicks.length === 0) return null
94
+
95
+ const spaceIdx = currentValue.lastIndexOf(" ")
96
+ const textBefore = spaceIdx >= 0 ? currentValue.slice(0, spaceIdx + 1) : ""
97
+ const partial = spaceIdx >= 0 ? currentValue.slice(spaceIdx + 1) : currentValue
98
+ const isStartOfLine = spaceIdx < 0
99
+
100
+ if (!partial) return null
101
+
102
+ const lower = partial.toLowerCase()
103
+ const matches = nicks
104
+ .filter((n) => n.toLowerCase().startsWith(lower))
105
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
106
+
107
+ if (matches.length === 0) return null
108
+
109
+ const s = { prefix: partial, matches, index: 0, textBefore, isStartOfLine, isCommand: false, isSubcommand: false }
110
+ tabState.current = s
111
+ return s
112
+ }
113
+
114
+ const handleTabCompletion = useCallback(() => {
115
+ const currentValue = inputRef.current?.value ?? value
116
+ let state = tabState.current
117
+
118
+ if (state) {
119
+ // Continue cycling through matches
120
+ state.index = (state.index + 1) % state.matches.length
121
+ } else {
122
+ // ── Command completion: "/par" → "/part "
123
+ if (currentValue.startsWith("/") && !currentValue.includes(" ")) {
124
+ const partial = currentValue.slice(1).toLowerCase()
125
+ if (!partial) return
126
+ const cmdNames = getCommandNames()
127
+ const matches = cmdNames.filter((n) => n.startsWith(partial))
128
+ if (matches.length === 0) return
129
+ state = { prefix: partial, matches, index: 0, textBefore: "/", isStartOfLine: false, isCommand: true, isSubcommand: false }
130
+ tabState.current = state
131
+ } else if (currentValue.startsWith("/") && currentValue.includes(" ")) {
132
+ // ── Subcommand / help argument completion
133
+ const firstSpace = currentValue.indexOf(" ")
134
+ const cmdName = currentValue.slice(1, firstSpace).toLowerCase()
135
+ const afterFirst = currentValue.slice(firstSpace + 1)
136
+
137
+ if (cmdName === "help") {
138
+ // /help special case: two levels of completion
139
+ const secondSpace = afterFirst.indexOf(" ")
140
+ if (secondSpace === -1) {
141
+ // "/help <partial>" → complete with command names
142
+ const partial = afterFirst.toLowerCase()
143
+ const cmdNames = getCommandNames()
144
+ const matches = partial ? cmdNames.filter((n) => n.startsWith(partial)) : cmdNames
145
+ if (matches.length > 0) {
146
+ state = { prefix: partial, matches, index: 0, textBefore: currentValue.slice(0, firstSpace + 1), isStartOfLine: false, isCommand: false, isSubcommand: true }
147
+ tabState.current = state
148
+ }
149
+ } else {
150
+ // "/help server <partial>" → complete with that command's subcommands
151
+ const helpTarget = afterFirst.slice(0, secondSpace).toLowerCase()
152
+ const partial = afterFirst.slice(secondSpace + 1).toLowerCase()
153
+ if (!partial.includes(" ")) {
154
+ const subs = getSubcommands(helpTarget)
155
+ if (subs.length > 0) {
156
+ const matches = partial ? subs.filter((s) => s.startsWith(partial)) : subs
157
+ if (matches.length > 0) {
158
+ state = { prefix: partial, matches, index: 0, textBefore: currentValue.slice(0, firstSpace + 1 + secondSpace + 1), isStartOfLine: false, isCommand: false, isSubcommand: true }
159
+ tabState.current = state
160
+ }
161
+ }
162
+ }
163
+ }
164
+ } else if (!afterFirst.includes(" ")) {
165
+ // Regular subcommand completion: "/server li" → "/server list "
166
+ const partial = afterFirst.toLowerCase()
167
+ const subs = getSubcommands(cmdName)
168
+ if (subs.length > 0) {
169
+ const matches = partial ? subs.filter((s) => s.startsWith(partial)) : subs
170
+ if (matches.length > 0) {
171
+ state = { prefix: partial, matches, index: 0, textBefore: currentValue.slice(0, firstSpace + 1), isStartOfLine: false, isCommand: false, isSubcommand: true }
172
+ tabState.current = state
173
+ }
174
+ }
175
+ }
176
+ // Fall through to nick completion if no subcommand matches
177
+ if (!state) {
178
+ state = tryNickCompletion(currentValue)
179
+ }
180
+ } else {
181
+ state = tryNickCompletion(currentValue)
182
+ }
183
+ }
184
+
185
+ if (!state) return
186
+
187
+ let completed: string
188
+ if (state.isCommand) {
189
+ completed = "/" + state.matches[state.index] + " "
190
+ } else if (state.isSubcommand) {
191
+ completed = state.textBefore + state.matches[state.index] + " "
192
+ } else {
193
+ const nick = state.matches[state.index]
194
+ const suffix = state.isStartOfLine ? ": " : " "
195
+ completed = state.textBefore + nick + suffix
196
+ }
197
+ isTabCompleting.current = true
198
+ setValue(completed)
199
+ if (inputRef.current) inputRef.current.value = completed
200
+ isTabCompleting.current = false
201
+ }, [value, buffer])
202
+
203
+ const resetTabState = useCallback(() => {
204
+ tabState.current = null
205
+ }, [])
206
+
207
+ useKeyboard((key) => {
208
+ if (key.eventType !== "press") return
209
+
210
+ // Ensure input stays focused on any keypress
211
+ if (inputRef.current) inputRef.current.focus()
212
+
213
+ if (key.name === "tab") {
214
+ handleTabCompletion()
215
+ return
216
+ }
217
+
218
+ // Any non-tab key resets tab completion cycling
219
+ resetTabState()
220
+
221
+ if (key.name === "up") {
222
+ if (history.length > 0) {
223
+ const newIdx = Math.min(historyIndex + 1, history.length - 1)
224
+ setHistoryIndex(newIdx)
225
+ const val = history[newIdx]
226
+ setValue(val)
227
+ if (inputRef.current) inputRef.current.value = val
228
+ }
229
+ }
230
+ if (key.name === "down") {
231
+ if (historyIndex > 0) {
232
+ const newIdx = historyIndex - 1
233
+ setHistoryIndex(newIdx)
234
+ const val = history[newIdx]
235
+ setValue(val)
236
+ if (inputRef.current) inputRef.current.value = val
237
+ } else {
238
+ setHistoryIndex(-1)
239
+ setValue("")
240
+ if (inputRef.current) inputRef.current.value = ""
241
+ }
242
+ }
243
+ })
244
+
245
+ // Build prompt from config template: $channel, $nick, $buffer, $server
246
+ const promptTemplate = sb.prompt
247
+ const prompt = promptTemplate
248
+ .replace("$server", conn?.label ?? "")
249
+ .replace("$channel", buffer?.name ?? "")
250
+ .replace("$nick", conn?.nick ?? "")
251
+ .replace("$buffer", buffer?.name ?? "")
252
+
253
+ return (
254
+ <box flexDirection="row" width="100%" backgroundColor={sb.bg}>
255
+ <text><span fg={sb.promptColor}>{prompt}</span></text>
256
+ <input
257
+ ref={inputRef}
258
+ value={value}
259
+ onChange={(v: string) => {
260
+ setValue(v)
261
+ // Reset tab state when user types, but not during programmatic tab completion
262
+ if (!isTabCompleting.current) resetTabState()
263
+ }}
264
+ onSubmit={handleSubmit}
265
+ focused
266
+ flexGrow={1}
267
+ backgroundColor={sb.bg}
268
+ textColor={sb.inputColor}
269
+ cursorColor={sb.cursorColor}
270
+ />
271
+ </box>
272
+ )
273
+ }
@@ -0,0 +1,126 @@
1
+ import { useState, useRef, useEffect } from "react"
2
+ import { useStore } from "@/core/state/store"
3
+ import { useStatusbarColors } from "@/ui/hooks/useStatusbarColors"
4
+ import { cloneConfig, saveConfig } from "@/core/config/loader"
5
+ import { CONFIG_PATH } from "@/core/constants"
6
+ import { BufferType } from "@/types"
7
+
8
+ const MIN_WIDTH = 10
9
+ const MAX_WIDTH = 50
10
+
11
+ interface Props {
12
+ sidebar: React.ReactNode
13
+ chat: React.ReactNode
14
+ nicklist: React.ReactNode
15
+ input: React.ReactNode
16
+ topicbar: React.ReactNode
17
+ statusline?: React.ReactNode
18
+ }
19
+
20
+ export function AppLayout({ sidebar, chat, nicklist, input, topicbar, statusline }: Props) {
21
+ const config = useStore((s) => s.config)
22
+ const colors = useStore((s) => s.theme?.colors)
23
+ const buffer = useStore((s) => s.activeBufferId ? s.buffers.get(s.activeBufferId) : null)
24
+ const sb = useStatusbarColors()
25
+ const leftWidth = config?.sidepanel.left.width ?? 20
26
+ const rightWidth = config?.sidepanel.right.width ?? 18
27
+ const leftVisible = config?.sidepanel.left.visible ?? true
28
+ const rightVisible = config?.sidepanel.right.visible ?? true
29
+
30
+ // Hide nicklist for non-channel buffers (query, server, special)
31
+ const showNicklist = rightVisible && buffer?.type === BufferType.Channel
32
+
33
+ // Drag-to-resize state
34
+ const [liveLeftWidth, setLiveLeftWidth] = useState(leftWidth)
35
+ const [liveRightWidth, setLiveRightWidth] = useState(rightWidth)
36
+ const dragRef = useRef<{ side: "left" | "right"; startX: number; startWidth: number; currentWidth: number } | null>(null)
37
+ const store = useStore()
38
+
39
+ useEffect(() => { setLiveLeftWidth(leftWidth) }, [leftWidth])
40
+ useEffect(() => { setLiveRightWidth(rightWidth) }, [rightWidth])
41
+
42
+ function startDrag(side: "left" | "right", event: any) {
43
+ const w = side === "left" ? liveLeftWidth : liveRightWidth
44
+ dragRef.current = { side, startX: event.x, startWidth: w, currentWidth: w }
45
+ }
46
+
47
+ function onDrag(event: any) {
48
+ const d = dragRef.current
49
+ if (!d) return
50
+ const delta = d.side === "left" ? event.x - d.startX : d.startX - event.x
51
+ const newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, d.startWidth + delta))
52
+ d.currentWidth = newWidth
53
+ if (d.side === "left") setLiveLeftWidth(newWidth)
54
+ else setLiveRightWidth(newWidth)
55
+ }
56
+
57
+ function endDrag() {
58
+ const d = dragRef.current
59
+ if (!d) return
60
+ dragRef.current = null
61
+ const newConfig = cloneConfig(store.config!)
62
+ if (d.side === "left") newConfig.sidepanel.left.width = d.currentWidth
63
+ else newConfig.sidepanel.right.width = d.currentWidth
64
+ store.setConfig(newConfig)
65
+ saveConfig(CONFIG_PATH, newConfig)
66
+ }
67
+
68
+ const bg = colors?.bg ?? "#1a1b26"
69
+ const border = colors?.border ?? "#292e42"
70
+
71
+ return (
72
+ <box flexDirection="column" width="100%" height="100%" backgroundColor={bg}>
73
+ {/* Topic bar */}
74
+ <box height={1}>{topicbar}</box>
75
+
76
+ {/* Main area: sidebar | chat | nicklist */}
77
+ <box flexDirection="row" flexGrow={1}>
78
+ {leftVisible && (
79
+ <box width={liveLeftWidth} flexDirection="column" border={["right"]} borderStyle="single" borderColor={border}>
80
+ {sidebar}
81
+ </box>
82
+ )}
83
+ <box flexGrow={1} flexDirection="column">
84
+ {chat}
85
+ </box>
86
+ {showNicklist && (
87
+ <box width={liveRightWidth} flexDirection="column" border={["left"]} borderStyle="single" borderColor={border}>
88
+ {nicklist}
89
+ </box>
90
+ )}
91
+
92
+ {/* Invisible drag hit zones overlaying the borders */}
93
+ {leftVisible && (
94
+ <box
95
+ position="absolute"
96
+ left={liveLeftWidth - 2}
97
+ top={0}
98
+ width={2}
99
+ height="100%"
100
+ onMouseDown={(e: any) => startDrag("left", e)}
101
+ onMouseDrag={onDrag}
102
+ onMouseDragEnd={endDrag}
103
+ />
104
+ )}
105
+ {showNicklist && (
106
+ <box
107
+ position="absolute"
108
+ right={liveRightWidth - 1}
109
+ top={0}
110
+ width={2}
111
+ height="100%"
112
+ onMouseDown={(e: any) => startDrag("right", e)}
113
+ onMouseDrag={onDrag}
114
+ onMouseDragEnd={endDrag}
115
+ />
116
+ )}
117
+ </box>
118
+
119
+ {/* Status line + Input area — shared background from config */}
120
+ <box height={statusline ? 3 : 2} flexDirection="column" border={["top"]} borderStyle="single" borderColor={border} backgroundColor={sb.bg}>
121
+ {statusline}
122
+ {input}
123
+ </box>
124
+ </box>
125
+ )
126
+ }
@@ -0,0 +1,46 @@
1
+ import { useStore } from "@/core/state/store"
2
+ import { parseFormatString, StyledText } from "@/core/theme"
3
+ import { BufferType } from "@/types"
4
+ import type { StyledSpan } from "@/types/theme"
5
+
6
+ export function TopicBar() {
7
+ const activeBufferId = useStore((s) => s.activeBufferId)
8
+ const buffer = useStore((s) => s.activeBufferId ? s.buffers.get(s.activeBufferId) : null)
9
+ const colors = useStore((s) => s.theme?.colors)
10
+ const topic = buffer?.topic ?? ""
11
+ const name = buffer?.name ?? ""
12
+
13
+ const bgAlt = colors?.bg_alt ?? "#16161e"
14
+ const accent = colors?.accent ?? "#7aa2f7"
15
+ const fgMuted = colors?.fg_muted ?? "#565f89"
16
+ const fg = colors?.fg ?? "#a9b1d6"
17
+
18
+ const isQuery = buffer?.type === BufferType.Query
19
+
20
+ // Build the topic bar as styled spans
21
+ const spans: StyledSpan[] = []
22
+ const plain = (text: string, color?: string): StyledSpan => ({
23
+ text, fg: color, bold: false, italic: false, underline: false, dim: false,
24
+ })
25
+
26
+ spans.push(plain(name, accent))
27
+
28
+ if (isQuery && topic) {
29
+ spans.push(plain(` (${topic})`, fgMuted))
30
+ } else if (topic) {
31
+ spans.push(plain(" — ", fgMuted))
32
+ // Parse mIRC/IRC formatting codes in topic text
33
+ const topicSpans = parseFormatString(topic, [])
34
+ // Set default fg on spans that don't have an explicit color
35
+ for (const s of topicSpans) {
36
+ if (!s.fg) s.fg = fg
37
+ }
38
+ spans.push(...topicSpans)
39
+ }
40
+
41
+ return (
42
+ <box width="100%" backgroundColor={bgAlt}>
43
+ <StyledText spans={spans} />
44
+ </box>
45
+ )
46
+ }