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 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 back
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
5
  "license": "MIT",
6
6
  "author": "soyunninja",
@@ -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 lines: string[] = [];
185
- lines.push(this.theme.bold(this.theme.fg("accent", panelTitle(screen))));
186
- lines.push("");
187
- lines.push(...this.body.render(width));
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
- // 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);
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
- return this.body.handleMouse?.(event);
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
- if (hint.key === "esc") {
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
- // 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 });
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) => submenu(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
- /** `kankaku` at root, `kankaku · <Screen>` on every other screen. */
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 "kankaku";
89
- return `kankaku · ${SCREEN_TITLES[screen]}`;
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` behave — back
101
- * at root closes the panel outright, so root shows only `esc close`; every
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