@bismawy/pi-arnative 0.2.2 → 0.2.4

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 ADDED
@@ -0,0 +1,81 @@
1
+ # Changelog
2
+
3
+ ## [0.2.4] - 2026-09-28
4
+
5
+ ### Added
6
+ - `lib/check.ts` and `lib/format.ts`: shared self-check helpers (`assert`, `isMain`) and shared display formatting (model names, capitalize, expand-hint key) — footer, header tabs and tool boxes now agree by construction.
7
+
8
+ ### Fixed
9
+ - A crashing third-party tool renderer (e.g. pi-web-access `get_content` with missing args) no longer exits pi: every external renderer call is wrapped in a safe fallback, matching pi's own `updateDisplay` semantics.
10
+ - Restored / `--continue` sessions now show the real send time on user messages (timestamps prefilled from session history). A message with no recorded time shows no clock instead of a fake "now".
11
+ - The spinner animates at the documented 500 ms cadence (was 150 ms); footer status stripping no longer carries a broken ANSI-regex leftover.
12
+ - The usage log prefilter no longer silently undercounts if the JSONL key order/shape changes.
13
+
14
+ ### Changed
15
+ - Self-check boilerplate, ANSI stripping and model-name formatting were deduplicated into `lib/`; dead compatibility exports removed. Behavior unchanged, `npm test` green.
16
+
17
+ ---
18
+
19
+ ## [0.2.3] - 2026-09-27
20
+
21
+ ### Added
22
+ - `Shortcut` header tab (rightmost, after `Themes`) holding the key cheatsheet: `Esc`, `Ctrl+c/d`, `/`, `!`, `Ctrl+o`, `Ctrl+alt+t`, `Ctrl+alt+n`, `Ctrl+alt+r`. Tab order is now `Directory → Model → Context → Skills → Extensions → Themes → Shortcut`.
23
+
24
+ ### Changed
25
+ - The header no longer spends a line on the shortcut legend. Line 1 greets the device username (`Welcome back, <user>`), line 2 shows `pi vX.Y.Z · Arnative vX.Y.Z` (names in the theme accent, versions dim). The tab box labels itself on its top border (`╭─ Menu ───╮`) instead of spending a text line. The Arnative version is read from the package.json of the running build, so an outdated npm install can no longer claim to be the executing code.
26
+ - The `Shortcut` tab is dropped first on narrow terminals (~<146 columns), like every rightmost tab.
27
+
28
+ ---
29
+
30
+ ## [0.2.2] - 2026-09-27
31
+
32
+ ### Added
33
+ - `ctrl+alt+r` reload legend in the header shortcut line (`[Ctrl+alt+r] Reload`), shown from 142 columns on.
34
+
35
+ ### Changed
36
+ - `Directory` is now the first header tab (and the default tab on open), so the working directory shows immediately. Tab order: `Directory → Model → Context → Skills → Extensions → Themes`.
37
+ - The `Directory` tab label is just `Directory`; the full path moved to the tab content line, shown on click.
38
+ - Model tab label uses brackets: `Model [Deepseek V4.1 Flash]` instead of `Model: Deepseek V4.1 Flash`.
39
+
40
+ ### Fixed
41
+ - Click hit-areas for header tabs were off by 2 columns (tab box border + leading space were not counted), so the leftmost tab looked dead on its last 2 columns.
42
+
43
+ ---
44
+
45
+ ## [0.2.1] - 2026-09-27
46
+
47
+ ### Fixed
48
+ - Fixed `ctrl+alt+r` reload shortcut by publishing missing extension logic to NPM registry.
49
+ - Hoisted reload shortcut key id to typed `Key.ctrlAlt("r")` with encoding assertions for both legacy ESC (`\x1b\x12`) and Kitty CSI-u (`\x1b[114;7u`).
50
+
51
+ ### Added
52
+ - Added `npm run dev` script for conflict-free local live TUI testing.
53
+
54
+ ---
55
+
56
+ ## [0.2.0] - 2026-09-27
57
+
58
+ ### Added
59
+ - Interactive header box with clickable resource tabs (`Model`, `Directory`, `Context`, `Skills`, `Extensions`, `Themes`).
60
+ - Full working directory view under the `Directory` tab (wrapped at path segments, never truncated).
61
+ - Keyboard shortcut `ctrl+alt+t` to cycle through header tabs.
62
+ - Keyboard shortcut `ctrl+alt+r` for instant runtime reload (`/reload`).
63
+ - Full `/usage` interactive dashboard modal with session/all-time token metrics and estimated costs.
64
+ - 10 expressive palette variants generated from base Arnative (`sun`, `zinc`, `violet`, `emerald`, `matrix`, `cyberpunk`, `synthwave`, `gruvbox`, `nord`, `dracula`).
65
+ - Visual rounded boxes with animated status spinners for built-in and MCP tool executions.
66
+ - Transcript timestamp clock and message bubble background repair.
67
+
68
+ ### Changed
69
+ - Shortened footer working directory display to tail segments.
70
+ - Compacted compaction and status notices into single-line dimmed highlights.
71
+ - Unified theme token contrast check across all variants via automated test suite.
72
+
73
+ ---
74
+
75
+ ## [0.1.0] - 2026-09-24
76
+
77
+ ### Added
78
+ - Initial release of `@bismawy/pi-arnative`.
79
+ - Warm-neutral base Arnative theme with cyan accents.
80
+ - Fixed 2-line status footer with real-time streaming speed (tok/s), Git branch/tag status, and token metrics.
81
+ - Boxed editor input with dim ` > ` prompt.
package/README.md CHANGED
@@ -11,7 +11,7 @@ A visual layer and tool styling tailored to your vibe coding.
11
11
  pi-arnative replaces pi’s default terminal chrome with a unified, cohesive visual aesthetic.
12
12
 
13
13
  - Warm Themes: Cohesive, contrast-checked color palettes generated without drift.
14
- - Tabbed Header: Compact box menu with quick access to Directory, Model, Skills, Extensions, and Themes.
14
+ - Tabbed Header: Greeting, version line, and a compact box menu over Directory, Model, Context, Skills, Extensions, Themes, and Shortcut.
15
15
  - Rounded Tool Boxes: Clear status, spinner, and duration for every tool call.
16
16
  - Enhanced Footer: 2-line layout showing directory path, git branch, model, and token metrics.
17
17
  - Transcript Clock: Timestamps for messages with clean bubble backgrounds.
@@ -36,11 +36,27 @@ pi --extension ./extensions/footer.ts
36
36
  | Key / Action | Result |
37
37
  | --- | --- |
38
38
  | `/usage` | Show session usage & token metrics |
39
- | `ctrl+alt+t` | Cycle header tabs (Directory → Model → Context → Skills → Extensions → Themes) |
39
+ | `ctrl+alt+t` | Cycle header tabs (Directory → Model → Context → Skills → Extensions → Themes → Shortcut) |
40
+ | `ctrl+alt+n` | Start a new session (`/new`) — see [Keybindings](#keybindings) |
40
41
  | `ctrl+alt+r` | Quick reload runtime (`/reload`) |
41
42
  | Click tab | Open selected header tab |
42
43
  | Click tool / `ctrl+e` | Toggle tool output box |
43
44
 
45
+ Same cheatsheet in the header's **Shortcut** tab; on narrow terminals the rightmost tabs drop off first.
46
+
47
+ ## Keybindings
48
+
49
+ `ctrl+alt+n` is a plain user keybinding, not a package shortcut — pi-arnative never registers it.
50
+ Add it once to `~/.pi/agent/keybindings.json`:
51
+
52
+ ```json
53
+ {
54
+ "app.session.new": "ctrl+alt+n"
55
+ }
56
+ ```
57
+
58
+ Restart pi to pick it up.
59
+
44
60
  ## Architecture
45
61
 
46
62
  <details>
@@ -11,6 +11,9 @@ import type { AssistantMessage } from "@earendil-works/pi-ai";
11
11
  import { CustomEditor, FooterComponent, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
13
  import { execFile } from "node:child_process";
14
+ import { stripAnsi } from "../lib/ansi.ts";
15
+ import { assert, isMain } from "../lib/check.ts";
16
+ import { capitalize, modelDisplayParts } from "../lib/format.ts";
14
17
  import { formatTokens } from "../lib/usage-store.ts";
15
18
 
16
19
  // The TUI drops key-release events before handleInput, so press+release cannot
@@ -99,19 +102,9 @@ function formatModelName(
99
102
  dim: (t: string) => string,
100
103
  ): string {
101
104
  if (!model) return dim("No Model");
102
- let name = model.name || model.id;
103
- const provider = model.provider ? model.provider.charAt(0).toUpperCase() + model.provider.slice(1) : "";
104
- if (name === model.id) {
105
- name = name
106
- .split(/[-_]/)
107
- .map((w) => (w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : ""))
108
- .join(" ");
109
- }
110
- if (provider && name.toLowerCase().endsWith(`(${provider.toLowerCase()})`)) {
111
- name = name.slice(0, name.lastIndexOf("(")).trim();
112
- }
105
+ const { name, provider } = modelDisplayParts({ id: model.id, name: model.name, provider: model.provider });
113
106
  const provStr = provider ? ` ${dim(`(${provider})`)}` : "";
114
- const capThinking = thinkingLevel && thinkingLevel !== "off" ? thinkingLevel.charAt(0).toUpperCase() + thinkingLevel.slice(1) : "";
107
+ const capThinking = thinkingLevel && thinkingLevel !== "off" ? capitalize(thinkingLevel) : "";
115
108
  const thinkStr = capThinking ? `${acc("\udb80\udf35")} ${tint(capThinking)} ${dim("·")} ` : "";
116
109
  return `${thinkStr}${acc(name)}${provStr}`;
117
110
  }
@@ -140,15 +133,12 @@ export function formatDuration(ms: number): string {
140
133
  return `${s}s`;
141
134
  }
142
135
 
143
- // Full box editor + `> ` prompt. pi only draws lines above and below
144
- // (pi-tui editor.js: "no side borders, just horizontal lines above and below")
145
- // and no prompt char, so the `│` sides, the round ╭╮╰╯ corners and the prompt
146
- // are added here.
147
- // `lines` are already rendered by pi at the NARROWER width - 5: 2 columns for the
148
- // sides, 3 for the " > " prompt; the border is then patched with dashes to match.
149
- // `visible` = editor content line count (private `renderedVisibleLineCount`);
150
- // autocomplete lines come AFTER the bottom border and are deliberately outside the box.
151
- // JetBrainsMono Nerd Font has the round corner glyphs (verified via fontconfig).
136
+ // Full box editor: pi draws only the top/bottom lines (pi-tui editor.js: "no side
137
+ // borders, just horizontal lines above and below") and no prompt char, so the `│`
138
+ // sides, the round ╭╮╰╯ corners and the `> ` prompt are added here. `lines` are
139
+ // already rendered at width - 5 (2 sides + 3 prompt) and the border is patched with
140
+ // dashes to match. `visible` = content line count (private `renderedVisibleLineCount`);
141
+ // autocomplete lines stay outside the box. JetBrainsMono NF has the round glyphs.
152
142
  export function boxEditorLines(
153
143
  lines: readonly string[],
154
144
  visible: number,
@@ -234,6 +224,14 @@ function poke(): void {
234
224
  * Tool name → active progress text (gerund).
235
225
  * Fallback when unregistered: "Working".
236
226
  */
227
+ // Verb for an MCP sub-action (shared by the `mcp` and `mcp__*` tool names).
228
+ function mcpStatusOf(sub: string): string {
229
+ if (sub.includes("search") || sub.includes("find")) return "Searching";
230
+ if (sub.includes("fetch") || sub.includes("get") || sub.includes("read")) return "Fetching";
231
+ if (sub.includes("edit") || sub.includes("write") || sub.includes("patch")) return "Editing";
232
+ return "Executing";
233
+ }
234
+
237
235
  export function getToolWorkingMessage(toolName: string, args?: any): string {
238
236
  const n = (toolName ?? "").trim();
239
237
  if (!n) return "Working";
@@ -257,21 +255,11 @@ export function getToolWorkingMessage(toolName: string, args?: any): string {
257
255
  if (n === "mcpScript") return "Running script";
258
256
  if (n === "mcp") {
259
257
  const sub = typeof args === "object" && args ? (args.tool || args.search || args.describe || args.action) : "";
260
- if (sub) {
261
- const s = String(sub).toLowerCase();
262
- if (s.includes("search") || s.includes("find")) return "Searching";
263
- if (s.includes("fetch") || s.includes("get") || s.includes("read")) return "Fetching";
264
- if (s.includes("edit") || s.includes("write") || s.includes("patch")) return "Editing";
265
- return "Executing";
266
- }
267
- return "Executing";
258
+ return sub ? mcpStatusOf(String(sub).toLowerCase()) : "Executing";
268
259
  }
269
260
  if (n.startsWith("mcp__")) {
270
261
  const sub = typeof args === "object" && args?.tool ? String(args.tool).toLowerCase() : "";
271
- if (sub.includes("search") || sub.includes("find")) return "Searching";
272
- if (sub.includes("fetch") || sub.includes("get") || sub.includes("read")) return "Fetching";
273
- if (sub.includes("edit") || sub.includes("write") || sub.includes("patch")) return "Editing";
274
- return "Executing";
262
+ return mcpStatusOf(sub);
275
263
  }
276
264
 
277
265
  return "Working";
@@ -367,7 +355,7 @@ export default function (pi: ExtensionAPI) {
367
355
  const segs: string[] = [];
368
356
  const cleanStatus = (s: string) => {
369
357
  if (s.includes("MCP:")) {
370
- let clean = s.replace(/\x1b\[[0-9;]*m/g, "").replace(/^[0-9;]+m/, "");
358
+ let clean = stripAnsi(s);
371
359
  const idx = clean.indexOf("MCP:");
372
360
  let rest = (idx >= 0 ? clean.slice(idx) : clean).replace(/\uFFFD/g, "").trim();
373
361
  rest = rest.replace(/[\p{Extended_Pictographic}\uFE0F\u200D\u2800-\u28FF]/gu, "").replace(/\s{2,}/g, " ").trim();
@@ -397,7 +385,7 @@ export default function (pi: ExtensionAPI) {
397
385
  const label = isOff ? "OFF" : isReview ? "REVIEW" : "ON";
398
386
  return `${acc("\uedcf")} ${tint("Jev:")} ${bullet} ${tint(label)}`;
399
387
  }
400
- let clean = s.replace(/\x1b\[[0-9;]*m/g, "").replace(/^[0-9;]+m/, "");
388
+ let clean = stripAnsi(s);
401
389
  clean = clean.replace(/\uFFFD/g, "").replace(/\?{1,2}\s*/g, "");
402
390
  return tint(clean.trim());
403
391
  };
@@ -612,37 +600,30 @@ export default function (pi: ExtensionAPI) {
612
600
  }
613
601
 
614
602
  // Self-check: `node extensions/footer.ts`
615
- const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"));
616
- if (isMain) {
617
- const assert = (cond: boolean, msg: string) => {
618
- if (!cond) {
619
- console.error(`FAIL: ${msg}`);
620
- process.exit(1);
621
- }
622
- };
623
- assert(shortenCwd("/run/media/bisma/DATA/Pi/pi-arnative") === "\u2026/Pi/pi-arnative", "cwd dalam -> 2 segmen terakhir");
624
- assert(shortenCwd("/home/bisma") === "/home/bisma", "cwd dangkal tak diubah");
625
- assert(shortenCwd("D:\\Pi\\a\\pi-arnative") === "D:/\u2026/a/pi-arnative", "path Windows jauh: drive dipertahankan");
626
- assert(shortenCwd("D:\\Pi\\pi-arnative") === "D:\\Pi\\pi-arnative", "path Windows dangkal tak diubah");
627
- assert(shortenCwd("/a/x/y/z") === "\u2026/y/z", "path jauh -> tail");
628
- assert(shortenCwd("") === "", "cwd kosong");
629
- assert(formatDuration(0) === "-", "durasi kosong = -");
630
- assert(formatDuration(5_000) === "5s", "detik saja");
631
- assert(formatDuration(65_000) === "1m 5s", "menit + detik");
632
- assert(formatDuration(3_700_000) === "1h 1m", "jam + menit");
603
+ if (isMain(import.meta.url)) {
604
+ assert(shortenCwd("/run/media/bisma/DATA/Pi/pi-arnative") === "\u2026/Pi/pi-arnative", "cwd inside -> last 2 segments");
605
+ assert(shortenCwd("/home/bisma") === "/home/bisma", "shallow cwd left unchanged");
606
+ assert(shortenCwd("D:\\Pi\\a\\pi-arnative") === "D:/\u2026/a/pi-arnative", "long Windows path: drive kept");
607
+ assert(shortenCwd("D:\\Pi\\pi-arnative") === "D:\\Pi\\pi-arnative", "shallow Windows path left unchanged");
608
+ assert(shortenCwd("/a/x/y/z") === "\u2026/y/z", "long path -> tail");
609
+ assert(shortenCwd("") === "", "empty cwd");
610
+ assert(formatDuration(0) === "-", "empty duration = -");
611
+ assert(formatDuration(5_000) === "5s", "seconds only");
612
+ assert(formatDuration(65_000) === "1m 5s", "minutes + seconds");
613
+ assert(formatDuration(3_700_000) === "1h 1m", "hours + minutes");
633
614
 
634
615
  const plain = (s: string) => s;
635
- const D = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"; // 8 kolom = lebar isi pada uji ini
616
+ const D = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"; // 8 columns = content width in this test
636
617
  const boxed = boxEditorLines([D, " hi ", " lo ", D, " /mo "], 2, plain);
637
- assert(boxed[0] === `\u256d${D}\u2500\u2500\u2500\u256e`, "kotak editor: korner atas selebar isi");
638
- assert(boxed[1] === "\u2502 > hi \u2502", "kotak editor: prompt ' > ' di baris pertama");
639
- assert(boxed[2] === "\u2502 lo \u2502", "kotak editor: baris lanjutan indent, tanpa > kedua");
640
- assert(boxed[3] === `\u2570${D}\u2500\u2500\u2500\u256f`, "kotak editor: korner bawah");
641
- assert(boxed[4] === " /mo ", "baris autocomplete tetap di luar kotak");
618
+ assert(boxed[0] === `\u256d${D}\u2500\u2500\u2500\u256e`, "editor box: top corner spans the content");
619
+ assert(boxed[1] === "\u2502 > hi \u2502", "editor box: ' > ' prompt on the first line");
620
+ assert(boxed[2] === "\u2502 lo \u2502", "editor box: continuation indented, no second >");
621
+ assert(boxed[3] === `\u2570${D}\u2500\u2500\u2500\u256f`, "editor box: bottom corner");
622
+ assert(boxed[4] === " /mo ", "autocomplete lines stay outside the box");
642
623
  const dimP = (s: string) => `\x1b[2m${s}\x1b[0m`;
643
624
  const boxedDim = boxEditorLines([D, " hi ", D], 1, plain, dimP(" > "));
644
- assert(visibleWidth(boxedDim[1]!) === visibleWidth(D) + 5, "prompt ber-ANSI (dim) tak menggeser lebar");
645
- assert(boxedDim[1]!.startsWith(`\u2502${dimP(" > ")}`), "prompt dim dipertahankan apa adanya");
625
+ assert(visibleWidth(boxedDim[1]!) === visibleWidth(D) + 5, "ANSI (dim) prompt does not shift the width");
626
+ assert(boxedDim[1]!.startsWith(`\u2502${dimP(" > ")}`), "dim prompt kept as-is");
646
627
 
647
628
  // Self-check: getToolWorkingMessage
648
629
  assert(getToolWorkingMessage("edit") === "Editing", "edit -> Editing");
@@ -656,14 +637,14 @@ if (isMain) {
656
637
  assert(getToolWorkingMessage("todo") === "Updating tasks", "todo -> Updating tasks");
657
638
  assert(getToolWorkingMessage("unknown_tool") === "Working", "unknown -> Working");
658
639
  assert(getToolWorkingMessage("mcp", { tool: "fetch_repo" }) === "Fetching", "mcp fetch -> Fetching");
659
- assert(boxed.slice(0, 4).every((l) => visibleWidth(l) === visibleWidth(D) + 5), "lebar kotak seragam (sisi+prompt)");
660
- assert(boxEditorLines(["\u2500\u2500\u2500\u2500"], 1, plain).length === 1, "terlalu sempit: tak diubah");
640
+ assert(boxed.slice(0, 4).every((l) => visibleWidth(l) === visibleWidth(D) + 5), "uniform box width (sides + prompt)");
641
+ assert(boxEditorLines(["\u2500\u2500\u2500\u2500"], 1, plain).length === 1, "too narrow: left unchanged");
661
642
 
662
643
  // Guard: the reload shortcut must match every encoding a terminal can deliver,
663
644
  // so a typo in the key id fails here instead of silently doing nothing.
664
- assert(matchesKey("\x1b\x12", RELOAD_SHORTCUT), "shortcut cocok dgn ctrl+alt+r legacy (ESC + 0x12)");
665
- assert(matchesKey("\x1b[114;7u", RELOAD_SHORTCUT), "shortcut cocok dgn ctrl+alt+r Kitty CSI-u");
666
- assert(!matchesKey("\x12", RELOAD_SHORTCUT), "ctrl+r polos tidak memicu reload");
667
- assert(!matchesKey("r", RELOAD_SHORTCUT), "huruf r tidak memicu reload");
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");
668
649
  console.log("footer.ts self-check OK");
669
650
  }