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
30 changes: 30 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2619,6 +2619,36 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`.
`^C` → Control C, ⌫ → Backspace, Ctrl → Control, the arrows and page keys), translated with the
UI; a cap that is a word (Tab, Enter, Home, End) is its own name.
- The mobile drawer slides over a scrim. Closed visibility removes its controls from the tab order.
- The terminal's input line carries its name in view (`.terminal-input-label`, "Input line", the
key bar toggle's own words, `--fs-chat` in `--text-dim`) above the draft, whose text is
`max(--fs-input, --fs-xl)`. It has **Insert** (`.terminal-input-insert`, named "Insert text without
Enter") left of Send. Each action is one button: its words above (`.terminal-input-caption`,
`--fs-chat`: "Insert text"; "Send", or "Enter" on an empty line) and its `--touch-target` round
face below. Send's face is the filled accent; Insert's is outlined on `--bg-elevated` in
`--text-dim`, which holds 3:1 against the panel where the `--border` hairline does not; its glyph
is lucide's text-cursor-input, Send keeps its arrow. Off (an empty line, disconnected, a send or
insert on its way) a face turns to a dashed `--text-dim` outline with dim words, never a fade. A
composition says "Finish the word, then insert." instead of sending the preedit. The text goes as
the keyboard would type it, with no Enter after it (`insert`, feature `terminal-insert`), and no
resize, focus or take-over goes with it. It types one line: herdr reports no paste mode of the
program (its attach opens with its own ESC[?2004h whatever the program set), so several lines
are never sent as a guessed bracketed paste; the line says "Insert takes one line. Send several
lines, or insert them one at a time." and nothing goes. The line clears only on the bridge's
`insert-result`, and only the text that was sent; a refusal, a drop or an older PC keeps the
text with the note under the line (`.terminal-input-note`, `--fs-chat`), and a clipboard read
still waiting says so in the note's dim `.is-pending` form. Korean notes keep each clause whole
(no-break spaces inside it, keep-all between). On a touch screen the key bar's **Paste**
(`[data-key="paste"]`, lucide clipboard-paste, "Paste into the input line") stands beside the
Input line toggle, `--space-1` further from its neighbours than other keys: a tap, never a hold,
read from that tap alone and dropped when the line, its caret, a composition, the pane, mode,
role, input lease or connection changed before the clipboard answered, even and back again. With
the line put away (direct typing) it changes no mode and says "Tap Input line to paste." in the
bar's own row (`.key-bar-note`, in place of the other keys for a few seconds, gone on a tap),
beside the key it names and never over the terminal's output. No widely used coding-agent app (the
Codex and Claude apps, Paseo, T3 Code) was found to establish a precedent for a phone terminal
draft with a separate no-Enter insert; agent-of-empires' compose sheet has Insert beside Send but
clears its draft on a synchronous boolean, which this design rejects: the draft is cleared on the
bridge's acknowledgement, as Send's is, and nothing typed is queued across a disconnect.

### In-app alert
- While the app is on screen, a pane that needs input, finishes a turn (by the device's Finished
Expand Down
9 changes: 9 additions & 0 deletions changelog.d/terminal-paste-insert.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Added
- On a phone, the terminal's key bar has a Paste key: it reads the clipboard from that tap alone
and puts the text into the input line at the caret, never into the grid, and drops a read that
answers after the line, its caret or a composition changed, or the pane, the mode or the
connection did. Beside Send, the input line has Insert: it types one line into the terminal
without Enter, so a path or a flag can be put at the prompt and edited there. The line clears
only once the PC confirms the terminal has the text; a refusal, a dropped connection or an
older PC keeps it with a note, and nothing is sent again on its own. Several lines are not
inserted: the line says to send them or insert them one at a time.
1 change: 1 addition & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ bun scripts/font-swap-demo-regression.ts # the app's faces arriving late on
bun scripts/narrow-pane-demo-regression.ts # a split pane under 80 columns: the smaller font, a zoom, and a pane no font can bring to 80
bun scripts/agent-skill-menu-demo-regression.ts [agent …] # each agent's `/` (and `$`) skill menu, its origin groups and its skill chips at 1280 and 390px, one case file per agent in scripts/skill-menu-cases/, screenshots in evidence/agent-skill-menu/<agent>/
bun scripts/install-state-demo-regression.ts # Install app against a real CDP install on a throwaway profile and HOME: the help or the prompt before, gone in the app window and a tab of its origin, back after uninstall
bun scripts/terminal-paste-insert-demo-regression.ts # a phone's Paste key into the terminal's input line and Insert without Enter on the demo shell: the caret, a held or refused clipboard read, the acknowledged clear, an older bridge; with UI_EVIDENCE_DIR, every state at 390 and 1440, light and dark, and Korean
```

`FILE_VIEWER_CASE=landscape-notch` selects a viewer case; `FILE_VIEWER_CSS=/path/to/before.css` compares another stylesheet. These checks use Chromium mobile emulation and synthetic safe-area/keyboard geometry; they cannot verify actual iOS Safari keyboard dismissal or notch insets. The existing `bun scripts/file-viewer-regression.ts` separately checks history with an owned herdr pane. The original `scripts/mobile-viewport-regression.ts` exports `checkMobileViewport` for the real-app `bun run test:ui` suite; it also checks the command palette and xterm focus transitions. The demo runners build the real client into a temporary directory, inject the committed fictional-session transport and serve it only on loopback; they do not use a live herdr session or download website media. Run on its own, each builds the client itself (`scripts/demo-build.ts`); the browser lane builds it once and names the directory in `HERDR_DEMO_BUILD`, and each runner copies that instead. They exercise real-app viewport and alert geometry, but not live herdr connectivity.
Expand Down
1 change: 1 addition & 0 deletions scripts/ci-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ run_script scripts/chat-greeting-demo-regression.ts
run_script scripts/restored-draft-demo-regression.ts
run_script scripts/chat-local-send-follow-demo-regression.ts
run_script scripts/chat-tool-paths-demo-regression.ts
run_script scripts/terminal-paste-insert-demo-regression.ts
run_script scripts/composer-fit-demo-regression.ts
run_script scripts/held-rows-demo-regression.ts
run_script scripts/replay-clipboard-demo-regression.ts
Expand Down
9 changes: 5 additions & 4 deletions scripts/key-bar-customization-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ const repo = join(import.meta.dir, "..");
const app = mkdtempSync(join(tmpdir(), "herdr-key-bar-demo-"));
const evidence = process.env.UI_EVIDENCE_DIR;
const SETTINGS_KEY = "ovis:settings";
const MIGRATED_KEYS = ["direct", "Escape", "Tab", "BackTab", "Control", "Alt", "Shift", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "ctrl-c", "ctrl-d", "ctrl-z", "pipe", "tilde", "slash"];
const DEFAULT_KEY_BAR_KEYS = ["direct", "Escape", "Tab", "ctrl-c", "Control", "Alt", "Shift", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
// the touch key bar's fixed keys lead: the Input line toggle, then Paste into the input line
const MIGRATED_KEYS = ["direct", "paste", "Escape", "Tab", "BackTab", "Control", "Alt", "Shift", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "ctrl-c", "ctrl-d", "ctrl-z", "pipe", "tilde", "slash"];
const DEFAULT_KEY_BAR_KEYS = ["direct", "paste", "Escape", "Tab", "ctrl-c", "Control", "Alt", "Shift", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];

interface InputFrame { type: "input" | "keys"; pane_id: string; text?: string; keys?: string[] }
const framesOf = (page: Page): Promise<InputFrame[]> => page.evaluate(() => (window as unknown as { keyBarFrames: InputFrame[] }).keyBarFrames);
Expand Down Expand Up @@ -261,10 +262,10 @@ try {
while (await editor.locator(".key-bar-settings-row").count()) await editor.getByRole("button", { name: /^Remove / }).first().tap();
await editor.getByText("No keys selected.", { exact: true }).waitFor();
await closeSettings(page, dialog);
assert.deepEqual(await keysOf(page), ["direct"]);
assert.deepEqual(await keysOf(page), ["direct", "paste"]);
await page.reload();
await ready(page);
assert.deepEqual(await keysOf(page), ["direct"], "an intentionally empty list survives reload");
assert.deepEqual(await keysOf(page), ["direct", "paste"], "an intentionally empty list survives reload");
await bar.locator('[data-key="direct"]').tap();
await page.getByRole("textbox", { name: "Terminal input line", exact: true }).waitFor();
await bar.locator('[data-key="direct"]').tap();
Expand Down
36 changes: 36 additions & 0 deletions scripts/terminal-input-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export async function checkTerminalInput(browser: Browser, origin: string, pane:
const resizes: any[] = [];
let acknowledge: (() => void) | undefined;
let refuse: (() => void) | undefined;
// an Insert held here like a submit: the bridge's insert-result goes to the page only when the test says
let acknowledgeInsert: (() => void) | undefined;
let ready: (() => void) | undefined;
let delayReady = false;
let refuseConnections = false;
Expand Down Expand Up @@ -47,6 +49,9 @@ export async function checkTerminalInput(browser: Browser, origin: string, pane:
sent.push(frame);
acknowledge = () => socket.send(JSON.stringify({ type: "submit-result", id: frame.id, pane_id: frame.pane_id, ok: true }));
refuse = () => socket.send(JSON.stringify({ type: "submit-result", id: frame.id, pane_id: frame.pane_id, ok: false, code: "agent_blocked", message: "" }));
} else if (frame.type === "insert") {
sent.push(frame);
acknowledgeInsert = () => socket.send(JSON.stringify({ type: "insert-result", id: frame.id, pane_id: frame.pane_id, ok: true }));
} else if (frame.type === "input") sent.push(frame);
else if (frame.type === "ping" && holdPings) heldPings.push(String(raw));
else {
Expand Down Expand Up @@ -145,6 +150,37 @@ export async function checkTerminalInput(browser: Browser, origin: string, pane:
assert(Math.abs(await height(".terminal-input") - await height(".terminal-input-slot")) < 1, "a one-row line covers no grid row");
// what PaneTerminal's observer would have sent comes 120 ms after a change, and none happened
assert.equal(resizes.length, resizesBefore, "typing three lines and Send send no resize");

// Insert beside Send, in the still grid Send just used: the line typed with no Enter, in its own
// frame, and cleared only on the bridge's answer; what replaced it meanwhile stays, as after Send
const insertButton = page.getByRole("button", { name: "Insert text without Enter", exact: true });
await line.fill("cd");
await until(async () => !(await insertButton.isDisabled()));
await line.fill("");
await until(() => insertButton.isDisabled());
await line.fill("cd src/lib");
sent.length = 0;
acknowledgeInsert = undefined;
const resizesBeforeInsert = resizes.length;
await insertButton.click();
await until(() => sent.length === 1 && !!acknowledgeInsert);
assert.deepEqual([sent[0].type, sent[0].text, sent[0].pane_id], ["insert", "cd src/lib", sentPane], "Insert sends the line as one insert frame, no Enter");
assert.equal(await insertButton.isDisabled(), true, "Insert waits for the answer");
assert.equal(await page.getByRole("button", { name: "Send to the terminal", exact: true }).isDisabled(), true, "so does Send");
assert.equal(await line.inputValue(), "cd src/lib", "the line keeps its text until the bridge answers");
await line.fill("");
await line.fill("cd src/components");
acknowledgeInsert!();
await until(async () => !(await insertButton.isDisabled()));
assert.equal(await line.inputValue(), "cd src/components", "a late insert acknowledgement cannot erase replacement text");
assert.equal(sent.length, 1, "nothing but the insert frame went: no input, no submit, no Enter");
// in the same still grid as the Send above (its observer never fired): any resize now would be Insert's
assert.equal(await page.evaluate(() => (window as any).gridResized), 0, "the grid stayed still through Insert");
assert.equal(resizes.length, resizesBeforeInsert, "Insert takes no grid back: no resize went with it");
await line.fill("");
sent.length = 0;
acknowledgeInsert = undefined;

sent.length = 0;
acknowledge = undefined;

Expand Down
4 changes: 3 additions & 1 deletion scripts/terminal-input-spacing-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ export async function checkTerminalInputSpacing(browser: Browser, origin: string
return { top: own.top, bottom: own.bottom, tapTop, tapBottom, inView, landsAbove: inView && lands(tapTop + 0.5), landsBelow: inView && lands(tapBottom - 0.5) };
});
return {
line: { top: line.top + 1, bottom: line.bottom }, text: box(".terminal-input-text"), send: box(".terminal-input-send"),
// the field stands under its label (TerminalInput.css): the two are measured as one, as the send
// button is with the word above its round face
line: { top: line.top + 1, bottom: line.bottom }, text: { top: box(".terminal-input-label").top, bottom: box(".terminal-input-text").bottom }, send: box(".terminal-input-send"),
bar: { top: bar.top, bottom: bar.bottom - barPadding }, keys, target, barPadding, screenBottom: window.visualViewport!.height,
};
});
Expand Down
Loading
Loading