diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 565ceb67e08d..7d73180217a7 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -16,6 +16,7 @@ import { cn } from '@/lib/utils' import { copyFilePath, revealFile } from '@/store/file-actions' import { revealFileInTree } from '@/store/layout' import { $activeGatewayProfile } from '@/store/profile' +import { $projectTree, projectNameForCwd } from '@/store/projects' import { $activeSessionId, $busy, @@ -91,6 +92,12 @@ export function useStatusbarItems({ const terminalTakeover = useStore($terminalTakeover) const primaryBusy = useStore($busy) const currentCwd = useStore($currentCwd) + // Derive the workspace's project name from the already-cached project tree + // (backend truth via projects.*), so the status item labels by project without + // a second per-session copy of the same fact. Re-derives whenever the cwd or + // the tree changes; null (no named project) falls back to the cwd leaf below. + const projectTree = useStore($projectTree) + const projectName = useMemo(() => projectNameForCwd(currentCwd), [currentCwd, projectTree]) const primaryUsage = useStore($currentUsage) const gatewayRestarting = useStore($gatewayRestarting) const primarySessionStartedAt = useStore($sessionStartedAt) @@ -313,7 +320,10 @@ export function useStatusbarItems({ hidden: !currentCwd, icon: , id: 'workspace-cwd', - label: currentCwd ? workspaceLabel(currentCwd) : undefined, + // Prefer the named project; fall back to the cwd leaf. The full cwd is + // always in the tooltip (`title` below), so hovering reveals where the + // session actually sits — the worktree/subfolder, not just the project. + label: projectName || (currentCwd ? workspaceLabel(currentCwd) : undefined), menuItems: currentCwd ? [ { @@ -388,6 +398,7 @@ export function useStatusbarItems({ inferenceReady, inferenceStatus?.reason, openAgents, + projectName, subagentsFailed, subagentsRunning, toggleCommandCenter diff --git a/apps/desktop/src/store/projects.test.ts b/apps/desktop/src/store/projects.test.ts index 24bc2a6f854b..91dd14226b3d 100644 --- a/apps/desktop/src/store/projects.test.ts +++ b/apps/desktop/src/store/projects.test.ts @@ -2,10 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { $sidebarAgentsGrouped } from '@/store/layout' +import type { SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups' + import { $activeProjectId, $projectScope, $projectsRpcAvailable, + $projectTree, $worktreeRefreshToken, ALL_PROJECTS, createProject, @@ -13,6 +16,7 @@ import { exitProjectScope, openProjectCreate, pickProjectFolder, + projectNameForCwd, refreshProjects, refreshWorktrees } from './projects' @@ -81,6 +85,66 @@ describe('project scope', () => { }) }) +describe('projectNameForCwd', () => { + const treeNode = (over: Partial & Pick): SidebarProjectTree => ({ + path: null, + repos: [], + sessionCount: 0, + ...over + }) + + beforeEach(() => { + $projectTree.set([]) + }) + + it('names the explicit project owning the cwd (longest path match)', () => { + $projectTree.set([ + treeNode({ id: 'p_web', label: 'Website', path: '/repos/website' }), + treeNode({ id: 'p_api', label: 'API', path: '/repos/api' }) + ]) + + expect(projectNameForCwd('/repos/website/src/app')).toBe('Website') + }) + + it('matches nested repo and worktree paths, not just the project root', () => { + $projectTree.set([ + treeNode({ + id: 'p_mono', + label: 'Monorepo', + path: '/repos/mono', + repos: [ + { + id: 'r1', + label: 'mono', + path: '/repos/mono', + sessionCount: 0, + groups: [{ id: 'g1', label: 'feature', path: '/elsewhere/mono-feature', sessions: [] }] + } + ] + }) + ]) + + // A linked worktree lives OUTSIDE the project root but still belongs to it. + expect(projectNameForCwd('/elsewhere/mono-feature/src')).toBe('Monorepo') + }) + + it('ignores auto-projects and the No-project bucket (no named identity)', () => { + $projectTree.set([ + treeNode({ id: '/repos/loose', label: 'loose', path: '/repos/loose', isAuto: true }), + treeNode({ id: '__no_project__', label: 'No project', path: null, isNoProject: true }) + ]) + + expect(projectNameForCwd('/repos/loose/src')).toBeNull() + }) + + it('returns null for a cwd in no project and for a blank cwd', () => { + $projectTree.set([treeNode({ id: 'p_web', label: 'Website', path: '/repos/website' })]) + + expect(projectNameForCwd('/somewhere/else')).toBeNull() + expect(projectNameForCwd('')).toBeNull() + }) +}) + describe('worktree refresh', () => { it('refreshWorktrees bumps the probe token so useRepoWorktreeMap refetches', () => { const before = $worktreeRefreshToken.get() diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 7504f6686031..64f9b01fd3a7 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -180,6 +180,44 @@ export function projectIdForCwd(cwd: string): null | string { return best } +// The display NAME of the explicit, named project owning `cwd` (longest path +// match), or null when the cwd sits in no named project. The status bar reads +// this to label the workspace by project instead of the bare cwd leaf. We skip +// auto-projects (a repo root promoted with no projects.db row) and the synthetic +// "No project" bucket on purpose: those have no human name, so their sessions +// keep the cwd-leaf label — matching the backend `_project_info_for_cwd`, which +// only resolves projects.db rows, so the desktop and TUI name the same session +// identically without threading a second per-session copy through session.info. +export function projectNameForCwd(cwd: string): null | string { + const target = (cwd || '').trim() + + if (!target) { + return null + } + + let best: null | string = null + let bestLen = -1 + + for (const project of $projectTree.get()) { + if (project.isAuto || project.isNoProject) { + continue + } + + const paths = [project.path, ...project.repos.flatMap(repo => [repo.path, ...repo.groups.map(group => group.path)])] + + for (const path of paths) { + const p = (path || '').trim() + + if (p && underPath(p, target) && p.length > bestLen) { + bestLen = p.length + best = project.label + } + } + } + + return best +} + // The active session's agent relocated itself (created/entered another repo or // worktree via the terminal — backend re-anchors its cwd and emits session.info). // Re-pull projects + tree so a freshly created/auto project and the relocated diff --git a/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py index ca65803d5aee..2d38c1d7a535 100644 --- a/tests/tui_gateway/test_projects_rpc.py +++ b/tests/tui_gateway/test_projects_rpc.py @@ -172,6 +172,55 @@ def test_add_folder_and_for_cwd(tmp_path): assert "branch" in resolved +def test_project_info_for_cwd_returns_status_payload(tmp_path): + # The status-surface resolver returns the owning project's identity for a + # nested cwd — the shape the TUI status label + /status read. + folder = tmp_path / "repo" + folder.mkdir() + created = _call("projects.create", {"name": "Repo", "folders": [str(folder)]})["project"] + + nested = folder / "src" + nested.mkdir() + + assert server._project_info_for_cwd(str(nested)) == { + "id": created["id"], + "slug": "repo", + "name": "Repo", + "primary_path": str(folder), + } + + +def test_project_info_for_cwd_unowned_and_blank_are_none(tmp_path): + # A cwd outside every project — and an empty cwd — carry no project, so the + # status label falls back to the cwd leaf on every surface. + owned = tmp_path / "owned" + owned.mkdir() + _call("projects.create", {"name": "Owned", "folders": [str(owned)]}) + + outside = tmp_path / "outside" + outside.mkdir() + + assert server._project_info_for_cwd(str(outside)) is None + assert server._project_info_for_cwd("") is None + + +def test_session_info_carries_project_for_owned_cwd(tmp_path): + # session.info threads the resolved project through so the desktop/TUI can + # name the workspace without a second round-trip. + folder = tmp_path / "proj" + folder.mkdir() + _call("projects.create", {"name": "Proj", "folders": [str(folder)]}) + + info = server._session_info(None, {"cwd": str(folder), "session_key": "s1"}) + assert info["project"] == { + "id": info["project"]["id"], + "slug": "proj", + "name": "Proj", + "primary_path": str(folder), + } + assert info["project"]["name"] == "Proj" + + def test_update_and_archive(tmp_path): pid = _call("projects.create", {"name": "Orig", "folders": [str(tmp_path)]})["project"]["id"] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index cba10c4aad00..fc766f9f7470 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3699,6 +3699,35 @@ def _session_usage_snapshot(session: dict | None) -> dict: return dict(mirror_usage) if isinstance(mirror_usage, dict) else {} +def _project_info_for_cwd(cwd: str) -> dict | None: + """Return the first-class Project owning ``cwd`` for UI status surfaces. + + Backed by the per-profile projects.db (the same store the desktop's project + tree caches), so the TUI status label, the desktop status bar, and ``/status`` + all name the session's workspace identically. Only explicit, named projects + resolve here — an auto-discovered repo root has no projects.db row, so it + falls back to the cwd leaf on every surface. + """ + if not str(cwd or "").strip(): + return None + try: + from hermes_cli import projects_db as pdb + + with pdb.connect_closing() as conn: + project = pdb.project_for_path(conn, cwd) + if project is None: + return None + return { + "id": project.id, + "slug": project.slug, + "name": project.name, + "primary_path": project.primary_path, + } + except Exception: + logger.debug("failed to resolve project for cwd", exc_info=True) + return None + + def _session_info(agent, session: dict | None = None) -> dict: if session is None: for candidate in _sessions.values(): @@ -3754,6 +3783,7 @@ def _session_info(agent, session: dict | None = None) -> dict: "skills": dict(mirror.get("skills") or {}) if isinstance(mirror.get("skills"), dict) else {}, "cwd": cwd, "branch": _git_branch_for_cwd(cwd), + "project": _project_info_for_cwd(cwd), "personality": str(personality or ""), "running": bool((session or {}).get("running")), "title": _session_live_title(session or {}, session_key) if session_key else "", @@ -4357,7 +4387,12 @@ def _apply_project_workspace(task_id: str, path: str, _name: str = "") -> None: info = ( _session_info(agent, session) if agent is not None - else {"cwd": resolved, "branch": _git_branch_for_cwd(resolved), "lazy": True} + else { + "cwd": resolved, + "branch": _git_branch_for_cwd(resolved), + "project": _project_info_for_cwd(resolved), + "lazy": True, + } ) _emit("session.info", sid, info) except Exception: @@ -5812,6 +5847,7 @@ def _(rid, params: dict) -> dict: "skills": {}, "cwd": _sessions[sid]["cwd"], "branch": _git_branch_for_cwd(_sessions[sid]["cwd"]), + "project": _project_info_for_cwd(_sessions[sid]["cwd"]), "lazy": True, "desktop_contract": DESKTOP_BACKEND_CONTRACT, "profile_name": _current_profile_name(), @@ -5958,6 +5994,7 @@ def _lazy_resume_info(cwd: str, *, model: str = "", provider: str = "") -> dict: info = { "cwd": cwd, "branch": _git_branch_for_cwd(cwd), + "project": _project_info_for_cwd(cwd), "model": model or _resolve_model(), "tools": {}, "skills": {}, @@ -6452,6 +6489,7 @@ def _(rid, params: dict) -> dict: info = _session_info(agent, session) if agent is not None else { "cwd": cwd, "branch": _git_branch_for_cwd(cwd), + "project": _project_info_for_cwd(cwd), "lazy": True, } _emit("session.info", params.get("session_id", ""), info) @@ -6551,8 +6589,10 @@ def _fallback_session_info(session: dict) -> dict: agent = session.get("agent") if agent is not None: return _session_info(agent) + cwd = _default_session_cwd() return { - "cwd": _default_session_cwd(), + "cwd": cwd, + "project": _project_info_for_cwd(cwd), "lazy": True, "model": _resolve_model(), "skills": {}, @@ -8618,12 +8658,15 @@ def _(rid, params: dict) -> dict: usage = _session_usage_snapshot(session) provider = getattr(agent, "provider", None) or mirror.get("provider") or "unknown" model = getattr(agent, "model", None) or mirror.get("model") or "(unknown)" + project = _project_info_for_cwd(_display_session_cwd(session)) lines = [ "Hermes TUI Status", "", f"Session ID: {key}", f"Path: {display_hermes_home()}", ] + if project: + lines.append(f"Project: {project['name']}") title = (meta.get("title") or "").strip() if title: lines.append(f"Title: {title}") diff --git a/ui-tui/src/__tests__/paths.test.ts b/ui-tui/src/__tests__/paths.test.ts index d829dce2e5ea..b4404784cb3f 100644 --- a/ui-tui/src/__tests__/paths.test.ts +++ b/ui-tui/src/__tests__/paths.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js' +import { composeTabTitle, fmtCwdBranch, fmtProjectCwdBranch, shortCwd, shortProject } from '../domain/paths.js' describe('shortCwd', () => { const origHome = process.env.HOME @@ -69,6 +69,40 @@ describe('fmtCwdBranch', () => { }) }) +describe('shortProject', () => { + it('trims whitespace', () => { + expect(shortProject(' website ')).toBe('website') + }) + + it('truncates long project names from the right', () => { + expect(shortProject('a-very-long-project-name', 10)).toBe('a-very-lo…') + }) +}) + +describe('fmtProjectCwdBranch', () => { + const origHome = process.env.HOME + + beforeEach(() => { + process.env.HOME = '/Users/bb' + }) + + afterEach(() => { + process.env.HOME = origHome + }) + + it('prefixes the cwd/branch label with the project name', () => { + expect(fmtProjectCwdBranch('/Users/bb/proj', 'main', 'website', 28)).toBe('website · ~/proj (main)') + }) + + it('falls back to the cwd/branch label when no project is known', () => { + expect(fmtProjectCwdBranch('/Users/bb/proj', 'main', null, 28)).toBe('~/proj (main)') + }) + + it('keeps the project visible when space is tight', () => { + expect(fmtProjectCwdBranch('/Users/bb/proj', 'main', 'hermes-agent', 12)).toBe('hermes-agent') + }) +}) + describe('composeTabTitle', () => { it('joins marker, name, model, and cwd in order', () => { expect(composeTabTitle('✓', 'auth refactor', 'opus-4', '~/proj')).toBe('✓ auth refactor · opus-4 · ~/proj') diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 1b921b814b32..de67e7d13850 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -16,7 +16,7 @@ import { RESIZE_COALESCE_MS } from '../config/timing.js' import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js' import { SECTION_NAMES, sectionMode } from '../domain/details.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' -import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js' +import { composeTabTitle, fmtProjectCwdBranch, shortCwd } from '../domain/paths.js' import { sessionScopedModelArg } from '../domain/slash.js' import { type GatewayClient } from '../gatewayClient.js' import type { @@ -1127,7 +1127,7 @@ export function useMainApp(gw: GatewayClient) { // Cap the status-bar cwd/branch label tighter than the shared default so // it doesn't dominate the bar; the status rule reserves the left-side // essentials and truncates this further on narrow terminals. - cwdLabel: fmtCwdBranch(cwd, gitBranch, 28), + cwdLabel: fmtProjectCwdBranch(cwd, gitBranch, ui.info?.project?.name, 28), goodVibesTick, lastTurnEndedAt: ui.sid ? lastTurnEndedAt : null, sessionStartedAt: ui.sid ? sessionStartedAt : null, diff --git a/ui-tui/src/domain/paths.ts b/ui-tui/src/domain/paths.ts index 243c4fc50c84..1d52c9c045ee 100644 --- a/ui-tui/src/domain/paths.ts +++ b/ui-tui/src/domain/paths.ts @@ -15,6 +15,34 @@ export const fmtCwdBranch = (cwd: string, branch: null | string, max = 40) => { return `${shortCwd(cwd, Math.max(8, max - tag.length))}${tag}` } +export const shortProject = (projectName: string, max = 18) => { + const name = projectName.trim() + + return name.length <= max ? name : `${name.slice(0, Math.max(1, max - 1))}…` +} + +// Status-bar workspace label: the terminal has no hover tooltip, so the project +// name is shown INLINE alongside the cwd/branch (` · ~/cwd (branch)`). +// Falls back to the plain cwd/branch label when the session sits in no named +// project, and when space is tight the project name wins (it's the identity the +// user recognizes) with the cwd/branch dropped. +export const fmtProjectCwdBranch = (cwd: string, branch: null | string, projectName?: null | string, max = 40) => { + const project = shortProject(projectName || '') + + if (!project) { + return fmtCwdBranch(cwd, branch, max) + } + + const separator = ' · ' + const remaining = max - project.length - separator.length + + if (remaining < 8) { + return shortProject(project, max) + } + + return `${project}${separator}${fmtCwdBranch(cwd, branch, remaining)}` +} + /** * Compose the terminal titlebar string: * ` · · ` diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 6f6818e37cde..7ba16eda93da 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -149,6 +149,13 @@ export interface McpServerStatus { transport: string } +export interface ProjectInfo { + id: string + name: string + primary_path?: null | string + slug: string +} + export interface SessionInfo { cwd?: string fast?: boolean @@ -157,6 +164,7 @@ export interface SessionInfo { mcp_servers?: McpServerStatus[] model: string profile_name?: string + project?: null | ProjectInfo reasoning_effort?: string release_date?: string service_tier?: string