@pi-unipi/kanboard 2.0.7 → 2.0.9

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.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * @pi-unipi/kanboard — HTTP Server
3
+ *
4
+ * Lightweight HTTP server with port allocation, PID management,
5
+ * static file serving, and route registration.
6
+ */
7
+
8
+ import * as http from "node:http";
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { KANBOARD_DEFAULTS, KANBOARD_DIRS } from "@pi-unipi/core";
12
+ import type { KanboardConfig } from "../types.js";
13
+
14
+ /** Content-type map for static files */
15
+ const CONTENT_TYPES: Record<string, string> = {
16
+ ".html": "text/html; charset=utf-8",
17
+ ".css": "text/css; charset=utf-8",
18
+ ".js": "application/javascript; charset=utf-8",
19
+ ".json": "application/json; charset=utf-8",
20
+ ".png": "image/png",
21
+ ".jpg": "image/jpeg",
22
+ ".svg": "image/svg+xml",
23
+ ".ico": "image/x-icon",
24
+ };
25
+
26
+ /** Route handler type */
27
+ type RouteHandler = (
28
+ req: http.IncomingMessage,
29
+ res: http.ServerResponse,
30
+ params: Record<string, string>,
31
+ ) => void | Promise<void>;
32
+
33
+ /** Registered route */
34
+ interface Route {
35
+ method: string;
36
+ pattern: RegExp;
37
+ paramNames: string[];
38
+ handler: RouteHandler;
39
+ }
40
+
41
+ /** Kanboard server instance */
42
+ export class KanboardServer {
43
+ private server: http.Server | null = null;
44
+ private config: KanboardConfig;
45
+ private routes: Route[] = [];
46
+ private staticDir: string;
47
+
48
+ constructor(config?: Partial<KanboardConfig>) {
49
+ this.config = {
50
+ port: config?.port ?? KANBOARD_DEFAULTS.PORT,
51
+ maxPort: config?.maxPort ?? KANBOARD_DEFAULTS.MAX_PORT,
52
+ docsRoot: config?.docsRoot ?? ".unipi/docs",
53
+ pidFile: config?.pidFile ?? KANBOARD_DIRS.PID_FILE,
54
+ };
55
+ this.staticDir = path.resolve(
56
+ path.dirname(new URL(import.meta.url).pathname),
57
+ "..",
58
+ "ui",
59
+ "static",
60
+ );
61
+ }
62
+
63
+ /** Register a route */
64
+ route(method: string, pattern: string, handler: RouteHandler): void {
65
+ const paramNames: string[] = [];
66
+ // Convert Express-style params (:name) to regex capture groups
67
+ const regexStr = pattern.replace(/:(\w+)/g, (_match, name) => {
68
+ paramNames.push(name);
69
+ return "([^/]+)";
70
+ });
71
+ this.routes.push({
72
+ method: method.toUpperCase(),
73
+ pattern: new RegExp(`^${regexStr}$`),
74
+ paramNames,
75
+ handler,
76
+ });
77
+ }
78
+
79
+ /** Start the server with port allocation */
80
+ async start(): Promise<{ port: number; url: string }> {
81
+ // Check for existing instance
82
+ const existing = this.checkExistingInstance();
83
+ if (existing) {
84
+ // Removed console.log — existing instance detection is silent.
85
+ }
86
+
87
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
88
+
89
+ const port = await this.allocatePort();
90
+ if (port === null) {
91
+ throw new Error(
92
+ `[kanboard] Could not bind to any port ${this.config.port}-${this.config.maxPort}`,
93
+ );
94
+ }
95
+
96
+ // Write PID file
97
+ this.writePidFile();
98
+
99
+ // Graceful shutdown
100
+ const shutdown = () => {
101
+ // Removed console.log — shutdown is silent.
102
+ this.server?.close(() => {
103
+ this.removePidFile();
104
+ process.exit(0);
105
+ });
106
+ };
107
+ process.on("SIGINT", shutdown);
108
+ process.on("SIGTERM", shutdown);
109
+
110
+ const url = `http://localhost:${port}`;
111
+ // Removed console.log — server URL visible via /unipi:kanboard or info-screen.
112
+ return { port, url };
113
+ }
114
+
115
+ /** Stop the server */
116
+ stop(): void {
117
+ this.server?.close();
118
+ this.removePidFile();
119
+ this.server = null;
120
+ }
121
+
122
+ /** Get the docs root directory */
123
+ getDocsRoot(): string {
124
+ return this.config.docsRoot;
125
+ }
126
+
127
+ /** Try ports in range, return first available */
128
+ private async allocatePort(): Promise<number | null> {
129
+ for (let port = this.config.port; port <= this.config.maxPort; port++) {
130
+ try {
131
+ await this.listen(port);
132
+ return port;
133
+ } catch (err: unknown) {
134
+ if ((err instanceof Error && (err as NodeJS.ErrnoException).code === "EADDRINUSE")) {
135
+ // Removed console.log — port allocation is silent.
136
+ continue;
137
+ }
138
+ throw err;
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /** Bind server to a port */
145
+ private listen(port: number): Promise<void> {
146
+ return new Promise((resolve, reject) => {
147
+ this.server!.once("error", reject);
148
+ this.server!.listen(port, () => resolve());
149
+ });
150
+ }
151
+
152
+ /** Handle incoming HTTP request */
153
+ private async handleRequest(
154
+ req: http.IncomingMessage,
155
+ res: http.ServerResponse,
156
+ ): Promise<void> {
157
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
158
+ const pathname = url.pathname;
159
+
160
+ // Static files
161
+ if (pathname.startsWith("/static/")) {
162
+ return this.serveStatic(pathname, res);
163
+ }
164
+
165
+ // Match routes
166
+ for (const route of this.routes) {
167
+ if (req.method !== route.method) continue;
168
+ const match = pathname.match(route.pattern);
169
+ if (match) {
170
+ const params: Record<string, string> = {};
171
+ route.paramNames.forEach((name, i) => {
172
+ params[name] = match[i + 1];
173
+ });
174
+ try {
175
+ await route.handler(req, res, params);
176
+ } catch (_err: unknown) {
177
+ // Silently ignore — route handler error, send generic 500.
178
+ res.writeHead(500, { "Content-Type": "text/plain" });
179
+ res.end("Internal Server Error");
180
+ }
181
+ return;
182
+ }
183
+ }
184
+
185
+ // 404
186
+ res.writeHead(404, { "Content-Type": "text/plain" });
187
+ res.end("Not Found");
188
+ }
189
+
190
+ /** Serve static files from ui/static/ */
191
+ private serveStatic(pathname: string, res: http.ServerResponse): void {
192
+ const filePath = path.join(this.staticDir, pathname.replace("/static/", ""));
193
+ const resolved = path.resolve(filePath);
194
+
195
+ // Prevent directory traversal
196
+ if (!resolved.startsWith(path.resolve(this.staticDir))) {
197
+ res.writeHead(403, { "Content-Type": "text/plain" });
198
+ res.end("Forbidden");
199
+ return;
200
+ }
201
+
202
+ if (!fs.existsSync(resolved)) {
203
+ res.writeHead(404, { "Content-Type": "text/plain" });
204
+ res.end("Not Found");
205
+ return;
206
+ }
207
+
208
+ const ext = path.extname(resolved);
209
+ const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
210
+ const content = fs.readFileSync(resolved);
211
+ res.writeHead(200, { "Content-Type": contentType });
212
+ res.end(content);
213
+ }
214
+
215
+ /** Check if an existing kanboard instance is running */
216
+ checkExistingInstance(): string | null {
217
+ try {
218
+ if (!fs.existsSync(this.config.pidFile)) return null;
219
+ const pid = parseInt(fs.readFileSync(this.config.pidFile, "utf-8").trim(), 10);
220
+ if (isNaN(pid)) return null;
221
+ // Check if process exists
222
+ process.kill(pid, 0);
223
+ // Process exists — return warning URL
224
+ return `http://localhost:${this.config.port} (PID: ${pid})`;
225
+ } catch {
226
+ // Process doesn't exist or can't access PID file
227
+ return null;
228
+ }
229
+ }
230
+
231
+ /** Write current PID to file */
232
+ private writePidFile(): void {
233
+ try {
234
+ const dir = path.dirname(this.config.pidFile);
235
+ if (!fs.existsSync(dir)) {
236
+ fs.mkdirSync(dir, { recursive: true });
237
+ }
238
+ fs.writeFileSync(this.config.pidFile, String(process.pid));
239
+ } catch (_err: unknown) {
240
+ // PID write failure — non-critical, kanboard still works.
241
+ }
242
+ }
243
+
244
+ /** Remove PID file */
245
+ private removePidFile(): void {
246
+ try {
247
+ if (fs.existsSync(this.config.pidFile)) {
248
+ fs.unlinkSync(this.config.pidFile);
249
+ }
250
+ } catch {
251
+ // Ignore cleanup errors
252
+ }
253
+ }
254
+ }
255
+
256
+ /** Create and start a kanboard server with default routes */
257
+ export async function startServer(
258
+ config?: Partial<KanboardConfig>,
259
+ ): Promise<{ server: KanboardServer; port: number; url: string }> {
260
+ const server = new KanboardServer(config);
261
+ const docsRoot = server.getDocsRoot();
262
+
263
+ // Register route modules
264
+ const { registerMilestoneRoutes } = await import("./routes/milestone.js");
265
+ const { registerWorkflowRoutes } = await import("./routes/workflow.js");
266
+
267
+ registerMilestoneRoutes(server, docsRoot);
268
+ registerWorkflowRoutes(server, docsRoot);
269
+
270
+ server.route("POST", "/api/docs/:type/:file/items/:line", async (req, res) => {
271
+ // Placeholder — will be implemented with actual file updating
272
+ res.writeHead(200, { "Content-Type": "application/json" });
273
+ res.end(JSON.stringify({ ok: true }));
274
+ });
275
+
276
+ const { port, url } = await server.start();
277
+ return { server, port, url };
278
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @pi-unipi/kanboard — Milestone Routes
3
+ *
4
+ * Routes for the milestone page and API.
5
+ */
6
+
7
+ import * as path from "node:path";
8
+ import type { KanboardServer } from "../index.js";
9
+ import { ParserRegistry } from "../../parser/index.js";
10
+ import { MilestoneParser } from "../../parser/milestones.js";
11
+ import { renderMilestonePage } from "../../ui/milestone/page.js";
12
+
13
+ /** Register milestone routes on the server */
14
+ export function registerMilestoneRoutes(
15
+ server: KanboardServer,
16
+ docsRoot: string,
17
+ ): void {
18
+ const registry = new ParserRegistry();
19
+ registry.register(new MilestoneParser());
20
+
21
+ const milestonesPath = path.join(docsRoot, "MILESTONES.md");
22
+
23
+ // GET / — Milestone page
24
+ server.route("GET", "/", (_req, res) => {
25
+ const doc = registry.parse(milestonesPath);
26
+ const docs = doc ? [doc] : [];
27
+ const html = renderMilestonePage(docs);
28
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
29
+ res.end(html);
30
+ });
31
+
32
+ // GET /api/milestones — Milestone JSON data
33
+ server.route("GET", "/api/milestones", (_req, res) => {
34
+ const doc = registry.parse(milestonesPath);
35
+ res.writeHead(200, { "Content-Type": "application/json" });
36
+ res.end(JSON.stringify(doc ?? { items: [] }));
37
+ });
38
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @pi-unipi/kanboard — Workflow Routes
3
+ *
4
+ * Routes for the workflow page and API.
5
+ */
6
+
7
+ import type { KanboardServer } from "../index.js";
8
+ import { ParserRegistry, createDefaultRegistry } from "../../parser/index.js";
9
+ import { renderWorkflowPage } from "../../ui/workflow/page.js";
10
+
11
+ /** Register workflow routes on the server */
12
+ export function registerWorkflowRoutes(
13
+ server: KanboardServer,
14
+ docsRoot: string,
15
+ ): void {
16
+ let registry: ParserRegistry | null = null;
17
+
18
+ const getRegistry = async (): Promise<ParserRegistry> => {
19
+ if (!registry) {
20
+ registry = await createDefaultRegistry();
21
+ }
22
+ return registry;
23
+ };
24
+
25
+ // GET /workflow — Workflow page
26
+ server.route("GET", "/workflow", async (_req, res) => {
27
+ const reg = await getRegistry();
28
+ const docs = reg.parseAll(docsRoot);
29
+ const html = renderWorkflowPage(docs);
30
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
31
+ res.end(html);
32
+ });
33
+
34
+ // GET /api/workflow — Workflow JSON data
35
+ server.route("GET", "/api/workflow", async (_req, res) => {
36
+ const reg = await getRegistry();
37
+ const docs = reg.parseAll(docsRoot);
38
+ res.writeHead(200, { "Content-Type": "application/json" });
39
+ res.end(JSON.stringify({ docs }));
40
+ });
41
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * @pi-unipi/kanboard — Kanboard TUI Overlay
3
+ *
4
+ * Two tabs: Tasks list and Kanban Board.
5
+ * Uses pi-tui overlay API (same pattern as MCP add overlay).
6
+ */
7
+
8
+ import {
9
+ Key,
10
+ matchesKey,
11
+ truncateToWidth,
12
+ type TUI,
13
+ visibleWidth,
14
+ } from "@earendil-works/pi-tui";
15
+ import type { Theme, KeybindingsManager } from "@earendil-works/pi-coding-agent";
16
+ import type { ParsedDoc, ParsedItem, ItemStatus } from "../types.js";
17
+ import { createDefaultRegistry } from "../parser/index.js";
18
+
19
+ type Tab = "tasks" | "board";
20
+
21
+ interface KanboardState {
22
+ tab: Tab;
23
+ tasks: ParsedItem[];
24
+ taskIndex: number;
25
+ taskScroll: number;
26
+ /** Board columns: index into tasks array grouped by status */
27
+ boardColumns: {
28
+ todo: number[];
29
+ inProgress: number[];
30
+ done: number[];
31
+ };
32
+ boardCol: number; // 0=todo, 1=inProgress, 2=done
33
+ boardIndex: number[];
34
+ pendingG: boolean;
35
+ docsRoot: string;
36
+ }
37
+
38
+ /** Status icon */
39
+ function statusIcon(status: ItemStatus): string {
40
+ switch (status) {
41
+ case "done":
42
+ return "✓";
43
+ case "in-progress":
44
+ return "◐";
45
+ default:
46
+ return "○";
47
+ }
48
+ }
49
+
50
+ /** Pad string to visible width */
51
+ function padVisible(content: string, targetWidth: number): string {
52
+ const pad = Math.max(0, targetWidth - visibleWidth(content));
53
+ return content + " ".repeat(pad);
54
+ }
55
+
56
+ /**
57
+ * Render the kanboard overlay.
58
+ */
59
+ export function renderKanboardOverlay(params?: {
60
+ docsRoot?: string;
61
+ onComplete?: () => void;
62
+ }) {
63
+ return (
64
+ tui: TUI,
65
+ theme: Theme,
66
+ _kb: KeybindingsManager,
67
+ done: (result: { viewed: boolean } | null) => void,
68
+ ) => {
69
+ const state: KanboardState = {
70
+ tab: "tasks",
71
+ tasks: [],
72
+ taskIndex: 0,
73
+ taskScroll: 0,
74
+ boardColumns: { todo: [], inProgress: [], done: [] },
75
+ boardCol: 0,
76
+ boardIndex: [0, 0, 0],
77
+ pendingG: false,
78
+ docsRoot: params?.docsRoot ?? ".unipi/docs",
79
+ };
80
+
81
+ // Load data
82
+ let loaded = false;
83
+ const ensureLoaded = async () => {
84
+ if (loaded) return;
85
+ const registry = await createDefaultRegistry();
86
+ const docs = registry.parseAll(state.docsRoot);
87
+ state.tasks = docs.flatMap((d) => d.items);
88
+
89
+ // Build board columns
90
+ state.boardColumns = { todo: [], inProgress: [], done: [] };
91
+ state.tasks.forEach((item, idx) => {
92
+ if (item.status === "done") state.boardColumns.done.push(idx);
93
+ else if (item.status === "in-progress") state.boardColumns.inProgress.push(idx);
94
+ else state.boardColumns.todo.push(idx);
95
+ });
96
+
97
+ // Init board indices
98
+ state.boardIndex = [
99
+ 0,
100
+ 0,
101
+ 0,
102
+ ];
103
+
104
+ loaded = true;
105
+ };
106
+
107
+ const render = async () => {
108
+ await ensureLoaded();
109
+
110
+ const width = tui.terminal?.columns ?? 80;
111
+ const height = tui.terminal?.rows ?? 24;
112
+ const lines: string[] = [];
113
+
114
+ // Header
115
+ const header = " 📋 Kanboard ";
116
+ const tabLine =
117
+ " [T]asks | [B]oard ".replace(
118
+ state.tab === "tasks" ? "[T]" : "T",
119
+ state.tab === "tasks" ? "▸T" : " T",
120
+ ).replace(
121
+ state.tab === "board" ? "[B]" : "B",
122
+ state.tab === "board" ? "▸B" : " B",
123
+ );
124
+ lines.push(truncateToWidth(header, width));
125
+ lines.push(truncateToWidth(tabLine, width));
126
+ lines.push("─".repeat(width));
127
+
128
+ if (state.tab === "tasks") {
129
+ renderTasksTab(lines, width, height - 5);
130
+ } else {
131
+ renderBoardTab(lines, width, height - 5);
132
+ }
133
+
134
+ // Footer
135
+ lines.push("─".repeat(width));
136
+ const footer = " j/k: navigate Tab: switch tab q/Esc: close ";
137
+ lines.push(truncateToWidth(footer, width));
138
+
139
+ (tui as unknown as { setContent(lines: string[]): void }).setContent(lines);
140
+ };
141
+
142
+ const renderTasksTab = (lines: string[], width: number, maxLines: number) => {
143
+ if (state.tasks.length === 0) {
144
+ lines.push(truncateToWidth(" No tasks found.", width));
145
+ return;
146
+ }
147
+
148
+ // Ensure index in range
149
+ state.taskIndex = Math.min(state.taskIndex, state.tasks.length - 1);
150
+ state.taskIndex = Math.max(0, state.taskIndex);
151
+
152
+ // Scroll to keep selected visible
153
+ if (state.taskIndex < state.taskScroll) state.taskScroll = state.taskIndex;
154
+ if (state.taskIndex >= state.taskScroll + maxLines) {
155
+ state.taskScroll = state.taskIndex - maxLines + 1;
156
+ }
157
+
158
+ const visible = state.tasks.slice(
159
+ state.taskScroll,
160
+ state.taskScroll + maxLines,
161
+ );
162
+
163
+ for (let i = 0; i < visible.length; i++) {
164
+ const item = visible[i];
165
+ const globalIdx = state.taskScroll + i;
166
+ const selected = globalIdx === state.taskIndex;
167
+ const prefix = selected ? " ▸ " : " ";
168
+ const icon = statusIcon(item.status);
169
+ const source = `[${item.sourceFile}]`;
170
+ const line = `${prefix}${icon} ${item.text} ${source}`;
171
+ lines.push(
172
+ truncateToWidth(
173
+ selected ? `\x1b[7m${padVisible(line, width)}\x1b[0m` : line,
174
+ width,
175
+ ),
176
+ );
177
+ }
178
+ };
179
+
180
+ const renderBoardTab = (lines: string[], width: number, maxLines: number) => {
181
+ const colWidth = Math.floor(width / 3);
182
+ const cols: Array<{ title: string; indices: number[]; colIdx: number }> = [
183
+ { title: "To Do", indices: state.boardColumns.todo, colIdx: 0 },
184
+ { title: "In Progress", indices: state.boardColumns.inProgress, colIdx: 1 },
185
+ { title: "Done", indices: state.boardColumns.done, colIdx: 2 },
186
+ ];
187
+
188
+ // Column headers
189
+ let headerLine = "";
190
+ for (const col of cols) {
191
+ const isActive = col.colIdx === state.boardCol;
192
+ const title = isActive ? `▸ ${col.title}` : ` ${col.title}`;
193
+ headerLine += padVisible(` ${title} (${col.indices.length})`, colWidth);
194
+ }
195
+ lines.push(truncateToWidth(headerLine, width));
196
+ lines.push("─".repeat(width));
197
+
198
+ // Column items
199
+ const maxItems = Math.max(
200
+ ...cols.map((c) => c.indices.length),
201
+ maxLines - 2,
202
+ );
203
+ for (let row = 0; row < Math.min(maxItems, maxLines - 2); row++) {
204
+ let rowLine = "";
205
+ for (const col of cols) {
206
+ const active = col.colIdx === state.boardCol;
207
+ const boardIdx = state.boardIndex[col.colIdx] ?? 0;
208
+ if (row < col.indices.length) {
209
+ const item = state.tasks[col.indices[row]];
210
+ const selected = active && row === boardIdx;
211
+ const icon = statusIcon(item.status);
212
+ const text = truncateToWidth(` ${icon} ${item.text}`, colWidth - 1);
213
+ if (selected) {
214
+ rowLine += `\x1b[7m${padVisible(text, colWidth)}\x1b[0m`;
215
+ } else {
216
+ rowLine += padVisible(text, colWidth);
217
+ }
218
+ } else {
219
+ rowLine += padVisible("", colWidth);
220
+ }
221
+ }
222
+ lines.push(truncateToWidth(rowLine, width));
223
+ }
224
+ };
225
+
226
+ const handleKey = async (key: string) => {
227
+ await ensureLoaded();
228
+
229
+ // Close
230
+ if (matchesKey(key, "q") || matchesKey(key, "escape")) {
231
+ done({ viewed: true });
232
+ return;
233
+ }
234
+
235
+ // Tab switching
236
+ if (matchesKey(key, "tab") || matchesKey(key, "b")) {
237
+ state.tab = state.tab === "tasks" ? "board" : "tasks";
238
+ render();
239
+ return;
240
+ }
241
+
242
+ if (matchesKey(key, "t")) {
243
+ state.tab = "tasks";
244
+ render();
245
+ return;
246
+ }
247
+
248
+ // Navigation
249
+ if (state.tab === "tasks") {
250
+ if (matchesKey(key, "j") || matchesKey(key, "down")) {
251
+ state.taskIndex = Math.min(state.taskIndex + 1, state.tasks.length - 1);
252
+ } else if (matchesKey(key, "k") || matchesKey(key, "up")) {
253
+ state.taskIndex = Math.max(state.taskIndex - 1, 0);
254
+ } else if (matchesKey(key, "g")) {
255
+ if (state.pendingG) {
256
+ state.taskIndex = 0;
257
+ state.pendingG = false;
258
+ } else {
259
+ state.pendingG = true;
260
+ setTimeout(() => {
261
+ state.pendingG = false;
262
+ }, 500);
263
+ }
264
+ } else if (matchesKey(key, "shift+g")) {
265
+ state.taskIndex = state.tasks.length - 1;
266
+ }
267
+ } else {
268
+ // Board navigation
269
+ const cols = [
270
+ state.boardColumns.todo,
271
+ state.boardColumns.inProgress,
272
+ state.boardColumns.done,
273
+ ];
274
+
275
+ if (matchesKey(key, "h") || matchesKey(key, "left")) {
276
+ state.boardCol = Math.max(0, state.boardCol - 1);
277
+ } else if (matchesKey(key, "l") || matchesKey(key, "right")) {
278
+ state.boardCol = Math.min(2, state.boardCol + 1);
279
+ } else if (matchesKey(key, "j") || matchesKey(key, "down")) {
280
+ const col = cols[state.boardCol];
281
+ state.boardIndex[state.boardCol] = Math.min(
282
+ state.boardIndex[state.boardCol] + 1,
283
+ col.length - 1,
284
+ );
285
+ } else if (matchesKey(key, "k") || matchesKey(key, "up")) {
286
+ state.boardIndex[state.boardCol] = Math.max(
287
+ state.boardIndex[state.boardCol] - 1,
288
+ 0,
289
+ );
290
+ }
291
+ }
292
+
293
+ render();
294
+ };
295
+
296
+ // Start
297
+ render();
298
+ (tui as unknown as { on(event: string, handler: (data: string) => void): void }).on("key", handleKey);
299
+ };
300
+ }