tui-kanban-board 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.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # TUI Trello Kanban Board
2
+
3
+ ターミナル上で動作するカンバンボードです。キーボードだけで操作できます。
4
+
5
+ ## インストール
6
+
7
+ [GitHub Releases](../../releases/latest) からお使いのプラットフォームのファイルをダウンロードしてください。
8
+
9
+ > **注意**: プライベートリポジトリのため、ダウンロードには GitHub の認証が必要です。
10
+ > `gh` CLI([GitHub CLI](https://cli.github.com/))を使うと便利です。
11
+
12
+ ### Linux (x64)
13
+
14
+ ```bash
15
+ gh release download --repo OWNER/REPO --pattern "tkb-linux-x64.tar.gz"
16
+ tar xzf tkb-linux-x64.tar.gz
17
+ chmod +x tkb
18
+ ./tkb
19
+ ```
20
+
21
+ ### macOS (Apple Silicon / M1以降)
22
+
23
+ ```bash
24
+ gh release download --repo OWNER/REPO --pattern "tkb-darwin-arm64.tar.gz"
25
+ tar xzf tkb-darwin-arm64.tar.gz
26
+ chmod +x tkb
27
+ # セキュリティ確認をスキップ(初回のみ)
28
+ xattr -d com.apple.quarantine tkb
29
+ ./tkb
30
+ ```
31
+
32
+ ### macOS (Intel)
33
+
34
+ ```bash
35
+ gh release download --repo OWNER/REPO --pattern "tkb-darwin-x64.tar.gz"
36
+ tar xzf tkb-darwin-x64.tar.gz
37
+ chmod +x tkb
38
+ xattr -d com.apple.quarantine tkb
39
+ ./tkb
40
+ ```
41
+
42
+ ### Windows (x64)
43
+
44
+ ```powershell
45
+ gh release download --repo OWNER/REPO --pattern "tkb-windows-x64.zip"
46
+ Expand-Archive tkb-windows-x64.zip .
47
+ .\tkb.exe
48
+ ```
49
+
50
+ ---
51
+
52
+ ## 使い方
53
+
54
+ `.tkb` ファイルがあるディレクトリで実行します。ファイルが存在しない場合は自動で作成されます。
55
+
56
+ ```bash
57
+ # カレントディレクトリの .tkb を開く
58
+ ./tkb
59
+
60
+ # ファイルを指定して開く
61
+ ./tkb path/to/board.tkb
62
+ ```
63
+
64
+ ## キーボードショートカット
65
+
66
+ ### ボード操作
67
+
68
+ | キー | 動作 |
69
+ |------|------|
70
+ | `←` / `→` | 列を移動 |
71
+ | `↑` / `↓` / `k` / `j` | カードを移動 |
72
+ | `Enter` | カードをプレビュー |
73
+ | `n` | カードを新規追加 |
74
+ | `e` | カードを編集 |
75
+ | `d` | カードを削除 |
76
+ | `Shift+←/→` | カードを隣の列に移動 |
77
+ | `q` | 終了 |
78
+
79
+ ### 列操作
80
+
81
+ | キー | 動作 |
82
+ |------|------|
83
+ | `N`(Shift+n) | 列を追加(現在の列の右隣) |
84
+ | `E`(Shift+e) | 列名を編集 |
85
+ | `D`(Shift+d) | 列を削除 |
86
+ | `Ctrl+←/→` | 列を左右に並び替え |
87
+
88
+ ### プレビュー / 編集モード
89
+
90
+ | キー | 動作 |
91
+ |------|------|
92
+ | `Esc` | 閉じる / キャンセル |
93
+ | `Ctrl+S` | 保存 |
94
+ | `e` | プレビューから編集へ |
95
+
96
+ ## カードの書き方(Markdown対応)
97
+
98
+ カードの内容は Markdown で書けます。
99
+
100
+ ```markdown
101
+ # 見出し
102
+
103
+ **太字** / *斜体* / ~~取り消し線~~ / `コード`
104
+
105
+ - リストアイテム
106
+ - ネストされたアイテム
107
+
108
+ 1. 順序付きリスト
109
+ 2. 二番目
110
+
111
+ | ヘッダ1 | ヘッダ2 |
112
+ |---------|---------|
113
+ | セル1 | セル2 |
114
+ ```
115
+
116
+ ## ファイル形式 (.tkb)
117
+
118
+ JSON 形式のファイルです。操作のたびに自動保存されます。
119
+
120
+ ```json
121
+ {
122
+ "scope": "Workspace",
123
+ "columns": [
124
+ {
125
+ "id": "column-xxx",
126
+ "title": "To Do",
127
+ "tasksIds": ["task-yyy"]
128
+ }
129
+ ],
130
+ "tasks": {
131
+ "task-yyy": {
132
+ "id": "task-yyy",
133
+ "description": "タスクの内容",
134
+ "columnId": "column-xxx"
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ ---
141
+
142
+ ## 開発
143
+
144
+ ```bash
145
+ bun install
146
+ bun dev
147
+ ```
package/bin/tkb.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/index.tsx";
3
+
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "tui-kanban-board",
3
+ "description": "Task Management App of TUI",
4
+ "version": "0.1.0",
5
+ "module": "src/index.tsx",
6
+ "type": "module",
7
+ "private": false,
8
+ "bin": {
9
+ "tkb": "./bin/tkb.js"
10
+ },
11
+ "scripts": {
12
+ "dev": "bun run --watch src/index.tsx",
13
+ "build": "bun build src/index.tsx --outdir dist --target node --format esm"
14
+ },
15
+ "devDependencies": {
16
+ "@types/bun": "latest"
17
+ },
18
+ "peerDependencies": {
19
+ "typescript": "^5"
20
+ },
21
+ "dependencies": {
22
+ "@opentui/core": "^0.1.50",
23
+ "@opentui/react": "^0.1.50",
24
+ "react": "^19.2.4"
25
+ }
26
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,388 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import { useKeyboard, useRenderer } from "@opentui/react";
3
+ import type { BoardType, Mode } from "./types.ts";
4
+ import {
5
+ saveBoard,
6
+ createTask,
7
+ addTask,
8
+ updateTask,
9
+ deleteTask,
10
+ moveTask,
11
+ createColumn,
12
+ addColumn,
13
+ updateColumn,
14
+ deleteColumn,
15
+ moveColumn,
16
+ } from "./store.ts";
17
+ import { Board } from "./components/Board.tsx";
18
+ import { EditModal } from "./components/EditModal.tsx";
19
+ import { ConfirmModal } from "./components/ConfirmModal.tsx";
20
+ import { PreviewModal } from "./components/PreviewModal.tsx";
21
+ import { ColumnEditModal } from "./components/ColumnEditModal.tsx";
22
+ import { StatusBar } from "./components/StatusBar.tsx";
23
+
24
+ interface Props {
25
+ filePath: string;
26
+ initialBoard: BoardType;
27
+ }
28
+
29
+ /**
30
+ * アプリケーションのルートコンポーネント
31
+ * @param filePath - .tkbファイルのパス
32
+ * @param initialBoard - 初期ボードデータ
33
+ */
34
+ export function App({ filePath, initialBoard }: Props) {
35
+ const renderer = useRenderer();
36
+ const [board, setBoard] = useState(initialBoard);
37
+ const [colIdx, setColIdx] = useState(0);
38
+ const [cardIdx, setCardIdx] = useState(0);
39
+ const [mode, setMode] = useState<Mode>("board");
40
+ const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
41
+ const [editingColId, setEditingColId] = useState<string | null>(null);
42
+ const [statusMsg, setStatusMsg] = useState("");
43
+
44
+ // キーボードハンドラ内で常に最新のboard値を参照するためのref
45
+ const boardRef = useRef(board);
46
+ boardRef.current = board;
47
+
48
+ // EditModalのtextarea値をre-renderなしで追跡するref
49
+ const editValueRef = useRef("");
50
+
51
+ // 初回マウント時はスキップし、board変更時に自動保存
52
+ const isMountedRef = useRef(false);
53
+ useEffect(() => {
54
+ if (!isMountedRef.current) {
55
+ isMountedRef.current = true;
56
+ return;
57
+ }
58
+ try {
59
+ saveBoard(filePath, board);
60
+ showStatus("自動保存しました");
61
+ } catch (e) {
62
+ showStatus(`保存エラー: ${String(e).slice(0, 40)}`);
63
+ }
64
+ }, [board]);
65
+
66
+ const activeColumns = board.columns.filter((c) => !c.archived);
67
+ const safeColIdx = Math.min(colIdx, Math.max(0, activeColumns.length - 1));
68
+ const activeCol = activeColumns[safeColIdx];
69
+ const cardIds = activeCol?.tasksIds ?? [];
70
+ const safeCardIdx = cardIds.length > 0 ? Math.min(cardIdx, cardIds.length - 1) : -1;
71
+ const selectedTaskId = safeCardIdx >= 0 ? (cardIds[safeCardIdx] ?? null) : null;
72
+
73
+ /**
74
+ * ステータスバーにメッセージを2秒間表示する
75
+ * @param msg - 表示するメッセージ
76
+ */
77
+ const showStatus = (msg: string) => {
78
+ setStatusMsg(msg);
79
+ setTimeout(() => setStatusMsg(""), 2000);
80
+ };
81
+
82
+ /**
83
+ * 編集・新規作成モーダルを閉じてボードモードに戻る
84
+ */
85
+ const closeModal = () => {
86
+ setMode("board");
87
+ setEditingTaskId(null);
88
+ setEditingColId(null);
89
+ };
90
+
91
+ /**
92
+ * モーダルからのテキスト変更を受け取るハンドラ
93
+ * @param value - textareaの現在値
94
+ */
95
+ const handleEditValueChange = (value: string) => {
96
+ editValueRef.current = value;
97
+ };
98
+
99
+ /**
100
+ * カードの保存処理(新規作成・編集共通)
101
+ * @param description - カードの内容
102
+ */
103
+ const handleSave = (description: string) => {
104
+ const trimmed = description.trim();
105
+ if (mode === "new" && activeCol) {
106
+ if (trimmed) {
107
+ const task = createTask(trimmed, activeCol.id);
108
+ setBoard((b) => addTask(b, task));
109
+ setCardIdx(cardIds.length);
110
+ }
111
+ } else if (mode === "edit" && editingTaskId) {
112
+ setBoard((b) => updateTask(b, editingTaskId, trimmed || description));
113
+ }
114
+ closeModal();
115
+ };
116
+
117
+ /**
118
+ * 列の保存処理(新規作成・編集共通)
119
+ * @param title - 列名
120
+ */
121
+ const handleColumnSave = (title: string) => {
122
+ const trimmed = title.trim();
123
+ if (!trimmed) {
124
+ closeModal();
125
+ return;
126
+ }
127
+ if (mode === "new-column") {
128
+ const col = createColumn(trimmed);
129
+ setBoard((b) => addColumn(b, col, safeColIdx));
130
+ setColIdx(safeColIdx + 1);
131
+ setCardIdx(0);
132
+ } else if (mode === "edit-column" && editingColId) {
133
+ setBoard((b) => updateColumn(b, editingColId, trimmed));
134
+ }
135
+ closeModal();
136
+ };
137
+
138
+ // キーボードハンドラは1つに集約(OpenTUIでは複数のuseKeyboardが競合するため)
139
+ useKeyboard((key) => {
140
+ if (mode === "confirm-delete") {
141
+ if (key.name === "y") {
142
+ if (selectedTaskId) {
143
+ setBoard((b) => deleteTask(b, selectedTaskId));
144
+ setCardIdx((i) => Math.max(0, i - 1));
145
+ }
146
+ setMode("board");
147
+ } else if (key.name === "n" || key.name === "escape") {
148
+ setMode("board");
149
+ }
150
+ return;
151
+ }
152
+
153
+ if (mode === "confirm-delete-column") {
154
+ if (key.name === "y") {
155
+ if (activeCol) {
156
+ setBoard((b) => deleteColumn(b, activeCol.id));
157
+ setColIdx((i) => Math.max(0, i - 1));
158
+ setCardIdx(0);
159
+ }
160
+ setMode("board");
161
+ } else if (key.name === "n" || key.name === "escape") {
162
+ setMode("board");
163
+ }
164
+ return;
165
+ }
166
+
167
+ if (mode === "preview") {
168
+ if (key.name === "escape" || key.name === "q") {
169
+ setMode("board");
170
+ } else if (key.name === "e") {
171
+ if (selectedTaskId) {
172
+ editValueRef.current = board.tasks[selectedTaskId]?.description ?? "";
173
+ setEditingTaskId(selectedTaskId);
174
+ setMode("edit");
175
+ }
176
+ }
177
+ return;
178
+ }
179
+
180
+ if (mode === "edit" || mode === "new") {
181
+ if (key.name === "escape") {
182
+ closeModal();
183
+ } else if (key.ctrl && key.name === "s") {
184
+ handleSave(editValueRef.current);
185
+ }
186
+ return;
187
+ }
188
+
189
+ if (mode === "new-column" || mode === "edit-column") {
190
+ if (key.name === "escape") {
191
+ closeModal();
192
+ } else if (key.ctrl && key.name === "s") {
193
+ handleColumnSave(editValueRef.current);
194
+ }
195
+ return;
196
+ }
197
+
198
+ // ボード操作
199
+ switch (key.name) {
200
+ case "left":
201
+ if (key.ctrl) {
202
+ // Ctrl+← で列を左に移動
203
+ if (activeCol && safeColIdx > 0) {
204
+ const targetGlobalIdx = board.columns.findIndex((c) => c.id === activeCol.id);
205
+ const prevActiveCol = activeColumns[safeColIdx - 1];
206
+ if (prevActiveCol) {
207
+ setBoard((b) => moveColumn(b, activeCol.id, "left"));
208
+ // アーカイブ列が間にある場合もあるため、移動後も同じ列を選択する
209
+ const prevGlobalIdx = board.columns.findIndex((c) => c.id === prevActiveCol.id);
210
+ if (prevGlobalIdx < targetGlobalIdx) {
211
+ setColIdx((i) => i - 1);
212
+ }
213
+ }
214
+ }
215
+ } else if (key.shift) {
216
+ if (selectedTaskId && safeColIdx > 0) {
217
+ const targetCol = activeColumns[safeColIdx - 1]!;
218
+ setBoard((b) => moveTask(b, selectedTaskId, targetCol.id));
219
+ setColIdx((i) => i - 1);
220
+ setCardIdx(0);
221
+ }
222
+ } else {
223
+ setColIdx((i) => Math.max(0, i - 1));
224
+ setCardIdx(0);
225
+ }
226
+ break;
227
+
228
+ case "right":
229
+ if (key.ctrl) {
230
+ // Ctrl+→ で列を右に移動
231
+ if (activeCol && safeColIdx < activeColumns.length - 1) {
232
+ const targetGlobalIdx = board.columns.findIndex((c) => c.id === activeCol.id);
233
+ const nextActiveCol = activeColumns[safeColIdx + 1];
234
+ if (nextActiveCol) {
235
+ setBoard((b) => moveColumn(b, activeCol.id, "right"));
236
+ const nextGlobalIdx = board.columns.findIndex((c) => c.id === nextActiveCol.id);
237
+ if (nextGlobalIdx > targetGlobalIdx) {
238
+ setColIdx((i) => i + 1);
239
+ }
240
+ }
241
+ }
242
+ } else if (key.shift) {
243
+ if (selectedTaskId && safeColIdx < activeColumns.length - 1) {
244
+ const targetCol = activeColumns[safeColIdx + 1]!;
245
+ setBoard((b) => moveTask(b, selectedTaskId, targetCol.id));
246
+ setColIdx((i) => i + 1);
247
+ setCardIdx(0);
248
+ }
249
+ } else {
250
+ setColIdx((i) => Math.min(activeColumns.length - 1, i + 1));
251
+ setCardIdx(0);
252
+ }
253
+ break;
254
+
255
+ case "up":
256
+ case "k":
257
+ setCardIdx((i) => Math.max(0, i - 1));
258
+ break;
259
+
260
+ case "down":
261
+ case "j":
262
+ if (cardIds.length > 0) {
263
+ setCardIdx((i) => Math.min(cardIds.length - 1, i + 1));
264
+ }
265
+ break;
266
+
267
+ case "n":
268
+ if (key.shift) {
269
+ // Shift+N: 列を追加(現在の列の右隣に挿入)
270
+ editValueRef.current = "";
271
+ setEditingColId(null);
272
+ setMode("new-column");
273
+ } else if (activeCol) {
274
+ // n: カードを追加
275
+ editValueRef.current = "";
276
+ setEditingTaskId(null);
277
+ setMode("new");
278
+ }
279
+ break;
280
+
281
+ case "return":
282
+ if (selectedTaskId) {
283
+ setMode("preview");
284
+ }
285
+ break;
286
+
287
+ case "e":
288
+ if (key.shift) {
289
+ // Shift+E: 列名を編集
290
+ if (activeCol) {
291
+ editValueRef.current = activeCol.title;
292
+ setEditingColId(activeCol.id);
293
+ setMode("edit-column");
294
+ }
295
+ } else if (selectedTaskId) {
296
+ // e: カードを編集
297
+ editValueRef.current = board.tasks[selectedTaskId]?.description ?? "";
298
+ setEditingTaskId(selectedTaskId);
299
+ setMode("edit");
300
+ }
301
+ break;
302
+
303
+ case "d":
304
+ if (key.shift) {
305
+ // Shift+D: 列を削除
306
+ if (activeCol) setMode("confirm-delete-column");
307
+ } else if (selectedTaskId) {
308
+ // d: カードを削除
309
+ setMode("confirm-delete");
310
+ }
311
+ break;
312
+
313
+ case "q":
314
+ renderer.destroy();
315
+ break;
316
+ }
317
+ });
318
+
319
+ const editingTask = editingTaskId ? board.tasks[editingTaskId] : undefined;
320
+ const deletingTaskDesc =
321
+ selectedTaskId
322
+ ? (board.tasks[selectedTaskId]?.description.split("\n")[0] ?? "このカード")
323
+ : "このカード";
324
+
325
+ /** 列削除確認メッセージ(カードが存在する場合は枚数を警告) */
326
+ const deletingColMessage = (() => {
327
+ if (!activeCol) return "この列を削除しますか?";
328
+ const cardCount = activeCol.tasksIds.length;
329
+ if (cardCount > 0) {
330
+ return `「${activeCol.title}」(カード ${cardCount} 枚)を削除しますか? カードも全て削除されます。`;
331
+ }
332
+ return `「${activeCol.title}」を削除しますか?`;
333
+ })();
334
+
335
+ return (
336
+ <box
337
+ flexDirection="column"
338
+ width="100%"
339
+ height="100%"
340
+ backgroundColor="#0d1117"
341
+ position="relative"
342
+ >
343
+ <box paddingX={2} paddingTop={1} alignSelf="center">
344
+ <text color="#58a6ff" bold>Trello Kanban Board</text>
345
+ </box>
346
+ <box position="absolute" top={1} right={2}>
347
+ <text color="#484f58">© 2026 Yuki-Kikuya</text>
348
+ </box>
349
+ <Board board={board} colIdx={safeColIdx} cardIdx={safeCardIdx} />
350
+ <StatusBar filePath={filePath} statusMsg={statusMsg} />
351
+
352
+ {(mode === "edit" || mode === "new") && (
353
+ <EditModal
354
+ key={editingTaskId ?? "__new__"}
355
+ initialValue={editingTask?.description ?? ""}
356
+ title={mode === "new" ? "新規カード" : "カードを編集"}
357
+ onValueChange={handleEditValueChange}
358
+ />
359
+ )}
360
+
361
+ {mode === "preview" && selectedTaskId && (
362
+ <PreviewModal
363
+ content={board.tasks[selectedTaskId]?.description ?? ""}
364
+ title={board.tasks[selectedTaskId]?.description.split("\n")[0] ?? ""}
365
+ />
366
+ )}
367
+
368
+ {mode === "confirm-delete" && (
369
+ <ConfirmModal
370
+ message={`「${deletingTaskDesc}」を削除しますか?`}
371
+ />
372
+ )}
373
+
374
+ {(mode === "new-column" || mode === "edit-column") && (
375
+ <ColumnEditModal
376
+ key={editingColId ?? "__new-column__"}
377
+ initialValue={mode === "edit-column" ? (activeColumns.find((c) => c.id === editingColId)?.title ?? "") : ""}
378
+ title={mode === "new-column" ? "列を追加" : "列を編集"}
379
+ onValueChange={handleEditValueChange}
380
+ />
381
+ )}
382
+
383
+ {mode === "confirm-delete-column" && (
384
+ <ConfirmModal message={deletingColMessage} />
385
+ )}
386
+ </box>
387
+ );
388
+ }
@@ -0,0 +1,38 @@
1
+ import type { BoardType, TaskType } from "../types.ts";
2
+ import { Column } from "./Column.tsx";
3
+
4
+ interface Props {
5
+ board: BoardType;
6
+ colIdx: number;
7
+ cardIdx: number;
8
+ }
9
+
10
+ /**
11
+ * カンバンボード全体のレイアウトコンポーネント
12
+ * @param board - ボードデータ
13
+ * @param colIdx - フォーカス中の列インデックス
14
+ * @param cardIdx - フォーカス中のカードインデックス
15
+ */
16
+ export function Board({ board, colIdx, cardIdx }: Props) {
17
+ const activeColumns = board.columns.filter((c) => !c.archived);
18
+
19
+ return (
20
+ <box flexDirection="row" flexGrow={1} gap={1} paddingX={1} paddingTop={1}>
21
+ {activeColumns.map((col, i) => {
22
+ const tasks = col.tasksIds
23
+ .map((id) => board.tasks[id])
24
+ .filter((t): t is TaskType => t !== undefined);
25
+
26
+ return (
27
+ <Column
28
+ key={col.id}
29
+ column={col}
30
+ tasks={tasks}
31
+ isFocused={i === colIdx}
32
+ focusedCardIdx={i === colIdx ? cardIdx : -1}
33
+ />
34
+ );
35
+ })}
36
+ </box>
37
+ );
38
+ }
@@ -0,0 +1,30 @@
1
+ import type { TaskType } from "../types.ts";
2
+ import { MarkdownView } from "./MarkdownView.tsx";
3
+
4
+ interface Props {
5
+ task: TaskType;
6
+ isSelected: boolean;
7
+ }
8
+
9
+ /**
10
+ * カンバンボードのカードコンポーネント
11
+ * @param task - 表示するタスクデータ
12
+ * @param isSelected - このカードが選択中か
13
+ */
14
+ export function Card({ task, isSelected }: Props) {
15
+ return (
16
+ <box
17
+ border
18
+ borderStyle="single"
19
+ borderColor={isSelected ? "#00bfff" : "#3d444d"}
20
+ backgroundColor={isSelected ? "#0d2137" : "#161b22"}
21
+ paddingX={1}
22
+ flexDirection="column"
23
+ >
24
+ <MarkdownView
25
+ content={task.description}
26
+ textFg={isSelected ? "#e6edf3" : "#8b949e"}
27
+ />
28
+ </box>
29
+ );
30
+ }
@@ -0,0 +1,71 @@
1
+ import { useRef, useEffect } from "react";
2
+ import { ScrollBoxRenderable } from "@opentui/core";
3
+ import type { ColumnType, TaskType } from "../types.ts";
4
+ import { Card } from "./Card.tsx";
5
+
6
+ interface Props {
7
+ column: ColumnType;
8
+ tasks: TaskType[];
9
+ isFocused: boolean;
10
+ focusedCardIdx: number;
11
+ }
12
+
13
+ /**
14
+ * カンバンボードの列コンポーネント
15
+ * @param column - 列のデータ
16
+ * @param tasks - 列内のタスク一覧
17
+ * @param isFocused - この列にフォーカスが当たっているか
18
+ * @param focusedCardIdx - フォーカス中のカードインデックス(-1はなし)
19
+ */
20
+ export function Column({ column, tasks, isFocused, focusedCardIdx }: Props) {
21
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
22
+
23
+ useEffect(() => {
24
+ if (isFocused && focusedCardIdx >= 0) {
25
+ const task = tasks[focusedCardIdx];
26
+ if (task) {
27
+ scrollRef.current?.scrollChildIntoView(task.id);
28
+ }
29
+ }
30
+ }, [focusedCardIdx, isFocused, tasks]);
31
+
32
+ return (
33
+ <box
34
+ flexDirection="column"
35
+ flexGrow={1}
36
+ border
37
+ borderStyle="rounded"
38
+ borderColor={isFocused ? "#00bfff" : "#3d444d"}
39
+ backgroundColor="#0d1117"
40
+ >
41
+ <box
42
+ border={["bottom"]}
43
+ borderColor={isFocused ? "#00bfff" : "#3d444d"}
44
+ paddingX={1}
45
+ flexDirection="row"
46
+ justifyContent="space-between"
47
+ >
48
+ <text fg={isFocused ? "#e6edf3" : "#8b949e"}>
49
+ <strong>{column.title}</strong>
50
+ </text>
51
+ <text fg="#484f58">{tasks.length}</text>
52
+ </box>
53
+
54
+ <scrollbox ref={scrollRef} flexGrow={1}>
55
+ <box flexDirection="column" gap={1} padding={1}>
56
+ {tasks.map((task, i) => (
57
+ <box key={task.id} id={task.id}>
58
+ <Card
59
+ task={task}
60
+ isSelected={isFocused && i === focusedCardIdx}
61
+ />
62
+ </box>
63
+ ))}
64
+ {tasks.length === 0 && (
65
+ <text fg="#3d444d"> No cards yet</text>
66
+ )}
67
+ </box>
68
+ </scrollbox>
69
+ </box>
70
+ );
71
+ }