From 3d637e93b81cb4031b6cb11e12ffef84084db47a Mon Sep 17 00:00:00 2001 From: nonlooped Date: Fri, 31 Jul 2026 11:44:17 +0300 Subject: [PATCH 1/3] feat: expand integrated terminal with named tabs, shell profiles, and links --- apps/desktop/src/main/editors.ts | 34 ++- apps/desktop/src/main/ipc.ts | 37 ++- apps/desktop/src/main/terminal.ts | 146 +++++++-- .../src/renderer/components/TerminalDock.tsx | 280 +++++++++++++++--- .../src/renderer/lib/terminalLinks.test.ts | 36 +++ .../desktop/src/renderer/lib/terminalLinks.ts | 72 +++++ apps/desktop/src/shared/rpc-schema.ts | 25 +- 7 files changed, 554 insertions(+), 76 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/terminalLinks.test.ts create mode 100644 apps/desktop/src/renderer/lib/terminalLinks.ts diff --git a/apps/desktop/src/main/editors.ts b/apps/desktop/src/main/editors.ts index d6c441f..74eeea6 100644 --- a/apps/desktop/src/main/editors.ts +++ b/apps/desktop/src/main/editors.ts @@ -7,12 +7,22 @@ import type { InstalledEditor } from "../shared/rpc-schema.ts"; const execFileAsync = promisify(execFile); +/** Editors whose CLI accepts a line to jump to. Anything else just opens the file. */ +type EditorFamily = "vscode" | "jetbrains"; + type EditorSpec = InstalledEditor & { executables: string[]; paths: string[]; + family?: EditorFamily; }; -type ScannedEditor = { editor: InstalledEditor; executable?: string }; +type ScannedEditor = { editor: InstalledEditor; executable?: string; family?: EditorFamily }; + +function fileArgs(targetPath: string, family: EditorFamily | undefined, line: number | undefined): string[] { + if (line && family === "vscode") return ["-g", `${targetPath}:${line}`]; + if (line && family === "jetbrains") return ["--line", String(line), targetPath]; + return [targetPath]; +} const EXPLORER: InstalledEditor = { id: "explorer", name: "Explorer", icon: "explorer" }; @@ -31,6 +41,7 @@ const editorSpecs: EditorSpec[] = [ icon: "cursor", executables: ["Cursor.exe"], paths: under(local, "Programs", "Cursor", "Cursor.exe"), + family: "vscode", }, { id: "vscode", @@ -42,6 +53,7 @@ const editorSpecs: EditorSpec[] = [ ...under(programFiles, "Microsoft VS Code", "Code.exe"), ...under(programFilesX86, "Microsoft VS Code", "Code.exe"), ], + family: "vscode", }, { id: "antigravity", @@ -52,6 +64,7 @@ const editorSpecs: EditorSpec[] = [ ...under(local, "Programs", "Antigravity IDE", "Antigravity IDE.exe"), ...under(local, "Programs", "Antigravity", "Antigravity.exe"), ], + family: "vscode", }, { id: "windsurf", @@ -59,6 +72,7 @@ const editorSpecs: EditorSpec[] = [ icon: "windsurf", executables: ["Windsurf.exe"], paths: under(local, "Programs", "Windsurf", "Windsurf.exe"), + family: "vscode", }, { id: "vscode-insiders", @@ -66,6 +80,7 @@ const editorSpecs: EditorSpec[] = [ icon: "code", executables: ["Code - Insiders.exe"], paths: under(local, "Programs", "Microsoft VS Code Insiders", "Code - Insiders.exe"), + family: "vscode", }, { id: "vscodium", @@ -76,6 +91,7 @@ const editorSpecs: EditorSpec[] = [ ...under(local, "Programs", "VSCodium", "VSCodium.exe"), ...under(programFiles, "VSCodium", "VSCodium.exe"), ], + family: "vscode", }, { id: "zed", @@ -178,7 +194,7 @@ function scanJetBrainsDirectory( if (!existsSync(directory)) return; for (const [id, name, filename] of jetBrainsEditors) { const executable = join(directory, "bin", filename); - if (existsSync(executable) && !found.has(id)) found.set(id, { editor: { id, name, icon: "code" }, executable }); + if (existsSync(executable) && !found.has(id)) found.set(id, { editor: { id, name, icon: "code" }, executable, family: "jetbrains" }); } if (depth === 0 || jetBrainsEditors.every(([id]) => found.has(id))) return; let children; @@ -206,8 +222,8 @@ async function scanEditors(): Promise> { for (const spec of editorSpecs) { const executable = spec.paths.find(existsSync); if (executable) { - const { executables: _executables, paths: _paths, ...editor } = spec; - found.set(spec.id, { editor, executable }); + const { executables: _executables, paths: _paths, family, ...editor } = spec; + found.set(spec.id, { editor, executable, family }); } } try { @@ -221,14 +237,14 @@ async function scanEditors(): Promise> { if (found.has(spec.id)) continue; const executable = spec.executables.map((name) => registered.get(name.toLowerCase())).find(Boolean); if (executable) { - const { executables: _executables, paths: _paths, ...editor } = spec; - found.set(spec.id, { editor, executable }); + const { executables: _executables, paths: _paths, family, ...editor } = spec; + found.set(spec.id, { editor, executable, family }); } } for (const [id, name, filename] of jetBrainsEditors) { if (found.has(id)) continue; const executable = registered.get(filename.toLowerCase()); - if (executable) found.set(id, { editor: { id, name, icon: "code" }, executable }); + if (executable) found.set(id, { editor: { id, name, icon: "code" }, executable, family: "jetbrains" }); } } catch { // ignore @@ -284,7 +300,7 @@ export async function openProjectIn(projectDir: string, editorId: string): Promi }); } -export async function openFileIn(projectDir: string, file: string, editorId: string): Promise { +export async function openFileIn(projectDir: string, file: string, editorId: string, line?: number): Promise { const targetPath = resolve(projectDir, file); const relativePath = relative(projectDir, targetPath); if (isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith(`..${sep}`)) { @@ -298,7 +314,7 @@ export async function openFileIn(projectDir: string, file: string, editorId: str if (!target?.executable) throw new Error("That editor is no longer installed."); const executable = target.executable; await new Promise((resolveSpawn, reject) => { - const child = spawn(executable, [targetPath], { + const child = spawn(executable, fileArgs(targetPath, target.family, line), { detached: true, stdio: "ignore", windowsHide: true, diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 2898140..257bbdc 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -22,9 +22,12 @@ import { closeTerminal, closeProjectTerminals, createTerminal, + listShellProfiles, listTerminals, liveTerminalProjects, + renameTerminal, resizeTerminal, + restartTerminal, stopAllTerminals, terminalSnapshot, writeTerminal, @@ -283,7 +286,7 @@ function toSessionState(data: RpcSessionState): RpcSessionState { const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); const openProjectInParamsSchema = z.object({ projectDir: z.string().min(1), editorId: z.string().min(1) }); -const openFileInParamsSchema = openProjectInParamsSchema.extend({ file: z.string().min(1) }); +const openFileInParamsSchema = openProjectInParamsSchema.extend({ file: z.string().min(1), line: z.number().int().positive().optional() }); const saveImageParamsSchema = z.object({ data: z.string().min(1).max(64 * 1024 * 1024), mimeType: z.enum(["image/png", "image/jpeg", "image/gif", "image/webp"]), @@ -343,6 +346,11 @@ const tuiApplyParamsSchema = tuiCompleteParamsSchema.extend({ prefix: z.string().max(200), }); const terminalIdParamsSchema = projectDirParamsSchema.extend({ terminalId: z.string().min(1) }); +const terminalCreateParamsSchema = projectDirParamsSchema.extend({ + shellId: z.string().optional(), + name: z.string().optional(), +}); +const terminalRenameParamsSchema = terminalIdParamsSchema.extend({ name: z.string().min(1).max(64) }); const terminalResizeParamsSchema = terminalIdParamsSchema.extend({ cols: z.number().int().min(2).max(1000), rows: z.number().int().min(1).max(1000), @@ -775,8 +783,8 @@ const handlers: HandlerMap = { }, openFileIn: async (params) => { try { - const { projectDir, file, editorId } = openFileInParamsSchema.parse(params); - await openFileIn(projectDir, file, editorId); + const { projectDir, file, editorId, line } = openFileInParamsSchema.parse(params); + await openFileIn(projectDir, file, editorId, line); return { ok: true }; } catch (err) { return { ok: false, error: errorMessage(err) }; @@ -905,6 +913,8 @@ const handlers: HandlerMap = { terminals: [ createTerminal( projectDir, + undefined, + undefined, (payload) => push("terminalData", payload), (payload) => push("terminalExit", payload), ), @@ -912,10 +922,29 @@ const handlers: HandlerMap = { }; }, terminalCreate: (params) => { - const { projectDir } = projectDirParamsSchema.parse(params); + const { projectDir, shellId, name } = terminalCreateParamsSchema.parse(params); return { terminal: createTerminal( projectDir, + shellId, + name, + (payload) => push("terminalData", payload), + (payload) => push("terminalExit", payload), + ), + }; + }, + terminalListShells: () => ({ shells: listShellProfiles() }), + terminalRename: (params) => { + const { projectDir, terminalId, name } = terminalRenameParamsSchema.parse(params); + renameTerminal(projectDir, terminalId, name); + return { ok: true }; + }, + terminalRestart: (params) => { + const { projectDir, terminalId } = terminalIdParamsSchema.parse(params); + return { + terminal: restartTerminal( + projectDir, + terminalId, (payload) => push("terminalData", payload), (payload) => push("terminalExit", payload), ), diff --git a/apps/desktop/src/main/terminal.ts b/apps/desktop/src/main/terminal.ts index b311523..0913f88 100644 --- a/apps/desktop/src/main/terminal.ts +++ b/apps/desktop/src/main/terminal.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; import { delimiter, join } from "node:path"; import type { IPty } from "node-pty"; import { spawn } from "node-pty"; -import type { TerminalSession } from "../shared/rpc-schema.ts"; +import type { ShellProfile, TerminalSession } from "../shared/rpc-schema.ts"; const MAX_BUFFER_SIZE = 2 * 1024 * 1024; @@ -16,14 +16,55 @@ type ManagedTerminal = TerminalSession & { const terminals = new Map(); -function resolveShell(): string { +/** + * Shells NativePi knows how to look for. Detection is PATH-first with a few + * well-known install locations as a fallback, the same shape `editors.ts` + * uses for applications — adding a shell for another platform is one entry + * here, not a new branch. + */ +const SHELL_CANDIDATES: { id: string; name: string; executable: string; paths: string[]; args: string[] }[] = [ + { id: "pwsh", name: "PowerShell 7", executable: "pwsh.exe", paths: [], args: ["-NoLogo"] }, + { id: "powershell", name: "Windows PowerShell", executable: "powershell.exe", paths: [], args: ["-NoLogo"] }, + { id: "cmd", name: "Command Prompt", executable: "cmd.exe", paths: [], args: [] }, + { + id: "gitbash", + name: "Git Bash", + executable: "bash.exe", + paths: [ + join(process.env["ProgramFiles"] ?? "", "Git", "bin", "bash.exe"), + join(process.env["ProgramFiles(x86)"] ?? "", "Git", "bin", "bash.exe"), + ], + args: ["--login", "-i"], + }, + { id: "wsl", name: "WSL", executable: "wsl.exe", paths: [], args: [] }, +]; + +function findOnPath(executable: string): string | undefined { const path = process.env["PATH"] ?? ""; for (const dir of path.split(delimiter)) { if (!dir) continue; - const candidate = join(dir, "pwsh.exe"); + const candidate = join(dir, executable); if (existsSync(candidate)) return candidate; } - return "powershell.exe"; + return undefined; +} + +function resolveCandidate(candidate: { executable: string; paths: string[] }): string | undefined { + return findOnPath(candidate.executable) ?? candidate.paths.find(existsSync); +} + +export function listShellProfiles(): ShellProfile[] { + return SHELL_CANDIDATES.filter((candidate) => resolveCandidate(candidate) !== undefined).map( + ({ id, name }) => ({ id, name }), + ); +} + +function resolveShell(shellId: string | undefined): { id: string; path: string; args: string[] } { + const candidate = SHELL_CANDIDATES.find((entry) => entry.id === shellId); + const resolved = candidate ? resolveCandidate(candidate) : undefined; + if (candidate && resolved) return { id: candidate.id, path: resolved, args: candidate.args }; + const path = findOnPath("pwsh.exe") ?? "powershell.exe"; + return { id: "pwsh", path, args: ["-NoLogo"] }; } function shellEnv(): Record { @@ -44,6 +85,8 @@ function publicSession(terminal: ManagedTerminal): TerminalSession { return { id: terminal.id, projectDir: terminal.projectDir, + name: terminal.name, + shellId: terminal.shellId, exited: terminal.exited, exitCode: terminal.exitCode, }; @@ -60,31 +103,36 @@ export function liveTerminalProjects(): string[] { return [...terminals.values()].filter((terminal) => !terminal.exited).map((terminal) => terminal.projectDir); } -export function createTerminal( - projectDir: string, - onData: (payload: { projectDir: string; terminalId: string; data: string; sequence: number }) => void, - onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void, -): TerminalSession { - const id = randomUUID(); - const pty = spawn(resolveShell(), ["-NoLogo"], { +function nextTerminalName(projectDir: string): string { + const used = new Set( + [...terminals.values()].filter((terminal) => terminal.projectDir === projectDir).map((terminal) => terminal.name), + ); + let index = 1; + while (used.has(`Terminal ${index}`)) index += 1; + return `Terminal ${index}`; +} + +function spawnPty(projectDir: string, shellId: string | undefined): { pty: IPty; resolvedShellId: string } { + const shell = resolveShell(shellId); + const pty = spawn(shell.path, shell.args, { name: "xterm-256color", cols: 100, rows: 24, cwd: projectDir, env: shellEnv(), }); - const terminal: ManagedTerminal = { - id, - projectDir, - process: pty, - output: [], - outputSize: 0, - sequence: 0, - exited: false, - }; - terminals.set(id, terminal); + return { pty, resolvedShellId: shell.id }; +} - pty.onData((data) => { +function attachHandlers( + id: string, + projectDir: string, + onData: (payload: { projectDir: string; terminalId: string; data: string; sequence: number }) => void, + onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void, +): void { + const terminal = terminals.get(id); + if (!terminal) return; + terminal.process.onData((data) => { const current = terminals.get(id); if (!current) return; current.output.push(data); @@ -96,14 +144,66 @@ export function createTerminal( onData({ projectDir, terminalId: id, data, sequence: current.sequence }); }); - pty.onExit(({ exitCode }) => { + terminal.process.onExit(({ exitCode }) => { const current = terminals.get(id); if (!current) return; current.exited = true; current.exitCode = exitCode; onExit({ projectDir, terminalId: id, exitCode }); }); +} + +export function createTerminal( + projectDir: string, + shellId: string | undefined, + name: string | undefined, + onData: (payload: { projectDir: string; terminalId: string; data: string; sequence: number }) => void, + onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void, +): TerminalSession { + const id = randomUUID(); + const { pty, resolvedShellId } = spawnPty(projectDir, shellId); + const terminal: ManagedTerminal = { + id, + projectDir, + name: name?.trim() || nextTerminalName(projectDir), + shellId: resolvedShellId, + process: pty, + output: [], + outputSize: 0, + sequence: 0, + exited: false, + }; + terminals.set(id, terminal); + attachHandlers(id, projectDir, onData, onExit); + return publicSession(terminal); +} + +export function renameTerminal(projectDir: string, terminalId: string, name: string): void { + const terminal = terminals.get(terminalId); + if (!terminal || terminal.projectDir !== projectDir) return; + const trimmed = name.trim(); + if (trimmed) terminal.name = trimmed; +} +/** Kills the running shell and starts a fresh one under the same id, name, and profile. */ +export function restartTerminal( + projectDir: string, + terminalId: string, + onData: (payload: { projectDir: string; terminalId: string; data: string; sequence: number }) => void, + onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void, +): TerminalSession { + const terminal = terminals.get(terminalId); + if (!terminal || terminal.projectDir !== projectDir) throw new Error("Terminal not found"); + if (!terminal.exited) terminal.process.kill(); + const { pty, resolvedShellId } = spawnPty(projectDir, terminal.shellId); + terminal.process = pty; + terminal.shellId = resolvedShellId; + terminal.output = []; + terminal.outputSize = 0; + terminal.sequence = 0; + terminal.exited = false; + terminal.exitCode = undefined; + attachHandlers(terminalId, projectDir, onData, onExit); return publicSession(terminal); } diff --git a/apps/desktop/src/renderer/components/TerminalDock.tsx b/apps/desktop/src/renderer/components/TerminalDock.tsx index 8239d29..f588c23 100644 --- a/apps/desktop/src/renderer/components/TerminalDock.tsx +++ b/apps/desktop/src/renderer/components/TerminalDock.tsx @@ -1,13 +1,19 @@ import { useEffect, useRef, useState } from "react"; import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; +import { CaretDownIcon } from "@phosphor-icons/react/CaretDown"; +import { CheckIcon } from "@phosphor-icons/react/Check"; import { SplitHorizontalIcon } from "@phosphor-icons/react/SplitHorizontal"; import { TerminalWindowIcon } from "@phosphor-icons/react/TerminalWindow"; import { TrashIcon } from "@phosphor-icons/react/Trash"; import { XIcon } from "@phosphor-icons/react/X"; -import type { TerminalSession } from "../../shared/rpc-schema.ts"; +import { toast } from "sonner"; +import type { ShellProfile, TerminalSession } from "../../shared/rpc-schema.ts"; +import { findTerminalLinks } from "../lib/terminalLinks.ts"; import { useAppStore } from "../lib/store.ts"; import { Button } from "@/components/ui/button.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu.tsx"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable.tsx"; import { rpc } from "@/lib/rpc.ts"; import { @@ -26,8 +32,11 @@ export default function TerminalDock({ onMinimize: () => void; }) { const [terminals, setTerminals] = useState([]); + const [shells, setShells] = useState([]); const [error, setError] = useState(null); const [closing, setClosing] = useState(false); + const preferredShellId = useAppStore((s) => s.preferences.preferredShellId); + const setPreference = useAppStore((s) => s.setPreference); useEffect(() => { let cancelled = false; @@ -39,6 +48,12 @@ export default function TerminalDock({ if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); }, ); + void rpc.request.terminalListShells({}).then( + ({ shells: next }) => { + if (!cancelled) setShells(next); + }, + () => {}, + ); const offExit = rpc.events.on("terminalExit", ({ projectDir: exitedProject, terminalId, exitCode }) => { if (exitedProject !== projectDir) return; setTerminals((current) => @@ -53,15 +68,41 @@ export default function TerminalDock({ }; }, [projectDir]); - async function addSplit() { + async function addSplit(shellId?: string, name?: string) { try { - const { terminal } = await rpc.request.terminalCreate({ projectDir }); + const { terminal } = await rpc.request.terminalCreate({ projectDir, shellId, name }); setTerminals((current) => [...current, terminal]); } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } } + async function duplicateSplit(session: TerminalSession) { + await addSplit(session.shellId, `${session.name} copy`); + } + + async function restartSplit(terminalId: string) { + try { + const { terminal } = await rpc.request.terminalRestart({ projectDir, terminalId }); + setTerminals((current) => current.map((session) => (session.id === terminalId ? terminal : session))); + } catch (reason) { + toast.error(reason instanceof Error ? reason.message : String(reason)); + } + } + + async function renameSplit(terminalId: string, name: string) { + const trimmed = name.trim(); + if (!trimmed) return; + setTerminals((current) => + current.map((session) => (session.id === terminalId ? { ...session, name: trimmed } : session)), + ); + try { + await rpc.request.terminalRename({ projectDir, terminalId, name: trimmed }); + } catch (reason) { + toast.error(reason instanceof Error ? reason.message : String(reason)); + } + } + async function closeSplit(terminalId: string) { if (closing) return; setClosing(true); @@ -100,15 +141,12 @@ export default function TerminalDock({ }> - + void addSplit(preferredShellId || undefined)} closeAll={closeAll} closing={closing} /> {error ? (
- Could not start PowerShell: {error} + Could not start a shell: {error}
) : terminals.length === 0 ? (
- Starting PowerShell… + Starting a shell…
) : ( @@ -143,13 +181,13 @@ export default function TerminalDock({ projectDir={projectDir} defaultSize={`${100 / terminals.length}%`} showHandle={index > 0} - // A single terminal needs no chrome: the dock header already names it - // and can close it. Splits do, or an individual split cannot be closed. - showChrome={terminals.length > 1} closing={closing} onClose={() => void closeSplit(terminal.id)} - onSplit={addSplit} + onSplit={() => void addSplit(preferredShellId || undefined)} onCloseAll={closeAll} + onDuplicate={() => void duplicateSplit(terminal)} + onRestart={() => void restartSplit(terminal.id)} + onRename={(name) => void renameSplit(terminal.id, name)} /> ))} @@ -158,62 +196,184 @@ export default function TerminalDock({ ); } +function ShellSplitButton({ + shells, + preferredShellId, + onChoose, + onSplit, +}: { + shells: ShellProfile[]; + preferredShellId: string; + onChoose: (shellId: string) => void; + onSplit: (shellId?: string) => void; +}) { + if (shells.length === 0) { + return ( + + ); + } + const selected = shells.find((shell) => shell.id === preferredShellId); + return ( +
+ + + } + title="Choose a shell" + aria-label="Choose a shell" + > + + + + {shells.map((shell) => ( + onChoose(shell.id)} + className="text-sm" + > + {shell.name} + {shell.id === preferredShellId ? : null} + + ))} + + +
+ ); +} + function TerminalSplit({ session, projectDir, defaultSize, showHandle, - showChrome, closing, onClose, onSplit, onCloseAll, + onDuplicate, + onRestart, + onRename, }: { session: TerminalSession; projectDir: string; defaultSize: string; showHandle: boolean; - showChrome: boolean; closing: boolean; onClose: () => void; - onSplit: () => Promise; + onSplit: () => void; onCloseAll: () => Promise; + onDuplicate: () => void; + onRestart: () => void; + onRename: (name: string) => void; }) { + const [editingName, setEditingName] = useState(false); + return ( <> {showHandle ? : null}
- {showChrome ? ( - - }> + + }> + + {editingName ? ( + { + onRename(event.currentTarget.value); + setEditingName(false); + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") setEditingName(false); + }} + /> + ) : ( + + )} - - - - ) : null} - + + setEditingName(true)} + /> + +
); } +function StatusDot({ session }: { session: TerminalSession }) { + if (!session.exited) { + return