From 0ca11a501c7547b87e7d674c2b25d6fe1811f054 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 1 Jun 2026 15:42:01 -0400 Subject: [PATCH 01/31] Allow spaces and parentheses in markdown file links --- apps/web/src/markdown-links.test.ts | 24 ++++++++++++++++++++++++ apps/web/src/markdown-links.ts | 5 +++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index d3ca8bc99..22ce8b384 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -60,4 +60,28 @@ describe("resolveMarkdownFileLinkTarget", () => { it("does not treat app routes as file links", () => { expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); }); + + it("resolves file paths containing spaces", () => { + expect(resolveMarkdownFileLinkTarget("my file.ts", "/Users/julius/project")).toBe( + "/Users/julius/project/my file.ts", + ); + }); + + it("resolves file paths containing parentheses", () => { + expect(resolveMarkdownFileLinkTarget("file (copy).ts", "/Users/julius/project")).toBe( + "/Users/julius/project/file (copy).ts", + ); + }); + + it("resolves relative paths with spaces in directory names", () => { + expect(resolveMarkdownFileLinkTarget("src/my folder/process.ts", "/Users/julius/project")).toBe( + "/Users/julius/project/src/my folder/process.ts", + ); + }); + + it("resolves URL-encoded file paths with decoded spaces", () => { + expect(resolveMarkdownFileLinkTarget("my%20file.ts", "/Users/julius/project")).toBe( + "/Users/julius/project/my file.ts", + ); + }); }); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index b5dcab010..cbfc05aad 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -4,8 +4,9 @@ const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const RELATIVE_FILE_PATH_PATTERN = + /^[A-Za-z0-9._\-()[\] ]+(?:\/[A-Za-z0-9._\-()[\] ]+)+(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._\-()[\] ]+\.[A-Za-z0-9_\-()[\] ]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; const POSIX_FILE_ROOT_PREFIXES = [ From a05b8bb9bca6b16fd30d69ba62009e2dce0c363f Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 1 Jun 2026 15:48:22 -0400 Subject: [PATCH 02/31] Add terminal title override and mouse reset regression tests --- apps/web/src/terminalStateStore.test.ts | 38 +++++++++++++++++++++ packages/shared/src/terminalThreads.test.ts | 25 ++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 packages/shared/src/terminalThreads.test.ts diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index e924a9848..923485632 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -361,4 +361,42 @@ describe("terminalStateStore actions", () => { }, ]); }); + + it("sets and persists terminal title overrides", () => { + const store = useTerminalStateStore.getState(); + store.setTerminalTitleOverride(THREAD_ID, "default", "My Build Terminal"); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + expect(terminalState.terminalTitleOverridesById).toEqual({ + default: "My Build Terminal", + }); + }); + + it("removes terminal title override when set to empty string", () => { + const store = useTerminalStateStore.getState(); + store.setTerminalTitleOverride(THREAD_ID, "default", "Custom Title"); + store.setTerminalTitleOverride(THREAD_ID, "default", ""); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + expect(terminalState.terminalTitleOverridesById).toEqual({}); + }); + + it("clears title override when terminal closes", () => { + const store = useTerminalStateStore.getState(); + store.newTerminal(THREAD_ID, "terminal-2"); + store.setTerminalTitleOverride(THREAD_ID, "terminal-2", "Custom Title"); + store.closeTerminal(THREAD_ID, "terminal-2"); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + expect(terminalState.terminalTitleOverridesById).toEqual({}); + }); }); diff --git a/packages/shared/src/terminalThreads.test.ts b/packages/shared/src/terminalThreads.test.ts new file mode 100644 index 000000000..75f7d8ed5 --- /dev/null +++ b/packages/shared/src/terminalThreads.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { MOUSE_REPORTING_RESET_SEQUENCE } from "./terminalThreads"; + +describe("MOUSE_REPORTING_RESET_SEQUENCE", () => { + it("disables basic mouse tracking (mode 1000)", () => { + expect(MOUSE_REPORTING_RESET_SEQUENCE).toContain("\u001b[?1000l"); + }); + + it("disables button-event tracking (mode 1002)", () => { + expect(MOUSE_REPORTING_RESET_SEQUENCE).toContain("\u001b[?1002l"); + }); + + it("disables any-event tracking (mode 1003)", () => { + expect(MOUSE_REPORTING_RESET_SEQUENCE).toContain("\u001b[?1003l"); + }); + + it("disables SGR mouse protocol (mode 1006)", () => { + expect(MOUSE_REPORTING_RESET_SEQUENCE).toContain("\u001b[?1006l"); + }); + + it("disables UTF-8 mouse protocol (mode 1015)", () => { + expect(MOUSE_REPORTING_RESET_SEQUENCE).toContain("\u001b[?1015l"); + }); +}); From 093c1b396231c9d48ab6d8f9b0d5dbae2b8f7bad Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 1 Jun 2026 16:08:06 -0400 Subject: [PATCH 03/31] Wire ChangedFilesTree to persist per-turn directory expansion state --- .../src/components/chat/ChangedFilesTree.tsx | 58 ++++- .../chat/ChangedFilesTree.uiState.test.ts | 207 ++++++++++++++++++ .../chat/ChangedFilesTree.uiState.ts | 97 ++++++++ 3 files changed, 351 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/chat/ChangedFilesTree.uiState.test.ts create mode 100644 apps/web/src/components/chat/ChangedFilesTree.uiState.ts diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 23ed26fb2..e862b1596 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -4,7 +4,7 @@ // Exports: ChangedFilesTree import { type TurnId } from "@jcode/contracts"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type TurnDiffFileChange } from "../../types"; import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; import { FolderIcon, FolderClosedIcon } from "~/lib/icons"; @@ -12,6 +12,12 @@ import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { FileEntryIcon } from "./FileEntryIcon"; import { DisclosureChevron } from "../ui/DisclosureChevron"; +import { + getExpandedDirectoryPathsForTurn, + persistChangedFilesUiState, + readChangedFilesUiState, + setExpandedDirectoryPathsForTurn, +} from "./ChangedFilesTree.uiState"; const CHANGED_FILE_ROW_SEPARATOR_CLASS = "border-t border-[color:var(--color-border-light)]/60 first:border-t-0"; @@ -37,19 +43,49 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { ), [allDirectoriesExpanded, directoryPathsKey], ); - const [expandedDirectories, setExpandedDirectories] = useState>(() => - buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true), - ); + const [expandedDirectories, setExpandedDirectories] = useState>(() => { + const directoryPaths = directoryPathsKey ? directoryPathsKey.split("\u0000") : []; + const persistedPaths = getExpandedDirectoryPathsForTurn(readChangedFilesUiState(), turnId); + const persistedSet = new Set(persistedPaths); + const state = buildDirectoryExpansionState(directoryPaths, true); + for (const path of directoryPaths) { + if (persistedSet.has(path)) { + state[path] = true; + } + } + return state; + }); useEffect(() => { - setExpandedDirectories(allDirectoryExpansionState); + setExpandedDirectories((current) => { + const next = { ...allDirectoryExpansionState }; + for (const path of Object.keys(current)) { + if (current[path] && path in next) { + next[path] = true; + } + } + return next; + }); }, [allDirectoryExpansionState]); - const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { - setExpandedDirectories((current) => ({ - ...current, - [pathValue]: !(current[pathValue] ?? fallbackExpanded), - })); - }, []); + const toggleDirectory = useCallback( + (pathValue: string, fallbackExpanded: boolean) => { + setExpandedDirectories((current) => { + const next = { + ...current, + [pathValue]: !(current[pathValue] ?? fallbackExpanded), + }; + const expandedPaths = Object.keys(next).filter((p) => next[p]); + const uiState = setExpandedDirectoryPathsForTurn( + readChangedFilesUiState(), + turnId, + expandedPaths, + ); + persistChangedFilesUiState(uiState); + return next; + }); + }, + [turnId], + ); const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { const leftPadding = 8 + depth * 14; diff --git a/apps/web/src/components/chat/ChangedFilesTree.uiState.test.ts b/apps/web/src/components/chat/ChangedFilesTree.uiState.test.ts new file mode 100644 index 000000000..6c88183e6 --- /dev/null +++ b/apps/web/src/components/chat/ChangedFilesTree.uiState.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + getExpandedDirectoryPathsForTurn, + persistChangedFilesUiState, + readChangedFilesUiState, + setExpandedDirectoryPathsForTurn, +} from "./ChangedFilesTree.uiState"; + +function installWindowWithStorage() { + vi.stubGlobal("window", { + localStorage: { + getItem: () => null, + setItem: () => {}, + }, + }); +} + +describe("readChangedFilesUiState", () => { + beforeEach(() => { + installWindowWithStorage(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns default state when localStorage is empty", () => { + expect(readChangedFilesUiState()).toEqual({ + expandedDirectoryPathsByTurnId: {}, + }); + }); + + it("returns persisted state when localStorage has data", () => { + vi.stubGlobal("window", { + localStorage: { + getItem: () => + JSON.stringify({ + expandedDirectoryPathsByTurnId: { + "turn-1": ["src/components"], + }, + }), + setItem: () => {}, + }, + }); + + expect(readChangedFilesUiState()).toEqual({ + expandedDirectoryPathsByTurnId: { + "turn-1": ["src/components"], + }, + }); + }); + + it("filters out invalid entries", () => { + vi.stubGlobal("window", { + localStorage: { + getItem: () => + JSON.stringify({ + expandedDirectoryPathsByTurnId: { + "turn-1": ["src/components"], + "": ["invalid"], + bad: [123, null], + }, + }), + setItem: () => {}, + }, + }); + + expect(readChangedFilesUiState()).toEqual({ + expandedDirectoryPathsByTurnId: { + "turn-1": ["src/components"], + }, + }); + }); + + it("returns default state when localStorage data is malformed", () => { + vi.stubGlobal("window", { + localStorage: { + getItem: () => "not-json", + setItem: () => {}, + }, + }); + + expect(readChangedFilesUiState()).toEqual({ + expandedDirectoryPathsByTurnId: {}, + }); + }); +}); + +describe("persistChangedFilesUiState", () => { + beforeEach(() => { + installWindowWithStorage(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("writes valid state to localStorage", () => { + const setItem = vi.fn(); + vi.stubGlobal("window", { + localStorage: { + getItem: () => null, + setItem, + }, + }); + + persistChangedFilesUiState({ + expandedDirectoryPathsByTurnId: { + "turn-1": ["src/components", "src/lib"], + }, + }); + + expect(setItem).toHaveBeenCalledOnce(); + const call = setItem.mock.calls[0]!; + const [key, value] = call; + expect(key).toBe("jcode:changed-files-ui:v1"); + expect(JSON.parse(value as string)).toEqual({ + expandedDirectoryPathsByTurnId: { + "turn-1": ["src/components", "src/lib"], + }, + }); + }); + + it("filters out empty entries when persisting", () => { + const setItem = vi.fn(); + vi.stubGlobal("window", { + localStorage: { + getItem: () => null, + setItem, + }, + }); + + persistChangedFilesUiState({ + expandedDirectoryPathsByTurnId: { + "turn-1": [], + "turn-2": ["src/components"], + }, + }); + + const [, value] = setItem.mock.calls[0]!; + expect(JSON.parse(value as string)).toEqual({ + expandedDirectoryPathsByTurnId: { + "turn-2": ["src/components"], + }, + }); + }); +}); + +describe("getExpandedDirectoryPathsForTurn", () => { + it("returns paths for a known turn", () => { + const state = { + expandedDirectoryPathsByTurnId: { + "turn-1": ["src/components"], + }, + }; + expect(getExpandedDirectoryPathsForTurn(state, "turn-1")).toEqual(["src/components"]); + }); + + it("returns empty array for an unknown turn", () => { + const state = { + expandedDirectoryPathsByTurnId: {}, + }; + expect(getExpandedDirectoryPathsForTurn(state, "turn-1")).toEqual([]); + }); +}); + +describe("setExpandedDirectoryPathsForTurn", () => { + it("adds paths for a turn", () => { + const state = { + expandedDirectoryPathsByTurnId: {}, + }; + const result = setExpandedDirectoryPathsForTurn(state, "turn-1", ["src/components"]); + expect(result).toEqual({ + expandedDirectoryPathsByTurnId: { + "turn-1": ["src/components"], + }, + }); + }); + + it("removes the turn entry when paths are empty", () => { + const state = { + expandedDirectoryPathsByTurnId: { + "turn-1": ["src/components"], + "turn-2": ["src/lib"], + }, + }; + const result = setExpandedDirectoryPathsForTurn(state, "turn-1", []); + expect(result).toEqual({ + expandedDirectoryPathsByTurnId: { + "turn-2": ["src/lib"], + }, + }); + }); + + it("filters out empty path strings", () => { + const state = { + expandedDirectoryPathsByTurnId: {}, + }; + const result = setExpandedDirectoryPathsForTurn(state, "turn-1", ["src/components", ""]); + expect(result).toEqual({ + expandedDirectoryPathsByTurnId: { + "turn-1": ["src/components"], + }, + }); + }); +}); diff --git a/apps/web/src/components/chat/ChangedFilesTree.uiState.ts b/apps/web/src/components/chat/ChangedFilesTree.uiState.ts new file mode 100644 index 000000000..892746603 --- /dev/null +++ b/apps/web/src/components/chat/ChangedFilesTree.uiState.ts @@ -0,0 +1,97 @@ +// FILE: ChangedFilesTree.uiState.ts +// Purpose: Persist per-turn directory expansion state for changed-files trees. +// Layer: Browser storage helper +// Exports: read/write helpers for changed-files tree expansion state. + +const CHANGED_FILES_UI_STATE_KEY = "jcode:changed-files-ui:v1"; + +export type ChangedFilesUiState = { + expandedDirectoryPathsByTurnId: Record; +}; + +const DEFAULT_CHANGED_FILES_UI_STATE: ChangedFilesUiState = { + expandedDirectoryPathsByTurnId: {}, +}; + +export function readChangedFilesUiState(): ChangedFilesUiState { + if (typeof window === "undefined") { + return DEFAULT_CHANGED_FILES_UI_STATE; + } + + try { + const raw = window.localStorage.getItem(CHANGED_FILES_UI_STATE_KEY); + if (!raw) { + return DEFAULT_CHANGED_FILES_UI_STATE; + } + + const parsed = JSON.parse(raw) as { + expandedDirectoryPathsByTurnId?: Record; + }; + + return { + expandedDirectoryPathsByTurnId: Object.fromEntries( + Object.entries(parsed.expandedDirectoryPathsByTurnId ?? {}).filter( + ([turnId, paths]) => + typeof turnId === "string" && + turnId.length > 0 && + Array.isArray(paths) && + paths.every((p): p is string => typeof p === "string" && p.length > 0), + ), + ), + }; + } catch { + return DEFAULT_CHANGED_FILES_UI_STATE; + } +} + +export function persistChangedFilesUiState(state: ChangedFilesUiState): void { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem( + CHANGED_FILES_UI_STATE_KEY, + JSON.stringify({ + expandedDirectoryPathsByTurnId: Object.fromEntries( + Object.entries(state.expandedDirectoryPathsByTurnId).filter( + ([turnId, paths]) => + typeof turnId === "string" && + turnId.length > 0 && + Array.isArray(paths) && + paths.length > 0 && + paths.every((p) => typeof p === "string" && p.length > 0), + ), + ), + }), + ); + } catch { + // Ignore storage errors so rendering keeps working when persistence is unavailable. + } +} + +export function getExpandedDirectoryPathsForTurn( + state: ChangedFilesUiState, + turnId: string, +): string[] { + return state.expandedDirectoryPathsByTurnId[turnId] ?? []; +} + +export function setExpandedDirectoryPathsForTurn( + state: ChangedFilesUiState, + turnId: string, + paths: string[], +): ChangedFilesUiState { + const nextPaths = paths.filter((p) => p.length > 0); + if (nextPaths.length === 0) { + const nextByTurnId = { ...state.expandedDirectoryPathsByTurnId }; + delete nextByTurnId[turnId]; + return { expandedDirectoryPathsByTurnId: nextByTurnId }; + } + return { + expandedDirectoryPathsByTurnId: { + ...state.expandedDirectoryPathsByTurnId, + [turnId]: nextPaths, + }, + }; +} From f5cbe99d0750b6a777c43428872de5f620b29ed9 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 1 Jun 2026 16:19:02 -0400 Subject: [PATCH 04/31] Wire terminal pane tab inline rename to store action --- apps/web/src/components/ChatView.tsx | 5 + .../src/components/ThreadTerminalDrawer.tsx | 3 + .../terminal/TerminalViewportPane.tsx | 93 ++++++++++++++++++- 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3a4c1d9ed..7eb46261b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1070,6 +1070,7 @@ export default function ChatView({ const storeSetTerminalWorkspaceTab = useTerminalStateStore((s) => s.setTerminalWorkspaceTab); const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); const storeSetTerminalMetadata = useTerminalStateStore((s) => s.setTerminalMetadata); + const storeSetTerminalTitleOverride = useTerminalStateStore((s) => s.setTerminalTitleOverride); const storeSetTerminalActivity = useTerminalStateStore((s) => s.setTerminalActivity); const storeSplitTerminalLeft = useTerminalStateStore((s) => s.splitTerminalLeft); const storeSplitTerminalRight = useTerminalStateStore((s) => s.splitTerminalRight); @@ -3397,6 +3398,10 @@ export default function ChatView({ if (!activeThreadId) return; storeSetTerminalActivity(activeThreadId, terminalId, activity); }, + onRenameTerminal: (terminalId: string, name: string) => { + if (!activeThreadId) return; + storeSetTerminalTitleOverride(activeThreadId, terminalId, name); + }, onAddTerminalContext: addTerminalContextToDraft, }), [ diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 5dec45ee0..e0a14cebf 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -435,6 +435,7 @@ interface ThreadTerminalDrawerProps { terminalId: string, activity: { hasRunningSubprocess: boolean; agentState: TerminalActivityState | null }, ) => void; + onRenameTerminal?: (terminalId: string, name: string) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; onTogglePresentationMode?: (() => void) | undefined; } @@ -473,6 +474,7 @@ export default function ThreadTerminalDrawer({ onResizeTerminalSplit, onTerminalMetadataChange, onTerminalActivityChange, + onRenameTerminal, onAddTerminalContext, onTogglePresentationMode, }: ThreadTerminalDrawerProps) { @@ -665,6 +667,7 @@ export default function ThreadTerminalDrawer({ } onMoveTerminalToGroup={isWorkspaceMode ? onMoveTerminalToGroup : undefined} onCloseTerminal={onCloseTerminal} + onRenameTerminal={onRenameTerminal} presentationMode={presentationMode} onTogglePresentationMode={onTogglePresentationMode} renderViewport={(terminalId, options) => ( diff --git a/apps/web/src/components/terminal/TerminalViewportPane.tsx b/apps/web/src/components/terminal/TerminalViewportPane.tsx index 7ef529168..2ff8aecc0 100644 --- a/apps/web/src/components/terminal/TerminalViewportPane.tsx +++ b/apps/web/src/components/terminal/TerminalViewportPane.tsx @@ -3,7 +3,7 @@ // Layer: Terminal presentation components // Depends on: caller-provided viewport renderer so xterm lifecycle can stay external. -import type { PointerEvent as ReactPointerEvent, ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import type { ResolvedTerminalVisualIdentity } from "@jcode/shared/terminalThreads"; @@ -45,6 +45,7 @@ interface TerminalViewportPaneProps { onNewTerminalTab?: ((terminalId: string) => void) | undefined; onMoveTerminalToGroup?: ((terminalId: string) => void) | undefined; onCloseTerminal?: ((terminalId: string) => void) | undefined; + onRenameTerminal?: ((terminalId: string, name: string) => void) | undefined; presentationMode: ThreadTerminalPresentationMode; onTogglePresentationMode?: (() => void) | undefined; } @@ -72,6 +73,83 @@ function canMoveTerminalToOwnGroup(node: ThreadTerminalLayoutNode, terminalId: s }); } +function InlineRenameField(props: { + initialValue: string; + onCommit: (value: string) => void; + onCancel: () => void; + className?: string | undefined; +}) { + const [value, setValue] = useState(props.initialValue); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.select(); + }, []); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + props.onCommit(value.trim()); + } else if (event.key === "Escape") { + props.onCancel(); + } + }, + [value, props], + ); + + const handleBlur = useCallback(() => { + props.onCommit(value.trim()); + }, [value, props]); + + return ( + setValue(event.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className={cn( + "bg-background px-1.5 py-0.5 text-[11px] leading-4 text-foreground outline-none ring-1 ring-inset ring-[var(--color-ring)]", + props.className, + )} + autoFocus + /> + ); +} + +function TerminalTabTitle(props: { + title: string; + onRename: (name: string) => void; + className?: string | undefined; +}) { + const [isEditing, setIsEditing] = useState(false); + + if (isEditing) { + return ( + { + setIsEditing(false); + props.onRename(value); + }} + onCancel={() => setIsEditing(false)} + className={props.className} + /> + ); + } + + return ( + setIsEditing(true)} + title="Double-click to rename" + > + {props.title} + + ); +} + function PaneActionButton(props: { label: string; onClick: () => void; @@ -110,6 +188,7 @@ export default function TerminalViewportPane({ onNewTerminalTab, onMoveTerminalToGroup, onCloseTerminal, + onRenameTerminal, presentationMode, onTogglePresentationMode, }: TerminalViewportPaneProps) { @@ -174,9 +253,15 @@ export default function TerminalViewportPane({ state={visualIdentity.state} /> ) : null} - - {visualIdentity?.title ?? "Terminal"} - + { + if (onRenameTerminal) { + onRenameTerminal(terminalId, name); + } + }} + className="max-w-40 truncate text-[11px] leading-4" + /> {onCloseTerminal ? ( + + + + + +
+
+

+ Plan +

+ +
+ + {quote ? ( +
+
+ + Selected + + +
+
+ {quote} +
+
+ ) : null} + +
+