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
5 changes: 5 additions & 0 deletions changelog.d/opencode-drag-to-program.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed
- In an OpenCode pane, a drag in the desktop terminal goes to OpenCode, as a click already did, so
OpenCode selects inside the panel the drag starts in instead of Ovis selecting whole rows across
its side panel. Shift+drag (Option+drag on a Mac) still selects and copies in Ovis. Reported by
@matiemer in #2362.
2 changes: 1 addition & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ A pane's plan is the task list a Claude Code agent keeps (`TaskCreate` / `TaskUp
| **Read the conversation** | Prompts and Markdown answers (links, code blocks, tables). Each turn's commands, edits and progress are folded into one "Worked for …" block. Copy an answer as Markdown or plain text. In a pi, omp, Claude Code or Codex pane, a picture the agent's own tool opened shows inside that tool's row. A picture opens in a full-screen viewer: zoom at the pointer, pan, step through the message's other pictures, **Copy image** and **Download**. Skill names an answer writes (`/name`, or `$name` for Codex) show as chips when the pane's own command list names that skill. Once you have sent four or more messages, a rail of dashes at the left, one per message you sent, jumps between them (not on a phone, or when the chat column leaves less than 32 px of margin at each side). |
| **Follow the plan** | A supported todo-tool call folds into the turn's work block like any tool: it reads as the done count or the step it took, and opened, as the whole list by phase. |
| **Rewind in terminal** | Claude Code, grok and Antigravity messages offer **Rewind in terminal** beside Copy. It sends nothing, preserves your draft and opens the same pane with a hint to run `/rewind`. Choose and confirm there. Chat follows the native conversation once the agent records its new branch; Claude Code does so on the next prompt or command. |
| **Drop into the real terminal** | xterm.js on the live pane: full-screen TUIs, raw keys and herdr's scrollback, shared with your own herdr TUI. Drag to select and it is copied on release, unless herdr's `[ui] copy_on_select` is off on the PC serving Ovis: then it is only selected, and stays selected under the pointer until Ctrl+C or Copy copies it. The wheel or the screen's edge scrolls further back while you drag. Ctrl+C copies a selection instead of interrupting. **Quote in message**, shown while text is selected, adds the selection to that pane's chat draft in a code block headed by the pane name, and its line numbers when herdr reports them; nothing is sent. |
| **Drop into the real terminal** | xterm.js on the live pane: full-screen TUIs, raw keys and herdr's scrollback, shared with your own herdr TUI. Drag to select and it is copied on release, unless herdr's `[ui] copy_on_select` is off on the PC serving Ovis: then it is only selected, and stays selected under the pointer until Ctrl+C or Copy copies it. The wheel or the screen's edge scrolls further back while you drag. In an OpenCode pane a drag goes to OpenCode, which selects inside its own panel and copies it itself, as in a native terminal; hold Shift (Option on a Mac) to select and copy in Ovis instead, which is also the way when OpenCode's own copy does not reach this device's clipboard. Ctrl+C copies a selection instead of interrupting. **Quote in message**, shown while text is selected, adds the selection to that pane's chat draft in a code block headed by the pane name, and its line numbers when herdr reports them; nothing is sent. |
| **Find in terminal** | **More → Find in terminal** or Mod+Shift+F searches literal text in herdr's history, with a match count, previous/next navigation and visible match highlights. Search jumps and scrolling move the same viewport in every attached client, including the TUI. Older bridges retain search navigation without automatic highlights. |
| **Jump across PCs** | The command palette (Mod+Shift+K) searches all connected PCs at once by PC, workspace, branch, tab, pane title or agent kind. PC names distinguish results, and Recent remembers each pane's PC. Picking a result opens that PC and pane directly. Disconnected PCs are omitted; app and plugin actions remain on the currently selected PC. It also finds Settings pages and actions by related words (try `theme` or `font`), and browses folders: type a path such as `~/`, or pick **Browse folders**, and the folder you reach opens in Files. |
| **Answer prompts** | Approval, question and plan menus become cards. Tap an option, or type its number in the composer. The server checks that the menu is still current before answering. |
Expand Down
59 changes: 55 additions & 4 deletions scripts/terminal-arrows-clicks-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { chromium, type Browser, type Page } from "playwright-core";
import { createServer } from "../server/index.ts";
import { workspaceCreate, workspaceClose, paneSendText, paneSendKeys, paneRead } from "../server/herdr/client.ts";
import { workspaceCreate, workspaceClose, paneSendText, paneSendKeys, paneRead, herdrRpc } from "../server/herdr/client.ts";
import { chromePath } from "./chrome-path.ts";

const root = mkdtempSync(join(tmpdir(), "herdr-arrows-clicks-"));
Expand Down Expand Up @@ -67,8 +67,8 @@ async function mark(s: Session, label: string, from = s.frames.length): Promise<
return { read: read.slice(0, -MARK.length), frames: s.frames.slice(from, at()) };
}

/** A pane of its own running the stdin logger after `modes` were set, shown in a desktop browser. */
async function open(browser: Browser, server: ReturnType<typeof createServer>, name: string, modes: string): Promise<Session> {
/** A pane of its own running the stdin logger after `modes` were set, shown in a desktop browser; herdr reports `agent` in it. */
async function open(browser: Browser, server: ReturnType<typeof createServer>, name: string, modes: string, agent?: string): Promise<Session> {
const cwd = join(root, name);
mkdirSync(cwd);
const made = await workspaceCreate({ cwd, label: `ovis-test-arrows-clicks-${name}` });
Expand All @@ -78,6 +78,7 @@ async function open(browser: Browser, server: ReturnType<typeof createServer>, n
await paneSendText(pane, `clear; printf '${modes}'; python3 -u '${probe}' '${log}' plain`);
await paneSendKeys(pane, ["Enter"]);
await until(async () => (await paneRead({ paneId: pane, source: "visible" })).text.includes("PROBE READY plain"), `${name}: raw probe ready`);
if (agent) await herdrRpc("pane.report_agent", { pane_id: pane, source: "manual", agent, state: "idle" });
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
await context.addInitScript(({ pane }) => {
localStorage.setItem("ovis:settings", JSON.stringify({ language: "en" }));
Expand Down Expand Up @@ -358,7 +359,57 @@ try {
await s.close();
}

// 4. A mirrored pane: arrows are named keys there too, and a click sends nothing, since the
// 4. OpenCode reads the mouse and selects inside the panel a drag starts in (#2362): a plain drag is
// its own, as a click is, and the selection modifier still asks for the selection here.
{
const s = await open(browser, attached, "opencode", "\\033[?1049h\\033[?1000h\\033[?1002h\\033[?1006hchat column text | side panel\\r\\n", "opencode");
// the page knows the pane's agent once its row is in the sidebar
await s.page.locator(`.agent-item[data-pane="${s.pane}"]`).waitFor();
await mouseReporting(s);
const from = await s.point(20, 6);
const to = await s.point(300, 60);
/** a press where the pointer already is, dragged to `end` */
const dragFromHere = async (start: { x: number; y: number }, end: { x: number; y: number }): Promise<void> => {
await s.page.mouse.down();
await s.page.mouse.move((start.x + end.x) / 2, (start.y + end.y) / 2, { steps: 4 });
await s.page.mouse.move(end.x, end.y, { steps: 4 });
await s.page.mouse.up();
};
const dragged = async (): Promise<void> => {
await s.page.mouse.move(from.x, from.y);
await dragFromHere(from, to);
};
const drag = /\x1b\[<0;\d+;\d+M(?:[\s\S]*?\x1b\[<32;\d+;\d+M)+[\s\S]*?\x1b\[<0;(\d+);(\d+)m/;
let got = "";
await dragged();
await until(() => drag.test(got += s.received()), () => `a plain drag received as press, moves and release (read ${JSON.stringify(got)})`);
assert.equal(await s.page.locator(".xterm-selection div").count(), 0, "a plain drag selects nothing here");
if (evidence) await s.page.screenshot({ path: join(evidence, "opencode-plain-drag.png") });

// the selection modifier held by the user: the drag selects here and the program reads no button
assert.equal((await mark(s, "the plain drag")).read, "", "nothing more after the plain drag");
const selecting = await s.page.evaluate(() => /Mac/.test(navigator.platform)) ? "Alt" : "Shift";
got = "";
await s.page.keyboard.down(selecting);
await dragged();
await s.page.keyboard.up(selecting);
await until(async () => (await s.page.locator(".xterm-selection div").count()) > 0, `${selecting}+drag selects`);
if (evidence) await s.page.screenshot({ path: join(evidence, "opencode-modifier-drag.png") });

// a plain drag over that selection is the program's again and the highlight goes. Nothing reaches
// xterm in between: a key, or a buttonless move (a hover report), would clear the selection first,
// so the press is where the modifier drag let go. The mark after it shows the modifier drag sent nothing.
assert.ok((await s.page.locator(".xterm-selection div").count()) > 0, "the selection is still up when the plain drag starts");
await dragFromHere(to, from);
await until(() => drag.test(got += s.received()), () => `a plain drag after a selection received (read ${JSON.stringify(got)})`);
await until(async () => (await s.page.locator(".xterm-selection div").count()) === 0, "the earlier selection is cleared");
got += (await mark(s, `${selecting}+drag, then a plain drag`)).read;
assert.equal((got.match(/\x1b\[<\d+;\d+;\d+M/g) ?? []).filter((report) => !report.startsWith("\x1b[<32;")).length, 1, `one press: the plain drag's, none from the ${selecting}+drag`);
assert.equal((got.match(/\x1b\[<\d+;\d+;\d+m/g) ?? []).length, 1, `one release: the plain drag's, none from the ${selecting}+drag`);
await s.close();
}

// 5. A mirrored pane: arrows are named keys there too, and a click sends nothing, since the
// browser was never told the program reads the mouse and herdr has no pty to report it to.
{
const s = await open(browser, mirrored, "mirror", "\\033[?1049h\\033[?1h\\033[?1000h\\033[?1006h");
Expand Down
2 changes: 1 addition & 1 deletion src/components/PaneTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ export function PaneTerminal({
}, 2500);
}, []);

useTerminalMount({ activeRef, barKeyRef, baseFontRef, pinchingRef, cachedRef, cachedReleaseRef, cancelLeaveRef, cancelTouchRef, chatMountedRef, chatOutputRef, chatViewRef, clearModifiers, clipboardTimerRef, coarseRef, commitPendingText, composingRef, compositionCommitPendingRef, deferAttachRef, draftHeldForRef, draftPaneRef, endedRef, findOpenRef, fitRef, fittedRef, fixedGridRef, followCursorRef, followRef, frameGateRef, heldRef, hostRef, hyperlinksRef, keyboardHoldsRef, keyboardToggleFocusRef, leaveRef, lineScroll, machineId, machineSessions, mirroredRef, mirrorWrapRef, modifiersRef, modifyOtherKeysRef, narrowFontRef, noteClipboard, observeRef, onChatSuggestion, onInputRowFrameRef, onRoleAckRef, onServerMessageRef, openFileRef, openWebRef, osc52AllowedRef, outputTargetRef, palette, paneRef, pendingEpochRef, pendingScopeRef, pendingSubmissionsRef, pinchBadgeRef, pinchStatusRef, presentationRef, presentedRef, reassertIfStaleRef, reattachRef, redrawGreeting, releaseAwayRef, idleReleaseRef, setIdleView, releaseRef, releasedRef, retireOutputRef, secretRef, sessionRef, setChatInvalidation, setChatRefresh, setChatSent, setComposing, setConnected, setConnectedBefore, setConversationPushes, setDraft, setEnded, setHeld, setInputError, setInputReady, setObserving, setOutputError, setOutputReady, setParsedTarget, setReleased, setSecret, setSelectionQuote, setTerminal, setUnsupported, setWatching, sharedGridRef, shortcutSettings, shownRef, sizesPty, socketRef, stackRef, stalledRef, stopKeyRef, submitRetention, t, tRef, termRef, terminalFontSize, theme, updateSettings, uploadFileRef, watchingRef, wheelStateRef });
useTerminalMount({ activeRef, agentRef, barKeyRef, baseFontRef, pinchingRef, cachedRef, cachedReleaseRef, cancelLeaveRef, cancelTouchRef, chatMountedRef, chatOutputRef, chatViewRef, clearModifiers, clipboardTimerRef, coarseRef, commitPendingText, composingRef, compositionCommitPendingRef, deferAttachRef, draftHeldForRef, draftPaneRef, endedRef, findOpenRef, fitRef, fittedRef, fixedGridRef, followCursorRef, followRef, frameGateRef, heldRef, hostRef, hyperlinksRef, keyboardHoldsRef, keyboardToggleFocusRef, leaveRef, lineScroll, machineId, machineSessions, mirroredRef, mirrorWrapRef, modifiersRef, modifyOtherKeysRef, narrowFontRef, noteClipboard, observeRef, onChatSuggestion, onInputRowFrameRef, onRoleAckRef, onServerMessageRef, openFileRef, openWebRef, osc52AllowedRef, outputTargetRef, palette, paneRef, pendingEpochRef, pendingScopeRef, pendingSubmissionsRef, pinchBadgeRef, pinchStatusRef, presentationRef, presentedRef, reassertIfStaleRef, reattachRef, redrawGreeting, releaseAwayRef, idleReleaseRef, setIdleView, releaseRef, releasedRef, retireOutputRef, secretRef, sessionRef, setChatInvalidation, setChatRefresh, setChatSent, setComposing, setConnected, setConnectedBefore, setConversationPushes, setDraft, setEnded, setHeld, setInputError, setInputReady, setObserving, setOutputError, setOutputReady, setParsedTarget, setReleased, setSecret, setSelectionQuote, setTerminal, setUnsupported, setWatching, sharedGridRef, shortcutSettings, shownRef, sizesPty, socketRef, stackRef, stalledRef, stopKeyRef, submitRetention, t, tRef, termRef, terminalFontSize, theme, updateSettings, uploadFileRef, watchingRef, wheelStateRef });

useTerminalRefits({ activeRef, autoSelected, baseFontRef, pinchingRef, fontSettledRef, chatView, chatViewRef, coarseRef, findOpenRef, fitRef, fittedRef, fixedGridRef, follow, followCursorRef, keyboardHoldsRef, narrowFont, narrowFontFitted, observeRef, palette, paneRef, peekRef, sharedGridRef, shown, sizesPty, socketRef, termRef, terminalFontFamily, terminalFontSize, theme });

Expand Down
11 changes: 10 additions & 1 deletion src/lib/useTerminalMount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ type Typed = { input: string; keys: string[] | null; chord: boolean; stop: boole

export interface TerminalMountDeps {
activeRef: RefObject<boolean>;
/** the pane's agent as herdr reports it: OpenCode takes a plain drag itself */
agentRef: RefObject<string | null>;
barKeyRef: RefObject<{ key: string; chord: string | null; } | null>;
baseFontRef: RefObject<number>;
pinchingRef: RefObject<boolean>;
Expand Down Expand Up @@ -166,7 +168,7 @@ export interface TerminalMountDeps {
}

export function useTerminalMount(deps: TerminalMountDeps): void {
const { activeRef, barKeyRef, baseFontRef, pinchingRef, cachedRef, cachedReleaseRef, cancelLeaveRef, cancelTouchRef, chatMountedRef, chatOutputRef, chatViewRef, clearModifiers, clipboardTimerRef, coarseRef, commitPendingText, composingRef, compositionCommitPendingRef, deferAttachRef, draftHeldForRef, draftPaneRef, endedRef, findOpenRef, fitRef, fittedRef, fixedGridRef, followCursorRef, followRef, frameGateRef, heldRef, hostRef, hyperlinksRef, keyboardHoldsRef, keyboardToggleFocusRef, leaveRef, lineScroll, machineId, machineSessions, mirroredRef, mirrorWrapRef, modifiersRef, modifyOtherKeysRef, narrowFontRef, noteClipboard, observeRef, onChatSuggestion, onInputRowFrameRef, onRoleAckRef, onServerMessageRef, openFileRef, openWebRef, osc52AllowedRef, outputTargetRef, palette, paneRef, pendingEpochRef, pendingScopeRef, pendingSubmissionsRef, pinchBadgeRef, pinchStatusRef, presentationRef, presentedRef, reassertIfStaleRef, reattachRef, redrawGreeting, releaseAwayRef, idleReleaseRef, setIdleView, releaseRef, releasedRef, retireOutputRef, secretRef, sessionRef, setChatInvalidation, setChatRefresh, setChatSent, setComposing, setConnected, setConnectedBefore, setConversationPushes, setDraft, setEnded, setHeld, setInputError, setInputReady, setObserving, setOutputError, setOutputReady, setParsedTarget, setReleased, setSecret, setSelectionQuote, setTerminal, setUnsupported, setWatching, sharedGridRef, shortcutSettings, shownRef, sizesPty, socketRef, stackRef, stalledRef, stopKeyRef, submitRetention, t, tRef, termRef, terminalFontSize, theme, updateSettings, uploadFileRef, watchingRef, wheelStateRef } = deps;
const { activeRef, agentRef, barKeyRef, baseFontRef, pinchingRef, cachedRef, cachedReleaseRef, cancelLeaveRef, cancelTouchRef, chatMountedRef, chatOutputRef, chatViewRef, clearModifiers, clipboardTimerRef, coarseRef, commitPendingText, composingRef, compositionCommitPendingRef, deferAttachRef, draftHeldForRef, draftPaneRef, endedRef, findOpenRef, fitRef, fittedRef, fixedGridRef, followCursorRef, followRef, frameGateRef, heldRef, hostRef, hyperlinksRef, keyboardHoldsRef, keyboardToggleFocusRef, leaveRef, lineScroll, machineId, machineSessions, mirroredRef, mirrorWrapRef, modifiersRef, modifyOtherKeysRef, narrowFontRef, noteClipboard, observeRef, onChatSuggestion, onInputRowFrameRef, onRoleAckRef, onServerMessageRef, openFileRef, openWebRef, osc52AllowedRef, outputTargetRef, palette, paneRef, pendingEpochRef, pendingScopeRef, pendingSubmissionsRef, pinchBadgeRef, pinchStatusRef, presentationRef, presentedRef, reassertIfStaleRef, reattachRef, redrawGreeting, releaseAwayRef, idleReleaseRef, setIdleView, releaseRef, releasedRef, retireOutputRef, secretRef, sessionRef, setChatInvalidation, setChatRefresh, setChatSent, setComposing, setConnected, setConnectedBefore, setConversationPushes, setDraft, setEnded, setHeld, setInputError, setInputReady, setObserving, setOutputError, setOutputReady, setParsedTarget, setReleased, setSecret, setSelectionQuote, setTerminal, setUnsupported, setWatching, sharedGridRef, shortcutSettings, shownRef, sizesPty, socketRef, stackRef, stalledRef, stopKeyRef, submitRetention, t, tRef, termRef, terminalFontSize, theme, updateSettings, uploadFileRef, watchingRef, wheelStateRef } = deps;

// Where this terminal is: its pane, and whether it is active, presented, shown and not the chat view.
// Counted on every commit that changes it: an explicit copy still waiting for herdr's text is
Expand Down Expand Up @@ -680,6 +682,13 @@ export function useTerminalMount(deps: TerminalMountDeps): void {
const selecting = isMac ? event.altKey : event.shiftKey;
// a press that clears a selection, or opens a link, is not the program's click
const onLink = term.element?.querySelector(".xterm-screen")?.classList.contains("xterm-cursor-pointer") === true;
// OpenCode reads the mouse and selects inside the panel a drag starts in, as in a native terminal
// (#2362): a plain press is its own, drags included, as a click is, and it clears a selection made
// here. herdr reports which agent runs, never a program's mouse mode, so this goes by the agent.
if (reporting && agentRef.current === "opencode" && !event.shiftKey && !event.altKey && !onLink && !term.options.disableStdin) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
term.clearSelection();
return;
}
const click: ClickKeys | null = reporting && !selecting && !onLink && !term.hasSelection()
? { ctrlKey: event.ctrlKey, metaKey: event.metaKey, altKey: event.altKey, shiftKey: event.shiftKey } : null;
if (reporting) Object.defineProperty(event, isMac ? "altKey" : "shiftKey", { value: true });
Expand Down