@pi-archimedes/footer 2.2.0 → 2.4.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 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 — all in one clean line at the bottom. When subagents run, their costs merge seamlessly into the same view.
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 indicator), active model, thinking level, and worktree
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
- - **Smart layout** — single line on wide terminals, auto-splits to two lines when space is tight (threshold configurable)
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 a single clean line containing all session context, token usage, cost, and context window progress. When the terminal width falls below the configured split threshold, the footer automatically splits into two lines to keep all information clear and readable without clipping.
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) to display footer on a single line. Below this width, the footer splits into two lines. |
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.2.0",
3
+ "version": "2.4.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.2.0"
18
+ "@pi-archimedes/core": "2.4.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 | model | ◐thinking | branch [+status] | worktree | ↑↓R W $cost | ━━━━━ context%
3
- * Splits into two lines when terminal width < splitThreshold (default 150):
4
- * Line 1: system info (dir, branch, model, thinking, worktree)
5
- * Line 2: usage stats (↑↓R W $cost + context progress bar)
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 { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
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, getWorktreeBranch } from "./utils/git.js";
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 worktreeBranch = getWorktreeBranch();
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
- // ── Two-line split for narrow terminals ────────────────────────────
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
- // Left section: dir | branch [+status] | model | thinking | worktree
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", footerIcons.branch + " " + currentBranch + (gitStatusStr ? " " + gitStatusStr : "")) : "",
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
- const separator = theme.fg("dim", " · ");
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 rawStatsSectionStr = statsParts.join(" ");
112
- const statsSectionStr = theme.fg("dim", rawStatsSectionStr);
113
-
114
- if (shouldSplit) {
115
- // ── Two-line mode ──────────────────────────────────────────────
116
-
117
- // Calculate available space for the context progress bar on line 2
118
- const availableBarSpace = Math.max(2, width - visibleWidth(statsSectionStr) - 13);
119
-
120
- // Context progress bar (expands to fill remaining space)
121
- const contextBarStr = formatContextBar(colorize as (token: string, s: string) => string, contextPercentValue, availableBarSpace);
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
- // ── Single-line mode ───────────────────────────────────────────────
141
-
142
- // Separator between left and right sections
143
- const sectionSeparator = theme.fg("dim", " · ");
144
-
145
- // Calculate available space for the context progress bar (after stats)
146
- const availableBarSpace = Math.max(
147
- 2,
148
- width - visibleWidth(leftSectionStr) - 1 - visibleWidth(sectionSeparator) - visibleWidth(statsSectionStr) - 10,
149
- );
150
-
151
- // Context progress bar (expands to fill remaining space)
152
- const contextBarStr = formatContextBar(colorize as (token: string, s: string) => string, contextPercentValue, availableBarSpace);
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 [];
@@ -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 ───────────────────────────────────────────────
@@ -12,12 +12,20 @@ export function formatTokenCount(count: number): string {
12
12
  return Math.round(count / TOKEN_M) + "M";
13
13
  }
14
14
 
15
- export function formatContextBar(colorize: ColorFn, percentValue: number, availableSpace: number): string {
16
- if (availableSpace <= 2) return "";
17
-
18
- const pct = Math.min(1, percentValue / 100);
19
- const filledLength = percentValue > 0 ? Math.max(1, Math.round(pct * availableSpace)) : 0;
20
- const emptyLength = availableSpace - filledLength;
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, Math.round(percentValue) + "%");
36
+ return colorize(barToken, footerIcons.contextWindow) + " " + bar + " " + colorize(barToken, pctLabel);
29
37
  }
30
38
 
31
39
  export function formatGitStatusIndicators(
@@ -15,7 +15,7 @@ interface GitStatus {
15
15
 
16
16
  describe("getGitStatus", () => {
17
17
  let getGitStatus: () => GitStatus;
18
- let getWorktreeBranch: () => string | null;
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
- getWorktreeBranch = mod.getWorktreeBranch;
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("getWorktreeBranch returns null when not in worktree", async () => {
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
- const result = getWorktreeBranch();
102
- expect(result).toBeNull();
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: string | null;
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
- export function getWorktreeBranch(): string | null {
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: null, timestamp: Date.now() };
151
- return null;
154
+ worktreeCache = { value: false, timestamp: Date.now() };
155
+ return false;
152
156
  }
153
157
 
154
158
  const currentDirectoryPath = realpathSync(process.cwd());
155
- let result: string | null = null;
156
- for (const entry of worktreeEntries) {
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 = branchLine?.replace("branch refs/heads/", "") ?? null;
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: null, timestamp: Date.now() };
172
- return null;
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
+ });