From 944b9ea73fa9b32ef044c2c2b27f8b552d1bd601 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 17 Jun 2026 07:46:26 +0000 Subject: [PATCH] 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 --- packages/coding-agent/CHANGELOG.md | 2 + .../src/config/settings-schema.ts | 10 ++++ .../src/modes/components/footer.ts | 4 ++ .../modes/components/status-line/component.ts | 46 ++++++++++++++-- .../test/status-line-settings-cache.test.ts | 53 ++++++++++++++++++- 5 files changed, 110 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f950dd0da..f809c4b8c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index fd175969e..880bb445e 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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 }, diff --git a/packages/coding-agent/src/modes/components/footer.ts b/packages/coding-agent/src/modes/components/footer.ts index 7d7aae18a..712dbe804 100644 --- a/packages/coding-agent/src/modes/components/footer.ts +++ b/packages/coding-agent/src/modes/components/footer.ts @@ -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; } diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index ff8e07610..5e84f73fd 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -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 diff --git a/packages/coding-agent/test/status-line-settings-cache.test.ts b/packages/coding-agent/test/status-line-settings-cache.test.ts index a3fda0ff4..ad7d75eca 100644 --- a/packages/coding-agent/test/status-line-settings-cache.test.ts +++ b/packages/coding-agent/test/status-line-settings-cache.test.ts @@ -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(); + } + }); });