@oh-my-pi/pi-coding-agent 13.5.8 → 13.6.1
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 +37 -1
- package/package.json +7 -7
- package/src/cli/args.ts +7 -0
- package/src/cli/stats-cli.ts +5 -0
- package/src/cli/update-cli.ts +127 -67
- package/src/config/model-registry.ts +100 -22
- package/src/config/settings-schema.ts +22 -2
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/internal-urls/index.ts +2 -1
- package/src/internal-urls/mcp-protocol.ts +156 -0
- package/src/internal-urls/router.ts +1 -1
- package/src/internal-urls/types.ts +3 -3
- package/src/mcp/client.ts +235 -2
- package/src/mcp/index.ts +1 -1
- package/src/mcp/manager.ts +399 -5
- package/src/mcp/oauth-flow.ts +26 -1
- package/src/mcp/smithery-auth.ts +104 -0
- package/src/mcp/smithery-connect.ts +145 -0
- package/src/mcp/smithery-registry.ts +455 -0
- package/src/mcp/types.ts +140 -0
- package/src/modes/components/footer.ts +10 -4
- package/src/modes/components/settings-defs.ts +15 -1
- package/src/modes/components/status-line/git-utils.ts +42 -0
- package/src/modes/components/status-line/presets.ts +6 -6
- package/src/modes/components/status-line/segments.ts +27 -4
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/status-line-segment-editor.ts +1 -0
- package/src/modes/components/status-line.ts +109 -5
- package/src/modes/controllers/command-controller.ts +12 -2
- package/src/modes/controllers/extension-ui-controller.ts +12 -21
- package/src/modes/controllers/mcp-command-controller.ts +577 -14
- package/src/modes/controllers/selector-controller.ts +5 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/prompts/tools/hashline.md +4 -3
- package/src/sdk.ts +115 -3
- package/src/session/agent-session.ts +19 -4
- package/src/session/session-manager.ts +17 -5
- package/src/slash-commands/builtin-registry.ts +10 -0
- package/src/task/executor.ts +37 -3
- package/src/task/index.ts +37 -5
- package/src/task/isolation-backend.ts +72 -0
- package/src/task/render.ts +6 -1
- package/src/task/types.ts +1 -0
- package/src/task/worktree.ts +67 -5
- package/src/tools/index.ts +1 -1
- package/src/tools/path-utils.ts +2 -1
- package/src/tools/read.ts +3 -7
- package/src/utils/open.ts +1 -1
package/src/mcp/types.ts
CHANGED
|
@@ -244,6 +244,14 @@ export interface MCPServerConnection {
|
|
|
244
244
|
tools?: MCPToolDefinition[];
|
|
245
245
|
/** Source metadata (for display) */
|
|
246
246
|
_source?: SourceMeta;
|
|
247
|
+
/** Cached resources (populated on demand) */
|
|
248
|
+
resources?: MCPResource[];
|
|
249
|
+
/** Cached resource templates (populated on demand) */
|
|
250
|
+
resourceTemplates?: MCPResourceTemplate[];
|
|
251
|
+
/** Server instructions from initialize */
|
|
252
|
+
instructions?: string;
|
|
253
|
+
/** Cached prompts (populated on demand) */
|
|
254
|
+
prompts?: MCPPrompt[];
|
|
247
255
|
}
|
|
248
256
|
|
|
249
257
|
/** MCP tool with server context */
|
|
@@ -251,3 +259,135 @@ export interface MCPToolWithServer {
|
|
|
251
259
|
server: MCPServerConnection;
|
|
252
260
|
tool: MCPToolDefinition;
|
|
253
261
|
}
|
|
262
|
+
|
|
263
|
+
// =============================================================================
|
|
264
|
+
// MCP Resource Types
|
|
265
|
+
// =============================================================================
|
|
266
|
+
|
|
267
|
+
/** Annotations for resources, templates, and content blocks */
|
|
268
|
+
export interface MCPAnnotations {
|
|
269
|
+
audience?: ("user" | "assistant")[];
|
|
270
|
+
priority?: number;
|
|
271
|
+
lastModified?: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A concrete resource exposed by an MCP server */
|
|
275
|
+
export interface MCPResource {
|
|
276
|
+
uri: string;
|
|
277
|
+
name: string;
|
|
278
|
+
title?: string;
|
|
279
|
+
description?: string;
|
|
280
|
+
mimeType?: string;
|
|
281
|
+
size?: number;
|
|
282
|
+
annotations?: MCPAnnotations;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** A parameterized resource template (RFC 6570 URI template) */
|
|
286
|
+
export interface MCPResourceTemplate {
|
|
287
|
+
uriTemplate: string;
|
|
288
|
+
name: string;
|
|
289
|
+
title?: string;
|
|
290
|
+
description?: string;
|
|
291
|
+
mimeType?: string;
|
|
292
|
+
annotations?: MCPAnnotations;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Result of resources/list */
|
|
296
|
+
export interface MCPResourcesListResult {
|
|
297
|
+
resources: MCPResource[];
|
|
298
|
+
nextCursor?: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Result of resources/templates/list */
|
|
302
|
+
export interface MCPResourceTemplatesListResult {
|
|
303
|
+
resourceTemplates: MCPResourceTemplate[];
|
|
304
|
+
nextCursor?: string;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** A single content item from resources/read */
|
|
308
|
+
export interface MCPResourceContentItem {
|
|
309
|
+
uri: string;
|
|
310
|
+
mimeType?: string;
|
|
311
|
+
text?: string;
|
|
312
|
+
blob?: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Result of resources/read */
|
|
316
|
+
export interface MCPResourceReadResult {
|
|
317
|
+
contents: MCPResourceContentItem[];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Params for resources/read */
|
|
321
|
+
export interface MCPResourceReadParams {
|
|
322
|
+
uri: string;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Params for resources/subscribe and resources/unsubscribe */
|
|
326
|
+
export interface MCPResourceSubscribeParams {
|
|
327
|
+
uri: string;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// =============================================================================
|
|
331
|
+
// MCP Prompt Types
|
|
332
|
+
// =============================================================================
|
|
333
|
+
|
|
334
|
+
/** An argument definition for an MCP prompt */
|
|
335
|
+
export interface MCPPromptArgument {
|
|
336
|
+
name: string;
|
|
337
|
+
description?: string;
|
|
338
|
+
required?: boolean;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** A prompt definition exposed by an MCP server */
|
|
342
|
+
export interface MCPPrompt {
|
|
343
|
+
name: string;
|
|
344
|
+
title?: string;
|
|
345
|
+
description?: string;
|
|
346
|
+
arguments?: MCPPromptArgument[];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Result of prompts/list */
|
|
350
|
+
export interface MCPPromptsListResult {
|
|
351
|
+
prompts: MCPPrompt[];
|
|
352
|
+
nextCursor?: string;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Audio content in prompt messages */
|
|
356
|
+
export interface MCPAudioContent {
|
|
357
|
+
type: "audio";
|
|
358
|
+
data: string;
|
|
359
|
+
mimeType: string;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Content type union for prompt messages */
|
|
363
|
+
export type MCPPromptContent = MCPTextContent | MCPImageContent | MCPAudioContent | MCPResourceContent;
|
|
364
|
+
|
|
365
|
+
/** A single message in a prompt result */
|
|
366
|
+
export interface MCPPromptMessage {
|
|
367
|
+
role: "user" | "assistant";
|
|
368
|
+
content: MCPPromptContent | MCPPromptContent[];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Params for prompts/get */
|
|
372
|
+
export interface MCPGetPromptParams {
|
|
373
|
+
name: string;
|
|
374
|
+
arguments?: Record<string, string>;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Result of prompts/get */
|
|
378
|
+
export interface MCPGetPromptResult {
|
|
379
|
+
description?: string;
|
|
380
|
+
messages: MCPPromptMessage[];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// =============================================================================
|
|
384
|
+
// MCP Notification Method Names
|
|
385
|
+
// =============================================================================
|
|
386
|
+
|
|
387
|
+
/** MCP server notification method names */
|
|
388
|
+
export const MCPNotificationMethods = {
|
|
389
|
+
TOOLS_LIST_CHANGED: "notifications/tools/list_changed",
|
|
390
|
+
RESOURCES_LIST_CHANGED: "notifications/resources/list_changed",
|
|
391
|
+
RESOURCES_UPDATED: "notifications/resources/updated",
|
|
392
|
+
PROMPTS_LIST_CHANGED: "notifications/prompts/list_changed",
|
|
393
|
+
} as const;
|
|
@@ -131,6 +131,7 @@ export class FooterComponent implements Component {
|
|
|
131
131
|
let totalCacheRead = 0;
|
|
132
132
|
let totalCacheWrite = 0;
|
|
133
133
|
let totalCost = 0;
|
|
134
|
+
let totalPremiumRequests = 0;
|
|
134
135
|
|
|
135
136
|
for (const entry of this.session.sessionManager.getEntries()) {
|
|
136
137
|
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
@@ -139,6 +140,7 @@ export class FooterComponent implements Component {
|
|
|
139
140
|
totalCacheRead += entry.message.usage.cacheRead;
|
|
140
141
|
totalCacheWrite += entry.message.usage.cacheWrite;
|
|
141
142
|
totalCost += entry.message.usage.cost.total;
|
|
143
|
+
totalPremiumRequests += entry.message.usage.premiumRequests ?? 0;
|
|
142
144
|
}
|
|
143
145
|
}
|
|
144
146
|
|
|
@@ -177,11 +179,15 @@ export class FooterComponent implements Component {
|
|
|
177
179
|
if (totalCacheRead) statsParts.push(`R${formatNumber(totalCacheRead)}`);
|
|
178
180
|
if (totalCacheWrite) statsParts.push(`W${formatNumber(totalCacheWrite)}`);
|
|
179
181
|
|
|
180
|
-
// Show
|
|
182
|
+
// Show billing summary with subscription and premium-request indicators
|
|
181
183
|
const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
const normalizedPremiumRequests = Math.round((totalPremiumRequests + Number.EPSILON) * 100) / 100;
|
|
185
|
+
if (totalCost || usingSubscription || normalizedPremiumRequests) {
|
|
186
|
+
const billingParts: string[] = [];
|
|
187
|
+
if (totalCost) billingParts.push(`$${totalCost.toFixed(3)}`);
|
|
188
|
+
if (normalizedPremiumRequests) billingParts.push(`★ ${formatNumber(normalizedPremiumRequests)}`);
|
|
189
|
+
if (usingSubscription) billingParts.push("(sub)");
|
|
190
|
+
if (billingParts.length > 0) statsParts.push(billingParts.join(" "));
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
// Colorize context percentage based on usage
|
|
@@ -97,7 +97,16 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
|
|
|
97
97
|
"task.isolation.mode": [
|
|
98
98
|
{ value: "none", label: "None", description: "No isolation" },
|
|
99
99
|
{ value: "worktree", label: "Worktree", description: "Git worktree isolation" },
|
|
100
|
-
{
|
|
100
|
+
{
|
|
101
|
+
value: "fuse-overlay",
|
|
102
|
+
label: "Fuse Overlay",
|
|
103
|
+
description: "COW overlay via fuse-overlayfs (Unix only)",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
value: "fuse-projfs",
|
|
107
|
+
label: "Fuse ProjFS",
|
|
108
|
+
description: "COW overlay via ProjFS (Windows only; falls back to worktree if unavailable)",
|
|
109
|
+
},
|
|
101
110
|
],
|
|
102
111
|
// Task isolation merge strategy
|
|
103
112
|
"task.isolation.merge": [
|
|
@@ -206,6 +215,11 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
|
|
|
206
215
|
{ value: "openai", label: "OpenAI", description: "api.kimi.com" },
|
|
207
216
|
{ value: "anthropic", label: "Anthropic", description: "api.moonshot.ai" },
|
|
208
217
|
],
|
|
218
|
+
"providers.openaiWebsockets": [
|
|
219
|
+
{ value: "auto", label: "Auto", description: "Use model/provider default websocket behavior" },
|
|
220
|
+
{ value: "off", label: "Off", description: "Disable websockets for OpenAI Codex models" },
|
|
221
|
+
{ value: "on", label: "On", description: "Force websockets for OpenAI Codex models" },
|
|
222
|
+
],
|
|
209
223
|
// Default thinking level
|
|
210
224
|
defaultThinkingLevel: [
|
|
211
225
|
{ value: "off", label: "off", description: "No reasoning" },
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract "owner/repo" from a GitHub remote URL.
|
|
3
|
+
* Handles HTTPS, SSH (scp-style), and git:// protocols.
|
|
4
|
+
*
|
|
5
|
+
* @returns "owner/repo" or null if the URL isn't a recognized GitHub remote.
|
|
6
|
+
*/
|
|
7
|
+
export function parseGitHubRepo(remoteUrl: string): string | null {
|
|
8
|
+
const match = remoteUrl.match(/github\.com[:/]([^/]+\/[^/]+)/);
|
|
9
|
+
if (!match) return null;
|
|
10
|
+
return match[1].replace(/\.git$/, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract the branch name from a remote HEAD ref like "origin/main".
|
|
15
|
+
* Returns the portion after the first "/" or the whole string if no "/" is present.
|
|
16
|
+
*/
|
|
17
|
+
export function parseDefaultBranch(ref: string): string {
|
|
18
|
+
const slash = ref.indexOf("/");
|
|
19
|
+
return slash >= 0 ? ref.slice(slash + 1) : ref;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PrCacheContext {
|
|
23
|
+
branch: string;
|
|
24
|
+
repoId: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createPrCacheContext(branch: string, repoId: string | null): PrCacheContext {
|
|
28
|
+
return { branch, repoId };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isSamePrCacheContext(a: PrCacheContext | undefined, b: PrCacheContext | undefined): boolean {
|
|
32
|
+
if (!a || !b) return false;
|
|
33
|
+
return a.branch === b.branch && a.repoId === b.repoId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function canReuseCachedPr(
|
|
37
|
+
cachedPr: { number: number; url: string } | null | undefined,
|
|
38
|
+
cachedContext: PrCacheContext | undefined,
|
|
39
|
+
currentContext: PrCacheContext | null,
|
|
40
|
+
): boolean {
|
|
41
|
+
return cachedPr !== undefined && currentContext !== null && isSamePrCacheContext(cachedContext, currentContext);
|
|
42
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { PresetDef, StatusLinePreset } from "./types";
|
|
|
3
3
|
export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
4
4
|
default: {
|
|
5
5
|
// Matches current behavior
|
|
6
|
-
leftSegments: ["pi", "model", "plan_mode", "path", "git", "context_pct", "token_total", "cost"],
|
|
6
|
+
leftSegments: ["pi", "model", "plan_mode", "path", "git", "pr", "context_pct", "token_total", "cost"],
|
|
7
7
|
rightSegments: [],
|
|
8
8
|
separator: "powerline-thin",
|
|
9
9
|
segmentOptions: {
|
|
@@ -24,7 +24,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
24
24
|
},
|
|
25
25
|
|
|
26
26
|
compact: {
|
|
27
|
-
leftSegments: ["model", "plan_mode", "git"],
|
|
27
|
+
leftSegments: ["model", "plan_mode", "git", "pr"],
|
|
28
28
|
rightSegments: ["cost", "context_pct"],
|
|
29
29
|
separator: "powerline-thin",
|
|
30
30
|
segmentOptions: {
|
|
@@ -34,7 +34,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
34
34
|
},
|
|
35
35
|
|
|
36
36
|
full: {
|
|
37
|
-
leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "subagents"],
|
|
37
|
+
leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "pr", "subagents"],
|
|
38
38
|
rightSegments: ["token_in", "token_out", "cache_read", "cost", "context_pct", "time_spent", "time"],
|
|
39
39
|
separator: "powerline",
|
|
40
40
|
segmentOptions: {
|
|
@@ -47,7 +47,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
47
47
|
|
|
48
48
|
nerd: {
|
|
49
49
|
// Full preset with all Nerd Font icons
|
|
50
|
-
leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "session", "subagents"],
|
|
50
|
+
leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "pr", "session", "subagents"],
|
|
51
51
|
rightSegments: [
|
|
52
52
|
"token_in",
|
|
53
53
|
"token_out",
|
|
@@ -70,7 +70,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
70
70
|
|
|
71
71
|
ascii: {
|
|
72
72
|
// No Nerd Font dependencies
|
|
73
|
-
leftSegments: ["model", "plan_mode", "path", "git"],
|
|
73
|
+
leftSegments: ["model", "plan_mode", "path", "git", "pr"],
|
|
74
74
|
rightSegments: ["token_total", "cost", "context_pct"],
|
|
75
75
|
separator: "ascii",
|
|
76
76
|
segmentOptions: {
|
|
@@ -82,7 +82,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
82
82
|
|
|
83
83
|
custom: {
|
|
84
84
|
// User-defined - these are just defaults that get overridden
|
|
85
|
-
leftSegments: ["model", "plan_mode", "path", "git"],
|
|
85
|
+
leftSegments: ["model", "plan_mode", "path", "git", "pr"],
|
|
86
86
|
rightSegments: ["token_total", "cost", "context_pct"],
|
|
87
87
|
separator: "powerline-thin",
|
|
88
88
|
segmentOptions: {},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as os from "node:os";
|
|
2
|
+
import { TERMINAL } from "@oh-my-pi/pi-tui";
|
|
2
3
|
import { formatDuration, formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
|
|
3
4
|
import { theme } from "../../../modes/theme/theme";
|
|
4
5
|
import { shortenPath } from "../../../tools/render-utils";
|
|
@@ -14,6 +15,10 @@ function withIcon(icon: string, text: string): string {
|
|
|
14
15
|
return icon ? `${icon} ${text}` : text;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
function normalizePremiumRequests(value: number): number {
|
|
19
|
+
return Math.round((value + Number.EPSILON) * 100) / 100;
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
18
23
|
// Segment Implementations
|
|
19
24
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -141,6 +146,18 @@ const gitSegment: StatusLineSegment = {
|
|
|
141
146
|
},
|
|
142
147
|
};
|
|
143
148
|
|
|
149
|
+
const prSegment: StatusLineSegment = {
|
|
150
|
+
id: "pr",
|
|
151
|
+
render(ctx) {
|
|
152
|
+
const { pr } = ctx.git;
|
|
153
|
+
if (!pr) return { content: "", visible: false };
|
|
154
|
+
|
|
155
|
+
const label = withIcon(theme.icon.pr, `#${pr.number}`);
|
|
156
|
+
const content = TERMINAL.hyperlinks ? `\x1b]8;;${pr.url}\x07${label}\x1b]8;;\x07` : label;
|
|
157
|
+
return { content: theme.fg("accent", content), visible: true };
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
144
161
|
const subagentsSegment: StatusLineSegment = {
|
|
145
162
|
id: "subagents",
|
|
146
163
|
render(ctx) {
|
|
@@ -189,16 +206,21 @@ const tokenTotalSegment: StatusLineSegment = {
|
|
|
189
206
|
const costSegment: StatusLineSegment = {
|
|
190
207
|
id: "cost",
|
|
191
208
|
render(ctx) {
|
|
192
|
-
const { cost } = ctx.usageStats;
|
|
209
|
+
const { cost, premiumRequests } = ctx.usageStats;
|
|
210
|
+
const normalizedPremiumRequests = normalizePremiumRequests(premiumRequests);
|
|
193
211
|
const state = ctx.session.state;
|
|
194
212
|
const usingSubscription = state.model ? ctx.session.modelRegistry.isUsingOAuth(state.model) : false;
|
|
195
213
|
|
|
196
|
-
if (!cost && !usingSubscription) {
|
|
214
|
+
if (!cost && !usingSubscription && !normalizedPremiumRequests) {
|
|
197
215
|
return { content: "", visible: false };
|
|
198
216
|
}
|
|
199
217
|
|
|
200
|
-
const
|
|
201
|
-
|
|
218
|
+
const billingParts: string[] = [];
|
|
219
|
+
if (cost) billingParts.push(`$${cost.toFixed(2)}`);
|
|
220
|
+
if (normalizedPremiumRequests) billingParts.push(`★ ${formatNumber(normalizedPremiumRequests)}`);
|
|
221
|
+
if (usingSubscription) billingParts.push("(sub)");
|
|
222
|
+
|
|
223
|
+
return { content: theme.fg("statusLineCost", billingParts.join(" ")), visible: true };
|
|
202
224
|
},
|
|
203
225
|
};
|
|
204
226
|
|
|
@@ -324,6 +346,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
|
|
|
324
346
|
plan_mode: planModeSegment,
|
|
325
347
|
path: pathSegment,
|
|
326
348
|
git: gitSegment,
|
|
349
|
+
pr: prSegment,
|
|
327
350
|
subagents: subagentsSegment,
|
|
328
351
|
token_in: tokenInSegment,
|
|
329
352
|
token_out: tokenOutSegment,
|
|
@@ -30,6 +30,7 @@ export interface SegmentContext {
|
|
|
30
30
|
output: number;
|
|
31
31
|
cacheRead: number;
|
|
32
32
|
cacheWrite: number;
|
|
33
|
+
premiumRequests: number;
|
|
33
34
|
cost: number;
|
|
34
35
|
};
|
|
35
36
|
contextPercent: number;
|
|
@@ -40,6 +41,7 @@ export interface SegmentContext {
|
|
|
40
41
|
git: {
|
|
41
42
|
branch: string | null;
|
|
42
43
|
status: { staged: number; unstaged: number; untracked: number } | null;
|
|
44
|
+
pr: { number: number; url: string } | null;
|
|
43
45
|
};
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -20,6 +20,7 @@ const SEGMENT_INFO: Record<StatusLineSegmentId, { label: string; short: string }
|
|
|
20
20
|
plan_mode: { label: "Plan Mode", short: "plan status" },
|
|
21
21
|
path: { label: "Path", short: "working dir" },
|
|
22
22
|
git: { label: "Git", short: "branch/status" },
|
|
23
|
+
pr: { label: "PR", short: "pull request" },
|
|
23
24
|
subagents: { label: "Agents", short: "subagent count" },
|
|
24
25
|
token_in: { label: "Tokens In", short: "input tokens" },
|
|
25
26
|
token_out: { label: "Tokens Out", short: "output tokens" },
|
|
@@ -8,6 +8,13 @@ import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle }
|
|
|
8
8
|
import { theme } from "../../modes/theme/theme";
|
|
9
9
|
import type { AgentSession } from "../../session/agent-session";
|
|
10
10
|
import { findGitHeadPathSync, sanitizeStatusText } from "../shared";
|
|
11
|
+
import {
|
|
12
|
+
canReuseCachedPr,
|
|
13
|
+
createPrCacheContext,
|
|
14
|
+
isSamePrCacheContext,
|
|
15
|
+
type PrCacheContext,
|
|
16
|
+
parseDefaultBranch,
|
|
17
|
+
} from "./status-line/git-utils";
|
|
11
18
|
import { getPreset } from "./status-line/presets";
|
|
12
19
|
import { renderSegment, type SegmentContext } from "./status-line/segments";
|
|
13
20
|
import { getSeparator } from "./status-line/separators";
|
|
@@ -39,6 +46,7 @@ export interface StatusLineSettings {
|
|
|
39
46
|
export class StatusLineComponent implements Component {
|
|
40
47
|
#settings: StatusLineSettings = {};
|
|
41
48
|
#cachedBranch: string | null | undefined = undefined;
|
|
49
|
+
#cachedBranchRepoId: string | null | undefined = undefined;
|
|
42
50
|
#gitWatcher: fs.FSWatcher | null = null;
|
|
43
51
|
#onBranchChange: (() => void) | null = null;
|
|
44
52
|
#autoCompactEnabled: boolean = true;
|
|
@@ -52,6 +60,12 @@ export class StatusLineComponent implements Component {
|
|
|
52
60
|
#gitStatusLastFetch = 0;
|
|
53
61
|
#gitStatusInFlight = false;
|
|
54
62
|
|
|
63
|
+
// PR lookup caching (invalidated on branch/repo context changes)
|
|
64
|
+
#cachedPr: { number: number; url: string } | null | undefined = undefined;
|
|
65
|
+
#cachedPrContext: PrCacheContext | undefined = undefined;
|
|
66
|
+
#prLookupInFlight = false;
|
|
67
|
+
#defaultBranch?: string;
|
|
68
|
+
|
|
55
69
|
constructor(private readonly session: AgentSession) {
|
|
56
70
|
this.#settings = {
|
|
57
71
|
preset: settings.get("statusLine.preset"),
|
|
@@ -107,13 +121,13 @@ export class StatusLineComponent implements Component {
|
|
|
107
121
|
|
|
108
122
|
try {
|
|
109
123
|
this.#gitWatcher = fs.watch(gitHeadPath, () => {
|
|
110
|
-
this.#
|
|
124
|
+
this.#invalidateGitCaches();
|
|
111
125
|
if (this.#onBranchChange) {
|
|
112
126
|
this.#onBranchChange();
|
|
113
127
|
}
|
|
114
128
|
});
|
|
115
129
|
} catch {
|
|
116
|
-
|
|
130
|
+
this.#invalidateGitCaches();
|
|
117
131
|
}
|
|
118
132
|
}
|
|
119
133
|
|
|
@@ -125,19 +139,27 @@ export class StatusLineComponent implements Component {
|
|
|
125
139
|
}
|
|
126
140
|
|
|
127
141
|
invalidate(): void {
|
|
128
|
-
this.#
|
|
142
|
+
this.#invalidateGitCaches();
|
|
129
143
|
}
|
|
130
144
|
|
|
145
|
+
#invalidateGitCaches(): void {
|
|
146
|
+
this.#cachedBranch = undefined;
|
|
147
|
+
this.#cachedBranchRepoId = undefined;
|
|
148
|
+
this.#cachedPr = undefined;
|
|
149
|
+
this.#cachedPrContext = undefined;
|
|
150
|
+
}
|
|
131
151
|
#getCurrentBranch(): string | null {
|
|
132
|
-
|
|
152
|
+
const gitHeadPath = findGitHeadPathSync();
|
|
153
|
+
if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) {
|
|
133
154
|
return this.#cachedBranch;
|
|
134
155
|
}
|
|
135
156
|
|
|
136
|
-
|
|
157
|
+
this.#cachedBranchRepoId = gitHeadPath;
|
|
137
158
|
if (!gitHeadPath) {
|
|
138
159
|
this.#cachedBranch = null;
|
|
139
160
|
return null;
|
|
140
161
|
}
|
|
162
|
+
|
|
141
163
|
try {
|
|
142
164
|
const content = fs.readFileSync(gitHeadPath, "utf8").trim();
|
|
143
165
|
|
|
@@ -153,6 +175,26 @@ export class StatusLineComponent implements Component {
|
|
|
153
175
|
return this.#cachedBranch ?? null;
|
|
154
176
|
}
|
|
155
177
|
|
|
178
|
+
#isDefaultBranch(branch: string): boolean {
|
|
179
|
+
if (this.#defaultBranch === undefined) {
|
|
180
|
+
// Kick off async resolution, use hardcoded fallback until it resolves
|
|
181
|
+
this.#defaultBranch = "main";
|
|
182
|
+
(async () => {
|
|
183
|
+
// Try origin/HEAD first, fall back to upstream/HEAD
|
|
184
|
+
const origin = await $`git rev-parse --abbrev-ref origin/HEAD`.quiet().nothrow();
|
|
185
|
+
if (origin.exitCode === 0) {
|
|
186
|
+
this.#defaultBranch = parseDefaultBranch(origin.stdout.toString().trim());
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const upstream = await $`git rev-parse --abbrev-ref upstream/HEAD`.quiet().nothrow();
|
|
190
|
+
if (upstream.exitCode === 0) {
|
|
191
|
+
this.#defaultBranch = parseDefaultBranch(upstream.stdout.toString().trim());
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
194
|
+
}
|
|
195
|
+
return branch === this.#defaultBranch;
|
|
196
|
+
}
|
|
197
|
+
|
|
156
198
|
#getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
|
|
157
199
|
if (this.#gitStatusInFlight || Date.now() - this.#gitStatusLastFetch < 1000) {
|
|
158
200
|
return this.#cachedGitStatus;
|
|
@@ -207,6 +249,66 @@ export class StatusLineComponent implements Component {
|
|
|
207
249
|
return this.#cachedGitStatus;
|
|
208
250
|
}
|
|
209
251
|
|
|
252
|
+
#lookupPr(): { number: number; url: string } | null {
|
|
253
|
+
const branch = this.#getCurrentBranch();
|
|
254
|
+
const currentContext = branch ? createPrCacheContext(branch, this.#cachedBranchRepoId ?? null) : null;
|
|
255
|
+
|
|
256
|
+
if (canReuseCachedPr(this.#cachedPr, this.#cachedPrContext, currentContext)) {
|
|
257
|
+
return this.#cachedPr ?? null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (this.#cachedPr !== undefined) {
|
|
261
|
+
this.#cachedPr = undefined;
|
|
262
|
+
this.#cachedPrContext = undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Don't look up if no branch, detached HEAD, default branch, or already in flight
|
|
266
|
+
if (!branch || branch === "detached" || this.#isDefaultBranch(branch) || this.#prLookupInFlight) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
this.#prLookupInFlight = true;
|
|
271
|
+
const lookupContext = currentContext;
|
|
272
|
+
|
|
273
|
+
// Fire async lookup, return null until resolved
|
|
274
|
+
(async () => {
|
|
275
|
+
// Helper: only write cache if branch/repo context hasn't changed since launch
|
|
276
|
+
const setCachedPr = (value: { number: number; url: string } | null) => {
|
|
277
|
+
const latestBranch = this.#getCurrentBranch();
|
|
278
|
+
const latestContext = latestBranch
|
|
279
|
+
? createPrCacheContext(latestBranch, this.#cachedBranchRepoId ?? null)
|
|
280
|
+
: undefined;
|
|
281
|
+
if (lookupContext && isSamePrCacheContext(latestContext, lookupContext)) {
|
|
282
|
+
this.#cachedPr = value;
|
|
283
|
+
this.#cachedPrContext = lookupContext;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
try {
|
|
287
|
+
// Requires `gh repo set-default` to be configured; fails gracefully if not
|
|
288
|
+
const result = await $`gh pr view --json number,url`.quiet().nothrow();
|
|
289
|
+
if (result.exitCode !== 0) {
|
|
290
|
+
setCachedPr(null);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const pr = JSON.parse(result.stdout.toString()) as { number: number; url: string };
|
|
294
|
+
if (typeof pr.number === "number") {
|
|
295
|
+
setCachedPr({ number: pr.number, url: pr.url });
|
|
296
|
+
} else {
|
|
297
|
+
setCachedPr(null);
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
setCachedPr(null);
|
|
301
|
+
} finally {
|
|
302
|
+
this.#prLookupInFlight = false;
|
|
303
|
+
if (this.#cachedPr && this.#onBranchChange) {
|
|
304
|
+
this.#onBranchChange();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
})();
|
|
308
|
+
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
210
312
|
#buildSegmentContext(width: number): SegmentContext {
|
|
211
313
|
const state = this.session.state;
|
|
212
314
|
|
|
@@ -216,6 +318,7 @@ export class StatusLineComponent implements Component {
|
|
|
216
318
|
output: 0,
|
|
217
319
|
cacheRead: 0,
|
|
218
320
|
cacheWrite: 0,
|
|
321
|
+
premiumRequests: 0,
|
|
219
322
|
cost: 0,
|
|
220
323
|
};
|
|
221
324
|
|
|
@@ -248,6 +351,7 @@ export class StatusLineComponent implements Component {
|
|
|
248
351
|
git: {
|
|
249
352
|
branch: this.#getCurrentBranch(),
|
|
250
353
|
status: this.#getGitStatus(),
|
|
354
|
+
pr: this.#lookupPr(),
|
|
251
355
|
},
|
|
252
356
|
};
|
|
253
357
|
}
|
|
@@ -231,6 +231,11 @@ export class CommandController {
|
|
|
231
231
|
|
|
232
232
|
async handleSessionCommand(): Promise<void> {
|
|
233
233
|
const stats = this.ctx.session.getSessionStats();
|
|
234
|
+
const premiumRequests =
|
|
235
|
+
"premiumRequests" in stats && typeof stats.premiumRequests === "number"
|
|
236
|
+
? stats.premiumRequests
|
|
237
|
+
: this.ctx.session.sessionManager.getUsageStatistics().premiumRequests;
|
|
238
|
+
const normalizedPremiumRequests = Math.round((premiumRequests + Number.EPSILON) * 100) / 100;
|
|
234
239
|
|
|
235
240
|
let info = `${theme.bold("Session Info")}\n\n`;
|
|
236
241
|
info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`;
|
|
@@ -271,9 +276,14 @@ export class CommandController {
|
|
|
271
276
|
}
|
|
272
277
|
info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;
|
|
273
278
|
|
|
274
|
-
if (stats.cost > 0) {
|
|
279
|
+
if (stats.cost > 0 || normalizedPremiumRequests > 0) {
|
|
275
280
|
info += `\n${theme.bold("Cost")}\n`;
|
|
276
|
-
|
|
281
|
+
if (stats.cost > 0) {
|
|
282
|
+
info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}\n`;
|
|
283
|
+
}
|
|
284
|
+
if (normalizedPremiumRequests > 0) {
|
|
285
|
+
info += `${theme.fg("dim", "Premium Requests:")} ${normalizedPremiumRequests.toLocaleString()}\n`;
|
|
286
|
+
}
|
|
277
287
|
}
|
|
278
288
|
|
|
279
289
|
const gateway = await getGatewayStatus();
|