@pi-unipi/kanboard 2.0.8 → 2.0.10
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/commands.ts +84 -0
- package/package.json +10 -13
- package/parser/index.ts +121 -0
- package/parser/milestones.ts +110 -0
- package/parser/plans.ts +133 -0
- package/parser/remaining.ts +386 -0
- package/parser/specs.ts +105 -0
- package/server/index.ts +278 -0
- package/server/routes/milestone.ts +38 -0
- package/server/routes/workflow.ts +41 -0
- package/tui/kanboard-overlay.ts +300 -0
- package/types.ts +67 -0
package/server/index.ts
ADDED
|
@@ -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
|
+
}
|