diff --git a/ui-tui/src/__tests__/paths.test.ts b/ui-tui/src/__tests__/paths.test.ts new file mode 100644 index 0000000000..ef3c31ff36 --- /dev/null +++ b/ui-tui/src/__tests__/paths.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { fmtCwdBranch, shortCwd } from '../domain/paths.js' + +describe('shortCwd', () => { + const origHome = process.env.HOME + + beforeEach(() => { + process.env.HOME = '/Users/bb' + }) + + afterEach(() => { + process.env.HOME = origHome + }) + + it('collapses HOME to ~', () => { + expect(shortCwd('/Users/bb/proj/repo')).toBe('~/proj/repo') + }) + + it('leaves non-HOME paths alone', () => { + expect(shortCwd('/tmp/work')).toBe('/tmp/work') + }) + + it('truncates long paths from the left with ellipsis', () => { + const out = shortCwd('/var/long/deeply/nested/workspace/here', 10) + expect(out.startsWith('…')).toBe(true) + expect(out.length).toBe(10) + expect('/var/long/deeply/nested/workspace/here'.endsWith(out.slice(1))).toBe(true) + }) + + it('keeps paths shorter than max intact', () => { + expect(shortCwd('/a/b', 10)).toBe('/a/b') + }) +}) + +describe('fmtCwdBranch', () => { + const origHome = process.env.HOME + + beforeEach(() => { + process.env.HOME = '/Users/bb' + }) + + afterEach(() => { + process.env.HOME = origHome + }) + + it('returns bare cwd when branch is null', () => { + expect(fmtCwdBranch('/Users/bb/proj', null)).toBe('~/proj') + }) + + it('returns bare cwd when branch is empty', () => { + expect(fmtCwdBranch('/Users/bb/proj', '')).toBe('~/proj') + }) + + it('appends branch in parens', () => { + expect(fmtCwdBranch('/Users/bb/proj', 'main')).toBe('~/proj (main)') + }) + + it('truncates the path to keep the branch tag readable', () => { + const out = fmtCwdBranch('/Users/bb/very/deeply/nested/project/folder', 'feature-branch', 30) + expect(out).toMatch(/ \(feature-branch\)$/) + expect(out.length).toBeLessThanOrEqual(30) + }) + + it('truncates very long branch names from the right', () => { + const out = fmtCwdBranch('/Users/bb/p', 'a-very-long-feature-branch-name') + expect(out).toMatch(/^~\/p \(…/) + expect(out).toContain(')') + }) +}) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 46ab21c725..fb48badea9 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { STARTUP_RESUME_ID } from '../config/env.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' import { imageTokenMeta } from '../domain/messages.js' -import { shortCwd } from '../domain/paths.js' +import { fmtCwdBranch } from '../domain/paths.js' import { type GatewayClient } from '../gatewayClient.js' import type { ClarifyRespondResponse, @@ -13,6 +13,7 @@ import type { GatewayEvent, TerminalResizeResponse } from '../gatewayTypes.js' +import { useGitBranch } from '../hooks/useGitBranch.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' @@ -620,9 +621,12 @@ export function useMainApp(gw: GatewayClient) { [turn, showProgressArea] ) + const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd() + const gitBranch = useGitBranch(cwd) + const appStatus = useMemo( () => ({ - cwdLabel: shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd()), + cwdLabel: fmtCwdBranch(cwd, gitBranch), goodVibesTick, sessionStartedAt: ui.sid ? sessionStartedAt : null, showStickyPrompt: !!stickyPrompt, @@ -630,7 +634,7 @@ export function useMainApp(gw: GatewayClient) { stickyPrompt, voiceLabel: voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` }), - [goodVibesTick, sessionStartedAt, stickyPrompt, ui, voiceEnabled, voiceProcessing, voiceRecording] + [cwd, gitBranch, goodVibesTick, sessionStartedAt, stickyPrompt, ui, voiceEnabled, voiceProcessing, voiceRecording] ) const appTranscript = useMemo( diff --git a/ui-tui/src/domain/paths.ts b/ui-tui/src/domain/paths.ts index 78daff170a..6b95dcbac1 100644 --- a/ui-tui/src/domain/paths.ts +++ b/ui-tui/src/domain/paths.ts @@ -4,3 +4,14 @@ export const shortCwd = (cwd: string, max = 28) => { return p.length <= max ? p : `…${p.slice(-(max - 1))}` } + +export const fmtCwdBranch = (cwd: string, branch: null | string, max = 40) => { + if (!branch) { + return shortCwd(cwd, max) + } + + const b = branch.length > 16 ? `…${branch.slice(-15)}` : branch + const tag = ` (${b})` + + return `${shortCwd(cwd, Math.max(8, max - tag.length))}${tag}` +} diff --git a/ui-tui/src/hooks/useGitBranch.ts b/ui-tui/src/hooks/useGitBranch.ts new file mode 100644 index 0000000000..7eb4880177 --- /dev/null +++ b/ui-tui/src/hooks/useGitBranch.ts @@ -0,0 +1,72 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +import { useEffect, useState } from 'react' + +const TTL_MS = 15_000 +const TIMEOUT_MS = 500 + +const pexec = promisify(execFile) +const cache = new Map() +const inflight = new Map>() + +const resolveBranch = async (cwd: string): Promise => { + try { + const { stdout } = await pexec('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], { timeout: TIMEOUT_MS }) + const b = stdout.trim() + + return !b || b === 'HEAD' ? null : b + } catch { + return null + } +} + +const fetchBranch = (cwd: string): Promise => { + const pending = inflight.get(cwd) + + if (pending) { + return pending + } + + const p = resolveBranch(cwd).finally(() => inflight.delete(cwd)) + inflight.set(cwd, p) + + return p +} + +export function useGitBranch(cwd: string): null | string { + const [branch, setBranch] = useState(() => cache.get(cwd)?.branch ?? null) + + useEffect(() => { + let cancelled = false + + const tick = async () => { + const hit = cache.get(cwd) + + if (hit && Date.now() - hit.at < TTL_MS) { + if (!cancelled) { + setBranch(hit.branch) + } + + return + } + + const b = await fetchBranch(cwd) + cache.set(cwd, { at: Date.now(), branch: b }) + + if (!cancelled) { + setBranch(b) + } + } + + void tick() + const id = setInterval(() => void tick(), TTL_MS) + + return () => { + cancelled = true + clearInterval(id) + } + }, [cwd]) + + return branch +}