pi-message-sidebar 1.4.0 → 1.6.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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0
4
+
5
+ > Readability pass driven by a live populated PTY audit: less cropping, more signal per row.
6
+
7
+ - Collapsed message previews now use up to two wrapped lines instead of one clipped line.
8
+ - Single header row: `Messages 5/5 · #5 01:26` replaces the two-row header.
9
+ - Goal recap shows the objective on its own line(s) again, with a second line when dock space allows.
10
+ - cmux row drops the label and leads with the surface ref (`surface:38 · title`), so operational identity never truncates away.
11
+ - Status dock is priority-ordered (identity, goal status, objective, runtime, hint) and degrades to goal-status-only before dropping the goal.
12
+ - Dock budget raised to five rows at normal heights; tiny-height behavior unchanged.
13
+
14
+
15
+ ## 1.5.0
16
+
17
+ - Replaced selected/newest pinning with a row-aware contiguous chronological viewport.
18
+ - Top-aligned short histories and preserved selection, expansion, viewport anchor, and follow-tail state by message ID.
19
+ - Reduced the header to two compact rows and the normal status dock to at most four rows.
20
+ - Removed collapsed-row ordinal and timestamp columns; metadata now appears only on expanded rows.
21
+ - Preserved complete cmux surface refs by truncating workspace titles first.
22
+ - Corrected tiny-height allocation so a message remains visible whenever one row is available.
23
+ - 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.
24
+ - Clarified that fullscreen is persistent while regular mode is a current-screen compositor under terminal-owned scrollback.
25
+
3
26
  ## 1.2.0
4
27
 
5
28
  > 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.4.0",
3
+ "version": "1.6.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,106 +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
- type GoalEntrySource = "command" | "tool" | "runtime";
19
-
20
20
  function isGoalEntrySource(value: unknown): value is GoalEntrySource {
21
21
  return value === "command" || value === "tool" || value === "runtime";
22
22
  }
23
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;
28
- return (
29
- typeof goal.goalId === "string" &&
30
- typeof goal.objective === "string" &&
31
- (goal.status === "active" || goal.status === "paused" || goal.status === "budgetLimited" || goal.status === "complete") &&
32
- (goal.tokenBudget === null || typeof goal.tokenBudget === "number") &&
33
- typeof goal.createdAt === "number" &&
34
- typeof goal.updatedAt === "number" &&
35
- typeof usage?.tokensUsed === "number" &&
36
- typeof usage.activeSeconds === "number"
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)
37
44
  );
38
45
  }
39
46
 
40
- type RawGoal = Omit<ThreadGoal, "tokensUsed" | "activeSeconds"> & {
41
- usage: { tokensUsed: number; activeSeconds: number };
42
- };
47
+ function cloneGoal(goal: ThreadGoal): ThreadGoal {
48
+ return { ...goal, usage: { ...goal.usage } };
49
+ }
43
50
 
44
- /**
45
- * Reconstructs the current thread goal from pi-codex-goal session entries.
46
- * Mirrors reconstructGoal() from pi-codex-goal dist/state.js (schema version 1):
47
- * "set" entries replace the goal, "clear" entries drop it, and "usage" entries
48
- * advance tokens/time for runtime-usage statuses only.
49
- */
51
+ /** Mirrors pi-codex-goal's schema-v1 reconstruction contract. */
50
52
  export function readThreadGoal(entries: Iterable<BranchEntry>): ThreadGoal | null {
51
53
  let goal: ThreadGoal | null = null;
52
54
  for (const entry of entries) {
53
55
  if (entry.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
54
56
  const data = entry.data as Record<string, unknown> | null;
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")) {
57
+ if (!data || typeof data !== "object" || data.version !== 1 || !isFiniteNonNegative(data.at)) continue;
58
+
59
+ if (data.kind === "clear") {
60
+ if (!isGoalEntrySource(data.source) || (data.clearedGoalId !== null && typeof data.clearedGoalId !== "string")) continue;
57
61
  goal = null;
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) {
71
- const usage = data.usage as { tokensUsed?: number; activeSeconds?: number } | undefined;
72
- const updatedAt = data.updatedAt;
73
- const status = data.status;
74
- if (data.goalId !== goal.goalId) continue;
75
- if (status !== "active" && status !== "budgetLimited") continue;
76
- if (goal.status !== "active" && goal.status !== "budgetLimited") continue;
77
- if (goal.status === "budgetLimited" && status === "active") continue;
78
- if (typeof updatedAt !== "number" || typeof usage?.tokensUsed !== "number" || typeof usage.activeSeconds !== "number") continue;
79
- if (updatedAt < goal.updatedAt || usage.tokensUsed < goal.tokensUsed || usage.activeSeconds < goal.activeSeconds) continue;
80
- const current: ThreadGoal = goal;
81
- goal = {
82
- ...current,
83
- status,
84
- tokensUsed: usage.tokensUsed,
85
- activeSeconds: usage.activeSeconds,
86
- updatedAt,
87
- };
62
+ continue;
63
+ }
64
+ if (data.kind === "set") {
65
+ if (!isGoalEntrySource(data.source) || !isThreadGoal(data.goal)) continue;
66
+ goal = cloneGoal(data.goal);
67
+ continue;
88
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 };
89
79
  }
90
80
  return goal;
91
81
  }
92
82
 
93
- type GoalCache = { branch: object; key: string; goal: ThreadGoal | null };
94
- const cache = new WeakMap<ExtensionContext, GoalCache>();
83
+ type GoalCache = { key: string; goal: ThreadGoal | null };
84
+ const caches = new WeakMap<ExtensionContext, GoalCache>();
95
85
 
96
- /** Cached readThreadGoal over the current session branch. */
86
+ /** Cached reconstruction scoped to each extension context. */
97
87
  export function readSessionGoal(ctx: ExtensionContext): ThreadGoal | null {
98
88
  const branch = ctx.sessionManager.getBranch();
99
89
  const last = branch.at(-1);
100
90
  const key = `${branch.length}:${last?.id ?? ""}:${last?.timestamp ?? ""}`;
101
- const previous = cache.get(ctx);
102
- if (previous?.branch === branch && previous.key === key) return previous.goal;
91
+ const previous = caches.get(ctx);
92
+ if (previous?.key === key) return previous.goal;
103
93
  const goal = readThreadGoal(branch);
104
- cache.set(ctx, { branch, key, goal });
94
+ caches.set(ctx, { key, goal });
105
95
  return goal;
106
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 { 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,8 +42,6 @@ type SidebarOptions = {
42
42
  messages: UserMessage[];
43
43
  };
44
44
 
45
- type MessageRows = { index: number; lines: string[] };
46
-
47
45
  export class SidebarComponent implements Component {
48
46
  private focused = false;
49
47
  private selectedId: string | null;
@@ -54,6 +52,7 @@ export class SidebarComponent implements Component {
54
52
  private restoreFocus: Component | null = null;
55
53
  private cachedSignature = "";
56
54
  private cachedLines: string[] = [];
55
+ private viewportStartId: string | null = null;
57
56
 
58
57
  constructor(private readonly options: SidebarOptions) {
59
58
  this.messages = options.messages;
@@ -73,9 +72,7 @@ export class SidebarComponent implements Component {
73
72
  this.options.tui.setFocus(this);
74
73
  } else {
75
74
  this.focused = false;
76
- if ((this.options.tui as any).getFocusedComponent?.() === this) {
77
- this.options.tui.setFocus(this.restoreFocus);
78
- }
75
+ if ((this.options.tui as any).getFocusedComponent?.() === this) this.options.tui.setFocus(this.restoreFocus);
79
76
  this.restoreFocus = null;
80
77
  }
81
78
  this.refresh();
@@ -83,13 +80,16 @@ export class SidebarComponent implements Component {
83
80
 
84
81
  updateMessages(messages: UserMessage[]): void {
85
82
  const previousId = this.selectedId;
83
+ const previousStartId = this.viewportStartId;
86
84
  this.messages = messages;
87
85
  const ids = new Set(messages.map((message) => message.id));
88
86
  for (const id of this.expandedIds) if (!ids.has(id)) this.expandedIds.delete(id);
87
+ this.viewportStartId = previousStartId && ids.has(previousStartId) ? previousStartId : null;
89
88
 
90
89
  if (this.followTail || !previousId || !ids.has(previousId)) {
91
90
  this.selectedId = messages.at(-1)?.id ?? null;
92
91
  this.followTail = true;
92
+ this.viewportStartId = null;
93
93
  } else {
94
94
  this.selectedId = previousId;
95
95
  }
@@ -123,6 +123,7 @@ export class SidebarComponent implements Component {
123
123
  if (target < 0 || !this.messages[target]) return;
124
124
  this.selectedId = this.messages[target].id;
125
125
  this.followTail = target === this.messages.length - 1;
126
+ if (this.followTail) this.viewportStartId = null;
126
127
  this.refresh();
127
128
  }
128
129
 
@@ -132,9 +133,12 @@ export class SidebarComponent implements Component {
132
133
  const signature = this.signature(safeWidth, targetHeight);
133
134
  if (signature === this.cachedSignature) return this.cachedLines;
134
135
 
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);
136
+ const fullHeader = this.renderHeader(safeWidth);
137
+ const headerRows = this.messages.length > 0 && targetHeight === 1 ? 0 : 1;
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;
138
142
  const dock = renderStatusDock(
139
143
  safeWidth,
140
144
  this.options.ctx,
@@ -142,14 +146,12 @@ export class SidebarComponent implements Component {
142
146
  this.options.getThinkingLevel(),
143
147
  this.focused,
144
148
  this.options.getCmuxContext,
145
- maxDockRows,
149
+ Math.min(5, Math.max(0, available - messageReserve)),
146
150
  );
147
151
  const bodyHeight = Math.max(0, targetHeight - header.length - dock.length);
148
152
  const result = [...header, ...this.renderBody(safeWidth, bodyHeight), ...dock];
149
153
  assertLinesFit(result, safeWidth, "sidebar");
150
- if (result.length !== targetHeight) {
151
- throw new Error(`sidebar height mismatch (${result.length} != ${targetHeight})`);
152
- }
154
+ if (result.length !== targetHeight) throw new Error(`sidebar height mismatch (${result.length} != ${targetHeight})`);
153
155
  this.cachedSignature = signature;
154
156
  this.cachedLines = result;
155
157
  return result;
@@ -161,99 +163,118 @@ export class SidebarComponent implements Component {
161
163
  }
162
164
 
163
165
  private renderHeader(width: number): string[] {
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}`;
166
+ const position = this.selectedIndex() + 1;
167
+ const count = this.messages.length;
168
+ const selected = this.messages[this.selectedIndex()];
169
+ const detail = selected ? ` ${FG_FAINT}·${RST} ${FG_DIM}#${selected.index} ${formatTime(selected.timestamp)}${RST}` : "";
166
170
  return [
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),
171
+ fillRow(` ${BOLD}${FG_BRIGHT}Messages${RST} ${FG_FAINT}${count > 0 ? `${position}/${count}` : "0"}${RST}${detail}`, width, BG_HDR),
169
172
  ];
170
173
  }
171
174
 
172
175
  private renderBody(width: number, height: number): string[] {
173
176
  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
+ if (this.messages.length === 0) return this.padBottom([fillRow(` ${FG_DIM}No messages yet${RST}`, width, BG)], height, width);
177
178
 
178
179
  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) });
180
+ let start = this.followTail ? this.tailStart(width, height) : this.startForSelection(width, height, selected);
181
+ const preserved = this.indexForId(this.viewportStartId);
182
+ if (!this.followTail && preserved >= 0 && selected >= preserved && this.rangeFitsSelection(width, height, preserved, selected)) start = preserved;
183
+
184
+ const lines: string[] = [];
185
+ for (let index = start; index < this.messages.length && lines.length < height; index++) {
186
+ const remaining = height - lines.length;
187
+ lines.push(...this.renderMessage(index, width, remaining));
185
188
  }
189
+ this.viewportStartId = this.messages[start]?.id ?? null;
190
+ return this.padBottom(lines.slice(0, height), height, width);
191
+ }
186
192
 
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);
193
+ private tailStart(width: number, height: number): number {
194
+ let start = this.messages.length - 1;
195
+ let rows = this.messageRowCount(start, width, height);
196
+ while (start > 0) {
197
+ const next = this.messageRowCount(start - 1, width, height);
198
+ if (rows + next > height) break;
199
+ rows += next;
200
+ start--;
198
201
  }
202
+ return start;
203
+ }
199
204
 
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);
205
+ private startForSelection(width: number, height: number, selected: number): number {
206
+ let start = selected;
207
+ let rows = this.messageRowCount(selected, width, height);
208
+ while (start > 0) {
209
+ const next = this.messageRowCount(start - 1, width, height);
210
+ if (rows + next > height) break;
211
+ rows += next;
212
+ start--;
213
+ }
214
+ return start;
203
215
  }
204
216
 
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[] = [];
208
- let previous = -1;
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));
212
- }
213
- lines.push(...group.lines);
214
- previous = group.index;
217
+ private rangeFitsSelection(width: number, height: number, start: number, selected: number): boolean {
218
+ let rows = 0;
219
+ for (let index = start; index <= selected; index++) {
220
+ rows += this.messageRowCount(index, width, height);
221
+ if (rows > height) return false;
215
222
  }
216
- return lines;
223
+ return true;
217
224
  }
218
225
 
219
- private padTop(lines: string[], height: number, width: number): string[] {
226
+ private messageRowCount(index: number, width: number, maxRows: number): number {
227
+ return this.renderMessage(index, width, maxRows).length;
228
+ }
229
+
230
+ private padBottom(lines: string[], height: number, width: number): string[] {
220
231
  const padding = Array.from({ length: Math.max(0, height - lines.length) }, () => fillRow(" ", width, BG));
221
- return [...padding, ...lines];
232
+ return [...lines, ...padding];
222
233
  }
223
234
 
224
- private renderMessage(index: number, width: number, maxRows = 10): string[] {
235
+ private renderMessage(index: number, width: number, maxRows = Number.MAX_SAFE_INTEGER): string[] {
225
236
  const message = this.messages[index]!;
226
237
  const selected = message.id === this.selectedId;
227
238
  const background = selected ? BG_SEL : BG;
228
239
  const arrow = selected && this.focused ? `${FG_ACC}›${RST}` : " ";
229
- const number = `${FG_FAINT}${String(message.index).padStart(2)}${RST}`;
230
- const time = `${FG_TIME}${formatTime(message.timestamp)}${RST}`;
231
- if (!this.expandedIds.has(message.id) || maxRows <= 1) {
232
- const prefix = ` ${arrow}${number} ${time} `;
233
- const text = truncateToWidth(message.text.replace(/\s+/g, " "), Math.max(0, width - visibleWidth(prefix)), "…");
234
- return [fillRow(`${prefix}${selected ? FG_BRIGHT : FG_NORM}${text}${RST}`, width, background)];
240
+ if (!this.expandedIds.has(message.id)) {
241
+ // Collapsed preview: up to two wrapped lines so real prompts stay readable.
242
+ const prefix = ` ${arrow} `;
243
+ const textWidth = Math.max(1, width - visibleWidth(prefix));
244
+ const wrapped = wrapText(message.text, textWidth);
245
+ const rows = Math.max(1, Math.min(2, maxRows));
246
+ const shown = wrapped.slice(0, rows);
247
+ if (wrapped.length > shown.length && shown.length > 0) {
248
+ const overflow = shown[shown.length - 1]!;
249
+ shown[shown.length - 1] = truncateToWidth(overflow, Math.max(1, textWidth - 1), "") + "…";
250
+ }
251
+ return shown.map((line, position) =>
252
+ fillRow(`${position === 0 ? prefix : " "}${selected ? FG_BRIGHT : FG_NORM}${line}${RST}`, width, background),
253
+ );
235
254
  }
236
255
 
237
- const lines = [fillRow(` ${arrow}${number} ${time}`, width, background)];
256
+ const number = `${FG_FAINT}#${message.index}${RST}`;
257
+ const time = `${FG_TIME}${formatTime(message.timestamp)}${RST}`;
258
+ const lines = [fillRow(` ${arrow} ${number} ${time}`, width, background)];
238
259
  const wrapped = wrapText(message.text, Math.max(1, width - 4));
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));
260
+ const contentRows = Math.max(0, maxRows - 1);
261
+ for (const line of wrapped.slice(0, contentRows)) lines.push(fillRow(` ${FG_EXP}${line}${RST}`, width, background));
262
+ if (wrapped.length > contentRows && lines.length === maxRows) {
263
+ lines[lines.length - 1] = fillRow(` ${FG_DIM}${DIM}…+${wrapped.length - contentRows + 1} lines${RST}`, width, background);
247
264
  }
248
265
  return lines;
249
266
  }
250
267
 
251
268
  private selectedIndex(): number {
252
269
  if (this.messages.length === 0) return -1;
253
- const index = this.messages.findIndex((message) => message.id === this.selectedId);
270
+ const index = this.indexForId(this.selectedId);
254
271
  return index >= 0 ? index : this.messages.length - 1;
255
272
  }
256
273
 
274
+ private indexForId(id: string | null): number {
275
+ return id ? this.messages.findIndex((message) => message.id === id) : -1;
276
+ }
277
+
257
278
  private signature(width: number, height: number): string {
258
279
  const usage = this.options.ctx.getContextUsage?.();
259
280
  const statuses = this.options.getFooterData()?.getExtensionStatuses();
@@ -266,8 +287,8 @@ export class SidebarComponent implements Component {
266
287
  model: this.options.ctx.model?.id,
267
288
  thinking: this.options.getThinkingLevel(),
268
289
  usage,
269
- statuses: statuses ? [...statuses.entries()] : [],
270
- goal: goal ? `${goal.goalId}:${goal.status}:${goal.tokensUsed}:${goal.activeSeconds}:${goal.updatedAt}` : null,
290
+ statuses: statuses && typeof (statuses as any).entries === "function" ? [...statuses.entries()] : [],
291
+ goal: goal ? `${goal.goalId}:${goal.status}:${goal.usage.tokensUsed}:${goal.usage.activeSeconds}:${goal.updatedAt}` : null,
271
292
  cmux: cmux ? `${cmux.workspaceTitle}:${cmux.workspaceRef}:${cmux.surfaceRef}` : null,
272
293
  });
273
294
  }
@@ -3,8 +3,8 @@ import type {
3
3
  ReadonlyFooterDataProvider,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
- import { readSessionGoal, type ThreadGoal } from "./goal.ts";
7
6
  import type { CmuxContext } from "./cmux.ts";
7
+ import { readSessionGoal, type ThreadGoal } from "./goal.ts";
8
8
  import {
9
9
  BG,
10
10
  BG_HDR,
@@ -24,59 +24,65 @@ import {
24
24
  formatDuration,
25
25
  formatTokens,
26
26
  sanitizeStatusText,
27
+ wrapText,
27
28
  } from "./style.ts";
28
29
 
29
- type Usage = {
30
+ type UsageTotals = {
30
31
  input: number;
31
32
  output: number;
32
33
  cacheRead: number;
33
34
  cacheWrite: number;
34
35
  cost: number;
35
36
  latestCacheHitRate?: number;
37
+ };
38
+
39
+ type Usage = UsageTotals & {
36
40
  contextPercent: number | null;
37
41
  contextWindow: number;
38
42
  };
39
43
 
40
- type UsageCache = { owner: object; key: string; usage: Usage };
41
- let usageCache: UsageCache | null = null;
44
+ type UsageCache = { key: string; totals: UsageTotals };
45
+ const usageCaches = new WeakMap<ExtensionContext, UsageCache>();
42
46
 
43
- 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 {
44
49
  const entries = ctx.sessionManager.getEntries();
45
50
  const last = entries.at(-1);
46
- const key = `${entries.length}:${last?.id ?? ""}`;
47
- if (usageCache?.owner === ctx && usageCache.key === key) return usageCache.usage;
48
-
49
- let input = 0;
50
- let output = 0;
51
- let cacheRead = 0;
52
- let cacheWrite = 0;
53
- let cost = 0;
54
- let latestCacheHitRate: number | undefined;
55
- for (const entry of entries) {
56
- if (entry.type !== "message" || entry.message.role !== "assistant") continue;
57
- const usage = (entry.message as any).usage ?? {};
58
- input += usage.input ?? 0;
59
- output += usage.output ?? 0;
60
- cacheRead += usage.cacheRead ?? 0;
61
- cacheWrite += usage.cacheWrite ?? 0;
62
- cost += usage.cost?.total ?? 0;
63
- const prompt = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
64
- 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);
65
74
  }
66
75
 
67
76
  const context = ctx.getContextUsage?.();
68
- const usage = {
69
- input,
70
- output,
71
- cacheRead,
72
- cacheWrite,
73
- cost,
74
- latestCacheHitRate,
75
- contextPercent: context?.percent ?? null,
76
- 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,
77
85
  };
78
- usageCache = { owner: ctx, key, usage };
79
- return usage;
80
86
  }
81
87
 
82
88
  function row(width: number, label: string, value: string, background = BG): string {
@@ -93,32 +99,17 @@ function goalStatus(goal: ThreadGoal): { icon: string; color: string; label: str
93
99
  }
94
100
  }
95
101
 
96
- function renderGoal(width: number, goal: ThreadGoal): string[] {
102
+ function renderGoal(width: number, goal: ThreadGoal, objectiveLines: number): string[] {
97
103
  const status = goalStatus(goal);
98
- const objective = truncateToWidth(goal.objective.replace(/\s+/g, " ").trim(), Math.max(0, width - 4), "…");
99
104
  const budget = goal.tokenBudget
100
- ? `${formatTokens(goal.tokensUsed)}/${formatTokens(goal.tokenBudget)}`
101
- : `${formatTokens(goal.tokensUsed)}/∞`;
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
- ];
107
- }
108
-
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}`);
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
+ for (const line of objective.slice(0, Math.max(0, objectiveLines))) lines.push(fillRow(` ${FG_BRIGHT}${line}${RST}`, width, BG));
112
+ return lines;
122
113
  }
123
114
 
124
115
  function renderWorkspace(width: number, ctx: ExtensionContext, footerData: ReadonlyFooterDataProvider | null): string {
@@ -132,14 +123,37 @@ function renderWorkspace(width: number, ctx: ExtensionContext, footerData: Reado
132
123
  return row(width, "cwd", parts);
133
124
  }
134
125
 
135
- function renderSession(width: number, ctx: ExtensionContext, cmux: CmuxContext | null): string {
136
- if (cmux) {
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}`);
126
+ function renderCmux(width: number, cmux: CmuxContext): string {
127
+ // Operational identity first: the surface ref never truncates away.
128
+ const surface = cmux.surfaceRef ?? "";
129
+ const separator = surface ? ` ${FG_FAINT}·${RST} ` : "";
130
+ const titleText = cmux.workspaceTitle ?? cmux.workspaceRef ?? "";
131
+ const titleBudget = Math.max(0, width - 2 - visibleWidth(surface) - visibleWidth(separator));
132
+ const title = truncateToWidth(titleText, titleBudget, "…");
133
+ return fillRow(` ${FG_INFO}${surface}${RST}${separator}${FG_BRIGHT}${title}${RST}`, width, BG);
134
+ }
135
+
136
+ function renderRuntime(width: number, ctx: ExtensionContext, footerData: ReadonlyFooterDataProvider | null, thinkingLevel: string, usage: Usage): string | null {
137
+ const model = ctx.model;
138
+ if (!model) return null;
139
+ const provider = footerData && footerData.getAvailableProviderCount() > 1 ? `${model.provider}/` : "";
140
+ const thinking = model.reasoning ? ` ${FG_FAINT}·${RST} ${FG_MID}${thinkingLevel}${RST}` : "";
141
+ const percent = usage.contextPercent === null ? "?" : `${usage.contextPercent.toFixed(0)}%`;
142
+ const context = `${contextColor(usage.contextPercent)}${percent}/${formatTokens(usage.contextWindow)}${RST}`;
143
+ return row(width, "run", `${FG_BRIGHT}${provider}${model.id}${RST}${thinking} ${FG_FAINT}·${RST} ${context}`);
144
+ }
145
+
146
+ export function validExtensionStatuses(footerData: ReadonlyFooterDataProvider | null): string[] {
147
+ if (!footerData) return [];
148
+ const statuses = footerData.getExtensionStatuses();
149
+ if (!(statuses && typeof (statuses as any).entries === "function")) return [];
150
+ const valid: [string, string][] = [];
151
+ for (const entry of statuses.entries()) {
152
+ if (!Array.isArray(entry) || typeof entry[0] !== "string" || typeof entry[1] !== "string") continue;
153
+ const text = sanitizeStatusText(entry[1]);
154
+ if (text) valid.push([entry[0], text]);
140
155
  }
141
- const shortId = ctx.sessionManager.getSessionId().replace(/-/g, "").slice(-8);
142
- return row(width, "sess", `${FG_INFO}#${shortId}${RST}`);
156
+ return valid.sort(([a], [b]) => a.localeCompare(b)).map(([, text]) => text);
143
157
  }
144
158
 
145
159
  export function renderStatusDock(
@@ -149,31 +163,46 @@ export function renderStatusDock(
149
163
  thinkingLevel: string,
150
164
  focused: boolean,
151
165
  getCmuxContext: () => CmuxContext | null,
152
- maxRows = Number.MAX_SAFE_INTEGER,
166
+ maxRows = 5,
153
167
  ): string[] {
154
- if (maxRows <= 0) return [];
155
- const rowLimit = Math.max(1, Math.floor(maxRows));
168
+ const limit = Math.max(0, Math.min(5, Math.floor(maxRows)));
169
+ if (limit === 0) return [];
170
+ const hint = fillRow(
171
+ ` ${FG_DIM}${focused ? "↑↓ move Enter open c copy Esc close" : "Ctrl+Shift+H focus"}${RST}`,
172
+ width,
173
+ BG_HDR,
174
+ );
175
+ if (limit === 1) return [hint];
176
+
156
177
  const usage = computeUsage(ctx);
157
178
  const goal = readSessionGoal(ctx);
158
- const rows: string[] = [];
159
- if (goal) rows.push(...renderGoal(width, goal));
160
- rows.push(renderWorkspace(width, ctx, footerData));
161
- rows.push(renderSession(width, ctx, getCmuxContext()));
179
+ const cmux = getCmuxContext();
162
180
 
163
- const model = ctx.model;
164
- if (model) {
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}`));
181
+ // Priority order: identity, goal status, first objective line, runtime, then
182
+ // the second objective line. Truncation drops the least important rows last.
183
+ const rows: string[] = [];
184
+ const push = (line: string | string[] | null): boolean => {
185
+ if (!line) return true;
186
+ const added = Array.isArray(line) ? line : [line];
187
+ if (rows.length + added.length > limit - 1) return false;
188
+ rows.push(...added);
189
+ return true;
190
+ };
191
+ if (cmux) push(renderCmux(width, cmux));
192
+ else if (!goal) push(renderWorkspace(width, ctx, footerData));
193
+ if (goal) {
194
+ if (!push(renderGoal(width, goal, 1)) && rows.length + 1 <= limit - 1) {
195
+ // Degrade to goal status without the objective line before dropping it entirely.
196
+ rows.push(...renderGoal(width, goal, 0));
197
+ }
198
+ const runtime = renderRuntime(width, ctx, footerData, thinkingLevel, usage);
199
+ if (!push(runtime)) return [...rows.slice(0, limit - 1), hint];
200
+ push(renderGoal(width, goal, 2).slice(2));
201
+ } else {
202
+ push(renderRuntime(width, ctx, footerData, thinkingLevel, usage));
203
+ const status = validExtensionStatuses(footerData)[0];
204
+ if (status) push(row(width, "stat", `${FG_MID}${status}${RST}`));
168
205
  }
169
- rows.push(renderContextRow(width, usage));
170
-
171
- const statuses = footerData ? [...footerData.getExtensionStatuses().entries()].sort(([a], [b]) => a.localeCompare(b)) : [];
172
- if (statuses[0]) rows.push(row(width, "stat", `${FG_INFO}•${RST} ${FG_MID}${sanitizeStatusText(statuses[0][1])}${RST}`));
173
206
 
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)!];
207
+ return [...rows.slice(0, limit - 1), hint];
179
208
  }