Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/e2e-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
6 changes: 6 additions & 0 deletions docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
81 changes: 81 additions & 0 deletions e2e/specs/terminal-newline.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<InputState | null>;

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<HTMLTextAreaElement>(
`[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");
});
});
}
});
36 changes: 36 additions & 0 deletions scripts/fake-newline-agent.mjs
Original file line number Diff line number Diff line change
@@ -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();
9 changes: 8 additions & 1 deletion src/components/task/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/lib/terminalNewline.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
5 changes: 5 additions & 0 deletions src/lib/terminalNewline.ts
Original file line number Diff line number Diff line change
@@ -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];
}
Loading