@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 +18 -0
- package/README.md +13 -13
- package/extensions/footer.ts +9 -7
- package/extensions/section-headers.ts +66 -25
- package/extensions/timestamps.ts +44 -22
- package/lib/shortcuts.ts +67 -0
- package/package.json +3 -3
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
|
-
|
|
3
|
+
Refined aesthetics. Cohesive tools. Built for Pi.
|
|
4
4
|
|
|
5
5
|
[](https://pi.dev/packages/@bismawy/pi-arnative)
|
|
6
|
-
[](https://www.npmjs.com/package/@bismawy/pi-arnative)
|
|
7
7
|
[](https://github.com/bismawy/pi-arnative)
|
|
8
8
|
|
|
9
9
|

|
|
@@ -35,30 +35,30 @@ pi --extension ./extensions/footer.ts
|
|
|
35
35
|
|
|
36
36
|
## Shortcuts
|
|
37
37
|
|
|
38
|
-
| Key /
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
### Keybindings
|
|
50
53
|
|
|
51
|
-
|
|
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>
|
package/extensions/footer.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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(
|
|
646
|
-
assert(
|
|
647
|
-
assert(
|
|
648
|
-
assert(!
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
612
|
+
const body = sectionBodyOf(child);
|
|
613
|
+
const sectionName = body === null ? null : sectionNameOf(body);
|
|
591
614
|
const isSection =
|
|
592
|
-
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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(
|
|
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
|
}
|
package/extensions/timestamps.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
-
|
|
217
|
-
|
|
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
|
}
|
package/lib/shortcuts.ts
ADDED
|
@@ -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.
|
|
4
|
-
"description": "Arnative look for Pi:
|
|
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
|
}
|