trelly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +191 -0
  2. package/bin/run-ts +31 -0
  3. package/bin/trelly +2 -0
  4. package/bin/trelly-mcp +2 -0
  5. package/package.json +64 -0
  6. package/src/api/client.ts +332 -0
  7. package/src/api/http.ts +86 -0
  8. package/src/auth/browser-flow.ts +217 -0
  9. package/src/auth/profiles.ts +163 -0
  10. package/src/cli/commands/actions.ts +17 -0
  11. package/src/cli/commands/api.ts +46 -0
  12. package/src/cli/commands/auth.ts +248 -0
  13. package/src/cli/commands/boards.ts +152 -0
  14. package/src/cli/commands/cards.ts +194 -0
  15. package/src/cli/commands/checklists.ts +79 -0
  16. package/src/cli/commands/custom-fields.ts +75 -0
  17. package/src/cli/commands/labels.ts +47 -0
  18. package/src/cli/commands/lists.ts +54 -0
  19. package/src/cli/commands/members.ts +14 -0
  20. package/src/cli/commands/orgs.ts +23 -0
  21. package/src/cli/commands/run.ts +32 -0
  22. package/src/cli/commands/search.ts +23 -0
  23. package/src/cli/commands/ui.ts +48 -0
  24. package/src/cli/commands/webhooks.ts +44 -0
  25. package/src/cli/context.ts +70 -0
  26. package/src/cli/index.ts +47 -0
  27. package/src/cli/ui/app.tsx +753 -0
  28. package/src/cli/ui/custom-fields.ts +75 -0
  29. package/src/cli/ui/palette.ts +69 -0
  30. package/src/cli/ui/shapes.ts +68 -0
  31. package/src/cli/ui/static.tsx +382 -0
  32. package/src/index.test.ts +117 -0
  33. package/src/mcp/handlers.ts +61 -0
  34. package/src/mcp/server.ts +17 -0
  35. package/src/mcp/tools/api.ts +27 -0
  36. package/src/mcp/tools/boards.ts +116 -0
  37. package/src/mcp/tools/cards.ts +138 -0
  38. package/src/mcp/tools/checklists.ts +40 -0
  39. package/src/mcp/tools/index.ts +22 -0
  40. package/src/mcp/tools/labels.ts +40 -0
  41. package/src/mcp/tools/lists.ts +37 -0
  42. package/src/mcp/tools/profiles.ts +40 -0
  43. package/src/mcp/tools/search.ts +31 -0
  44. package/src/mcp/tools/webhooks.ts +52 -0
  45. package/src/util/runtime.ts +39 -0
  46. package/src/version.ts +15 -0
@@ -0,0 +1,753 @@
1
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import type { TrelloClient } from "../../api/client.ts";
4
+ import {
5
+ type CardChip,
6
+ customFieldChips,
7
+ toCustomFieldDefs,
8
+ type UiCustomFieldDef,
9
+ type UiCustomFieldItem,
10
+ } from "./custom-fields.ts";
11
+ import {
12
+ dueHex,
13
+ dueStatus,
14
+ formatDue,
15
+ labelHex,
16
+ listAccentHex,
17
+ TRELLO_BLUE,
18
+ } from "./palette.ts";
19
+
20
+ export type UiLabel = { id: string; name: string; color: string | null };
21
+
22
+ export type UiCard = {
23
+ id: string;
24
+ name: string;
25
+ desc: string;
26
+ due: string | null;
27
+ start?: string | null;
28
+ dueComplete: boolean;
29
+ idList: string;
30
+ idMembers?: string[];
31
+ pos: number;
32
+ shortUrl: string;
33
+ labels: UiLabel[];
34
+ badges?: {
35
+ checkItems?: number;
36
+ checkItemsChecked?: number;
37
+ comments?: number;
38
+ attachments?: number;
39
+ };
40
+ customFieldItems?: UiCustomFieldItem[];
41
+ };
42
+
43
+ export type UiList = { id: string; name: string; color?: string | null };
44
+
45
+ type BoardData = {
46
+ name: string;
47
+ lists: UiList[];
48
+ cardsByList: Map<string, UiCard[]>;
49
+ customFields: UiCustomFieldDef[];
50
+ membersById: Map<string, string>;
51
+ };
52
+
53
+ type CardExtras = {
54
+ attachments: Array<{ id: string; name: string }>;
55
+ checklists: Array<{
56
+ id: string;
57
+ name: string;
58
+ items: Array<{ id: string; name: string; complete: boolean }>;
59
+ }>;
60
+ comments: Array<{ id: string; author: string; date: string; text: string }>;
61
+ error?: string;
62
+ };
63
+
64
+ const COL_WIDTH = 30;
65
+
66
+ function truncate(text: string, width: number): string {
67
+ return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
68
+ }
69
+
70
+ function truncateLines(text: string, maxLines: number): string {
71
+ const lines = text.split("\n");
72
+ if (lines.length <= maxLines) return text;
73
+ return `${lines.slice(0, maxLines).join("\n")}\n…`;
74
+ }
75
+
76
+ function CardBadges({ card }: { card: UiCard }) {
77
+ const status = dueStatus(card.due, card.dueComplete);
78
+ const checkItems = card.badges?.checkItems ?? 0;
79
+ const checked = card.badges?.checkItemsChecked ?? 0;
80
+ const comments = card.badges?.comments ?? 0;
81
+ const attachments = card.badges?.attachments ?? 0;
82
+ return (
83
+ <Text wrap="truncate">
84
+ {card.labels.map((label) => (
85
+ <Text key={label.id} color={labelHex(label.color)}>
86
+ ●{" "}
87
+ </Text>
88
+ ))}
89
+ {card.due ? (
90
+ <Text color={dueHex(status)} dimColor={status === "later"}>
91
+ {formatDue(card.due)}
92
+ {status === "complete" ? " ✓" : ""}{" "}
93
+ </Text>
94
+ ) : null}
95
+ {checkItems > 0 ? (
96
+ <Text dimColor>
97
+ ✓{checked}/{checkItems}{" "}
98
+ </Text>
99
+ ) : null}
100
+ {card.desc ? <Text dimColor>≡ </Text> : null}
101
+ {comments > 0 ? <Text dimColor>💬{comments} </Text> : null}
102
+ {attachments > 0 ? <Text dimColor>📎{attachments}</Text> : null}
103
+ </Text>
104
+ );
105
+ }
106
+
107
+ function ChipRow({ chips }: { chips: CardChip[] }) {
108
+ return (
109
+ <Text wrap="truncate">
110
+ {chips.map((chip) => (
111
+ <Text key={chip.id}>
112
+ <Text
113
+ backgroundColor={chip.color ? labelHex(chip.color) : undefined}
114
+ color={chip.color ? "#1d2125" : undefined}
115
+ dimColor={!chip.color}
116
+ >
117
+ {` ${chip.label} `}
118
+ </Text>{" "}
119
+ </Text>
120
+ ))}
121
+ </Text>
122
+ );
123
+ }
124
+
125
+ function CardBox({
126
+ card,
127
+ focused,
128
+ width,
129
+ accent,
130
+ height,
131
+ frontFields,
132
+ }: {
133
+ card: UiCard;
134
+ focused: boolean;
135
+ width: number;
136
+ accent: string;
137
+ height: number;
138
+ frontFields: UiCustomFieldDef[];
139
+ }) {
140
+ const complete = card.dueComplete === true;
141
+ const chips =
142
+ frontFields.length > 0 ? customFieldChips(frontFields, card.customFieldItems) : [];
143
+ return (
144
+ <Box
145
+ flexDirection="column"
146
+ borderStyle="round"
147
+ borderColor={focused ? accent : "gray"}
148
+ width={width}
149
+ height={height}
150
+ paddingX={1}
151
+ >
152
+ <Text bold={focused} wrap="truncate">
153
+ {complete ? <Text color="#61bd4f">✓ </Text> : null}
154
+ {truncate(card.name, width - 4 - (complete ? 2 : 0))}
155
+ </Text>
156
+ <CardBadges card={card} />
157
+ {height >= 5 ? <ChipRow chips={chips} /> : null}
158
+ </Box>
159
+ );
160
+ }
161
+
162
+ function Column({
163
+ list,
164
+ cards,
165
+ focused,
166
+ focusedRow,
167
+ width,
168
+ maxCards,
169
+ cardHeight,
170
+ frontFields,
171
+ }: {
172
+ list: UiList;
173
+ cards: UiCard[];
174
+ focused: boolean;
175
+ focusedRow: number | null;
176
+ width: number;
177
+ maxCards: number;
178
+ cardHeight: number;
179
+ frontFields: UiCustomFieldDef[];
180
+ }) {
181
+ const total = cards.length;
182
+ const accent = listAccentHex(list.color);
183
+ let start = 0;
184
+ if (focusedRow !== null && focusedRow >= maxCards) {
185
+ start = focusedRow - maxCards + 1;
186
+ }
187
+ const visible = cards.slice(start, start + maxCards);
188
+ const below = total - (start + visible.length);
189
+ return (
190
+ <Box flexDirection="column" width={width} marginRight={1}>
191
+ {focused ? (
192
+ <Text backgroundColor={accent} color="#1d2125" bold wrap="truncate">
193
+ {` ${truncate(list.name, width - 8)} (${total}) `}
194
+ </Text>
195
+ ) : (
196
+ <Text bold color={accent} wrap="truncate">
197
+ {truncate(list.name, width - 6)} <Text dimColor>({total})</Text>
198
+ </Text>
199
+ )}
200
+ {start > 0 ? <Text dimColor> ↑ {start} more</Text> : null}
201
+ {visible.map((card, i) => (
202
+ <CardBox
203
+ key={card.id}
204
+ card={card}
205
+ width={width - 1}
206
+ focused={focusedRow === start + i}
207
+ accent={accent}
208
+ height={cardHeight}
209
+ frontFields={frontFields}
210
+ />
211
+ ))}
212
+ {total === 0 ? <Text dimColor> (empty)</Text> : null}
213
+ {below > 0 ? <Text dimColor> ↓ {below} more</Text> : null}
214
+ </Box>
215
+ );
216
+ }
217
+
218
+ function CardDetail({
219
+ card,
220
+ listName,
221
+ width,
222
+ defs,
223
+ membersById,
224
+ extras,
225
+ }: {
226
+ card: UiCard;
227
+ listName: string;
228
+ width: number;
229
+ defs: UiCustomFieldDef[];
230
+ membersById: Map<string, string>;
231
+ extras?: CardExtras;
232
+ }) {
233
+ const status = dueStatus(card.due, card.dueComplete);
234
+ const checkItems = card.badges?.checkItems ?? 0;
235
+ const checked = card.badges?.checkItemsChecked ?? 0;
236
+ const complete = card.dueComplete === true;
237
+ const chips = customFieldChips(defs, card.customFieldItems);
238
+ const memberNames = (card.idMembers ?? [])
239
+ .map((id) => membersById.get(id))
240
+ .filter((name): name is string => Boolean(name));
241
+ return (
242
+ <Box flexDirection="column" paddingX={1} width={Math.min(width, 82)}>
243
+ <Text bold wrap="truncate">
244
+ {complete ? <Text color="#61bd4f">✓ </Text> : null}
245
+ {card.name}
246
+ </Text>
247
+ <Text dimColor wrap="truncate">
248
+ in {listName} · {card.shortUrl}
249
+ </Text>
250
+ {memberNames.length > 0 ? (
251
+ <Text wrap="truncate">
252
+ <Text dimColor>members: </Text>
253
+ <Text color={TRELLO_BLUE}>
254
+ {memberNames.map((name) => `@${name}`).join(" ")}
255
+ </Text>
256
+ </Text>
257
+ ) : null}
258
+ {card.labels.length > 0 ? (
259
+ <Box marginTop={1} gap={1}>
260
+ {card.labels.map((label) => (
261
+ <Text
262
+ key={label.id}
263
+ backgroundColor={labelHex(label.color)}
264
+ color="#1d2125"
265
+ >
266
+ {` ${label.name || label.color || "label"} `}
267
+ </Text>
268
+ ))}
269
+ </Box>
270
+ ) : null}
271
+ {card.due || card.start ? (
272
+ <Box marginTop={1}>
273
+ {card.start ? <Text dimColor>start {formatDue(card.start)} </Text> : null}
274
+ {card.due ? (
275
+ <Text color={dueHex(status)}>
276
+ due {formatDue(card.due)}
277
+ {status === "complete" ? " ✓" : status === "overdue" ? " (overdue)" : ""}
278
+ </Text>
279
+ ) : null}
280
+ </Box>
281
+ ) : null}
282
+ {chips.length > 0 ? (
283
+ <Box marginTop={1}>
284
+ <ChipRow chips={chips} />
285
+ </Box>
286
+ ) : null}
287
+ {card.desc ? (
288
+ <Box marginTop={1}>
289
+ <Text>{truncateLines(card.desc, 10)}</Text>
290
+ </Box>
291
+ ) : null}
292
+ {!extras ? (
293
+ <Box marginTop={1}>
294
+ <Text dimColor>
295
+ loading details…
296
+ {checkItems > 0 ? ` (checklist ${checked}/${checkItems})` : ""}
297
+ </Text>
298
+ </Box>
299
+ ) : (
300
+ <>
301
+ {extras.error ? <Text color="#eb5a46">{extras.error}</Text> : null}
302
+ {extras.checklists.map((checklist) => (
303
+ <Box key={checklist.id} flexDirection="column" marginTop={1}>
304
+ <Text bold>{checklist.name}</Text>
305
+ {checklist.items.slice(0, 6).map((item) => (
306
+ <Text key={item.id} wrap="truncate">
307
+ {item.complete ? <Text color="#61bd4f">☑ </Text> : "☐ "}
308
+ <Text dimColor={item.complete} strikethrough={item.complete}>
309
+ {item.name}
310
+ </Text>
311
+ </Text>
312
+ ))}
313
+ {checklist.items.length > 6 ? (
314
+ <Text dimColor>… {checklist.items.length - 6} more</Text>
315
+ ) : null}
316
+ </Box>
317
+ ))}
318
+ {extras.attachments.length > 0 ? (
319
+ <Box flexDirection="column" marginTop={1}>
320
+ <Text bold>📎 {extras.attachments.length} attachments</Text>
321
+ {extras.attachments.slice(0, 4).map((attachment) => (
322
+ <Text key={attachment.id} dimColor wrap="truncate">
323
+ {attachment.name}
324
+ </Text>
325
+ ))}
326
+ {extras.attachments.length > 4 ? (
327
+ <Text dimColor>… {extras.attachments.length - 4} more</Text>
328
+ ) : null}
329
+ </Box>
330
+ ) : null}
331
+ {extras.comments.length > 0 ? (
332
+ <Box flexDirection="column" marginTop={1}>
333
+ <Text bold>💬 comments</Text>
334
+ {extras.comments.slice(0, 3).map((comment) => (
335
+ <Box key={comment.id} flexDirection="column">
336
+ <Text dimColor wrap="truncate">
337
+ {comment.author} · {comment.date.slice(0, 16).replace("T", " ")}
338
+ </Text>
339
+ <Text wrap="truncate-end">{truncateLines(comment.text, 2)}</Text>
340
+ </Box>
341
+ ))}
342
+ </Box>
343
+ ) : null}
344
+ </>
345
+ )}
346
+ </Box>
347
+ );
348
+ }
349
+
350
+ function BoardView({
351
+ client,
352
+ boardId,
353
+ profileName,
354
+ onBack,
355
+ }: {
356
+ client: TrelloClient;
357
+ boardId: string;
358
+ profileName: string;
359
+ onBack?: () => void;
360
+ }) {
361
+ const { exit } = useApp();
362
+ const { stdout } = useStdout();
363
+ const [data, setData] = useState<BoardData | null>(null);
364
+ const [error, setError] = useState<string | null>(null);
365
+ const [loading, setLoading] = useState(true);
366
+ const [col, setCol] = useState(0);
367
+ const [row, setRow] = useState(0);
368
+ const [detail, setDetail] = useState(false);
369
+ const [extras, setExtras] = useState<Map<string, CardExtras>>(new Map());
370
+
371
+ const load = useCallback(async () => {
372
+ setLoading(true);
373
+ setError(null);
374
+ try {
375
+ const [board, lists, cards, customFieldsRaw, members] = await Promise.all([
376
+ client.boardGet(boardId, { fields: "name" }) as Promise<{ name: string }>,
377
+ client.boardLists(boardId, {
378
+ filter: "open",
379
+ fields: "name,pos,color",
380
+ }) as Promise<UiList[]>,
381
+ client.boardCards(boardId, {
382
+ fields:
383
+ "name,desc,due,start,dueComplete,idList,pos,shortUrl,labels,badges,idMembers",
384
+ customFieldItems: true,
385
+ }) as Promise<UiCard[]>,
386
+ client.boardCustomFields(boardId),
387
+ client.boardMembers(boardId) as Promise<
388
+ Array<{ id: string; fullName?: string; username?: string }>
389
+ >,
390
+ ]);
391
+ const cardsByList = new Map<string, UiCard[]>();
392
+ for (const list of lists) cardsByList.set(list.id, []);
393
+ for (const card of [...cards].sort((a, b) => a.pos - b.pos)) {
394
+ cardsByList.get(card.idList)?.push(card);
395
+ }
396
+ setExtras(new Map());
397
+ setData({
398
+ name: board.name,
399
+ lists,
400
+ cardsByList,
401
+ customFields: toCustomFieldDefs(customFieldsRaw),
402
+ membersById: new Map(
403
+ members.map((m) => [m.id, m.fullName || m.username || "member"]),
404
+ ),
405
+ });
406
+ } catch (err) {
407
+ setError(err instanceof Error ? err.message : String(err));
408
+ } finally {
409
+ setLoading(false);
410
+ }
411
+ }, [client, boardId]);
412
+
413
+ useEffect(() => {
414
+ void load();
415
+ }, [load]);
416
+
417
+ const loadExtras = useCallback(
418
+ async (card: UiCard) => {
419
+ if (extras.has(card.id)) return;
420
+ try {
421
+ const [attachments, checklists, actions] = await Promise.all([
422
+ client.cardAttachments(card.id) as Promise<
423
+ Array<{ id: string; name: string }>
424
+ >,
425
+ client.get(`/cards/${card.id}/checklists`) as Promise<
426
+ Array<{
427
+ id: string;
428
+ name: string;
429
+ checkItems?: Array<{ id: string; name: string; state?: string }>;
430
+ }>
431
+ >,
432
+ client.cardActions(card.id, { filter: "commentCard", limit: 5 }) as Promise<
433
+ Array<{
434
+ id: string;
435
+ date: string;
436
+ memberCreator?: { fullName?: string; username?: string };
437
+ data?: { text?: string };
438
+ }>
439
+ >,
440
+ ]);
441
+ setExtras((prev) =>
442
+ new Map(prev).set(card.id, {
443
+ attachments: attachments.map((a) => ({ id: a.id, name: a.name })),
444
+ checklists: checklists.map((cl) => ({
445
+ id: cl.id,
446
+ name: cl.name,
447
+ items: (cl.checkItems ?? []).map((item) => ({
448
+ id: item.id,
449
+ name: item.name,
450
+ complete: item.state === "complete",
451
+ })),
452
+ })),
453
+ comments: actions.map((action) => ({
454
+ id: action.id,
455
+ author:
456
+ action.memberCreator?.fullName ??
457
+ action.memberCreator?.username ??
458
+ "unknown",
459
+ date: action.date,
460
+ text: action.data?.text ?? "",
461
+ })),
462
+ }),
463
+ );
464
+ } catch (err) {
465
+ setExtras((prev) =>
466
+ new Map(prev).set(card.id, {
467
+ attachments: [],
468
+ checklists: [],
469
+ comments: [],
470
+ error: err instanceof Error ? err.message : String(err),
471
+ }),
472
+ );
473
+ }
474
+ },
475
+ [client, extras],
476
+ );
477
+
478
+ useInput((input, key) => {
479
+ if (detail) {
480
+ if (key.escape || key.return || input === "q") setDetail(false);
481
+ return;
482
+ }
483
+ if (input === "q") {
484
+ exit();
485
+ return;
486
+ }
487
+ if (input === "r") {
488
+ void load();
489
+ return;
490
+ }
491
+ if ((key.escape || key.backspace) && onBack) {
492
+ onBack();
493
+ return;
494
+ }
495
+ if (!data || data.lists.length === 0) return;
496
+ const lists = data.lists;
497
+ const safeCol = Math.min(col, lists.length - 1);
498
+ const cards = data.cardsByList.get(lists[safeCol].id) ?? [];
499
+ const safeRow = cards.length === 0 ? -1 : Math.min(row, cards.length - 1);
500
+ if (key.leftArrow || input === "h") {
501
+ setCol(Math.max(0, safeCol - 1));
502
+ setRow(0);
503
+ } else if (key.rightArrow || input === "l") {
504
+ setCol(Math.min(lists.length - 1, safeCol + 1));
505
+ setRow(0);
506
+ } else if (key.upArrow || input === "k") {
507
+ if (safeRow > 0) setRow(safeRow - 1);
508
+ } else if (key.downArrow || input === "j") {
509
+ if (safeRow >= 0 && safeRow < cards.length - 1) setRow(safeRow + 1);
510
+ } else if (key.return && safeRow >= 0) {
511
+ const focusedCard = cards[safeRow];
512
+ if (focusedCard) void loadExtras(focusedCard);
513
+ setDetail(true);
514
+ }
515
+ });
516
+
517
+ const columns = stdout?.columns ?? 80;
518
+ const rows = stdout?.rows ?? 24;
519
+
520
+ if (loading && !data) {
521
+ return <Text> Loading board {boardId}…</Text>;
522
+ }
523
+ if (error && !data) {
524
+ return (
525
+ <Box flexDirection="column">
526
+ <Text color="#eb5a46"> {error}</Text>
527
+ <Text dimColor> r retry · q quit</Text>
528
+ </Box>
529
+ );
530
+ }
531
+ if (!data) return null;
532
+
533
+ const lists = data.lists;
534
+ const safeCol = lists.length > 0 ? Math.min(col, lists.length - 1) : 0;
535
+ const focusedCards =
536
+ lists.length > 0 ? (data.cardsByList.get(lists[safeCol].id) ?? []) : [];
537
+ const safeRow = focusedCards.length > 0 ? Math.min(row, focusedCards.length - 1) : -1;
538
+ const focusedCard = safeRow >= 0 ? focusedCards[safeRow] : undefined;
539
+
540
+ const visibleCols = Math.max(1, Math.floor((columns - 2) / (COL_WIDTH + 1)));
541
+ const colStart = Math.min(
542
+ Math.max(0, safeCol - visibleCols + 1),
543
+ Math.max(0, lists.length - visibleCols),
544
+ );
545
+ const shown = lists.slice(colStart, colStart + visibleCols);
546
+ const frontFields = data.customFields.filter((def) => def.cardFront);
547
+ const cardHeight = frontFields.length > 0 ? 5 : 4;
548
+ const maxCards = Math.max(1, Math.floor((rows - 7) / cardHeight));
549
+
550
+ return (
551
+ <Box flexDirection="column" paddingX={1}>
552
+ <Box marginBottom={1}>
553
+ <Text backgroundColor="#0079bf" color="#ffffff" bold>
554
+ {` ${data.name} `}
555
+ </Text>
556
+ <Text dimColor>
557
+ {" "}
558
+ {profileName} · {lists.length} lists
559
+ {loading ? " · refreshing…" : ""}
560
+ {error ? ` · ${error}` : ""}
561
+ </Text>
562
+ </Box>
563
+ {detail && focusedCard ? (
564
+ <CardDetail
565
+ card={focusedCard}
566
+ listName={lists[safeCol].name}
567
+ width={columns}
568
+ defs={data.customFields}
569
+ membersById={data.membersById}
570
+ extras={extras.get(focusedCard.id)}
571
+ />
572
+ ) : (
573
+ <Box>
574
+ {colStart > 0 ? <Text dimColor>‹ </Text> : null}
575
+ {shown.map((list, i) => (
576
+ <Column
577
+ key={list.id}
578
+ list={list}
579
+ cards={data.cardsByList.get(list.id) ?? []}
580
+ focused={colStart + i === safeCol}
581
+ focusedRow={colStart + i === safeCol ? safeRow : null}
582
+ width={COL_WIDTH}
583
+ maxCards={maxCards}
584
+ cardHeight={cardHeight}
585
+ frontFields={frontFields}
586
+ />
587
+ ))}
588
+ {colStart + visibleCols < lists.length ? <Text dimColor>›</Text> : null}
589
+ </Box>
590
+ )}
591
+ <Box marginTop={1}>
592
+ <Text dimColor>
593
+ {detail
594
+ ? "esc/⏎/q back"
595
+ : `←→ lists · ↑↓ cards · ⏎ detail · r refresh${onBack ? " · esc boards" : ""} · q quit`}
596
+ </Text>
597
+ </Box>
598
+ </Box>
599
+ );
600
+ }
601
+
602
+ export type UiBoard = {
603
+ id: string;
604
+ name: string;
605
+ shortUrl?: string;
606
+ closed?: boolean;
607
+ prefs?: {
608
+ backgroundColor?: string | null;
609
+ backgroundTopColor?: string | null;
610
+ };
611
+ };
612
+
613
+ function boardDotHex(board: UiBoard): string {
614
+ return board.prefs?.backgroundColor ?? board.prefs?.backgroundTopColor ?? TRELLO_BLUE;
615
+ }
616
+
617
+ function BoardPicker({
618
+ client,
619
+ profileName,
620
+ onSelect,
621
+ }: {
622
+ client: TrelloClient;
623
+ profileName: string;
624
+ onSelect: (board: UiBoard) => void;
625
+ }) {
626
+ const { exit } = useApp();
627
+ const { stdout } = useStdout();
628
+ const [boards, setBoards] = useState<UiBoard[] | null>(null);
629
+ const [error, setError] = useState<string | null>(null);
630
+ const [loading, setLoading] = useState(true);
631
+ const [idx, setIdx] = useState(0);
632
+
633
+ const load = useCallback(async () => {
634
+ setLoading(true);
635
+ setError(null);
636
+ try {
637
+ const result = (await client.memberBoards("me", {
638
+ filter: "open",
639
+ fields: "name,shortUrl,closed,prefs",
640
+ })) as UiBoard[];
641
+ setBoards(result);
642
+ } catch (err) {
643
+ setError(err instanceof Error ? err.message : String(err));
644
+ } finally {
645
+ setLoading(false);
646
+ }
647
+ }, [client]);
648
+
649
+ useEffect(() => {
650
+ void load();
651
+ }, [load]);
652
+
653
+ useInput((input, key) => {
654
+ if (input === "q") {
655
+ exit();
656
+ return;
657
+ }
658
+ if (input === "r") {
659
+ void load();
660
+ return;
661
+ }
662
+ if (!boards || boards.length === 0) return;
663
+ const safeIdx = Math.min(idx, boards.length - 1);
664
+ if (key.upArrow || input === "k") {
665
+ setIdx(Math.max(0, safeIdx - 1));
666
+ } else if (key.downArrow || input === "j") {
667
+ setIdx(Math.min(boards.length - 1, safeIdx + 1));
668
+ } else if (key.return) {
669
+ onSelect(boards[safeIdx]);
670
+ }
671
+ });
672
+
673
+ const rows = stdout?.rows ?? 24;
674
+
675
+ if (loading && !boards) return <Text> Loading boards…</Text>;
676
+ if (error && !boards) {
677
+ return (
678
+ <Box flexDirection="column">
679
+ <Text color="#eb5a46"> {error}</Text>
680
+ <Text dimColor> r retry · q quit</Text>
681
+ </Box>
682
+ );
683
+ }
684
+ if (!boards) return null;
685
+
686
+ const safeIdx = boards.length > 0 ? Math.min(idx, boards.length - 1) : 0;
687
+ const maxRows = Math.max(1, rows - 6);
688
+ const start = safeIdx >= maxRows ? safeIdx - maxRows + 1 : 0;
689
+ const visible = boards.slice(start, start + maxRows);
690
+ const below = boards.length - (start + visible.length);
691
+
692
+ return (
693
+ <Box flexDirection="column" paddingX={1}>
694
+ <Box marginBottom={1}>
695
+ <Text backgroundColor="#0079bf" color="#ffffff" bold>
696
+ {" Boards "}
697
+ </Text>
698
+ <Text dimColor>
699
+ {" "}
700
+ {profileName} · {boards.length} open
701
+ {loading ? " · refreshing…" : ""}
702
+ {error ? ` · ${error}` : ""}
703
+ </Text>
704
+ </Box>
705
+ {boards.length === 0 ? <Text dimColor> (no open boards)</Text> : null}
706
+ {start > 0 ? <Text dimColor> ↑ {start} more</Text> : null}
707
+ {visible.map((board, i) => {
708
+ const focused = start + i === safeIdx;
709
+ return (
710
+ <Text key={board.id} wrap="truncate">
711
+ {focused ? <Text color={TRELLO_BLUE}>❯ </Text> : " "}
712
+ <Text color={boardDotHex(board)}>● </Text>
713
+ <Text bold={focused}>{board.name}</Text>
714
+ {board.shortUrl ? <Text dimColor> {board.shortUrl}</Text> : null}
715
+ </Text>
716
+ );
717
+ })}
718
+ {below > 0 ? <Text dimColor> ↓ {below} more</Text> : null}
719
+ <Box marginTop={1}>
720
+ <Text dimColor>↑↓ move · ⏎ open · r refresh · q quit</Text>
721
+ </Box>
722
+ </Box>
723
+ );
724
+ }
725
+
726
+ export function App({
727
+ client,
728
+ boardId,
729
+ profileName,
730
+ }: {
731
+ client: TrelloClient;
732
+ boardId?: string;
733
+ profileName: string;
734
+ }) {
735
+ const [picked, setPicked] = useState<UiBoard | null>(null);
736
+ if (boardId) {
737
+ return <BoardView client={client} boardId={boardId} profileName={profileName} />;
738
+ }
739
+ if (!picked) {
740
+ return (
741
+ <BoardPicker client={client} profileName={profileName} onSelect={setPicked} />
742
+ );
743
+ }
744
+ return (
745
+ <BoardView
746
+ key={picked.id}
747
+ client={client}
748
+ boardId={picked.id}
749
+ profileName={profileName}
750
+ onBack={() => setPicked(null)}
751
+ />
752
+ );
753
+ }