opencode-agora 0.4.0 → 0.4.2

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 (248) hide show
  1. package/README.md +89 -415
  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 +11 -19
  11. package/dist/cli/app.d.ts.map +1 -1
  12. package/dist/cli/app.js +159 -2028
  13. package/dist/cli/app.js.map +1 -1
  14. package/dist/cli/commands/browse.d.ts +4 -0
  15. package/dist/cli/commands/browse.d.ts.map +1 -0
  16. package/dist/cli/commands/browse.js +80 -0
  17. package/dist/cli/commands/browse.js.map +1 -0
  18. package/dist/cli/commands/chat.d.ts +4 -0
  19. package/dist/cli/commands/chat.d.ts.map +1 -0
  20. package/dist/cli/commands/chat.js +125 -0
  21. package/dist/cli/commands/chat.js.map +1 -0
  22. package/dist/cli/commands/community.d.ts +12 -0
  23. package/dist/cli/commands/community.d.ts.map +1 -0
  24. package/dist/cli/commands/community.js +453 -0
  25. package/dist/cli/commands/community.js.map +1 -0
  26. package/dist/cli/commands/export.d.ts +3 -0
  27. package/dist/cli/commands/export.d.ts.map +1 -0
  28. package/dist/cli/commands/export.js +108 -0
  29. package/dist/cli/commands/export.js.map +1 -0
  30. package/dist/cli/commands/init.d.ts +4 -0
  31. package/dist/cli/commands/init.d.ts.map +1 -0
  32. package/dist/cli/commands/init.js +299 -0
  33. package/dist/cli/commands/init.js.map +1 -0
  34. package/dist/cli/commands/learn.d.ts +4 -0
  35. package/dist/cli/commands/learn.d.ts.map +1 -0
  36. package/dist/cli/commands/learn.js +62 -0
  37. package/dist/cli/commands/learn.js.map +1 -0
  38. package/dist/cli/commands/marketplace.d.ts +9 -0
  39. package/dist/cli/commands/marketplace.d.ts.map +1 -0
  40. package/dist/cli/commands/marketplace.js +321 -0
  41. package/dist/cli/commands/marketplace.js.map +1 -0
  42. package/dist/cli/commands/notify.d.ts +3 -0
  43. package/dist/cli/commands/notify.d.ts.map +1 -0
  44. package/dist/cli/commands/notify.js +59 -0
  45. package/dist/cli/commands/notify.js.map +1 -0
  46. package/dist/cli/commands/operations.d.ts +16 -0
  47. package/dist/cli/commands/operations.d.ts.map +1 -0
  48. package/dist/cli/commands/operations.js +1041 -0
  49. package/dist/cli/commands/operations.js.map +1 -0
  50. package/dist/cli/commands/outdated.d.ts +3 -0
  51. package/dist/cli/commands/outdated.d.ts.map +1 -0
  52. package/dist/cli/commands/outdated.js +48 -0
  53. package/dist/cli/commands/outdated.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/scan.d.ts +3 -0
  59. package/dist/cli/commands/scan.d.ts.map +1 -0
  60. package/dist/cli/commands/scan.js +35 -0
  61. package/dist/cli/commands/scan.js.map +1 -0
  62. package/dist/cli/commands/today.d.ts +3 -0
  63. package/dist/cli/commands/today.d.ts.map +1 -0
  64. package/dist/cli/commands/today.js +142 -0
  65. package/dist/cli/commands/today.js.map +1 -0
  66. package/dist/cli/commands/types.d.ts +5 -0
  67. package/dist/cli/commands/types.d.ts.map +1 -0
  68. package/dist/cli/commands/types.js +2 -0
  69. package/dist/cli/commands/types.js.map +1 -0
  70. package/dist/cli/commands/watch.d.ts +3 -0
  71. package/dist/cli/commands/watch.d.ts.map +1 -0
  72. package/dist/cli/commands/watch.js +41 -0
  73. package/dist/cli/commands/watch.js.map +1 -0
  74. package/dist/cli/commands/welcome.d.ts +3 -0
  75. package/dist/cli/commands/welcome.d.ts.map +1 -0
  76. package/dist/cli/commands/welcome.js +97 -0
  77. package/dist/cli/commands/welcome.js.map +1 -0
  78. package/dist/cli/commands-meta.d.ts.map +1 -1
  79. package/dist/cli/commands-meta.js +286 -29
  80. package/dist/cli/commands-meta.js.map +1 -1
  81. package/dist/cli/completions-gen.d.ts +2 -0
  82. package/dist/cli/completions-gen.d.ts.map +1 -0
  83. package/dist/cli/completions-gen.js +195 -0
  84. package/dist/cli/completions-gen.js.map +1 -0
  85. package/dist/cli/completions.d.ts.map +1 -1
  86. package/dist/cli/completions.js +42 -5
  87. package/dist/cli/completions.js.map +1 -1
  88. package/dist/cli/flags.d.ts +19 -0
  89. package/dist/cli/flags.d.ts.map +1 -0
  90. package/dist/cli/flags.js +91 -0
  91. package/dist/cli/flags.js.map +1 -0
  92. package/dist/cli/format.d.ts +19 -0
  93. package/dist/cli/format.d.ts.map +1 -0
  94. package/dist/cli/format.js +249 -0
  95. package/dist/cli/format.js.map +1 -0
  96. package/dist/cli/helpers.d.ts +95 -0
  97. package/dist/cli/helpers.d.ts.map +1 -0
  98. package/dist/cli/helpers.js +301 -0
  99. package/dist/cli/helpers.js.map +1 -0
  100. package/dist/cli/mcp-server.d.ts +7 -1
  101. package/dist/cli/mcp-server.d.ts.map +1 -1
  102. package/dist/cli/mcp-server.js +70 -2
  103. package/dist/cli/mcp-server.js.map +1 -1
  104. package/dist/cli/menu.d.ts.map +1 -1
  105. package/dist/cli/menu.js +11 -3
  106. package/dist/cli/menu.js.map +1 -1
  107. package/dist/cli/pages/community.d.ts +6 -0
  108. package/dist/cli/pages/community.d.ts.map +1 -1
  109. package/dist/cli/pages/community.js +882 -64
  110. package/dist/cli/pages/community.js.map +1 -1
  111. package/dist/cli/pages/helpers.d.ts +14 -9
  112. package/dist/cli/pages/helpers.d.ts.map +1 -1
  113. package/dist/cli/pages/helpers.js +37 -6
  114. package/dist/cli/pages/helpers.js.map +1 -1
  115. package/dist/cli/pages/home.d.ts +1 -0
  116. package/dist/cli/pages/home.d.ts.map +1 -1
  117. package/dist/cli/pages/home.js +203 -120
  118. package/dist/cli/pages/home.js.map +1 -1
  119. package/dist/cli/pages/marketplace.d.ts +2 -0
  120. package/dist/cli/pages/marketplace.d.ts.map +1 -1
  121. package/dist/cli/pages/marketplace.js +524 -62
  122. package/dist/cli/pages/marketplace.js.map +1 -1
  123. package/dist/cli/pages/news.d.ts +28 -0
  124. package/dist/cli/pages/news.d.ts.map +1 -1
  125. package/dist/cli/pages/news.js +209 -82
  126. package/dist/cli/pages/news.js.map +1 -1
  127. package/dist/cli/pages/settings.d.ts.map +1 -1
  128. package/dist/cli/pages/settings.js +163 -33
  129. package/dist/cli/pages/settings.js.map +1 -1
  130. package/dist/cli/pages/types.d.ts +1 -1
  131. package/dist/cli/pages/types.d.ts.map +1 -1
  132. package/dist/cli/prompter.d.ts.map +1 -1
  133. package/dist/cli/prompter.js +43 -8
  134. package/dist/cli/prompter.js.map +1 -1
  135. package/dist/cli/shell.d.ts +2 -2
  136. package/dist/cli/shell.d.ts.map +1 -1
  137. package/dist/cli/shell.js +321 -18
  138. package/dist/cli/shell.js.map +1 -1
  139. package/dist/cli/tui.d.ts +1 -1
  140. package/dist/cli/tui.d.ts.map +1 -1
  141. package/dist/cli/tui.js +69 -23
  142. package/dist/cli/tui.js.map +1 -1
  143. package/dist/community/client.d.ts +45 -8
  144. package/dist/community/client.d.ts.map +1 -1
  145. package/dist/community/client.js +118 -23
  146. package/dist/community/client.js.map +1 -1
  147. package/dist/community/search.d.ts +25 -0
  148. package/dist/community/search.d.ts.map +1 -0
  149. package/dist/community/search.js +62 -0
  150. package/dist/community/search.js.map +1 -0
  151. package/dist/community/types.d.ts +21 -0
  152. package/dist/community/types.d.ts.map +1 -1
  153. package/dist/community/types.js +1 -1
  154. package/dist/config.d.ts +0 -4
  155. package/dist/config.d.ts.map +1 -1
  156. package/dist/config.js +0 -15
  157. package/dist/config.js.map +1 -1
  158. package/dist/data.d.ts.map +1 -1
  159. package/dist/data.js +142 -68
  160. package/dist/data.js.map +1 -1
  161. package/dist/format.d.ts +0 -2
  162. package/dist/format.d.ts.map +1 -1
  163. package/dist/format.js +0 -2
  164. package/dist/format.js.map +1 -1
  165. package/dist/hubs/cache.d.ts +6 -0
  166. package/dist/hubs/cache.d.ts.map +1 -0
  167. package/dist/hubs/cache.js +46 -0
  168. package/dist/hubs/cache.js.map +1 -0
  169. package/dist/hubs/enrichment.d.ts +43 -0
  170. package/dist/hubs/enrichment.d.ts.map +1 -0
  171. package/dist/hubs/enrichment.js +239 -0
  172. package/dist/hubs/enrichment.js.map +1 -0
  173. package/dist/hubs/github.d.ts +12 -0
  174. package/dist/hubs/github.d.ts.map +1 -0
  175. package/dist/hubs/github.js +54 -0
  176. package/dist/hubs/github.js.map +1 -0
  177. package/dist/hubs/huggingface.d.ts +27 -0
  178. package/dist/hubs/huggingface.d.ts.map +1 -0
  179. package/dist/hubs/huggingface.js +88 -0
  180. package/dist/hubs/huggingface.js.map +1 -0
  181. package/dist/hubs/quality.d.ts +26 -0
  182. package/dist/hubs/quality.d.ts.map +1 -0
  183. package/dist/hubs/quality.js +57 -0
  184. package/dist/hubs/quality.js.map +1 -0
  185. package/dist/hubs/types.d.ts +30 -0
  186. package/dist/hubs/types.d.ts.map +1 -0
  187. package/dist/hubs/types.js +2 -0
  188. package/dist/hubs/types.js.map +1 -0
  189. package/dist/index.d.ts.map +1 -1
  190. package/dist/index.js +84 -0
  191. package/dist/index.js.map +1 -1
  192. package/dist/init.js +2 -2
  193. package/dist/init.js.map +1 -1
  194. package/dist/live.d.ts +10 -0
  195. package/dist/live.d.ts.map +1 -1
  196. package/dist/live.js +31 -1
  197. package/dist/live.js.map +1 -1
  198. package/dist/marketplace.d.ts +16 -3
  199. package/dist/marketplace.d.ts.map +1 -1
  200. package/dist/marketplace.js +174 -7
  201. package/dist/marketplace.js.map +1 -1
  202. package/dist/news/cache.d.ts.map +1 -1
  203. package/dist/news/cache.js +4 -3
  204. package/dist/news/cache.js.map +1 -1
  205. package/dist/news/score.js +1 -1
  206. package/dist/news/sources/arxiv.d.ts.map +1 -1
  207. package/dist/news/sources/arxiv.js +10 -6
  208. package/dist/news/sources/arxiv.js.map +1 -1
  209. package/dist/news/sources/github-trending.d.ts.map +1 -1
  210. package/dist/news/sources/github-trending.js +9 -5
  211. package/dist/news/sources/github-trending.js.map +1 -1
  212. package/dist/news/sources/hn.d.ts.map +1 -1
  213. package/dist/news/sources/hn.js +8 -4
  214. package/dist/news/sources/hn.js.map +1 -1
  215. package/dist/news/sources/reddit.d.ts.map +1 -1
  216. package/dist/news/sources/reddit.js +5 -4
  217. package/dist/news/sources/reddit.js.map +1 -1
  218. package/dist/news/sources/rss.d.ts +10 -11
  219. package/dist/news/sources/rss.d.ts.map +1 -1
  220. package/dist/news/sources/rss.js +11 -99
  221. package/dist/news/sources/rss.js.map +1 -1
  222. package/dist/news/types.d.ts +3 -0
  223. package/dist/news/types.d.ts.map +1 -1
  224. package/dist/news/types.js +15 -6
  225. package/dist/news/types.js.map +1 -1
  226. package/dist/outdated.d.ts +24 -0
  227. package/dist/outdated.d.ts.map +1 -0
  228. package/dist/outdated.js +53 -0
  229. package/dist/outdated.js.map +1 -0
  230. package/dist/preferences.d.ts.map +1 -1
  231. package/dist/preferences.js +4 -4
  232. package/dist/preferences.js.map +1 -1
  233. package/dist/scan.d.ts +26 -0
  234. package/dist/scan.d.ts.map +1 -0
  235. package/dist/scan.js +179 -0
  236. package/dist/scan.js.map +1 -0
  237. package/dist/settings.d.ts.map +1 -1
  238. package/dist/settings.js +14 -20
  239. package/dist/settings.js.map +1 -1
  240. package/dist/state.d.ts +9 -2
  241. package/dist/state.d.ts.map +1 -1
  242. package/dist/state.js +41 -19
  243. package/dist/state.js.map +1 -1
  244. package/dist/types.d.ts +13 -0
  245. package/dist/types.d.ts.map +1 -1
  246. package/dist/ui.d.ts +1 -1
  247. package/dist/ui.js +1 -1
  248. package/package.json +4 -2
@@ -1,7 +1,7 @@
1
- import { communityBoardsSource, communityThreadsSource, communityThreadSource } from '../../community/client.js';
1
+ import { communityBoardsSource, communityThreadsSource, communityThreadSource, communitySearchSource, createThreadSource, createReplySource, voteSource, flagSource } from '../../community/client.js';
2
2
  import { BOARD_LABELS } from '../../community/types.js';
3
- import { loadAgoraState, getAuthState } from '../../state.js';
4
- import { vlen, rail, noRail, sep, frame } from './helpers.js';
3
+ import { vlen, rail, noRail, sep, frame, pageSourceOptions } from './helpers.js';
4
+ // ── Helpers ──────────────────────────────────────────────────────────────────
5
5
  function hoursAgo(iso) {
6
6
  return Math.max(0, (Date.now() - new Date(iso).getTime()) / 3600000);
7
7
  }
@@ -12,17 +12,44 @@ function fmtAge(hours) {
12
12
  return Math.round(hours) + 'h';
13
13
  return Math.round(hours / 24) + 'd';
14
14
  }
15
- function detectDataDir() {
16
- return process.env.AGORA_DATA_DIR || (process.env.HOME ? process.env.HOME + '/.config/agora' : '.agora');
17
- }
15
+ // ── Module state ─────────────────────────────────────────────────────────────
18
16
  let cachedBoards = [];
19
17
  let cachedThreads = [];
20
18
  let cachedReplies = [];
21
19
  let boardsLoading = true;
22
20
  const state = {
23
- view: 'boards', boardCur: 0, threadCur: 0, replyCur: 0,
24
- filter: '', filtering: false,
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
25
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 ──────────────────────────────────────────────────────────
26
53
  function flattenReplies(replies, depth = 0) {
27
54
  const flat = [];
28
55
  for (const r of replies) {
@@ -34,33 +61,318 @@ function flattenReplies(replies, depth = 0) {
34
61
  return flat;
35
62
  }
36
63
  function buildSourceOptions(ctx) {
37
- const dir = detectDataDir();
38
- let apiUrl = process.env.AGORA_API_URL || '';
39
- let token = process.env.AGORA_TOKEN || process.env.AGORA_API_TOKEN || '';
40
- if (!apiUrl || !token) {
41
- try {
42
- const agoraState = loadAgoraState(dir);
43
- const auth = getAuthState(agoraState);
44
- if (auth) {
45
- if (!apiUrl)
46
- apiUrl = auth.apiUrl || '';
47
- if (!token)
48
- token = auth.token || '';
49
- }
50
- }
51
- catch { /* ignore */ }
52
- }
53
- return { useApi: Boolean(apiUrl), apiUrl, token, fetcher: ctx.io.fetcher, timeoutMs: 10000 };
64
+ return pageSourceOptions(ctx);
54
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 ──────────────────────────────────────────────────────────────
55
76
  async function loadBoards(ctx) {
56
77
  try {
57
78
  const opts = buildSourceOptions(ctx);
58
79
  const result = await communityBoardsSource(opts);
59
80
  cachedBoards = result.data.boards;
60
81
  }
61
- catch { /* keep empty */ }
82
+ catch {
83
+ /* keep empty */
84
+ }
62
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.trim() || undefined, targetType });
249
+ setStatus('Flagged.');
250
+ }
251
+ catch {
252
+ setStatus('Flag failed.');
253
+ }
254
+ state.flagModal = null;
63
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 ────────────────────────────────────────────────────────────────────
64
376
  export const communityPage = {
65
377
  id: 'community',
66
378
  title: 'COMMUNITY',
@@ -68,30 +380,56 @@ export const communityPage = {
68
380
  navIcon: 'C',
69
381
  hotkeys: [
70
382
  { key: 'j/k', label: 'nav' },
383
+ { key: 'g/G', label: 'top/end' },
71
384
  { key: 'Enter', label: 'open' },
72
385
  { key: 'n', label: 'new' },
73
- { key: '/', label: 'filter' },
386
+ { key: 'o', label: 'sort' },
387
+ { key: '/', label: 'search' },
74
388
  { key: 'r', label: 'reply' },
75
- { key: 'v', label: 'vote' },
389
+ { key: '+/-', label: 'vote' },
76
390
  { key: 'f', label: 'flag' },
77
- { key: 'Esc', label: 'back' },
391
+ { key: 'X', label: 'expand' },
392
+ { key: 'Esc', label: 'back' }
78
393
  ],
79
394
  mount(_ctx) {
80
395
  boardsLoading = true;
81
396
  loadBoards(_ctx);
82
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
+ },
83
416
  render(ctx) {
84
417
  const { style, width, height } = ctx;
85
418
  const lines = [];
86
- const rule = ' ' + style.dim('\u2500'.repeat(Math.max(0, width - 2)));
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
+ }
87
424
  if (boardsLoading) {
88
- lines.push(' ' + style.dim('Loading community\u2026'));
425
+ lines.push(' ' + style.dim('Loading community'));
89
426
  return frame(lines, width, height);
90
427
  }
91
428
  if (state.view === 'boards') {
92
429
  const newTotal = cachedBoards.reduce((s, b) => s + b.newToday, 0);
93
- lines.push(' ' + style.bold(style.accent('COMMUNITY'))
94
- + style.dim(' ' + cachedBoards.length + ' boards \u00b7 ' + newTotal + ' new today'));
430
+ lines.push(' ' +
431
+ style.bold(style.accent('COMMUNITY')) +
432
+ style.dim(' ' + cachedBoards.length + ' boards · ' + newTotal + ' new today'));
95
433
  lines.push(rule);
96
434
  const list = cachedBoards.filter((b) => !state.filter || b.id.includes(state.filter));
97
435
  if (list.length === 0) {
@@ -103,8 +441,10 @@ export const communityPage = {
103
441
  const lead = sel ? rail(style) : noRail();
104
442
  const displayName = '/' + b.id;
105
443
  const name = sel ? style.bold(displayName) : displayName;
106
- const stats = style.dim(b.threadCount.toString().padStart(4) + ' th '
107
- + b.newToday.toString().padStart(2) + ' new');
444
+ const stats = style.dim(b.threadCount.toString().padStart(4) +
445
+ ' th ' +
446
+ b.newToday.toString().padStart(2) +
447
+ ' new');
108
448
  const gap = Math.max(2, width - vlen(' ' + lead + displayName) - vlen(stats) - 1);
109
449
  lines.push(' ' + lead + name + ' '.repeat(gap) + stats);
110
450
  const label = BOARD_LABELS[b.id];
@@ -116,22 +456,39 @@ export const communityPage = {
116
456
  else if (state.view === 'threads') {
117
457
  const board = state.board ?? cachedBoards[state.boardCur]?.id ?? 'mcp';
118
458
  const list = cachedThreads.filter((t) => !state.filter || t.title.toLowerCase().includes(state.filter.toLowerCase()));
119
- lines.push(' ' + style.bold(style.accent('/' + board))
120
- + style.dim(' ' + list.length + ' threads'));
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));
121
465
  lines.push(rule);
122
466
  if (list.length === 0) {
123
- lines.push(' ' + style.dim('Empty board. ')
124
- + style.accent('n') + style.dim(' to start.'));
467
+ lines.push(' ' + style.dim('Empty board. ') + style.accent('n') + style.dim(' to start.'));
125
468
  }
126
469
  else {
127
470
  list.forEach((t, i) => {
128
471
  const sel = i === state.threadCur;
129
472
  const lead = sel ? rail(style) : noRail();
130
- const title = sel ? style.bold(t.title) : t.title;
131
473
  const age = fmtAge(hoursAgo(t.createdAt));
132
- const meta = style.dim(t.author + ' \u00b7 ' + age);
133
- const counts = style.accent(t.score.toString().padStart(3)) + style.dim('\u2191 ')
134
- + style.accent(t.replyCount.toString().padStart(2)) + style.dim(' replies');
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');
135
492
  lines.push(' ' + lead + title);
136
493
  lines.push(' ' + meta + ' ' + counts);
137
494
  lines.push(rule);
@@ -146,9 +503,13 @@ export const communityPage = {
146
503
  }
147
504
  else {
148
505
  const age = fmtAge(hoursAgo(t.createdAt));
149
- lines.push(' ' + style.bold(t.title) + ' '
150
- + style.accent(t.score + ' \u2191'));
151
- lines.push(' ' + style.dim(t.author + ' \u00b7 ' + age + ' \u00b7 ' + t.replyCount + ' replies'));
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'));
152
513
  lines.push(' ' + sep('body', width - 2, style));
153
514
  lines.push(' ' + t.content);
154
515
  lines.push(' ' + sep('replies', width - 2, style));
@@ -156,21 +517,311 @@ export const communityPage = {
156
517
  flatReplies.forEach((rn, i) => {
157
518
  const sel = i === state.replyCur;
158
519
  const lead = sel ? rail(style) : noRail();
159
- const indent = '\u2502 '.repeat(rn.depth);
520
+ const indent = ' '.repeat(rn.depth);
160
521
  const ra = fmtAge(hoursAgo(rn.reply.createdAt));
161
- lines.push(' ' + indent + lead + style.dim(rn.reply.author + ' \u00b7 ' + ra)
162
- + ' ' + style.accent(rn.reply.score + ' \u2191'));
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);
163
538
  lines.push(' ' + indent + rn.reply.content);
164
539
  lines.push(rule);
165
540
  });
166
541
  }
167
542
  }
543
+ // Status message
544
+ if (state.statusMessage) {
545
+ lines.push(' ' + style.dim(state.statusMessage));
546
+ }
168
547
  if (state.filtering) {
169
- lines.push(' ' + style.accent('/') + ' ' + state.filter + style.dim('\u258f'));
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');
170
618
  }
171
619
  return frame(lines, width, height);
172
620
  },
173
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 ─────────────────────────────────────────────────────────
174
825
  if (state.filtering) {
175
826
  if (event.key === 'esc') {
176
827
  state.filtering = false;
@@ -191,6 +842,31 @@ export const communityPage = {
191
842
  }
192
843
  return { kind: 'none' };
193
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 ──────────────────────────────────────────────────────────
194
870
  if (state.view === 'boards') {
195
871
  switch (event.key) {
196
872
  case 'j':
@@ -214,25 +890,44 @@ export const communityPage = {
214
890
  }
215
891
  return { kind: 'none' };
216
892
  }
217
- case 'n': return { kind: 'status', message: 'new thread (fixture)' };
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
+ }
218
900
  case '/':
219
- state.filtering = true;
901
+ openSearch('all');
902
+ return { kind: 'none' };
903
+ default:
220
904
  return { kind: 'none' };
221
- default: return { kind: 'none' };
222
905
  }
223
906
  }
907
+ // ── Thread list view ────────────────────────────────────────────────────
224
908
  if (state.view === 'threads') {
909
+ const list = cachedThreads.filter((t) => !state.filter || t.title.toLowerCase().includes(state.filter.toLowerCase()));
225
910
  switch (event.key) {
226
911
  case 'j':
227
912
  case 'down':
228
- state.threadCur = Math.min(cachedThreads.length - 1, state.threadCur + 1);
913
+ state.threadCur = Math.min(list.length - 1, state.threadCur + 1);
229
914
  return { kind: 'none' };
230
915
  case 'k':
231
916
  case 'up':
232
917
  state.threadCur = Math.max(0, state.threadCur - 1);
233
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
+ }
234
929
  case 'enter': {
235
- const t = cachedThreads[state.threadCur];
930
+ const t = list[state.threadCur];
236
931
  if (t) {
237
932
  const opts = buildSourceOptions(ctx);
238
933
  const result = await communityThreadSource(opts, t.id);
@@ -243,16 +938,62 @@ export const communityPage = {
243
938
  }
244
939
  return { kind: 'none' };
245
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
+ }
246
977
  case 'esc':
247
978
  state.view = 'boards';
248
979
  return { kind: 'none' };
249
- case 'n': return { kind: 'status', message: 'new thread (fixture)' };
250
- case '/':
251
- state.filtering = true;
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:
252
993
  return { kind: 'none' };
253
- default: return { kind: 'none' };
254
994
  }
255
995
  }
996
+ // ── Reader view ─────────────────────────────────────────────────────────
256
997
  const flatReplies = flattenReplies(cachedReplies);
257
998
  switch (event.key) {
258
999
  case 'j':
@@ -266,11 +1007,88 @@ export const communityPage = {
266
1007
  case 'esc':
267
1008
  state.view = 'threads';
268
1009
  return { kind: 'none' };
269
- case 'r': return { kind: 'status', message: 'reply composer (fixture)' };
270
- case 'v': return { kind: 'status', message: 'voted' };
271
- case 'f': return { kind: 'status', message: 'flagged' };
272
- default: 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' };
273
1091
  }
274
- },
1092
+ }
275
1093
  };
276
1094
  //# sourceMappingURL=community.js.map