Skip to content
Closed
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
58 changes: 58 additions & 0 deletions apps/server/src/terminal/terminalThreadTitle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";

import {
consumeTerminalThreadTitleInput,
deriveTerminalThreadTitleFromCommand,
isGenericTerminalThreadTitle,
} from "./terminalThreadTitle";
import { TerminalThreadTitleTracker } from "./terminalThreadTitleTracker";

describe("terminalThreadTitle", () => {
it("recognizes the generic terminal placeholder title", () => {
expect(isGenericTerminalThreadTitle("New terminal")).toBe(true);
expect(isGenericTerminalThreadTitle("git push")).toBe(false);
});

it("derives CLI-focused labels from submitted commands", () => {
expect(deriveTerminalThreadTitleFromCommand("codex --model gpt-5.4")).toBe("Codex CLI");
expect(deriveTerminalThreadTitleFromCommand("claude code")).toBe("Claude Code");
expect(deriveTerminalThreadTitleFromCommand("git push origin main")).toBe("git push");
expect(deriveTerminalThreadTitleFromCommand("npm run dev -- --token secret")).toBe(
"npm run dev",
);
});

it("drops blank and low-signal shell commands", () => {
expect(deriveTerminalThreadTitleFromCommand(" ")).toBeNull();
expect(deriveTerminalThreadTitleFromCommand("cd /repo/project")).toBeNull();
});

it("buffers terminal input until Enter and emits a sanitized title once submitted", () => {
const firstChunk = consumeTerminalThreadTitleInput("", "git pu");
expect(firstChunk).toEqual({ buffer: "git pu", title: null });

const secondChunk = consumeTerminalThreadTitleInput(firstChunk.buffer, "sh origin main\r");
expect(secondChunk).toEqual({ buffer: "", title: "git push" });
});

it("only emits tracked titles while the thread still has the generic placeholder", () => {
const tracker = new TerminalThreadTitleTracker();

expect(
tracker.consumeWrite({
currentTitle: "New terminal",
data: "codex --model gpt-5.4\r",
terminalId: "default",
threadId: "thread-1",
}),
).toBe("Codex CLI");
expect(
tracker.consumeWrite({
currentTitle: "Manual rename",
data: "git push origin main\r",
terminalId: "default",
threadId: "thread-1",
}),
).toBeNull();
});
});
14 changes: 14 additions & 0 deletions apps/server/src/terminal/terminalThreadTitle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// FILE: terminalThreadTitle.ts
// Purpose: Server-facing aliases around the shared terminal title parser.
// Layer: Server terminal helper
// Exports: generic-title checks plus incremental command parsing for thread renames.

export {
GENERIC_TERMINAL_THREAD_TITLE,
consumeTerminalIdentityInput as consumeTerminalThreadIdentityInput,
consumeTerminalTitleInput as consumeTerminalThreadTitleInput,
deriveTerminalCommandIdentity as deriveTerminalThreadCommandIdentity,
deriveTerminalTitleFromCommand as deriveTerminalThreadTitleFromCommand,
isGenericTerminalThreadTitle,
resolveTerminalVisualIdentity,
} from "@t3tools/shared/terminalThreads";
54 changes: 54 additions & 0 deletions apps/server/src/terminal/terminalThreadTitleTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// FILE: terminalThreadTitleTracker.ts
// Purpose: Tracks per-terminal input buffers and emits safe one-shot thread title updates.
// Layer: Server terminal metadata helper
// Exports: TerminalThreadTitleTracker

import {
consumeTerminalThreadTitleInput,
isGenericTerminalThreadTitle,
} from "./terminalThreadTitle";

function terminalTitleSessionKey(threadId: string, terminalId: string): string {
return `${threadId}:${terminalId}`;
}

export class TerminalThreadTitleTracker {
private readonly bufferBySession = new Map<string, string>();

reset(threadId: string, terminalId?: string | null): void {
if (terminalId && terminalId.length > 0) {
this.bufferBySession.delete(terminalTitleSessionKey(threadId, terminalId));
return;
}
for (const key of Array.from(this.bufferBySession.keys())) {
if (key.startsWith(`${threadId}:`)) {
this.bufferBySession.delete(key);
}
}
}

// Returns a safe title only when a submitted command should rename a generic thread.
consumeWrite(input: {
currentTitle: string | null | undefined;
data: string;
terminalId: string;
threadId: string;
}): string | null {
const sessionKey = terminalTitleSessionKey(input.threadId, input.terminalId);
const nextInputState = consumeTerminalThreadTitleInput(
this.bufferBySession.get(sessionKey) ?? "",
input.data,
);

if (nextInputState.buffer.length > 0) {
this.bufferBySession.set(sessionKey, nextInputState.buffer);
} else {
this.bufferBySession.delete(sessionKey);
}

if (!nextInputState.title || !isGenericTerminalThreadTitle(input.currentTitle)) {
return null;
}
return nextInputState.title;
}
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.0",
"@vitest/browser-playwright": "^4.0.18",
"@xterm/addon-search": "^0.16.0",
"babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
"msw": "2.12.11",
"playwright": "^1.58.2",
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1769,7 +1769,19 @@ describe("ChatView timeline estimator parity (full app)", () => {
terminalIds: ["default"],
runningTerminalIds: [],
activeTerminalId: "default",
terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
terminalGroups: [
{
id: "group-default",
terminalIds: ["default"],
activeTerminalId: "default",
layout: {
type: "terminal" as const,
paneId: "pane-default",
terminalIds: ["default"],
activeTerminalId: "default",
},
},
],
activeTerminalGroupId: "group-default",
},
},
Expand Down
78 changes: 78 additions & 0 deletions apps/web/src/components/TerminalScrollToBottom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Terminal } from "@xterm/xterm";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "~/lib/utils";

interface TerminalScrollToBottomProps {
terminal: Terminal | null;
}

export function TerminalScrollToBottom({ terminal }: TerminalScrollToBottomProps) {
const [isVisible, setIsVisible] = useState(false);
const visibilityRafRef = useRef<number | null>(null);

const checkPosition = useCallback(() => {
if (!terminal) return;
const buf = terminal.buffer.active;
const nextVisible = buf.viewportY < buf.baseY;
setIsVisible((current) => (current === nextVisible ? current : nextVisible));
}, [terminal]);

const scheduleVisibilityCheck = useCallback(() => {
if (visibilityRafRef.current !== null) {
return;
}
visibilityRafRef.current = window.requestAnimationFrame(() => {
visibilityRafRef.current = null;
checkPosition();
});
}, [checkPosition]);

useEffect(() => {
if (!terminal) {
setIsVisible(false);
return;
}
scheduleVisibilityCheck();
const d1 = terminal.onWriteParsed(scheduleVisibilityCheck);
const d2 = terminal.onScroll(scheduleVisibilityCheck);
return () => {
if (visibilityRafRef.current !== null) {
window.cancelAnimationFrame(visibilityRafRef.current);
visibilityRafRef.current = null;
}
d1.dispose();
d2.dispose();
};
}, [terminal, scheduleVisibilityCheck]);

const handleClick = () => terminal?.scrollToBottom();

return (
<div
className={cn(
"absolute bottom-4 left-1/2 z-10 -translate-x-1/2 transition-all duration-200",
isVisible ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-2 opacity-0",
)}
>
<button
type="button"
onClick={handleClick}
className="flex size-7 items-center justify-center rounded-full border border-border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
aria-label="Scroll to bottom"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="size-3.5"
>
<path
fillRule="evenodd"
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
);
}
Loading
Loading