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
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,16 @@ jobs:
- name: Build
run: make build

- name: Verify binary size under 300KB
- name: Verify binary size under 420KB
run: |
SIZE=$(stat -f%z webview-cli)
echo "Binary size: $SIZE bytes"
if [ "$SIZE" -gt 307200 ]; then
echo "Binary exceeds 300KB budget (v0.2.0 baseline; markdown editor adds ~100KB over the v0.1.x 193KB base)"
# Budget history: 193KB (v0.1.x) -> ~290KB (markdown editor) ->
# ~348KB (--editor mode: file tree, syntax highlighter, link routing,
# frontmatter + comments). 420KB leaves headroom for the embedded UI
# JS while still being ~120x smaller than an Electron equivalent.
if [ "$SIZE" -gt 430080 ]; then
echo "Binary exceeds 420KB budget"
exit 1
fi

Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to webview-cli. Format follows [Keep a Changelog](https://ke

## [Unreleased]

### Added

- **`--editor <path>` mode** — turns webview-cli into a small native text editor over a directory (or a single file's parent dir):
- **File navigation** — a lazy-expanding file tree sidebar (dotfiles hidden, dirs first). Click a file to open it.
- **Syntax highlighting** — a compact dependency-free tokenizer (js/ts, python, go, rust, swift, json, yaml, bash, sql, css, c-like fallback) colors code files in a live editor and fenced code blocks in markdown previews. Always HTML-escaped (XSS-safe).
- **Markdown preview + link following** — markdown files open with Preview/Source tabs; external `http(s)` links open in the system browser, relative links resolve against the current file and open in the editor.
- **Frontmatter** — a leading `---` YAML block is surfaced as a metadata block atop the preview (and stays editable in the Source view) rather than vanishing.
- **⌘P quick-open** — fuzzy-find a file by name/path across the whole tree and open it.
- **⌘⇧F content search** — grep file contents across the tree (scoped, case-insensitive); results group by file with line numbers, click to open at that line.
- **Edits save to disk in place** (`⌘S`); the window stays open so you can keep navigating and editing.
- **Regular app window** — editor mode runs with `.regular` activation policy (Dock icon + ⌘-Tab switcher), unlike the transient `.accessory` agent-prompt modes.
- **`--comments` flow preserved** — with `--editor --comments`, click a preview block to attach a comment; **Submit** (`⌘↵`) returns `{action:"submit", file, edited_text, comments}` to the agent over stdout and closes, exactly like `--markdown --comments`.
- **Scoped file-I/O bridge** — a `fileOp` WKScriptMessageHandler (`listDir`/`readFile`/`writeFile`/`listAll`/`search`) scoped to the opened root, rejecting `../` and symlink escapes. `listAll` powers ⌘P; `search` powers ⌘⇧F (skips dotfiles + heavy build dirs like `node_modules`, caps walk/results). Also driveable via a `{"type":"fileop",...}` stdin command (the no-GUI CI test seam).
- **Editor test harnesses** — `scripts/editor-runtime-smoke.mjs` (jsdom: tree, open/edit/save, highlighting, links, frontmatter, comments) and `scripts/editor-smoke.sh` (binary: the `fileop` stdin protocol + path-escape rejection against a temp dir), both wired into `make test` + CI.

### Fixed

- **YAML frontmatter in `--markdown` mode** — a leading `---\n...\n---\n` block at the top of the input no longer renders as a stray `<hr>` + a setext `<h2>` containing the YAML keys. It's stripped from the preview while the Source tab still shows it unchanged and `data-src-start` / `data-src-end` annotations on downstream blocks stay anchored to the original line numbers (so comment payloads reference the lines the user actually sees).
Expand Down
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ install: $(BINARY)
test: $(BINARY)
@python3 scripts/check-js-syntax.py || (echo "FAIL: embedded JS has syntax errors (see above)" && exit 1)
@node scripts/runtime-smoke.mjs || (echo "FAIL: runtime smoke (see above) — embedded JS functionally broken" && exit 1)
@node scripts/editor-runtime-smoke.mjs || (echo "FAIL: editor runtime smoke (see above) — editor JS functionally broken" && exit 1)
@./$(BINARY) --help 2>&1 >/dev/null | head -1 | grep -q Usage && echo "PASS: --help prints usage to stderr" || (echo "FAIL: --help" && exit 1)
@echo '{}' | ./$(BINARY) --a2ui --timeout 1 2>/dev/null | grep -q status && echo "PASS: a2ui smoke" || (echo "FAIL: a2ui smoke" && exit 1)
@./$(BINARY) --url "not-a-valid-url" 2>/dev/null | grep -q '"error"' && echo "PASS: invalid URL emits error JSON" || (echo "FAIL: invalid URL" && exit 1)
Expand All @@ -31,4 +32,9 @@ test: $(BINARY)
@echo '{"surfaceUpdate":{"components":[{"id":"root","component":{"Column":{"children":{"explicitList":["doc","btn"]}}}},{"id":"doc","component":{"MarkdownDoc":{"fieldName":"review","text":"# Hi\n\nHello."}}},{"id":"btn","component":{"Button":{"label":{"literalString":"OK"},"action":{"name":"ok"}}}}]}}{"beginRendering":{"root":"root"}}' | ./$(BINARY) --a2ui --timeout 1 2>&1 | grep -qv '"error"' && echo "PASS: MarkdownDoc renders without error" || (echo "FAIL: MarkdownDoc errored" && exit 1)
@echo '# Hi' | ./$(BINARY) --markdown --timeout 1 2>/dev/null | grep -qv '"error"' && echo "PASS: --markdown stdin test with heading renders" || (echo "FAIL: --markdown stdin rendering" && exit 1)
@printf '' | ./$(BINARY) --markdown --timeout 1 2>/dev/null | grep -q 'no markdown provided on stdin' && echo "PASS: empty markdown stdin yields error message" || (echo "FAIL: empty markdown stdin" && exit 1)
@./$(BINARY) --editor 2>&1 | grep -q "requires a path" && echo "PASS: --editor with no path rejects" || (echo "FAIL: --editor no path" && exit 1)
@./$(BINARY) --editor /tmp --a2ui 2>&1 | grep -q "mutually exclusive" && echo "PASS: --editor --a2ui rejects" || (echo "FAIL: --editor --a2ui mutual exclusion" && exit 1)
@./$(BINARY) --editor /tmp --markdown 2>&1 | grep -q "mutually exclusive" && echo "PASS: --editor --markdown rejects" || (echo "FAIL: --editor --markdown mutual exclusion" && exit 1)
@strings $(BINARY) | grep -q "__editorBoot" && echo "PASS: editor JS embedded in binary" || (echo "FAIL: editor JS not embedded" && exit 1)
@bash scripts/editor-smoke.sh ./$(BINARY) || (echo "FAIL: editor smoke (see above)" && exit 1)
@echo "All smoke tests pass"
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,23 @@ Review a spec AND pick options in one window. Comments, edits, and form fields t

</table>

## Editor mode (`--editor`)

```bash
webview-cli --editor ./docs # open a directory
webview-cli --editor ./notes/spec.md # open a single file (its dir becomes the tree root)
```

Turns the same tiny binary into a native text editor — a file-tree sidebar, syntax highlighting, markdown preview with clickable links, and frontmatter shown as a metadata block. Edits save to disk in place (`⌘S`); the window stays open so you keep navigating.

- **File tree** — lazy-expanding sidebar (dotfiles hidden, directories first). Click to open.
- **⌘P quick-open** — fuzzy-find any file by name/path and jump to it. **⌘⇧F** — search file contents across the tree; results group by file with line numbers, click to open at the match.
- **Syntax highlighting** — code files open in a live highlighted editor; fenced code blocks in markdown previews are colored too. ~12 languages + a c-like fallback, all HTML-escaped.
- **Markdown** — Preview/Source tabs. External `http(s)` links open in your browser; relative links (`./other.md`) open in the editor. A leading `---` YAML frontmatter block is surfaced as metadata, not hidden.
- **Review flow preserved** — add `--comments` to click a preview block and attach feedback; **Submit** (`⌘↵`) returns `{action:"submit", file, edited_text, comments}` on stdout and closes, just like `--markdown --comments`.

All filesystem access is scoped to the opened root — `../` and symlink escapes are rejected. The editor talks to the binary over a small `fileOp` bridge (`listDir`/`readFile`/`writeFile`), which is also driveable via stdin (`{"type":"fileop",...}`) for scripting and tests.

<details>
<summary><b>What the raw A2UI JSONL looks like</b> (click to expand — the skill writes this for you)</summary>

Expand Down
39 changes: 35 additions & 4 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,25 @@ The binary **blocks** until the user interacts with the window, the window is cl
## Command-line arguments

```
webview-cli [--url <url>] [--a2ui] [--markdown] [options]
webview-cli [--url <url>] [--a2ui] [--markdown] [--editor <path>] [options]

Options:
--url <url> URL to open (http/https/file/agent). Required unless --a2ui or --markdown.
--url <url> URL to open (http/https/file/agent). Required unless --a2ui/--markdown/--editor.
--a2ui A2UI mode: read A2UI v0.8 JSONL from stdin and render.
--markdown Markdown mode: read markdown from stdin and render with optional review UI.
--editor <path> Editor mode: open a file or directory with a file tree, syntax
highlighting, markdown preview + link following. Edits save in place.
--title <string> Window title (default: "webview-cli")
--width <int> Window width in pixels (default: 1024)
--height <int> Window height in pixels (default: 768)
--timeout <int> Auto-close after N seconds. 0 = no timeout (default: 0).
--comments Enable comment UI (inline + doc-level). Requires --markdown. Default: false.
--comments Enable comment UI (inline + doc-level). Used with --markdown or --editor. Default: false.
--edits Enable source editor tab. Requires --markdown. Default: false.
--allow-html Pass raw HTML through. Default strips <script>/<iframe>/handlers. Default: false.
--help, -h Show usage and exit.
```

**Note:** `--markdown`, `--a2ui`, and `--url` are mutually exclusive. Exactly one must be specified.
**Note:** `--markdown`, `--a2ui`, `--editor`, and `--url` are mutually exclusive. Exactly one must be specified.

## Stdin protocol

Expand Down Expand Up @@ -56,6 +58,35 @@ When `--comments` is on, the rendered preview includes inline comment anchors on

See [Markdown mode](#markdown-mode) below for the full interaction model and output shape.

### `--editor` mode

Opens `<path>` as a text editor. If `<path>` is a file, its parent directory becomes the tree root and the file is selected on launch. The window stays open; edits save to disk on `⌘S`. Closing the window exits with `cancelled` (no auto-save).

```bash
webview-cli --editor ./docs --title "Docs" --timeout 0
webview-cli --editor ./notes/spec.md --comments # review flow: Submit returns JSON
```

All filesystem access is scoped to the resolved root — paths containing `../` or symlinks that resolve outside the root are rejected.

**File-I/O bridge.** The editor JS talks to the binary over a `fileOp` `WKScriptMessageHandler`: it posts `{id, op, path, content?}` and the binary replies via `window.__fileOpReply(id, <json>)`. `op` is one of `listDir` / `readFile` / `writeFile`. The same operations are driveable on **stdin** for scripting and tests — emit one `fileop` command and the binary prints the result to stdout and exits:

```bash
echo '{"type":"fileop","op":"listDir","path":""}' | webview-cli --editor ./docs --timeout 3
echo '{"type":"fileop","op":"readFile","path":"readme.md"}' | webview-cli --editor ./docs --timeout 3
echo '{"type":"fileop","op":"writeFile","path":"a.txt","content":"hi"}' | webview-cli --editor ./docs --timeout 3
```

| `op` | Fields | Result `data` |
|------|--------|---------------|
| `listDir` | `path` (relative to root, `""` = root) | `{ok, path, entries:[{name, path, type:"dir"\|"file"}]}` — dirs first, dotfiles hidden |
| `readFile` | `path` | `{ok, path, content}` — UTF-8 only, 4MB cap; `{ok:false, binary:true}` otherwise |
| `writeFile` | `path`, `content` | `{ok, path}` (atomic write) |
| `listAll` | — | `{ok, files:[relpath...], truncated}` — flat recursive list for ⌘P (dotfiles + heavy build dirs skipped, capped at 5000) |
| `search` | `query` | `{ok, matches:[{path, line, text}], truncated}` — case-insensitive content grep for ⌘⇧F (text files <1MB, capped at 500 matches) |

Any escaping path returns `{ok:false, error:"path escapes root"}`; `listAll`/`search` only ever walk within the root. **Keyboard:** `⌘S` save, `⌘P` quick-open palette, `⌘⇧F` content search, `⌘↵` submit (comments mode). With `--comments`, the **Submit** action posts `{action:"submit", file, edited_text, comments}` through the standard `complete` bridge (see [Stdout contract](#stdout-contract)) and exits — identical in shape to `--markdown --comments`.

### `agent://` scheme (URL mode)

When using `--url`, you can pipe JSON commands on stdin to load in-process resources served via the `agent://` URL scheme. This lets an agent push arbitrary HTML/CSS/JS into the webview without an HTTP server.
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-js-syntax.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ def main() -> int:
("a2uiRendererJS", extract_plain(content, "a2uiRendererJS")),
("markdownRendererJS", extract_raw(content, "markdownRendererJS")),
("micromarkJS", extract_raw(content, "micromarkJS")),
("editorJS", extract_raw(content, "editorJS")),
("highlightJS", extract_raw(content, "highlightJS")),
]

ok = True
Expand Down
Loading
Loading