From 5a8e24ccbe29e9d69f5bd32f95594caaede718f4 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 1 Sep 2026 12:57:29 -0700 Subject: [PATCH] Extract the Claude newline sequence and the session display name --- app/src/components/layout/MainTabs.tsx | 18 +++----- app/src/components/terminal/TerminalView.tsx | 3 +- app/src/lib/claudeInput.test.ts | 29 +++++++++++++ app/src/lib/claudeInput.ts | 29 +++++++++++++ app/src/lib/sessionName.test.ts | 45 ++++++++++++++++++++ app/src/lib/sessionName.ts | 27 ++++++++++++ 6 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 app/src/lib/claudeInput.test.ts create mode 100644 app/src/lib/claudeInput.ts create mode 100644 app/src/lib/sessionName.test.ts create mode 100644 app/src/lib/sessionName.ts diff --git a/app/src/components/layout/MainTabs.tsx b/app/src/components/layout/MainTabs.tsx index 0ac7287..ef90553 100644 --- a/app/src/components/layout/MainTabs.tsx +++ b/app/src/components/layout/MainTabs.tsx @@ -10,6 +10,7 @@ import { } from "../../store/appState"; import { effectivePermissionMode } from "../projects/PermissionModeControl"; import { ProjectStatusIndicator } from "../ui/StatusIndicator"; +import { sessionDisplayName } from "../../lib/sessionName"; import type { PermissionMode } from "../../lib/types"; interface ContextMenuState { @@ -195,11 +196,10 @@ export default function MainTabs() { } const session = sessions.find((s) => s.id === tabKeyId(key)); if (!session) return ""; - const custom = getCustomName(session.projectId, session.id); - return custom - ? `${session.projectName}: ${custom}` - : (session.sessionName ?? session.projectName) + - (session.sessionType === "bash" ? " (bash)" : ""); + return sessionDisplayName( + session, + projects.find((p) => p.id === session.projectId), + ); }; const endDrag = () => { @@ -358,13 +358,7 @@ export default function MainTabs() { const session = sessions.find((s) => s.id === sessionId); if (!session) return null; const project = projects.find((p) => p.id === session.projectId); - const customName = getCustomName(session.projectId, session.id); - const baseLabel = - (session.sessionName ?? session.projectName) + - (session.sessionType === "bash" ? " (bash)" : ""); - const displayLabel = customName - ? `${session.projectName}: ${customName}` - : baseLabel; + const displayLabel = sessionDisplayName(session, project); const isRenaming = renamingId === session.id; const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null; diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index eb658bd..1111ec6 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -7,6 +7,7 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import "@xterm/xterm/css/xterm.css"; import { useTerminal } from "../../hooks/useTerminal"; import { useAppState } from "../../store/appState"; +import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput"; import { awsSsoRefresh, openPageInContainerBrowser, @@ -415,7 +416,7 @@ export default function TerminalView({ sessionId, active }: Props) { !event.isComposing && sessionTypeRef.current === "claude" ) { - sendInput(sessionId, "\x1b\r"); + sendInput(sessionId, CLAUDE_SOFT_NEWLINE); // **`preventDefault()` is what stops the submit, not the `return false`.** // // xterm's `_keyDown` returns the instant a custom handler says `false` diff --git a/app/src/lib/claudeInput.test.ts b/app/src/lib/claudeInput.test.ts new file mode 100644 index 0000000..4eaaaf7 --- /dev/null +++ b/app/src/lib/claudeInput.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { CLAUDE_SOFT_NEWLINE, toClaudePayload } from "./claudeInput"; + +describe("toClaudePayload", () => { + it("is ESC+CR, the sequence Claude Code's own /terminal-setup installs", () => { + expect(CLAUDE_SOFT_NEWLINE).toBe("\x1b\r"); + }); + + it("replaces every newline so the note arrives as one prompt", () => { + // Typed raw, each \n submits — the note would arrive as three truncated + // messages instead of one. + expect(toClaudePayload("one\ntwo\nthree")).toBe("one\x1b\rtwo\x1b\rthree"); + }); + + it("normalises CRLF, which is what a paste from Windows carries", () => { + expect(toClaudePayload("one\r\ntwo")).toBe("one\x1b\rtwo"); + }); + + it("leaves single-line text untouched", () => { + expect(toClaudePayload("just one line")).toBe("just one line"); + }); + + it("never appends a terminator", () => { + // The note lands in the prompt unsubmitted; the user presses Enter. An + // unsent prompt is recoverable, a sent one is not. + expect(toClaudePayload("text").endsWith("\r")).toBe(false); + expect(toClaudePayload("text\n")).toBe("text\x1b\r"); + }); +}); diff --git a/app/src/lib/claudeInput.ts b/app/src/lib/claudeInput.ts new file mode 100644 index 0000000..a4761ff --- /dev/null +++ b/app/src/lib/claudeInput.ts @@ -0,0 +1,29 @@ +/** + * The bytes that insert a newline in Claude Code's prompt without submitting + * it: ESC then CR. + * + * These are the in-band bytes, not a guess — they are exactly what Claude + * Code's own `/terminal-setup` writes into the VS Code, Cursor, Alacritty and + * Zed keymaps, and `TerminalView`'s Shift+Enter handler has sent them since + * that feature landed. **This must not be "simplified" to `\n`:** Claude Code + * accepts `\n` too, but a shell would *run* the line, so the two session types + * would quietly diverge. + * + * That last sentence is also why anything sending this must first check the + * session is a Claude one. `bash -l`'s readline has no binding for `\e\r` and + * answers with a bell. + */ +export const CLAUDE_SOFT_NEWLINE = "\x1b\r"; + +/** + * Turn multi-line text into something that arrives in a Claude prompt as one + * message. + * + * Sent as raw keystrokes, every `\n` submits, so an N-line note would arrive + * as N truncated prompts. Deliberately appends no terminator: the text lands + * in the prompt and the user presses Enter, which is what speech-to-text does + * for the same reason — an unsent prompt is recoverable and a sent one is not. + */ +export function toClaudePayload(text: string): string { + return text.replace(/\r?\n/g, CLAUDE_SOFT_NEWLINE); +} diff --git a/app/src/lib/sessionName.test.ts b/app/src/lib/sessionName.test.ts new file mode 100644 index 0000000..fab2002 --- /dev/null +++ b/app/src/lib/sessionName.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { sessionDisplayName } from "./sessionName"; +import type { Project, TerminalSession } from "./types"; + +const session = (over: Partial = {}): TerminalSession => ({ + id: "s1", + projectId: "p1", + projectName: "api", + sessionType: "claude", + sessionName: null, + ...over, +}); + +const project = (renamed: Record = {}) => + ({ id: "p1", name: "api", renamed_session_names: renamed }) as unknown as Project; + +describe("sessionDisplayName", () => { + it("prefers a user-set custom name, prefixed with the project", () => { + expect(sessionDisplayName(session(), project({ s1: "release work" }))).toBe( + "api: release work", + ); + }); + + it("falls back to the session name when there is no custom one", () => { + expect(sessionDisplayName(session({ sessionName: "review" }), project())).toBe("review"); + }); + + it("falls back to the project name when there is no session name", () => { + expect(sessionDisplayName(session(), project())).toBe("api"); + }); + + it("marks bash sessions", () => { + expect(sessionDisplayName(session({ sessionType: "bash" }), project())).toBe("api (bash)"); + }); + + it("works with no project, which is how a closing tab renders", () => { + expect(sessionDisplayName(session())).toBe("api"); + }); + + it("does not mark bash when a custom name is set, matching the existing rule", () => { + expect( + sessionDisplayName(session({ sessionType: "bash" }), project({ s1: "logs" })), + ).toBe("api: logs"); + }); +}); diff --git a/app/src/lib/sessionName.ts b/app/src/lib/sessionName.ts new file mode 100644 index 0000000..6b11544 --- /dev/null +++ b/app/src/lib/sessionName.ts @@ -0,0 +1,27 @@ +import type { Project, TerminalSession } from "./types"; + +/** + * What a terminal session is called on screen. + * + * The rule used to be written twice inside `MainTabs.tsx` — once in `tabLabel` + * for the drag ghost, once inline in `renderTab` — both local and neither + * exported, so the two could disagree the moment either was edited. It is here + * because a third caller (the note send-target picker) would have made that + * three. + * + * A user-set name wins and is prefixed with the project, because a custom name + * is usually about the work rather than the project and needs the context. The + * `(bash)` marker only appears on the fallback: a session someone bothered to + * name does not need to be told apart from its neighbours. + */ +export function sessionDisplayName( + session: TerminalSession, + project?: Project, +): string { + const custom = project?.renamed_session_names?.[session.id]; + if (custom) return `${session.projectName}: ${custom}`; + return ( + (session.sessionName ?? session.projectName) + + (session.sessionType === "bash" ? " (bash)" : "") + ); +}