mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
feat(tui): append git branch to cwd label in status bar
Adds useGitBranch hook (async, cached, 15s TTL) and fmtCwdBranch helper so the footer shows `~/repo (main)` instead of just `~/repo`. Degrades silently when git is unavailable or cwd is outside a repo. Partial fix for #12267 (TUI portion; #12277 covers the Python side).
This commit is contained in:
70
ui-tui/src/__tests__/paths.test.ts
Normal file
70
ui-tui/src/__tests__/paths.test.ts
Normal file
@@ -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(')')
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
72
ui-tui/src/hooks/useGitBranch.ts
Normal file
72
ui-tui/src/hooks/useGitBranch.ts
Normal file
@@ -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<string, { at: number; branch: null | string }>()
|
||||
const inflight = new Map<string, Promise<null | string>>()
|
||||
|
||||
const resolveBranch = async (cwd: string): Promise<null | string> => {
|
||||
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<null | string> => {
|
||||
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<null | string>(() => 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
|
||||
}
|
||||
Reference in New Issue
Block a user