@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/package.json +7 -7
  3. package/src/cli/args.ts +7 -0
  4. package/src/cli/stats-cli.ts +5 -0
  5. package/src/cli/update-cli.ts +127 -67
  6. package/src/config/model-registry.ts +100 -22
  7. package/src/config/settings-schema.ts +22 -2
  8. package/src/extensibility/extensions/types.ts +2 -0
  9. package/src/internal-urls/docs-index.generated.ts +2 -2
  10. package/src/internal-urls/index.ts +2 -1
  11. package/src/internal-urls/mcp-protocol.ts +156 -0
  12. package/src/internal-urls/router.ts +1 -1
  13. package/src/internal-urls/types.ts +3 -3
  14. package/src/mcp/client.ts +235 -2
  15. package/src/mcp/index.ts +1 -1
  16. package/src/mcp/manager.ts +399 -5
  17. package/src/mcp/oauth-flow.ts +26 -1
  18. package/src/mcp/smithery-auth.ts +104 -0
  19. package/src/mcp/smithery-connect.ts +145 -0
  20. package/src/mcp/smithery-registry.ts +455 -0
  21. package/src/mcp/types.ts +140 -0
  22. package/src/modes/components/footer.ts +10 -4
  23. package/src/modes/components/settings-defs.ts +15 -1
  24. package/src/modes/components/status-line/git-utils.ts +42 -0
  25. package/src/modes/components/status-line/presets.ts +6 -6
  26. package/src/modes/components/status-line/segments.ts +27 -4
  27. package/src/modes/components/status-line/types.ts +2 -0
  28. package/src/modes/components/status-line-segment-editor.ts +1 -0
  29. package/src/modes/components/status-line.ts +109 -5
  30. package/src/modes/controllers/command-controller.ts +12 -2
  31. package/src/modes/controllers/extension-ui-controller.ts +12 -21
  32. package/src/modes/controllers/mcp-command-controller.ts +577 -14
  33. package/src/modes/controllers/selector-controller.ts +5 -0
  34. package/src/modes/theme/theme.ts +6 -0
  35. package/src/prompts/tools/hashline.md +4 -3
  36. package/src/sdk.ts +115 -3
  37. package/src/session/agent-session.ts +19 -4
  38. package/src/session/session-manager.ts +17 -5
  39. package/src/slash-commands/builtin-registry.ts +10 -0
  40. package/src/task/executor.ts +37 -3
  41. package/src/task/index.ts +37 -5
  42. package/src/task/isolation-backend.ts +72 -0
  43. package/src/task/render.ts +6 -1
  44. package/src/task/types.ts +1 -0
  45. package/src/task/worktree.ts +67 -5
  46. package/src/tools/index.ts +1 -1
  47. package/src/tools/path-utils.ts +2 -1
  48. package/src/tools/read.ts +3 -7
  49. 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 cost with "(sub)" indicator if using OAuth subscription
182
+ // Show billing summary with subscription and premium-request indicators
181
183
  const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
182
- if (totalCost || usingSubscription) {
183
- const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
184
- statsParts.push(costStr);
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
- { value: "fuse-overlay", label: "Fuse Overlay", description: "COW overlay via fuse-overlayfs" },
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 costDisplay = usingSubscription ? "(sub)" : `$${cost.toFixed(2)}`;
201
- return { content: theme.fg("statusLineCost", costDisplay), visible: true };
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.#cachedBranch = undefined;
124
+ this.#invalidateGitCaches();
111
125
  if (this.#onBranchChange) {
112
126
  this.#onBranchChange();
113
127
  }
114
128
  });
115
129
  } catch {
116
- // Silently fail
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.#cachedBranch = undefined;
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
- if (this.#cachedBranch !== undefined) {
152
+ const gitHeadPath = findGitHeadPathSync();
153
+ if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) {
133
154
  return this.#cachedBranch;
134
155
  }
135
156
 
136
- const gitHeadPath = findGitHeadPathSync();
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
- info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}\n`;
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();