pi-message-sidebar 1.3.0 → 1.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0
4
+
5
+ - Replaced selected/newest pinning with a row-aware contiguous chronological viewport.
6
+ - Top-aligned short histories and preserved selection, expansion, viewport anchor, and follow-tail state by message ID.
7
+ - Reduced the header to two compact rows and the normal status dock to at most four rows.
8
+ - Removed collapsed-row ordinal and timestamp columns; metadata now appears only on expanded rows.
9
+ - Preserved complete cmux surface refs by truncating workspace titles first.
10
+ - Corrected tiny-height allocation so a message remains visible whenever one row is available.
11
+ - Matched the nested pi-codex-goal usage schema, tightened goal status/value validation, refreshed live context independently of entry totals, and isolated usage caches per context.
12
+ - Clarified that fullscreen is persistent while regular mode is a current-screen compositor under terminal-owned scrollback.
13
+
3
14
  ## 1.2.0
4
15
 
5
16
  > Live goal tracking and cmux session context in the status dock.
package/README.md CHANGED
@@ -5,14 +5,14 @@ Persistent message history sidebar for [Pi](https://pi.dev).
5
5
  ## Features
6
6
 
7
7
  - Fixed 42-column panel on the right
8
- - Live goal card: objective, status, token budget, and elapsed time from pi-codex-goal entries
9
- - cmux session context: workspace title and surface ref replace the session file datetime inside cmux
8
+ - Two-row message header with position and selected-message metadata
9
+ - Contiguous chronological message viewport that top-aligns short histories and follows new messages until you browse away
10
+ - Compact status dock of at most four rows, including an optional two-line goal
11
+ - cmux session context that preserves the complete surface ref by truncating the workspace title first
10
12
  - Main transcript and editor render in their own reserved width
11
- - Native side-by-side layout in fullscreen TUI mode
12
- - Regular-mode compositor that reserves the same width in scrollback mode
13
+ - Persistent native right rail in fullscreen TUI mode
14
+ - Compact regular-mode compositor in terminal-owned scrollback mode
13
15
  - Automatic collapse when the terminal cannot keep an 80-column main pane
14
- - First and last five user messages remain visible
15
- - Gap indicator shows hidden message counts
16
16
  - `Ctrl+Shift+H` focuses the sidebar
17
17
  - Arrow keys navigate messages
18
18
  - `Enter` expands or collapses a message
@@ -43,7 +43,9 @@ pi install git:github.com/Fornace/pi-message-sidebar
43
43
 
44
44
  ## Usage
45
45
 
46
- The sidebar appears automatically in interactive mode when the terminal is at least 123 columns wide. It collapses below that breakpoint so Pi keeps a usable main pane.
46
+ The sidebar appears automatically in interactive mode when the terminal is at least 123 columns wide. It collapses below that breakpoint so Pi keeps a usable main pane. Fullscreen mode uses a persistent `HStack` right rail. Regular mode uses a compact compositor over the terminal's current screenful; because the terminal owns regular-mode scrollback, the sidebar is not permanently sticky while browsing old scrollback. An on-demand overlay is intentionally not implemented: overlay components are disposed on close, which conflicts with the persistent ID-stable sidebar state, so the compact compositor is kept instead.
47
+
48
+ The message body is one contiguous chronological viewport. New messages remain selected while follow-tail is active. Navigating away preserves the selected message, expansion state, and visible range by message ID when history entries are inserted or refreshed.
47
49
 
48
50
  - Press `Ctrl+Shift+H` to focus or unfocus the sidebar.
49
51
  - Press `↑` or `↓` to navigate.
@@ -55,9 +57,9 @@ The sidebar appears automatically in interactive mode when the terminal is at le
55
57
  ## Architecture
56
58
 
57
59
  - `index.ts` is the auto-discovered extension entrypoint.
58
- - `src/layout.ts` reserves a real horizontal region in fullscreen mode and composes an equivalent region in regular mode.
59
- - `src/sidebar-component.ts` owns message navigation and bounded rendering.
60
- - `src/status-dock.ts` renders session, model, context, cost, goal, and extension status data.
60
+ - `src/layout.ts` reserves a persistent horizontal region in fullscreen mode and composes the current screenful in regular mode.
61
+ - `src/sidebar-component.ts` owns ID-stable navigation, expansion, follow-tail behavior, and the row-aware contiguous viewport.
62
+ - `src/status-dock.ts` renders a compact, height-bounded goal, runtime, cmux, and validated status summary.
61
63
  - `src/goal.ts` reconstructs the active pi-codex-goal from session entries.
62
64
  - `src/cmux.ts` resolves the cmux workspace title and surface ref once per session.
63
65
  - `src/style.ts` provides ANSI-safe row filling and width helpers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-message-sidebar",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Persistent responsive message history sidebar for Pi",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -31,6 +31,7 @@
31
31
  "scripts": {
32
32
  "test": "node --import tsx --test test/*.test.ts",
33
33
  "test:pty": "node test/pty-smoke.mjs regular 142 55 && node test/pty-smoke.mjs regular 121 55 && node test/pty-smoke.mjs fullscreen 142 55 && node test/pty-smoke.mjs fullscreen 121 55",
34
+ "test:pty:matrix": "node test/pty-smoke.mjs regular 80 24 && node test/pty-smoke.mjs regular 120 40 && node test/pty-smoke.mjs regular 148 55 && node test/pty-smoke.mjs regular 200 60 && node test/pty-smoke.mjs fullscreen 80 24 && node test/pty-smoke.mjs fullscreen 120 40 && node test/pty-smoke.mjs fullscreen 148 55 && node test/pty-smoke.mjs fullscreen 200 60",
34
35
  "typecheck": "tsc --noEmit",
35
36
  "test:load": "node --import tsx test/load.test.mjs"
36
37
  },
package/src/constants.ts CHANGED
@@ -2,8 +2,6 @@ export const SIDEBAR_WIDTH = 42;
2
2
  export const SIDEBAR_GAP = 1;
3
3
  export const RESERVED_WIDTH = SIDEBAR_WIDTH + SIDEBAR_GAP;
4
4
  export const MIN_MAIN_WIDTH = 80;
5
- export const PINNED_COUNT = 5;
6
- export const GAP_WINDOW = 3;
7
5
 
8
6
  export function isSidebarVisible(terminalWidth: number): boolean {
9
7
  return terminalWidth >= MIN_MAIN_WIDTH + RESERVED_WIDTH;
package/src/goal.ts CHANGED
@@ -1,100 +1,96 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ export type GoalStatus = "active" | "paused" | "budgetLimited" | "complete";
4
+
3
5
  export type ThreadGoal = {
4
6
  goalId: string;
5
7
  objective: string;
6
- status: "active" | "paused" | "budgetLimited" | "complete";
8
+ status: GoalStatus;
7
9
  tokenBudget: number | null;
8
- tokensUsed: number;
9
- activeSeconds: number;
10
+ usage: { tokensUsed: number; activeSeconds: number };
10
11
  createdAt: number;
11
12
  updatedAt: number;
12
13
  };
13
14
 
14
- type BranchEntry = { type?: string; customType?: string; data?: unknown };
15
+ type BranchEntry = { type?: string; customType?: string; data?: unknown; id?: string; timestamp?: string };
16
+ type GoalEntrySource = "command" | "tool" | "runtime";
15
17
 
16
18
  const GOAL_ENTRY_TYPE = "pi-codex-goal";
17
19
 
18
- function isThreadGoal(value: unknown): value is RawGoal {
19
- const goal = value as RawGoal | null;
20
- if (!goal || typeof goal !== "object") return false;
21
- const usage = goal.usage as { tokensUsed?: unknown; activeSeconds?: unknown } | undefined;
22
- return (
23
- typeof goal.goalId === "string" &&
24
- typeof goal.objective === "string" &&
25
- (goal.status === "active" || goal.status === "paused" || goal.status === "budgetLimited" || goal.status === "complete") &&
26
- (goal.tokenBudget === null || typeof goal.tokenBudget === "number") &&
27
- typeof goal.createdAt === "number" &&
28
- typeof goal.updatedAt === "number" &&
29
- typeof usage?.tokensUsed === "number" &&
30
- typeof usage.activeSeconds === "number"
20
+ function isGoalEntrySource(value: unknown): value is GoalEntrySource {
21
+ return value === "command" || value === "tool" || value === "runtime";
22
+ }
23
+
24
+ function isFiniteNonNegative(value: unknown): value is number {
25
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
26
+ }
27
+
28
+ function isGoalStatus(value: unknown): value is GoalStatus {
29
+ return value === "active" || value === "paused" || value === "budgetLimited" || value === "complete";
30
+ }
31
+
32
+ function isThreadGoal(value: unknown): value is ThreadGoal {
33
+ const goal = value as ThreadGoal | null;
34
+ return Boolean(
35
+ goal && typeof goal === "object" &&
36
+ typeof goal.goalId === "string" && goal.goalId.length > 0 &&
37
+ typeof goal.objective === "string" && goal.objective.trim().length > 0 &&
38
+ isGoalStatus(goal.status) &&
39
+ (goal.tokenBudget === null || (Number.isInteger(goal.tokenBudget) && goal.tokenBudget >= 0)) &&
40
+ isFiniteNonNegative(goal.createdAt) &&
41
+ isFiniteNonNegative(goal.updatedAt) &&
42
+ isFiniteNonNegative(goal.usage?.tokensUsed) &&
43
+ isFiniteNonNegative(goal.usage?.activeSeconds)
31
44
  );
32
45
  }
33
46
 
34
- type RawGoal = Omit<ThreadGoal, "tokensUsed" | "activeSeconds"> & {
35
- usage: { tokensUsed: number; activeSeconds: number };
36
- };
47
+ function cloneGoal(goal: ThreadGoal): ThreadGoal {
48
+ return { ...goal, usage: { ...goal.usage } };
49
+ }
37
50
 
38
- /**
39
- * Reconstructs the current thread goal from pi-codex-goal session entries.
40
- * Mirrors reconstructGoal() from pi-codex-goal dist/state.js (schema version 1):
41
- * "set" entries replace the goal, "clear" entries drop it, and "usage" entries
42
- * advance tokens/time for runtime-usage statuses only.
43
- */
51
+ /** Mirrors pi-codex-goal's schema-v1 reconstruction contract. */
44
52
  export function readThreadGoal(entries: Iterable<BranchEntry>): ThreadGoal | null {
45
53
  let goal: ThreadGoal | null = null;
46
54
  for (const entry of entries) {
47
55
  if (entry.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
48
56
  const data = entry.data as Record<string, unknown> | null;
49
- if (!data || typeof data !== "object" || data.version !== 1) continue;
57
+ if (!data || typeof data !== "object" || data.version !== 1 || !isFiniteNonNegative(data.at)) continue;
58
+
50
59
  if (data.kind === "clear") {
60
+ if (!isGoalEntrySource(data.source) || (data.clearedGoalId !== null && typeof data.clearedGoalId !== "string")) continue;
51
61
  goal = null;
52
- } else if (data.kind === "set" && isThreadGoal(data.goal)) {
53
- const raw = data.goal as RawGoal;
54
- goal = {
55
- goalId: raw.goalId,
56
- objective: raw.objective,
57
- status: raw.status,
58
- tokenBudget: raw.tokenBudget,
59
- tokensUsed: raw.usage.tokensUsed,
60
- activeSeconds: raw.usage.activeSeconds,
61
- createdAt: raw.createdAt,
62
- updatedAt: raw.updatedAt,
63
- };
64
- } else if (data.kind === "usage" && goal) {
65
- const usage = data.usage as { tokensUsed?: number; activeSeconds?: number } | undefined;
66
- const updatedAt = data.updatedAt;
67
- const status = data.status;
68
- if (data.goalId !== goal.goalId) continue;
69
- if (status !== "active" && status !== "budgetLimited") continue;
70
- if (goal.status !== "active" && goal.status !== "budgetLimited") continue;
71
- if (goal.status === "budgetLimited" && status === "active") continue;
72
- if (typeof updatedAt !== "number" || typeof usage?.tokensUsed !== "number" || typeof usage.activeSeconds !== "number") continue;
73
- if (updatedAt < goal.updatedAt || usage.tokensUsed < goal.tokensUsed || usage.activeSeconds < goal.activeSeconds) continue;
74
- const current: ThreadGoal = goal;
75
- goal = {
76
- ...current,
77
- status,
78
- tokensUsed: usage.tokensUsed,
79
- activeSeconds: usage.activeSeconds,
80
- updatedAt,
81
- };
62
+ continue;
63
+ }
64
+ if (data.kind === "set") {
65
+ if (!isGoalEntrySource(data.source) || !isThreadGoal(data.goal)) continue;
66
+ goal = cloneGoal(data.goal);
67
+ continue;
82
68
  }
69
+ if (data.kind !== "usage" || data.source !== "runtime" || !goal) continue;
70
+
71
+ const status = data.status;
72
+ const usage = data.usage as ThreadGoal["usage"] | undefined;
73
+ if (data.goalId !== goal.goalId || (status !== "active" && status !== "budgetLimited")) continue;
74
+ if (goal.status !== "active" && goal.status !== "budgetLimited") continue;
75
+ if (goal.status === "budgetLimited" && status === "active") continue;
76
+ if (!isFiniteNonNegative(data.updatedAt) || !isFiniteNonNegative(usage?.tokensUsed) || !isFiniteNonNegative(usage.activeSeconds)) continue;
77
+ if (data.updatedAt < goal.updatedAt || usage.tokensUsed < goal.usage.tokensUsed || usage.activeSeconds < goal.usage.activeSeconds) continue;
78
+ goal = { ...goal, status, usage: { ...usage }, updatedAt: data.updatedAt };
83
79
  }
84
80
  return goal;
85
81
  }
86
82
 
87
- type GoalCache = { branch: object; key: string; goal: ThreadGoal | null };
88
- const cache = new WeakMap<ExtensionContext, GoalCache>();
83
+ type GoalCache = { key: string; goal: ThreadGoal | null };
84
+ const caches = new WeakMap<ExtensionContext, GoalCache>();
89
85
 
90
- /** Cached readThreadGoal over the current session branch. */
86
+ /** Cached reconstruction scoped to each extension context. */
91
87
  export function readSessionGoal(ctx: ExtensionContext): ThreadGoal | null {
92
88
  const branch = ctx.sessionManager.getBranch();
93
89
  const last = branch.at(-1);
94
90
  const key = `${branch.length}:${last?.id ?? ""}:${last?.timestamp ?? ""}`;
95
- const previous = cache.get(ctx);
96
- if (previous?.branch === branch && previous.key === key) return previous.goal;
91
+ const previous = caches.get(ctx);
92
+ if (previous?.key === key) return previous.goal;
97
93
  const goal = readThreadGoal(branch);
98
- cache.set(ctx, { branch, key, goal });
94
+ caches.set(ctx, { key, goal });
99
95
  return goal;
100
96
  }
@@ -6,8 +6,8 @@ import {
6
6
  import { basename } from "node:path";
7
7
  import type { Component, TUI } from "@earendil-works/pi-tui";
8
8
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
- import { GAP_WINDOW, PINNED_COUNT, SIDEBAR_WIDTH } from "./constants.ts";
10
9
  import type { CmuxContext } from "./cmux.ts";
10
+ import { SIDEBAR_WIDTH } from "./constants.ts";
11
11
  import { readSessionGoal } from "./goal.ts";
12
12
  import { assertLinesFit } from "./layout.ts";
13
13
  import { renderStatusDock } from "./status-dock.ts";
@@ -42,22 +42,27 @@ type SidebarOptions = {
42
42
  messages: UserMessage[];
43
43
  };
44
44
 
45
- export class SidebarComponent {
45
+ export class SidebarComponent implements Component {
46
46
  private focused = false;
47
- private selected = 0;
48
- private expanded = new Set<number>();
47
+ private selectedId: string | null;
48
+ private readonly expandedIds = new Set<string>();
49
+ private followTail = true;
49
50
  private messages: UserMessage[];
50
51
  private version = 0;
51
52
  private restoreFocus: Component | null = null;
52
53
  private cachedSignature = "";
53
54
  private cachedLines: string[] = [];
55
+ private viewportStartId: string | null = null;
54
56
 
55
57
  constructor(private readonly options: SidebarOptions) {
56
58
  this.messages = options.messages;
57
- this.selected = Math.max(0, this.messages.length - 1);
59
+ this.selectedId = this.messages.at(-1)?.id ?? null;
58
60
  }
59
61
 
60
62
  isFocused(): boolean { return this.focused; }
63
+ getSelectedMessageId(): string | null { return this.selectedId; }
64
+ isFollowingTail(): boolean { return this.followTail; }
65
+ isExpanded(messageId: string): boolean { return this.expandedIds.has(messageId); }
61
66
 
62
67
  setFocused(focused: boolean): void {
63
68
  if (this.focused === focused) return;
@@ -67,17 +72,27 @@ export class SidebarComponent {
67
72
  this.options.tui.setFocus(this);
68
73
  } else {
69
74
  this.focused = false;
70
- if ((this.options.tui as any).getFocusedComponent?.() === this) {
71
- this.options.tui.setFocus(this.restoreFocus);
72
- }
75
+ if ((this.options.tui as any).getFocusedComponent?.() === this) this.options.tui.setFocus(this.restoreFocus);
73
76
  this.restoreFocus = null;
74
77
  }
75
78
  this.refresh();
76
79
  }
77
80
 
78
81
  updateMessages(messages: UserMessage[]): void {
82
+ const previousId = this.selectedId;
83
+ const previousStartId = this.viewportStartId;
79
84
  this.messages = messages;
80
- this.selected = Math.min(this.selected, Math.max(0, messages.length - 1));
85
+ const ids = new Set(messages.map((message) => message.id));
86
+ for (const id of this.expandedIds) if (!ids.has(id)) this.expandedIds.delete(id);
87
+ this.viewportStartId = previousStartId && ids.has(previousStartId) ? previousStartId : null;
88
+
89
+ if (this.followTail || !previousId || !ids.has(previousId)) {
90
+ this.selectedId = messages.at(-1)?.id ?? null;
91
+ this.followTail = true;
92
+ this.viewportStartId = null;
93
+ } else {
94
+ this.selectedId = previousId;
95
+ }
81
96
  this.refresh();
82
97
  }
83
98
 
@@ -89,32 +104,41 @@ export class SidebarComponent {
89
104
  handleInput(data: string): void {
90
105
  if (matchesKey(data, "escape")) return this.setFocused(false);
91
106
  if (matchesKey(data, "c")) { void this.copySessionPath(); return; }
92
- if (matchesKey(data, "up")) this.selected = Math.max(0, this.selected - 1);
93
- else if (matchesKey(data, "down")) this.selected = Math.min(this.messages.length - 1, this.selected + 1);
94
- else if (matchesKey(data, "pageUp")) this.selected = Math.max(0, this.selected - 10);
95
- else if (matchesKey(data, "pageDown")) this.selected = Math.min(this.messages.length - 1, this.selected + 10);
96
- else if (matchesKey(data, "home")) this.selected = 0;
97
- else if (matchesKey(data, "end")) this.selected = Math.max(0, this.messages.length - 1);
107
+ const current = this.selectedIndex();
108
+ let target: number | null = null;
109
+ if (matchesKey(data, "up")) target = Math.max(0, current - 1);
110
+ else if (matchesKey(data, "down")) target = Math.min(this.messages.length - 1, current + 1);
111
+ else if (matchesKey(data, "pageUp")) target = Math.max(0, current - 10);
112
+ else if (matchesKey(data, "pageDown")) target = Math.min(this.messages.length - 1, current + 10);
113
+ else if (matchesKey(data, "home")) target = 0;
114
+ else if (matchesKey(data, "end")) target = Math.max(0, this.messages.length - 1);
98
115
  else if (matchesKey(data, "return") || matchesKey(data, "enter") || data === " ") {
99
- if (this.expanded.has(this.selected)) this.expanded.delete(this.selected);
100
- else this.expanded.add(this.selected);
116
+ if (!this.selectedId) return;
117
+ if (this.expandedIds.has(this.selectedId)) this.expandedIds.delete(this.selectedId);
118
+ else this.expandedIds.add(this.selectedId);
119
+ this.refresh();
120
+ return;
101
121
  } else return;
122
+
123
+ if (target < 0 || !this.messages[target]) return;
124
+ this.selectedId = this.messages[target].id;
125
+ this.followTail = target === this.messages.length - 1;
126
+ if (this.followTail) this.viewportStartId = null;
102
127
  this.refresh();
103
128
  }
104
129
 
105
130
  render(width: number): string[] {
106
131
  const safeWidth = Math.max(1, Math.min(SIDEBAR_WIDTH, width));
107
- const targetHeight = Math.max(15, this.options.tui.terminal.rows);
132
+ const targetHeight = Math.max(1, this.options.tui.terminal.rows);
108
133
  const signature = this.signature(safeWidth, targetHeight);
109
134
  if (signature === this.cachedSignature) return this.cachedLines;
110
135
 
111
- const lines = this.renderHeader(safeWidth);
112
- if (this.messages.length === 0) {
113
- lines.push(fillRow(" No messages yet", safeWidth, BG));
114
- } else {
115
- lines.push(...this.renderMessages(safeWidth));
116
- }
117
-
136
+ const fullHeader = this.renderHeader(safeWidth);
137
+ const headerRows = this.messages.length > 0 && targetHeight === 1 ? 0 : targetHeight <= 5 ? 1 : 2;
138
+ const header = fullHeader.slice(0, Math.min(headerRows, targetHeight));
139
+ const available = Math.max(0, targetHeight - header.length);
140
+ // At tiny heights, keep one chronological message row before allocating dock chrome.
141
+ const messageReserve = this.messages.length > 0 && available > 0 ? 1 : 0;
118
142
  const dock = renderStatusDock(
119
143
  safeWidth,
120
144
  this.options.ctx,
@@ -122,11 +146,12 @@ export class SidebarComponent {
122
146
  this.options.getThinkingLevel(),
123
147
  this.focused,
124
148
  this.options.getCmuxContext,
149
+ Math.min(4, Math.max(0, available - messageReserve)),
125
150
  );
126
- while (lines.length < targetHeight - dock.length) lines.push(fillRow(" ", safeWidth, BG));
127
- const bodyLimit = Math.max(0, targetHeight - dock.length);
128
- const result = [...lines.slice(0, bodyLimit), ...dock].slice(0, targetHeight);
151
+ const bodyHeight = Math.max(0, targetHeight - header.length - dock.length);
152
+ const result = [...header, ...this.renderBody(safeWidth, bodyHeight), ...dock];
129
153
  assertLinesFit(result, safeWidth, "sidebar");
154
+ if (result.length !== targetHeight) throw new Error(`sidebar height mismatch (${result.length} != ${targetHeight})`);
130
155
  this.cachedSignature = signature;
131
156
  this.cachedLines = result;
132
157
  return result;
@@ -138,64 +163,110 @@ export class SidebarComponent {
138
163
  }
139
164
 
140
165
  private renderHeader(width: number): string[] {
141
- const icon = this.focused ? `${FG_ACC}●${RST}` : `${FG_DIM}○${RST}`;
142
- const mode = this.focused ? `${FG_ACC}●${RST} ${FG_MID}focused${RST}` : `${FG_DIM}passive${RST}`;
143
- const separator = `${FG_DIM}${"".repeat(Math.max(0, width - 2))}${RST}`;
166
+ const position = this.selectedIndex() + 1;
167
+ const count = this.messages.length;
168
+ const location = count > 0 ? `${position}/${count}` : "0";
169
+ const selected = this.messages[this.selectedIndex()];
170
+ const detail = selected
171
+ ? `${this.focused ? "Selected" : "Current"} #${selected.index} ${formatTime(selected.timestamp)}`
172
+ : "User prompts";
144
173
  return [
145
- fillRow(" ", width, BG_HDR),
146
- fillRow(` ${icon} ${BOLD}${FG_BRIGHT}Messages${RST}${FG_DIM} ${this.messages.length}${RST} ${mode}`, width, BG_HDR),
147
- fillRow(" ", width, BG_HDR),
148
- fillRow(` ${separator}`, width, BG),
174
+ fillRow(` ${BOLD}${FG_BRIGHT}Messages${RST} ${FG_FAINT}${location}${RST}`, width, BG_HDR),
175
+ fillRow(` ${FG_DIM}${detail}${RST}`, width, BG),
149
176
  ];
150
177
  }
151
178
 
152
- private renderMessages(width: number): string[] {
153
- const result: string[] = [];
154
- let previous = -1;
155
- for (const index of this.visibleIndices()) {
156
- if (index > previous + 1 && previous >= 0) {
157
- result.push(fillRow(` ${FG_DIM}··· ${index - previous - 1} more ···${RST}`, width, BG));
158
- }
159
- result.push(...this.renderMessage(index, width));
160
- previous = index;
179
+ private renderBody(width: number, height: number): string[] {
180
+ if (height === 0) return [];
181
+ if (this.messages.length === 0) return this.padBottom([fillRow(` ${FG_DIM}No messages yet${RST}`, width, BG)], height, width);
182
+
183
+ const selected = this.selectedIndex();
184
+ let start = this.followTail ? this.tailStart(width, height) : this.startForSelection(width, height, selected);
185
+ const preserved = this.indexForId(this.viewportStartId);
186
+ if (!this.followTail && preserved >= 0 && selected >= preserved && this.rangeFitsSelection(width, height, preserved, selected)) start = preserved;
187
+
188
+ const lines: string[] = [];
189
+ for (let index = start; index < this.messages.length && lines.length < height; index++) {
190
+ const remaining = height - lines.length;
191
+ lines.push(...this.renderMessage(index, width, remaining));
161
192
  }
162
- return result;
193
+ this.viewportStartId = this.messages[start]?.id ?? null;
194
+ return this.padBottom(lines.slice(0, height), height, width);
195
+ }
196
+
197
+ private tailStart(width: number, height: number): number {
198
+ let start = this.messages.length - 1;
199
+ let rows = this.messageRowCount(start, width, height);
200
+ while (start > 0) {
201
+ const next = this.messageRowCount(start - 1, width, height);
202
+ if (rows + next > height) break;
203
+ rows += next;
204
+ start--;
205
+ }
206
+ return start;
207
+ }
208
+
209
+ private startForSelection(width: number, height: number, selected: number): number {
210
+ let start = selected;
211
+ let rows = this.messageRowCount(selected, width, height);
212
+ while (start > 0) {
213
+ const next = this.messageRowCount(start - 1, width, height);
214
+ if (rows + next > height) break;
215
+ rows += next;
216
+ start--;
217
+ }
218
+ return start;
219
+ }
220
+
221
+ private rangeFitsSelection(width: number, height: number, start: number, selected: number): boolean {
222
+ let rows = 0;
223
+ for (let index = start; index <= selected; index++) {
224
+ rows += this.messageRowCount(index, width, height);
225
+ if (rows > height) return false;
226
+ }
227
+ return true;
228
+ }
229
+
230
+ private messageRowCount(index: number, width: number, maxRows: number): number {
231
+ return this.renderMessage(index, width, maxRows).length;
163
232
  }
164
233
 
165
- private renderMessage(index: number, width: number): string[] {
234
+ private padBottom(lines: string[], height: number, width: number): string[] {
235
+ const padding = Array.from({ length: Math.max(0, height - lines.length) }, () => fillRow(" ", width, BG));
236
+ return [...lines, ...padding];
237
+ }
238
+
239
+ private renderMessage(index: number, width: number, maxRows = Number.MAX_SAFE_INTEGER): string[] {
166
240
  const message = this.messages[index]!;
167
- const selected = index === this.selected;
241
+ const selected = message.id === this.selectedId;
168
242
  const background = selected ? BG_SEL : BG;
169
- const arrow = selected && this.focused ? `${FG_ACC}▸${RST}` : " ";
170
- const number = `${FG_FAINT}${String(message.index).padStart(2)}${RST}`;
171
- const time = `${FG_TIME}${formatTime(message.timestamp)}${RST}`;
172
- if (!this.expanded.has(index)) {
173
- const prefix = ` ${arrow}${number} ${time} `;
243
+ const arrow = selected && this.focused ? `${FG_ACC}›${RST}` : " ";
244
+ if (!this.expandedIds.has(message.id) || maxRows <= 1) {
245
+ const prefix = ` ${arrow} `;
174
246
  const text = truncateToWidth(message.text.replace(/\s+/g, " "), Math.max(0, width - visibleWidth(prefix)), "…");
175
247
  return [fillRow(`${prefix}${selected ? FG_BRIGHT : FG_NORM}${text}${RST}`, width, background)];
176
248
  }
177
249
 
178
- const lines = [fillRow(` ${arrow}${number} ${time}`, width, background)];
250
+ const number = `${FG_FAINT}#${message.index}${RST}`;
251
+ const time = `${FG_TIME}${formatTime(message.timestamp)}${RST}`;
252
+ const lines = [fillRow(` ${arrow} ${number} ${time}`, width, background)];
179
253
  const wrapped = wrapText(message.text, Math.max(1, width - 4));
180
- for (const line of wrapped.slice(0, 8)) lines.push(fillRow(` ${FG_EXP}${line}${RST}`, width, background));
181
- if (wrapped.length > 8) {
182
- lines.push(fillRow(` ${FG_DIM}${DIM}…+${wrapped.length - 8} lines${RST}`, width, background));
254
+ const contentRows = Math.max(0, maxRows - 1);
255
+ for (const line of wrapped.slice(0, contentRows)) lines.push(fillRow(` ${FG_EXP}${line}${RST}`, width, background));
256
+ if (wrapped.length > contentRows && lines.length === maxRows) {
257
+ lines[lines.length - 1] = fillRow(` ${FG_DIM}${DIM}…+${wrapped.length - contentRows + 1} lines${RST}`, width, background);
183
258
  }
184
- lines.push(fillRow(" ", width, background));
185
259
  return lines;
186
260
  }
187
261
 
188
- private visibleIndices(): number[] {
189
- const total = this.messages.length;
190
- if (total <= PINNED_COUNT * 2 + 1) return Array.from({ length: total }, (_, index) => index);
191
- const indices = new Set<number>();
192
- for (let i = 0; i < PINNED_COUNT; i++) indices.add(i);
193
- for (let i = total - PINNED_COUNT; i < total; i++) indices.add(i);
194
- if (this.selected >= PINNED_COUNT && this.selected < total - PINNED_COUNT) {
195
- const start = Math.max(PINNED_COUNT, Math.min(this.selected - 1, total - PINNED_COUNT - GAP_WINDOW));
196
- for (let i = start; i < start + GAP_WINDOW; i++) indices.add(i);
197
- }
198
- return [...indices].sort((a, b) => a - b);
262
+ private selectedIndex(): number {
263
+ if (this.messages.length === 0) return -1;
264
+ const index = this.indexForId(this.selectedId);
265
+ return index >= 0 ? index : this.messages.length - 1;
266
+ }
267
+
268
+ private indexForId(id: string | null): number {
269
+ return id ? this.messages.findIndex((message) => message.id === id) : -1;
199
270
  }
200
271
 
201
272
  private signature(width: number, height: number): string {
@@ -207,13 +278,12 @@ export class SidebarComponent {
207
278
  width,
208
279
  height,
209
280
  version: this.version,
210
- messages: this.messages.length,
211
281
  model: this.options.ctx.model?.id,
212
282
  thinking: this.options.getThinkingLevel(),
213
283
  usage,
214
- statuses: statuses ? [...statuses.entries()] : [],
215
- goal: goal ? `${goal.goalId}:${goal.status}:${goal.tokensUsed}:${goal.activeSeconds}:${goal.updatedAt}` : null,
216
- cmux: cmux ? `${cmux.workspaceTitle}:${cmux.surfaceRef}` : null,
284
+ statuses: statuses && typeof (statuses as any).entries === "function" ? [...statuses.entries()] : [],
285
+ goal: goal ? `${goal.goalId}:${goal.status}:${goal.usage.tokensUsed}:${goal.usage.activeSeconds}:${goal.updatedAt}` : null,
286
+ cmux: cmux ? `${cmux.workspaceTitle}:${cmux.workspaceRef}:${cmux.surfaceRef}` : null,
217
287
  });
218
288
  }
219
289
 
@@ -2,13 +2,11 @@ import type {
2
2
  ExtensionContext,
3
3
  ReadonlyFooterDataProvider,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { basename } from "node:path";
6
5
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
- import { readSessionGoal, type ThreadGoal } from "./goal.ts";
8
6
  import type { CmuxContext } from "./cmux.ts";
7
+ import { readSessionGoal, type ThreadGoal } from "./goal.ts";
9
8
  import {
10
9
  BG,
11
- BG_CARD,
12
10
  BG_HDR,
13
11
  FG_ACC,
14
12
  FG_BRIGHT,
@@ -17,7 +15,6 @@ import {
17
15
  FG_FAINT,
18
16
  FG_INFO,
19
17
  FG_MID,
20
- FG_NORM,
21
18
  FG_OK,
22
19
  FG_WARN,
23
20
  RST,
@@ -26,131 +23,139 @@ import {
26
23
  formatCwd,
27
24
  formatDuration,
28
25
  formatTokens,
29
- progressBar,
30
26
  sanitizeStatusText,
31
27
  wrapText,
32
28
  } from "./style.ts";
33
29
 
34
- type Usage = {
30
+ type UsageTotals = {
35
31
  input: number;
36
32
  output: number;
37
33
  cacheRead: number;
38
34
  cacheWrite: number;
39
35
  cost: number;
40
36
  latestCacheHitRate?: number;
37
+ };
38
+
39
+ type Usage = UsageTotals & {
41
40
  contextPercent: number | null;
42
41
  contextWindow: number;
43
42
  };
44
43
 
45
- let usageCache: { key: string; usage: Usage } | null = null;
44
+ type UsageCache = { key: string; totals: UsageTotals };
45
+ const usageCaches = new WeakMap<ExtensionContext, UsageCache>();
46
46
 
47
- function computeUsage(ctx: ExtensionContext): Usage {
47
+ /** Aggregate persisted usage once, but sample live context usage on every render. */
48
+ export function computeUsage(ctx: ExtensionContext): Usage {
48
49
  const entries = ctx.sessionManager.getEntries();
49
50
  const last = entries.at(-1);
50
- const key = `${entries.length}:${last?.id ?? ""}`;
51
- if (usageCache && usageCache.key === key) return usageCache.usage;
52
-
53
- let input = 0;
54
- let output = 0;
55
- let cacheRead = 0;
56
- let cacheWrite = 0;
57
- let cost = 0;
58
- let latestCacheHitRate: number | undefined;
59
-
60
- for (const entry of entries) {
61
- if (entry.type !== "message" || entry.message.role !== "assistant") continue;
62
- const usage = (entry.message as any).usage ?? {};
63
- input += usage.input ?? 0;
64
- output += usage.output ?? 0;
65
- cacheRead += usage.cacheRead ?? 0;
66
- cacheWrite += usage.cacheWrite ?? 0;
67
- cost += usage.cost?.total ?? 0;
68
- const prompt = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
69
- latestCacheHitRate = prompt > 0 ? ((usage.cacheRead ?? 0) / prompt) * 100 : undefined;
51
+ const key = `${entries.length}:${last?.id ?? ""}:${last?.timestamp ?? ""}`;
52
+ let totals = usageCaches.get(ctx);
53
+
54
+ if (!totals || totals.key !== key) {
55
+ let input = 0;
56
+ let output = 0;
57
+ let cacheRead = 0;
58
+ let cacheWrite = 0;
59
+ let cost = 0;
60
+ let latestCacheHitRate: number | undefined;
61
+ for (const entry of entries) {
62
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
63
+ const value = (entry.message as any).usage ?? {};
64
+ input += value.input ?? 0;
65
+ output += value.output ?? 0;
66
+ cacheRead += value.cacheRead ?? 0;
67
+ cacheWrite += value.cacheWrite ?? 0;
68
+ cost += value.cost?.total ?? 0;
69
+ const prompt = (value.input ?? 0) + (value.cacheRead ?? 0) + (value.cacheWrite ?? 0);
70
+ latestCacheHitRate = prompt > 0 ? ((value.cacheRead ?? 0) / prompt) * 100 : undefined;
71
+ }
72
+ totals = { key, totals: { input, output, cacheRead, cacheWrite, cost, latestCacheHitRate } };
73
+ usageCaches.set(ctx, totals);
70
74
  }
71
75
 
72
76
  const context = ctx.getContextUsage?.();
73
- const usage = {
74
- input,
75
- output,
76
- cacheRead,
77
- cacheWrite,
78
- cost,
79
- latestCacheHitRate,
80
- contextPercent: context?.percent ?? null,
81
- contextWindow: context?.contextWindow ?? ctx.model?.contextWindow ?? 0,
77
+ return {
78
+ ...totals.totals,
79
+ contextPercent: typeof context?.percent === "number" && Number.isFinite(context.percent)
80
+ ? context.percent
81
+ : null,
82
+ contextWindow: typeof context?.contextWindow === "number" && Number.isFinite(context.contextWindow)
83
+ ? context.contextWindow
84
+ : ctx.model?.contextWindow ?? 0,
82
85
  };
83
- usageCache = { key, usage };
84
- return usage;
85
86
  }
86
87
 
87
- function dockHeader(width: number, title: string): string {
88
- const pad = " ";
89
- const ruleWidth = Math.max(0, width - visibleWidth(pad) - visibleWidth(title) - 1);
90
- return fillRow(`${pad}${FG_FAINT}${title}${RST} ${FG_FAINT}${"─".repeat(ruleWidth)}${RST}`, width, BG);
88
+ function row(width: number, label: string, value: string, background = BG): string {
89
+ const prefix = ` ${FG_FAINT}${label}${RST} `;
90
+ return fillRow(`${prefix}${truncateToWidth(value, Math.max(0, width - visibleWidth(prefix)), "…")}`, width, background);
91
91
  }
92
92
 
93
- function dockRow(width: number, label: string, value: string): string {
94
- const pad = " ";
95
- const labelWidth = 5;
96
- const maxValueWidth = Math.max(0, width - visibleWidth(pad) - labelWidth - 1);
97
- const clipped = truncateToWidth(value, maxValueWidth, "…");
98
- return fillRow(`${pad}${FG_FAINT}${label.padEnd(labelWidth)}${RST} ${clipped}`, width, BG_CARD);
99
- }
100
-
101
- function goalStatusGlyph(goal: ThreadGoal): { icon: string; color: string; label: string } {
93
+ function goalStatus(goal: ThreadGoal): { icon: string; color: string; label: string } {
102
94
  switch (goal.status) {
103
- case "active": return { icon: "", color: FG_ACC, label: "active" };
95
+ case "active": return { icon: "", color: FG_ACC, label: "active" };
104
96
  case "paused": return { icon: "○", color: FG_DIM, label: "paused" };
105
97
  case "budgetLimited": return { icon: "▲", color: FG_WARN, label: "budget" };
106
98
  case "complete": return { icon: "✓", color: FG_OK, label: "done" };
107
99
  }
108
100
  }
109
101
 
110
- function renderGoalCard(width: number, goal: ThreadGoal): string[] {
111
- const glyph = goalStatusGlyph(goal);
112
- const rows = [dockHeader(width, "goal")];
102
+ function renderGoal(width: number, goal: ThreadGoal): string[] {
103
+ const status = goalStatus(goal);
104
+ const budget = goal.tokenBudget
105
+ ? `${formatTokens(goal.usage.tokensUsed)}/${formatTokens(goal.tokenBudget)}`
106
+ : `${formatTokens(goal.usage.tokensUsed)}`;
107
+ const overBudget = goal.tokenBudget !== null && goal.usage.tokensUsed > goal.tokenBudget;
108
+ const metadata = `${status.color}${status.icon} ${status.label}${RST} ${overBudget ? FG_ERR : FG_MID}${budget}${RST} ${FG_FAINT}${formatDuration(goal.usage.activeSeconds)}${RST}`;
109
+ const objective = wrapText(goal.objective, Math.max(1, width - 4));
110
+ const lines = [fillRow(` ${metadata}`, width, BG)];
111
+ if (objective[0]) lines.push(fillRow(` ${FG_BRIGHT}${objective[0]}${RST}`, width, BG));
112
+ return lines;
113
+ }
113
114
 
114
- const indent = " ";
115
- const wrapped = wrapText(goal.objective, Math.max(1, width - 4));
116
- const shown = wrapped.slice(0, 2);
117
- rows.push(fillRow(` ${glyph.color}${glyph.icon}${RST} ${shown[0] ? `${FG_BRIGHT}${shown[0]}${RST}` : ""}`, width, BG_CARD));
118
- if (shown[1]) rows.push(fillRow(`${indent}${FG_NORM}${shown[1]}${RST}`, width, BG_CARD));
119
- if (wrapped.length > 2) rows.push(fillRow(`${indent}${FG_FAINT}…+${wrapped.length - 2} lines${RST}`, width, BG_CARD));
115
+ function renderWorkspace(width: number, ctx: ExtensionContext, footerData: ReadonlyFooterDataProvider | null): string {
116
+ const branch = footerData?.getGitBranch();
117
+ const sessionName = ctx.sessionManager.getSessionName();
118
+ const parts = [
119
+ `${FG_BRIGHT}${formatCwd(ctx.sessionManager.getCwd())}${RST}`,
120
+ branch ? `${FG_INFO}${branch}${RST}` : undefined,
121
+ sessionName ? `${FG_MID}${sessionName}${RST}` : undefined,
122
+ ].filter(Boolean).join(` ${FG_FAINT}·${RST} `);
123
+ return row(width, "cwd", parts);
124
+ }
120
125
 
121
- const budget = goal.tokenBudget
122
- ? `${formatTokens(goal.tokensUsed)}/${formatTokens(goal.tokenBudget)}`
123
- : `${formatTokens(goal.tokensUsed)}/∞`;
124
- const percent = goal.tokenBudget ? (goal.tokensUsed / goal.tokenBudget) * 100 : null;
125
- const overBudget = percent !== null && percent > 100;
126
- const budgetColor = overBudget ? FG_ERR : glyph.color;
127
- rows.push(fillRow(
128
- ` ${budgetColor}${budget}${RST} ${progressBar(percent)} ${glyph.color}${glyph.label}${RST} ${FG_FAINT}·${RST} ${FG_MID}${formatDuration(goal.activeSeconds)}${RST}`,
129
- width,
130
- BG_CARD,
131
- ));
132
- return rows;
126
+ function renderCmux(width: number, cmux: CmuxContext): string {
127
+ const prefix = ` ${FG_FAINT}cmux${RST} `;
128
+ const available = Math.max(0, width - visibleWidth(prefix));
129
+ const surfaceText = cmux.surfaceRef ?? "";
130
+ const surface = truncateToWidth(surfaceText, available, "");
131
+ const separator = surface ? ` ${FG_FAINT}·${RST} ` : "";
132
+ const titleBudget = Math.max(0, available - visibleWidth(separator) - visibleWidth(surface));
133
+ const titleText = cmux.workspaceTitle ?? cmux.workspaceRef ?? "cmux";
134
+ const title = truncateToWidth(titleText, titleBudget, "…");
135
+ return fillRow(`${prefix}${FG_BRIGHT}${title}${RST}${separator}${FG_INFO}${surface}${RST}`, width, BG);
133
136
  }
134
137
 
135
- function renderSessRow(width: number, ctx: ExtensionContext, cmux: CmuxContext | null): string {
136
- const sessionId = ctx.sessionManager.getSessionId();
137
- const shortId = sessionId.replace(/-/g, "").slice(-8);
138
- let value: string;
139
- if (cmux) {
140
- const surfacePart = cmux.surfaceRef ? ` ${FG_FAINT}·${RST} ${FG_FAINT}${cmux.surfaceRef}${RST}` : "";
141
- const budget = width - 7 - visibleWidth(surfacePart);
142
- const name = cmux.workspaceTitle
143
- ? truncateToWidth(cmux.workspaceTitle, budget, "…")
144
- : cmux.workspaceRef ?? "";
145
- value = `${FG_BRIGHT}${name}${RST}${surfacePart}`;
146
- } else {
147
- const sessionFile = ctx.sessionManager.getSessionFile();
148
- value = [
149
- `${FG_INFO}#${shortId}${RST}`,
150
- sessionFile ? `${FG_FAINT}${basename(sessionFile)}${RST}` : undefined,
151
- ].filter(Boolean).join(` ${FG_FAINT}·${RST} `);
138
+ function renderRuntime(width: number, ctx: ExtensionContext, footerData: ReadonlyFooterDataProvider | null, thinkingLevel: string, usage: Usage): string | null {
139
+ const model = ctx.model;
140
+ if (!model) return null;
141
+ const provider = footerData && footerData.getAvailableProviderCount() > 1 ? `${model.provider}/` : "";
142
+ const thinking = model.reasoning ? ` ${FG_FAINT}·${RST} ${FG_MID}${thinkingLevel}${RST}` : "";
143
+ const percent = usage.contextPercent === null ? "?" : `${usage.contextPercent.toFixed(0)}%`;
144
+ const context = `${contextColor(usage.contextPercent)}${percent}/${formatTokens(usage.contextWindow)}${RST}`;
145
+ return row(width, "run", `${FG_BRIGHT}${provider}${model.id}${RST}${thinking} ${FG_FAINT}·${RST} ${context}`);
146
+ }
147
+
148
+ export function validExtensionStatuses(footerData: ReadonlyFooterDataProvider | null): string[] {
149
+ if (!footerData) return [];
150
+ const statuses = footerData.getExtensionStatuses();
151
+ if (!(statuses && typeof (statuses as any).entries === "function")) return [];
152
+ const valid: [string, string][] = [];
153
+ for (const entry of statuses.entries()) {
154
+ if (!Array.isArray(entry) || typeof entry[0] !== "string" || typeof entry[1] !== "string") continue;
155
+ const text = sanitizeStatusText(entry[1]);
156
+ if (text) valid.push([entry[0], text]);
152
157
  }
153
- return dockRow(width, "sess", value);
158
+ return valid.sort(([a], [b]) => a.localeCompare(b)).map(([, text]) => text);
154
159
  }
155
160
 
156
161
  export function renderStatusDock(
@@ -160,67 +165,33 @@ export function renderStatusDock(
160
165
  thinkingLevel: string,
161
166
  focused: boolean,
162
167
  getCmuxContext: () => CmuxContext | null,
168
+ maxRows = 4,
163
169
  ): string[] {
170
+ const limit = Math.max(0, Math.min(4, Math.floor(maxRows)));
171
+ if (limit === 0) return [];
172
+ const hint = fillRow(
173
+ ` ${FG_DIM}${focused ? "↑↓ move Enter open c copy Esc close" : "Ctrl+Shift+H focus"}${RST}`,
174
+ width,
175
+ BG_HDR,
176
+ );
177
+ if (limit === 1) return [hint];
178
+
164
179
  const usage = computeUsage(ctx);
165
180
  const goal = readSessionGoal(ctx);
181
+ const cmux = getCmuxContext();
166
182
  const rows: string[] = [];
167
183
 
168
- if (goal) rows.push(...renderGoalCard(width, goal));
169
-
170
- rows.push(fillRow(" ", width, BG), dockHeader(width, "runtime"));
171
- const branch = footerData?.getGitBranch();
172
- const sessionName = ctx.sessionManager.getSessionName();
173
- const workspace = [
174
- `${FG_BRIGHT}${formatCwd(ctx.sessionManager.getCwd())}${RST}`,
175
- branch ? `${FG_INFO}${branch}${RST}` : undefined,
176
- sessionName ? `${FG_MID}${sessionName}${RST}` : undefined,
177
- ].filter(Boolean).join(` ${FG_FAINT}•${RST} `);
178
- rows.push(dockRow(width, "cwd", workspace));
179
- rows.push(renderSessRow(width, ctx, getCmuxContext()));
180
-
181
- const model = ctx.model;
182
- if (model) {
183
- const provider = footerData && footerData.getAvailableProviderCount() > 1
184
- ? `${FG_FAINT}${model.provider}${RST} `
185
- : "";
186
- const thinking = model.reasoning ? ` ${FG_FAINT}•${RST} ${FG_MID}${thinkingLevel}${RST}` : "";
187
- rows.push(dockRow(width, "model", `${provider}${FG_BRIGHT}${model.id}${RST}${thinking}`));
188
- }
189
-
190
- const contextDisplay = usage.contextPercent === null
191
- ? `?/${formatTokens(usage.contextWindow)}`
192
- : `${usage.contextPercent.toFixed(1)}%/${formatTokens(usage.contextWindow)}`;
193
- const usingSubscription = model ? Boolean((ctx.modelRegistry as any).isUsingOAuth?.(model)) : false;
194
- const tokenParts = [
195
- usage.input ? `↑${formatTokens(usage.input)}` : undefined,
196
- usage.output ? `↓${formatTokens(usage.output)}` : undefined,
197
- usage.cacheRead ? `R${formatTokens(usage.cacheRead)}` : undefined,
198
- usage.cacheWrite ? `W${formatTokens(usage.cacheWrite)}` : undefined,
199
- usage.latestCacheHitRate !== undefined && (usage.cacheRead || usage.cacheWrite)
200
- ? `CH${usage.latestCacheHitRate.toFixed(1)}%`
201
- : undefined,
202
- ].filter(Boolean).join(" ");
203
- rows.push(dockRow(
204
- width,
205
- "ctx",
206
- `${contextColor(usage.contextPercent)}${contextDisplay}${RST} ${progressBar(usage.contextPercent)}`,
207
- ));
208
- rows.push(dockRow(
209
- width,
210
- "use",
211
- `${FG_BRIGHT}$${usage.cost.toFixed(3)}${usingSubscription ? " sub" : ""}${RST}` +
212
- (tokenParts ? ` ${FG_FAINT}•${RST} ${FG_MID}${tokenParts}${RST}` : ` ${FG_FAINT}• no token usage${RST}`),
213
- ));
214
-
215
- const statuses = footerData ? [...footerData.getExtensionStatuses().entries()].sort(([a], [b]) => a.localeCompare(b)) : [];
216
- for (const [, text] of statuses.slice(0, 2)) {
217
- rows.push(dockRow(width, "stat", `${FG_INFO}•${RST} ${FG_MID}${sanitizeStatusText(text)}${RST}`));
184
+ // A cmux surface reference is operational identity. Keep it ahead of optional
185
+ // title, runtime, and status text so truncation cannot discard it.
186
+ if (cmux) rows.push(renderCmux(width, cmux));
187
+ if (goal) rows.push(...renderGoal(width, goal));
188
+ if (!cmux && !goal) rows.push(renderWorkspace(width, ctx, footerData));
189
+ const runtime = renderRuntime(width, ctx, footerData, thinkingLevel, usage);
190
+ if (runtime) rows.push(runtime);
191
+ if (!goal) {
192
+ const status = validExtensionStatuses(footerData)[0];
193
+ if (status) rows.push(row(width, "stat", `${FG_MID}${status}${RST}`));
218
194
  }
219
195
 
220
- rows.push(fillRow(" ", width, BG));
221
- const hint = focused
222
- ? `${FG_DIM}↑↓${RST} ${FG_MID}nav${RST} ${FG_FAINT}·${RST} ${FG_DIM}Enter${RST} ${FG_MID}expand${RST} ${FG_FAINT}·${RST} ${FG_DIM}c${RST} ${FG_MID}copy${RST} ${FG_FAINT}·${RST} ${FG_DIM}Esc${RST} ${FG_MID}done${RST}`
223
- : `${FG_DIM}Ctrl+Shift+H${RST} ${FG_MID}focus${RST}`;
224
- rows.push(fillRow(` ${hint}`, width, BG_HDR));
225
- return rows;
196
+ return [...rows.slice(0, limit - 1), hint];
226
197
  }