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 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
 
@@ -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.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Persistent responsive message history sidebar for Pi",
5
- "keywords": ["pi-package", "pi-extension", "sidebar", "tui"],
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": ["index.ts", "message-sidebar.ts", "src", "README.md", "CHANGELOG.md", "LICENSE"],
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": ["./index.ts"]
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
+ }
@@ -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 - 4))}${RST}`;
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
 
@@ -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 ctx.sessionManager.getEntries()) {
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
- return {
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 rows = [fillRow(" ", width, BG), dockHeader(width, "runtime")];
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
  }