pi-message-sidebar 1.4.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 +56 -66
- package/src/sidebar-component.ts +84 -69
- package/src/status-dock.ts +103 -85
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,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 : 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;
|
|
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(4, 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,112 @@ 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 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";
|
|
166
173
|
return [
|
|
167
|
-
fillRow(` ${BOLD}${FG_BRIGHT}Messages${RST} ${FG_FAINT}${
|
|
168
|
-
fillRow(` ${
|
|
174
|
+
fillRow(` ${BOLD}${FG_BRIGHT}Messages${RST} ${FG_FAINT}${location}${RST}`, width, BG_HDR),
|
|
175
|
+
fillRow(` ${FG_DIM}${detail}${RST}`, width, BG),
|
|
169
176
|
];
|
|
170
177
|
}
|
|
171
178
|
|
|
172
179
|
private renderBody(width: number, height: number): string[] {
|
|
173
180
|
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
|
-
}
|
|
181
|
+
if (this.messages.length === 0) return this.padBottom([fillRow(` ${FG_DIM}No messages yet${RST}`, width, BG)], height, width);
|
|
177
182
|
|
|
178
183
|
const selected = this.selectedIndex();
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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));
|
|
185
192
|
}
|
|
193
|
+
this.viewportStartId = this.messages[start]?.id ?? null;
|
|
194
|
+
return this.padBottom(lines.slice(0, height), height, width);
|
|
195
|
+
}
|
|
186
196
|
|
|
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);
|
|
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--;
|
|
198
205
|
}
|
|
206
|
+
return start;
|
|
207
|
+
}
|
|
199
208
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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;
|
|
203
219
|
}
|
|
204
220
|
|
|
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;
|
|
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;
|
|
215
226
|
}
|
|
216
|
-
return
|
|
227
|
+
return true;
|
|
217
228
|
}
|
|
218
229
|
|
|
219
|
-
private
|
|
230
|
+
private messageRowCount(index: number, width: number, maxRows: number): number {
|
|
231
|
+
return this.renderMessage(index, width, maxRows).length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private padBottom(lines: string[], height: number, width: number): string[] {
|
|
220
235
|
const padding = Array.from({ length: Math.max(0, height - lines.length) }, () => fillRow(" ", width, BG));
|
|
221
|
-
return [...
|
|
236
|
+
return [...lines, ...padding];
|
|
222
237
|
}
|
|
223
238
|
|
|
224
|
-
private renderMessage(index: number, width: number, maxRows =
|
|
239
|
+
private renderMessage(index: number, width: number, maxRows = Number.MAX_SAFE_INTEGER): string[] {
|
|
225
240
|
const message = this.messages[index]!;
|
|
226
241
|
const selected = message.id === this.selectedId;
|
|
227
242
|
const background = selected ? BG_SEL : BG;
|
|
228
243
|
const arrow = selected && this.focused ? `${FG_ACC}›${RST}` : " ";
|
|
229
|
-
const number = `${FG_FAINT}${String(message.index).padStart(2)}${RST}`;
|
|
230
|
-
const time = `${FG_TIME}${formatTime(message.timestamp)}${RST}`;
|
|
231
244
|
if (!this.expandedIds.has(message.id) || maxRows <= 1) {
|
|
232
|
-
const prefix = ` ${arrow}
|
|
245
|
+
const prefix = ` ${arrow} `;
|
|
233
246
|
const text = truncateToWidth(message.text.replace(/\s+/g, " "), Math.max(0, width - visibleWidth(prefix)), "…");
|
|
234
247
|
return [fillRow(`${prefix}${selected ? FG_BRIGHT : FG_NORM}${text}${RST}`, width, background)];
|
|
235
248
|
}
|
|
236
249
|
|
|
237
|
-
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)];
|
|
238
253
|
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));
|
|
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);
|
|
247
258
|
}
|
|
248
259
|
return lines;
|
|
249
260
|
}
|
|
250
261
|
|
|
251
262
|
private selectedIndex(): number {
|
|
252
263
|
if (this.messages.length === 0) return -1;
|
|
253
|
-
const index = this.
|
|
264
|
+
const index = this.indexForId(this.selectedId);
|
|
254
265
|
return index >= 0 ? index : this.messages.length - 1;
|
|
255
266
|
}
|
|
256
267
|
|
|
268
|
+
private indexForId(id: string | null): number {
|
|
269
|
+
return id ? this.messages.findIndex((message) => message.id === id) : -1;
|
|
270
|
+
}
|
|
271
|
+
|
|
257
272
|
private signature(width: number, height: number): string {
|
|
258
273
|
const usage = this.options.ctx.getContextUsage?.();
|
|
259
274
|
const statuses = this.options.getFooterData()?.getExtensionStatuses();
|
|
@@ -266,8 +281,8 @@ export class SidebarComponent implements Component {
|
|
|
266
281
|
model: this.options.ctx.model?.id,
|
|
267
282
|
thinking: this.options.getThinkingLevel(),
|
|
268
283
|
usage,
|
|
269
|
-
statuses: statuses ? [...statuses.entries()] : [],
|
|
270
|
-
goal: goal ? `${goal.goalId}:${goal.status}:${goal.tokensUsed}:${goal.activeSeconds}:${goal.updatedAt}` : 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,
|
|
271
286
|
cmux: cmux ? `${cmux.workspaceTitle}:${cmux.workspaceRef}:${cmux.surfaceRef}` : null,
|
|
272
287
|
});
|
|
273
288
|
}
|
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 {
|
|
@@ -95,30 +101,15 @@ function goalStatus(goal: ThreadGoal): { icon: string; color: string; label: str
|
|
|
95
101
|
|
|
96
102
|
function renderGoal(width: number, goal: ThreadGoal): 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
|
+
if (objective[0]) lines.push(fillRow(` ${FG_BRIGHT}${objective[0]}${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,39 @@ 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
|
+
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);
|
|
136
|
+
}
|
|
137
|
+
|
|
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]);
|
|
140
157
|
}
|
|
141
|
-
|
|
142
|
-
return row(width, "sess", `${FG_INFO}#${shortId}${RST}`);
|
|
158
|
+
return valid.sort(([a], [b]) => a.localeCompare(b)).map(([, text]) => text);
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
export function renderStatusDock(
|
|
@@ -149,31 +165,33 @@ export function renderStatusDock(
|
|
|
149
165
|
thinkingLevel: string,
|
|
150
166
|
focused: boolean,
|
|
151
167
|
getCmuxContext: () => CmuxContext | null,
|
|
152
|
-
maxRows =
|
|
168
|
+
maxRows = 4,
|
|
153
169
|
): string[] {
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
|
|
156
179
|
const usage = computeUsage(ctx);
|
|
157
180
|
const goal = readSessionGoal(ctx);
|
|
181
|
+
const cmux = getCmuxContext();
|
|
158
182
|
const rows: string[] = [];
|
|
159
|
-
if (goal) rows.push(...renderGoal(width, goal));
|
|
160
|
-
rows.push(renderWorkspace(width, ctx, footerData));
|
|
161
|
-
rows.push(renderSession(width, ctx, getCmuxContext()));
|
|
162
183
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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}`));
|
|
168
194
|
}
|
|
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
195
|
|
|
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)!];
|
|
196
|
+
return [...rows.slice(0, limit - 1), hint];
|
|
179
197
|
}
|