pi-tui-footer 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/extensions/config.ts +196 -0
- package/extensions/editor.ts +295 -0
- package/extensions/footer.ts +344 -0
- package/extensions/fullscreen-scroll.ts +34 -0
- package/extensions/git.ts +151 -0
- package/extensions/header.ts +244 -0
- package/extensions/icons.ts +190 -0
- package/extensions/index.ts +457 -0
- package/extensions/peek.ts +168 -0
- package/extensions/rounded-tools.ts +269 -0
- package/extensions/runtime.ts +174 -0
- package/extensions/session-lifecycle.ts +43 -0
- package/extensions/settings-command.ts +511 -0
- package/extensions/state.ts +104 -0
- package/extensions/telemetry.ts +303 -0
- package/extensions/utils.ts +381 -0
- package/package.json +41 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentSettledEvent,
|
|
3
|
+
AgentStartEvent,
|
|
4
|
+
MessageEndEvent,
|
|
5
|
+
MessageStartEvent,
|
|
6
|
+
MessageUpdateEvent,
|
|
7
|
+
ToolExecutionStartEvent,
|
|
8
|
+
TurnEndEvent,
|
|
9
|
+
TurnStartEvent,
|
|
10
|
+
Theme,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { IconMode, TelemetryConfig } from "./config.ts";
|
|
13
|
+
import { resolveGlyphs } from "./icons.ts";
|
|
14
|
+
import { finiteOrZero, fmtTokens, formatDuration, formatInputBreakdown } from "./utils.ts";
|
|
15
|
+
|
|
16
|
+
const STALL_THRESHOLD_MS = 1000;
|
|
17
|
+
|
|
18
|
+
type TelemetryEvent =
|
|
19
|
+
| AgentStartEvent
|
|
20
|
+
| AgentSettledEvent
|
|
21
|
+
| TurnStartEvent
|
|
22
|
+
| MessageStartEvent
|
|
23
|
+
| MessageUpdateEvent
|
|
24
|
+
| MessageEndEvent
|
|
25
|
+
| ToolExecutionStartEvent
|
|
26
|
+
| TurnEndEvent;
|
|
27
|
+
type AgentMessage = MessageStartEvent["message"];
|
|
28
|
+
type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
|
|
29
|
+
|
|
30
|
+
interface MessageTiming {
|
|
31
|
+
lastUpdateMs: number;
|
|
32
|
+
firstOutputMs: number | null;
|
|
33
|
+
inStall: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface TurnTiming {
|
|
37
|
+
startMs: number;
|
|
38
|
+
firstTokenMs: number | null;
|
|
39
|
+
currentMessage: MessageTiming | null;
|
|
40
|
+
messages: AssistantMessage[];
|
|
41
|
+
generationMs: number;
|
|
42
|
+
stallMs: number;
|
|
43
|
+
stallCount: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TurnTelemetry {
|
|
47
|
+
tps: number | null;
|
|
48
|
+
ttftMs: number;
|
|
49
|
+
totalMs: number;
|
|
50
|
+
inputTokens: number;
|
|
51
|
+
outputTokens: number;
|
|
52
|
+
cacheReadTokens: number;
|
|
53
|
+
stallMs: number;
|
|
54
|
+
stallCount: number;
|
|
55
|
+
rateUsdPerMTokens: number | null;
|
|
56
|
+
generationMs: number;
|
|
57
|
+
totalTokens: number;
|
|
58
|
+
costUsd: number;
|
|
59
|
+
measurementMs: number | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
|
|
63
|
+
return message.role === "assistant";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function round(value: number, decimals: number): number {
|
|
67
|
+
const factor = 10 ** decimals;
|
|
68
|
+
return Math.round(value * factor) / factor;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class TurnTelemetryTracker {
|
|
72
|
+
private readonly now: () => number;
|
|
73
|
+
private turn: TurnTiming | undefined;
|
|
74
|
+
private agentStartMs: number | null = null;
|
|
75
|
+
private agentTurns: TurnTelemetry[] = [];
|
|
76
|
+
|
|
77
|
+
constructor(now: () => number = () => performance.now()) {
|
|
78
|
+
this.now = now;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
handle(event: TelemetryEvent): TurnTelemetry | undefined {
|
|
82
|
+
switch (event.type) {
|
|
83
|
+
case "agent_start":
|
|
84
|
+
if (this.agentStartMs === null) {
|
|
85
|
+
this.agentStartMs = this.now();
|
|
86
|
+
this.agentTurns = [];
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
case "agent_settled":
|
|
90
|
+
return this.endAgent();
|
|
91
|
+
case "turn_start":
|
|
92
|
+
this.startTurn();
|
|
93
|
+
return;
|
|
94
|
+
case "message_start":
|
|
95
|
+
this.startMessage(event.message);
|
|
96
|
+
return;
|
|
97
|
+
case "message_update":
|
|
98
|
+
this.updateMessage(event);
|
|
99
|
+
return;
|
|
100
|
+
case "message_end":
|
|
101
|
+
this.endMessage(event.message);
|
|
102
|
+
return;
|
|
103
|
+
case "tool_execution_start":
|
|
104
|
+
return;
|
|
105
|
+
case "turn_end":
|
|
106
|
+
return this.endTurnAndCollect();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private startTurn(): void {
|
|
111
|
+
this.turn = {
|
|
112
|
+
startMs: this.now(),
|
|
113
|
+
firstTokenMs: null,
|
|
114
|
+
currentMessage: null,
|
|
115
|
+
messages: [],
|
|
116
|
+
generationMs: 0,
|
|
117
|
+
stallMs: 0,
|
|
118
|
+
stallCount: 0,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private startMessage(message: AgentMessage): void {
|
|
123
|
+
if (!this.turn || !isAssistantMessage(message)) return;
|
|
124
|
+
const now = this.now();
|
|
125
|
+
this.turn.currentMessage = {
|
|
126
|
+
lastUpdateMs: now,
|
|
127
|
+
firstOutputMs: null,
|
|
128
|
+
inStall: false,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private updateMessage(event: MessageUpdateEvent): void {
|
|
133
|
+
const turn = this.turn;
|
|
134
|
+
const current = turn?.currentMessage;
|
|
135
|
+
const streamEvent = event.assistantMessageEvent;
|
|
136
|
+
if (
|
|
137
|
+
streamEvent.type !== "text_delta" &&
|
|
138
|
+
streamEvent.type !== "thinking_delta" &&
|
|
139
|
+
streamEvent.type !== "toolcall_delta"
|
|
140
|
+
) return;
|
|
141
|
+
if (streamEvent.delta.length === 0) return;
|
|
142
|
+
const message = event.message;
|
|
143
|
+
if (!turn || !current || !isAssistantMessage(message)) return;
|
|
144
|
+
|
|
145
|
+
const now = this.now();
|
|
146
|
+
if (current.firstOutputMs === null) {
|
|
147
|
+
current.firstOutputMs = now;
|
|
148
|
+
turn.firstTokenMs ??= now;
|
|
149
|
+
current.lastUpdateMs = now;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const gap = now - current.lastUpdateMs;
|
|
154
|
+
if (gap >= STALL_THRESHOLD_MS) {
|
|
155
|
+
if (!current.inStall) turn.stallCount++;
|
|
156
|
+
current.inStall = true;
|
|
157
|
+
turn.stallMs += gap;
|
|
158
|
+
} else {
|
|
159
|
+
current.inStall = false;
|
|
160
|
+
}
|
|
161
|
+
current.lastUpdateMs = now;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private endMessage(message: AgentMessage): void {
|
|
165
|
+
const turn = this.turn;
|
|
166
|
+
if (!turn || !isAssistantMessage(message)) return;
|
|
167
|
+
|
|
168
|
+
const current = turn.currentMessage;
|
|
169
|
+
if (current) {
|
|
170
|
+
const endMs = this.now();
|
|
171
|
+
turn.generationMs = endMs - turn.startMs;
|
|
172
|
+
if (current.firstOutputMs === null && finiteOrZero(message.usage?.output) > 0) {
|
|
173
|
+
turn.firstTokenMs ??= endMs;
|
|
174
|
+
}
|
|
175
|
+
turn.currentMessage = null;
|
|
176
|
+
}
|
|
177
|
+
turn.messages.push(message);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private endTurnAndCollect(): TurnTelemetry | undefined {
|
|
181
|
+
const telemetry = this.endTurn();
|
|
182
|
+
if (telemetry && this.agentStartMs !== null) this.agentTurns.push(telemetry);
|
|
183
|
+
return telemetry;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private endTurn(): TurnTelemetry | undefined {
|
|
187
|
+
const turn = this.turn;
|
|
188
|
+
this.turn = undefined;
|
|
189
|
+
if (!turn || turn.firstTokenMs === null || turn.messages.length === 0) return;
|
|
190
|
+
|
|
191
|
+
const endMs = this.now();
|
|
192
|
+
let inputTokens = 0;
|
|
193
|
+
let outputTokens = 0;
|
|
194
|
+
let cacheReadTokens = 0;
|
|
195
|
+
let totalTokens = 0;
|
|
196
|
+
let costUsd = 0;
|
|
197
|
+
for (const message of turn.messages) {
|
|
198
|
+
// match /session's "uncached" total: cacheWrite is fresh, near-full-price
|
|
199
|
+
// content; only cacheRead is discounted repeat content.
|
|
200
|
+
inputTokens += finiteOrZero(message.usage?.input) + finiteOrZero(message.usage?.cacheWrite);
|
|
201
|
+
outputTokens += finiteOrZero(message.usage?.output);
|
|
202
|
+
cacheReadTokens += finiteOrZero(message.usage?.cacheRead);
|
|
203
|
+
totalTokens += finiteOrZero(message.usage?.totalTokens);
|
|
204
|
+
costUsd += finiteOrZero(message.usage?.cost?.total);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const measurementMs = outputTokens > 0 && turn.generationMs > 0 ? turn.generationMs : null;
|
|
208
|
+
const tps = measurementMs === null
|
|
209
|
+
? null
|
|
210
|
+
: round(outputTokens / (measurementMs / 1000), 1);
|
|
211
|
+
const validCost = Number.isFinite(costUsd) && costUsd > 0;
|
|
212
|
+
const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
|
|
213
|
+
return {
|
|
214
|
+
tps,
|
|
215
|
+
ttftMs: turn.firstTokenMs - turn.startMs,
|
|
216
|
+
totalMs: endMs - turn.startMs,
|
|
217
|
+
inputTokens,
|
|
218
|
+
outputTokens,
|
|
219
|
+
cacheReadTokens,
|
|
220
|
+
stallMs: turn.stallMs,
|
|
221
|
+
stallCount: turn.stallCount,
|
|
222
|
+
rateUsdPerMTokens: validCost && validTokens
|
|
223
|
+
? round(costUsd / (totalTokens / 1_000_000), 2)
|
|
224
|
+
: null,
|
|
225
|
+
generationMs: turn.generationMs,
|
|
226
|
+
totalTokens,
|
|
227
|
+
costUsd: validCost ? costUsd : 0,
|
|
228
|
+
measurementMs,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private endAgent(): TurnTelemetry | undefined {
|
|
233
|
+
const startMs = this.agentStartMs;
|
|
234
|
+
const turns = this.agentTurns;
|
|
235
|
+
this.agentStartMs = null;
|
|
236
|
+
this.agentTurns = [];
|
|
237
|
+
if (startMs === null || turns.length === 0) return;
|
|
238
|
+
|
|
239
|
+
const outputTokens = turns.reduce((sum, turn) => sum + turn.outputTokens, 0);
|
|
240
|
+
const inputTokens = turns.reduce((sum, turn) => sum + turn.inputTokens, 0);
|
|
241
|
+
const cacheReadTokens = turns.reduce((sum, turn) => sum + turn.cacheReadTokens, 0);
|
|
242
|
+
const totalTokens = turns.reduce((sum, turn) => sum + turn.totalTokens, 0);
|
|
243
|
+
const costUsd = turns.reduce((sum, turn) => sum + turn.costUsd, 0);
|
|
244
|
+
const stallMs = turns.reduce((sum, turn) => sum + turn.stallMs, 0);
|
|
245
|
+
const stallCount = turns.reduce((sum, turn) => sum + turn.stallCount, 0);
|
|
246
|
+
const generationMs = turns.reduce((sum, turn) => sum + turn.generationMs, 0);
|
|
247
|
+
const measurementMs = outputTokens > 0 && generationMs > 0 ? generationMs : null;
|
|
248
|
+
const tps = measurementMs === null
|
|
249
|
+
? null
|
|
250
|
+
: round(outputTokens / (measurementMs / 1000), 1);
|
|
251
|
+
const validRate = costUsd > 0 && totalTokens > 0;
|
|
252
|
+
return {
|
|
253
|
+
tps,
|
|
254
|
+
ttftMs: turns[0]!.ttftMs,
|
|
255
|
+
totalMs: this.now() - startMs,
|
|
256
|
+
inputTokens,
|
|
257
|
+
outputTokens,
|
|
258
|
+
cacheReadTokens,
|
|
259
|
+
stallMs,
|
|
260
|
+
stallCount,
|
|
261
|
+
rateUsdPerMTokens: validRate ? round(costUsd / (totalTokens / 1_000_000), 2) : null,
|
|
262
|
+
generationMs,
|
|
263
|
+
totalTokens,
|
|
264
|
+
costUsd,
|
|
265
|
+
measurementMs,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function formatTurnDuration(ms: number): string {
|
|
271
|
+
return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function formatTurnTelemetry(
|
|
275
|
+
telemetry: TurnTelemetry,
|
|
276
|
+
theme: Theme,
|
|
277
|
+
config: TelemetryConfig,
|
|
278
|
+
iconMode: IconMode,
|
|
279
|
+
): string {
|
|
280
|
+
const glyphs = resolveGlyphs(iconMode);
|
|
281
|
+
const parts: string[] = [];
|
|
282
|
+
if (config.tps) {
|
|
283
|
+
const value = telemetry.tps === null ? "—" : `${telemetry.tps.toFixed(1)} tok/s`;
|
|
284
|
+
parts.push(theme.fg(telemetry.tps === null ? "muted" : "accent", `${glyphs.speed} TPS ${value}`));
|
|
285
|
+
}
|
|
286
|
+
if (config.ttft) {
|
|
287
|
+
parts.push(theme.fg("text", `${glyphs.latency} TTFT ${formatTurnDuration(telemetry.ttftMs)}`));
|
|
288
|
+
}
|
|
289
|
+
if (config.duration) {
|
|
290
|
+
parts.push(theme.fg("success", `${glyphs.done} ${formatTurnDuration(telemetry.totalMs)}`));
|
|
291
|
+
}
|
|
292
|
+
if (config.tokens) {
|
|
293
|
+
parts.push(theme.fg("accent", `${glyphs.input} ${formatInputBreakdown(telemetry.inputTokens, telemetry.cacheReadTokens)}`));
|
|
294
|
+
parts.push(theme.fg("success", `${glyphs.output} ${fmtTokens(telemetry.outputTokens)}`));
|
|
295
|
+
}
|
|
296
|
+
if (config.stalls && telemetry.stallMs > 0) {
|
|
297
|
+
parts.push(theme.fg("warning", `${glyphs.stall} stall ${telemetry.stallCount}x / ${formatTurnDuration(telemetry.stallMs)}`));
|
|
298
|
+
}
|
|
299
|
+
if (config.cost && telemetry.rateUsdPerMTokens !== null) {
|
|
300
|
+
parts.push(theme.fg("warning", `${glyphs.cost} $${telemetry.rateUsdPerMTokens.toFixed(2)}/M`));
|
|
301
|
+
}
|
|
302
|
+
return parts.join(` ${theme.fg("dim", "|")} `);
|
|
303
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
stripTerminalSequences as stripTerminalSequencesFromTui,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
export { truncateToWidth, visibleWidth };
|
|
11
|
+
|
|
12
|
+
// pi-tui handles the common ANSI/OSC/APC forms. Keep a generic CSI pass for
|
|
13
|
+
// valid sequences whose final byte is outside its intentionally narrow set.
|
|
14
|
+
const CSI_SEQUENCE = /(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g;
|
|
15
|
+
|
|
16
|
+
export function stripAnsi(text: string): string {
|
|
17
|
+
// Remove CSI first so pi-tui's parser cannot mistake an unsupported CSI
|
|
18
|
+
// final byte for part of a later sequence and swallow intervening text.
|
|
19
|
+
return stripTerminalSequencesFromTui(text.replace(CSI_SEQUENCE, ""));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatCwd(cwd: string): string {
|
|
23
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
24
|
+
if (!home) return cwd;
|
|
25
|
+
const resolvedCwd = resolve(cwd);
|
|
26
|
+
const resolvedHome = resolve(home);
|
|
27
|
+
const rel = relative(resolvedHome, resolvedCwd);
|
|
28
|
+
const insideHome =
|
|
29
|
+
rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
30
|
+
if (!insideHome) return cwd;
|
|
31
|
+
return rel === "" ? "~" : `~${sep}${rel}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function basenamePath(path: string): string {
|
|
35
|
+
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Truncate branch names from the end so prefixes like `fix/` stay visible. */
|
|
39
|
+
export function truncateBranch(branch: string, maxLen: number): string {
|
|
40
|
+
if (branch.length <= maxLen) return branch;
|
|
41
|
+
if (maxLen <= 3) return "...".slice(0, maxLen);
|
|
42
|
+
return `${branch.slice(0, maxLen - 3)}...`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function truncatePath(path: string, maxLen: number): string {
|
|
46
|
+
if (path.length <= maxLen) return path;
|
|
47
|
+
if (maxLen <= 3) return "...".slice(0, maxLen);
|
|
48
|
+
const sepChar = path.includes("/") ? "/" : "\\";
|
|
49
|
+
const parts = path.split(/[\\/]/);
|
|
50
|
+
if (parts.length <= 2) return path.slice(0, maxLen - 3) + "...";
|
|
51
|
+
// Keep first segment (e.g. ~) and as many trailing segments as fit.
|
|
52
|
+
const tail: string[] = [];
|
|
53
|
+
let tailLen = 0;
|
|
54
|
+
for (let i = parts.length - 1; i >= 1; i--) {
|
|
55
|
+
const seg = parts[i]!;
|
|
56
|
+
if (tailLen + seg.length + 4 > maxLen) break;
|
|
57
|
+
tail.unshift(seg);
|
|
58
|
+
tailLen += seg.length + 1;
|
|
59
|
+
}
|
|
60
|
+
const head = parts[0]!;
|
|
61
|
+
const result = `${head}${sepChar}...${sepChar}${tail.join(sepChar)}`;
|
|
62
|
+
return result.length > maxLen ? result.slice(0, maxLen - 3) + "..." : result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function finiteOrZero(value: unknown): number {
|
|
66
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function fmtTokens(n: number): string {
|
|
70
|
+
if (n < 1000) return n.toString();
|
|
71
|
+
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
|
|
72
|
+
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
|
73
|
+
if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
74
|
+
return `${Math.round(n / 1_000_000)}M`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatInputBreakdown(uncached: number, cacheRead: number): string {
|
|
78
|
+
const total = fmtTokens(uncached + cacheRead);
|
|
79
|
+
return cacheRead > 0
|
|
80
|
+
? `${total} (U ${fmtTokens(uncached)} + R ${fmtTokens(cacheRead)})`
|
|
81
|
+
: total;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatDuration(ms: number): string {
|
|
85
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
86
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
87
|
+
const s = totalSeconds % 60;
|
|
88
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
89
|
+
if (totalMinutes < 60) return `${totalMinutes}m ${s}s`;
|
|
90
|
+
const m = totalMinutes % 60;
|
|
91
|
+
const h = Math.floor(totalMinutes / 60);
|
|
92
|
+
return `${h}h ${m}m ${s}s`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatModelLabel(model: { provider?: string; id?: string } | null | undefined): string {
|
|
96
|
+
if (!model?.id) return "no-model";
|
|
97
|
+
return model.provider ? `${model.provider}/${model.id}` : model.id;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatProviderLabel(provider: string | undefined, capitalize: boolean): string {
|
|
101
|
+
if (!provider) return "Unknown";
|
|
102
|
+
return capitalize ? provider.charAt(0).toUpperCase() + provider.slice(1) : provider;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function alignRight(left: string, right: string, width: number, theme: Theme): string {
|
|
106
|
+
const rightW = visibleWidth(right);
|
|
107
|
+
if (rightW > width) {
|
|
108
|
+
right = truncateToWidth(right, width, theme.fg("dim", "..."));
|
|
109
|
+
}
|
|
110
|
+
const leftW = visibleWidth(left);
|
|
111
|
+
const rightW2 = visibleWidth(right);
|
|
112
|
+
const pad = width - leftW - rightW2;
|
|
113
|
+
if (pad >= 1) {
|
|
114
|
+
return left + " ".repeat(pad) + right;
|
|
115
|
+
}
|
|
116
|
+
const availableForLeft = Math.max(0, width - rightW2 - 1);
|
|
117
|
+
const truncatedLeft =
|
|
118
|
+
availableForLeft > 0 ? truncateToWidth(left, availableForLeft, theme.fg("dim", "...")) : "";
|
|
119
|
+
return truncatedLeft ? truncatedLeft + " " + right : right;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type PrioritizedSegment = {
|
|
123
|
+
text: string;
|
|
124
|
+
priority: number;
|
|
125
|
+
/** Compact form swapped in before any segment is truncated or dropped. */
|
|
126
|
+
compactText?: string;
|
|
127
|
+
/** Segment-aware truncation replacing the generic truncateToWidth. */
|
|
128
|
+
truncate?: (text: string, maxWidth: number, ellipsis: string) => string;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Pack segments into maxWidth: compact segments first, then shrink/drop the
|
|
133
|
+
* lowest-priority segments (higher priority = survives longer). Returns the
|
|
134
|
+
* surviving segment texts in original order, space-joined.
|
|
135
|
+
*/
|
|
136
|
+
export function fitSegmentsByPriority(
|
|
137
|
+
segs: readonly PrioritizedSegment[],
|
|
138
|
+
maxW: number,
|
|
139
|
+
ellipsis = "...",
|
|
140
|
+
): string[] {
|
|
141
|
+
const items = segs.map((s) => ({
|
|
142
|
+
text: s.text,
|
|
143
|
+
compactText: s.compactText,
|
|
144
|
+
priority: s.priority,
|
|
145
|
+
truncate: s.truncate,
|
|
146
|
+
w: visibleWidth(s.text),
|
|
147
|
+
}));
|
|
148
|
+
const totalW = () => {
|
|
149
|
+
const active = items.filter((it) => it.text !== "");
|
|
150
|
+
return active.reduce((a, it) => a + it.w, 0) + Math.max(0, active.length - 1);
|
|
151
|
+
};
|
|
152
|
+
// Compact (e.g. cwd -> basename) before sacrificing any segment content.
|
|
153
|
+
if (totalW() > maxW) {
|
|
154
|
+
for (const item of items) {
|
|
155
|
+
if (!item.compactText || visibleWidth(item.compactText) >= item.w) continue;
|
|
156
|
+
item.text = item.compactText;
|
|
157
|
+
item.w = visibleWidth(item.text);
|
|
158
|
+
if (totalW() <= maxW) break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
while (totalW() > maxW) {
|
|
162
|
+
let target = -1;
|
|
163
|
+
for (let i = 0; i < items.length; i++) {
|
|
164
|
+
if (items[i].text !== "" && (target === -1 || items[i].priority < items[target].priority)) {
|
|
165
|
+
target = i;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (target === -1) break;
|
|
169
|
+
const others = items.filter((_, i) => i !== target && items[i].text !== "");
|
|
170
|
+
const otherW = others.reduce((a, it) => a + it.w, 0) + Math.max(0, others.length - 1);
|
|
171
|
+
const avail = maxW - otherW - (others.length > 0 ? 1 : 0);
|
|
172
|
+
if (avail <= visibleWidth(ellipsis)) {
|
|
173
|
+
items[target].text = "";
|
|
174
|
+
items[target].w = 0;
|
|
175
|
+
} else if (avail < items[target].w) {
|
|
176
|
+
const truncate = items[target].truncate;
|
|
177
|
+
items[target].text = truncate
|
|
178
|
+
? truncate(items[target].text, avail, ellipsis)
|
|
179
|
+
: truncateToWidth(items[target].text, avail, ellipsis);
|
|
180
|
+
items[target].w = visibleWidth(items[target].text);
|
|
181
|
+
} else {
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return items.filter((it) => it.text !== "").map((it) => it.text);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function stressColor(value: number, warn = 70, danger = 90): ThemeColor {
|
|
189
|
+
if (value >= danger) return "error";
|
|
190
|
+
if (value >= warn) return "warning";
|
|
191
|
+
return "accent";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function cacheHitColor(value: number): ThemeColor {
|
|
195
|
+
if (value < 30) return "error";
|
|
196
|
+
if (value < 70) return "warning";
|
|
197
|
+
return "success";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function providerColor(provider: string): ThemeColor {
|
|
201
|
+
switch (provider.toLowerCase()) {
|
|
202
|
+
case "anthropic":
|
|
203
|
+
return "accent";
|
|
204
|
+
case "openai":
|
|
205
|
+
case "openai-codex":
|
|
206
|
+
return "success";
|
|
207
|
+
case "google":
|
|
208
|
+
case "google-vertex":
|
|
209
|
+
return "warning";
|
|
210
|
+
case "amazon-bedrock":
|
|
211
|
+
return "thinkingHigh";
|
|
212
|
+
case "github-copilot":
|
|
213
|
+
return "mdLink";
|
|
214
|
+
case "deepseek":
|
|
215
|
+
return "thinkingLow";
|
|
216
|
+
case "xai":
|
|
217
|
+
case "groq":
|
|
218
|
+
return "error";
|
|
219
|
+
default:
|
|
220
|
+
return "muted";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function effortColor(level: ThinkingLevel | string | undefined): ThemeColor {
|
|
225
|
+
switch (level) {
|
|
226
|
+
case "minimal":
|
|
227
|
+
return "thinkingMinimal";
|
|
228
|
+
case "low":
|
|
229
|
+
return "thinkingLow";
|
|
230
|
+
case "medium":
|
|
231
|
+
return "thinkingMedium";
|
|
232
|
+
case "high":
|
|
233
|
+
return "thinkingHigh";
|
|
234
|
+
case "xhigh":
|
|
235
|
+
return "thinkingXhigh";
|
|
236
|
+
default:
|
|
237
|
+
return "thinkingMedium";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function isEditorBorderLine(line: string): boolean {
|
|
242
|
+
if (typeof line !== "string") return false;
|
|
243
|
+
const plain = stripAnsi(line);
|
|
244
|
+
if (/^─+$/.test(plain)) return true;
|
|
245
|
+
if (/^─*\s*[↑↓]\s+\d+\s+more\s*─*$/.test(plain)) return true;
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function findBottomBorderIndex(lines: string[]): number {
|
|
250
|
+
for (let i = lines.length - 1; i >= 1; i--) {
|
|
251
|
+
const line = lines[i];
|
|
252
|
+
if (line !== undefined && isEditorBorderLine(line)) return i;
|
|
253
|
+
}
|
|
254
|
+
return Math.max(0, lines.length - 1);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function padRight(text: string, width: number, ellipsis = ""): string {
|
|
258
|
+
const clipped = truncateToWidth(text, width, ellipsis);
|
|
259
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function center(text: string, width: number): string {
|
|
263
|
+
if (width <= 0) return "";
|
|
264
|
+
const w = visibleWidth(text);
|
|
265
|
+
if (w >= width) return truncateToWidth(text, width, "...");
|
|
266
|
+
return `${" ".repeat(Math.floor((width - w) / 2))}${text}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function sanitizeStatus(text: string): string {
|
|
270
|
+
return stripAnsi(text)
|
|
271
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
272
|
+
.replace(/ +/g, " ")
|
|
273
|
+
.trim();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function formatThinkingLabel(level: string): string {
|
|
277
|
+
if (level === "off") return "thinking off";
|
|
278
|
+
return `${level} effort`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export const PI_BUILTIN_SLASH_COMMAND_NAMES = [
|
|
282
|
+
"settings",
|
|
283
|
+
"model",
|
|
284
|
+
"scoped-models",
|
|
285
|
+
"export",
|
|
286
|
+
"import",
|
|
287
|
+
"share",
|
|
288
|
+
"copy",
|
|
289
|
+
"name",
|
|
290
|
+
"session",
|
|
291
|
+
"changelog",
|
|
292
|
+
"hotkeys",
|
|
293
|
+
"fork",
|
|
294
|
+
"clone",
|
|
295
|
+
"tree",
|
|
296
|
+
"trust",
|
|
297
|
+
"login",
|
|
298
|
+
"logout",
|
|
299
|
+
"new",
|
|
300
|
+
"compact",
|
|
301
|
+
"resume",
|
|
302
|
+
"reload",
|
|
303
|
+
"quit",
|
|
304
|
+
] as const;
|
|
305
|
+
|
|
306
|
+
export function collectPiCommandNames(sessionCommands: readonly { name: string }[]): string[] {
|
|
307
|
+
const names = new Set<string>(PI_BUILTIN_SLASH_COMMAND_NAMES);
|
|
308
|
+
for (const command of sessionCommands) {
|
|
309
|
+
if (command.name) names.add(command.name);
|
|
310
|
+
}
|
|
311
|
+
return [...names];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function pickSlashCommandTips(
|
|
315
|
+
availableNames: readonly string[],
|
|
316
|
+
options: {
|
|
317
|
+
fixed?: readonly string[];
|
|
318
|
+
count?: number;
|
|
319
|
+
exclude?: readonly string[];
|
|
320
|
+
random?: () => number;
|
|
321
|
+
} = {},
|
|
322
|
+
): string[] {
|
|
323
|
+
const fixed = [...(options.fixed ?? [])];
|
|
324
|
+
const count = options.count ?? 3;
|
|
325
|
+
const exclude = new Set<string>([...(options.exclude ?? []), ...fixed]);
|
|
326
|
+
const random = options.random ?? Math.random;
|
|
327
|
+
|
|
328
|
+
const pool = [...new Set(availableNames.map((n) => n.trim()).filter(Boolean))].filter(
|
|
329
|
+
(name) => !exclude.has(name),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
for (let i = pool.length - 1; i > 0; i--) {
|
|
333
|
+
const j = Math.floor(random() * (i + 1));
|
|
334
|
+
const tmp = pool[i]!;
|
|
335
|
+
pool[i] = pool[j]!;
|
|
336
|
+
pool[j] = tmp;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const picked = pool.slice(0, Math.max(0, count));
|
|
340
|
+
return [...fixed, ...picked].map((name) => (name.startsWith("/") ? name : `/${name}`));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export const MIN_LEFT_WIDTH = 28;
|
|
344
|
+
export const MIN_TIPS_WIDTH = 16;
|
|
345
|
+
export const MAX_TIPS_WIDTH = 28;
|
|
346
|
+
const COLUMN_GAP = 3;
|
|
347
|
+
|
|
348
|
+
export function headerColumnWidths(
|
|
349
|
+
innerWidth: number,
|
|
350
|
+
minTipsWidth = MIN_TIPS_WIDTH,
|
|
351
|
+
maxTipsWidth = MAX_TIPS_WIDTH,
|
|
352
|
+
minLeftWidth = MIN_LEFT_WIDTH,
|
|
353
|
+
): { leftWidth: number; rightWidth: number; useTips: boolean } {
|
|
354
|
+
if (innerWidth <= 0) {
|
|
355
|
+
return { leftWidth: 0, rightWidth: 0, useTips: false };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const gap = COLUMN_GAP;
|
|
359
|
+
if (innerWidth < minLeftWidth + gap + minTipsWidth) {
|
|
360
|
+
return { leftWidth: innerWidth, rightWidth: 0, useTips: false };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let rightWidth = Math.min(maxTipsWidth, Math.max(minTipsWidth, Math.round(innerWidth * 0.28)));
|
|
364
|
+
let leftWidth = innerWidth - gap - rightWidth;
|
|
365
|
+
|
|
366
|
+
if (leftWidth < minLeftWidth) {
|
|
367
|
+
leftWidth = minLeftWidth;
|
|
368
|
+
rightWidth = innerWidth - gap - leftWidth;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (leftWidth <= rightWidth) {
|
|
372
|
+
leftWidth = Math.ceil((innerWidth - gap) * 0.65);
|
|
373
|
+
rightWidth = innerWidth - gap - leftWidth;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (rightWidth < minTipsWidth || leftWidth < minLeftWidth) {
|
|
377
|
+
return { leftWidth: innerWidth, rightWidth: 0, useTips: false };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { leftWidth, rightWidth, useTips: true };
|
|
381
|
+
}
|