pi-message-sidebar 1.1.0 → 1.2.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 +15 -0
- package/README.md +5 -1
- package/message-sidebar.ts +13 -0
- package/package.json +18 -4
- package/src/cmux.ts +75 -0
- package/src/goal.ts +76 -0
- package/src/sidebar-component.ts +9 -1
- package/src/status-dock.ts +83 -11
- package/src/style.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
> Live goal tracking and cmux session context in the status dock.
|
|
6
|
+
|
|
7
|
+
- Added a goal card to the status dock: when pi-codex-goal is active, the dock shows the objective (two wrapped lines), status glyph, token budget progress, and elapsed time, kept current across goal set/usage/clear entries.
|
|
8
|
+
- Added cmux awareness: inside cmux, the `sess` row shows the workspace title (or ref) and surface ref instead of the session file datetime; outside cmux the previous format is kept.
|
|
9
|
+
- Memoized session usage aggregation and goal reconstruction so dock refreshes stay O(1) for unchanged sessions.
|
|
10
|
+
- Added refresh triggers for `agent_settled` so goal continuation updates surface without a turn boundary.
|
|
11
|
+
- Aligned the header rule with the panel width.
|
|
12
|
+
|
|
13
|
+
## 1.1.1
|
|
14
|
+
|
|
15
|
+
> First release published from GitHub Actions with npm trusted publishing (OIDC); no tokens involved.
|
|
16
|
+
|
|
17
|
+
|
|
3
18
|
## 1.1.0
|
|
4
19
|
|
|
5
20
|
> Pi 0.84.4: transcript overlap during TUI mode switches and narrow-pane width failures are resolved by mode-specific reserved layouts.
|
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@ 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
10
|
- Main transcript and editor render in their own reserved width
|
|
9
11
|
- Native side-by-side layout in fullscreen TUI mode
|
|
10
12
|
- Regular-mode compositor that reserves the same width in scrollback mode
|
|
@@ -55,7 +57,9 @@ The sidebar appears automatically in interactive mode when the terminal is at le
|
|
|
55
57
|
- `index.ts` is the auto-discovered extension entrypoint.
|
|
56
58
|
- `src/layout.ts` reserves a real horizontal region in fullscreen mode and composes an equivalent region in regular mode.
|
|
57
59
|
- `src/sidebar-component.ts` owns message navigation and bounded rendering.
|
|
58
|
-
- `src/status-dock.ts` renders session, model, context, cost, and extension status data.
|
|
60
|
+
- `src/status-dock.ts` renders session, model, context, cost, goal, and extension status data.
|
|
61
|
+
- `src/goal.ts` reconstructs the active pi-codex-goal from session entries.
|
|
62
|
+
- `src/cmux.ts` resolves the cmux workspace title and surface ref once per session.
|
|
59
63
|
- `src/style.ts` provides ANSI-safe row filling and width helpers.
|
|
60
64
|
- `src/constants.ts` owns responsive layout thresholds.
|
|
61
65
|
|
package/message-sidebar.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { TUI } from "@earendil-works/pi-tui";
|
|
7
7
|
import { isViewportTUI, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { CmuxContext } from "./src/cmux.ts";
|
|
9
|
+
import { resolveCmuxContext } from "./src/cmux.ts";
|
|
8
10
|
import { isSidebarVisible } from "./src/constants.ts";
|
|
9
11
|
import { SidebarLayoutBridge } from "./src/layout.ts";
|
|
10
12
|
import { SidebarComponent, type UserMessage } from "./src/sidebar-component.ts";
|
|
@@ -61,6 +63,7 @@ export default function messageSidebar(pi: ExtensionAPI): void {
|
|
|
61
63
|
let tui: TUI | null = null;
|
|
62
64
|
let cachedContext: ExtensionContext | null = null;
|
|
63
65
|
let footerData: ReadonlyFooterDataProvider | null = null;
|
|
66
|
+
let cmuxContext: CmuxContext | null = null;
|
|
64
67
|
let refreshQueued = false;
|
|
65
68
|
|
|
66
69
|
const scheduleRefresh = (ctx: ExtensionContext | null = cachedContext) => {
|
|
@@ -91,6 +94,10 @@ export default function messageSidebar(pi: ExtensionAPI): void {
|
|
|
91
94
|
pi.on("session_start", (_event, ctx) => {
|
|
92
95
|
if (ctx.mode !== "tui") return;
|
|
93
96
|
cachedContext = ctx;
|
|
97
|
+
void resolveCmuxContext().then((resolved) => {
|
|
98
|
+
cmuxContext = resolved;
|
|
99
|
+
scheduleRefresh(ctx);
|
|
100
|
+
});
|
|
94
101
|
ctx.ui.setWidget(
|
|
95
102
|
"message-sidebar-layout",
|
|
96
103
|
(currentTui) => {
|
|
@@ -100,6 +107,7 @@ export default function messageSidebar(pi: ExtensionAPI): void {
|
|
|
100
107
|
ctx,
|
|
101
108
|
getFooterData: () => footerData,
|
|
102
109
|
getThinkingLevel: () => pi.getThinkingLevel(),
|
|
110
|
+
getCmuxContext: () => cmuxContext,
|
|
103
111
|
messages: collectUserMessages(ctx),
|
|
104
112
|
});
|
|
105
113
|
return new SidebarLayoutBridge(currentTui, sidebar);
|
|
@@ -116,6 +124,9 @@ export default function messageSidebar(pi: ExtensionAPI): void {
|
|
|
116
124
|
});
|
|
117
125
|
|
|
118
126
|
ctx.ui.onTerminalInput((data) => {
|
|
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();
|
|
119
130
|
if (tui && !isSidebarVisible(tui.terminal.columns) && sidebar?.isFocused()) {
|
|
120
131
|
sidebar.setFocused(false);
|
|
121
132
|
tui.requestRender();
|
|
@@ -140,6 +151,7 @@ export default function messageSidebar(pi: ExtensionAPI): void {
|
|
|
140
151
|
tui = null;
|
|
141
152
|
cachedContext = null;
|
|
142
153
|
footerData = null;
|
|
154
|
+
cmuxContext = null;
|
|
143
155
|
});
|
|
144
156
|
|
|
145
157
|
pi.registerShortcut("ctrl+shift+h", {
|
|
@@ -155,6 +167,7 @@ export default function messageSidebar(pi: ExtensionAPI): void {
|
|
|
155
167
|
pi.on("message_end", (_event, ctx) => scheduleRefresh(ctx));
|
|
156
168
|
pi.on("turn_end", (_event, ctx) => scheduleRefresh(ctx));
|
|
157
169
|
pi.on("agent_end", (_event, ctx) => scheduleRefresh(ctx));
|
|
170
|
+
pi.on("agent_settled", (_event, ctx) => scheduleRefresh(ctx));
|
|
158
171
|
pi.on("model_select", (_event, ctx) => scheduleRefresh(ctx));
|
|
159
172
|
pi.on("thinking_level_select", (_event, ctx) => scheduleRefresh(ctx));
|
|
160
173
|
pi.on("session_compact", (_event, ctx) => scheduleRefresh(ctx));
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-message-sidebar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Persistent responsive message history sidebar for Pi",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"sidebar",
|
|
9
|
+
"tui"
|
|
10
|
+
],
|
|
6
11
|
"author": "Francesco Frapporti <effedue@gmail.com>",
|
|
7
12
|
"license": "MIT",
|
|
8
13
|
"type": "module",
|
|
@@ -10,9 +15,18 @@
|
|
|
10
15
|
"type": "git",
|
|
11
16
|
"url": "git+https://github.com/Fornace/pi-message-sidebar.git"
|
|
12
17
|
},
|
|
13
|
-
"files": [
|
|
18
|
+
"files": [
|
|
19
|
+
"index.ts",
|
|
20
|
+
"message-sidebar.ts",
|
|
21
|
+
"src",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
14
26
|
"pi": {
|
|
15
|
-
"extensions": [
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./index.ts"
|
|
29
|
+
]
|
|
16
30
|
},
|
|
17
31
|
"scripts": {
|
|
18
32
|
"test": "node --import tsx --test test/*.test.ts",
|
package/src/cmux.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type CmuxContext = {
|
|
4
|
+
workspaceTitle: string | null;
|
|
5
|
+
workspaceRef: string | null;
|
|
6
|
+
surfaceRef: string | null;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function runningInCmux(): boolean {
|
|
10
|
+
return Boolean(process.env.CMUX_WORKSPACE_ID || process.env.CMUX_SURFACE_ID);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function cmuxBin(): string {
|
|
14
|
+
return process.env.CMUX_PI_CMUX_BIN || "cmux";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function run(args: string[], timeoutMs: number): Promise<string> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
execFile(cmuxBin(), args, { timeout: timeoutMs }, (error, stdout) => {
|
|
20
|
+
if (error) reject(error);
|
|
21
|
+
else resolve(stdout);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function identifyRefs(): Promise<{ workspaceRef: string | null; surfaceRef: string | null }> {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(await run(["identify"], 3000)) as {
|
|
29
|
+
caller?: { workspace_ref?: string; surface_ref?: string };
|
|
30
|
+
};
|
|
31
|
+
return { workspaceRef: parsed.caller?.workspace_ref ?? null, surfaceRef: parsed.caller?.surface_ref ?? null };
|
|
32
|
+
} catch {
|
|
33
|
+
return { workspaceRef: null, surfaceRef: null };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function workspaceTitle(): Promise<string | null> {
|
|
38
|
+
const workspaceId = process.env.CMUX_WORKSPACE_ID;
|
|
39
|
+
if (!workspaceId) return null;
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(await run(["rpc", "workspace.list", "{}"], 3000)) as {
|
|
42
|
+
workspaces?: { id?: string; custom_title?: string | null; description?: string | null }[];
|
|
43
|
+
};
|
|
44
|
+
const match = parsed.workspaces?.find((workspace) => workspace.id === workspaceId);
|
|
45
|
+
return match?.custom_title ?? match?.description ?? null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let cached: Promise<CmuxContext | null> | null = null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolves the cmux workspace title and surface ref for this session, once.
|
|
55
|
+
* Returns null when pi runs outside cmux. Lookup failures degrade to null
|
|
56
|
+
* fields so the sidebar keeps rendering with whatever it has.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveCmuxContext(): Promise<CmuxContext | null> {
|
|
59
|
+
if (!cached) {
|
|
60
|
+
if (!runningInCmux()) cached = Promise.resolve(null);
|
|
61
|
+
else {
|
|
62
|
+
cached = (async () => {
|
|
63
|
+
const [refs, title] = await Promise.all([identifyRefs(), workspaceTitle()]);
|
|
64
|
+
const context: CmuxContext = {
|
|
65
|
+
workspaceTitle: title,
|
|
66
|
+
workspaceRef: refs.workspaceRef,
|
|
67
|
+
surfaceRef: refs.surfaceRef,
|
|
68
|
+
};
|
|
69
|
+
if (!context.workspaceTitle && !context.workspaceRef && !context.surfaceRef) return null;
|
|
70
|
+
return context;
|
|
71
|
+
})();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return cached;
|
|
75
|
+
}
|
package/src/goal.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type ThreadGoal = {
|
|
4
|
+
goalId: string;
|
|
5
|
+
objective: string;
|
|
6
|
+
status: "active" | "paused" | "budgetLimited" | "complete";
|
|
7
|
+
tokenBudget: number | null;
|
|
8
|
+
tokensUsed: number;
|
|
9
|
+
activeSeconds: number;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
updatedAt: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type BranchEntry = { type?: string; customType?: string; data?: unknown };
|
|
15
|
+
|
|
16
|
+
const GOAL_ENTRY_TYPE = "pi-codex-goal";
|
|
17
|
+
|
|
18
|
+
function isThreadGoal(value: unknown): value is ThreadGoal {
|
|
19
|
+
const goal = value as ThreadGoal | null;
|
|
20
|
+
return (
|
|
21
|
+
!!goal &&
|
|
22
|
+
typeof goal === "object" &&
|
|
23
|
+
typeof goal.goalId === "string" &&
|
|
24
|
+
typeof goal.objective === "string" &&
|
|
25
|
+
(goal.status === "active" || goal.status === "paused" || goal.status === "budgetLimited" || goal.status === "complete") &&
|
|
26
|
+
(goal.tokenBudget === null || typeof goal.tokenBudget === "number") &&
|
|
27
|
+
typeof goal.tokensUsed === "number" &&
|
|
28
|
+
typeof goal.activeSeconds === "number" &&
|
|
29
|
+
typeof goal.createdAt === "number" &&
|
|
30
|
+
typeof goal.updatedAt === "number"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reconstructs the current thread goal from pi-codex-goal session entries.
|
|
36
|
+
* Mirrors reconstructGoal() from pi-codex-goal dist/state.js (schema version 1):
|
|
37
|
+
* "set" entries replace the goal, "clear" entries drop it, and "usage" entries
|
|
38
|
+
* advance tokens/time for runtime-usage statuses only.
|
|
39
|
+
*/
|
|
40
|
+
export function readThreadGoal(entries: Iterable<BranchEntry>): ThreadGoal | null {
|
|
41
|
+
let goal: ThreadGoal | null = null;
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (entry.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
|
|
44
|
+
const data = entry.data as Record<string, unknown> | null;
|
|
45
|
+
if (!data || typeof data !== "object" || data.version !== 1) continue;
|
|
46
|
+
if (data.kind === "clear") {
|
|
47
|
+
goal = null;
|
|
48
|
+
} else if (data.kind === "set" && isThreadGoal(data.goal)) {
|
|
49
|
+
goal = { ...(data.goal as ThreadGoal) };
|
|
50
|
+
} else if (data.kind === "usage" && goal) {
|
|
51
|
+
const usage = data.usage as { tokensUsed?: number; activeSeconds?: number } | undefined;
|
|
52
|
+
const updatedAt = data.updatedAt;
|
|
53
|
+
if (data.goalId !== goal.goalId) continue;
|
|
54
|
+
if (goal.status !== "active" && goal.status !== "budgetLimited") continue;
|
|
55
|
+
if (goal.status === "budgetLimited" && data.status === "active") continue;
|
|
56
|
+
if (typeof updatedAt !== "number" || typeof usage?.tokensUsed !== "number" || typeof usage.activeSeconds !== "number") continue;
|
|
57
|
+
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;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return goal;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type GoalCache = { key: string; goal: ThreadGoal | null };
|
|
65
|
+
let cache: GoalCache | null = null;
|
|
66
|
+
|
|
67
|
+
/** Cached readThreadGoal over the current session branch. */
|
|
68
|
+
export function readSessionGoal(ctx: ExtensionContext): ThreadGoal | null {
|
|
69
|
+
const branch = ctx.sessionManager.getBranch();
|
|
70
|
+
const last = branch.at(-1);
|
|
71
|
+
const key = `${branch.length}:${last?.id ?? ""}:${last?.timestamp ?? ""}`;
|
|
72
|
+
if (cache && cache.key === key) return cache.goal;
|
|
73
|
+
const goal = readThreadGoal(branch);
|
|
74
|
+
cache = { key, goal };
|
|
75
|
+
return goal;
|
|
76
|
+
}
|
package/src/sidebar-component.ts
CHANGED
|
@@ -7,6 +7,8 @@ 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
9
|
import { GAP_WINDOW, PINNED_COUNT, SIDEBAR_WIDTH } from "./constants.ts";
|
|
10
|
+
import type { CmuxContext } from "./cmux.ts";
|
|
11
|
+
import { readSessionGoal } from "./goal.ts";
|
|
10
12
|
import { assertLinesFit } from "./layout.ts";
|
|
11
13
|
import { renderStatusDock } from "./status-dock.ts";
|
|
12
14
|
import {
|
|
@@ -36,6 +38,7 @@ type SidebarOptions = {
|
|
|
36
38
|
ctx: ExtensionContext;
|
|
37
39
|
getFooterData: () => ReadonlyFooterDataProvider | null;
|
|
38
40
|
getThinkingLevel: () => string;
|
|
41
|
+
getCmuxContext: () => CmuxContext | null;
|
|
39
42
|
messages: UserMessage[];
|
|
40
43
|
};
|
|
41
44
|
|
|
@@ -118,6 +121,7 @@ export class SidebarComponent {
|
|
|
118
121
|
this.options.getFooterData(),
|
|
119
122
|
this.options.getThinkingLevel(),
|
|
120
123
|
this.focused,
|
|
124
|
+
this.options.getCmuxContext,
|
|
121
125
|
);
|
|
122
126
|
while (lines.length < targetHeight - dock.length) lines.push(fillRow(" ", safeWidth, BG));
|
|
123
127
|
const bodyLimit = Math.max(0, targetHeight - dock.length);
|
|
@@ -136,7 +140,7 @@ export class SidebarComponent {
|
|
|
136
140
|
private renderHeader(width: number): string[] {
|
|
137
141
|
const icon = this.focused ? `${FG_ACC}●${RST}` : `${FG_DIM}○${RST}`;
|
|
138
142
|
const mode = this.focused ? `${FG_ACC}●${RST} ${FG_MID}focused${RST}` : `${FG_DIM}passive${RST}`;
|
|
139
|
-
const separator = `${FG_DIM}${"─".repeat(Math.max(0, width -
|
|
143
|
+
const separator = `${FG_DIM}${"─".repeat(Math.max(0, width - 2))}${RST}`;
|
|
140
144
|
return [
|
|
141
145
|
fillRow(" ", width, BG_HDR),
|
|
142
146
|
fillRow(` ${icon} ${BOLD}${FG_BRIGHT}Messages${RST}${FG_DIM} ${this.messages.length}${RST} ${mode}`, width, BG_HDR),
|
|
@@ -197,6 +201,8 @@ export class SidebarComponent {
|
|
|
197
201
|
private signature(width: number, height: number): string {
|
|
198
202
|
const usage = this.options.ctx.getContextUsage?.();
|
|
199
203
|
const statuses = this.options.getFooterData()?.getExtensionStatuses();
|
|
204
|
+
const goal = readSessionGoal(this.options.ctx);
|
|
205
|
+
const cmux = this.options.getCmuxContext();
|
|
200
206
|
return JSON.stringify({
|
|
201
207
|
width,
|
|
202
208
|
height,
|
|
@@ -206,6 +212,8 @@ export class SidebarComponent {
|
|
|
206
212
|
thinking: this.options.getThinkingLevel(),
|
|
207
213
|
usage,
|
|
208
214
|
statuses: statuses ? [...statuses.entries()] : [],
|
|
215
|
+
goal: goal ? `${goal.goalId}:${goal.status}:${goal.tokensUsed}:${goal.activeSeconds}:${goal.updatedAt}` : null,
|
|
216
|
+
cmux: cmux ? `${cmux.workspaceTitle}:${cmux.surfaceRef}` : null,
|
|
209
217
|
});
|
|
210
218
|
}
|
|
211
219
|
|
package/src/status-dock.ts
CHANGED
|
@@ -4,22 +4,31 @@ import type {
|
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { basename } from "node:path";
|
|
6
6
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { readSessionGoal, type ThreadGoal } from "./goal.ts";
|
|
8
|
+
import type { CmuxContext } from "./cmux.ts";
|
|
7
9
|
import {
|
|
8
10
|
BG,
|
|
9
11
|
BG_CARD,
|
|
10
12
|
BG_HDR,
|
|
13
|
+
FG_ACC,
|
|
11
14
|
FG_BRIGHT,
|
|
12
15
|
FG_DIM,
|
|
16
|
+
FG_ERR,
|
|
13
17
|
FG_FAINT,
|
|
14
18
|
FG_INFO,
|
|
15
19
|
FG_MID,
|
|
20
|
+
FG_NORM,
|
|
21
|
+
FG_OK,
|
|
22
|
+
FG_WARN,
|
|
16
23
|
RST,
|
|
17
24
|
contextColor,
|
|
18
25
|
fillRow,
|
|
19
26
|
formatCwd,
|
|
27
|
+
formatDuration,
|
|
20
28
|
formatTokens,
|
|
21
29
|
progressBar,
|
|
22
30
|
sanitizeStatusText,
|
|
31
|
+
wrapText,
|
|
23
32
|
} from "./style.ts";
|
|
24
33
|
|
|
25
34
|
type Usage = {
|
|
@@ -33,7 +42,14 @@ type Usage = {
|
|
|
33
42
|
contextWindow: number;
|
|
34
43
|
};
|
|
35
44
|
|
|
45
|
+
let usageCache: { key: string; usage: Usage } | null = null;
|
|
46
|
+
|
|
36
47
|
function computeUsage(ctx: ExtensionContext): Usage {
|
|
48
|
+
const entries = ctx.sessionManager.getEntries();
|
|
49
|
+
const last = entries.at(-1);
|
|
50
|
+
const key = `${entries.length}:${last?.id ?? ""}`;
|
|
51
|
+
if (usageCache && usageCache.key === key) return usageCache.usage;
|
|
52
|
+
|
|
37
53
|
let input = 0;
|
|
38
54
|
let output = 0;
|
|
39
55
|
let cacheRead = 0;
|
|
@@ -41,7 +57,7 @@ function computeUsage(ctx: ExtensionContext): Usage {
|
|
|
41
57
|
let cost = 0;
|
|
42
58
|
let latestCacheHitRate: number | undefined;
|
|
43
59
|
|
|
44
|
-
for (const entry of
|
|
60
|
+
for (const entry of entries) {
|
|
45
61
|
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
46
62
|
const usage = (entry.message as any).usage ?? {};
|
|
47
63
|
input += usage.input ?? 0;
|
|
@@ -54,7 +70,7 @@ function computeUsage(ctx: ExtensionContext): Usage {
|
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
const context = ctx.getContextUsage?.();
|
|
57
|
-
|
|
73
|
+
const usage = {
|
|
58
74
|
input,
|
|
59
75
|
output,
|
|
60
76
|
cacheRead,
|
|
@@ -64,6 +80,8 @@ function computeUsage(ctx: ExtensionContext): Usage {
|
|
|
64
80
|
contextPercent: context?.percent ?? null,
|
|
65
81
|
contextWindow: context?.contextWindow ?? ctx.model?.contextWindow ?? 0,
|
|
66
82
|
};
|
|
83
|
+
usageCache = { key, usage };
|
|
84
|
+
return usage;
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
function dockHeader(width: number, title: string): string {
|
|
@@ -80,15 +98,76 @@ function dockRow(width: number, label: string, value: string): string {
|
|
|
80
98
|
return fillRow(`${pad}${FG_FAINT}${label.padEnd(labelWidth)}${RST} ${clipped}`, width, BG_CARD);
|
|
81
99
|
}
|
|
82
100
|
|
|
101
|
+
function goalStatusGlyph(goal: ThreadGoal): { icon: string; color: string; label: string } {
|
|
102
|
+
switch (goal.status) {
|
|
103
|
+
case "active": return { icon: "◉", color: FG_ACC, label: "active" };
|
|
104
|
+
case "paused": return { icon: "○", color: FG_DIM, label: "paused" };
|
|
105
|
+
case "budgetLimited": return { icon: "▲", color: FG_WARN, label: "budget" };
|
|
106
|
+
case "complete": return { icon: "✓", color: FG_OK, label: "done" };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
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
|
+
|
|
121
|
+
const budget = goal.tokenBudget
|
|
122
|
+
? `${formatTokens(goal.tokensUsed)}/${formatTokens(goal.tokenBudget)}`
|
|
123
|
+
: `${formatTokens(goal.tokensUsed)}/∞`;
|
|
124
|
+
const percent = goal.tokenBudget ? (goal.tokensUsed / goal.tokenBudget) * 100 : null;
|
|
125
|
+
const overBudget = percent !== null && percent > 100;
|
|
126
|
+
const budgetColor = overBudget ? FG_ERR : glyph.color;
|
|
127
|
+
rows.push(fillRow(
|
|
128
|
+
` ${budgetColor}${budget}${RST} ${progressBar(percent)} ${glyph.color}${glyph.label}${RST} ${FG_FAINT}·${RST} ${FG_MID}${formatDuration(goal.activeSeconds)}${RST}`,
|
|
129
|
+
width,
|
|
130
|
+
BG_CARD,
|
|
131
|
+
));
|
|
132
|
+
return rows;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderSessRow(width: number, ctx: ExtensionContext, cmux: CmuxContext | null): string {
|
|
136
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
137
|
+
const shortId = sessionId.replace(/-/g, "").slice(-8);
|
|
138
|
+
let value: string;
|
|
139
|
+
if (cmux) {
|
|
140
|
+
const surfacePart = cmux.surfaceRef ? ` ${FG_FAINT}·${RST} ${FG_FAINT}${cmux.surfaceRef}${RST}` : "";
|
|
141
|
+
const budget = width - 7 - visibleWidth(surfacePart);
|
|
142
|
+
const name = cmux.workspaceTitle
|
|
143
|
+
? truncateToWidth(cmux.workspaceTitle, budget, "…")
|
|
144
|
+
: cmux.workspaceRef ?? "";
|
|
145
|
+
value = `${FG_BRIGHT}${name}${RST}${surfacePart}`;
|
|
146
|
+
} else {
|
|
147
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
148
|
+
value = [
|
|
149
|
+
`${FG_INFO}#${shortId}${RST}`,
|
|
150
|
+
sessionFile ? `${FG_FAINT}${basename(sessionFile)}${RST}` : undefined,
|
|
151
|
+
].filter(Boolean).join(` ${FG_FAINT}·${RST} `);
|
|
152
|
+
}
|
|
153
|
+
return dockRow(width, "sess", value);
|
|
154
|
+
}
|
|
155
|
+
|
|
83
156
|
export function renderStatusDock(
|
|
84
157
|
width: number,
|
|
85
158
|
ctx: ExtensionContext,
|
|
86
159
|
footerData: ReadonlyFooterDataProvider | null,
|
|
87
160
|
thinkingLevel: string,
|
|
88
161
|
focused: boolean,
|
|
162
|
+
getCmuxContext: () => CmuxContext | null,
|
|
89
163
|
): string[] {
|
|
90
164
|
const usage = computeUsage(ctx);
|
|
91
|
-
const
|
|
165
|
+
const goal = readSessionGoal(ctx);
|
|
166
|
+
const rows: string[] = [];
|
|
167
|
+
|
|
168
|
+
if (goal) rows.push(...renderGoalCard(width, goal));
|
|
169
|
+
|
|
170
|
+
rows.push(fillRow(" ", width, BG), dockHeader(width, "runtime"));
|
|
92
171
|
const branch = footerData?.getGitBranch();
|
|
93
172
|
const sessionName = ctx.sessionManager.getSessionName();
|
|
94
173
|
const workspace = [
|
|
@@ -97,14 +176,7 @@ export function renderStatusDock(
|
|
|
97
176
|
sessionName ? `${FG_MID}${sessionName}${RST}` : undefined,
|
|
98
177
|
].filter(Boolean).join(` ${FG_FAINT}•${RST} `);
|
|
99
178
|
rows.push(dockRow(width, "cwd", workspace));
|
|
100
|
-
|
|
101
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
102
|
-
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
103
|
-
const shortId = sessionId.replace(/-/g, "").slice(-8);
|
|
104
|
-
rows.push(dockRow(width, "sess", [
|
|
105
|
-
`${FG_INFO}#${shortId}${RST}`,
|
|
106
|
-
sessionFile ? `${FG_FAINT}${basename(sessionFile)}${RST}` : undefined,
|
|
107
|
-
].filter(Boolean).join(` ${FG_FAINT}•${RST} `)));
|
|
179
|
+
rows.push(renderSessRow(width, ctx, getCmuxContext()));
|
|
108
180
|
|
|
109
181
|
const model = ctx.model;
|
|
110
182
|
if (model) {
|
package/src/style.ts
CHANGED
|
@@ -38,6 +38,15 @@ export function formatTime(timestamp: string): string {
|
|
|
38
38
|
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function formatDuration(totalSeconds: number): string {
|
|
42
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
43
|
+
if (minutes < 1) return "<1m";
|
|
44
|
+
if (minutes < 60) return `${minutes}m`;
|
|
45
|
+
const hours = Math.floor(minutes / 60);
|
|
46
|
+
const rest = minutes % 60;
|
|
47
|
+
return rest ? `${hours}h ${String(rest).padStart(2, "0")}m` : `${hours}h`;
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
export function sanitizeStatusText(text: string): string {
|
|
42
51
|
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
43
52
|
}
|