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,55 @@
|
|
|
1
|
+
import { useStore } from "@/core/state/store"
|
|
2
|
+
import { useSortedBuffers } from "@/core/state/selectors"
|
|
3
|
+
import { resolveAbstractions, parseFormatString, StyledText } from "@/core/theme"
|
|
4
|
+
|
|
5
|
+
export function BufferList() {
|
|
6
|
+
const activeBufferId = useStore((s) => s.activeBufferId)
|
|
7
|
+
const theme = useStore((s) => s.theme)
|
|
8
|
+
const setActiveBuffer = useStore((s) => s.setActiveBuffer)
|
|
9
|
+
const leftWidth = useStore((s) => s.config?.sidepanel.left.width ?? 20)
|
|
10
|
+
|
|
11
|
+
const buffers = useSortedBuffers()
|
|
12
|
+
|
|
13
|
+
let lastConnectionId = ""
|
|
14
|
+
let refNum = 0
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<scrollbox height="100%">
|
|
18
|
+
{buffers.map((buf) => {
|
|
19
|
+
const items: React.ReactNode[] = []
|
|
20
|
+
|
|
21
|
+
// Connection header
|
|
22
|
+
if (buf.connectionId !== lastConnectionId) {
|
|
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])
|
|
27
|
+
items.push(
|
|
28
|
+
<box key={`h-${buf.connectionId}`} width="100%">
|
|
29
|
+
<StyledText spans={spans} />
|
|
30
|
+
</box>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
refNum++
|
|
35
|
+
const isActive = buf.id === activeBufferId
|
|
36
|
+
const formatKey = isActive
|
|
37
|
+
? "item_selected"
|
|
38
|
+
: `item_activity_${buf.activity}`
|
|
39
|
+
const format = theme?.formats.sidepanel[formatKey] ?? "$0. $1"
|
|
40
|
+
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])
|
|
44
|
+
|
|
45
|
+
items.push(
|
|
46
|
+
<box key={buf.id} width="100%" onMouseDown={() => setActiveBuffer(buf.id)}>
|
|
47
|
+
<StyledText spans={spans} />
|
|
48
|
+
</box>
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return items
|
|
52
|
+
})}
|
|
53
|
+
</scrollbox>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useMemo } from "react"
|
|
2
|
+
import { useStore } from "@/core/state/store"
|
|
3
|
+
import { sortNicks } from "@/core/state/sorting"
|
|
4
|
+
import { resolveAbstractions, parseFormatString, StyledText } from "@/core/theme"
|
|
5
|
+
import { BufferType, ActivityLevel, makeBufferId } from "@/types"
|
|
6
|
+
|
|
7
|
+
const DEFAULT_PREFIX_ORDER = "~&@%+"
|
|
8
|
+
const EMPTY_NICKS: import("@/types").NickEntry[] = []
|
|
9
|
+
|
|
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)
|
|
14
|
+
const theme = useStore((s) => s.theme)
|
|
15
|
+
const colors = theme?.colors
|
|
16
|
+
|
|
17
|
+
const buffer = activeBufferId ? buffersMap.get(activeBufferId) ?? null : null
|
|
18
|
+
const conn = buffer ? connectionsMap.get(buffer.connectionId) : undefined
|
|
19
|
+
|
|
20
|
+
const prefixOrder = conn?.isupport?.PREFIX
|
|
21
|
+
? extractPrefixChars(conn.isupport.PREFIX)
|
|
22
|
+
: DEFAULT_PREFIX_ORDER
|
|
23
|
+
|
|
24
|
+
const sortedNicks = useMemo(() => {
|
|
25
|
+
if (!buffer) return EMPTY_NICKS
|
|
26
|
+
return sortNicks(Array.from(buffer.users.values()), prefixOrder)
|
|
27
|
+
}, [buffer, prefixOrder])
|
|
28
|
+
|
|
29
|
+
if (!buffer || buffer.type !== BufferType.Channel) {
|
|
30
|
+
return (
|
|
31
|
+
<box flexGrow={1}>
|
|
32
|
+
<text><span fg={colors?.fg_dim ?? "#292e42"}>{"\u2014"}</span></text>
|
|
33
|
+
</box>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const formats = theme?.formats.nicklist ?? {}
|
|
38
|
+
const abstracts = theme?.abstracts ?? {}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<scrollbox height="100%">
|
|
42
|
+
<box width="100%">
|
|
43
|
+
<text><span fg={colors?.fg_muted ?? "#565f89"}>{sortedNicks.length} users</span></text>
|
|
44
|
+
</box>
|
|
45
|
+
{sortedNicks.map((entry) => {
|
|
46
|
+
const formatKey = getFormatKey(entry.prefix)
|
|
47
|
+
const format = formats[formatKey] ?? " $0"
|
|
48
|
+
const resolved = resolveAbstractions(format, abstracts)
|
|
49
|
+
const spans = parseFormatString(resolved, [entry.nick])
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<box key={entry.nick} width="100%"
|
|
53
|
+
onMouseDown={() => {
|
|
54
|
+
if (!buffer) return
|
|
55
|
+
const store = useStore.getState()
|
|
56
|
+
const queryId = makeBufferId(buffer.connectionId, entry.nick)
|
|
57
|
+
if (!store.buffers.has(queryId)) {
|
|
58
|
+
store.addBuffer({
|
|
59
|
+
id: queryId,
|
|
60
|
+
connectionId: buffer.connectionId,
|
|
61
|
+
type: BufferType.Query,
|
|
62
|
+
name: entry.nick,
|
|
63
|
+
messages: [],
|
|
64
|
+
activity: ActivityLevel.None,
|
|
65
|
+
unreadCount: 0,
|
|
66
|
+
lastRead: new Date(),
|
|
67
|
+
users: new Map(),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
store.setActiveBuffer(queryId)
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<StyledText spans={spans} />
|
|
74
|
+
</box>
|
|
75
|
+
)
|
|
76
|
+
})}
|
|
77
|
+
</scrollbox>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getFormatKey(prefix: string): string {
|
|
82
|
+
switch (prefix) {
|
|
83
|
+
case "~": return "owner"
|
|
84
|
+
case "&": return "admin"
|
|
85
|
+
case "@": return "op"
|
|
86
|
+
case "%": return "halfop"
|
|
87
|
+
case "+": return "voice"
|
|
88
|
+
default: return "normal"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractPrefixChars(isupportPrefix: unknown): string {
|
|
93
|
+
if (typeof isupportPrefix !== "string") return DEFAULT_PREFIX_ORDER
|
|
94
|
+
const match = isupportPrefix.match(/\)(.+)$/)
|
|
95
|
+
return match ? match[1] : DEFAULT_PREFIX_ORDER
|
|
96
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react"
|
|
2
|
+
import { useKeyboard } from "@opentui/react"
|
|
3
|
+
|
|
4
|
+
const BIRD = [
|
|
5
|
+
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⣷⣦⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
6
|
+
"⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
7
|
+
"⠀⠀⠀⣀⣀⣤⣤⣤⣬⣻⣿⣿⣿⣿⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
8
|
+
"⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣠⣤⣤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
9
|
+
"⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
10
|
+
"⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣿⣿⣿⣿⡿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
11
|
+
"⠀⠀⠀⠀⣾⣿⣿⣿⡿⠟⠉⠀⠀⠀⠀⠀⠀⠉⠻⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
12
|
+
"⠀⠀⠀⠀⠈⠛⠛⡟⠀⠀⠀⠀⠀⢀⡴⠦⣄⠀⠀⠈⣖⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
13
|
+
"⠀⠀⠀⠀⠀⣖⣚⡁⣀⢠⢠⣠⣖⣉⣤⣤⢞⡆⠀⠀⠈⢻⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
14
|
+
"⠀⠀⠀⠀⠀⢸⣤⣬⣭⡻⣏⣿⠝⠓⠊⣉⣹⠃⠀⠀⠀⠀⠙⢄⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
15
|
+
"⠀⠀⠀⠀⢀⢘⣯⣉⣴⣿⣿⣿⣷⣿⣿⣿⣿⣷⠤⢤⣄⡀⠀⠀⠙⢦⡀⠀⠀⠀⠀⠀",
|
|
16
|
+
"⠀⠀⠀⣴⠟⠁⠀⡀⠀⠀⠈⠙⠻⠿⠿⠿⢛⡋⠀⠀⠀⠙⣆⠀⠀⠀⠈⠑⠦⠤⢄⡀",
|
|
17
|
+
"⠀⠀⢸⢻⠀⢠⠊⠀⠀⠀⠀⠀⣀⣠⠴⣞⠉⠙⢯⡉⠙⢢⢘⡆⠀⠀⠀⠀⠀⠀⢤⡞",
|
|
18
|
+
"⠀⠀⢸⡜⡀⠀⠀⠀⠀⢀⣴⡛⠙⢦⡀⠈⠳⡀⠀⠓⣄⢈⣧⡟⠀⠀⠀⠀⣀⣀⡀⠙",
|
|
19
|
+
"⠀⠀⠈⢧⠀⠀⠀⢀⡔⠋⣠⠽⠶⠤⠬⠦⠴⠿⠶⠒⠛⠋⣾⡅⠀⠀⠀⠀⠈⠳⣍⠉",
|
|
20
|
+
"⠀⠀⠀⠹⣦⠀⠀⡎⠀⠀⠙⠓⠦⢤⢄⣀⣀⣀⣀⣤⣴⣾⣿⣷⣦⣀⠀⢠⢤⣤⣄⣣",
|
|
21
|
+
"⠀⠀⠀⠀⠉⠳⡼⠁⠀⠀⠀⠀⠀⠀⠀⢸⠟⠻⣿⣿⣿⣿⣿⣿⣿⣿⣷⡸⡇⠀⠀⠀",
|
|
22
|
+
"⠀⠀⣠⡴⠴⠤⣀⠀⠀⠀⠀⠀⠀⠀⢠⠏⣠⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⠃⠀⠀⠀",
|
|
23
|
+
"⠀⢰⠃⠀⠀⢀⡴⠟⠲⢤⡀⠀⠀⠠⣏⣴⣿⢀⣿⣿⣿⣿⠛⠉⡽⠋⠉⠙⠒⢤⡀⠀",
|
|
24
|
+
"⢀⡼⠓⠦⣄⡟⠀⠀⢀⠀⣳⠀⠀⠀⠉⣸⢻⣠⠟⢿⡇⠉⠀⠘⠃⠀⠀⡴⢃⡼⠧⡀",
|
|
25
|
+
"⢸⠀⠀⠀⢸⠁⠀⠀⣴⠀⡗⢆⡀⠀⣠⡇⠀⠀⠀⢸⠀⠀⠀⠀⠀⠀⠀⠙⠋⠀⠀⢳",
|
|
26
|
+
"⠈⡗⣤⡀⠸⣆⠀⢠⡏⠊⠀⠀⠑⢋⣹⠀⠀⠀⠀⠸⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⡴⠓",
|
|
27
|
+
"⠰⣇⠀⠉⠉⠉⠳⢾⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠘⠢⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
28
|
+
"⠀⠈⠓⠦⢤⣀⡤⢞⠀⠀⠀⠀⠀⠈⡇⠀⠀⠀⠀⠀⠀⠀⠈⠓⢦⡀⠀⠀⠀⠀⠀⠀",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const LOGO = [
|
|
32
|
+
" __ __ _______ _____",
|
|
33
|
+
" / /_____ / /_____ / _/ _ \\/ ___/",
|
|
34
|
+
" / '_/ _ \\/ '_/ _ \\_/ // , _/ /__ ",
|
|
35
|
+
"/_/\\_\\\\___/_/\\_\\\\___/___/_/|_|\\___/ ",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const SUBTITLE = "koko maxed terminal irc client"
|
|
39
|
+
|
|
40
|
+
const BG = "#1a1b26"
|
|
41
|
+
const BIRD_COLOR = "#e0af68"
|
|
42
|
+
const LOGO_COLOR = "#7aa2f7"
|
|
43
|
+
const DIM = "#565f89"
|
|
44
|
+
|
|
45
|
+
export function SplashScreen({ onDone }: { onDone: () => void }) {
|
|
46
|
+
const [visibleLines, setVisibleLines] = useState(0)
|
|
47
|
+
const [showLogo, setShowLogo] = useState(false)
|
|
48
|
+
const doneRef = useRef(false)
|
|
49
|
+
|
|
50
|
+
const finish = () => {
|
|
51
|
+
if (doneRef.current) return
|
|
52
|
+
doneRef.current = true
|
|
53
|
+
onDone()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
useKeyboard(() => finish())
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
let count = 0
|
|
60
|
+
const timer = setInterval(() => {
|
|
61
|
+
count++
|
|
62
|
+
setVisibleLines(count)
|
|
63
|
+
if (count >= BIRD.length) {
|
|
64
|
+
clearInterval(timer)
|
|
65
|
+
setShowLogo(true)
|
|
66
|
+
setTimeout(finish, 2500)
|
|
67
|
+
}
|
|
68
|
+
}, 50)
|
|
69
|
+
return () => clearInterval(timer)
|
|
70
|
+
}, [])
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<box
|
|
74
|
+
width="100%"
|
|
75
|
+
height="100%"
|
|
76
|
+
flexDirection="column"
|
|
77
|
+
justifyContent="center"
|
|
78
|
+
alignItems="center"
|
|
79
|
+
backgroundColor={BG}
|
|
80
|
+
>
|
|
81
|
+
<box flexDirection="column" alignItems="center">
|
|
82
|
+
{BIRD.map((line, i) => (
|
|
83
|
+
<text key={i}>
|
|
84
|
+
<span fg={i < visibleLines ? BIRD_COLOR : BG}>{line}</span>
|
|
85
|
+
</text>
|
|
86
|
+
))}
|
|
87
|
+
</box>
|
|
88
|
+
<box flexDirection="column" alignItems="center" marginTop={1}>
|
|
89
|
+
{LOGO.map((line, i) => (
|
|
90
|
+
<text key={`l${i}`}>
|
|
91
|
+
<span fg={showLogo ? LOGO_COLOR : BG}>{line}</span>
|
|
92
|
+
</text>
|
|
93
|
+
))}
|
|
94
|
+
<text>
|
|
95
|
+
<span fg={showLogo ? DIM : BG}>{SUBTITLE}</span>
|
|
96
|
+
</text>
|
|
97
|
+
</box>
|
|
98
|
+
</box>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useState, useEffect } from "react"
|
|
2
|
+
import { useStore } from "@/core/state/store"
|
|
3
|
+
import { useSortedBuffers } from "@/core/state/selectors"
|
|
4
|
+
import { BufferType, ActivityLevel } from "@/types"
|
|
5
|
+
import { DEFAULT_ITEM_FORMATS, type StatusbarItem } from "@/types/config"
|
|
6
|
+
import { useStatusbarColors } from "@/ui/hooks/useStatusbarColors"
|
|
7
|
+
|
|
8
|
+
export function StatusLine() {
|
|
9
|
+
const config = useStore((s) => s.config)
|
|
10
|
+
const buffer = useStore((s) => s.activeBufferId ? s.buffers.get(s.activeBufferId) : null)
|
|
11
|
+
const connections = useStore((s) => s.connections)
|
|
12
|
+
const activeBufferId = useStore((s) => s.activeBufferId)
|
|
13
|
+
const setActiveBuffer = useStore((s) => s.setActiveBuffer)
|
|
14
|
+
|
|
15
|
+
// Ticking clock — re-render every second
|
|
16
|
+
const [now, setNow] = useState(new Date())
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const id = setInterval(() => setNow(new Date()), 1_000)
|
|
19
|
+
return () => clearInterval(id)
|
|
20
|
+
}, [])
|
|
21
|
+
|
|
22
|
+
const sortedBuffers = useSortedBuffers()
|
|
23
|
+
const sb = useStatusbarColors()
|
|
24
|
+
|
|
25
|
+
if (!config?.statusbar.enabled) return null
|
|
26
|
+
|
|
27
|
+
const conn = buffer ? connections.get(buffer.connectionId) : null
|
|
28
|
+
const items = config.statusbar.items
|
|
29
|
+
|
|
30
|
+
function getItemFormat(item: StatusbarItem): string {
|
|
31
|
+
return config!.statusbar.item_formats?.[item] ?? DEFAULT_ITEM_FORMATS[item]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse a format string with $variables and render colored slots as JSX. */
|
|
35
|
+
function renderWithFormat(
|
|
36
|
+
format: string,
|
|
37
|
+
slots: Record<string, React.ReactNode>,
|
|
38
|
+
key: string,
|
|
39
|
+
): React.ReactNode {
|
|
40
|
+
const parts: React.ReactNode[] = []
|
|
41
|
+
const regex = /\$([a-z_]+)/g
|
|
42
|
+
let lastIndex = 0
|
|
43
|
+
let pi = 0
|
|
44
|
+
let match: RegExpExecArray | null
|
|
45
|
+
|
|
46
|
+
while ((match = regex.exec(format)) !== null) {
|
|
47
|
+
if (match.index > lastIndex) {
|
|
48
|
+
parts.push(<span key={`${key}-${pi++}`} fg={sb.muted}>{format.slice(lastIndex, match.index)}</span>)
|
|
49
|
+
}
|
|
50
|
+
const slot = slots[match[1]]
|
|
51
|
+
if (slot != null) {
|
|
52
|
+
parts.push(<span key={`${key}-${pi++}`}>{slot}</span>)
|
|
53
|
+
}
|
|
54
|
+
lastIndex = regex.lastIndex
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (lastIndex < format.length) {
|
|
58
|
+
parts.push(<span key={`${key}-${pi++}`} fg={sb.muted}>{format.slice(lastIndex)}</span>)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return <text key={key}>{parts}</text>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const renderedItems: React.ReactNode[] = []
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < items.length; i++) {
|
|
67
|
+
const item = items[i]
|
|
68
|
+
if (i > 0) {
|
|
69
|
+
renderedItems.push(
|
|
70
|
+
<text key={`sep-${i}`}><span fg={sb.dim}>{sb.separator}</span></text>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
renderedItems.push(renderItem(item, i))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderItem(item: StatusbarItem, idx: number): React.ReactNode {
|
|
77
|
+
switch (item) {
|
|
78
|
+
case "active_windows": return renderActiveWindows(idx)
|
|
79
|
+
case "nick_info": return renderNickInfo(idx)
|
|
80
|
+
case "channel_info": return renderChannelInfo(idx)
|
|
81
|
+
case "lag": return renderLag(idx)
|
|
82
|
+
case "time": return renderTime(idx)
|
|
83
|
+
default: return null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderActiveWindows(idx: number): React.ReactNode {
|
|
88
|
+
const entries: { winNum: number; color: string; bufferId: string }[] = []
|
|
89
|
+
for (let i = 0; i < sortedBuffers.length; i++) {
|
|
90
|
+
const buf = sortedBuffers[i]
|
|
91
|
+
if (buf.id === activeBufferId) continue
|
|
92
|
+
if (buf.activity === ActivityLevel.None) continue
|
|
93
|
+
|
|
94
|
+
let color = "#9ece6a"
|
|
95
|
+
if (buf.activity >= ActivityLevel.Mention) color = "#bb9af7"
|
|
96
|
+
else if (buf.activity >= ActivityLevel.Highlight) color = "#f7768e"
|
|
97
|
+
else if (buf.activity >= ActivityLevel.Activity) color = "#e0af68"
|
|
98
|
+
|
|
99
|
+
entries.push({ winNum: i + 1, color, bufferId: buf.id })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (entries.length === 0) return null
|
|
103
|
+
|
|
104
|
+
// Split format at $activity to render numbers as individual clickable elements
|
|
105
|
+
const format = getItemFormat("active_windows")
|
|
106
|
+
const actIdx = format.indexOf("$activity")
|
|
107
|
+
const result: React.ReactNode[] = []
|
|
108
|
+
|
|
109
|
+
// Render prefix (everything before $activity)
|
|
110
|
+
const prefix = actIdx >= 0 ? format.slice(0, actIdx) : format
|
|
111
|
+
if (prefix) {
|
|
112
|
+
result.push(renderWithFormat(prefix, {}, `awp-${idx}`))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Render each activity number as a clickable <text>
|
|
116
|
+
if (actIdx >= 0) {
|
|
117
|
+
for (let j = 0; j < entries.length; j++) {
|
|
118
|
+
const e = entries[j]
|
|
119
|
+
if (j > 0) {
|
|
120
|
+
result.push(<text key={`ac-${e.winNum}`}><span fg={sb.dim}>,</span></text>)
|
|
121
|
+
}
|
|
122
|
+
result.push(
|
|
123
|
+
<text key={`a-${e.winNum}`} onMouseDown={() => setActiveBuffer(e.bufferId)}>
|
|
124
|
+
<span fg={e.color}>{e.winNum}</span>
|
|
125
|
+
</text>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Render suffix (everything after $activity)
|
|
130
|
+
const suffix = format.slice(actIdx + 9)
|
|
131
|
+
if (suffix) {
|
|
132
|
+
result.push(renderWithFormat(suffix, {}, `aws-${idx}`))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return <>{result}</>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderNickInfo(idx: number): React.ReactNode {
|
|
140
|
+
const nick = conn?.nick ?? "?"
|
|
141
|
+
const modes = conn?.userModes ? `(+${conn.userModes})` : ""
|
|
142
|
+
return renderWithFormat(getItemFormat("nick_info"), {
|
|
143
|
+
nick: <span fg={sb.accent}>{nick}</span>,
|
|
144
|
+
modes: modes ? <span fg={sb.muted}>{modes}</span> : null,
|
|
145
|
+
}, `nick-${idx}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function renderChannelInfo(idx: number): React.ReactNode {
|
|
149
|
+
if (!buffer) return null
|
|
150
|
+
let nameNode: React.ReactNode = null
|
|
151
|
+
let modesNode: React.ReactNode = null
|
|
152
|
+
|
|
153
|
+
if (buffer.type === BufferType.Channel) {
|
|
154
|
+
nameNode = <span fg={sb.accent}>{buffer.name}</span>
|
|
155
|
+
if (buffer.modes) {
|
|
156
|
+
// Append params for modes that have them (e.g. +l 50, +k secret)
|
|
157
|
+
const paramStr = buffer.modes
|
|
158
|
+
.split("")
|
|
159
|
+
.map((ch) => buffer.modeParams?.[ch])
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.join(" ")
|
|
162
|
+
const display = `(+${buffer.modes}${paramStr ? " " + paramStr : ""})`
|
|
163
|
+
modesNode = <span fg={sb.muted}>{display}</span>
|
|
164
|
+
}
|
|
165
|
+
} else if (buffer.type === BufferType.Query) {
|
|
166
|
+
nameNode = <span fg="#e0af68">{buffer.name}</span>
|
|
167
|
+
} else if (buffer.type === BufferType.Server) {
|
|
168
|
+
nameNode = <span fg={sb.muted}>{conn?.label ?? "server"}</span>
|
|
169
|
+
} else {
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return renderWithFormat(getItemFormat("channel_info"), {
|
|
174
|
+
name: nameNode,
|
|
175
|
+
modes: modesNode,
|
|
176
|
+
}, `chan-${idx}`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function renderLag(idx: number): React.ReactNode {
|
|
180
|
+
const lag = conn?.lag
|
|
181
|
+
if (lag == null) return null
|
|
182
|
+
const seconds = (lag / 1000).toFixed(1)
|
|
183
|
+
const lagColor = lag > 5000 ? "#f7768e" : lag > 2000 ? "#e0af68" : "#9ece6a"
|
|
184
|
+
return renderWithFormat(getItemFormat("lag"), {
|
|
185
|
+
lag: <span fg={lagColor}>{seconds}s</span>,
|
|
186
|
+
}, `lag-${idx}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderTime(idx: number): React.ReactNode {
|
|
190
|
+
const h = String(now.getHours()).padStart(2, "0")
|
|
191
|
+
const m = String(now.getMinutes()).padStart(2, "0")
|
|
192
|
+
const s = String(now.getSeconds()).padStart(2, "0")
|
|
193
|
+
return renderWithFormat(getItemFormat("time"), {
|
|
194
|
+
time: <span fg={sb.muted}>{h}:{m}:{s}</span>,
|
|
195
|
+
}, `time-${idx}`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<box width="100%" height={1} flexDirection="row">
|
|
200
|
+
<text><span fg={sb.dim}>[</span></text>
|
|
201
|
+
{renderedItems}
|
|
202
|
+
<text><span fg={sb.dim}>]</span></text>
|
|
203
|
+
</box>
|
|
204
|
+
)
|
|
205
|
+
}
|
package/themes/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[meta]
|
|
2
|
+
name = "Nightfall"
|
|
3
|
+
description = "Modern dark theme with subtle accents"
|
|
4
|
+
|
|
5
|
+
[colors]
|
|
6
|
+
bg = "#1a1b26"
|
|
7
|
+
bg_alt = "#16161e"
|
|
8
|
+
border = "#292e42"
|
|
9
|
+
fg = "#a9b1d6"
|
|
10
|
+
fg_muted = "#565f89"
|
|
11
|
+
fg_dim = "#292e42"
|
|
12
|
+
accent = "#7aa2f7"
|
|
13
|
+
cursor = "#7aa2f7"
|
|
14
|
+
|
|
15
|
+
[abstracts]
|
|
16
|
+
timestamp = "%Z6e738d$*%Z7aa2f7%N"
|
|
17
|
+
msgnick = "%Z565f89$0$1%Z7aa2f7❯%N%| "
|
|
18
|
+
ownnick = "%Z9ece6a%_$*%_%N"
|
|
19
|
+
pubnick = "%Z7aa2f7%_$*%_%N"
|
|
20
|
+
menick = "%Zbb9af7%_$*%_%N"
|
|
21
|
+
channel = "%Z7aa2f7%_$*%_%N"
|
|
22
|
+
action = "%Ze0af68* $*%N"
|
|
23
|
+
error = "%Zf7768e! $*%N"
|
|
24
|
+
|
|
25
|
+
[formats.messages]
|
|
26
|
+
own_msg = "{msgnick $2 {ownnick $0}}%Zc0caf5$1%N"
|
|
27
|
+
pubmsg = "{msgnick $2 {pubnick $0}}%Za9b1d6$1%N"
|
|
28
|
+
pubmsg_mention = "{msgnick %Zbb9af7$2 {menick $0}}%Zbb9af7$1%N"
|
|
29
|
+
pubmsg_highlight = "{msgnick %Zf7768e$2 %Zf7768e%_$0%_%N}%Zf7768e$1%N"
|
|
30
|
+
action = "{action $0} %Ze0af68$1%N"
|
|
31
|
+
notice = "%Z7dcfff-%Z7aa2f7$0%Z7dcfff-%N $1"
|
|
32
|
+
|
|
33
|
+
[formats.events]
|
|
34
|
+
join = "%Z9ece6a-->%N %Za9b1d6$0%N %Z565f89($1@$2)%N has joined {channel $3}"
|
|
35
|
+
part = "%Ze0af68<--%N %Za9b1d6$0%N %Z565f89($1@$2)%N has left {channel $3} %Z565f89($4)%N"
|
|
36
|
+
quit = "%Zf7768e<--%N %Za9b1d6$0%N %Z565f89($1@$2)%N has quit %Z565f89($3)%N"
|
|
37
|
+
nick_change = "%Zbb9af7---%N %Za9b1d6$0%N is now known as %Za9b1d6$1%N"
|
|
38
|
+
topic = "%Z7aa2f7---%N Topic set by %Z565f89$0%N: $1"
|
|
39
|
+
mode = "%Z7aa2f7---%N %Za9b1d6$0%N sets mode %Ze0af68$1%N on {channel $2}"
|
|
40
|
+
|
|
41
|
+
[formats.sidepanel]
|
|
42
|
+
header = "%Z7aa2f7%_$0%_%N"
|
|
43
|
+
item = "%Z565f89$0.%N %Z6e738d$1%N"
|
|
44
|
+
item_selected = "%Ze0af68$0.%N %Zc0caf5%_$1%_%N"
|
|
45
|
+
item_activity_0 = "%Z565f89$0.%N %Z6e738d$1%N"
|
|
46
|
+
item_activity_1 = "%Z565f89$0.%N %Z9ece6a$1%N"
|
|
47
|
+
item_activity_2 = "%Z565f89$0.%N %Zf7768e$1%N"
|
|
48
|
+
item_activity_3 = "%Z565f89$0.%N %Ze0af68$1%N"
|
|
49
|
+
item_activity_4 = "%Z565f89$0.%N %Zbb9af7$1%N"
|
|
50
|
+
|
|
51
|
+
[formats.nicklist]
|
|
52
|
+
owner = "%Ze0af68~%Zc0caf5$0%N"
|
|
53
|
+
admin = "%Zf7768e&%Zc0caf5$0%N"
|
|
54
|
+
op = "%Z9ece6a@%Za9b1d6$0%N"
|
|
55
|
+
halfop = "%Z7aa2f7%%%Za9b1d6$0%N"
|
|
56
|
+
voice = "%Z7dcfff+%Z6e738d$0%N"
|
|
57
|
+
normal = " %Z565f89$0%N"
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext", "DOM"],
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"jsxImportSource": "@opentui/react",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"types": ["bun-types"],
|
|
13
|
+
"baseUrl": ".",
|
|
14
|
+
"paths": {
|
|
15
|
+
"@/*": ["src/*"]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"]
|
|
19
|
+
}
|