Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<HTMLDivElement>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
const isExitedRef = useRef(false);
const pendingEventsRef = useRef<TerminalStreamEvent[]>([]);
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;

Expand All @@ -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),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleStreamData = (event: TerminalStreamEvent) => {
if (!xtermRef.current) {
// Prevent data loss during terminal initialization
Expand All @@ -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 = () => {
Expand All @@ -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,
() => {
Expand All @@ -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();
Expand Down Expand Up @@ -194,7 +211,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
{
tabId: paneId,
workspaceId,
tabTitle: paneName,
tabTitle: paneNameRef.current,
cols: xterm.cols,
rows: xterm.rows,
},
Expand All @@ -214,16 +231,40 @@ 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;
}
};
Comment on lines +239 to 261
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Command buffering approach has significant limitations.

The current implementation buffers keystrokes character-by-character, which works for simple sequential typing but fails in several common scenarios:

  • Cursor movement: Typing "test", pressing ←← (left arrow twice), then "x" results in buffer "testx" but actual command is "tex st"
  • Shell history: Using ↑/↓ to navigate command history won't update the buffer
  • Multi-line commands: Continuation lines aren't properly tracked
  • TUI applications: Vim, nano, etc. will completely break this approach
  • Unicode handling: domEvent.key.length === 1 may not correctly handle multi-byte characters

Given the PR objectives mention this is for deriving auto-titles (not comprehensive command tracking), this simplified approach may be acceptable for v1, but users will encounter incorrect titles in the above scenarios.

Would you like me to:

  1. Add comments documenting these known limitations?
  2. Suggest a more robust approach using shell integration (OSC 133 sequences)?
  3. Help implement cursor position tracking for at least basic left/right arrow support?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's 2?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Kitenite Shell integration using OSC 133 sequences is a protocol that modern shells can emit to communicate with terminal emulators. Here's how it works:

What are OSC 133 sequences?

OSC (Operating System Command) 133 is a set of escape sequences that shells can output to mark semantic regions in the terminal. The key sequences are:

  • OSC 133 ; A ST - Marks the start of a command prompt
  • OSC 133 ; B ST - Marks the end of prompt, start of command input
  • OSC 133 ; C ST - Marks the end of command input, start of execution
  • OSC 133 ; D ST - Marks the end of command output

How this helps:

Instead of trying to track keystrokes, the terminal would listen for these escape sequences. When the shell emits OSC 133 ; B and OSC 133 ; C, everything between them is the actual command being executed - regardless of cursor movements, history navigation, or multi-line edits.

Example:

[Prompt shown]
OSC 133 ; A ST  (prompt starts)
OSC 133 ; B ST  (command input starts)
git status      (user types this - shell captures the final command)
OSC 133 ; C ST  (command about to execute)
[output shown]
OSC 133 ; D ST  (command finished)

Real-world implementations:

  • VS Code terminal uses this for "smart command detection" and command history features
  • iTerm2 pioneered this with their Shell Integration
  • Warp terminal relies heavily on this

Trade-offs:

  • ✅ Accurate: Gets the actual executed command, not keystroke approximations
  • ✅ Handles all edge cases: arrows, history, multi-line, etc.
  • ❌ Requires shell configuration: Users need to modify .bashrc, .zshrc, etc.
  • ❌ Not universal: Won't work until user sets it up

For your use case, shell integration would be significantly more robust but requires user setup. The current keystroke approach works immediately but with known limitations.

Would you like me to sketch out what shell integration setup would look like, or are you leaning toward documenting the limitations of the current v1 approach?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keystroke is fine, perhaps improve it with some simple cursor tracking

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Kitenite I'll add simple cursor tracking to handle arrow keys and character insertion at the cursor position. Here's an improved implementation:

// Add a ref to track cursor position within the command buffer
const commandBufferRef = useRef("");
const cursorPositionRef = useRef(0);

// ... existing code ...

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 = "";
		cursorPositionRef.current = 0;
	} else if (domEvent.key === "Backspace") {
		// Delete character before cursor
		if (cursorPositionRef.current > 0) {
			const buffer = commandBufferRef.current;
			commandBufferRef.current =
				buffer.slice(0, cursorPositionRef.current - 1) +
				buffer.slice(cursorPositionRef.current);
			cursorPositionRef.current--;
		}
	} else if (domEvent.key === "ArrowLeft") {
		// Move cursor left
		cursorPositionRef.current = Math.max(0, cursorPositionRef.current - 1);
	} else if (domEvent.key === "ArrowRight") {
		// Move cursor right
		cursorPositionRef.current = Math.min(
			commandBufferRef.current.length,
			cursorPositionRef.current + 1,
		);
	} else if (domEvent.key === "Home") {
		// Move cursor to start
		cursorPositionRef.current = 0;
	} else if (domEvent.key === "End") {
		// Move cursor to end
		cursorPositionRef.current = commandBufferRef.current.length;
	} else if (domEvent.key === "ArrowUp" || domEvent.key === "ArrowDown") {
		// Shell history navigation - reset buffer
		commandBufferRef.current = "";
		cursorPositionRef.current = 0;
	} else if (domEvent.key === "c" && domEvent.ctrlKey) {
		commandBufferRef.current = "";
		cursorPositionRef.current = 0;
	} else if (
		domEvent.key.length === 1 &&
		!domEvent.ctrlKey &&
		!domEvent.metaKey
	) {
		// Insert character at cursor position
		const buffer = commandBufferRef.current;
		commandBufferRef.current =
			buffer.slice(0, cursorPositionRef.current) +
			domEvent.key +
			buffer.slice(cursorPositionRef.current);
		cursorPositionRef.current++;
	}
};

What this adds:

  1. Cursor tracking: cursorPositionRef tracks position within the buffer
  2. Arrow keys: Left/right move cursor, up/down reset buffer (shell history)
  3. Home/End: Jump to start/end of buffer
  4. Positional editing: Backspace and insertion now work at cursor position

Example scenario:

  • Type "test" → buffer: "test", cursor: 4
  • Press ← ← → buffer: "test", cursor: 2
  • Type "x" → buffer: "text st", cursor: 3 ✅

This handles basic cursor movement while keeping the implementation simple. Still won't track shell history contents or handle TUI apps, but covers the common editing cases.

🐰✏️


🧠 Learnings used
Learnt from: CR
Repo: superset-sh/superset PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-08T23:32:32.232Z
Learning: Applies to apps/desktop/src/main/lib/terminal-*.ts : Use node-pty for terminal session management in the desktop app


createOrAttachRef.current(
{
tabId: paneId,
workspaceId,
tabTitle: paneName,
tabTitle: paneNameRef.current,
cols: xterm.cols,
rows: xterm.rows,
},
Expand All @@ -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, {
Expand All @@ -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(),
);
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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 () => {};

Expand All @@ -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~)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down
10 changes: 9 additions & 1 deletion apps/desktop/src/renderer/stores/tabs/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,15 @@ export const useTabsStore = create<TabsStore>()(
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,
),
}));
},
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/stores/tabs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface Pane {
export interface Tab {
id: string;
name: string;
userTitle?: string;
workspaceId: string;
layout: MosaicNode<string>; // Always defined, leaves are paneIds
createdAt: number;
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/renderer/stores/tabs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
};

Expand Down
Loading