opencode-agora 0.3.0 → 0.4.1

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 (280) hide show
  1. package/README.md +86 -255
  2. package/dist/atomic-write.d.ts +10 -0
  3. package/dist/atomic-write.d.ts.map +1 -0
  4. package/dist/atomic-write.js +23 -0
  5. package/dist/atomic-write.js.map +1 -0
  6. package/dist/auth/refresh.d.ts +17 -0
  7. package/dist/auth/refresh.d.ts.map +1 -0
  8. package/dist/auth/refresh.js +50 -0
  9. package/dist/auth/refresh.js.map +1 -0
  10. package/dist/cli/app.d.ts +13 -18
  11. package/dist/cli/app.d.ts.map +1 -1
  12. package/dist/cli/app.js +184 -1187
  13. package/dist/cli/app.js.map +1 -1
  14. package/dist/cli/chat-renderer.d.ts +31 -0
  15. package/dist/cli/chat-renderer.d.ts.map +1 -0
  16. package/dist/cli/chat-renderer.js +275 -0
  17. package/dist/cli/chat-renderer.js.map +1 -0
  18. package/dist/cli/commands/browse.d.ts +4 -0
  19. package/dist/cli/commands/browse.d.ts.map +1 -0
  20. package/dist/cli/commands/browse.js +80 -0
  21. package/dist/cli/commands/browse.js.map +1 -0
  22. package/dist/cli/commands/chat.d.ts +4 -0
  23. package/dist/cli/commands/chat.d.ts.map +1 -0
  24. package/dist/cli/commands/chat.js +125 -0
  25. package/dist/cli/commands/chat.js.map +1 -0
  26. package/dist/cli/commands/community.d.ts +12 -0
  27. package/dist/cli/commands/community.d.ts.map +1 -0
  28. package/dist/cli/commands/community.js +453 -0
  29. package/dist/cli/commands/community.js.map +1 -0
  30. package/dist/cli/commands/export.d.ts +3 -0
  31. package/dist/cli/commands/export.d.ts.map +1 -0
  32. package/dist/cli/commands/export.js +108 -0
  33. package/dist/cli/commands/export.js.map +1 -0
  34. package/dist/cli/commands/init.d.ts +4 -0
  35. package/dist/cli/commands/init.d.ts.map +1 -0
  36. package/dist/cli/commands/init.js +299 -0
  37. package/dist/cli/commands/init.js.map +1 -0
  38. package/dist/cli/commands/learn.d.ts +4 -0
  39. package/dist/cli/commands/learn.d.ts.map +1 -0
  40. package/dist/cli/commands/learn.js +62 -0
  41. package/dist/cli/commands/learn.js.map +1 -0
  42. package/dist/cli/commands/marketplace.d.ts +9 -0
  43. package/dist/cli/commands/marketplace.d.ts.map +1 -0
  44. package/dist/cli/commands/marketplace.js +321 -0
  45. package/dist/cli/commands/marketplace.js.map +1 -0
  46. package/dist/cli/commands/notify.d.ts +3 -0
  47. package/dist/cli/commands/notify.d.ts.map +1 -0
  48. package/dist/cli/commands/notify.js +59 -0
  49. package/dist/cli/commands/notify.js.map +1 -0
  50. package/dist/cli/commands/operations.d.ts +16 -0
  51. package/dist/cli/commands/operations.d.ts.map +1 -0
  52. package/dist/cli/commands/operations.js +1006 -0
  53. package/dist/cli/commands/operations.js.map +1 -0
  54. package/dist/cli/commands/ping.d.ts +3 -0
  55. package/dist/cli/commands/ping.d.ts.map +1 -0
  56. package/dist/cli/commands/ping.js +56 -0
  57. package/dist/cli/commands/ping.js.map +1 -0
  58. package/dist/cli/commands/today.d.ts +3 -0
  59. package/dist/cli/commands/today.d.ts.map +1 -0
  60. package/dist/cli/commands/today.js +142 -0
  61. package/dist/cli/commands/today.js.map +1 -0
  62. package/dist/cli/commands/types.d.ts +5 -0
  63. package/dist/cli/commands/types.d.ts.map +1 -0
  64. package/dist/cli/commands/types.js +2 -0
  65. package/dist/cli/commands/types.js.map +1 -0
  66. package/dist/cli/commands/watch.d.ts +3 -0
  67. package/dist/cli/commands/watch.d.ts.map +1 -0
  68. package/dist/cli/commands/watch.js +41 -0
  69. package/dist/cli/commands/watch.js.map +1 -0
  70. package/dist/cli/commands/welcome.d.ts +3 -0
  71. package/dist/cli/commands/welcome.d.ts.map +1 -0
  72. package/dist/cli/commands/welcome.js +97 -0
  73. package/dist/cli/commands/welcome.js.map +1 -0
  74. package/dist/cli/commands-meta.d.ts +21 -0
  75. package/dist/cli/commands-meta.d.ts.map +1 -0
  76. package/dist/cli/commands-meta.js +828 -0
  77. package/dist/cli/commands-meta.js.map +1 -0
  78. package/dist/cli/completions-gen.d.ts +2 -0
  79. package/dist/cli/completions-gen.d.ts.map +1 -0
  80. package/dist/cli/completions-gen.js +195 -0
  81. package/dist/cli/completions-gen.js.map +1 -0
  82. package/dist/cli/completions.d.ts +18 -0
  83. package/dist/cli/completions.d.ts.map +1 -0
  84. package/dist/cli/completions.js +227 -0
  85. package/dist/cli/completions.js.map +1 -0
  86. package/dist/cli/flags.d.ts +19 -0
  87. package/dist/cli/flags.d.ts.map +1 -0
  88. package/dist/cli/flags.js +91 -0
  89. package/dist/cli/flags.js.map +1 -0
  90. package/dist/cli/format.d.ts +19 -0
  91. package/dist/cli/format.d.ts.map +1 -0
  92. package/dist/cli/format.js +249 -0
  93. package/dist/cli/format.js.map +1 -0
  94. package/dist/cli/helpers.d.ts +95 -0
  95. package/dist/cli/helpers.d.ts.map +1 -0
  96. package/dist/cli/helpers.js +301 -0
  97. package/dist/cli/helpers.js.map +1 -0
  98. package/dist/cli/mcp-server.d.ts +4 -0
  99. package/dist/cli/mcp-server.d.ts.map +1 -0
  100. package/dist/cli/mcp-server.js +277 -0
  101. package/dist/cli/mcp-server.js.map +1 -0
  102. package/dist/cli/menu.d.ts +7 -0
  103. package/dist/cli/menu.d.ts.map +1 -0
  104. package/dist/cli/menu.js +172 -0
  105. package/dist/cli/menu.js.map +1 -0
  106. package/dist/cli/pages/community.d.ts +9 -0
  107. package/dist/cli/pages/community.d.ts.map +1 -0
  108. package/dist/cli/pages/community.js +1094 -0
  109. package/dist/cli/pages/community.js.map +1 -0
  110. package/dist/cli/pages/helpers.d.ts +37 -0
  111. package/dist/cli/pages/helpers.d.ts.map +1 -0
  112. package/dist/cli/pages/helpers.js +98 -0
  113. package/dist/cli/pages/helpers.js.map +1 -0
  114. package/dist/cli/pages/home.d.ts +4 -0
  115. package/dist/cli/pages/home.d.ts.map +1 -0
  116. package/dist/cli/pages/home.js +231 -0
  117. package/dist/cli/pages/home.js.map +1 -0
  118. package/dist/cli/pages/marketplace.d.ts +5 -0
  119. package/dist/cli/pages/marketplace.d.ts.map +1 -0
  120. package/dist/cli/pages/marketplace.js +583 -0
  121. package/dist/cli/pages/marketplace.js.map +1 -0
  122. package/dist/cli/pages/news.d.ts +31 -0
  123. package/dist/cli/pages/news.d.ts.map +1 -0
  124. package/dist/cli/pages/news.js +688 -0
  125. package/dist/cli/pages/news.js.map +1 -0
  126. package/dist/cli/pages/settings.d.ts +3 -0
  127. package/dist/cli/pages/settings.d.ts.map +1 -0
  128. package/dist/cli/pages/settings.js +296 -0
  129. package/dist/cli/pages/settings.js.map +1 -0
  130. package/dist/cli/pages/types.d.ts +67 -0
  131. package/dist/cli/pages/types.d.ts.map +1 -0
  132. package/dist/cli/pages/types.js +2 -0
  133. package/dist/cli/pages/types.js.map +1 -0
  134. package/dist/cli/prompter.d.ts +135 -0
  135. package/dist/cli/prompter.d.ts.map +1 -0
  136. package/dist/cli/prompter.js +710 -0
  137. package/dist/cli/prompter.js.map +1 -0
  138. package/dist/cli/shell.d.ts +23 -0
  139. package/dist/cli/shell.d.ts.map +1 -0
  140. package/dist/cli/shell.js +1106 -0
  141. package/dist/cli/shell.js.map +1 -0
  142. package/dist/cli/tui.d.ts +7 -0
  143. package/dist/cli/tui.d.ts.map +1 -0
  144. package/dist/cli/tui.js +419 -0
  145. package/dist/cli/tui.js.map +1 -0
  146. package/dist/cli.js +1 -1
  147. package/dist/cli.js.map +1 -1
  148. package/dist/commands.d.ts +14 -0
  149. package/dist/commands.d.ts.map +1 -0
  150. package/dist/commands.js +28 -0
  151. package/dist/commands.js.map +1 -0
  152. package/dist/community/client.d.ts +84 -0
  153. package/dist/community/client.d.ts.map +1 -0
  154. package/dist/community/client.js +340 -0
  155. package/dist/community/client.js.map +1 -0
  156. package/dist/community/search.d.ts +25 -0
  157. package/dist/community/search.d.ts.map +1 -0
  158. package/dist/community/search.js +62 -0
  159. package/dist/community/search.js.map +1 -0
  160. package/dist/community/types.d.ts +71 -0
  161. package/dist/community/types.d.ts.map +1 -0
  162. package/dist/community/types.js +11 -0
  163. package/dist/community/types.js.map +1 -0
  164. package/dist/config.d.ts +1 -7
  165. package/dist/config.d.ts.map +1 -1
  166. package/dist/config.js +0 -32
  167. package/dist/config.js.map +1 -1
  168. package/dist/data.d.ts +1 -1
  169. package/dist/data.d.ts.map +1 -1
  170. package/dist/data.js +778 -40
  171. package/dist/data.js.map +1 -1
  172. package/dist/format.d.ts +5 -39
  173. package/dist/format.d.ts.map +1 -1
  174. package/dist/format.js +5 -120
  175. package/dist/format.js.map +1 -1
  176. package/dist/history.d.ts +13 -0
  177. package/dist/history.d.ts.map +1 -0
  178. package/dist/history.js +37 -0
  179. package/dist/history.js.map +1 -0
  180. package/dist/hubs/cache.d.ts +6 -0
  181. package/dist/hubs/cache.d.ts.map +1 -0
  182. package/dist/hubs/cache.js +46 -0
  183. package/dist/hubs/cache.js.map +1 -0
  184. package/dist/hubs/enrichment.d.ts +43 -0
  185. package/dist/hubs/enrichment.d.ts.map +1 -0
  186. package/dist/hubs/enrichment.js +239 -0
  187. package/dist/hubs/enrichment.js.map +1 -0
  188. package/dist/hubs/github.d.ts +12 -0
  189. package/dist/hubs/github.d.ts.map +1 -0
  190. package/dist/hubs/github.js +54 -0
  191. package/dist/hubs/github.js.map +1 -0
  192. package/dist/hubs/huggingface.d.ts +27 -0
  193. package/dist/hubs/huggingface.d.ts.map +1 -0
  194. package/dist/hubs/huggingface.js +88 -0
  195. package/dist/hubs/huggingface.js.map +1 -0
  196. package/dist/hubs/quality.d.ts +26 -0
  197. package/dist/hubs/quality.d.ts.map +1 -0
  198. package/dist/hubs/quality.js +57 -0
  199. package/dist/hubs/quality.js.map +1 -0
  200. package/dist/hubs/types.d.ts +30 -0
  201. package/dist/hubs/types.d.ts.map +1 -0
  202. package/dist/hubs/types.js +2 -0
  203. package/dist/hubs/types.js.map +1 -0
  204. package/dist/index.d.ts.map +1 -1
  205. package/dist/index.js +188 -224
  206. package/dist/index.js.map +1 -1
  207. package/dist/init.d.ts.map +1 -1
  208. package/dist/init.js +6 -11
  209. package/dist/init.js.map +1 -1
  210. package/dist/live.d.ts +14 -0
  211. package/dist/live.d.ts.map +1 -1
  212. package/dist/live.js +35 -3
  213. package/dist/live.js.map +1 -1
  214. package/dist/marketplace.d.ts +25 -3
  215. package/dist/marketplace.d.ts.map +1 -1
  216. package/dist/marketplace.js +279 -22
  217. package/dist/marketplace.js.map +1 -1
  218. package/dist/news/cache.d.ts +13 -0
  219. package/dist/news/cache.d.ts.map +1 -0
  220. package/dist/news/cache.js +66 -0
  221. package/dist/news/cache.js.map +1 -0
  222. package/dist/news/score.d.ts +4 -0
  223. package/dist/news/score.d.ts.map +1 -0
  224. package/dist/news/score.js +43 -0
  225. package/dist/news/score.js.map +1 -0
  226. package/dist/news/sources/arxiv.d.ts +9 -0
  227. package/dist/news/sources/arxiv.d.ts.map +1 -0
  228. package/dist/news/sources/arxiv.js +107 -0
  229. package/dist/news/sources/arxiv.js.map +1 -0
  230. package/dist/news/sources/github-trending.d.ts +9 -0
  231. package/dist/news/sources/github-trending.d.ts.map +1 -0
  232. package/dist/news/sources/github-trending.js +97 -0
  233. package/dist/news/sources/github-trending.js.map +1 -0
  234. package/dist/news/sources/hn.d.ts +9 -0
  235. package/dist/news/sources/hn.d.ts.map +1 -0
  236. package/dist/news/sources/hn.js +57 -0
  237. package/dist/news/sources/hn.js.map +1 -0
  238. package/dist/news/sources/reddit.d.ts +9 -0
  239. package/dist/news/sources/reddit.d.ts.map +1 -0
  240. package/dist/news/sources/reddit.js +69 -0
  241. package/dist/news/sources/reddit.js.map +1 -0
  242. package/dist/news/sources/rss.d.ts +13 -0
  243. package/dist/news/sources/rss.d.ts.map +1 -0
  244. package/dist/news/sources/rss.js +14 -0
  245. package/dist/news/sources/rss.js.map +1 -0
  246. package/dist/news/types.d.ts +42 -0
  247. package/dist/news/types.d.ts.map +1 -0
  248. package/dist/news/types.js +56 -0
  249. package/dist/news/types.js.map +1 -0
  250. package/dist/preferences.d.ts +14 -0
  251. package/dist/preferences.d.ts.map +1 -0
  252. package/dist/preferences.js +31 -0
  253. package/dist/preferences.js.map +1 -0
  254. package/dist/settings.d.ts +26 -0
  255. package/dist/settings.d.ts.map +1 -0
  256. package/dist/settings.js +251 -0
  257. package/dist/settings.js.map +1 -0
  258. package/dist/state.d.ts +9 -2
  259. package/dist/state.d.ts.map +1 -1
  260. package/dist/state.js +41 -19
  261. package/dist/state.js.map +1 -1
  262. package/dist/transcript.d.ts +28 -0
  263. package/dist/transcript.d.ts.map +1 -0
  264. package/dist/transcript.js +79 -0
  265. package/dist/transcript.js.map +1 -0
  266. package/dist/types.d.ts +19 -1
  267. package/dist/types.d.ts.map +1 -1
  268. package/dist/ui.d.ts +157 -0
  269. package/dist/ui.d.ts.map +1 -0
  270. package/dist/ui.js +296 -0
  271. package/dist/ui.js.map +1 -0
  272. package/package.json +11 -9
  273. package/dist/api.d.ts +0 -72
  274. package/dist/api.d.ts.map +0 -1
  275. package/dist/api.js +0 -109
  276. package/dist/api.js.map +0 -1
  277. package/dist/logger.d.ts +0 -20
  278. package/dist/logger.d.ts.map +0 -1
  279. package/dist/logger.js +0 -59
  280. package/dist/logger.js.map +0 -1
@@ -0,0 +1,1094 @@
1
+ import { communityBoardsSource, communityThreadsSource, communityThreadSource, communitySearchSource, createThreadSource, createReplySource, voteSource, flagSource } from '../../community/client.js';
2
+ import { BOARD_LABELS } from '../../community/types.js';
3
+ import { vlen, rail, noRail, sep, frame, pageSourceOptions } from './helpers.js';
4
+ // ── Helpers ──────────────────────────────────────────────────────────────────
5
+ function hoursAgo(iso) {
6
+ return Math.max(0, (Date.now() - new Date(iso).getTime()) / 3600000);
7
+ }
8
+ function fmtAge(hours) {
9
+ if (hours < 1)
10
+ return '<1h';
11
+ if (hours < 24)
12
+ return Math.round(hours) + 'h';
13
+ return Math.round(hours / 24) + 'd';
14
+ }
15
+ // ── Module state ─────────────────────────────────────────────────────────────
16
+ let cachedBoards = [];
17
+ let cachedThreads = [];
18
+ let cachedReplies = [];
19
+ let boardsLoading = true;
20
+ const state = {
21
+ view: 'boards',
22
+ boardCur: 0,
23
+ threadCur: 0,
24
+ replyCur: 0,
25
+ filter: '',
26
+ filtering: false,
27
+ threadSort: 'top',
28
+ composer: null,
29
+ flagModal: null,
30
+ expandedItems: new Set(),
31
+ userVotes: new Map(),
32
+ statusMessage: '',
33
+ statusTimer: null,
34
+ search: null
35
+ };
36
+ // Debounce timer for search API calls
37
+ let searchDebounceTimer = null;
38
+ // ── Pure helpers (exported for tests) ────────────────────────────────────────
39
+ export function isCollapsed(id, flagCount, expandedItems) {
40
+ return flagCount >= 3 && !expandedItems.has(id);
41
+ }
42
+ export function renderCollapsed(flagCount) {
43
+ return `[flagged: ${flagCount} · press X to expand]`;
44
+ }
45
+ export function voteGlyph(yourVote, score, style) {
46
+ if (yourVote === 1)
47
+ return style.accent('▲') + style.accent(String(score));
48
+ if (yourVote === -1)
49
+ return style.accent('▼') + style.accent(String(score));
50
+ return style.dim('↑') + String(score);
51
+ }
52
+ // ── Internal helpers ──────────────────────────────────────────────────────────
53
+ function flattenReplies(replies, depth = 0) {
54
+ const flat = [];
55
+ for (const r of replies) {
56
+ flat.push({ reply: r, depth });
57
+ if (r.children && r.children.length > 0) {
58
+ flat.push(...flattenReplies(r.children, depth + 1));
59
+ }
60
+ }
61
+ return flat;
62
+ }
63
+ function buildSourceOptions(ctx) {
64
+ return pageSourceOptions(ctx);
65
+ }
66
+ function setStatus(msg) {
67
+ if (state.statusTimer)
68
+ clearTimeout(state.statusTimer);
69
+ state.statusMessage = msg;
70
+ state.statusTimer = setTimeout(() => {
71
+ state.statusMessage = '';
72
+ state.statusTimer = null;
73
+ }, 3000);
74
+ }
75
+ // ── Data loading ──────────────────────────────────────────────────────────────
76
+ async function loadBoards(ctx) {
77
+ try {
78
+ const opts = buildSourceOptions(ctx);
79
+ const result = await communityBoardsSource(opts);
80
+ cachedBoards = result.data.boards;
81
+ }
82
+ catch {
83
+ /* keep empty */
84
+ }
85
+ boardsLoading = false;
86
+ ctx.repaint();
87
+ }
88
+ async function loadThreads(boardId, ctx) {
89
+ const opts = buildSourceOptions(ctx);
90
+ const result = await communityThreadsSource(opts, boardId, state.threadSort);
91
+ cachedThreads = result.data.threads;
92
+ }
93
+ // ── Composer state-machine ─────────────────────────────────────────────────
94
+ function composerInsertChar(ch) {
95
+ if (!state.composer)
96
+ return;
97
+ const c = state.composer;
98
+ const line = c.lines[c.cursorLine] ?? '';
99
+ c.lines[c.cursorLine] = line.slice(0, c.cursorCol) + ch + line.slice(c.cursorCol);
100
+ c.cursorCol++;
101
+ }
102
+ function composerBackspace() {
103
+ if (!state.composer)
104
+ return;
105
+ const c = state.composer;
106
+ const line = c.lines[c.cursorLine] ?? '';
107
+ if (c.cursorCol > 0) {
108
+ c.lines[c.cursorLine] = line.slice(0, c.cursorCol - 1) + line.slice(c.cursorCol);
109
+ c.cursorCol--;
110
+ }
111
+ else if (c.cursorLine > 0) {
112
+ const prev = c.lines[c.cursorLine - 1] ?? '';
113
+ c.lines.splice(c.cursorLine, 1);
114
+ c.cursorLine--;
115
+ c.cursorCol = prev.length;
116
+ c.lines[c.cursorLine] = prev + line;
117
+ }
118
+ }
119
+ function composerNewline() {
120
+ if (!state.composer)
121
+ return;
122
+ const c = state.composer;
123
+ const line = c.lines[c.cursorLine] ?? '';
124
+ const rest = line.slice(c.cursorCol);
125
+ c.lines[c.cursorLine] = line.slice(0, c.cursorCol);
126
+ c.lines.splice(c.cursorLine + 1, 0, rest);
127
+ c.cursorLine++;
128
+ c.cursorCol = 0;
129
+ }
130
+ function openComposer(mode, replyTo) {
131
+ state.composer = {
132
+ active: true,
133
+ mode,
134
+ lines: [''],
135
+ cursorLine: 0,
136
+ cursorCol: 0,
137
+ title: mode === 'new-thread' ? '' : undefined,
138
+ board: mode === 'new-thread' ? (state.board ?? 'meta') : undefined,
139
+ replyTo,
140
+ status: 'editing'
141
+ };
142
+ }
143
+ function closeComposer() {
144
+ state.composer = null;
145
+ }
146
+ async function sendComposer(ctx) {
147
+ if (!state.composer)
148
+ return;
149
+ const c = state.composer;
150
+ const content = c.lines.join('\n').trim();
151
+ if (!content)
152
+ return;
153
+ c.status = 'sending';
154
+ const opts = buildSourceOptions(ctx);
155
+ try {
156
+ if (c.mode === 'reply' && c.replyTo) {
157
+ await createReplySource(opts, c.replyTo, { content });
158
+ // Refresh thread
159
+ const tid = state.thread ?? '';
160
+ if (tid) {
161
+ const result = await communityThreadSource(opts, tid);
162
+ cachedReplies = result.data.replies;
163
+ }
164
+ closeComposer();
165
+ setStatus('Reply posted.');
166
+ }
167
+ else if (c.mode === 'new-thread') {
168
+ const title = (c.title ?? '').trim();
169
+ if (!title) {
170
+ c.status = 'error';
171
+ c.errorMessage = 'Title is required.';
172
+ return;
173
+ }
174
+ const board = c.board ?? 'meta';
175
+ await createThreadSource(opts, { board, title, content });
176
+ // Refresh threads
177
+ const result = await communityThreadsSource(opts, board);
178
+ cachedThreads = result.data.threads;
179
+ state.view = 'threads';
180
+ closeComposer();
181
+ setStatus('Thread created.');
182
+ }
183
+ }
184
+ catch (err) {
185
+ c.status = 'error';
186
+ c.errorMessage = err instanceof Error ? err.message : 'Unknown error';
187
+ }
188
+ }
189
+ // ── Voting ────────────────────────────────────────────────────────────────────
190
+ async function doVote(targetId, targetType, dir, ctx) {
191
+ const current = state.userVotes.get(targetId) ?? 0;
192
+ const newVal = current === dir ? 0 : dir;
193
+ // Optimistic update
194
+ state.userVotes.set(targetId, newVal);
195
+ const delta = newVal - current;
196
+ if (targetType === 'discussion') {
197
+ const t = cachedThreads.find((x) => x.id === targetId);
198
+ if (t)
199
+ t.score += delta;
200
+ }
201
+ else {
202
+ const flat = flattenReplies(cachedReplies);
203
+ const rn = flat.find((f) => f.reply.id === targetId);
204
+ if (rn)
205
+ rn.reply.score += delta;
206
+ }
207
+ try {
208
+ const opts = buildSourceOptions(ctx);
209
+ const result = await voteSource(opts, targetId, { value: newVal, targetType });
210
+ // Reconcile with actual score
211
+ if (targetType === 'discussion') {
212
+ const t = cachedThreads.find((x) => x.id === targetId);
213
+ if (t)
214
+ t.score = result.data.score;
215
+ }
216
+ else {
217
+ const flat = flattenReplies(cachedReplies);
218
+ const rn = flat.find((f) => f.reply.id === targetId);
219
+ if (rn)
220
+ rn.reply.score = result.data.score;
221
+ }
222
+ state.userVotes.set(targetId, result.data.userVote);
223
+ }
224
+ catch {
225
+ setStatus('Vote failed.');
226
+ // Revert optimistic
227
+ state.userVotes.set(targetId, current);
228
+ if (targetType === 'discussion') {
229
+ const t = cachedThreads.find((x) => x.id === targetId);
230
+ if (t)
231
+ t.score -= delta;
232
+ }
233
+ else {
234
+ const flat = flattenReplies(cachedReplies);
235
+ const rn = flat.find((f) => f.reply.id === targetId);
236
+ if (rn)
237
+ rn.reply.score -= delta;
238
+ }
239
+ }
240
+ }
241
+ // ── Flagging ──────────────────────────────────────────────────────────────────
242
+ async function submitFlag(reason, ctx) {
243
+ if (!state.flagModal)
244
+ return;
245
+ const { targetId, targetType, notes } = state.flagModal;
246
+ const opts = buildSourceOptions(ctx);
247
+ try {
248
+ await flagSource(opts, targetId, { reason, notes: notes || undefined, targetType });
249
+ setStatus('Flagged.');
250
+ }
251
+ catch {
252
+ setStatus('Flag failed.');
253
+ }
254
+ state.flagModal = null;
255
+ }
256
+ // ── Search ────────────────────────────────────────────────────────────────────
257
+ function openSearch(scope) {
258
+ state.search = {
259
+ active: true,
260
+ query: '',
261
+ cursorCol: 0,
262
+ results: null,
263
+ loading: false,
264
+ selectedIndex: 0,
265
+ scope
266
+ };
267
+ }
268
+ function closeSearch() {
269
+ if (searchDebounceTimer) {
270
+ clearTimeout(searchDebounceTimer);
271
+ searchDebounceTimer = null;
272
+ }
273
+ state.search = null;
274
+ }
275
+ function scheduleSearch(ctx) {
276
+ if (!state.search)
277
+ return;
278
+ if (searchDebounceTimer)
279
+ clearTimeout(searchDebounceTimer);
280
+ const q = state.search.query;
281
+ if (q.length < 2) {
282
+ state.search.results = null;
283
+ state.search.loading = false;
284
+ return;
285
+ }
286
+ state.search.loading = true;
287
+ // Debounce: fire the API call only when the user pauses typing for 400ms
288
+ searchDebounceTimer = setTimeout(async () => {
289
+ searchDebounceTimer = null;
290
+ if (!state.search || state.search.query !== q)
291
+ return;
292
+ try {
293
+ const opts = buildSourceOptions(ctx);
294
+ const board = state.search.scope === 'all' ? undefined : state.search.scope;
295
+ const result = await communitySearchSource(opts, q, board);
296
+ if (state.search && state.search.query === q) {
297
+ state.search.results = result.data;
298
+ state.search.loading = false;
299
+ state.search.selectedIndex = 0;
300
+ }
301
+ }
302
+ catch {
303
+ if (state.search)
304
+ state.search.loading = false;
305
+ }
306
+ }, 400);
307
+ }
308
+ function searchFlatList(results) {
309
+ return [...results.results.threads, ...results.results.replies];
310
+ }
311
+ function renderSearchView(ctx, width, height) {
312
+ const { style } = ctx;
313
+ const s = state.search;
314
+ if (!s)
315
+ return frame([], width, height);
316
+ const lines = [];
317
+ const rule = ' ' + style.dim('─'.repeat(Math.max(0, width - 2)));
318
+ // Header
319
+ const scopeLabel = 'scope: ' + s.scope;
320
+ const headerLeft = style.bold('─ SEARCH ─');
321
+ const headerRight = style.dim(scopeLabel);
322
+ const gap = Math.max(2, width - vlen(' ' + headerLeft) - vlen(headerRight) - 2);
323
+ lines.push(' ' + headerLeft + ' '.repeat(gap) + headerRight);
324
+ // Query input line
325
+ const cursor = style.dim('▏');
326
+ const queryBefore = s.query.slice(0, s.cursorCol);
327
+ const queryAfter = s.query.slice(s.cursorCol);
328
+ lines.push(' ' + style.accent('> ') + queryBefore + cursor + queryAfter);
329
+ lines.push(rule);
330
+ if (!s.results && !s.loading) {
331
+ lines.push(' ' + style.dim('type to search (min 2 chars) · Tab to change scope'));
332
+ }
333
+ else if (s.loading) {
334
+ lines.push(' ' + style.dim('(loading…)'));
335
+ }
336
+ else if (s.results) {
337
+ const flat = searchFlatList(s.results);
338
+ if (flat.length === 0) {
339
+ lines.push(' ' + style.dim('(no results)'));
340
+ }
341
+ else {
342
+ if (s.results.truncated) {
343
+ lines.push(' ' + style.dim('(truncated — refine query)'));
344
+ }
345
+ const threads = s.results.results.threads;
346
+ const replies = s.results.results.replies;
347
+ if (threads.length > 0) {
348
+ lines.push(' ' + style.bold('THREADS (' + threads.length + ')'));
349
+ threads.forEach((hit, i) => {
350
+ const absIdx = i;
351
+ const sel = absIdx === s.selectedIndex;
352
+ const lead = sel ? style.accent('> ') : ' ';
353
+ lines.push(lead + style.dim('/' + hit.board + ' · ') + (sel ? style.bold(hit.title) : hit.title));
354
+ lines.push(' ' + style.dim(' ') + renderSnippet(hit.snippet, style));
355
+ });
356
+ }
357
+ if (replies.length > 0) {
358
+ lines.push(' ' + style.bold('REPLIES (' + replies.length + ')'));
359
+ replies.forEach((hit, i) => {
360
+ const absIdx = threads.length + i;
361
+ const sel = absIdx === s.selectedIndex;
362
+ const lead = sel ? style.accent('> ') : ' ';
363
+ lines.push(lead + style.dim('/' + hit.board + ' · in "' + hit.title + '"'));
364
+ lines.push(' ' + style.dim(' ') + renderSnippet(hit.snippet, style));
365
+ });
366
+ }
367
+ }
368
+ }
369
+ return frame(lines, width, height);
370
+ }
371
+ function renderSnippet(snippet, style) {
372
+ // Highlight [matched] text with accent style; keep surrounding text dim
373
+ return snippet.replace(/\[([^\]]*)\]/g, (_match, inner) => style.accent(inner));
374
+ }
375
+ // ── Render ────────────────────────────────────────────────────────────────────
376
+ export const communityPage = {
377
+ id: 'community',
378
+ title: 'COMMUNITY',
379
+ navLabel: 'Comm',
380
+ navIcon: 'C',
381
+ hotkeys: [
382
+ { key: 'j/k', label: 'nav' },
383
+ { key: 'g/G', label: 'top/end' },
384
+ { key: 'Enter', label: 'open' },
385
+ { key: 'n', label: 'new' },
386
+ { key: 'o', label: 'sort' },
387
+ { key: '/', label: 'search' },
388
+ { key: 'r', label: 'reply' },
389
+ { key: '+/-', label: 'vote' },
390
+ { key: 'f', label: 'flag' },
391
+ { key: 'X', label: 'expand' },
392
+ { key: 'Esc', label: 'back' }
393
+ ],
394
+ mount(_ctx) {
395
+ boardsLoading = true;
396
+ loadBoards(_ctx);
397
+ },
398
+ unmount() {
399
+ if (state.statusTimer) {
400
+ clearTimeout(state.statusTimer);
401
+ state.statusTimer = null;
402
+ }
403
+ if (searchDebounceTimer) {
404
+ clearTimeout(searchDebounceTimer);
405
+ searchDebounceTimer = null;
406
+ }
407
+ state.search = null;
408
+ state.composer = null;
409
+ state.flagModal = null;
410
+ state.filtering = false;
411
+ state.filter = '';
412
+ state.view = 'boards';
413
+ state.expandedItems.clear();
414
+ state.userVotes.clear();
415
+ },
416
+ render(ctx) {
417
+ const { style, width, height } = ctx;
418
+ const lines = [];
419
+ const rule = ' ' + style.dim('─'.repeat(Math.max(0, width - 2)));
420
+ // Search overlay takes priority over all other views
421
+ if (state.search?.active) {
422
+ return renderSearchView(ctx, width, height);
423
+ }
424
+ if (boardsLoading) {
425
+ lines.push(' ' + style.dim('Loading community…'));
426
+ return frame(lines, width, height);
427
+ }
428
+ if (state.view === 'boards') {
429
+ const newTotal = cachedBoards.reduce((s, b) => s + b.newToday, 0);
430
+ lines.push(' ' +
431
+ style.bold(style.accent('COMMUNITY')) +
432
+ style.dim(' ' + cachedBoards.length + ' boards · ' + newTotal + ' new today'));
433
+ lines.push(rule);
434
+ const list = cachedBoards.filter((b) => !state.filter || b.id.includes(state.filter));
435
+ if (list.length === 0) {
436
+ lines.push(' ' + style.dim('No boards match.'));
437
+ }
438
+ else {
439
+ list.forEach((b, i) => {
440
+ const sel = i === state.boardCur;
441
+ const lead = sel ? rail(style) : noRail();
442
+ const displayName = '/' + b.id;
443
+ const name = sel ? style.bold(displayName) : displayName;
444
+ const stats = style.dim(b.threadCount.toString().padStart(4) +
445
+ ' th ' +
446
+ b.newToday.toString().padStart(2) +
447
+ ' new');
448
+ const gap = Math.max(2, width - vlen(' ' + lead + displayName) - vlen(stats) - 1);
449
+ lines.push(' ' + lead + name + ' '.repeat(gap) + stats);
450
+ const label = BOARD_LABELS[b.id];
451
+ if (label)
452
+ lines.push(' ' + style.dim(label));
453
+ });
454
+ }
455
+ }
456
+ else if (state.view === 'threads') {
457
+ const board = state.board ?? cachedBoards[state.boardCur]?.id ?? 'mcp';
458
+ const list = cachedThreads.filter((t) => !state.filter || t.title.toLowerCase().includes(state.filter.toLowerCase()));
459
+ lines.push(' ' +
460
+ style.bold(style.accent('/' + board)) +
461
+ style.dim(' ' + list.length + ' threads') +
462
+ ' ' +
463
+ style.dim('sort: ') +
464
+ style.accent(state.threadSort));
465
+ lines.push(rule);
466
+ if (list.length === 0) {
467
+ lines.push(' ' + style.dim('Empty board. ') + style.accent('n') + style.dim(' to start.'));
468
+ }
469
+ else {
470
+ list.forEach((t, i) => {
471
+ const sel = i === state.threadCur;
472
+ const lead = sel ? rail(style) : noRail();
473
+ const age = fmtAge(hoursAgo(t.createdAt));
474
+ if (isCollapsed(t.id, t.flagCount, state.expandedItems)) {
475
+ const collapsed = renderCollapsed(t.flagCount);
476
+ const title = sel ? style.bold(collapsed) : style.dim(collapsed);
477
+ lines.push(' ' + lead + title);
478
+ lines.push(rule);
479
+ return;
480
+ }
481
+ const title = sel ? style.bold(t.title) : t.title;
482
+ let authorMeta = t.author;
483
+ if (t.authorIsLLM)
484
+ authorMeta += style.dim(' [bot · ' + (t.authorModel ?? 'llm') + ']');
485
+ const meta = style.dim(authorMeta + ' · ' + age);
486
+ const myVote = state.userVotes.get(t.id) ?? 0;
487
+ const voteStr = voteGlyph(myVote, t.score, style);
488
+ const counts = voteStr +
489
+ style.dim(' ') +
490
+ style.accent(t.replyCount.toString().padStart(2)) +
491
+ style.dim(' replies');
492
+ lines.push(' ' + lead + title);
493
+ lines.push(' ' + meta + ' ' + counts);
494
+ lines.push(rule);
495
+ });
496
+ }
497
+ }
498
+ else {
499
+ const tid = state.thread ?? '';
500
+ const t = cachedThreads.find((x) => x.id === tid);
501
+ if (!t) {
502
+ lines.push(' ' + style.dim('Thread not found. Esc to go back.'));
503
+ }
504
+ else {
505
+ const age = fmtAge(hoursAgo(t.createdAt));
506
+ let authorMeta = t.author;
507
+ if (t.authorIsLLM)
508
+ authorMeta += style.dim(' [bot · ' + (t.authorModel ?? 'llm') + ']');
509
+ const myVoteThread = state.userVotes.get(t.id) ?? 0;
510
+ const threadVoteStr = voteGlyph(myVoteThread, t.score, style);
511
+ lines.push(' ' + style.bold(t.title) + ' ' + threadVoteStr);
512
+ lines.push(' ' + style.dim(authorMeta + ' · ' + age + ' · ' + t.replyCount + ' replies'));
513
+ lines.push(' ' + sep('body', width - 2, style));
514
+ lines.push(' ' + t.content);
515
+ lines.push(' ' + sep('replies', width - 2, style));
516
+ const flatReplies = flattenReplies(cachedReplies);
517
+ flatReplies.forEach((rn, i) => {
518
+ const sel = i === state.replyCur;
519
+ const lead = sel ? rail(style) : noRail();
520
+ const indent = '│ '.repeat(rn.depth);
521
+ const ra = fmtAge(hoursAgo(rn.reply.createdAt));
522
+ if (isCollapsed(rn.reply.id, rn.reply.flagCount, state.expandedItems)) {
523
+ lines.push(' ' + indent + lead + style.dim(renderCollapsed(rn.reply.flagCount)));
524
+ lines.push(rule);
525
+ return;
526
+ }
527
+ let replyAuthor = rn.reply.author;
528
+ if (rn.reply.authorIsLLM)
529
+ replyAuthor += style.dim(' [bot · ' + (rn.reply.authorModel ?? 'llm') + ']');
530
+ const myVoteReply = state.userVotes.get(rn.reply.id) ?? 0;
531
+ const replyVoteStr = voteGlyph(myVoteReply, rn.reply.score, style);
532
+ lines.push(' ' +
533
+ indent +
534
+ lead +
535
+ style.dim(replyAuthor + ' · ' + ra) +
536
+ ' ' +
537
+ replyVoteStr);
538
+ lines.push(' ' + indent + rn.reply.content);
539
+ lines.push(rule);
540
+ });
541
+ }
542
+ }
543
+ // Status message
544
+ if (state.statusMessage) {
545
+ lines.push(' ' + style.dim(state.statusMessage));
546
+ }
547
+ if (state.filtering) {
548
+ lines.push(' ' + style.accent('/') + ' ' + state.filter + style.dim('▏'));
549
+ }
550
+ // Flag modal overlay (last — overwrites bottom lines in frame)
551
+ if (state.flagModal?.active) {
552
+ const fm = state.flagModal;
553
+ const rendered = frame(lines, width, height).split('\n');
554
+ const modalLines = [
555
+ ' ' + style.bold('Flag reason:'),
556
+ ' ' + style.accent('1') + style.dim('. spam'),
557
+ ' ' + style.accent('2') + style.dim('. harassment'),
558
+ ' ' + style.accent('3') + style.dim('. undisclosed-llm'),
559
+ ' ' + style.accent('4') + style.dim('. malicious'),
560
+ ' ' + style.accent('5') + style.dim('. other'),
561
+ ' ' + style.dim('Esc to cancel')
562
+ ];
563
+ if (fm.awaitingNotes) {
564
+ modalLines.push(' Notes: ' + fm.notes + style.dim('▏'));
565
+ }
566
+ const startLine = Math.max(0, height - modalLines.length - 1);
567
+ rendered[startLine] = ' ' + style.dim('─'.repeat(Math.max(0, width - 2)));
568
+ for (let i = 0; i < modalLines.length; i++) {
569
+ if (startLine + 1 + i < height) {
570
+ rendered[startLine + 1 + i] = modalLines[i].padEnd(width).slice(0, width);
571
+ }
572
+ }
573
+ return rendered.join('\n');
574
+ }
575
+ // Composer overlay
576
+ if (state.composer?.active) {
577
+ const comp = state.composer;
578
+ const rendered = frame(lines, width, height).split('\n');
579
+ const label = comp.mode === 'reply'
580
+ ? '[REPLY to ' + (comp.replyTo ?? '') + ']'
581
+ : '[NEW THREAD in /' + (comp.board ?? 'meta') + ']';
582
+ const compLines = [];
583
+ compLines.push(' ' + style.dim('─'.repeat(Math.max(0, width - 2))));
584
+ compLines.push(' ' + style.accent(label) + style.dim(' ' + comp.lines.length + ' lines'));
585
+ if (comp.mode === 'new-thread') {
586
+ compLines.push(' Title: ' + (comp.title ?? '') + (comp.status === 'editing' ? style.dim('▏') : ''));
587
+ }
588
+ const visibleContentLines = Math.max(3, height - rendered.length + compLines.length - 2);
589
+ const contentStart = Math.max(0, comp.cursorLine - visibleContentLines + 1);
590
+ for (let i = contentStart; i < Math.min(comp.lines.length, contentStart + visibleContentLines); i++) {
591
+ const lineText = comp.lines[i] ?? '';
592
+ if (i === comp.cursorLine) {
593
+ compLines.push(' ' +
594
+ lineText.slice(0, comp.cursorCol) +
595
+ style.dim('▏') +
596
+ lineText.slice(comp.cursorCol));
597
+ }
598
+ else {
599
+ compLines.push(' ' + lineText);
600
+ }
601
+ }
602
+ if (comp.status === 'sending') {
603
+ compLines.push(' ' + style.dim('Sending…'));
604
+ }
605
+ else if (comp.status === 'error') {
606
+ compLines.push(' ' + style.dim('Error: ' + (comp.errorMessage ?? 'unknown')));
607
+ }
608
+ else {
609
+ compLines.push(' ' + style.dim('Ctrl+S send · Esc cancel'));
610
+ }
611
+ const startLine = Math.max(0, height - compLines.length);
612
+ for (let i = 0; i < compLines.length; i++) {
613
+ if (startLine + i < height) {
614
+ rendered[startLine + i] = (compLines[i] ?? '').padEnd(width).slice(0, width);
615
+ }
616
+ }
617
+ return rendered.join('\n');
618
+ }
619
+ return frame(lines, width, height);
620
+ },
621
+ async handleKey(event, ctx) {
622
+ // ── Search ──────────────────────────────────────────────────────────────
623
+ if (state.search?.active) {
624
+ const s = state.search;
625
+ const flat = s.results ? searchFlatList(s.results) : [];
626
+ if (event.key === 'esc') {
627
+ closeSearch();
628
+ return { kind: 'none' };
629
+ }
630
+ if (event.key === 'enter') {
631
+ const hit = flat[s.selectedIndex];
632
+ if (hit) {
633
+ closeSearch();
634
+ // Fetch thread if not in cache
635
+ const opts = buildSourceOptions(ctx);
636
+ let thread = cachedThreads.find((t) => t.id === hit.threadId);
637
+ if (!thread) {
638
+ const result = await communityThreadSource(opts, hit.threadId);
639
+ if (result.data.thread) {
640
+ cachedThreads = [...cachedThreads, result.data.thread];
641
+ cachedReplies = result.data.replies;
642
+ thread = result.data.thread;
643
+ }
644
+ }
645
+ else {
646
+ const result = await communityThreadSource(opts, hit.threadId);
647
+ cachedReplies = result.data.replies;
648
+ }
649
+ if (thread) {
650
+ state.board = thread.board;
651
+ state.thread = hit.threadId;
652
+ state.view = 'reader';
653
+ const flatR = flattenReplies(cachedReplies);
654
+ if (hit.kind === 'reply') {
655
+ const idx = flatR.findIndex((r) => r.reply.id === hit.id);
656
+ state.replyCur = idx >= 0 ? idx : 0;
657
+ }
658
+ else {
659
+ state.replyCur = 0;
660
+ }
661
+ }
662
+ }
663
+ return { kind: 'none' };
664
+ }
665
+ if (event.key === 'j' || event.key === 'down') {
666
+ s.selectedIndex = Math.min(flat.length - 1, s.selectedIndex + 1);
667
+ return { kind: 'none' };
668
+ }
669
+ if (event.key === 'k' || event.key === 'up') {
670
+ s.selectedIndex = Math.max(0, s.selectedIndex - 1);
671
+ return { kind: 'none' };
672
+ }
673
+ if (event.key === 'tab') {
674
+ // Cycle scope: 'all' ↔ current board
675
+ const currentBoard = state.board ?? undefined;
676
+ if (s.scope === 'all' && currentBoard) {
677
+ s.scope = currentBoard;
678
+ }
679
+ else {
680
+ s.scope = 'all';
681
+ }
682
+ scheduleSearch(ctx);
683
+ return { kind: 'none' };
684
+ }
685
+ if (event.key === 'backspace') {
686
+ if (s.cursorCol > 0) {
687
+ s.query = s.query.slice(0, s.cursorCol - 1) + s.query.slice(s.cursorCol);
688
+ s.cursorCol--;
689
+ scheduleSearch(ctx);
690
+ }
691
+ return { kind: 'none' };
692
+ }
693
+ if (event.key.length === 1 && !event.ctrl && !event.meta) {
694
+ s.query = s.query.slice(0, s.cursorCol) + event.key + s.query.slice(s.cursorCol);
695
+ s.cursorCol++;
696
+ scheduleSearch(ctx);
697
+ return { kind: 'none' };
698
+ }
699
+ if (event.key === 'space') {
700
+ s.query = s.query.slice(0, s.cursorCol) + ' ' + s.query.slice(s.cursorCol);
701
+ s.cursorCol++;
702
+ scheduleSearch(ctx);
703
+ return { kind: 'none' };
704
+ }
705
+ return { kind: 'none' };
706
+ }
707
+ // ── Flag modal ──────────────────────────────────────────────────────────
708
+ if (state.flagModal?.active) {
709
+ const fm = state.flagModal;
710
+ if (fm.awaitingNotes) {
711
+ if (event.key === 'esc') {
712
+ state.flagModal = null;
713
+ return { kind: 'none' };
714
+ }
715
+ if (event.key === 'enter') {
716
+ await submitFlag('other', ctx);
717
+ return { kind: 'none' };
718
+ }
719
+ if (event.key === 'backspace') {
720
+ fm.notes = fm.notes.slice(0, -1);
721
+ return { kind: 'none' };
722
+ }
723
+ if (event.key.length === 1 && !event.ctrl) {
724
+ fm.notes += event.key;
725
+ return { kind: 'none' };
726
+ }
727
+ return { kind: 'none' };
728
+ }
729
+ if (event.key === 'esc') {
730
+ state.flagModal = null;
731
+ return { kind: 'none' };
732
+ }
733
+ const reasonMap = {
734
+ '1': 'spam',
735
+ '2': 'harassment',
736
+ '3': 'undisclosed-llm',
737
+ '4': 'malicious',
738
+ '5': 'other'
739
+ };
740
+ const reason = reasonMap[event.key];
741
+ if (reason) {
742
+ if (reason === 'other') {
743
+ fm.awaitingNotes = true;
744
+ return { kind: 'none' };
745
+ }
746
+ await submitFlag(reason, ctx);
747
+ return { kind: 'none' };
748
+ }
749
+ return { kind: 'none' };
750
+ }
751
+ // ── Composer ────────────────────────────────────────────────────────────
752
+ if (state.composer?.active) {
753
+ const comp = state.composer;
754
+ if (comp.status === 'sending')
755
+ return { kind: 'none' };
756
+ if (event.ctrl && event.key === 's') {
757
+ await sendComposer(ctx);
758
+ return { kind: 'none' };
759
+ }
760
+ if (event.key === 'esc') {
761
+ const hasContent = comp.lines.some((l) => l.length > 0);
762
+ if (hasContent) {
763
+ // confirm discard — just close for now
764
+ }
765
+ closeComposer();
766
+ return { kind: 'none' };
767
+ }
768
+ if (event.key === 'enter') {
769
+ // If new-thread and title not yet set, move to content
770
+ if (comp.mode === 'new-thread' && comp.title === '') {
771
+ comp.title = comp.lines.join('').trim();
772
+ comp.lines = [''];
773
+ comp.cursorLine = 0;
774
+ comp.cursorCol = 0;
775
+ return { kind: 'none' };
776
+ }
777
+ composerNewline();
778
+ return { kind: 'none' };
779
+ }
780
+ if (event.key === 'backspace') {
781
+ if (comp.mode === 'new-thread' &&
782
+ typeof comp.title === 'string' &&
783
+ comp.title.length === 0 &&
784
+ comp.lines.join('') === '') {
785
+ // editing title still
786
+ }
787
+ composerBackspace();
788
+ return { kind: 'none' };
789
+ }
790
+ if (event.key === 'up' && comp.cursorLine > 0) {
791
+ comp.cursorLine--;
792
+ comp.cursorCol = Math.min(comp.cursorCol, (comp.lines[comp.cursorLine] ?? '').length);
793
+ return { kind: 'none' };
794
+ }
795
+ if (event.key === 'down' && comp.cursorLine < comp.lines.length - 1) {
796
+ comp.cursorLine++;
797
+ comp.cursorCol = Math.min(comp.cursorCol, (comp.lines[comp.cursorLine] ?? '').length);
798
+ return { kind: 'none' };
799
+ }
800
+ if (event.key === 'left' && comp.cursorCol > 0) {
801
+ comp.cursorCol--;
802
+ return { kind: 'none' };
803
+ }
804
+ if (event.key === 'right') {
805
+ const lineLen = (comp.lines[comp.cursorLine] ?? '').length;
806
+ if (comp.cursorCol < lineLen)
807
+ comp.cursorCol++;
808
+ return { kind: 'none' };
809
+ }
810
+ if (event.key.length === 1 && !event.ctrl && !event.meta) {
811
+ // If new-thread mode and title not set yet, type into title field
812
+ if (comp.mode === 'new-thread' && typeof comp.title === 'string' && !comp.title) {
813
+ // title is filled by user hitting enter — for now just accumulate in lines
814
+ }
815
+ composerInsertChar(event.key);
816
+ return { kind: 'none' };
817
+ }
818
+ if (event.key === 'space') {
819
+ composerInsertChar(' ');
820
+ return { kind: 'none' };
821
+ }
822
+ return { kind: 'none' };
823
+ }
824
+ // ── Filter mode ─────────────────────────────────────────────────────────
825
+ if (state.filtering) {
826
+ if (event.key === 'esc') {
827
+ state.filtering = false;
828
+ state.filter = '';
829
+ return { kind: 'none' };
830
+ }
831
+ if (event.key === 'enter') {
832
+ state.filtering = false;
833
+ return { kind: 'none' };
834
+ }
835
+ if (event.key === 'backspace') {
836
+ state.filter = state.filter.slice(0, -1);
837
+ return { kind: 'none' };
838
+ }
839
+ if (event.key.length === 1 && !event.ctrl) {
840
+ state.filter += event.key;
841
+ return { kind: 'none' };
842
+ }
843
+ return { kind: 'none' };
844
+ }
845
+ // ── g/G global jumps ────────────────────────────────────────────────────
846
+ if (event.key === 'g') {
847
+ if (state.view === 'boards')
848
+ state.boardCur = 0;
849
+ else if (state.view === 'threads')
850
+ state.threadCur = 0;
851
+ else
852
+ state.replyCur = 0;
853
+ return { kind: 'none' };
854
+ }
855
+ if (event.key === 'G') {
856
+ if (state.view === 'boards') {
857
+ state.boardCur = Math.max(0, cachedBoards.length - 1);
858
+ }
859
+ else if (state.view === 'threads') {
860
+ const list = cachedThreads.filter((t) => !state.filter || t.title.toLowerCase().includes(state.filter.toLowerCase()));
861
+ state.threadCur = Math.max(0, list.length - 1);
862
+ }
863
+ else {
864
+ const flatReplies = flattenReplies(cachedReplies);
865
+ state.replyCur = Math.max(0, flatReplies.length - 1);
866
+ }
867
+ return { kind: 'none' };
868
+ }
869
+ // ── Board view ──────────────────────────────────────────────────────────
870
+ if (state.view === 'boards') {
871
+ switch (event.key) {
872
+ case 'j':
873
+ case 'down':
874
+ state.boardCur = Math.min(cachedBoards.length - 1, state.boardCur + 1);
875
+ return { kind: 'none' };
876
+ case 'k':
877
+ case 'up':
878
+ state.boardCur = Math.max(0, state.boardCur - 1);
879
+ return { kind: 'none' };
880
+ case 'enter': {
881
+ const boardId = cachedBoards[state.boardCur]?.id;
882
+ if (boardId) {
883
+ const opts = buildSourceOptions(ctx);
884
+ const result = await communityThreadsSource(opts, boardId);
885
+ cachedThreads = result.data.threads;
886
+ cachedReplies = [];
887
+ state.board = boardId;
888
+ state.view = 'threads';
889
+ state.threadCur = 0;
890
+ }
891
+ return { kind: 'none' };
892
+ }
893
+ case 'n': {
894
+ const boardId = (cachedBoards[state.boardCur]?.id ?? 'meta');
895
+ openComposer('new-thread');
896
+ if (state.composer)
897
+ state.composer.board = boardId;
898
+ return { kind: 'none' };
899
+ }
900
+ case '/':
901
+ openSearch('all');
902
+ return { kind: 'none' };
903
+ default:
904
+ return { kind: 'none' };
905
+ }
906
+ }
907
+ // ── Thread list view ────────────────────────────────────────────────────
908
+ if (state.view === 'threads') {
909
+ const list = cachedThreads.filter((t) => !state.filter || t.title.toLowerCase().includes(state.filter.toLowerCase()));
910
+ switch (event.key) {
911
+ case 'j':
912
+ case 'down':
913
+ state.threadCur = Math.min(list.length - 1, state.threadCur + 1);
914
+ return { kind: 'none' };
915
+ case 'k':
916
+ case 'up':
917
+ state.threadCur = Math.max(0, state.threadCur - 1);
918
+ return { kind: 'none' };
919
+ case 'o': {
920
+ const sortOrder = ['top', 'new', 'active'];
921
+ state.threadSort =
922
+ sortOrder[(sortOrder.indexOf(state.threadSort) + 1) % sortOrder.length] ?? 'top';
923
+ const board = state.board ?? cachedBoards[state.boardCur]?.id;
924
+ if (board) {
925
+ loadThreads(board, ctx).then(() => ctx.repaint()).catch(() => { });
926
+ }
927
+ return { kind: 'none' };
928
+ }
929
+ case 'enter': {
930
+ const t = list[state.threadCur];
931
+ if (t) {
932
+ const opts = buildSourceOptions(ctx);
933
+ const result = await communityThreadSource(opts, t.id);
934
+ cachedReplies = result.data.replies;
935
+ state.thread = t.id;
936
+ state.view = 'reader';
937
+ state.replyCur = 0;
938
+ }
939
+ return { kind: 'none' };
940
+ }
941
+ case 'X': {
942
+ const t = list[state.threadCur];
943
+ if (t) {
944
+ if (state.expandedItems.has(t.id))
945
+ state.expandedItems.delete(t.id);
946
+ else
947
+ state.expandedItems.add(t.id);
948
+ }
949
+ return { kind: 'none' };
950
+ }
951
+ case '+':
952
+ case '=': {
953
+ const t = list[state.threadCur];
954
+ if (t)
955
+ await doVote(t.id, 'discussion', 1, ctx);
956
+ return { kind: 'none' };
957
+ }
958
+ case '-': {
959
+ const t = list[state.threadCur];
960
+ if (t)
961
+ await doVote(t.id, 'discussion', -1, ctx);
962
+ return { kind: 'none' };
963
+ }
964
+ case 'f': {
965
+ const t = list[state.threadCur];
966
+ if (t) {
967
+ state.flagModal = {
968
+ active: true,
969
+ targetId: t.id,
970
+ targetType: 'discussion',
971
+ awaitingNotes: false,
972
+ notes: ''
973
+ };
974
+ }
975
+ return { kind: 'none' };
976
+ }
977
+ case 'esc':
978
+ state.view = 'boards';
979
+ return { kind: 'none' };
980
+ case 'n': {
981
+ const boardId = (state.board ?? 'meta');
982
+ openComposer('new-thread');
983
+ if (state.composer)
984
+ state.composer.board = boardId;
985
+ return { kind: 'none' };
986
+ }
987
+ case '/': {
988
+ const scope = state.board ?? 'all';
989
+ openSearch(scope);
990
+ return { kind: 'none' };
991
+ }
992
+ default:
993
+ return { kind: 'none' };
994
+ }
995
+ }
996
+ // ── Reader view ─────────────────────────────────────────────────────────
997
+ const flatReplies = flattenReplies(cachedReplies);
998
+ switch (event.key) {
999
+ case 'j':
1000
+ case 'down':
1001
+ state.replyCur = Math.min(flatReplies.length - 1, state.replyCur + 1);
1002
+ return { kind: 'none' };
1003
+ case 'k':
1004
+ case 'up':
1005
+ state.replyCur = Math.max(0, state.replyCur - 1);
1006
+ return { kind: 'none' };
1007
+ case 'esc':
1008
+ state.view = 'threads';
1009
+ return { kind: 'none' };
1010
+ case '/': {
1011
+ const scope = state.board ?? 'all';
1012
+ openSearch(scope);
1013
+ return { kind: 'none' };
1014
+ }
1015
+ case 'r': {
1016
+ const threadId = state.thread ?? '';
1017
+ if (state.replyCur >= 0 && flatReplies.length > 0) {
1018
+ const focused = flatReplies[state.replyCur];
1019
+ openComposer('reply', focused?.reply.id ?? threadId);
1020
+ }
1021
+ else {
1022
+ openComposer('reply', threadId);
1023
+ }
1024
+ return { kind: 'none' };
1025
+ }
1026
+ case '+':
1027
+ case '=': {
1028
+ if (flatReplies.length > 0) {
1029
+ const focused = flatReplies[state.replyCur];
1030
+ if (focused)
1031
+ await doVote(focused.reply.id, 'reply', 1, ctx);
1032
+ }
1033
+ else {
1034
+ const t = cachedThreads.find((x) => x.id === state.thread);
1035
+ if (t)
1036
+ await doVote(t.id, 'discussion', 1, ctx);
1037
+ }
1038
+ return { kind: 'none' };
1039
+ }
1040
+ case '-': {
1041
+ if (flatReplies.length > 0) {
1042
+ const focused = flatReplies[state.replyCur];
1043
+ if (focused)
1044
+ await doVote(focused.reply.id, 'reply', -1, ctx);
1045
+ }
1046
+ else {
1047
+ const t = cachedThreads.find((x) => x.id === state.thread);
1048
+ if (t)
1049
+ await doVote(t.id, 'discussion', -1, ctx);
1050
+ }
1051
+ return { kind: 'none' };
1052
+ }
1053
+ case 'f': {
1054
+ const focused = flatReplies[state.replyCur];
1055
+ if (focused) {
1056
+ state.flagModal = {
1057
+ active: true,
1058
+ targetId: focused.reply.id,
1059
+ targetType: 'reply',
1060
+ awaitingNotes: false,
1061
+ notes: ''
1062
+ };
1063
+ }
1064
+ else {
1065
+ const t = cachedThreads.find((x) => x.id === state.thread);
1066
+ if (t) {
1067
+ state.flagModal = {
1068
+ active: true,
1069
+ targetId: t.id,
1070
+ targetType: 'discussion',
1071
+ awaitingNotes: false,
1072
+ notes: ''
1073
+ };
1074
+ }
1075
+ }
1076
+ return { kind: 'none' };
1077
+ }
1078
+ case 'X': {
1079
+ const focused = flatReplies[state.replyCur];
1080
+ if (focused) {
1081
+ const id = focused.reply.id;
1082
+ if (state.expandedItems.has(id))
1083
+ state.expandedItems.delete(id);
1084
+ else
1085
+ state.expandedItems.add(id);
1086
+ }
1087
+ return { kind: 'none' };
1088
+ }
1089
+ default:
1090
+ return { kind: 'none' };
1091
+ }
1092
+ }
1093
+ };
1094
+ //# sourceMappingURL=community.js.map