fix(tui): gated git status line work

Added a git.enabled setting and skipped footer/status-line git probes when integration is disabled or no git-backed status segment is rendered.

Fixes #2847
This commit is contained in:
roboomp
2026-06-17 12:24:18 +02:00
committed by can1357
parent e911e9da71
commit 944b9ea73f
5 changed files with 110 additions and 5 deletions
+2
View File
@@ -59,6 +59,8 @@
- Fixed startup model fallback choosing the plain OpenAI `gpt-5.5` provider before the Codex OAuth provider when both shared the same default model id, which could surface a misleading OpenAI 401 despite valid Codex credentials ([#2807](https://github.com/can1357/oh-my-pi/issues/2807)).
- Fixed local auto-thinking classification for reasoning-capable tiny models by giving them the same safe answer budget as online reasoning classifiers, with a larger local floor for non-reasoning tiny models ([#2808](https://github.com/can1357/oh-my-pi/issues/2808)).
- Fixed WSL2 TUI stutter by adding a `git.enabled` setting and skipping footer/status-line git probes when disabled or when no git-backed status segment is visible ([#2847](https://github.com/can1357/oh-my-pi/issues/2847)).
### Removed
- Removed the built-in `render_mermaid` tool and its `renderMermaid.enabled` setting, so it can no longer be invoked directly
@@ -415,6 +415,16 @@ export const SETTINGS_SCHEMA = {
},
},
shellPath: { type: "string", default: undefined },
"git.enabled": {
type: "boolean",
default: true,
ui: {
tab: "interaction",
group: "Git",
label: "Enable Git Integration",
description: "Show git branch, status, and PR information in the TUI and watch repository metadata.",
},
},
extensions: { type: "array", default: EMPTY_STRING_ARRAY },
@@ -4,6 +4,7 @@ import { stripVTControlCharacters } from "node:util";
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import { formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
import { settings } from "../../config/settings";
import { theme } from "../../modes/theme/theme";
import type { AgentSession } from "../../session/agent-session";
import { shortenPath } from "../../tools/render-utils";
@@ -58,6 +59,8 @@ export class FooterComponent implements Component {
this.#gitWatcher = null;
}
if (!settings.get("git.enabled")) return;
void git.head
.resolve(getProjectDir())
.then(head => {
@@ -102,6 +105,7 @@ export class FooterComponent implements Component {
* Returns null if not in a git repo, branch name otherwise.
*/
#getCurrentBranch(): string | null {
if (!settings.get("git.enabled")) return null;
if (this.#cachedBranch !== undefined) {
return this.#cachedBranch;
}
@@ -104,6 +104,13 @@ const EMPTY_MESSAGES: readonly AgentMessage[] = [];
function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
return segments.includes("context_pct") || segments.includes("context_total");
}
function hasGitSegment(segments: readonly StatusLineSegmentId[]): boolean {
return segments.includes("git");
}
function hasPrSegment(segments: readonly StatusLineSegmentId[]): boolean {
return segments.includes("pr");
}
// ═══════════════════════════════════════════════════════════════════════════
// StatusLineComponent
@@ -166,6 +173,9 @@ export class StatusLineComponent implements Component {
transparent: settings.get("statusLine.transparent"),
};
}
#gitEnabled(): boolean {
return settings.get("git.enabled");
}
/**
* Re-point the status line at another session (focus proxy). Invalidate: model/context/usage all derive
@@ -241,6 +251,11 @@ export class StatusLineComponent implements Component {
this.#gitWatcher = null;
}
if (!this.#gitEnabled()) {
this.#invalidateGitCaches();
return;
}
const repository = git.repo.resolveSync(getProjectDir());
if (!repository) return;
@@ -286,6 +301,8 @@ export class StatusLineComponent implements Component {
this.#cachedPrContext = undefined;
}
#getCurrentBranch(): string | null {
if (!this.#gitEnabled()) return null;
const cwd = getProjectDir();
if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === cwd) {
return this.#cachedBranch;
@@ -322,6 +339,7 @@ export class StatusLineComponent implements Component {
}
#getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
if (!this.#gitEnabled()) return null;
if (this.#gitStatusInFlight || Date.now() - this.#gitStatusLastFetch < 1000) {
return this.#cachedGitStatus;
}
@@ -343,6 +361,8 @@ export class StatusLineComponent implements Component {
}
#lookupPr(): { number: number; url: string } | null {
if (!this.#gitEnabled()) return null;
const branch = this.#getCurrentBranch();
const currentContext = branch ? createPrCacheContext(branch, this.#cachedBranchRepoId ?? null) : null;
@@ -550,6 +570,8 @@ export class StatusLineComponent implements Component {
width: number,
segmentOptions: StatusLineSettings["segmentOptions"],
includeContext: boolean,
includeGit: boolean,
includePr: boolean,
): SegmentContext {
const state = this.session.state;
@@ -587,6 +609,10 @@ export class StatusLineComponent implements Component {
contextPercent = collabState.contextUsage.percent ?? contextPercent;
}
const gitBranch = includeGit || includePr ? this.#getCurrentBranch() : null;
const gitStatus = includeGit ? this.#getGitStatus() : null;
const gitPr = includePr ? this.#lookupPr() : null;
return {
session: this.session,
focusedAgentId: this.#focusedAgentId,
@@ -603,9 +629,9 @@ export class StatusLineComponent implements Component {
subagentCount: this.#subagentCount,
sessionStartTime: this.#sessionStartTime,
git: {
branch: this.#getCurrentBranch(),
status: this.#getGitStatus(),
pr: this.#lookupPr(),
branch: gitBranch,
status: gitStatus,
pr: gitPr,
},
usage: this.#cachedUsage,
};
@@ -656,7 +682,19 @@ export class StatusLineComponent implements Component {
const effectiveSettings = this.#resolveSettings();
const includeContext =
hasContextSegment(effectiveSettings.leftSegments) || hasContextSegment(effectiveSettings.rightSegments);
const ctx = this.#buildSegmentContext(width, effectiveSettings.segmentOptions, includeContext);
const gitEnabled = this.#gitEnabled();
const includeGit =
gitEnabled &&
(hasGitSegment(effectiveSettings.leftSegments) || hasGitSegment(effectiveSettings.rightSegments));
const includePr =
gitEnabled && (hasPrSegment(effectiveSettings.leftSegments) || hasPrSegment(effectiveSettings.rightSegments));
const ctx = this.#buildSegmentContext(
width,
effectiveSettings.segmentOptions,
includeContext,
includeGit,
includePr,
);
const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
// `transparent` reuses the empty-string sentinel (`\x1b[49m`) so the bar
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -7,6 +7,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { StatusLineComponent, type StatusLineSettings } from "@oh-my-pi/pi-coding-agent/modes/components/status-line";
import { STATUS_LINE_PRESETS } from "@oh-my-pi/pi-coding-agent/modes/components/status-line/presets";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import * as git from "@oh-my-pi/pi-coding-agent/utils/git";
import { setProjectDir } from "@oh-my-pi/pi-utils";
import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state";
@@ -196,4 +197,54 @@ describe("StatusLineComponent effective settings cache", () => {
expect(nextEffective).not.toBe(effective);
expect(component.getEffectiveSettingsForTest()).toBe(nextEffective);
});
it("skips git probes when git integration is disabled", async () => {
const headSpy = spyOn(git.head, "resolveSync").mockReturnValue(null);
const statusSpy = spyOn(git.status, "summary").mockResolvedValue({ staged: 0, unstaged: 0, untracked: 0 });
const repoSpy = spyOn(git.repo, "resolveSync").mockReturnValue(null);
try {
Settings.instance.override("git.enabled", false);
const component = makeComponent({
preset: "custom",
leftSegments: ["git", "pr"],
rightSegments: [],
sessionAccent: false,
});
component.watchBranch(() => {
throw new Error("git watcher should not fire while git integration is disabled");
});
component.getTopBorder(100);
await Promise.resolve();
expect(repoSpy).not.toHaveBeenCalled();
expect(headSpy).not.toHaveBeenCalled();
expect(statusSpy).not.toHaveBeenCalled();
} finally {
repoSpy.mockRestore();
statusSpy.mockRestore();
headSpy.mockRestore();
}
});
it("skips git probes when no git-backed segment is visible", async () => {
const headSpy = spyOn(git.head, "resolveSync").mockReturnValue(null);
const statusSpy = spyOn(git.status, "summary").mockResolvedValue({ staged: 0, unstaged: 0, untracked: 0 });
try {
const component = makeComponent({
preset: "custom",
leftSegments: ["pi"],
rightSegments: ["session_name"],
sessionAccent: false,
});
component.getTopBorder(100);
await Promise.resolve();
expect(headSpy).not.toHaveBeenCalled();
expect(statusSpy).not.toHaveBeenCalled();
} finally {
statusSpy.mockRestore();
headSpy.mockRestore();
}
});
});