@pi-archimedes/footer 2.1.0 → 2.3.0
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 +6 -7
- package/package.json +4 -2
- package/src/index.ts +42 -68
- package/src/utils/format.test.ts +13 -0
- package/src/utils/format.ts +15 -7
- package/src/utils/git.test.ts +30 -5
- package/src/utils/git.ts +17 -10
- package/src/utils/layout.test.ts +107 -0
- package/src/utils/layout.ts +70 -0
- package/src/utils/render-smoke.test.ts +87 -0
package/README.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
A status bar that shows what matters — at a glance, without getting in the way.
|
|
4
4
|
|
|
5
|
-
Your terminal is already full of information. The footer gives you exactly what you need to know about your session — where you are, what model you're using, how many tokens you've burned, and how close you are to the context limit —
|
|
5
|
+
Your terminal is already full of information. The footer gives you exactly what you need to know about your session — where you are, what model you're using, how many tokens you've burned, and how close you are to the context limit — laid out at the bottom of the terminal, adapting to its width without ever clipping. When subagents run, their costs merge seamlessly into the same view.
|
|
6
6
|
|
|
7
7
|
## What you get
|
|
8
8
|
|
|
9
|
-
- **Session context at a glance** — directory, git branch (with clean/dirty
|
|
9
|
+
- **Session context at a glance** — directory, git branch (with clean/dirty indicators), active model, thinking level; the branch icon switches from `⎇` to `🌲` when cwd is inside a linked worktree — no extra chip, no duplication
|
|
10
10
|
- **Token stats** — input ↑, output ↓, cache read/write, and real-dollar cost, all in one compact display
|
|
11
11
|
- **Context window bar** — color-coded progress bar (green → yellow → red) showing how much of your context window is used
|
|
12
|
-
- **
|
|
12
|
+
- **Adaptive layout** — everything on one line when it fits; otherwise it wraps to additional lines (two or three) instead of clipping. Width at which it switches is configurable
|
|
13
13
|
- **Unified subagent costs** — when subagents run, their token usage and cost merge into the main footer automatically
|
|
14
14
|
|
|
15
15
|
## Install
|
|
@@ -28,19 +28,18 @@ pi install npm:pi-archimedes
|
|
|
28
28
|
|
|
29
29
|
Footer renders automatically at the bottom of the Pi TUI. No commands to run.
|
|
30
30
|
|
|
31
|
-
On wide terminals, the footer displays as
|
|
31
|
+
On wide terminals, the footer displays as one compact line with all session context, token usage, cost, and a context window progress bar that fills the remaining space. When that no longer fits on one line, the footer wraps — usage moves to a second line, and sections themselves overflow to a third line if the terminal is truly narrow — so nothing ever gets clipped. Below the configured split threshold, it always uses at least the two-line layout (system info above, stats below).
|
|
32
32
|
|
|
33
33
|
## Settings
|
|
34
34
|
|
|
35
35
|
| Setting | Type | Default | Description |
|
|
36
36
|
|---------|------|---------|-------------|
|
|
37
|
-
| `splitThreshold` | number | `150` | Minimum terminal width (columns)
|
|
37
|
+
| `splitThreshold` | number | `150` | Minimum terminal width (columns) where a single-line footer is still allowed. Below it, the footer always uses at least the two-line layout; above it, it stays on one line as long as the content fits and wraps otherwise — never clipping. |
|
|
38
38
|
|
|
39
39
|
Settings are stored in `~/.pi/agent/settings.json` under the `archimedes.footer` namespace.
|
|
40
40
|
|
|
41
41
|
## Integration
|
|
42
42
|
|
|
43
|
-
When installed via meta package, footer consumes cost events from subagents through the core bus, giving a unified token/cost view. Standalone install shows only the main agent's stats.
|
|
44
|
-
|
|
43
|
+
When installed via the meta package, footer consumes cost events from subagents through the core bus, giving a unified token/cost view across the main agent and any running subagents. Standalone install shows only the main agent's stats.
|
|
45
44
|
|
|
46
45
|
← Back to [pi-archimedes](../../README.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/footer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -15,13 +15,15 @@
|
|
|
15
15
|
"./config": "./src/config.ts"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@pi-archimedes/core": "2.
|
|
18
|
+
"@pi-archimedes/core": "2.3.0"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
22
22
|
"@earendil-works/pi-tui": ">=0.1.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
26
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
25
27
|
"typescript": "^6.0.0"
|
|
26
28
|
},
|
|
27
29
|
"pi": {
|
package/src/index.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dir |
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* dir | branch [+status] | worktree | model | ◐thinking | ↑↓R W $cost ━ context%
|
|
3
|
+
*
|
|
4
|
+
* Adaptive layout — wraps to additional lines instead of truncating:
|
|
5
|
+
* - fits width → single line (system info · stats · context bar)
|
|
6
|
+
* - doesn't fit → two lines (system info above, stats + bar below)
|
|
7
|
+
* - left sections alone overflow → three or more lines
|
|
8
|
+
* - below splitThreshold (default 150) → at least two lines, as configured
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import {
|
|
12
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
10
13
|
import { clampLine } from "@pi-archimedes/core/text";
|
|
11
14
|
import { loadFooterConfig } from "./config.js";
|
|
12
15
|
import { CostAccumulator } from "./cost-accumulator.js";
|
|
13
|
-
import { getGitStatus,
|
|
16
|
+
import { getGitStatus, isInsideLinkedWorktree } from "./utils/git.js";
|
|
14
17
|
import { getContextWindowInfo, getTokenUsageStats, type TokenUsageStats } from "./utils/stats.js";
|
|
15
18
|
import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, formatTokenCount } from "./utils/format.js";
|
|
16
19
|
import { footerIcons } from "./utils/icons.js";
|
|
20
|
+
import { packFooterLines, SEP_W, SEPARATOR } from "./utils/layout.js";
|
|
17
21
|
|
|
18
22
|
export function registerFooter(pi: ExtensionAPI): void {
|
|
19
23
|
// Module-level state for session lifecycle (shared between session_start and session_shutdown)
|
|
@@ -48,7 +52,7 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
48
52
|
const currentBranch = footerData.getGitBranch();
|
|
49
53
|
const currentDirectory = process.cwd().split("/").pop() || process.cwd();
|
|
50
54
|
const gitStatus = getGitStatus();
|
|
51
|
-
const
|
|
55
|
+
const inWorktree = isInsideLinkedWorktree();
|
|
52
56
|
const thinkingLevel = pi.getThinkingLevel();
|
|
53
57
|
|
|
54
58
|
// Merge main agent stats with subagent stats from accumulator
|
|
@@ -65,29 +69,22 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
65
69
|
const { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost } = mergedStats;
|
|
66
70
|
const { percent: contextPercent, percentValue: contextPercentValue, windowSize: contextWindowSize } = getContextWindowInfo(ctx);
|
|
67
71
|
|
|
68
|
-
// ──
|
|
72
|
+
// ── Sections ──────────────────────────────────────────────────
|
|
69
73
|
|
|
70
|
-
const shouldSplit = width < splitThreshold;
|
|
71
|
-
|
|
72
|
-
// Thinking display
|
|
73
74
|
const thinkingIndicatorStr = formatThinkingIndicator(thinkingLevel, colorize);
|
|
74
|
-
|
|
75
|
-
// Git status indicators
|
|
76
75
|
const gitStatusStr = formatGitStatusIndicators(gitStatus, colorize);
|
|
77
76
|
|
|
78
|
-
|
|
77
|
+
const branchIcon = inWorktree ? footerIcons.worktree : footerIcons.branch;
|
|
78
|
+
|
|
79
|
+
// System info sections: dir | branch [+status] | model | thinking
|
|
79
80
|
const leftSections = [
|
|
80
81
|
colorize("syntaxFunction", " " + footerIcons.directory + currentDirectory),
|
|
81
|
-
currentBranch ? colorize("success",
|
|
82
|
+
currentBranch ? colorize("success", branchIcon + " " + currentBranch + (gitStatusStr ? " " + gitStatusStr : "")) : "",
|
|
82
83
|
colorize("syntaxType", footerIcons.model + " " + activeModel),
|
|
83
84
|
thinkingIndicatorStr,
|
|
84
|
-
worktreeBranch ? colorize("syntaxNumber", footerIcons.worktree + " " + worktreeBranch) : "",
|
|
85
85
|
].filter(Boolean);
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
const leftSectionStr = leftSections.join(separator);
|
|
89
|
-
|
|
90
|
-
// Token stats with context percentage
|
|
87
|
+
// Usage stats: ↑in ↓out RcacheRead WcacheWrite $cost contextWindow
|
|
91
88
|
const statsParts: string[] = [];
|
|
92
89
|
if (totalInput) statsParts.push("↑" + formatTokenCount(totalInput));
|
|
93
90
|
if (totalOutput) statsParts.push("↓" + formatTokenCount(totalOutput));
|
|
@@ -108,56 +105,33 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
108
105
|
: contextDisplay;
|
|
109
106
|
statsParts.push(contextColored);
|
|
110
107
|
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
// Assemble line 2: stats | bar
|
|
124
|
-
const rightSections: string[] = [];
|
|
125
|
-
if (statsSectionStr) rightSections.push(statsSectionStr);
|
|
126
|
-
if (contextBarStr) rightSections.push(contextBarStr);
|
|
127
|
-
const rightSectionStr = rightSections.join(theme.fg("dim", " · "));
|
|
128
|
-
|
|
129
|
-
// Edge case: if both stats and bar are empty, return only line 1
|
|
130
|
-
if (!rightSectionStr) {
|
|
131
|
-
return [clampLine(leftSectionStr, width)];
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return [
|
|
135
|
-
clampLine(leftSectionStr, width),
|
|
136
|
-
clampLine(rightSectionStr, width),
|
|
137
|
-
];
|
|
108
|
+
const statsSectionStr = theme.fg("dim", statsParts.join(" "));
|
|
109
|
+
|
|
110
|
+
// ── Adaptive multi-line layout ──────────────────────────────
|
|
111
|
+
// Pack sections left-to-right; whatever doesn't fit wraps to the
|
|
112
|
+
// next line. Below the splitThreshold setting, force at least the
|
|
113
|
+
// two-line split (system info above, stats below) even when one
|
|
114
|
+
// line would fit. The context bar expands into the remainder of
|
|
115
|
+
// the last line and is dropped only if no reasonable space is left.
|
|
116
|
+
const separator = theme.fg("dim", SEPARATOR);
|
|
117
|
+
let groups = packFooterLines([...leftSections, statsSectionStr], width, SEP_W);
|
|
118
|
+
if (groups.length < 2 && width < splitThreshold && leftSections.length > 0) {
|
|
119
|
+
groups = [leftSections, [statsSectionStr]];
|
|
138
120
|
}
|
|
139
121
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// Assemble: left | stats | bar
|
|
155
|
-
const rightSections: string[] = [];
|
|
156
|
-
if (statsSectionStr) rightSections.push(statsSectionStr);
|
|
157
|
-
if (contextBarStr) rightSections.push(contextBarStr);
|
|
158
|
-
const rightSectionStr = rightSections.join(theme.fg("dim", " · "));
|
|
159
|
-
|
|
160
|
-
return [clampLine(leftSectionStr + sectionSeparator + rightSectionStr, width)];
|
|
122
|
+
return groups
|
|
123
|
+
.map((group, idx) => {
|
|
124
|
+
let line = group.join(separator);
|
|
125
|
+
if (idx === groups.length - 1) {
|
|
126
|
+
const remaining = width - visibleWidth(line) - (line ? SEP_W : 0);
|
|
127
|
+
const contextBarStr = formatContextBar(colorize, contextPercentValue, remaining);
|
|
128
|
+
if (contextBarStr) {
|
|
129
|
+
line = line ? line + separator + contextBarStr : contextBarStr;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return clampLine(line, width);
|
|
133
|
+
})
|
|
134
|
+
.filter((line) => line.length > 0);
|
|
161
135
|
} catch (e) {
|
|
162
136
|
console.error("[archimedes:footer] Render error:", e);
|
|
163
137
|
return [];
|
package/src/utils/format.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
3
|
import {
|
|
3
4
|
formatTokenCount,
|
|
4
5
|
formatContextBar,
|
|
@@ -76,6 +77,18 @@ describe("formatContextBar", () => {
|
|
|
76
77
|
const result = formatContextBar(mockColor, 75, 10);
|
|
77
78
|
expect(result).toContain("75%");
|
|
78
79
|
});
|
|
80
|
+
|
|
81
|
+
it("occupies exactly totalSpace visible columns", () => {
|
|
82
|
+
expect(visibleWidth(formatContextBar(mockColor, 50, 24))).toBe(24);
|
|
83
|
+
expect(visibleWidth(formatContextBar(mockColor, 100, 30))).toBe(30);
|
|
84
|
+
expect(visibleWidth(formatContextBar(mockColor, 0, 16))).toBe(16);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns empty when the fixed overhead alone would not fit", () => {
|
|
88
|
+
// icon + gaps (4) + label leave no room for a bar segment
|
|
89
|
+
expect(formatContextBar(mockColor, 50, 7)).toBe("");
|
|
90
|
+
expect(formatContextBar(mockColor, 100, 8)).toBe("");
|
|
91
|
+
});
|
|
79
92
|
});
|
|
80
93
|
|
|
81
94
|
// ── formatGitStatusIndicators ───────────────────────────────────────────────
|
package/src/utils/format.ts
CHANGED
|
@@ -12,12 +12,20 @@ export function formatTokenCount(count: number): string {
|
|
|
12
12
|
return Math.round(count / TOKEN_M) + "M";
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Context progress bar, sized to occupy EXACTLY `totalSpace` visible columns
|
|
17
|
+
* (icon + gaps + bar + percentage label). Returns "" when there isn't room
|
|
18
|
+
* for the icon, label and at least one bar segment.
|
|
19
|
+
*/
|
|
20
|
+
export function formatContextBar(colorize: ColorFn, percentValue: number, totalSpace: number): string {
|
|
21
|
+
const pct = Math.min(1, Math.max(0, percentValue / 100));
|
|
22
|
+
const pctLabel = Math.round(Math.max(0, percentValue)) + "%";
|
|
23
|
+
// Fixed overhead: icon (1) + " " (2) + " " (1) + percentage label
|
|
24
|
+
const barLength = totalSpace - 4 - pctLabel.length;
|
|
25
|
+
if (barLength < 1) return "";
|
|
26
|
+
|
|
27
|
+
const filledLength = percentValue > 0 ? Math.max(1, Math.round(pct * barLength)) : 0;
|
|
28
|
+
const emptyLength = barLength - filledLength;
|
|
21
29
|
|
|
22
30
|
const barToken = pct >= 0.9 ? "error" : pct >= 0.7 ? "warning" : "syntaxString";
|
|
23
31
|
|
|
@@ -25,7 +33,7 @@ export function formatContextBar(colorize: ColorFn, percentValue: number, availa
|
|
|
25
33
|
const emptyBar = emptyLength > 0 ? colorize("dim", "━".repeat(emptyLength)) : "";
|
|
26
34
|
const bar = filledBar + emptyBar;
|
|
27
35
|
|
|
28
|
-
return colorize(barToken, footerIcons.contextWindow) + " " + bar + " " + colorize(barToken,
|
|
36
|
+
return colorize(barToken, footerIcons.contextWindow) + " " + bar + " " + colorize(barToken, pctLabel);
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export function formatGitStatusIndicators(
|
package/src/utils/git.test.ts
CHANGED
|
@@ -15,7 +15,7 @@ interface GitStatus {
|
|
|
15
15
|
|
|
16
16
|
describe("getGitStatus", () => {
|
|
17
17
|
let getGitStatus: () => GitStatus;
|
|
18
|
-
let
|
|
18
|
+
let isInsideLinkedWorktree: () => boolean;
|
|
19
19
|
|
|
20
20
|
async function loadModule(mockExecSync: ReturnType<typeof vi.fn>) {
|
|
21
21
|
vi.resetModules();
|
|
@@ -30,7 +30,7 @@ describe("getGitStatus", () => {
|
|
|
30
30
|
|
|
31
31
|
const mod = await import("./git.js");
|
|
32
32
|
getGitStatus = mod.getGitStatus;
|
|
33
|
-
|
|
33
|
+
isInsideLinkedWorktree = mod.isInsideLinkedWorktree;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
afterEach(() => {
|
|
@@ -95,11 +95,36 @@ describe("getGitStatus", () => {
|
|
|
95
95
|
expect(result.unstaged).toBe(1);
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
it("
|
|
98
|
+
it("isInsideLinkedWorktree returns false for a single (main) worktree", async () => {
|
|
99
99
|
const mockExecSync = vi.fn(() => "head ref/heads/main\nworktree /path/to/repo\n");
|
|
100
100
|
await loadModule(mockExecSync);
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
expect(isInsideLinkedWorktree()).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("isInsideLinkedWorktree returns false when cwd is the MAIN worktree", async () => {
|
|
105
|
+
// cwd equals the first porcelain entry → main clone, not a linked worktree
|
|
106
|
+
const cwd = process.cwd();
|
|
107
|
+
const mockExecSync = vi.fn(() =>
|
|
108
|
+
`worktree ${cwd}\nbranch refs/heads/main\n\n` + "worktree /home/x/wt-other\nbranch refs/heads/other\n");
|
|
109
|
+
await loadModule(mockExecSync);
|
|
110
|
+
expect(isInsideLinkedWorktree()).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("isInsideLinkedWorktree returns true when cwd is a linked worktree", async () => {
|
|
114
|
+
const cwd = process.cwd();
|
|
115
|
+
const mockExecSync = vi.fn(() =>
|
|
116
|
+
"worktree /nope/main\nbranch refs/heads/other\n\n" +
|
|
117
|
+
`worktree ${cwd}\n` + "branch refs/heads/feature/x\n");
|
|
118
|
+
await loadModule(mockExecSync);
|
|
119
|
+
expect(isInsideLinkedWorktree()).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("isInsideLinkedWorktree returns true for a detached-HEAD linked worktree", async () => {
|
|
123
|
+
const cwd = process.cwd();
|
|
124
|
+
const mockExecSync = vi.fn(() =>
|
|
125
|
+
"worktree /nope/main\nbranch ref/heads/other\n\n" + `worktree ${cwd}\n` + "detached\n");
|
|
126
|
+
await loadModule(mockExecSync);
|
|
127
|
+
expect(isInsideLinkedWorktree()).toBe(true);
|
|
103
128
|
});
|
|
104
129
|
|
|
105
130
|
it("property: getGitStatus never throws (catches execSync errors gracefully)", async () => {
|
package/src/utils/git.ts
CHANGED
|
@@ -21,7 +21,7 @@ interface GitCacheEntry {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
interface WorktreeCacheEntry {
|
|
24
|
-
value:
|
|
24
|
+
value: boolean;
|
|
25
25
|
timestamp: number;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -131,7 +131,11 @@ export function getGitStatus(): GitStatus {
|
|
|
131
131
|
return gitStatusCache.value;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Returns true when cwd is inside a linked worktree (not the main clone).
|
|
136
|
+
* Returns false when cwd is the main clone or not a git repo.
|
|
137
|
+
*/
|
|
138
|
+
export function isInsideLinkedWorktree(): boolean {
|
|
135
139
|
// Return cached result if still fresh
|
|
136
140
|
if (worktreeCache && Date.now() - worktreeCache.timestamp < GIT_CACHE_TTL_MS) {
|
|
137
141
|
return worktreeCache.value;
|
|
@@ -147,20 +151,23 @@ export function getWorktreeBranch(): string | null {
|
|
|
147
151
|
|
|
148
152
|
const worktreeEntries = worktreeOutput.trim().split("\n\n").filter(Boolean);
|
|
149
153
|
if (worktreeEntries.length <= 1) {
|
|
150
|
-
worktreeCache = { value:
|
|
151
|
-
return
|
|
154
|
+
worktreeCache = { value: false, timestamp: Date.now() };
|
|
155
|
+
return false;
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
const currentDirectoryPath = realpathSync(process.cwd());
|
|
155
|
-
let result
|
|
156
|
-
|
|
159
|
+
let result = false;
|
|
160
|
+
// The first porcelain entry is always the main worktree: being in the
|
|
161
|
+
// main clone isn't "being in a worktree", so only linked ones (index >= 1)
|
|
162
|
+
// can match.
|
|
163
|
+
for (let i = 1; i < worktreeEntries.length; i++) {
|
|
164
|
+
const entry = worktreeEntries[i]!;
|
|
157
165
|
const entryLines = entry.split("\n");
|
|
158
166
|
const pathLine = entryLines.find((l) => l.startsWith("worktree "));
|
|
159
|
-
const branchLine = entryLines.find((l) => l.startsWith("branch "));
|
|
160
167
|
const worktreePath = pathLine?.replace("worktree ", "");
|
|
161
168
|
|
|
162
169
|
if (worktreePath && (currentDirectoryPath === worktreePath || currentDirectoryPath.startsWith(worktreePath + "/"))) {
|
|
163
|
-
result =
|
|
170
|
+
result = true;
|
|
164
171
|
break;
|
|
165
172
|
}
|
|
166
173
|
}
|
|
@@ -168,7 +175,7 @@ export function getWorktreeBranch(): string | null {
|
|
|
168
175
|
return result;
|
|
169
176
|
} catch {
|
|
170
177
|
/* not a git repo */
|
|
171
|
-
worktreeCache = { value:
|
|
172
|
-
return
|
|
178
|
+
worktreeCache = { value: false, timestamp: Date.now() };
|
|
179
|
+
return false;
|
|
173
180
|
}
|
|
174
181
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { packFooterLines, SEP_W } from "./layout.js";
|
|
4
|
+
|
|
5
|
+
/** Local copy of measureChunks (not exported from layout.ts — test-only helper). */
|
|
6
|
+
function measureChunks(chunks: string[], sepWidth = SEP_W): number {
|
|
7
|
+
let w = 0;
|
|
8
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
9
|
+
w += (i > 0 ? sepWidth : 0) + visibleWidth(chunks[i]!);
|
|
10
|
+
}
|
|
11
|
+
return w;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Plain-Chinese chunks keep the math readable; packFooterLines only
|
|
15
|
+
// needs to *measure* them, not render them.
|
|
16
|
+
const DIR = "a"; // dir section
|
|
17
|
+
const BRANCH = "b".repeat(40); // branch section
|
|
18
|
+
const WORKTREE = "w".repeat(40); // worktree section
|
|
19
|
+
const MODEL = "model"; // model section
|
|
20
|
+
const THINKING = "high"; // thinking level
|
|
21
|
+
const STATS = "s".repeat(20); // stats section
|
|
22
|
+
|
|
23
|
+
describe("measureChunks", () => {
|
|
24
|
+
it("measures a single chunk", () => {
|
|
25
|
+
expect(measureChunks(["abcde"])).toBe(5);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("measures joined chunks with the separator", () => {
|
|
29
|
+
expect(measureChunks(["ab", "cde"])) // 2 + 3 + 3
|
|
30
|
+
.toBe(8);
|
|
31
|
+
expect(measureChunks(["ab", "cde", "fg"])) // 2 + (3+3) + (2+3)
|
|
32
|
+
.toBe(13);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("measures empty chunks as zero", () => {
|
|
36
|
+
expect(measureChunks([])).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("packFooterLines", () => {
|
|
41
|
+
it("returns an empty array for empty chunks", () => {
|
|
42
|
+
expect(packFooterLines([], 100, 3)).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps everything on one line when it fits", () => {
|
|
46
|
+
const chunks = [DIR, BRANCH, WORKTREE, MODEL, THINKING, STATS];
|
|
47
|
+
const groups = packFooterLines(chunks, measureChunks(chunks) + 10, SEP_W);
|
|
48
|
+
expect(groups).toEqual([chunks]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("exactly-fits means one line (boundary is inclusive)", () => {
|
|
52
|
+
const chunks = [DIR, BRANCH];
|
|
53
|
+
const width = measureChunks(chunks); // 1 + SEP_W + 40
|
|
54
|
+
expect(packFooterLines(chunks, width, SEP_W)).toEqual([chunks]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("wraps the tail to a second line when it no longer fits", () => {
|
|
58
|
+
const chunks = [DIR, BRANCH, WORKTREE, MODEL, THINKING, STATS];
|
|
59
|
+
const oneLineW = measureChunks(chunks);
|
|
60
|
+
|
|
61
|
+
// One less column than needed → STATS drops to its own line
|
|
62
|
+
const groups = packFooterLines(chunks, oneLineW - 1, SEP_W);
|
|
63
|
+
expect(groups).toEqual([[DIR, BRANCH, WORKTREE, MODEL, THINKING], [STATS]]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("wraps left sections when they alone overflow (three lines)", () => {
|
|
67
|
+
// ""(1) + sep + 40"" fits, +worktree doesn't.
|
|
68
|
+
// worktree+model+thinking (55) fits, +stats (78) doesn't.
|
|
69
|
+
const chunks = [DIR, BRANCH, WORKTREE, MODEL, THINKING, STATS];
|
|
70
|
+
const groups = packFooterLines(chunks, 70, SEP_W);
|
|
71
|
+
expect(groups).toEqual([
|
|
72
|
+
[DIR, BRANCH],
|
|
73
|
+
[WORKTREE, MODEL, THINKING],
|
|
74
|
+
[STATS],
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("gives an over-wide chunk its own line", () => {
|
|
79
|
+
const huge = "x".repeat(120); // wider than the terminal
|
|
80
|
+
const groups = packFooterLines([DIR, huge, STATS], 60, SEP_W);
|
|
81
|
+
expect(groups).toEqual([[DIR], [huge], [STATS]]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("splits per chunk on a near-zero width", () => {
|
|
85
|
+
const groups = packFooterLines([DIR, MODEL], 0, SEP_W);
|
|
86
|
+
expect(groups).toEqual([[DIR], [MODEL]]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("respects a custom separator width", () => {
|
|
90
|
+
// separator of 0: dir(1) and branch(40) fit in 41
|
|
91
|
+
expect(packFooterLines(["a", "b".repeat(40)], 41, 0)).toHaveLength(1);
|
|
92
|
+
// no slack for separator SEP_W → wraps
|
|
93
|
+
expect(packFooterLines(["a", "b".repeat(40)], 41, SEP_W)).toHaveLength(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("measures ANSI-escaped chunks by their visible width only", () => {
|
|
97
|
+
const plain = "branch"; // 6 visible cols
|
|
98
|
+
const ansi = "\x1b[32mbranch\x1b[0m"; // same 6 visible cols, with color escape
|
|
99
|
+
// Both should produce the same grouping structure:
|
|
100
|
+
// "branch" (6) + sep (3) + "stats" (5) = 14 ≤ 20 → one group of two
|
|
101
|
+
const plainGroups = packFooterLines([plain, "stats"], 20, 3);
|
|
102
|
+
const ansiGroups = packFooterLines([ansi, "stats"], 20, 3);
|
|
103
|
+
// Same number of lines and same number of chunks per line
|
|
104
|
+
expect(plainGroups.map((g) => g.length)).toEqual(ansiGroups.map((g) => g.length));
|
|
105
|
+
expect(plainGroups.length).toBe(1);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Footer line-packing.
|
|
5
|
+
*
|
|
6
|
+
* Chunks are the individual footer pieces (dir, branch, worktree, model,
|
|
7
|
+
* thinking, stats, context bar). Packing decides how many lines the footer
|
|
8
|
+
* needs so nothing gets silently truncated:
|
|
9
|
+
*
|
|
10
|
+
* - one line, if everything fits
|
|
11
|
+
* - two lines, if the tail (e.g. stats) no longer fits
|
|
12
|
+
* - three or more, if the left sections themselves overflow the terminal
|
|
13
|
+
*
|
|
14
|
+
* Every chunk is atomic — it is never cut in half. A single chunk wider
|
|
15
|
+
* than the terminal occupies a line of its own; the caller is responsible
|
|
16
|
+
* for clamping it (last-resort truncation of one irreducibly long item).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Visible width of the " · " footer separator. */
|
|
20
|
+
export const SEP_W = 3;
|
|
21
|
+
/** The footer section separator string (without ANSI colouring — colour it at the call site). */
|
|
22
|
+
export const SEPARATOR = " · ";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Total visible columns of chunks joined by a separator of `sepWidth`
|
|
26
|
+
* visible characters.
|
|
27
|
+
*/
|
|
28
|
+
function measureChunks(chunks: string[], sepWidth = 3): number {
|
|
29
|
+
let w = 0;
|
|
30
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
31
|
+
w += (i > 0 ? sepWidth : 0) + visibleWidth(chunks[i]!);
|
|
32
|
+
}
|
|
33
|
+
return w;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Greedily pack chunks into groups of lines, each guaranteed to satisfy
|
|
38
|
+
* `sum(chunkWidth) + sepWidth * (count - 1) <= width` when joined — with the
|
|
39
|
+
* sole exception of a single chunk wider than `width` (own line).
|
|
40
|
+
*
|
|
41
|
+
* @param chunks atomic footer pieces, in desired display order
|
|
42
|
+
* @param width terminal width in visible columns
|
|
43
|
+
* @param sepWidth visible width of the join separator (default 3, for " · ")
|
|
44
|
+
*/
|
|
45
|
+
export function packFooterLines(chunks: string[], width: number, sepWidth = 3): string[][] {
|
|
46
|
+
if (chunks.length === 0) return [];
|
|
47
|
+
if (width <= 0) return chunks.map((c) => [c]);
|
|
48
|
+
|
|
49
|
+
const groups: string[][] = [];
|
|
50
|
+
let group: string[] = [];
|
|
51
|
+
let groupW = 0;
|
|
52
|
+
|
|
53
|
+
for (const chunk of chunks) {
|
|
54
|
+
const w = visibleWidth(chunk);
|
|
55
|
+
if (group.length === 0) {
|
|
56
|
+
group = [chunk];
|
|
57
|
+
groupW = w;
|
|
58
|
+
} else if (groupW + sepWidth + w <= width) {
|
|
59
|
+
group.push(chunk);
|
|
60
|
+
groupW += sepWidth + w;
|
|
61
|
+
} else {
|
|
62
|
+
groups.push(group);
|
|
63
|
+
group = [chunk];
|
|
64
|
+
groupW = w;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (group.length > 0) groups.push(group);
|
|
68
|
+
|
|
69
|
+
return groups;
|
|
70
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { stripAnsi } from "@pi-archimedes/core/text";
|
|
3
|
+
import { packFooterLines, SEP_W, SEPARATOR } from "./layout.js";
|
|
4
|
+
import { formatContextBar } from "./format.js";
|
|
5
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
// Minimal stand-ins: same visible chars as the real footer, no ANSI
|
|
8
|
+
// left joined: dir(7)+sep+branch(38)+sep+worktree(36)+sep+model(20)+sep+thinking(6) = 119
|
|
9
|
+
// full single line (left + stats 37): 159
|
|
10
|
+
const DIR = "📁 auth";
|
|
11
|
+
const BRANCH = "⎇ feat/plan-040-jwt-cookie-security+~3"; // + fake status
|
|
12
|
+
const WORKTREE = "⛅ feat/plan-040-jwt-cookie-security";
|
|
13
|
+
const MODEL = "🧠 claude-sonnet-4-6";
|
|
14
|
+
const THINKING = "◐ high";
|
|
15
|
+
const STATS = "↑141 ↓42k R8.7M W220k $4.25 116k/977k";
|
|
16
|
+
|
|
17
|
+
function buildLines(width: number, pct: number, splitThreshold = 150) {
|
|
18
|
+
const leftSections = [
|
|
19
|
+
DIR,
|
|
20
|
+
BRANCH,
|
|
21
|
+
WORKTREE,
|
|
22
|
+
MODEL,
|
|
23
|
+
THINKING,
|
|
24
|
+
].filter(Boolean);
|
|
25
|
+
let groups = packFooterLines([...leftSections, STATS], width, SEP_W);
|
|
26
|
+
if (groups.length < 2 && width < splitThreshold && leftSections.length > 0) {
|
|
27
|
+
groups = [leftSections, [STATS]];
|
|
28
|
+
}
|
|
29
|
+
return groups
|
|
30
|
+
.map((g, idx) => {
|
|
31
|
+
let line = g.join(SEPARATOR);
|
|
32
|
+
if (idx === groups.length - 1) {
|
|
33
|
+
const remaining = width - visibleWidth(line) - (line ? SEP_W : 0);
|
|
34
|
+
const bar = formatContextBar((t, s) => s, pct, remaining);
|
|
35
|
+
if (bar) line = line ? line + SEPARATOR + bar : bar;
|
|
36
|
+
}
|
|
37
|
+
return line;
|
|
38
|
+
})
|
|
39
|
+
.map((l) => "\x1b[0m" + l); // put a dummy escape in to exercise stripAnsi below
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("render glue simulation", () => {
|
|
43
|
+
it("wide terminal (width 200): single line, nothing clipped, bar exactly fills", () => {
|
|
44
|
+
const lines = buildLines(200, 12);
|
|
45
|
+
expect(lines.length).toBe(1);
|
|
46
|
+
// clamp to terminal — must survive (nothing cut)
|
|
47
|
+
const clamped = lines[0];
|
|
48
|
+
expect(stripAnsi(clamped!)).toContain("116k/977k");
|
|
49
|
+
expect(visibleWidth(stripAnsi(clamped!))).toBeLessThanOrEqual(200);
|
|
50
|
+
// stats line ends with the bar percentage label
|
|
51
|
+
expect(stripAnsi(lines[0]!)).toContain("12%");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("user's terminal: one column short of fitting → two lines, nothing lost", () => {
|
|
55
|
+
const lines = buildLines(158, 12);
|
|
56
|
+
expect(lines.length).toBe(2);
|
|
57
|
+
expect(stripAnsi(lines[0]!)).toContain("auth");
|
|
58
|
+
expect(stripAnsi(lines[0]!)).toContain("feat/plan-"); // worktree on line 1
|
|
59
|
+
expect(stripAnsi(lines[1]!)).toContain("116k/977k");
|
|
60
|
+
expect(stripAnsi(lines[1]!)).toContain("12%");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("narrow (width 69): three lines", () => {
|
|
64
|
+
const lines = buildLines(69, 12, 150);
|
|
65
|
+
expect(lines.length).toBe(3);
|
|
66
|
+
const all = lines.map((l) => stripAnsi(l)).join(" | ");
|
|
67
|
+
expect(all).toContain("116k/977k");
|
|
68
|
+
expect(all).toContain("12%");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("below splitThreshold but fits in one line → forced two lines", () => {
|
|
72
|
+
const lines = buildLines(165, 12, 200); // 159 + bar ≤ 165 fits one line; 165 < 200 forces split
|
|
73
|
+
expect(lines.length).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("very narrow (width 40): bar is dropped, stats still present, no line exceeds 40 cols", () => {
|
|
77
|
+
const lines = buildLines(40, 12);
|
|
78
|
+
const stripped = lines.map((l) => stripAnsi(l));
|
|
79
|
+
// Bar should be dropped — no % sign in any line
|
|
80
|
+
expect(stripped.every((l) => !l.includes("%"))).toBe(true);
|
|
81
|
+
// Stats should still appear somewhere
|
|
82
|
+
const all = stripped.join(" ");
|
|
83
|
+
expect(all).toContain("116k/977k");
|
|
84
|
+
// No line exceeds 40 visible columns
|
|
85
|
+
expect(lines.every((l) => visibleWidth(stripAnsi(l)) <= 40)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|