kankaku-pi 1.0.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 +21 -0
- package/README.md +1438 -0
- package/dist/adapters/cached-catalog.d.ts +42 -0
- package/dist/adapters/cached-catalog.js +121 -0
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/file-modes.d.ts +20 -0
- package/dist/adapters/file-modes.js +34 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -0
- package/dist/adapters/hub-credentials.d.ts +35 -0
- package/dist/adapters/hub-credentials.js +58 -0
- package/dist/adapters/jsonl-work-log.d.ts +20 -0
- package/dist/adapters/jsonl-work-log.js +62 -0
- package/dist/adapters/kankaku-dir.d.ts +38 -0
- package/dist/adapters/kankaku-dir.js +85 -0
- package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
- package/dist/adapters/lazy-jsonl-work-log.js +31 -0
- package/dist/adapters/pocketbase-catalog.d.ts +16 -0
- package/dist/adapters/pocketbase-catalog.js +56 -0
- package/dist/adapters/pocketbase-client.d.ts +81 -0
- package/dist/adapters/pocketbase-client.js +148 -0
- package/dist/adapters/pocketbase-sink.d.ts +53 -0
- package/dist/adapters/pocketbase-sink.js +181 -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 +114 -0
- package/dist/adapters/sync-runner.js +273 -0
- package/dist/adapters/sync-state-store.d.ts +62 -0
- package/dist/adapters/sync-state-store.js +188 -0
- package/dist/config.d.ts +168 -0
- package/dist/config.js +392 -0
- package/dist/domain/ancestry-match.d.ts +49 -0
- package/dist/domain/ancestry-match.js +82 -0
- package/dist/domain/client-label.d.ts +28 -0
- package/dist/domain/client-label.js +44 -0
- package/dist/domain/day.d.ts +2 -0
- package/dist/domain/day.js +8 -0
- package/dist/domain/export.d.ts +38 -0
- package/dist/domain/export.js +68 -0
- package/dist/domain/hub-entry.d.ts +234 -0
- package/dist/domain/hub-entry.js +265 -0
- package/dist/domain/index.d.ts +19 -0
- package/dist/domain/index.js +19 -0
- package/dist/domain/intervals.d.ts +17 -0
- package/dist/domain/intervals.js +43 -0
- package/dist/domain/registry-health.d.ts +49 -0
- package/dist/domain/registry-health.js +58 -0
- package/dist/domain/segment-rule.d.ts +10 -0
- package/dist/domain/segment-rule.js +1 -0
- package/dist/domain/subagent-profile.d.ts +278 -0
- package/dist/domain/subagent-profile.js +418 -0
- package/dist/domain/sync-plan.d.ts +151 -0
- package/dist/domain/sync-plan.js +196 -0
- package/dist/domain/task-view.d.ts +117 -0
- package/dist/domain/task-view.js +428 -0
- package/dist/domain/work-record.d.ts +236 -0
- package/dist/domain/work-record.js +91 -0
- package/dist/domain/work-target.d.ts +101 -0
- package/dist/domain/work-target.js +149 -0
- package/dist/domain/work-tracker.d.ts +90 -0
- package/dist/domain/work-tracker.js +405 -0
- package/dist/hub/index.d.ts +25 -0
- package/dist/hub/index.js +25 -0
- package/dist/ports/catalog.d.ts +31 -0
- package/dist/ports/catalog.js +1 -0
- package/dist/ports/clock.d.ts +3 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/index.d.ts +11 -0
- package/dist/ports/index.js +1 -0
- package/dist/ports/inflight-store.d.ts +15 -0
- package/dist/ports/inflight-store.js +1 -0
- package/dist/ports/process-registry.d.ts +72 -0
- package/dist/ports/process-registry.js +1 -0
- package/dist/ports/work-log.d.ts +14 -0
- package/dist/ports/work-log.js +1 -0
- package/dist/ports/work-sink.d.ts +39 -0
- package/dist/ports/work-sink.js +1 -0
- package/package.json +66 -0
- package/src/adapters/agent-info.ts +86 -0
- package/src/adapters/ancestry.ts +260 -0
- package/src/adapters/cached-catalog.ts +147 -0
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +115 -0
- package/src/adapters/file-modes.ts +35 -0
- package/src/adapters/hub-actions.ts +82 -0
- package/src/adapters/hub-credentials.ts +95 -0
- package/src/adapters/jsonl-work-log.ts +67 -0
- package/src/adapters/kankaku-command.ts +717 -0
- package/src/adapters/kankaku-dir.ts +102 -0
- package/src/adapters/lazy-file-inflight-store.ts +43 -0
- package/src/adapters/lazy-jsonl-work-log.ts +39 -0
- package/src/adapters/machine-process-registry.ts +256 -0
- package/src/adapters/panel/kankaku-panel.ts +419 -0
- package/src/adapters/panel/panel-items.ts +87 -0
- package/src/adapters/panel/panel-lines.ts +13 -0
- package/src/adapters/panel/panel-theme.ts +32 -0
- package/src/adapters/panel/screens/about.ts +69 -0
- package/src/adapters/panel/screens/doctor.ts +89 -0
- package/src/adapters/panel/screens/export.ts +123 -0
- package/src/adapters/panel/screens/report.ts +143 -0
- package/src/adapters/panel/screens/sync.ts +136 -0
- package/src/adapters/panel/screens/target.ts +384 -0
- package/src/adapters/pi-tracker.ts +753 -0
- package/src/adapters/pocketbase-catalog.ts +89 -0
- package/src/adapters/pocketbase-client.ts +197 -0
- package/src/adapters/pocketbase-sink.ts +236 -0
- package/src/adapters/process-identity-memo.ts +102 -0
- package/src/adapters/process-identity.ts +162 -0
- package/src/adapters/project-config.ts +116 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +335 -0
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/session-dir.ts +28 -0
- package/src/adapters/session-target.ts +431 -0
- package/src/adapters/status-bar.ts +86 -0
- package/src/adapters/subagent-startup.ts +66 -0
- package/src/adapters/sync-runner.ts +340 -0
- package/src/adapters/sync-state-store.ts +227 -0
- package/src/adapters/target-picker.ts +127 -0
- package/src/config.ts +536 -0
- package/src/domain/ancestry-match.ts +84 -0
- package/src/domain/client-label.ts +56 -0
- package/src/domain/day.ts +8 -0
- package/src/domain/export.ts +107 -0
- package/src/domain/hub-entry.ts +433 -0
- package/src/domain/index.ts +19 -0
- package/src/domain/intervals.ts +53 -0
- package/src/domain/panel-model.ts +270 -0
- package/src/domain/registry-health.ts +87 -0
- package/src/domain/segment-rule.ts +10 -0
- package/src/domain/subagent-profile.ts +495 -0
- package/src/domain/sync-plan.ts +266 -0
- package/src/domain/task-view.ts +526 -0
- package/src/domain/work-record.ts +320 -0
- package/src/domain/work-target.ts +234 -0
- package/src/domain/work-tracker.ts +485 -0
- package/src/extension.ts +346 -0
- package/src/hub/index.ts +25 -0
- package/src/ports/catalog.ts +33 -0
- package/src/ports/clock.ts +3 -0
- package/src/ports/index.ts +11 -0
- package/src/ports/inflight-store.ts +16 -0
- package/src/ports/process-registry.ts +75 -0
- package/src/ports/work-log.ts +15 -0
- package/src/ports/work-sink.ts +35 -0
|
@@ -0,0 +1,419 @@
|
|
|
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, truncateToWidth, 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
|
+
* 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;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface KankakuPanelDeps {
|
|
57
|
+
hubConfigured: boolean;
|
|
58
|
+
screens: Partial<Record<Exclude<PanelScreenId, "root">, PanelScreenFactory>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type PanelBody = Component & { dispose?(): void; searchable?: boolean };
|
|
62
|
+
|
|
63
|
+
interface HintSpan {
|
|
64
|
+
start: number;
|
|
65
|
+
end: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const HINT_SEPARATOR = " · ";
|
|
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
|
+
|
|
111
|
+
/** Placeholder body for a screen id with no registered factory yet (P2–P4 fill these in). */
|
|
112
|
+
function comingSoonBody(theme: Theme): PanelBody {
|
|
113
|
+
return new Text(theme.fg("muted", "coming soon"));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Builds one root-menu row per {@link rootMenu} item. */
|
|
117
|
+
function buildRootItems(hubConfigured: boolean): SelectItem[] {
|
|
118
|
+
return rootMenu({ hubConfigured }).map((item) => ({
|
|
119
|
+
value: item.id,
|
|
120
|
+
label: item.label,
|
|
121
|
+
description: item.description,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Render the footer hint line and, alongside it, the clickable column range
|
|
127
|
+
* of each hint (in local, cell-based coordinates — see
|
|
128
|
+
* `TuiMouseEvent.x`), so `handleMouse` can hit-test a click without
|
|
129
|
+
* re-deriving the layout from ANSI-colored text.
|
|
130
|
+
*/
|
|
131
|
+
function renderFooter(hints: PanelHint[], theme: Theme, hoveredIndex: number | undefined): { line: string; spans: HintSpan[] } {
|
|
132
|
+
let column = 0;
|
|
133
|
+
let line = "";
|
|
134
|
+
const spans: HintSpan[] = [];
|
|
135
|
+
|
|
136
|
+
hints.forEach((hint, index) => {
|
|
137
|
+
if (index > 0) {
|
|
138
|
+
line += theme.fg("muted", HINT_SEPARATOR);
|
|
139
|
+
column += visibleWidth(HINT_SEPARATOR);
|
|
140
|
+
}
|
|
141
|
+
const text = `${hint.key} ${hint.label}`;
|
|
142
|
+
const start = column;
|
|
143
|
+
line += index === hoveredIndex ? theme.inverse(text) : `${theme.fg("accent", hint.key)} ${theme.fg("muted", hint.label)}`;
|
|
144
|
+
column += visibleWidth(text);
|
|
145
|
+
spans.push({ start, end: column });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return { line, spans };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class KankakuPanelComponent implements Component {
|
|
152
|
+
private readonly tui: TUI;
|
|
153
|
+
private readonly theme: Theme;
|
|
154
|
+
private readonly deps: KankakuPanelDeps;
|
|
155
|
+
private readonly done: (result: void) => void;
|
|
156
|
+
private readonly host: PanelHost;
|
|
157
|
+
|
|
158
|
+
private nav: PanelNav;
|
|
159
|
+
private body: PanelBody;
|
|
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. */
|
|
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;
|
|
164
|
+
private hints: PanelHint[] = [];
|
|
165
|
+
private hintSpans: HintSpan[] = [];
|
|
166
|
+
/** -1 until the first `render()`, so a mouse event that arrives before any render never mismatches row 0 for the footer. */
|
|
167
|
+
private footerRowIndex = -1;
|
|
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;
|
|
176
|
+
|
|
177
|
+
constructor(tui: TUI, theme: Theme, deps: KankakuPanelDeps, done: (result: void) => void) {
|
|
178
|
+
this.tui = tui;
|
|
179
|
+
this.theme = theme;
|
|
180
|
+
this.deps = deps;
|
|
181
|
+
this.done = done;
|
|
182
|
+
this.host = {
|
|
183
|
+
tui,
|
|
184
|
+
theme,
|
|
185
|
+
push: (id) => this.pushScreen(id),
|
|
186
|
+
back: () => this.goBack(),
|
|
187
|
+
close: () => this.closePanel(),
|
|
188
|
+
requestRender: () => this.tui.requestRender(),
|
|
189
|
+
setBodyWantsText: (flag) => {
|
|
190
|
+
this.bodyWantsText = flag;
|
|
191
|
+
},
|
|
192
|
+
setBodyCapturesEscape: (flag) => {
|
|
193
|
+
this.bodyCapturesEscape = flag;
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
this.nav = navRoot();
|
|
197
|
+
this.body = this.createBody("root");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private createBody(id: PanelScreenId): PanelBody {
|
|
201
|
+
if (id === "root") {
|
|
202
|
+
const list = new SelectList(buildRootItems(this.deps.hubConfigured), 10, buildSelectListTheme(this.theme));
|
|
203
|
+
list.onSelect = (item) => this.pushScreen(item.value as PanelScreenId);
|
|
204
|
+
list.onCancel = () => this.goBack();
|
|
205
|
+
return list;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const factory = this.deps.screens[id];
|
|
209
|
+
return factory ? factory(this.host) : comingSoonBody(this.theme);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private pushScreen(id: PanelScreenId): void {
|
|
213
|
+
this.body.dispose?.();
|
|
214
|
+
this.nav = navPush(this.nav, id);
|
|
215
|
+
this.body = this.createBody(id);
|
|
216
|
+
this.bodyWantsText = false;
|
|
217
|
+
this.bodyCapturesEscape = false;
|
|
218
|
+
this.hoveredHintIndex = undefined;
|
|
219
|
+
this.tui.requestRender();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private goBack(): void {
|
|
223
|
+
const { nav, closed } = navBack(this.nav);
|
|
224
|
+
if (closed) {
|
|
225
|
+
this.closePanel();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.body.dispose?.();
|
|
229
|
+
this.nav = nav;
|
|
230
|
+
this.body = this.createBody(navCurrent(nav));
|
|
231
|
+
this.bodyWantsText = false;
|
|
232
|
+
this.bodyCapturesEscape = false;
|
|
233
|
+
this.hoveredHintIndex = undefined;
|
|
234
|
+
this.tui.requestRender();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private closePanel(): void {
|
|
238
|
+
this.body.dispose?.();
|
|
239
|
+
this.done(undefined);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
invalidate(): void {
|
|
243
|
+
this.body.invalidate();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
dispose(): void {
|
|
247
|
+
this.body.dispose?.();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
render(width: number): string[] {
|
|
251
|
+
const screen = navCurrent(this.nav);
|
|
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);
|
|
271
|
+
lines.push("");
|
|
272
|
+
|
|
273
|
+
const { line, spans } = renderFooter(this.hints, this.theme, this.hoveredHintIndex);
|
|
274
|
+
this.hintSpans = spans;
|
|
275
|
+
this.footerRowIndex = lines.length;
|
|
276
|
+
lines.push(line);
|
|
277
|
+
|
|
278
|
+
return lines;
|
|
279
|
+
}
|
|
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
|
+
|
|
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.
|
|
327
|
+
if (matchesKey(data, Key.escape)) {
|
|
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");
|
|
341
|
+
} else {
|
|
342
|
+
this.goBack();
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (data === "q" && !this.bodyWantsText) {
|
|
347
|
+
this.closePanel();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
this.body.handleInput?.(data);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
354
|
+
if (event.y === this.footerRowIndex) {
|
|
355
|
+
return this.handleFooterMouse(event);
|
|
356
|
+
}
|
|
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
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private handleFooterMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
375
|
+
const hitIndex = this.hintSpans.findIndex((span) => event.x >= span.start && event.x < span.end);
|
|
376
|
+
|
|
377
|
+
if (event.type === "move") {
|
|
378
|
+
if (hitIndex !== this.hoveredHintIndex) {
|
|
379
|
+
this.hoveredHintIndex = hitIndex === -1 ? undefined : hitIndex;
|
|
380
|
+
return { handled: true, render: true };
|
|
381
|
+
}
|
|
382
|
+
return { handled: true, render: false };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (event.type === "click" && event.button === "left" && hitIndex !== -1) {
|
|
386
|
+
const hint = this.hints[hitIndex]!;
|
|
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")) {
|
|
390
|
+
this.goBack();
|
|
391
|
+
return { handled: true };
|
|
392
|
+
}
|
|
393
|
+
if (hint.key === "q") {
|
|
394
|
+
this.closePanel();
|
|
395
|
+
return { handled: true };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return { handled: true, render: false };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Builds the panel shell component; see {@link openKankakuPanel} for how it is mounted. */
|
|
404
|
+
export function createPanelComponent(tui: TUI, theme: Theme, deps: KankakuPanelDeps, done: (result: void) => void): Component & { dispose?(): void } {
|
|
405
|
+
return new KankakuPanelComponent(tui, theme, deps, done);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Opens the `/kankaku` panel as a focused overlay. A no-op when no UI is
|
|
410
|
+
* attached (print/RPC mode) — the caller (`kankaku-command.ts`) falls back
|
|
411
|
+
* to the existing report notification in that case.
|
|
412
|
+
*/
|
|
413
|
+
export async function openKankakuPanel(ctx: ExtensionContext, deps: KankakuPanelDeps): Promise<void> {
|
|
414
|
+
if (!ctx.hasUI) return;
|
|
415
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => createPanelComponent(tui, theme, deps, done), {
|
|
416
|
+
overlay: true,
|
|
417
|
+
overlayOptions: { width: "80%", minWidth: 64, maxHeight: "80%", anchor: "center" },
|
|
418
|
+
});
|
|
419
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
/** 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;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class ActionItemComponent implements Component {
|
|
28
|
+
private readonly theme: SettingsListTheme;
|
|
29
|
+
private readonly options: ActionItemOptions;
|
|
30
|
+
private readonly done: (selectedValue?: string, options?: { navigateTo?: string }) => void;
|
|
31
|
+
private lines: string[] = [];
|
|
32
|
+
private pending = true;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
theme: SettingsListTheme,
|
|
36
|
+
options: ActionItemOptions,
|
|
37
|
+
done: (selectedValue?: string, options?: { navigateTo?: string }) => void,
|
|
38
|
+
) {
|
|
39
|
+
this.theme = theme;
|
|
40
|
+
this.options = options;
|
|
41
|
+
this.done = done;
|
|
42
|
+
this.options.onOpen?.();
|
|
43
|
+
void this.start();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private async start(): Promise<void> {
|
|
47
|
+
try {
|
|
48
|
+
this.lines = await this.options.run();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
51
|
+
this.lines = [`error: ${message}`];
|
|
52
|
+
} finally {
|
|
53
|
+
this.pending = false;
|
|
54
|
+
this.options.onDone?.();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
invalidate(): void {}
|
|
59
|
+
|
|
60
|
+
render(_width: number): string[] {
|
|
61
|
+
return this.pending ? [this.theme.hint(PENDING_LINE)] : this.lines;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
handleInput(data: string): void {
|
|
65
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) {
|
|
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();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build a `SettingItem` whose submenu is a busy/result/error view over `options.run`. See the module doc comment. */
|
|
79
|
+
export function actionItem(theme: SettingsListTheme, options: ActionItemOptions): SettingItem {
|
|
80
|
+
return {
|
|
81
|
+
id: options.id,
|
|
82
|
+
label: options.label,
|
|
83
|
+
currentValue: "",
|
|
84
|
+
...(options.description !== undefined ? { description: options.description } : {}),
|
|
85
|
+
submenu: (_currentValue, done) => new ActionItemComponent(theme, options, done),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -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
|
+
}
|