kankaku 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -23
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -0
- package/dist/adapters/project-config.d.ts +42 -0
- package/dist/adapters/project-config.js +108 -0
- package/dist/adapters/report-data.d.ts +12 -0
- package/dist/adapters/report-data.js +8 -0
- package/dist/adapters/report-views.d.ts +45 -0
- package/dist/adapters/report-views.js +73 -0
- package/dist/adapters/report.d.ts +112 -0
- package/dist/adapters/report.js +236 -0
- package/dist/adapters/sync-runner.d.ts +21 -6
- package/dist/adapters/sync-runner.js +8 -1
- package/dist/domain/hub-entry.d.ts +26 -2
- package/dist/domain/hub-entry.js +50 -6
- package/dist/domain/sync-plan.js +10 -0
- package/dist/domain/work-record.d.ts +17 -0
- package/dist/domain/work-record.js +5 -1
- package/dist/hub/index.d.ts +6 -0
- package/dist/hub/index.js +6 -0
- package/package.json +1 -1
- package/src/adapters/hub-actions.ts +2 -3
- package/src/adapters/kankaku-command.ts +5 -9
- package/src/adapters/panel/kankaku-panel.ts +164 -19
- package/src/adapters/panel/panel-items.ts +13 -3
- package/src/adapters/panel/screens/doctor.ts +4 -0
- package/src/adapters/panel/screens/export.ts +4 -0
- package/src/adapters/panel/screens/report.ts +2 -0
- package/src/adapters/panel/screens/sync.ts +10 -0
- package/src/adapters/panel/screens/target.ts +18 -1
- package/src/adapters/pi-tracker.ts +8 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +1 -1
- package/src/adapters/sync-runner.ts +21 -7
- package/src/domain/hub-entry.ts +87 -8
- package/src/domain/panel-model.ts +10 -7
- package/src/domain/sync-plan.ts +10 -0
- package/src/domain/work-record.ts +22 -1
- package/src/hub/index.ts +6 -0
|
@@ -6,15 +6,14 @@
|
|
|
6
6
|
* see odd/tasks/kankaku-panel.md P4.
|
|
7
7
|
*/
|
|
8
8
|
import type { CatalogSnapshot } from "../ports/catalog.ts";
|
|
9
|
-
import type {
|
|
10
|
-
import type { SyncSummary } from "./sync-runner.ts";
|
|
9
|
+
import type { SyncStatusSnapshot, SyncSummary } from "./sync-runner.ts";
|
|
11
10
|
|
|
12
11
|
/**
|
|
13
12
|
* `/kankaku sync status`'s report lines: the watermark, the pending count,
|
|
14
13
|
* how many tasks fell outside this run's revisit window (R3), and the last
|
|
15
14
|
* error, when any. Exact body of `handleSyncCommand`'s `status` branch.
|
|
16
15
|
*/
|
|
17
|
-
export function buildSyncStatusLines(status:
|
|
16
|
+
export function buildSyncStatusLines(status: SyncStatusSnapshot): string[] {
|
|
18
17
|
const { state, pending, staleOutsideWindow } = status;
|
|
19
18
|
const lines = [state?.syncedThrough ? `synced through ${state.syncedThrough}` : "never synced", `pending: ${pending}`];
|
|
20
19
|
if (staleOutsideWindow > 0) {
|
|
@@ -7,24 +7,20 @@ import { formatWorkTargetLabel } from "../domain/work-target.ts";
|
|
|
7
7
|
import { findAmbiguousToolNames } from "../domain/subagent-profile.ts";
|
|
8
8
|
import type { SubagentProfile } from "../domain/subagent-profile.ts";
|
|
9
9
|
import type { RejectedChildEnvMarker } from "../config.ts";
|
|
10
|
-
import type { SyncState } from "../domain/sync-plan.ts";
|
|
11
10
|
import type { RegistryClassification } from "../domain/registry-health.ts";
|
|
12
11
|
import type { Catalog } from "../ports/catalog.ts";
|
|
13
12
|
import type { WorkLog } from "../ports/work-log.ts";
|
|
14
13
|
import { buildSyncStatusLines, formatBackfillLines, formatCatalogRefreshLines, formatSyncSummaryLines } from "./hub-actions.ts";
|
|
15
14
|
import { buildClientsView, buildExportContent, buildProjectsView, buildSessionsView, buildSummaryView, buildTasksView } from "./report-views.ts";
|
|
16
|
-
import type { SyncSummary, SyncTrigger } from "./sync-runner.ts";
|
|
15
|
+
import type { SyncStatusSnapshot, SyncSummary, SyncTrigger } from "./sync-runner.ts";
|
|
17
16
|
import type { SessionClient } from "./session-client.ts";
|
|
18
17
|
import { readNonDefaultSessionDir } from "./session-dir.ts";
|
|
19
18
|
import type { SessionTarget } from "./session-target.ts";
|
|
19
|
+
import type { KankakuReportData } from "./report-data.ts";
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
export type { KankakuReportData } from "./report-data.ts";
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
export interface KankakuReportData {
|
|
25
|
-
title: string;
|
|
26
|
-
lines: string[];
|
|
27
|
-
}
|
|
23
|
+
const REPORT_ENTRY_TYPE = "kankaku-report";
|
|
28
24
|
|
|
29
25
|
/** Notify the user of an error through the UI, when one is available. */
|
|
30
26
|
export function notifyError(ctx: ExtensionContext, error: unknown): void {
|
|
@@ -217,7 +213,7 @@ export interface SyncCommandDeps {
|
|
|
217
213
|
* count, and (R3) how many tasks changed since their last sync but fall
|
|
218
214
|
* outside this run's revisit window — needs `sync all`. No network.
|
|
219
215
|
*/
|
|
220
|
-
status: () =>
|
|
216
|
+
status: () => SyncStatusSnapshot;
|
|
221
217
|
}
|
|
222
218
|
|
|
223
219
|
export interface KankakuCommandDeps {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import type { Component, TUI, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
15
|
-
import { Key, matchesKey, SelectList, Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
+
import { Key, matchesKey, SelectList, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
16
16
|
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
17
17
|
import { footerHints, navBack, navCurrent, navPush, navRoot, panelTitle, rootMenu } from "../../domain/panel-model.ts";
|
|
18
18
|
import type { PanelHint, PanelNav, PanelScreenId } from "../../domain/panel-model.ts";
|
|
@@ -38,6 +38,19 @@ export interface PanelHost {
|
|
|
38
38
|
* `screens/target.ts`'s legacy-label submenu).
|
|
39
39
|
*/
|
|
40
40
|
setBodyWantsText(flag: boolean): void;
|
|
41
|
+
/**
|
|
42
|
+
* Set (or clear) whether the current body owns Escape (a submenu or text
|
|
43
|
+
* field is open and must see it before the shell does — e.g. the target
|
|
44
|
+
* screen's client/project/task submenus, its legacy-label `Input`, or an
|
|
45
|
+
* `actionItem`'s result view). The shell owns Escape (and left arrow) by
|
|
46
|
+
* default and pops the current screen itself: it never relies on
|
|
47
|
+
* pi-tui's `SettingsList`/`SelectList` `onCancel` firing on its own,
|
|
48
|
+
* since that path does not reliably fire in the real TUI (see
|
|
49
|
+
* `KankakuPanelComponent#handleInput`). A screen must reset this to
|
|
50
|
+
* `false` when the thing that captured Escape closes (see
|
|
51
|
+
* `screens/target.ts` and every `actionItem` call site).
|
|
52
|
+
*/
|
|
53
|
+
setBodyCapturesEscape(flag: boolean): void;
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
export interface KankakuPanelDeps {
|
|
@@ -54,6 +67,47 @@ interface HintSpan {
|
|
|
54
67
|
|
|
55
68
|
const HINT_SEPARATOR = " · ";
|
|
56
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Below this width the frame (border + one column of inner padding) no
|
|
72
|
+
* longer leaves room for any content, so `render` falls back to the
|
|
73
|
+
* unframed layout instead of throwing (see `KankakuPanelComponent#render`).
|
|
74
|
+
*/
|
|
75
|
+
const MIN_FRAME_WIDTH = 8;
|
|
76
|
+
|
|
77
|
+
/** Columns the frame's left border ("│") and its one column of padding occupy, left of every inner/body row. */
|
|
78
|
+
const FRAME_LEFT_PADDING = 2;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Wraps `content` between the frame's left/right border (`│ ... │`),
|
|
82
|
+
* padding/truncating it to `innerWidth` first — ANSI-safe, via pi-tui's
|
|
83
|
+
* `truncateToWidth` (never raw string length; see the module doc).
|
|
84
|
+
*/
|
|
85
|
+
function frameLine(theme: Theme, content: string, innerWidth: number): string {
|
|
86
|
+
const left = theme.fg("border", "│ ");
|
|
87
|
+
const right = theme.fg("border", " │");
|
|
88
|
+
const padded = truncateToWidth(content, innerWidth, "…", true);
|
|
89
|
+
return `${left}${padded}${right}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The framed top border: `╭─ <title> ` then a `─` fill, then `╮`, sized to
|
|
94
|
+
* `width`. `titleStyled` keeps its own styling (bold/accent, applied by the
|
|
95
|
+
* caller); it is itself truncated (ANSI-aware) if the title cannot fit.
|
|
96
|
+
*/
|
|
97
|
+
function frameTop(theme: Theme, titleStyled: string, width: number): string {
|
|
98
|
+
const available = Math.max(0, width - 5); // "╭─ " + " " + "╮"
|
|
99
|
+
const title = truncateToWidth(titleStyled, available, "…", false);
|
|
100
|
+
const fillLen = Math.max(0, available - visibleWidth(title));
|
|
101
|
+
const left = theme.fg("border", "╭─ ");
|
|
102
|
+
const fill = theme.fg("border", `${"─".repeat(fillLen)}╮`);
|
|
103
|
+
return `${left}${title} ${fill}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The framed bottom border: `╰` + a `─` fill + `╯`, sized to `width`. */
|
|
107
|
+
function frameBottom(theme: Theme, width: number): string {
|
|
108
|
+
return theme.fg("border", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
|
|
109
|
+
}
|
|
110
|
+
|
|
57
111
|
/** Placeholder body for a screen id with no registered factory yet (P2–P4 fill these in). */
|
|
58
112
|
function comingSoonBody(theme: Theme): PanelBody {
|
|
59
113
|
return new Text(theme.fg("muted", "coming soon"));
|
|
@@ -105,11 +159,20 @@ class KankakuPanelComponent implements Component {
|
|
|
105
159
|
private body: PanelBody;
|
|
106
160
|
/** Set via `PanelHost.setBodyWantsText` by a screen that owns a text field (e.g. the target screen's legacy-label `Input`), so `q` types instead of closing. */
|
|
107
161
|
private bodyWantsText = false;
|
|
162
|
+
/** Set via `PanelHost.setBodyCapturesEscape` by a screen whose body must see Escape/← itself (an open submenu or text field) before the shell pops the screen. */
|
|
163
|
+
private bodyCapturesEscape = false;
|
|
108
164
|
private hints: PanelHint[] = [];
|
|
109
165
|
private hintSpans: HintSpan[] = [];
|
|
110
166
|
/** -1 until the first `render()`, so a mouse event that arrives before any render never mismatches row 0 for the footer. */
|
|
111
167
|
private footerRowIndex = -1;
|
|
112
168
|
private hoveredHintIndex: number | undefined;
|
|
169
|
+
/** Whether the last `render()` drew the frame (`width >= MIN_FRAME_WIDTH`) or fell back to the unframed layout. */
|
|
170
|
+
private framed = false;
|
|
171
|
+
/** -1 until the first `render()` draws the frame; the absolute row index of the body's first rendered line. */
|
|
172
|
+
private bodyRowStart = -1;
|
|
173
|
+
private bodyRowCount = 0;
|
|
174
|
+
/** The width last passed to `this.body.render(...)` (`width` unframed, `width - 4` framed), for shifted mouse events. */
|
|
175
|
+
private bodyWidth = 0;
|
|
113
176
|
|
|
114
177
|
constructor(tui: TUI, theme: Theme, deps: KankakuPanelDeps, done: (result: void) => void) {
|
|
115
178
|
this.tui = tui;
|
|
@@ -126,6 +189,9 @@ class KankakuPanelComponent implements Component {
|
|
|
126
189
|
setBodyWantsText: (flag) => {
|
|
127
190
|
this.bodyWantsText = flag;
|
|
128
191
|
},
|
|
192
|
+
setBodyCapturesEscape: (flag) => {
|
|
193
|
+
this.bodyCapturesEscape = flag;
|
|
194
|
+
},
|
|
129
195
|
};
|
|
130
196
|
this.nav = navRoot();
|
|
131
197
|
this.body = this.createBody("root");
|
|
@@ -148,6 +214,7 @@ class KankakuPanelComponent implements Component {
|
|
|
148
214
|
this.nav = navPush(this.nav, id);
|
|
149
215
|
this.body = this.createBody(id);
|
|
150
216
|
this.bodyWantsText = false;
|
|
217
|
+
this.bodyCapturesEscape = false;
|
|
151
218
|
this.hoveredHintIndex = undefined;
|
|
152
219
|
this.tui.requestRender();
|
|
153
220
|
}
|
|
@@ -162,6 +229,7 @@ class KankakuPanelComponent implements Component {
|
|
|
162
229
|
this.nav = nav;
|
|
163
230
|
this.body = this.createBody(navCurrent(nav));
|
|
164
231
|
this.bodyWantsText = false;
|
|
232
|
+
this.bodyCapturesEscape = false;
|
|
165
233
|
this.hoveredHintIndex = undefined;
|
|
166
234
|
this.tui.requestRender();
|
|
167
235
|
}
|
|
@@ -181,13 +249,27 @@ class KankakuPanelComponent implements Component {
|
|
|
181
249
|
|
|
182
250
|
render(width: number): string[] {
|
|
183
251
|
const screen = navCurrent(this.nav);
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
252
|
+
const titleStyled = this.theme.bold(this.theme.fg("accent", panelTitle(screen)));
|
|
253
|
+
this.hints = footerHints(screen, { searchable: this.body.searchable ?? false });
|
|
254
|
+
|
|
255
|
+
if (width < MIN_FRAME_WIDTH) {
|
|
256
|
+
return this.renderUnframed(width, titleStyled);
|
|
257
|
+
}
|
|
258
|
+
return this.renderFramed(width, titleStyled, screen);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Legacy, unframed layout: title, body at `width`, footer — used when `width` is too narrow to fit a frame (see `MIN_FRAME_WIDTH`). */
|
|
262
|
+
private renderUnframed(width: number, titleStyled: string): string[] {
|
|
263
|
+
this.framed = false;
|
|
264
|
+
const lines: string[] = [titleStyled, ""];
|
|
265
|
+
|
|
266
|
+
const bodyLines = this.body.render(width);
|
|
267
|
+
this.bodyRowStart = lines.length;
|
|
268
|
+
this.bodyRowCount = bodyLines.length;
|
|
269
|
+
this.bodyWidth = width;
|
|
270
|
+
lines.push(...bodyLines);
|
|
188
271
|
lines.push("");
|
|
189
272
|
|
|
190
|
-
this.hints = footerHints(screen, { searchable: this.body.searchable ?? false });
|
|
191
273
|
const { line, spans } = renderFooter(this.hints, this.theme, this.hoveredHintIndex);
|
|
192
274
|
this.hintSpans = spans;
|
|
193
275
|
this.footerRowIndex = lines.length;
|
|
@@ -196,19 +278,66 @@ class KankakuPanelComponent implements Component {
|
|
|
196
278
|
return lines;
|
|
197
279
|
}
|
|
198
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Rounded frame around the panel (see the module's "no padding/border"
|
|
283
|
+
* feature doc, `odd/tasks/kankaku-panel.md`): a `╭─ <title> ─…─╮` top
|
|
284
|
+
* border, one blank inner line, the body rendered at `innerWidth`
|
|
285
|
+
* (`width - 4`), a blank inner line, the footer hints, and a `╰─…─╯`
|
|
286
|
+
* bottom border. Every inner line is `│ <content> │`.
|
|
287
|
+
*/
|
|
288
|
+
private renderFramed(width: number, titleStyled: string, screen: PanelScreenId): string[] {
|
|
289
|
+
this.framed = true;
|
|
290
|
+
const innerWidth = width - 4;
|
|
291
|
+
const lines: string[] = [];
|
|
292
|
+
|
|
293
|
+
lines.push(frameTop(this.theme, titleStyled, width));
|
|
294
|
+
lines.push(frameLine(this.theme, "", innerWidth));
|
|
295
|
+
|
|
296
|
+
const bodyLines = this.body.render(innerWidth);
|
|
297
|
+
this.bodyRowStart = lines.length;
|
|
298
|
+
this.bodyRowCount = bodyLines.length;
|
|
299
|
+
this.bodyWidth = innerWidth;
|
|
300
|
+
for (const bodyLine of bodyLines) lines.push(frameLine(this.theme, bodyLine, innerWidth));
|
|
301
|
+
|
|
302
|
+
lines.push(frameLine(this.theme, "", innerWidth));
|
|
303
|
+
|
|
304
|
+
const { line, spans } = renderFooter(this.hints, this.theme, this.hoveredHintIndex);
|
|
305
|
+
// The footer's own hit-test spans are local to its (unframed) content;
|
|
306
|
+
// shift them by the left border + padding so they match the actual
|
|
307
|
+
// rendered column once wrapped in `frameLine`.
|
|
308
|
+
this.hintSpans = spans.map((span) => ({ start: span.start + FRAME_LEFT_PADDING, end: span.end + FRAME_LEFT_PADDING }));
|
|
309
|
+
this.footerRowIndex = lines.length;
|
|
310
|
+
lines.push(frameLine(this.theme, line, innerWidth));
|
|
311
|
+
|
|
312
|
+
lines.push(frameBottom(this.theme, width));
|
|
313
|
+
|
|
314
|
+
return lines;
|
|
315
|
+
}
|
|
316
|
+
|
|
199
317
|
handleInput(data: string): void {
|
|
318
|
+
// The shell owns Escape and left arrow itself; it never relies on
|
|
319
|
+
// pi-tui's `SettingsList`/`SelectList` calling `onCancel` on its own
|
|
320
|
+
// (via `getKeybindings().matches(data, "tui.select.cancel")`) — that
|
|
321
|
+
// path does not reliably fire in the real TUI, which is the bug this
|
|
322
|
+
// guards against. Only when the current body has explicitly captured
|
|
323
|
+
// Escape (`PanelHost.setBodyCapturesEscape(true)` — an open submenu or
|
|
324
|
+
// text field that must see the key itself, e.g. the target screen's
|
|
325
|
+
// client/project/task submenus or its legacy-label `Input`) is the key
|
|
326
|
+
// forwarded; otherwise the shell pops the current screen directly.
|
|
200
327
|
if (matchesKey(data, Key.escape)) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
328
|
+
if (this.bodyCapturesEscape) {
|
|
329
|
+
this.body.handleInput?.(data);
|
|
330
|
+
} else {
|
|
331
|
+
this.goBack();
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (matchesKey(data, Key.left)) {
|
|
336
|
+
if (this.bodyCapturesEscape) {
|
|
337
|
+
// Translate to the escape sequence so a captured body (which only
|
|
338
|
+
// ever wires up Escape, not left arrow) closes exactly as it would
|
|
339
|
+
// on Escape.
|
|
340
|
+
this.body.handleInput?.("\x1b");
|
|
212
341
|
} else {
|
|
213
342
|
this.goBack();
|
|
214
343
|
}
|
|
@@ -225,7 +354,21 @@ class KankakuPanelComponent implements Component {
|
|
|
225
354
|
if (event.y === this.footerRowIndex) {
|
|
226
355
|
return this.handleFooterMouse(event);
|
|
227
356
|
}
|
|
228
|
-
|
|
357
|
+
if (!this.framed) {
|
|
358
|
+
// Legacy, unframed layout: any non-footer row delegates as-is.
|
|
359
|
+
return this.body.handleMouse?.(event);
|
|
360
|
+
}
|
|
361
|
+
const bodyRowEnd = this.bodyRowStart + this.bodyRowCount;
|
|
362
|
+
if (event.y < this.bodyRowStart || event.y >= bodyRowEnd) {
|
|
363
|
+
// Frame border or blank padding row: nothing to delegate to.
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
return this.body.handleMouse?.({
|
|
367
|
+
...event,
|
|
368
|
+
x: event.x - FRAME_LEFT_PADDING,
|
|
369
|
+
y: event.y - this.bodyRowStart,
|
|
370
|
+
width: this.bodyWidth,
|
|
371
|
+
});
|
|
229
372
|
}
|
|
230
373
|
|
|
231
374
|
private handleFooterMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
@@ -241,7 +384,9 @@ class KankakuPanelComponent implements Component {
|
|
|
241
384
|
|
|
242
385
|
if (event.type === "click" && event.button === "left" && hitIndex !== -1) {
|
|
243
386
|
const hint = this.hints[hitIndex]!;
|
|
244
|
-
|
|
387
|
+
// "esc" at root, "esc/←" on every other screen (see `footerHints`) —
|
|
388
|
+
// `goBack()` already closes the panel outright when at root.
|
|
389
|
+
if (hint.key.startsWith("esc")) {
|
|
245
390
|
this.goBack();
|
|
246
391
|
return { handled: true };
|
|
247
392
|
}
|
|
@@ -18,6 +18,10 @@ export interface ActionItemOptions {
|
|
|
18
18
|
run: () => string[] | Promise<string[]>;
|
|
19
19
|
/** Called once `run()` settles (success or error), so the caller can re-render — this component has no `host` reference of its own. */
|
|
20
20
|
onDone?: () => void;
|
|
21
|
+
/** Called synchronously when the submenu component is created (the action starts running). */
|
|
22
|
+
onOpen?: () => void;
|
|
23
|
+
/** Called right before `done(...)` is invoked on close (Enter or Escape), so the caller can, e.g., clear `PanelHost.setBodyCapturesEscape`. */
|
|
24
|
+
onClose?: () => void;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
class ActionItemComponent implements Component {
|
|
@@ -35,6 +39,7 @@ class ActionItemComponent implements Component {
|
|
|
35
39
|
this.theme = theme;
|
|
36
40
|
this.options = options;
|
|
37
41
|
this.done = done;
|
|
42
|
+
this.options.onOpen?.();
|
|
38
43
|
void this.start();
|
|
39
44
|
}
|
|
40
45
|
|
|
@@ -58,9 +63,14 @@ class ActionItemComponent implements Component {
|
|
|
58
63
|
|
|
59
64
|
handleInput(data: string): void {
|
|
60
65
|
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) {
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
66
|
+
this.options.onClose?.();
|
|
67
|
+
// Close with NO `navigateTo`: pi-tui's `SettingsList.closeSubmenu`
|
|
68
|
+
// treats `navigateTo` as "select that row and activate it", and
|
|
69
|
+
// activating this row opens its submenu again — the result would
|
|
70
|
+
// reopen and the action re-run on every Enter/Escape, leaving the
|
|
71
|
+
// user stuck until `q`. Without it the list restores the cursor to
|
|
72
|
+
// the row that opened the submenu on its own (`submenuItemIndex`).
|
|
73
|
+
this.done();
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
76
|
}
|
|
@@ -47,6 +47,8 @@ class DoctorScreenComponent implements Component {
|
|
|
47
47
|
this.deps.pinReport({ title: "doctor", lines: this.lines });
|
|
48
48
|
return ["pinned to the chat transcript"];
|
|
49
49
|
},
|
|
50
|
+
onOpen: () => this.host.setBodyCapturesEscape(true),
|
|
51
|
+
onClose: () => this.host.setBodyCapturesEscape(false),
|
|
50
52
|
onDone: () => this.host.requestRender(),
|
|
51
53
|
}),
|
|
52
54
|
actionItem(this.settingsTheme, {
|
|
@@ -56,6 +58,8 @@ class DoctorScreenComponent implements Component {
|
|
|
56
58
|
this.lines = buildDoctorLines(this.deps.commandDeps, this.deps.ctx);
|
|
57
59
|
return ["refreshed"];
|
|
58
60
|
},
|
|
61
|
+
onOpen: () => this.host.setBodyCapturesEscape(true),
|
|
62
|
+
onClose: () => this.host.setBodyCapturesEscape(false),
|
|
59
63
|
onDone: () => this.host.requestRender(),
|
|
60
64
|
}),
|
|
61
65
|
];
|
|
@@ -70,6 +70,8 @@ class ExportScreenComponent implements Component {
|
|
|
70
70
|
this.lastNote = `wrote ${path}`;
|
|
71
71
|
return [this.lastNote];
|
|
72
72
|
},
|
|
73
|
+
onOpen: () => this.host.setBodyCapturesEscape(true),
|
|
74
|
+
onClose: () => this.host.setBodyCapturesEscape(false),
|
|
73
75
|
onDone: () => this.host.requestRender(),
|
|
74
76
|
}),
|
|
75
77
|
actionItem(this.settingsTheme, {
|
|
@@ -79,6 +81,8 @@ class ExportScreenComponent implements Component {
|
|
|
79
81
|
this.deps.pinReport({ title: "export", lines: [this.lastNote] });
|
|
80
82
|
return ["pinned to the chat transcript"];
|
|
81
83
|
},
|
|
84
|
+
onOpen: () => this.host.setBodyCapturesEscape(true),
|
|
85
|
+
onClose: () => this.host.setBodyCapturesEscape(false),
|
|
82
86
|
onDone: () => this.host.requestRender(),
|
|
83
87
|
}),
|
|
84
88
|
];
|
|
@@ -99,6 +99,8 @@ class ReportScreenComponent implements Component {
|
|
|
99
99
|
this.deps.pinReport(this.report);
|
|
100
100
|
return ["pinned to the chat transcript"];
|
|
101
101
|
},
|
|
102
|
+
onOpen: () => this.host.setBodyCapturesEscape(true),
|
|
103
|
+
onClose: () => this.host.setBodyCapturesEscape(false),
|
|
102
104
|
onDone: () => this.host.requestRender(),
|
|
103
105
|
}),
|
|
104
106
|
];
|
|
@@ -60,18 +60,24 @@ class SyncScreenComponent implements Component {
|
|
|
60
60
|
id: "sync-now",
|
|
61
61
|
label: "Sync now",
|
|
62
62
|
run: async () => formatSyncSummaryLines(await this.deps.sync.run({})),
|
|
63
|
+
onOpen: () => this.host.setBodyCapturesEscape(true),
|
|
64
|
+
onClose: () => this.host.setBodyCapturesEscape(false),
|
|
63
65
|
onDone: () => this.refreshStatus(),
|
|
64
66
|
}),
|
|
65
67
|
actionItem(this.settingsTheme, {
|
|
66
68
|
id: "sync-all",
|
|
67
69
|
label: "Sync all",
|
|
68
70
|
run: async () => formatSyncSummaryLines(await this.deps.sync.run({ full: true })),
|
|
71
|
+
onOpen: () => this.host.setBodyCapturesEscape(true),
|
|
72
|
+
onClose: () => this.host.setBodyCapturesEscape(false),
|
|
69
73
|
onDone: () => this.refreshStatus(),
|
|
70
74
|
}),
|
|
71
75
|
actionItem(this.settingsTheme, {
|
|
72
76
|
id: "backfill",
|
|
73
77
|
label: "Backfill",
|
|
74
78
|
run: async () => formatBackfillLines(await this.deps.sync.run({ full: true })),
|
|
79
|
+
onOpen: () => this.host.setBodyCapturesEscape(true),
|
|
80
|
+
onClose: () => this.host.setBodyCapturesEscape(false),
|
|
75
81
|
onDone: () => this.refreshStatus(),
|
|
76
82
|
}),
|
|
77
83
|
];
|
|
@@ -83,6 +89,8 @@ class SyncScreenComponent implements Component {
|
|
|
83
89
|
id: "catalog-refresh",
|
|
84
90
|
label: "Refresh catalog",
|
|
85
91
|
run: async () => formatCatalogRefreshLines(await catalog.refresh()),
|
|
92
|
+
onOpen: () => this.host.setBodyCapturesEscape(true),
|
|
93
|
+
onClose: () => this.host.setBodyCapturesEscape(false),
|
|
86
94
|
onDone: () => this.refreshStatus(),
|
|
87
95
|
}),
|
|
88
96
|
);
|
|
@@ -96,6 +104,8 @@ class SyncScreenComponent implements Component {
|
|
|
96
104
|
this.deps.pinReport({ title: STATUS_TITLE, lines: this.statusLines });
|
|
97
105
|
return ["pinned to the chat transcript"];
|
|
98
106
|
},
|
|
107
|
+
onOpen: () => this.host.setBodyCapturesEscape(true),
|
|
108
|
+
onClose: () => this.host.setBodyCapturesEscape(false),
|
|
99
109
|
onDone: () => this.host.requestRender(),
|
|
100
110
|
}),
|
|
101
111
|
);
|
|
@@ -235,10 +235,25 @@ class TargetScreenComponent implements Component {
|
|
|
235
235
|
return { id: row.id, label: row.label, currentValue: row.value, ...(row.description !== undefined ? { description: row.description } : {}) };
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Wraps a row's submenu factory so opening it sets
|
|
240
|
+
* `PanelHost.setBodyCapturesEscape(true)` (the shell forwards Escape/←
|
|
241
|
+
* to this screen instead of popping it while the submenu — or the
|
|
242
|
+
* legacy-label `Input`, which also uses this via `buildLegacySubmenu` —
|
|
243
|
+
* is open) and clears it again right before the submenu's own `done` is
|
|
244
|
+
* invoked, on every close path (a pick, "— use defaults —"/"— none —",
|
|
245
|
+
* cancel, or a disabled note closing on any key).
|
|
246
|
+
*/
|
|
238
247
|
private rowItem(row: PanelRow, submenu: (done: (selectedValue?: string, options?: { navigateTo?: string }) => void) => Component): SettingItem {
|
|
239
248
|
return {
|
|
240
249
|
...this.plainItem(row),
|
|
241
|
-
submenu: (_currentValue, done) =>
|
|
250
|
+
submenu: (_currentValue, done) => {
|
|
251
|
+
this.host.setBodyCapturesEscape(true);
|
|
252
|
+
return submenu((selectedValue, options) => {
|
|
253
|
+
this.host.setBodyCapturesEscape(false);
|
|
254
|
+
done(selectedValue, options);
|
|
255
|
+
});
|
|
256
|
+
},
|
|
242
257
|
};
|
|
243
258
|
}
|
|
244
259
|
|
|
@@ -320,6 +335,8 @@ class TargetScreenComponent implements Component {
|
|
|
320
335
|
const saved = this.deps.sessionTarget?.rememberTarget() ?? false;
|
|
321
336
|
return [saved ? "saved clientId/projectId to config.json" : "nothing to save"];
|
|
322
337
|
},
|
|
338
|
+
onOpen: () => this.host.setBodyCapturesEscape(true),
|
|
339
|
+
onClose: () => this.host.setBodyCapturesEscape(false),
|
|
323
340
|
onDone: () => this.host.requestRender(),
|
|
324
341
|
});
|
|
325
342
|
return { ...item, currentValue: row.value };
|
|
@@ -496,6 +496,14 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
496
496
|
...(roleConfidence !== undefined ? { roleConfidence } : {}),
|
|
497
497
|
...(deps.orchestratorRef !== undefined ? { orchestratorRef: deps.orchestratorRef } : {}),
|
|
498
498
|
...(deps.profile !== undefined ? { profile: deps.profile } : {}),
|
|
499
|
+
// Who measured this record, not who later syncs it — see
|
|
500
|
+
// `domain/work-record.ts#WorkRecordMetadata.agent`. Always stamped for
|
|
501
|
+
// every record this process appends; the versions are the same ones
|
|
502
|
+
// `extension.ts` already resolves for the `about` screen.
|
|
503
|
+
agent: "pi",
|
|
504
|
+
...(deps.agentVersion !== undefined ? { agentVersion: deps.agentVersion } : {}),
|
|
505
|
+
plugin: "kankaku",
|
|
506
|
+
...(deps.pluginVersion !== undefined ? { pluginVersion: deps.pluginVersion } : {}),
|
|
499
507
|
};
|
|
500
508
|
}
|
|
501
509
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi-free home for {@link KankakuReportData}, the shape every `/kankaku`
|
|
3
|
+
* report view and the panel's report screen produce. Extracted out of
|
|
4
|
+
* `kankaku-command.ts` (which still re-exports it) so `report-views.ts` can
|
|
5
|
+
* be published through `kankaku/hub` without pulling in anything that
|
|
6
|
+
* touches `@earendil-works/*` — see AGENTS.md "Code conventions".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Durable report rendered inside the chat transcript; never sent to the LLM. */
|
|
10
|
+
export interface KankakuReportData {
|
|
11
|
+
title: string;
|
|
12
|
+
lines: string[];
|
|
13
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { buildSessions, buildTasks } from "../domain/task-view.ts";
|
|
10
10
|
import { exportRows, toCsv, toJson } from "../domain/export.ts";
|
|
11
11
|
import type { WorkRecord } from "../domain/work-record.ts";
|
|
12
|
-
import type { KankakuReportData } from "./
|
|
12
|
+
import type { KankakuReportData } from "./report-data.ts";
|
|
13
13
|
import { countUncertain, formatClients, formatProjects, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient, summarizeByProject } from "./report.ts";
|
|
14
14
|
|
|
15
15
|
/** Shared by every view except `buildTasksView`: `all` includes every day, otherwise only today's local day. */
|
|
@@ -250,12 +250,31 @@ function persistError(deps: SyncRunnerDeps, state: ReturnType<SyncStateStore["re
|
|
|
250
250
|
});
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
/**
|
|
253
|
+
/**
|
|
254
|
+
* Number of tasks pending a sync right now — computed locally, no network.
|
|
255
|
+
*
|
|
256
|
+
* @deprecated Superseded by {@link computeSyncStatus}, which returns the same
|
|
257
|
+
* `pending` count together with `state` and `staleOutsideWindow`. Nothing in
|
|
258
|
+
* kankaku calls this any more; it stays only because it is part of the
|
|
259
|
+
* published `kankaku/hub` surface. It will be removed in the next minor.
|
|
260
|
+
*/
|
|
254
261
|
export function pendingCount(tasks: TaskView[], state: ReturnType<SyncStateStore["read"]>, target: string, windowHours?: number): number {
|
|
255
262
|
const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
|
|
256
263
|
return plan.toSync.length + plan.correctionsDeferred;
|
|
257
264
|
}
|
|
258
265
|
|
|
266
|
+
/**
|
|
267
|
+
* {@link computeSyncStatus}'s return shape, exported so a caller (e.g.
|
|
268
|
+
* `hub-actions.ts#buildSyncStatusLines`, or a future standalone TUI reusing
|
|
269
|
+
* `kankaku/hub`) can depend on this type without importing anything that
|
|
270
|
+
* touches `@earendil-works/*` — see AGENTS.md "Code conventions".
|
|
271
|
+
*/
|
|
272
|
+
export interface SyncStatusSnapshot {
|
|
273
|
+
state: SyncState | undefined;
|
|
274
|
+
pending: number;
|
|
275
|
+
staleOutsideWindow: number;
|
|
276
|
+
}
|
|
277
|
+
|
|
259
278
|
/**
|
|
260
279
|
* `/kankaku sync status`: the persisted state, a locally-computed pending
|
|
261
280
|
* count, and (R3) how many tasks changed since their last sync but fall
|
|
@@ -263,12 +282,7 @@ export function pendingCount(tasks: TaskView[], state: ReturnType<SyncStateStore
|
|
|
263
282
|
* (see `domain/sync-plan.ts#SyncPlan.staleOutsideWindow`, and README "Hub
|
|
264
283
|
* (PocketBase)" > "Sync" > "Limitations"). No network.
|
|
265
284
|
*/
|
|
266
|
-
export function computeSyncStatus(
|
|
267
|
-
log: WorkLog,
|
|
268
|
-
stateStore: SyncStateStore,
|
|
269
|
-
target: string,
|
|
270
|
-
windowHours?: number,
|
|
271
|
-
): { state: ReturnType<SyncStateStore["read"]>; pending: number; staleOutsideWindow: number } {
|
|
285
|
+
export function computeSyncStatus(log: WorkLog, stateStore: SyncStateStore, target: string, windowHours?: number): SyncStatusSnapshot {
|
|
272
286
|
const state = stateStore.read();
|
|
273
287
|
const tasks = buildTasks(log.readAll());
|
|
274
288
|
const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
|