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