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,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
+ }
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
+ }