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