diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 0d585b017e40..eec49afc1f5d 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -32,6 +32,7 @@ import { setCurrentFastMode, setCurrentModel, setCurrentPersonality, + setCurrentProject, setCurrentProvider, setCurrentReasoningEffort, setCurrentServiceTier, @@ -196,6 +197,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { setCurrentBranch(payload.branch) } + if ('project' in (payload ?? {})) { + setCurrentProject(payload?.project ?? null) + } + if (typeof payload?.personality === 'string') { setCurrentPersonality(normalizePersonalityValue(payload.personality)) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/utils.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream/utils.test.ts index 47994355074c..22afff58864a 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/utils.test.ts @@ -33,8 +33,8 @@ describe('toTodoPayload', () => { describe('sessionInfoStatePatch / hasSessionInfoStatePatch', () => { it('extracts only present runtime fields', () => { - const patch = sessionInfoStatePatch(payload({ model: 'gpt', fast: true, branch: 'main' })) - expect(patch).toMatchObject({ model: 'gpt', fast: true, branch: 'main' }) + const patch = sessionInfoStatePatch(payload({ model: 'gpt', fast: true, branch: 'main', project: null })) + expect(patch).toMatchObject({ model: 'gpt', fast: true, branch: 'main', project: null }) expect(hasSessionInfoStatePatch(patch)).toBe(true) expect(hasSessionInfoStatePatch(sessionInfoStatePatch(payload({})))).toBe(false) }) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts b/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts index d9a90b366920..b1a47e381b4a 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts @@ -6,7 +6,16 @@ import type { ClientSessionState } from '../../../types' type SessionRuntimeStatePatch = Partial< Pick< ClientSessionState, - 'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo' + | 'branch' + | 'cwd' + | 'fast' + | 'model' + | 'personality' + | 'project' + | 'provider' + | 'reasoningEffort' + | 'serviceTier' + | 'yolo' > > @@ -29,6 +38,10 @@ export function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): patch.branch = payload.branch } + if (payload && Object.hasOwn(payload, 'project')) { + patch.project = payload.project ?? null + } + if (typeof payload?.personality === 'string') { patch.personality = normalizePersonalityValue(payload.personality) } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 1b725f65737e..19f22d45b8a8 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -10,11 +10,13 @@ import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' import { $activeSessionId, $currentCwd, + $currentProject, $messages, $newChatWorkspaceTarget, $resumeFailedSessionId, setActiveSessionId, setCurrentCwd, + setCurrentProject, setMessages, setNewChatWorkspaceTarget, setResumeFailedSessionId, @@ -35,6 +37,7 @@ vi.mock('@/hermes', async importOriginal => ({ })) const RUNTIME_SESSION_ID = 'rt-new-001' +const PROJECT = { id: 'p_repo', name: 'Repo', primary_path: '/repo', slug: 'repo' } type HarnessHandle = Pick< ReturnType, 'createBackendSessionForSend' | 'startFreshSessionDraft' @@ -256,6 +259,7 @@ describe('resumeSession failure recovery', () => { setResumeFailedSessionId(null) setMessages([]) setSessions([]) + setCurrentProject(null) vi.restoreAllMocks() }) @@ -381,6 +385,29 @@ describe('resumeSession failure recovery', () => { expect(resumeParams).toMatchObject({ source: 'desktop' }) }) + it('applies project metadata from synchronous resume info', async () => { + setCurrentProject({ id: 'p_old', name: 'Old', primary_path: '/old', slug: 'old' }) + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.resume') { + return { + session_id: 'runtime-1', + resumed: params?.session_id, + messages: [], + info: { branch: 'main', cwd: '/repo', project: PROJECT } + } as never + } + + return {} as never + }) + + vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never) + + await runResume(requestGateway) + + expect($currentProject.get()).toEqual(PROJECT) + }) + it('arms the failure latch when resume succeeds with an empty transcript for a non-empty stored session', async () => { setSessions([storedSession({ message_count: 4 })]) @@ -423,6 +450,7 @@ describe('resumeSession failure recovery', () => { pendingBranchGroup: null, personality: '', provider: '', + project: null, reasoningEffort: '', sawAssistantPayload: false, serviceTier: '', @@ -645,6 +673,7 @@ describe('createBackendSessionForSend workspace target', () => { $newChatProfile.set(null) $activeGatewayProfile.set('default') setCurrentCwd('') + setCurrentProject(null) setNewChatWorkspaceTarget(undefined) vi.restoreAllMocks() }) @@ -677,4 +706,19 @@ describe('createBackendSessionForSend workspace target', () => { expect(params).toMatchObject({ cwd: '/clicked-workspace' }) }) + it('clears the project label when starting a fresh draft', async () => { + const requestGateway = vi.fn(async () => ({}) as never) + let handle: HarnessHandle | null = null + + render( (handle = h)} requestGateway={requestGateway} />) + await waitFor(() => expect(handle).not.toBeNull()) + + setCurrentProject(PROJECT) + + await act(async () => { + handle!.startFreshSessionDraft() + }) + + expect($currentProject.get()).toBeNull() + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index cd1a4f35c8f7..96b9c3c46e79 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -30,6 +30,7 @@ import { setCurrentBranch, setCurrentCwd, setCurrentCwdTransient, + setCurrentProject, setCurrentServiceTier, setCurrentUsage, setFreshDraftReady, @@ -228,6 +229,7 @@ export function useSessionActions({ } setCurrentBranch('') + setCurrentProject(null) // Never clear the composer here — ChatBar's per-thread draft swap owns it. setFreshDraftReady(true) }, @@ -509,6 +511,7 @@ export function useSessionActions({ syncSessionStateToView(cachedRuntimeId, cachedViewState) setCurrentCwd(cachedViewState.cwd) setCurrentBranch(cachedViewState.branch) + setCurrentProject(cachedViewState.project) setSessionStartedAt(Date.now()) try { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index f202997ef1fc..39905223e742 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -14,6 +14,7 @@ import { setCurrentFastMode, setCurrentModel, setCurrentPersonality, + setCurrentProject, setCurrentProvider, setCurrentReasoningEffort, setCurrentServiceTier, @@ -255,7 +256,16 @@ export async function resolveStoredSession(storedSessionId: string): Promise > @@ -298,6 +308,11 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR sessionState.branch = info.branch || '' } + if (Object.hasOwn(info, 'project')) { + setCurrentProject(info.project ?? null) + sessionState.project = info.project ?? null + } + if (typeof info.personality === 'string') { const personality = normalizePersonalityValue(info.personality) setCurrentPersonality(personality) @@ -338,6 +353,7 @@ export function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | st setCurrentServiceTier('') setCurrentFastMode(false) setYoloActive(false) + setCurrentProject(null) setCurrentPersonality('') } diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 7f89eec64f9e..e628a087a4ce 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -13,6 +13,7 @@ import { setCurrentFastMode, setCurrentModel, setCurrentPersonality, + setCurrentProject, setCurrentProvider, setCurrentReasoningEffort, setCurrentServiceTier, @@ -64,6 +65,7 @@ function syncRuntimeMetadataToView(state: ClientSessionState) { setCurrentFastMode(state.fast ?? false) setYoloActive(state.yolo ?? false) setCurrentPersonality(state.personality ?? '') + setCurrentProject(state.project ?? null) } export function useSessionStateCache({ 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 10d5171c74a4..6cb976ef14ef 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -21,6 +21,7 @@ import { $busy, $connection, $currentCwd, + $currentProject, $currentUsage, $selectedStoredSessionId, $sessions, @@ -91,6 +92,7 @@ export function useStatusbarItems({ const terminalTakeover = useStore($terminalTakeover) const primaryBusy = useStore($busy) const currentCwd = useStore($currentCwd) + const currentProject = useStore($currentProject) const primaryUsage = useStore($currentUsage) const gatewayRestarting = useStore($gatewayRestarting) const primarySessionStartedAt = useStore($sessionStartedAt) @@ -313,7 +315,7 @@ export function useStatusbarItems({ hidden: !currentCwd, icon: , id: 'workspace-cwd', - label: currentCwd ? workspaceLabel(currentCwd) : undefined, + label: currentProject?.name || (currentCwd ? workspaceLabel(currentCwd) : undefined), menuItems: currentCwd ? [ { @@ -336,7 +338,7 @@ export function useStatusbarItems({ } ] : undefined, - title: currentCwd || undefined, + title: currentProject?.primary_path || currentCwd || undefined, variant: 'menu' }, { @@ -378,6 +380,8 @@ export function useStatusbarItems({ commandCenterOpen, copy, currentCwd, + currentProject?.name, + currentProject?.primary_path, fileMenu.copyPath, fileMenu.revealFileManager, fileMenu.revealInSidebar, diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 129acd3179dd..e854eae72df8 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -1,7 +1,7 @@ import type * as React from 'react' import type { ChatMessage } from '@/lib/chat-messages' -import type { UsageStats } from '@/types/hermes' +import type { SessionRuntimeProjectInfo, UsageStats } from '@/types/hermes' export interface ContextSuggestion { text: string @@ -139,6 +139,7 @@ export interface ClientSessionState { messages: ChatMessage[] branch: string cwd: string + project: SessionRuntimeProjectInfo | null model: string provider: string reasoningEffort: string diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index c622b90d85e7..18a1a678a7b8 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -4,7 +4,7 @@ import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images' import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media' import { normalize } from '@/lib/text' import { parseTodos } from '@/lib/todos' -import type { SessionMessage, UsageStats } from '@/types/hermes' +import type { SessionMessage, SessionRuntimeProjectInfo, UsageStats } from '@/types/hermes' export type ChatMessagePart = Exclude[number] @@ -51,6 +51,7 @@ export type GatewayEventPayload = { running?: boolean cwd?: string branch?: string + project?: null | SessionRuntimeProjectInfo credential_warning?: string install_warning?: string personality?: string diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 0e200a50da8e..e99923f8412d 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -41,6 +41,7 @@ export function createClientSessionState( messages, branch: '', cwd: '', + project: null, model: '', provider: '', reasoningEffort: '', diff --git a/apps/desktop/src/lib/markdown-code.test.ts b/apps/desktop/src/lib/markdown-code.test.ts index f71f564c1c2a..d60d431f2dd1 100644 --- a/apps/desktop/src/lib/markdown-code.test.ts +++ b/apps/desktop/src/lib/markdown-code.test.ts @@ -20,4 +20,34 @@ describe('isLikelyProseCodeBlock', () => { it('keeps real code blocks', () => { expect(isLikelyProseCodeBlock('ts', 'const value = { bunny: true };\nreturn value')).toBe(false) }) + + it('keeps zsh command blocks as code even when they look like prose lines', () => { + expect( + isLikelyProseCodeBlock( + 'zsh', + [ + 'cd ~/Documents/dan-personal', + 'bash -n install.sh', + 'brew bundle check --file=packages/Brewfile', + 'env -i HOME="$HOME" USER="$USER" LOGNAME="$LOGNAME" SHELL=/bin/zsh TERM=xterm-256color \\', + " /bin/zsh -lic 'command -v brew && command -v starship && command -v gh && command -v stow'" + ].join('\n') + ) + ).toBe(false) + }) + + it('keeps text-labeled shell command blocks as code', () => { + expect( + isLikelyProseCodeBlock( + 'text', + [ + 'cd ~/Documents/', + 'bash -n install.sh', + 'brew bundle check --file=packages/Brewfile', + 'env -i HOME="$HOME" USER="$USER" LOGNAME="$LOGNAME" SHELL=/bin/zsh TERM=xterm-256color \\', + "/bin/zsh -lic 'command -v brew && command -v starship && command -v gh && command -v stow'" + ].join('\n') + ) + ).toBe(false) + }) }) diff --git a/apps/desktop/src/lib/markdown-code.ts b/apps/desktop/src/lib/markdown-code.ts index 4b1632b98603..8fd66dca8bd1 100644 --- a/apps/desktop/src/lib/markdown-code.ts +++ b/apps/desktop/src/lib/markdown-code.ts @@ -6,9 +6,12 @@ const NON_CODE_FENCE_LANGUAGES = new Set(['', 'text', 'plain', 'plaintext', 'md' const COMMON_CODE_LANGUAGES = new Set([ 'bash', 'c', + 'cmd', + 'console', 'cpp', 'css', 'diff', + 'fish', 'go', 'html', 'java', @@ -19,12 +22,15 @@ const COMMON_CODE_LANGUAGES = new Set([ 'markdown', 'md', 'php', + 'powershell', + 'ps1', 'python', 'py', 'ruby', 'rust', 'rs', 'sh', + 'shell', 'sql', 'swift', 'tsx', @@ -32,7 +38,8 @@ const COMMON_CODE_LANGUAGES = new Set([ 'typescript', 'xml', 'yaml', - 'yml' + 'yml', + 'zsh' ]) interface CodeSignals { @@ -252,7 +259,9 @@ function proseLineCount(body: string): number { const CODE_SIGNAL_RE = [ /(^|\s)(const|let|var|function|class|import|export|return|if|for|while|switch)\b/gim, /=>|==|===|!=|!==|\{|\}|;|<\/?[a-z][^>]*>/gi, - /^\s*(#include|SELECT|INSERT|UPDATE|DELETE|CREATE|DROP)\b/gim + /^\s*(#include|SELECT|INSERT|UPDATE|DELETE|CREATE|DROP)\b/gim, + /^\s*(?:\/?(?:bin\/)?(?:bash|zsh|sh|fish)|brew|cd|command|curl|docker|env|gh|git|grep|make|npm|pnpm|python3?|stow|uv|wget|yarn)\b/gim, + /\b[A-Z_][A-Z0-9_]*="\$[A-Z_][A-Z0-9_]*"|&&|\|\||\\\s*$/gm ] function codeSignalCount(body: string): number { diff --git a/apps/desktop/src/store/gateway-switch.test.ts b/apps/desktop/src/store/gateway-switch.test.ts index 5b24c120ccc9..10a30db410dd 100644 --- a/apps/desktop/src/store/gateway-switch.test.ts +++ b/apps/desktop/src/store/gateway-switch.test.ts @@ -3,12 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $sessionsLimit, resetSessionsLimit, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout' import { $cronSessions, + $currentProject, $freshDraftReady, $messagingSessions, $sessions, $sessionsLoading, $sessionsTotal, setCronSessions, + setCurrentProject, setFreshDraftReady, setMessagingSessions, setSessions, @@ -18,6 +20,8 @@ import { import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from './gateway-switch' +const PROJECT = { id: 'p_old', name: 'Old', primary_path: '/old', slug: 'old' } + vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } })) @@ -31,6 +35,7 @@ describe('wipeSessionListsForGatewaySwitch', () => { setMessagingSessions([{ id: 'm1', title: 'tg', profile: 'default' } as never]) setSessionsLoading(false) setFreshDraftReady(false) + setCurrentProject(PROJECT) $sessionsLimit.set(SIDEBAR_SESSIONS_PAGE_SIZE * 3) }) @@ -40,6 +45,7 @@ describe('wipeSessionListsForGatewaySwitch', () => { setCronSessions([]) setMessagingSessions([]) setSessionsLoading(true) + setCurrentProject(null) $gatewaySwitching.set(false) }) @@ -52,6 +58,7 @@ describe('wipeSessionListsForGatewaySwitch', () => { expect($messagingSessions.get()).toEqual([]) expect($sessionsLoading.get()).toBe(true) expect($sessionsLimit.get()).toBe(SIDEBAR_SESSIONS_PAGE_SIZE) + expect($currentProject.get()).toBeNull() expect($freshDraftReady.get()).toBe(true) }) }) diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts index e728d57d46f0..836995f9de78 100644 --- a/apps/desktop/src/store/gateway-switch.ts +++ b/apps/desktop/src/store/gateway-switch.ts @@ -6,6 +6,7 @@ import { setActiveSessionId, setAttentionSessionIds, setCronSessions, + setCurrentProject, setFreshDraftReady, setMessages, setMessagingPlatformTotals, @@ -54,6 +55,7 @@ export function wipeSessionListsForGatewaySwitch(): void { setActiveSessionId(null) setSelectedStoredSessionId(null) setMessages([]) + setCurrentProject(null) setFreshDraftReady(true) void queryClient.invalidateQueries() diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index b36704b03244..c7c1ac8c09eb 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -5,7 +5,7 @@ import type { ContextSuggestion } from '@/app/types' import type { HermesConnection } from '@/global' import type { ChatMessage } from '@/lib/chat-messages' import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage' -import type { SessionInfo, UsageStats } from '@/types/hermes' +import type { SessionInfo, SessionRuntimeProjectInfo, UsageStats } from '@/types/hermes' type Updater = T | ((current: T) => T) @@ -289,6 +289,7 @@ export const $currentCwd = atom(getRememberedWorkspaceCwd()) export const $newChatWorkspaceTarget = atom(undefined) export const $newChatWorkspaceTargetGeneration = atom(0) export const $currentBranch = atom('') +export const $currentProject = atom(null) export const $currentUsage = atom({ calls: 0, input: 0, @@ -391,6 +392,7 @@ export const workspaceCwdForNewSession = (): string => { } export const setCurrentBranch = (next: Updater) => updateAtom($currentBranch, next) +export const setCurrentProject = (next: Updater) => updateAtom($currentProject, next) export const setCurrentUsage = (next: Updater) => updateAtom($currentUsage, next) export const setSessionStartedAt = (next: Updater) => updateAtom($sessionStartedAt, next) export const setTurnStartedAt = (next: Updater) => updateAtom($turnStartedAt, next) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 0c4a4f8f788c..01168b488203 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -393,6 +393,13 @@ export interface SessionResumeResponse { session_id: string } +export interface SessionRuntimeProjectInfo { + id: string + name: string + primary_path?: null | string + slug: string +} + export interface SessionRuntimeInfo { approval_mode?: 'manual' | 'off' | 'smart' branch?: string @@ -404,6 +411,7 @@ export interface SessionRuntimeInfo { install_warning?: string model?: string personality?: string + project?: null | SessionRuntimeProjectInfo provider?: string reasoning_effort?: string running?: boolean diff --git a/package-lock.json b/package-lock.json index cde4ea308593..7f4c1e5f2eb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19497,6 +19497,7 @@ "name": "@hermes/root-tests", "devDependencies": { "@types/plist": "^3.0.5", + "eslint": "^9.39.4", "plist": "^3.1.0", "typescript": "^6.0.3", "vitest": "^4.1.9" diff --git a/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py index ca65803d5aee..a9607d377ee3 100644 --- a/tests/tui_gateway/test_projects_rpc.py +++ b/tests/tui_gateway/test_projects_rpc.py @@ -172,6 +172,50 @@ def test_add_folder_and_for_cwd(tmp_path): assert "branch" in resolved +def test_project_info_for_cwd_returns_project_status_payload(tmp_path): + folder = tmp_path / "repo" + folder.mkdir() + created = _call("projects.create", {"name": "Repo", "folders": [str(folder)]})["project"] + + nested = folder / "src" + nested.mkdir() + payload = server._project_info_for_cwd(str(nested)) + + assert payload == { + "id": created["id"], + "slug": "repo", + "name": "Repo", + "primary_path": str(folder), + } + + +def test_session_cwd_set_agentless_reports_unowned_project_null(tmp_path, monkeypatch): + owned = tmp_path / "owned" + owned.mkdir() + _call("projects.create", {"name": "Owned", "folders": [str(owned)]}) + outside = tmp_path / "outside" + outside.mkdir() + + sid = "agentless-cwd" + emitted = [] + monkeypatch.setattr(server, "_emit", lambda event, session_id, payload: emitted.append((event, session_id, payload))) + monkeypatch.setattr(server, "_persist_session_git_meta", lambda *_args, **_kwargs: None) + server._sessions[sid] = { + "agent": None, + "cwd": str(owned), + "explicit_cwd": True, + "running": False, + "session_key": "stored-agentless-cwd", + } + try: + payload = _call("session.cwd.set", {"session_id": sid, "cwd": str(outside)}) + finally: + server._sessions.pop(sid, None) + + assert payload["project"] is None + assert emitted == [("session.info", sid, payload)] + + 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 6fc7fe38b246..f29ccefa17da 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3355,6 +3355,28 @@ def _current_profile_name() -> str: DESKTOP_BACKEND_CONTRACT = 3 +def _project_info_for_cwd(cwd: str) -> dict | None: + """Return the first-class Project owning ``cwd`` for UI status surfaces.""" + 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(): @@ -3409,6 +3431,7 @@ def _session_info(agent, session: dict | None = None) -> dict: "skills": {}, "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 "", @@ -4004,7 +4027,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: @@ -5340,6 +5368,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(), @@ -5486,6 +5515,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": {}, @@ -5972,6 +6002,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) @@ -6067,8 +6098,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": {}, @@ -7836,12 +7869,16 @@ def _dt(value, fallback: datetime | None = None) -> datetime: usage = _get_usage(agent) if agent is not None else {} provider = getattr(agent, "provider", None) or "unknown" model = getattr(agent, "model", None) or "(unknown)" + cwd = _display_session_cwd(session) + project = _project_info_for_cwd(cwd) 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..5e8464d2cac1 100644 --- a/ui-tui/src/domain/paths.ts +++ b/ui-tui/src/domain/paths.ts @@ -15,6 +15,29 @@ 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))}…` +} + +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