diff --git a/src/mcp/daemonState.ts b/src/mcp/daemonState.ts index 20318dc..7fc18cb 100644 --- a/src/mcp/daemonState.ts +++ b/src/mcp/daemonState.ts @@ -118,6 +118,7 @@ export class HunkDaemonState { title: entry.registration.title, sourceLabel: entry.registration.sourceLabel, launchedAt: entry.registration.launchedAt, + terminal: entry.registration.terminal, fileCount: entry.registration.files.length, files: entry.registration.files, snapshot: entry.snapshot, diff --git a/src/mcp/sessionRegistration.ts b/src/mcp/sessionRegistration.ts index f1515be..6e5dec4 100644 --- a/src/mcp/sessionRegistration.ts +++ b/src/mcp/sessionRegistration.ts @@ -1,8 +1,25 @@ import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; import type { AppBootstrap } from "../core/types"; import { hunkLineRange } from "../core/liveComments"; +import { resolveSessionTerminalMetadata } from "./sessionTerminalMetadata"; import type { HunkSessionRegistration, HunkSessionSnapshot, SessionFileSummary } from "./types"; +/** Resolve the TTY device path for the current process, if available. */ +function ttyname(): string | undefined { + if (!process.stdin.isTTY) { + return undefined; + } + + try { + const result = spawnSync("tty", [], { stdio: ["inherit", "pipe", "pipe"] }); + const name = result.stdout?.toString().trim(); + return name && !name.startsWith("not a tty") ? name : undefined; + } catch { + return undefined; + } +} + function inferRepoRoot(bootstrap: AppBootstrap) { return bootstrap.input.kind === "git" || bootstrap.input.kind === "show" || @@ -24,6 +41,8 @@ function buildSessionFiles(bootstrap: AppBootstrap): SessionFileSummary[] { /** Build the daemon-facing metadata for one live Hunk TUI session. */ export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionRegistration { + const terminal = resolveSessionTerminalMetadata({ tty: ttyname() }); + return { sessionId: randomUUID(), pid: process.pid, @@ -33,6 +52,7 @@ export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionR title: bootstrap.changeset.title, sourceLabel: bootstrap.changeset.sourceLabel, launchedAt: new Date().toISOString(), + terminal, files: buildSessionFiles(bootstrap), }; } diff --git a/src/mcp/sessionTerminalMetadata.ts b/src/mcp/sessionTerminalMetadata.ts new file mode 100644 index 0000000..fff9b90 --- /dev/null +++ b/src/mcp/sessionTerminalMetadata.ts @@ -0,0 +1,120 @@ +import type { SessionTerminalLocation, SessionTerminalMetadata } from "./types"; + +function trimmed(value: string | undefined) { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : undefined; +} + +function sameLocation(left: SessionTerminalLocation, right: SessionTerminalLocation) { + return ( + left.source === right.source && + left.tty === right.tty && + left.windowId === right.windowId && + left.tabId === right.tabId && + left.paneId === right.paneId && + left.terminalId === right.terminalId && + left.sessionId === right.sessionId + ); +} + +function pushLocation(locations: SessionTerminalLocation[], location: SessionTerminalLocation) { + if (!locations.some((existing) => sameLocation(existing, location))) { + locations.push(location); + } +} + +function inferLocationSource(program: string | undefined) { + const normalized = program?.trim().toLowerCase(); + if (!normalized) { + return "terminal"; + } + + if (normalized === "iterm.app" || normalized === "iterm2") { + return "iterm2"; + } + + if (normalized === "ghostty") { + return "ghostty"; + } + + if (normalized === "apple_terminal" || normalized === "apple terminal") { + return "terminal.app"; + } + + return "terminal"; +} + +function parseHierarchicalIds(sessionId: string) { + const prefix = sessionId.split(":", 1)[0]?.trim(); + if (!prefix) { + return {}; + } + + const match = /^w(?\d+)t(?\d+)(?:p(?\d+))?$/i.exec(prefix); + if (!match?.groups) { + return {}; + } + + return { + windowId: match.groups.window, + tabId: match.groups.tab, + paneId: match.groups.pane, + } satisfies Pick; +} + +/** + * Capture terminal- and multiplexer-facing location metadata for one Hunk TUI session. + * + * The structure is intentionally generic so we can layer tmux, iTerm2, Ghostty, + * and future terminal integrations without adding a new top-level field for each one. + */ +export function resolveSessionTerminalMetadata({ + env = process.env, + tty, +}: { + env?: NodeJS.ProcessEnv; + tty?: string; +} = {}): SessionTerminalMetadata | undefined { + const termProgram = trimmed(env.TERM_PROGRAM); + const lcTerminal = trimmed(env.LC_TERMINAL); + const program = + termProgram?.toLowerCase() === "tmux" && lcTerminal ? lcTerminal : (termProgram ?? lcTerminal); + const locations: SessionTerminalLocation[] = []; + + const ttyPath = trimmed(tty); + if (ttyPath) { + pushLocation(locations, { source: "tty", tty: ttyPath }); + } + + const tmuxPane = trimmed(env.TMUX_PANE); + if (tmuxPane) { + pushLocation(locations, { source: "tmux", paneId: tmuxPane }); + } + + const iTermSessionId = trimmed(env.ITERM_SESSION_ID); + if (iTermSessionId) { + pushLocation(locations, { + source: "iterm2", + sessionId: iTermSessionId, + ...parseHierarchicalIds(iTermSessionId), + }); + } + + const terminalSessionId = trimmed(env.TERM_SESSION_ID); + if (terminalSessionId && terminalSessionId !== iTermSessionId) { + pushLocation(locations, { + source: inferLocationSource(program), + sessionId: terminalSessionId, + ...parseHierarchicalIds(terminalSessionId), + }); + } + + if (!program && locations.length === 0) { + return undefined; + } + + return { + program, + locations, + }; +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts index f113364..5f617c0 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -22,6 +22,21 @@ export interface SelectedHunkSummary { newRange?: [number, number]; } +export interface SessionTerminalLocation { + source: string; + tty?: string; + windowId?: string; + tabId?: string; + paneId?: string; + terminalId?: string; + sessionId?: string; +} + +export interface SessionTerminalMetadata { + program?: string; + locations: SessionTerminalLocation[]; +} + export interface HunkSessionRegistration { sessionId: string; pid: number; @@ -31,6 +46,7 @@ export interface HunkSessionRegistration { title: string; sourceLabel: string; launchedAt: string; + terminal?: SessionTerminalMetadata; files: SessionFileSummary[]; } @@ -237,6 +253,7 @@ export interface ListedSession { title: string; sourceLabel: string; launchedAt: string; + terminal?: SessionTerminalMetadata; fileCount: number; files: SessionFileSummary[]; snapshot: HunkSessionSnapshot; diff --git a/src/session/commands.ts b/src/session/commands.ts index a90b014..a8b94d4 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -26,6 +26,8 @@ import type { RemovedCommentResult, SelectedSessionContext, SessionLiveCommentSummary, + SessionTerminalLocation, + SessionTerminalMetadata, } from "../mcp/types"; import { HUNK_SESSION_API_PATH, @@ -359,25 +361,88 @@ function formatSelectedSummary(session: ListedSession) { return filePath === "(none)" ? filePath : `${filePath} hunk ${hunkNumber}`; } +function formatTerminalLocation(location: SessionTerminalLocation) { + const parts: string[] = []; + + if (location.tty) { + parts.push(location.tty); + } + + if (location.windowId) { + parts.push(`window ${location.windowId}`); + } + + if (location.tabId) { + parts.push(`tab ${location.tabId}`); + } + + if (location.paneId) { + parts.push(`pane ${location.paneId}`); + } + + if (location.terminalId) { + parts.push(`terminal ${location.terminalId}`); + } + + if (location.sessionId) { + parts.push(`session ${location.sessionId}`); + } + + return parts.length > 0 ? parts.join(", ") : "present"; +} + +function resolveSessionTerminal(session: ListedSession) { + return session.terminal; +} + +function formatTerminalLines( + terminal: SessionTerminalMetadata | undefined, + { + headerLabel, + locationLabel, + }: { + headerLabel: string; + locationLabel: string; + }, +) { + if (!terminal) { + return []; + } + + return [ + ...(terminal.program ? [`${headerLabel}: ${terminal.program}`] : []), + ...terminal.locations.map( + (location) => `${locationLabel}[${location.source}]: ${formatTerminalLocation(location)}`, + ), + ]; +} + function formatListOutput(sessions: ListedSession[]) { if (sessions.length === 0) { return "No active Hunk sessions.\n"; } return `${sessions - .map((session) => - [ + .map((session) => { + const terminal = resolveSessionTerminal(session); + return [ `${session.sessionId} ${session.title}`, ` repo: ${session.repoRoot ?? session.cwd}`, + ...formatTerminalLines(terminal, { + headerLabel: " terminal", + locationLabel: " location", + }), ` focus: ${formatSelectedSummary(session)}`, ` files: ${session.fileCount}`, ` comments: ${session.snapshot.liveCommentCount}`, - ].join("\n"), - ) + ].join("\n"); + }) .join("\n\n")}\n`; } function formatSessionOutput(session: ListedSession) { + const terminal = resolveSessionTerminal(session); + return [ `Session: ${session.sessionId}`, `Title: ${session.title}`, @@ -385,6 +450,10 @@ function formatSessionOutput(session: ListedSession) { `Repo: ${session.repoRoot ?? session.cwd}`, `Input: ${session.inputKind}`, `Launched: ${session.launchedAt}`, + ...formatTerminalLines(terminal, { + headerLabel: "Terminal", + locationLabel: "Location", + }), `Selected: ${formatSelectedSummary(session)}`, `Agent notes visible: ${session.snapshot.showAgentNotes ? "yes" : "no"}`, `Live comments: ${session.snapshot.liveCommentCount}`, diff --git a/test/session-commands.test.ts b/test/session-commands.test.ts index d37870e..0467488 100644 --- a/test/session-commands.test.ts +++ b/test/session-commands.test.ts @@ -292,3 +292,127 @@ describe("session command compatibility checks", () => { expect(restartCalls).toEqual([]); }); }); + +describe("session list includes terminal metadata", () => { + test("list output includes generic terminal and location lines when present", async () => { + const session = { + ...createListedSession("session-1"), + terminal: { + program: "iTerm.app", + locations: [ + { source: "tty", tty: "/dev/ttys003" }, + { source: "tmux", paneId: "%2" }, + { source: "iterm2", windowId: "1", tabId: "2", paneId: "3" }, + ], + }, + }; + + setSessionCommandTestHooks({ + createClient: () => + createClient({ + listSessions: async () => [session], + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "list", + output: "text", + } satisfies SessionCommandInput); + + expect(output).toContain("terminal: iTerm.app"); + expect(output).toContain("location[tty]: /dev/ttys003"); + expect(output).toContain("location[tmux]: pane %2"); + expect(output).toContain("location[iterm2]: window 1, tab 2, pane 3"); + }); + + test("list output omits terminal lines when absent", async () => { + setSessionCommandTestHooks({ + createClient: () => + createClient({ + listSessions: async () => [createListedSession("session-1")], + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "list", + output: "text", + } satisfies SessionCommandInput); + + expect(output).not.toContain("terminal:"); + expect(output).not.toContain("location["); + }); + + test("get output includes generic terminal location lines when present", async () => { + const session = { + ...createListedSession("session-1"), + terminal: { + program: "ghostty", + locations: [ + { source: "tty", tty: "/dev/ttys005" }, + { source: "tmux", paneId: "%0" }, + ], + }, + }; + + setSessionCommandTestHooks({ + createClient: () => + createClient({ + getSession: async () => session, + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "get", + selector: { sessionId: "session-1" }, + output: "text", + } satisfies SessionCommandInput); + + expect(output).toContain("Terminal: ghostty"); + expect(output).toContain("Location[tty]: /dev/ttys005"); + expect(output).toContain("Location[tmux]: pane %0"); + }); + + test("json output includes terminal metadata fields", async () => { + const session = { + ...createListedSession("session-1"), + terminal: { + program: "iTerm.app", + locations: [ + { source: "tty", tty: "/dev/ttys003" }, + { source: "tmux", paneId: "%2" }, + ], + }, + }; + + setSessionCommandTestHooks({ + createClient: () => + createClient({ + listSessions: async () => [session], + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "list", + output: "json", + } satisfies SessionCommandInput); + + const parsed = JSON.parse(output); + expect(parsed.sessions[0].terminal).toEqual({ + program: "iTerm.app", + locations: [ + { source: "tty", tty: "/dev/ttys003" }, + { source: "tmux", paneId: "%2" }, + ], + }); + expect(parsed.sessions[0]).not.toHaveProperty("tty"); + expect(parsed.sessions[0]).not.toHaveProperty("tmuxPane"); + }); +}); diff --git a/test/session-registration.test.ts b/test/session-registration.test.ts new file mode 100644 index 0000000..83f57c4 --- /dev/null +++ b/test/session-registration.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { HunkDaemonState } from "../src/mcp/daemonState"; +import type { HunkSessionRegistration, HunkSessionSnapshot } from "../src/mcp/types"; + +function createRegistration( + overrides: Partial = {}, +): HunkSessionRegistration { + return { + sessionId: "test-session", + pid: 1234, + cwd: "/repo", + repoRoot: "/repo", + inputKind: "diff", + title: "repo diff", + sourceLabel: "/repo", + launchedAt: "2026-03-23T00:00:00.000Z", + files: [], + ...overrides, + }; +} + +function createSnapshot(): HunkSessionSnapshot { + return { + selectedFileId: undefined, + selectedFilePath: undefined, + selectedHunkIndex: 0, + showAgentNotes: false, + liveCommentCount: 0, + liveComments: [], + updatedAt: "2026-03-23T00:00:00.000Z", + }; +} + +function createMockSocket() { + return { send: () => {} }; +} + +describe("session registration terminal metadata", () => { + test("daemon state passes generic terminal metadata through to listed sessions", () => { + const state = new HunkDaemonState(); + const registration = createRegistration({ + terminal: { + program: "iTerm.app", + locations: [ + { source: "tty", tty: "/dev/ttys003" }, + { source: "tmux", paneId: "%2" }, + { + source: "iterm2", + windowId: "1", + tabId: "2", + paneId: "3", + sessionId: "w1t2p3:ABC", + }, + ], + }, + }); + + state.registerSession(createMockSocket(), registration, createSnapshot()); + + const sessions = state.listSessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.terminal).toEqual(registration.terminal); + }); + + test("daemon state omits terminal metadata when nothing is known", () => { + const state = new HunkDaemonState(); + const registration = createRegistration(); + + state.registerSession(createMockSocket(), registration, createSnapshot()); + + const sessions = state.listSessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.terminal).toBeUndefined(); + }); +}); diff --git a/test/session-terminal-metadata.test.ts b/test/session-terminal-metadata.test.ts new file mode 100644 index 0000000..cb72965 --- /dev/null +++ b/test/session-terminal-metadata.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { resolveSessionTerminalMetadata } from "../src/mcp/sessionTerminalMetadata"; + +describe("session terminal metadata", () => { + test("captures tty, tmux, and iTerm2 identifiers in one generic structure", () => { + const terminal = resolveSessionTerminalMetadata({ + env: { + TERM_PROGRAM: "tmux", + LC_TERMINAL: "iTerm2", + ITERM_SESSION_ID: "w1t2p3:ABCDEF", + TMUX_PANE: "%7", + }, + tty: "/dev/ttys003", + }); + + expect(terminal).toEqual({ + program: "iTerm2", + locations: [ + { source: "tty", tty: "/dev/ttys003" }, + { source: "tmux", paneId: "%7" }, + { + source: "iterm2", + windowId: "1", + tabId: "2", + paneId: "3", + sessionId: "w1t2p3:ABCDEF", + }, + ], + }); + }); + + test("keeps terminal program metadata even when no window or pane ids are available", () => { + const terminal = resolveSessionTerminalMetadata({ + env: { + TERM_PROGRAM: "ghostty", + }, + tty: "/dev/pts/4", + }); + + expect(terminal).toEqual({ + program: "ghostty", + locations: [{ source: "tty", tty: "/dev/pts/4" }], + }); + }); + + test("returns undefined when no terminal metadata is available", () => { + expect(resolveSessionTerminalMetadata({ env: {}, tty: undefined })).toBeUndefined(); + }); +});