From 28915fe2d012688f67dfc9778b0527fa36b59127 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 12:59:46 -0800 Subject: [PATCH 01/14] feat(desktop): add userTitle field for user-renamed tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separates auto-generated tab names from user-set titles. The auto-generated name (e.g., "Terminal 1") is preserved for consistent tab numbering while userTitle stores custom names set via rename. User title takes precedence when non-empty; clearing it reverts to the auto-generated name. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../WorkspaceView/Sidebar/TabsView/TabItem/index.tsx | 6 +++--- apps/desktop/src/renderer/stores/tabs/store.ts | 2 +- apps/desktop/src/renderer/stores/tabs/types.ts | 1 + apps/desktop/src/renderer/stores/tabs/utils.ts | 8 ++++---- 4 files changed, 9 insertions(+), 8 deletions(-) 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..10b3a5d66ed 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -161,7 +161,7 @@ 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, ), })); }, diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 0f72c9624fd..19391d05ca6 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; 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"; }; From 72d71e62f9ee665236004e5dff753e72a2a365cf Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 13:08:26 -0800 Subject: [PATCH 02/14] feat(desktop): update tab auto-title with last submitted command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks terminal input and updates the tab's auto-generated title when the user submits a command (presses Enter). Handles backspace, Ctrl+C, and Ctrl+U for accurate command tracking. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../TabsContent/Terminal/Terminal.tsx | 30 +++++++++++++++++-- .../desktop/src/renderer/stores/tabs/store.ts | 8 +++++ .../desktop/src/renderer/stores/tabs/types.ts | 1 + 3 files changed, 37 insertions(+), 2 deletions(-) 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..addf07997e5 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 @@ -26,15 +26,18 @@ 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(); @@ -65,6 +68,11 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { resizeRef.current = resizeMutation.mutate; detachRef.current = detachMutation.mutate; + const setTabAutoTitleRef = useRef(setTabAutoTitle); + setTabAutoTitleRef.current = setTabAutoTitle; + const parentTabIdRef = useRef(parentTabId); + parentTabIdRef.current = parentTabId; + const handleStreamData = (event: TerminalStreamEvent) => { if (!xtermRef.current) { // Prevent data loss during terminal initialization @@ -213,10 +221,28 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const handleTerminalInput = (data: string) => { if (isExitedRef.current) { + commandBufferRef.current = ""; restartTerminal(); - } else { - writeRef.current({ tabId: paneId, data }); + return; + } + + for (const char of data) { + if (char === "\r" || char === "\n") { + const command = commandBufferRef.current.trim(); + if (command && parentTabIdRef.current) { + setTabAutoTitleRef.current(parentTabIdRef.current, command); + } + commandBufferRef.current = ""; + } else if (char === "\x7f" || char === "\b") { + commandBufferRef.current = commandBufferRef.current.slice(0, -1); + } else if (char === "\x03" || char === "\x15") { + commandBufferRef.current = ""; + } else if (char >= " " || char === "\t") { + commandBufferRef.current += char; + } } + + writeRef.current({ tabId: paneId, data }); }; createOrAttachRef.current( diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 10b3a5d66ed..4db61432aab 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -166,6 +166,14 @@ export const useTabsStore = create()( })); }, + setTabAutoTitle: (tabId, title) => { + set((state) => ({ + tabs: state.tabs.map((t) => + t.id === tabId ? { ...t, name: title } : t, + ), + })); + }, + setActiveTab: (workspaceId, tabId) => { const state = get(); const tab = state.tabs.find((t) => t.id === tabId); diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 19391d05ca6..113584ffa6e 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -50,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, From 06d2af51e0b67653ee209849180f963b7c3ea26d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 13:14:21 -0800 Subject: [PATCH 03/14] refactor(desktop): extract command buffer logic to separate module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves command input processing to commandBuffer.ts with comprehensive tests for enter, backspace, and cancel key handling. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../TabsContent/Terminal/Terminal.tsx | 23 ++-- .../Terminal/commandBuffer.test.ts | 103 ++++++++++++++++++ .../TabsContent/Terminal/commandBuffer.ts | 45 ++++++++ 3 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/commandBuffer.test.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/commandBuffer.ts 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 addf07997e5..ce10d61f0d5 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 @@ -8,6 +8,7 @@ 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 { processCommandInput } from "./commandBuffer"; import { createTerminalInstance, getDefaultTerminalBg, @@ -226,20 +227,14 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { return; } - for (const char of data) { - if (char === "\r" || char === "\n") { - const command = commandBufferRef.current.trim(); - if (command && parentTabIdRef.current) { - setTabAutoTitleRef.current(parentTabIdRef.current, command); - } - commandBufferRef.current = ""; - } else if (char === "\x7f" || char === "\b") { - commandBufferRef.current = commandBufferRef.current.slice(0, -1); - } else if (char === "\x03" || char === "\x15") { - commandBufferRef.current = ""; - } else if (char >= " " || char === "\t") { - commandBufferRef.current += char; - } + const result = processCommandInput(commandBufferRef.current, data); + commandBufferRef.current = result.buffer; + + if (result.submittedCommand && parentTabIdRef.current) { + setTabAutoTitleRef.current( + parentTabIdRef.current, + result.submittedCommand, + ); } writeRef.current({ tabId: paneId, data }); 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..76f2707d2c3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/commandBuffer.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test"; +import { processCommandInput } from "./commandBuffer"; + +describe("processCommandInput", () => { + describe("enter key submission", () => { + it("should submit command on carriage return", () => { + const result = processCommandInput("ls -la", "\r"); + expect(result.submittedCommand).toBe("ls -la"); + expect(result.buffer).toBe(""); + }); + + it("should submit command on newline", () => { + const result = processCommandInput("git status", "\n"); + expect(result.submittedCommand).toBe("git status"); + expect(result.buffer).toBe(""); + }); + + it("should trim whitespace from submitted command", () => { + const result = processCommandInput(" npm install ", "\r"); + expect(result.submittedCommand).toBe("npm install"); + }); + + it("should return null for empty buffer submission", () => { + const result = processCommandInput("", "\r"); + expect(result.submittedCommand).toBeNull(); + expect(result.buffer).toBe(""); + }); + + it("should return null for whitespace-only buffer submission", () => { + const result = processCommandInput(" ", "\r"); + expect(result.submittedCommand).toBeNull(); + }); + }); + + describe("backspace handling", () => { + it("should remove last character on backspace (\\x7f)", () => { + const result = processCommandInput("hello", "\x7f"); + expect(result.buffer).toBe("hell"); + expect(result.submittedCommand).toBeNull(); + }); + + it("should remove last character on backspace (\\b)", () => { + const result = processCommandInput("world", "\b"); + expect(result.buffer).toBe("worl"); + expect(result.submittedCommand).toBeNull(); + }); + + it("should handle backspace on empty buffer", () => { + const result = processCommandInput("", "\x7f"); + expect(result.buffer).toBe(""); + }); + }); + + describe("cancel handling", () => { + it("should clear buffer on Ctrl+C (\\x03)", () => { + const result = processCommandInput("partial command", "\x03"); + expect(result.buffer).toBe(""); + expect(result.submittedCommand).toBeNull(); + }); + + it("should clear buffer on Ctrl+U (\\x15)", () => { + const result = processCommandInput("another command", "\x15"); + expect(result.buffer).toBe(""); + expect(result.submittedCommand).toBeNull(); + }); + }); + + describe("printable character input", () => { + it("should append printable characters to buffer", () => { + const result = processCommandInput("hel", "lo"); + expect(result.buffer).toBe("hello"); + expect(result.submittedCommand).toBeNull(); + }); + + it("should append tab character", () => { + const result = processCommandInput("echo", "\t"); + expect(result.buffer).toBe("echo\t"); + }); + + it("should filter out non-printable characters", () => { + const result = processCommandInput("cmd", "\x01\x02abc"); + expect(result.buffer).toBe("cmdabc"); + }); + + it("should handle empty input", () => { + const result = processCommandInput("existing", ""); + expect(result.buffer).toBe("existing"); + }); + }); + + describe("edge cases", () => { + it("should handle mixed input with enter taking precedence", () => { + const result = processCommandInput("cmd", "x\r"); + expect(result.submittedCommand).toBe("cmd"); + expect(result.buffer).toBe(""); + }); + + it("should start from empty buffer", () => { + const result = processCommandInput("", "first"); + expect(result.buffer).toBe("first"); + }); + }); +}); 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..0bb290e20e4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/commandBuffer.ts @@ -0,0 +1,45 @@ +const ENTER = ["\r", "\n"]; +const BACKSPACE = ["\x7f", "\b"]; +const CANCEL = ["\x03", "\x15"]; // Ctrl+C, Ctrl+U + +export type CommandBufferResult = { + buffer: string; + submittedCommand: string | null; +}; + +export function processCommandInput( + currentBuffer: string, + input: string, +): CommandBufferResult { + const hasEnter = ENTER.some((char) => input.includes(char)); + const hasBackspace = BACKSPACE.some((char) => input.includes(char)); + const hasCancel = CANCEL.some((char) => input.includes(char)); + + if (hasEnter) { + const command = currentBuffer.trim(); + return { + buffer: "", + submittedCommand: command || null, + }; + } + + if (hasBackspace) { + return { + buffer: currentBuffer.slice(0, -1), + submittedCommand: null, + }; + } + + if (hasCancel) { + return { + buffer: "", + submittedCommand: null, + }; + } + + const printableChars = input.replace(/[^\x20-\x7e\t]/g, ""); + return { + buffer: currentBuffer + printableChars, + submittedCommand: null, + }; +} From c7251093eedc0afe0004feae7346df4f4e870c5f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 14:39:43 -0800 Subject: [PATCH 04/14] refactor(desktop): use strip-ansi and lodash debounce for tab titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use strip-ansi library for ANSI escape sequence removal - Add lodash debounce (100ms) to prevent rapid title updates - Limit title length to 32 characters - Add tests for sanitization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/package.json | 1 + .../TabsContent/Terminal/Terminal.tsx | 8 +++-- .../Terminal/commandBuffer.test.ts | 36 ++++++++++++++++++- .../TabsContent/Terminal/commandBuffer.ts | 15 ++++++-- bun.lock | 1 + 5 files changed, 55 insertions(+), 6 deletions(-) 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 ce10d61f0d5..a80adbae320 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,6 +2,7 @@ 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"; @@ -69,11 +70,12 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { resizeRef.current = resizeMutation.mutate; detachRef.current = detachMutation.mutate; - const setTabAutoTitleRef = useRef(setTabAutoTitle); - setTabAutoTitleRef.current = setTabAutoTitle; const parentTabIdRef = useRef(parentTabId); parentTabIdRef.current = parentTabId; + const debouncedSetTabAutoTitleRef = useRef(debounce(setTabAutoTitle, 100)); + debouncedSetTabAutoTitleRef.current = debounce(setTabAutoTitle, 100); + const handleStreamData = (event: TerminalStreamEvent) => { if (!xtermRef.current) { // Prevent data loss during terminal initialization @@ -231,7 +233,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { commandBufferRef.current = result.buffer; if (result.submittedCommand && parentTabIdRef.current) { - setTabAutoTitleRef.current( + debouncedSetTabAutoTitleRef.current( parentTabIdRef.current, result.submittedCommand, ); 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 index 76f2707d2c3..d71d286b9b9 100644 --- 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 @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { processCommandInput } from "./commandBuffer"; +import { processCommandInput, sanitizeForTitle } from "./commandBuffer"; describe("processCommandInput", () => { describe("enter key submission", () => { @@ -101,3 +101,37 @@ describe("processCommandInput", () => { }); }); }); + +describe("sanitizeForTitle", () => { + it("should strip ANSI color codes", () => { + expect(sanitizeForTitle("\x1b[32mgreen text\x1b[0m")).toBe("green text"); + }); + + it("should strip multiple escape sequences", () => { + expect(sanitizeForTitle("\x1b[1m\x1b[31mbold red\x1b[0m normal")).toBe( + "bold red normal", + ); + }); + + it("should strip non-printable characters", () => { + expect(sanitizeForTitle("hello\x00\x01\x02world")).toBe("helloworld"); + }); + + 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("\x1b[32m\x1b[0m")).toBeNull(); + }); + + it("should return null for whitespace-only result", () => { + expect(sanitizeForTitle(" \t ")).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 index 0bb290e20e4..e89c44f577d 100644 --- 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 @@ -1,12 +1,24 @@ +import stripAnsi from "strip-ansi"; + const ENTER = ["\r", "\n"]; const BACKSPACE = ["\x7f", "\b"]; const CANCEL = ["\x03", "\x15"]; // Ctrl+C, Ctrl+U +const MAX_TITLE_LENGTH = 32; export type CommandBufferResult = { buffer: string; submittedCommand: string | null; }; +export function sanitizeForTitle(text: string): string | null { + const cleaned = stripAnsi(text) + .replace(/[^\x20-\x7e]/g, "") + .trim() + .slice(0, MAX_TITLE_LENGTH); + + return cleaned || null; +} + export function processCommandInput( currentBuffer: string, input: string, @@ -16,10 +28,9 @@ export function processCommandInput( const hasCancel = CANCEL.some((char) => input.includes(char)); if (hasEnter) { - const command = currentBuffer.trim(); return { buffer: "", - submittedCommand: command || null, + submittedCommand: sanitizeForTitle(currentBuffer), }; } 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", From c5dbfc3daa6c2193179719f243731c27ff3585f3 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 14:42:14 -0800 Subject: [PATCH 05/14] simplify(desktop): only keep alphanumeric chars in tab titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove strip-ansi dependency and use simple regex to keep only alphanumeric, space, underscore, dash, dot, and slash characters. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/package.json | 1 - .../TabsContent/Terminal/commandBuffer.test.ts | 18 ++++++++---------- .../TabsContent/Terminal/commandBuffer.ts | 6 ++---- bun.lock | 1 - 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fe7428b8f4b..b3445efb367 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -81,7 +81,6 @@ "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/commandBuffer.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/commandBuffer.test.ts index d71d286b9b9..e113d4724da 100644 --- 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 @@ -103,18 +103,16 @@ describe("processCommandInput", () => { }); describe("sanitizeForTitle", () => { - it("should strip ANSI color codes", () => { - expect(sanitizeForTitle("\x1b[32mgreen text\x1b[0m")).toBe("green text"); + it("should keep alphanumeric and common chars", () => { + expect(sanitizeForTitle("ls -la ./src")).toBe("ls -la ./src"); }); - it("should strip multiple escape sequences", () => { - expect(sanitizeForTitle("\x1b[1m\x1b[31mbold red\x1b[0m normal")).toBe( - "bold red normal", - ); + it("should strip special characters", () => { + expect(sanitizeForTitle("[?1016;2$y command")).toBe("10162y command"); }); - it("should strip non-printable characters", () => { - expect(sanitizeForTitle("hello\x00\x01\x02world")).toBe("helloworld"); + it("should strip escape sequences", () => { + expect(sanitizeForTitle("\x1b[32mtext\x1b[0m")).toBe("32mtext0m"); }); it("should truncate to max length", () => { @@ -124,11 +122,11 @@ describe("sanitizeForTitle", () => { }); it("should return null for empty result", () => { - expect(sanitizeForTitle("\x1b[32m\x1b[0m")).toBeNull(); + expect(sanitizeForTitle("[]$;?")).toBeNull(); }); it("should return null for whitespace-only result", () => { - expect(sanitizeForTitle(" \t ")).toBeNull(); + expect(sanitizeForTitle(" ")).toBeNull(); }); it("should trim whitespace", () => { 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 index e89c44f577d..4c6404e8bbb 100644 --- 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 @@ -1,5 +1,3 @@ -import stripAnsi from "strip-ansi"; - const ENTER = ["\r", "\n"]; const BACKSPACE = ["\x7f", "\b"]; const CANCEL = ["\x03", "\x15"]; // Ctrl+C, Ctrl+U @@ -11,8 +9,8 @@ export type CommandBufferResult = { }; export function sanitizeForTitle(text: string): string | null { - const cleaned = stripAnsi(text) - .replace(/[^\x20-\x7e]/g, "") + const cleaned = text + .replace(/[^a-zA-Z0-9 _\-./]/g, "") .trim() .slice(0, MAX_TITLE_LENGTH); diff --git a/bun.lock b/bun.lock index 7f04cc9dfd3..bf55bcc76cc 100644 --- a/bun.lock +++ b/bun.lock @@ -122,7 +122,6 @@ "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", From 4bae7dcd1fed83429ea117886122e998ca83bb75 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 14:43:03 -0800 Subject: [PATCH 06/14] perf(desktop): slice before replace in sanitizeForTitle --- .../ContentView/TabsContent/Terminal/commandBuffer.ts | 1 + 1 file changed, 1 insertion(+) 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 index 4c6404e8bbb..00e5933f3ad 100644 --- 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 @@ -10,6 +10,7 @@ export type CommandBufferResult = { export function sanitizeForTitle(text: string): string | null { const cleaned = text + .slice(0, MAX_TITLE_LENGTH * 2) .replace(/[^a-zA-Z0-9 _\-./]/g, "") .trim() .slice(0, MAX_TITLE_LENGTH); From 33a53981e874e7bf21a85e1175d3cb9e16521b22 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 14:45:16 -0800 Subject: [PATCH 07/14] fix(desktop): create debounced function only once --- .../TabsContent/Terminal/Terminal.tsx | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) 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 a80adbae320..eeaae36aca6 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 @@ -73,8 +73,14 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const parentTabIdRef = useRef(parentTabId); parentTabIdRef.current = parentTabId; - const debouncedSetTabAutoTitleRef = useRef(debounce(setTabAutoTitle, 100)); - debouncedSetTabAutoTitleRef.current = debounce(setTabAutoTitle, 100); + const setTabAutoTitleRef = useRef(setTabAutoTitle); + setTabAutoTitleRef.current = setTabAutoTitle; + + const debouncedSetTabAutoTitle = useRef( + debounce((tabId: string, title: string) => { + setTabAutoTitleRef.current(tabId, title); + }, 100), + ).current; const handleStreamData = (event: TerminalStreamEvent) => { if (!xtermRef.current) { @@ -233,7 +239,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { commandBufferRef.current = result.buffer; if (result.submittedCommand && parentTabIdRef.current) { - debouncedSetTabAutoTitleRef.current( + debouncedSetTabAutoTitle( parentTabIdRef.current, result.submittedCommand, ); @@ -311,7 +317,14 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xtermRef.current = null; searchAddonRef.current = null; }; - }, [paneId, workspaceId, workspaceCwd, paneName, terminalTheme]); + }, [ + paneId, + workspaceId, + workspaceCwd, + paneName, + terminalTheme, + debouncedSetTabAutoTitle, + ]); // Sync theme changes to xterm instance for live theme switching useEffect(() => { From 043a42dcfe837094ef1c0ea2d19ab654151d07c8 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 14:52:22 -0800 Subject: [PATCH 08/14] fix(desktop): strip uppercase letters from tab title (escape codes) --- .../TabsContent/Terminal/commandBuffer.test.ts | 10 +++++----- .../ContentView/TabsContent/Terminal/commandBuffer.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) 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 index e113d4724da..83b3faa93e5 100644 --- 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 @@ -103,16 +103,16 @@ describe("processCommandInput", () => { }); describe("sanitizeForTitle", () => { - it("should keep alphanumeric and common chars", () => { + it("should keep lowercase alphanumeric and common chars", () => { expect(sanitizeForTitle("ls -la ./src")).toBe("ls -la ./src"); }); - it("should strip special characters", () => { - expect(sanitizeForTitle("[?1016;2$y command")).toBe("10162y command"); + it("should strip uppercase (escape codes use A-Z)", () => { + expect(sanitizeForTitle("open[Code")).toBe("openode"); }); - it("should strip escape sequences", () => { - expect(sanitizeForTitle("\x1b[32mtext\x1b[0m")).toBe("32mtext0m"); + it("should strip special characters", () => { + expect(sanitizeForTitle("[?1016;2$y command")).toBe("10162y command"); }); it("should truncate to max length", () => { 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 index 00e5933f3ad..ff95163f93c 100644 --- 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 @@ -11,7 +11,7 @@ export type CommandBufferResult = { export function sanitizeForTitle(text: string): string | null { const cleaned = text .slice(0, MAX_TITLE_LENGTH * 2) - .replace(/[^a-zA-Z0-9 _\-./]/g, "") + .replace(/[^a-z0-9 _\-./]/g, "") .trim() .slice(0, MAX_TITLE_LENGTH); From da5282e47398736588bdc239980bcbbfa4828376 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 14:56:00 -0800 Subject: [PATCH 09/14] refactor(desktop): use xterm.onKey for command tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track keystrokes via xterm.onKey instead of parsing PTY data from onData. This avoids TUI escape sequence pollution in tab titles since onKey captures actual keyboard events rather than shell output. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../TabsContent/Terminal/Terminal.tsx | 36 ++++-- .../Terminal/commandBuffer.test.ts | 103 +----------------- .../TabsContent/Terminal/commandBuffer.ts | 44 -------- 3 files changed, 26 insertions(+), 157 deletions(-) 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 eeaae36aca6..48ddf65317c 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 @@ -9,7 +9,6 @@ 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 { processCommandInput } from "./commandBuffer"; import { createTerminalInstance, getDefaultTerminalBg, @@ -234,18 +233,31 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { restartTerminal(); return; } + writeRef.current({ tabId: paneId, data }); + }; - const result = processCommandInput(commandBufferRef.current, data); - commandBufferRef.current = result.buffer; - - if (result.submittedCommand && parentTabIdRef.current) { - debouncedSetTabAutoTitle( - parentTabIdRef.current, - result.submittedCommand, - ); + const handleKeyPress = (event: { + key: string; + domEvent: KeyboardEvent; + }) => { + const { domEvent } = event; + if (domEvent.key === "Enter") { + const command = commandBufferRef.current.trim(); + if (command && parentTabIdRef.current) { + debouncedSetTabAutoTitle(parentTabIdRef.current, command); + } + 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; } - - writeRef.current({ tabId: paneId, data }); }; createOrAttachRef.current( @@ -273,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, { @@ -305,6 +318,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { return () => { isUnmounted = true; inputDisposable.dispose(); + keyDisposable.dispose(); cleanupKeyboard(); cleanupFocus?.(); cleanupResize(); 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 index 83b3faa93e5..09acc3d1aa3 100644 --- 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 @@ -1,106 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { processCommandInput, sanitizeForTitle } from "./commandBuffer"; - -describe("processCommandInput", () => { - describe("enter key submission", () => { - it("should submit command on carriage return", () => { - const result = processCommandInput("ls -la", "\r"); - expect(result.submittedCommand).toBe("ls -la"); - expect(result.buffer).toBe(""); - }); - - it("should submit command on newline", () => { - const result = processCommandInput("git status", "\n"); - expect(result.submittedCommand).toBe("git status"); - expect(result.buffer).toBe(""); - }); - - it("should trim whitespace from submitted command", () => { - const result = processCommandInput(" npm install ", "\r"); - expect(result.submittedCommand).toBe("npm install"); - }); - - it("should return null for empty buffer submission", () => { - const result = processCommandInput("", "\r"); - expect(result.submittedCommand).toBeNull(); - expect(result.buffer).toBe(""); - }); - - it("should return null for whitespace-only buffer submission", () => { - const result = processCommandInput(" ", "\r"); - expect(result.submittedCommand).toBeNull(); - }); - }); - - describe("backspace handling", () => { - it("should remove last character on backspace (\\x7f)", () => { - const result = processCommandInput("hello", "\x7f"); - expect(result.buffer).toBe("hell"); - expect(result.submittedCommand).toBeNull(); - }); - - it("should remove last character on backspace (\\b)", () => { - const result = processCommandInput("world", "\b"); - expect(result.buffer).toBe("worl"); - expect(result.submittedCommand).toBeNull(); - }); - - it("should handle backspace on empty buffer", () => { - const result = processCommandInput("", "\x7f"); - expect(result.buffer).toBe(""); - }); - }); - - describe("cancel handling", () => { - it("should clear buffer on Ctrl+C (\\x03)", () => { - const result = processCommandInput("partial command", "\x03"); - expect(result.buffer).toBe(""); - expect(result.submittedCommand).toBeNull(); - }); - - it("should clear buffer on Ctrl+U (\\x15)", () => { - const result = processCommandInput("another command", "\x15"); - expect(result.buffer).toBe(""); - expect(result.submittedCommand).toBeNull(); - }); - }); - - describe("printable character input", () => { - it("should append printable characters to buffer", () => { - const result = processCommandInput("hel", "lo"); - expect(result.buffer).toBe("hello"); - expect(result.submittedCommand).toBeNull(); - }); - - it("should append tab character", () => { - const result = processCommandInput("echo", "\t"); - expect(result.buffer).toBe("echo\t"); - }); - - it("should filter out non-printable characters", () => { - const result = processCommandInput("cmd", "\x01\x02abc"); - expect(result.buffer).toBe("cmdabc"); - }); - - it("should handle empty input", () => { - const result = processCommandInput("existing", ""); - expect(result.buffer).toBe("existing"); - }); - }); - - describe("edge cases", () => { - it("should handle mixed input with enter taking precedence", () => { - const result = processCommandInput("cmd", "x\r"); - expect(result.submittedCommand).toBe("cmd"); - expect(result.buffer).toBe(""); - }); - - it("should start from empty buffer", () => { - const result = processCommandInput("", "first"); - expect(result.buffer).toBe("first"); - }); - }); -}); +import { sanitizeForTitle } from "./commandBuffer"; describe("sanitizeForTitle", () => { it("should keep lowercase alphanumeric and common chars", () => { 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 index ff95163f93c..f3e00d10917 100644 --- 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 @@ -1,13 +1,5 @@ -const ENTER = ["\r", "\n"]; -const BACKSPACE = ["\x7f", "\b"]; -const CANCEL = ["\x03", "\x15"]; // Ctrl+C, Ctrl+U const MAX_TITLE_LENGTH = 32; -export type CommandBufferResult = { - buffer: string; - submittedCommand: string | null; -}; - export function sanitizeForTitle(text: string): string | null { const cleaned = text .slice(0, MAX_TITLE_LENGTH * 2) @@ -17,39 +9,3 @@ export function sanitizeForTitle(text: string): string | null { return cleaned || null; } - -export function processCommandInput( - currentBuffer: string, - input: string, -): CommandBufferResult { - const hasEnter = ENTER.some((char) => input.includes(char)); - const hasBackspace = BACKSPACE.some((char) => input.includes(char)); - const hasCancel = CANCEL.some((char) => input.includes(char)); - - if (hasEnter) { - return { - buffer: "", - submittedCommand: sanitizeForTitle(currentBuffer), - }; - } - - if (hasBackspace) { - return { - buffer: currentBuffer.slice(0, -1), - submittedCommand: null, - }; - } - - if (hasCancel) { - return { - buffer: "", - submittedCommand: null, - }; - } - - const printableChars = input.replace(/[^\x20-\x7e\t]/g, ""); - return { - buffer: currentBuffer + printableChars, - submittedCommand: null, - }; -} From e52395c0927e53932b698182623cb4b24a3cde88 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 14:57:06 -0800 Subject: [PATCH 10/14] fix(desktop): track pasted text in command buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass onPaste callback to setupPasteHandler so pasted text is appended to the command buffer, not just typed keystrokes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../ContentView/TabsContent/Terminal/Terminal.tsx | 6 +++++- .../ContentView/TabsContent/Terminal/helpers.ts | 13 ++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) 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 48ddf65317c..7ed88600169 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 @@ -313,7 +313,11 @@ 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; 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~) From 3178f529d9be1ecce8f0353696451e3fdef5a685 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 14:58:50 -0800 Subject: [PATCH 11/14] refactor(desktop): use strip-ansi for tab title sanitization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep most characters in tab titles, only strip ANSI escape sequences. This preserves uppercase, special chars like @, etc. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/package.json | 1 + .../Terminal/commandBuffer.test.ts | 19 +++++++++++++------ .../TabsContent/Terminal/commandBuffer.ts | 8 +++----- bun.lock | 1 + 4 files changed, 18 insertions(+), 11 deletions(-) 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/commandBuffer.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/commandBuffer.test.ts index 09acc3d1aa3..c65cb9183b1 100644 --- 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 @@ -2,16 +2,23 @@ import { describe, expect, it } from "bun:test"; import { sanitizeForTitle } from "./commandBuffer"; describe("sanitizeForTitle", () => { - it("should keep lowercase alphanumeric and common chars", () => { + it("should keep normal text unchanged", () => { expect(sanitizeForTitle("ls -la ./src")).toBe("ls -la ./src"); }); - it("should strip uppercase (escape codes use A-Z)", () => { - expect(sanitizeForTitle("open[Code")).toBe("openode"); + it("should keep uppercase letters", () => { + expect(sanitizeForTitle("openCode")).toBe("openCode"); }); - it("should strip special characters", () => { - expect(sanitizeForTitle("[?1016;2$y command")).toBe("10162y command"); + 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", () => { @@ -21,7 +28,7 @@ describe("sanitizeForTitle", () => { }); it("should return null for empty result", () => { - expect(sanitizeForTitle("[]$;?")).toBeNull(); + expect(sanitizeForTitle("")).toBeNull(); }); it("should return null for whitespace-only result", () => { 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 index f3e00d10917..85ba01caa18 100644 --- 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 @@ -1,11 +1,9 @@ +import stripAnsi from "strip-ansi"; + const MAX_TITLE_LENGTH = 32; export function sanitizeForTitle(text: string): string | null { - const cleaned = text - .slice(0, MAX_TITLE_LENGTH * 2) - .replace(/[^a-z0-9 _\-./]/g, "") - .trim() - .slice(0, MAX_TITLE_LENGTH); + const cleaned = stripAnsi(text).trim().slice(0, MAX_TITLE_LENGTH); return cleaned || null; } 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", From af59e96bb2f4cc33549326ebbfbaea9fb2e31f19 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 15:05:54 -0800 Subject: [PATCH 12/14] feat(desktop): read terminal buffer for tab title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read current line from xterm buffer when Enter is pressed instead of tracking keystrokes. This captures autocompleted text from shells like zsh with powerlevel10k or zsh-autosuggestions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../TabsContent/Terminal/Terminal.tsx | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) 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 7ed88600169..a6e7aeb6b2a 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 @@ -34,7 +34,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { 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); @@ -229,7 +228,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const handleTerminalInput = (data: string) => { if (isExitedRef.current) { - commandBufferRef.current = ""; restartTerminal(); return; } @@ -242,21 +240,13 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }) => { const { domEvent } = event; if (domEvent.key === "Enter") { - const command = commandBufferRef.current.trim(); + // Read current line from terminal buffer to capture autocompleted text + const buffer = xterm.buffer.active; + const line = buffer.getLine(buffer.cursorY); + const command = line?.translateToString(true).trim(); if (command && parentTabIdRef.current) { debouncedSetTabAutoTitle(parentTabIdRef.current, command); } - 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; } }; @@ -313,11 +303,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }, ); // Setup paste handler to ensure bracketed paste mode works for TUI apps like opencode - const cleanupPaste = setupPasteHandler(xterm, { - onPaste: (text) => { - commandBufferRef.current += text; - }, - }); + const cleanupPaste = setupPasteHandler(xterm); return () => { isUnmounted = true; From ed8cbe387b9971792b4fd45fbab78100d5d5bfaf Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 15:10:59 -0800 Subject: [PATCH 13/14] fix(desktop): prevent infinite render loops in Terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use refs for paneName, terminalTheme, and debouncedSetTabAutoTitle to avoid triggering effect re-runs - Theme changes handled by separate sync effect, not terminal recreation - Revert to keystroke tracking (onKey + paste) for command buffer - Add sanitizeForTitle to strip ANSI sequences from tab titles 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../TabsContent/Terminal/Terminal.tsx | 57 ++++++++++++------- 1 file changed, 37 insertions(+), 20 deletions(-) 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 a6e7aeb6b2a..88434dcede6 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 @@ -9,6 +9,7 @@ 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, @@ -34,6 +35,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { 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); @@ -41,6 +43,9 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); const terminalTheme = useTerminalTheme(); + // Ref for initial theme to avoid recreating terminal on theme change + const initialThemeRef = useRef(terminalTheme); + // Check if this terminal is the focused pane in its tab const isFocused = pane?.tabId ? focusedPaneIds[pane.tabId] === paneId : false; @@ -71,14 +76,17 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const parentTabIdRef = useRef(parentTabId); parentTabIdRef.current = parentTabId; + const paneNameRef = useRef(paneName); + paneNameRef.current = paneName; + const setTabAutoTitleRef = useRef(setTabAutoTitle); setTabAutoTitleRef.current = setTabAutoTitle; - const debouncedSetTabAutoTitle = useRef( + const debouncedSetTabAutoTitleRef = useRef( debounce((tabId: string, title: string) => { setTabAutoTitleRef.current(tabId, title); }, 100), - ).current; + ); const handleStreamData = (event: TerminalStreamEvent) => { if (!xtermRef.current) { @@ -155,7 +163,11 @@ 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; @@ -209,7 +221,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { { tabId: paneId, workspaceId, - tabTitle: paneName, + tabTitle: paneNameRef.current, cols: xterm.cols, rows: xterm.rows, }, @@ -240,13 +252,21 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }) => { const { domEvent } = event; if (domEvent.key === "Enter") { - // Read current line from terminal buffer to capture autocompleted text - const buffer = xterm.buffer.active; - const line = buffer.getLine(buffer.cursorY); - const command = line?.translateToString(true).trim(); - if (command && parentTabIdRef.current) { - debouncedSetTabAutoTitle(parentTabIdRef.current, command); + 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; } }; @@ -254,7 +274,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { { tabId: paneId, workspaceId, - tabTitle: paneName, + tabTitle: paneNameRef.current, cols: xterm.cols, rows: xterm.rows, }, @@ -303,7 +323,11 @@ 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; @@ -321,14 +345,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xtermRef.current = null; searchAddonRef.current = null; }; - }, [ - paneId, - workspaceId, - workspaceCwd, - paneName, - terminalTheme, - debouncedSetTabAutoTitle, - ]); + }, [paneId, workspaceId, workspaceCwd]); // Sync theme changes to xterm instance for live theme switching useEffect(() => { From 2ade02017685d4c9e77b55db4f523e5c10d098fb Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 8 Dec 2025 15:47:00 -0800 Subject: [PATCH 14/14] refactor(desktop): clean up redundant comments in Terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove comments that describe what the code does rather than why. Also cancel debounced tab title updates on unmount to prevent memory leaks from pending timers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../ContentView/TabsContent/Terminal/Terminal.tsx | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) 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 88434dcede6..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 @@ -46,10 +46,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { // Ref for initial theme to avoid recreating terminal on theme change const initialThemeRef = useRef(terminalTheme); - // Check if this terminal is the focused pane in its tab const isFocused = pane?.tabId ? focusedPaneIds[pane.tabId] === paneId : false; - - // Ref to track focus state for use in terminal creation effect const isFocusedRef = useRef(isFocused); isFocusedRef.current = isFocused; @@ -113,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 = () => { @@ -129,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, () => { @@ -172,12 +164,10 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { 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(); @@ -310,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(), ); @@ -338,7 +327,8 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { 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(); @@ -347,7 +337,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }; }, [paneId, workspaceId, workspaceCwd]); - // Sync theme changes to xterm instance for live theme switching useEffect(() => { const xterm = xtermRef.current; if (!xterm || !terminalTheme) return;