pi-message-sidebar 1.2.0 → 1.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.
@@ -125,8 +125,8 @@ export default function messageSidebar(pi: ExtensionAPI): void {
125
125
 
126
126
  ctx.ui.onTerminalInput((data) => {
127
127
  // Slash commands such as /goal mutate session entries without firing
128
- // turn events; a coalesced refresh on any keystroke keeps the dock live.
129
- scheduleRefresh();
128
+ // turn events. Refresh on submit, not every keystroke.
129
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) scheduleRefresh();
130
130
  if (tui && !isSidebarVisible(tui.terminal.columns) && sidebar?.isFocused()) {
131
131
  sidebar.setFocused(false);
132
132
  tui.requestRender();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-message-sidebar",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Persistent responsive message history sidebar for Pi",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/goal.ts CHANGED
@@ -15,22 +15,32 @@ type BranchEntry = { type?: string; customType?: string; data?: unknown };
15
15
 
16
16
  const GOAL_ENTRY_TYPE = "pi-codex-goal";
17
17
 
18
- function isThreadGoal(value: unknown): value is ThreadGoal {
19
- const goal = value as ThreadGoal | null;
18
+ type GoalEntrySource = "command" | "tool" | "runtime";
19
+
20
+ function isGoalEntrySource(value: unknown): value is GoalEntrySource {
21
+ return value === "command" || value === "tool" || value === "runtime";
22
+ }
23
+
24
+ function isThreadGoal(value: unknown): value is RawGoal {
25
+ const goal = value as RawGoal | null;
26
+ if (!goal || typeof goal !== "object") return false;
27
+ const usage = goal.usage as { tokensUsed?: unknown; activeSeconds?: unknown } | undefined;
20
28
  return (
21
- !!goal &&
22
- typeof goal === "object" &&
23
29
  typeof goal.goalId === "string" &&
24
30
  typeof goal.objective === "string" &&
25
31
  (goal.status === "active" || goal.status === "paused" || goal.status === "budgetLimited" || goal.status === "complete") &&
26
32
  (goal.tokenBudget === null || typeof goal.tokenBudget === "number") &&
27
- typeof goal.tokensUsed === "number" &&
28
- typeof goal.activeSeconds === "number" &&
29
33
  typeof goal.createdAt === "number" &&
30
- typeof goal.updatedAt === "number"
34
+ typeof goal.updatedAt === "number" &&
35
+ typeof usage?.tokensUsed === "number" &&
36
+ typeof usage.activeSeconds === "number"
31
37
  );
32
38
  }
33
39
 
40
+ type RawGoal = Omit<ThreadGoal, "tokensUsed" | "activeSeconds"> & {
41
+ usage: { tokensUsed: number; activeSeconds: number };
42
+ };
43
+
34
44
  /**
35
45
  * Reconstructs the current thread goal from pi-codex-goal session entries.
36
46
  * Mirrors reconstructGoal() from pi-codex-goal dist/state.js (schema version 1):
@@ -42,35 +52,55 @@ export function readThreadGoal(entries: Iterable<BranchEntry>): ThreadGoal | nul
42
52
  for (const entry of entries) {
43
53
  if (entry.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
44
54
  const data = entry.data as Record<string, unknown> | null;
45
- if (!data || typeof data !== "object" || data.version !== 1) continue;
46
- if (data.kind === "clear") {
55
+ if (!data || typeof data !== "object" || data.version !== 1 || typeof data.at !== "number") continue;
56
+ if (data.kind === "clear" && isGoalEntrySource(data.source) && (data.clearedGoalId === null || typeof data.clearedGoalId === "string")) {
47
57
  goal = null;
48
- } else if (data.kind === "set" && isThreadGoal(data.goal)) {
49
- goal = { ...(data.goal as ThreadGoal) };
50
- } else if (data.kind === "usage" && goal) {
58
+ } else if (data.kind === "set" && isGoalEntrySource(data.source) && isThreadGoal(data.goal)) {
59
+ const raw = data.goal as RawGoal;
60
+ goal = {
61
+ goalId: raw.goalId,
62
+ objective: raw.objective,
63
+ status: raw.status,
64
+ tokenBudget: raw.tokenBudget,
65
+ tokensUsed: raw.usage.tokensUsed,
66
+ activeSeconds: raw.usage.activeSeconds,
67
+ createdAt: raw.createdAt,
68
+ updatedAt: raw.updatedAt,
69
+ };
70
+ } else if (data.kind === "usage" && data.source === "runtime" && goal) {
51
71
  const usage = data.usage as { tokensUsed?: number; activeSeconds?: number } | undefined;
52
72
  const updatedAt = data.updatedAt;
73
+ const status = data.status;
53
74
  if (data.goalId !== goal.goalId) continue;
75
+ if (status !== "active" && status !== "budgetLimited") continue;
54
76
  if (goal.status !== "active" && goal.status !== "budgetLimited") continue;
55
- if (goal.status === "budgetLimited" && data.status === "active") continue;
77
+ if (goal.status === "budgetLimited" && status === "active") continue;
56
78
  if (typeof updatedAt !== "number" || typeof usage?.tokensUsed !== "number" || typeof usage.activeSeconds !== "number") continue;
57
79
  if (updatedAt < goal.updatedAt || usage.tokensUsed < goal.tokensUsed || usage.activeSeconds < goal.activeSeconds) continue;
58
- goal = { ...goal, status: data.status as ThreadGoal["status"], tokensUsed: usage.tokensUsed, activeSeconds: usage.activeSeconds, updatedAt } as ThreadGoal;
80
+ const current: ThreadGoal = goal;
81
+ goal = {
82
+ ...current,
83
+ status,
84
+ tokensUsed: usage.tokensUsed,
85
+ activeSeconds: usage.activeSeconds,
86
+ updatedAt,
87
+ };
59
88
  }
60
89
  }
61
90
  return goal;
62
91
  }
63
92
 
64
- type GoalCache = { key: string; goal: ThreadGoal | null };
65
- let cache: GoalCache | null = null;
93
+ type GoalCache = { branch: object; key: string; goal: ThreadGoal | null };
94
+ const cache = new WeakMap<ExtensionContext, GoalCache>();
66
95
 
67
96
  /** Cached readThreadGoal over the current session branch. */
68
97
  export function readSessionGoal(ctx: ExtensionContext): ThreadGoal | null {
69
98
  const branch = ctx.sessionManager.getBranch();
70
99
  const last = branch.at(-1);
71
100
  const key = `${branch.length}:${last?.id ?? ""}:${last?.timestamp ?? ""}`;
72
- if (cache && cache.key === key) return cache.goal;
101
+ const previous = cache.get(ctx);
102
+ if (previous?.branch === branch && previous.key === key) return previous.goal;
73
103
  const goal = readThreadGoal(branch);
74
- cache = { key, goal };
104
+ cache.set(ctx, { branch, key, goal });
75
105
  return goal;
76
106
  }
@@ -6,7 +6,7 @@ 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";
9
+ import { SIDEBAR_WIDTH } from "./constants.ts";
10
10
  import type { CmuxContext } from "./cmux.ts";
11
11
  import { readSessionGoal } from "./goal.ts";
12
12
  import { assertLinesFit } from "./layout.ts";
@@ -42,10 +42,13 @@ type SidebarOptions = {
42
42
  messages: UserMessage[];
43
43
  };
44
44
 
45
- export class SidebarComponent {
45
+ type MessageRows = { index: number; lines: string[] };
46
+
47
+ export class SidebarComponent implements Component {
46
48
  private focused = false;
47
- private selected = 0;
48
- private expanded = new Set<number>();
49
+ private selectedId: string | null;
50
+ private readonly expandedIds = new Set<string>();
51
+ private followTail = true;
49
52
  private messages: UserMessage[];
50
53
  private version = 0;
51
54
  private restoreFocus: Component | null = null;
@@ -54,10 +57,13 @@ export class SidebarComponent {
54
57
 
55
58
  constructor(private readonly options: SidebarOptions) {
56
59
  this.messages = options.messages;
57
- this.selected = Math.max(0, this.messages.length - 1);
60
+ this.selectedId = this.messages.at(-1)?.id ?? null;
58
61
  }
59
62
 
60
63
  isFocused(): boolean { return this.focused; }
64
+ getSelectedMessageId(): string | null { return this.selectedId; }
65
+ isFollowingTail(): boolean { return this.followTail; }
66
+ isExpanded(messageId: string): boolean { return this.expandedIds.has(messageId); }
61
67
 
62
68
  setFocused(focused: boolean): void {
63
69
  if (this.focused === focused) return;
@@ -76,8 +82,17 @@ export class SidebarComponent {
76
82
  }
77
83
 
78
84
  updateMessages(messages: UserMessage[]): void {
85
+ const previousId = this.selectedId;
79
86
  this.messages = messages;
80
- this.selected = Math.min(this.selected, Math.max(0, messages.length - 1));
87
+ const ids = new Set(messages.map((message) => message.id));
88
+ for (const id of this.expandedIds) if (!ids.has(id)) this.expandedIds.delete(id);
89
+
90
+ if (this.followTail || !previousId || !ids.has(previousId)) {
91
+ this.selectedId = messages.at(-1)?.id ?? null;
92
+ this.followTail = true;
93
+ } else {
94
+ this.selectedId = previousId;
95
+ }
81
96
  this.refresh();
82
97
  }
83
98
 
@@ -89,32 +104,37 @@ 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;
102
126
  this.refresh();
103
127
  }
104
128
 
105
129
  render(width: number): string[] {
106
130
  const safeWidth = Math.max(1, Math.min(SIDEBAR_WIDTH, width));
107
- const targetHeight = Math.max(15, this.options.tui.terminal.rows);
131
+ const targetHeight = Math.max(1, this.options.tui.terminal.rows);
108
132
  const signature = this.signature(safeWidth, targetHeight);
109
133
  if (signature === this.cachedSignature) return this.cachedLines;
110
134
 
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
-
135
+ const renderedHeader = this.renderHeader(safeWidth);
136
+ const header = renderedHeader.slice(0, Math.min(renderedHeader.length, targetHeight));
137
+ const maxDockRows = Math.max(0, targetHeight - header.length);
118
138
  const dock = renderStatusDock(
119
139
  safeWidth,
120
140
  this.options.ctx,
@@ -122,11 +142,14 @@ export class SidebarComponent {
122
142
  this.options.getThinkingLevel(),
123
143
  this.focused,
124
144
  this.options.getCmuxContext,
145
+ maxDockRows,
125
146
  );
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);
147
+ const bodyHeight = Math.max(0, targetHeight - header.length - dock.length);
148
+ const result = [...header, ...this.renderBody(safeWidth, bodyHeight), ...dock];
129
149
  assertLinesFit(result, safeWidth, "sidebar");
150
+ if (result.length !== targetHeight) {
151
+ throw new Error(`sidebar height mismatch (${result.length} != ${targetHeight})`);
152
+ }
130
153
  this.cachedSignature = signature;
131
154
  this.cachedLines = result;
132
155
  return result;
@@ -138,38 +161,74 @@ export class SidebarComponent {
138
161
  }
139
162
 
140
163
  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}`;
164
+ const focus = this.focused ? `${FG_ACC}focused${RST}` : `${FG_DIM}passive${RST}`;
165
+ const tail = this.followTail ? `${FG_DIM}tail${RST}` : `${FG_MID}browsing${RST}`;
144
166
  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),
167
+ fillRow(` ${BOLD}${FG_BRIGHT}Messages${RST} ${FG_FAINT}${this.messages.length}${RST}`, width, BG_HDR),
168
+ fillRow(` ${focus}${FG_FAINT} · ${RST}${tail}`, width, BG),
149
169
  ];
150
170
  }
151
171
 
152
- private renderMessages(width: number): string[] {
153
- const result: string[] = [];
172
+ private renderBody(width: number, height: number): string[] {
173
+ if (height === 0) return [];
174
+ if (this.messages.length === 0) {
175
+ return this.padTop([fillRow(` ${FG_DIM}No messages yet${RST}`, width, BG)], height, width);
176
+ }
177
+
178
+ const selected = this.selectedIndex();
179
+ const newest = this.messages.length - 1;
180
+ const selectedBudget = Math.max(1, height - (selected === newest ? 0 : height >= 3 ? 2 : 1));
181
+ const groups = new Map<number, MessageRows>();
182
+ groups.set(selected, { index: selected, lines: this.renderMessage(selected, width, selectedBudget) });
183
+ if (selected !== newest && height >= 2) {
184
+ groups.set(newest, { index: newest, lines: this.renderMessage(newest, width, 1) });
185
+ }
186
+
187
+ const candidates = Array.from({ length: this.messages.length }, (_, index) => index)
188
+ .filter((index) => !groups.has(index))
189
+ .sort((a, b) => {
190
+ const aDistance = Math.min(Math.abs(a - selected), newest - a);
191
+ const bDistance = Math.min(Math.abs(b - selected), newest - b);
192
+ return aDistance - bDistance || b - a;
193
+ });
194
+ for (const index of candidates) {
195
+ const group = { index, lines: this.renderMessage(index, width) };
196
+ groups.set(index, group);
197
+ if (this.composeGroups(groups, width, true).length > height) groups.delete(index);
198
+ }
199
+
200
+ let lines = this.composeGroups(groups, width, true);
201
+ if (lines.length > height) lines = this.composeGroups(groups, width, false);
202
+ return this.padTop(lines, height, width);
203
+ }
204
+
205
+ private composeGroups(groups: Map<number, MessageRows>, width: number, showGaps: boolean): string[] {
206
+ const ordered = [...groups.values()].sort((a, b) => a.index - b.index);
207
+ const lines: string[] = [];
154
208
  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));
209
+ for (const group of ordered) {
210
+ if (showGaps && previous >= 0 && group.index > previous + 1) {
211
+ lines.push(fillRow(` ${FG_FAINT}··· ${group.index - previous - 1} hidden${RST}`, width, BG));
158
212
  }
159
- result.push(...this.renderMessage(index, width));
160
- previous = index;
213
+ lines.push(...group.lines);
214
+ previous = group.index;
161
215
  }
162
- return result;
216
+ return lines;
217
+ }
218
+
219
+ private padTop(lines: string[], height: number, width: number): string[] {
220
+ const padding = Array.from({ length: Math.max(0, height - lines.length) }, () => fillRow(" ", width, BG));
221
+ return [...padding, ...lines];
163
222
  }
164
223
 
165
- private renderMessage(index: number, width: number): string[] {
224
+ private renderMessage(index: number, width: number, maxRows = 10): string[] {
166
225
  const message = this.messages[index]!;
167
- const selected = index === this.selected;
226
+ const selected = message.id === this.selectedId;
168
227
  const background = selected ? BG_SEL : BG;
169
- const arrow = selected && this.focused ? `${FG_ACC}▸${RST}` : " ";
228
+ const arrow = selected && this.focused ? `${FG_ACC}›${RST}` : " ";
170
229
  const number = `${FG_FAINT}${String(message.index).padStart(2)}${RST}`;
171
230
  const time = `${FG_TIME}${formatTime(message.timestamp)}${RST}`;
172
- if (!this.expanded.has(index)) {
231
+ if (!this.expandedIds.has(message.id) || maxRows <= 1) {
173
232
  const prefix = ` ${arrow}${number} ${time} `;
174
233
  const text = truncateToWidth(message.text.replace(/\s+/g, " "), Math.max(0, width - visibleWidth(prefix)), "…");
175
234
  return [fillRow(`${prefix}${selected ? FG_BRIGHT : FG_NORM}${text}${RST}`, width, background)];
@@ -177,25 +236,22 @@ export class SidebarComponent {
177
236
 
178
237
  const lines = [fillRow(` ${arrow}${number} ${time}`, width, background)];
179
238
  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));
239
+ const contentRows = Math.max(0, maxRows - 2);
240
+ for (const line of wrapped.slice(0, contentRows)) {
241
+ lines.push(fillRow(` ${FG_EXP}${line}${RST}`, width, background));
242
+ }
243
+ if (wrapped.length > contentRows && lines.length < maxRows) {
244
+ lines.push(fillRow(` ${FG_DIM}${DIM}…+${wrapped.length - contentRows} lines${RST}`, width, background));
245
+ } else if (lines.length < maxRows) {
246
+ lines.push(fillRow(" ", width, background));
183
247
  }
184
- lines.push(fillRow(" ", width, background));
185
248
  return lines;
186
249
  }
187
250
 
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);
251
+ private selectedIndex(): number {
252
+ if (this.messages.length === 0) return -1;
253
+ const index = this.messages.findIndex((message) => message.id === this.selectedId);
254
+ return index >= 0 ? index : this.messages.length - 1;
199
255
  }
200
256
 
201
257
  private signature(width: number, height: number): string {
@@ -207,13 +263,12 @@ export class SidebarComponent {
207
263
  width,
208
264
  height,
209
265
  version: this.version,
210
- messages: this.messages.length,
211
266
  model: this.options.ctx.model?.id,
212
267
  thinking: this.options.getThinkingLevel(),
213
268
  usage,
214
269
  statuses: statuses ? [...statuses.entries()] : [],
215
270
  goal: goal ? `${goal.goalId}:${goal.status}:${goal.tokensUsed}:${goal.activeSeconds}:${goal.updatedAt}` : null,
216
- cmux: cmux ? `${cmux.workspaceTitle}:${cmux.surfaceRef}` : null,
271
+ cmux: cmux ? `${cmux.workspaceTitle}:${cmux.workspaceRef}:${cmux.surfaceRef}` : null,
217
272
  });
218
273
  }
219
274
 
@@ -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
6
  import { readSessionGoal, type ThreadGoal } from "./goal.ts";
8
7
  import type { CmuxContext } from "./cmux.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,9 +23,7 @@ import {
26
23
  formatCwd,
27
24
  formatDuration,
28
25
  formatTokens,
29
- progressBar,
30
26
  sanitizeStatusText,
31
- wrapText,
32
27
  } from "./style.ts";
33
28
 
34
29
  type Usage = {
@@ -42,13 +37,14 @@ type Usage = {
42
37
  contextWindow: number;
43
38
  };
44
39
 
45
- let usageCache: { key: string; usage: Usage } | null = null;
40
+ type UsageCache = { owner: object; key: string; usage: Usage };
41
+ let usageCache: UsageCache | null = null;
46
42
 
47
43
  function computeUsage(ctx: ExtensionContext): Usage {
48
44
  const entries = ctx.sessionManager.getEntries();
49
45
  const last = entries.at(-1);
50
46
  const key = `${entries.length}:${last?.id ?? ""}`;
51
- if (usageCache && usageCache.key === key) return usageCache.usage;
47
+ if (usageCache?.owner === ctx && usageCache.key === key) return usageCache.usage;
52
48
 
53
49
  let input = 0;
54
50
  let output = 0;
@@ -56,7 +52,6 @@ function computeUsage(ctx: ExtensionContext): Usage {
56
52
  let cacheWrite = 0;
57
53
  let cost = 0;
58
54
  let latestCacheHitRate: number | undefined;
59
-
60
55
  for (const entry of entries) {
61
56
  if (entry.type !== "message" || entry.message.role !== "assistant") continue;
62
57
  const usage = (entry.message as any).usage ?? {};
@@ -80,77 +75,71 @@ function computeUsage(ctx: ExtensionContext): Usage {
80
75
  contextPercent: context?.percent ?? null,
81
76
  contextWindow: context?.contextWindow ?? ctx.model?.contextWindow ?? 0,
82
77
  };
83
- usageCache = { key, usage };
78
+ usageCache = { owner: ctx, key, usage };
84
79
  return usage;
85
80
  }
86
81
 
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);
91
- }
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);
82
+ function row(width: number, label: string, value: string, background = BG): string {
83
+ const prefix = ` ${FG_FAINT}${label}${RST} `;
84
+ return fillRow(`${prefix}${truncateToWidth(value, Math.max(0, width - visibleWidth(prefix)), "…")}`, width, background);
99
85
  }
100
86
 
101
- function goalStatusGlyph(goal: ThreadGoal): { icon: string; color: string; label: string } {
87
+ function goalStatus(goal: ThreadGoal): { icon: string; color: string; label: string } {
102
88
  switch (goal.status) {
103
- case "active": return { icon: "", color: FG_ACC, label: "active" };
89
+ case "active": return { icon: "", color: FG_ACC, label: "active" };
104
90
  case "paused": return { icon: "○", color: FG_DIM, label: "paused" };
105
91
  case "budgetLimited": return { icon: "▲", color: FG_WARN, label: "budget" };
106
92
  case "complete": return { icon: "✓", color: FG_OK, label: "done" };
107
93
  }
108
94
  }
109
95
 
110
- function renderGoalCard(width: number, goal: ThreadGoal): string[] {
111
- const glyph = goalStatusGlyph(goal);
112
- const rows = [dockHeader(width, "goal")];
113
-
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));
120
-
96
+ function renderGoal(width: number, goal: ThreadGoal): string[] {
97
+ const status = goalStatus(goal);
98
+ const objective = truncateToWidth(goal.objective.replace(/\s+/g, " ").trim(), Math.max(0, width - 4), "");
121
99
  const budget = goal.tokenBudget
122
100
  ? `${formatTokens(goal.tokensUsed)}/${formatTokens(goal.tokenBudget)}`
123
101
  : `${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;
102
+ const overBudget = goal.tokenBudget !== null && goal.tokensUsed > goal.tokenBudget;
103
+ return [
104
+ row(width, `${status.color}${status.icon}${RST}`, `${FG_BRIGHT}${objective}${RST}`),
105
+ row(width, "goal", `${overBudget ? FG_ERR : status.color}${budget}${RST} ${FG_FAINT}·${RST} ${FG_MID}${status.label} ${formatDuration(goal.activeSeconds)}${RST}`),
106
+ ];
133
107
  }
134
108
 
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;
109
+ function renderContextRow(width: number, usage: Usage): string {
110
+ const percent = usage.contextPercent === null ? "?" : `${usage.contextPercent.toFixed(0)}%`;
111
+ const parts = [
112
+ `${contextColor(usage.contextPercent)}${percent}${RST}/${formatTokens(usage.contextWindow)}`,
113
+ usage.input ? `↑${formatTokens(usage.input)}` : undefined,
114
+ usage.output ? `↓${formatTokens(usage.output)}` : undefined,
115
+ usage.cacheRead ? `R${formatTokens(usage.cacheRead)}` : undefined,
116
+ usage.latestCacheHitRate !== undefined && (usage.cacheRead || usage.cacheWrite)
117
+ ? `CH${usage.latestCacheHitRate.toFixed(0)}%`
118
+ : undefined,
119
+ `$${usage.cost.toFixed(3)}`,
120
+ ].filter(Boolean).join(` ${FG_FAINT}·${RST} `);
121
+ return row(width, "ctx", `${FG_MID}${parts}${RST}`);
122
+ }
123
+
124
+ function renderWorkspace(width: number, ctx: ExtensionContext, footerData: ReadonlyFooterDataProvider | null): string {
125
+ const branch = footerData?.getGitBranch();
126
+ const sessionName = ctx.sessionManager.getSessionName();
127
+ const parts = [
128
+ `${FG_BRIGHT}${formatCwd(ctx.sessionManager.getCwd())}${RST}`,
129
+ branch ? `${FG_INFO}${branch}${RST}` : undefined,
130
+ sessionName ? `${FG_MID}${sessionName}${RST}` : undefined,
131
+ ].filter(Boolean).join(` ${FG_FAINT}·${RST} `);
132
+ return row(width, "cwd", parts);
133
+ }
134
+
135
+ function renderSession(width: number, ctx: ExtensionContext, cmux: CmuxContext | null): string {
139
136
  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} `);
137
+ const workspace = cmux.workspaceTitle ?? cmux.workspaceRef ?? "cmux";
138
+ const surface = cmux.surfaceRef ? ` ${FG_FAINT}· ${cmux.surfaceRef}${RST}` : "";
139
+ return row(width, "cmux", `${FG_BRIGHT}${workspace}${RST}${surface}`);
152
140
  }
153
- return dockRow(width, "sess", value);
141
+ const shortId = ctx.sessionManager.getSessionId().replace(/-/g, "").slice(-8);
142
+ return row(width, "sess", `${FG_INFO}#${shortId}${RST}`);
154
143
  }
155
144
 
156
145
  export function renderStatusDock(
@@ -160,67 +149,31 @@ export function renderStatusDock(
160
149
  thinkingLevel: string,
161
150
  focused: boolean,
162
151
  getCmuxContext: () => CmuxContext | null,
152
+ maxRows = Number.MAX_SAFE_INTEGER,
163
153
  ): string[] {
154
+ if (maxRows <= 0) return [];
155
+ const rowLimit = Math.max(1, Math.floor(maxRows));
164
156
  const usage = computeUsage(ctx);
165
157
  const goal = readSessionGoal(ctx);
166
158
  const rows: string[] = [];
167
-
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()));
159
+ if (goal) rows.push(...renderGoal(width, goal));
160
+ rows.push(renderWorkspace(width, ctx, footerData));
161
+ rows.push(renderSession(width, ctx, getCmuxContext()));
180
162
 
181
163
  const model = ctx.model;
182
164
  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}`));
165
+ const provider = footerData && footerData.getAvailableProviderCount() > 1 ? `${model.provider}/` : "";
166
+ const thinking = model.reasoning ? ` ${FG_FAINT}·${RST} ${FG_MID}${thinkingLevel}${RST}` : "";
167
+ rows.push(row(width, "model", `${FG_BRIGHT}${provider}${model.id}${RST}${thinking}`));
188
168
  }
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
- ));
169
+ rows.push(renderContextRow(width, usage));
214
170
 
215
171
  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}`));
218
- }
172
+ if (statuses[0]) rows.push(row(width, "stat", `${FG_INFO}•${RST} ${FG_MID}${sanitizeStatusText(statuses[0][1])}${RST}`));
219
173
 
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;
174
+ const hint = focused ? "↑↓ navigate Enter expand c copy Esc done" : "Ctrl+Shift+H focus";
175
+ rows.push(fillRow(` ${FG_DIM}${hint}${RST}`, width, BG_HDR));
176
+ if (rows.length <= rowLimit) return rows;
177
+ if (rowLimit === 1) return [rows.at(-1)!];
178
+ return [...rows.slice(0, rowLimit - 1), rows.at(-1)!];
226
179
  }