@bismawy/pi-arnative 0.2.7 → 0.2.8
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 +10 -0
- package/README.md +17 -6
- package/extensions/footer.ts +9 -7
- package/extensions/section-headers.ts +20 -17
- package/extensions/timestamps.ts +44 -22
- package/lib/shortcuts.ts +67 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.8] - 2026-09-28
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- 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).
|
|
7
|
+
- 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.
|
|
8
|
+
- 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).
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
3
13
|
## [0.2.7] - 2026-09-28
|
|
4
14
|
|
|
5
15
|
### Fixed
|
package/README.md
CHANGED
|
@@ -38,22 +38,33 @@ pi --extension ./extensions/footer.ts
|
|
|
38
38
|
| Key / Action | Result |
|
|
39
39
|
| --- | --- |
|
|
40
40
|
| `/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`) |
|
|
41
|
+
| `ctrl+alt+t` (`alt+t` on Windows/WSL) | Cycle header tabs (Directory → Model → Context → Skills → Extensions → Themes → Shortcut) |
|
|
42
|
+
| `ctrl+alt+n` (`alt+n` on Windows/WSL) | Start a new session (`/new`) — see [Keybindings](#keybindings) |
|
|
43
|
+
| `ctrl+alt+r` (`alt+r` on Windows/WSL) | 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
|
+
Same cheatsheet in the header's **Shortcut** tab — it shows the variant your platform actually uses (on macOS `alt` reads `option`); on narrow terminals the rightmost tabs drop off first.
|
|
48
|
+
|
|
49
|
+
### Platform notes
|
|
50
|
+
|
|
51
|
+
- **Windows / WSL:** 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 never leaves the terminal. Both variants are registered there; `alt+…` is what pi itself uses for the same reason (`alt+v` paste image, `alt+p` cycle model). Setting `"altGrAliasing": false` in your Windows Terminal profile brings `ctrl+alt+…` back.
|
|
52
|
+
- **macOS:** only `ctrl+option+…` is registered — plain Option composes characters (`Option+r` = ®) unless “Use Option as Meta key” is on. Works in iTerm2 and Terminal.app as-is.
|
|
53
|
+
- **Linux desktops:** `ctrl+alt+t` is “launch terminal” on Ubuntu/Fedora GNOME, which eats the key before pi sees it. Rebind it there if you want tab cycling.
|
|
54
|
+
- **Multiplexers (Herdr/tmux) + slow PTYs:** every `ctrl+alt+…` / `alt+…` shortcut is sent as `ESC` followed by a byte. pi waits only ~10 ms for that second byte before treating a lone `ESC` as the Escape key — and an Escape mid-turn aborts it, leaving a stray `r`/`t`/`n` in the editor. That is why the shortcut can look “glitchy” and why it is intermittent. Raise the window with `PI_TUI_ESC_TIMEOUT=100` in the environment that launches pi (same knob pi auto-applies over SSH, at the cost of a ~100 ms delay before a real Escape registers):
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
$env:PI_TUI_ESC_TIMEOUT = 100; pi
|
|
58
|
+
```
|
|
48
59
|
|
|
49
60
|
## Keybindings
|
|
50
61
|
|
|
51
62
|
`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
|
|
63
|
+
Add it once to `~/.pi/agent/keybindings.json` (the list form keeps Linux and Windows working):
|
|
53
64
|
|
|
54
65
|
```json
|
|
55
66
|
{
|
|
56
|
-
"app.session.new": "ctrl+alt+n"
|
|
67
|
+
"app.session.new": ["ctrl+alt+n", "alt+n"]
|
|
57
68
|
}
|
|
58
69
|
```
|
|
59
70
|
|
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> = {
|
|
@@ -176,9 +176,9 @@ export const SHORTCUTS: string[] = [
|
|
|
176
176
|
"[/] Commands",
|
|
177
177
|
"[!] Bash",
|
|
178
178
|
"[Ctrl+o] More/expand",
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
`[${SHORTCUT_NEXT_TAB.label}] Next tab`,
|
|
180
|
+
`[${SHORTCUT_NEW_SESSION.label}] New`,
|
|
181
|
+
`[${SHORTCUT_RELOAD.label}] Reload`,
|
|
182
182
|
];
|
|
183
183
|
|
|
184
184
|
export function wrapCommaItems(items: string[], maxWidth: number): string[] {
|
|
@@ -633,17 +633,17 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
|
|
|
633
633
|
}
|
|
634
634
|
|
|
635
635
|
export default function (pi: ExtensionAPI) {
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
}
|
|
646
|
-
}
|
|
636
|
+
// Next header tab — every platform variant of the shortcut (lib/shortcuts.ts).
|
|
637
|
+
const nextTab = async () => {
|
|
638
|
+
const hdr = activeHeaderInstance;
|
|
639
|
+
if (!hdr) return;
|
|
640
|
+
const order = TAB_ORDER;
|
|
641
|
+
hdr.activeTab = order[(order.indexOf(hdr.activeTab) + 1) % order.length]!;
|
|
642
|
+
hdr.tui?.requestRender?.();
|
|
643
|
+
};
|
|
644
|
+
for (const key of SHORTCUT_NEXT_TAB.keys) {
|
|
645
|
+
pi.registerShortcut(key, { description: "Next header tab", handler: nextTab });
|
|
646
|
+
}
|
|
647
647
|
|
|
648
648
|
pi.on("session_start", async (_event, ctx) => {
|
|
649
649
|
activeThemeProxy = themeOf<Themeish>(ctx) ?? activeThemeProxy;
|
|
@@ -860,7 +860,10 @@ if (isMain(import.meta.url)) {
|
|
|
860
860
|
);
|
|
861
861
|
const scContent = stripAnsi(linesShortcut.slice(6).join(" "));
|
|
862
862
|
assert(scContent.includes("[Esc] Interrupt"), "Shortcut tab: legend moved into the content");
|
|
863
|
-
assert(
|
|
863
|
+
assert(
|
|
864
|
+
scContent.includes(`[${SHORTCUT_RELOAD.label}] Reload`),
|
|
865
|
+
`Shortcut tab: reload shortcut present in the content (${SHORTCUT_RELOAD.label})`,
|
|
866
|
+
);
|
|
864
867
|
assert(stripAnsi(linesShortcut[2]!).includes("Arnative v"), "line 2 keeps the Arnative brand while the Shortcut tab is active");
|
|
865
868
|
hdr.activeTab = "Model";
|
|
866
869
|
hdr.render(140);
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bismawy/pi-arnative",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -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
|
}
|