@zinn-dev/opencode 0.0.0-stage → 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/LICENSE +9 -0
- package/README.md +24 -2
- package/package.json +62 -3
- package/pkg/commands.ts +89 -0
- package/pkg/components.tsx +221 -0
- package/pkg/data.ts +349 -0
- package/pkg/dialogs.tsx +208 -0
- package/pkg/options.ts +39 -0
- package/pkg/rows.ts +109 -0
- package/pkg/scan.ts +267 -0
- package/pkg/session.ts +175 -0
- package/pkg/sidebar.tsx +543 -0
- package/pkg/theme.ts +51 -0
- package/pkg/tui.tsx +32 -0
- package/pkg/types.ts +66 -0
package/pkg/data.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import type { Ctx, DetailState, Loaded, Relation, TaskDetail, TaskSummary } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
import { appendFileSync } from "node:fs";
|
|
4
|
+
import { createSignal } from "solid-js";
|
|
5
|
+
|
|
6
|
+
type BunProc = Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
7
|
+
|
|
8
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === "object" && value !== null;
|
|
10
|
+
}
|
|
11
|
+
export function asString(value: unknown): string | undefined {
|
|
12
|
+
return typeof value === "string" ? value : undefined;
|
|
13
|
+
}
|
|
14
|
+
export function asStringArray(value: unknown): string[] {
|
|
15
|
+
if (!Array.isArray(value)) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
const out: string[] = [];
|
|
19
|
+
for (const item of value) {
|
|
20
|
+
if (typeof item === "string") {
|
|
21
|
+
out.push(item);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
export function errorText(error: unknown): string {
|
|
27
|
+
if (error instanceof Error) {
|
|
28
|
+
return error.message;
|
|
29
|
+
}
|
|
30
|
+
return String(error);
|
|
31
|
+
}
|
|
32
|
+
export function log(_ctx: Ctx, message: string, extra?: Record<string, unknown>): void {
|
|
33
|
+
const path = process.env.ZINN_INSPECTOR_LOG;
|
|
34
|
+
if (!path) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const line = `${new Date().toISOString()} ${message}${extra ? ` ${JSON.stringify(extra)}` : ""}\n`;
|
|
39
|
+
appendFileSync(path, line);
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
export function zinnErrorCode(stderr: string): string | undefined {
|
|
43
|
+
const trimmed = stderr.trim();
|
|
44
|
+
if (!trimmed) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
49
|
+
const body = isRecord(parsed) ? parsed["error"] : undefined;
|
|
50
|
+
return isRecord(body) ? asString(body["code"]) : undefined;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function zinnErrorMessage(stderr: string): string | undefined {
|
|
56
|
+
const trimmed = stderr.trim();
|
|
57
|
+
if (!trimmed) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
63
|
+
if (isRecord(parsed)) {
|
|
64
|
+
const body = parsed["error"];
|
|
65
|
+
if (isRecord(body)) {
|
|
66
|
+
const message = asString(body["message"]);
|
|
67
|
+
if (message) {
|
|
68
|
+
return message;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
return trimmed.split("\n")[0];
|
|
74
|
+
}
|
|
75
|
+
return trimmed.split("\n")[0];
|
|
76
|
+
}
|
|
77
|
+
export async function runZinn(args: string[]): Promise<Loaded<string>> {
|
|
78
|
+
const cmd = ["zinn", ...args];
|
|
79
|
+
let proc: BunProc;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return { ok: false, error: `cannot run zinn: ${errorText(error)}` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
89
|
+
new Response(proc.stdout).text(),
|
|
90
|
+
new Response(proc.stderr).text(),
|
|
91
|
+
proc.exited,
|
|
92
|
+
]);
|
|
93
|
+
if (exitCode !== 0) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: zinnErrorMessage(stderr) ?? `zinn exited with code ${exitCode}`,
|
|
97
|
+
code: zinnErrorCode(stderr),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { ok: true, value: stdout };
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return { ok: false, error: `zinn read failed: ${errorText(error)}` };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export function parseTaskList(raw: string): Loaded<Map<string, TaskSummary>> {
|
|
106
|
+
let data: unknown;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
data = JSON.parse(raw);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return { ok: false, error: `bad zinn json: ${errorText(error)}` };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!Array.isArray(data)) {
|
|
115
|
+
return { ok: false, error: "unexpected zinn task list shape" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const map = new Map<string, TaskSummary>();
|
|
119
|
+
for (const item of data) {
|
|
120
|
+
if (!isRecord(item)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const key = asString(item["key"]);
|
|
124
|
+
const title = asString(item["title"]);
|
|
125
|
+
if (!key || !title) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
map.set(key, {
|
|
129
|
+
key,
|
|
130
|
+
title,
|
|
131
|
+
status: asString(item["status"]) ?? "",
|
|
132
|
+
column: asString(item["column"]) ?? "",
|
|
133
|
+
priority: asString(item["priority"]) ?? null,
|
|
134
|
+
labels: asStringArray(item["labels"]),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, value: map };
|
|
138
|
+
}
|
|
139
|
+
export const LABEL_HEX = {
|
|
140
|
+
red: "#cd3131",
|
|
141
|
+
green: "#0dbc79",
|
|
142
|
+
yellow: "#e5e510",
|
|
143
|
+
blue: "#2472c8",
|
|
144
|
+
magenta: "#bc3fbc",
|
|
145
|
+
cyan: "#11a8cd",
|
|
146
|
+
white: "#e5e5e5",
|
|
147
|
+
gray: "#666666",
|
|
148
|
+
};
|
|
149
|
+
export type LabelColor = string | null;
|
|
150
|
+
export type LabelColors = ReadonlyMap<string, LabelColor>;
|
|
151
|
+
export const PRIORITY_COLOR: Record<string, string> = {
|
|
152
|
+
urgent: LABEL_HEX["red"],
|
|
153
|
+
high: LABEL_HEX["yellow"],
|
|
154
|
+
medium: LABEL_HEX["blue"],
|
|
155
|
+
low: LABEL_HEX["gray"],
|
|
156
|
+
};
|
|
157
|
+
export const CHIP_PRIORITY = "urgent";
|
|
158
|
+
export const CHIP_PAD = 2;
|
|
159
|
+
export const CONTEXT_SEPARATOR = " | ";
|
|
160
|
+
export const LABEL_SEPARATOR = ", ";
|
|
161
|
+
export const [labelColors, setLabelColors] = createSignal<LabelColors>(new Map());
|
|
162
|
+
export function parseLabelColors(raw: string): LabelColors {
|
|
163
|
+
let data: unknown;
|
|
164
|
+
try {
|
|
165
|
+
data = JSON.parse(raw);
|
|
166
|
+
} catch {
|
|
167
|
+
return new Map();
|
|
168
|
+
}
|
|
169
|
+
if (!Array.isArray(data)) {
|
|
170
|
+
return new Map();
|
|
171
|
+
}
|
|
172
|
+
const out = new Map<string, LabelColor>();
|
|
173
|
+
for (const item of data) {
|
|
174
|
+
if (!isRecord(item)) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const name = asString(item["name"]);
|
|
178
|
+
if (!name) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
out.set(
|
|
182
|
+
name,
|
|
183
|
+
(LABEL_HEX as Record<string, string | undefined>)[asString(item["color"]) ?? ""] ?? null,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
export function parseRelations(value: unknown): Relation[] {
|
|
189
|
+
if (!Array.isArray(value)) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const out: Relation[] = [];
|
|
194
|
+
|
|
195
|
+
for (const item of value) {
|
|
196
|
+
if (!isRecord(item)) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const task = item["task"];
|
|
200
|
+
if (!isRecord(task)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const key = asString(task["key"]);
|
|
204
|
+
if (!key) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
out.push({
|
|
208
|
+
type: asString(item["type"]) ?? "related",
|
|
209
|
+
direction: asString(item["direction"]) ?? "outgoing",
|
|
210
|
+
task: {
|
|
211
|
+
key,
|
|
212
|
+
title: asString(task["title"]) ?? "",
|
|
213
|
+
status: asString(task["status"]) ?? "",
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
export function parseTaskDetail(raw: string): Loaded<TaskDetail> {
|
|
220
|
+
let data: unknown;
|
|
221
|
+
try {
|
|
222
|
+
data = JSON.parse(raw);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return { ok: false, error: `bad zinn json: ${errorText(error)}` };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!isRecord(data)) {
|
|
228
|
+
return { ok: false, error: "unexpected zinn task view shape" };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const key = asString(data["key"]);
|
|
232
|
+
const title = asString(data["title"]);
|
|
233
|
+
if (!key || !title) {
|
|
234
|
+
return { ok: false, error: "zinn task view had no key/title" };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const comments = Array.isArray(data["comments"]) ? data["comments"].length : 0;
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
value: {
|
|
241
|
+
key,
|
|
242
|
+
title,
|
|
243
|
+
status: asString(data["status"]) ?? "",
|
|
244
|
+
column: asString(data["column"]) ?? "",
|
|
245
|
+
priority: asString(data["priority"]) ?? null,
|
|
246
|
+
labels: asStringArray(data["labels"]),
|
|
247
|
+
relations: parseRelations(data["relations"]),
|
|
248
|
+
comments,
|
|
249
|
+
description: asString(data["description"]) ?? "",
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
export let keyMapCache: Promise<Loaded<Map<string, TaskSummary>>> | undefined;
|
|
254
|
+
export let keyMapInflight: Promise<Loaded<Map<string, TaskSummary>>> | undefined;
|
|
255
|
+
export function loadKeyMap(): Promise<Loaded<Map<string, TaskSummary>>> {
|
|
256
|
+
if (!keyMapCache) {
|
|
257
|
+
void loadLabelColors();
|
|
258
|
+
const pending = runZinn(["task", "list", "--json"]).then((result) => {
|
|
259
|
+
if (!result.ok) {
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
return parseTaskList(result.value);
|
|
263
|
+
});
|
|
264
|
+
keyMapCache = pending;
|
|
265
|
+
keyMapInflight = pending;
|
|
266
|
+
void pending.then(() => {
|
|
267
|
+
if (keyMapInflight === pending) {
|
|
268
|
+
keyMapInflight = undefined;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return keyMapCache;
|
|
273
|
+
}
|
|
274
|
+
export const LABEL_TTL_MS = 5 * 60 * 1000;
|
|
275
|
+
export let labelColorsCache: Promise<void> | undefined;
|
|
276
|
+
export let labelColorsAt = 0;
|
|
277
|
+
export let labelColorsInflight = false;
|
|
278
|
+
export function loadLabelColors(force = false): Promise<void> {
|
|
279
|
+
if (labelColorsInflight && labelColorsCache !== undefined) {
|
|
280
|
+
return labelColorsCache;
|
|
281
|
+
}
|
|
282
|
+
if (
|
|
283
|
+
!force &&
|
|
284
|
+
labelColorsCache !== undefined &&
|
|
285
|
+
labelColorsAt !== 0 &&
|
|
286
|
+
Date.now() - labelColorsAt <= LABEL_TTL_MS
|
|
287
|
+
) {
|
|
288
|
+
return labelColorsCache;
|
|
289
|
+
}
|
|
290
|
+
labelColorsInflight = true;
|
|
291
|
+
const pending = runZinn(["label", "list", "--json"])
|
|
292
|
+
.then((result) => {
|
|
293
|
+
if (result.ok) {
|
|
294
|
+
setLabelColors(parseLabelColors(result.value));
|
|
295
|
+
labelColorsAt = Date.now();
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
.finally(() => {
|
|
299
|
+
labelColorsInflight = false;
|
|
300
|
+
});
|
|
301
|
+
labelColorsCache = pending;
|
|
302
|
+
return pending;
|
|
303
|
+
}
|
|
304
|
+
export function refreshKeyMap(forceLabels = false): Promise<Loaded<Map<string, TaskSummary>>> {
|
|
305
|
+
void loadLabelColors(forceLabels);
|
|
306
|
+
if (keyMapInflight) {
|
|
307
|
+
keyMapCache = keyMapInflight;
|
|
308
|
+
return keyMapInflight;
|
|
309
|
+
}
|
|
310
|
+
keyMapCache = undefined;
|
|
311
|
+
return loadKeyMap();
|
|
312
|
+
}
|
|
313
|
+
export const BOARD_TTL_MS = 60 * 1000;
|
|
314
|
+
export function shouldReadBoard(input: {
|
|
315
|
+
force: boolean;
|
|
316
|
+
zinnTotal: number;
|
|
317
|
+
lastReadZinn: number;
|
|
318
|
+
msSinceRead: number;
|
|
319
|
+
ttl: number;
|
|
320
|
+
}): boolean {
|
|
321
|
+
if (input.force) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
if (input.zinnTotal !== input.lastReadZinn) {
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
return input.msSinceRead > input.ttl;
|
|
328
|
+
}
|
|
329
|
+
export const flaggedUnknown = new Map<string, Set<string>>();
|
|
330
|
+
export function flaggedFor(sessionID: string): Set<string> {
|
|
331
|
+
let set = flaggedUnknown.get(sessionID);
|
|
332
|
+
if (!set) {
|
|
333
|
+
set = new Set<string>();
|
|
334
|
+
flaggedUnknown.set(sessionID, set);
|
|
335
|
+
}
|
|
336
|
+
return set;
|
|
337
|
+
}
|
|
338
|
+
export function loadDetail(key: string): Promise<DetailState> {
|
|
339
|
+
return runZinn(["task", "view", key, "--json"]).then((result) => {
|
|
340
|
+
if (!result.ok) {
|
|
341
|
+
return { kind: "error", error: result.error };
|
|
342
|
+
}
|
|
343
|
+
const parsed = parseTaskDetail(result.value);
|
|
344
|
+
if (!parsed.ok) {
|
|
345
|
+
return { kind: "error", error: parsed.error };
|
|
346
|
+
}
|
|
347
|
+
return { kind: "ready", detail: parsed.value };
|
|
348
|
+
});
|
|
349
|
+
}
|
package/pkg/dialogs.tsx
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { JSX } from "@opentui/solid";
|
|
3
|
+
import type { Ctx, DetailState, Relation, TaskSummary } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
|
|
6
|
+
|
|
7
|
+
import { labelColors, loadDetail, log, PRIORITY_COLOR } from "./data.ts";
|
|
8
|
+
import { firstParagraph, truncate } from "./rows.ts";
|
|
9
|
+
import { inspectorTheme } from "./theme.ts";
|
|
10
|
+
|
|
11
|
+
export function RelationLine(props: { ctx: Ctx; relation: Relation }): JSX.Element {
|
|
12
|
+
const theme = () => inspectorTheme(props.ctx);
|
|
13
|
+
const arrow = () => (props.relation.direction === "incoming" ? "←" : "→");
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<text fg={theme().textMuted}>
|
|
17
|
+
{`${arrow()} ${props.relation.type} ${props.relation.task.key} ${truncate(props.relation.task.title, 40)}`}
|
|
18
|
+
</text>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Detail(props: {
|
|
23
|
+
ctx: Ctx;
|
|
24
|
+
taskKey: string;
|
|
25
|
+
unknown: boolean;
|
|
26
|
+
mentions?: number;
|
|
27
|
+
}): JSX.Element {
|
|
28
|
+
const theme = () => inspectorTheme(props.ctx);
|
|
29
|
+
const [state, setState] = createSignal<DetailState>({ kind: "loading" });
|
|
30
|
+
let alive = true;
|
|
31
|
+
const mentionLine = () => {
|
|
32
|
+
const count = props.mentions;
|
|
33
|
+
if (count === undefined) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return `mentioned ${count} ${count === 1 ? "time" : "times"} in this chat`;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
onMount(() => {
|
|
41
|
+
const started = Date.now();
|
|
42
|
+
log(props.ctx, "detail loading", { key: props.taskKey });
|
|
43
|
+
onCleanup(() => {
|
|
44
|
+
alive = false;
|
|
45
|
+
});
|
|
46
|
+
void loadDetail(props.taskKey).then((result) => {
|
|
47
|
+
log(props.ctx, "detail settled", {
|
|
48
|
+
key: props.taskKey,
|
|
49
|
+
kind: result.kind,
|
|
50
|
+
ms: Date.now() - started,
|
|
51
|
+
});
|
|
52
|
+
if (!alive) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
setState(result);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const body = createMemo<JSX.Element>(() => {
|
|
60
|
+
const current = state();
|
|
61
|
+
if (current.kind === "loading") {
|
|
62
|
+
return <text fg={theme().textMuted}>Loading {props.taskKey}…</text>;
|
|
63
|
+
}
|
|
64
|
+
if (current.kind === "error") {
|
|
65
|
+
return (
|
|
66
|
+
<box flexDirection="column" gap={1}>
|
|
67
|
+
<Show when={props.unknown}>
|
|
68
|
+
<text fg={theme().warning}>
|
|
69
|
+
{props.taskKey} is not in this store. Check the task key.
|
|
70
|
+
</text>
|
|
71
|
+
</Show>
|
|
72
|
+
<text fg={theme().error}>{current.error}</text>
|
|
73
|
+
</box>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const detail = current.detail;
|
|
78
|
+
return (
|
|
79
|
+
<box flexDirection="column" gap={1}>
|
|
80
|
+
<text fg={theme().text}>
|
|
81
|
+
<b>{detail.title}</b>
|
|
82
|
+
</text>
|
|
83
|
+
|
|
84
|
+
<text wrapMode="none" fg={theme().textMuted}>
|
|
85
|
+
<span style={{ fg: theme().text }}>{detail.key}</span>
|
|
86
|
+
<span>{` · ${detail.column} · ${detail.status} · priority `}</span>
|
|
87
|
+
<Show when={detail.priority} fallback={<span>none</span>}>
|
|
88
|
+
<span style={{ fg: PRIORITY_COLOR[detail.priority ?? ""] ?? theme().textMuted }}>
|
|
89
|
+
{detail.priority}
|
|
90
|
+
</span>
|
|
91
|
+
</Show>
|
|
92
|
+
</text>
|
|
93
|
+
<Show
|
|
94
|
+
when={detail.labels.length > 0}
|
|
95
|
+
fallback={<text fg={theme().textMuted}>labels: none</text>}
|
|
96
|
+
>
|
|
97
|
+
<text wrapMode="none" fg={theme().textMuted}>
|
|
98
|
+
<span>{"labels: "}</span>
|
|
99
|
+
<For each={detail.labels}>
|
|
100
|
+
{(label, index) => (
|
|
101
|
+
<>
|
|
102
|
+
<Show when={index() > 0}>
|
|
103
|
+
<span>{", "}</span>
|
|
104
|
+
</Show>
|
|
105
|
+
<span style={{ fg: labelColors().get(label) ?? theme().textMuted }}>{label}</span>
|
|
106
|
+
</>
|
|
107
|
+
)}
|
|
108
|
+
</For>
|
|
109
|
+
</text>
|
|
110
|
+
</Show>
|
|
111
|
+
<Show when={detail.relations.length > 0}>
|
|
112
|
+
<box flexDirection="column">
|
|
113
|
+
<text fg={theme().textMuted}>relations:</text>
|
|
114
|
+
<For each={detail.relations}>
|
|
115
|
+
{(relation) => <RelationLine ctx={props.ctx} relation={relation} />}
|
|
116
|
+
</For>
|
|
117
|
+
</box>
|
|
118
|
+
</Show>
|
|
119
|
+
<text fg={theme().textMuted}>{`comments: ${detail.comments}`}</text>
|
|
120
|
+
<Show when={mentionLine()}>
|
|
121
|
+
<text fg={theme().textMuted}>{mentionLine()}</text>
|
|
122
|
+
</Show>
|
|
123
|
+
<Show when={detail.description.length > 0}>
|
|
124
|
+
<text fg={theme().text}>{firstParagraph(detail.description)}</text>
|
|
125
|
+
</Show>
|
|
126
|
+
<text fg={theme().textMuted}>esc closes</text>
|
|
127
|
+
</box>
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<box flexDirection="column" paddingLeft={2} paddingRight={2}>
|
|
133
|
+
{body()}
|
|
134
|
+
</box>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type PickerOption = {
|
|
139
|
+
title: string;
|
|
140
|
+
description: string;
|
|
141
|
+
value: string;
|
|
142
|
+
};
|
|
143
|
+
export function pickerOptions(
|
|
144
|
+
keys: readonly string[],
|
|
145
|
+
map: Map<string, TaskSummary> | undefined,
|
|
146
|
+
includeUnknown: boolean,
|
|
147
|
+
): PickerOption[] {
|
|
148
|
+
const out: PickerOption[] = [];
|
|
149
|
+
for (const key of keys) {
|
|
150
|
+
const summary = map?.get(key);
|
|
151
|
+
|
|
152
|
+
if (!summary) {
|
|
153
|
+
if (!includeUnknown) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
out.push({ title: key, description: "not in store", value: key });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const meta = [summary.column, summary.status, summary.priority].filter(
|
|
162
|
+
(part): part is string => typeof part === "string" && part !== "none" && part.length > 0,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
out.push({
|
|
166
|
+
title: `${key} · ${truncate(summary.title, 64)}`,
|
|
167
|
+
description: meta.length > 0 ? meta.join(" · ") : "·",
|
|
168
|
+
value: key,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
export function openDetail(ctx: Ctx, key: string, unknown: boolean, mentions?: number): void {
|
|
174
|
+
log(ctx, "row clicked, opening dialog", { key, unknown });
|
|
175
|
+
ctx.ui.dialog.show(
|
|
176
|
+
() => <Detail ctx={ctx} taskKey={key} unknown={unknown} mentions={mentions} />,
|
|
177
|
+
() => log(ctx, "dialog closed", { key }),
|
|
178
|
+
);
|
|
179
|
+
ctx.ui.dialog.set({ size: "large" });
|
|
180
|
+
}
|
|
181
|
+
export function openPicker(
|
|
182
|
+
ctx: Ctx,
|
|
183
|
+
title: string,
|
|
184
|
+
keys: readonly string[],
|
|
185
|
+
map: Map<string, TaskSummary> | undefined,
|
|
186
|
+
includeUnknown: boolean,
|
|
187
|
+
mentions?: ReadonlyMap<string, number>,
|
|
188
|
+
): void {
|
|
189
|
+
const options = pickerOptions(keys, map, includeUnknown);
|
|
190
|
+
|
|
191
|
+
if (options.length === 0) {
|
|
192
|
+
ctx.ui.toast.show({
|
|
193
|
+
variant: "warning",
|
|
194
|
+
title: "Zinn",
|
|
195
|
+
message: "nothing to pick: no tasks in the store",
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
log(ctx, "picker opened", { title, options: options.length });
|
|
201
|
+
|
|
202
|
+
void ctx.ui.dialog.select({ title, placeholder: "search tasks", options }).then((value) => {
|
|
203
|
+
if (value === undefined) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
openDetail(ctx, value, includeUnknown, mentions?.get(value));
|
|
207
|
+
});
|
|
208
|
+
}
|
package/pkg/options.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const SECTION_ROWS = 5;
|
|
2
|
+
// Reserve two columns for status glyphs that terminals can render wide.
|
|
3
|
+
export const MARKER_COLUMNS = 2;
|
|
4
|
+
export const PRIORITY_COLUMNS = 1;
|
|
5
|
+
export const RAIL_COLUMNS = 1;
|
|
6
|
+
export const SESSION_GLYPH = "⧗";
|
|
7
|
+
export const RUNNING_FRAMES = ["■", "□", "▪", "▫"];
|
|
8
|
+
export const RUNNING_INTERVAL = 300;
|
|
9
|
+
export const SCROLLBAR_COLUMNS = 1;
|
|
10
|
+
// Sidebar width minus horizontal padding and its scrollbar.
|
|
11
|
+
export const SLOT_COLUMNS = 42 - 2 - 2 - SCROLLBAR_COLUMNS;
|
|
12
|
+
export const PRIORITY_LETTER: Record<string, string | undefined> = {
|
|
13
|
+
urgent: "U",
|
|
14
|
+
high: "H",
|
|
15
|
+
medium: "M",
|
|
16
|
+
low: "L",
|
|
17
|
+
};
|
|
18
|
+
export function priorityLetter(priority: string | null): string {
|
|
19
|
+
if (!priority) {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
return PRIORITY_LETTER[priority] ?? "";
|
|
23
|
+
}
|
|
24
|
+
export type CurrentMark = "off" | "glyph";
|
|
25
|
+
export type InspectorOptions = {
|
|
26
|
+
rows: 1 | 2;
|
|
27
|
+
current: CurrentMark;
|
|
28
|
+
};
|
|
29
|
+
export const DEFAULT_OPTIONS: InspectorOptions = { rows: 1, current: "off" };
|
|
30
|
+
export function readCurrentMark(value: unknown): CurrentMark {
|
|
31
|
+
return value === "glyph" ? "glyph" : "off";
|
|
32
|
+
}
|
|
33
|
+
export function readOptions(options: unknown): InspectorOptions {
|
|
34
|
+
if (typeof options !== "object" || options === null) {
|
|
35
|
+
return DEFAULT_OPTIONS;
|
|
36
|
+
}
|
|
37
|
+
const bag = options as Record<string, unknown>;
|
|
38
|
+
return { rows: bag["rows"] === 2 ? 2 : 1, current: readCurrentMark(bag["current"]) };
|
|
39
|
+
}
|
package/pkg/rows.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export function flatten(text: string): string {
|
|
2
|
+
return text.replace(/\s+/g, " ").trim();
|
|
3
|
+
}
|
|
4
|
+
export function truncate(text: string, max: number): string {
|
|
5
|
+
const flat = flatten(text);
|
|
6
|
+
if (flat.length <= max) {
|
|
7
|
+
return flat;
|
|
8
|
+
}
|
|
9
|
+
return `${flat.slice(0, Math.max(0, max - 1))}…`;
|
|
10
|
+
}
|
|
11
|
+
export function firstParagraph(text: string): string {
|
|
12
|
+
const block = text.split(/\n\s*\n/)[0] ?? "";
|
|
13
|
+
return truncate(block, 500);
|
|
14
|
+
}
|
|
15
|
+
export const COLUMN_GLYPH: Record<string, string | undefined> = {
|
|
16
|
+
Done: "✔",
|
|
17
|
+
Blocked: "✖",
|
|
18
|
+
"In Progress": "▸",
|
|
19
|
+
};
|
|
20
|
+
export function columnGlyph(column: string): string {
|
|
21
|
+
return COLUMN_GLYPH[column] ?? "";
|
|
22
|
+
}
|
|
23
|
+
export type RowTone = "normal" | "done" | "created" | "blocked" | "warning";
|
|
24
|
+
export type DisplayRow = {
|
|
25
|
+
key: string;
|
|
26
|
+
title: string;
|
|
27
|
+
glyph: string;
|
|
28
|
+
tone: RowTone;
|
|
29
|
+
unknown: boolean;
|
|
30
|
+
created: boolean;
|
|
31
|
+
mine: boolean;
|
|
32
|
+
priority: string | null;
|
|
33
|
+
labels: string[];
|
|
34
|
+
column: string;
|
|
35
|
+
};
|
|
36
|
+
export function sameDisplayRow(a: DisplayRow, b: DisplayRow): boolean {
|
|
37
|
+
if (a === b) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
a.key === b.key &&
|
|
43
|
+
a.title === b.title &&
|
|
44
|
+
a.priority === b.priority &&
|
|
45
|
+
a.column === b.column &&
|
|
46
|
+
a.glyph === b.glyph &&
|
|
47
|
+
a.tone === b.tone &&
|
|
48
|
+
a.unknown === b.unknown &&
|
|
49
|
+
a.created === b.created &&
|
|
50
|
+
a.mine === b.mine &&
|
|
51
|
+
sameLabels(a.labels, b.labels)
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
export function sameLabels(a: readonly string[], b: readonly string[]): boolean {
|
|
55
|
+
if (a === b) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (a.length !== b.length) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < a.length; i++) {
|
|
64
|
+
if (a[i] !== b[i]) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
// Solid keys rows by object identity. Retain unchanged row objects.
|
|
72
|
+
export function stableRows(
|
|
73
|
+
cache: Map<string, DisplayRow>,
|
|
74
|
+
candidates: readonly DisplayRow[],
|
|
75
|
+
): Array<DisplayRow> {
|
|
76
|
+
const out: Array<DisplayRow> = [];
|
|
77
|
+
|
|
78
|
+
for (const candidate of candidates) {
|
|
79
|
+
const previous = cache.get(candidate.key);
|
|
80
|
+
const row =
|
|
81
|
+
previous !== undefined && sameDisplayRow(previous, candidate) ? previous : candidate;
|
|
82
|
+
cache.set(candidate.key, row);
|
|
83
|
+
out.push(row);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const keep = new Set(candidates.map((candidate) => candidate.key));
|
|
87
|
+
for (const key of [...cache.keys()]) {
|
|
88
|
+
if (!keep.has(key)) {
|
|
89
|
+
cache.delete(key);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
export function rowTone(column: string, unknown: boolean, created: boolean): RowTone {
|
|
96
|
+
if (unknown) {
|
|
97
|
+
return "warning";
|
|
98
|
+
}
|
|
99
|
+
if (created) {
|
|
100
|
+
return "created";
|
|
101
|
+
}
|
|
102
|
+
if (column === "Done") {
|
|
103
|
+
return "done";
|
|
104
|
+
}
|
|
105
|
+
if (column === "Blocked") {
|
|
106
|
+
return "blocked";
|
|
107
|
+
}
|
|
108
|
+
return "normal";
|
|
109
|
+
}
|