diff --git a/docs/e2e-coverage.md b/docs/e2e-coverage.md index 635b3baf..c9981341 100644 --- a/docs/e2e-coverage.md +++ b/docs/e2e-coverage.md @@ -192,6 +192,7 @@ until `make e2e` is green and this file reflects it. | ✅ Windowless mode | Close backgrounds without killing the task; panes collapse to zero geometry; agent output keeps flowing; `raise` restores window + panes | `app.e2e.ts` | | ✅ Agent race | Fire one prompt at 2 agents: cohort recorded, both spawn a PTY + receive the prompt (lastInputAt) + drive a fakeagent OSC title; runs on a no-remote repo; RaceDialog gates Start then steppers+prompt launch it; a name collision surfaces an error and records no new race | `task.e2e.ts` | | ✅ Preferences | Sandbox default, editor font, terminal font setters | `settings.e2e.ts` | +| ✅ Shift+Enter newline | A raw-mode zero-token OpenCode-derived fixture receives LF without submitting or losing the draft; other agents retain backslash + CR; ordinary Enter submits both lines | `terminal-newline.e2e.ts` | | ✅ Agent extras | YOLO toggle; aux (bottom) terminal | `agent.e2e.ts` | | ✅ Worktree task | Create a task on its own worktree branch (not repo-root); on a no-remote repo the default-base create falls back to local main | `task.e2e.ts` | | ✅ Project rename | Rename a project (add covered too) | `projects.e2e.ts` | diff --git a/docs/ui.md b/docs/ui.md index e5b43fca..6fb76518 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -25,6 +25,12 @@ The bands are what the app looks like and runs, then how it behaves while you wo Appearance carries its own sub-tabs (Terminal, Editor, Interface & Sidebar) on the strip Settings → Projects uses. Terminal leads. +Shift+Enter in task terminals sends LF (Ctrl+J, OpenCode's `input.newline` +binding) for OpenCode and agents extending it; other agents retain +backslash + CR. This is automatic, with no user preference. Enter is +unchanged. The scratch shell and settings preview keep their ordinary +xterm keyboard behavior. + A per-project page carries four of those sub-tabs: Scripts & run (Members & scripts on a multi-repo project), Sandbox, Code navigation and More. Code navigation is named by `codeIntelName()`, so it reads Code intelligence once type checking is on, exactly like the panel it opens. It earned a tab rather than sitting under Scripts & run, where it started: it shares nothing with the setup/run/archive scripts that tab exists for, it is the size of a page on its own (arming, languages, the server picker, per-language settings), and it is machine-local `projects.json` while that tab's storage strip is switching between personal and the committed `.termic.yaml`. Its live preview is a real `AuxTerminal`, so it is click-armed: a settings visit must never fork a shell on its own, and the pty dies when the tab unmounts. Each page owns one domain, and a setting belongs to the page whose domain it changes, not the page that happened to be open when it was written. General is app-level only (repos directory, personal file-tree excludes, remote images in the markdown preview); it is deliberately short. A new setting that needs a fifth thing on General is a sign the domain wants its own rail item. diff --git a/e2e/specs/terminal-newline.e2e.ts b/e2e/specs/terminal-newline.e2e.ts new file mode 100644 index 00000000..840d921e --- /dev/null +++ b/e2e/specs/terminal-newline.e2e.ts @@ -0,0 +1,81 @@ +import path from "node:path"; +import { archiveTask, openTask, submitToAgent, typeIntoAgent, waitForAgentReady, waitForAppShell } from "../helpers"; + +type InputState = { draft: string; submitted: string; submits: number; bytes: number[] }; +const agentIds = ["e2e-newline-opencode", "e2e-newline-claude"]; + +describe("terminal Shift+Enter", () => { + before(async () => { + await waitForAppShell(); + await browser.execute(async (command, script, ids) => { + const t = window.__termic!; + await t.ipc.agentsSave([ + ...t.useApp.getState().agents.filter((a: { id: string }) => !ids.includes(a.id)), + ...ids.map((id: string) => ({ id, display_name: "Newline fixture", command, args: [script], + extends: id.endsWith("opencode") ? "opencode" : "claude", builtin: false, icon_id: "lucide:terminal", + color: "#9aa0a6", capabilities: {}, sandbox_allowed_paths: [], + // Do not inherit OpenCode's session-list probe on fixture exit. + post_launch_capture: { command: "true" } })), + ]); + await t.useApp.getState().loadAll(); + }, process.execPath, path.resolve("scripts/fake-newline-agent.mjs"), agentIds); + }); + + after(async () => { + // Sweep by name too if setup created the task but failed before returning + // its id. Never leave a live fixture behind in the shared profile. + const ids = await browser.execute(() => window.__termic!.useApp.getState().tasks + .filter((t: { name: string }) => t.name.startsWith("e2e-terminal-newline")) + .map((t: { id: string }) => t.id)); + for (const id of ids) await archiveTask(id); + await browser.execute(async ids => { + const t = window.__termic!; + await t.ipc.agentsSave(t.useApp.getState().agents.filter((a: { id: string }) => !ids.includes(a.id))); + await t.useApp.getState().loadAll(); + }, agentIds); + }); + + for (const base of ["opencode", "claude"]) { + describe(`${base}-derived agent`, () => { + let taskId: string; + before(async () => { + taskId = await openTask(`e2e-terminal-newline-${base}`, true, `e2e-newline-${base}`); + await waitForAgentReady(taskId); + }); + + const state = () => browser.execute(id => { + const title = window.__termic!.useApp.getState().tabs[id]?.[0]?.liveTitle ?? ""; + return title.startsWith("newline:") ? JSON.parse(title.slice(8)) : null; + }, taskId) as Promise; + + it("inserts a newline without submitting or losing the draft", async () => { + await typeIntoAgent(taskId, "first"); + await browser.waitUntil(async () => (await state())?.draft === "first"); + + const prevented = await browser.execute(id => { + const ta = document.querySelector( + `[data-task-id="${id}"] [data-terminal-host] .xterm-helper-textarea`, + )!; + ta.focus(); + const key = { key: "Enter", code: "Enter", keyCode: 13, shiftKey: true, bubbles: true, cancelable: true }; + const allowed = ta.dispatchEvent(new KeyboardEvent("keydown", key)); + ta.dispatchEvent(new KeyboardEvent("keyup", key)); + return !allowed; + }, taskId); + expect(prevented).toBe(true); + await browser.waitUntil(async () => (await state())?.draft === "first\n"); + expect(await state()).toEqual({ + draft: "first\n", submitted: "", submits: 0, + bytes: [102, 105, 114, 115, 116, ...(base === "opencode" ? [0x0a] : [0x5c, 0x0d])], + }); + expect(await browser.execute(id => window.__termic!.useApp.getState().tabs[id]?.[0]?.composing, taskId)).toBe(true); + }); + + it("still submits both lines with ordinary Enter", async () => { + await submitToAgent(taskId, "second"); + await browser.waitUntil(async () => (await state())?.submits === 1); + expect((await state())?.submitted).toBe("first\nsecond"); + }); + }); + } +}); diff --git a/scripts/fake-newline-agent.mjs b/scripts/fake-newline-agent.mjs new file mode 100644 index 00000000..ed3b7057 --- /dev/null +++ b/scripts/fake-newline-agent.mjs @@ -0,0 +1,36 @@ +// Zero-token raw-mode fixture. Reports exactly which input bytes arrived, +// and models LF / backslash-return as newlines and plain CR as submit. +process.stdin.setRawMode(true); +process.stdin.resume(); +let draft = ""; +let submitted = ""; +let submits = 0; +let bytes = []; + +function report() { + const state = JSON.stringify({ draft, submitted, submits, bytes }); + process.stdout.write(`\x1b]0;newline:${state}\x1b\\`); +} + +process.stdin.on("data", chunk => { + for (const byte of chunk) { + if (byte === 0x03) process.exit(0); + if (byte === 0x15) { + draft = ""; + submitted = ""; + submits = 0; + bytes = []; + continue; + } + bytes.push(byte); + if (byte === 0x0a) draft += "\n"; + else if (byte === 0x0d && draft.endsWith("\\")) draft = draft.slice(0, -1) + "\n"; + else if (byte === 0x0d) { + submitted = draft; + draft = ""; + submits++; + } else draft += String.fromCharCode(byte); + } + report(); +}); +report(); diff --git a/src/components/task/TerminalPane.tsx b/src/components/task/TerminalPane.tsx index cdcb2917..0e00cdc2 100644 --- a/src/components/task/TerminalPane.tsx +++ b/src/components/task/TerminalPane.tsx @@ -33,6 +33,7 @@ import { preserveImagesOnErase } from "@/lib/terminalImagePersistence"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { SearchAddon } from "@xterm/addon-search"; import { loadTerminalRenderer, awaitTerminalFonts } from "@/lib/terminalRenderer"; +import { terminalNewlineBytes } from "@/lib/terminalNewline"; import { resyncViewportAfterReveal } from "@/lib/xtermViewportSync"; import { IS_MAC, bindingMatches, isAppChordInTerminal, superIsHeld, terminalPassesToApp, type ShortcutId } from "@/lib/shortcuts"; import { registerTerminalDropTarget } from "@/lib/terminalDrop"; @@ -1359,6 +1360,10 @@ const captureArmedRef = useRef(false); // xterm.js by default sends plain \r for BOTH Enter and Shift+Enter, // so without an override the agent can't tell them apart. // + // OpenCode uses its Ctrl+J newline binding (LF); other agents keep + // backslash + CR. Resolve the base agent so OpenCode clones work too. + // + // For the other agents: // We send `\\` + `\r` (literal backslash then CR) — the same // sequence `/terminal-setup` writes into iTerm2 for Claude Code. // Claude's input parser reads the trailing backslash as a @@ -1511,9 +1516,11 @@ const captureArmedRef = useRef(false); if (e.type === "keydown" && e.shiftKey && isEnter && !e.altKey && !e.ctrlKey && !e.metaKey) { const pid = ptyRef.current; if (pid) { + // For non-OpenCode agents: // `\` + `\r` (0x5c 0x0d) — claude's `hasUsedBackslashReturn` // path. Gemini + codex accept it too. - ipc.ptyWrite(pid, [0x5c, 0x0d]).catch(() => {}); + const baseId = builtinBaseId(tab.cli, useApp.getState().agents); + ipc.ptyWrite(pid, terminalNewlineBytes(baseId)).catch(() => {}); } // BOTH stops are required: // - return false → xterm.js skips its keydown handler diff --git a/src/lib/terminalNewline.test.ts b/src/lib/terminalNewline.test.ts new file mode 100644 index 00000000..5554981e --- /dev/null +++ b/src/lib/terminalNewline.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { terminalNewlineBytes } from "./terminalNewline"; + +describe("terminal Shift+Enter", () => { + it("uses OpenCode's newline binding without a backslash or submit CR", () => { + expect(terminalNewlineBytes("opencode")).toEqual([0x0a]); + }); + + it.each(["claude", "codex", "agy", "shell", "custom"])("keeps %s's existing encoding", id => { + expect(terminalNewlineBytes(id)).toEqual([0x5c, 0x0d]); + }); +}); diff --git a/src/lib/terminalNewline.ts b/src/lib/terminalNewline.ts new file mode 100644 index 00000000..9c9bb2f3 --- /dev/null +++ b/src/lib/terminalNewline.ts @@ -0,0 +1,5 @@ +/** OpenCode binds Ctrl+J (LF) to input.newline. Other agents keep the + * existing backslash-return continuation convention. */ +export function terminalNewlineBytes(baseAgentId: string): number[] { + return baseAgentId === "opencode" ? [0x0a] : [0x5c, 0x0d]; +}