Skip to content
Merged
10 changes: 10 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,16 @@ blocks of `src/styles.css`.

### File viewer

- Edit text files (off by default, Settings → File viewer): a pencil **Edit file** button in the header
turns the body into a text box (`.file-editor-text`, the source's mono size, never under
`--fs-input` on a touch screen so iOS does not zoom) under one bar: what is going on on the left
in `--text-dim` (`--notice` for a conflict or a refusal, with `role="alert"`), the buttons on the right.
Save asks again in the same bar ("Save changes to NAME?", Keep editing / Save) instead of a
dialog over the dialog. A file changed since it was opened follows Zed's "changed on disk" handling:
the typed text stays in the box to copy, and Reload file shows the file as it is now; unlike
Zed there is no Overwrite, so a save never replaces a change the editor has not seen. While the
text differs from the file, Escape, a press on the scrim and the sheet swipe do not close the
viewer; Cancel and Close do.
- `--notice` colors a note that the view is partial (a file cut short, code too long to color): the
size in the header's meta line becomes `256 KB of 1.3 MB`, always beside a warning icon so the
color is not the only sign. It is a warning yellow, at least 4.5:1 on
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/edit-text-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added
- The file viewer can edit a text file in the pane's folder: turn on Settings → File viewer → Edit
text files (off by default). Save asks once more, and a file that changed on the PC since you
opened it is not overwritten. Only a device that drives the PC can edit, on Linux and macOS PCs.
1 change: 1 addition & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ bun scripts/settings-pages-demo-regression.ts # every Settings page on a 390px a
bun scripts/agent-entry-points-demo-regression.ts # Add another agent… in New workspace and New worktree, Settings' agent list (installed, the tiles to install) and Open setup guide, and none of it on a watching device, at 1280 and 390
bun scripts/interface-font-demo-regression.ts # Interface font at 390 and 1440: System and a typed list lead --font-ui, a reload keeps it, Default restores it; the message box takes the Chat font
bun scripts/file-text-size-demo-regression.ts # Settings → File text size at 390 and 1280, light and dark: a Markdown preview follows the chat and a source the interface until a size is chosen, then both take it while the chat and the viewer's header stay; Reset drops it
bun scripts/text-file-edit-regression.ts # Settings → Chat → File viewer → Edit text files at 1280 and 390, light and dark, on an owned herdr pane: off by default; on, Save asks once more and writes, Cancel leaves the file, a file changed on the PC is not overwritten (Reload file shows it), a read-only file says it can't be written
bun scripts/files-panel-demo-regression.ts # Settings → Chat → Files beside the pane at 1280 and 390, light and dark: off, Files is the dialog; on, a wide window shows it right of the pane with the pane live, a docked file takes its place and closing it brings the list back; a phone keeps the sheet
bun scripts/palette-local-page-demo-regression.ts # Web links on In the app, at 1280 and 390, light and dark: the palette opens a page this PC serves by its typed address (`localhost:<port>`) beside the pane, a phone's popup closes with Back, and Close page over two stacked pages closes it; off by default, the palette offers no page
bun scripts/theme-first-paint-demo-regression.ts # the saved theme in every painted frame before and after the app starts, root and demo builds under the app's CSP
Expand Down
1 change: 1 addition & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ across an Ovis restart, which forgets where it saw each `letta` before. Check th
| **Compose** | `/` commands and `@` file mentions, any file or image up to 32 MB attached by path, and a draft per pane. Send during work schedules a next-turn message; use its ↑ Send now action to send it now. A strip under the input card names the pane's working folder (the whole path on hover) and, for a workspace of a repository with a worktree open, its branch at the other end; a phone leaves it out. Pick or type a terminal-only command and send normally: after acceptance, the pane's terminal opens over the chat, in this browser only, with **Back to Chat** and **Keep Terminal**; the chat stays where you were reading. For Claude Code's panels (`/config`, `/permissions`, `/theme` and the like) the chat may come back by itself when you close the panel, once Claude records the close; for Codex, omp, pi, gjc, OmO, Hermes (classic CLI), Devin, agy and Grok it comes back after their recorded pickers and panels, once the agent's empty input row is back on the screen; otherwise use **Back to Chat**, the header's Chat, or Back on a phone. If you go back to the same pane's chat while the command's screen is still open, the composer says so (Claude Code and every agent above, not OpenCode) and holds Send, quick replies and a queued message's Send now until the screen closes (**Send anyway** sends the message once); **Show terminal** opens it again. Messages the PC sends by itself at the end of a turn are not held. Automatic queued delivery never changes your view. Chat keeps one quiet **Ran in terminal** row with **Open Terminal**, which shows the terminal over the chat again; ordinary commands keep their outcome chips, and Hermes keeps its informational result cards. `/resume` opens **Earlier sessions…**. [Agent commands in Chat](agent-commands.md) explains where each agent's commands come from and what counts as a result. |
| **Follow every agent** | Live RUN / INPUT / DONE / READY status for all panes, and alerts when an agent needs input, finishes or its terminal ends. |
| **Open what agents make** | A file path in an answer opens in a viewer (images, video, audio, PDF, text), or find it with **Browse files**, and download it to your phone. A source file opens whole up to 8 MB and 200,000 lines; past 2,000 lines it scrolls as a window of rows, colored up to 256 KB and 5,000 lines and plain beyond. A link with `:line` or `#L12` opens at that line. |
| **Edit a text file** | Off by default: turn on **Settings → Chat → File viewer → Edit text files** on this device. A text file under the pane's folder, up to 1 MB, then shows **Edit file** in the viewer: change it in a plain text box and press **Save** twice (the second press confirms). If the file changed on the PC since you opened it, nothing is written: your text stays in the box to copy, and **Reload file** shows the file as it is now. Only a device that drives the PC can edit (not a watching device, a Portal visitor or an open LAN window), and only a UTF-8 file with one kind of line ending that is yours, not read-only and not hard-linked; a file outside the pane's folder, or in `.git`, never opens for editing. The file is replaced in one step with its permissions and line endings kept. Saving works on Linux and macOS PCs; a Windows PC says it can't save yet. A remote PC's file is edited and checked on that PC. |
| **See what changed** | **Browse files → Changes** lists what changed in the git repository the pane sits in: the **Working tree**, what is **Staged**, or two **Commits** you name, as a folder tree with git's status letter and the added and removed lines. A row opens its diff, **Unified** or **Split** (Settings → Chat → File viewer → Diff layout; a phone shows one column), with **Copy path**, **Copy patch** and **Open file**. The files a turn's edit tools changed, listed in its work block, open the same diff. It only reads; a remote PC's repository is read on that PC. |
| **Manage sessions** | Start an agent in a folder you type or pick with **Browse**. In New workspace, Browse filters the currently loaded folders as you type (case-insensitive); opening a folder updates Directory, so **Start** uses that path even without **Use this folder** (which closes Browse), and a folder under home stays written as `~/…`. **New folder** (the folder-plus button in Browse's bar) makes a folder inside the one shown and opens it. It does not search subfolders or folders beyond the displayed 500. Add a tab to a workspace (as herdr's prefix+c), switch tabs from the strip over the pane, rename workspaces from their ⋯/right-click menu and panes from their own menu, reorder workspaces (drag a row, or on a phone long-press it and drag; Alt+Up/Down moves a focused row one step), and jump anywhere from the command palette. For Claude Code, New workspace and New tab have an **Ultracode** switch under Model and Effort, which asks Claude Code to start the session with Ultracode on at the chosen effort level. It needs Claude Code 2.1.284 or later, a model with xhigh and dynamic workflows on; otherwise Claude Code ignores it without a word (see [Starting an agent with a first message](agent-commands.md#starting-an-agent-with-a-first-message)). |
| **Speak instead of typing** | A mic beside Attach in the composer and beside Send in the terminal input line. Tap to start and again to finish, or hold it while you talk (not on a touch screen without an API key); the words land at the caret and are never sent by themselves. See [Voice input](#voice-input). |
Expand Down
1 change: 1 addition & 0 deletions scripts/ci-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ run_script scripts/omp-codex-image-browser-qa.ts
run_script scripts/grok-image-browser-qa.ts
run_script scripts/copilot-image-browser-qa.ts
run_script scripts/file-viewer-regression.ts
run_script scripts/text-file-edit-regression.ts
run_script scripts/web-panel-mobile-regression.ts
run_script scripts/palette-local-page-demo-regression.ts
run_script scripts/pwa-update-browser-qa.ts
Expand Down
151 changes: 151 additions & 0 deletions scripts/text-file-edit-regression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Settings → File viewer → Edit text files (#771) at 1280 and 390, light and dark, on an owned herdr
* pane: off by default no Edit; on, a file in the pane's folder is edited, Save asks once more and
* writes it, Cancel leaves it, a file changed on the PC meanwhile is not overwritten (the typed
* text stays, Reload file shows the PC's), and a read-only file says it cannot be written.
* Screenshots go to UI_EVIDENCE_DIR when it is set.
*/
import "./test-herdr.ts";
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { chromium, type Browser, type Locator, type Page } from "playwright-core";
import { createServer } from "../server/index.ts";
import { workspaceClose, workspaceCreate } from "../server/herdr/client.ts";
import { chromePath } from "./chrome-path.ts";

const root = mkdtempSync(join(tmpdir(), "ovis-text-edit-"));
const project = join(root, "project");
mkdirSync(project);
const evidence = process.env["UI_EVIDENCE_DIR"] || null;
let server: ReturnType<typeof createServer> | undefined;
let workspace: string | undefined;
let browser: Browser | undefined;

async function open(origin: string, pane: string, width: number, theme: "light" | "dark", settings: Record<string, unknown>, file: string): Promise<{ page: Page; viewer: Locator; errors: string[]; close: () => Promise<void> }> {
const phone = width <= 480;
const context = await browser!.newContext({ viewport: { width, height: phone ? 844 : 900 }, locale: "en-US", hasTouch: phone, isMobile: phone, colorScheme: theme });
await context.addInitScript((value) => { if (window.top === window && localStorage.getItem("ovis:settings") === null) localStorage.setItem("ovis:settings", value); }, JSON.stringify({ language: "en", theme, fileOpen: "popup", ...settings }));
const page = await context.newPage();
page.setDefaultTimeout(10_000);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(`${origin}/?pane=${encodeURIComponent(pane)}&file=${encodeURIComponent(file)}`);
const viewer = page.getByRole("dialog", { name: file, exact: true });
await viewer.waitFor();
return { page, viewer, errors, close: () => context.close() };
}

const shot = async (page: Page, name: string): Promise<void> => { if (evidence) await page.screenshot({ path: join(evidence, `${name}.png`) }); };

try {
const created = await workspaceCreate({ cwd: project, label: "ovis-test-text-edit" });
workspace = created.workspace.workspace_id;
const pane = created.root_pane.pane_id;
server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(root, "state") });
const origin = `http://127.0.0.1:${server.port}`;
browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] });

for (const width of [1280, 390]) {
for (const theme of ["light", "dark"] as const) {
const tag = `${width}-${theme}`;
const notes = join(project, `notes-${tag}.md`);
writeFileSync(notes, "# Notes\n\nFirst line.\n");

// off by default: the viewer offers no Edit
const off = await open(origin, pane, width, theme, {}, `notes-${tag}.md`);
await off.viewer.getByRole("button", { name: "Show source", exact: true }).waitFor();
assert.equal(await off.viewer.getByRole("button", { name: "Edit file", exact: true }).count(), 0, `${tag}: no Edit while the setting is off`);
// Settings → Chat → File viewer holds the switch, off
await off.page.goto(`${origin}/?pane=${encodeURIComponent(pane)}&settings=chat`);
const toggle = off.page.locator(".settings-dialog").getByRole("switch", { name: "Edit text files", exact: true });
await toggle.scrollIntoViewIfNeeded();
assert.equal(await toggle.getAttribute("aria-checked"), "false", `${tag}: Edit text files is off by default`);
await shot(off.page, `text-edit-setting-${tag}`);
await off.close();

const on = await open(origin, pane, width, theme, { editTextFiles: true }, `notes-${tag}.md`);
const { page, viewer } = on;
await viewer.getByRole("button", { name: "Edit file", exact: true }).click();
const box = viewer.getByRole("textbox", { name: `Text of notes-${tag}.md`, exact: true });
assert.equal(await box.inputValue(), "# Notes\n\nFirst line.\n");
await box.fill("# Notes\n\nFirst line.\nSecond line.\n");
await viewer.getByText("Unsaved changes", { exact: true }).waitFor();
// Save fades in as it is enabled: shown once it is
await viewer.getByRole("button", { name: "Save", exact: true }).evaluate((button) => Promise.all(button.getAnimations().map((animation) => animation.finished)));
await shot(page, `text-edit-editing-${tag}`);
// Escape does not close over changes not saved
await page.keyboard.press("Escape");
assert.equal(await viewer.isVisible(), true, `${tag}: Escape keeps the editor open over changes`);
await viewer.getByRole("button", { name: "Save", exact: true }).click();
await viewer.getByText(`Save changes to notes-${tag}.md?`, { exact: true }).waitFor();
assert.equal(readFileSync(notes, "utf8"), "# Notes\n\nFirst line.\n", `${tag}: the first Save writes nothing`);
await shot(page, `text-edit-confirm-${tag}`);
await viewer.getByRole("button", { name: "Save", exact: true }).click();
await viewer.getByRole("button", { name: "Edit file", exact: true }).waitFor();
assert.equal(readFileSync(notes, "utf8"), "# Notes\n\nFirst line.\nSecond line.\n", `${tag}: the second Save writes the file`);
await viewer.getByText("Second line.", { exact: true }).waitFor();
console.log(`PASS ${tag}: Edit, Save asks once more, then the file holds the text and the viewer shows it`);

// moved beside the pane (a wide window) while edited: the viewer is drawn again, and the text typed stays
if (width >= 1280) {
await viewer.getByRole("button", { name: "Edit file", exact: true }).click();
await box.fill("typed before the move\n");
await viewer.getByRole("button", { name: "Show beside the pane", exact: true }).click();
const docked = page.getByRole("complementary", { name: `notes-${tag}.md`, exact: true });
await docked.getByText("Unsaved changes", { exact: true }).waitFor();
assert.equal(await docked.getByRole("textbox", { name: `Text of notes-${tag}.md`, exact: true }).inputValue(), "typed before the move\n", `${tag}: the draft survives the move beside the pane`);
await docked.getByRole("button", { name: "Cancel", exact: true }).click();
await docked.getByRole("button", { name: "Show as a popup", exact: true }).click();
await viewer.waitFor();
console.log(`PASS ${tag}: text typed and not saved survives moving the viewer beside the pane`);
}

// Cancel leaves the file as it is
await viewer.getByRole("button", { name: "Edit file", exact: true }).click();
await box.fill("thrown away\n");
await viewer.getByRole("button", { name: "Cancel", exact: true }).click();
await viewer.getByRole("button", { name: "Edit file", exact: true }).waitFor();
assert.equal(readFileSync(notes, "utf8"), "# Notes\n\nFirst line.\nSecond line.\n", `${tag}: Cancel writes nothing`);
console.log(`PASS ${tag}: Cancel leaves the file as it was`);

// the PC's copy changes while the text is edited: nothing is overwritten
await viewer.getByRole("button", { name: "Edit file", exact: true }).click();
await box.fill("mine\n");
writeFileSync(notes, "the agent's version\n");
await viewer.getByRole("button", { name: "Save", exact: true }).click();
await viewer.getByRole("button", { name: "Save", exact: true }).click();
await viewer.getByRole("alert").getByText(/your changes were not saved/).waitFor();
assert.equal(readFileSync(notes, "utf8"), "the agent's version\n", `${tag}: a changed file is not overwritten`);
assert.equal(await box.inputValue(), "mine\n", `${tag}: the typed text stays to copy`);
await shot(page, `text-edit-conflict-${tag}`);
await viewer.getByRole("button", { name: "Reload file", exact: true }).click();
await page.waitForFunction(([name]) => document.querySelector<HTMLTextAreaElement>(`textarea[aria-label="Text of ${name}"]`)?.value === "the agent's version\n", [`notes-${tag}.md`]);
await viewer.getByRole("button", { name: "Cancel", exact: true }).click();
console.log(`PASS ${tag}: a file changed on the PC is not overwritten; Reload file shows the PC's text`);
assert.deepEqual(on.errors, [], `${tag}: no page errors`);
await on.close();

// a read-only file opens for editing but says it cannot be written
const locked = join(project, `locked-${tag}.md`);
writeFileSync(locked, "locked\n");
chmodSync(locked, 0o444);
const ro = await open(origin, pane, width, theme, { editTextFiles: true }, `locked-${tag}.md`);
await ro.viewer.getByRole("button", { name: "Edit file", exact: true }).click();
await ro.viewer.getByRole("textbox").fill("changed\n");
await ro.viewer.getByRole("button", { name: "Save", exact: true }).click();
await ro.viewer.getByRole("button", { name: "Save", exact: true }).click();
await ro.viewer.getByRole("alert").getByText("This file can't be written here.", { exact: true }).waitFor();
assert.equal(readFileSync(locked, "utf8"), "locked\n");
await shot(ro.page, `text-edit-refused-${tag}`);
console.log(`PASS ${tag}: a read-only file says it can't be written, and stays as it was`);
await ro.close();
}
}
} finally {
await browser?.close();
server?.stop();
if (workspace) await workspaceClose(workspace);
rmSync(root, { recursive: true, force: true });
}
Loading
Loading