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,185 @@
1
+ // ─── Help Display Formatter ─────────────────────────────────
2
+ // Three display modes using addLocalEvent() for in-buffer output.
3
+
4
+ import { addLocalEvent } from "./helpers"
5
+ import { getHelp, getCategories, type CommandHelp } from "./docs"
6
+ import { commands, findByAlias, getCanonicalName } from "./registry"
7
+
8
+ const CATEGORY_ORDER = [
9
+ "Connection", "Channel", "Messaging", "Moderation",
10
+ "Configuration", "Statusbar", "Info",
11
+ ]
12
+
13
+ // ─── /help (no args) — categorized command list ─────────────
14
+
15
+ export function showCommandList(): void {
16
+ const cats = getCategories()
17
+
18
+ // Collect commands that have doc files
19
+ const documented = new Set<string>()
20
+ for (const list of cats.values()) {
21
+ for (const name of list) documented.add(name)
22
+ }
23
+
24
+ // Commands without doc files go under "Other"
25
+ const other: string[] = []
26
+ for (const name of Object.keys(commands)) {
27
+ if (!documented.has(name)) other.push(name)
28
+ }
29
+ if (other.length > 0) cats.set("Other", other.sort())
30
+
31
+ addLocalEvent(`%Z7aa2f7───── Commands ─────────────────────────────────%N`)
32
+
33
+ // Ordered categories first, then any extras
34
+ const seen = new Set<string>()
35
+ const orderedCats = [...CATEGORY_ORDER, ...cats.keys()].filter((c) => {
36
+ if (seen.has(c)) return false
37
+ seen.add(c)
38
+ return cats.has(c)
39
+ })
40
+
41
+ for (const cat of orderedCats) {
42
+ const list = cats.get(cat)!
43
+ addLocalEvent(` %Z7aa2f7${cat}%N`)
44
+ for (const name of list) {
45
+ const help = getHelp(name)
46
+ const desc = help?.description ?? commands[name]?.description ?? ""
47
+ addLocalEvent(` %Zc0caf5${"/" + name}${" ".repeat(Math.max(1, 16 - name.length))}%Z565f89${desc}%N`)
48
+ }
49
+ }
50
+
51
+ addLocalEvent(`%Z7aa2f7─────────────────────────────────────────────────%N`)
52
+ addLocalEvent(` %Z565f89Type %Z7aa2f7/help <command>%Z565f89 for details%N`)
53
+ }
54
+
55
+ // ─── /help <command> — full command help ────────────────────
56
+
57
+ export function showCommandHelp(name: string): void {
58
+ const canonical = getCanonicalName(name)
59
+ const help = getHelp(canonical)
60
+ const def = commands[canonical] ?? findByAlias(name)
61
+
62
+ if (!help && !def) {
63
+ addLocalEvent(`%Zf7768eUnknown command: ${name}%N`)
64
+ return
65
+ }
66
+
67
+ addLocalEvent(`%Z7aa2f7───── /${canonical} ─────────────────────────────────%N`)
68
+
69
+ if (help) {
70
+ if (help.description) {
71
+ addLocalEvent(` %Za9b1d6${help.description}%N`)
72
+ }
73
+ if (help.syntax) {
74
+ addLocalEvent("")
75
+ for (const line of help.syntax.split("\n")) {
76
+ addLocalEvent(` %Zc0caf5${line}%N`)
77
+ }
78
+ }
79
+ if (def?.aliases?.length) {
80
+ addLocalEvent(` %Z565f89Aliases: ${def.aliases.map((a) => "/" + a).join(", ")}%N`)
81
+ }
82
+ if (help.subcommands.length > 0) {
83
+ addLocalEvent("")
84
+ addLocalEvent(` %Z7aa2f7Subcommands:%N`)
85
+ for (const sub of help.subcommands) {
86
+ const aliasNote = sub.aliases.length > 0 ? ` %Z565f89(${sub.aliases.join(", ")})%N` : ""
87
+ addLocalEvent(` %Zc0caf5${sub.name.padEnd(14)}%Z565f89${sub.description}%N${aliasNote}`)
88
+ }
89
+ }
90
+ if (help.body) {
91
+ addLocalEvent("")
92
+ for (const line of help.body.split("\n")) {
93
+ addLocalEvent(` %Za9b1d6${line}%N`)
94
+ }
95
+ }
96
+ if (help.examples.length > 0) {
97
+ addLocalEvent("")
98
+ addLocalEvent(` %Z7aa2f7Examples:%N`)
99
+ for (const ex of help.examples) {
100
+ addLocalEvent(` %Zc0caf5${ex}%N`)
101
+ }
102
+ }
103
+ if (help.seeAlso.length > 0) {
104
+ addLocalEvent("")
105
+ addLocalEvent(` %Z565f89See also: ${help.seeAlso.map((s) => "/" + s).join(", ")}%N`)
106
+ }
107
+ } else if (def) {
108
+ // Fallback for commands without doc files
109
+ addLocalEvent(` %Zc0caf5${def.usage}%N`)
110
+ addLocalEvent(` %Za9b1d6${def.description}%N`)
111
+ if (def.aliases?.length) {
112
+ addLocalEvent(` %Z565f89Aliases: ${def.aliases.map((a) => "/" + a).join(", ")}%N`)
113
+ }
114
+ }
115
+
116
+ addLocalEvent(`%Z7aa2f7─────────────────────────────────────────────────%N`)
117
+ }
118
+
119
+ // ─── /help <command> <subcommand> — subcommand detail ───────
120
+
121
+ export function showSubcommandHelp(cmdName: string, subName: string): void {
122
+ const canonical = getCanonicalName(cmdName)
123
+ const help = getHelp(canonical)
124
+
125
+ if (!help) {
126
+ showCommandHelp(cmdName)
127
+ return
128
+ }
129
+
130
+ const sub = help.subcommands.find(
131
+ (s) => s.name === subName || s.aliases.includes(subName)
132
+ )
133
+
134
+ if (!sub) {
135
+ addLocalEvent(`%Zf7768eUnknown subcommand: /${canonical} ${subName}%N`)
136
+ if (help.subcommands.length > 0) {
137
+ const names = help.subcommands.map((s) => s.name).join(", ")
138
+ addLocalEvent(` %Z565f89Available: ${names}%N`)
139
+ }
140
+ return
141
+ }
142
+
143
+ addLocalEvent(`%Z7aa2f7───── /${canonical} ${sub.name} ─────────────────────────%N`)
144
+
145
+ if (sub.description) {
146
+ addLocalEvent(` %Za9b1d6${sub.description}%N`)
147
+ }
148
+ if (sub.syntax) {
149
+ addLocalEvent("")
150
+ for (const line of sub.syntax.split("\n")) {
151
+ addLocalEvent(` %Zc0caf5${line}%N`)
152
+ }
153
+ }
154
+ if (sub.aliases.length > 0) {
155
+ addLocalEvent(` %Z565f89Aliases: ${sub.aliases.join(", ")}%N`)
156
+ }
157
+
158
+ // Show flags and extra body content (after first paragraph and syntax)
159
+ const bodyLines = sub.body.split("\n")
160
+ let inFlags = false
161
+ const extraLines: string[] = []
162
+ for (const line of bodyLines) {
163
+ if (line.startsWith("**Flags:**")) {
164
+ inFlags = true
165
+ extraLines.push("")
166
+ addLocalEvent(` %Z7aa2f7Flags:%N`)
167
+ continue
168
+ }
169
+ if (inFlags) {
170
+ if (line.startsWith("- ")) {
171
+ const flagText = line.slice(2)
172
+ const dashIdx = flagText.indexOf(" — ")
173
+ if (dashIdx > 0) {
174
+ addLocalEvent(` %Zc0caf5${flagText.slice(0, dashIdx)}%Z565f89${flagText.slice(dashIdx)}%N`)
175
+ } else {
176
+ addLocalEvent(` %Zc0caf5${flagText}%N`)
177
+ }
178
+ } else if (line.trim() === "") {
179
+ inFlags = false
180
+ }
181
+ }
182
+ }
183
+
184
+ addLocalEvent(`%Z7aa2f7─────────────────────────────────────────────────%N`)
185
+ }
@@ -0,0 +1,168 @@
1
+ import { useStore } from "@/core/state/store"
2
+ import { makeBufferId, BufferType } from "@/types"
3
+ import type { AppConfig } from "@/types/config"
4
+ import { CREDENTIAL_FIELDS } from "./types"
5
+ import type { ResolvedConfig } from "./types"
6
+
7
+ /** Display a local event message in the active buffer. */
8
+ export function addLocalEvent(text: string) {
9
+ const s = useStore.getState()
10
+ const buf = s.activeBufferId
11
+ if (!buf) return
12
+ s.addMessage(buf, {
13
+ id: crypto.randomUUID(),
14
+ timestamp: new Date(),
15
+ type: "event",
16
+ text,
17
+ highlight: false,
18
+ })
19
+ }
20
+
21
+ /** Get the channel name from the active buffer (if it's a channel buffer). */
22
+ export function getActiveChannel(): string | null {
23
+ const s = useStore.getState()
24
+ const buf = s.activeBufferId ? s.buffers.get(s.activeBufferId) : null
25
+ if (!buf || buf.type !== BufferType.Channel) return null
26
+ return buf.name
27
+ }
28
+
29
+ /** Switch to a connection's Status buffer. */
30
+ export function switchToStatusBuffer(connId: string) {
31
+ const s = useStore.getState()
32
+ const statusId = makeBufferId(connId, "Status")
33
+ if (s.buffers.has(statusId)) {
34
+ s.setActiveBuffer(statusId)
35
+ }
36
+ }
37
+
38
+ // ─── /set helpers ───────────────────────────────────────────
39
+
40
+ /** Resolve a dot-path like "general.nick" or "servers.ircnet.port" to the config value. */
41
+ export function getConfigValue(config: AppConfig, path: string): ResolvedConfig | null {
42
+ const parts = path.split(".")
43
+ if (parts.length < 2) return null
44
+
45
+ const section = parts[0]
46
+
47
+ // servers.<id>.<field>
48
+ if (section === "servers") {
49
+ if (parts.length < 3) return null
50
+ const serverId = parts[1]
51
+ const field = parts.slice(2).join(".")
52
+ const server = config.servers[serverId]
53
+ if (!server || !(field in server)) return null
54
+ return {
55
+ value: (server as any)[field],
56
+ field,
57
+ isCredential: CREDENTIAL_FIELDS.has(field),
58
+ serverId,
59
+ }
60
+ }
61
+
62
+ // aliases.<name> — allow creating new aliases via /set
63
+ if (section === "aliases") {
64
+ if (parts.length < 2) return null
65
+ const name = parts[1]
66
+ const value = config.aliases[name] ?? ""
67
+ return { value, field: name, isCredential: false }
68
+ }
69
+
70
+ // sidepanel.left.<field> / sidepanel.right.<field>
71
+ if (section === "sidepanel") {
72
+ if (parts.length < 3) return null
73
+ const side = parts[1] as "left" | "right"
74
+ const field = parts[2]
75
+ const panel = config.sidepanel?.[side]
76
+ if (!panel || !(field in panel)) return null
77
+ return { value: (panel as any)[field], field, isCredential: false }
78
+ }
79
+
80
+ // general.<field>, display.<field>, statusbar.<field>
81
+ const field = parts.slice(1).join(".")
82
+ const obj = (config as any)[section]
83
+ if (!obj || typeof obj !== "object" || !(field in obj)) return null
84
+ return { value: obj[field], field, isCredential: false }
85
+ }
86
+
87
+ /** Set a value in the config object in-place. */
88
+ export function setConfigValue(config: AppConfig, path: string, value: any): void {
89
+ const parts = path.split(".")
90
+ const section = parts[0]
91
+
92
+ if (section === "aliases" && parts.length >= 2) {
93
+ config.aliases[parts[1]] = String(value)
94
+ return
95
+ }
96
+
97
+ if (section === "servers" && parts.length >= 3) {
98
+ const server = config.servers[parts[1]]
99
+ if (server) (server as any)[parts.slice(2).join(".")] = value
100
+ return
101
+ }
102
+
103
+ if (section === "sidepanel" && parts.length >= 3) {
104
+ const panel = (config.sidepanel as any)?.[parts[1]]
105
+ if (panel) panel[parts[2]] = value
106
+ return
107
+ }
108
+
109
+ const field = parts.slice(1).join(".")
110
+ const obj = (config as any)[section]
111
+ if (obj) obj[field] = value
112
+ }
113
+
114
+ /** Coerce a raw string value to match the type of the existing value. */
115
+ export function coerceValue(raw: string, existing: any): any {
116
+ if (typeof existing === "boolean") {
117
+ if (raw === "true") return true
118
+ if (raw === "false") return false
119
+ return undefined
120
+ }
121
+ if (typeof existing === "number") {
122
+ const n = Number(raw)
123
+ return isNaN(n) ? undefined : n
124
+ }
125
+ if (Array.isArray(existing)) {
126
+ return raw.split(",").map((s) => s.trim())
127
+ }
128
+ return raw
129
+ }
130
+
131
+ /** Format a value for display. Escapes % so the theme parser doesn't eat color codes. */
132
+ export function formatValue(v: any): string {
133
+ const raw = Array.isArray(v) ? v.join(", ") : String(v)
134
+ return raw.replace(/%/g, "%%")
135
+ }
136
+
137
+ /** Display all settings grouped by section. */
138
+ export function listAllSettings(config: AppConfig): void {
139
+ addLocalEvent(`%Z7aa2f7───── Settings ─────────────────────────────────%N`)
140
+
141
+ const showSection = (label: string, prefix: string, obj: Record<string, any>) => {
142
+ addLocalEvent(`%Z565f89[${label}]%N`)
143
+ for (const [key, val] of Object.entries(obj)) {
144
+ if (typeof val === "object" && val !== null && !Array.isArray(val)) continue
145
+ const fullPath = `${prefix}.${key}`
146
+ const isCredential = CREDENTIAL_FIELDS.has(key)
147
+ const display = isCredential ? "***" : formatValue(val)
148
+ const envTag = isCredential ? " %Z565f89[.env]%N" : ""
149
+ addLocalEvent(` %Z7aa2f7${fullPath.padEnd(32)}%N= %Zc0caf5${display}%N${envTag}`)
150
+ }
151
+ }
152
+
153
+ showSection("general", "general", config.general)
154
+ showSection("display", "display", config.display)
155
+ showSection("sidepanel.left", "sidepanel.left", config.sidepanel.left)
156
+ showSection("sidepanel.right", "sidepanel.right", config.sidepanel.right)
157
+ showSection("statusbar", "statusbar", config.statusbar)
158
+
159
+ if (Object.keys(config.aliases).length > 0) {
160
+ showSection("aliases", "aliases", config.aliases)
161
+ }
162
+
163
+ for (const [id, srv] of Object.entries(config.servers)) {
164
+ showSection(`servers.${id}`, `servers.${id}`, srv)
165
+ }
166
+
167
+ addLocalEvent(`%Z7aa2f7─────────────────────────────────────────────────%N`)
168
+ }
@@ -0,0 +1,7 @@
1
+ export { parseCommand } from "./parser"
2
+ export type { ParsedCommand } from "./parser"
3
+ export { executeCommand, getCommandNames } from "./execution"
4
+ export { commands } from "./registry"
5
+ export type { CommandDef } from "./types"
6
+ export { loadAllDocs, getHelp, getSubcommands, getCategories } from "./docs"
7
+ export type { CommandHelp, SubcommandHelp } from "./docs"
@@ -0,0 +1,33 @@
1
+ export interface ParsedCommand {
2
+ command: string
3
+ args: string[]
4
+ }
5
+
6
+ const GREEDY_COMMANDS = new Set(["msg", "notice", "me", "quit", "topic", "kick", "kb", "close", "disconnect", "set", "alias"])
7
+
8
+ export function parseCommand(input: string): ParsedCommand | null {
9
+ if (!input.startsWith("/")) return null
10
+
11
+ const trimmed = input.slice(1)
12
+ const spaceIndex = trimmed.indexOf(" ")
13
+
14
+ if (spaceIndex === -1) {
15
+ return { command: trimmed.toLowerCase(), args: [] }
16
+ }
17
+
18
+ const command = trimmed.slice(0, spaceIndex).toLowerCase()
19
+ const rest = trimmed.slice(spaceIndex + 1).trim()
20
+
21
+ if (GREEDY_COMMANDS.has(command)) {
22
+ if (command === "me" || command === "quit" || command === "close") {
23
+ return { command, args: [rest] }
24
+ }
25
+ const firstSpace = rest.indexOf(" ")
26
+ if (firstSpace === -1) {
27
+ return { command, args: [rest] }
28
+ }
29
+ return { command, args: [rest.slice(0, firstSpace), rest.slice(firstSpace + 1)] }
30
+ }
31
+
32
+ return { command, args: rest.split(/\s+/) }
33
+ }