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.
- package/README.md +227 -0
- package/docs/commands/alias.md +42 -0
- package/docs/commands/ban.md +26 -0
- package/docs/commands/close.md +25 -0
- package/docs/commands/connect.md +26 -0
- package/docs/commands/deop.md +24 -0
- package/docs/commands/devoice.md +24 -0
- package/docs/commands/disconnect.md +26 -0
- package/docs/commands/help.md +28 -0
- package/docs/commands/ignore.md +47 -0
- package/docs/commands/items.md +95 -0
- package/docs/commands/join.md +25 -0
- package/docs/commands/kb.md +26 -0
- package/docs/commands/kick.md +25 -0
- package/docs/commands/log.md +82 -0
- package/docs/commands/me.md +24 -0
- package/docs/commands/mode.md +29 -0
- package/docs/commands/msg.md +26 -0
- package/docs/commands/nick.md +24 -0
- package/docs/commands/notice.md +24 -0
- package/docs/commands/op.md +24 -0
- package/docs/commands/part.md +25 -0
- package/docs/commands/quit.md +24 -0
- package/docs/commands/reload.md +19 -0
- package/docs/commands/script.md +126 -0
- package/docs/commands/server.md +61 -0
- package/docs/commands/set.md +37 -0
- package/docs/commands/topic.md +24 -0
- package/docs/commands/unalias.md +22 -0
- package/docs/commands/unban.md +25 -0
- package/docs/commands/unignore.md +25 -0
- package/docs/commands/voice.md +25 -0
- package/docs/commands/whois.md +24 -0
- package/docs/commands/wii.md +23 -0
- package/package.json +38 -0
- package/src/app/App.tsx +205 -0
- package/src/core/commands/docs.ts +183 -0
- package/src/core/commands/execution.ts +114 -0
- package/src/core/commands/help-formatter.ts +185 -0
- package/src/core/commands/helpers.ts +168 -0
- package/src/core/commands/index.ts +7 -0
- package/src/core/commands/parser.ts +33 -0
- package/src/core/commands/registry.ts +1394 -0
- package/src/core/commands/types.ts +19 -0
- package/src/core/config/defaults.ts +66 -0
- package/src/core/config/loader.ts +209 -0
- package/src/core/constants.ts +20 -0
- package/src/core/init.ts +32 -0
- package/src/core/irc/antiflood.ts +244 -0
- package/src/core/irc/client.ts +145 -0
- package/src/core/irc/events.ts +1031 -0
- package/src/core/irc/formatting.ts +132 -0
- package/src/core/irc/ignore.ts +84 -0
- package/src/core/irc/index.ts +2 -0
- package/src/core/irc/netsplit.ts +292 -0
- package/src/core/scripts/api.ts +240 -0
- package/src/core/scripts/event-bus.ts +82 -0
- package/src/core/scripts/index.ts +26 -0
- package/src/core/scripts/manager.ts +154 -0
- package/src/core/scripts/types.ts +256 -0
- package/src/core/state/selectors.ts +39 -0
- package/src/core/state/sorting.ts +30 -0
- package/src/core/state/store.ts +242 -0
- package/src/core/storage/crypto.ts +78 -0
- package/src/core/storage/db.ts +107 -0
- package/src/core/storage/index.ts +80 -0
- package/src/core/storage/query.ts +204 -0
- package/src/core/storage/types.ts +37 -0
- package/src/core/storage/writer.ts +130 -0
- package/src/core/theme/index.ts +3 -0
- package/src/core/theme/loader.ts +45 -0
- package/src/core/theme/parser.ts +518 -0
- package/src/core/theme/renderer.tsx +25 -0
- package/src/index.tsx +17 -0
- package/src/types/config.ts +126 -0
- package/src/types/index.ts +107 -0
- package/src/types/irc-framework.d.ts +569 -0
- package/src/types/theme.ts +37 -0
- package/src/ui/ErrorBoundary.tsx +42 -0
- package/src/ui/chat/ChatView.tsx +39 -0
- package/src/ui/chat/MessageLine.tsx +92 -0
- package/src/ui/hooks/useStatusbarColors.ts +23 -0
- package/src/ui/input/CommandInput.tsx +273 -0
- package/src/ui/layout/AppLayout.tsx +126 -0
- package/src/ui/layout/TopicBar.tsx +46 -0
- package/src/ui/sidebar/BufferList.tsx +55 -0
- package/src/ui/sidebar/NickList.tsx +96 -0
- package/src/ui/splash/SplashScreen.tsx +100 -0
- package/src/ui/statusbar/StatusLine.tsx +205 -0
- package/themes/.gitkeep +0 -0
- package/themes/default.theme +57 -0
- 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
|
+
}
|