diff --git a/.github/evidence/pr-1603-before-after.png b/.github/evidence/pr-1603-before-after.png new file mode 100644 index 000000000..f36bc9650 Binary files /dev/null and b/.github/evidence/pr-1603-before-after.png differ diff --git a/apps/code/package.json b/apps/code/package.json index 159dab795..b77cdb6b1 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -88,6 +88,7 @@ }, "dependencies": { "@base-ui/react": "^1.3.0", + "@chenglou/pretext": "^0.0.5", "@codemirror/lang-angular": "^0.1.4", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", diff --git a/apps/code/src/renderer/features/sessions/components/session-update/FileMentionChip.tsx b/apps/code/src/renderer/features/sessions/components/session-update/FileMentionChip.tsx index 8d29842fe..ce7a7c4b8 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/FileMentionChip.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/FileMentionChip.tsx @@ -3,13 +3,18 @@ import { usePanelLayoutStore } from "@features/panels"; import { useCwd } from "@features/sidebar/hooks/useCwd"; import { useTaskStore } from "@features/tasks/stores/taskStore"; import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; -import { Flex, Text } from "@radix-ui/themes"; +import { Flex, Text, Tooltip } from "@radix-ui/themes"; +import { prepare, layout } from "@chenglou/pretext"; import { trpcClient } from "@renderer/trpc/client"; import { handleExternalAppAction } from "@utils/handleExternalAppAction"; import { isAbsolutePath } from "@utils/path"; -import { memo, useCallback } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; import { getFilename } from "./toolCallUtils"; +const FONT = + '12px ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace'; +const LINE_HEIGHT = 16; + interface FileMentionChipProps { filePath: string; } @@ -29,6 +34,56 @@ function toRelativePath(absolutePath: string, repoPath: string | null): string { return absolutePath; } +function fitsInOneLine(text: string, maxWidth: number): boolean { + try { + const prepared = prepare(text, FONT); + const { lineCount } = layout(prepared, maxWidth, LINE_HEIGHT); + return lineCount <= 1; + } catch { + return true; + } +} + +/** + * Middle-truncate a path, always breaking between slashes. + * Keeps first segment(s) + filename, replaces middle with "..." + * e.g. "a/b/c/d/file.tsx" → "a/.../d/file.tsx" + */ +function middleTruncatePath( + path: string, + maxWidth: number, +): { display: string; isTruncated: boolean } { + if (fitsInOneLine(path, maxWidth)) { + return { display: path, isTruncated: false }; + } + + const parts = path.split("/"); + const filename = parts[parts.length - 1]; + + // Try keeping first N segments + last M segments, with "..." between + // Prefer keeping more leading segments (left-to-right preference) + for (let totalRemove = 1; totalRemove < parts.length - 1; totalRemove++) { + for ( + let keepLeading = 1; + keepLeading <= parts.length - totalRemove - 1; + keepLeading++ + ) { + const candidate = [ + ...parts.slice(0, keepLeading), + "...", + ...parts.slice(keepLeading + totalRemove), + ].join("/"); + + if (fitsInOneLine(candidate, maxWidth)) { + return { display: candidate, isTruncated: true }; + } + } + } + + // Last resort: just ".../filename" + return { display: `.../${filename}`, isTruncated: true }; +} + export const FileMentionChip = memo(function FileMentionChip({ filePath, }: FileMentionChipProps) { @@ -36,14 +91,32 @@ export const FileMentionChip = memo(function FileMentionChip({ const repoPath = useCwd(taskId ?? ""); const workspace = useWorkspace(taskId ?? undefined); const openFileInSplit = usePanelLayoutStore((s) => s.openFileInSplit); + const [containerWidth, setContainerWidth] = useState(null); const filename = getFilename(filePath); const mainRepoPath = workspace?.folderPath; + const measuredRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + const available = node.parentElement?.clientWidth ?? 0; + // Account for icon (~16px) and gap (4px) + setContainerWidth(Math.max(available - 20, 60)); + } + }, []); + + const relativePath = toRelativePath(filePath, repoPath ?? null); + + const truncated = useMemo(() => { + if (!containerWidth || !relativePath) { + return { display: relativePath || filename, isTruncated: false }; + } + return middleTruncatePath(relativePath, containerWidth); + }, [relativePath, filename, containerWidth]); + const handleClick = useCallback(() => { if (!taskId) return; - const relativePath = toRelativePath(filePath, repoPath ?? null); - openFileInSplit(taskId, relativePath, true); + const relPath = toRelativePath(filePath, repoPath ?? null); + openFileInSplit(taskId, relPath, true); }, [taskId, filePath, repoPath, openFileInSplit]); const handleContextMenu = useCallback( @@ -76,19 +149,26 @@ export const FileMentionChip = memo(function FileMentionChip({ const isClickable = !!taskId; - return ( + const content = ( - {filename} + {truncated.display} ); + + if (truncated.isTruncated) { + return {content}; + } + + return content; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33bbe2522..31fee2a28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: '@base-ui/react': specifier: ^1.3.0 version: 1.3.0(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@chenglou/pretext': + specifier: ^0.0.5 + version: 0.0.5 '@codemirror/lang-angular': specifier: ^0.1.4 version: 0.1.4 @@ -1463,6 +1466,9 @@ packages: cpu: [x64] os: [win32] + '@chenglou/pretext@0.0.5': + resolution: {integrity: sha512-A8GZN10REdFGsyuiUgLV8jjPDDFMg5GmgxGWV0I3igxBOnzj+jgz2VMmVD7g+SFyoctfeqHFxbNatKSzVRWtRg==} + '@codemirror/autocomplete@6.20.0': resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} @@ -12171,6 +12177,8 @@ snapshots: '@biomejs/cli-win32-x64@2.2.4': optional: true + '@chenglou/pretext@0.0.5': {} + '@codemirror/autocomplete@6.20.0': dependencies: '@codemirror/language': 6.12.2