mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
feat(tui): include session name in the terminal titlebar (#43188)
The terminal/console titlebar was composed from status marker + model + cwd only; the session's (auto-)title never appeared, even though the TUI already knows it. Change the format to `<marker> <session name> · <model> · <cwd>`, with the session name and cwd each omitted when absent so single-segment titles stay clean. The current session's live title is pulled from the existing session.active_list poll (which already carries each session's current flag and title), so there's no extra round-trip; UiState gains a sessionTitle field updated only when it actually changes, preserving the existing idle-flicker guard. Extract the join logic into a pure composeTabTitle() helper in domain/paths and cover its edge cases (name omitted, cwd omitted, whitespace-only name, marker-only fallback, truncation, boundary length) in paths.test.ts.
This commit is contained in:
parent
258d24039f
commit
d33965396e
5 changed files with 85 additions and 6 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
|
||||
import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js'
|
||||
|
||||
describe('shortCwd', () => {
|
||||
const origHome = process.env.HOME
|
||||
|
|
@ -68,3 +68,43 @@ describe('fmtCwdBranch', () => {
|
|||
expect(out).toContain(')')
|
||||
})
|
||||
})
|
||||
|
||||
describe('composeTabTitle', () => {
|
||||
it('joins marker, name, model, and cwd in order', () => {
|
||||
expect(composeTabTitle('✓', 'auth refactor', 'opus-4', '~/proj')).toBe('✓ auth refactor · opus-4 · ~/proj')
|
||||
})
|
||||
|
||||
it('glues the marker to the first segment with a space, not a separator', () => {
|
||||
expect(composeTabTitle('⏳', 'my session', 'opus-4', '~/proj').startsWith('⏳ my session')).toBe(true)
|
||||
})
|
||||
|
||||
it('omits the session name when empty (matches the pre-name format)', () => {
|
||||
expect(composeTabTitle('✓', '', 'opus-4', '~/proj')).toBe('✓ opus-4 · ~/proj')
|
||||
})
|
||||
|
||||
it('treats a whitespace-only name as absent', () => {
|
||||
expect(composeTabTitle('✓', ' ', 'opus-4', '~/proj')).toBe('✓ opus-4 · ~/proj')
|
||||
})
|
||||
|
||||
it('omits the cwd when empty', () => {
|
||||
expect(composeTabTitle('✓', 'my session', 'opus-4', '')).toBe('✓ my session · opus-4')
|
||||
})
|
||||
|
||||
it('falls back to just the marker when only the marker is present', () => {
|
||||
expect(composeTabTitle('✓', '', '', '')).toBe('✓')
|
||||
})
|
||||
|
||||
it('truncates an over-long session name with an ellipsis', () => {
|
||||
const long = 'a'.repeat(40)
|
||||
const out = composeTabTitle('✓', long, 'opus-4', '', 28)
|
||||
const namePart = out.slice('✓ '.length).split(' · ')[0]
|
||||
expect(namePart.endsWith('…')).toBe(true)
|
||||
expect(namePart.length).toBe(28)
|
||||
})
|
||||
|
||||
it('keeps a name at the boundary length intact', () => {
|
||||
const name = 'b'.repeat(28)
|
||||
const out = composeTabTitle('✓', name, 'opus-4', '', 28)
|
||||
expect(out).toBe(`✓ ${name} · opus-4`)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export interface UiState {
|
|||
pasteCollapseChars: number
|
||||
|
||||
sections: SectionVisibility
|
||||
sessionTitle: string
|
||||
showCost: boolean
|
||||
showReasoning: boolean
|
||||
indicatorStyle: IndicatorStyle
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const buildUiState = (): UiState => ({
|
|||
pasteCollapseLines: 5,
|
||||
pasteCollapseChars: 2000,
|
||||
sections: {},
|
||||
sessionTitle: '',
|
||||
showCost: false,
|
||||
showReasoning: false,
|
||||
sid: null,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
|
|||
import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js'
|
||||
import { SECTION_NAMES, sectionMode } from '../domain/details.js'
|
||||
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
|
||||
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
|
||||
import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js'
|
||||
import { type GatewayClient } from '../gatewayClient.js'
|
||||
import type {
|
||||
ClarifyRespondResponse,
|
||||
|
|
@ -524,12 +524,22 @@ export function useMainApp(gw: GatewayClient) {
|
|||
if (!stopped && result?.sessions) {
|
||||
const liveSessionCount = result.sessions.length
|
||||
|
||||
// Only patch when the count actually changed. patchUiState always
|
||||
// Surface the current session's (auto-)title for the terminal
|
||||
// titlebar. The active_list poll already carries it, so no extra
|
||||
// round-trip is needed.
|
||||
const currentSid = getUiState().sid
|
||||
|
||||
const sessionTitle =
|
||||
result.sessions.find(s => s.current || s.id === currentSid)?.title?.trim() ?? ''
|
||||
|
||||
// Only patch when something actually changed. patchUiState always
|
||||
// produces a new state object, which notifies every $uiState
|
||||
// subscriber; patching unconditionally on each 1.5s poll re-renders
|
||||
// the whole TUI and causes idle flicker.
|
||||
if (getUiState().liveSessionCount !== liveSessionCount) {
|
||||
patchUiState({ liveSessionCount })
|
||||
const prev = getUiState()
|
||||
|
||||
if (prev.liveSessionCount !== liveSessionCount || prev.sessionTitle !== sessionTitle) {
|
||||
patchUiState({ liveSessionCount, sessionTitle })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -546,13 +556,16 @@ export function useMainApp(gw: GatewayClient) {
|
|||
}, [gw, ui.sid])
|
||||
|
||||
// Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle.
|
||||
// Format: `<marker> <session name> · <model> · <cwd>` — name/cwd omitted when absent.
|
||||
const model = ui.info?.model?.replace(/^.*\//, '') ?? ''
|
||||
|
||||
const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓'
|
||||
|
||||
const tabCwd = ui.info?.cwd
|
||||
|
||||
useTerminalTitle(model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Hermes')
|
||||
useTerminalTitle(
|
||||
model ? composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : '') : 'Hermes'
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ui.sid || !stdout) {
|
||||
|
|
|
|||
|
|
@ -14,3 +14,27 @@ export const fmtCwdBranch = (cwd: string, branch: null | string, max = 40) => {
|
|||
|
||||
return `${shortCwd(cwd, Math.max(8, max - tag.length))}${tag}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the terminal titlebar string:
|
||||
* `<marker> <session name> · <model> · <cwd>`
|
||||
*
|
||||
* The session name and cwd are each omitted when empty, and a long session
|
||||
* name is truncated. The marker is always glued to the first present segment
|
||||
* with a plain space (not a ` · ` separator). When no model is known yet the
|
||||
* caller should fall back to a plain brand string instead of calling this.
|
||||
*/
|
||||
export const composeTabTitle = (
|
||||
marker: string,
|
||||
sessionName: string,
|
||||
model: string,
|
||||
cwd: string,
|
||||
maxName = 28
|
||||
): string => {
|
||||
const name = sessionName.trim()
|
||||
const shortName = name.length > maxName ? `${name.slice(0, maxName - 1)}…` : name
|
||||
|
||||
const segments = [shortName, model, cwd].filter(Boolean)
|
||||
|
||||
return segments.length ? `${marker} ${segments.join(' · ')}` : marker
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue