Extract the Claude newline sequence and the session display name
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../../store/appState";
|
} from "../../store/appState";
|
||||||
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
||||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
|
import { sessionDisplayName } from "../../lib/sessionName";
|
||||||
import type { PermissionMode } from "../../lib/types";
|
import type { PermissionMode } from "../../lib/types";
|
||||||
|
|
||||||
interface ContextMenuState {
|
interface ContextMenuState {
|
||||||
@@ -195,11 +196,10 @@ export default function MainTabs() {
|
|||||||
}
|
}
|
||||||
const session = sessions.find((s) => s.id === tabKeyId(key));
|
const session = sessions.find((s) => s.id === tabKeyId(key));
|
||||||
if (!session) return "";
|
if (!session) return "";
|
||||||
const custom = getCustomName(session.projectId, session.id);
|
return sessionDisplayName(
|
||||||
return custom
|
session,
|
||||||
? `${session.projectName}: ${custom}`
|
projects.find((p) => p.id === session.projectId),
|
||||||
: (session.sessionName ?? session.projectName) +
|
);
|
||||||
(session.sessionType === "bash" ? " (bash)" : "");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const endDrag = () => {
|
const endDrag = () => {
|
||||||
@@ -358,13 +358,7 @@ export default function MainTabs() {
|
|||||||
const session = sessions.find((s) => s.id === sessionId);
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
const project = projects.find((p) => p.id === session.projectId);
|
const project = projects.find((p) => p.id === session.projectId);
|
||||||
const customName = getCustomName(session.projectId, session.id);
|
const displayLabel = sessionDisplayName(session, project);
|
||||||
const baseLabel =
|
|
||||||
(session.sessionName ?? session.projectName) +
|
|
||||||
(session.sessionType === "bash" ? " (bash)" : "");
|
|
||||||
const displayLabel = customName
|
|
||||||
? `${session.projectName}: ${customName}`
|
|
||||||
: baseLabel;
|
|
||||||
const isRenaming = renamingId === session.id;
|
const isRenaming = renamingId === session.id;
|
||||||
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
|
||||||
import {
|
import {
|
||||||
awsSsoRefresh,
|
awsSsoRefresh,
|
||||||
openPageInContainerBrowser,
|
openPageInContainerBrowser,
|
||||||
@@ -415,7 +416,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
!event.isComposing &&
|
!event.isComposing &&
|
||||||
sessionTypeRef.current === "claude"
|
sessionTypeRef.current === "claude"
|
||||||
) {
|
) {
|
||||||
sendInput(sessionId, "\x1b\r");
|
sendInput(sessionId, CLAUDE_SOFT_NEWLINE);
|
||||||
// **`preventDefault()` is what stops the submit, not the `return false`.**
|
// **`preventDefault()` is what stops the submit, not the `return false`.**
|
||||||
//
|
//
|
||||||
// xterm's `_keyDown` returns the instant a custom handler says `false`
|
// xterm's `_keyDown` returns the instant a custom handler says `false`
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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> = {}): TerminalSession => ({
|
||||||
|
id: "s1",
|
||||||
|
projectId: "p1",
|
||||||
|
projectName: "api",
|
||||||
|
sessionType: "claude",
|
||||||
|
sessionName: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = (renamed: Record<string, string> = {}) =>
|
||||||
|
({ 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)" : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user