@oh-my-pi/pi-coding-agent 16.0.4 → 16.0.5

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 (71) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/cli.js +341 -261
  3. package/dist/types/advisor/advise-tool.d.ts +9 -0
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/cli/bench-cli.d.ts +6 -0
  6. package/dist/types/commands/launch.d.ts +3 -0
  7. package/dist/types/config/settings-schema.d.ts +91 -2
  8. package/dist/types/extensibility/extensions/runner.d.ts +5 -2
  9. package/dist/types/extensibility/extensions/types.d.ts +8 -7
  10. package/dist/types/extensibility/shared-events.d.ts +22 -1
  11. package/dist/types/main.d.ts +1 -0
  12. package/dist/types/modes/components/status-line/component.d.ts +1 -1
  13. package/dist/types/modes/components/status-line/context-thresholds.d.ts +0 -1
  14. package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
  15. package/dist/types/modes/utils/context-usage.d.ts +12 -0
  16. package/dist/types/sdk.d.ts +3 -1
  17. package/dist/types/session/agent-session.d.ts +20 -0
  18. package/dist/types/session/session-persistence.d.ts +4 -0
  19. package/dist/types/tools/read.d.ts +1 -0
  20. package/dist/types/tui/code-cell.d.ts +2 -0
  21. package/dist/types/utils/image-vision-fallback.d.ts +28 -0
  22. package/dist/types/web/search/providers/base.d.ts +1 -0
  23. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  24. package/package.json +12 -12
  25. package/src/advisor/__tests__/advisor.test.ts +59 -0
  26. package/src/advisor/advise-tool.ts +13 -0
  27. package/src/cli/args.ts +1 -0
  28. package/src/cli/bench-cli.ts +30 -7
  29. package/src/cli/flag-tables.ts +8 -0
  30. package/src/collab/host.ts +2 -2
  31. package/src/commands/launch.ts +3 -0
  32. package/src/config/settings-schema.ts +84 -2
  33. package/src/eval/py/runner.py +44 -0
  34. package/src/extensibility/extensions/runner.ts +20 -2
  35. package/src/extensibility/extensions/types.ts +16 -5
  36. package/src/extensibility/shared-events.ts +24 -0
  37. package/src/internal-urls/docs-index.generated.ts +7 -7
  38. package/src/main.ts +12 -5
  39. package/src/modes/components/branch-summary-message.ts +1 -0
  40. package/src/modes/components/collab-prompt-message.ts +9 -7
  41. package/src/modes/components/compaction-summary-message.ts +1 -0
  42. package/src/modes/components/custom-message.ts +1 -0
  43. package/src/modes/components/footer.ts +6 -5
  44. package/src/modes/components/hook-message.ts +1 -0
  45. package/src/modes/components/read-tool-group.ts +9 -3
  46. package/src/modes/components/skill-message.ts +1 -0
  47. package/src/modes/components/status-line/component.ts +131 -14
  48. package/src/modes/components/status-line/context-thresholds.ts +0 -1
  49. package/src/modes/components/todo-reminder.ts +1 -0
  50. package/src/modes/components/ttsr-notification.ts +1 -0
  51. package/src/modes/components/user-message.ts +6 -6
  52. package/src/modes/controllers/event-controller.ts +2 -7
  53. package/src/modes/controllers/selector-controller.ts +10 -3
  54. package/src/modes/interactive-mode.ts +4 -2
  55. package/src/modes/rpc/rpc-types.ts +1 -1
  56. package/src/modes/utils/context-usage.ts +28 -15
  57. package/src/prompts/tools/image-attachment-describe-system.md +8 -0
  58. package/src/prompts/tools/image-attachment-describe.md +10 -0
  59. package/src/sdk.ts +14 -18
  60. package/src/session/agent-session.ts +564 -231
  61. package/src/session/session-loader.ts +19 -32
  62. package/src/session/session-persistence.ts +27 -11
  63. package/src/ssh/connection-manager.ts +3 -2
  64. package/src/task/executor.ts +1 -1
  65. package/src/tools/image-gen.ts +67 -25
  66. package/src/tools/read.ts +28 -6
  67. package/src/tui/code-cell.ts +44 -3
  68. package/src/utils/image-vision-fallback.ts +197 -0
  69. package/src/web/search/index.ts +12 -0
  70. package/src/web/search/providers/base.ts +1 -0
  71. package/src/web/search/providers/gemini.ts +56 -18
package/src/main.ts CHANGED
@@ -103,6 +103,10 @@ function maybeShowStartupSplash(options: {
103
103
  //process.stdout.write(`${chalk.dim(`omp ${options.version}`)}\n${chalk.dim("Initializing session…")}\n`);
104
104
  }
105
105
 
106
+ export function writeStartupNotice(parsedArgs: Pick<Args, "mode">, text: string): void {
107
+ (parsedArgs.mode === "json" ? process.stderr : process.stdout).write(text);
108
+ }
109
+
106
110
  async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
107
111
  if (!settings.get("startup.checkUpdate")) {
108
112
  return;
@@ -758,6 +762,9 @@ async function buildSessionOptions(
758
762
  cwd: parsed.cwd ?? getProjectDir(),
759
763
  autoApprove: parsed.autoApprove ?? false,
760
764
  };
765
+ if (parsed.maxTime !== undefined) {
766
+ options.deadline = Date.now() + parsed.maxTime * 1000;
767
+ }
761
768
 
762
769
  // Auto-discover SYSTEM.md if no CLI system prompt provided
763
770
  const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile();
@@ -944,7 +951,7 @@ export async function runRootCommand(
944
951
  const modelRegistry = logger.time("modelRegistry:init", () => new ModelRegistry(authStorage));
945
952
 
946
953
  if (parsedArgs.version) {
947
- process.stdout.write(`${VERSION}\n`);
954
+ writeStartupNotice(parsedArgs, `${VERSION}\n`);
948
955
  process.exit(0);
949
956
  }
950
957
 
@@ -959,7 +966,7 @@ export async function runRootCommand(
959
966
  process.stderr.write(`${chalk.red(`Error: ${message}`)}\n`);
960
967
  process.exit(1);
961
968
  }
962
- process.stdout.write(`Exported to: ${result}\n`);
969
+ writeStartupNotice(parsedArgs, `Exported to: ${result}\n`);
963
970
  process.exit(0);
964
971
  }
965
972
 
@@ -1095,7 +1102,7 @@ export async function runRootCommand(
1095
1102
  // message rather than letting the decline bubble up as an uncaught exception
1096
1103
  // (see issue #1668).
1097
1104
  if (typeof parsedArgs.resume === "string" && !sessionManager) {
1098
- process.stdout.write(`${chalk.dim("Resume cancelled: session is in another project.")}\n`);
1105
+ writeStartupNotice(parsedArgs, `${chalk.dim("Resume cancelled: session is in another project.")}\n`);
1099
1106
  return;
1100
1107
  }
1101
1108
 
@@ -1109,7 +1116,7 @@ export async function runRootCommand(
1109
1116
  // picker can still open in all-projects scope instead of dead-ending.
1110
1117
  preloadedAllSessions = await logger.time("SessionManager.listAll", SessionManager.listAll);
1111
1118
  if (preloadedAllSessions.length === 0) {
1112
- process.stdout.write(`${chalk.dim("No sessions found")}\n`);
1119
+ writeStartupNotice(parsedArgs, `${chalk.dim("No sessions found")}\n`);
1113
1120
  return;
1114
1121
  }
1115
1122
  startInAllScope = true;
@@ -1121,7 +1128,7 @@ export async function runRootCommand(
1121
1128
  });
1122
1129
  resumeStartupWatchdog();
1123
1130
  if (!selected) {
1124
- process.stdout.write(`${chalk.dim("No session selected")}\n`);
1131
+ writeStartupNotice(parsedArgs, `${chalk.dim("No session selected")}\n`);
1125
1132
  return;
1126
1133
  }
1127
1134
  // Resuming a session from another project: switch the process into that
@@ -11,6 +11,7 @@ export class BranchSummaryMessageComponent extends Box {
11
11
 
12
12
  constructor(private readonly message: BranchSummaryMessage) {
13
13
  super(1, 1, t => theme.bg("customMessageBg", t));
14
+ this.setIgnoreTight(true);
14
15
  this.#updateDisplay();
15
16
  }
16
17
 
@@ -12,7 +12,9 @@ export class CollabPromptMessageComponent extends Container {
12
12
  constructor(message: CustomMessage<CollabPromptDetails>) {
13
13
  super();
14
14
  const from = message.details?.from?.trim() || "guest";
15
- this.addChild(new Text(theme.fg("accent", `\x1b[1m«${from}»\x1b[22m ›`), 1, 0));
15
+ const authorText = new Text(theme.fg("accent", `\x1b[1m«${from}»\x1b[22m ›`), 1, 0);
16
+ authorText.setIgnoreTight(true);
17
+ this.addChild(authorText);
16
18
  const text =
17
19
  typeof message.content === "string"
18
20
  ? message.content
@@ -20,11 +22,11 @@ export class CollabPromptMessageComponent extends Container {
20
22
  .filter((content): content is TextContent => content.type === "text")
21
23
  .map(content => content.text)
22
24
  .join("");
23
- this.addChild(
24
- new Markdown(text, 1, 1, getMarkdownTheme(), {
25
- bgColor: (value: string) => theme.bg("userMessageBg", value),
26
- color: (value: string) => theme.fg("userMessageText", value),
27
- }),
28
- );
25
+ const md = new Markdown(text, 1, 1, getMarkdownTheme(), {
26
+ bgColor: (value: string) => theme.bg("userMessageBg", value),
27
+ color: (value: string) => theme.fg("userMessageText", value),
28
+ });
29
+ md.setIgnoreTight(true);
30
+ this.addChild(md);
29
31
  }
30
32
  }
@@ -62,6 +62,7 @@ class SummaryDividerComponent implements Component {
62
62
  #detailBox(): Box {
63
63
  if (this.#detail) return this.#detail;
64
64
  const box = new Box(1, 1, t => theme.bg("customMessageBg", t));
65
+ box.setIgnoreTight(true);
65
66
  box.addChild(
66
67
  new Markdown(this.options.detailMarkdown(), 0, 0, getMarkdownTheme(), {
67
68
  color: (text: string) => theme.fg("customMessageText", text),
@@ -22,6 +22,7 @@ export class CustomMessageComponent extends Container {
22
22
 
23
23
  // Create box with custom background (used for default rendering)
24
24
  this.#box = new Box(1, 1, t => theme.bg("customMessageBg", t));
25
+ this.#box.setIgnoreTight(true);
25
26
 
26
27
  this.#rebuild();
27
28
  }
@@ -4,6 +4,7 @@ import { stripVTControlCharacters } from "node:util";
4
4
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
5
  import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
6
6
  import { formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
7
+ import { settings } from "../../config/settings";
7
8
  import { theme } from "../../modes/theme/theme";
8
9
  import type { AgentSession } from "../../session/agent-session";
9
10
  import { shortenPath } from "../../tools/render-utils";
@@ -58,6 +59,8 @@ export class FooterComponent implements Component {
58
59
  this.#gitWatcher = null;
59
60
  }
60
61
 
62
+ if (!settings.get("git.enabled")) return;
63
+
61
64
  void git.head
62
65
  .resolve(getProjectDir())
63
66
  .then(head => {
@@ -102,6 +105,7 @@ export class FooterComponent implements Component {
102
105
  * Returns null if not in a git repo, branch name otherwise.
103
106
  */
104
107
  #getCurrentBranch(): string | null {
108
+ if (!settings.get("git.enabled")) return null;
105
109
  if (this.#cachedBranch !== undefined) {
106
110
  return this.#cachedBranch;
107
111
  }
@@ -182,11 +186,8 @@ export class FooterComponent implements Component {
182
186
  // Colorize context percentage based on usage
183
187
  let contextPercentStr: string;
184
188
  const autoIndicator = this.#autoCompactEnabled ? " (auto)" : "";
185
- const contextPercentDisplay = `${formatContextUsage(
186
- contextUsage?.percent === null ? null : contextPercentValue,
187
- contextWindow,
188
- )}${autoIndicator}`;
189
- if (contextUsage?.percent !== null && contextUsage?.percent !== undefined) {
189
+ const contextPercentDisplay = `${formatContextUsage(contextPercentValue, contextWindow)}${autoIndicator}`;
190
+ if (contextUsage) {
190
191
  const color = getContextUsageThemeColor(getContextUsageLevel(contextPercentValue, contextWindow));
191
192
  contextPercentStr =
192
193
  color === "statusLineContext" ? contextPercentDisplay : theme.fg(color, contextPercentDisplay);
@@ -25,6 +25,7 @@ export class HookMessageComponent extends Container {
25
25
 
26
26
  // Create box with purple background (used for default rendering)
27
27
  this.#box = new Box(1, 1, t => theme.bg("customMessageBg", t));
28
+ this.#box.setIgnoreTight(true);
28
29
 
29
30
  this.#rebuild();
30
31
  }
@@ -57,6 +57,7 @@ type ReadToolResultDetails = {
57
57
  displayContent?: {
58
58
  text?: string;
59
59
  startLine?: number;
60
+ lineNumbers?: Array<number | null>;
60
61
  };
61
62
  meta?: {
62
63
  source?: {
@@ -86,6 +87,8 @@ type ReadEntry = {
86
87
  correctedFrom?: string;
87
88
  contentText?: string;
88
89
  conflictCount?: number;
90
+ codeStartLine?: number;
91
+ codeLineNumbers?: Array<number | null>;
89
92
  };
90
93
 
91
94
  /** Number of code lines to show in collapsed preview mode */
@@ -379,11 +382,12 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
379
382
  entry.status = result.isError ? "error" : suffixResolution ? "warning" : "success";
380
383
  // Store clean display content for preview/expanded display when the read
381
384
  // tool provides it; fall back to model-facing text for legacy results.
382
- const displayContent =
383
- typeof details?.displayContent?.text === "string" ? details.displayContent.text : undefined;
385
+ const displayContent = details?.displayContent;
384
386
  const textContent = result.content?.find(c => c.type === "text")?.text;
385
387
  if (displayContent !== undefined || textContent !== undefined) {
386
- entry.contentText = displayContent ?? textContent;
388
+ entry.contentText = displayContent?.text ?? textContent;
389
+ entry.codeStartLine = displayContent?.startLine;
390
+ entry.codeLineNumbers = displayContent?.lineNumbers;
387
391
  }
388
392
  this.#updateDisplay();
389
393
  }
@@ -636,6 +640,8 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
636
640
  status: entry.status === "success" ? "complete" : entry.status,
637
641
  expanded,
638
642
  codeMaxLines: expanded ? undefined : COLLAPSED_PREVIEW_LINES,
643
+ codeStartLine: entry.codeStartLine,
644
+ codeLineNumbers: entry.codeLineNumbers,
639
645
  width,
640
646
  },
641
647
  theme,
@@ -13,6 +13,7 @@ export class SkillMessageComponent extends Container {
13
13
  super();
14
14
 
15
15
  this.#box = new Box(1, 1, t => theme.bg("customMessageBg", t));
16
+ this.#box.setIgnoreTight(true);
16
17
  this.#rebuild();
17
18
  }
18
19
 
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
4
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
5
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
5
6
  import { getProjectDir } from "@oh-my-pi/pi-utils";
6
7
  import { $ } from "bun";
@@ -55,21 +56,70 @@ function messageFingerprint(msg: AgentMessage): string {
55
56
  }
56
57
  }
57
58
  } else if (role === "assistant") {
59
+ const assistantMsg = msg as AssistantMessage;
60
+ const usageExt = assistantMsg.usage as unknown as { promptTokensDetails?: unknown };
61
+ const usageTotal = assistantMsg.usage?.totalTokens ?? 0;
62
+ const promptBuckets = usageExt?.promptTokensDetails ? 1 : 0;
63
+ const stopReason = assistantMsg.stopReason ?? "";
64
+
65
+ let signatureLen = 0;
66
+ let redactedLen = 0;
67
+ const msgExt = assistantMsg as unknown as {
68
+ thinkingSignature?: string;
69
+ textSignature?: string;
70
+ thoughtSignature?: string;
71
+ redactedThinking?: { data?: string };
72
+ };
73
+ const thinkingSignature = msgExt.thinkingSignature;
74
+ if (typeof thinkingSignature === "string") {
75
+ signatureLen += thinkingSignature.length;
76
+ }
77
+ const textSignature = msgExt.textSignature;
78
+ if (typeof textSignature === "string") {
79
+ signatureLen += textSignature.length;
80
+ }
81
+ const thoughtSignature = msgExt.thoughtSignature;
82
+ if (typeof thoughtSignature === "string") {
83
+ signatureLen += thoughtSignature.length;
84
+ }
85
+ const redactedData = msgExt.redactedThinking?.data;
86
+ if (typeof redactedData === "string") {
87
+ redactedLen += redactedData.length;
88
+ }
89
+
58
90
  const content = (msg as { content?: unknown }).content;
59
91
  if (Array.isArray(content)) {
60
92
  blocks = content.length;
61
93
  for (const block of content) {
62
94
  if (!block || typeof block !== "object") continue;
63
- const b = block as { type?: string; text?: string; thinking?: string; name?: string; arguments?: unknown };
95
+ const b = block as {
96
+ type?: string;
97
+ text?: string;
98
+ thinking?: string;
99
+ thinkingSignature?: string;
100
+ signature?: string;
101
+ textSignature?: string;
102
+ thoughtSignature?: string;
103
+ data?: string;
104
+ name?: string;
105
+ arguments?: unknown;
106
+ };
64
107
  if (b.type === "text" && typeof b.text === "string") textLen += b.text.length;
65
- else if (b.type === "thinking" && typeof b.thinking === "string") textLen += b.thinking.length;
66
- else if (b.type === "toolCall") {
108
+ else if (b.type === "thinking") {
109
+ if (typeof b.thinking === "string") textLen += b.thinking.length;
110
+ if (typeof b.thinkingSignature === "string") signatureLen += b.thinkingSignature.length;
111
+ if (typeof b.signature === "string") signatureLen += b.signature.length;
112
+ if (typeof b.textSignature === "string") signatureLen += b.textSignature.length;
113
+ if (typeof b.thoughtSignature === "string") signatureLen += b.thoughtSignature.length;
114
+ } else if (b.type === "redactedThinking" && typeof b.data === "string") {
115
+ redactedLen += b.data.length;
116
+ } else if (b.type === "toolCall") {
67
117
  if (typeof b.name === "string") textLen += b.name.length;
68
- // Argument bytes vary; a length proxy is enough to detect in-place edits.
69
118
  textLen += b.arguments === undefined ? 0 : JSON.stringify(b.arguments).length;
70
119
  }
71
120
  }
72
121
  }
122
+ return `${role}:${ts}:${textLen}:${blocks}:${images}:${signatureLen}:${redactedLen}:${usageTotal}:${promptBuckets}:${stopReason}`;
73
123
  } else if (role === "toolResult" || role === "hookMessage") {
74
124
  const content = (msg as { content?: unknown }).content;
75
125
  if (typeof content === "string") {
@@ -95,8 +145,12 @@ interface ContextUsageMemo {
95
145
  length: number;
96
146
  lastFingerprint: string | undefined;
97
147
  modelContextWindow: number;
98
- usedTokens: number | null;
148
+ contextUsageRevision: number;
149
+ usedTokens: number;
99
150
  contextWindow: number;
151
+ systemPromptRef: readonly string[] | undefined;
152
+ toolsRef: readonly any[] | undefined;
153
+ skillsRef: readonly any[] | undefined;
100
154
  }
101
155
 
102
156
  const EMPTY_MESSAGES: readonly AgentMessage[] = [];
@@ -104,6 +158,16 @@ const EMPTY_MESSAGES: readonly AgentMessage[] = [];
104
158
  function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
105
159
  return segments.includes("context_pct") || segments.includes("context_total");
106
160
  }
161
+ function hasGitSegment(segments: readonly StatusLineSegmentId[]): boolean {
162
+ return segments.includes("git");
163
+ }
164
+
165
+ function hasPrSegment(segments: readonly StatusLineSegmentId[]): boolean {
166
+ return segments.includes("pr");
167
+ }
168
+ function hasGitBackedSegment(segments: readonly StatusLineSegmentId[]): boolean {
169
+ return hasGitSegment(segments) || hasPrSegment(segments);
170
+ }
107
171
 
108
172
  // ═══════════════════════════════════════════════════════════════════════════
109
173
  // StatusLineComponent
@@ -166,6 +230,15 @@ export class StatusLineComponent implements Component {
166
230
  transparent: settings.get("statusLine.transparent"),
167
231
  };
168
232
  }
233
+ #gitEnabled(): boolean {
234
+ return settings.get("git.enabled");
235
+ }
236
+ #hasGitBackedSegment(): boolean {
237
+ const effectiveSettings = this.#resolveSettings();
238
+ return (
239
+ hasGitBackedSegment(effectiveSettings.leftSegments) || hasGitBackedSegment(effectiveSettings.rightSegments)
240
+ );
241
+ }
169
242
 
170
243
  /**
171
244
  * Re-point the status line at another session (focus proxy). Invalidate: model/context/usage all derive
@@ -183,6 +256,7 @@ export class StatusLineComponent implements Component {
183
256
  updateSettings(settings: StatusLineSettings): void {
184
257
  this.#settings = settings;
185
258
  this.#effectiveSettings = undefined;
259
+ if (this.#onBranchChange) this.#setupGitWatcher();
186
260
  }
187
261
 
188
262
  getEffectiveSettingsForTest(): EffectiveStatusLineSettings {
@@ -241,6 +315,11 @@ export class StatusLineComponent implements Component {
241
315
  this.#gitWatcher = null;
242
316
  }
243
317
 
318
+ if (!this.#gitEnabled() || !this.#hasGitBackedSegment()) {
319
+ this.#invalidateGitCaches();
320
+ return;
321
+ }
322
+
244
323
  const repository = git.repo.resolveSync(getProjectDir());
245
324
  if (!repository) return;
246
325
 
@@ -286,6 +365,8 @@ export class StatusLineComponent implements Component {
286
365
  this.#cachedPrContext = undefined;
287
366
  }
288
367
  #getCurrentBranch(): string | null {
368
+ if (!this.#gitEnabled()) return null;
369
+
289
370
  const cwd = getProjectDir();
290
371
  if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === cwd) {
291
372
  return this.#cachedBranch;
@@ -322,6 +403,7 @@ export class StatusLineComponent implements Component {
322
403
  }
323
404
 
324
405
  #getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
406
+ if (!this.#gitEnabled()) return null;
325
407
  if (this.#gitStatusInFlight || Date.now() - this.#gitStatusLastFetch < 1000) {
326
408
  return this.#cachedGitStatus;
327
409
  }
@@ -343,6 +425,8 @@ export class StatusLineComponent implements Component {
343
425
  }
344
426
 
345
427
  #lookupPr(): { number: number; url: string } | null {
428
+ if (!this.#gitEnabled()) return null;
429
+
346
430
  const branch = this.#getCurrentBranch();
347
431
  const currentContext = branch ? createPrCacheContext(branch, this.#cachedBranchRepoId ?? null) : null;
348
432
 
@@ -515,11 +599,19 @@ export class StatusLineComponent implements Component {
515
599
  * (right after compaction, before the next response). Exposed (non-private)
516
600
  * for unit tests and the collab host's state broadcast.
517
601
  */
518
- getCachedContextBreakdown(): { usedTokens: number | null; contextWindow: number } {
602
+ getCachedContextBreakdown(): { usedTokens: number; contextWindow: number } {
519
603
  const messages = this.session.messages ?? EMPTY_MESSAGES;
520
604
  const modelContextWindow = this.session.model?.contextWindow ?? 0;
521
605
  const length = messages.length;
522
606
  const lastFingerprint = length > 0 ? messageFingerprint(messages[length - 1]!) : undefined;
607
+ // Bumps when the in-flight pending snapshot is set/cleared. Without it a
608
+ // value computed mid-turn (estimate of the active tail) would survive after
609
+ // the turn ends/aborts, since clearing the snapshot touches no message.
610
+ const contextUsageRevision = this.session.contextUsageRevision ?? 0;
611
+
612
+ const systemPrompt = this.session.systemPrompt;
613
+ const tools = this.session.agent?.state?.tools;
614
+ const skills = this.session.skills;
523
615
 
524
616
  const cache = this.#contextUsageCache;
525
617
  if (
@@ -527,21 +619,29 @@ export class StatusLineComponent implements Component {
527
619
  cache.messagesRef === messages &&
528
620
  cache.length === length &&
529
621
  cache.lastFingerprint === lastFingerprint &&
530
- cache.modelContextWindow === modelContextWindow
622
+ cache.modelContextWindow === modelContextWindow &&
623
+ cache.contextUsageRevision === contextUsageRevision &&
624
+ cache.systemPromptRef === systemPrompt &&
625
+ cache.toolsRef === tools &&
626
+ cache.skillsRef === skills
531
627
  ) {
532
628
  return { usedTokens: cache.usedTokens, contextWindow: cache.contextWindow };
533
629
  }
534
630
 
535
631
  const usage = this.session.getContextUsage();
536
- const usedTokens = usage?.tokens ?? null;
632
+ const usedTokens = usage?.tokens ?? 0;
537
633
  const contextWindow = usage?.contextWindow ?? modelContextWindow;
538
634
  this.#contextUsageCache = {
539
635
  messagesRef: messages,
540
636
  length,
541
637
  lastFingerprint,
542
638
  modelContextWindow,
639
+ contextUsageRevision,
543
640
  usedTokens,
544
641
  contextWindow,
642
+ systemPromptRef: systemPrompt,
643
+ toolsRef: tools,
644
+ skillsRef: skills,
545
645
  };
546
646
  return { usedTokens, contextWindow };
547
647
  }
@@ -550,6 +650,8 @@ export class StatusLineComponent implements Component {
550
650
  width: number,
551
651
  segmentOptions: StatusLineSettings["segmentOptions"],
552
652
  includeContext: boolean,
653
+ includeGit: boolean,
654
+ includePr: boolean,
553
655
  ): SegmentContext {
554
656
  const state = this.session.state;
555
657
 
@@ -575,8 +677,7 @@ export class StatusLineComponent implements Component {
575
677
  if (includeContext) {
576
678
  const breakdown = this.getCachedContextBreakdown();
577
679
  contextWindow = breakdown.contextWindow || contextWindow;
578
- contextPercent =
579
- breakdown.usedTokens === null ? null : contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : 0;
680
+ contextPercent = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : 0;
580
681
  }
581
682
 
582
683
  // Collab guest: context comes from the host's state frames — the local
@@ -587,6 +688,10 @@ export class StatusLineComponent implements Component {
587
688
  contextPercent = collabState.contextUsage.percent ?? contextPercent;
588
689
  }
589
690
 
691
+ const gitBranch = includeGit || includePr ? this.#getCurrentBranch() : null;
692
+ const gitStatus = includeGit ? this.#getGitStatus() : null;
693
+ const gitPr = includePr ? this.#lookupPr() : null;
694
+
590
695
  return {
591
696
  session: this.session,
592
697
  focusedAgentId: this.#focusedAgentId,
@@ -603,9 +708,9 @@ export class StatusLineComponent implements Component {
603
708
  subagentCount: this.#subagentCount,
604
709
  sessionStartTime: this.#sessionStartTime,
605
710
  git: {
606
- branch: this.#getCurrentBranch(),
607
- status: this.#getGitStatus(),
608
- pr: this.#lookupPr(),
711
+ branch: gitBranch,
712
+ status: gitStatus,
713
+ pr: gitPr,
609
714
  },
610
715
  usage: this.#cachedUsage,
611
716
  };
@@ -656,7 +761,19 @@ export class StatusLineComponent implements Component {
656
761
  const effectiveSettings = this.#resolveSettings();
657
762
  const includeContext =
658
763
  hasContextSegment(effectiveSettings.leftSegments) || hasContextSegment(effectiveSettings.rightSegments);
659
- const ctx = this.#buildSegmentContext(width, effectiveSettings.segmentOptions, includeContext);
764
+ const gitEnabled = this.#gitEnabled();
765
+ const includeGit =
766
+ gitEnabled &&
767
+ (hasGitSegment(effectiveSettings.leftSegments) || hasGitSegment(effectiveSettings.rightSegments));
768
+ const includePr =
769
+ gitEnabled && (hasPrSegment(effectiveSettings.leftSegments) || hasPrSegment(effectiveSettings.rightSegments));
770
+ const ctx = this.#buildSegmentContext(
771
+ width,
772
+ effectiveSettings.segmentOptions,
773
+ includeContext,
774
+ includeGit,
775
+ includePr,
776
+ );
660
777
  const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
661
778
 
662
779
  // `transparent` reuses the empty-string sentinel (`\x1b[49m`) so the bar
@@ -58,7 +58,6 @@ export function getContextUsageLevel(contextPercent: number, contextWindow: numb
58
58
  /**
59
59
  * Format context usage as `<percent>%/<window>` (e.g. `5.1%/1M`), matching the
60
60
  * status line's context gauge so subagent and footer renderers stay in sync.
61
- * A `null`/`undefined` percent (unknown, e.g. right after compaction) renders as `?`.
62
61
  */
63
62
  export function formatContextUsage(contextPercent: number | null | undefined, contextWindow: number): string {
64
63
  const pct = contextPercent === null || contextPercent === undefined ? "?" : `${contextPercent.toFixed(1)}%`;
@@ -17,6 +17,7 @@ export class TodoReminderComponent extends Container {
17
17
  super();
18
18
 
19
19
  this.#box = new Box(1, 1, t => theme.inverse(theme.fg("warning", t)));
20
+ this.#box.setIgnoreTight(true);
20
21
  this.addChild(this.#box);
21
22
 
22
23
  this.#rebuild();
@@ -25,6 +25,7 @@ export class TtsrNotificationComponent extends Container {
25
25
 
26
26
  // Use inverse warning color for yellow background effect
27
27
  this.#box = new Box(1, 1, t => theme.inverse(theme.fg("warning", t)));
28
+ this.#box.setIgnoreTight(true);
28
29
  this.addChild(this.#box);
29
30
 
30
31
  this.#rebuild();
@@ -42,12 +42,12 @@ export class UserMessageComponent extends Container {
42
42
  ? imageReferenceHyperlink(label, index, imageLinks, imageLabel)
43
43
  : theme.fg("accent", `\x1b[1m${label}\x1b[22m`),
44
44
  });
45
- this.addChild(
46
- new Markdown(text, 1, 1, getMarkdownTheme(), {
47
- bgColor,
48
- color,
49
- }),
50
- );
45
+ const md = new Markdown(text, 1, 1, getMarkdownTheme(), {
46
+ bgColor,
47
+ color,
48
+ });
49
+ md.setIgnoreTight(true);
50
+ this.addChild(md);
51
51
  }
52
52
 
53
53
  override render(width: number): readonly string[] {
@@ -1,6 +1,5 @@
1
1
  import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
2
- import { calculatePromptTokens } from "@oh-my-pi/pi-agent-core/compaction/compaction";
3
- import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
2
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
4
3
  import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui";
5
4
  import { extractTextContent } from "../../commit/utils";
6
5
  import { settings } from "../../config/settings";
@@ -1107,11 +1106,7 @@ export class EventController {
1107
1106
  }
1108
1107
 
1109
1108
  #currentContextTokens(): number {
1110
- const lastAssistant = this.ctx.viewSession.agent.state.messages
1111
- .slice()
1112
- .reverse()
1113
- .find((m): m is AssistantMessage => m.role === "assistant" && m.stopReason !== "aborted");
1114
- return lastAssistant?.usage ? calculatePromptTokens(lastAssistant.usage) : 0;
1109
+ return this.ctx.viewSession.getContextUsage()?.tokens ?? 0;
1115
1110
  }
1116
1111
 
1117
1112
  sendCompletionNotification(): void {
@@ -3,7 +3,7 @@ import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai";
3
3
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
4
4
  import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types";
5
5
  import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui";
6
- import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
6
+ import { Input, Loader, Spacer, setTuiTight, Text } from "@oh-my-pi/pi-tui";
7
7
  import { getAgentDbPath, getProjectDir, normalizePathForComparison } from "@oh-my-pi/pi-utils";
8
8
  import { formatModelSelectorValue } from "../../config/model-resolver";
9
9
  import { getRoleInfo } from "../../config/model-roles";
@@ -67,7 +67,6 @@ import { TranscriptBlock } from "../components/transcript-container";
67
67
  import { TreeSelectorComponent } from "../components/tree-selector";
68
68
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
69
69
  import type { SessionObserverRegistry } from "../session-observer-registry";
70
- import { computeContextBreakdown } from "../utils/context-usage";
71
70
  import { buildCopyTargets } from "../utils/copy-targets";
72
71
 
73
72
  const MANUAL_LOGIN_TIP = "Tip: You can complete pairing with /login <redirect URL>.";
@@ -325,6 +324,13 @@ export class SelectorController {
325
324
  }
326
325
  }
327
326
  break;
327
+ case "tui.tight":
328
+ setTuiTight(value as boolean);
329
+ this.ctx.ui.invalidate();
330
+ this.ctx.updateEditorTopBorder();
331
+ this.ctx.ui.requestRender();
332
+ break;
333
+
328
334
  case "theme": {
329
335
  setTheme(value as string, true).then(result => {
330
336
  this.ctx.statusLine.invalidate();
@@ -380,6 +386,7 @@ export class SelectorController {
380
386
  this.ctx.session.agent.repetitionPenalty = repetitionPenalty >= 0 ? repetitionPenalty : undefined;
381
387
  break;
382
388
  }
389
+ case "git.enabled":
383
390
  case "statusLinePreset":
384
391
  case "statusLine.preset":
385
392
  case "statusLineSeparator":
@@ -443,7 +450,7 @@ export class SelectorController {
443
450
  }
444
451
 
445
452
  showModelSelector(options?: { temporaryOnly?: boolean }): void {
446
- const currentContextTokens = computeContextBreakdown(this.ctx.session).usedTokens;
453
+ const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
447
454
  this.showSelector(done => {
448
455
  const selector = new ModelSelectorComponent(
449
456
  this.ctx.ui,
@@ -30,6 +30,7 @@ import {
30
30
  ProcessTerminal,
31
31
  Spacer,
32
32
  setTerminalTextSizing,
33
+ setTuiTight,
33
34
  TERMINAL,
34
35
  Text,
35
36
  TUI,
@@ -566,6 +567,7 @@ export class InteractiveMode implements InteractiveModeContext {
566
567
  );
567
568
  }
568
569
 
570
+ setTuiTight(settings.get("tui.tight"));
569
571
  this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
570
572
  this.ui.setMaxInlineImages(settings.get("tui.maxInlineImages"));
571
573
  // OSC 66 text-sizing is Kitty-only; resolve the setting against the terminal's
@@ -2179,7 +2181,7 @@ export class InteractiveMode implements InteractiveModeContext {
2179
2181
  }
2180
2182
 
2181
2183
  #formatKeepContextLabel(contextUsage: ContextUsage | undefined): string {
2182
- if (contextUsage?.tokens == null) {
2184
+ if (!contextUsage) {
2183
2185
  return "Approve and keep context";
2184
2186
  }
2185
2187
  const tokens = formatContextTokenCount(contextUsage.tokens);
@@ -2188,7 +2190,7 @@ export class InteractiveMode implements InteractiveModeContext {
2188
2190
  }
2189
2191
 
2190
2192
  #isKeepContextDisabled(contextUsage: ContextUsage | undefined): boolean {
2191
- return contextUsage?.percent != null && contextUsage.percent > PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT;
2193
+ return contextUsage !== undefined && contextUsage.percent > PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT;
2192
2194
  }
2193
2195
 
2194
2196
  async #openPlanInExternalEditor(planFilePath: string): Promise<void> {
@@ -108,7 +108,7 @@ export interface RpcSessionState {
108
108
  /** For session dump / export (plain-text parity with /dump). */
109
109
  systemPrompt?: string[];
110
110
  dumpTools?: Array<{ name: string; description: string; parameters: unknown; examples?: readonly ToolExample[] }>;
111
- /** Current context window usage. Null tokens/percent when unknown (e.g. right after compaction). */
111
+ /** Current context window usage. */
112
112
  contextUsage?: ContextUsage;
113
113
  }
114
114