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.
- package/message-sidebar.ts +2 -2
- package/package.json +1 -1
- package/src/goal.ts +48 -18
- package/src/sidebar-component.ts +116 -61
- package/src/status-dock.ts +65 -112
package/message-sidebar.ts
CHANGED
|
@@ -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
|
|
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
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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" &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
+
cache.set(ctx, { branch, key, goal });
|
|
75
105
|
return goal;
|
|
76
106
|
}
|
package/src/sidebar-component.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
45
|
+
type MessageRows = { index: number; lines: string[] };
|
|
46
|
+
|
|
47
|
+
export class SidebarComponent implements Component {
|
|
46
48
|
private focused = false;
|
|
47
|
-
private
|
|
48
|
-
private
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
else if (matchesKey(data, "
|
|
96
|
-
else if (matchesKey(data, "
|
|
97
|
-
else if (matchesKey(data, "
|
|
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.
|
|
100
|
-
|
|
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(
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
127
|
-
const
|
|
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
|
|
142
|
-
const
|
|
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(
|
|
146
|
-
fillRow(` ${
|
|
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
|
|
153
|
-
|
|
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
|
|
156
|
-
if (
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
previous = index;
|
|
213
|
+
lines.push(...group.lines);
|
|
214
|
+
previous = group.index;
|
|
161
215
|
}
|
|
162
|
-
return
|
|
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 =
|
|
226
|
+
const selected = message.id === this.selectedId;
|
|
168
227
|
const background = selected ? BG_SEL : BG;
|
|
169
|
-
const arrow = selected && this.focused ? `${FG_ACC}
|
|
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.
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
lines.push(fillRow(` ${
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
package/src/status-dock.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
88
|
-
const
|
|
89
|
-
|
|
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
|
|
87
|
+
function goalStatus(goal: ThreadGoal): { icon: string; color: string; label: string } {
|
|
102
88
|
switch (goal.status) {
|
|
103
|
-
case "active": return { icon: "
|
|
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
|
|
111
|
-
const
|
|
112
|
-
const
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
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
|
|
141
|
-
const
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
rows.
|
|
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
|
}
|