diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b3445efb367..fe7428b8f4b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -81,6 +81,7 @@ "react-syntax-highlighter": "^16.1.0", "shell-quote": "^1.8.3", "simple-git": "^3.30.0", + "strip-ansi": "^7.1.2", "superjson": "^2.2.5", "tailwind-merge": "^3.4.0", "trpc-electron": "^0.1.2", diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index eebbed6d245..bb632105a56 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -2,12 +2,14 @@ import "@xterm/xterm/css/xterm.css"; import type { FitAddon } from "@xterm/addon-fit"; import type { SearchAddon } from "@xterm/addon-search"; import type { Terminal as XTerm } from "@xterm/xterm"; +import debounce from "lodash/debounce"; import { useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTerminalTheme } from "renderer/stores/theme"; import { HOTKEYS } from "shared/hotkeys"; +import { sanitizeForTitle } from "./commandBuffer"; import { createTerminalInstance, getDefaultTerminalBg, @@ -26,22 +28,25 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const panes = useTabsStore((s) => s.panes); const pane = panes[paneId]; const paneName = pane?.name || "Terminal"; + const parentTabId = pane?.tabId; const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const searchAddonRef = useRef(null); const isExitedRef = useRef(false); const pendingEventsRef = useRef([]); + const commandBufferRef = useRef(""); const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); const setFocusedPane = useTabsStore((s) => s.setFocusedPane); + const setTabAutoTitle = useTabsStore((s) => s.setTabAutoTitle); const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); const terminalTheme = useTerminalTheme(); - // Check if this terminal is the focused pane in its tab - const isFocused = pane?.tabId ? focusedPaneIds[pane.tabId] === paneId : false; + // Ref for initial theme to avoid recreating terminal on theme change + const initialThemeRef = useRef(terminalTheme); - // Ref to track focus state for use in terminal creation effect + const isFocused = pane?.tabId ? focusedPaneIds[pane.tabId] === paneId : false; const isFocusedRef = useRef(isFocused); isFocusedRef.current = isFocused; @@ -65,6 +70,21 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { resizeRef.current = resizeMutation.mutate; detachRef.current = detachMutation.mutate; + const parentTabIdRef = useRef(parentTabId); + parentTabIdRef.current = parentTabId; + + const paneNameRef = useRef(paneName); + paneNameRef.current = paneName; + + const setTabAutoTitleRef = useRef(setTabAutoTitle); + setTabAutoTitleRef.current = setTabAutoTitle; + + const debouncedSetTabAutoTitleRef = useRef( + debounce((tabId: string, title: string) => { + setTabAutoTitleRef.current(tabId, title); + }, 100), + ); + const handleStreamData = (event: TerminalStreamEvent) => { if (!xtermRef.current) { // Prevent data loss during terminal initialization @@ -90,14 +110,12 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { } }; - // Use paneId (tabId) for stream subscription trpc.terminal.stream.useSubscription(paneId, { onData: handleStreamData, // Always listen to prevent missing events during initialization enabled: true, }); - // Handler to set focused pane when terminal gains focus // Use ref to avoid triggering full terminal recreation when focus handler changes const handleTerminalFocusRef = useRef(() => {}); handleTerminalFocusRef.current = () => { @@ -106,21 +124,18 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { } }; - // Auto-close search when terminal loses focus useEffect(() => { if (!isFocused) { setIsSearchOpen(false); } }, [isFocused]); - // Autofocus terminal when it becomes the focused pane (e.g., after split) useEffect(() => { if (isFocused && xtermRef.current) { xtermRef.current.focus(); } }, [isFocused]); - // Toggle search with Cmd+F (only for the focused terminal) useHotkeys( HOTKEYS.FIND_IN_TERMINAL.keys, () => { @@ -140,17 +155,19 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xterm, fitAddon, cleanup: cleanupQuerySuppression, - } = createTerminalInstance(container, workspaceCwd, terminalTheme); + } = createTerminalInstance( + container, + workspaceCwd, + initialThemeRef.current, + ); xtermRef.current = xterm; fitAddonRef.current = fitAddon; isExitedRef.current = false; - // Autofocus on initial render if this terminal is the focused pane if (isFocusedRef.current) { xterm.focus(); } - // Load search addon for Cmd+F functionality import("@xterm/addon-search").then(({ SearchAddon }) => { if (isUnmounted) return; const searchAddon = new SearchAddon(); @@ -194,7 +211,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { { tabId: paneId, workspaceId, - tabTitle: paneName, + tabTitle: paneNameRef.current, cols: xterm.cols, rows: xterm.rows, }, @@ -214,8 +231,32 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const handleTerminalInput = (data: string) => { if (isExitedRef.current) { restartTerminal(); - } else { - writeRef.current({ tabId: paneId, data }); + return; + } + writeRef.current({ tabId: paneId, data }); + }; + + const handleKeyPress = (event: { + key: string; + domEvent: KeyboardEvent; + }) => { + const { domEvent } = event; + if (domEvent.key === "Enter") { + const title = sanitizeForTitle(commandBufferRef.current); + if (title && parentTabIdRef.current) { + debouncedSetTabAutoTitleRef.current(parentTabIdRef.current, title); + } + commandBufferRef.current = ""; + } else if (domEvent.key === "Backspace") { + commandBufferRef.current = commandBufferRef.current.slice(0, -1); + } else if (domEvent.key === "c" && domEvent.ctrlKey) { + commandBufferRef.current = ""; + } else if ( + domEvent.key.length === 1 && + !domEvent.ctrlKey && + !domEvent.metaKey + ) { + commandBufferRef.current += domEvent.key; } }; @@ -223,7 +264,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { { tabId: paneId, workspaceId, - tabTitle: paneName, + tabTitle: paneNameRef.current, cols: xterm.cols, rows: xterm.rows, }, @@ -244,6 +285,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { ); const inputDisposable = xterm.onData(handleTerminalInput); + const keyDisposable = xterm.onKey(handleKeyPress); // Intercept keyboard events to handle app hotkeys and provide iTerm-like line continuation UX const cleanupKeyboard = setupKeyboardHandler(xterm, { @@ -258,7 +300,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }, }); - // Setup focus listener to track focused pane (use ref to get latest handler) const cleanupFocus = setupFocusListener(xterm, () => handleTerminalFocusRef.current(), ); @@ -271,26 +312,31 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }, ); // Setup paste handler to ensure bracketed paste mode works for TUI apps like opencode - const cleanupPaste = setupPasteHandler(xterm); + const cleanupPaste = setupPasteHandler(xterm, { + onPaste: (text) => { + commandBufferRef.current += text; + }, + }); return () => { isUnmounted = true; inputDisposable.dispose(); + keyDisposable.dispose(); cleanupKeyboard(); cleanupFocus?.(); cleanupResize(); cleanupPaste(); cleanupQuerySuppression(); - // Keep PTY running for reattachment + debouncedSetTabAutoTitleRef.current?.cancel?.(); + // Detach instead of kill to keep PTY running for reattachment detachRef.current({ tabId: paneId }); setSubscriptionEnabled(false); xterm.dispose(); xtermRef.current = null; searchAddonRef.current = null; }; - }, [paneId, workspaceId, workspaceCwd, paneName, terminalTheme]); + }, [paneId, workspaceId, workspaceCwd]); - // Sync theme changes to xterm instance for live theme switching useEffect(() => { const xterm = xtermRef.current; if (!xterm || !terminalTheme) return; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/commandBuffer.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/commandBuffer.test.ts new file mode 100644 index 00000000000..c65cb9183b1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/commandBuffer.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test"; +import { sanitizeForTitle } from "./commandBuffer"; + +describe("sanitizeForTitle", () => { + it("should keep normal text unchanged", () => { + expect(sanitizeForTitle("ls -la ./src")).toBe("ls -la ./src"); + }); + + it("should keep uppercase letters", () => { + expect(sanitizeForTitle("openCode")).toBe("openCode"); + }); + + it("should keep special characters", () => { + expect(sanitizeForTitle("npm install @scope/pkg")).toBe( + "npm install @scope/pkg", + ); + }); + + it("should strip ANSI escape sequences", () => { + expect(sanitizeForTitle("\x1b[32mgreen\x1b[0m")).toBe("green"); + expect(sanitizeForTitle("\x1b[1;34mbold blue\x1b[0m")).toBe("bold blue"); + }); + + it("should truncate to max length", () => { + const longCommand = "a".repeat(100); + const result = sanitizeForTitle(longCommand); + expect(result?.length).toBe(32); + }); + + it("should return null for empty result", () => { + expect(sanitizeForTitle("")).toBeNull(); + }); + + it("should return null for whitespace-only result", () => { + expect(sanitizeForTitle(" ")).toBeNull(); + }); + + it("should trim whitespace", () => { + expect(sanitizeForTitle(" command ")).toBe("command"); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/commandBuffer.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/commandBuffer.ts new file mode 100644 index 00000000000..85ba01caa18 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/commandBuffer.ts @@ -0,0 +1,9 @@ +import stripAnsi from "strip-ansi"; + +const MAX_TITLE_LENGTH = 32; + +export function sanitizeForTitle(text: string): string | null { + const cleaned = stripAnsi(text).trim().slice(0, MAX_TITLE_LENGTH); + + return cleaned || null; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index dc3f767adf6..76db0056d14 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -134,6 +134,11 @@ export interface KeyboardHandlerOptions { onClear?: () => void; } +export interface PasteHandlerOptions { + /** Callback when text is pasted, receives the pasted text */ + onPaste?: (text: string) => void; +} + /** * Setup paste handler for xterm to ensure bracketed paste mode works correctly. * @@ -148,7 +153,10 @@ export interface KeyboardHandlerOptions { * * Returns a cleanup function to remove the handler. */ -export function setupPasteHandler(xterm: XTerm): () => void { +export function setupPasteHandler( + xterm: XTerm, + options: PasteHandlerOptions = {}, +): () => void { const textarea = xterm.textarea; if (!textarea) return () => {}; @@ -161,6 +169,9 @@ export function setupPasteHandler(xterm: XTerm): () => void { event.preventDefault(); event.stopImmediatePropagation(); + // Notify caller of pasted text (for command buffer tracking) + options.onPaste?.(text); + // xterm.paste() handles: // 1. Line ending normalization (CRLF/LF -> CR) // 2. Bracketed paste mode wrapping (\x1b[200~ ... \x1b[201~) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx index 9029c063de8..24d2b3c33c6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx @@ -77,7 +77,7 @@ export function TabItem({ tab, index, isActive }: TabItemProps) { }; const startRename = () => { - setRenameValue(tab.name || displayName); + setRenameValue(tab.userTitle ?? tab.name ?? displayName); setIsRenaming(true); setTimeout(() => { inputRef.current?.focus(); @@ -87,8 +87,8 @@ export function TabItem({ tab, index, isActive }: TabItemProps) { const submitRename = () => { const trimmedValue = renameValue.trim(); - // Only update if the name actually changed - if (trimmedValue && trimmedValue !== tab.name) { + const currentUserTitle = tab.userTitle?.trim() ?? ""; + if (trimmedValue !== currentUserTitle) { renameTab(tab.id, trimmedValue); } setIsRenaming(false); diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index cc6e7e1d3bc..4db61432aab 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -161,7 +161,15 @@ export const useTabsStore = create()( renameTab: (tabId, newName) => { set((state) => ({ tabs: state.tabs.map((t) => - t.id === tabId ? { ...t, name: newName } : t, + t.id === tabId ? { ...t, userTitle: newName } : t, + ), + })); + }, + + setTabAutoTitle: (tabId, title) => { + set((state) => ({ + tabs: state.tabs.map((t) => + t.id === tabId ? { ...t, name: title } : t, ), })); }, diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 0f72c9624fd..113584ffa6e 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -25,6 +25,7 @@ export interface Pane { export interface Tab { id: string; name: string; + userTitle?: string; workspaceId: string; layout: MosaicNode; // Always defined, leaves are paneIds createdAt: number; @@ -49,6 +50,7 @@ export interface TabsStore extends TabsState { addTab: (workspaceId: string) => { tabId: string; paneId: string }; removeTab: (tabId: string) => void; renameTab: (tabId: string, newName: string) => void; + setTabAutoTitle: (tabId: string, title: string) => void; setActiveTab: (workspaceId: string, tabId: string) => void; reorderTabs: ( workspaceId: string, diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index b65fe98a9f3..276f62f1a64 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -8,11 +8,11 @@ export const generateId = (prefix: string): string => { return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; }; -/** - * Gets the display name for a tab - * Now just returns the stored name since names are static at creation - */ export const getTabDisplayName = (tab: Tab): string => { + const userTitle = tab.userTitle?.trim(); + if (userTitle) { + return userTitle; + } return tab.name || "Terminal"; }; diff --git a/bun.lock b/bun.lock index bf55bcc76cc..7f04cc9dfd3 100644 --- a/bun.lock +++ b/bun.lock @@ -122,6 +122,7 @@ "react-syntax-highlighter": "^16.1.0", "shell-quote": "^1.8.3", "simple-git": "^3.30.0", + "strip-ansi": "^7.1.2", "superjson": "^2.2.5", "tailwind-merge": "^3.4.0", "trpc-electron": "^0.1.2",