pi-message-sidebar 1.3.0 → 1.5.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 +11 -0
- package/README.md +12 -10
- package/package.json +2 -1
- package/src/constants.ts +0 -2
- package/src/goal.ts +59 -63
- package/src/sidebar-component.ts +142 -72
- package/src/status-dock.ts +119 -148
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.0
|
|
4
|
+
|
|
5
|
+
- Replaced selected/newest pinning with a row-aware contiguous chronological viewport.
|
|
6
|
+
- Top-aligned short histories and preserved selection, expansion, viewport anchor, and follow-tail state by message ID.
|
|
7
|
+
- Reduced the header to two compact rows and the normal status dock to at most four rows.
|
|
8
|
+
- Removed collapsed-row ordinal and timestamp columns; metadata now appears only on expanded rows.
|
|
9
|
+
- Preserved complete cmux surface refs by truncating workspace titles first.
|
|
10
|
+
- Corrected tiny-height allocation so a message remains visible whenever one row is available.
|
|
11
|
+
- 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.
|
|
12
|
+
- Clarified that fullscreen is persistent while regular mode is a current-screen compositor under terminal-owned scrollback.
|
|
13
|
+
|
|
3
14
|
## 1.2.0
|
|
4
15
|
|
|
5
16
|
> 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
|
-
-
|
|
9
|
-
-
|
|
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
|
-
-
|
|
12
|
-
-
|
|
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
|
|
59
|
-
- `src/sidebar-component.ts` owns
|
|
60
|
-
- `src/status-dock.ts` renders
|
|
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.
|
|
3
|
+
"version": "1.5.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,100 +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:
|
|
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
|
-
function
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
20
|
+
function isGoalEntrySource(value: unknown): value is GoalEntrySource {
|
|
21
|
+
return value === "command" || value === "tool" || value === "runtime";
|
|
22
|
+
}
|
|
23
|
+
|
|
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)
|
|
31
44
|
);
|
|
32
45
|
}
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
47
|
+
function cloneGoal(goal: ThreadGoal): ThreadGoal {
|
|
48
|
+
return { ...goal, usage: { ...goal.usage } };
|
|
49
|
+
}
|
|
37
50
|
|
|
38
|
-
/**
|
|
39
|
-
* Reconstructs the current thread goal from pi-codex-goal session entries.
|
|
40
|
-
* Mirrors reconstructGoal() from pi-codex-goal dist/state.js (schema version 1):
|
|
41
|
-
* "set" entries replace the goal, "clear" entries drop it, and "usage" entries
|
|
42
|
-
* advance tokens/time for runtime-usage statuses only.
|
|
43
|
-
*/
|
|
51
|
+
/** Mirrors pi-codex-goal's schema-v1 reconstruction contract. */
|
|
44
52
|
export function readThreadGoal(entries: Iterable<BranchEntry>): ThreadGoal | null {
|
|
45
53
|
let goal: ThreadGoal | null = null;
|
|
46
54
|
for (const entry of entries) {
|
|
47
55
|
if (entry.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
|
|
48
56
|
const data = entry.data as Record<string, unknown> | null;
|
|
49
|
-
if (!data || typeof data !== "object" || data.version !== 1) continue;
|
|
57
|
+
if (!data || typeof data !== "object" || data.version !== 1 || !isFiniteNonNegative(data.at)) continue;
|
|
58
|
+
|
|
50
59
|
if (data.kind === "clear") {
|
|
60
|
+
if (!isGoalEntrySource(data.source) || (data.clearedGoalId !== null && typeof data.clearedGoalId !== "string")) continue;
|
|
51
61
|
goal = null;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
tokenBudget: raw.tokenBudget,
|
|
59
|
-
tokensUsed: raw.usage.tokensUsed,
|
|
60
|
-
activeSeconds: raw.usage.activeSeconds,
|
|
61
|
-
createdAt: raw.createdAt,
|
|
62
|
-
updatedAt: raw.updatedAt,
|
|
63
|
-
};
|
|
64
|
-
} else if (data.kind === "usage" && goal) {
|
|
65
|
-
const usage = data.usage as { tokensUsed?: number; activeSeconds?: number } | undefined;
|
|
66
|
-
const updatedAt = data.updatedAt;
|
|
67
|
-
const status = data.status;
|
|
68
|
-
if (data.goalId !== goal.goalId) continue;
|
|
69
|
-
if (status !== "active" && status !== "budgetLimited") continue;
|
|
70
|
-
if (goal.status !== "active" && goal.status !== "budgetLimited") continue;
|
|
71
|
-
if (goal.status === "budgetLimited" && status === "active") continue;
|
|
72
|
-
if (typeof updatedAt !== "number" || typeof usage?.tokensUsed !== "number" || typeof usage.activeSeconds !== "number") continue;
|
|
73
|
-
if (updatedAt < goal.updatedAt || usage.tokensUsed < goal.tokensUsed || usage.activeSeconds < goal.activeSeconds) continue;
|
|
74
|
-
const current: ThreadGoal = goal;
|
|
75
|
-
goal = {
|
|
76
|
-
...current,
|
|
77
|
-
status,
|
|
78
|
-
tokensUsed: usage.tokensUsed,
|
|
79
|
-
activeSeconds: usage.activeSeconds,
|
|
80
|
-
updatedAt,
|
|
81
|
-
};
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (data.kind === "set") {
|
|
65
|
+
if (!isGoalEntrySource(data.source) || !isThreadGoal(data.goal)) continue;
|
|
66
|
+
goal = cloneGoal(data.goal);
|
|
67
|
+
continue;
|
|
82
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 };
|
|
83
79
|
}
|
|
84
80
|
return goal;
|
|
85
81
|
}
|
|
86
82
|
|
|
87
|
-
type GoalCache = {
|
|
88
|
-
const
|
|
83
|
+
type GoalCache = { key: string; goal: ThreadGoal | null };
|
|
84
|
+
const caches = new WeakMap<ExtensionContext, GoalCache>();
|
|
89
85
|
|
|
90
|
-
/** Cached
|
|
86
|
+
/** Cached reconstruction scoped to each extension context. */
|
|
91
87
|
export function readSessionGoal(ctx: ExtensionContext): ThreadGoal | null {
|
|
92
88
|
const branch = ctx.sessionManager.getBranch();
|
|
93
89
|
const last = branch.at(-1);
|
|
94
90
|
const key = `${branch.length}:${last?.id ?? ""}:${last?.timestamp ?? ""}`;
|
|
95
|
-
const previous =
|
|
96
|
-
if (previous?.
|
|
91
|
+
const previous = caches.get(ctx);
|
|
92
|
+
if (previous?.key === key) return previous.goal;
|
|
97
93
|
const goal = readThreadGoal(branch);
|
|
98
|
-
|
|
94
|
+
caches.set(ctx, { key, goal });
|
|
99
95
|
return goal;
|
|
100
96
|
}
|
package/src/sidebar-component.ts
CHANGED
|
@@ -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 { GAP_WINDOW, PINNED_COUNT, 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,22 +42,27 @@ type SidebarOptions = {
|
|
|
42
42
|
messages: UserMessage[];
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
-
export class SidebarComponent {
|
|
45
|
+
export class SidebarComponent implements Component {
|
|
46
46
|
private focused = false;
|
|
47
|
-
private
|
|
48
|
-
private
|
|
47
|
+
private selectedId: string | null;
|
|
48
|
+
private readonly expandedIds = new Set<string>();
|
|
49
|
+
private followTail = true;
|
|
49
50
|
private messages: UserMessage[];
|
|
50
51
|
private version = 0;
|
|
51
52
|
private restoreFocus: Component | null = null;
|
|
52
53
|
private cachedSignature = "";
|
|
53
54
|
private cachedLines: string[] = [];
|
|
55
|
+
private viewportStartId: string | null = null;
|
|
54
56
|
|
|
55
57
|
constructor(private readonly options: SidebarOptions) {
|
|
56
58
|
this.messages = options.messages;
|
|
57
|
-
this.
|
|
59
|
+
this.selectedId = this.messages.at(-1)?.id ?? null;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
isFocused(): boolean { return this.focused; }
|
|
63
|
+
getSelectedMessageId(): string | null { return this.selectedId; }
|
|
64
|
+
isFollowingTail(): boolean { return this.followTail; }
|
|
65
|
+
isExpanded(messageId: string): boolean { return this.expandedIds.has(messageId); }
|
|
61
66
|
|
|
62
67
|
setFocused(focused: boolean): void {
|
|
63
68
|
if (this.focused === focused) return;
|
|
@@ -67,17 +72,27 @@ export class SidebarComponent {
|
|
|
67
72
|
this.options.tui.setFocus(this);
|
|
68
73
|
} else {
|
|
69
74
|
this.focused = false;
|
|
70
|
-
if ((this.options.tui as any).getFocusedComponent?.() === this)
|
|
71
|
-
this.options.tui.setFocus(this.restoreFocus);
|
|
72
|
-
}
|
|
75
|
+
if ((this.options.tui as any).getFocusedComponent?.() === this) this.options.tui.setFocus(this.restoreFocus);
|
|
73
76
|
this.restoreFocus = null;
|
|
74
77
|
}
|
|
75
78
|
this.refresh();
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
updateMessages(messages: UserMessage[]): void {
|
|
82
|
+
const previousId = this.selectedId;
|
|
83
|
+
const previousStartId = this.viewportStartId;
|
|
79
84
|
this.messages = messages;
|
|
80
|
-
|
|
85
|
+
const ids = new Set(messages.map((message) => message.id));
|
|
86
|
+
for (const id of this.expandedIds) if (!ids.has(id)) this.expandedIds.delete(id);
|
|
87
|
+
this.viewportStartId = previousStartId && ids.has(previousStartId) ? previousStartId : null;
|
|
88
|
+
|
|
89
|
+
if (this.followTail || !previousId || !ids.has(previousId)) {
|
|
90
|
+
this.selectedId = messages.at(-1)?.id ?? null;
|
|
91
|
+
this.followTail = true;
|
|
92
|
+
this.viewportStartId = null;
|
|
93
|
+
} else {
|
|
94
|
+
this.selectedId = previousId;
|
|
95
|
+
}
|
|
81
96
|
this.refresh();
|
|
82
97
|
}
|
|
83
98
|
|
|
@@ -89,32 +104,41 @@ 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;
|
|
126
|
+
if (this.followTail) this.viewportStartId = null;
|
|
102
127
|
this.refresh();
|
|
103
128
|
}
|
|
104
129
|
|
|
105
130
|
render(width: number): string[] {
|
|
106
131
|
const safeWidth = Math.max(1, Math.min(SIDEBAR_WIDTH, width));
|
|
107
|
-
const targetHeight = Math.max(
|
|
132
|
+
const targetHeight = Math.max(1, this.options.tui.terminal.rows);
|
|
108
133
|
const signature = this.signature(safeWidth, targetHeight);
|
|
109
134
|
if (signature === this.cachedSignature) return this.cachedLines;
|
|
110
135
|
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
136
|
+
const fullHeader = this.renderHeader(safeWidth);
|
|
137
|
+
const headerRows = this.messages.length > 0 && targetHeight === 1 ? 0 : targetHeight <= 5 ? 1 : 2;
|
|
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;
|
|
118
142
|
const dock = renderStatusDock(
|
|
119
143
|
safeWidth,
|
|
120
144
|
this.options.ctx,
|
|
@@ -122,11 +146,12 @@ export class SidebarComponent {
|
|
|
122
146
|
this.options.getThinkingLevel(),
|
|
123
147
|
this.focused,
|
|
124
148
|
this.options.getCmuxContext,
|
|
149
|
+
Math.min(4, Math.max(0, available - messageReserve)),
|
|
125
150
|
);
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const result = [...lines.slice(0, bodyLimit), ...dock].slice(0, targetHeight);
|
|
151
|
+
const bodyHeight = Math.max(0, targetHeight - header.length - dock.length);
|
|
152
|
+
const result = [...header, ...this.renderBody(safeWidth, bodyHeight), ...dock];
|
|
129
153
|
assertLinesFit(result, safeWidth, "sidebar");
|
|
154
|
+
if (result.length !== targetHeight) throw new Error(`sidebar height mismatch (${result.length} != ${targetHeight})`);
|
|
130
155
|
this.cachedSignature = signature;
|
|
131
156
|
this.cachedLines = result;
|
|
132
157
|
return result;
|
|
@@ -138,64 +163,110 @@ export class SidebarComponent {
|
|
|
138
163
|
}
|
|
139
164
|
|
|
140
165
|
private renderHeader(width: number): string[] {
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
const
|
|
166
|
+
const position = this.selectedIndex() + 1;
|
|
167
|
+
const count = this.messages.length;
|
|
168
|
+
const location = count > 0 ? `${position}/${count}` : "0";
|
|
169
|
+
const selected = this.messages[this.selectedIndex()];
|
|
170
|
+
const detail = selected
|
|
171
|
+
? `${this.focused ? "Selected" : "Current"} #${selected.index} ${formatTime(selected.timestamp)}`
|
|
172
|
+
: "User prompts";
|
|
144
173
|
return [
|
|
145
|
-
fillRow(
|
|
146
|
-
fillRow(` ${
|
|
147
|
-
fillRow(" ", width, BG_HDR),
|
|
148
|
-
fillRow(` ${separator}`, width, BG),
|
|
174
|
+
fillRow(` ${BOLD}${FG_BRIGHT}Messages${RST} ${FG_FAINT}${location}${RST}`, width, BG_HDR),
|
|
175
|
+
fillRow(` ${FG_DIM}${detail}${RST}`, width, BG),
|
|
149
176
|
];
|
|
150
177
|
}
|
|
151
178
|
|
|
152
|
-
private
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
179
|
+
private renderBody(width: number, height: number): string[] {
|
|
180
|
+
if (height === 0) return [];
|
|
181
|
+
if (this.messages.length === 0) return this.padBottom([fillRow(` ${FG_DIM}No messages yet${RST}`, width, BG)], height, width);
|
|
182
|
+
|
|
183
|
+
const selected = this.selectedIndex();
|
|
184
|
+
let start = this.followTail ? this.tailStart(width, height) : this.startForSelection(width, height, selected);
|
|
185
|
+
const preserved = this.indexForId(this.viewportStartId);
|
|
186
|
+
if (!this.followTail && preserved >= 0 && selected >= preserved && this.rangeFitsSelection(width, height, preserved, selected)) start = preserved;
|
|
187
|
+
|
|
188
|
+
const lines: string[] = [];
|
|
189
|
+
for (let index = start; index < this.messages.length && lines.length < height; index++) {
|
|
190
|
+
const remaining = height - lines.length;
|
|
191
|
+
lines.push(...this.renderMessage(index, width, remaining));
|
|
161
192
|
}
|
|
162
|
-
|
|
193
|
+
this.viewportStartId = this.messages[start]?.id ?? null;
|
|
194
|
+
return this.padBottom(lines.slice(0, height), height, width);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private tailStart(width: number, height: number): number {
|
|
198
|
+
let start = this.messages.length - 1;
|
|
199
|
+
let rows = this.messageRowCount(start, width, height);
|
|
200
|
+
while (start > 0) {
|
|
201
|
+
const next = this.messageRowCount(start - 1, width, height);
|
|
202
|
+
if (rows + next > height) break;
|
|
203
|
+
rows += next;
|
|
204
|
+
start--;
|
|
205
|
+
}
|
|
206
|
+
return start;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private startForSelection(width: number, height: number, selected: number): number {
|
|
210
|
+
let start = selected;
|
|
211
|
+
let rows = this.messageRowCount(selected, width, height);
|
|
212
|
+
while (start > 0) {
|
|
213
|
+
const next = this.messageRowCount(start - 1, width, height);
|
|
214
|
+
if (rows + next > height) break;
|
|
215
|
+
rows += next;
|
|
216
|
+
start--;
|
|
217
|
+
}
|
|
218
|
+
return start;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private rangeFitsSelection(width: number, height: number, start: number, selected: number): boolean {
|
|
222
|
+
let rows = 0;
|
|
223
|
+
for (let index = start; index <= selected; index++) {
|
|
224
|
+
rows += this.messageRowCount(index, width, height);
|
|
225
|
+
if (rows > height) return false;
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private messageRowCount(index: number, width: number, maxRows: number): number {
|
|
231
|
+
return this.renderMessage(index, width, maxRows).length;
|
|
163
232
|
}
|
|
164
233
|
|
|
165
|
-
private
|
|
234
|
+
private padBottom(lines: string[], height: number, width: number): string[] {
|
|
235
|
+
const padding = Array.from({ length: Math.max(0, height - lines.length) }, () => fillRow(" ", width, BG));
|
|
236
|
+
return [...lines, ...padding];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private renderMessage(index: number, width: number, maxRows = Number.MAX_SAFE_INTEGER): string[] {
|
|
166
240
|
const message = this.messages[index]!;
|
|
167
|
-
const selected =
|
|
241
|
+
const selected = message.id === this.selectedId;
|
|
168
242
|
const background = selected ? BG_SEL : BG;
|
|
169
|
-
const arrow = selected && this.focused ? `${FG_ACC}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (!this.expanded.has(index)) {
|
|
173
|
-
const prefix = ` ${arrow}${number} ${time} `;
|
|
243
|
+
const arrow = selected && this.focused ? `${FG_ACC}›${RST}` : " ";
|
|
244
|
+
if (!this.expandedIds.has(message.id) || maxRows <= 1) {
|
|
245
|
+
const prefix = ` ${arrow} `;
|
|
174
246
|
const text = truncateToWidth(message.text.replace(/\s+/g, " "), Math.max(0, width - visibleWidth(prefix)), "…");
|
|
175
247
|
return [fillRow(`${prefix}${selected ? FG_BRIGHT : FG_NORM}${text}${RST}`, width, background)];
|
|
176
248
|
}
|
|
177
249
|
|
|
178
|
-
const
|
|
250
|
+
const number = `${FG_FAINT}#${message.index}${RST}`;
|
|
251
|
+
const time = `${FG_TIME}${formatTime(message.timestamp)}${RST}`;
|
|
252
|
+
const lines = [fillRow(` ${arrow} ${number} ${time}`, width, background)];
|
|
179
253
|
const wrapped = wrapText(message.text, Math.max(1, width - 4));
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
254
|
+
const contentRows = Math.max(0, maxRows - 1);
|
|
255
|
+
for (const line of wrapped.slice(0, contentRows)) lines.push(fillRow(` ${FG_EXP}${line}${RST}`, width, background));
|
|
256
|
+
if (wrapped.length > contentRows && lines.length === maxRows) {
|
|
257
|
+
lines[lines.length - 1] = fillRow(` ${FG_DIM}${DIM}…+${wrapped.length - contentRows + 1} lines${RST}`, width, background);
|
|
183
258
|
}
|
|
184
|
-
lines.push(fillRow(" ", width, background));
|
|
185
259
|
return lines;
|
|
186
260
|
}
|
|
187
261
|
|
|
188
|
-
private
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
for (let i = start; i < start + GAP_WINDOW; i++) indices.add(i);
|
|
197
|
-
}
|
|
198
|
-
return [...indices].sort((a, b) => a - b);
|
|
262
|
+
private selectedIndex(): number {
|
|
263
|
+
if (this.messages.length === 0) return -1;
|
|
264
|
+
const index = this.indexForId(this.selectedId);
|
|
265
|
+
return index >= 0 ? index : this.messages.length - 1;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private indexForId(id: string | null): number {
|
|
269
|
+
return id ? this.messages.findIndex((message) => message.id === id) : -1;
|
|
199
270
|
}
|
|
200
271
|
|
|
201
272
|
private signature(width: number, height: number): string {
|
|
@@ -207,13 +278,12 @@ export class SidebarComponent {
|
|
|
207
278
|
width,
|
|
208
279
|
height,
|
|
209
280
|
version: this.version,
|
|
210
|
-
messages: this.messages.length,
|
|
211
281
|
model: this.options.ctx.model?.id,
|
|
212
282
|
thinking: this.options.getThinkingLevel(),
|
|
213
283
|
usage,
|
|
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,
|
|
284
|
+
statuses: statuses && typeof (statuses as any).entries === "function" ? [...statuses.entries()] : [],
|
|
285
|
+
goal: goal ? `${goal.goalId}:${goal.status}:${goal.usage.tokensUsed}:${goal.usage.activeSeconds}:${goal.updatedAt}` : null,
|
|
286
|
+
cmux: cmux ? `${cmux.workspaceTitle}:${cmux.workspaceRef}:${cmux.surfaceRef}` : null,
|
|
217
287
|
});
|
|
218
288
|
}
|
|
219
289
|
|
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
|
-
import { readSessionGoal, type ThreadGoal } from "./goal.ts";
|
|
8
6
|
import type { CmuxContext } from "./cmux.ts";
|
|
7
|
+
import { readSessionGoal, type ThreadGoal } from "./goal.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,131 +23,139 @@ import {
|
|
|
26
23
|
formatCwd,
|
|
27
24
|
formatDuration,
|
|
28
25
|
formatTokens,
|
|
29
|
-
progressBar,
|
|
30
26
|
sanitizeStatusText,
|
|
31
27
|
wrapText,
|
|
32
28
|
} from "./style.ts";
|
|
33
29
|
|
|
34
|
-
type
|
|
30
|
+
type UsageTotals = {
|
|
35
31
|
input: number;
|
|
36
32
|
output: number;
|
|
37
33
|
cacheRead: number;
|
|
38
34
|
cacheWrite: number;
|
|
39
35
|
cost: number;
|
|
40
36
|
latestCacheHitRate?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type Usage = UsageTotals & {
|
|
41
40
|
contextPercent: number | null;
|
|
42
41
|
contextWindow: number;
|
|
43
42
|
};
|
|
44
43
|
|
|
45
|
-
|
|
44
|
+
type UsageCache = { key: string; totals: UsageTotals };
|
|
45
|
+
const usageCaches = new WeakMap<ExtensionContext, UsageCache>();
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
/** Aggregate persisted usage once, but sample live context usage on every render. */
|
|
48
|
+
export function computeUsage(ctx: ExtensionContext): Usage {
|
|
48
49
|
const entries = ctx.sessionManager.getEntries();
|
|
49
50
|
const last = entries.at(-1);
|
|
50
|
-
const key = `${entries.length}:${last?.id ?? ""}`;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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);
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
const context = ctx.getContextUsage?.();
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
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,
|
|
82
85
|
};
|
|
83
|
-
usageCache = { key, usage };
|
|
84
|
-
return usage;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
function
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
return fillRow(`${pad}${FG_FAINT}${title}${RST} ${FG_FAINT}${"─".repeat(ruleWidth)}${RST}`, width, BG);
|
|
88
|
+
function row(width: number, label: string, value: string, background = BG): string {
|
|
89
|
+
const prefix = ` ${FG_FAINT}${label}${RST} `;
|
|
90
|
+
return fillRow(`${prefix}${truncateToWidth(value, Math.max(0, width - visibleWidth(prefix)), "…")}`, width, background);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
function
|
|
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);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function goalStatusGlyph(goal: ThreadGoal): { icon: string; color: string; label: string } {
|
|
93
|
+
function goalStatus(goal: ThreadGoal): { icon: string; color: string; label: string } {
|
|
102
94
|
switch (goal.status) {
|
|
103
|
-
case "active": return { icon: "
|
|
95
|
+
case "active": return { icon: "●", color: FG_ACC, label: "active" };
|
|
104
96
|
case "paused": return { icon: "○", color: FG_DIM, label: "paused" };
|
|
105
97
|
case "budgetLimited": return { icon: "▲", color: FG_WARN, label: "budget" };
|
|
106
98
|
case "complete": return { icon: "✓", color: FG_OK, label: "done" };
|
|
107
99
|
}
|
|
108
100
|
}
|
|
109
101
|
|
|
110
|
-
function
|
|
111
|
-
const
|
|
112
|
-
const
|
|
102
|
+
function renderGoal(width: number, goal: ThreadGoal): string[] {
|
|
103
|
+
const status = goalStatus(goal);
|
|
104
|
+
const budget = goal.tokenBudget
|
|
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
|
+
if (objective[0]) lines.push(fillRow(` ${FG_BRIGHT}${objective[0]}${RST}`, width, BG));
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
113
114
|
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
function renderWorkspace(width: number, ctx: ExtensionContext, footerData: ReadonlyFooterDataProvider | null): string {
|
|
116
|
+
const branch = footerData?.getGitBranch();
|
|
117
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
118
|
+
const parts = [
|
|
119
|
+
`${FG_BRIGHT}${formatCwd(ctx.sessionManager.getCwd())}${RST}`,
|
|
120
|
+
branch ? `${FG_INFO}${branch}${RST}` : undefined,
|
|
121
|
+
sessionName ? `${FG_MID}${sessionName}${RST}` : undefined,
|
|
122
|
+
].filter(Boolean).join(` ${FG_FAINT}·${RST} `);
|
|
123
|
+
return row(width, "cwd", parts);
|
|
124
|
+
}
|
|
120
125
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
));
|
|
132
|
-
return rows;
|
|
126
|
+
function renderCmux(width: number, cmux: CmuxContext): string {
|
|
127
|
+
const prefix = ` ${FG_FAINT}cmux${RST} `;
|
|
128
|
+
const available = Math.max(0, width - visibleWidth(prefix));
|
|
129
|
+
const surfaceText = cmux.surfaceRef ?? "";
|
|
130
|
+
const surface = truncateToWidth(surfaceText, available, "");
|
|
131
|
+
const separator = surface ? ` ${FG_FAINT}·${RST} ` : "";
|
|
132
|
+
const titleBudget = Math.max(0, available - visibleWidth(separator) - visibleWidth(surface));
|
|
133
|
+
const titleText = cmux.workspaceTitle ?? cmux.workspaceRef ?? "cmux";
|
|
134
|
+
const title = truncateToWidth(titleText, titleBudget, "…");
|
|
135
|
+
return fillRow(`${prefix}${FG_BRIGHT}${title}${RST}${separator}${FG_INFO}${surface}${RST}`, width, BG);
|
|
133
136
|
}
|
|
134
137
|
|
|
135
|
-
function
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
138
|
+
function renderRuntime(width: number, ctx: ExtensionContext, footerData: ReadonlyFooterDataProvider | null, thinkingLevel: string, usage: Usage): string | null {
|
|
139
|
+
const model = ctx.model;
|
|
140
|
+
if (!model) return null;
|
|
141
|
+
const provider = footerData && footerData.getAvailableProviderCount() > 1 ? `${model.provider}/` : "";
|
|
142
|
+
const thinking = model.reasoning ? ` ${FG_FAINT}·${RST} ${FG_MID}${thinkingLevel}${RST}` : "";
|
|
143
|
+
const percent = usage.contextPercent === null ? "?" : `${usage.contextPercent.toFixed(0)}%`;
|
|
144
|
+
const context = `${contextColor(usage.contextPercent)}${percent}/${formatTokens(usage.contextWindow)}${RST}`;
|
|
145
|
+
return row(width, "run", `${FG_BRIGHT}${provider}${model.id}${RST}${thinking} ${FG_FAINT}·${RST} ${context}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function validExtensionStatuses(footerData: ReadonlyFooterDataProvider | null): string[] {
|
|
149
|
+
if (!footerData) return [];
|
|
150
|
+
const statuses = footerData.getExtensionStatuses();
|
|
151
|
+
if (!(statuses && typeof (statuses as any).entries === "function")) return [];
|
|
152
|
+
const valid: [string, string][] = [];
|
|
153
|
+
for (const entry of statuses.entries()) {
|
|
154
|
+
if (!Array.isArray(entry) || typeof entry[0] !== "string" || typeof entry[1] !== "string") continue;
|
|
155
|
+
const text = sanitizeStatusText(entry[1]);
|
|
156
|
+
if (text) valid.push([entry[0], text]);
|
|
152
157
|
}
|
|
153
|
-
return
|
|
158
|
+
return valid.sort(([a], [b]) => a.localeCompare(b)).map(([, text]) => text);
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
export function renderStatusDock(
|
|
@@ -160,67 +165,33 @@ export function renderStatusDock(
|
|
|
160
165
|
thinkingLevel: string,
|
|
161
166
|
focused: boolean,
|
|
162
167
|
getCmuxContext: () => CmuxContext | null,
|
|
168
|
+
maxRows = 4,
|
|
163
169
|
): string[] {
|
|
170
|
+
const limit = Math.max(0, Math.min(4, Math.floor(maxRows)));
|
|
171
|
+
if (limit === 0) return [];
|
|
172
|
+
const hint = fillRow(
|
|
173
|
+
` ${FG_DIM}${focused ? "↑↓ move Enter open c copy Esc close" : "Ctrl+Shift+H focus"}${RST}`,
|
|
174
|
+
width,
|
|
175
|
+
BG_HDR,
|
|
176
|
+
);
|
|
177
|
+
if (limit === 1) return [hint];
|
|
178
|
+
|
|
164
179
|
const usage = computeUsage(ctx);
|
|
165
180
|
const goal = readSessionGoal(ctx);
|
|
181
|
+
const cmux = getCmuxContext();
|
|
166
182
|
const rows: string[] = [];
|
|
167
183
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
rows.push(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
rows.push(dockRow(width, "cwd", workspace));
|
|
179
|
-
rows.push(renderSessRow(width, ctx, getCmuxContext()));
|
|
180
|
-
|
|
181
|
-
const model = ctx.model;
|
|
182
|
-
if (model) {
|
|
183
|
-
const provider = footerData && footerData.getAvailableProviderCount() > 1
|
|
184
|
-
? `${FG_FAINT}${model.provider}${RST} `
|
|
185
|
-
: "";
|
|
186
|
-
const thinking = model.reasoning ? ` ${FG_FAINT}•${RST} ${FG_MID}${thinkingLevel}${RST}` : "";
|
|
187
|
-
rows.push(dockRow(width, "model", `${provider}${FG_BRIGHT}${model.id}${RST}${thinking}`));
|
|
188
|
-
}
|
|
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
|
-
));
|
|
214
|
-
|
|
215
|
-
const statuses = footerData ? [...footerData.getExtensionStatuses().entries()].sort(([a], [b]) => a.localeCompare(b)) : [];
|
|
216
|
-
for (const [, text] of statuses.slice(0, 2)) {
|
|
217
|
-
rows.push(dockRow(width, "stat", `${FG_INFO}•${RST} ${FG_MID}${sanitizeStatusText(text)}${RST}`));
|
|
184
|
+
// A cmux surface reference is operational identity. Keep it ahead of optional
|
|
185
|
+
// title, runtime, and status text so truncation cannot discard it.
|
|
186
|
+
if (cmux) rows.push(renderCmux(width, cmux));
|
|
187
|
+
if (goal) rows.push(...renderGoal(width, goal));
|
|
188
|
+
if (!cmux && !goal) rows.push(renderWorkspace(width, ctx, footerData));
|
|
189
|
+
const runtime = renderRuntime(width, ctx, footerData, thinkingLevel, usage);
|
|
190
|
+
if (runtime) rows.push(runtime);
|
|
191
|
+
if (!goal) {
|
|
192
|
+
const status = validExtensionStatuses(footerData)[0];
|
|
193
|
+
if (status) rows.push(row(width, "stat", `${FG_MID}${status}${RST}`));
|
|
218
194
|
}
|
|
219
195
|
|
|
220
|
-
rows.
|
|
221
|
-
const hint = focused
|
|
222
|
-
? `${FG_DIM}↑↓${RST} ${FG_MID}nav${RST} ${FG_FAINT}·${RST} ${FG_DIM}Enter${RST} ${FG_MID}expand${RST} ${FG_FAINT}·${RST} ${FG_DIM}c${RST} ${FG_MID}copy${RST} ${FG_FAINT}·${RST} ${FG_DIM}Esc${RST} ${FG_MID}done${RST}`
|
|
223
|
-
: `${FG_DIM}Ctrl+Shift+H${RST} ${FG_MID}focus${RST}`;
|
|
224
|
-
rows.push(fillRow(` ${hint}`, width, BG_HDR));
|
|
225
|
-
return rows;
|
|
196
|
+
return [...rows.slice(0, limit - 1), hint];
|
|
226
197
|
}
|