kankaku 0.6.0 → 0.7.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.
Files changed (44) hide show
  1. package/README.md +103 -14
  2. package/dist/adapters/cached-catalog.d.ts +8 -2
  3. package/dist/adapters/cached-catalog.js +13 -3
  4. package/dist/adapters/pocketbase-catalog.d.ts +7 -5
  5. package/dist/adapters/pocketbase-catalog.js +22 -5
  6. package/dist/adapters/pocketbase-sink.d.ts +2 -1
  7. package/dist/adapters/pocketbase-sink.js +2 -1
  8. package/dist/domain/hub-entry.d.ts +12 -3
  9. package/dist/domain/hub-entry.js +14 -4
  10. package/dist/domain/task-view.d.ts +4 -0
  11. package/dist/domain/task-view.js +2 -0
  12. package/dist/domain/work-record.d.ts +18 -0
  13. package/dist/domain/work-record.js +18 -0
  14. package/dist/domain/work-target.d.ts +30 -1
  15. package/dist/domain/work-target.js +33 -11
  16. package/dist/ports/catalog.d.ts +4 -2
  17. package/package.json +1 -1
  18. package/src/adapters/cached-catalog.ts +22 -6
  19. package/src/adapters/hub-actions.ts +83 -0
  20. package/src/adapters/kankaku-command.ts +236 -244
  21. package/src/adapters/panel/kankaku-panel.ts +274 -0
  22. package/src/adapters/panel/panel-items.ts +77 -0
  23. package/src/adapters/panel/panel-lines.ts +13 -0
  24. package/src/adapters/panel/panel-theme.ts +32 -0
  25. package/src/adapters/panel/screens/about.ts +69 -0
  26. package/src/adapters/panel/screens/doctor.ts +85 -0
  27. package/src/adapters/panel/screens/export.ts +119 -0
  28. package/src/adapters/panel/screens/report.ts +141 -0
  29. package/src/adapters/panel/screens/sync.ts +126 -0
  30. package/src/adapters/panel/screens/target.ts +367 -0
  31. package/src/adapters/pi-tracker.ts +110 -6
  32. package/src/adapters/pocketbase-catalog.ts +36 -7
  33. package/src/adapters/pocketbase-sink.ts +15 -3
  34. package/src/adapters/report-views.ts +98 -0
  35. package/src/adapters/report.ts +66 -16
  36. package/src/adapters/session-target.ts +111 -4
  37. package/src/adapters/target-picker.ts +46 -1
  38. package/src/domain/hub-entry.ts +20 -5
  39. package/src/domain/panel-model.ts +267 -0
  40. package/src/domain/task-view.ts +6 -0
  41. package/src/domain/work-record.ts +23 -0
  42. package/src/domain/work-target.ts +60 -11
  43. package/src/extension.ts +19 -8
  44. package/src/ports/catalog.ts +4 -2
@@ -0,0 +1,274 @@
1
+ /**
2
+ * The `/kankaku` overlay panel shell (see odd/tasks/kankaku-panel.md):
3
+ * title row, current screen body, and a footer hint row that is both a
4
+ * keyboard legend and (in fullscreen, where mouse events are dispatched —
5
+ * see the "Verified facts" section of the feature doc) clickable.
6
+ *
7
+ * Screens (`screens/root.ts` for P1; `screens/target.ts` etc. in later
8
+ * tasks) are supplied by the caller through {@link KankakuPanelDeps}; a
9
+ * screen id with no registered factory renders a "coming soon" placeholder
10
+ * so navigation stays testable before every screen exists.
11
+ */
12
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import type { Theme } from "@earendil-works/pi-coding-agent";
14
+ import type { Component, TUI, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
15
+ import { Key, matchesKey, SelectList, Text, visibleWidth } from "@earendil-works/pi-tui";
16
+ import type { SelectItem } from "@earendil-works/pi-tui";
17
+ import { footerHints, navBack, navCurrent, navPush, navRoot, panelTitle, rootMenu } from "../../domain/panel-model.ts";
18
+ import type { PanelHint, PanelNav, PanelScreenId } from "../../domain/panel-model.ts";
19
+ import { buildSelectListTheme } from "./panel-theme.ts";
20
+
21
+ /** A screen's component factory, given a {@link PanelHost} so it can navigate the panel or trigger a re-render. */
22
+ export type PanelScreenFactory = (host: PanelHost) => Component & { dispose?(): void; searchable?: boolean };
23
+
24
+ /** What a screen factory can do to the panel that hosts it. */
25
+ export interface PanelHost {
26
+ tui: TUI;
27
+ theme: Theme;
28
+ push(id: PanelScreenId): void;
29
+ back(): void;
30
+ close(): void;
31
+ requestRender(): void;
32
+ /**
33
+ * Set (or clear) whether the current body owns a text field (e.g. the
34
+ * target screen's legacy-label `Input`). While `true`, `q` is forwarded
35
+ * to the body like any other key instead of closing the panel — so
36
+ * typing the letter `q` into a text field never closes the overlay. A
37
+ * screen must reset this to `false` when its text field closes (see
38
+ * `screens/target.ts`'s legacy-label submenu).
39
+ */
40
+ setBodyWantsText(flag: boolean): void;
41
+ }
42
+
43
+ export interface KankakuPanelDeps {
44
+ hubConfigured: boolean;
45
+ screens: Partial<Record<Exclude<PanelScreenId, "root">, PanelScreenFactory>>;
46
+ }
47
+
48
+ type PanelBody = Component & { dispose?(): void; searchable?: boolean };
49
+
50
+ interface HintSpan {
51
+ start: number;
52
+ end: number;
53
+ }
54
+
55
+ const HINT_SEPARATOR = " · ";
56
+
57
+ /** Placeholder body for a screen id with no registered factory yet (P2–P4 fill these in). */
58
+ function comingSoonBody(theme: Theme): PanelBody {
59
+ return new Text(theme.fg("muted", "coming soon"));
60
+ }
61
+
62
+ /** Builds one root-menu row per {@link rootMenu} item. */
63
+ function buildRootItems(hubConfigured: boolean): SelectItem[] {
64
+ return rootMenu({ hubConfigured }).map((item) => ({
65
+ value: item.id,
66
+ label: item.label,
67
+ description: item.description,
68
+ }));
69
+ }
70
+
71
+ /**
72
+ * Render the footer hint line and, alongside it, the clickable column range
73
+ * of each hint (in local, cell-based coordinates — see
74
+ * `TuiMouseEvent.x`), so `handleMouse` can hit-test a click without
75
+ * re-deriving the layout from ANSI-colored text.
76
+ */
77
+ function renderFooter(hints: PanelHint[], theme: Theme, hoveredIndex: number | undefined): { line: string; spans: HintSpan[] } {
78
+ let column = 0;
79
+ let line = "";
80
+ const spans: HintSpan[] = [];
81
+
82
+ hints.forEach((hint, index) => {
83
+ if (index > 0) {
84
+ line += theme.fg("muted", HINT_SEPARATOR);
85
+ column += visibleWidth(HINT_SEPARATOR);
86
+ }
87
+ const text = `${hint.key} ${hint.label}`;
88
+ const start = column;
89
+ line += index === hoveredIndex ? theme.inverse(text) : `${theme.fg("accent", hint.key)} ${theme.fg("muted", hint.label)}`;
90
+ column += visibleWidth(text);
91
+ spans.push({ start, end: column });
92
+ });
93
+
94
+ return { line, spans };
95
+ }
96
+
97
+ class KankakuPanelComponent implements Component {
98
+ private readonly tui: TUI;
99
+ private readonly theme: Theme;
100
+ private readonly deps: KankakuPanelDeps;
101
+ private readonly done: (result: void) => void;
102
+ private readonly host: PanelHost;
103
+
104
+ private nav: PanelNav;
105
+ private body: PanelBody;
106
+ /** 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
+ private bodyWantsText = false;
108
+ private hints: PanelHint[] = [];
109
+ private hintSpans: HintSpan[] = [];
110
+ /** -1 until the first `render()`, so a mouse event that arrives before any render never mismatches row 0 for the footer. */
111
+ private footerRowIndex = -1;
112
+ private hoveredHintIndex: number | undefined;
113
+
114
+ constructor(tui: TUI, theme: Theme, deps: KankakuPanelDeps, done: (result: void) => void) {
115
+ this.tui = tui;
116
+ this.theme = theme;
117
+ this.deps = deps;
118
+ this.done = done;
119
+ this.host = {
120
+ tui,
121
+ theme,
122
+ push: (id) => this.pushScreen(id),
123
+ back: () => this.goBack(),
124
+ close: () => this.closePanel(),
125
+ requestRender: () => this.tui.requestRender(),
126
+ setBodyWantsText: (flag) => {
127
+ this.bodyWantsText = flag;
128
+ },
129
+ };
130
+ this.nav = navRoot();
131
+ this.body = this.createBody("root");
132
+ }
133
+
134
+ private createBody(id: PanelScreenId): PanelBody {
135
+ if (id === "root") {
136
+ const list = new SelectList(buildRootItems(this.deps.hubConfigured), 10, buildSelectListTheme(this.theme));
137
+ list.onSelect = (item) => this.pushScreen(item.value as PanelScreenId);
138
+ list.onCancel = () => this.goBack();
139
+ return list;
140
+ }
141
+
142
+ const factory = this.deps.screens[id];
143
+ return factory ? factory(this.host) : comingSoonBody(this.theme);
144
+ }
145
+
146
+ private pushScreen(id: PanelScreenId): void {
147
+ this.body.dispose?.();
148
+ this.nav = navPush(this.nav, id);
149
+ this.body = this.createBody(id);
150
+ this.bodyWantsText = false;
151
+ this.hoveredHintIndex = undefined;
152
+ this.tui.requestRender();
153
+ }
154
+
155
+ private goBack(): void {
156
+ const { nav, closed } = navBack(this.nav);
157
+ if (closed) {
158
+ this.closePanel();
159
+ return;
160
+ }
161
+ this.body.dispose?.();
162
+ this.nav = nav;
163
+ this.body = this.createBody(navCurrent(nav));
164
+ this.bodyWantsText = false;
165
+ this.hoveredHintIndex = undefined;
166
+ this.tui.requestRender();
167
+ }
168
+
169
+ private closePanel(): void {
170
+ this.body.dispose?.();
171
+ this.done(undefined);
172
+ }
173
+
174
+ invalidate(): void {
175
+ this.body.invalidate();
176
+ }
177
+
178
+ dispose(): void {
179
+ this.body.dispose?.();
180
+ }
181
+
182
+ render(width: number): string[] {
183
+ const screen = navCurrent(this.nav);
184
+ const lines: string[] = [];
185
+ lines.push(this.theme.bold(this.theme.fg("accent", panelTitle(screen))));
186
+ lines.push("");
187
+ lines.push(...this.body.render(width));
188
+ lines.push("");
189
+
190
+ this.hints = footerHints(screen, { searchable: this.body.searchable ?? false });
191
+ const { line, spans } = renderFooter(this.hints, this.theme, this.hoveredHintIndex);
192
+ this.hintSpans = spans;
193
+ this.footerRowIndex = lines.length;
194
+ lines.push(line);
195
+
196
+ return lines;
197
+ }
198
+
199
+ handleInput(data: string): void {
200
+ if (matchesKey(data, Key.escape)) {
201
+ // Forward escape to the body when it can handle input itself (a real
202
+ // `SettingsList`/`SelectList`-backed screen): pi-tui's own
203
+ // `SettingsList.handleInput`/`SelectList.handleInput` already close an
204
+ // open submenu on escape and fall through to the body's own
205
+ // `onCancel` only once no submenu remains — wired to `host.back()` by
206
+ // every real screen (the root `SelectList`, the target screen). Only
207
+ // a body with no `handleInput` at all (the placeholder "coming soon"
208
+ // `Text`, or a read-only note like the subagent target screen) has no
209
+ // way to react, so the shell pops the stack itself in that case.
210
+ if (this.body.handleInput) {
211
+ this.body.handleInput(data);
212
+ } else {
213
+ this.goBack();
214
+ }
215
+ return;
216
+ }
217
+ if (data === "q" && !this.bodyWantsText) {
218
+ this.closePanel();
219
+ return;
220
+ }
221
+ this.body.handleInput?.(data);
222
+ }
223
+
224
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
225
+ if (event.y === this.footerRowIndex) {
226
+ return this.handleFooterMouse(event);
227
+ }
228
+ return this.body.handleMouse?.(event);
229
+ }
230
+
231
+ private handleFooterMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
232
+ const hitIndex = this.hintSpans.findIndex((span) => event.x >= span.start && event.x < span.end);
233
+
234
+ if (event.type === "move") {
235
+ if (hitIndex !== this.hoveredHintIndex) {
236
+ this.hoveredHintIndex = hitIndex === -1 ? undefined : hitIndex;
237
+ return { handled: true, render: true };
238
+ }
239
+ return { handled: true, render: false };
240
+ }
241
+
242
+ if (event.type === "click" && event.button === "left" && hitIndex !== -1) {
243
+ const hint = this.hints[hitIndex]!;
244
+ if (hint.key === "esc") {
245
+ this.goBack();
246
+ return { handled: true };
247
+ }
248
+ if (hint.key === "q") {
249
+ this.closePanel();
250
+ return { handled: true };
251
+ }
252
+ }
253
+
254
+ return { handled: true, render: false };
255
+ }
256
+ }
257
+
258
+ /** Builds the panel shell component; see {@link openKankakuPanel} for how it is mounted. */
259
+ export function createPanelComponent(tui: TUI, theme: Theme, deps: KankakuPanelDeps, done: (result: void) => void): Component & { dispose?(): void } {
260
+ return new KankakuPanelComponent(tui, theme, deps, done);
261
+ }
262
+
263
+ /**
264
+ * Opens the `/kankaku` panel as a focused overlay. A no-op when no UI is
265
+ * attached (print/RPC mode) — the caller (`kankaku-command.ts`) falls back
266
+ * to the existing report notification in that case.
267
+ */
268
+ export async function openKankakuPanel(ctx: ExtensionContext, deps: KankakuPanelDeps): Promise<void> {
269
+ if (!ctx.hasUI) return;
270
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => createPanelComponent(tui, theme, deps, done), {
271
+ overlay: true,
272
+ overlayOptions: { width: "80%", minWidth: 64, maxHeight: "80%", anchor: "center" },
273
+ });
274
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A reusable action row for a panel `SettingsList` screen: its submenu runs
3
+ * an async action, shows a busy indicator while it runs, then the lines it
4
+ * returned (or an inline error), and closes back to the row that opened it
5
+ * on any Enter/Escape. Used by the target screen's "Remember" row (P2) and,
6
+ * per the feature doc, meant for reuse by the sync/export screens (P4).
7
+ */
8
+ import type { Component, SettingItem, SettingsListTheme } from "@earendil-works/pi-tui";
9
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
10
+
11
+ const PENDING_LINE = "…";
12
+
13
+ export interface ActionItemOptions {
14
+ id: string;
15
+ label: string;
16
+ description?: string;
17
+ /** Runs the action and returns the lines to show once it settles. May throw; a thrown error is shown as `error: <message>` instead. */
18
+ run: () => string[] | Promise<string[]>;
19
+ /** Called once `run()` settles (success or error), so the caller can re-render — this component has no `host` reference of its own. */
20
+ onDone?: () => void;
21
+ }
22
+
23
+ class ActionItemComponent implements Component {
24
+ private readonly theme: SettingsListTheme;
25
+ private readonly options: ActionItemOptions;
26
+ private readonly done: (selectedValue?: string, options?: { navigateTo?: string }) => void;
27
+ private lines: string[] = [];
28
+ private pending = true;
29
+
30
+ constructor(
31
+ theme: SettingsListTheme,
32
+ options: ActionItemOptions,
33
+ done: (selectedValue?: string, options?: { navigateTo?: string }) => void,
34
+ ) {
35
+ this.theme = theme;
36
+ this.options = options;
37
+ this.done = done;
38
+ void this.start();
39
+ }
40
+
41
+ private async start(): Promise<void> {
42
+ try {
43
+ this.lines = await this.options.run();
44
+ } catch (error) {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ this.lines = [`error: ${message}`];
47
+ } finally {
48
+ this.pending = false;
49
+ this.options.onDone?.();
50
+ }
51
+ }
52
+
53
+ invalidate(): void {}
54
+
55
+ render(_width: number): string[] {
56
+ return this.pending ? [this.theme.hint(PENDING_LINE)] : this.lines;
57
+ }
58
+
59
+ handleInput(data: string): void {
60
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) {
61
+ // Keep the cursor on the row that opened this action — the caller
62
+ // (the enclosing SettingsList) restores selection to it by id.
63
+ this.done(undefined, { navigateTo: this.options.id });
64
+ }
65
+ }
66
+ }
67
+
68
+ /** Build a `SettingItem` whose submenu is a busy/result/error view over `options.run`. See the module doc comment. */
69
+ export function actionItem(theme: SettingsListTheme, options: ActionItemOptions): SettingItem {
70
+ return {
71
+ id: options.id,
72
+ label: options.label,
73
+ currentValue: "",
74
+ ...(options.description !== undefined ? { description: options.description } : {}),
75
+ submenu: (_currentValue, done) => new ActionItemComponent(theme, options, done),
76
+ };
77
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Bounded, scroll-free line rendering shared by the panel's report and
3
+ * doctor screens' body area (see odd/tasks/kankaku-panel.md P3): show at
4
+ * most `maxLines` lines, with a "… N more" footer when the content was
5
+ * truncated. Plain array/string logic, no pi imports, reused by
6
+ * `screens/report.ts` and `screens/doctor.ts`.
7
+ */
8
+ export function renderBoundedLines(lines: string[], maxLines: number): string[] {
9
+ if (lines.length <= maxLines) return lines;
10
+ const shown = lines.slice(0, maxLines);
11
+ const more = lines.length - maxLines;
12
+ return [...shown, `… ${more} more`];
13
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Builds pi-tui `SelectListTheme`/`SettingsListTheme` objects from a pi
3
+ * `Theme` instance, mirroring the role mapping pi's own `/settings`
4
+ * (`node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/
5
+ * theme/theme.js`'s `getSelectListTheme`/`getSettingsListTheme`) uses —
6
+ * so the panel looks and behaves like every other pi list, without
7
+ * depending on pi's internal (non-exported-per-extension) theme singleton.
8
+ */
9
+ import type { SelectListTheme, SettingsListTheme } from "@earendil-works/pi-tui";
10
+ import type { Theme } from "@earendil-works/pi-coding-agent";
11
+
12
+ /** `SelectListTheme` for the panel's root menu and other select lists. */
13
+ export function buildSelectListTheme(theme: Theme): SelectListTheme {
14
+ return {
15
+ selectedPrefix: (text) => theme.fg("accent", text),
16
+ selectedText: (text) => theme.fg("accent", text),
17
+ description: (text) => theme.fg("muted", text),
18
+ scrollInfo: (text) => theme.fg("muted", text),
19
+ noMatch: (text) => theme.fg("muted", text),
20
+ };
21
+ }
22
+
23
+ /** `SettingsListTheme` for screens built on `SettingsList` (e.g. the target screen). */
24
+ export function buildSettingsListTheme(theme: Theme): SettingsListTheme {
25
+ return {
26
+ label: (text, selected) => (selected ? theme.fg("accent", text) : text),
27
+ value: (text, selected) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
28
+ description: (text) => theme.fg("dim", text),
29
+ cursor: theme.fg("accent", "→ "),
30
+ hint: (text) => theme.fg("dim", text),
31
+ };
32
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The `/kankaku` panel's `about` screen (see odd/tasks/kankaku-panel.md):
3
+ * versions, the resolved `KANKAKU_DIR`, the hub URL, and every env-only
4
+ * setting, all read-only (no `values`, no `submenu`) — `buildAboutRows`
5
+ * (`domain/panel-model.ts`) builds the rows; this stays a thin adapter that
6
+ * maps `KankakuConfig` into that pure input.
7
+ */
8
+ import type { Component, SettingItem, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
9
+ import { SettingsList } from "@earendil-works/pi-tui";
10
+ import type { KankakuConfig } from "../../../config.ts";
11
+ import { buildAboutRows } from "../../../domain/panel-model.ts";
12
+ import type { PanelHost, PanelScreenFactory } from "../kankaku-panel.ts";
13
+ import { buildSettingsListTheme } from "../panel-theme.ts";
14
+
15
+ export interface AboutScreenDeps {
16
+ config: KankakuConfig;
17
+ kankakuDir: string;
18
+ agentVersion?: string;
19
+ pluginVersion?: string;
20
+ hubUrl?: string;
21
+ }
22
+
23
+ const MAX_VISIBLE = 10;
24
+
25
+ class AboutScreenComponent implements Component {
26
+ readonly searchable = false;
27
+ private readonly list: SettingsList;
28
+
29
+ constructor(deps: AboutScreenDeps, host: PanelHost) {
30
+ const rows = buildAboutRows({
31
+ pluginVersion: deps.pluginVersion,
32
+ agentVersion: deps.agentVersion,
33
+ kankakuDir: deps.kankakuDir,
34
+ hubUrl: deps.hubUrl,
35
+ interactiveTools: deps.config.interactiveTools,
36
+ segmentRuleCount: deps.config.segmentRules.length,
37
+ subagentProfileNames: deps.config.subagentProfiles.map((profile) => profile.id),
38
+ client: deps.config.client,
39
+ });
40
+ const items: SettingItem[] = rows.map((row) => ({
41
+ id: row.id,
42
+ label: row.label,
43
+ currentValue: row.value,
44
+ ...(row.description !== undefined ? { description: row.description } : {}),
45
+ }));
46
+ this.list = new SettingsList(items, MAX_VISIBLE, buildSettingsListTheme(host.theme), () => {}, () => host.back(), { enableSearch: false });
47
+ }
48
+
49
+ invalidate(): void {
50
+ this.list.invalidate();
51
+ }
52
+
53
+ render(width: number): string[] {
54
+ return this.list.render(width);
55
+ }
56
+
57
+ handleInput(data: string): void {
58
+ this.list.handleInput(data);
59
+ }
60
+
61
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
62
+ return this.list.handleMouse?.(event);
63
+ }
64
+ }
65
+
66
+ /** `openKankakuPanel`'s `screens.about` factory; see {@link AboutScreenDeps}. */
67
+ export function createAboutScreen(deps: AboutScreenDeps): PanelScreenFactory {
68
+ return (host: PanelHost) => new AboutScreenComponent(deps, host);
69
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The `/kankaku` panel's `doctor` screen (see odd/tasks/kankaku-panel.md):
3
+ * renders `buildDoctorLines`' output (the exact lines `/kankaku doctor`
4
+ * shows) in a bounded body, with `pin`/`refresh` actions.
5
+ */
6
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import type { Component, SettingsListTheme, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
8
+ import { SettingsList } from "@earendil-works/pi-tui";
9
+ import { buildDoctorLines } from "../../kankaku-command.ts";
10
+ import type { KankakuCommandDeps, KankakuReportData } from "../../kankaku-command.ts";
11
+ import type { PanelHost, PanelScreenFactory } from "../kankaku-panel.ts";
12
+ import { actionItem } from "../panel-items.ts";
13
+ import { renderBoundedLines } from "../panel-lines.ts";
14
+ import { buildSettingsListTheme } from "../panel-theme.ts";
15
+
16
+ export interface DoctorScreenDeps {
17
+ commandDeps: KankakuCommandDeps;
18
+ ctx: ExtensionContext;
19
+ pinReport: (report: KankakuReportData) => void;
20
+ }
21
+
22
+ const MAX_VISIBLE = 4;
23
+ const MAX_BODY_LINES = 14;
24
+
25
+ class DoctorScreenComponent implements Component {
26
+ readonly searchable = false;
27
+ private readonly deps: DoctorScreenDeps;
28
+ private readonly host: PanelHost;
29
+ private readonly settingsTheme: SettingsListTheme;
30
+ private lines: string[];
31
+ private list: SettingsList;
32
+
33
+ constructor(deps: DoctorScreenDeps, host: PanelHost) {
34
+ this.deps = deps;
35
+ this.host = host;
36
+ this.settingsTheme = buildSettingsListTheme(host.theme);
37
+ this.lines = buildDoctorLines(deps.commandDeps, deps.ctx);
38
+ this.list = this.buildList();
39
+ }
40
+
41
+ private buildList(): SettingsList {
42
+ const items = [
43
+ actionItem(this.settingsTheme, {
44
+ id: "pin",
45
+ label: "Pin to chat",
46
+ run: () => {
47
+ this.deps.pinReport({ title: "doctor", lines: this.lines });
48
+ return ["pinned to the chat transcript"];
49
+ },
50
+ onDone: () => this.host.requestRender(),
51
+ }),
52
+ actionItem(this.settingsTheme, {
53
+ id: "refresh",
54
+ label: "Refresh",
55
+ run: () => {
56
+ this.lines = buildDoctorLines(this.deps.commandDeps, this.deps.ctx);
57
+ return ["refreshed"];
58
+ },
59
+ onDone: () => this.host.requestRender(),
60
+ }),
61
+ ];
62
+ return new SettingsList(items, MAX_VISIBLE, this.settingsTheme, () => {}, () => this.host.back(), { enableSearch: false });
63
+ }
64
+
65
+ invalidate(): void {
66
+ this.list.invalidate();
67
+ }
68
+
69
+ render(width: number): string[] {
70
+ return [...this.list.render(width), "", ...renderBoundedLines(this.lines, MAX_BODY_LINES)];
71
+ }
72
+
73
+ handleInput(data: string): void {
74
+ this.list.handleInput(data);
75
+ }
76
+
77
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
78
+ return this.list.handleMouse?.(event);
79
+ }
80
+ }
81
+
82
+ /** `openKankakuPanel`'s `screens.doctor` factory; see {@link DoctorScreenDeps}. */
83
+ export function createDoctorScreen(deps: DoctorScreenDeps): PanelScreenFactory {
84
+ return (host: PanelHost) => new DoctorScreenComponent(deps, host);
85
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The `/kankaku` panel's `export` screen (see odd/tasks/kankaku-panel.md
3
+ * P4): `Format`/`Range` rows plus a `Write file` action that computes the
4
+ * export content through the exact same `buildExportContent`
5
+ * (`adapters/report-views.ts`) `/kankaku export` uses, so the panel and the
6
+ * subcommand can never drift. `Pin to chat` pins a short note of the last
7
+ * write (or the current selection, before anything has been written).
8
+ */
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type { Component, SettingItem, SettingsListTheme, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
11
+ import { SettingsList } from "@earendil-works/pi-tui";
12
+ import type { KankakuReportData } from "../../kankaku-command.ts";
13
+ import { buildExportContent } from "../../report-views.ts";
14
+ import type { WorkLog } from "../../../ports/work-log.ts";
15
+ import type { PanelHost, PanelScreenFactory } from "../kankaku-panel.ts";
16
+ import { actionItem } from "../panel-items.ts";
17
+ import { buildSettingsListTheme } from "../panel-theme.ts";
18
+
19
+ export interface ExportScreenDeps {
20
+ log: WorkLog;
21
+ /**
22
+ * Write an export file (name, content) under the kankaku dir and return
23
+ * its absolute path — the same dep `/kankaku export` uses. When absent,
24
+ * `Write file` reports the exact message the subcommand gives today
25
+ * ("export is not configured").
26
+ */
27
+ writeExportFile?: (name: string, content: string) => string;
28
+ ctx: ExtensionContext;
29
+ pinReport: (report: KankakuReportData) => void;
30
+ }
31
+
32
+ type ExportFormat = "csv" | "json";
33
+ type ExportRange = "today" | "all";
34
+
35
+ const FORMAT_VALUES: ExportFormat[] = ["csv", "json"];
36
+ const RANGE_VALUES: ExportRange[] = ["today", "all"];
37
+ const MAX_VISIBLE = 4;
38
+ const EXPORT_NOT_CONFIGURED = "export is not configured";
39
+
40
+ class ExportScreenComponent implements Component {
41
+ readonly searchable = false;
42
+ private readonly deps: ExportScreenDeps;
43
+ private readonly host: PanelHost;
44
+ private readonly settingsTheme: SettingsListTheme;
45
+ private format: ExportFormat = "csv";
46
+ private range: ExportRange = "today";
47
+ private lastNote = "nothing written yet";
48
+ private list: SettingsList;
49
+
50
+ constructor(deps: ExportScreenDeps, host: PanelHost) {
51
+ this.deps = deps;
52
+ this.host = host;
53
+ this.settingsTheme = buildSettingsListTheme(host.theme);
54
+ this.list = this.buildList();
55
+ }
56
+
57
+ private buildList(): SettingsList {
58
+ const items: SettingItem[] = [
59
+ { id: "format", label: "Format", currentValue: this.format, values: FORMAT_VALUES },
60
+ { id: "range", label: "Range", currentValue: this.range, values: RANGE_VALUES },
61
+ actionItem(this.settingsTheme, {
62
+ id: "write",
63
+ label: "Write file",
64
+ run: () => {
65
+ if (!this.deps.writeExportFile) {
66
+ throw new Error(EXPORT_NOT_CONFIGURED);
67
+ }
68
+ const { name, content } = buildExportContent(this.deps.log.readAll(), { format: this.format, all: this.range === "all" });
69
+ const path = this.deps.writeExportFile(name, content);
70
+ this.lastNote = `wrote ${path}`;
71
+ return [this.lastNote];
72
+ },
73
+ onDone: () => this.host.requestRender(),
74
+ }),
75
+ actionItem(this.settingsTheme, {
76
+ id: "pin",
77
+ label: "Pin to chat",
78
+ run: () => {
79
+ this.deps.pinReport({ title: "export", lines: [this.lastNote] });
80
+ return ["pinned to the chat transcript"];
81
+ },
82
+ onDone: () => this.host.requestRender(),
83
+ }),
84
+ ];
85
+ return new SettingsList(items, MAX_VISIBLE, this.settingsTheme, (id, value) => this.onChange(id, value), () => this.host.back(), {
86
+ enableSearch: false,
87
+ });
88
+ }
89
+
90
+ private onChange(id: string, value: string): void {
91
+ if (id === "format") this.format = value as ExportFormat;
92
+ else if (id === "range") this.range = value as ExportRange;
93
+
94
+ this.list = this.buildList();
95
+ this.list.selectItem(id);
96
+ this.host.requestRender();
97
+ }
98
+
99
+ invalidate(): void {
100
+ this.list.invalidate();
101
+ }
102
+
103
+ render(width: number): string[] {
104
+ return this.list.render(width);
105
+ }
106
+
107
+ handleInput(data: string): void {
108
+ this.list.handleInput(data);
109
+ }
110
+
111
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
112
+ return this.list.handleMouse?.(event);
113
+ }
114
+ }
115
+
116
+ /** `openKankakuPanel`'s `screens.export` factory; see {@link ExportScreenDeps}. */
117
+ export function createExportScreen(deps: ExportScreenDeps): PanelScreenFactory {
118
+ return (host: PanelHost) => new ExportScreenComponent(deps, host);
119
+ }