diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 0e6be1e7e09..151cd70c0b6 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -425,6 +425,9 @@ export const makeGitManager = Effect.gen(function* () { const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) { const details = yield* gitCore.statusDetails(input.cwd); + const repositoryUrl = yield* gitCore + .readConfigValue(input.cwd, "remote.origin.url") + .pipe(Effect.catch(() => Effect.succeed(null))); const pr = details.branch !== null @@ -436,6 +439,7 @@ export const makeGitManager = Effect.gen(function* () { return { branch: details.branch, + ...(repositoryUrl ? { repositoryUrl } : {}), hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, hasUpstream: details.hasUpstream, diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 581b0b490c5..10b5e6d4146 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -16,7 +16,7 @@ import { type PtyProcess, type PtySpawnInput, } from "../Services/PTY"; -import { TerminalManagerRuntime } from "./Manager"; +import { __terminalManagerInternals, TerminalManagerRuntime } from "./Manager"; import { Effect, Encoding } from "effect"; class FakePtyProcess implements PtyProcess { @@ -172,6 +172,20 @@ describe("TerminalManager", () => { options: { shellResolver?: () => string; subprocessChecker?: (terminalPid: number) => Promise; + externalServerDiscoverer?: ( + filter: { projectRoot?: string; cwd?: string }, + ) => Promise< + Array<{ + pid: number; + port: number; + address: string; + name: string | null; + commandLine: string | null; + parentPid: number | null; + createdAt: string | null; + }> + >; + externalProcessKiller?: (pid: number) => Promise; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; @@ -187,6 +201,10 @@ describe("TerminalManager", () => { historyLineLimit, shellResolver: options.shellResolver ?? (() => "/bin/bash"), ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}), + ...(options.externalServerDiscoverer + ? { externalServerDiscoverer: options.externalServerDiscoverer } + : {}), + ...(options.externalProcessKiller ? { externalProcessKiller: options.externalProcessKiller } : {}), ...(options.subprocessPollIntervalMs ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), @@ -198,6 +216,45 @@ describe("TerminalManager", () => { return { logsDir, ptyAdapter, manager }; } + it("matches command lines for project roots that contain spaces", () => { + const commandLine = + 'node "C:\\Users\\First Last\\source\\repos\\t3code-main\\apps\\web\\node_modules\\vite\\bin\\vite.js"'; + const result = __terminalManagerInternals.commandMatchesProjectRoot(commandLine, [ + "C:\\Users\\First Last\\source\\repos\\t3code-main", + ]); + + expect(result).toBe(true); + }); + + it("does not match project roots by substring collision", () => { + const commandLine = + 'node "C:\\Users\\Addis\\source\\repos\\t3code-main-2\\apps\\web\\node_modules\\vite\\bin\\vite.js"'; + const result = __terminalManagerInternals.commandMatchesProjectRoot(commandLine, [ + "C:\\Users\\Addis\\source\\repos\\t3code-main", + ]); + + expect(result).toBe(false); + }); + + it("rejects stopping external pids that were not registered for the thread", async () => { + const killedPids: number[] = []; + const { manager } = makeManager(5, { + externalProcessKiller: async (pid) => { + killedPids.push(pid); + }, + }); + + await expect( + manager.close({ + threadId: "thread-1", + terminalId: "external:51515", + }), + ).rejects.toThrow(/not registered for thread/i); + expect(killedPids).toEqual([]); + + manager.dispose(); + }); + it("spawns lazily and reuses running terminal per thread", async () => { const { manager, ptyAdapter } = makeManager(); const [first, second] = await Promise.all([ diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index d9558f43b7e..ec6f3f7e654 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -6,10 +6,14 @@ import { DEFAULT_TERMINAL_ID, TerminalClearInput, TerminalCloseInput, + TerminalListInput, TerminalOpenInput, TerminalResizeInput, + TerminalSessionMetadata, TerminalWriteInput, type TerminalEvent, + type TerminalListResult, + type TerminalSessionSummary, type TerminalSessionSnapshot, } from "@t3tools/contracts"; import { Effect, Encoding, Layer, Path, Schema } from "effect"; @@ -34,6 +38,10 @@ const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; const DEFAULT_OPEN_COLS = 120; const DEFAULT_OPEN_ROWS = 30; +const DEFAULT_RECENT_OUTPUT_LINE_LIMIT = 160; +const DEFAULT_RECENT_OUTPUT_CHAR_LIMIT = 24_000; +const DEFAULT_EXTERNAL_SERVER_SCAN_TIMEOUT_MS = 4_000; +const EXTERNAL_TERMINAL_ID_PREFIX = "external:"; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput); @@ -41,9 +49,29 @@ const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput); const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput); const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput); const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); +const decodeTerminalListInput = Schema.decodeUnknownSync(TerminalListInput); +const decodeTerminalSessionMetadata = Schema.decodeUnknownSync(TerminalSessionMetadata); type TerminalSubprocessChecker = (terminalPid: number) => Promise; +interface ExternalServerFilter { + projectRoot?: string; + cwd?: string; +} + +interface ExternalServerDescriptor { + pid: number; + port: number; + address: string; + name: string | null; + commandLine: string | null; + parentPid: number | null; + createdAt: string | null; +} + +type ExternalServerDiscoverer = (filter: ExternalServerFilter) => Promise; +type ExternalProcessKiller = (pid: number) => Promise; + function defaultShellResolver(): string { if (process.platform === "win32") { return process.env.ComSpec ?? "cmd.exe"; @@ -252,6 +280,312 @@ function capHistory(history: string, maxLines: number): string { return hasTrailingNewline ? `${capped}\n` : capped; } +function tailTerminalOutput(history: string): string { + if (history.length === 0) return history; + const lines = history.split(/\r?\n/g); + const recentLines = lines.slice(Math.max(0, lines.length - DEFAULT_RECENT_OUTPUT_LINE_LIMIT)); + const recent = recentLines.join("\n"); + if (recent.length <= DEFAULT_RECENT_OUTPUT_CHAR_LIMIT) { + return recent; + } + return recent.slice(recent.length - DEFAULT_RECENT_OUTPUT_CHAR_LIMIT); +} + +function normalizeComparablePath(value: string): string { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function samePath(left: string, right: string): boolean { + return normalizeComparablePath(left) === normalizeComparablePath(right); +} + +function pathContains(parent: string, child: string): boolean { + const relativePath = path.relative(path.resolve(parent), path.resolve(child)); + return ( + relativePath.length === 0 || + (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) + ); +} + +function normalizeTerminalMetadata( + metadata: TerminalSessionMetadata | undefined, + runtimeEnv: Record | undefined, +): TerminalSessionMetadata | null { + const candidate = { + ...(runtimeEnv?.T3CODE_PROJECT_ROOT ? { projectRoot: runtimeEnv.T3CODE_PROJECT_ROOT } : {}), + ...metadata, + }; + if (Object.keys(candidate).length === 0) { + return null; + } + return decodeTerminalSessionMetadata(candidate); +} + +function encodePowershellCommand(script: string): string { + return Buffer.from(script, "utf16le").toString("base64"); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizeProjectRootForMatch(value: string): string { + return value + .trim() + .replaceAll("\\", "/") + .replace(/\/+/g, "/") + .replace(/\/+$/g, "") + .toLowerCase(); +} + +function encodePowershellCommand(script: string): string { + return Buffer.from(script, "utf16le").toString("base64"); +} + +function normalizeProjectRootForMatch(value: string): string { + return value + .trim() + .replaceAll("\\", "/") + .replace(/\/+/g, "/") + .replace(/\/+$/g, "") + .toLowerCase(); +} + +function parseJsonArrayOrObject(value: string): T[] { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return []; + } + const parsed = JSON.parse(trimmed) as T | T[]; + return Array.isArray(parsed) ? parsed : [parsed]; +} + +function normalizeExternalServerAddress(address: string, port: number): string { + const normalizedAddress = address.trim(); + if ( + normalizedAddress === "127.0.0.1" || + normalizedAddress === "::1" || + normalizedAddress === "[::1]" || + normalizedAddress === "0.0.0.0" || + normalizedAddress === "::" || + normalizedAddress === "[::]" + ) { + return `localhost:${port}`; + } + return `${normalizedAddress}:${port}`; +} + +function commandMatchesProjectRoot( + commandLine: string | null, + projectRoots: readonly string[], +): boolean { + if (!commandLine) { + return false; + } + const normalizedCommandLine = normalizeProjectRootForMatch(commandLine).replaceAll('"', ""); + return projectRoots.some((root) => { + const normalizedRoot = normalizeProjectRootForMatch(root); + const boundaryPattern = new RegExp( + `(^|\\s)${escapeRegExp(normalizedRoot)}(?:/|\\s|$)`, + "i", + ); + return ( + normalizedCommandLine === normalizedRoot || + normalizedCommandLine.includes(`${normalizedRoot}/`) || + boundaryPattern.test(normalizedCommandLine) + ); + }); +} + +function extractRecentOutputPorts(value: string): Set { + const ports = new Set(); + for (const match of value.matchAll(/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1):(\d{2,5})/gi)) { + const port = Number(match[1]); + if (Number.isInteger(port) && port > 0) { + ports.add(port); + } + } + return ports; +} + +async function discoverWindowsExternalServers( + filter: ExternalServerFilter, +): Promise { + const projectRoots = [filter.projectRoot, filter.cwd] + .filter((value): value is string => Boolean(value)) + .map(normalizeProjectRootForMatch); + if (projectRoots.length === 0) { + return []; + } + + const command = [ + "$connections = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |", + " Where-Object { $_.OwningProcess -gt 0 -and $_.LocalPort -gt 0 -and $_.LocalAddress -in @('127.0.0.1','::1','0.0.0.0','::') }", + "if (-not $connections) { '[]'; exit 0 }", + "$processIds = $connections | Select-Object -ExpandProperty OwningProcess -Unique", + "$processMap = @{}", + "$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $processIds -contains $_.ProcessId }", + "foreach ($proc in $processes) {", + " $processMap[$proc.ProcessId] = $proc", + "}", + "$result = foreach ($connection in $connections) {", + " $proc = $processMap[$connection.OwningProcess]", + " if ($null -eq $proc) { continue }", + " [pscustomobject]@{", + " pid = [int]$proc.ProcessId", + " port = [int]$connection.LocalPort", + " address = [string]$connection.LocalAddress", + " name = [string]$proc.Name", + " commandLine = [string]$proc.CommandLine", + " parentPid = if ($null -ne $proc.ParentProcessId) { [int]$proc.ParentProcessId } else { $null }", + " createdAt = if ($null -ne $proc.CreationDate) { [string]$proc.CreationDate } else { $null }", + " }", + "}", + "$result | ConvertTo-Json -Compress", + ].join("\n"); + + const result = await runProcess( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowershellCommand(command)], + { + timeoutMs: DEFAULT_EXTERNAL_SERVER_SCAN_TIMEOUT_MS, + allowNonZeroExit: true, + maxBufferBytes: 512_000, + outputMode: "truncate", + }, + ); + if (result.code !== 0) { + return []; + } + + return parseJsonArrayOrObject(result.stdout).filter((server) => + commandMatchesProjectRoot(server.commandLine, projectRoots), + ); +} + +export const __terminalManagerInternals = { + commandMatchesProjectRoot, +}; + +async function discoverPosixExternalServers( + filter: ExternalServerFilter, +): Promise { + const projectRoots = [filter.projectRoot, filter.cwd] + .filter((value): value is string => Boolean(value)) + .map(normalizeProjectRootForMatch); + if (projectRoots.length === 0) { + return []; + } + + let rawListeners = ""; + try { + const result = await runProcess("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcPn"], { + timeoutMs: DEFAULT_EXTERNAL_SERVER_SCAN_TIMEOUT_MS, + allowNonZeroExit: true, + maxBufferBytes: 512_000, + outputMode: "truncate", + }); + rawListeners = result.stdout; + } catch { + return []; + } + + const descriptors: ExternalServerDescriptor[] = []; + let currentPid: number | null = null; + let currentName: string | null = null; + + for (const line of rawListeners.split(/\r?\n/g)) { + if (line.startsWith("p")) { + const pid = Number(line.slice(1).trim()); + currentPid = Number.isInteger(pid) && pid > 0 ? pid : null; + currentName = null; + continue; + } + if (line.startsWith("c")) { + currentName = line.slice(1).trim() || null; + continue; + } + if (!line.startsWith("n") || currentPid === null) { + continue; + } + + const addressValue = line.slice(1).trim(); + const portMatch = /:(\d+)(?:->|$)/.exec(addressValue); + const port = Number(portMatch?.[1] ?? NaN); + if (!Number.isInteger(port) || port <= 0) { + continue; + } + + let commandLine = ""; + let parentPid: number | null = null; + try { + const psResult = await runProcess( + "ps", + ["-p", String(currentPid), "-o", "ppid=,command="], + { + timeoutMs: 1_500, + allowNonZeroExit: true, + maxBufferBytes: 65_536, + outputMode: "truncate", + }, + ); + const raw = psResult.stdout.trim(); + const firstSpace = raw.search(/\s/); + if (firstSpace > 0) { + parentPid = Number(raw.slice(0, firstSpace).trim()); + commandLine = raw.slice(firstSpace).trim(); + } else { + commandLine = raw; + } + } catch { + commandLine = currentName ?? ""; + } + + descriptors.push({ + pid: currentPid, + port, + address: addressValue, + name: currentName, + commandLine, + parentPid: Number.isInteger(parentPid) ? parentPid : null, + createdAt: null, + }); + } + + return descriptors.filter((server) => commandMatchesProjectRoot(server.commandLine, projectRoots)); +} + +async function defaultExternalServerDiscoverer( + filter: ExternalServerFilter, +): Promise { + if (process.platform === "win32") { + return discoverWindowsExternalServers(filter); + } + return discoverPosixExternalServers(filter); +} + +async function defaultExternalProcessKiller(pid: number): Promise { + if (!Number.isInteger(pid) || pid <= 0) { + return; + } + if (process.platform === "win32") { + await runProcess("taskkill", ["/PID", String(pid), "/T", "/F"], { + timeoutMs: DEFAULT_EXTERNAL_SERVER_SCAN_TIMEOUT_MS, + allowNonZeroExit: true, + maxBufferBytes: 32_768, + outputMode: "truncate", + }); + return; + } + await runProcess("kill", ["-TERM", String(pid)], { + timeoutMs: 1_500, + allowNonZeroExit: true, + maxBufferBytes: 32_768, + outputMode: "truncate", + }); +} + function legacySafeThreadId(threadId: string): string { return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); } @@ -316,6 +650,8 @@ interface TerminalManagerOptions { ptyAdapter: PtyAdapterShape; shellResolver?: () => string; subprocessChecker?: TerminalSubprocessChecker; + externalServerDiscoverer?: ExternalServerDiscoverer; + externalProcessKiller?: ExternalProcessKiller; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; @@ -323,6 +659,7 @@ interface TerminalManagerOptions { export class TerminalManagerRuntime extends EventEmitter { private readonly sessions = new Map(); + private readonly externalServerPidsByThread = new Map>(); private readonly logsDir: string; private readonly historyLineLimit: number; private readonly ptyAdapter: PtyAdapterShape; @@ -333,6 +670,8 @@ export class TerminalManagerRuntime extends EventEmitter private readonly threadLocks = new Map>(); private readonly persistDebounceMs: number; private readonly subprocessChecker: TerminalSubprocessChecker; + private readonly externalServerDiscoverer: ExternalServerDiscoverer; + private readonly externalProcessKiller: ExternalProcessKiller; private readonly subprocessPollIntervalMs: number; private readonly processKillGraceMs: number; private readonly maxRetainedInactiveSessions: number; @@ -349,6 +688,9 @@ export class TerminalManagerRuntime extends EventEmitter this.shellResolver = options.shellResolver ?? defaultShellResolver; this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; + this.externalServerDiscoverer = + options.externalServerDiscoverer ?? defaultExternalServerDiscoverer; + this.externalProcessKiller = options.externalProcessKiller ?? defaultExternalProcessKiller; this.subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; this.processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; @@ -361,6 +703,8 @@ export class TerminalManagerRuntime extends EventEmitter const input = decodeTerminalOpenInput(raw); return this.runWithThreadLock(input.threadId, async () => { await this.assertValidCwd(input.cwd); + const nextMetadata = normalizeTerminalMetadata(input.metadata, input.env); + const metadataProvided = input.metadata !== undefined; const sessionKey = toSessionKey(input.threadId, input.terminalId); const existing = this.sessions.get(sessionKey); @@ -386,6 +730,7 @@ export class TerminalManagerRuntime extends EventEmitter unsubscribeExit: null, hasRunningSubprocess: false, runtimeEnv: normalizedRuntimeEnv(input.env), + metadata: nextMetadata, }; this.sessions.set(sessionKey, session); this.evictInactiveSessionsIfNeeded(); @@ -399,19 +744,31 @@ export class TerminalManagerRuntime extends EventEmitter const targetRows = input.rows ?? existing.rows; const runtimeEnvChanged = JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); + if (metadataProvided || !existing.metadata) { + existing.metadata = nextMetadata; + } if (existing.cwd !== input.cwd || runtimeEnvChanged) { this.stopProcess(existing); existing.cwd = input.cwd; existing.runtimeEnv = nextRuntimeEnv; + if (!metadataProvided) { + existing.metadata = nextMetadata; + } existing.history = ""; await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } else if (existing.status === "exited" || existing.status === "error") { existing.runtimeEnv = nextRuntimeEnv; + if (!metadataProvided) { + existing.metadata = nextMetadata; + } existing.history = ""; await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } else if (currentRuntimeEnv !== nextRuntimeEnv) { existing.runtimeEnv = nextRuntimeEnv; + if (!metadataProvided) { + existing.metadata = nextMetadata; + } } if (!existing.process) { @@ -482,6 +839,7 @@ export class TerminalManagerRuntime extends EventEmitter const input = decodeTerminalOpenInput(raw); return this.runWithThreadLock(input.threadId, async () => { await this.assertValidCwd(input.cwd); + const nextMetadata = normalizeTerminalMetadata(input.metadata, input.env); const sessionKey = toSessionKey(input.threadId, input.terminalId); let session = this.sessions.get(sessionKey); @@ -505,6 +863,7 @@ export class TerminalManagerRuntime extends EventEmitter unsubscribeExit: null, hasRunningSubprocess: false, runtimeEnv: normalizedRuntimeEnv(input.env), + metadata: nextMetadata, }; this.sessions.set(sessionKey, session); this.evictInactiveSessionsIfNeeded(); @@ -512,6 +871,7 @@ export class TerminalManagerRuntime extends EventEmitter this.stopProcess(session); session.cwd = input.cwd; session.runtimeEnv = normalizedRuntimeEnv(input.env); + session.metadata = nextMetadata; } const cols = input.cols ?? session.cols; @@ -524,10 +884,50 @@ export class TerminalManagerRuntime extends EventEmitter }); } + async list(raw: TerminalListInput): Promise { + const input = decodeTerminalListInput(raw); + const includeInactive = input.includeInactive === true; + const managedSessions = [...this.sessions.values()] + .filter((session) => includeInactive || session.status === "running") + .filter((session) => !input.threadId || session.threadId === input.threadId) + .filter((session) => !input.cwd || samePath(session.cwd, input.cwd)) + .filter((session) => !input.projectRoot || this.sessionMatchesProjectRoot(session, input.projectRoot)) + .map((session) => this.summary(session)) + .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + + const managedPorts = new Set(); + for (const session of managedSessions) { + for (const port of extractRecentOutputPorts(session.recentOutput)) { + managedPorts.add(port); + } + } + + const externalSessions = await this.listExternalSessions(input, managedPorts); + const sessions = [...managedSessions, ...externalSessions].toSorted((left, right) => + right.updatedAt.localeCompare(left.updatedAt), + ); + return { sessions }; + } + async close(raw: TerminalCloseInput): Promise { const input = decodeTerminalCloseInput(raw); await this.runWithThreadLock(input.threadId, async () => { if (input.terminalId) { + const externalPid = this.externalPidFromTerminalId(input.terminalId); + if (externalPid !== null) { + const allowedPids = this.externalServerPidsByThread.get(input.threadId); + if (!allowedPids?.has(externalPid)) { + throw new Error( + `External server is not registered for thread: ${input.threadId}, terminal: ${input.terminalId}`, + ); + } + await this.externalProcessKiller(externalPid); + allowedPids.delete(externalPid); + if (allowedPids.size === 0) { + this.externalServerPidsByThread.delete(input.threadId); + } + return; + } await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true); return; } @@ -565,6 +965,7 @@ export class TerminalManagerRuntime extends EventEmitter clearTimeout(timer); } this.killEscalationTimers.clear(); + this.externalServerPidsByThread.clear(); this.pendingPersistHistory.clear(); this.threadLocks.clear(); this.persistQueues.clear(); @@ -1133,6 +1534,92 @@ export class TerminalManagerRuntime extends EventEmitter return session; } + private sessionMatchesProjectRoot(session: TerminalSessionState, projectRoot: string): boolean { + const metadataRoot = session.metadata?.projectRoot ?? session.runtimeEnv?.T3CODE_PROJECT_ROOT; + if (metadataRoot && samePath(metadataRoot, projectRoot)) { + return true; + } + return pathContains(projectRoot, session.cwd); + } + + private externalPidFromTerminalId(terminalId: string): number | null { + if (!terminalId.startsWith(EXTERNAL_TERMINAL_ID_PREFIX)) { + return null; + } + const pid = Number(terminalId.slice(EXTERNAL_TERMINAL_ID_PREFIX.length)); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } + + private async listExternalSessions( + input: TerminalListInput, + managedPorts: ReadonlySet, + ): Promise { + const filter: ExternalServerFilter = { + ...(input.projectRoot ? { projectRoot: input.projectRoot } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + }; + const discovered = await this.externalServerDiscoverer(filter).catch((error) => { + this.logger.warn("failed to discover external local servers", { + error: error instanceof Error ? error.message : String(error), + ...filter, + }); + return []; + }); + + const fallbackCwd = input.projectRoot ?? input.cwd ?? process.cwd(); + const threadId = input.threadId ?? "external-local-server"; + const seenPorts = new Set(); + const sessions = discovered + .filter((server) => !managedPorts.has(server.port)) + .filter((server) => !seenPorts.has(server.port) && seenPorts.add(server.port)) + .map((server) => this.externalSummary(server, threadId, fallbackCwd)); + + this.externalServerPidsByThread.set( + threadId, + new Set(sessions.map((session) => session.pid).filter((pid): pid is number => pid !== null)), + ); + + return sessions; + } + + private summary(session: TerminalSessionState): TerminalSessionSummary { + return { + threadId: session.threadId, + terminalId: session.terminalId, + cwd: session.cwd, + status: session.status, + pid: session.pid, + hasRunningSubprocess: session.hasRunningSubprocess, + recentOutput: tailTerminalOutput(session.history), + metadata: session.metadata, + updatedAt: session.updatedAt, + }; + } + + private externalSummary( + server: ExternalServerDescriptor, + threadId: string, + cwd: string, + ): TerminalSessionSummary { + const title = normalizeExternalServerAddress(server.address, server.port); + const command = server.commandLine?.trim() || server.name?.trim() || title; + return { + threadId, + terminalId: `${EXTERNAL_TERMINAL_ID_PREFIX}${String(server.pid)}`, + cwd, + status: "running", + pid: server.pid, + hasRunningSubprocess: true, + recentOutput: "", + metadata: { + title, + command, + ...(cwd.trim().length > 0 ? { projectRoot: cwd } : {}), + }, + updatedAt: server.createdAt ?? new Date().toISOString(), + }; + } + private snapshot(session: TerminalSessionState): TerminalSessionSnapshot { return { threadId: session.threadId, @@ -1226,6 +1713,11 @@ export const TerminalManagerLive = Layer.effect( try: () => runtime.close(input), catch: (cause) => new TerminalError({ message: "Failed to close terminal", cause }), }), + list: (input) => + Effect.tryPromise({ + try: () => runtime.list(input), + catch: (cause) => new TerminalError({ message: "Failed to list terminals", cause }), + }), subscribe: (listener) => Effect.sync(() => { runtime.on("event", listener); diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 9cff3239919..74b6da8269d 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -10,8 +10,11 @@ import { TerminalClearInput, TerminalCloseInput, TerminalEvent, + TerminalListInput, + TerminalListResult, TerminalOpenInput, TerminalResizeInput, + TerminalSessionMetadata, TerminalSessionSnapshot, TerminalSessionStatus, TerminalWriteInput, @@ -41,6 +44,7 @@ export interface TerminalSessionState { unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; runtimeEnv: Record | null; + metadata: TerminalSessionMetadata | null; } export interface ShellCandidate { @@ -98,6 +102,11 @@ export interface TerminalManagerShape { */ readonly close: (input: TerminalCloseInput) => Effect.Effect; + /** + * List live terminal sessions for lightweight UI surfaces. + */ + readonly list: (input: TerminalListInput) => Effect.Effect; + /** * Subscribe to terminal runtime events. */ diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index d024e631384..11f329e2dc6 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -40,6 +40,7 @@ import type { TerminalEvent, TerminalOpenInput, TerminalResizeInput, + TerminalSessionSummary, TerminalSessionSnapshot, TerminalWriteInput, } from "@t3tools/contracts"; @@ -370,6 +371,23 @@ class MockTerminalManager implements TerminalManagerShape { } }); + readonly list: TerminalManagerShape["list"] = () => + Effect.sync(() => ({ + sessions: [...this.sessions.values()].map( + (snapshot): TerminalSessionSummary => ({ + threadId: snapshot.threadId, + terminalId: snapshot.terminalId, + cwd: snapshot.cwd, + status: snapshot.status, + pid: snapshot.pid, + hasRunningSubprocess: false, + recentOutput: snapshot.history, + metadata: null, + updatedAt: snapshot.updatedAt, + }), + ), + })); + readonly subscribe: TerminalManagerShape["subscribe"] = (listener) => Effect.sync(() => { this.listeners.add(listener); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 95d36cf57ee..30d0123dfc5 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -1311,6 +1311,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } if ( method === WS_METHODS.terminalOpen || + method === WS_METHODS.terminalList || method === WS_METHODS.terminalWrite || method === WS_METHODS.terminalResize || method === WS_METHODS.terminalClose @@ -2596,6 +2597,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* terminalManager.close(body); } + case WS_METHODS.terminalList: { + const body = stripRequestTag(request.body); + return yield* terminalManager.list(body); + } + case WS_METHODS.serverGetConfig: yield* fileSystem.makeDirectory(chatWorkspaceRoot, { recursive: true }).pipe(Effect.ignore); const keybindingsConfig = yield* keybindingsManager.loadConfigState; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 83cb4a2cd78..289d5dc8353 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -12,6 +12,7 @@ import { type ProjectId, type ProjectEntry, type ProjectScript, + type ProjectScriptIcon, type ModelSlug, PROVIDER_DISPLAY_NAMES, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -21,6 +22,8 @@ import { type ProviderApprovalDecision, type ProviderModelOptions, type ServerProviderStatus, + type TerminalListResult, + type TerminalSessionSummary, type ProviderKind, type ProviderNativeCommandDescriptor, type ProviderPluginDescriptor, @@ -49,6 +52,7 @@ import { useMemo, useRef, useState, + type FormEvent, type ReactNode, } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -119,6 +123,7 @@ import { type LocalServerArtifact, } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; +import { parseMarkdownGitHubLink } from "../markdown-links"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -171,12 +176,17 @@ import { ChevronRightIcon, CheckIcon, CircleAlertIcon, + CopyIcon, CloudIcon, + BugIcon, + ExternalLinkIcon, FileTextIcon, FilesIcon, + FlaskConicalIcon, FolderClosedIcon, GlobeIcon, KanbanSquareIcon, + HammerIcon, MessageSquareIcon, PanelLeftIcon, BoxIcon, @@ -185,13 +195,19 @@ import { HardDriveIcon, ImageIcon, LaptopIcon, + ListChecksIcon, MousePointer2Icon, PlusIcon, Maximize2Icon, ArrowLeftRight, + PlayIcon, + ServerIcon, + SettingsIcon, + SquareIcon, TerminalIcon, Undo2Icon, Trash2Icon, + WrenchIcon, XIcon, ZapIcon, PinIcon, @@ -229,6 +245,7 @@ import { ClaudeAI, CursorIcon, Gemini, + GitHubIcon, Icon, OpenAI, OpenCodeIcon, @@ -239,6 +256,7 @@ import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; +import { Spinner } from "./ui/spinner"; import { canCreateThreadHandoff, inferProviderFromModel, @@ -441,6 +459,12 @@ const THREAD_CONTEXT_ARTIFACT_EXTENSIONS = new Set([ const THREAD_CONTEXT_ARTIFACT_COLLECT_LIMIT = 30; const THREAD_CONTEXT_ARTIFACT_PAGE_SIZE = 5; const THREAD_CONTEXT_MARKDOWN_ARTIFACT_EXTENSIONS = new Set(["md", "mdown", "mdx", "mkd"]); +const LOCAL_SERVER_REFRESH_MS = 2_000; +const LOCAL_SERVER_COPY_LINE_LIMIT = 160; +const ANSI_ESCAPE_PATTERN = new RegExp( + `${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, + "g", +); const THREAD_CONTEXT_LOW_VALUE_ARTIFACT_FILENAMES = new Set([ "agents.md", "authors.md", @@ -506,6 +530,238 @@ interface ThreadContextProgressItem { createdAt: string; } +interface LocalServerSessionView { + session: TerminalSessionSummary; + artifacts: LocalServerArtifact[]; + title: string; + detail: string; +} + +interface ThreadContextRepositoryLink { + href: string; + label: string; + icon: "github" | "git"; +} + +function projectScriptIconNode(icon: ProjectScriptIcon, className = "size-3.5"): ReactNode { + if (icon === "test") return ; + if (icon === "lint") return ; + if (icon === "configure") return ; + if (icon === "build") return ; + if (icon === "debug") return ; + return ; +} + +function normalizeLocalServerPath(pathValue: string | null | undefined): string | null { + const normalized = pathValue?.replaceAll("\\", "/").replace(/\/+$/g, "").trim(); + return normalized && normalized.length > 0 ? normalized : null; +} + +function localServerPathMatches(root: string | null | undefined, candidate: string): boolean { + const normalizedRoot = normalizeLocalServerPath(root); + const normalizedCandidate = normalizeLocalServerPath(candidate); + if (!normalizedRoot || !normalizedCandidate) { + return false; + } + return ( + normalizedCandidate === normalizedRoot || + normalizedCandidate.startsWith(`${normalizedRoot}/`) + ); +} + +function terminalSessionBelongsToProject( + session: TerminalSessionSummary, + input: { + activeThreadId: ThreadId; + projectId?: ProjectId | undefined; + projectCwd?: string | null | undefined; + workspaceCwd?: string | null | undefined; + }, +): boolean { + if (input.projectId && session.metadata?.projectId === input.projectId) { + return true; + } + if (input.projectCwd && session.metadata?.projectRoot) { + return localServerPathMatches(input.projectCwd, session.metadata.projectRoot); + } + if (input.workspaceCwd && localServerPathMatches(input.workspaceCwd, session.cwd)) { + return true; + } + if (input.projectCwd && localServerPathMatches(input.projectCwd, session.cwd)) { + return true; + } + return session.threadId === input.activeThreadId; +} + +function stripAnsiSequences(value: string): string { + return value.replace(ANSI_ESCAPE_PATTERN, ""); +} + +function recentOutputForCopy(value: string): string { + const clean = stripAnsiSequences(value).trim(); + if (clean.length === 0) { + return ""; + } + return clean.split(/\r?\n/g).slice(-LOCAL_SERVER_COPY_LINE_LIMIT).join("\n").trim(); +} + +function compactCommandLabel(command: string): string { + const normalized = command.replace(/\s+/g, " ").trim(); + if (normalized.length <= 64) { + return normalized; + } + return `${normalized.slice(0, 61)}...`; +} + +function tokenizeCommand(command: string): string[] { + const tokens = command.match(/"[^"]+"|'[^']+'|\S+/g) ?? []; + return tokens.map((token) => token.replace(/^['"]|['"]$/g, "")); +} + +function friendlyCommandSummary(command: string): string { + const tokens = tokenizeCommand(command); + if (tokens.length === 0) { + return compactCommandLabel(command); + } + + const first = basenameOfPath(tokens[0] ?? ""); + const second = tokens[1]; + if ( + second && + (first === "node" || + first === "bun" || + first === "python" || + first === "python3" || + first === "deno") + ) { + const secondBase = basenameOfPath(second); + if (secondBase.length > 0 && secondBase !== second) { + return secondBase; + } + if (secondBase.length > 0) { + return secondBase; + } + } + + if (first === "npm" || first === "pnpm" || first === "yarn" || first === "bun") { + const task = tokens.find((token, index) => index > 0 && token !== "run" && token !== "--"); + if (task && task !== first) { + return `${first} ${task}`; + } + } + + if (first.length > 0) { + return first; + } + + return compactCommandLabel(command); +} + +function isExternalLocalServerSession(session: TerminalSessionSummary): boolean { + return session.terminalId.startsWith("external:"); +} + +function resolveLocalServerSessionTitle( + session: TerminalSessionSummary, + scripts: ReadonlyArray, +): string { + const scriptId = session.metadata?.scriptId ?? null; + const configuredScript = scriptId ? scripts.find((script) => script.id === scriptId) : null; + if (configuredScript) { + return configuredScript.name; + } + if (session.metadata?.title) { + return session.metadata.title; + } + if (session.metadata?.command) { + return friendlyCommandSummary(session.metadata.command); + } + return `Terminal ${session.terminalId}`; +} + +function buildLocalServerSessionView( + session: TerminalSessionSummary, + scripts: ReadonlyArray, +): LocalServerSessionView { + const artifacts = extractLocalServerArtifactsFromText(session.recentOutput); + const latestArtifact = artifacts.at(-1) ?? null; + return { + session, + artifacts, + title: resolveLocalServerSessionTitle(session, scripts), + detail: + latestArtifact?.label ?? + (session.metadata?.command + ? friendlyCommandSummary(session.metadata.command) + : session.cwd), + }; +} + +function normalizeRepositoryUrl(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + + const sshLikeMatch = /^git@([^/:]+):([^?#]+?)(?:\.git)?\/?$/.exec(trimmed); + if (sshLikeMatch?.[1] && sshLikeMatch[2]) { + return `https://${sshLikeMatch[1]}/${sshLikeMatch[2].replace(/\.git$/i, "")}`; + } + + const gitProtocolMatch = /^git:\/\/([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(trimmed); + if (gitProtocolMatch?.[1] && gitProtocolMatch[2]) { + return `https://${gitProtocolMatch[1]}/${gitProtocolMatch[2].replace(/\.git$/i, "")}`; + } + + try { + const url = new URL(trimmed); + if (url.protocol === "http:" || url.protocol === "https:") { + url.pathname = url.pathname.replace(/\.git$/i, ""); + url.search = ""; + url.hash = ""; + return url.toString(); + } + if (url.protocol === "ssh:" && url.hostname) { + const normalizedPath = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, ""); + return `https://${url.hostname}/${normalizedPath}`; + } + } catch { + return null; + } + + return null; +} + +function resolveThreadContextRepositoryLink( + repositoryUrl: string | null | undefined, +): ThreadContextRepositoryLink | null { + const href = normalizeRepositoryUrl(repositoryUrl); + if (!href) { + return null; + } + + const githubLink = parseMarkdownGitHubLink(href); + if (githubLink) { + return { + href: githubLink.href, + label: githubLink.label, + icon: "github", + }; + } + + try { + const url = new URL(href); + const path = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, ""); + return { + href, + label: path.length > 0 ? `${url.hostname}/${path}` : url.hostname, + icon: "git", + }; + } catch { + return null; + } +} + function extensionOf(pathValue: string): string { const filename = pathValue.split(/[\\/]/).at(-1) ?? pathValue; const dotIndex = filename.lastIndexOf("."); @@ -3220,7 +3476,8 @@ export default function ChatView({ return; } const api = readNativeApi(); - if (!api?.browser) { + const hasIntegratedBrowser = typeof window !== "undefined" && Boolean(window.desktopBridge?.browser); + if (!api?.browser || !hasIntegratedBrowser) { window.open(url, "_blank", "noopener,noreferrer"); return; } @@ -3242,7 +3499,8 @@ export default function ChatView({ const api = readNativeApi(); const projectId = activeProject?.id; const url = pathToBrowserFileUrl(path, options?.cwd ?? threadWorkspaceCwd ?? undefined); - if (!api?.browser || !projectId) { + const hasIntegratedBrowser = typeof window !== "undefined" && Boolean(window.desktopBridge?.browser); + if (!api?.browser || !projectId || !hasIntegratedBrowser) { window.open(url, "_blank", "noopener,noreferrer"); return; } @@ -3441,12 +3699,20 @@ export default function ChatView({ worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, ...(options?.env ? { extraEnv: options.env } : {}), }); + const terminalMetadata = { + projectId: activeProject.id, + projectRoot: activeProject.cwd, + scriptId: script.id, + title: script.name, + command: script.command, + }; const openTerminalInput: Parameters[0] = shouldCreateNewTerminal ? { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, env: runtimeEnv, + metadata: terminalMetadata, cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS, } @@ -3455,6 +3721,7 @@ export default function ChatView({ terminalId: targetTerminalId, cwd: targetCwd, env: runtimeEnv, + metadata: terminalMetadata, }; try { @@ -3604,6 +3871,25 @@ export default function ChatView({ }, [activeProject, persistProjectScripts], ); + const deleteProjectScript = useCallback( + async (scriptId: string) => { + if (!activeProject) return; + const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); + if (nextScripts.length === activeProject.scripts.length) { + return; + } + + await persistProjectScripts({ + projectId: activeProject.id, + projectCwd: activeProject.cwd, + previousScripts: activeProject.scripts, + nextScripts, + keybinding: null, + keybindingCommand: commandForProjectScript(scriptId), + }); + }, + [activeProject, persistProjectScripts], + ); const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { @@ -6538,10 +6824,19 @@ export default function ChatView({ workspaceCwd={threadWorkspaceCwd} homeDirectory={homeDirectory ?? undefined} activeThreadId={activeThread.id} + activeProjectId={activeProject?.id} + activeProjectCwd={activeProject?.cwd} + activeProjectScripts={activeProject?.scripts ?? []} accountSummary={getCodexAccountSummary(serverConfigQuery.data?.providerAccounts)} envLocked={envLocked} handoffBusy={handoffBusy} isServerThread={isServerThread} + onRunProjectScript={(script) => { + void runProjectScript(script); + }} + onAddProjectScript={saveProjectScript} + onUpdateProjectScript={updateProjectScript} + onDeleteProjectScript={deleteProjectScript} onEnvModeChange={onEnvModeChange} onHandoffToLocal={onHandoffToLocal} onHandoffToWorktree={onHandoffToWorktree} @@ -7612,10 +7907,17 @@ interface ThreadContextPanelProps { workspaceCwd: string | null; homeDirectory: string | undefined; activeThreadId: ThreadId; + activeProjectId: ProjectId | undefined; + activeProjectCwd: string | undefined; + activeProjectScripts: ProjectScript[]; accountSummary: ServerProviderAccountSummary | null; envLocked: boolean; handoffBusy: boolean; isServerThread: boolean; + onRunProjectScript: (script: ProjectScript) => void; + onAddProjectScript: (input: NewProjectScriptInput) => Promise; + onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; + onDeleteProjectScript: (scriptId: string) => Promise; onEnvModeChange: (mode: DraftThreadEnvMode) => void; onHandoffToLocal: () => void; onHandoffToWorktree: () => void; @@ -7649,6 +7951,530 @@ function persistThreadContextPanelPreference(key: string, value: boolean): void localStorage.setItem(key, value ? "true" : "false"); } +interface LocalServersPopoverControlProps { + activeThreadId: ThreadId; + activeProjectId: ProjectId | undefined; + activeProjectCwd: string | undefined; + workspaceCwd: string | null; + scripts: ProjectScript[]; + action?: ReactNode; + onRunProjectScript: (script: ProjectScript) => void; + onAddProjectScript: (input: NewProjectScriptInput) => Promise; + onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; + onDeleteProjectScript: (scriptId: string) => Promise; +} + +function LocalServersPopoverControl({ + activeThreadId, + activeProjectId, + activeProjectCwd, + workspaceCwd, + scripts, + action, + onRunProjectScript, + onAddProjectScript, + onUpdateProjectScript, + onDeleteProjectScript, +}: LocalServersPopoverControlProps) { + const [actionsExpanded, setActionsExpanded] = useState(false); + const [editingScriptId, setEditingScriptId] = useState(null); + const [formOpen, setFormOpen] = useState(false); + const [popoverOpen, setPopoverOpen] = useState(false); + const [actionName, setActionName] = useState(""); + const [actionCommand, setActionCommand] = useState(""); + const [formError, setFormError] = useState(null); + const [savingAction, setSavingAction] = useState(false); + const [stoppingTerminalKey, setStoppingTerminalKey] = useState(null); + const [cachedTerminalSessions, setCachedTerminalSessions] = useState( + null, + ); + + const quickActions = useMemo( + () => scripts.filter((script) => !script.runOnWorktreeCreate), + [scripts], + ); + const terminalSessionsQuery = useQuery({ + queryKey: [ + "terminal", + "local-server-sessions", + activeThreadId, + activeProjectId ?? null, + activeProjectCwd ?? null, + workspaceCwd ?? null, + popoverOpen, + ], + queryFn: async () => { + const api = readNativeApi(); + if (!api) { + return { sessions: [] }; + } + return api.terminal.list({ + includeInactive: false, + threadId: activeThreadId, + ...(activeProjectCwd ? { projectRoot: activeProjectCwd } : {}), + ...(workspaceCwd ? { cwd: workspaceCwd } : {}), + }); + }, + enabled: Boolean(activeThreadId && popoverOpen), + refetchInterval: popoverOpen ? LOCAL_SERVER_REFRESH_MS : false, + retry: false, + }); + const { + data: terminalSessionsData, + isError: terminalSessionsError, + isLoading: terminalSessionsLoading, + refetch: refetchTerminalSessions, + } = terminalSessionsQuery; + useEffect(() => { + if (!terminalSessionsData) { + return; + } + setCachedTerminalSessions(terminalSessionsData); + }, [terminalSessionsData]); + + const terminalSessionsSnapshot = terminalSessionsData ?? cachedTerminalSessions; + const hasLoadedTerminalSessions = terminalSessionsSnapshot !== null; + const isInitialServerCheckPending = + popoverOpen && terminalSessionsLoading && terminalSessionsData === undefined && !hasLoadedTerminalSessions; + const hasServerCheckError = + popoverOpen && terminalSessionsError && terminalSessionsData === undefined && !hasLoadedTerminalSessions; + + const localServerSessions = useMemo(() => { + const sessions = terminalSessionsSnapshot?.sessions ?? []; + return sessions + .filter((session) => + terminalSessionBelongsToProject(session, { + activeThreadId, + projectId: activeProjectId, + projectCwd: activeProjectCwd, + workspaceCwd, + }), + ) + .filter((session) => session.status === "running") + .map((session) => buildLocalServerSessionView(session, scripts)) + .filter((view) => view.session.hasRunningSubprocess || view.artifacts.length > 0); + }, [ + activeProjectCwd, + activeProjectId, + activeThreadId, + scripts, + terminalSessionsSnapshot?.sessions, + workspaceCwd, + ]); + + const resetForm = useCallback(() => { + setEditingScriptId(null); + setActionName(""); + setActionCommand(""); + setFormError(null); + setSavingAction(false); + setFormOpen(false); + }, []); + + const openAddForm = useCallback(() => { + setEditingScriptId(null); + setActionName(""); + setActionCommand(""); + setFormError(null); + setFormOpen(true); + }, []); + + const openEditForm = useCallback((script: ProjectScript) => { + setEditingScriptId(script.id); + setActionName(script.name); + setActionCommand(script.command); + setFormError(null); + setFormOpen(true); + setActionsExpanded(true); + }, []); + + const submitQuickAction = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + const trimmedName = actionName.trim(); + const trimmedCommand = actionCommand.trim(); + if (trimmedName.length === 0) { + setFormError("Name is required."); + return; + } + if (trimmedCommand.length === 0) { + setFormError("Command is required."); + return; + } + + setSavingAction(true); + setFormError(null); + const existingScript = editingScriptId + ? scripts.find((script) => script.id === editingScriptId) + : null; + const payload: NewProjectScriptInput = { + name: trimmedName, + command: trimmedCommand, + icon: existingScript?.icon ?? "play", + runOnWorktreeCreate: false, + keybinding: null, + }; + try { + if (editingScriptId) { + await onUpdateProjectScript(editingScriptId, payload); + } else { + await onAddProjectScript(payload); + } + resetForm(); + setActionsExpanded(true); + } catch (error) { + setFormError(error instanceof Error ? error.message : "Failed to save action."); + } finally { + setSavingAction(false); + } + }, + [ + actionCommand, + actionName, + editingScriptId, + onAddProjectScript, + onUpdateProjectScript, + resetForm, + scripts, + ], + ); + + const copyOutput = useCallback(async (view: LocalServerSessionView) => { + const output = recentOutputForCopy(view.session.recentOutput); + if (output.length === 0) { + toastManager.add({ + type: "info", + title: "No output to copy", + description: "This server has not written useful output yet.", + }); + return; + } + try { + await navigator.clipboard.writeText(output); + toastManager.add({ + type: "success", + title: "Output copied", + description: "Copied the most recent terminal output.", + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Copy failed", + description: error instanceof Error ? error.message : "Unable to copy output.", + }); + } + }, []); + + const stopSession = useCallback( + async (view: LocalServerSessionView) => { + const api = readNativeApi(); + if (!api) { + return; + } + const terminalKey = `${view.session.threadId}:${view.session.terminalId}`; + setStoppingTerminalKey(terminalKey); + try { + await api.terminal.close({ + threadId: view.session.threadId, + terminalId: view.session.terminalId, + }); + useTerminalStateStore + .getState() + .setTerminalActivity( + view.session.threadId as ThreadId, + view.session.terminalId, + false, + ); + await refetchTerminalSessions(); + toastManager.add({ + type: "success", + title: "Server stopped", + description: view.title, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Stop failed", + description: error instanceof Error ? error.message : "Unable to stop that server.", + }); + } finally { + setStoppingTerminalKey(null); + } + }, + [refetchTerminalSessions], + ); + + const deleteQuickAction = useCallback( + async (script: ProjectScript) => { + const confirmed = window.confirm(`Delete quick action "${script.name}"?`); + if (!confirmed) { + return; + } + try { + await onDeleteProjectScript(script.id); + } catch (error) { + toastManager.add({ + type: "error", + title: "Delete failed", + description: error instanceof Error ? error.message : "Unable to delete that action.", + }); + } + }, + [onDeleteProjectScript], + ); + + const runQuickAction = useCallback( + (script: ProjectScript) => { + onRunProjectScript(script); + window.setTimeout(() => { + void refetchTerminalSessions(); + }, 800); + }, + [onRunProjectScript, refetchTerminalSessions], + ); + + return ( +
+
+ + + } + > + + Local servers + {isInitialServerCheckPending ? null : ( + + {localServerSessions.length} + + )} + + + +
+
+

+ Running servers +

+ {isInitialServerCheckPending ? null : ( + + {localServerSessions.length} + + )} +
+ +
+ {isInitialServerCheckPending ? ( +
+ +
+ ) : hasServerCheckError ? ( +

+ Could not check local servers. +

+ ) : localServerSessions.length > 0 ? ( + localServerSessions.map((view) => { + const terminalKey = `${view.session.threadId}:${view.session.terminalId}`; + const stopping = stoppingTerminalKey === terminalKey; + const externalSession = isExternalLocalServerSession(view.session); + return ( +
+
+ + + + + + {view.title} + + {externalSession ? ( + + E + + ) : null} + + + {view.detail} + + + + + + + + +
+
+ ); + }) + ) : ( +

+ No running local servers. +

+ )} +
+ +
+ {formOpen ? ( +
+ setActionName(event.target.value)} + /> +