@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/sidebar.tsx
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { JSX } from "@opentui/solid";
|
|
3
|
+
import type { PanelSnapshot } from "./commands.ts";
|
|
4
|
+
import type { InspectorOptions } from "./options.ts";
|
|
5
|
+
import type { DisplayRow } from "./rows.ts";
|
|
6
|
+
import type { ScanCache } from "./scan.ts";
|
|
7
|
+
import type { Ctx, Loaded, Scan, TaskSummary } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
For,
|
|
11
|
+
Show,
|
|
12
|
+
createEffect,
|
|
13
|
+
createMemo,
|
|
14
|
+
createSignal,
|
|
15
|
+
onCleanup,
|
|
16
|
+
onMount,
|
|
17
|
+
untrack,
|
|
18
|
+
} from "solid-js";
|
|
19
|
+
|
|
20
|
+
import { clearPanel, publishPanel } from "./commands.ts";
|
|
21
|
+
import { Overflow, Row } from "./components.tsx";
|
|
22
|
+
import {
|
|
23
|
+
BOARD_TTL_MS,
|
|
24
|
+
errorText,
|
|
25
|
+
flaggedFor,
|
|
26
|
+
loadKeyMap,
|
|
27
|
+
log,
|
|
28
|
+
refreshKeyMap,
|
|
29
|
+
shouldReadBoard,
|
|
30
|
+
} from "./data.ts";
|
|
31
|
+
import { openDetail, openPicker } from "./dialogs.tsx";
|
|
32
|
+
import {
|
|
33
|
+
MARKER_COLUMNS,
|
|
34
|
+
PRIORITY_COLUMNS,
|
|
35
|
+
RAIL_COLUMNS,
|
|
36
|
+
RUNNING_FRAMES,
|
|
37
|
+
RUNNING_INTERVAL,
|
|
38
|
+
SECTION_ROWS,
|
|
39
|
+
SLOT_COLUMNS,
|
|
40
|
+
} from "./options.ts";
|
|
41
|
+
import { columnGlyph, rowTone, stableRows, truncate } from "./rows.ts";
|
|
42
|
+
import { mentionCounts, newestIdleID, scanSession } from "./scan.ts";
|
|
43
|
+
import {
|
|
44
|
+
PROBE_CONCURRENCY,
|
|
45
|
+
activityProbesSupported,
|
|
46
|
+
disableActivityProbes,
|
|
47
|
+
probeAnySession,
|
|
48
|
+
probeSessions,
|
|
49
|
+
runPool,
|
|
50
|
+
sessionRunning,
|
|
51
|
+
} from "./session.ts";
|
|
52
|
+
import { inspectorTheme } from "./theme.ts";
|
|
53
|
+
|
|
54
|
+
export function Sidebar(props: {
|
|
55
|
+
ctx: Ctx;
|
|
56
|
+
sessionID: string;
|
|
57
|
+
options: InspectorOptions;
|
|
58
|
+
}): JSX.Element {
|
|
59
|
+
const theme = () => inspectorTheme(props.ctx);
|
|
60
|
+
const [map, setMap] = createSignal<Loaded<Map<string, TaskSummary>>>({
|
|
61
|
+
ok: false,
|
|
62
|
+
error: "Zinn unavailable",
|
|
63
|
+
});
|
|
64
|
+
const [loading, setLoading] = createSignal(true);
|
|
65
|
+
const [boardSeq, setBoardSeq] = createSignal(0);
|
|
66
|
+
let alive = true;
|
|
67
|
+
let probeCycle = 0;
|
|
68
|
+
const [baselineReady, setBaselineReady] = createSignal(false);
|
|
69
|
+
let lastIdleID: string | undefined;
|
|
70
|
+
let lastBoardReadAt = 0;
|
|
71
|
+
let lastBoardReadZinn = 0;
|
|
72
|
+
const apply = (result: Loaded<Map<string, TaskSummary>>, reason: string) => {
|
|
73
|
+
if (!alive) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (result.ok) {
|
|
78
|
+
log(props.ctx, "board keys loaded", { reason, keys: result.value.size });
|
|
79
|
+
} else {
|
|
80
|
+
log(props.ctx, "board keys failed", { reason, error: result.error });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setMap(result);
|
|
84
|
+
setLoading(false);
|
|
85
|
+
setBoardSeq((n) => n + 1);
|
|
86
|
+
if (result.ok) {
|
|
87
|
+
lastBoardReadAt = Date.now();
|
|
88
|
+
lastBoardReadZinn = untrack(() => scan().zinnCommands);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
// Merge overlapping refresh requests into one read.
|
|
92
|
+
let refreshInflight: Promise<void> | undefined;
|
|
93
|
+
const refresh = (reason: string, showLoading: boolean, forceLabels = false): Promise<void> => {
|
|
94
|
+
if (!alive) {
|
|
95
|
+
return Promise.resolve();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (refreshInflight !== undefined) {
|
|
99
|
+
return refreshInflight;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const run = (async () => {
|
|
103
|
+
if (showLoading) {
|
|
104
|
+
setLoading(true);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await props.ctx.data.session.message.sync(props.sessionID);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
log(props.ctx, "message sync failed", { reason, error: errorText(error) });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!alive) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const zinnTotal = untrack(() => scan().zinnCommands);
|
|
118
|
+
const msSinceRead = Date.now() - lastBoardReadAt;
|
|
119
|
+
const force = reason !== "turn ended";
|
|
120
|
+
if (
|
|
121
|
+
!shouldReadBoard({
|
|
122
|
+
force,
|
|
123
|
+
zinnTotal,
|
|
124
|
+
lastReadZinn: lastBoardReadZinn,
|
|
125
|
+
msSinceRead,
|
|
126
|
+
ttl: BOARD_TTL_MS,
|
|
127
|
+
})
|
|
128
|
+
) {
|
|
129
|
+
log(props.ctx, "board read skipped", {
|
|
130
|
+
reason,
|
|
131
|
+
zinnTotal,
|
|
132
|
+
lastReadZinn: lastBoardReadZinn,
|
|
133
|
+
msSinceRead,
|
|
134
|
+
});
|
|
135
|
+
if (showLoading) {
|
|
136
|
+
setLoading(false);
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const result = await refreshKeyMap(forceLabels);
|
|
141
|
+
apply(result, reason);
|
|
142
|
+
})().finally(() => {
|
|
143
|
+
if (refreshInflight === run) {
|
|
144
|
+
refreshInflight = undefined;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
refreshInflight = run;
|
|
148
|
+
return run;
|
|
149
|
+
};
|
|
150
|
+
const reload = () => {
|
|
151
|
+
void refresh("header click", true, true);
|
|
152
|
+
};
|
|
153
|
+
onMount(() => {
|
|
154
|
+
log(props.ctx, "sidebar mounted", { session: props.sessionID });
|
|
155
|
+
onCleanup(() => {
|
|
156
|
+
alive = false;
|
|
157
|
+
probeCycle += 1;
|
|
158
|
+
clearPanel(published);
|
|
159
|
+
});
|
|
160
|
+
void (async () => {
|
|
161
|
+
try {
|
|
162
|
+
await props.ctx.data.session.message.sync(props.sessionID);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
log(props.ctx, "message sync failed", { reason: "mount", error: errorText(error) });
|
|
165
|
+
}
|
|
166
|
+
if (!alive) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
lastIdleID = newestIdleID(props.ctx.data.session.message.list(props.sessionID));
|
|
170
|
+
setBaselineReady(true);
|
|
171
|
+
const result = await loadKeyMap();
|
|
172
|
+
apply(result, "mount");
|
|
173
|
+
})();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
createEffect(() => {
|
|
177
|
+
if (!baselineReady()) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const newest = newestIdleID(props.ctx.data.session.message.list(props.sessionID));
|
|
181
|
+
if (newest === undefined || newest === lastIdleID) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
lastIdleID = newest;
|
|
185
|
+
void refresh("turn ended", false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const running = () => sessionRunning(props.ctx, props.sessionID);
|
|
189
|
+
const [frame, setFrame] = createSignal(0);
|
|
190
|
+
|
|
191
|
+
createEffect(() => {
|
|
192
|
+
void props.ctx.data.session.message.list(props.sessionID).length;
|
|
193
|
+
if (props.options.current !== "glyph" || !running()) {
|
|
194
|
+
setFrame(0);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const timer = setInterval(() => {
|
|
199
|
+
if (!running()) {
|
|
200
|
+
clearInterval(timer);
|
|
201
|
+
setFrame(0);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
setFrame((current) => (current + 1) % RUNNING_FRAMES.length);
|
|
205
|
+
}, RUNNING_INTERVAL);
|
|
206
|
+
onCleanup(() => clearInterval(timer));
|
|
207
|
+
});
|
|
208
|
+
const scanCache: ScanCache = { byID: new Map(), computes: 0 };
|
|
209
|
+
const scan = createMemo<Scan>(() => {
|
|
210
|
+
const loaded = map();
|
|
211
|
+
if (!loaded.ok) {
|
|
212
|
+
return {
|
|
213
|
+
known: [],
|
|
214
|
+
unknown: [],
|
|
215
|
+
tokens: 0,
|
|
216
|
+
textParts: 0,
|
|
217
|
+
streamingParts: 0,
|
|
218
|
+
zinnCommands: 0,
|
|
219
|
+
ms: 0,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const result = scanSession(props.ctx, props.sessionID, loaded.value, scanCache);
|
|
223
|
+
const flagged = flaggedFor(props.sessionID);
|
|
224
|
+
for (const mention of result.unknown) {
|
|
225
|
+
flagged.add(mention.key);
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
});
|
|
229
|
+
let lastScanSig = "";
|
|
230
|
+
createEffect(() => {
|
|
231
|
+
const result = scan();
|
|
232
|
+
const sig = `${result.tokens}|${result.textParts}|${result.streamingParts}|${result.known.map((m) => m.key).join(",")}`;
|
|
233
|
+
if (sig === lastScanSig) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
lastScanSig = sig;
|
|
238
|
+
log(props.ctx, "scan", {
|
|
239
|
+
tokens: result.tokens,
|
|
240
|
+
known: result.known.length,
|
|
241
|
+
unknown: result.unknown.length,
|
|
242
|
+
unknownKeys: result.unknown.map((m) => m.key),
|
|
243
|
+
textParts: result.textParts,
|
|
244
|
+
streamingParts: result.streamingParts,
|
|
245
|
+
ms: result.ms,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
const createdKeys = createMemo<ReadonlySet<string>>(() => {
|
|
249
|
+
const loaded = map();
|
|
250
|
+
if (!loaded.ok) {
|
|
251
|
+
return new Set<string>();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const flagged = flaggedFor(props.sessionID);
|
|
255
|
+
const out = new Set<string>();
|
|
256
|
+
|
|
257
|
+
for (const key of flagged) {
|
|
258
|
+
if (loaded.value.has(key)) {
|
|
259
|
+
out.add(key);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const [mineKeyMap, setMineKeyMap] = createSignal<ReadonlyMap<string, boolean>>(new Map());
|
|
266
|
+
let lastProbeSig = "";
|
|
267
|
+
let lastSessionsSig = "";
|
|
268
|
+
|
|
269
|
+
createEffect(() => {
|
|
270
|
+
if (!activityProbesSupported) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (props.options.current === "off") {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const sessions = probeSessions(props.ctx, props.sessionID);
|
|
277
|
+
const sessionsSig = sessions.join(",");
|
|
278
|
+
if (sessionsSig !== lastSessionsSig) {
|
|
279
|
+
lastSessionsSig = sessionsSig;
|
|
280
|
+
log(props.ctx, "current-session probe set", { sessions });
|
|
281
|
+
}
|
|
282
|
+
const keys = visibleKnown().map((row) => row.key);
|
|
283
|
+
const sig = `${sessionsSig}|${boardSeq()}|${keys.join(",")}`;
|
|
284
|
+
if (sig === lastProbeSig) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
lastProbeSig = sig;
|
|
288
|
+
// Activity matches persist for this session family. Recheck only unmarked tasks.
|
|
289
|
+
const marked = untrack(() => mineKeyMap());
|
|
290
|
+
const pending = keys.filter((key) => marked.get(key) !== true);
|
|
291
|
+
const cycle = ++probeCycle;
|
|
292
|
+
if (pending.length === 0) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// Keep existing marks while new reads settle, avoiding transient blank states.
|
|
296
|
+
const settled = new Map<string, boolean>();
|
|
297
|
+
for (const [key, value] of marked) {
|
|
298
|
+
if (value === true) {
|
|
299
|
+
settled.set(key, true);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
let degraded = false;
|
|
303
|
+
void runPool(
|
|
304
|
+
pending,
|
|
305
|
+
PROBE_CONCURRENCY,
|
|
306
|
+
async (key) => {
|
|
307
|
+
const probe = await probeAnySession(sessions, key);
|
|
308
|
+
if (!alive || cycle !== probeCycle) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (probe.unsupported) {
|
|
312
|
+
degraded = true;
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (probe.mine) {
|
|
316
|
+
if (settled.get(key) === true) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
settled.set(key, true);
|
|
320
|
+
} else {
|
|
321
|
+
if (settled.get(key) !== true) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
settled.delete(key);
|
|
325
|
+
}
|
|
326
|
+
setMineKeyMap(new Map(settled));
|
|
327
|
+
},
|
|
328
|
+
() => !alive || cycle !== probeCycle,
|
|
329
|
+
).then(() => {
|
|
330
|
+
if (!alive || cycle !== probeCycle) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
log(props.ctx, "activity probes settled", { matches: [...settled.keys()] });
|
|
334
|
+
if (!degraded) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
disableActivityProbes();
|
|
338
|
+
log(
|
|
339
|
+
props.ctx,
|
|
340
|
+
"Zinn rejected the activity session filter. The current-session mark requires Zinn 0.41.0 or newer.",
|
|
341
|
+
{ sessions },
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
const knownRowCache = new Map<string, DisplayRow>();
|
|
346
|
+
const unknownRowCache = new Map<string, DisplayRow>();
|
|
347
|
+
|
|
348
|
+
const knownRows = createMemo<DisplayRow[]>(() => {
|
|
349
|
+
const loaded = map();
|
|
350
|
+
const created = createdKeys();
|
|
351
|
+
const mineKeys = mineKeyMap();
|
|
352
|
+
const candidates = scan().known.map((mention): DisplayRow => {
|
|
353
|
+
const summary = loaded.ok ? loaded.value.get(mention.key) : undefined;
|
|
354
|
+
const isCreated = created.has(mention.key);
|
|
355
|
+
const column = summary?.column ?? "";
|
|
356
|
+
const priority = summary?.priority ?? null;
|
|
357
|
+
const labels = summary?.labels ?? [];
|
|
358
|
+
const mine = mineKeys.get(mention.key) === true;
|
|
359
|
+
return {
|
|
360
|
+
key: mention.key,
|
|
361
|
+
title: summary?.title ?? "",
|
|
362
|
+
priority,
|
|
363
|
+
labels,
|
|
364
|
+
column,
|
|
365
|
+
glyph: isCreated ? "+" : columnGlyph(summary?.column ?? ""),
|
|
366
|
+
tone: rowTone(column, false, isCreated),
|
|
367
|
+
unknown: false,
|
|
368
|
+
created: isCreated,
|
|
369
|
+
mine,
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
return stableRows(knownRowCache, candidates);
|
|
373
|
+
});
|
|
374
|
+
const unknownRows = createMemo<DisplayRow[]>(() => {
|
|
375
|
+
const candidates = scan().unknown.map(
|
|
376
|
+
(mention): DisplayRow => ({
|
|
377
|
+
key: mention.key,
|
|
378
|
+
title: "",
|
|
379
|
+
glyph: "⚠",
|
|
380
|
+
tone: "warning",
|
|
381
|
+
unknown: true,
|
|
382
|
+
created: false,
|
|
383
|
+
mine: false,
|
|
384
|
+
priority: null,
|
|
385
|
+
labels: [],
|
|
386
|
+
column: "",
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
return stableRows(unknownRowCache, candidates);
|
|
390
|
+
});
|
|
391
|
+
const mentionCountsByKey = createMemo(() => mentionCounts(scan().known));
|
|
392
|
+
const open = (key: string, unknown: boolean) =>
|
|
393
|
+
openDetail(props.ctx, key, unknown, mentionCountsByKey().get(key));
|
|
394
|
+
const visibleKnown = createMemo(() => knownRows().slice(0, SECTION_ROWS));
|
|
395
|
+
const knownOverflow = createMemo(() => knownRows().length - visibleKnown().length);
|
|
396
|
+
const visibleUnknown = createMemo(() => unknownRows().slice(0, SECTION_ROWS));
|
|
397
|
+
const unknownOverflow = createMemo(() => unknownRows().length - visibleUnknown().length);
|
|
398
|
+
const contentIndent = () => RAIL_COLUMNS + (props.options.current === "glyph" ? 1 : 0) + 1;
|
|
399
|
+
const titleColumns = createMemo(() => {
|
|
400
|
+
const rows = [...visibleKnown(), ...visibleUnknown()];
|
|
401
|
+
let key = 0;
|
|
402
|
+
for (const row of rows) {
|
|
403
|
+
key = Math.max(key, row.key.length);
|
|
404
|
+
}
|
|
405
|
+
const gaps = props.options.rows === 2 ? 3 : 4;
|
|
406
|
+
const priority = props.options.rows === 2 ? 0 : PRIORITY_COLUMNS;
|
|
407
|
+
return Math.max(8, SLOT_COLUMNS - RAIL_COLUMNS - MARKER_COLUMNS - priority - key - gaps);
|
|
408
|
+
});
|
|
409
|
+
const metaColumns = Math.max(8, SLOT_COLUMNS - RAIL_COLUMNS - MARKER_COLUMNS);
|
|
410
|
+
let published: PanelSnapshot | undefined;
|
|
411
|
+
createEffect(() => {
|
|
412
|
+
const loaded = map();
|
|
413
|
+
published = {
|
|
414
|
+
map: loaded.ok ? loaded.value : undefined,
|
|
415
|
+
known: scan().known,
|
|
416
|
+
};
|
|
417
|
+
publishPanel(published);
|
|
418
|
+
});
|
|
419
|
+
const headerDetail = createMemo(() => {
|
|
420
|
+
if (loading()) {
|
|
421
|
+
return "loading…";
|
|
422
|
+
}
|
|
423
|
+
const loaded = map();
|
|
424
|
+
if (!loaded.ok) {
|
|
425
|
+
return truncate(loaded.error, 32);
|
|
426
|
+
}
|
|
427
|
+
const count = loaded.value.size;
|
|
428
|
+
return `${count} ${count === 1 ? "task" : "tasks"}`;
|
|
429
|
+
});
|
|
430
|
+
const failure = createMemo(() => {
|
|
431
|
+
if (loading()) {
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
const loaded = map();
|
|
435
|
+
return loaded.ok ? undefined : loaded.error;
|
|
436
|
+
});
|
|
437
|
+
const knownKeys = () => scan().known.map((mention) => mention.key);
|
|
438
|
+
const unknownKeys = () => scan().unknown.map((mention) => mention.key);
|
|
439
|
+
const knownPicker = () => {
|
|
440
|
+
const loaded = map();
|
|
441
|
+
openPicker(
|
|
442
|
+
props.ctx,
|
|
443
|
+
"Zinn | keys in this chat",
|
|
444
|
+
knownKeys(),
|
|
445
|
+
loaded.ok ? loaded.value : undefined,
|
|
446
|
+
false,
|
|
447
|
+
mentionCountsByKey(),
|
|
448
|
+
);
|
|
449
|
+
};
|
|
450
|
+
const unknownPicker = () => {
|
|
451
|
+
const loaded = map();
|
|
452
|
+
openPicker(
|
|
453
|
+
props.ctx,
|
|
454
|
+
"Zinn | unknown keys in this chat",
|
|
455
|
+
unknownKeys(),
|
|
456
|
+
loaded.ok ? loaded.value : undefined,
|
|
457
|
+
true,
|
|
458
|
+
mentionCountsByKey(),
|
|
459
|
+
);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
<box flexDirection="column">
|
|
464
|
+
<box flexDirection="row" gap={1} paddingLeft={contentIndent()} onMouseDown={reload}>
|
|
465
|
+
<text fg={theme().text}>
|
|
466
|
+
<b>ZINN</b>
|
|
467
|
+
</text>
|
|
468
|
+
<text fg={theme().textMuted}>[{headerDetail()}]</text>
|
|
469
|
+
</box>
|
|
470
|
+
<Show when={failure()}>
|
|
471
|
+
<text fg={theme().error}>{truncate(failure() ?? "", 40)}</text>
|
|
472
|
+
</Show>
|
|
473
|
+
<Show when={!loading() && map().ok && knownRows().length === 0 && unknownRows().length === 0}>
|
|
474
|
+
<text fg={theme().textMuted}>no task keys in this chat</text>
|
|
475
|
+
</Show>
|
|
476
|
+
<For each={visibleKnown()}>
|
|
477
|
+
{(row, index) => (
|
|
478
|
+
<>
|
|
479
|
+
<Show when={props.options.rows === 2 && index() > 0}>
|
|
480
|
+
<box
|
|
481
|
+
height={1}
|
|
482
|
+
borderStyle="single"
|
|
483
|
+
border={["top"]}
|
|
484
|
+
borderColor={theme().borderSubtle}
|
|
485
|
+
marginLeft={contentIndent()}
|
|
486
|
+
/>
|
|
487
|
+
</Show>
|
|
488
|
+
<Row
|
|
489
|
+
ctx={props.ctx}
|
|
490
|
+
row={row}
|
|
491
|
+
titleColumns={titleColumns()}
|
|
492
|
+
metaColumns={metaColumns}
|
|
493
|
+
dense={props.options.rows === 2}
|
|
494
|
+
current={props.options.current}
|
|
495
|
+
markGlyph={
|
|
496
|
+
props.options.current === "glyph" && row.mine && running()
|
|
497
|
+
? RUNNING_FRAMES[frame() % RUNNING_FRAMES.length]
|
|
498
|
+
: undefined
|
|
499
|
+
}
|
|
500
|
+
onOpen={() => open(row.key, row.unknown)}
|
|
501
|
+
/>
|
|
502
|
+
</>
|
|
503
|
+
)}
|
|
504
|
+
</For>
|
|
505
|
+
<Show when={knownOverflow() > 0}>
|
|
506
|
+
<Overflow ctx={props.ctx} count={knownOverflow()} label="more" onOpen={knownPicker} />
|
|
507
|
+
</Show>
|
|
508
|
+
<Show when={createdKeys().size > 0}>
|
|
509
|
+
<text fg={theme().info}>{`+ created this session (${createdKeys().size})`}</text>
|
|
510
|
+
</Show>
|
|
511
|
+
<Show when={unknownRows().length > 0}>
|
|
512
|
+
<text fg={theme().warning}>unknown keys (not in store)</text>
|
|
513
|
+
<For each={visibleUnknown()}>
|
|
514
|
+
{(row) => (
|
|
515
|
+
<Row
|
|
516
|
+
ctx={props.ctx}
|
|
517
|
+
row={row}
|
|
518
|
+
titleColumns={titleColumns()}
|
|
519
|
+
metaColumns={metaColumns}
|
|
520
|
+
dense={props.options.rows === 2 && !row.unknown}
|
|
521
|
+
current={props.options.current}
|
|
522
|
+
onOpen={() => open(row.key, row.unknown)}
|
|
523
|
+
/>
|
|
524
|
+
)}
|
|
525
|
+
</For>
|
|
526
|
+
<Show when={unknownOverflow() > 0}>
|
|
527
|
+
<Overflow
|
|
528
|
+
ctx={props.ctx}
|
|
529
|
+
count={unknownOverflow()}
|
|
530
|
+
label="more unknown"
|
|
531
|
+
onOpen={unknownPicker}
|
|
532
|
+
/>
|
|
533
|
+
</Show>
|
|
534
|
+
</Show>
|
|
535
|
+
|
|
536
|
+
<Show when={!loading() && map().ok && knownRows().length + unknownRows().length > 0}>
|
|
537
|
+
<box paddingLeft={contentIndent()}>
|
|
538
|
+
<text fg={theme().textMuted}>recent mentions</text>
|
|
539
|
+
</box>
|
|
540
|
+
</Show>
|
|
541
|
+
</box>
|
|
542
|
+
);
|
|
543
|
+
}
|
package/pkg/theme.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { RGBA } from "@opentui/core";
|
|
2
|
+
import type { RowTone } from "./rows.ts";
|
|
3
|
+
import type { Ctx } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export type InspectorTheme = {
|
|
6
|
+
text: RGBA;
|
|
7
|
+
textMuted: RGBA;
|
|
8
|
+
error: RGBA;
|
|
9
|
+
warning: RGBA;
|
|
10
|
+
success: RGBA;
|
|
11
|
+
info: RGBA;
|
|
12
|
+
borderSubtle: RGBA;
|
|
13
|
+
backgroundElement: RGBA;
|
|
14
|
+
accent: RGBA;
|
|
15
|
+
};
|
|
16
|
+
// Alpha suffixes for tinted backgrounds: about 10% at rest and 20% when active.
|
|
17
|
+
export const TINT_ALPHA_SUBTLE = "1a";
|
|
18
|
+
export const TINT_ALPHA_STRONG = "33";
|
|
19
|
+
|
|
20
|
+
// Read theme values inside a reactive computation so theme changes repaint.
|
|
21
|
+
export function inspectorTheme(ctx: Ctx): InspectorTheme {
|
|
22
|
+
const t = ctx.theme;
|
|
23
|
+
return {
|
|
24
|
+
text: t.text.base,
|
|
25
|
+
textMuted: t.text.muted,
|
|
26
|
+
error: t.text.feedback.error.base,
|
|
27
|
+
warning: t.text.feedback.warning.base,
|
|
28
|
+
success: t.text.feedback.success.base,
|
|
29
|
+
info: t.text.feedback.info.base,
|
|
30
|
+
borderSubtle: t.border.base,
|
|
31
|
+
backgroundElement: t.background.raised.high,
|
|
32
|
+
accent: t.text.action.primary.base,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function toneColor(ctx: Ctx, tone: () => RowTone): () => RGBA {
|
|
37
|
+
const theme = () => inspectorTheme(ctx);
|
|
38
|
+
return () => {
|
|
39
|
+
const value = tone();
|
|
40
|
+
if (value === "warning" || value === "blocked") {
|
|
41
|
+
return theme().warning;
|
|
42
|
+
}
|
|
43
|
+
if (value === "created") {
|
|
44
|
+
return theme().info;
|
|
45
|
+
}
|
|
46
|
+
if (value === "done") {
|
|
47
|
+
return theme().success;
|
|
48
|
+
}
|
|
49
|
+
return theme().text;
|
|
50
|
+
};
|
|
51
|
+
}
|
package/pkg/tui.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
3
|
+
|
|
4
|
+
import { registerCommands } from "./commands.ts";
|
|
5
|
+
import { errorText, log } from "./data.ts";
|
|
6
|
+
import { readOptions } from "./options.ts";
|
|
7
|
+
import { Sidebar } from "./sidebar.tsx";
|
|
8
|
+
|
|
9
|
+
const tui = Plugin.define({
|
|
10
|
+
id: "zinn.tasks",
|
|
11
|
+
setup(ctx) {
|
|
12
|
+
const settings = readOptions(ctx.options);
|
|
13
|
+
log(ctx, "plugin options", {
|
|
14
|
+
rows: settings.rows,
|
|
15
|
+
raw: ctx.options === undefined ? null : "present",
|
|
16
|
+
});
|
|
17
|
+
registerCommands(ctx);
|
|
18
|
+
try {
|
|
19
|
+
log(ctx, "keymap commands", { ids: ctx.keymap.commands().map((c) => c.id) });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
log(ctx, "keymap commands failed", { error: errorText(error) });
|
|
22
|
+
}
|
|
23
|
+
const dispose = ctx.ui.slot({
|
|
24
|
+
append: "sidebar.content",
|
|
25
|
+
render: (input) => <Sidebar ctx={ctx} sessionID={input.sessionID} options={settings} />,
|
|
26
|
+
});
|
|
27
|
+
return () => {
|
|
28
|
+
dispose();
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
export default tui;
|
package/pkg/types.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/tui";
|
|
2
|
+
|
|
3
|
+
export type Ctx = Plugin.Context;
|
|
4
|
+
export type TaskSummary = {
|
|
5
|
+
key: string;
|
|
6
|
+
title: string;
|
|
7
|
+
status: string;
|
|
8
|
+
column: string;
|
|
9
|
+
priority: string | null;
|
|
10
|
+
labels: string[];
|
|
11
|
+
};
|
|
12
|
+
export type Relation = {
|
|
13
|
+
type: string;
|
|
14
|
+
direction: string;
|
|
15
|
+
task: {
|
|
16
|
+
key: string;
|
|
17
|
+
title: string;
|
|
18
|
+
status: string;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export type TaskDetail = {
|
|
22
|
+
key: string;
|
|
23
|
+
title: string;
|
|
24
|
+
status: string;
|
|
25
|
+
column: string;
|
|
26
|
+
priority: string | null;
|
|
27
|
+
labels: string[];
|
|
28
|
+
relations: Relation[];
|
|
29
|
+
comments: number;
|
|
30
|
+
description: string;
|
|
31
|
+
};
|
|
32
|
+
export type Loaded<T> =
|
|
33
|
+
| {
|
|
34
|
+
ok: true;
|
|
35
|
+
value: T;
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
ok: false;
|
|
39
|
+
error: string;
|
|
40
|
+
code?: string;
|
|
41
|
+
};
|
|
42
|
+
export type Mention = {
|
|
43
|
+
key: string;
|
|
44
|
+
count: number;
|
|
45
|
+
};
|
|
46
|
+
export type Scan = {
|
|
47
|
+
known: Mention[];
|
|
48
|
+
unknown: Mention[];
|
|
49
|
+
tokens: number;
|
|
50
|
+
textParts: number;
|
|
51
|
+
streamingParts: number;
|
|
52
|
+
zinnCommands: number;
|
|
53
|
+
ms: number;
|
|
54
|
+
};
|
|
55
|
+
export type DetailState =
|
|
56
|
+
| {
|
|
57
|
+
kind: "loading";
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
kind: "ready";
|
|
61
|
+
detail: TaskDetail;
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
kind: "error";
|
|
65
|
+
error: string;
|
|
66
|
+
};
|