kankaku 0.7.0 → 0.7.1
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 +33 -2
- package/package.json +1 -1
- 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/domain/panel-model.ts +10 -7
package/README.md
CHANGED
|
@@ -41,6 +41,37 @@ trusted, which a subagent child may not inherit.
|
|
|
41
41
|
|
|
42
42
|
To try it without installing: `pi -e /absolute/path/to/kankaku`.
|
|
43
43
|
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
Once installed, kankaku records every prompt on its own; there is nothing
|
|
47
|
+
to start. Inside pi's TUI, type `/kankaku` to open the panel, the one
|
|
48
|
+
place everything is managed from:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
╭─ >_ kankaku ─────────────────────────────────────────╮
|
|
52
|
+
│ │
|
|
53
|
+
│ → Target Billing client, project, hub task │
|
|
54
|
+
│ Report Today/all totals, tasks, sessions │
|
|
55
|
+
│ Sync Status, sync now, sync all, backfill │
|
|
56
|
+
│ Export Write today's or every task as csv │
|
|
57
|
+
│ Doctor Orphan/uncertain subagent counts │
|
|
58
|
+
│ About Versions, KANKAKU_DIR, hub URL │
|
|
59
|
+
│ │
|
|
60
|
+
│ ↑↓ move · enter open · esc close │
|
|
61
|
+
╰──────────────────────────────────────────────────────╯
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- **Target** is where you pick the client and project the time is billed
|
|
65
|
+
to and, with a hub, the task you are working on right now.
|
|
66
|
+
- **Report** shows today's work, waiting and cost, per task or grouped by
|
|
67
|
+
client or project.
|
|
68
|
+
- **Sync** pushes the consolidated tasks to your hub when one is configured.
|
|
69
|
+
|
|
70
|
+
Every panel action is also a subcommand (`/kankaku tasks`, `/kankaku sync`,
|
|
71
|
+
…) for scripts and headless runs — see "The `/kankaku` command" below. The
|
|
72
|
+
footer clock (`🕒 03:12 · acme`) shows the running prompt's elapsed time
|
|
73
|
+
and billing client while an agent works.
|
|
74
|
+
|
|
44
75
|
## Record schema
|
|
45
76
|
|
|
46
77
|
Each line in `worklog.jsonl` is one JSON object:
|
|
@@ -707,8 +738,8 @@ action is reachable:
|
|
|
707
738
|
- **About** — versions, the resolved `KANKAKU_DIR`, the hub URL, and every
|
|
708
739
|
env-only setting, read-only.
|
|
709
740
|
|
|
710
|
-
Keys: `↑↓` move, `Enter` open a section or select a value, `Esc` go
|
|
711
|
-
(or close the panel at the root), `q` close from anywhere. Mouse: the
|
|
741
|
+
Keys: `↑↓` move, `Enter` open a section or select a value, `Esc` or `←` go
|
|
742
|
+
back (or close the panel at the root), `q` close from anywhere. Mouse: the
|
|
712
743
|
footer hints and list rows are clickable, but only in pi's fullscreen
|
|
713
744
|
mode — pi does not dispatch mouse events in its regular (non-fullscreen)
|
|
714
745
|
mode, so there the panel is keyboard-only.
|
package/package.json
CHANGED
|
@@ -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 };
|
|
@@ -83,10 +83,13 @@ const SCREEN_TITLES: Record<Exclude<PanelScreenId, "root">, string> = {
|
|
|
83
83
|
about: "About",
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
-
/**
|
|
86
|
+
/** The prompt glyph that opens every panel title, the owner's mark for kankaku. */
|
|
87
|
+
export const PANEL_TITLE_PREFIX = ">_";
|
|
88
|
+
|
|
89
|
+
/** `>_ kankaku` at root, `>_ kankaku · <Screen>` on every other screen. */
|
|
87
90
|
export function panelTitle(screen: PanelScreenId): string {
|
|
88
|
-
if (screen === "root") return
|
|
89
|
-
return
|
|
91
|
+
if (screen === "root") return `${PANEL_TITLE_PREFIX} kankaku`;
|
|
92
|
+
return `${PANEL_TITLE_PREFIX} kankaku · ${SCREEN_TITLES[screen]}`;
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
/** One clickable/keyboard hint shown in the panel's footer. */
|
|
@@ -97,9 +100,9 @@ export interface PanelHint {
|
|
|
97
100
|
|
|
98
101
|
/**
|
|
99
102
|
* The footer hint row for a screen: navigation hints, an optional search
|
|
100
|
-
* hint when the current body supports it, and how Escape/`q`
|
|
101
|
-
* at root closes the panel outright, so root shows only
|
|
102
|
-
* other screen shows both `esc back` and `q close`.
|
|
103
|
+
* hint when the current body supports it, and how Escape/left arrow/`q`
|
|
104
|
+
* behave — back at root closes the panel outright, so root shows only
|
|
105
|
+
* `esc close`; every other screen shows both `esc/← back` and `q close`.
|
|
103
106
|
*/
|
|
104
107
|
export function footerHints(screen: PanelScreenId, options: { searchable: boolean }): PanelHint[] {
|
|
105
108
|
const hints: PanelHint[] = [{ key: "↑↓", label: "move" }];
|
|
@@ -113,7 +116,7 @@ export function footerHints(screen: PanelScreenId, options: { searchable: boolea
|
|
|
113
116
|
|
|
114
117
|
hints.push({ key: "enter", label: "select" });
|
|
115
118
|
if (options.searchable) hints.push({ key: "/", label: "search" });
|
|
116
|
-
hints.push({ key: "esc", label: "back" }, { key: "q", label: "close" });
|
|
119
|
+
hints.push({ key: "esc/←", label: "back" }, { key: "q", label: "close" });
|
|
117
120
|
return hints;
|
|
118
121
|
}
|
|
119
122
|
|