@bismawy/pi-arnative 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -61
- package/extensions/footer.ts +45 -3
- package/extensions/section-headers.ts +123 -24
- package/extensions/tools.ts +45 -6
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,89 +1,69 @@
|
|
|
1
|
-
|
|
1
|
+
# Arnative
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A visual layer and tool styling tailored to your vibe coding.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://pi.dev/packages/@bismawy/pi-arnative)
|
|
6
|
+
[](https://www.npmjs.com/package/@bismawy/pi-arnative)
|
|
7
|
+
[](https://github.com/bismawy/pi-arnative)
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
## Overview
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-

|
|
11
|
+
pi-arnative replaces pi’s default terminal chrome with a unified, cohesive visual aesthetic.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- **11 themes, one generator:** `arnative` plus 10 variants (`sun`, `zinc`, `violet`, `emerald`, `matrix`, `cyberpunk`, `synthwave`, `gruvbox`, `nord`, `dracula`), all generated from a single base file so their structure can never drift.
|
|
19
|
-
- **Header box with tabs:** replaces pi's stacked resource list with a 5-line box — logo, version + shortcuts, and a clickable `Model / Context / Skills / Extensions / Themes` tab menu.
|
|
20
|
-
- **Tool boxes:** every tool call renders in its own rounded box — spinner while running, then `✓`/`x`, right-aligned duration, and a one-line summary. Click or `ctrl+e` for the full output.
|
|
21
|
-
- **2-line footer:** `cwd | duration | branch/tag/status` on the first line, extension status + tok/s + cache + tokens on the second, plus a full-box editor with a `> ` prompt.
|
|
22
|
-
- **Transcript clock:** user and assistant messages get a right-aligned time, and the user bubble background is repaired where Markdown resets would otherwise leave black blocks.
|
|
23
|
-
- **Render fixes:** hidden reload box, readable drag selection, single-line compaction messages, and a higher-contrast `↓ Jump to latest message` pill.
|
|
24
|
-
- **Contrast-checked themes:** the self-check measures every `themes/*.json` against its own canvas, so no variant ships an unreadable box.
|
|
13
|
+
- Warm Themes: Cohesive, contrast-checked color palettes generated without drift.
|
|
14
|
+
- Tabbed Header: Compact box menu with quick access to Model, Skills, Extensions, and Themes.
|
|
15
|
+
- Rounded Tool Boxes: Clear status, spinner, and duration for every tool call.
|
|
16
|
+
- Enhanced Footer: 2-line layout showing directory path, git branch, model, and token metrics.
|
|
17
|
+
- Transcript Clock: Timestamps for messages with clean bubble backgrounds.
|
|
18
|
+
- Usage Dashboard: /usage command to track tokens and session costs.
|
|
25
19
|
|
|
26
20
|
## Install
|
|
27
21
|
|
|
28
|
-
**Install [JetBrains Mono Nerd Font](https://www.nerdfonts.com/font-downloads) first.** Every icon in this package (tab icons ` `, tool icons ` ✓`, footer icons ` `, and the rounded box corners `╭─╮`) is a Nerd Font glyph — without the font they render as empty boxes (□) or random characters.
|
|
29
|
-
|
|
30
|
-
1. Download **JetBrainsMono Nerd Font** from [nerdfonts.com/font-downloads](https://www.nerdfonts.com/font-downloads).
|
|
31
|
-
2. Extract and install it (Linux: copy into `~/.local/share/fonts/` then `fc-cache -f`; Windows: right click → Install).
|
|
32
|
-
3. Set your terminal to **JetBrainsMono NF** (or another Nerd Font) as the primary font.
|
|
33
|
-
|
|
34
22
|
```bash
|
|
35
23
|
pi install npm:@bismawy/pi-arnative
|
|
36
24
|
```
|
|
37
25
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
To try it without installing, from this directory:
|
|
26
|
+
Select a theme via `/settings` → **Theme** → `arnative` (or any `arnative-*` variant).
|
|
41
27
|
|
|
28
|
+
To test locally without installing:
|
|
42
29
|
```bash
|
|
43
30
|
pi --extension ./extensions/footer.ts
|
|
44
31
|
```
|
|
32
|
+
> **Prerequisite:** Requires a [Nerd Font](https://www.nerdfonts.com/font-downloads) (e.g. JetBrains Mono Nerd Font) for icons and rounded box borders.
|
|
45
33
|
|
|
46
|
-
##
|
|
47
|
-
|
|
48
|
-
| Command | Action |
|
|
49
|
-
| :--- | :--- |
|
|
50
|
-
| `/usage` | Token, message & cache usage across all local Pi sessions |
|
|
34
|
+
## Shortcuts
|
|
51
35
|
|
|
52
|
-
|
|
|
53
|
-
|
|
|
54
|
-
| `
|
|
55
|
-
|
|
|
56
|
-
|
|
|
36
|
+
| Key / Action | Result |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `/usage` | Show session usage & token metrics |
|
|
39
|
+
| `ctrl+alt+t` | Cycle header tabs (Model → Directory → Context → Skills → Extensions → Themes) |
|
|
40
|
+
| `ctrl+alt+r` | Quick reload runtime (`/reload`) |
|
|
41
|
+
| Click tab | Open selected header tab |
|
|
42
|
+
| Click tool / `ctrl+e` | Toggle tool output box |
|
|
57
43
|
|
|
58
|
-
##
|
|
44
|
+
## Architecture
|
|
59
45
|
|
|
60
46
|
<details>
|
|
61
47
|
<summary><b>Extensions</b></summary>
|
|
62
48
|
|
|
63
49
|
| File | Role |
|
|
64
|
-
|
|
|
65
|
-
| `extensions/tools.ts` |
|
|
66
|
-
| `extensions/section-headers.ts` |
|
|
67
|
-
| `extensions/footer.ts` | 2-line footer and
|
|
68
|
-
| `extensions/timestamps.ts` |
|
|
69
|
-
| `extensions/ui-render-tweaks.ts` |
|
|
70
|
-
| `extensions/usage.ts` | `/usage`
|
|
71
|
-
|
|
72
|
-
`lib/box.ts` and `lib/ansi.ts` hold the single definitions shared by all of them. They live outside `extensions/` so pi never loads them as extensions.
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `extensions/tools.ts` | Rounded tool boxes, spinners, and duration |
|
|
52
|
+
| `extensions/section-headers.ts` | Tabbed header and resource box |
|
|
53
|
+
| `extensions/footer.ts` | 2-line status footer and boxed editor |
|
|
54
|
+
| `extensions/timestamps.ts` | Message clock and bubble background fixes |
|
|
55
|
+
| `extensions/ui-render-tweaks.ts` | Contrast tweaks, selection style, and UI polish |
|
|
56
|
+
| `extensions/usage.ts` | `/usage` token dashboard |
|
|
73
57
|
|
|
74
58
|
</details>
|
|
75
59
|
|
|
76
60
|
<details>
|
|
77
61
|
<summary><b>Themes</b></summary>
|
|
78
62
|
|
|
79
|
-
- `themes/arnative.json`
|
|
80
|
-
- `themes/gen-themes.mjs`
|
|
81
|
-
- `npm run themes` rewrites every variant; `npm test` runs the same generator in `--check` mode, so a hand-edited variant file cannot drift.
|
|
82
|
-
- Every variant fills all `vars`/`colors`/`export` keys, so no color can silently inherit the base.
|
|
63
|
+
- `themes/arnative.json` serves as the structure source of truth.
|
|
64
|
+
- `themes/gen-themes.mjs` generates variants with automated contrast checks.
|
|
83
65
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
Contrast rules enforced by the self-check (measured against each theme's own `userMessageBg` canvas): content ≥ 3.0, secondary text ≥ 2.0, `thinking*` ramp 1.3–4.0, accent pill ≥ 4.5, tool box text ≥ 4.5 on both success and error backgrounds, error box weight matched to the success box, and `scrollbarThumb` / `searchMatchBg` distinguishable from their track / selection.
|
|
66
|
+
**Variants:** `sun` · `zinc` · `violet` · `emerald` · `matrix` · `cyberpunk` · `synthwave` · `gruvbox` · `nord` · `dracula`
|
|
87
67
|
|
|
88
68
|
</details>
|
|
89
69
|
|
|
@@ -91,18 +71,16 @@ Contrast rules enforced by the self-check (measured against each theme's own `us
|
|
|
91
71
|
<summary><b>Development</b></summary>
|
|
92
72
|
|
|
93
73
|
```bash
|
|
94
|
-
npm test #
|
|
95
|
-
npm run themes #
|
|
74
|
+
npm test # Run self-checks & theme generator check
|
|
75
|
+
npm run themes # Rebuild all theme variants
|
|
96
76
|
```
|
|
97
77
|
|
|
98
|
-
Each extension is also a standalone self-check: `node extensions/tools.ts`.
|
|
99
|
-
|
|
100
78
|
</details>
|
|
101
79
|
|
|
102
80
|
## License
|
|
103
81
|
|
|
104
82
|
Distributed under the **MIT** license.
|
|
105
83
|
|
|
106
|
-
##
|
|
84
|
+
## Author
|
|
107
85
|
|
|
108
|
-
|
|
86
|
+
[Bisma](https://github.com/bismawy)
|
package/extensions/footer.ts
CHANGED
|
@@ -9,10 +9,14 @@
|
|
|
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 { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
13
|
import { execFile } from "node:child_process";
|
|
14
14
|
import { formatTokens } from "../lib/usage-store.ts";
|
|
15
15
|
|
|
16
|
+
// The TUI drops key-release events before handleInput, so press+release cannot
|
|
17
|
+
// fire this twice even when the terminal reports event types (Kitty flag 2).
|
|
18
|
+
const RELOAD_SHORTCUT = Key.ctrlAlt("r");
|
|
19
|
+
|
|
16
20
|
// Intercept the built-in FooterComponent to force a pure 2-line footer
|
|
17
21
|
const PATCHED_KEY = Symbol.for("pi-arnative.footer2LinesPatched");
|
|
18
22
|
if (FooterComponent?.prototype?.render && !(globalThis as Record<symbol, boolean>)[PATCHED_KEY]) {
|
|
@@ -112,6 +116,19 @@ function formatModelName(
|
|
|
112
116
|
return `${thinkStr}${acc(name)}${provStr}`;
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
// Footer is a single line: a deep cwd (`/Users/x/Workspace/personal/proj`)
|
|
120
|
+
// pushes git state and the model off screen. Keep the last 2 segments.
|
|
121
|
+
export function shortenCwd(raw: string): string {
|
|
122
|
+
const p = (raw || "").trim();
|
|
123
|
+
if (!p) return p;
|
|
124
|
+
const drive = p.match(/^[A-Za-z]:/);
|
|
125
|
+
const body = drive ? p.slice(2) : p;
|
|
126
|
+
const parts = body.replace(/\\/g, "/").replace(/\/+$/, "").split("/").filter(Boolean);
|
|
127
|
+
if (parts.length <= 2) return p;
|
|
128
|
+
const head = drive ? `${drive[0]}/\u2026/` : p.startsWith("/") ? "\u2026/" : "\u2026";
|
|
129
|
+
return head + parts.slice(-2).join("/");
|
|
130
|
+
}
|
|
131
|
+
|
|
115
132
|
export function formatDuration(ms: number): string {
|
|
116
133
|
if (!ms || ms <= 0) return "-";
|
|
117
134
|
const totalSec = Math.floor(ms / 1000);
|
|
@@ -325,14 +342,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
325
342
|
|
|
326
343
|
const durationStr = formatDuration(Date.now() - sessionStartMs);
|
|
327
344
|
const pDuration = `${acc("\uf017")} ${tint(durationStr)}`;
|
|
328
|
-
|
|
345
|
+
const cwdShort = shortenCwd(cwd);
|
|
346
|
+
let left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}`;
|
|
329
347
|
if (git) {
|
|
330
348
|
const gitIcon = acc("\uf172");
|
|
331
349
|
const gitText = git.uncommitted > 0 ? tint(`~${git.uncommitted}`) : tint("clean");
|
|
332
350
|
const pBranch = `${acc("\uf126")} ${tint(git.branch)}${git.ahead > 0 ? ` ${tint(`↑${git.ahead}`)}` : ""}${git.behind > 0 ? ` ${tint(`↓${git.behind}`)}` : ""}`;
|
|
333
351
|
const pTag = `${acc("\uf02b")} ${tint(git.tag)}`;
|
|
334
352
|
const pState = `${gitIcon} ${gitText}`;
|
|
335
|
-
left1 = `${acc("\uf07b")} ${dim(
|
|
353
|
+
left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${sep}${pBranch}${sep}${pTag}${sep}${pState}`;
|
|
336
354
|
}
|
|
337
355
|
const right1 = formatModelName(currentModel, currentThinkingLevel, acc, tint, dim);
|
|
338
356
|
const pad1 = " ".repeat(Math.max(1, width - visibleWidth(left1) - visibleWidth(right1)));
|
|
@@ -457,6 +475,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
457
475
|
return super.renderTopBorder(width, hiddenLineCount);
|
|
458
476
|
}
|
|
459
477
|
|
|
478
|
+
handleInput(data: string) {
|
|
479
|
+
if (matchesKey(data, RELOAD_SHORTCUT)) {
|
|
480
|
+
this.setText("");
|
|
481
|
+
if (this.onSubmit) {
|
|
482
|
+
this.onSubmit("/reload");
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
super.handleInput(data);
|
|
487
|
+
}
|
|
488
|
+
|
|
460
489
|
render(width: number): string[] {
|
|
461
490
|
// The box eats 5 columns: 2 sides + the 3-column " > " prompt, so the
|
|
462
491
|
// content is rendered 5 columns narrower.
|
|
@@ -591,6 +620,12 @@ if (isMain) {
|
|
|
591
620
|
process.exit(1);
|
|
592
621
|
}
|
|
593
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");
|
|
594
629
|
assert(formatDuration(0) === "-", "durasi kosong = -");
|
|
595
630
|
assert(formatDuration(5_000) === "5s", "detik saja");
|
|
596
631
|
assert(formatDuration(65_000) === "1m 5s", "menit + detik");
|
|
@@ -623,5 +658,12 @@ if (isMain) {
|
|
|
623
658
|
assert(getToolWorkingMessage("mcp", { tool: "fetch_repo" }) === "Fetching", "mcp fetch -> Fetching");
|
|
624
659
|
assert(boxed.slice(0, 4).every((l) => visibleWidth(l) === visibleWidth(D) + 5), "lebar kotak seragam (sisi+prompt)");
|
|
625
660
|
assert(boxEditorLines(["\u2500\u2500\u2500\u2500"], 1, plain).length === 1, "terlalu sempit: tak diubah");
|
|
661
|
+
|
|
662
|
+
// Guard: the reload shortcut must match every encoding a terminal can deliver,
|
|
663
|
+
// 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");
|
|
626
668
|
console.log("footer.ts self-check OK");
|
|
627
669
|
}
|
|
@@ -31,10 +31,14 @@ import {
|
|
|
31
31
|
import { renderBoxLines } from "../lib/box.ts";
|
|
32
32
|
import { formatTokens, getModelAllTimeUsage } from "../lib/usage-store.ts";
|
|
33
33
|
|
|
34
|
-
export type TabKey = "Model" | "Context" | "Skills" | "Extensions" | "Themes";
|
|
34
|
+
export type TabKey = "Model" | "Directory" | "Context" | "Skills" | "Extensions" | "Themes";
|
|
35
|
+
|
|
36
|
+
// Tab order for Ctrl+Alt+T and for the menu line.
|
|
37
|
+
export const TAB_ORDER: TabKey[] = ["Model", "Directory", "Context", "Skills", "Extensions", "Themes"];
|
|
35
38
|
|
|
36
39
|
export const SECTION_ICONS: Record<TabKey, string> = {
|
|
37
40
|
Model: "\uf1b2",
|
|
41
|
+
Directory: "\uf07b",
|
|
38
42
|
Context: "\udb84\uddd7",
|
|
39
43
|
Skills: "\uec21",
|
|
40
44
|
Extensions: "\ueea8",
|
|
@@ -158,6 +162,38 @@ export function wrapCommaItems(items: string[], maxWidth: number): string[] {
|
|
|
158
162
|
return lines;
|
|
159
163
|
}
|
|
160
164
|
|
|
165
|
+
// Full path, hard-wrapped at "/" so the Directory tab never truncates it
|
|
166
|
+
// (a single long segment is sliced as a last resort).
|
|
167
|
+
export function wrapPath(path: string, maxWidth: number): string[] {
|
|
168
|
+
const p = (path || "").replace(/\\/g, "/");
|
|
169
|
+
if (!p) return [p];
|
|
170
|
+
if (maxWidth < 1) return [p];
|
|
171
|
+
const abs = p.startsWith("/");
|
|
172
|
+
const segs = p.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
173
|
+
const lines: string[] = [];
|
|
174
|
+
let cur = abs ? "/" : "";
|
|
175
|
+
for (const seg of segs) {
|
|
176
|
+
const tok = cur === "/" ? seg : abs || cur ? `/${seg}` : seg;
|
|
177
|
+
if (visibleWidth(cur) + visibleWidth(tok) <= maxWidth) {
|
|
178
|
+
cur += tok;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (cur) lines.push(cur);
|
|
182
|
+
if (visibleWidth(tok) <= maxWidth) {
|
|
183
|
+
cur = abs ? `/${seg}` : tok;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
let rest = seg;
|
|
187
|
+
while (visibleWidth(rest) > maxWidth) {
|
|
188
|
+
lines.push(rest.slice(0, maxWidth));
|
|
189
|
+
rest = rest.slice(maxWidth);
|
|
190
|
+
}
|
|
191
|
+
cur = rest;
|
|
192
|
+
}
|
|
193
|
+
if (cur) lines.push(cur);
|
|
194
|
+
return lines.length > 0 ? lines : [p];
|
|
195
|
+
}
|
|
196
|
+
|
|
161
197
|
export function centerLine(text: string, width: number, side = "│"): string {
|
|
162
198
|
const sideW = visibleWidth(side);
|
|
163
199
|
const innerW = Math.max(0, width - sideW * 2);
|
|
@@ -200,6 +236,7 @@ export const tabStore: Map<TabKey, string[]> =
|
|
|
200
236
|
(globalThis as Record<symbol, Map<TabKey, string[]>>)[STORE_KEY] ??
|
|
201
237
|
new Map<TabKey, string[]>([
|
|
202
238
|
["Model", []],
|
|
239
|
+
["Directory", []],
|
|
203
240
|
["Context", []],
|
|
204
241
|
["Skills", []],
|
|
205
242
|
["Extensions", []],
|
|
@@ -231,6 +268,13 @@ export function formatModelDisplayName(model?: { id?: string; name?: string; pro
|
|
|
231
268
|
return `${name}${provStr}`;
|
|
232
269
|
}
|
|
233
270
|
|
|
271
|
+
// Full working directory for the Directory tab. ctx.cwd is authoritative; the
|
|
272
|
+
// snapshot survives header re-creation when ctx is missing.
|
|
273
|
+
const CWD_SNAPSHOT_KEY = Symbol.for("pi-arnative.lastCwd");
|
|
274
|
+
export function getCwd(ctx?: { cwd?: string }): string {
|
|
275
|
+
return ctx?.cwd || (globalThis as Record<symbol, any>)[CWD_SNAPSHOT_KEY] || "";
|
|
276
|
+
}
|
|
277
|
+
|
|
234
278
|
export class ArnativeHeader implements Component {
|
|
235
279
|
public activeTab: TabKey = "Model";
|
|
236
280
|
private renderedTabLineY = -1;
|
|
@@ -259,6 +303,11 @@ export class ArnativeHeader implements Component {
|
|
|
259
303
|
|
|
260
304
|
getTabsData(width: number): Array<{ key: TabKey; name: string; icon: string; count: number; label: string }> {
|
|
261
305
|
const modelObj = this.ctx?.model ?? (globalThis as Record<symbol, any>)[MODEL_SNAPSHOT_KEY];
|
|
306
|
+
const cwd = getCwd(this.ctx);
|
|
307
|
+
// Tab row stays compact (basename only): the full path is in the content
|
|
308
|
+
// line below, and render() expands this label when every tab still fits.
|
|
309
|
+
const dirBase = cwd.replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop() || cwd;
|
|
310
|
+
const dirLabel = dirBase ? `Directory: ${dirBase}` : "Directory";
|
|
262
311
|
const fullModelName = formatModelDisplayName(modelObj);
|
|
263
312
|
// Drop bracketed details from the tail of the name ("... Free (1M) [OpenCode] (Freeflow)"
|
|
264
313
|
// -> "Space Bunny Free"); the details still show in the Model tab data line.
|
|
@@ -274,6 +323,7 @@ export class ArnativeHeader implements Component {
|
|
|
274
323
|
|
|
275
324
|
return [
|
|
276
325
|
{ key: "Model", name: "Model", icon: SECTION_ICONS.Model, count: 0, label: modelLabel },
|
|
326
|
+
{ key: "Directory", name: "Directory", icon: SECTION_ICONS.Directory, count: 0, label: dirLabel },
|
|
277
327
|
{
|
|
278
328
|
key: "Context",
|
|
279
329
|
name: "Context",
|
|
@@ -384,18 +434,16 @@ export class ArnativeHeader implements Component {
|
|
|
384
434
|
const sepTab = fgFirst(th, ["dim"], sepTabPlain);
|
|
385
435
|
|
|
386
436
|
const tabParts: Array<{ key: TabKey; plain: string; formatted: string }> = [];
|
|
387
|
-
|
|
388
|
-
const plain = `${t.icon} ${
|
|
437
|
+
const makeTabPart = (t: { key: TabKey; icon: string; label: string }, label = t.label) => {
|
|
438
|
+
const plain = `${t.icon} ${label}`;
|
|
389
439
|
const isActive = t.key === this.activeTab;
|
|
390
|
-
const iconStyled = isActive
|
|
391
|
-
? fgFirst(th, ["accent"], t.icon)
|
|
392
|
-
: fgFirst(th, ["dim"], t.icon);
|
|
440
|
+
const iconStyled = isActive ? fgFirst(th, ["accent"], t.icon) : fgFirst(th, ["dim"], t.icon);
|
|
393
441
|
const labelStyled = isActive
|
|
394
|
-
? fgFirst(th, ["tint", "text"], `\x1b[1m${
|
|
395
|
-
: fgFirst(th, ["dim"],
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
442
|
+
? fgFirst(th, ["tint", "text"], `\x1b[1m${label}\x1b[22m`)
|
|
443
|
+
: fgFirst(th, ["dim"], label);
|
|
444
|
+
return { key: t.key, plain, formatted: `${iconStyled} ${labelStyled}` };
|
|
445
|
+
};
|
|
446
|
+
for (const t of tabs) tabParts.push(makeTabPart(t));
|
|
399
447
|
|
|
400
448
|
// Tabs that don't fit on narrow screens are dropped from the right (the active tab always stays).
|
|
401
449
|
const kept: typeof tabParts = [];
|
|
@@ -407,6 +455,20 @@ export class ArnativeHeader implements Component {
|
|
|
407
455
|
usedWidth += w;
|
|
408
456
|
}
|
|
409
457
|
}
|
|
458
|
+
// All tabs fit? Then the Directory tab may show the whole path instead of the
|
|
459
|
+
// basename. Skipped when it would push another tab off the menu.
|
|
460
|
+
if (kept.length === tabParts.length) {
|
|
461
|
+
const i = kept.findIndex((t) => t.key === "Directory");
|
|
462
|
+
const cwdFull = getCwd(this.ctx);
|
|
463
|
+
const dirTab = tabs[i];
|
|
464
|
+
if (i >= 0 && dirTab && cwdFull) {
|
|
465
|
+
const full = makeTabPart(dirTab, `Directory: ${cwdFull}`);
|
|
466
|
+
if (usedWidth - visibleWidth(kept[i]!.plain) + visibleWidth(full.plain) <= innerW) {
|
|
467
|
+
kept[i] = full;
|
|
468
|
+
usedWidth += visibleWidth(full.plain) - visibleWidth(tabParts[i]!.plain);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
410
472
|
this.renderedTabRegions = [];
|
|
411
473
|
let curX = INFO_X;
|
|
412
474
|
for (const t of kept) {
|
|
@@ -463,7 +525,11 @@ export class ArnativeHeader implements Component {
|
|
|
463
525
|
activeItems = tabStore.get(this.activeTab) ?? [];
|
|
464
526
|
}
|
|
465
527
|
|
|
466
|
-
if (
|
|
528
|
+
if (this.activeTab === "Directory") {
|
|
529
|
+
for (const line of wrapPath(getCwd(this.ctx), infoW)) {
|
|
530
|
+
infoLines.push(fgFirst(th, ["tint", "text"], line));
|
|
531
|
+
}
|
|
532
|
+
} else if (activeItems.length === 0) {
|
|
467
533
|
infoLines.push(fgFirst(th, ["tint"], "(kosong)"));
|
|
468
534
|
} else if (this.activeTab === "Model") {
|
|
469
535
|
// The Model tab already carries tint styling + a dim separator
|
|
@@ -554,8 +620,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
554
620
|
handler: async () => {
|
|
555
621
|
const hdr = activeHeaderInstance;
|
|
556
622
|
if (!hdr) return;
|
|
557
|
-
const order
|
|
558
|
-
hdr.activeTab = order[(order.indexOf(hdr.activeTab) + 1) % order.length]
|
|
623
|
+
const order = TAB_ORDER;
|
|
624
|
+
hdr.activeTab = order[(order.indexOf(hdr.activeTab) + 1) % order.length]!;
|
|
559
625
|
hdr.tui?.requestRender?.();
|
|
560
626
|
},
|
|
561
627
|
});
|
|
@@ -571,6 +637,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
571
637
|
pi.on("turn_start", async (_event, ctx) => {
|
|
572
638
|
(globalThis as Record<symbol, any>)[MODEL_SNAPSHOT_KEY] = ctx.model;
|
|
573
639
|
(globalThis as Record<symbol, any>)[THINKING_SNAPSHOT_KEY] = ctx.thinkingLevel;
|
|
640
|
+
if (ctx.cwd) (globalThis as Record<symbol, any>)[CWD_SNAPSHOT_KEY] = ctx.cwd;
|
|
574
641
|
});
|
|
575
642
|
|
|
576
643
|
pi.on("turn_end", async () => {
|
|
@@ -598,6 +665,13 @@ if (isMain) {
|
|
|
598
665
|
assert(extractItemsFromBody(" a, b, c").length === 3, "extractItemsFromBody koma = 3");
|
|
599
666
|
assert(extractItemsFromBody(" x\n y\n z").length === 3, "extractItemsFromBody baris = 3");
|
|
600
667
|
|
|
668
|
+
// Directory path wrapping: never truncated, always the full path
|
|
669
|
+
const wp = wrapPath("/run/media/bisma/DATA/Pi/pi-arnative", 20);
|
|
670
|
+
assert(wp.join("") === "/run/media/bisma/DATA/Pi/pi-arnative", "wrapPath tak menghilangkan segmen");
|
|
671
|
+
assert(wp.every((l) => visibleWidth(l) <= 20), "wrapPath hormati lebar");
|
|
672
|
+
assert(wrapPath("D:\\Pi\\pi-arnative", 80).join("") === "D:/Pi/pi-arnative", "wrapPath normalisasi backslash");
|
|
673
|
+
assert(wrapPath("/a/very-long-segment-name", 6).every((l) => visibleWidth(l) <= 6), "segmen panjang diiris");
|
|
674
|
+
|
|
601
675
|
const wrapTest = wrapCommaItems(["item1", "item2", "item3", "item4"], 15);
|
|
602
676
|
assert(wrapTest.length >= 2, "wrapCommaItems membungkus baris");
|
|
603
677
|
assert(visibleWidth(wrapTest[0]!) <= 15, "lebar baris wrap sesuai batas");
|
|
@@ -643,6 +717,7 @@ if (isMain) {
|
|
|
643
717
|
const fakeCtx: any = {
|
|
644
718
|
model: { name: "Gemini 3.8 Flash", provider: "antigravity" },
|
|
645
719
|
thinkingLevel: "low",
|
|
720
|
+
cwd: "/run/media/bisma/DATA/Pi/pi-arnative",
|
|
646
721
|
};
|
|
647
722
|
|
|
648
723
|
// Only "dim" is colored, so the box line colors can be asserted.
|
|
@@ -663,7 +738,21 @@ if (isMain) {
|
|
|
663
738
|
);
|
|
664
739
|
assert(stripAnsi(lines100[1]!).includes("pi v") && stripAnsi(lines100[1]!).includes("Arnative"), "baris versi + merek");
|
|
665
740
|
assert(stripAnsi(lines100[4]!).includes("Model: Gemini 3.8 Flash"), "menu tab Model tampil");
|
|
741
|
+
// Directory tab: whole path in the content line, no truncation
|
|
742
|
+
hdr.activeTab = "Directory";
|
|
743
|
+
hdr.render(100);
|
|
744
|
+
assert(stripAnsi(lines100[4]!).includes("Directory:"), "tab Directory tampil di menu");
|
|
745
|
+
hdr.activeTab = "Model";
|
|
746
|
+
hdr.render(100);
|
|
666
747
|
assert(stripAnsi(lines100[6]!).includes("Gemini 3.8 Flash (Antigravity)"), "data tab aktif Model tampil di baris info terakhir");
|
|
748
|
+
// Directory tab content: full path, wrapped, nothing lost
|
|
749
|
+
hdr.activeTab = "Directory";
|
|
750
|
+
const linesDir = hdr.render(100);
|
|
751
|
+
const dirContent = stripAnsi(linesDir[6]!);
|
|
752
|
+
assert(dirContent.includes("/run/media/bisma"), "path lengkap tab Directory (awal) tampil");
|
|
753
|
+
assert(dirContent.includes("pi-arnative"), "path lengkap tab Directory (akhir) tampil — tanpa truncate");
|
|
754
|
+
hdr.activeTab = "Model";
|
|
755
|
+
hdr.render(100);
|
|
667
756
|
// The inner box lines are one fully dimmed block (the "─" runs are colored too, not just the corners).
|
|
668
757
|
for (const i of [3, 5]) {
|
|
669
758
|
assert((lines100[i]!.match(/\x1b\[/g) ?? []).length === 8, `garis kotak dalam baris ${i} = satu blok dim penuh (garis "─" ikut ter-warnai, bukan hanya sudut)`);
|
|
@@ -673,7 +762,8 @@ if (isMain) {
|
|
|
673
762
|
const tabY = (hdr as any).renderedTabLineY;
|
|
674
763
|
assert(tabY > 0, "posisi Y baris tab tercatat");
|
|
675
764
|
const tabRegions = (hdr as any).renderedTabRegions as Array<{ key: TabKey; startX: number; endX: number }>;
|
|
676
|
-
assert(tabRegions.length >=
|
|
765
|
+
assert(tabRegions.length >= 3, "region tab terisi (tab kanan bisa terpotong di 100 kolom)");
|
|
766
|
+
assert(tabRegions.some((r) => r.key === "Directory"), "tab Directory ada di menu (lebar 100)");
|
|
677
767
|
|
|
678
768
|
// Click the Extensions tab (when truncated at 100 columns, use the rightmost tab)
|
|
679
769
|
const extRegion = tabRegions.find((r) => r.key === "Extensions") ?? tabRegions[tabRegions.length - 1]!;
|
|
@@ -690,20 +780,29 @@ if (isMain) {
|
|
|
690
780
|
const linesExt = hdr.render(100);
|
|
691
781
|
assert(stripAnsi(linesExt[6]!).includes(extRegion.key === "Extensions" ? "@bismawy/pi-agentrouter@1.6.1" : ""), "data tab aktif tampil di baris info terakhir");
|
|
692
782
|
|
|
693
|
-
// Click the Skills tab
|
|
694
|
-
const
|
|
783
|
+
// Click the Skills tab (measured at a width where every tab fits)
|
|
784
|
+
const wide = hdr.render(140);
|
|
785
|
+
const wideRegions = (hdr as any).renderedTabRegions as Array<{ key: TabKey; startX: number; endX: number }>;
|
|
786
|
+
const skillsRegion = wideRegions.find((r) => r.key === "Skills") ?? wideRegions[wideRegions.length - 1]!;
|
|
787
|
+
hdr.activeTab = "Model";
|
|
788
|
+
hdr.render(140);
|
|
789
|
+
const skillsY = (hdr as any).renderedTabLineY;
|
|
695
790
|
hdr.handleMouse({
|
|
696
791
|
type: "click",
|
|
697
792
|
button: "left",
|
|
698
793
|
x: Math.floor((skillsRegion.startX + skillsRegion.endX) / 2),
|
|
699
|
-
y:
|
|
794
|
+
y: skillsY,
|
|
700
795
|
} as any);
|
|
701
|
-
assert(hdr.activeTab ===
|
|
702
|
-
|
|
703
|
-
const linesSkills = hdr.render(
|
|
704
|
-
assert(
|
|
705
|
-
assert(
|
|
706
|
-
|
|
796
|
+
assert(hdr.activeTab === skillsRegion.key, `activeTab berubah ke ${skillsRegion.key} setelah klik`);
|
|
797
|
+
|
|
798
|
+
const linesSkills = hdr.render(140);
|
|
799
|
+
assert(wide.every((l) => visibleWidth(l) === 140), "baris 140 kolom tetap 140");
|
|
800
|
+
assert(wideRegions.length === 6, "lebar 140: semua 6 tab tampil");
|
|
801
|
+
if (skillsRegion.key === "Skills") {
|
|
802
|
+
assert(linesSkills[6]!.includes("agents-sdk, cloudflare"), "data tab Skills tampil di baris info terakhir");
|
|
803
|
+
assert(!linesSkills[6]!.includes("Gemini 3.8 Flash (Antigravity)"), "data Model tidak tampil di konten tab Skills");
|
|
804
|
+
assert(linesSkills[4]!.includes("Model: Gemini 3.8 Flash"), "menu tab tetap tampil saat tab lain aktif");
|
|
805
|
+
}
|
|
707
806
|
|
|
708
807
|
// The Y of a tab must point at the tab menu line, not the shortcut line above
|
|
709
808
|
const rowsPlain = lines100.map((l) => stripAnsi(l));
|
package/extensions/tools.ts
CHANGED
|
@@ -266,7 +266,14 @@ for (const n of BOXED_TOOLS.keys()) OWN_BOX.add(n);
|
|
|
266
266
|
// of lines. A 1-line summary + [ctrl+o to expand] is tighter; their args lines
|
|
267
267
|
// still feed the call box so the invoked MCP tool stays visible.
|
|
268
268
|
const MCP_TOOL = (name: string): boolean => name === "mcp" || name === "mcpScript" || name.startsWith("mcp__");
|
|
269
|
-
|
|
269
|
+
|
|
270
|
+
// pi-fff "override" mode re-registers `find`/`grep` with its own renderers, replacing our
|
|
271
|
+
// boxed versions -> plain text again. Box theirs too; ours carry renderShell "self" (minimal).
|
|
272
|
+
const FFF_OVERRIDE = ["find", "grep"];
|
|
273
|
+
const pakaiCallMereka = (self: any): boolean =>
|
|
274
|
+
BOXED_TOOLS.has(self.toolName) ||
|
|
275
|
+
MCP_TOOL(self.toolName) ||
|
|
276
|
+
(FFF_OVERRIDE.includes(self.toolName) && self.toolDefinition?.renderShell !== "self");
|
|
270
277
|
|
|
271
278
|
// The MCP box title names the invoked tool (full args when expanded).
|
|
272
279
|
const mcpInfo = (name: string, args: any, th: Theme): string => {
|
|
@@ -326,14 +333,15 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
|
|
|
326
333
|
};
|
|
327
334
|
|
|
328
335
|
proto.getRenderShell = function (): string {
|
|
329
|
-
if (pakaiCallMereka(this
|
|
336
|
+
if (pakaiCallMereka(this)) return "self";
|
|
330
337
|
return hasOwnRenderer(this) ? origShell.call(this) : "self";
|
|
331
338
|
};
|
|
332
339
|
|
|
333
340
|
proto.getCallRenderer = function () {
|
|
334
341
|
const name: string = this.toolName;
|
|
342
|
+
const mine = pakaiCallMereka(this);
|
|
335
343
|
const own = origCall.call(this);
|
|
336
|
-
if (own && !
|
|
344
|
+
if (own && !mine) return own;
|
|
337
345
|
return (args: any, th: Theme, ctx: TCtx) =>
|
|
338
346
|
new Lines((width) => {
|
|
339
347
|
if ((ctx.state as Record<string, unknown> | undefined)?.hasResult) return [];
|
|
@@ -341,7 +349,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
|
|
|
341
349
|
const theirs = own ? componentLines(own.call(this, args, th, ctx), width - 6) : [];
|
|
342
350
|
if (name === "todo" && theirs.length)
|
|
343
351
|
return box(th, width, formatTodoRows(theirs, th), "toolPendingBg");
|
|
344
|
-
if (
|
|
352
|
+
if (mine && theirs.length)
|
|
345
353
|
return box(th, width, boxedRows(icon, capped(th, theirs, Boolean(this.expanded))));
|
|
346
354
|
const inner = Math.max(8, width - 4);
|
|
347
355
|
return box(th, width, titleRow(th, icon, th.fg("accent", displayName(name)), "", inner, "", false), boxBgOf(name));
|
|
@@ -351,8 +359,10 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
|
|
|
351
359
|
proto.getResultRenderer = function () {
|
|
352
360
|
const name: string = this.toolName;
|
|
353
361
|
const own = origResult.call(this);
|
|
362
|
+
// pi-fff splits title (renderCall) and result (renderResult); both go in one box.
|
|
363
|
+
const fff = FFF_OVERRIDE.includes(name) && this.toolDefinition?.renderShell !== "self";
|
|
354
364
|
// MCP: their result renderer is deliberately skipped (unbounded error dump).
|
|
355
|
-
if (own && !pakaiCallMereka(
|
|
365
|
+
if (own && !pakaiCallMereka(this)) return own;
|
|
356
366
|
const boxed = BOXED_TOOLS.has(name);
|
|
357
367
|
return (result: TResult, opts: { expanded: boolean; isPartial?: boolean }, th: Theme, ctx: TCtx) => {
|
|
358
368
|
if (opts.isPartial) return EMPTY;
|
|
@@ -364,7 +374,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
|
|
|
364
374
|
// Errors use our own path: pi-web-access' own error box is a box inside a
|
|
365
375
|
// box. Their renderer content is only used on success.
|
|
366
376
|
const theirs =
|
|
367
|
-
boxed && !isErr
|
|
377
|
+
(boxed || fff) && !isErr
|
|
368
378
|
? own.call(this, { content: result.content, details: result.details }, opts, th, ctx)
|
|
369
379
|
: null;
|
|
370
380
|
return new Lines((width) => {
|
|
@@ -372,6 +382,10 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
|
|
|
372
382
|
const inner = Math.max(8, width - 4);
|
|
373
383
|
const icon = isErr ? th.fg("error", "x") : th.fg("success", "✓");
|
|
374
384
|
const boxed2 = componentLines(theirs, width - 6);
|
|
385
|
+
if (fff && boxed2.length) {
|
|
386
|
+
const callLines = componentLines(origCall.call(this)?.call(this, ctx.args, th, ctx), width - 6);
|
|
387
|
+
return box(th, width, boxedRows(icon, [callLines[0]?.trim() || displayName(name), ...boxed2]));
|
|
388
|
+
}
|
|
375
389
|
if (name === "todo" && boxed2.length) {
|
|
376
390
|
// todo call result (status completed / in_progress)
|
|
377
391
|
const callComp = origCall.call(this)?.call(this, ctx.args, th, ctx);
|
|
@@ -788,6 +802,31 @@ if (isMain) {
|
|
|
788
802
|
"pi-web-access: baris args renderer mereka masuk kotak",
|
|
789
803
|
);
|
|
790
804
|
|
|
805
|
+
// pi-fff override mode: find/grep re-registered with own renderers, no renderShell
|
|
806
|
+
const fffCall = { render: () => ["\x1b[1mfind \x1b[22m\x1b[36m*.ts in D:/Pi/x\x1b[39m"] };
|
|
807
|
+
const fffRes = { render: () => ["src/a.ts \x1b[2m... (6 more lines)\x1b[22m"] };
|
|
808
|
+
const fffFind = { toolName: "find", toolDefinition: { renderCall: () => fffCall, renderResult: () => fffRes } };
|
|
809
|
+
assert(
|
|
810
|
+
ToolExecutionComponent.prototype.getRenderShell.call(fffFind) === "self",
|
|
811
|
+
"pi-fff find: shell dipaksa self (ikut kotak kita)",
|
|
812
|
+
);
|
|
813
|
+
const fffOut = (ToolExecutionComponent.prototype.getResultRenderer.call(fffFind) as any)(
|
|
814
|
+
{ content: [{ type: "text", text: "src/a.ts\nsrc/b.ts" }] },
|
|
815
|
+
{ expanded: false, isPartial: false },
|
|
816
|
+
th,
|
|
817
|
+
{ args: { pattern: "*.ts", path: "D:/Pi/x" }, state: {} },
|
|
818
|
+
).render(60);
|
|
819
|
+
assert(fffOut[0].startsWith("╭") && fffOut[fffOut.length - 1].startsWith("╰"), "pi-fff find: hasil dibungkus kotak");
|
|
820
|
+
assert(fffOut.some((l: string) => l.includes("*.ts in D:/Pi/x")), "pi-fff find: judul renderCall ikut masuk kotak");
|
|
821
|
+
assert(fffOut.some((l: string) => l.includes("6 more lines")), "pi-fff find: ringkasan renderResult ikut masuk kotak");
|
|
822
|
+
|
|
823
|
+
const oursFind = { toolName: "find", toolDefinition: { renderShell: "self", renderCall: () => fffCall, renderResult: () => fffRes } };
|
|
824
|
+
assert(
|
|
825
|
+
(ToolExecutionComponent.prototype.getCallRenderer.call(oursFind) as any)() === fffCall &&
|
|
826
|
+
(ToolExecutionComponent.prototype.getResultRenderer.call(oursFind) as any)() === fffRes,
|
|
827
|
+
"find kita (renderShell self): renderer sendiri apa adanya, tak dibungkus kotak lagi",
|
|
828
|
+
);
|
|
829
|
+
|
|
791
830
|
// Guard: source_check is deliberately NOT boxed — its curator partial carries
|
|
792
831
|
// URLs + approval state, while our partial path returns nothing. Without this
|
|
793
832
|
// assert a later edit of BOXED_TOOLS would swallow it silently.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bismawy/pi-arnative",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"@earendil-works/pi-tui": "*"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
|
+
"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",
|
|
40
41
|
"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",
|
|
41
42
|
"themes": "node themes/gen-themes.mjs"
|
|
42
43
|
}
|