diff --git a/tui/src/session-menu.test.ts b/tui/src/session-menu.test.ts index 7976dc1fe..7d0900e5f 100644 --- a/tui/src/session-menu.test.ts +++ b/tui/src/session-menu.test.ts @@ -62,4 +62,68 @@ describe("SessionMenu", () => { "Release checklist", ) }) + + test("shows compact workspace names only when they distinguish sessions", async () => { + setup = await createTestRenderer({ width: 100, height: 18, screenMode: "alternate-screen" }) + const menu = new SessionMenu(setup.renderer, { + text: "#FFFFFF", + muted: "#999999", + border: "#555555", + }) + setup.renderer.root.add(menu.root) + const scoped = sessions.map((session) => ({ + ...session, + workspaceScope: { + project_path: "/work/nanobot", + project_name: "nanobot", + access_mode: "restricted" as const, + }, + })) + + menu.open(scoped, "one", 6) + await setup.renderOnce() + expect(setup.captureCharFrame()).not.toContain("nanobot · Codex") + + menu.open([ + scoped[0]!, + { + ...scoped[1]!, + workspaceScope: { + project_path: "C:\\work\\desktop", + project_name: "desktop", + access_mode: "restricted", + }, + }, + ], "one", 6) + await setup.renderOnce() + const frame = setup.captureCharFrame() + expect(frame).toContain("nanobot · Codex") + expect(frame).toContain("desktop") + + menu.update("desktop", 6) + expect(menu.choose()?.chatId).toBe("two") + + menu.open([ + { + ...scoped[0]!, + workspaceScope: { + project_path: "/work/frontend/nanobot", + project_name: "nanobot", + access_mode: "restricted", + }, + }, + { + ...scoped[1]!, + workspaceScope: { + project_path: "/work/backend/nanobot", + project_name: "nanobot", + access_mode: "restricted", + }, + }, + ], "one", 6) + await setup.renderOnce() + const duplicates = setup.captureCharFrame() + expect(duplicates).toContain("frontend/nanobot") + expect(duplicates).toContain("backend/nanobot") + }) }) diff --git a/tui/src/session-menu.ts b/tui/src/session-menu.ts index e6ccc4e7c..9dde91e3a 100644 --- a/tui/src/session-menu.ts +++ b/tui/src/session-menu.ts @@ -25,6 +25,8 @@ function updatedLabel(value: string | null): string { export class SessionMenu { readonly root: BoxRenderable private readonly picker: PickerMenu + private readonly workspaceLabels = new Map() + private showWorkspaces = false constructor( renderer: CliRenderer, @@ -38,11 +40,14 @@ export class SessionMenu { session.modelPreset || "", session.preview, session.chatId, + session.workspaceScope?.project_name || "", + session.workspaceScope?.project_path || "", ].join(" "), render: (session) => { const age = updatedLabel(session.updatedAt) const preview = session.preview.trim() const detail = [ + this.showWorkspaces ? this.workspaceLabel(session) : "", session.modelPreset, age, preview && preview !== sessionLabel(session) ? preview : "", @@ -63,6 +68,7 @@ export class SessionMenu { } open(sessions: SessionSummary[], currentChatId: string, limit: number): void { + this.prepareWorkspaceLabels(sessions) const rows = sessions .map((session) => ({ ...session, active: session.chatId === currentChatId })) .sort((left, right) => { @@ -92,4 +98,41 @@ export class SessionMenu { setTheme(theme: PickerMenuTheme): void { this.picker.setTheme(theme) } + + private prepareWorkspaceLabels(sessions: SessionSummary[]): void { + this.workspaceLabels.clear() + const scopes = sessions.flatMap((session) => { + const path = normalizeWorkspacePath(session.workspaceScope?.project_path) + return path ? [{ path, name: session.workspaceScope?.project_name?.trim() || pathName(path) }] : [] + }) + const paths = new Set(scopes.map(({ path }) => path)) + this.showWorkspaces = paths.size > 1 + if (!this.showWorkspaces) return + const namePaths = new Map>() + for (const { path, name } of scopes) { + const pathsForName = namePaths.get(name) || new Set() + pathsForName.add(path) + namePaths.set(name, pathsForName) + } + for (const { path, name } of scopes) { + this.workspaceLabels.set(path, namePaths.get(name)?.size === 1 ? name : shortPath(path)) + } + } + + private workspaceLabel(session: SessionSummary): string { + return this.workspaceLabels.get(normalizeWorkspacePath(session.workspaceScope?.project_path)) || "" + } +} + +function normalizeWorkspacePath(value: string | undefined): string { + return (value || "").trim().replace(/\\/gu, "/").replace(/\/+$/u, "") +} + +function pathName(path: string): string { + return path.split("/").filter(Boolean).at(-1) || path +} + +function shortPath(path: string): string { + const parts = path.split("/").filter(Boolean) + return parts.slice(-2).join("/") || path }