@bismawy/pi-arnative 0.2.7 → 0.2.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.9] - 2026-09-30
4
+
5
+ ### Fixed
6
+ - The startup `[Context]`, `[Skills]`, `[Extensions]` and `[Themes]` lists stacked under the header again on pi ≥ 0.99: pi replaced `ExpandableText`'s `getCollapsedText`/`getExpandedText` fields with a `build` callback plus a `state` object, so the section detector matched nothing and the tabs stayed at `[0]`. Both shapes are read now (`sectionBodyOf`), and the same applies to the `[Extension issues]` box, which read the stale `child.text` snapshot instead of the theme-aware `build()` output.
7
+ - Fixed NPM version badge in `README.md` to reference `@bismawy/pi-arnative` instead of placeholder `react`.
8
+
9
+ ---
10
+
11
+ ## [0.2.8] - 2026-09-28
12
+
13
+ ### Fixed
14
+ - Custom `ctrl+alt+<letter>` shortcuts (`t` next tab, `n` new session, `r` reload) never fired on Windows: Windows Terminal ≥ 1.24 aliases every `Ctrl+Alt` combo to AltGr ([microsoft/terminal#20052](https://github.com/microsoft/terminal/pull/20052)), so the legacy `ESC` + control-byte encoding is never emitted. All of them now also accept `alt+<letter>` on Windows **and WSL** (same input translation), matching pi's own convention (`alt+v`, `alt+p`). Linux keeps `ctrl+alt`, macOS keeps `ctrl+option` (plain Option composes characters there).
15
+ - Shortcut ids, platform variants and cheatsheet labels now come from one place (`lib/shortcuts.ts`) instead of being spelled out per extension; the label is derived from the key id that is actually registered, so a cheatsheet can no longer advertise a key the platform never binds.
16
+ - README: platform notes for Windows/WSL, macOS, Linux desktops and multiplexers, including `PI_TUI_ESC_TIMEOUT` for the intermittent “Escape + stray letter” glitch (`ESC`-prefixed shortcuts flushed by pi's ~10 ms lone-ESC timer).
17
+ - Reload state for the chat clock now lives on a `globalThis` symbol instead of module locals: `/reload` re-imports the module while the prototype patches stay on the class, so the surviving render closure and the new `session_start` handler read the same map and theme. The old shape captured `ctx` in the module, which pi rejects after reload/session replacement, and that throw surfaced as an uncaughtException during a render pass.
18
+
19
+ ---
20
+
3
21
  ## [0.2.7] - 2026-09-28
4
22
 
5
23
  ### Fixed
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # Arnative
2
2
 
3
- A visual layer and tool styling tailored to your vibe coding.
3
+ Refined aesthetics. Cohesive tools. Built for Pi.
4
4
 
5
5
  [![Custom badge](https://shieldcn.dev/badge/pi-%20Packages.svg?variant=outline&size=xs&logo=ri%3APiPiBold)](https://pi.dev/packages/@bismawy/pi-arnative)
6
- [![badge](https://shieldcn.dev/npm/react.svg?variant=outline&size=xs)](https://www.npmjs.com/package/@bismawy/pi-arnative)
6
+ [![badge](https://shieldcn.dev/npm/@bismawy/pi-arnative.svg?variant=outline&size=xs)](https://www.npmjs.com/package/@bismawy/pi-arnative)
7
7
  [![license](https://shieldcn.dev/github/bismawy/pi-arnative/license.svg?variant=outline&size=xs)](https://github.com/bismawy/pi-arnative)
8
8
 
9
9
  ![Arnative: tabbed header, rounded tool boxes, transcript clock and /usage dashboard](https://raw.githubusercontent.com/bismawy/pi-arnative/main/assets/pi-arnative.webp)
@@ -35,30 +35,30 @@ pi --extension ./extensions/footer.ts
35
35
 
36
36
  ## Shortcuts
37
37
 
38
- | Key / Action | Result |
38
+ | Key (Linux / Win) | Action |
39
39
  | --- | --- |
40
+ | `ctrl+alt+t` · `alt+t` | Cycle header tabs |
41
+ | `ctrl+alt+r` · `alt+r` | Quick reload (`/reload`) |
42
+ | `ctrl+alt+n` · `alt+n` | New session (`/new`) — requires keybinding below |
40
43
  | `/usage` | Show session usage & token metrics |
41
- | `ctrl+alt+t` | Cycle header tabs (Directory → Model → Context → Skills → Extensions → Themes → Shortcut) |
42
- | `ctrl+alt+n` | Start a new session (`/new`) — see [Keybindings](#keybindings) |
43
- | `ctrl+alt+r` | Quick reload runtime (`/reload`) |
44
44
  | Click tab | Open selected header tab |
45
45
  | Click tool / `ctrl+e` | Toggle tool output box |
46
46
 
47
- Same cheatsheet in the header's **Shortcut** tab; on narrow terminals the rightmost tabs drop off first.
47
+ > **Notes:**
48
+ > - **Windows / WSL:** Windows Terminal aliases `Ctrl+Alt` to AltGr; use `alt+…` (or set `"altGrAliasing": false` in WT profile).
49
+ > - **macOS:** uses `ctrl+option+…` (plain Option composes special characters).
50
+ > - **Herdr / tmux glitch:** if shortcuts feel delayed or type stray letters, set `PI_TUI_ESC_TIMEOUT=100` in your shell profile.
48
51
 
49
- ## Keybindings
52
+ ### Keybindings
50
53
 
51
- `ctrl+alt+n` is a plain user keybinding, not a package shortcut — pi-arnative never registers it.
52
- Add it once to `~/.pi/agent/keybindings.json`:
54
+ Add to `~/.pi/agent/keybindings.json` to bind `app.session.new`:
53
55
 
54
56
  ```json
55
57
  {
56
- "app.session.new": "ctrl+alt+n"
58
+ "app.session.new": ["ctrl+alt+n", "alt+n"]
57
59
  }
58
60
  ```
59
61
 
60
- Restart pi to pick it up.
61
-
62
62
  ## Architecture
63
63
 
64
64
  <details>
@@ -9,16 +9,17 @@
9
9
  */
10
10
  import type { AssistantMessage } from "@earendil-works/pi-ai";
11
11
  import { CustomEditor, FooterComponent, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
- import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
12
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
13
  import { execFile } from "node:child_process";
14
14
  import { stripAnsi } from "../lib/ansi.ts";
15
15
  import { assert, isMain } from "../lib/check.ts";
16
16
  import { capitalize, modelDisplayParts } from "../lib/format.ts";
17
+ import { IS_WINDOWS_LIKE, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
17
18
  import { formatTokens } from "../lib/usage-store.ts";
18
19
 
20
+ // ctrl+alt+r (alt+r on Windows/WSL — see lib/shortcuts.ts).
19
21
  // The TUI drops key-release events before handleInput, so press+release cannot
20
22
  // fire this twice even when the terminal reports event types (Kitty flag 2).
21
- const RELOAD_SHORTCUT = Key.ctrlAlt("r");
22
23
 
23
24
  // Intercept the built-in FooterComponent to force a pure 2-line footer
24
25
  const PATCHED_KEY = Symbol.for("pi-arnative.footer2LinesPatched");
@@ -464,7 +465,7 @@ export default function (pi: ExtensionAPI) {
464
465
  }
465
466
 
466
467
  handleInput(data: string) {
467
- if (matchesKey(data, RELOAD_SHORTCUT)) {
468
+ if (SHORTCUT_RELOAD.matches(data)) {
468
469
  this.setText("");
469
470
  if (this.onSubmit) {
470
471
  this.onSubmit("/reload");
@@ -642,9 +643,10 @@ if (isMain(import.meta.url)) {
642
643
 
643
644
  // Guard: the reload shortcut must match every encoding a terminal can deliver,
644
645
  // so a typo in the key id fails here instead of silently doing nothing.
645
- assert(matchesKey("\x1b\x12", RELOAD_SHORTCUT), "shortcut matches legacy ctrl+alt+r (ESC + 0x12)");
646
- assert(matchesKey("\x1b[114;7u", RELOAD_SHORTCUT), "shortcut matches Kitty CSI-u ctrl+alt+r");
647
- assert(!matchesKey("\x12", RELOAD_SHORTCUT), "plain ctrl+r does not trigger reload");
648
- assert(!matchesKey("r", RELOAD_SHORTCUT), "bare letter r does not trigger reload");
646
+ assert(SHORTCUT_RELOAD.matches("\x1b\x12"), "shortcut matches legacy ctrl+alt+r (ESC + 0x12)");
647
+ assert(SHORTCUT_RELOAD.matches("\x1b[114;7u"), "shortcut matches Kitty CSI-u ctrl+alt+r");
648
+ assert(SHORTCUT_RELOAD.matches("\x1br") === IS_WINDOWS_LIKE, "ESC + r triggers reload only where alt+r is bound");
649
+ assert(!SHORTCUT_RELOAD.matches("\x12"), "plain ctrl+r does not trigger reload");
650
+ assert(!SHORTCUT_RELOAD.matches("r"), "bare letter r does not trigger reload");
649
651
  console.log("footer.ts self-check OK");
650
652
  }
@@ -24,7 +24,6 @@ import {
24
24
  import {
25
25
  truncateToWidth,
26
26
  visibleWidth,
27
- Key,
28
27
  type Component,
29
28
  type TUI,
30
29
  type TuiMouseEvent,
@@ -34,11 +33,12 @@ import { renderBoxLines } from "../lib/box.ts";
34
33
  import { stripAnsi, themeOf } from "../lib/ansi.ts";
35
34
  import { assert, isMain } from "../lib/check.ts";
36
35
  import { capitalize, modelDisplayParts } from "../lib/format.ts";
36
+ import { SHORTCUT_NEW_SESSION, SHORTCUT_NEXT_TAB, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
37
37
  import { formatTokens, getModelAllTimeUsage } from "../lib/usage-store.ts";
38
38
 
39
39
  export type TabKey = "Model" | "Directory" | "Context" | "Skills" | "Extensions" | "Themes" | "Shortcut";
40
40
 
41
- // Tab order for Ctrl+Alt+T and for the menu line.
41
+ // Tab order for the next-tab shortcut and for the menu line.
42
42
  export const TAB_ORDER: TabKey[] = ["Directory", "Model", "Context", "Skills", "Extensions", "Themes", "Shortcut"];
43
43
 
44
44
  export const SECTION_ICONS: Record<TabKey, string> = {
@@ -72,6 +72,28 @@ export function fgFirst(th: Themeish, names: string[], text: string): string {
72
72
  return text;
73
73
  }
74
74
 
75
+ /**
76
+ * Collapsed body of a pi startup section ("[Skills]\n a, b"). pi 0.99 dropped
77
+ * ExpandableText's own getCollapsedText/getExpandedText fields for a `build` callback
78
+ * plus a `state` object, so both shapes are read here and older pi keeps working.
79
+ * Returns null when the child is not an expandable section.
80
+ */
81
+ export function sectionBodyOf(child: any): string | null {
82
+ if (typeof child?.getCollapsedText === "function") return String(child.getCollapsedText());
83
+ if (typeof child?.build === "function" && child?.state && typeof child.state === "object") {
84
+ return String(child.build());
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /** Text of a themed text line, whichever pi version built it (pi 0.99 defers it to `build`). */
90
+ export function themedTextOf(child: any): string {
91
+ if (typeof child?.build === "function") return String(child.build());
92
+ const direct = child?.text ?? child?.content;
93
+ if (typeof direct === "string" && direct !== "") return direct;
94
+ return typeof child?.getText === "function" ? String(child.getText()) : "";
95
+ }
96
+
75
97
  export function sectionNameOf(text: string): "Context" | "Skills" | "Extensions" | "Themes" | null {
76
98
  const nl = text.indexOf("\n");
77
99
  const first = stripAnsi(nl === -1 ? text : text.slice(0, nl)).trim();
@@ -176,9 +198,9 @@ export const SHORTCUTS: string[] = [
176
198
  "[/] Commands",
177
199
  "[!] Bash",
178
200
  "[Ctrl+o] More/expand",
179
- "[Ctrl+alt+t] Next tab",
180
- "[Ctrl+alt+n] New",
181
- "[Ctrl+alt+r] Reload",
201
+ `[${SHORTCUT_NEXT_TAB.label}] Next tab`,
202
+ `[${SHORTCUT_NEW_SESSION.label}] New`,
203
+ `[${SHORTCUT_RELOAD.label}] Reload`,
182
204
  ];
183
205
 
184
206
  export function wrapCommaItems(items: string[], maxWidth: number): string[] {
@@ -587,20 +609,19 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
587
609
  const origAddChild = containerProto.addChild;
588
610
  containerProto.addChild = function (child: any): unknown {
589
611
  try {
590
- const sectionName = sectionNameOf(String(child?.getCollapsedText?.() ?? ""));
612
+ const body = sectionBodyOf(child);
613
+ const sectionName = body === null ? null : sectionNameOf(body);
591
614
  const isSection =
592
- child &&
593
- typeof child.getCollapsedText === "function" &&
594
- typeof child.getExpandedText === "function" &&
615
+ body !== null &&
595
616
  typeof child.setText === "function" &&
617
+ typeof child.render === "function" &&
596
618
  (sectionName === null ? false : SECTION_ICONS[sectionName] !== undefined);
597
619
 
598
- if (isSection && sectionName) {
620
+ if (isSection && sectionName && body !== null) {
599
621
  // Mark this container as the loadedResourcesContainer
600
622
  (this as Record<string, unknown>)._isLoadedResourcesContainer = true;
601
623
 
602
- const origCollapsed = child.getCollapsedText.bind(child) as () => string;
603
- const rawBody = origCollapsed().split("\n").slice(1).join("\n");
624
+ const rawBody = body.split("\n").slice(1).join("\n");
604
625
  const items = extractItemsFromBody(rawBody, sectionName === "Extensions");
605
626
  tabStore.set(sectionName, items);
606
627
 
@@ -613,7 +634,7 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
613
634
  }
614
635
  } else if ((this as Record<string, unknown>)._isLoadedResourcesContainer) {
615
636
  // pi core's "[Extension issues]" -> wrap in an arnative box.
616
- const text = String(child?.text ?? child?.content ?? (typeof child?.getText === "function" ? child.getText() : ""));
637
+ const text = themedTextOf(child);
617
638
  if (text.includes("[Extension issues]") && typeof child.render === "function") {
618
639
  const origRender = child.render.bind(child);
619
640
  child.render = (w: number) => renderBoxLines(activeThemeProxy, w, origRender(w), undefined, "warning");
@@ -633,17 +654,17 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
633
654
  }
634
655
 
635
656
  export default function (pi: ExtensionAPI) {
636
- // ctrl+alt+t: next tab.
637
- pi.registerShortcut(Key.ctrlAlt("t"), {
638
- description: "Next header tab",
639
- handler: async () => {
640
- const hdr = activeHeaderInstance;
641
- if (!hdr) return;
642
- const order = TAB_ORDER;
643
- hdr.activeTab = order[(order.indexOf(hdr.activeTab) + 1) % order.length]!;
644
- hdr.tui?.requestRender?.();
645
- },
646
- });
657
+ // Next header tab — every platform variant of the shortcut (lib/shortcuts.ts).
658
+ const nextTab = async () => {
659
+ const hdr = activeHeaderInstance;
660
+ if (!hdr) return;
661
+ const order = TAB_ORDER;
662
+ hdr.activeTab = order[(order.indexOf(hdr.activeTab) + 1) % order.length]!;
663
+ hdr.tui?.requestRender?.();
664
+ };
665
+ for (const key of SHORTCUT_NEXT_TAB.keys) {
666
+ pi.registerShortcut(key, { description: "Next header tab", handler: nextTab });
667
+ }
647
668
 
648
669
  pi.on("session_start", async (_event, ctx) => {
649
670
  activeThemeProxy = themeOf<Themeish>(ctx) ?? activeThemeProxy;
@@ -860,7 +881,10 @@ if (isMain(import.meta.url)) {
860
881
  );
861
882
  const scContent = stripAnsi(linesShortcut.slice(6).join(" "));
862
883
  assert(scContent.includes("[Esc] Interrupt"), "Shortcut tab: legend moved into the content");
863
- assert(scContent.includes("[Ctrl+alt+r] Reload"), "Shortcut tab: reload shortcut present in the content");
884
+ assert(
885
+ scContent.includes(`[${SHORTCUT_RELOAD.label}] Reload`),
886
+ `Shortcut tab: reload shortcut present in the content (${SHORTCUT_RELOAD.label})`,
887
+ );
864
888
  assert(stripAnsi(linesShortcut[2]!).includes("Arnative v"), "line 2 keeps the Arnative brand while the Shortcut tab is active");
865
889
  hdr.activeTab = "Model";
866
890
  hdr.render(140);
@@ -890,5 +914,22 @@ if (isMain(import.meta.url)) {
890
914
  assert(fakeSection.render(80).length === 0, "child of loadedResourcesContainer suppressed (render = [])");
891
915
  assert(tabStore.get("Skills")?.includes("new-skill-1") === true, "new data lands in tabStore");
892
916
 
917
+ // pi 0.99 ExpandableText shape: `build` callback + `state` object, no getters.
918
+ const modernSection = {
919
+ state: { expanded: false },
920
+ build: () => "[Themes]\n arnative-sun, arnative-dusk",
921
+ setText: () => {},
922
+ render: (_w: number) => ["should be hidden"],
923
+ };
924
+ container.addChild(modernSection);
925
+ assert(modernSection.render(80).length === 0, "pi 0.99 section suppressed (build + state shape)");
926
+ assert(tabStore.get("Themes")?.includes("arnative-sun") === true, "pi 0.99 section data lands in tabStore");
927
+
928
+ // Startup help block is not a resource section and must survive untouched.
929
+ const helpBlock = { build: () => "\u2580\u2580\u2588 v0.99.1\n\u2588\u2580 \u2588 escape interrupt", state: { expanded: false }, render: () => ["help"] };
930
+ container.addChild(helpBlock);
931
+ assert(sectionBodyOf(helpBlock) === "\u2580\u2580\u2588 v0.99.1\n\u2588\u2580 \u2588 escape interrupt", "sectionBodyOf reads the build shape");
932
+ assert(sectionNameOf(sectionBodyOf(helpBlock)!) === null, "startup help block is not a resource section");
933
+
893
934
  console.log("section-headers.ts self-check OK");
894
935
  }
@@ -15,9 +15,19 @@ import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
15
15
  import { ansiBgOpen, stripAnsi, themeOf } from "../lib/ansi.ts";
16
16
  import { assert, isMain } from "../lib/check.ts";
17
17
 
18
- const USER_TIMESTAMPS_MAP = new Map<string, number>();
19
- let getBranch: (() => readonly unknown[]) | null = null;
20
- let prefilled = false;
18
+ // Shared across reloads: /reload re-imports this module (moduleCache: false), but the
19
+ // prototype patches below survive on the class, so the patched closure and the newly
20
+ // registered handlers must read the SAME map and theme. Module-local state would leave
21
+ // the closure on stale data — and a captured ctx even on an invalidated one (pi throws
22
+ // "stale after session replacement or reload" inside the render pass -> uncaughtException).
23
+ type ThemeProxy = { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null;
24
+ const TS_STATE_KEY = Symbol.for("pi-arnative.chatTimestampState");
25
+ const tsState = ((globalThis as Record<symbol, unknown>)[TS_STATE_KEY] ??= {
26
+ patched: false,
27
+ map: new Map<string, number>(),
28
+ theme: null as ThemeProxy,
29
+ }) as { patched: boolean; map: Map<string, number>; theme: ThemeProxy };
30
+ const USER_TIMESTAMPS_MAP = tsState.map;
21
31
 
22
32
  function userTextOf(message: { content?: unknown }): string {
23
33
  const c = message.content;
@@ -44,8 +54,6 @@ function prefillUserTimestamps(entries: readonly unknown[]): void {
44
54
  if (text) USER_TIMESTAMPS_MAP.set(text, m.timestamp || Date.parse(entry.timestamp ?? "") || Date.now());
45
55
  }
46
56
  }
47
- let activeThemeProxy: { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null = null;
48
-
49
57
  function formatClock(timestamp?: number): string {
50
58
  const d = timestamp && timestamp > 0 ? new Date(timestamp) : new Date();
51
59
  const h = String(d.getHours()).padStart(2, "0");
@@ -98,9 +106,8 @@ export function placeTimeAtRight(
98
106
  return `${leftPart}${bg}${pad}${timeBadge} ${reset}`;
99
107
  }
100
108
 
101
- const CHAT_TIMESTAMP_PATCHED = Symbol.for("pi-arnative.chatTimestampPatched");
102
- if (!(globalThis as Record<symbol, boolean>)[CHAT_TIMESTAMP_PATCHED]) {
103
- (globalThis as Record<symbol, boolean>)[CHAT_TIMESTAMP_PATCHED] = true;
109
+ if (!tsState.patched) {
110
+ tsState.patched = true;
104
111
 
105
112
  if (UserMessageComponent?.prototype?.render) {
106
113
  const origRender = UserMessageComponent.prototype.render;
@@ -110,22 +117,15 @@ if (!(globalThis as Record<symbol, boolean>)[CHAT_TIMESTAMP_PATCHED]) {
110
117
 
111
118
  // Fix the bubble bg first (reset Markdown lines make padding dark),
112
119
  // then paste the clock (which deliberately drops the bg in the badge area).
113
- const bgOpen = ansiBgOpen(activeThemeProxy, "userMessageBg");
120
+ const bgOpen = ansiBgOpen(tsState.theme, "userMessageBg");
114
121
  for (let i = 0; i < lines.length; i++) lines[i] = repairBubbleBg(lines[i], bgOpen);
115
122
 
116
- // Restore/reload safe: old user messages arrive without message_start events,
117
- // so fill the map once from the session history before the first lookup.
118
- if (!prefilled && getBranch) {
119
- prefillUserTimestamps(getBranch());
120
- prefilled = true;
121
- }
122
-
123
123
  const textKey = (this as { text?: string }).text?.trim() ?? "";
124
124
  const ts = USER_TIMESTAMPS_MAP.get(textKey);
125
125
  // No known timestamp -> no clock (a "now" clock would be a lie on old messages).
126
126
  if (!ts) return lines;
127
127
  const timeStr = formatClock(ts);
128
- const badge = formatTimeBadge(timeStr, activeThemeProxy);
128
+ const badge = formatTimeBadge(timeStr, tsState.theme);
129
129
  const timeW = visibleWidth(timeStr);
130
130
 
131
131
  // First text line sits at index 1 (below the box top padding)
@@ -165,7 +165,7 @@ if (!(globalThis as Record<symbol, boolean>)[CHAT_TIMESTAMP_PATCHED]) {
165
165
  if (!hasText || lines.length === 0) return lines;
166
166
 
167
167
  const timeStr = formatClock(lastMsg?.timestamp);
168
- const badge = formatTimeBadge(timeStr, activeThemeProxy);
168
+ const badge = formatTimeBadge(timeStr, tsState.theme);
169
169
  const timeW = visibleWidth(timeStr);
170
170
 
171
171
  // First text line that is neither an OSC zone nor a thinking header
@@ -212,11 +212,15 @@ export default function (pi: ExtensionAPI) {
212
212
  });
213
213
 
214
214
  // The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
215
+ // History for old user messages is read HERE, never at render time: the ctx is only
216
+ // valid inside a handler. A ctx captured in the module throws after session
217
+ // replacement/reload (pi's "stale after session replacement or reload" guard) and
218
+ // that throw would land inside a render pass -> uncaughtException. The session
219
+ // manager already holds the restored history when session_start fires (all reasons:
220
+ // startup/resume/fork/new/reload).
215
221
  pi.on("session_start", async (_event, ctx) => {
216
- activeThemeProxy = themeOf<typeof activeThemeProxy>(ctx) ?? activeThemeProxy;
217
- // History getter for the restore prefill (render-time, once).
218
- getBranch = () => ctx.sessionManager.getBranch();
219
- prefilled = false;
222
+ tsState.theme = themeOf<ThemeProxy>(ctx) ?? tsState.theme;
223
+ prefillUserTimestamps(ctx.sessionManager.getBranch());
220
224
  });
221
225
  }
222
226
 
@@ -264,5 +268,23 @@ if (isMain(import.meta.url)) {
264
268
  ]);
265
269
  assert(USER_TIMESTAMPS_MAP.get("old question") === 1000, "prefill stores the message timestamp");
266
270
  assert((USER_TIMESTAMPS_MAP.get("old reply") ?? 0) > 0, "prefill falls back to the entry timestamp");
271
+
272
+ // Reload invariant: /reload re-imports the module while the prototype patch below keeps
273
+ // running, so the fresh handlers MUST write into the same map/theme the patched render reads.
274
+ const reloaded = (await import(`${import.meta.url}?reload=1`)) as typeof import("./timestamps.ts");
275
+ const handlers: Record<string, (event: unknown, ctx: unknown) => unknown> = {};
276
+ const theme = { fg: (_c: string, t: string) => t };
277
+ reloaded.default({ on: (ev: string, h: never) => void (handlers[ev] = h) } as never);
278
+ await handlers.session_start(
279
+ {},
280
+ {
281
+ sessionManager: {
282
+ getBranch: () => [{ timestamp: "", message: { role: "user", content: "after reload", timestamp: 4242 } }],
283
+ },
284
+ ui: { theme },
285
+ },
286
+ );
287
+ assert(USER_TIMESTAMPS_MAP.get("after reload") === 4242, "reloaded module prefills the shared map read by the patched render");
288
+ assert(tsState.theme === theme, "reloaded module shares the theme read by the patched render");
267
289
  console.log("timestamps.ts self-check OK");
268
290
  }
@@ -0,0 +1,67 @@
1
+ // Platform-correct key ids for the package's single-letter shortcuts (t / n / r).
2
+ //
3
+ // Windows Terminal >= 1.24 aliases every Ctrl+Alt combo to AltGr (microsoft/terminal
4
+ // PR #20052), so the legacy "ESC + control byte" pair (ctrl+alt+r = \x1b\x12) that
5
+ // matchesKey() looks for is never emitted — the keystroke dies inside the terminal.
6
+ // A WSL pane inherits that same input translation, so it counts as Windows-like too,
7
+ // mirroring pi's own useWindowsKeybindings() (not exported from the package root).
8
+ // pi falls back to alt+<letter> for this class of problem (app.clipboard.pasteImage ->
9
+ // alt+v, app.model.cycleBackward -> alt+p) and every Windows terminal prefixes
10
+ // alt+<letter> with ESC (libuv win/tty.c), so do the same here. macOS keeps ctrl+alt
11
+ // only: plain Option there composes characters (Option+r = ®) unless "Use Option as
12
+ // Meta key" is enabled, while ctrl+alt transmits in every macOS terminal.
13
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
14
+ import { assert, isMain } from "./check.ts";
15
+
16
+ /** Windows Terminal's AltGr aliasing hits native Windows and WSL panes alike. */
17
+ export const IS_WINDOWS_LIKE =
18
+ process.platform === "win32" ||
19
+ (process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP));
20
+
21
+ type Shortcut = {
22
+ /** Key ids for pi.registerShortcut; the last one is the platform-native key. */
23
+ keys: string[];
24
+ /** Cheatsheet label: `Alt+t` on Windows-like, `Ctrl+alt+t` elsewhere, `Ctrl+option+t` on macOS. */
25
+ label: string;
26
+ /** Matcher for editors that own their input loop (footer.ts). */
27
+ matches: (data: string) => boolean;
28
+ };
29
+
30
+ // Built once, at module scope: `matches` runs on the per-keystroke input path.
31
+ function build(letter: string): Shortcut {
32
+ const keys = IS_WINDOWS_LIKE ? [Key.ctrlAlt(letter), Key.alt(letter)] : [Key.ctrlAlt(letter)];
33
+ const native = keys[keys.length - 1]!;
34
+ const named = process.platform === "darwin" ? native.replace(/alt/g, "option") : native;
35
+ return {
36
+ keys,
37
+ label: named.charAt(0).toUpperCase() + named.slice(1),
38
+ matches: (data) => keys.some((key) => matchesKey(data, key)),
39
+ };
40
+ }
41
+
42
+ export const SHORTCUT_NEXT_TAB = build("t");
43
+ export const SHORTCUT_NEW_SESSION = build("n");
44
+ export const SHORTCUT_RELOAD = build("r");
45
+
46
+ if (isMain(import.meta.url)) {
47
+ assert(SHORTCUT_RELOAD.keys[0] === Key.ctrlAlt("r"), "ctrl+alt+r is registered on every platform");
48
+ assert(
49
+ SHORTCUT_RELOAD.keys.includes(Key.alt("r")) === IS_WINDOWS_LIKE,
50
+ "alt+r is registered only where Ctrl+Alt is aliased away",
51
+ );
52
+ assert(
53
+ SHORTCUT_RELOAD.label.toLowerCase().replace(/option/, "alt") === SHORTCUT_RELOAD.keys[SHORTCUT_RELOAD.keys.length - 1],
54
+ `label derives from the native key id (${SHORTCUT_RELOAD.label})`,
55
+ );
56
+ assert(SHORTCUT_RELOAD.matches("\x1b\x12"), "legacy ESC + 0x12 matches ctrl+alt+r");
57
+ assert(SHORTCUT_RELOAD.matches("\x1b[114;7u"), "Kitty CSI-u matches ctrl+alt+r");
58
+ assert(SHORTCUT_RELOAD.matches("\x1br") === IS_WINDOWS_LIKE, "ESC + r matches only where alt+r is registered");
59
+ assert(
60
+ SHORTCUT_RELOAD.matches("\x1b[114;3u") === IS_WINDOWS_LIKE,
61
+ "Kitty CSI-u alt+r fires exactly where alt+r is registered",
62
+ );
63
+ assert(!SHORTCUT_RELOAD.matches("\x12"), "bare control char does not match ctrl+alt+r");
64
+ assert(!SHORTCUT_RELOAD.matches("r"), "bare letter does not match ctrl+alt+r");
65
+ assert(!SHORTCUT_NEXT_TAB.matches("\x1br"), "alt+r does not match the next-tab shortcut");
66
+ console.log("shortcuts.ts self-check OK");
67
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bismawy/pi-arnative",
3
- "version": "0.2.7",
4
- "description": "Arnative look for Pi: 11 warm-neutral themes, rounded tool boxes, branded header with tabs, 2-line footer with git/model/status, transcript clock, and a /usage dashboard.",
3
+ "version": "0.2.9",
4
+ "description": "Arnative look for Pi: warm-neutral themes, rounded tool boxes, tabbed header, compact footer, transcript clock, and a /usage dashboard.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-theme",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "scripts": {
42
42
  "dev": "pi --extension ./extensions/tools.ts --extension ./extensions/section-headers.ts --extension ./extensions/footer.ts --extension ./extensions/timestamps.ts --extension ./extensions/ui-render-tweaks.ts --extension ./extensions/usage.ts",
43
- "test": "node lib/box.ts && node lib/ansi.ts && node themes/gen-themes.mjs --check && node extensions/tools.ts && node extensions/footer.ts && node extensions/timestamps.ts && node extensions/section-headers.ts && node extensions/ui-render-tweaks.ts && node extensions/usage.ts",
43
+ "test": "node lib/box.ts && node lib/ansi.ts && node lib/shortcuts.ts && node themes/gen-themes.mjs --check && node extensions/tools.ts && node extensions/footer.ts && node extensions/timestamps.ts && node extensions/section-headers.ts && node extensions/ui-render-tweaks.ts && node extensions/usage.ts",
44
44
  "themes": "node themes/gen-themes.mjs"
45
45
  }
46
46
  }