turbo-editors/turbo-corepublic Fork 0
fe23288
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

📦 Turbo Core — files rewritten outside reload into their windows; the agent window wraps what you type and keeps a long paste aside as a token (#28); configrepo fetches a shared .turbo-<slug> from a forge URL (#25)

k33g committed 2026-09-20T09:13:22+02:00 Browse files
fe23288 parent: 3561e52
added .memory/handoffs/2026-09-20-reload-on-disk-change.md +51 -0
new file mode 100644
@@ -0,0 +1,51 @@
1+# Handoff — 2026-09-20 — files changed on disk reload into their windows (v1.0.3)
2+
3+## State
4+
5+- Feature in place and green: `app/watch.go`, wired into `tick` and `Run`; `reloadFromDisk` shared with the tools reload. Ten tests, `make check` green, `go test -race ./app` green, quality gate #31 PASS.
6+- `release.env` says `TAG="v1.0.3"`. **Not committed, not tagged.**
7+
8+## Next steps
9+
10+1. Review the diff (`git diff`, `git status`), commit, then `./01-release.tag.sh` from a machine that reaches Rickub.
11+2. Re-pin every editor: `go get rickub.com/turbo-editors/turbo-core@v1.0.3 && go mod tidy && GOWORK=off make check`, then each `./01-release.tag.sh`.
12+3. When re-pinning, each editor's user docs could gain one paragraph: files rewritten outside the editor are reloaded; windows with unsaved changes are kept and told. turbo-go's `docs/*/explanation/go-tools.md` and `how-to/run-go-commands.md` already describe the narrower tools reload and are the natural place.
13+
14+## Traps
15+
16+- The stamp is size + mtime. A test that rewrites a file with content of the **same length within the same second** may be invisible on a filesystem with 1 s mtime granularity — every test in `watch_test.go` changes the length for that reason. Do the same in editor-level tests.
17+- `watchFiles` is started by `Run` only. A test driving `tick()` by hand gets no wake and needs none; a test of the wake sets `a.fileWatchInterval` short and polls the simulation screen for `*tcell.EventInterrupt` (`wokeWithin`).
18+- Anything that writes a file on the loop's behalf must call `a.stampFile(path)` afterwards or the next turn will re-read it (harmless — `Reload` compares text — but a wasted read, and a `Reloaded` message would not appear since nothing changed).
19+- Not run against a real terminal in this session (no TTY in the sandbox): the wake → reload → redraw chain is asserted with the simulation screen, never watched.
20+
21+## Also this session
22+
23+The umbrella `turbo-editors/README.md` (one directory up, its own git repository with no `.memory/`) gained a section on testing a turbo-core change from the editors through a root `go.work`. Note the root has no `.gitignore`, so a `go.work` created there shows up as untracked — the section says not to commit it.
24+
25+## Also this session — the agent window's box wraps (same v1.0.3)
26+
27+- `acp/input.go` + `acp/input_test.go`; `drawInput` and the `↑`/`↓` cases in `view_events.go` changed. Everything green (`make check`, quality #32).
28+- **Trap for tests**: a view 30 wide gives rows of 27 runes; the box's first screen row in a 12-high view is row 9. `drawWithCursor` in `input_test.go` is the helper to reuse — the old `draw` does not report the cursor, and the cursor is where the old defect showed.
29+- **Trap for the layout**: the row that breaks keeps its space (`end` is one past it), so a row can be `width+1` runes; `TextLimited` is given `width+1` for that reason. Do not "fix" that to `width` — it would put `…` back.
30+- Not tried on a real terminal (no TTY here). Mouse clicks in the box still only give it the focus; they do not place the cursor — unchanged, and worth doing if somebody asks.
31+- The editors' agent-window docs (turbo-go `docs/*/reference/acp.md`, `how-to/talk-to-an-agent.md`, and the same files in the other editors) list the box's keys; when re-pinning, a line saying the box wraps and `↑↓` move by row belongs there.
32+
33+## Also this session — ticket #28, pastes into the agent window (same v1.0.3)
34+
35+- Done and green: `acp/paste.go`, `acp/paste_test.go`, `app/paste.go`, `app/paste_test.go`; `Session.PromptWith`; bracketed paste enabled for the whole editor. Quality #34 PASS.
36+- **Ticket `.tickets/issues/28.yaml` is still `state: open`** — close it once tried in a real terminal.
37+- **Not tried in a real terminal** (no TTY here). Two things to check first: that the terminal actually brackets (iTerm2, Terminal.app, kitty, WezTerm, GNOME Terminal all do; tmux needs `set -g set-clipboard`/passes `\e[200~` through by default), and that a paste with tabs and CRLF lands as one token with the right line count.
38+- **Behaviour to know**: a single-line paste ≤ 200 runes is typed inline (a path, a command); anything longer or with a newline is a token. The thresholds are `pasteInlineLimit` in `acp/paste.go`. A token longer than the box's row wraps at its `·` spaces — ugly but correct; making it unbreakable was left out.
39+- **Trap**: `app/app.go` sits just under qlty's file-complexity threshold (50 → smell). Anything new for the event loop should go in its own file, as `paste.go` did.
40+- The editors' agent-window docs (turbo-go `docs/*/reference/acp.md`, `how-to/talk-to-an-agent.md`) should gain: the box wraps, `↑↓` move by row, a long paste becomes a token that Backspace removes whole, Ctrl-V / Shift-Ins paste the editor's clipboard.
41+
42+## Also this session — ticket #25, `configrepo` and `-load-config` in every editor (same v1.0.3)
43+
44+- `configrepo/` here; `-load-config` in each editor's `main.go`. Editors' `go.mod` still pin v1.0.2: **release order matters** — tag turbo-core v1.0.3 first, then in each editor `go get rickub.com/turbo-editors/turbo-core@v1.0.3 && go mod tidy && GOWORK=off make check`, then their releases.
45+- To rebuild everything against this checkout meanwhile: from `turbo-editors/`, `go work init ./turbo-core ./turbo-go ./turbo-rust ./turbo-python ./turbo-moonbit ./turbo-golo ./turbo-js ./turbo-go/demo ./turbo-go/bin` — the two nested modules must be listed or `go` fails inside them. Remove `go.work` afterwards; the root has no `.gitignore`.
46+- The sample repository holds only `.turbo-go`; a `-load-config` from turbo-rust against it correctly ends in `ErrNotFound`. Add a `rust-init/.turbo-rust` there to try the others.
47+- `rickub.com/api/v1` wants a PAT even for public reads (measured 401). If that ever changes, an API path could replace the clone; nothing else in the design depends on git.
48+- Ticket #25 is closed in `.tickets/issues/0025-turbo-load-config.yaml`; #28 is still open (left for the user to try first).
49+- **Note on the workspace**: turbo-go already carries its own `go.work` (`.` + `../turbo-core`, dated 2026-09-19, gitignored), so it builds against this checkout on its own; the root workspace is only needed for the five others. Final verification was done that way: root `go.work` over `turbo-core` and the five, removed afterwards.
50+- **Observed, not caused by this session as far as can be told**: during the last test passes the tracked files `turbo-go/demo/{README.md,go.mod,index.html,index.js,main.go}` disappeared from the working tree (git shows `D`; dotfiles in `demo/` survived; no git operation in the reflog; the tree is a kDrive-synced folder on which the user was active — tickets changed at the same time). A 6.9 MB `turbo-go/demo-golang` binary appeared at the same moment. **Nothing was restored**: `git checkout -- demo` in turbo-go puts the files back if the deletion was not deliberate.
51+
new file mode 100644
@@ -0,0 +1,51 @@
1+# Handoff — 2026-09-20 — files changed on disk reload into their windows (v1.0.3)
2+
3+## State
4+
5+- Feature in place and green: `app/watch.go`, wired into `tick` and `Run`; `reloadFromDisk` shared with the tools reload. Ten tests, `make check` green, `go test -race ./app` green, quality gate #31 PASS.
6+- `release.env` says `TAG="v1.0.3"`. **Not committed, not tagged.**
7+
8+## Next steps
9+
10+1. Review the diff (`git diff`, `git status`), commit, then `./01-release.tag.sh` from a machine that reaches Rickub.
11+2. Re-pin every editor: `go get rickub.com/turbo-editors/turbo-core@v1.0.3 && go mod tidy && GOWORK=off make check`, then each `./01-release.tag.sh`.
12+3. When re-pinning, each editor's user docs could gain one paragraph: files rewritten outside the editor are reloaded; windows with unsaved changes are kept and told. turbo-go's `docs/*/explanation/go-tools.md` and `how-to/run-go-commands.md` already describe the narrower tools reload and are the natural place.
13+
14+## Traps
15+
16+- The stamp is size + mtime. A test that rewrites a file with content of the **same length within the same second** may be invisible on a filesystem with 1 s mtime granularity — every test in `watch_test.go` changes the length for that reason. Do the same in editor-level tests.
17+- `watchFiles` is started by `Run` only. A test driving `tick()` by hand gets no wake and needs none; a test of the wake sets `a.fileWatchInterval` short and polls the simulation screen for `*tcell.EventInterrupt` (`wokeWithin`).
18+- Anything that writes a file on the loop's behalf must call `a.stampFile(path)` afterwards or the next turn will re-read it (harmless — `Reload` compares text — but a wasted read, and a `Reloaded` message would not appear since nothing changed).
19+- Not run against a real terminal in this session (no TTY in the sandbox): the wake → reload → redraw chain is asserted with the simulation screen, never watched.
20+
21+## Also this session
22+
23+The umbrella `turbo-editors/README.md` (one directory up, its own git repository with no `.memory/`) gained a section on testing a turbo-core change from the editors through a root `go.work`. Note the root has no `.gitignore`, so a `go.work` created there shows up as untracked — the section says not to commit it.
24+
25+## Also this session — the agent window's box wraps (same v1.0.3)
26+
27+- `acp/input.go` + `acp/input_test.go`; `drawInput` and the `↑`/`↓` cases in `view_events.go` changed. Everything green (`make check`, quality #32).
28+- **Trap for tests**: a view 30 wide gives rows of 27 runes; the box's first screen row in a 12-high view is row 9. `drawWithCursor` in `input_test.go` is the helper to reuse — the old `draw` does not report the cursor, and the cursor is where the old defect showed.
29+- **Trap for the layout**: the row that breaks keeps its space (`end` is one past it), so a row can be `width+1` runes; `TextLimited` is given `width+1` for that reason. Do not "fix" that to `width` — it would put `…` back.
30+- Not tried on a real terminal (no TTY here). Mouse clicks in the box still only give it the focus; they do not place the cursor — unchanged, and worth doing if somebody asks.
31+- The editors' agent-window docs (turbo-go `docs/*/reference/acp.md`, `how-to/talk-to-an-agent.md`, and the same files in the other editors) list the box's keys; when re-pinning, a line saying the box wraps and `↑↓` move by row belongs there.
32+
33+## Also this session — ticket #28, pastes into the agent window (same v1.0.3)
34+
35+- Done and green: `acp/paste.go`, `acp/paste_test.go`, `app/paste.go`, `app/paste_test.go`; `Session.PromptWith`; bracketed paste enabled for the whole editor. Quality #34 PASS.
36+- **Ticket `.tickets/issues/28.yaml` is still `state: open`** — close it once tried in a real terminal.
37+- **Not tried in a real terminal** (no TTY here). Two things to check first: that the terminal actually brackets (iTerm2, Terminal.app, kitty, WezTerm, GNOME Terminal all do; tmux needs `set -g set-clipboard`/passes `\e[200~` through by default), and that a paste with tabs and CRLF lands as one token with the right line count.
38+- **Behaviour to know**: a single-line paste ≤ 200 runes is typed inline (a path, a command); anything longer or with a newline is a token. The thresholds are `pasteInlineLimit` in `acp/paste.go`. A token longer than the box's row wraps at its `·` spaces — ugly but correct; making it unbreakable was left out.
39+- **Trap**: `app/app.go` sits just under qlty's file-complexity threshold (50 → smell). Anything new for the event loop should go in its own file, as `paste.go` did.
40+- The editors' agent-window docs (turbo-go `docs/*/reference/acp.md`, `how-to/talk-to-an-agent.md`) should gain: the box wraps, `↑↓` move by row, a long paste becomes a token that Backspace removes whole, Ctrl-V / Shift-Ins paste the editor's clipboard.
41+
42+## Also this session — ticket #25, `configrepo` and `-load-config` in every editor (same v1.0.3)
43+
44+- `configrepo/` here; `-load-config` in each editor's `main.go`. Editors' `go.mod` still pin v1.0.2: **release order matters** — tag turbo-core v1.0.3 first, then in each editor `go get rickub.com/turbo-editors/turbo-core@v1.0.3 && go mod tidy && GOWORK=off make check`, then their releases.
45+- To rebuild everything against this checkout meanwhile: from `turbo-editors/`, `go work init ./turbo-core ./turbo-go ./turbo-rust ./turbo-python ./turbo-moonbit ./turbo-golo ./turbo-js ./turbo-go/demo ./turbo-go/bin` — the two nested modules must be listed or `go` fails inside them. Remove `go.work` afterwards; the root has no `.gitignore`.
46+- The sample repository holds only `.turbo-go`; a `-load-config` from turbo-rust against it correctly ends in `ErrNotFound`. Add a `rust-init/.turbo-rust` there to try the others.
47+- `rickub.com/api/v1` wants a PAT even for public reads (measured 401). If that ever changes, an API path could replace the clone; nothing else in the design depends on git.
48+- Ticket #25 is closed in `.tickets/issues/0025-turbo-load-config.yaml`; #28 is still open (left for the user to try first).
49+- **Note on the workspace**: turbo-go already carries its own `go.work` (`.` + `../turbo-core`, dated 2026-09-19, gitignored), so it builds against this checkout on its own; the root workspace is only needed for the five others. Final verification was done that way: root `go.work` over `turbo-core` and the five, removed afterwards.
50+- **Observed, not caused by this session as far as can be told**: during the last test passes the tracked files `turbo-go/demo/{README.md,go.mod,index.html,index.js,main.go}` disappeared from the working tree (git shows `D`; dotfiles in `demo/` survived; no git operation in the reflog; the tree is a kDrive-synced folder on which the user was active — tickets changed at the same time). A 6.9 MB `turbo-go/demo-golang` binary appeared at the same moment. **Nothing was restored**: `git checkout -- demo` in turbo-go puts the files back if the deletion was not deliberate.
51+
modified .memory/history.md +17 -0
@@ -287,3 +287,20 @@
287287 - **Verified**: `make check` green; whole suite green with `TMPDIR` under a symlink. In turbo-moonbit (through `go.work`), the rewritten `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP` passes (plain and symlinked `TMPDIR`) and **fails against the published v1.0.1** — the falsification. A first version of that test failed for a reason of its own: its `typeText` dropped `\n`, so the four-line fixture became one `///|` doc-comment line and compiled; traced with a tee wrapper around `moon-lsp`. The helper now sends Enter for a newline.
288288 - **Docs**: `app/README.md`, `docs/{en,fr}/reference/app.md`, `docs/{en,fr}/how-to/talk-to-a-language-server.md`, `lsp/README.md`.
289289 - **Not done**: not committed, not tagged; `release.env` says v1.0.2. Whether moon-lsp on macOS watches the directory itself is inferred from the user's run, not measured.
290+
291+## 2026-09-20 — a file changed by another program is reloaded into its open window
292+
293+- **Asked**: when a program outside the editor modifies a file that is open in a window, the window should show the change; until now it did not, short of closing and reopening the file (in French).
294+- **Found**: `buffer.Reload` and `reloadAfterTools` already existed, but only ran when a command from the tools menu ended. Nothing looked at the disk otherwise, and the loop sleeps in `PollEvent` between keystrokes.
295+- **Changed**: new `app/watch.go` — `reloadChangedFiles` in `tick` (stamp per open file, canonical key, pruned when windows close), `fileChangedOnDisk`, `reloadFromDisk` (shared with `reloadUnmodifiedBuffers` in `toolchain.go`, now also sending `didChange`), `stampFile`, `fileWatch` + `watchFiles` goroutine started by `Run`. `app.go`: three fields, `tick` step, `Run` starts/stops the watcher. `actions_file.go`: `openBuffer` and `afterSave` stamp. Docs: `app/README.md`, `docs/{en,fr}/reference/app.md`. `release.env` → v1.0.3.
296+- **Decisions**: stat-based stamps, not fsnotify; watcher goroutine wakes only on a difference, not a bare ticker; modified buffers kept with a status-bar notice once per change; vanished files silent. See `summary.md`.
297+- **Tests**: 10 new in `app/watch_test.go`, 6 falsified by commenting the `tick` step out. `make check` green across all 18 packages (including `release_test.go`, which passed in this sandbox this time). `go test -race ./app` green.
298+- **Quality gate**: run #31, PASS, 0 lint, 0 smells.
299+- **Not done**: not committed, not tagged; editors not re-pinned; the editors' own user docs (e.g. turbo-go's `go-tools.md`, which describes the tools reload) do not mention the new behaviour.
300+- **Method note**: the user is not watching this session, so the plan-approval gate of `methodical-dev` was not held; the scope was taken as stated.
301+- **Later, same session**: added a "How to test a turbo-core change without publishing it" section to the umbrella `turbo-editors/README.md` (the directory above this repository): root `go.work` over `./turbo-core` and the editors, the `go list -f '{{.Dir}}'` check, the nested-module trap (`turbo-go/demo`, `turbo-go/bin`), `GOWORK=off`, and the publish sequence. Verified by actually creating the workspace: turbo-go built against this uncommitted checkout, then the workspace was removed.
302+- **Later again, same session — the agent window's box wraps**: asked (in French) for word wrap in the ACP window's input area. Found `drawInput` drawing each typed line with `TextLimited` (cut with `…`) and `ShowCursor` hiding a cursor past the frame. Added `acp/input.go` (`inputRow`, `inputWidth`, `inputRows`, `wrapInputLine`, `inputBreak`, `inputCursor`, `moveInputRow`); `drawInput` rewritten over rows; `↑`/`↓` in `handleInputKey` now `moveInputRow`, `clampColumn` gone. Eight tests in `acp/input_test.go` with a `drawWithCursor` helper reading the simulation screen's cursor; one expectation of mine was wrong (column kept across a row move lands mid-word) and the test was corrected, not the code. `make check` green, quality gate #32 PASS, 0 smells. `acp/README.md` documents it; `release.env` ABOUT now names both changes. Still not committed.
303+- **Later again, same session — ticket #28, pastes into the agent window**: asked to implement `.tickets/issues/28.yaml` (a long paste becomes `[Pasted #1 · 6 lines · 700 chars]` in the box; the model gets the text). Found bracketed paste was never enabled anywhere — a terminal paste arrived as keystrokes, so each newline *sent* a prompt — and the agent view had no way to read the editor's clipboard. Added `acp/paste.go` (+ `Paste` type, `View.Paste`, `OnPaste`, token editing, `expandPastes`), `Session.PromptWith` with `pending.pastes`, `drawInputRow`, `app/paste.go` (`handlePaste`, `pastedText`), `screen.EnablePaste()` in `New`, `handleKey` collecting during a paste, `App.Paste` routing to the agent box, `view.OnPaste = a.clipboard.Text`. Tests: 16 in `acp/paste_test.go`, 5 in `app/paste_test.go`; two of mine had the trace's length wrong (151, not 140) and were corrected. `make check` green, race green, quality #33 FAIL (file complexity of `app/app.go` at 50) → paste handling moved to `app/paste.go` → #34 PASS, 0 smells. Docs: `acp/README.md`, `app/README.md`. Ticket left `open` for the user to close after trying it. Still not committed.
304+- **Correction to the #28 entry above**: bracketed paste *was* enabled — by each editor's own `newScreen` (`screen.EnablePaste()` in `main.go`) — but `app.handle` ignored the `EventPaste` marks, so the keys still streamed through and every Enter sent. What was missing was the handling, not the enabling; `app.New` now enables it too so the library needs nothing from the command.
305+- **Later again, same session — ticket #25, `-load-config <url>`**: the user created `rickub.com/turbo-editors/configs` (directories `golang-init`, `golang-init-with-agents`, each with a `.turbo-go`) and asked that `turbo-go --load-config <tree URL>` copy that `.turbo-go` into the working directory, `.turbo-rust` for turbo-rust, etc.; mid-way, that the URL may be GitHub, GitLab or Codeberg too. Probed rickub from the sandbox: `raw/<ref>/<path>` works, `/api/v1/…/contents` is 401 without a token, no archive endpoint, git smart HTTP on `git.rickub.com` only. Chose git. New `configrepo` package (`configrepo.go`, `git.go`, `copy.go`, README), 15 tests incl. a gated network one that passed. Wired into all six editors by one script (main.go flag + `loadConfig`/`describeLoad`, 3 tests each, `cli.md` EN/FR rows + example + files + errors, `configure-a-project.md` EN/FR new section, each `.memory` history/handoff/summary). Docs here: `README.md`, `docs/{en,fr}/reference/packages.md` (nineteen packages), summary table. Ticket #25 set to `closed` with a "Done" note in its body. Editors verified with `make check` under a root `go.work` listing the six editors and `turbo-go/demo`, `turbo-go/bin` (nested modules); the workspace was removed afterwards. Still nothing committed anywhere.
306+
@@ -287,3 +287,20 @@
287 - **Verified**: `make check` green; whole suite green with `TMPDIR` under a symlink. In turbo-moonbit (through `go.work`), the rewritten `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP` passes (plain and symlinked `TMPDIR`) and **fails against the published v1.0.1** — the falsification. A first version of that test failed for a reason of its own: its `typeText` dropped `\n`, so the four-line fixture became one `///|` doc-comment line and compiled; traced with a tee wrapper around `moon-lsp`. The helper now sends Enter for a newline.287 - **Verified**: `make check` green; whole suite green with `TMPDIR` under a symlink. In turbo-moonbit (through `go.work`), the rewritten `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP` passes (plain and symlinked `TMPDIR`) and **fails against the published v1.0.1** — the falsification. A first version of that test failed for a reason of its own: its `typeText` dropped `\n`, so the four-line fixture became one `///|` doc-comment line and compiled; traced with a tee wrapper around `moon-lsp`. The helper now sends Enter for a newline.
288 - **Docs**: `app/README.md`, `docs/{en,fr}/reference/app.md`, `docs/{en,fr}/how-to/talk-to-a-language-server.md`, `lsp/README.md`.288 - **Docs**: `app/README.md`, `docs/{en,fr}/reference/app.md`, `docs/{en,fr}/how-to/talk-to-a-language-server.md`, `lsp/README.md`.
289 - **Not done**: not committed, not tagged; `release.env` says v1.0.2. Whether moon-lsp on macOS watches the directory itself is inferred from the user's run, not measured.289 - **Not done**: not committed, not tagged; `release.env` says v1.0.2. Whether moon-lsp on macOS watches the directory itself is inferred from the user's run, not measured.
290+
291+## 2026-09-20 — a file changed by another program is reloaded into its open window
292+
293+- **Asked**: when a program outside the editor modifies a file that is open in a window, the window should show the change; until now it did not, short of closing and reopening the file (in French).
294+- **Found**: `buffer.Reload` and `reloadAfterTools` already existed, but only ran when a command from the tools menu ended. Nothing looked at the disk otherwise, and the loop sleeps in `PollEvent` between keystrokes.
295+- **Changed**: new `app/watch.go` — `reloadChangedFiles` in `tick` (stamp per open file, canonical key, pruned when windows close), `fileChangedOnDisk`, `reloadFromDisk` (shared with `reloadUnmodifiedBuffers` in `toolchain.go`, now also sending `didChange`), `stampFile`, `fileWatch` + `watchFiles` goroutine started by `Run`. `app.go`: three fields, `tick` step, `Run` starts/stops the watcher. `actions_file.go`: `openBuffer` and `afterSave` stamp. Docs: `app/README.md`, `docs/{en,fr}/reference/app.md`. `release.env` → v1.0.3.
296+- **Decisions**: stat-based stamps, not fsnotify; watcher goroutine wakes only on a difference, not a bare ticker; modified buffers kept with a status-bar notice once per change; vanished files silent. See `summary.md`.
297+- **Tests**: 10 new in `app/watch_test.go`, 6 falsified by commenting the `tick` step out. `make check` green across all 18 packages (including `release_test.go`, which passed in this sandbox this time). `go test -race ./app` green.
298+- **Quality gate**: run #31, PASS, 0 lint, 0 smells.
299+- **Not done**: not committed, not tagged; editors not re-pinned; the editors' own user docs (e.g. turbo-go's `go-tools.md`, which describes the tools reload) do not mention the new behaviour.
300+- **Method note**: the user is not watching this session, so the plan-approval gate of `methodical-dev` was not held; the scope was taken as stated.
301+- **Later, same session**: added a "How to test a turbo-core change without publishing it" section to the umbrella `turbo-editors/README.md` (the directory above this repository): root `go.work` over `./turbo-core` and the editors, the `go list -f '{{.Dir}}'` check, the nested-module trap (`turbo-go/demo`, `turbo-go/bin`), `GOWORK=off`, and the publish sequence. Verified by actually creating the workspace: turbo-go built against this uncommitted checkout, then the workspace was removed.
302+- **Later again, same session — the agent window's box wraps**: asked (in French) for word wrap in the ACP window's input area. Found `drawInput` drawing each typed line with `TextLimited` (cut with `…`) and `ShowCursor` hiding a cursor past the frame. Added `acp/input.go` (`inputRow`, `inputWidth`, `inputRows`, `wrapInputLine`, `inputBreak`, `inputCursor`, `moveInputRow`); `drawInput` rewritten over rows; `↑`/`↓` in `handleInputKey` now `moveInputRow`, `clampColumn` gone. Eight tests in `acp/input_test.go` with a `drawWithCursor` helper reading the simulation screen's cursor; one expectation of mine was wrong (column kept across a row move lands mid-word) and the test was corrected, not the code. `make check` green, quality gate #32 PASS, 0 smells. `acp/README.md` documents it; `release.env` ABOUT now names both changes. Still not committed.
303+- **Later again, same session — ticket #28, pastes into the agent window**: asked to implement `.tickets/issues/28.yaml` (a long paste becomes `[Pasted #1 · 6 lines · 700 chars]` in the box; the model gets the text). Found bracketed paste was never enabled anywhere — a terminal paste arrived as keystrokes, so each newline *sent* a prompt — and the agent view had no way to read the editor's clipboard. Added `acp/paste.go` (+ `Paste` type, `View.Paste`, `OnPaste`, token editing, `expandPastes`), `Session.PromptWith` with `pending.pastes`, `drawInputRow`, `app/paste.go` (`handlePaste`, `pastedText`), `screen.EnablePaste()` in `New`, `handleKey` collecting during a paste, `App.Paste` routing to the agent box, `view.OnPaste = a.clipboard.Text`. Tests: 16 in `acp/paste_test.go`, 5 in `app/paste_test.go`; two of mine had the trace's length wrong (151, not 140) and were corrected. `make check` green, race green, quality #33 FAIL (file complexity of `app/app.go` at 50) → paste handling moved to `app/paste.go` → #34 PASS, 0 smells. Docs: `acp/README.md`, `app/README.md`. Ticket left `open` for the user to close after trying it. Still not committed.
304+- **Correction to the #28 entry above**: bracketed paste *was* enabled — by each editor's own `newScreen` (`screen.EnablePaste()` in `main.go`) — but `app.handle` ignored the `EventPaste` marks, so the keys still streamed through and every Enter sent. What was missing was the handling, not the enabling; `app.New` now enables it too so the library needs nothing from the command.
305+- **Later again, same session — ticket #25, `-load-config <url>`**: the user created `rickub.com/turbo-editors/configs` (directories `golang-init`, `golang-init-with-agents`, each with a `.turbo-go`) and asked that `turbo-go --load-config <tree URL>` copy that `.turbo-go` into the working directory, `.turbo-rust` for turbo-rust, etc.; mid-way, that the URL may be GitHub, GitLab or Codeberg too. Probed rickub from the sandbox: `raw/<ref>/<path>` works, `/api/v1/…/contents` is 401 without a token, no archive endpoint, git smart HTTP on `git.rickub.com` only. Chose git. New `configrepo` package (`configrepo.go`, `git.go`, `copy.go`, README), 15 tests incl. a gated network one that passed. Wired into all six editors by one script (main.go flag + `loadConfig`/`describeLoad`, 3 tests each, `cli.md` EN/FR rows + example + files + errors, `configure-a-project.md` EN/FR new section, each `.memory` history/handoff/summary). Docs here: `README.md`, `docs/{en,fr}/reference/packages.md` (nineteen packages), summary table. Ticket #25 set to `closed` with a "Done" note in its body. Editors verified with `make check` under a root `go.work` listing the six editors and `turbo-go/demo`, `turbo-go/bin` (nested modules); the workspace was removed afterwards. Still nothing committed anywhere.
306+
modified .memory/summary.md +16 -2
@@ -14,7 +14,7 @@ Extracted from turbo-go on 2026-09-01, at the point a second editor was wanted.
1414
1515 ## Architecture
1616
17-Eighteen packages. Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one.
17+Nineteen packages. Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one.
1818
1919 ```
2020 app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}
@@ -23,6 +23,7 @@ app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, t
2323 filetree → {ui, theme, tcell}
2424 acp → {jsonrpc, profile, projectfile, syntax, theme, ui, tcell, toml}
2525 settings │ snippets │ tools → {toml, projectfile, profile}
26+ configrepo → {projectfile, profile} ← execs git
2627 syntax → theme
2728 ui → theme
2829 lsp → {jsonrpc, profile}
@@ -35,6 +36,7 @@ app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, t
3536 | `profile` | The editor's identity: name, slug, language, tools menu, root markers, server, templates — and every path derived from the slug | **stdlib only** |
3637 | `buffer` | Text of one file: lines of runes, cursor, selection, undo, search, load/save | **stdlib only** |
3738 | `projectfile` | Atomic writes of a project's own TOML files | **stdlib only** |
39+| `configrepo` | A shared `.turbo-<slug>` fetched from a forge URL by a shallow git clone (`-load-config`) | `projectfile`, `profile`, the `git` command |
3840 | `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** |
3941 | `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` |
4042 | `theme` | TOML → tcell styles, nine embedded themes + the user's, two kinds of inheritance | `tcell`, `toml` |
@@ -129,6 +131,14 @@ The decisions inherited from turbo-go about the *editor's behaviour* — the eve
129131
130132 - **A save that creates a file says so to the server (2026-09-19, v1.0.2).** `lsp.Client.FileCreated` sends `workspace/didChangeWatchedFiles` (type 1); `app.announceSaved` sends it after the document's `didOpen`/`didSave` whenever the save created the file (`fileExists` asked before the write, in both `save` and `writeQuietly`). Reason: moon-lsp lists a package's files from the directory, so a `.mbt` saved for the first time — `turbo-moonbit new.mbt`, type, save — was known as a document and never diagnosed. Verified at the protocol level (every order of didOpen/didSave/didChangeWatchedFiles gets the file diagnosed; nothing does without it) and by Turbo MoonBit's `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP`, which fails against v1.0.1 and passes against this. Sent unasked (moon-lsp registers no watcher); harmless to the fake and, by the spec, to any server. Only `Created` is ever sent: Save As leaves the old file on disk, so no `Deleted`.
131133
134+- **A file rewritten by another program is reloaded into its window (2026-09-20, v1.0.3).** `app/watch.go`: `reloadChangedFiles` runs in `tick`, keeps a `fileStamp` (size + mtime, the guard `refreshToolMenus` already used) per open file by canonical path in `a.fileStamps`, and re-reads through `reloadFromDisk` — now shared with the tools reload, so a reload also sends `didChange` to the server, which the tools reload never did. The editor's own writes re-stamp (`openBuffer`, `afterSave`, the reload), so a save is not mistaken for a change. **A modified buffer is never reloaded**; the status bar says `changed on disk; your unsaved changes are kept`, once per change. A file that vanishes is left alone silently (delete-then-write replacers exist). Because the loop sleeps in `PollEvent`, `Run` — and only `Run` — starts a `fileWatch` goroutine that stats the published stamps every `fileWatchInterval` (1 s) and posts a wake when one differs: a nudge only, the loop decides by itself, and an idle editor with unchanged files is never woken. Rejected: fsnotify (a dependency, platform-specific limits, and still needs the state-driven check); a bare 1 Hz ticker (redraws an idle editor forever). Same-second rewrite of the same size is invisible to the stamp — accepted, as every stamp-based editor does. Ten tests in `app/watch_test.go`; six fail with the step removed from `tick`.
135+
136+- **The agent window's box wraps what you type (2026-09-20, v1.0.3).** `acp/input.go`: the typed lines (`View.input`, the ones Alt-Enter makes) stay the model; `inputRows` lays them out as `inputRow{line,start,end}` at `inputWidth()` = `W-3` on every frame and every arrow key, nothing stored. A row breaks *after* the last space that fits (the space stays on the row, undrawn), so rows partition the line's runes and `inputCursor` is unambiguous; the third reserved column is for the cursor just past a full row, which `Painter.ShowCursor` otherwise hides as out of clip. `↑`/`↓` move by visual row (`moveInputRow`), falling through to the transcript off the top; `Home`/`End` stay per logical line. The rows drawn end with the cursor's row; `>` is drawn only when the first row is visible. Before: `TextLimited` cut the line with `…` and the cursor vanished. Rejected: wrapping the model itself (would make Backspace at a wrap join nothing and the wire carry soft breaks); reusing `acp.Wrap` (it trims spaces, losing the rune offsets the cursor needs). Eight tests in `acp/input_test.go`, asserting on the simulation screen's cursor.
137+
138+- **A long paste into the agent window is a token in the box and its text on the wire (2026-09-20, v1.0.3, ticket #28).** `acp/paste.go`: `View.Paste(text)` types a single line of ≤ `pasteInlineLimit` (200) runes as if typed, and otherwise keeps it in `View.pastes` and inserts `[Pasted #N · L lines · C chars]`; `Send` calls the new `Session.PromptWith(text, pastes, mentions…)` — `Prompt` is now a wrapper — which keeps the token text in the transcript and has `expandPastes` replace tokens **inside text blocks only, after `blocksFor`** (so an `@name` in a pasted log is never a mention; a paste is byte for byte). Tokens are found by text (`tokensIn`), never by remembered position; Backspace/Delete remove one whole, `←`/`→` step over, `↑`/`↓` landing inside snap to the nearer edge; drawn in the type style (`drawInputRow`, rune by rune). Numbers run on per window. **Two ways in**: bracketed paste — `app.New` calls `screen.EnablePaste()`, `handleKey` collects keys while `a.pasting`, `handlePaste` (`app/paste.go`) hands an agent window the whole text (`pastedText`: rune/Enter→`\n`/Tab→`\t`) and replays keys to any other window, so files are typed exactly as before — and the editor's clipboard via `View.OnPaste` (Ctrl-V, Shift-Ins) and `App.Paste` (Edit ▸ Paste) routing to the agent's box. Before this, pasting forty lines into the box sent forty prompts, one per Enter. Rejected: expanding tokens in the text before `blocksFor` (mentions read out of pastes); a per-prompt numbering (two `#1`s in one window); making the tokens unbreakable across a wrap (a 34-rune token still wraps at its `·` spaces — accepted). Sixteen tests in `acp/paste_test.go`, five in `app/paste_test.go`.
139+
140+- **A shared configuration is fetched by git, from the URL a forge shows (2026-09-20, v1.0.3, ticket #25).** New package `configrepo`: `Parse` reads rickub/GitHub (`tree/`), GitLab (`-/tree/`, owner keeps its slashes) and Gitea-family (`src/branch|tag|commit/`) URLs, or a bare repository URL; `Load(ctx, profile, url, dest)` runs `git ls-remote` on `https://host/owner/repo.git` then `https://git.host/owner/repo.git` (rickub serves git on `git.rickub.com`; the pages host returns 404 for git), resolves a slashed branch as the **longest** ref that prefixes `ref/path`, does `git clone --depth 1 --branch <ref>` into a temp dir, locates `<path>/<ProjectDir>` (or `<path>` itself when it already is the directory) and copies every regular file through `projectfile.Write` into `dest/<ProjectDir>`. `ErrExists` when the project already has the directory — no `--force`, deliberately; `ErrNoGit`; `ErrNotFound`. `GIT_TERMINAL_PROMPT=0` always. Rejected: the forges' JSON APIs (all different, and rickub's `/api/v1/repos/…/contents` answers 401 without a PAT even for a public repository — measured); scraping the tree pages (fragile); implementing smart HTTP in Go (a dependency's worth of code for what `git` already does). Cost accepted: `git` must be installed. Fifteen tests, one of them the real thing against `rickub.com/turbo-editors/configs` behind `TURBO_CORE_NETWORK=1` (passed from the sandbox, 0.35 s). **Every editor wires it as `-load-config <url>`** (`loadConfig`, `describeLoad` in each `main.go`), printing what arrived and exiting without starting the editor.
141+
132142 ## Build, test, run
133143
134144 ```bash
@@ -164,7 +174,11 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
164174
165175 - **v1.0.0 is released** under the new module path `rickub.com/turbo-editors/turbo-core`, by `01-release.tag.sh` and the Release workflow; turbo-go v1.0.0 is published against it and the five other editors are re-pinned to it, waiting for their first Rickub release.
166176 - **v1.0.1 is released**: the canonical-path fix (`lsp.CanonicalPath`, `pathKey`, `windowFor`). turbo-moonbit is re-pinned to it.
167-- **v1.0.2 is ready to tag** (`release.env` says so): `FileCreated` above; four new tests (`lsp` ×1 in the notification-order test, `app` ×3 in `save_test.go`), the fake server records the last `didOpen` and the last file event and the order of every method. Turbo MoonBit's suite needs it (its new first-save test fails on v1.0.1); every editor should re-pin before releasing.
177+- **v1.0.2 is released**: `FileCreated` above; four new tests (`lsp` ×1 in the notification-order test, `app` ×3 in `save_test.go`), the fake server records the last `didOpen` and the last file event and the order of every method. Turbo MoonBit's suite needs it (its new first-save test fails on v1.0.1); every editor should re-pin before releasing.
178+
179+## State as of 2026-09-20
180+
181+- **v1.0.3 is ready to tag** (`release.env` says so): files changed on disk reload into their open windows, the agent window's box wraps, a long paste into it is kept aside as a token (ticket #28), and `configrepo` fetches a shared configuration from a forge URL (ticket #25) — all above. **All six editors already carry `-load-config`** against this checkout; each still pins v1.0.2 and builds only under the umbrella `go.work` until re-pinned. `make check` green, `go test -race ./acp ./app` green, quality gate run #34 PASS with zero smells (run #33 failed on `app/app.go` file complexity; moving the paste handling into `app/paste.go` cleared it). **Not committed, not tagged.** Every editor should re-pin to pick both up; their user docs do not yet mention either behaviour (the agent-window docs — turbo-go's `reference/acp.md`, `how-to/talk-to-an-agent.md` — list the box's keys and could say it wraps and that `↑↓` move by row).
168182
169183 ## Not yet established
170184
@@ -14,7 +14,7 @@ Extracted from turbo-go on 2026-09-01, at the point a second editor was wanted.
14 14
15 ## Architecture15 ## Architecture
16 16
17-Eighteen packages. Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one.17+Nineteen packages. Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one.
18 18
19 ```19 ```
20 app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}20 app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}
@@ -23,6 +23,7 @@ app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, t
23 filetree → {ui, theme, tcell}23 filetree → {ui, theme, tcell}
24 acp → {jsonrpc, profile, projectfile, syntax, theme, ui, tcell, toml}24 acp → {jsonrpc, profile, projectfile, syntax, theme, ui, tcell, toml}
25 settings │ snippets │ tools → {toml, projectfile, profile}25 settings │ snippets │ tools → {toml, projectfile, profile}
26+ configrepo → {projectfile, profile} ← execs git
26 syntax → theme27 syntax → theme
27 ui → theme28 ui → theme
28 lsp → {jsonrpc, profile}29 lsp → {jsonrpc, profile}
@@ -35,6 +36,7 @@ app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, t
35 | `profile` | The editor's identity: name, slug, language, tools menu, root markers, server, templates — and every path derived from the slug | **stdlib only** |36 | `profile` | The editor's identity: name, slug, language, tools menu, root markers, server, templates — and every path derived from the slug | **stdlib only** |
36 | `buffer` | Text of one file: lines of runes, cursor, selection, undo, search, load/save | **stdlib only** |37 | `buffer` | Text of one file: lines of runes, cursor, selection, undo, search, load/save | **stdlib only** |
37 | `projectfile` | Atomic writes of a project's own TOML files | **stdlib only** |38 | `projectfile` | Atomic writes of a project's own TOML files | **stdlib only** |
39+| `configrepo` | A shared `.turbo-<slug>` fetched from a forge URL by a shallow git clone (`-load-config`) | `projectfile`, `profile`, the `git` command |
38 | `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** |40 | `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** |
39 | `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` |41 | `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` |
40 | `theme` | TOML → tcell styles, nine embedded themes + the user's, two kinds of inheritance | `tcell`, `toml` |42 | `theme` | TOML → tcell styles, nine embedded themes + the user's, two kinds of inheritance | `tcell`, `toml` |
@@ -129,6 +131,14 @@ The decisions inherited from turbo-go about the *editor's behaviour* — the eve
129 131
130 - **A save that creates a file says so to the server (2026-09-19, v1.0.2).** `lsp.Client.FileCreated` sends `workspace/didChangeWatchedFiles` (type 1); `app.announceSaved` sends it after the document's `didOpen`/`didSave` whenever the save created the file (`fileExists` asked before the write, in both `save` and `writeQuietly`). Reason: moon-lsp lists a package's files from the directory, so a `.mbt` saved for the first time — `turbo-moonbit new.mbt`, type, save — was known as a document and never diagnosed. Verified at the protocol level (every order of didOpen/didSave/didChangeWatchedFiles gets the file diagnosed; nothing does without it) and by Turbo MoonBit's `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP`, which fails against v1.0.1 and passes against this. Sent unasked (moon-lsp registers no watcher); harmless to the fake and, by the spec, to any server. Only `Created` is ever sent: Save As leaves the old file on disk, so no `Deleted`.132 - **A save that creates a file says so to the server (2026-09-19, v1.0.2).** `lsp.Client.FileCreated` sends `workspace/didChangeWatchedFiles` (type 1); `app.announceSaved` sends it after the document's `didOpen`/`didSave` whenever the save created the file (`fileExists` asked before the write, in both `save` and `writeQuietly`). Reason: moon-lsp lists a package's files from the directory, so a `.mbt` saved for the first time — `turbo-moonbit new.mbt`, type, save — was known as a document and never diagnosed. Verified at the protocol level (every order of didOpen/didSave/didChangeWatchedFiles gets the file diagnosed; nothing does without it) and by Turbo MoonBit's `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP`, which fails against v1.0.1 and passes against this. Sent unasked (moon-lsp registers no watcher); harmless to the fake and, by the spec, to any server. Only `Created` is ever sent: Save As leaves the old file on disk, so no `Deleted`.
131 133
134+- **A file rewritten by another program is reloaded into its window (2026-09-20, v1.0.3).** `app/watch.go`: `reloadChangedFiles` runs in `tick`, keeps a `fileStamp` (size + mtime, the guard `refreshToolMenus` already used) per open file by canonical path in `a.fileStamps`, and re-reads through `reloadFromDisk` — now shared with the tools reload, so a reload also sends `didChange` to the server, which the tools reload never did. The editor's own writes re-stamp (`openBuffer`, `afterSave`, the reload), so a save is not mistaken for a change. **A modified buffer is never reloaded**; the status bar says `changed on disk; your unsaved changes are kept`, once per change. A file that vanishes is left alone silently (delete-then-write replacers exist). Because the loop sleeps in `PollEvent`, `Run` — and only `Run` — starts a `fileWatch` goroutine that stats the published stamps every `fileWatchInterval` (1 s) and posts a wake when one differs: a nudge only, the loop decides by itself, and an idle editor with unchanged files is never woken. Rejected: fsnotify (a dependency, platform-specific limits, and still needs the state-driven check); a bare 1 Hz ticker (redraws an idle editor forever). Same-second rewrite of the same size is invisible to the stamp — accepted, as every stamp-based editor does. Ten tests in `app/watch_test.go`; six fail with the step removed from `tick`.
135+
136+- **The agent window's box wraps what you type (2026-09-20, v1.0.3).** `acp/input.go`: the typed lines (`View.input`, the ones Alt-Enter makes) stay the model; `inputRows` lays them out as `inputRow{line,start,end}` at `inputWidth()` = `W-3` on every frame and every arrow key, nothing stored. A row breaks *after* the last space that fits (the space stays on the row, undrawn), so rows partition the line's runes and `inputCursor` is unambiguous; the third reserved column is for the cursor just past a full row, which `Painter.ShowCursor` otherwise hides as out of clip. `↑`/`↓` move by visual row (`moveInputRow`), falling through to the transcript off the top; `Home`/`End` stay per logical line. The rows drawn end with the cursor's row; `>` is drawn only when the first row is visible. Before: `TextLimited` cut the line with `…` and the cursor vanished. Rejected: wrapping the model itself (would make Backspace at a wrap join nothing and the wire carry soft breaks); reusing `acp.Wrap` (it trims spaces, losing the rune offsets the cursor needs). Eight tests in `acp/input_test.go`, asserting on the simulation screen's cursor.
137+
138+- **A long paste into the agent window is a token in the box and its text on the wire (2026-09-20, v1.0.3, ticket #28).** `acp/paste.go`: `View.Paste(text)` types a single line of ≤ `pasteInlineLimit` (200) runes as if typed, and otherwise keeps it in `View.pastes` and inserts `[Pasted #N · L lines · C chars]`; `Send` calls the new `Session.PromptWith(text, pastes, mentions…)` — `Prompt` is now a wrapper — which keeps the token text in the transcript and has `expandPastes` replace tokens **inside text blocks only, after `blocksFor`** (so an `@name` in a pasted log is never a mention; a paste is byte for byte). Tokens are found by text (`tokensIn`), never by remembered position; Backspace/Delete remove one whole, `←`/`→` step over, `↑`/`↓` landing inside snap to the nearer edge; drawn in the type style (`drawInputRow`, rune by rune). Numbers run on per window. **Two ways in**: bracketed paste — `app.New` calls `screen.EnablePaste()`, `handleKey` collects keys while `a.pasting`, `handlePaste` (`app/paste.go`) hands an agent window the whole text (`pastedText`: rune/Enter→`\n`/Tab→`\t`) and replays keys to any other window, so files are typed exactly as before — and the editor's clipboard via `View.OnPaste` (Ctrl-V, Shift-Ins) and `App.Paste` (Edit ▸ Paste) routing to the agent's box. Before this, pasting forty lines into the box sent forty prompts, one per Enter. Rejected: expanding tokens in the text before `blocksFor` (mentions read out of pastes); a per-prompt numbering (two `#1`s in one window); making the tokens unbreakable across a wrap (a 34-rune token still wraps at its `·` spaces — accepted). Sixteen tests in `acp/paste_test.go`, five in `app/paste_test.go`.
139+
140+- **A shared configuration is fetched by git, from the URL a forge shows (2026-09-20, v1.0.3, ticket #25).** New package `configrepo`: `Parse` reads rickub/GitHub (`tree/`), GitLab (`-/tree/`, owner keeps its slashes) and Gitea-family (`src/branch|tag|commit/`) URLs, or a bare repository URL; `Load(ctx, profile, url, dest)` runs `git ls-remote` on `https://host/owner/repo.git` then `https://git.host/owner/repo.git` (rickub serves git on `git.rickub.com`; the pages host returns 404 for git), resolves a slashed branch as the **longest** ref that prefixes `ref/path`, does `git clone --depth 1 --branch <ref>` into a temp dir, locates `<path>/<ProjectDir>` (or `<path>` itself when it already is the directory) and copies every regular file through `projectfile.Write` into `dest/<ProjectDir>`. `ErrExists` when the project already has the directory — no `--force`, deliberately; `ErrNoGit`; `ErrNotFound`. `GIT_TERMINAL_PROMPT=0` always. Rejected: the forges' JSON APIs (all different, and rickub's `/api/v1/repos/…/contents` answers 401 without a PAT even for a public repository — measured); scraping the tree pages (fragile); implementing smart HTTP in Go (a dependency's worth of code for what `git` already does). Cost accepted: `git` must be installed. Fifteen tests, one of them the real thing against `rickub.com/turbo-editors/configs` behind `TURBO_CORE_NETWORK=1` (passed from the sandbox, 0.35 s). **Every editor wires it as `-load-config <url>`** (`loadConfig`, `describeLoad` in each `main.go`), printing what arrived and exiting without starting the editor.
141+
132 ## Build, test, run142 ## Build, test, run
133 143
134 ```bash144 ```bash
@@ -164,7 +174,11 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
164 174
165 - **v1.0.0 is released** under the new module path `rickub.com/turbo-editors/turbo-core`, by `01-release.tag.sh` and the Release workflow; turbo-go v1.0.0 is published against it and the five other editors are re-pinned to it, waiting for their first Rickub release.175 - **v1.0.0 is released** under the new module path `rickub.com/turbo-editors/turbo-core`, by `01-release.tag.sh` and the Release workflow; turbo-go v1.0.0 is published against it and the five other editors are re-pinned to it, waiting for their first Rickub release.
166 - **v1.0.1 is released**: the canonical-path fix (`lsp.CanonicalPath`, `pathKey`, `windowFor`). turbo-moonbit is re-pinned to it.176 - **v1.0.1 is released**: the canonical-path fix (`lsp.CanonicalPath`, `pathKey`, `windowFor`). turbo-moonbit is re-pinned to it.
167-- **v1.0.2 is ready to tag** (`release.env` says so): `FileCreated` above; four new tests (`lsp` ×1 in the notification-order test, `app` ×3 in `save_test.go`), the fake server records the last `didOpen` and the last file event and the order of every method. Turbo MoonBit's suite needs it (its new first-save test fails on v1.0.1); every editor should re-pin before releasing.177+- **v1.0.2 is released**: `FileCreated` above; four new tests (`lsp` ×1 in the notification-order test, `app` ×3 in `save_test.go`), the fake server records the last `didOpen` and the last file event and the order of every method. Turbo MoonBit's suite needs it (its new first-save test fails on v1.0.1); every editor should re-pin before releasing.
178+
179+## State as of 2026-09-20
180+
181+- **v1.0.3 is ready to tag** (`release.env` says so): files changed on disk reload into their open windows, the agent window's box wraps, a long paste into it is kept aside as a token (ticket #28), and `configrepo` fetches a shared configuration from a forge URL (ticket #25) — all above. **All six editors already carry `-load-config`** against this checkout; each still pins v1.0.2 and builds only under the umbrella `go.work` until re-pinned. `make check` green, `go test -race ./acp ./app` green, quality gate run #34 PASS with zero smells (run #33 failed on `app/app.go` file complexity; moving the paste handling into `app/paste.go` cleared it). **Not committed, not tagged.** Every editor should re-pin to pick both up; their user docs do not yet mention either behaviour (the agent-window docs — turbo-go's `reference/acp.md`, `how-to/talk-to-an-agent.md` — list the box's keys and could say it wraps and that `↑↓` move by row).
168 182
169 ## Not yet established183 ## Not yet established
170 184
modified .quality/history.jsonl +6 -0
@@ -28,3 +28,9 @@
2828 {"branch": "main", "breaches": ["code smells: 1 (max 0)"], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 199, "complex": 2305, "cyclo": 5161, "fields": 594, "funcs": 1497, "lcom": 0, "lines": 24812, "loc": 15266}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 28, "smells": 1, "timestamp": "2026-09-17T04:41:36Z"}
2929 {"branch": "main", "breaches": [], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 199, "complex": 2306, "cyclo": 5165, "fields": 594, "funcs": 1499, "lcom": 0, "lines": 24833, "loc": 15281}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 29, "smells": 0, "timestamp": "2026-09-17T04:42:34Z"}
3030 {"branch": "main", "breaches": [], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 199, "complex": 2308, "cyclo": 5170, "fields": 594, "funcs": 1501, "lcom": 0, "lines": 24873, "loc": 15295}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 30, "smells": 0, "timestamp": "2026-09-18T19:50:22Z"}
31+{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 203, "complex": 2339, "cyclo": 5201, "fields": 601, "funcs": 1513, "lcom": 0, "lines": 25173, "loc": 15445}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 31, "smells": 0, "timestamp": "2026-09-20T02:58:30Z"}
32+{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 204, "complex": 2353, "cyclo": 5238, "fields": 604, "funcs": 1518, "lcom": 0, "lines": 25285, "loc": 15506}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 32, "smells": 0, "timestamp": "2026-09-20T03:21:36Z"}
33+{"branch": "main", "breaches": ["code smells: 1 (max 0)"], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 206, "complex": 2405, "cyclo": 5315, "fields": 614, "funcs": 1536, "lcom": 0, "lines": 25641, "loc": 15725}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 33, "smells": 1, "timestamp": "2026-09-20T04:09:00Z"}
34+{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 206, "complex": 2406, "cyclo": 5316, "fields": 614, "funcs": 1536, "lcom": 0, "lines": 25650, "loc": 15729}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 34, "smells": 0, "timestamp": "2026-09-20T04:09:35Z"}
35+{"branch": "main", "breaches": ["code smells: 2 (max 0)"], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5441, "fields": 623, "funcs": 1549, "lcom": 0, "lines": 26044, "loc": 15984}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 35, "smells": 2, "timestamp": "2026-09-20T07:01:34Z"}
36+{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5447, "fields": 623, "funcs": 1553, "lcom": 0, "lines": 26074, "loc": 16003}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 36, "smells": 0, "timestamp": "2026-09-20T07:02:54Z"}
@@ -28,3 +28,9 @@
28 {"branch": "main", "breaches": ["code smells: 1 (max 0)"], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 199, "complex": 2305, "cyclo": 5161, "fields": 594, "funcs": 1497, "lcom": 0, "lines": 24812, "loc": 15266}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 28, "smells": 1, "timestamp": "2026-09-17T04:41:36Z"}28 {"branch": "main", "breaches": ["code smells: 1 (max 0)"], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 199, "complex": 2305, "cyclo": 5161, "fields": 594, "funcs": 1497, "lcom": 0, "lines": 24812, "loc": 15266}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 28, "smells": 1, "timestamp": "2026-09-17T04:41:36Z"}
29 {"branch": "main", "breaches": [], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 199, "complex": 2306, "cyclo": 5165, "fields": 594, "funcs": 1499, "lcom": 0, "lines": 24833, "loc": 15281}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 29, "smells": 0, "timestamp": "2026-09-17T04:42:34Z"}29 {"branch": "main", "breaches": [], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 199, "complex": 2306, "cyclo": 5165, "fields": 594, "funcs": 1499, "lcom": 0, "lines": 24833, "loc": 15281}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 29, "smells": 0, "timestamp": "2026-09-17T04:42:34Z"}
30 {"branch": "main", "breaches": [], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 199, "complex": 2308, "cyclo": 5170, "fields": 594, "funcs": 1501, "lcom": 0, "lines": 24873, "loc": 15295}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 30, "smells": 0, "timestamp": "2026-09-18T19:50:22Z"}30 {"branch": "main", "breaches": [], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 199, "complex": 2308, "cyclo": 5170, "fields": 594, "funcs": 1501, "lcom": 0, "lines": 24873, "loc": 15295}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 30, "smells": 0, "timestamp": "2026-09-18T19:50:22Z"}
31+{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 203, "complex": 2339, "cyclo": 5201, "fields": 601, "funcs": 1513, "lcom": 0, "lines": 25173, "loc": 15445}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 31, "smells": 0, "timestamp": "2026-09-20T02:58:30Z"}
32+{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 204, "complex": 2353, "cyclo": 5238, "fields": 604, "funcs": 1518, "lcom": 0, "lines": 25285, "loc": 15506}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 32, "smells": 0, "timestamp": "2026-09-20T03:21:36Z"}
33+{"branch": "main", "breaches": ["code smells: 1 (max 0)"], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 206, "complex": 2405, "cyclo": 5315, "fields": 614, "funcs": 1536, "lcom": 0, "lines": 25641, "loc": 15725}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 33, "smells": 1, "timestamp": "2026-09-20T04:09:00Z"}
34+{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 206, "complex": 2406, "cyclo": 5316, "fields": 614, "funcs": 1536, "lcom": 0, "lines": 25650, "loc": 15729}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 34, "smells": 0, "timestamp": "2026-09-20T04:09:35Z"}
35+{"branch": "main", "breaches": ["code smells: 2 (max 0)"], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5441, "fields": 623, "funcs": 1549, "lcom": 0, "lines": 26044, "loc": 15984}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 35, "smells": 2, "timestamp": "2026-09-20T07:01:34Z"}
36+{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5447, "fields": 623, "funcs": 1553, "lcom": 0, "lines": 26074, "loc": 16003}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 36, "smells": 0, "timestamp": "2026-09-20T07:02:54Z"}
added .quality/report-20260920T025830Z.md +72 -0
new file mode 100644
@@ -0,0 +1,72 @@
1+# Quality report — 2026-09-20T02:58:30Z
2+
3+- **Gate**: ✅ **PASS**
4+- **Commit**: `3561e52` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #31 (previous: 2026-09-18T19:50:22Z)
7+
8+## Lint issues (`qlty check`)
9+
10+_none_
11+
12+### Top rules
13+
14+_none_
15+
16+### Most affected files
17+
18+_none_
19+
20+## Code smells (`qlty smells`)
21+
22+Total: **0** (vs previous: ±0)
23+
24+_none_
25+
26+## Metrics (`qlty metrics`)
27+
28+| metric | total | vs previous |
29+|---|---|---|
30+| funcs | 1513 | +12 |
31+| classes | 203 | +4 |
32+| fields | 601 | +7 |
33+| cyclo | 5201 | +31 |
34+| complex | 2339 | +31 |
35+| lcom | 0 | ±0 |
36+| lines | 25173 | +300 |
37+| loc | 15445 | +150 |
38+
39+### Most complex files
40+
41+| file | complex | cyclo | loc |
42+|---|---|---|---|
43+| editor/view.go | 48 | 124 | 380 |
44+| app/actions_file.go | 47 | 72 | 246 |
45+| app/agents.go | 45 | 94 | 248 |
46+| theme/load.go | 44 | 71 | 214 |
47+| app/app.go | 43 | 123 | 383 |
48+| acp/session.go | 39 | 88 | 414 |
49+| acp/picker.go | 38 | 83 | 203 |
50+| acp/transcript.go | 38 | 89 | 231 |
51+| syntax/toml.go | 38 | 92 | 179 |
52+| ui/dialog.go | 38 | 71 | 197 |
53+| ui/menu_events.go | 38 | 66 | 145 |
54+| app/toolchain.go | 35 | 70 | 238 |
55+| ui/window.go | 35 | 110 | 251 |
56+| acp/config.go | 34 | 43 | 149 |
57+| snippets/snippets.go | 34 | 39 | 135 |
58+
59+## Trend
60+
61+| run | timestamp | error | warning | smells | complex | gate |
62+|---|---|---|---|---|---|---|
63+| 22 | 2026-09-15T09:34:55Z | 0 | 0 | 0 | 2119 | PASS |
64+| 23 | 2026-09-15T16:57:07Z | 0 | 0 | 2 | 2157 | FAIL |
65+| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS |
66+| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL |
67+| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS |
68+| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
69+| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
70+| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
71+| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
72+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
new file mode 100644
@@ -0,0 +1,72 @@
1+# Quality report — 2026-09-20T02:58:30Z
2+
3+- **Gate**: ✅ **PASS**
4+- **Commit**: `3561e52` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #31 (previous: 2026-09-18T19:50:22Z)
7+
8+## Lint issues (`qlty check`)
9+
10+_none_
11+
12+### Top rules
13+
14+_none_
15+
16+### Most affected files
17+
18+_none_
19+
20+## Code smells (`qlty smells`)
21+
22+Total: **0** (vs previous: ±0)
23+
24+_none_
25+
26+## Metrics (`qlty metrics`)
27+
28+| metric | total | vs previous |
29+|---|---|---|
30+| funcs | 1513 | +12 |
31+| classes | 203 | +4 |
32+| fields | 601 | +7 |
33+| cyclo | 5201 | +31 |
34+| complex | 2339 | +31 |
35+| lcom | 0 | ±0 |
36+| lines | 25173 | +300 |
37+| loc | 15445 | +150 |
38+
39+### Most complex files
40+
41+| file | complex | cyclo | loc |
42+|---|---|---|---|
43+| editor/view.go | 48 | 124 | 380 |
44+| app/actions_file.go | 47 | 72 | 246 |
45+| app/agents.go | 45 | 94 | 248 |
46+| theme/load.go | 44 | 71 | 214 |
47+| app/app.go | 43 | 123 | 383 |
48+| acp/session.go | 39 | 88 | 414 |
49+| acp/picker.go | 38 | 83 | 203 |
50+| acp/transcript.go | 38 | 89 | 231 |
51+| syntax/toml.go | 38 | 92 | 179 |
52+| ui/dialog.go | 38 | 71 | 197 |
53+| ui/menu_events.go | 38 | 66 | 145 |
54+| app/toolchain.go | 35 | 70 | 238 |
55+| ui/window.go | 35 | 110 | 251 |
56+| acp/config.go | 34 | 43 | 149 |
57+| snippets/snippets.go | 34 | 39 | 135 |
58+
59+## Trend
60+
61+| run | timestamp | error | warning | smells | complex | gate |
62+|---|---|---|---|---|---|---|
63+| 22 | 2026-09-15T09:34:55Z | 0 | 0 | 0 | 2119 | PASS |
64+| 23 | 2026-09-15T16:57:07Z | 0 | 0 | 2 | 2157 | FAIL |
65+| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS |
66+| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL |
67+| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS |
68+| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
69+| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
70+| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
71+| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
72+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
added .quality/report-20260920T032136Z.md +72 -0
new file mode 100644
@@ -0,0 +1,72 @@
1+# Quality report — 2026-09-20T03:21:36Z
2+
3+- **Gate**: ✅ **PASS**
4+- **Commit**: `3561e52` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #32 (previous: 2026-09-20T02:58:30Z)
7+
8+## Lint issues (`qlty check`)
9+
10+_none_
11+
12+### Top rules
13+
14+_none_
15+
16+### Most affected files
17+
18+_none_
19+
20+## Code smells (`qlty smells`)
21+
22+Total: **0** (vs previous: ±0)
23+
24+_none_
25+
26+## Metrics (`qlty metrics`)
27+
28+| metric | total | vs previous |
29+|---|---|---|
30+| funcs | 1518 | +5 |
31+| classes | 204 | +1 |
32+| fields | 604 | +3 |
33+| cyclo | 5238 | +37 |
34+| complex | 2353 | +14 |
35+| lcom | 0 | ±0 |
36+| lines | 25285 | +112 |
37+| loc | 15506 | +61 |
38+
39+### Most complex files
40+
41+| file | complex | cyclo | loc |
42+|---|---|---|---|
43+| editor/view.go | 48 | 124 | 380 |
44+| app/actions_file.go | 47 | 72 | 246 |
45+| app/agents.go | 45 | 94 | 248 |
46+| theme/load.go | 44 | 71 | 214 |
47+| app/app.go | 43 | 123 | 383 |
48+| acp/session.go | 39 | 88 | 414 |
49+| acp/picker.go | 38 | 83 | 203 |
50+| acp/transcript.go | 38 | 89 | 231 |
51+| syntax/toml.go | 38 | 92 | 179 |
52+| ui/dialog.go | 38 | 71 | 197 |
53+| ui/menu_events.go | 38 | 66 | 145 |
54+| app/toolchain.go | 35 | 70 | 238 |
55+| ui/window.go | 35 | 110 | 251 |
56+| acp/config.go | 34 | 43 | 149 |
57+| acp/view_draw.go | 34 | 81 | 161 |
58+
59+## Trend
60+
61+| run | timestamp | error | warning | smells | complex | gate |
62+|---|---|---|---|---|---|---|
63+| 23 | 2026-09-15T16:57:07Z | 0 | 0 | 2 | 2157 | FAIL |
64+| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS |
65+| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL |
66+| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS |
67+| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
68+| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
69+| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
70+| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
71+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
72+| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS |
new file mode 100644
@@ -0,0 +1,72 @@
1+# Quality report — 2026-09-20T03:21:36Z
2+
3+- **Gate**: ✅ **PASS**
4+- **Commit**: `3561e52` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #32 (previous: 2026-09-20T02:58:30Z)
7+
8+## Lint issues (`qlty check`)
9+
10+_none_
11+
12+### Top rules
13+
14+_none_
15+
16+### Most affected files
17+
18+_none_
19+
20+## Code smells (`qlty smells`)
21+
22+Total: **0** (vs previous: ±0)
23+
24+_none_
25+
26+## Metrics (`qlty metrics`)
27+
28+| metric | total | vs previous |
29+|---|---|---|
30+| funcs | 1518 | +5 |
31+| classes | 204 | +1 |
32+| fields | 604 | +3 |
33+| cyclo | 5238 | +37 |
34+| complex | 2353 | +14 |
35+| lcom | 0 | ±0 |
36+| lines | 25285 | +112 |
37+| loc | 15506 | +61 |
38+
39+### Most complex files
40+
41+| file | complex | cyclo | loc |
42+|---|---|---|---|
43+| editor/view.go | 48 | 124 | 380 |
44+| app/actions_file.go | 47 | 72 | 246 |
45+| app/agents.go | 45 | 94 | 248 |
46+| theme/load.go | 44 | 71 | 214 |
47+| app/app.go | 43 | 123 | 383 |
48+| acp/session.go | 39 | 88 | 414 |
49+| acp/picker.go | 38 | 83 | 203 |
50+| acp/transcript.go | 38 | 89 | 231 |
51+| syntax/toml.go | 38 | 92 | 179 |
52+| ui/dialog.go | 38 | 71 | 197 |
53+| ui/menu_events.go | 38 | 66 | 145 |
54+| app/toolchain.go | 35 | 70 | 238 |
55+| ui/window.go | 35 | 110 | 251 |
56+| acp/config.go | 34 | 43 | 149 |
57+| acp/view_draw.go | 34 | 81 | 161 |
58+
59+## Trend
60+
61+| run | timestamp | error | warning | smells | complex | gate |
62+|---|---|---|---|---|---|---|
63+| 23 | 2026-09-15T16:57:07Z | 0 | 0 | 2 | 2157 | FAIL |
64+| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS |
65+| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL |
66+| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS |
67+| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
68+| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
69+| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
70+| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
71+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
72+| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS |
added .quality/report-20260920T040900Z.md +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
1+# Quality report — 2026-09-20T04:09:00Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `3561e52` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #33 (previous: 2026-09-20T03:21:36Z)
7+
8+## Gate violations
9+
10+- code smells: 1 (max 0)
11+
12+## Lint issues (`qlty check`)
13+
14+_none_
15+
16+### Top rules
17+
18+_none_
19+
20+### Most affected files
21+
22+_none_
23+
24+## Code smells (`qlty smells`)
25+
26+Total: **1** (vs previous: +1)
27+
28+| smell | file | line | detail |
29+|---|---|---|---|
30+| qlty:file-complexity | app/app.go | 1 | High total complexity (count = 50) |
31+
32+## Metrics (`qlty metrics`)
33+
34+| metric | total | vs previous |
35+|---|---|---|
36+| funcs | 1536 | +18 |
37+| classes | 206 | +2 |
38+| fields | 614 | +10 |
39+| cyclo | 5315 | +77 |
40+| complex | 2405 | +52 |
41+| lcom | 0 | ±0 |
42+| lines | 25641 | +356 |
43+| loc | 15725 | +219 |
44+
45+### Most complex files
46+
47+| file | complex | cyclo | loc |
48+|---|---|---|---|
49+| app/app.go | 50 | 135 | 422 |
50+| editor/view.go | 48 | 124 | 380 |
51+| app/actions_file.go | 47 | 72 | 246 |
52+| app/agents.go | 45 | 94 | 249 |
53+| theme/load.go | 44 | 71 | 214 |
54+| acp/session.go | 39 | 88 | 418 |
55+| acp/picker.go | 38 | 83 | 203 |
56+| acp/transcript.go | 38 | 89 | 231 |
57+| syntax/toml.go | 38 | 92 | 179 |
58+| ui/dialog.go | 38 | 71 | 197 |
59+| ui/menu_events.go | 38 | 66 | 145 |
60+| acp/paste.go | 37 | 53 | 135 |
61+| acp/view_draw.go | 37 | 85 | 172 |
62+| app/toolchain.go | 35 | 70 | 238 |
63+| ui/window.go | 35 | 110 | 251 |
64+
65+## Trend
66+
67+| run | timestamp | error | warning | smells | complex | gate |
68+|---|---|---|---|---|---|---|
69+| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS |
70+| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL |
71+| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS |
72+| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
73+| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
74+| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
75+| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
76+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
77+| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS |
78+| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL |
new file mode 100644
@@ -0,0 +1,78 @@
1+# Quality report — 2026-09-20T04:09:00Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `3561e52` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #33 (previous: 2026-09-20T03:21:36Z)
7+
8+## Gate violations
9+
10+- code smells: 1 (max 0)
11+
12+## Lint issues (`qlty check`)
13+
14+_none_
15+
16+### Top rules
17+
18+_none_
19+
20+### Most affected files
21+
22+_none_
23+
24+## Code smells (`qlty smells`)
25+
26+Total: **1** (vs previous: +1)
27+
28+| smell | file | line | detail |
29+|---|---|---|---|
30+| qlty:file-complexity | app/app.go | 1 | High total complexity (count = 50) |
31+
32+## Metrics (`qlty metrics`)
33+
34+| metric | total | vs previous |
35+|---|---|---|
36+| funcs | 1536 | +18 |
37+| classes | 206 | +2 |
38+| fields | 614 | +10 |
39+| cyclo | 5315 | +77 |
40+| complex | 2405 | +52 |
41+| lcom | 0 | ±0 |
42+| lines | 25641 | +356 |
43+| loc | 15725 | +219 |
44+
45+### Most complex files
46+
47+| file | complex | cyclo | loc |
48+|---|---|---|---|
49+| app/app.go | 50 | 135 | 422 |
50+| editor/view.go | 48 | 124 | 380 |
51+| app/actions_file.go | 47 | 72 | 246 |
52+| app/agents.go | 45 | 94 | 249 |
53+| theme/load.go | 44 | 71 | 214 |
54+| acp/session.go | 39 | 88 | 418 |
55+| acp/picker.go | 38 | 83 | 203 |
56+| acp/transcript.go | 38 | 89 | 231 |
57+| syntax/toml.go | 38 | 92 | 179 |
58+| ui/dialog.go | 38 | 71 | 197 |
59+| ui/menu_events.go | 38 | 66 | 145 |
60+| acp/paste.go | 37 | 53 | 135 |
61+| acp/view_draw.go | 37 | 85 | 172 |
62+| app/toolchain.go | 35 | 70 | 238 |
63+| ui/window.go | 35 | 110 | 251 |
64+
65+## Trend
66+
67+| run | timestamp | error | warning | smells | complex | gate |
68+|---|---|---|---|---|---|---|
69+| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS |
70+| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL |
71+| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS |
72+| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
73+| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
74+| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
75+| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
76+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
77+| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS |
78+| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL |
added .quality/report-20260920T040935Z.md +72 -0
new file mode 100644
@@ -0,0 +1,72 @@
1+# Quality report — 2026-09-20T04:09:35Z
2+
3+- **Gate**: ✅ **PASS**
4+- **Commit**: `3561e52` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #34 (previous: 2026-09-20T04:09:00Z)
7+
8+## Lint issues (`qlty check`)
9+
10+_none_
11+
12+### Top rules
13+
14+_none_
15+
16+### Most affected files
17+
18+_none_
19+
20+## Code smells (`qlty smells`)
21+
22+Total: **0** (vs previous: -1)
23+
24+_none_
25+
26+## Metrics (`qlty metrics`)
27+
28+| metric | total | vs previous |
29+|---|---|---|
30+| funcs | 1536 | ±0 |
31+| classes | 206 | ±0 |
32+| fields | 614 | ±0 |
33+| cyclo | 5316 | +1 |
34+| complex | 2406 | +1 |
35+| lcom | 0 | ±0 |
36+| lines | 25650 | +9 |
37+| loc | 15729 | +4 |
38+
39+### Most complex files
40+
41+| file | complex | cyclo | loc |
42+|---|---|---|---|
43+| editor/view.go | 48 | 124 | 380 |
44+| app/actions_file.go | 47 | 72 | 246 |
45+| app/agents.go | 45 | 94 | 249 |
46+| app/app.go | 44 | 125 | 392 |
47+| theme/load.go | 44 | 71 | 214 |
48+| acp/session.go | 39 | 88 | 418 |
49+| acp/picker.go | 38 | 83 | 203 |
50+| acp/transcript.go | 38 | 89 | 231 |
51+| syntax/toml.go | 38 | 92 | 179 |
52+| ui/dialog.go | 38 | 71 | 197 |
53+| ui/menu_events.go | 38 | 66 | 145 |
54+| acp/paste.go | 37 | 53 | 135 |
55+| acp/view_draw.go | 37 | 85 | 172 |
56+| app/toolchain.go | 35 | 70 | 238 |
57+| ui/window.go | 35 | 110 | 251 |
58+
59+## Trend
60+
61+| run | timestamp | error | warning | smells | complex | gate |
62+|---|---|---|---|---|---|---|
63+| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL |
64+| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS |
65+| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
66+| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
67+| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
68+| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
69+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
70+| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS |
71+| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL |
72+| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS |
new file mode 100644
@@ -0,0 +1,72 @@
1+# Quality report — 2026-09-20T04:09:35Z
2+
3+- **Gate**: ✅ **PASS**
4+- **Commit**: `3561e52` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #34 (previous: 2026-09-20T04:09:00Z)
7+
8+## Lint issues (`qlty check`)
9+
10+_none_
11+
12+### Top rules
13+
14+_none_
15+
16+### Most affected files
17+
18+_none_
19+
20+## Code smells (`qlty smells`)
21+
22+Total: **0** (vs previous: -1)
23+
24+_none_
25+
26+## Metrics (`qlty metrics`)
27+
28+| metric | total | vs previous |
29+|---|---|---|
30+| funcs | 1536 | ±0 |
31+| classes | 206 | ±0 |
32+| fields | 614 | ±0 |
33+| cyclo | 5316 | +1 |
34+| complex | 2406 | +1 |
35+| lcom | 0 | ±0 |
36+| lines | 25650 | +9 |
37+| loc | 15729 | +4 |
38+
39+### Most complex files
40+
41+| file | complex | cyclo | loc |
42+|---|---|---|---|
43+| editor/view.go | 48 | 124 | 380 |
44+| app/actions_file.go | 47 | 72 | 246 |
45+| app/agents.go | 45 | 94 | 249 |
46+| app/app.go | 44 | 125 | 392 |
47+| theme/load.go | 44 | 71 | 214 |
48+| acp/session.go | 39 | 88 | 418 |
49+| acp/picker.go | 38 | 83 | 203 |
50+| acp/transcript.go | 38 | 89 | 231 |
51+| syntax/toml.go | 38 | 92 | 179 |
52+| ui/dialog.go | 38 | 71 | 197 |
53+| ui/menu_events.go | 38 | 66 | 145 |
54+| acp/paste.go | 37 | 53 | 135 |
55+| acp/view_draw.go | 37 | 85 | 172 |
56+| app/toolchain.go | 35 | 70 | 238 |
57+| ui/window.go | 35 | 110 | 251 |
58+
59+## Trend
60+
61+| run | timestamp | error | warning | smells | complex | gate |
62+|---|---|---|---|---|---|---|
63+| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL |
64+| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS |
65+| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
66+| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
67+| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
68+| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
69+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
70+| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS |
71+| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL |
72+| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS |
added .quality/report-20260920T070134Z.md +79 -0
new file mode 100644
@@ -0,0 +1,79 @@
1+# Quality report — 2026-09-20T07:01:34Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `3561e52` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #35 (previous: 2026-09-20T04:09:35Z)
7+
8+## Gate violations
9+
10+- code smells: 2 (max 0)
11+
12+## Lint issues (`qlty check`)
13+
14+_none_
15+
16+### Top rules
17+
18+_none_
19+
20+### Most affected files
21+
22+_none_
23+
24+## Code smells (`qlty smells`)
25+
26+Total: **2** (vs previous: +2)
27+
28+| smell | file | line | detail |
29+|---|---|---|---|
30+| qlty:return-statements | configrepo/configrepo.go | 166 | Function with many returns (count = 8): loadFrom |
31+| qlty:return-statements | configrepo/copy.go | 21 | Function with many returns (count = 8): copyTree |
32+
33+## Metrics (`qlty metrics`)
34+
35+| metric | total | vs previous |
36+|---|---|---|
37+| funcs | 1549 | +13 |
38+| classes | 209 | +3 |
39+| fields | 623 | +9 |
40+| cyclo | 5441 | +125 |
41+| complex | 2474 | +68 |
42+| lcom | 0 | ±0 |
43+| lines | 26044 | +394 |
44+| loc | 15984 | +255 |
45+
46+### Most complex files
47+
48+| file | complex | cyclo | loc |
49+|---|---|---|---|
50+| editor/view.go | 48 | 124 | 380 |
51+| app/actions_file.go | 47 | 72 | 246 |
52+| app/agents.go | 45 | 94 | 249 |
53+| app/app.go | 44 | 125 | 392 |
54+| theme/load.go | 44 | 71 | 214 |
55+| acp/session.go | 39 | 88 | 418 |
56+| acp/picker.go | 38 | 83 | 203 |
57+| acp/transcript.go | 38 | 89 | 231 |
58+| syntax/toml.go | 38 | 92 | 179 |
59+| ui/dialog.go | 38 | 71 | 197 |
60+| ui/menu_events.go | 38 | 66 | 145 |
61+| acp/paste.go | 37 | 53 | 135 |
62+| acp/view_draw.go | 37 | 85 | 172 |
63+| app/toolchain.go | 35 | 70 | 238 |
64+| ui/window.go | 35 | 110 | 251 |
65+
66+## Trend
67+
68+| run | timestamp | error | warning | smells | complex | gate |
69+|---|---|---|---|---|---|---|
70+| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS |
71+| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
72+| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
73+| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
74+| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
75+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
76+| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS |
77+| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL |
78+| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS |
79+| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL |
new file mode 100644
@@ -0,0 +1,79 @@
1+# Quality report — 2026-09-20T07:01:34Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `3561e52` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #35 (previous: 2026-09-20T04:09:35Z)
7+
8+## Gate violations
9+
10+- code smells: 2 (max 0)
11+
12+## Lint issues (`qlty check`)
13+
14+_none_
15+
16+### Top rules
17+
18+_none_
19+
20+### Most affected files
21+
22+_none_
23+
24+## Code smells (`qlty smells`)
25+
26+Total: **2** (vs previous: +2)
27+
28+| smell | file | line | detail |
29+|---|---|---|---|
30+| qlty:return-statements | configrepo/configrepo.go | 166 | Function with many returns (count = 8): loadFrom |
31+| qlty:return-statements | configrepo/copy.go | 21 | Function with many returns (count = 8): copyTree |
32+
33+## Metrics (`qlty metrics`)
34+
35+| metric | total | vs previous |
36+|---|---|---|
37+| funcs | 1549 | +13 |
38+| classes | 209 | +3 |
39+| fields | 623 | +9 |
40+| cyclo | 5441 | +125 |
41+| complex | 2474 | +68 |
42+| lcom | 0 | ±0 |
43+| lines | 26044 | +394 |
44+| loc | 15984 | +255 |
45+
46+### Most complex files
47+
48+| file | complex | cyclo | loc |
49+|---|---|---|---|
50+| editor/view.go | 48 | 124 | 380 |
51+| app/actions_file.go | 47 | 72 | 246 |
52+| app/agents.go | 45 | 94 | 249 |
53+| app/app.go | 44 | 125 | 392 |
54+| theme/load.go | 44 | 71 | 214 |
55+| acp/session.go | 39 | 88 | 418 |
56+| acp/picker.go | 38 | 83 | 203 |
57+| acp/transcript.go | 38 | 89 | 231 |
58+| syntax/toml.go | 38 | 92 | 179 |
59+| ui/dialog.go | 38 | 71 | 197 |
60+| ui/menu_events.go | 38 | 66 | 145 |
61+| acp/paste.go | 37 | 53 | 135 |
62+| acp/view_draw.go | 37 | 85 | 172 |
63+| app/toolchain.go | 35 | 70 | 238 |
64+| ui/window.go | 35 | 110 | 251 |
65+
66+## Trend
67+
68+| run | timestamp | error | warning | smells | complex | gate |
69+|---|---|---|---|---|---|---|
70+| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS |
71+| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
72+| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
73+| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
74+| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
75+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
76+| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS |
77+| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL |
78+| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS |
79+| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL |
added .quality/report-20260920T070254Z.md +72 -0
new file mode 100644
@@ -0,0 +1,72 @@
1+# Quality report — 2026-09-20T07:02:54Z
2+
3+- **Gate**: ✅ **PASS**
4+- **Commit**: `3561e52` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #36 (previous: 2026-09-20T07:01:34Z)
7+
8+## Lint issues (`qlty check`)
9+
10+_none_
11+
12+### Top rules
13+
14+_none_
15+
16+### Most affected files
17+
18+_none_
19+
20+## Code smells (`qlty smells`)
21+
22+Total: **0** (vs previous: -2)
23+
24+_none_
25+
26+## Metrics (`qlty metrics`)
27+
28+| metric | total | vs previous |
29+|---|---|---|
30+| funcs | 1553 | +4 |
31+| classes | 209 | ±0 |
32+| fields | 623 | ±0 |
33+| cyclo | 5447 | +6 |
34+| complex | 2474 | ±0 |
35+| lcom | 0 | ±0 |
36+| lines | 26074 | +30 |
37+| loc | 16003 | +19 |
38+
39+### Most complex files
40+
41+| file | complex | cyclo | loc |
42+|---|---|---|---|
43+| editor/view.go | 48 | 124 | 380 |
44+| app/actions_file.go | 47 | 72 | 246 |
45+| app/agents.go | 45 | 94 | 249 |
46+| app/app.go | 44 | 125 | 392 |
47+| theme/load.go | 44 | 71 | 214 |
48+| acp/session.go | 39 | 88 | 418 |
49+| acp/picker.go | 38 | 83 | 203 |
50+| acp/transcript.go | 38 | 89 | 231 |
51+| syntax/toml.go | 38 | 92 | 179 |
52+| ui/dialog.go | 38 | 71 | 197 |
53+| ui/menu_events.go | 38 | 66 | 145 |
54+| acp/paste.go | 37 | 53 | 135 |
55+| acp/view_draw.go | 37 | 85 | 172 |
56+| app/toolchain.go | 35 | 70 | 238 |
57+| ui/window.go | 35 | 110 | 251 |
58+
59+## Trend
60+
61+| run | timestamp | error | warning | smells | complex | gate |
62+|---|---|---|---|---|---|---|
63+| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
64+| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
65+| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
66+| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
67+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
68+| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS |
69+| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL |
70+| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS |
71+| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL |
72+| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS |
new file mode 100644
@@ -0,0 +1,72 @@
1+# Quality report — 2026-09-20T07:02:54Z
2+
3+- **Gate**: ✅ **PASS**
4+- **Commit**: `3561e52` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #36 (previous: 2026-09-20T07:01:34Z)
7+
8+## Lint issues (`qlty check`)
9+
10+_none_
11+
12+### Top rules
13+
14+_none_
15+
16+### Most affected files
17+
18+_none_
19+
20+## Code smells (`qlty smells`)
21+
22+Total: **0** (vs previous: -2)
23+
24+_none_
25+
26+## Metrics (`qlty metrics`)
27+
28+| metric | total | vs previous |
29+|---|---|---|
30+| funcs | 1553 | +4 |
31+| classes | 209 | ±0 |
32+| fields | 623 | ±0 |
33+| cyclo | 5447 | +6 |
34+| complex | 2474 | ±0 |
35+| lcom | 0 | ±0 |
36+| lines | 26074 | +30 |
37+| loc | 16003 | +19 |
38+
39+### Most complex files
40+
41+| file | complex | cyclo | loc |
42+|---|---|---|---|
43+| editor/view.go | 48 | 124 | 380 |
44+| app/actions_file.go | 47 | 72 | 246 |
45+| app/agents.go | 45 | 94 | 249 |
46+| app/app.go | 44 | 125 | 392 |
47+| theme/load.go | 44 | 71 | 214 |
48+| acp/session.go | 39 | 88 | 418 |
49+| acp/picker.go | 38 | 83 | 203 |
50+| acp/transcript.go | 38 | 89 | 231 |
51+| syntax/toml.go | 38 | 92 | 179 |
52+| ui/dialog.go | 38 | 71 | 197 |
53+| ui/menu_events.go | 38 | 66 | 145 |
54+| acp/paste.go | 37 | 53 | 135 |
55+| acp/view_draw.go | 37 | 85 | 172 |
56+| app/toolchain.go | 35 | 70 | 238 |
57+| ui/window.go | 35 | 110 | 251 |
58+
59+## Trend
60+
61+| run | timestamp | error | warning | smells | complex | gate |
62+|---|---|---|---|---|---|---|
63+| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
64+| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
65+| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
66+| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
67+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
68+| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS |
69+| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL |
70+| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS |
71+| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL |
72+| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS |
modified .quality/report-latest.md +24 -24
@@ -1,9 +1,9 @@
1-# Quality report — 2026-09-18T19:50:22Z
1+# Quality report — 2026-09-20T07:02:54Z
22
33 - **Gate**: ✅ **PASS**
4-- **Commit**: `c845294` on `main`
4+- **Commit**: `3561e52` on `main`
55 - **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6-- **Run**: #30 (previous: 2026-09-17T04:42:34Z)
6+- **Run**: #36 (previous: 2026-09-20T07:01:34Z)
77
88 ## Lint issues (`qlty check`)
99
@@ -19,7 +19,7 @@ _none_
1919
2020 ## Code smells (`qlty smells`)
2121
22-Total: **0** (vs previous: ±0)
22+Total: **0** (vs previous: -2)
2323
2424 _none_
2525
@@ -27,46 +27,46 @@ _none_
2727
2828 | metric | total | vs previous |
2929 |---|---|---|
30-| funcs | 1501 | +2 |
31-| classes | 199 | ±0 |
32-| fields | 594 | ±0 |
33-| cyclo | 5170 | +5 |
34-| complex | 2308 | +2 |
30+| funcs | 1553 | +4 |
31+| classes | 209 | ±0 |
32+| fields | 623 | ±0 |
33+| cyclo | 5447 | +6 |
34+| complex | 2474 | ±0 |
3535 | lcom | 0 | ±0 |
36-| lines | 24873 | +40 |
37-| loc | 15295 | +14 |
36+| lines | 26074 | +30 |
37+| loc | 16003 | +19 |
3838
3939 ### Most complex files
4040
4141 | file | complex | cyclo | loc |
4242 |---|---|---|---|
4343 | editor/view.go | 48 | 124 | 380 |
44-| app/actions_file.go | 46 | 74 | 238 |
45-| app/agents.go | 45 | 94 | 248 |
44+| app/actions_file.go | 47 | 72 | 246 |
45+| app/agents.go | 45 | 94 | 249 |
46+| app/app.go | 44 | 125 | 392 |
4647 | theme/load.go | 44 | 71 | 214 |
47-| app/app.go | 43 | 123 | 375 |
48-| acp/session.go | 39 | 88 | 414 |
48+| acp/session.go | 39 | 88 | 418 |
4949 | acp/picker.go | 38 | 83 | 203 |
5050 | acp/transcript.go | 38 | 89 | 231 |
5151 | syntax/toml.go | 38 | 92 | 179 |
5252 | ui/dialog.go | 38 | 71 | 197 |
5353 | ui/menu_events.go | 38 | 66 | 145 |
54-| app/toolchain.go | 35 | 72 | 242 |
54+| acp/paste.go | 37 | 53 | 135 |
55+| acp/view_draw.go | 37 | 85 | 172 |
56+| app/toolchain.go | 35 | 70 | 238 |
5557 | ui/window.go | 35 | 110 | 251 |
56-| acp/config.go | 34 | 43 | 149 |
57-| snippets/snippets.go | 34 | 39 | 135 |
5858
5959 ## Trend
6060
6161 | run | timestamp | error | warning | smells | complex | gate |
6262 |---|---|---|---|---|---|---|
63-| 21 | 2026-09-15T09:34:09Z | 0 | 0 | 3 | 2119 | FAIL |
64-| 22 | 2026-09-15T09:34:55Z | 0 | 0 | 0 | 2119 | PASS |
65-| 23 | 2026-09-15T16:57:07Z | 0 | 0 | 2 | 2157 | FAIL |
66-| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS |
67-| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL |
68-| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS |
6963 | 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
7064 | 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
7165 | 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
7266 | 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
67+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
68+| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS |
69+| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL |
70+| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS |
71+| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL |
72+| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS |
@@ -1,9 +1,9 @@
1-# Quality report — 2026-09-18T19:50:22Z1+# Quality report — 2026-09-20T07:02:54Z
2 2
3 - **Gate**: ✅ **PASS**3 - **Gate**: ✅ **PASS**
4-- **Commit**: `c845294` on `main`4+- **Commit**: `3561e52` on `main`
5 - **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)5 - **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6-- **Run**: #30 (previous: 2026-09-17T04:42:34Z)6+- **Run**: #36 (previous: 2026-09-20T07:01:34Z)
7 7
8 ## Lint issues (`qlty check`)8 ## Lint issues (`qlty check`)
9 9
@@ -19,7 +19,7 @@ _none_
19 19
20 ## Code smells (`qlty smells`)20 ## Code smells (`qlty smells`)
21 21
22-Total: **0** (vs previous: ±0)22+Total: **0** (vs previous: -2)
23 23
24 _none_24 _none_
25 25
@@ -27,46 +27,46 @@ _none_
27 27
28 | metric | total | vs previous |28 | metric | total | vs previous |
29 |---|---|---|29 |---|---|---|
30-| funcs | 1501 | +2 |30+| funcs | 1553 | +4 |
31-| classes | 199 | ±0 |31+| classes | 209 | ±0 |
32-| fields | 594 | ±0 |32+| fields | 623 | ±0 |
33-| cyclo | 5170 | +5 |33+| cyclo | 5447 | +6 |
34-| complex | 2308 | +2 |34+| complex | 2474 | ±0 |
35 | lcom | 0 | ±0 |35 | lcom | 0 | ±0 |
36-| lines | 24873 | +40 |36+| lines | 26074 | +30 |
37-| loc | 15295 | +14 |37+| loc | 16003 | +19 |
38 38
39 ### Most complex files39 ### Most complex files
40 40
41 | file | complex | cyclo | loc |41 | file | complex | cyclo | loc |
42 |---|---|---|---|42 |---|---|---|---|
43 | editor/view.go | 48 | 124 | 380 |43 | editor/view.go | 48 | 124 | 380 |
44-| app/actions_file.go | 46 | 74 | 238 |44+| app/actions_file.go | 47 | 72 | 246 |
45-| app/agents.go | 45 | 94 | 248 |45+| app/agents.go | 45 | 94 | 249 |
46+| app/app.go | 44 | 125 | 392 |
46 | theme/load.go | 44 | 71 | 214 |47 | theme/load.go | 44 | 71 | 214 |
47-| app/app.go | 43 | 123 | 375 |48+| acp/session.go | 39 | 88 | 418 |
48-| acp/session.go | 39 | 88 | 414 |
49 | acp/picker.go | 38 | 83 | 203 |49 | acp/picker.go | 38 | 83 | 203 |
50 | acp/transcript.go | 38 | 89 | 231 |50 | acp/transcript.go | 38 | 89 | 231 |
51 | syntax/toml.go | 38 | 92 | 179 |51 | syntax/toml.go | 38 | 92 | 179 |
52 | ui/dialog.go | 38 | 71 | 197 |52 | ui/dialog.go | 38 | 71 | 197 |
53 | ui/menu_events.go | 38 | 66 | 145 |53 | ui/menu_events.go | 38 | 66 | 145 |
54-| app/toolchain.go | 35 | 72 | 242 |54+| acp/paste.go | 37 | 53 | 135 |
55+| acp/view_draw.go | 37 | 85 | 172 |
56+| app/toolchain.go | 35 | 70 | 238 |
55 | ui/window.go | 35 | 110 | 251 |57 | ui/window.go | 35 | 110 | 251 |
56-| acp/config.go | 34 | 43 | 149 |
57-| snippets/snippets.go | 34 | 39 | 135 |
58 58
59 ## Trend59 ## Trend
60 60
61 | run | timestamp | error | warning | smells | complex | gate |61 | run | timestamp | error | warning | smells | complex | gate |
62 |---|---|---|---|---|---|---|62 |---|---|---|---|---|---|---|
63-| 21 | 2026-09-15T09:34:09Z | 0 | 0 | 3 | 2119 | FAIL |
64-| 22 | 2026-09-15T09:34:55Z | 0 | 0 | 0 | 2119 | PASS |
65-| 23 | 2026-09-15T16:57:07Z | 0 | 0 | 2 | 2157 | FAIL |
66-| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS |
67-| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL |
68-| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS |
69 | 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |63 | 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS |
70 | 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |64 | 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL |
71 | 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |65 | 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS |
72 | 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |66 | 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS |
67+| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS |
68+| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS |
69+| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL |
70+| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS |
71+| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL |
72+| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS |
modified README.md +1 -0
@@ -52,6 +52,7 @@ Sixteen packages. Dependencies run strictly downwards, with no cycles and no int
5252 | `profile` | The editor's identity: name, slug, language server, starter templates |
5353 | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search |
5454 | `projectfile` | Atomic writes of a project's own TOML files |
55+| `configrepo` | A shared `.turbo-<slug>` directory fetched from a repository URL — rickub, GitHub, GitLab, Codeberg — by a shallow git clone |
5556 | `version` | What build of itself a binary is |
5657 | `lsp` | LSP framing, JSON-RPC 2.0, the child process |
5758 | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own |
@@ -52,6 +52,7 @@ Sixteen packages. Dependencies run strictly downwards, with no cycles and no int
52 | `profile` | The editor's identity: name, slug, language server, starter templates |52 | `profile` | The editor's identity: name, slug, language server, starter templates |
53 | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search |53 | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search |
54 | `projectfile` | Atomic writes of a project's own TOML files |54 | `projectfile` | Atomic writes of a project's own TOML files |
55+| `configrepo` | A shared `.turbo-<slug>` directory fetched from a repository URL — rickub, GitHub, GitLab, Codeberg — by a shallow git clone |
55 | `version` | What build of itself a binary is |56 | `version` | What build of itself a binary is |
56 | `lsp` | LSP framing, JSON-RPC 2.0, the child process |57 | `lsp` | LSP framing, JSON-RPC 2.0, the child process |
57 | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own |58 | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own |
modified acp/README.md +5 -0
@@ -18,6 +18,7 @@ The editor holds no API key, knows no provider, and implements no tool-calling l
1818 | `transcript.go` | The conversation as a **value** — no terminal, no colours |
1919 | `session_files.go` | The file half: `fs/read_text_file`, `fs/write_text_file`, and reading a mentioned file's text |
2020 | `mention.go` | A file named with `@`, and the content blocks a prompt becomes — text, `resource`, `resource_link` |
21+| `paste.go` | A paste kept aside: the `[Pasted #1 · 6 lines · 700 chars]` token in the box, and the text the agent gets for it |
2122 | `render.go` | Entries into drawn lines: fences, wrapping, styles |
2223 | `view.go` `view_draw.go` `view_events.go` | The widget: the conversation above, a box to type in below |
2324 | `picker.go` `picker_draw.go` | The popup `/` and `@` open in the box: the agent's commands, the project's files |
@@ -36,6 +37,10 @@ The editor holds no API key, knows no provider, and implements no tool-calling l
3637
3738 **A command is a text prompt, not a method.** `available_commands_update` tells the client what the agent answers to — `/web`, `/compact` — and the client sends the command as the text `"/web agent client protocol"`, exactly as Zed does. There is nothing else on the wire, so an agent that documents its commands for Zed has documented them for this editor. The picker that lists them is a convenience over that fact, not a protocol feature.
3839
40+**The box wraps what you type; the lines are still yours.** `input` holds the lines Alt-Enter made, and `input.go` lays them out as rows at the pane's width on every frame and every arrow key — nothing is stored, so a resize cannot leave the two apart. A row breaks after the last space that fits, keeping that space at the end of the row, so the runes of a line are exactly its rows laid end to end and every cursor position belongs to one row. A row is `W-3` runes wide — prompt, space, and one column kept free so the cursor just past a full row is inside the pane; before this, `TextLimited` cut the line off with `…` and `ShowCursor` hid a cursor that had reached the frame. `↑`/`↓` move by row, not by line: a paragraph wrapped three times is three rows to the eye. The rows shown end with the cursor's, so a prompt taller than the box scrolls under the rule, and the `>` marker goes with the first row rather than standing beside whatever is on top.
41+
42+**A long paste is a token in the box and its text on the wire.** `View.Paste` takes what was pasted — from the terminal, bracketed and handed over whole by `app`, or from the editor's clipboard through `OnPaste` on Ctrl-V / Shift-Ins / Edit ▸ Paste — and either types it (one line, up to `pasteInlineLimit` runes: a path, a command) or keeps it in `pastes` and inserts `[Pasted #N · L lines · C chars]` where the cursor is. `Send` calls `Session.PromptWith(text, pastesIn(text), mentions…)`: the transcript keeps the text with the tokens in it, so the exchange stays readable, and `expandPastes` replaces each token by its text **inside the text blocks only, after `blocksFor` has cut the mentions out** — a paste goes byte for byte, so an `@name` inside a pasted log is characters, not a file. A token whose text was deleted with it is simply absent from `pastesIn`. Tokens are found in a line by their text (`tokensIn`), never remembered by position; Backspace after one and Delete before one remove it whole, `←`/`→` step over it, and a move by row that lands inside one is pushed to the nearer edge (`leaveToken`) — a half token stands for nothing. It is drawn in the type style, as a tool call is: a thing the agent will be handed, not words. Numbers run on for the life of the window, so `#1` in an earlier exchange is not `#1` in this one.
43+
3944 **A mention takes the name out of the text.** `@app/menus.go` in the box becomes a content block *in place* — the file's text as a `resource` when the agent declared `promptCapabilities.embeddedContext`, a `resource_link` otherwise — with text blocks either side. The conversation keeps what was typed; the wire does not carry the name twice.
4045
4146 **Only a fence makes a block code.** No scanner is guessed at from the shape of the text, which is the rule every scanner in this library already follows. A fence naming a language nothing colours is drawn plainly.
@@ -18,6 +18,7 @@ The editor holds no API key, knows no provider, and implements no tool-calling l
18 | `transcript.go` | The conversation as a **value** — no terminal, no colours |18 | `transcript.go` | The conversation as a **value** — no terminal, no colours |
19 | `session_files.go` | The file half: `fs/read_text_file`, `fs/write_text_file`, and reading a mentioned file's text |19 | `session_files.go` | The file half: `fs/read_text_file`, `fs/write_text_file`, and reading a mentioned file's text |
20 | `mention.go` | A file named with `@`, and the content blocks a prompt becomes — text, `resource`, `resource_link` |20 | `mention.go` | A file named with `@`, and the content blocks a prompt becomes — text, `resource`, `resource_link` |
21+| `paste.go` | A paste kept aside: the `[Pasted #1 · 6 lines · 700 chars]` token in the box, and the text the agent gets for it |
21 | `render.go` | Entries into drawn lines: fences, wrapping, styles |22 | `render.go` | Entries into drawn lines: fences, wrapping, styles |
22 | `view.go` `view_draw.go` `view_events.go` | The widget: the conversation above, a box to type in below |23 | `view.go` `view_draw.go` `view_events.go` | The widget: the conversation above, a box to type in below |
23 | `picker.go` `picker_draw.go` | The popup `/` and `@` open in the box: the agent's commands, the project's files |24 | `picker.go` `picker_draw.go` | The popup `/` and `@` open in the box: the agent's commands, the project's files |
@@ -36,6 +37,10 @@ The editor holds no API key, knows no provider, and implements no tool-calling l
36 37
37 **A command is a text prompt, not a method.** `available_commands_update` tells the client what the agent answers to — `/web`, `/compact` — and the client sends the command as the text `"/web agent client protocol"`, exactly as Zed does. There is nothing else on the wire, so an agent that documents its commands for Zed has documented them for this editor. The picker that lists them is a convenience over that fact, not a protocol feature.38 **A command is a text prompt, not a method.** `available_commands_update` tells the client what the agent answers to — `/web`, `/compact` — and the client sends the command as the text `"/web agent client protocol"`, exactly as Zed does. There is nothing else on the wire, so an agent that documents its commands for Zed has documented them for this editor. The picker that lists them is a convenience over that fact, not a protocol feature.
38 39
40+**The box wraps what you type; the lines are still yours.** `input` holds the lines Alt-Enter made, and `input.go` lays them out as rows at the pane's width on every frame and every arrow key — nothing is stored, so a resize cannot leave the two apart. A row breaks after the last space that fits, keeping that space at the end of the row, so the runes of a line are exactly its rows laid end to end and every cursor position belongs to one row. A row is `W-3` runes wide — prompt, space, and one column kept free so the cursor just past a full row is inside the pane; before this, `TextLimited` cut the line off with `…` and `ShowCursor` hid a cursor that had reached the frame. `↑`/`↓` move by row, not by line: a paragraph wrapped three times is three rows to the eye. The rows shown end with the cursor's, so a prompt taller than the box scrolls under the rule, and the `>` marker goes with the first row rather than standing beside whatever is on top.
41+
42+**A long paste is a token in the box and its text on the wire.** `View.Paste` takes what was pasted — from the terminal, bracketed and handed over whole by `app`, or from the editor's clipboard through `OnPaste` on Ctrl-V / Shift-Ins / Edit ▸ Paste — and either types it (one line, up to `pasteInlineLimit` runes: a path, a command) or keeps it in `pastes` and inserts `[Pasted #N · L lines · C chars]` where the cursor is. `Send` calls `Session.PromptWith(text, pastesIn(text), mentions…)`: the transcript keeps the text with the tokens in it, so the exchange stays readable, and `expandPastes` replaces each token by its text **inside the text blocks only, after `blocksFor` has cut the mentions out** — a paste goes byte for byte, so an `@name` inside a pasted log is characters, not a file. A token whose text was deleted with it is simply absent from `pastesIn`. Tokens are found in a line by their text (`tokensIn`), never remembered by position; Backspace after one and Delete before one remove it whole, `←`/`→` step over it, and a move by row that lands inside one is pushed to the nearer edge (`leaveToken`) — a half token stands for nothing. It is drawn in the type style, as a tool call is: a thing the agent will be handed, not words. Numbers run on for the life of the window, so `#1` in an earlier exchange is not `#1` in this one.
43+
39 **A mention takes the name out of the text.** `@app/menus.go` in the box becomes a content block *in place* — the file's text as a `resource` when the agent declared `promptCapabilities.embeddedContext`, a `resource_link` otherwise — with text blocks either side. The conversation keeps what was typed; the wire does not carry the name twice.44 **A mention takes the name out of the text.** `@app/menus.go` in the box becomes a content block *in place* — the file's text as a `resource` when the agent declared `promptCapabilities.embeddedContext`, a `resource_link` otherwise — with text blocks either side. The conversation keeps what was typed; the wire does not carry the name twice.
40 45
41 **Only a fence makes a block code.** No scanner is guessed at from the shape of the text, which is the rule every scanner in this library already follows. A fence naming a language nothing colours is drawn plainly.46 **Only a fence makes a block code.** No scanner is guessed at from the shape of the text, which is the rule every scanner in this library already follows. A fence naming a language nothing colours is drawn plainly.
added acp/input.go +111 -0
new file mode 100644
@@ -0,0 +1,111 @@
1+// Laying the box you type in out as rows: a long line wraps at spaces rather
2+// than running off the edge and taking the cursor with it.
3+
4+package acp
5+
6+// inputRow is one row of the box as drawn: which typed line it shows, and
7+// which of that line's runes, as a half-open range.
8+//
9+// The typed lines stay the model — Alt-Enter makes one, Backspace at a line
10+// start joins two — and wrapping is only how they are shown. A row is never
11+// stored: it is recomputed from the lines and the width at every frame and
12+// every key, so a resize cannot leave the two out of step.
13+type inputRow struct {
14+ line int
15+ start, end int
16+}
17+
18+// inputWidth is how many runes a row of the box holds: the prompt, a space,
19+// and one column kept free so the cursor sitting just past a full row is
20+// still inside the pane rather than on the frame.
21+func (v *View) inputWidth() int {
22+ return max(v.Bounds().W-3, 1)
23+}
24+
25+// inputRows lays every typed line out at a width.
26+func (v *View) inputRows(width int) []inputRow {
27+ var rows []inputRow
28+ for line, text := range v.input {
29+ rows = append(rows, wrapInputLine(line, []rune(text), width)...)
30+ }
31+ return rows
32+}
33+
34+// wrapInputLine breaks one typed line into rows no wider than width, at the
35+// last space that fits and mid-word when a word is longer than a row.
36+//
37+// The space a row breaks at stays at the end of that row rather than opening
38+// the next, so the runes of the line are exactly the rows laid end to end and
39+// a cursor position is one row's and no other's. A row may therefore hold
40+// width+1 runes, the last a space, which is not drawn.
41+//
42+// A line that ends in such a space — you have just typed it — is followed by
43+// an empty row, which falls out of the loop: what is left after the break is
44+// nothing, and nothing is a row. That is where the cursor then sits.
45+func wrapInputLine(line int, runes []rune, width int) []inputRow {
46+ var rows []inputRow
47+ start := 0
48+ for {
49+ remaining := runes[start:]
50+ if len(remaining) <= width {
51+ return append(rows, inputRow{line: line, start: start, end: len(runes)})
52+ }
53+ at := start + inputBreak(remaining, width)
54+ rows = append(rows, inputRow{line: line, start: start, end: at})
55+ start = at
56+ }
57+}
58+
59+// inputBreak returns how many runes the first row of a run longer than width
60+// takes: through the last space in the first width+1 runes, or width when
61+// there is none.
62+func inputBreak(runes []rune, width int) int {
63+ for at := width; at >= 0; at-- {
64+ if runes[at] == ' ' {
65+ return at + 1
66+ }
67+ }
68+ return width
69+}
70+
71+// inputCursor returns which row the cursor is on and how far along it.
72+//
73+// A position at the very end of a row that continues belongs to the next
74+// row's start, which is what makes typing across a wrap look like typing.
75+func (v *View) inputCursor(rows []inputRow) (row, column int) {
76+ for i, r := range rows {
77+ if r.line != v.cursor {
78+ continue
79+ }
80+ last := i == len(rows)-1 || rows[i+1].line != r.line
81+ if v.column < r.end || (last && v.column <= r.end) {
82+ return i, v.column - r.start
83+ }
84+ }
85+ return max(len(rows)-1, 0), 0
86+}
87+
88+// moveInputRow moves the cursor to the row above or below the one it is on,
89+// keeping its column where the row allows, and reports whether there was one.
90+//
91+// Rows, not lines: a paragraph that wraps three times is three rows to the
92+// eye, and Up from its last row should land on its middle, not on the
93+// paragraph before.
94+func (v *View) moveInputRow(by int) bool {
95+ rows := v.inputRows(v.inputWidth())
96+ row, column := v.inputCursor(rows)
97+ target := row + by
98+ if target < 0 || target >= len(rows) {
99+ return false
100+ }
101+
102+ r := rows[target]
103+ widest := r.end - r.start
104+ if last := target == len(rows)-1 || rows[target+1].line != r.line; !last {
105+ widest-- // the row's last position is the next row's first
106+ }
107+ v.cursor = r.line
108+ v.column = r.start + min(column, max(widest, 0))
109+ v.leaveToken()
110+ return true
111+}
new file mode 100644
@@ -0,0 +1,111 @@
1+// Laying the box you type in out as rows: a long line wraps at spaces rather
2+// than running off the edge and taking the cursor with it.
3+
4+package acp
5+
6+// inputRow is one row of the box as drawn: which typed line it shows, and
7+// which of that line's runes, as a half-open range.
8+//
9+// The typed lines stay the model — Alt-Enter makes one, Backspace at a line
10+// start joins two — and wrapping is only how they are shown. A row is never
11+// stored: it is recomputed from the lines and the width at every frame and
12+// every key, so a resize cannot leave the two out of step.
13+type inputRow struct {
14+ line int
15+ start, end int
16+}
17+
18+// inputWidth is how many runes a row of the box holds: the prompt, a space,
19+// and one column kept free so the cursor sitting just past a full row is
20+// still inside the pane rather than on the frame.
21+func (v *View) inputWidth() int {
22+ return max(v.Bounds().W-3, 1)
23+}
24+
25+// inputRows lays every typed line out at a width.
26+func (v *View) inputRows(width int) []inputRow {
27+ var rows []inputRow
28+ for line, text := range v.input {
29+ rows = append(rows, wrapInputLine(line, []rune(text), width)...)
30+ }
31+ return rows
32+}
33+
34+// wrapInputLine breaks one typed line into rows no wider than width, at the
35+// last space that fits and mid-word when a word is longer than a row.
36+//
37+// The space a row breaks at stays at the end of that row rather than opening
38+// the next, so the runes of the line are exactly the rows laid end to end and
39+// a cursor position is one row's and no other's. A row may therefore hold
40+// width+1 runes, the last a space, which is not drawn.
41+//
42+// A line that ends in such a space — you have just typed it — is followed by
43+// an empty row, which falls out of the loop: what is left after the break is
44+// nothing, and nothing is a row. That is where the cursor then sits.
45+func wrapInputLine(line int, runes []rune, width int) []inputRow {
46+ var rows []inputRow
47+ start := 0
48+ for {
49+ remaining := runes[start:]
50+ if len(remaining) <= width {
51+ return append(rows, inputRow{line: line, start: start, end: len(runes)})
52+ }
53+ at := start + inputBreak(remaining, width)
54+ rows = append(rows, inputRow{line: line, start: start, end: at})
55+ start = at
56+ }
57+}
58+
59+// inputBreak returns how many runes the first row of a run longer than width
60+// takes: through the last space in the first width+1 runes, or width when
61+// there is none.
62+func inputBreak(runes []rune, width int) int {
63+ for at := width; at >= 0; at-- {
64+ if runes[at] == ' ' {
65+ return at + 1
66+ }
67+ }
68+ return width
69+}
70+
71+// inputCursor returns which row the cursor is on and how far along it.
72+//
73+// A position at the very end of a row that continues belongs to the next
74+// row's start, which is what makes typing across a wrap look like typing.
75+func (v *View) inputCursor(rows []inputRow) (row, column int) {
76+ for i, r := range rows {
77+ if r.line != v.cursor {
78+ continue
79+ }
80+ last := i == len(rows)-1 || rows[i+1].line != r.line
81+ if v.column < r.end || (last && v.column <= r.end) {
82+ return i, v.column - r.start
83+ }
84+ }
85+ return max(len(rows)-1, 0), 0
86+}
87+
88+// moveInputRow moves the cursor to the row above or below the one it is on,
89+// keeping its column where the row allows, and reports whether there was one.
90+//
91+// Rows, not lines: a paragraph that wraps three times is three rows to the
92+// eye, and Up from its last row should land on its middle, not on the
93+// paragraph before.
94+func (v *View) moveInputRow(by int) bool {
95+ rows := v.inputRows(v.inputWidth())
96+ row, column := v.inputCursor(rows)
97+ target := row + by
98+ if target < 0 || target >= len(rows) {
99+ return false
100+ }
101+
102+ r := rows[target]
103+ widest := r.end - r.start
104+ if last := target == len(rows)-1 || rows[target+1].line != r.line; !last {
105+ widest-- // the row's last position is the next row's first
106+ }
107+ v.cursor = r.line
108+ v.column = r.start + min(column, max(widest, 0))
109+ v.leaveToken()
110+ return true
111+}
added acp/input_test.go +194 -0
new file mode 100644
@@ -0,0 +1,194 @@
1+package acp_test
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/gdamore/tcell/v2"
8+
9+ "rickub.com/turbo-editors/turbo-core/acp"
10+ "rickub.com/turbo-editors/turbo-core/theme"
11+ "rickub.com/turbo-editors/turbo-core/ui"
12+)
13+
14+// drawWithCursor paints a focused view and returns the rows and where the
15+// terminal's cursor was put — or -1, -1 when it was hidden, which is what
16+// happened to a cursor that ran off the edge of the box.
17+func drawWithCursor(t *testing.T, view *acp.View, width, height int) (rows []string, x, y int) {
18+ t.Helper()
19+
20+ screen := tcell.NewSimulationScreen("UTF-8")
21+ if err := screen.Init(); err != nil {
22+ t.Fatalf("starting the screen: %v", err)
23+ }
24+ defer screen.Fini()
25+ screen.SetSize(width, height)
26+
27+ th, err := theme.Load(theme.DefaultName, "")
28+ if err != nil {
29+ t.Fatalf("loading the theme: %v", err)
30+ }
31+
32+ view.SetFocused(true)
33+ view.Draw(ui.NewPainter(screen), th)
34+ screen.Show()
35+
36+ cells, w, h := screen.GetContents()
37+ rows = make([]string, h)
38+ for row := range h {
39+ var text strings.Builder
40+ for col := range w {
41+ text.WriteString(string(cells[row*w+col].Runes))
42+ }
43+ rows[row] = strings.TrimRight(text.String(), " ")
44+ }
45+
46+ x, y, visible := screen.GetCursor()
47+ if !visible {
48+ return rows, -1, -1
49+ }
50+ return rows, x, y
51+}
52+
53+// inputRows returns the rows of the box you type in: everything below the
54+// rule.
55+func inputRows(rows []string) []string {
56+ for i, row := range rows {
57+ if strings.Contains(row, "─") {
58+ return rows[i+1:]
59+ }
60+ }
61+ return nil
62+}
63+
64+func typeInto(view *acp.View, text string) {
65+ for _, r := range text {
66+ view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
67+ }
68+}
69+
70+func TestALongPromptWrapsAtSpacesInsideTheBox(t *testing.T) {
71+ // This is the feature: before, the line was cut off with an ellipsis at
72+ // the edge and the rest of what you typed was invisible.
73+ view, _ := newView(t, 30, 12)
74+ typeInto(view, "please explain how the wrapping of a long prompt works")
75+
76+ rows, _, _ := drawWithCursor(t, view, 30, 12)
77+ box := inputRows(rows)
78+
79+ if !strings.HasPrefix(box[0], "> please explain how the") || box[1] != " wrapping of a long prompt" || box[2] != " works" {
80+ t.Errorf("the box is not wrapped at spaces:\n%s", strings.Join(box, "\n"))
81+ }
82+ if strings.Contains(strings.Join(box, "\n"), "…") {
83+ t.Errorf("the box still cuts the prompt off:\n%s", strings.Join(box, "\n"))
84+ }
85+}
86+
87+func TestTheCursorFollowsTheTextOntoTheWrappedRow(t *testing.T) {
88+ view, _ := newView(t, 30, 12)
89+ typeInto(view, "please explain how the wrapping")
90+
91+ _, x, y := drawWithCursor(t, view, 30, 12)
92+
93+ // Row 0 of the box is screen row 9 (12 rows: 8 of conversation, the rule,
94+ // then 3 of box); "wrapping" is 8 runes on the second row, after "> " and
95+ // its own indent.
96+ if x != 2+len("wrapping") || y != 10 {
97+ t.Errorf("cursor at (%d, %d), want it after %q on the second row (%d, %d)", x, y, "wrapping", 2+len("wrapping"), 10)
98+ }
99+}
100+
101+func TestTheCursorAtTheEndOfAFullRowStaysInsideThePane(t *testing.T) {
102+ // A row holds width-3 runes: prompt, space, and a column for exactly
103+ // this cursor. Without it the cursor sat on the frame and tcell hid it.
104+ view, _ := newView(t, 30, 12)
105+ typeInto(view, strings.Repeat("x", 27))
106+
107+ _, x, y := drawWithCursor(t, view, 30, 12)
108+
109+ if x != 29 || y != 9 {
110+ t.Errorf("cursor at (%d, %d), want (29, 9): the last column of the pane, first row of the box", x, y)
111+ }
112+}
113+
114+func TestAWordLongerThanTheBoxBreaksAtTheEdgeRatherThanVanishing(t *testing.T) {
115+ view, _ := newView(t, 30, 12)
116+ typeInto(view, strings.Repeat("a", 40))
117+
118+ rows, _, _ := drawWithCursor(t, view, 30, 12)
119+ box := inputRows(rows)
120+
121+ if box[0] != "> "+strings.Repeat("a", 27) || box[1] != " "+strings.Repeat("a", 13) {
122+ t.Errorf("a long word is not broken at the edge:\n%s", strings.Join(box, "\n"))
123+ }
124+}
125+
126+func TestUpAndDownMoveByRowInsideAWrappedPrompt(t *testing.T) {
127+ // Three rows to the eye are three rows to the arrow keys. Up from the last
128+ // row lands on the middle one, not on the conversation.
129+ view, _ := newView(t, 30, 12)
130+ typeInto(view, "please explain how the wrapping of a long prompt works")
131+
132+ if !view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) {
133+ t.Fatal("Up was not taken by the box, though there is a row above the cursor")
134+ }
135+ _, x, y := drawWithCursor(t, view, 30, 12)
136+ if y != 10 || x != 2+len("works") {
137+ t.Errorf("after Up the cursor is at (%d, %d), want the middle row at the same column (%d, 10)", x, y, 2+len("works"))
138+ }
139+
140+ view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone))
141+ view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone))
142+ _, x, y = drawWithCursor(t, view, 30, 12)
143+ if y != 10 || x != 2+len("works") {
144+ t.Errorf("Up then Down did not come back: cursor at (%d, %d)", x, y)
145+ }
146+
147+ // The column is kept, not the word: five in on the middle row is between
148+ // "wrapp" and "ing", which is where a key pressed now goes.
149+ typeInto(view, "!")
150+ if got := view.Input(); got != "please explain how the wrapp!ing of a long prompt works" {
151+ t.Errorf("typing after the moves went to the wrong place: %q", got)
152+ }
153+}
154+
155+func TestUpFromTheFirstRowOfTheBoxIsLeftForTheConversation(t *testing.T) {
156+ view, _ := newView(t, 30, 12)
157+ typeInto(view, "short")
158+
159+ if view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) && view.Input() != "short" {
160+ t.Error("Up on a one-row prompt changed the input")
161+ }
162+}
163+
164+func TestAPromptTallerThanTheBoxScrollsToKeepTheCursorInSight(t *testing.T) {
165+ // Three rows of box, five rows of prompt: the first two go up under the
166+ // rule, and the prompt marker goes with them.
167+ view, _ := newView(t, 30, 12)
168+ typeInto(view, "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty")
169+
170+ rows, _, y := drawWithCursor(t, view, 30, 12)
171+ box := inputRows(rows)
172+
173+ if y != 11 {
174+ t.Errorf("the cursor is on screen row %d, want the last row of the box, 11", y)
175+ }
176+ if strings.HasPrefix(box[0], ">") {
177+ t.Errorf("the prompt marker is beside a row that is not the first:\n%s", strings.Join(box, "\n"))
178+ }
179+ if !strings.HasSuffix(box[2], "twenty") {
180+ t.Errorf("the last row does not end with what was typed last:\n%s", strings.Join(box, "\n"))
181+ }
182+}
183+
184+func TestATypedTrailingSpaceAtTheEdgePutsTheCursorOnTheNextRow(t *testing.T) {
185+ // 27 runes fill a row; the space after them is the break, not drawn, and
186+ // the cursor is at the start of an empty row below rather than on the frame.
187+ view, _ := newView(t, 30, 12)
188+ typeInto(view, strings.Repeat("x", 27)+" ")
189+
190+ _, x, y := drawWithCursor(t, view, 30, 12)
191+ if x != 2 || y != 10 {
192+ t.Errorf("cursor at (%d, %d), want (2, 10)", x, y)
193+ }
194+}
new file mode 100644
@@ -0,0 +1,194 @@
1+package acp_test
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/gdamore/tcell/v2"
8+
9+ "rickub.com/turbo-editors/turbo-core/acp"
10+ "rickub.com/turbo-editors/turbo-core/theme"
11+ "rickub.com/turbo-editors/turbo-core/ui"
12+)
13+
14+// drawWithCursor paints a focused view and returns the rows and where the
15+// terminal's cursor was put — or -1, -1 when it was hidden, which is what
16+// happened to a cursor that ran off the edge of the box.
17+func drawWithCursor(t *testing.T, view *acp.View, width, height int) (rows []string, x, y int) {
18+ t.Helper()
19+
20+ screen := tcell.NewSimulationScreen("UTF-8")
21+ if err := screen.Init(); err != nil {
22+ t.Fatalf("starting the screen: %v", err)
23+ }
24+ defer screen.Fini()
25+ screen.SetSize(width, height)
26+
27+ th, err := theme.Load(theme.DefaultName, "")
28+ if err != nil {
29+ t.Fatalf("loading the theme: %v", err)
30+ }
31+
32+ view.SetFocused(true)
33+ view.Draw(ui.NewPainter(screen), th)
34+ screen.Show()
35+
36+ cells, w, h := screen.GetContents()
37+ rows = make([]string, h)
38+ for row := range h {
39+ var text strings.Builder
40+ for col := range w {
41+ text.WriteString(string(cells[row*w+col].Runes))
42+ }
43+ rows[row] = strings.TrimRight(text.String(), " ")
44+ }
45+
46+ x, y, visible := screen.GetCursor()
47+ if !visible {
48+ return rows, -1, -1
49+ }
50+ return rows, x, y
51+}
52+
53+// inputRows returns the rows of the box you type in: everything below the
54+// rule.
55+func inputRows(rows []string) []string {
56+ for i, row := range rows {
57+ if strings.Contains(row, "─") {
58+ return rows[i+1:]
59+ }
60+ }
61+ return nil
62+}
63+
64+func typeInto(view *acp.View, text string) {
65+ for _, r := range text {
66+ view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
67+ }
68+}
69+
70+func TestALongPromptWrapsAtSpacesInsideTheBox(t *testing.T) {
71+ // This is the feature: before, the line was cut off with an ellipsis at
72+ // the edge and the rest of what you typed was invisible.
73+ view, _ := newView(t, 30, 12)
74+ typeInto(view, "please explain how the wrapping of a long prompt works")
75+
76+ rows, _, _ := drawWithCursor(t, view, 30, 12)
77+ box := inputRows(rows)
78+
79+ if !strings.HasPrefix(box[0], "> please explain how the") || box[1] != " wrapping of a long prompt" || box[2] != " works" {
80+ t.Errorf("the box is not wrapped at spaces:\n%s", strings.Join(box, "\n"))
81+ }
82+ if strings.Contains(strings.Join(box, "\n"), "…") {
83+ t.Errorf("the box still cuts the prompt off:\n%s", strings.Join(box, "\n"))
84+ }
85+}
86+
87+func TestTheCursorFollowsTheTextOntoTheWrappedRow(t *testing.T) {
88+ view, _ := newView(t, 30, 12)
89+ typeInto(view, "please explain how the wrapping")
90+
91+ _, x, y := drawWithCursor(t, view, 30, 12)
92+
93+ // Row 0 of the box is screen row 9 (12 rows: 8 of conversation, the rule,
94+ // then 3 of box); "wrapping" is 8 runes on the second row, after "> " and
95+ // its own indent.
96+ if x != 2+len("wrapping") || y != 10 {
97+ t.Errorf("cursor at (%d, %d), want it after %q on the second row (%d, %d)", x, y, "wrapping", 2+len("wrapping"), 10)
98+ }
99+}
100+
101+func TestTheCursorAtTheEndOfAFullRowStaysInsideThePane(t *testing.T) {
102+ // A row holds width-3 runes: prompt, space, and a column for exactly
103+ // this cursor. Without it the cursor sat on the frame and tcell hid it.
104+ view, _ := newView(t, 30, 12)
105+ typeInto(view, strings.Repeat("x", 27))
106+
107+ _, x, y := drawWithCursor(t, view, 30, 12)
108+
109+ if x != 29 || y != 9 {
110+ t.Errorf("cursor at (%d, %d), want (29, 9): the last column of the pane, first row of the box", x, y)
111+ }
112+}
113+
114+func TestAWordLongerThanTheBoxBreaksAtTheEdgeRatherThanVanishing(t *testing.T) {
115+ view, _ := newView(t, 30, 12)
116+ typeInto(view, strings.Repeat("a", 40))
117+
118+ rows, _, _ := drawWithCursor(t, view, 30, 12)
119+ box := inputRows(rows)
120+
121+ if box[0] != "> "+strings.Repeat("a", 27) || box[1] != " "+strings.Repeat("a", 13) {
122+ t.Errorf("a long word is not broken at the edge:\n%s", strings.Join(box, "\n"))
123+ }
124+}
125+
126+func TestUpAndDownMoveByRowInsideAWrappedPrompt(t *testing.T) {
127+ // Three rows to the eye are three rows to the arrow keys. Up from the last
128+ // row lands on the middle one, not on the conversation.
129+ view, _ := newView(t, 30, 12)
130+ typeInto(view, "please explain how the wrapping of a long prompt works")
131+
132+ if !view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) {
133+ t.Fatal("Up was not taken by the box, though there is a row above the cursor")
134+ }
135+ _, x, y := drawWithCursor(t, view, 30, 12)
136+ if y != 10 || x != 2+len("works") {
137+ t.Errorf("after Up the cursor is at (%d, %d), want the middle row at the same column (%d, 10)", x, y, 2+len("works"))
138+ }
139+
140+ view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone))
141+ view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone))
142+ _, x, y = drawWithCursor(t, view, 30, 12)
143+ if y != 10 || x != 2+len("works") {
144+ t.Errorf("Up then Down did not come back: cursor at (%d, %d)", x, y)
145+ }
146+
147+ // The column is kept, not the word: five in on the middle row is between
148+ // "wrapp" and "ing", which is where a key pressed now goes.
149+ typeInto(view, "!")
150+ if got := view.Input(); got != "please explain how the wrapp!ing of a long prompt works" {
151+ t.Errorf("typing after the moves went to the wrong place: %q", got)
152+ }
153+}
154+
155+func TestUpFromTheFirstRowOfTheBoxIsLeftForTheConversation(t *testing.T) {
156+ view, _ := newView(t, 30, 12)
157+ typeInto(view, "short")
158+
159+ if view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) && view.Input() != "short" {
160+ t.Error("Up on a one-row prompt changed the input")
161+ }
162+}
163+
164+func TestAPromptTallerThanTheBoxScrollsToKeepTheCursorInSight(t *testing.T) {
165+ // Three rows of box, five rows of prompt: the first two go up under the
166+ // rule, and the prompt marker goes with them.
167+ view, _ := newView(t, 30, 12)
168+ typeInto(view, "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty")
169+
170+ rows, _, y := drawWithCursor(t, view, 30, 12)
171+ box := inputRows(rows)
172+
173+ if y != 11 {
174+ t.Errorf("the cursor is on screen row %d, want the last row of the box, 11", y)
175+ }
176+ if strings.HasPrefix(box[0], ">") {
177+ t.Errorf("the prompt marker is beside a row that is not the first:\n%s", strings.Join(box, "\n"))
178+ }
179+ if !strings.HasSuffix(box[2], "twenty") {
180+ t.Errorf("the last row does not end with what was typed last:\n%s", strings.Join(box, "\n"))
181+ }
182+}
183+
184+func TestATypedTrailingSpaceAtTheEdgePutsTheCursorOnTheNextRow(t *testing.T) {
185+ // 27 runes fill a row; the space after them is the break, not drawn, and
186+ // the cursor is at the start of an empty row below rather than on the frame.
187+ view, _ := newView(t, 30, 12)
188+ typeInto(view, strings.Repeat("x", 27)+" ")
189+
190+ _, x, y := drawWithCursor(t, view, 30, 12)
191+ if x != 2 || y != 10 {
192+ t.Errorf("cursor at (%d, %d), want (2, 10)", x, y)
193+ }
194+}
added acp/paste.go +214 -0
new file mode 100644
@@ -0,0 +1,214 @@
1+// Pasting into the box: a long paste becomes a token, and the agent gets the
2+// text the token stands for.
3+
4+package acp
5+
6+import (
7+ "fmt"
8+ "strings"
9+ "unicode/utf8"
10+
11+ "github.com/gdamore/tcell/v2"
12+)
13+
14+// Paste is text that was pasted into the box and kept aside: the box shows
15+// Token where it went, and the agent receives Text in its place.
16+//
17+// acp.Paste{Token: "[Pasted #1 · 6 lines · 700 chars]", Text: trace}
18+type Paste struct {
19+ Token string
20+ Text string
21+}
22+
23+// pasteInlineLimit is how long a single-line paste may be and still go into
24+// the box as if typed. Longer, and it is kept aside like a multi-line one: a
25+// box three rows tall filled by one paste is as unreadable as forty lines.
26+const pasteInlineLimit = 200
27+
28+// Paste puts pasted text into the box at the cursor, whichever pane had the
29+// focus — a paste is something to send, and the box is where that happens.
30+//
31+// A short single line goes in as if typed: a path, a command, an identifier
32+// is what you want to edit around. Anything with a line break in it, or
33+// longer than pasteInlineLimit, is **kept aside and stands in the box as a
34+// token** — `[Pasted #1 · 6 lines · 700 chars]` — because a box a few rows
35+// tall cannot show forty lines of log, and what was typed around them would be
36+// pushed off the screen. Send gives the agent the text, byte for byte, where
37+// the token was; the conversation keeps the token, so the exchange stays
38+// readable afterwards too. Deleting the paste instead is no answer: the text
39+// is what the model needs.
40+//
41+// A trailing newline is dropped: copying a line usually brings one along, and
42+// it would otherwise make a one-line paste a two-line token.
43+func (v *View) Paste(text string) {
44+ text = strings.ReplaceAll(text, "\r\n", "\n")
45+ text = strings.ReplaceAll(text, "\r", "\n")
46+ text = strings.TrimSuffix(text, "\n")
47+ if text == "" {
48+ return
49+ }
50+ v.onInput = true
51+
52+ if !strings.Contains(text, "\n") && utf8.RuneCountInString(text) <= pasteInlineLimit {
53+ v.insertText(text)
54+ return
55+ }
56+
57+ v.pasteCount++
58+ paste := Paste{Token: pasteToken(v.pasteCount, text), Text: text}
59+ v.pastes = append(v.pastes, paste)
60+ v.insertText(paste.Token)
61+}
62+
63+// pasteToken is what stands in the box for a paste: its number, so two are
64+// told apart, and its size, so you know what you are sending.
65+func pasteToken(number int, text string) string {
66+ lines := strings.Count(text, "\n") + 1
67+ return fmt.Sprintf("[Pasted #%d · %s · %s]", number, counted(lines, "line"), counted(utf8.RuneCountInString(text), "char"))
68+}
69+
70+// counted renders a count with its noun.
71+func counted(count int, noun string) string {
72+ if count == 1 {
73+ return "1 " + noun
74+ }
75+ return fmt.Sprintf("%d %ss", count, noun)
76+}
77+
78+// insertText types a run of characters — no line breaks — at the cursor.
79+func (v *View) insertText(text string) {
80+ for _, r := range text {
81+ v.insert(r)
82+ }
83+}
84+
85+// pasteClipboard pastes what the editor's clipboard holds, when the editor
86+// has said how to read it.
87+func (v *View) pasteClipboard() bool {
88+ if v.OnPaste == nil {
89+ return false
90+ }
91+ v.Paste(v.OnPaste())
92+ return true
93+}
94+
95+// isPasteKey reports whether a key is the editor's own Paste — Shift-Ins, as
96+// the Edit menu says. Ctrl-V is accepted beside it in handleWindowKey, as
97+// Ctrl-C is for Copy.
98+func isPasteKey(ev *tcell.EventKey) bool {
99+ return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModShift != 0
100+}
101+
102+// pastesIn returns the pastes whose token is still in a text — the ones Send
103+// has to expand. A token that was deleted takes its text with it.
104+func (v *View) pastesIn(text string) []Paste {
105+ var out []Paste
106+ for _, paste := range v.pastes {
107+ if strings.Contains(text, paste.Token) {
108+ out = append(out, paste)
109+ }
110+ }
111+ return out
112+}
113+
114+// tokenSpan is where a paste token sits in a line, in runes.
115+type tokenSpan struct{ start, end int }
116+
117+// tokensIn returns every paste token in a line, in order.
118+//
119+// Tokens are found by their text, not remembered by position: the line is
120+// edited by a dozen functions, and a position kept alongside would be wrong
121+// the first time one of them forgot to move it.
122+func (v *View) tokensIn(line string) []tokenSpan {
123+ var spans []tokenSpan
124+ for _, paste := range v.pastes {
125+ rest, offset := line, 0
126+ for {
127+ at := strings.Index(rest, paste.Token)
128+ if at < 0 {
129+ break
130+ }
131+ start := offset + utf8.RuneCountInString(rest[:at])
132+ spans = append(spans, tokenSpan{start: start, end: start + utf8.RuneCountInString(paste.Token)})
133+ skip := at + len(paste.Token)
134+ rest, offset = rest[skip:], start+utf8.RuneCountInString(paste.Token)
135+ }
136+ }
137+ return spans
138+}
139+
140+// tokenEndingAt returns the token whose last rune is just before a column.
141+func (v *View) tokenEndingAt(column int) (tokenSpan, bool) {
142+ for _, span := range v.tokensIn(v.input[v.cursor]) {
143+ if span.end == column {
144+ return span, true
145+ }
146+ }
147+ return tokenSpan{}, false
148+}
149+
150+// tokenStartingAt returns the token whose first rune is at a column.
151+func (v *View) tokenStartingAt(column int) (tokenSpan, bool) {
152+ for _, span := range v.tokensIn(v.input[v.cursor]) {
153+ if span.start == column {
154+ return span, true
155+ }
156+ }
157+ return tokenSpan{}, false
158+}
159+
160+// leaveToken moves a cursor that has landed inside a token to its nearer
161+// edge. Arrow keys step over tokens, so only a move by row can land inside
162+// one; a token is one thing, and typing into the middle of it would break it
163+// into text that stands for nothing.
164+func (v *View) leaveToken() {
165+ for _, span := range v.tokensIn(v.input[v.cursor]) {
166+ if v.column > span.start && v.column < span.end {
167+ if v.column-span.start < span.end-v.column {
168+ v.column = span.start
169+ } else {
170+ v.column = span.end
171+ }
172+ return
173+ }
174+ }
175+}
176+
177+// removeSpan takes a run of runes out of the cursor's line and leaves the
178+// cursor where the run began.
179+func (v *View) removeSpan(span tokenSpan) {
180+ runes := v.runes()
181+ v.input[v.cursor] = string(append(append([]rune{}, runes[:span.start]...), runes[span.end:]...))
182+ v.column = span.start
183+}
184+
185+// inToken reports whether a column of a line is inside one of its tokens.
186+func inToken(spans []tokenSpan, at int) bool {
187+ for _, span := range spans {
188+ if at >= span.start && at < span.end {
189+ return true
190+ }
191+ }
192+ return false
193+}
194+
195+// expandPastes puts each paste's text where its token stands, in the text
196+// blocks of a prompt and nowhere else.
197+//
198+// After the mentions have been cut out, not before: a paste is sent byte for
199+// byte, so an "@name" inside a pasted log must stay the characters it is and
200+// not become a file.
201+func expandPastes(blocks []ContentBlock, pastes []Paste) []ContentBlock {
202+ if len(pastes) == 0 {
203+ return blocks
204+ }
205+ for i := range blocks {
206+ if blocks[i].Type != ContentText {
207+ continue
208+ }
209+ for _, paste := range pastes {
210+ blocks[i].Text = strings.ReplaceAll(blocks[i].Text, paste.Token, paste.Text)
211+ }
212+ }
213+ return blocks
214+}
new file mode 100644
@@ -0,0 +1,214 @@
1+// Pasting into the box: a long paste becomes a token, and the agent gets the
2+// text the token stands for.
3+
4+package acp
5+
6+import (
7+ "fmt"
8+ "strings"
9+ "unicode/utf8"
10+
11+ "github.com/gdamore/tcell/v2"
12+)
13+
14+// Paste is text that was pasted into the box and kept aside: the box shows
15+// Token where it went, and the agent receives Text in its place.
16+//
17+// acp.Paste{Token: "[Pasted #1 · 6 lines · 700 chars]", Text: trace}
18+type Paste struct {
19+ Token string
20+ Text string
21+}
22+
23+// pasteInlineLimit is how long a single-line paste may be and still go into
24+// the box as if typed. Longer, and it is kept aside like a multi-line one: a
25+// box three rows tall filled by one paste is as unreadable as forty lines.
26+const pasteInlineLimit = 200
27+
28+// Paste puts pasted text into the box at the cursor, whichever pane had the
29+// focus — a paste is something to send, and the box is where that happens.
30+//
31+// A short single line goes in as if typed: a path, a command, an identifier
32+// is what you want to edit around. Anything with a line break in it, or
33+// longer than pasteInlineLimit, is **kept aside and stands in the box as a
34+// token** — `[Pasted #1 · 6 lines · 700 chars]` — because a box a few rows
35+// tall cannot show forty lines of log, and what was typed around them would be
36+// pushed off the screen. Send gives the agent the text, byte for byte, where
37+// the token was; the conversation keeps the token, so the exchange stays
38+// readable afterwards too. Deleting the paste instead is no answer: the text
39+// is what the model needs.
40+//
41+// A trailing newline is dropped: copying a line usually brings one along, and
42+// it would otherwise make a one-line paste a two-line token.
43+func (v *View) Paste(text string) {
44+ text = strings.ReplaceAll(text, "\r\n", "\n")
45+ text = strings.ReplaceAll(text, "\r", "\n")
46+ text = strings.TrimSuffix(text, "\n")
47+ if text == "" {
48+ return
49+ }
50+ v.onInput = true
51+
52+ if !strings.Contains(text, "\n") && utf8.RuneCountInString(text) <= pasteInlineLimit {
53+ v.insertText(text)
54+ return
55+ }
56+
57+ v.pasteCount++
58+ paste := Paste{Token: pasteToken(v.pasteCount, text), Text: text}
59+ v.pastes = append(v.pastes, paste)
60+ v.insertText(paste.Token)
61+}
62+
63+// pasteToken is what stands in the box for a paste: its number, so two are
64+// told apart, and its size, so you know what you are sending.
65+func pasteToken(number int, text string) string {
66+ lines := strings.Count(text, "\n") + 1
67+ return fmt.Sprintf("[Pasted #%d · %s · %s]", number, counted(lines, "line"), counted(utf8.RuneCountInString(text), "char"))
68+}
69+
70+// counted renders a count with its noun.
71+func counted(count int, noun string) string {
72+ if count == 1 {
73+ return "1 " + noun
74+ }
75+ return fmt.Sprintf("%d %ss", count, noun)
76+}
77+
78+// insertText types a run of characters — no line breaks — at the cursor.
79+func (v *View) insertText(text string) {
80+ for _, r := range text {
81+ v.insert(r)
82+ }
83+}
84+
85+// pasteClipboard pastes what the editor's clipboard holds, when the editor
86+// has said how to read it.
87+func (v *View) pasteClipboard() bool {
88+ if v.OnPaste == nil {
89+ return false
90+ }
91+ v.Paste(v.OnPaste())
92+ return true
93+}
94+
95+// isPasteKey reports whether a key is the editor's own Paste — Shift-Ins, as
96+// the Edit menu says. Ctrl-V is accepted beside it in handleWindowKey, as
97+// Ctrl-C is for Copy.
98+func isPasteKey(ev *tcell.EventKey) bool {
99+ return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModShift != 0
100+}
101+
102+// pastesIn returns the pastes whose token is still in a text — the ones Send
103+// has to expand. A token that was deleted takes its text with it.
104+func (v *View) pastesIn(text string) []Paste {
105+ var out []Paste
106+ for _, paste := range v.pastes {
107+ if strings.Contains(text, paste.Token) {
108+ out = append(out, paste)
109+ }
110+ }
111+ return out
112+}
113+
114+// tokenSpan is where a paste token sits in a line, in runes.
115+type tokenSpan struct{ start, end int }
116+
117+// tokensIn returns every paste token in a line, in order.
118+//
119+// Tokens are found by their text, not remembered by position: the line is
120+// edited by a dozen functions, and a position kept alongside would be wrong
121+// the first time one of them forgot to move it.
122+func (v *View) tokensIn(line string) []tokenSpan {
123+ var spans []tokenSpan
124+ for _, paste := range v.pastes {
125+ rest, offset := line, 0
126+ for {
127+ at := strings.Index(rest, paste.Token)
128+ if at < 0 {
129+ break
130+ }
131+ start := offset + utf8.RuneCountInString(rest[:at])
132+ spans = append(spans, tokenSpan{start: start, end: start + utf8.RuneCountInString(paste.Token)})
133+ skip := at + len(paste.Token)
134+ rest, offset = rest[skip:], start+utf8.RuneCountInString(paste.Token)
135+ }
136+ }
137+ return spans
138+}
139+
140+// tokenEndingAt returns the token whose last rune is just before a column.
141+func (v *View) tokenEndingAt(column int) (tokenSpan, bool) {
142+ for _, span := range v.tokensIn(v.input[v.cursor]) {
143+ if span.end == column {
144+ return span, true
145+ }
146+ }
147+ return tokenSpan{}, false
148+}
149+
150+// tokenStartingAt returns the token whose first rune is at a column.
151+func (v *View) tokenStartingAt(column int) (tokenSpan, bool) {
152+ for _, span := range v.tokensIn(v.input[v.cursor]) {
153+ if span.start == column {
154+ return span, true
155+ }
156+ }
157+ return tokenSpan{}, false
158+}
159+
160+// leaveToken moves a cursor that has landed inside a token to its nearer
161+// edge. Arrow keys step over tokens, so only a move by row can land inside
162+// one; a token is one thing, and typing into the middle of it would break it
163+// into text that stands for nothing.
164+func (v *View) leaveToken() {
165+ for _, span := range v.tokensIn(v.input[v.cursor]) {
166+ if v.column > span.start && v.column < span.end {
167+ if v.column-span.start < span.end-v.column {
168+ v.column = span.start
169+ } else {
170+ v.column = span.end
171+ }
172+ return
173+ }
174+ }
175+}
176+
177+// removeSpan takes a run of runes out of the cursor's line and leaves the
178+// cursor where the run began.
179+func (v *View) removeSpan(span tokenSpan) {
180+ runes := v.runes()
181+ v.input[v.cursor] = string(append(append([]rune{}, runes[:span.start]...), runes[span.end:]...))
182+ v.column = span.start
183+}
184+
185+// inToken reports whether a column of a line is inside one of its tokens.
186+func inToken(spans []tokenSpan, at int) bool {
187+ for _, span := range spans {
188+ if at >= span.start && at < span.end {
189+ return true
190+ }
191+ }
192+ return false
193+}
194+
195+// expandPastes puts each paste's text where its token stands, in the text
196+// blocks of a prompt and nowhere else.
197+//
198+// After the mentions have been cut out, not before: a paste is sent byte for
199+// byte, so an "@name" inside a pasted log must stay the characters it is and
200+// not become a file.
201+func expandPastes(blocks []ContentBlock, pastes []Paste) []ContentBlock {
202+ if len(pastes) == 0 {
203+ return blocks
204+ }
205+ for i := range blocks {
206+ if blocks[i].Type != ContentText {
207+ continue
208+ }
209+ for _, paste := range pastes {
210+ blocks[i].Text = strings.ReplaceAll(blocks[i].Text, paste.Token, paste.Text)
211+ }
212+ }
213+ return blocks
214+}
added acp/paste_test.go +273 -0
new file mode 100644
@@ -0,0 +1,273 @@
1+package acp_test
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/gdamore/tcell/v2"
8+
9+ "rickub.com/turbo-editors/turbo-core/acp"
10+ "rickub.com/turbo-editors/turbo-core/theme"
11+ "rickub.com/turbo-editors/turbo-core/ui"
12+)
13+
14+// trace is six lines of the kind of thing that gets pasted into an agent's
15+// box: a log, longer than the box is tall.
16+const trace = "panic: runtime error: index out of range [3] with length 3\n" +
17+ "goroutine 1 [running]:\n" +
18+ "main.pick(...)\n" +
19+ "\t/src/p/main.go:12\n" +
20+ "main.main()\n" +
21+ "\t/src/p/main.go:7 +0x1d"
22+
23+func TestAMultiLinePasteStandsInTheBoxAsAToken(t *testing.T) {
24+ // The ticket's own example: a paste a few rows tall would make the box
25+ // unreadable and push what was typed around it off the screen.
26+ view, _ := newView(t, 60, 12)
27+ typeInto(view, "here is the trace ")
28+ view.Paste(trace)
29+ typeInto(view, " what is failing?")
30+
31+ want := "here is the trace [Pasted #1 · 6 lines · 151 chars] what is failing?"
32+ if got := view.Input(); got != want {
33+ t.Errorf("Input() = %q\nwant %q", got, want)
34+ }
35+}
36+
37+func TestAShortSingleLinePasteGoesInAsIfTyped(t *testing.T) {
38+ // A path, a command, an identifier: what you paste to edit around. The
39+ // newline copying a line brings along is dropped, not made a second line.
40+ view, _ := newView(t, 60, 12)
41+ view.Paste("go build ./...\n")
42+
43+ if got := view.Input(); got != "go build ./..." {
44+ t.Errorf("Input() = %q, want the line itself", got)
45+ }
46+}
47+
48+func TestALongSingleLinePasteIsKeptAsideToo(t *testing.T) {
49+ view, _ := newView(t, 60, 12)
50+ view.Paste(strings.Repeat("x", 300))
51+
52+ if got := view.Input(); got != "[Pasted #1 · 1 line · 300 chars]" {
53+ t.Errorf("Input() = %q, want a token for one long line", got)
54+ }
55+}
56+
57+func TestWindowsLineEndingsCountAsLines(t *testing.T) {
58+ view, _ := newView(t, 60, 12)
59+ view.Paste("one\r\ntwo\r\nthree\r\n")
60+
61+ if got := view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" {
62+ t.Errorf("Input() = %q", got)
63+ }
64+}
65+
66+func TestTheAgentReceivesThePastedTextWhereTheTokenStood(t *testing.T) {
67+ // This is the point: the token is for the box; the model gets the bytes.
68+ view, agent := mentioningView(t, nil)
69+ agent.handshake()
70+ waitFor(t, "ready", view.Session().Ready)
71+
72+ typeInto(view, "here is the trace ")
73+ view.Paste(trace)
74+ typeInto(view, " what is failing?")
75+ view.Send()
76+
77+ blocks := promptBlocks(t, agent.read())
78+ if len(blocks) != 1 || blocks[0]["type"] != "text" {
79+ t.Fatalf("the prompt is %v, want one text block", blocks)
80+ }
81+ if got := blocks[0]["text"]; got != "here is the trace "+trace+" what is failing?" {
82+ t.Errorf("the agent received %q", got)
83+ }
84+
85+ // The conversation keeps the token, so the exchange stays readable.
86+ if got := textOf(view.Session().Entries(), acp.EntryUser); !strings.Contains(got, "[Pasted #1 · 6 lines") || strings.Contains(got, "goroutine") {
87+ t.Errorf("the conversation says %q, want the token and not the trace", got)
88+ }
89+}
90+
91+func TestAFileNamedInsideAPasteIsNotTakenForAMention(t *testing.T) {
92+ // A paste is sent byte for byte. An "@name" in a pasted log is characters,
93+ // not a request to attach the file.
94+ view, agent := mentioningView(t, func(string) (string, error) { return "package internal\n", nil })
95+ agent.handshake()
96+ waitFor(t, "ready", view.Session().Ready)
97+
98+ pasted := "see @internal/scanner.go for details\nand the next line"
99+ view.Paste(pasted)
100+ view.Send()
101+
102+ blocks := promptBlocks(t, agent.read())
103+ if len(blocks) != 1 || blocks[0]["type"] != "text" || blocks[0]["text"] != pasted {
104+ t.Errorf("the prompt is %v, want the pasted text alone, verbatim", blocks)
105+ }
106+}
107+
108+func TestAMentionTypedBesideATokenStillWorks(t *testing.T) {
109+ view, agent := mentioningView(t, func(string) (string, error) { return "package internal\n", nil })
110+ agent.handshake()
111+ waitFor(t, "ready", view.Session().Ready)
112+
113+ typeInto(view, "compare @internal/scanner.go with ")
114+ view.Paste(trace)
115+ view.Send()
116+
117+ blocks := promptBlocks(t, agent.read())
118+ if len(blocks) != 3 || blocks[1]["type"] != "resource" {
119+ t.Fatalf("the prompt has %d blocks, want text, resource, text: %v", len(blocks), blocks)
120+ }
121+ if blocks[2]["text"] != " with "+trace {
122+ t.Errorf("block 2 = %q, want the words and the trace", blocks[2]["text"])
123+ }
124+}
125+
126+func TestBackspaceRemovesAWholeToken(t *testing.T) {
127+ // A token missing its last bracket stands for nothing, so the token is
128+ // one thing to the editing keys.
129+ view, agent := mentioningView(t, nil)
130+ agent.handshake()
131+ waitFor(t, "ready", view.Session().Ready)
132+
133+ typeInto(view, "a ")
134+ view.Paste(trace)
135+ view.HandleKey(tcell.NewEventKey(tcell.KeyBackspace2, 0, tcell.ModNone))
136+
137+ if got := view.Input(); got != "a " {
138+ t.Errorf("Input() = %q after Backspace, want the token gone whole", got)
139+ }
140+
141+ // And its text goes with it: nothing of the trace reaches the agent.
142+ typeInto(view, "b")
143+ view.Send()
144+ blocks := promptBlocks(t, agent.read())
145+ if len(blocks) != 1 || blocks[0]["text"] != "a b" {
146+ t.Errorf("the agent received %v, want just %q", blocks, "a b")
147+ }
148+}
149+
150+func TestDeleteAtTheStartOfATokenRemovesItWhole(t *testing.T) {
151+ view, _ := newView(t, 60, 12)
152+ view.Paste(trace)
153+ typeInto(view, "!")
154+ view.HandleKey(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone))
155+ view.HandleKey(tcell.NewEventKey(tcell.KeyDelete, 0, tcell.ModNone))
156+
157+ if got := view.Input(); got != "!" {
158+ t.Errorf("Input() = %q after Delete at the token's start", got)
159+ }
160+}
161+
162+func TestTheArrowKeysStepOverATokenRatherThanInto(t *testing.T) {
163+ view, _ := newView(t, 60, 12)
164+ view.Paste(trace)
165+ typeInto(view, "x")
166+
167+ view.HandleKey(tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)) // before x
168+ view.HandleKey(tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)) // over the token
169+ typeInto(view, "y")
170+ view.HandleKey(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)) // over it again
171+ typeInto(view, "z")
172+
173+ if got := view.Input(); got != "y[Pasted #1 · 6 lines · 151 chars]zx" {
174+ t.Errorf("Input() = %q", got)
175+ }
176+}
177+
178+func TestAMoveByRowThatLandsInsideATokenIsPushedToItsEdge(t *testing.T) {
179+ // Only ↑/↓ can land inside one; typing into the middle of a token would
180+ // break it into text that stands for nothing.
181+ view, _ := newView(t, 40, 12) // rows of 37 runes
182+ typeInto(view, strings.Repeat("a", 30)+" ")
183+ view.Paste(trace) // the 34-rune token wraps onto the second row
184+ view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone))
185+ view.HandleKey(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone))
186+ view.HandleKey(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone))
187+ view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) // column 1 of the token's row
188+ typeInto(view, "!")
189+
190+ got := view.Input()
191+ if !strings.Contains(got, "![Pasted") && !strings.Contains(got, "chars]!") {
192+ t.Errorf("Input() = %q, want the ! at an edge of the token", got)
193+ }
194+}
195+
196+func TestATokenIsDrawnInItsOwnColour(t *testing.T) {
197+ view, _ := newView(t, 60, 12)
198+ typeInto(view, "see ")
199+ view.Paste(trace)
200+
201+ screen := tcell.NewSimulationScreen("UTF-8")
202+ if err := screen.Init(); err != nil {
203+ t.Fatal(err)
204+ }
205+ defer screen.Fini()
206+ screen.SetSize(60, 12)
207+ th, err := theme.Load(theme.DefaultName, "")
208+ if err != nil {
209+ t.Fatal(err)
210+ }
211+ view.Draw(ui.NewPainter(screen), th)
212+ screen.Show()
213+
214+ cells, w, _ := screen.GetContents()
215+ row := 9 // the box's first row
216+ word, token := cells[row*w+2].Style, cells[row*w+2+len("see ")].Style
217+ if word == token {
218+ t.Error("the token is drawn in the same colour as the words around it")
219+ }
220+ if got := string(cells[row*w+2+len("see ")].Runes); got != "[" {
221+ t.Errorf("the token's first cell shows %q, want its bracket", got)
222+ }
223+}
224+
225+func TestCtrlVPastesWhatTheEditorsClipboardHolds(t *testing.T) {
226+ view, _ := newView(t, 60, 12)
227+ view.OnPaste = func() string { return "one\ntwo\nthree" }
228+
229+ view.HandleKey(tcell.NewEventKey(tcell.KeyCtrlV, 0, tcell.ModNone))
230+
231+ if got := view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" {
232+ t.Errorf("Input() = %q after Ctrl-V", got)
233+ }
234+}
235+
236+func TestShiftInsertPastesToo(t *testing.T) {
237+ view, _ := newView(t, 60, 12)
238+ view.OnPaste = func() string { return "short" }
239+
240+ view.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModShift))
241+
242+ if got := view.Input(); got != "short" {
243+ t.Errorf("Input() = %q after Shift-Ins", got)
244+ }
245+}
246+
247+func TestPasteNumbersRunOnAcrossPrompts(t *testing.T) {
248+ // "#1" in an earlier exchange is not the same thing as "#1" in this one.
249+ view, agent := mentioningView(t, nil)
250+ agent.handshake()
251+ waitFor(t, "ready", view.Session().Ready)
252+
253+ view.Paste(trace)
254+ view.Send()
255+ agent.read()
256+ view.Paste(trace)
257+
258+ if got := view.Input(); !strings.HasPrefix(got, "[Pasted #2 ") {
259+ t.Errorf("the second paste is %q, want #2", got)
260+ }
261+}
262+
263+func TestAPasteGoesIntoTheBoxWhicheverPaneHadTheFocus(t *testing.T) {
264+ view, _ := newView(t, 60, 12)
265+ view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) // to the conversation
266+
267+ view.Paste("hello")
268+ typeInto(view, "!")
269+
270+ if got := view.Input(); got != "hello!" {
271+ t.Errorf("Input() = %q, want the paste and the keystroke after it in the box", got)
272+ }
273+}
new file mode 100644
@@ -0,0 +1,273 @@
1+package acp_test
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/gdamore/tcell/v2"
8+
9+ "rickub.com/turbo-editors/turbo-core/acp"
10+ "rickub.com/turbo-editors/turbo-core/theme"
11+ "rickub.com/turbo-editors/turbo-core/ui"
12+)
13+
14+// trace is six lines of the kind of thing that gets pasted into an agent's
15+// box: a log, longer than the box is tall.
16+const trace = "panic: runtime error: index out of range [3] with length 3\n" +
17+ "goroutine 1 [running]:\n" +
18+ "main.pick(...)\n" +
19+ "\t/src/p/main.go:12\n" +
20+ "main.main()\n" +
21+ "\t/src/p/main.go:7 +0x1d"
22+
23+func TestAMultiLinePasteStandsInTheBoxAsAToken(t *testing.T) {
24+ // The ticket's own example: a paste a few rows tall would make the box
25+ // unreadable and push what was typed around it off the screen.
26+ view, _ := newView(t, 60, 12)
27+ typeInto(view, "here is the trace ")
28+ view.Paste(trace)
29+ typeInto(view, " what is failing?")
30+
31+ want := "here is the trace [Pasted #1 · 6 lines · 151 chars] what is failing?"
32+ if got := view.Input(); got != want {
33+ t.Errorf("Input() = %q\nwant %q", got, want)
34+ }
35+}
36+
37+func TestAShortSingleLinePasteGoesInAsIfTyped(t *testing.T) {
38+ // A path, a command, an identifier: what you paste to edit around. The
39+ // newline copying a line brings along is dropped, not made a second line.
40+ view, _ := newView(t, 60, 12)
41+ view.Paste("go build ./...\n")
42+
43+ if got := view.Input(); got != "go build ./..." {
44+ t.Errorf("Input() = %q, want the line itself", got)
45+ }
46+}
47+
48+func TestALongSingleLinePasteIsKeptAsideToo(t *testing.T) {
49+ view, _ := newView(t, 60, 12)
50+ view.Paste(strings.Repeat("x", 300))
51+
52+ if got := view.Input(); got != "[Pasted #1 · 1 line · 300 chars]" {
53+ t.Errorf("Input() = %q, want a token for one long line", got)
54+ }
55+}
56+
57+func TestWindowsLineEndingsCountAsLines(t *testing.T) {
58+ view, _ := newView(t, 60, 12)
59+ view.Paste("one\r\ntwo\r\nthree\r\n")
60+
61+ if got := view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" {
62+ t.Errorf("Input() = %q", got)
63+ }
64+}
65+
66+func TestTheAgentReceivesThePastedTextWhereTheTokenStood(t *testing.T) {
67+ // This is the point: the token is for the box; the model gets the bytes.
68+ view, agent := mentioningView(t, nil)
69+ agent.handshake()
70+ waitFor(t, "ready", view.Session().Ready)
71+
72+ typeInto(view, "here is the trace ")
73+ view.Paste(trace)
74+ typeInto(view, " what is failing?")
75+ view.Send()
76+
77+ blocks := promptBlocks(t, agent.read())
78+ if len(blocks) != 1 || blocks[0]["type"] != "text" {
79+ t.Fatalf("the prompt is %v, want one text block", blocks)
80+ }
81+ if got := blocks[0]["text"]; got != "here is the trace "+trace+" what is failing?" {
82+ t.Errorf("the agent received %q", got)
83+ }
84+
85+ // The conversation keeps the token, so the exchange stays readable.
86+ if got := textOf(view.Session().Entries(), acp.EntryUser); !strings.Contains(got, "[Pasted #1 · 6 lines") || strings.Contains(got, "goroutine") {
87+ t.Errorf("the conversation says %q, want the token and not the trace", got)
88+ }
89+}
90+
91+func TestAFileNamedInsideAPasteIsNotTakenForAMention(t *testing.T) {
92+ // A paste is sent byte for byte. An "@name" in a pasted log is characters,
93+ // not a request to attach the file.
94+ view, agent := mentioningView(t, func(string) (string, error) { return "package internal\n", nil })
95+ agent.handshake()
96+ waitFor(t, "ready", view.Session().Ready)
97+
98+ pasted := "see @internal/scanner.go for details\nand the next line"
99+ view.Paste(pasted)
100+ view.Send()
101+
102+ blocks := promptBlocks(t, agent.read())
103+ if len(blocks) != 1 || blocks[0]["type"] != "text" || blocks[0]["text"] != pasted {
104+ t.Errorf("the prompt is %v, want the pasted text alone, verbatim", blocks)
105+ }
106+}
107+
108+func TestAMentionTypedBesideATokenStillWorks(t *testing.T) {
109+ view, agent := mentioningView(t, func(string) (string, error) { return "package internal\n", nil })
110+ agent.handshake()
111+ waitFor(t, "ready", view.Session().Ready)
112+
113+ typeInto(view, "compare @internal/scanner.go with ")
114+ view.Paste(trace)
115+ view.Send()
116+
117+ blocks := promptBlocks(t, agent.read())
118+ if len(blocks) != 3 || blocks[1]["type"] != "resource" {
119+ t.Fatalf("the prompt has %d blocks, want text, resource, text: %v", len(blocks), blocks)
120+ }
121+ if blocks[2]["text"] != " with "+trace {
122+ t.Errorf("block 2 = %q, want the words and the trace", blocks[2]["text"])
123+ }
124+}
125+
126+func TestBackspaceRemovesAWholeToken(t *testing.T) {
127+ // A token missing its last bracket stands for nothing, so the token is
128+ // one thing to the editing keys.
129+ view, agent := mentioningView(t, nil)
130+ agent.handshake()
131+ waitFor(t, "ready", view.Session().Ready)
132+
133+ typeInto(view, "a ")
134+ view.Paste(trace)
135+ view.HandleKey(tcell.NewEventKey(tcell.KeyBackspace2, 0, tcell.ModNone))
136+
137+ if got := view.Input(); got != "a " {
138+ t.Errorf("Input() = %q after Backspace, want the token gone whole", got)
139+ }
140+
141+ // And its text goes with it: nothing of the trace reaches the agent.
142+ typeInto(view, "b")
143+ view.Send()
144+ blocks := promptBlocks(t, agent.read())
145+ if len(blocks) != 1 || blocks[0]["text"] != "a b" {
146+ t.Errorf("the agent received %v, want just %q", blocks, "a b")
147+ }
148+}
149+
150+func TestDeleteAtTheStartOfATokenRemovesItWhole(t *testing.T) {
151+ view, _ := newView(t, 60, 12)
152+ view.Paste(trace)
153+ typeInto(view, "!")
154+ view.HandleKey(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone))
155+ view.HandleKey(tcell.NewEventKey(tcell.KeyDelete, 0, tcell.ModNone))
156+
157+ if got := view.Input(); got != "!" {
158+ t.Errorf("Input() = %q after Delete at the token's start", got)
159+ }
160+}
161+
162+func TestTheArrowKeysStepOverATokenRatherThanInto(t *testing.T) {
163+ view, _ := newView(t, 60, 12)
164+ view.Paste(trace)
165+ typeInto(view, "x")
166+
167+ view.HandleKey(tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)) // before x
168+ view.HandleKey(tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)) // over the token
169+ typeInto(view, "y")
170+ view.HandleKey(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)) // over it again
171+ typeInto(view, "z")
172+
173+ if got := view.Input(); got != "y[Pasted #1 · 6 lines · 151 chars]zx" {
174+ t.Errorf("Input() = %q", got)
175+ }
176+}
177+
178+func TestAMoveByRowThatLandsInsideATokenIsPushedToItsEdge(t *testing.T) {
179+ // Only ↑/↓ can land inside one; typing into the middle of a token would
180+ // break it into text that stands for nothing.
181+ view, _ := newView(t, 40, 12) // rows of 37 runes
182+ typeInto(view, strings.Repeat("a", 30)+" ")
183+ view.Paste(trace) // the 34-rune token wraps onto the second row
184+ view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone))
185+ view.HandleKey(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone))
186+ view.HandleKey(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone))
187+ view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) // column 1 of the token's row
188+ typeInto(view, "!")
189+
190+ got := view.Input()
191+ if !strings.Contains(got, "![Pasted") && !strings.Contains(got, "chars]!") {
192+ t.Errorf("Input() = %q, want the ! at an edge of the token", got)
193+ }
194+}
195+
196+func TestATokenIsDrawnInItsOwnColour(t *testing.T) {
197+ view, _ := newView(t, 60, 12)
198+ typeInto(view, "see ")
199+ view.Paste(trace)
200+
201+ screen := tcell.NewSimulationScreen("UTF-8")
202+ if err := screen.Init(); err != nil {
203+ t.Fatal(err)
204+ }
205+ defer screen.Fini()
206+ screen.SetSize(60, 12)
207+ th, err := theme.Load(theme.DefaultName, "")
208+ if err != nil {
209+ t.Fatal(err)
210+ }
211+ view.Draw(ui.NewPainter(screen), th)
212+ screen.Show()
213+
214+ cells, w, _ := screen.GetContents()
215+ row := 9 // the box's first row
216+ word, token := cells[row*w+2].Style, cells[row*w+2+len("see ")].Style
217+ if word == token {
218+ t.Error("the token is drawn in the same colour as the words around it")
219+ }
220+ if got := string(cells[row*w+2+len("see ")].Runes); got != "[" {
221+ t.Errorf("the token's first cell shows %q, want its bracket", got)
222+ }
223+}
224+
225+func TestCtrlVPastesWhatTheEditorsClipboardHolds(t *testing.T) {
226+ view, _ := newView(t, 60, 12)
227+ view.OnPaste = func() string { return "one\ntwo\nthree" }
228+
229+ view.HandleKey(tcell.NewEventKey(tcell.KeyCtrlV, 0, tcell.ModNone))
230+
231+ if got := view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" {
232+ t.Errorf("Input() = %q after Ctrl-V", got)
233+ }
234+}
235+
236+func TestShiftInsertPastesToo(t *testing.T) {
237+ view, _ := newView(t, 60, 12)
238+ view.OnPaste = func() string { return "short" }
239+
240+ view.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModShift))
241+
242+ if got := view.Input(); got != "short" {
243+ t.Errorf("Input() = %q after Shift-Ins", got)
244+ }
245+}
246+
247+func TestPasteNumbersRunOnAcrossPrompts(t *testing.T) {
248+ // "#1" in an earlier exchange is not the same thing as "#1" in this one.
249+ view, agent := mentioningView(t, nil)
250+ agent.handshake()
251+ waitFor(t, "ready", view.Session().Ready)
252+
253+ view.Paste(trace)
254+ view.Send()
255+ agent.read()
256+ view.Paste(trace)
257+
258+ if got := view.Input(); !strings.HasPrefix(got, "[Pasted #2 ") {
259+ t.Errorf("the second paste is %q, want #2", got)
260+ }
261+}
262+
263+func TestAPasteGoesIntoTheBoxWhicheverPaneHadTheFocus(t *testing.T) {
264+ view, _ := newView(t, 60, 12)
265+ view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) // to the conversation
266+
267+ view.Paste("hello")
268+ typeInto(view, "!")
269+
270+ if got := view.Input(); got != "hello!" {
271+ t.Errorf("Input() = %q, want the paste and the keystroke after it in the box", got)
272+ }
273+}
modified acp/session.go +19 -5
@@ -156,6 +156,7 @@ type Session struct {
156156 // pending is a prompt typed before the handshake finished, held until it has.
157157 type pending struct {
158158 text string
159+ pastes []Paste
159160 mentions []Mention
160161 }
161162
@@ -238,7 +239,7 @@ func (s *Session) handshake(cwd string) {
238239
239240 s.wake()
240241 for _, prompt := range queued {
241- s.send(prompt.text, prompt.mentions)
242+ s.send(prompt.text, prompt.pastes, prompt.mentions)
242243 }
243244 }
244245
@@ -315,6 +316,19 @@ func (s *Session) newSession(ctx context.Context, cwd string) error {
315316 // session.Prompt("what does buildMenus do?")
316317 // session.Prompt("explain @app/menus.go", acp.Mention{Name: "app/menus.go", Path: "/src/p/app/menus.go"})
317318 func (s *Session) Prompt(text string, mentions ...Mention) {
319+ s.PromptWith(text, nil, mentions...)
320+}
321+
322+// PromptWith is Prompt for a text in which pasted passages stand as tokens.
323+//
324+// Each paste's Token is replaced by its Text in what the agent receives —
325+// byte for byte, and only inside the text blocks, so a mention is never read
326+// out of a paste. The conversation keeps the text with the tokens in it: that
327+// is what makes the exchange readable afterwards, which is half the point of
328+// keeping a paste aside at all.
329+//
330+// session.PromptWith("here is the trace "+paste.Token+" what is failing?", []acp.Paste{paste})
331+func (s *Session) PromptWith(text string, pastes []Paste, mentions ...Mention) {
318332 if text == "" {
319333 return
320334 }
@@ -323,7 +337,7 @@ func (s *Session) Prompt(text string, mentions ...Mention) {
323337 s.transcript.AddUser("You", text)
324338 ready, failed := s.ready, s.failed
325339 if !ready && failed == nil {
326- s.queued = append(s.queued, pending{text: text, mentions: mentions})
340+ s.queued = append(s.queued, pending{text: text, pastes: pastes, mentions: mentions})
327341 }
328342 s.mu.Unlock()
329343
@@ -332,12 +346,12 @@ func (s *Session) Prompt(text string, mentions ...Mention) {
332346 case failed != nil:
333347 s.note(fmt.Sprintf("The agent is not running: %v", failed))
334348 case ready:
335- go s.send(text, mentions)
349+ go s.send(text, pastes, mentions)
336350 }
337351 }
338352
339353 // send runs one turn and records how it ended.
340-func (s *Session) send(text string, mentions []Mention) {
354+func (s *Session) send(text string, pastes []Paste, mentions []Mention) {
341355 s.mu.Lock()
342356 id, ready, embeds := s.sessionID, s.ready, s.embeds
343357 s.turn = true
@@ -348,7 +362,7 @@ func (s *Session) send(text string, mentions []Mention) {
348362 }
349363 s.wake()
350364
351- params := PromptParams{SessionID: id, Prompt: blocksFor(text, mentions, embeds, s.readFile)}
365+ params := PromptParams{SessionID: id, Prompt: expandPastes(blocksFor(text, mentions, embeds, s.readFile), pastes)}
352366
353367 // No timeout: a turn takes as long as the model takes, and cutting one off
354368 // after an arbitrary number of seconds would look exactly like a refusal.
@@ -156,6 +156,7 @@ type Session struct {
156 // pending is a prompt typed before the handshake finished, held until it has.156 // pending is a prompt typed before the handshake finished, held until it has.
157 type pending struct {157 type pending struct {
158 text string158 text string
159+ pastes []Paste
159 mentions []Mention160 mentions []Mention
160 }161 }
161 162
@@ -238,7 +239,7 @@ func (s *Session) handshake(cwd string) {
238 239
239 s.wake()240 s.wake()
240 for _, prompt := range queued {241 for _, prompt := range queued {
241- s.send(prompt.text, prompt.mentions)242+ s.send(prompt.text, prompt.pastes, prompt.mentions)
242 }243 }
243 }244 }
244 245
@@ -315,6 +316,19 @@ func (s *Session) newSession(ctx context.Context, cwd string) error {
315 // session.Prompt("what does buildMenus do?")316 // session.Prompt("what does buildMenus do?")
316 // session.Prompt("explain @app/menus.go", acp.Mention{Name: "app/menus.go", Path: "/src/p/app/menus.go"})317 // session.Prompt("explain @app/menus.go", acp.Mention{Name: "app/menus.go", Path: "/src/p/app/menus.go"})
317 func (s *Session) Prompt(text string, mentions ...Mention) {318 func (s *Session) Prompt(text string, mentions ...Mention) {
319+ s.PromptWith(text, nil, mentions...)
320+}
321+
322+// PromptWith is Prompt for a text in which pasted passages stand as tokens.
323+//
324+// Each paste's Token is replaced by its Text in what the agent receives —
325+// byte for byte, and only inside the text blocks, so a mention is never read
326+// out of a paste. The conversation keeps the text with the tokens in it: that
327+// is what makes the exchange readable afterwards, which is half the point of
328+// keeping a paste aside at all.
329+//
330+// session.PromptWith("here is the trace "+paste.Token+" what is failing?", []acp.Paste{paste})
331+func (s *Session) PromptWith(text string, pastes []Paste, mentions ...Mention) {
318 if text == "" {332 if text == "" {
319 return333 return
320 }334 }
@@ -323,7 +337,7 @@ func (s *Session) Prompt(text string, mentions ...Mention) {
323 s.transcript.AddUser("You", text)337 s.transcript.AddUser("You", text)
324 ready, failed := s.ready, s.failed338 ready, failed := s.ready, s.failed
325 if !ready && failed == nil {339 if !ready && failed == nil {
326- s.queued = append(s.queued, pending{text: text, mentions: mentions})340+ s.queued = append(s.queued, pending{text: text, pastes: pastes, mentions: mentions})
327 }341 }
328 s.mu.Unlock()342 s.mu.Unlock()
329 343
@@ -332,12 +346,12 @@ func (s *Session) Prompt(text string, mentions ...Mention) {
332 case failed != nil:346 case failed != nil:
333 s.note(fmt.Sprintf("The agent is not running: %v", failed))347 s.note(fmt.Sprintf("The agent is not running: %v", failed))
334 case ready:348 case ready:
335- go s.send(text, mentions)349+ go s.send(text, pastes, mentions)
336 }350 }
337 }351 }
338 352
339 // send runs one turn and records how it ended.353 // send runs one turn and records how it ended.
340-func (s *Session) send(text string, mentions []Mention) {354+func (s *Session) send(text string, pastes []Paste, mentions []Mention) {
341 s.mu.Lock()355 s.mu.Lock()
342 id, ready, embeds := s.sessionID, s.ready, s.embeds356 id, ready, embeds := s.sessionID, s.ready, s.embeds
343 s.turn = true357 s.turn = true
@@ -348,7 +362,7 @@ func (s *Session) send(text string, mentions []Mention) {
348 }362 }
349 s.wake()363 s.wake()
350 364
351- params := PromptParams{SessionID: id, Prompt: blocksFor(text, mentions, embeds, s.readFile)}365+ params := PromptParams{SessionID: id, Prompt: expandPastes(blocksFor(text, mentions, embeds, s.readFile), pastes)}
352 366
353 // No timeout: a turn takes as long as the model takes, and cutting one off367 // No timeout: a turn takes as long as the model takes, and cutting one off
354 // after an arbitrary number of seconds would look exactly like a refusal.368 // after an arbitrary number of seconds would look exactly like a refusal.
modified acp/view.go +19 -4
@@ -10,7 +10,7 @@ import (
1010
1111 // InputHeight is how many rows the box you type into takes, not counting the
1212 // rule above it. Three is enough for a sentence that wrapped once, and leaves
13-// the conversation the rest.
13+// the conversation the rest; a longer prompt scrolls inside the box.
1414 const InputHeight = 3
1515
1616 // View is the inside of an agent window: the conversation above, a box to type
@@ -27,11 +27,18 @@ type View struct {
2727
2828 session *Session
2929
30- // input is what has been typed but not sent, as lines.
30+ // input is what has been typed but not sent, as lines — the ones Alt-Enter
31+ // makes. How they wrap to the pane is worked out when drawing (input.go).
3132 input []string
3233 cursor int // which line the cursor is on
3334 column int // and where in it, in runes
3435
36+ // pastes is the text kept aside for each token in the box (paste.go), and
37+ // pasteCount numbers them for the life of the window, so that "#1" in an
38+ // earlier exchange is not the same thing as "#1" in this one.
39+ pastes []Paste
40+ pasteCount int
41+
3542 // scroll is how many laid-out lines are hidden above the top of the
3643 // conversation, and follow says to keep the last line in sight as the
3744 // agent writes.
@@ -64,6 +71,12 @@ type View struct {
6471 // window rather than to the window.
6572 OnCopy func(text string)
6673
74+ // OnPaste returns what the editor's clipboard holds, for Ctrl-V and
75+ // Shift-Ins. Same shape as OnCopy, for the same reasons; nil means those
76+ // keys paste nothing. A paste from outside the editor does not come this
77+ // way: the terminal brackets it and the editor hands it to Paste whole.
78+ OnPaste func() string
79+
6780 // Files returns the project's files, for the picker "@" opens and for
6881 // telling a mention from an email address when the prompt is sent. It is
6982 // the editor's to supply because the editor knows where the project is;
@@ -137,15 +150,17 @@ func (v *View) SetInput(text string) {
137150 //
138151 // It does nothing with an empty box: Enter on a blank line is somebody
139152 // thinking, not a turn worth spending. A file named with "@" goes along as a
140-// mention, so the agent is given the file and not merely its name.
153+// mention, so the agent is given the file and not merely its name; a paste
154+// standing in the box as a token goes along as its text.
141155 func (v *View) Send() {
142156 text := strings.TrimSpace(v.Input())
143157 if text == "" {
144158 return
145159 }
146160
147- v.session.Prompt(text, v.mentions(text)...)
161+ v.session.PromptWith(text, v.pastesIn(text), v.mentions(text)...)
148162 v.SetInput("")
163+ v.pastes = nil
149164 v.follow = true
150165 }
151166
@@ -10,7 +10,7 @@ import (
10 10
11 // InputHeight is how many rows the box you type into takes, not counting the11 // InputHeight is how many rows the box you type into takes, not counting the
12 // rule above it. Three is enough for a sentence that wrapped once, and leaves12 // rule above it. Three is enough for a sentence that wrapped once, and leaves
13-// the conversation the rest.13+// the conversation the rest; a longer prompt scrolls inside the box.
14 const InputHeight = 314 const InputHeight = 3
15 15
16 // View is the inside of an agent window: the conversation above, a box to type16 // View is the inside of an agent window: the conversation above, a box to type
@@ -27,11 +27,18 @@ type View struct {
27 27
28 session *Session28 session *Session
29 29
30- // input is what has been typed but not sent, as lines.30+ // input is what has been typed but not sent, as lines — the ones Alt-Enter
31+ // makes. How they wrap to the pane is worked out when drawing (input.go).
31 input []string32 input []string
32 cursor int // which line the cursor is on33 cursor int // which line the cursor is on
33 column int // and where in it, in runes34 column int // and where in it, in runes
34 35
36+ // pastes is the text kept aside for each token in the box (paste.go), and
37+ // pasteCount numbers them for the life of the window, so that "#1" in an
38+ // earlier exchange is not the same thing as "#1" in this one.
39+ pastes []Paste
40+ pasteCount int
41+
35 // scroll is how many laid-out lines are hidden above the top of the42 // scroll is how many laid-out lines are hidden above the top of the
36 // conversation, and follow says to keep the last line in sight as the43 // conversation, and follow says to keep the last line in sight as the
37 // agent writes.44 // agent writes.
@@ -64,6 +71,12 @@ type View struct {
64 // window rather than to the window.71 // window rather than to the window.
65 OnCopy func(text string)72 OnCopy func(text string)
66 73
74+ // OnPaste returns what the editor's clipboard holds, for Ctrl-V and
75+ // Shift-Ins. Same shape as OnCopy, for the same reasons; nil means those
76+ // keys paste nothing. A paste from outside the editor does not come this
77+ // way: the terminal brackets it and the editor hands it to Paste whole.
78+ OnPaste func() string
79+
67 // Files returns the project's files, for the picker "@" opens and for80 // Files returns the project's files, for the picker "@" opens and for
68 // telling a mention from an email address when the prompt is sent. It is81 // telling a mention from an email address when the prompt is sent. It is
69 // the editor's to supply because the editor knows where the project is;82 // the editor's to supply because the editor knows where the project is;
@@ -137,15 +150,17 @@ func (v *View) SetInput(text string) {
137 //150 //
138 // It does nothing with an empty box: Enter on a blank line is somebody151 // It does nothing with an empty box: Enter on a blank line is somebody
139 // thinking, not a turn worth spending. A file named with "@" goes along as a152 // thinking, not a turn worth spending. A file named with "@" goes along as a
140-// mention, so the agent is given the file and not merely its name.153+// mention, so the agent is given the file and not merely its name; a paste
154+// standing in the box as a token goes along as its text.
141 func (v *View) Send() {155 func (v *View) Send() {
142 text := strings.TrimSpace(v.Input())156 text := strings.TrimSpace(v.Input())
143 if text == "" {157 if text == "" {
144 return158 return
145 }159 }
146 160
147- v.session.Prompt(text, v.mentions(text)...)161+ v.session.PromptWith(text, v.pastesIn(text), v.mentions(text)...)
148 v.SetInput("")162 v.SetInput("")
163+ v.pastes = nil
149 v.follow = true164 v.follow = true
150 }165 }
151 166
modified acp/view_draw.go +41 -9
@@ -200,7 +200,13 @@ func (v *View) pickerLabel() string {
200200 return label
201201 }
202202
203-// drawInput paints what has been typed, and the cursor when it has the focus.
203+// drawInput paints what has been typed, wrapped to the pane, and the cursor
204+// when the box has the focus.
205+//
206+// The rows shown are the ones that end with the cursor's: a prompt longer
207+// than the box scrolls up under the rule as you type, and the prompt marker
208+// goes with its first row rather than standing beside whatever row happens
209+// to be at the top.
204210 func (v *View) drawInput(p *ui.Painter, th *theme.Theme, body tcell.Style) {
205211 size := p.Size()
206212 top := v.transcriptHeight() + 1
@@ -209,19 +215,45 @@ func (v *View) drawInput(p *ui.Painter, th *theme.Theme, body tcell.Style) {
209215 return
210216 }
211217
212- prompt := th.Style(theme.KeySyntaxKeyword).Background(backgroundOf(body))
213- p.Text(0, top, ">", prompt)
218+ width := v.inputWidth()
219+ rows := v.inputRows(width)
220+ row, column := v.inputCursor(rows)
221+ from := max(row-height+1, 0)
214222
215- from := max(v.cursor-height+1, 0)
216- for row := 0; row < height; row++ {
217- at := from + row
218- if at >= len(v.input) {
223+ if from == 0 {
224+ prompt := th.Style(theme.KeySyntaxKeyword).Background(backgroundOf(body))
225+ p.Text(0, top, ">", prompt)
226+ }
227+ for y := 0; y < height; y++ {
228+ at := from + y
229+ if at >= len(rows) {
219230 break
220231 }
221- p.TextLimited(2, top+row, max(size.W-2, 0), v.input[at], body)
232+ v.drawInputRow(p, th, top+y, rows[at], body)
222233 }
223234
224235 if v.focused && v.onInput {
225- p.ShowCursor(2+v.column, top+v.cursor-from)
236+ p.ShowCursor(2+column, top+row-from)
237+ }
238+}
239+
240+// drawInputRow paints one row of the box, rune by rune, a paste token in the
241+// colour tool calls take — it is a thing the agent will be handed, not words
242+// — and everything else as body text.
243+//
244+// A row may hold one rune more than the width, the space it broke at; the
245+// loop draws through it and the frame clips it, which keeps the rule "a row
246+// is its runes" in one place.
247+func (v *View) drawInputRow(p *ui.Painter, th *theme.Theme, y int, row inputRow, body tcell.Style) {
248+ runes := []rune(v.input[row.line])
249+ spans := v.tokensIn(v.input[row.line])
250+ token := th.Style(theme.KeySyntaxType).Background(backgroundOf(body))
251+
252+ for at := row.start; at < row.end; at++ {
253+ style := body
254+ if inToken(spans, at) {
255+ style = token
256+ }
257+ p.SetCell(2+at-row.start, y, runes[at], style)
226258 }
227259 }
@@ -200,7 +200,13 @@ func (v *View) pickerLabel() string {
200 return label200 return label
201 }201 }
202 202
203-// drawInput paints what has been typed, and the cursor when it has the focus.203+// drawInput paints what has been typed, wrapped to the pane, and the cursor
204+// when the box has the focus.
205+//
206+// The rows shown are the ones that end with the cursor's: a prompt longer
207+// than the box scrolls up under the rule as you type, and the prompt marker
208+// goes with its first row rather than standing beside whatever row happens
209+// to be at the top.
204 func (v *View) drawInput(p *ui.Painter, th *theme.Theme, body tcell.Style) {210 func (v *View) drawInput(p *ui.Painter, th *theme.Theme, body tcell.Style) {
205 size := p.Size()211 size := p.Size()
206 top := v.transcriptHeight() + 1212 top := v.transcriptHeight() + 1
@@ -209,19 +215,45 @@ func (v *View) drawInput(p *ui.Painter, th *theme.Theme, body tcell.Style) {
209 return215 return
210 }216 }
211 217
212- prompt := th.Style(theme.KeySyntaxKeyword).Background(backgroundOf(body))218+ width := v.inputWidth()
213- p.Text(0, top, ">", prompt)219+ rows := v.inputRows(width)
220+ row, column := v.inputCursor(rows)
221+ from := max(row-height+1, 0)
214 222
215- from := max(v.cursor-height+1, 0)223+ if from == 0 {
216- for row := 0; row < height; row++ {224+ prompt := th.Style(theme.KeySyntaxKeyword).Background(backgroundOf(body))
217- at := from + row225+ p.Text(0, top, ">", prompt)
218- if at >= len(v.input) {226+ }
227+ for y := 0; y < height; y++ {
228+ at := from + y
229+ if at >= len(rows) {
219 break230 break
220 }231 }
221- p.TextLimited(2, top+row, max(size.W-2, 0), v.input[at], body)232+ v.drawInputRow(p, th, top+y, rows[at], body)
222 }233 }
223 234
224 if v.focused && v.onInput {235 if v.focused && v.onInput {
225- p.ShowCursor(2+v.column, top+v.cursor-from)236+ p.ShowCursor(2+column, top+row-from)
237+ }
238+}
239+
240+// drawInputRow paints one row of the box, rune by rune, a paste token in the
241+// colour tool calls take — it is a thing the agent will be handed, not words
242+// — and everything else as body text.
243+//
244+// A row may hold one rune more than the width, the space it broke at; the
245+// loop draws through it and the frame clips it, which keeps the rule "a row
246+// is its runes" in one place.
247+func (v *View) drawInputRow(p *ui.Painter, th *theme.Theme, y int, row inputRow, body tcell.Style) {
248+ runes := []rune(v.input[row.line])
249+ spans := v.tokensIn(v.input[row.line])
250+ token := th.Style(theme.KeySyntaxType).Background(backgroundOf(body))
251+
252+ for at := row.start; at < row.end; at++ {
253+ style := body
254+ if inToken(spans, at) {
255+ style = token
256+ }
257+ p.SetCell(2+at-row.start, y, runes[at], style)
226 }258 }
227 }259 }
modified acp/view_events.go +28 -16
@@ -40,6 +40,8 @@ func (v *View) handleWindowKey(ev *tcell.EventKey) (handled, claimed bool) {
4040 v.onInput = !v.onInput
4141 case ev.Key() == tcell.KeyCtrlC, isCopyKey(ev):
4242 return v.copySelection(), true
43+ case ev.Key() == tcell.KeyCtrlV, isPasteKey(ev):
44+ return v.pasteClipboard(), true
4345 case ev.Key() == tcell.KeyEscape:
4446 return v.clearOrCancel(), true
4547 case ev.Key() == tcell.KeyEnter && ev.Modifiers()&tcell.ModAlt != 0:
@@ -102,17 +104,9 @@ func (v *View) handleInputKey(ev *tcell.EventKey) bool {
102104 case tcell.KeyEnd:
103105 v.column = len(v.runes())
104106 case tcell.KeyUp:
105- if v.cursor == 0 {
106- return false // let it move through the conversation instead
107- }
108- v.cursor--
109- v.clampColumn()
107+ return v.moveInputRow(-1) // off the top, it moves through the conversation instead
110108 case tcell.KeyDown:
111- if v.cursor >= len(v.input)-1 {
112- return false
113- }
114- v.cursor++
115- v.clampColumn()
109+ return v.moveInputRow(1)
116110 default:
117111 return false
118112 }
@@ -221,7 +215,13 @@ func (v *View) insertNewline() {
221215 }
222216
223217 // backspace removes the rune before the cursor, joining lines at a line start.
218+// Before a paste token it removes the whole token: the token is one thing,
219+// and a token missing its last bracket stands for nothing.
224220 func (v *View) backspace() {
221+ if span, ok := v.tokenEndingAt(v.column); ok {
222+ v.removeSpan(span)
223+ return
224+ }
225225 if v.column > 0 {
226226 runes := v.runes()
227227 v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column-1]...), runes[v.column:]...))
@@ -239,8 +239,13 @@ func (v *View) backspace() {
239239 v.cursor--
240240 }
241241
242-// delete removes the rune under the cursor.
242+// delete removes the rune under the cursor — or the whole paste token that
243+// starts there, for the reason backspace gives.
243244 func (v *View) delete() {
245+ if span, ok := v.tokenStartingAt(v.column); ok {
246+ v.removeSpan(span)
247+ return
248+ }
244249 runes := v.runes()
245250 if v.column >= len(runes) {
246251 return
@@ -248,8 +253,13 @@ func (v *View) delete() {
248253 v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column]...), runes[v.column+1:]...))
249254 }
250255
251-// moveLeft moves back one rune, onto the end of the line above at a line start.
256+// moveLeft moves back one rune — over a whole paste token — onto the end of
257+// the line above at a line start.
252258 func (v *View) moveLeft() {
259+ if span, ok := v.tokenEndingAt(v.column); ok {
260+ v.column = span.start
261+ return
262+ }
253263 switch {
254264 case v.column > 0:
255265 v.column--
@@ -259,8 +269,13 @@ func (v *View) moveLeft() {
259269 }
260270 }
261271
262-// moveRight moves on one rune, onto the start of the next line at a line end.
272+// moveRight moves on one rune — over a whole paste token — onto the start of
273+// the next line at a line end.
263274 func (v *View) moveRight() {
275+ if span, ok := v.tokenStartingAt(v.column); ok {
276+ v.column = span.end
277+ return
278+ }
264279 switch {
265280 case v.column < len(v.runes()):
266281 v.column++
@@ -278,9 +293,6 @@ func (v *View) runes() []rune {
278293 return []rune(v.input[v.cursor])
279294 }
280295
281-// clampColumn keeps the cursor inside the line it moved onto.
282-func (v *View) clampColumn() { v.column = min(v.column, len(v.runes())) }
283-
284296 // scrollBy moves through the conversation, and stops following the end as soon
285297 // as somebody scrolls up — reading what happened is not interrupted by the
286298 // agent still writing.
@@ -40,6 +40,8 @@ func (v *View) handleWindowKey(ev *tcell.EventKey) (handled, claimed bool) {
40 v.onInput = !v.onInput40 v.onInput = !v.onInput
41 case ev.Key() == tcell.KeyCtrlC, isCopyKey(ev):41 case ev.Key() == tcell.KeyCtrlC, isCopyKey(ev):
42 return v.copySelection(), true42 return v.copySelection(), true
43+ case ev.Key() == tcell.KeyCtrlV, isPasteKey(ev):
44+ return v.pasteClipboard(), true
43 case ev.Key() == tcell.KeyEscape:45 case ev.Key() == tcell.KeyEscape:
44 return v.clearOrCancel(), true46 return v.clearOrCancel(), true
45 case ev.Key() == tcell.KeyEnter && ev.Modifiers()&tcell.ModAlt != 0:47 case ev.Key() == tcell.KeyEnter && ev.Modifiers()&tcell.ModAlt != 0:
@@ -102,17 +104,9 @@ func (v *View) handleInputKey(ev *tcell.EventKey) bool {
102 case tcell.KeyEnd:104 case tcell.KeyEnd:
103 v.column = len(v.runes())105 v.column = len(v.runes())
104 case tcell.KeyUp:106 case tcell.KeyUp:
105- if v.cursor == 0 {107+ return v.moveInputRow(-1) // off the top, it moves through the conversation instead
106- return false // let it move through the conversation instead
107- }
108- v.cursor--
109- v.clampColumn()
110 case tcell.KeyDown:108 case tcell.KeyDown:
111- if v.cursor >= len(v.input)-1 {109+ return v.moveInputRow(1)
112- return false
113- }
114- v.cursor++
115- v.clampColumn()
116 default:110 default:
117 return false111 return false
118 }112 }
@@ -221,7 +215,13 @@ func (v *View) insertNewline() {
221 }215 }
222 216
223 // backspace removes the rune before the cursor, joining lines at a line start.217 // backspace removes the rune before the cursor, joining lines at a line start.
218+// Before a paste token it removes the whole token: the token is one thing,
219+// and a token missing its last bracket stands for nothing.
224 func (v *View) backspace() {220 func (v *View) backspace() {
221+ if span, ok := v.tokenEndingAt(v.column); ok {
222+ v.removeSpan(span)
223+ return
224+ }
225 if v.column > 0 {225 if v.column > 0 {
226 runes := v.runes()226 runes := v.runes()
227 v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column-1]...), runes[v.column:]...))227 v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column-1]...), runes[v.column:]...))
@@ -239,8 +239,13 @@ func (v *View) backspace() {
239 v.cursor--239 v.cursor--
240 }240 }
241 241
242-// delete removes the rune under the cursor.242+// delete removes the rune under the cursor — or the whole paste token that
243+// starts there, for the reason backspace gives.
243 func (v *View) delete() {244 func (v *View) delete() {
245+ if span, ok := v.tokenStartingAt(v.column); ok {
246+ v.removeSpan(span)
247+ return
248+ }
244 runes := v.runes()249 runes := v.runes()
245 if v.column >= len(runes) {250 if v.column >= len(runes) {
246 return251 return
@@ -248,8 +253,13 @@ func (v *View) delete() {
248 v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column]...), runes[v.column+1:]...))253 v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column]...), runes[v.column+1:]...))
249 }254 }
250 255
251-// moveLeft moves back one rune, onto the end of the line above at a line start.256+// moveLeft moves back one rune — over a whole paste token — onto the end of
257+// the line above at a line start.
252 func (v *View) moveLeft() {258 func (v *View) moveLeft() {
259+ if span, ok := v.tokenEndingAt(v.column); ok {
260+ v.column = span.start
261+ return
262+ }
253 switch {263 switch {
254 case v.column > 0:264 case v.column > 0:
255 v.column--265 v.column--
@@ -259,8 +269,13 @@ func (v *View) moveLeft() {
259 }269 }
260 }270 }
261 271
262-// moveRight moves on one rune, onto the start of the next line at a line end.272+// moveRight moves on one rune — over a whole paste token — onto the start of
273+// the next line at a line end.
263 func (v *View) moveRight() {274 func (v *View) moveRight() {
275+ if span, ok := v.tokenStartingAt(v.column); ok {
276+ v.column = span.end
277+ return
278+ }
264 switch {279 switch {
265 case v.column < len(v.runes()):280 case v.column < len(v.runes()):
266 v.column++281 v.column++
@@ -278,9 +293,6 @@ func (v *View) runes() []rune {
278 return []rune(v.input[v.cursor])293 return []rune(v.input[v.cursor])
279 }294 }
280 295
281-// clampColumn keeps the cursor inside the line it moved onto.
282-func (v *View) clampColumn() { v.column = min(v.column, len(v.runes())) }
283-
284 // scrollBy moves through the conversation, and stops following the end as soon296 // scrollBy moves through the conversation, and stops following the end as soon
285 // as somebody scrolls up — reading what happened is not interrupted by the297 // as somebody scrolls up — reading what happened is not interrupted by the
286 // agent still writing.298 // agent still writing.
modified app/README.md +9 -1
@@ -82,7 +82,15 @@ The popup is refreshed from `tick`, not from the reading goroutine, which may no
8282
8383 The exit is noticed on a goroutine that may not touch a buffer, so it sets `a.toolsRan` and the loop does the work.
8484
85-`tick` is that loop turn, extracted so a test can take one. Everything in it is **state-driven** — `announceOpenDocuments`, `refreshRunningTool`, `reloadAfterTools`, `saveDueDocuments`, `refreshTerminalTitles` — for the same reason each time: the only way to wake this loop from another goroutine is `PostEvent`, which drops what does not fit, so **the wake-up may be lost and the state must not be.** Tests call `tick`, not the individual step, or removing a step from the loop would leave them passing.
85+**Anything else rewriting a file is noticed the same way, by stamp.** `reloadChangedFiles` (`watch.go`) keeps what each open file looked like on disk — size and modification time, `fileStamp`, the same guard `refreshToolMenus` uses — by canonical path in `a.fileStamps`, and on every turn of the loop re-reads the ones whose stamp moved, through the `reloadFromDisk` the tools reload shares (so telling the language server, added for this, could not go missing from the other). The editor's own writes re-stamp in `openBuffer`, `afterSave` and the reload itself, so a save does not come round as a reload of the text just written. A modified buffer is kept and the status bar says so once. A file that has gone is left alone, silently: some programs replace a file by deleting it and writing it again, and a stat between the two sees nothing.
86+
87+The loop sits in `PollEvent` while the user is idle, which is exactly when another program is most likely to be writing — so a `fileWatch` goroutine, started by `Run` and only by `Run`, stats the same stamps once a second and posts a wake when any differs. It is a nudge and nothing more: the loop decides for itself, so a dropped wake costs a late reload, never a lost one, and an idle editor with unchanged files is never woken. Tests set `a.fileWatchInterval` short and watch the simulation screen for the interrupt event.
88+
89+`tick` is that loop turn, extracted so a test can take one. Everything in it is **state-driven** — `announceOpenDocuments`, `refreshRunningTool`, `reloadAfterTools`, `reloadChangedFiles`, `saveDueDocuments`, `refreshTerminalTitles` — for the same reason each time: the only way to wake this loop from another goroutine is `PostEvent`, which drops what does not fit, so **the wake-up may be lost and the state must not be.** Tests call `tick`, not the individual step, or removing a step from the loop would leave them passing.
90+
91+## A paste from outside
92+
93+`New` turns bracketed paste on (`screen.EnablePaste()`), so a paste from the terminal arrives between two `tcell.EventPaste` marks with its characters as ordinary keys in between. `handleKey` only collects those keys while `a.pasting` is set; the end mark (`handlePaste`, in `paste.go`) then decides where the whole goes. An agent window in front, with no dialog up, gets it as one `acp.View.Paste`, which is what lets forty lines become a token instead of forty Enters each sending a prompt. Every other window gets the keys replayed through `handleKey` exactly as it would have had them, so a paste into a file is typed as it always was — nothing else changed. `pastedText` is the inverse of what tcell did: a rune per character, a newline per Enter, a tab per Tab. `Paste` (Edit ▸ Paste, Shift-Ins) routes to the agent's box when an agent window is in front, and the agent view reads the editor's clipboard through `OnPaste` for its own Ctrl-V.
8694
8795 ## Menus the tools file asks for
8896
@@ -82,7 +82,15 @@ The popup is refreshed from `tick`, not from the reading goroutine, which may no
82 82
83 The exit is noticed on a goroutine that may not touch a buffer, so it sets `a.toolsRan` and the loop does the work.83 The exit is noticed on a goroutine that may not touch a buffer, so it sets `a.toolsRan` and the loop does the work.
84 84
85-`tick` is that loop turn, extracted so a test can take one. Everything in it is **state-driven** — `announceOpenDocuments`, `refreshRunningTool`, `reloadAfterTools`, `saveDueDocuments`, `refreshTerminalTitles` — for the same reason each time: the only way to wake this loop from another goroutine is `PostEvent`, which drops what does not fit, so **the wake-up may be lost and the state must not be.** Tests call `tick`, not the individual step, or removing a step from the loop would leave them passing.85+**Anything else rewriting a file is noticed the same way, by stamp.** `reloadChangedFiles` (`watch.go`) keeps what each open file looked like on disk — size and modification time, `fileStamp`, the same guard `refreshToolMenus` uses — by canonical path in `a.fileStamps`, and on every turn of the loop re-reads the ones whose stamp moved, through the `reloadFromDisk` the tools reload shares (so telling the language server, added for this, could not go missing from the other). The editor's own writes re-stamp in `openBuffer`, `afterSave` and the reload itself, so a save does not come round as a reload of the text just written. A modified buffer is kept and the status bar says so once. A file that has gone is left alone, silently: some programs replace a file by deleting it and writing it again, and a stat between the two sees nothing.
86+
87+The loop sits in `PollEvent` while the user is idle, which is exactly when another program is most likely to be writing — so a `fileWatch` goroutine, started by `Run` and only by `Run`, stats the same stamps once a second and posts a wake when any differs. It is a nudge and nothing more: the loop decides for itself, so a dropped wake costs a late reload, never a lost one, and an idle editor with unchanged files is never woken. Tests set `a.fileWatchInterval` short and watch the simulation screen for the interrupt event.
88+
89+`tick` is that loop turn, extracted so a test can take one. Everything in it is **state-driven** — `announceOpenDocuments`, `refreshRunningTool`, `reloadAfterTools`, `reloadChangedFiles`, `saveDueDocuments`, `refreshTerminalTitles` — for the same reason each time: the only way to wake this loop from another goroutine is `PostEvent`, which drops what does not fit, so **the wake-up may be lost and the state must not be.** Tests call `tick`, not the individual step, or removing a step from the loop would leave them passing.
90+
91+## A paste from outside
92+
93+`New` turns bracketed paste on (`screen.EnablePaste()`), so a paste from the terminal arrives between two `tcell.EventPaste` marks with its characters as ordinary keys in between. `handleKey` only collects those keys while `a.pasting` is set; the end mark (`handlePaste`, in `paste.go`) then decides where the whole goes. An agent window in front, with no dialog up, gets it as one `acp.View.Paste`, which is what lets forty lines become a token instead of forty Enters each sending a prompt. Every other window gets the keys replayed through `handleKey` exactly as it would have had them, so a paste into a file is typed as it always was — nothing else changed. `pastedText` is the inverse of what tcell did: a rune per character, a newline per Enter, a tab per Tab. `Paste` (Edit ▸ Paste, Shift-Ins) routes to the agent's box when an agent window is in front, and the agent view reads the editor's clipboard through `OnPaste` for its own Ctrl-V.
86 94
87 ## Menus the tools file asks for95 ## Menus the tools file asks for
88 96
modified app/actions_edit.go +10 -1
@@ -19,9 +19,18 @@ func (a *App) Undo() { a.withView(func(v *editor.View) { v.Undo() }) }
1919 func (a *App) Redo() { a.withView(func(v *editor.View) { v.Redo() }) }
2020 func (a *App) Cut() { a.withView(func(v *editor.View) { v.Cut() }) }
2121 func (a *App) Copy() { a.withView(func(v *editor.View) { v.Copy() }) }
22-func (a *App) Paste() { a.withView(func(v *editor.View) { v.Paste() }) }
2322 func (a *App) SelectAll() { a.withView(func(v *editor.View) { v.SelectAll() }) }
2423
24+// Paste puts the clipboard into the front window: the file being edited, or
25+// the box of an agent window, which is the one other place text is typed.
26+func (a *App) Paste() {
27+ if agent := a.activeAgent(); agent != nil {
28+ agent.view.Paste(a.clipboard.Text())
29+ return
30+ }
31+ a.withView(func(v *editor.View) { v.Paste() })
32+}
33+
2534 // InsertLine and DeleteLine are Turbo C's Ctrl-N and Ctrl-Y — a blank line
2635 // opened above the cursor, and the cursor's own line removed.
2736 func (a *App) InsertLine() { a.withView(func(v *editor.View) { v.InsertLine() }) }
@@ -19,9 +19,18 @@ func (a *App) Undo() { a.withView(func(v *editor.View) { v.Undo() }) }
19 func (a *App) Redo() { a.withView(func(v *editor.View) { v.Redo() }) }19 func (a *App) Redo() { a.withView(func(v *editor.View) { v.Redo() }) }
20 func (a *App) Cut() { a.withView(func(v *editor.View) { v.Cut() }) }20 func (a *App) Cut() { a.withView(func(v *editor.View) { v.Cut() }) }
21 func (a *App) Copy() { a.withView(func(v *editor.View) { v.Copy() }) }21 func (a *App) Copy() { a.withView(func(v *editor.View) { v.Copy() }) }
22-func (a *App) Paste() { a.withView(func(v *editor.View) { v.Paste() }) }
23 func (a *App) SelectAll() { a.withView(func(v *editor.View) { v.SelectAll() }) }22 func (a *App) SelectAll() { a.withView(func(v *editor.View) { v.SelectAll() }) }
24 23
24+// Paste puts the clipboard into the front window: the file being edited, or
25+// the box of an agent window, which is the one other place text is typed.
26+func (a *App) Paste() {
27+ if agent := a.activeAgent(); agent != nil {
28+ agent.view.Paste(a.clipboard.Text())
29+ return
30+ }
31+ a.withView(func(v *editor.View) { v.Paste() })
32+}
33+
25 // InsertLine and DeleteLine are Turbo C's Ctrl-N and Ctrl-Y — a blank line34 // InsertLine and DeleteLine are Turbo C's Ctrl-N and Ctrl-Y — a blank line
26 // opened above the cursor, and the cursor's own line removed.35 // opened above the cursor, and the cursor's own line removed.
27 func (a *App) InsertLine() { a.withView(func(v *editor.View) { v.InsertLine() }) }36 func (a *App) InsertLine() { a.withView(func(v *editor.View) { v.InsertLine() }) }
modified app/actions_file.go +2 -0
@@ -86,6 +86,7 @@ func (a *App) openBuffer(buf *buffer.Buffer) {
8686
8787 a.desktop.Add(window)
8888 a.windowsOpened++
89+ a.stampFile(buf.Path())
8990 a.language.DidOpen(buf.Path(), buf.Text())
9091 }
9192
@@ -208,6 +209,7 @@ func renamed(previous, path string) bool {
208209 // project's settings is exactly such a step, and would have been missing from
209210 // autosave.
210211 func (a *App) afterSave(view *editor.View, path string, created bool) {
212+ a.stampFile(path)
211213 view.RefreshSyntax()
212214 if window := a.windowOf(view); window != nil {
213215 window.SetTitle(windowTitle(view.Buffer()))
@@ -86,6 +86,7 @@ func (a *App) openBuffer(buf *buffer.Buffer) {
86 86
87 a.desktop.Add(window)87 a.desktop.Add(window)
88 a.windowsOpened++88 a.windowsOpened++
89+ a.stampFile(buf.Path())
89 a.language.DidOpen(buf.Path(), buf.Text())90 a.language.DidOpen(buf.Path(), buf.Text())
90 }91 }
91 92
@@ -208,6 +209,7 @@ func renamed(previous, path string) bool {
208 // project's settings is exactly such a step, and would have been missing from209 // project's settings is exactly such a step, and would have been missing from
209 // autosave.210 // autosave.
210 func (a *App) afterSave(view *editor.View, path string, created bool) {211 func (a *App) afterSave(view *editor.View, path string, created bool) {
212+ a.stampFile(path)
211 view.RefreshSyntax()213 view.RefreshSyntax()
212 if window := a.windowOf(view); window != nil {214 if window := a.windowOf(view); window != nil {
213 window.SetTitle(windowTitle(view.Buffer()))215 window.SetTitle(windowTitle(view.Buffer()))
modified app/agents.go +1 -0
@@ -57,6 +57,7 @@ func (a *App) NewAgent(name string) {
5757
5858 view := acp.NewView(session)
5959 view.OnCopy = a.copyFromAgent
60+ view.OnPaste = a.clipboard.Text
6061 view.Files = a.projectFiles
6162 window := ui.NewWindow(view.Title(), view)
6263 window.SetBounds(a.newWindowBounds())
@@ -57,6 +57,7 @@ func (a *App) NewAgent(name string) {
57 57
58 view := acp.NewView(session)58 view := acp.NewView(session)
59 view.OnCopy = a.copyFromAgent59 view.OnCopy = a.copyFromAgent
60+ view.OnPaste = a.clipboard.Text
60 view.Files = a.projectFiles61 view.Files = a.projectFiles
61 window := ui.NewWindow(view.Title(), view)62 window := ui.NewWindow(view.Title(), view)
62 window.SetBounds(a.newWindowBounds())63 window.SetBounds(a.newWindowBounds())
modified app/app.go +30 -0
@@ -105,10 +105,23 @@ type App struct {
105105 settingsPath string
106106 // autosave is the pending automatic save, when the project asked for one.
107107 autosave autosave
108+ // fileStamps is what each open file looked like on disk when the loop
109+ // last saw it, by canonical path — how a change made by another program
110+ // is noticed. fileWatch shares it with the goroutine that wakes the loop
111+ // for one, at fileWatchInterval, while the editor is idle.
112+ fileStamps map[string]fileStamp
113+ fileWatch fileWatch
114+ fileWatchInterval time.Duration
108115 // now is the clock, so that autosave's deadlines can be tested without
109116 // waiting for them.
110117 now func() time.Time
111118
119+ // pasting says the terminal is in the middle of a bracketed paste, and
120+ // pasted is what it has sent so far: a paste is handed to an agent window
121+ // whole, once it has ended, rather than key by key.
122+ pasting bool
123+ pasted []*tcell.EventKey
124+
112125 lastSearch string
113126 lastMatchCase bool
114127 windowsOpened int
@@ -137,10 +150,17 @@ func New(screen tcell.Screen, themeName string, p profile.Profile) *App {
137150 terminals: map[*ui.Window]*terminal.View{},
138151 agents: map[*ui.Window]*agentWindow{},
139152 now: time.Now,
153+
154+ fileStamps: map[string]fileStamp{},
155+ fileWatchInterval: defaultFileWatchInterval,
140156 }
141157 a.autosave.delay = settings.DefaultAutosaveDelay
142158
143159 a.setTheme(themeName)
160+ // Bracketed paste, so that a paste from outside arrives between two
161+ // EventPaste marks and can be told from typing. Without it forty lines
162+ // pasted into an agent window are forty Enters, each one sending.
163+ screen.EnablePaste()
144164 // Stamped before the bar is built, so a tools file written between the two
145165 // is picked up by the next turn of the loop rather than missed.
146166 a.toolsStamp = a.toolsFileStamp()
@@ -255,6 +275,9 @@ func (a *App) Language() *Language { return a.language }
255275
256276 // Run draws and handles events until the user asks to leave.
257277 func (a *App) Run() error {
278+ stop := a.watchFiles()
279+ defer stop()
280+
258281 for !a.quitting {
259282 a.tick()
260283 a.layout()
@@ -286,6 +309,7 @@ func (a *App) tick() {
286309 a.refreshToolMenus()
287310 a.refreshRunningTool()
288311 a.reloadAfterTools()
312+ a.reloadChangedFiles()
289313 a.saveDueDocuments()
290314 a.refreshTerminalTitles()
291315 a.refreshAgentTitles()
@@ -393,6 +417,8 @@ func (a *App) handle(event tcell.Event) {
393417 switch typed := event.(type) {
394418 case *tcell.EventResize:
395419 a.resize()
420+ case *tcell.EventPaste:
421+ a.handlePaste(typed)
396422 case *tcell.EventKey:
397423 a.handleKey(typed)
398424 case *tcell.EventMouse:
@@ -420,6 +446,10 @@ func (a *App) resize() {
420446 // handleKey offers a key to each layer in turn, front to back, and gives what
421447 // nobody claimed to the window on the desktop.
422448 func (a *App) handleKey(ev *tcell.EventKey) {
449+ if a.pasting {
450+ a.pasted = append(a.pasted, ev)
451+ return
452+ }
423453 a.status.SetMessage("") // any keystroke clears a transient message
424454
425455 for _, claims := range a.keyLayers() {
@@ -105,10 +105,23 @@ type App struct {
105 settingsPath string105 settingsPath string
106 // autosave is the pending automatic save, when the project asked for one.106 // autosave is the pending automatic save, when the project asked for one.
107 autosave autosave107 autosave autosave
108+ // fileStamps is what each open file looked like on disk when the loop
109+ // last saw it, by canonical path — how a change made by another program
110+ // is noticed. fileWatch shares it with the goroutine that wakes the loop
111+ // for one, at fileWatchInterval, while the editor is idle.
112+ fileStamps map[string]fileStamp
113+ fileWatch fileWatch
114+ fileWatchInterval time.Duration
108 // now is the clock, so that autosave's deadlines can be tested without115 // now is the clock, so that autosave's deadlines can be tested without
109 // waiting for them.116 // waiting for them.
110 now func() time.Time117 now func() time.Time
111 118
119+ // pasting says the terminal is in the middle of a bracketed paste, and
120+ // pasted is what it has sent so far: a paste is handed to an agent window
121+ // whole, once it has ended, rather than key by key.
122+ pasting bool
123+ pasted []*tcell.EventKey
124+
112 lastSearch string125 lastSearch string
113 lastMatchCase bool126 lastMatchCase bool
114 windowsOpened int127 windowsOpened int
@@ -137,10 +150,17 @@ func New(screen tcell.Screen, themeName string, p profile.Profile) *App {
137 terminals: map[*ui.Window]*terminal.View{},150 terminals: map[*ui.Window]*terminal.View{},
138 agents: map[*ui.Window]*agentWindow{},151 agents: map[*ui.Window]*agentWindow{},
139 now: time.Now,152 now: time.Now,
153+
154+ fileStamps: map[string]fileStamp{},
155+ fileWatchInterval: defaultFileWatchInterval,
140 }156 }
141 a.autosave.delay = settings.DefaultAutosaveDelay157 a.autosave.delay = settings.DefaultAutosaveDelay
142 158
143 a.setTheme(themeName)159 a.setTheme(themeName)
160+ // Bracketed paste, so that a paste from outside arrives between two
161+ // EventPaste marks and can be told from typing. Without it forty lines
162+ // pasted into an agent window are forty Enters, each one sending.
163+ screen.EnablePaste()
144 // Stamped before the bar is built, so a tools file written between the two164 // Stamped before the bar is built, so a tools file written between the two
145 // is picked up by the next turn of the loop rather than missed.165 // is picked up by the next turn of the loop rather than missed.
146 a.toolsStamp = a.toolsFileStamp()166 a.toolsStamp = a.toolsFileStamp()
@@ -255,6 +275,9 @@ func (a *App) Language() *Language { return a.language }
255 275
256 // Run draws and handles events until the user asks to leave.276 // Run draws and handles events until the user asks to leave.
257 func (a *App) Run() error {277 func (a *App) Run() error {
278+ stop := a.watchFiles()
279+ defer stop()
280+
258 for !a.quitting {281 for !a.quitting {
259 a.tick()282 a.tick()
260 a.layout()283 a.layout()
@@ -286,6 +309,7 @@ func (a *App) tick() {
286 a.refreshToolMenus()309 a.refreshToolMenus()
287 a.refreshRunningTool()310 a.refreshRunningTool()
288 a.reloadAfterTools()311 a.reloadAfterTools()
312+ a.reloadChangedFiles()
289 a.saveDueDocuments()313 a.saveDueDocuments()
290 a.refreshTerminalTitles()314 a.refreshTerminalTitles()
291 a.refreshAgentTitles()315 a.refreshAgentTitles()
@@ -393,6 +417,8 @@ func (a *App) handle(event tcell.Event) {
393 switch typed := event.(type) {417 switch typed := event.(type) {
394 case *tcell.EventResize:418 case *tcell.EventResize:
395 a.resize()419 a.resize()
420+ case *tcell.EventPaste:
421+ a.handlePaste(typed)
396 case *tcell.EventKey:422 case *tcell.EventKey:
397 a.handleKey(typed)423 a.handleKey(typed)
398 case *tcell.EventMouse:424 case *tcell.EventMouse:
@@ -420,6 +446,10 @@ func (a *App) resize() {
420 // handleKey offers a key to each layer in turn, front to back, and gives what446 // handleKey offers a key to each layer in turn, front to back, and gives what
421 // nobody claimed to the window on the desktop.447 // nobody claimed to the window on the desktop.
422 func (a *App) handleKey(ev *tcell.EventKey) {448 func (a *App) handleKey(ev *tcell.EventKey) {
449+ if a.pasting {
450+ a.pasted = append(a.pasted, ev)
451+ return
452+ }
423 a.status.SetMessage("") // any keystroke clears a transient message453 a.status.SetMessage("") // any keystroke clears a transient message
424 454
425 for _, claims := range a.keyLayers() {455 for _, claims := range a.keyLayers() {
added app/paste.go +53 -0
new file mode 100644
@@ -0,0 +1,53 @@
1+// A paste from the terminal: collected between its two marks and handed to
2+// the window in front whole, or replayed as the keys it came as.
3+
4+package app
5+
6+import (
7+ "strings"
8+
9+ "github.com/gdamore/tcell/v2"
10+)
11+
12+// handlePaste marks the start and end of a bracketed paste from the terminal.
13+//
14+// tcell sends the pasted text as ordinary keys between the two marks, so
15+// handleKey collects them while a paste is on, and the end mark decides
16+// where the whole goes. An agent window in front gets it as one paste, which
17+// is what lets a long one be kept aside as a token (acp.View.Paste). Any
18+// other window gets the keys replayed exactly as it would have had them, so
19+// nothing else changes: a paste into a file is typed, as it always was.
20+func (a *App) handlePaste(ev *tcell.EventPaste) {
21+ if ev.Start() {
22+ a.pasting, a.pasted = true, nil
23+ return
24+ }
25+ keys := a.pasted
26+ a.pasting, a.pasted = false, nil
27+
28+ if agent := a.activeAgent(); agent != nil && len(a.modals) == 0 {
29+ agent.view.Paste(pastedText(keys))
30+ return
31+ }
32+ for _, key := range keys {
33+ a.handleKey(key)
34+ }
35+}
36+
37+// pastedText puts the keys of a bracketed paste back into the text they
38+// were: a rune for each character, a newline for each Enter, a tab for each
39+// Tab. Anything else a terminal might send inside a paste is not text.
40+func pastedText(keys []*tcell.EventKey) string {
41+ var text strings.Builder
42+ for _, key := range keys {
43+ switch key.Key() {
44+ case tcell.KeyRune:
45+ text.WriteRune(key.Rune())
46+ case tcell.KeyEnter, tcell.KeyLF:
47+ text.WriteByte('\n')
48+ case tcell.KeyTab:
49+ text.WriteByte('\t')
50+ }
51+ }
52+ return text.String()
53+}
new file mode 100644
@@ -0,0 +1,53 @@
1+// A paste from the terminal: collected between its two marks and handed to
2+// the window in front whole, or replayed as the keys it came as.
3+
4+package app
5+
6+import (
7+ "strings"
8+
9+ "github.com/gdamore/tcell/v2"
10+)
11+
12+// handlePaste marks the start and end of a bracketed paste from the terminal.
13+//
14+// tcell sends the pasted text as ordinary keys between the two marks, so
15+// handleKey collects them while a paste is on, and the end mark decides
16+// where the whole goes. An agent window in front gets it as one paste, which
17+// is what lets a long one be kept aside as a token (acp.View.Paste). Any
18+// other window gets the keys replayed exactly as it would have had them, so
19+// nothing else changes: a paste into a file is typed, as it always was.
20+func (a *App) handlePaste(ev *tcell.EventPaste) {
21+ if ev.Start() {
22+ a.pasting, a.pasted = true, nil
23+ return
24+ }
25+ keys := a.pasted
26+ a.pasting, a.pasted = false, nil
27+
28+ if agent := a.activeAgent(); agent != nil && len(a.modals) == 0 {
29+ agent.view.Paste(pastedText(keys))
30+ return
31+ }
32+ for _, key := range keys {
33+ a.handleKey(key)
34+ }
35+}
36+
37+// pastedText puts the keys of a bracketed paste back into the text they
38+// were: a rune for each character, a newline for each Enter, a tab for each
39+// Tab. Anything else a terminal might send inside a paste is not text.
40+func pastedText(keys []*tcell.EventKey) string {
41+ var text strings.Builder
42+ for _, key := range keys {
43+ switch key.Key() {
44+ case tcell.KeyRune:
45+ text.WriteRune(key.Rune())
46+ case tcell.KeyEnter, tcell.KeyLF:
47+ text.WriteByte('\n')
48+ case tcell.KeyTab:
49+ text.WriteByte('\t')
50+ }
51+ }
52+ return text.String()
53+}
added app/paste_test.go +92 -0
new file mode 100644
@@ -0,0 +1,92 @@
1+package app
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/gdamore/tcell/v2"
8+)
9+
10+// pasteKeys sends text as a terminal's bracketed paste would: a start mark,
11+// the characters as keys with Enter for each newline, an end mark.
12+func pasteKeys(a *App, text string) {
13+ a.handle(tcell.NewEventPaste(true))
14+ for _, r := range text {
15+ if r == '\n' {
16+ a.handle(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))
17+ continue
18+ }
19+ a.handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
20+ }
21+ a.handle(tcell.NewEventPaste(false))
22+}
23+
24+func TestABracketedPasteReachesAnAgentWindowWhole(t *testing.T) {
25+ // Key by key, the two Enters would each have sent a prompt; whole, the
26+ // three lines are one paste, kept aside as a token.
27+ a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n")
28+ a.NewAgent("Echo")
29+ agent := a.activeAgent()
30+ if agent == nil {
31+ t.Fatal("no agent window is in front")
32+ }
33+
34+ pasteKeys(a, "one\ntwo\nthree")
35+
36+ if got := agent.view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" {
37+ t.Errorf("the box holds %q", got)
38+ }
39+ if entries := agent.session.Entries(); len(entries) != 0 {
40+ t.Errorf("%d prompt(s) were sent by the newlines inside the paste", len(entries))
41+ }
42+}
43+
44+func TestABracketedPasteIntoAFileIsTypedAsBefore(t *testing.T) {
45+ a, _ := newTestApp(t)
46+ a.NewFile()
47+
48+ pasteKeys(a, "ab")
49+
50+ if got := a.activeView().Buffer().Text(); got != "ab" {
51+ t.Errorf("the file holds %q after a paste, want %q", got, "ab")
52+ }
53+}
54+
55+func TestKeysAreNotLostWhenAPasteIsCancelledByAnotherWindow(t *testing.T) {
56+ // The paste starts in an agent window; nothing else in the editor sees
57+ // its keys until it ends — so a key layer cannot half-act on them.
58+ a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n")
59+ a.NewAgent("Echo")
60+
61+ a.handle(tcell.NewEventPaste(true))
62+ a.handle(tcell.NewEventKey(tcell.KeyF4, 0, tcell.ModNone)) // New file, were it a keystroke
63+ a.handle(tcell.NewEventPaste(false))
64+
65+ if len(a.desktop.Windows()) != 1 {
66+ t.Errorf("%d windows are open: a key inside a paste acted as a shortcut", len(a.desktop.Windows()))
67+ }
68+}
69+
70+func TestEditPasteGoesIntoTheAgentBox(t *testing.T) {
71+ a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n")
72+ a.NewAgent("Echo")
73+ a.clipboard.SetText("from a file\nin the editor\n")
74+
75+ a.Paste()
76+
77+ if got := a.activeAgent().view.Input(); !strings.HasPrefix(got, "[Pasted #1 · 2 lines") {
78+ t.Errorf("the box holds %q after Edit ▸ Paste", got)
79+ }
80+}
81+
82+func TestCtrlVInAnAgentWindowPastesTheEditorsClipboard(t *testing.T) {
83+ a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n")
84+ a.NewAgent("Echo")
85+ a.clipboard.SetText("buildMenus")
86+
87+ press(a, tcell.KeyCtrlV, 0, tcell.ModNone)
88+
89+ if got := a.activeAgent().view.Input(); got != "buildMenus" {
90+ t.Errorf("the box holds %q after Ctrl-V", got)
91+ }
92+}
new file mode 100644
@@ -0,0 +1,92 @@
1+package app
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/gdamore/tcell/v2"
8+)
9+
10+// pasteKeys sends text as a terminal's bracketed paste would: a start mark,
11+// the characters as keys with Enter for each newline, an end mark.
12+func pasteKeys(a *App, text string) {
13+ a.handle(tcell.NewEventPaste(true))
14+ for _, r := range text {
15+ if r == '\n' {
16+ a.handle(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))
17+ continue
18+ }
19+ a.handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
20+ }
21+ a.handle(tcell.NewEventPaste(false))
22+}
23+
24+func TestABracketedPasteReachesAnAgentWindowWhole(t *testing.T) {
25+ // Key by key, the two Enters would each have sent a prompt; whole, the
26+ // three lines are one paste, kept aside as a token.
27+ a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n")
28+ a.NewAgent("Echo")
29+ agent := a.activeAgent()
30+ if agent == nil {
31+ t.Fatal("no agent window is in front")
32+ }
33+
34+ pasteKeys(a, "one\ntwo\nthree")
35+
36+ if got := agent.view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" {
37+ t.Errorf("the box holds %q", got)
38+ }
39+ if entries := agent.session.Entries(); len(entries) != 0 {
40+ t.Errorf("%d prompt(s) were sent by the newlines inside the paste", len(entries))
41+ }
42+}
43+
44+func TestABracketedPasteIntoAFileIsTypedAsBefore(t *testing.T) {
45+ a, _ := newTestApp(t)
46+ a.NewFile()
47+
48+ pasteKeys(a, "ab")
49+
50+ if got := a.activeView().Buffer().Text(); got != "ab" {
51+ t.Errorf("the file holds %q after a paste, want %q", got, "ab")
52+ }
53+}
54+
55+func TestKeysAreNotLostWhenAPasteIsCancelledByAnotherWindow(t *testing.T) {
56+ // The paste starts in an agent window; nothing else in the editor sees
57+ // its keys until it ends — so a key layer cannot half-act on them.
58+ a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n")
59+ a.NewAgent("Echo")
60+
61+ a.handle(tcell.NewEventPaste(true))
62+ a.handle(tcell.NewEventKey(tcell.KeyF4, 0, tcell.ModNone)) // New file, were it a keystroke
63+ a.handle(tcell.NewEventPaste(false))
64+
65+ if len(a.desktop.Windows()) != 1 {
66+ t.Errorf("%d windows are open: a key inside a paste acted as a shortcut", len(a.desktop.Windows()))
67+ }
68+}
69+
70+func TestEditPasteGoesIntoTheAgentBox(t *testing.T) {
71+ a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n")
72+ a.NewAgent("Echo")
73+ a.clipboard.SetText("from a file\nin the editor\n")
74+
75+ a.Paste()
76+
77+ if got := a.activeAgent().view.Input(); !strings.HasPrefix(got, "[Pasted #1 · 2 lines") {
78+ t.Errorf("the box holds %q after Edit ▸ Paste", got)
79+ }
80+}
81+
82+func TestCtrlVInAnAgentWindowPastesTheEditorsClipboard(t *testing.T) {
83+ a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n")
84+ a.NewAgent("Echo")
85+ a.clipboard.SetText("buildMenus")
86+
87+ press(a, tcell.KeyCtrlV, 0, tcell.ModNone)
88+
89+ if got := a.activeAgent().view.Input(); got != "buildMenus" {
90+ t.Errorf("the box holds %q after Ctrl-V", got)
91+ }
92+}
modified app/toolchain.go +2 -6
@@ -360,13 +360,9 @@ func (a *App) reloadUnmodifiedBuffers() (reloaded, skipped int) {
360360 continue
361361 }
362362
363- changed, err := view.Buffer().Reload()
364- if err != nil || !changed {
365- continue // a file that has gone, or one nothing touched
363+ if a.reloadFromDisk(window, view) {
364+ reloaded++
366365 }
367- view.RefreshSyntax()
368- window.SetTitle(windowTitle(view.Buffer()))
369- reloaded++
370366 }
371367 return reloaded, skipped
372368 }
@@ -360,13 +360,9 @@ func (a *App) reloadUnmodifiedBuffers() (reloaded, skipped int) {
360 continue360 continue
361 }361 }
362 362
363- changed, err := view.Buffer().Reload()363+ if a.reloadFromDisk(window, view) {
364- if err != nil || !changed {364+ reloaded++
365- continue // a file that has gone, or one nothing touched
366 }365 }
367- view.RefreshSyntax()
368- window.SetTitle(windowTitle(view.Buffer()))
369- reloaded++
370 }366 }
371 return reloaded, skipped367 return reloaded, skipped
372 }368 }
added app/watch.go +184 -0
new file mode 100644
@@ -0,0 +1,184 @@
1+// Files changing on disk while they are open: noticing it, and re-reading them
2+// when nothing typed in the window would be lost.
3+
4+package app
5+
6+import (
7+ "path/filepath"
8+ "sync"
9+ "time"
10+
11+ "rickub.com/turbo-editors/turbo-core/editor"
12+ "rickub.com/turbo-editors/turbo-core/ui"
13+)
14+
15+// defaultFileWatchInterval is how often the disk is looked at for a change to
16+// a file that is open, while the editor is otherwise idle.
17+//
18+// One second is what a person notices as "straight away" and what a machine
19+// does not notice at all: one stat per open window per second.
20+const defaultFileWatchInterval = time.Second
21+
22+// fileWatch is what the event loop knows about the files on disk behind its
23+// windows, shared with the goroutine that watches them while the loop sleeps.
24+//
25+// The loop is the only thing that may touch a buffer, and it sits blocked in
26+// PollEvent until something happens — so a file rewritten by a formatter, a
27+// generator or a coding agent working in another terminal changed nothing on
28+// screen until the next keystroke, and then only because the tools reload
29+// happened to run. The goroutine here exists solely to wake the loop; the loop
30+// then decides for itself, by stat, what actually changed. That split is the
31+// same one every other state-driven step in tick makes: a wake-up may be
32+// dropped, so it must never be the only thing carrying a fact.
33+type fileWatch struct {
34+ mu sync.Mutex
35+ // stamps is what the loop last saw of each open file, by canonical path.
36+ // Written by the loop, read by the watching goroutine.
37+ stamps map[string]fileStamp
38+}
39+
40+// publish hands the goroutine what the loop has just seen on disk.
41+func (w *fileWatch) publish(stamps map[string]fileStamp) {
42+ w.mu.Lock()
43+ defer w.mu.Unlock()
44+ w.stamps = stamps
45+}
46+
47+// changed reports whether any file the loop last saw is now different on
48+// disk. It reads the disk and nothing else, so it is safe from any goroutine.
49+func (w *fileWatch) changed() bool {
50+ w.mu.Lock()
51+ defer w.mu.Unlock()
52+ for path, stamp := range w.stamps {
53+ if stampOf(path) != stamp {
54+ return true
55+ }
56+ }
57+ return false
58+}
59+
60+// watchFiles starts looking at the disk behind the open windows, and returns
61+// what stops it. Only Run calls it: a test drives the loop by hand and has no
62+// need of a wake-up.
63+//
64+// A dropped wake costs nothing here — the file is still different next time
65+// round, so the goroutine wakes the loop again a second later.
66+func (a *App) watchFiles() (stop func()) {
67+ done := make(chan struct{})
68+ go func() {
69+ ticker := time.NewTicker(a.fileWatchInterval)
70+ defer ticker.Stop()
71+ for {
72+ select {
73+ case <-done:
74+ return
75+ case <-ticker.C:
76+ if a.fileWatch.changed() {
77+ a.wake()
78+ }
79+ }
80+ }
81+ }()
82+ return func() { close(done) }
83+}
84+
85+// stampFile records what a file looks like on disk now, so that a later turn
86+// of the loop compares against this and not against something older.
87+//
88+// It is called wherever the editor itself is the reason the file changed —
89+// opening it, saving it, re-reading it — because a change the editor made is
90+// not one it needs to be told about.
91+func (a *App) stampFile(path string) {
92+ if path == "" {
93+ return
94+ }
95+ a.fileStamps[pathKey(path)] = stampOf(path)
96+}
97+
98+// reloadChangedFiles re-reads every open file that something else rewrote.
99+//
100+// It runs at the top of every turn of the event loop, like the other
101+// state-driven work, and costs one stat per open window. A file whose stamp —
102+// size and modification time — is what it was last turn is not read.
103+//
104+// **A buffer with unsaved changes is never reloaded**, whatever happened on
105+// disk. The window says so on the status bar, once per change, and keeps the
106+// user's work; the next save writes the buffer over the file, which is the
107+// only answer the editor is entitled to give. A file that has gone from disk
108+// is left as it is too, silently: it is still what the window shows, and
109+// saving it puts it back.
110+func (a *App) reloadChangedFiles() {
111+ open := map[string]bool{}
112+ for _, window := range a.desktop.Windows() {
113+ view, ok := editorViewOf(window)
114+ if !ok || view.Buffer().Path() == "" {
115+ continue
116+ }
117+ path := view.Buffer().Path()
118+ key := pathKey(path)
119+ open[key] = true
120+
121+ stamp := stampOf(path)
122+ last, known := a.fileStamps[key]
123+ a.fileStamps[key] = stamp
124+ if !known || stamp == last {
125+ continue
126+ }
127+ a.fileChangedOnDisk(window, view)
128+ }
129+
130+ // Stamps outlive their windows otherwise — and a closed window's file
131+ // would still wake the loop every time something touched it.
132+ for key := range a.fileStamps {
133+ if !open[key] {
134+ delete(a.fileStamps, key)
135+ }
136+ }
137+ a.fileWatch.publish(copyStamps(a.fileStamps))
138+}
139+
140+// fileChangedOnDisk is what one window does about its file having changed
141+// under it: re-read it when nothing would be lost, say so when something
142+// would.
143+func (a *App) fileChangedOnDisk(window *ui.Window, view *editor.View) {
144+ name := filepath.Base(view.Buffer().Path())
145+ if view.Buffer().Modified() {
146+ a.Message(name + " changed on disk; your unsaved changes are kept")
147+ return
148+ }
149+ if a.reloadFromDisk(window, view) {
150+ a.Message("Reloaded " + name)
151+ }
152+}
153+
154+// reloadFromDisk re-reads one unmodified window's file and brings everything
155+// that depends on its text up to date, reporting whether the text changed.
156+//
157+// It is one function for the two callers — a command from the tools menu
158+// finishing, and a change noticed on disk — so that a step added for one
159+// cannot go missing from the other. Telling the language server is exactly
160+// such a step: a server answering completions from the text the window
161+// showed before a formatter ran is the same stale-copy problem in a different
162+// place.
163+func (a *App) reloadFromDisk(window *ui.Window, view *editor.View) bool {
164+ buf := view.Buffer()
165+ changed, err := buf.Reload()
166+ a.stampFile(buf.Path())
167+ if err != nil || !changed {
168+ return false // a file that has gone, or one nothing touched
169+ }
170+ view.RefreshSyntax()
171+ window.SetTitle(windowTitle(buf))
172+ a.language.DidChange(buf.Path(), buf.Text())
173+ return true
174+}
175+
176+// copyStamps returns a map the goroutine can read while the loop goes on
177+// writing its own.
178+func copyStamps(stamps map[string]fileStamp) map[string]fileStamp {
179+ out := make(map[string]fileStamp, len(stamps))
180+ for key, stamp := range stamps {
181+ out[key] = stamp
182+ }
183+ return out
184+}
new file mode 100644
@@ -0,0 +1,184 @@
1+// Files changing on disk while they are open: noticing it, and re-reading them
2+// when nothing typed in the window would be lost.
3+
4+package app
5+
6+import (
7+ "path/filepath"
8+ "sync"
9+ "time"
10+
11+ "rickub.com/turbo-editors/turbo-core/editor"
12+ "rickub.com/turbo-editors/turbo-core/ui"
13+)
14+
15+// defaultFileWatchInterval is how often the disk is looked at for a change to
16+// a file that is open, while the editor is otherwise idle.
17+//
18+// One second is what a person notices as "straight away" and what a machine
19+// does not notice at all: one stat per open window per second.
20+const defaultFileWatchInterval = time.Second
21+
22+// fileWatch is what the event loop knows about the files on disk behind its
23+// windows, shared with the goroutine that watches them while the loop sleeps.
24+//
25+// The loop is the only thing that may touch a buffer, and it sits blocked in
26+// PollEvent until something happens — so a file rewritten by a formatter, a
27+// generator or a coding agent working in another terminal changed nothing on
28+// screen until the next keystroke, and then only because the tools reload
29+// happened to run. The goroutine here exists solely to wake the loop; the loop
30+// then decides for itself, by stat, what actually changed. That split is the
31+// same one every other state-driven step in tick makes: a wake-up may be
32+// dropped, so it must never be the only thing carrying a fact.
33+type fileWatch struct {
34+ mu sync.Mutex
35+ // stamps is what the loop last saw of each open file, by canonical path.
36+ // Written by the loop, read by the watching goroutine.
37+ stamps map[string]fileStamp
38+}
39+
40+// publish hands the goroutine what the loop has just seen on disk.
41+func (w *fileWatch) publish(stamps map[string]fileStamp) {
42+ w.mu.Lock()
43+ defer w.mu.Unlock()
44+ w.stamps = stamps
45+}
46+
47+// changed reports whether any file the loop last saw is now different on
48+// disk. It reads the disk and nothing else, so it is safe from any goroutine.
49+func (w *fileWatch) changed() bool {
50+ w.mu.Lock()
51+ defer w.mu.Unlock()
52+ for path, stamp := range w.stamps {
53+ if stampOf(path) != stamp {
54+ return true
55+ }
56+ }
57+ return false
58+}
59+
60+// watchFiles starts looking at the disk behind the open windows, and returns
61+// what stops it. Only Run calls it: a test drives the loop by hand and has no
62+// need of a wake-up.
63+//
64+// A dropped wake costs nothing here — the file is still different next time
65+// round, so the goroutine wakes the loop again a second later.
66+func (a *App) watchFiles() (stop func()) {
67+ done := make(chan struct{})
68+ go func() {
69+ ticker := time.NewTicker(a.fileWatchInterval)
70+ defer ticker.Stop()
71+ for {
72+ select {
73+ case <-done:
74+ return
75+ case <-ticker.C:
76+ if a.fileWatch.changed() {
77+ a.wake()
78+ }
79+ }
80+ }
81+ }()
82+ return func() { close(done) }
83+}
84+
85+// stampFile records what a file looks like on disk now, so that a later turn
86+// of the loop compares against this and not against something older.
87+//
88+// It is called wherever the editor itself is the reason the file changed —
89+// opening it, saving it, re-reading it — because a change the editor made is
90+// not one it needs to be told about.
91+func (a *App) stampFile(path string) {
92+ if path == "" {
93+ return
94+ }
95+ a.fileStamps[pathKey(path)] = stampOf(path)
96+}
97+
98+// reloadChangedFiles re-reads every open file that something else rewrote.
99+//
100+// It runs at the top of every turn of the event loop, like the other
101+// state-driven work, and costs one stat per open window. A file whose stamp —
102+// size and modification time — is what it was last turn is not read.
103+//
104+// **A buffer with unsaved changes is never reloaded**, whatever happened on
105+// disk. The window says so on the status bar, once per change, and keeps the
106+// user's work; the next save writes the buffer over the file, which is the
107+// only answer the editor is entitled to give. A file that has gone from disk
108+// is left as it is too, silently: it is still what the window shows, and
109+// saving it puts it back.
110+func (a *App) reloadChangedFiles() {
111+ open := map[string]bool{}
112+ for _, window := range a.desktop.Windows() {
113+ view, ok := editorViewOf(window)
114+ if !ok || view.Buffer().Path() == "" {
115+ continue
116+ }
117+ path := view.Buffer().Path()
118+ key := pathKey(path)
119+ open[key] = true
120+
121+ stamp := stampOf(path)
122+ last, known := a.fileStamps[key]
123+ a.fileStamps[key] = stamp
124+ if !known || stamp == last {
125+ continue
126+ }
127+ a.fileChangedOnDisk(window, view)
128+ }
129+
130+ // Stamps outlive their windows otherwise — and a closed window's file
131+ // would still wake the loop every time something touched it.
132+ for key := range a.fileStamps {
133+ if !open[key] {
134+ delete(a.fileStamps, key)
135+ }
136+ }
137+ a.fileWatch.publish(copyStamps(a.fileStamps))
138+}
139+
140+// fileChangedOnDisk is what one window does about its file having changed
141+// under it: re-read it when nothing would be lost, say so when something
142+// would.
143+func (a *App) fileChangedOnDisk(window *ui.Window, view *editor.View) {
144+ name := filepath.Base(view.Buffer().Path())
145+ if view.Buffer().Modified() {
146+ a.Message(name + " changed on disk; your unsaved changes are kept")
147+ return
148+ }
149+ if a.reloadFromDisk(window, view) {
150+ a.Message("Reloaded " + name)
151+ }
152+}
153+
154+// reloadFromDisk re-reads one unmodified window's file and brings everything
155+// that depends on its text up to date, reporting whether the text changed.
156+//
157+// It is one function for the two callers — a command from the tools menu
158+// finishing, and a change noticed on disk — so that a step added for one
159+// cannot go missing from the other. Telling the language server is exactly
160+// such a step: a server answering completions from the text the window
161+// showed before a formatter ran is the same stale-copy problem in a different
162+// place.
163+func (a *App) reloadFromDisk(window *ui.Window, view *editor.View) bool {
164+ buf := view.Buffer()
165+ changed, err := buf.Reload()
166+ a.stampFile(buf.Path())
167+ if err != nil || !changed {
168+ return false // a file that has gone, or one nothing touched
169+ }
170+ view.RefreshSyntax()
171+ window.SetTitle(windowTitle(buf))
172+ a.language.DidChange(buf.Path(), buf.Text())
173+ return true
174+}
175+
176+// copyStamps returns a map the goroutine can read while the loop goes on
177+// writing its own.
178+func copyStamps(stamps map[string]fileStamp) map[string]fileStamp {
179+ out := make(map[string]fileStamp, len(stamps))
180+ for key, stamp := range stamps {
181+ out[key] = stamp
182+ }
183+ return out
184+}
added app/watch_test.go +201 -0
new file mode 100644
@@ -0,0 +1,201 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/gdamore/tcell/v2"
11+)
12+
13+// newWatchedFile returns an editor with a file open in the front window, and
14+// that file's path. The contents written later are deliberately of a different
15+// length: on a filesystem that stamps modification times by the second, two
16+// writes in the same second are told apart by size or not at all.
17+func newWatchedFile(t *testing.T) (*App, tcell.SimulationScreen, string) {
18+ t.Helper()
19+
20+ a, screen := newTestApp(t)
21+ path := filepath.Join(t.TempDir(), "main.go")
22+ writeTestFile(t, path, "package main\n")
23+ a.Open(path)
24+ a.tick() // the loop has seen the file as it was opened
25+ return a, screen, path
26+}
27+
28+func TestAFileRewrittenByAnotherProgramIsReloaded(t *testing.T) {
29+ // This is the whole feature: a formatter, a generator or an agent in
30+ // another terminal rewrites the file, and the window shows it without
31+ // closing and reopening.
32+ a, _, path := newWatchedFile(t)
33+
34+ writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n")
35+ a.tick()
36+
37+ if got := activeEditorText(t, a, path); !strings.Contains(got, "rewritten outside") {
38+ t.Errorf("the window still shows %q after the file changed on disk", got)
39+ }
40+ if got := a.status.Message(); got != "Reloaded main.go" {
41+ t.Errorf("status message = %q, want %q", got, "Reloaded main.go")
42+ }
43+ if title := a.desktop.Active().Title(); title != "main.go" {
44+ t.Errorf("window title = %q after a reload, want %q", title, "main.go")
45+ }
46+}
47+
48+func TestAFileWithUnsavedChangesIsKeptWhenTheDiskChanges(t *testing.T) {
49+ // The user's work and the other program's genuinely conflict, and the
50+ // editor is not the one to decide — so it keeps the work and says so.
51+ a, _, path := newWatchedFile(t)
52+ typeText(a, "// mine")
53+ edited := activeEditorText(t, a, path)
54+
55+ writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n")
56+ a.tick()
57+
58+ if got := activeEditorText(t, a, path); got != edited {
59+ t.Errorf("the modified buffer was reloaded: %q, want the unsaved edit %q", got, edited)
60+ }
61+ if got := a.status.Message(); !strings.Contains(got, "changed on disk") {
62+ t.Errorf("status message = %q, want it to say the file changed on disk", got)
63+ }
64+}
65+
66+func TestAChangeOnDiskIsReportedOncePerChange(t *testing.T) {
67+ // The message would otherwise be re-issued on every turn of the loop for as
68+ // long as the window stays modified, hiding everything else the status bar
69+ // has to say.
70+ a, _, path := newWatchedFile(t)
71+ typeText(a, "// mine")
72+ writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n")
73+ a.tick()
74+
75+ a.Message("something else")
76+ a.tick()
77+
78+ if got := a.status.Message(); got != "something else" {
79+ t.Errorf("status message = %q; the change on disk was reported a second time", got)
80+ }
81+}
82+
83+func TestTheEditorsOwnSaveIsNotMistakenForAChangeOnDisk(t *testing.T) {
84+ // A save rewrites the file — through a rename, so with a new inode and
85+ // stamp — and must not come round as a reload of the text just written.
86+ a, _, path := newWatchedFile(t)
87+ typeText(a, "// mine")
88+ a.SaveFile()
89+ a.tick()
90+
91+ if got := a.status.Message(); got != "Saved main.go" {
92+ t.Errorf("status message = %q after a save, want %q", got, "Saved main.go")
93+ }
94+ if got := activeEditorText(t, a, path); !strings.Contains(got, "// mine") {
95+ t.Errorf("the saved text was lost: %q", got)
96+ }
97+}
98+
99+func TestAFileThatDisappearsIsLeftAsItWasUntilItComesBack(t *testing.T) {
100+ // Some programs replace a file by deleting it and writing a new one; a
101+ // stat between the two sees nothing. The window keeps what it has, and
102+ // picks the file up when it is there again.
103+ a, _, path := newWatchedFile(t)
104+
105+ if err := os.Remove(path); err != nil {
106+ t.Fatal(err)
107+ }
108+ a.tick()
109+ if got := activeEditorText(t, a, path); got != "package main\n" {
110+ t.Errorf("the window lost its text when the file went: %q", got)
111+ }
112+
113+ writeTestFile(t, path, "package main\n\n// back again, longer\n")
114+ a.tick()
115+ if got := activeEditorText(t, a, path); !strings.Contains(got, "back again") {
116+ t.Errorf("the window did not pick the file up when it came back: %q", got)
117+ }
118+}
119+
120+func TestAReloadTellsTheLanguageServerWhatTheFileNowSays(t *testing.T) {
121+ // A server answering completions from the text before the formatter ran
122+ // is the same stale-copy problem in another place.
123+ a, server, project := newCodeApp(t)
124+ path := filepath.Join(project, "main.go")
125+ waitForMethod(t, server, "textDocument/didOpen")
126+ a.tick()
127+ before := server.methodCount("textDocument/didChange")
128+
129+ writeTestFile(t, path, "package main\n\nfunc main() { println() }\n")
130+ a.tick()
131+
132+ deadline := time.After(2 * time.Second)
133+ for server.methodCount("textDocument/didChange") == before {
134+ select {
135+ case <-deadline:
136+ t.Fatal("the server was never told the reloaded text")
137+ case <-time.After(time.Millisecond):
138+ }
139+ }
140+}
141+
142+func TestAClosedWindowsFileIsNoLongerWatched(t *testing.T) {
143+ a, _, path := newWatchedFile(t)
144+
145+ a.CloseFile()
146+ a.tick()
147+ writeTestFile(t, path, "package main\n\n// nobody is looking\n")
148+
149+ if len(a.fileStamps) != 0 {
150+ t.Errorf("%d file(s) still stamped after the only window closed", len(a.fileStamps))
151+ }
152+ if a.fileWatch.changed() {
153+ t.Error("the watcher would wake the loop for a file no window shows")
154+ }
155+}
156+
157+func TestTheWatcherWakesTheLoopWhenAnOpenFileChanges(t *testing.T) {
158+ // Run sits in PollEvent; without this wake the reload would wait for the
159+ // next keystroke, which is the defect being fixed.
160+ a, screen, path := newWatchedFile(t)
161+ a.fileWatchInterval = 5 * time.Millisecond
162+ stop := a.watchFiles()
163+ defer stop()
164+
165+ writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n")
166+
167+ if !wokeWithin(screen, time.Second) {
168+ t.Fatal("the event loop was never woken for a file that changed on disk")
169+ }
170+}
171+
172+func TestTheWatcherLeavesAnIdleEditorAlone(t *testing.T) {
173+ // Waking the loop every second regardless would make the editor redraw
174+ // forever with nothing to show; the wake is for a change, not for time.
175+ a, screen, _ := newWatchedFile(t)
176+ a.fileWatchInterval = 5 * time.Millisecond
177+ stop := a.watchFiles()
178+ defer stop()
179+
180+ if wokeWithin(screen, 100*time.Millisecond) {
181+ t.Fatal("the event loop was woken though no file changed")
182+ }
183+}
184+
185+// wokeWithin reports whether the watcher posted its wake-up to the screen
186+// before the deadline.
187+func wokeWithin(screen tcell.SimulationScreen, within time.Duration) bool {
188+ deadline := time.After(within)
189+ for {
190+ for screen.HasPendingEvent() {
191+ if _, ok := screen.PollEvent().(*tcell.EventInterrupt); ok {
192+ return true
193+ }
194+ }
195+ select {
196+ case <-deadline:
197+ return false
198+ case <-time.After(2 * time.Millisecond):
199+ }
200+ }
201+}
new file mode 100644
@@ -0,0 +1,201 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/gdamore/tcell/v2"
11+)
12+
13+// newWatchedFile returns an editor with a file open in the front window, and
14+// that file's path. The contents written later are deliberately of a different
15+// length: on a filesystem that stamps modification times by the second, two
16+// writes in the same second are told apart by size or not at all.
17+func newWatchedFile(t *testing.T) (*App, tcell.SimulationScreen, string) {
18+ t.Helper()
19+
20+ a, screen := newTestApp(t)
21+ path := filepath.Join(t.TempDir(), "main.go")
22+ writeTestFile(t, path, "package main\n")
23+ a.Open(path)
24+ a.tick() // the loop has seen the file as it was opened
25+ return a, screen, path
26+}
27+
28+func TestAFileRewrittenByAnotherProgramIsReloaded(t *testing.T) {
29+ // This is the whole feature: a formatter, a generator or an agent in
30+ // another terminal rewrites the file, and the window shows it without
31+ // closing and reopening.
32+ a, _, path := newWatchedFile(t)
33+
34+ writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n")
35+ a.tick()
36+
37+ if got := activeEditorText(t, a, path); !strings.Contains(got, "rewritten outside") {
38+ t.Errorf("the window still shows %q after the file changed on disk", got)
39+ }
40+ if got := a.status.Message(); got != "Reloaded main.go" {
41+ t.Errorf("status message = %q, want %q", got, "Reloaded main.go")
42+ }
43+ if title := a.desktop.Active().Title(); title != "main.go" {
44+ t.Errorf("window title = %q after a reload, want %q", title, "main.go")
45+ }
46+}
47+
48+func TestAFileWithUnsavedChangesIsKeptWhenTheDiskChanges(t *testing.T) {
49+ // The user's work and the other program's genuinely conflict, and the
50+ // editor is not the one to decide — so it keeps the work and says so.
51+ a, _, path := newWatchedFile(t)
52+ typeText(a, "// mine")
53+ edited := activeEditorText(t, a, path)
54+
55+ writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n")
56+ a.tick()
57+
58+ if got := activeEditorText(t, a, path); got != edited {
59+ t.Errorf("the modified buffer was reloaded: %q, want the unsaved edit %q", got, edited)
60+ }
61+ if got := a.status.Message(); !strings.Contains(got, "changed on disk") {
62+ t.Errorf("status message = %q, want it to say the file changed on disk", got)
63+ }
64+}
65+
66+func TestAChangeOnDiskIsReportedOncePerChange(t *testing.T) {
67+ // The message would otherwise be re-issued on every turn of the loop for as
68+ // long as the window stays modified, hiding everything else the status bar
69+ // has to say.
70+ a, _, path := newWatchedFile(t)
71+ typeText(a, "// mine")
72+ writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n")
73+ a.tick()
74+
75+ a.Message("something else")
76+ a.tick()
77+
78+ if got := a.status.Message(); got != "something else" {
79+ t.Errorf("status message = %q; the change on disk was reported a second time", got)
80+ }
81+}
82+
83+func TestTheEditorsOwnSaveIsNotMistakenForAChangeOnDisk(t *testing.T) {
84+ // A save rewrites the file — through a rename, so with a new inode and
85+ // stamp — and must not come round as a reload of the text just written.
86+ a, _, path := newWatchedFile(t)
87+ typeText(a, "// mine")
88+ a.SaveFile()
89+ a.tick()
90+
91+ if got := a.status.Message(); got != "Saved main.go" {
92+ t.Errorf("status message = %q after a save, want %q", got, "Saved main.go")
93+ }
94+ if got := activeEditorText(t, a, path); !strings.Contains(got, "// mine") {
95+ t.Errorf("the saved text was lost: %q", got)
96+ }
97+}
98+
99+func TestAFileThatDisappearsIsLeftAsItWasUntilItComesBack(t *testing.T) {
100+ // Some programs replace a file by deleting it and writing a new one; a
101+ // stat between the two sees nothing. The window keeps what it has, and
102+ // picks the file up when it is there again.
103+ a, _, path := newWatchedFile(t)
104+
105+ if err := os.Remove(path); err != nil {
106+ t.Fatal(err)
107+ }
108+ a.tick()
109+ if got := activeEditorText(t, a, path); got != "package main\n" {
110+ t.Errorf("the window lost its text when the file went: %q", got)
111+ }
112+
113+ writeTestFile(t, path, "package main\n\n// back again, longer\n")
114+ a.tick()
115+ if got := activeEditorText(t, a, path); !strings.Contains(got, "back again") {
116+ t.Errorf("the window did not pick the file up when it came back: %q", got)
117+ }
118+}
119+
120+func TestAReloadTellsTheLanguageServerWhatTheFileNowSays(t *testing.T) {
121+ // A server answering completions from the text before the formatter ran
122+ // is the same stale-copy problem in another place.
123+ a, server, project := newCodeApp(t)
124+ path := filepath.Join(project, "main.go")
125+ waitForMethod(t, server, "textDocument/didOpen")
126+ a.tick()
127+ before := server.methodCount("textDocument/didChange")
128+
129+ writeTestFile(t, path, "package main\n\nfunc main() { println() }\n")
130+ a.tick()
131+
132+ deadline := time.After(2 * time.Second)
133+ for server.methodCount("textDocument/didChange") == before {
134+ select {
135+ case <-deadline:
136+ t.Fatal("the server was never told the reloaded text")
137+ case <-time.After(time.Millisecond):
138+ }
139+ }
140+}
141+
142+func TestAClosedWindowsFileIsNoLongerWatched(t *testing.T) {
143+ a, _, path := newWatchedFile(t)
144+
145+ a.CloseFile()
146+ a.tick()
147+ writeTestFile(t, path, "package main\n\n// nobody is looking\n")
148+
149+ if len(a.fileStamps) != 0 {
150+ t.Errorf("%d file(s) still stamped after the only window closed", len(a.fileStamps))
151+ }
152+ if a.fileWatch.changed() {
153+ t.Error("the watcher would wake the loop for a file no window shows")
154+ }
155+}
156+
157+func TestTheWatcherWakesTheLoopWhenAnOpenFileChanges(t *testing.T) {
158+ // Run sits in PollEvent; without this wake the reload would wait for the
159+ // next keystroke, which is the defect being fixed.
160+ a, screen, path := newWatchedFile(t)
161+ a.fileWatchInterval = 5 * time.Millisecond
162+ stop := a.watchFiles()
163+ defer stop()
164+
165+ writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n")
166+
167+ if !wokeWithin(screen, time.Second) {
168+ t.Fatal("the event loop was never woken for a file that changed on disk")
169+ }
170+}
171+
172+func TestTheWatcherLeavesAnIdleEditorAlone(t *testing.T) {
173+ // Waking the loop every second regardless would make the editor redraw
174+ // forever with nothing to show; the wake is for a change, not for time.
175+ a, screen, _ := newWatchedFile(t)
176+ a.fileWatchInterval = 5 * time.Millisecond
177+ stop := a.watchFiles()
178+ defer stop()
179+
180+ if wokeWithin(screen, 100*time.Millisecond) {
181+ t.Fatal("the event loop was woken though no file changed")
182+ }
183+}
184+
185+// wokeWithin reports whether the watcher posted its wake-up to the screen
186+// before the deadline.
187+func wokeWithin(screen tcell.SimulationScreen, within time.Duration) bool {
188+ deadline := time.After(within)
189+ for {
190+ for screen.HasPendingEvent() {
191+ if _, ok := screen.PollEvent().(*tcell.EventInterrupt); ok {
192+ return true
193+ }
194+ }
195+ select {
196+ case <-deadline:
197+ return false
198+ case <-time.After(2 * time.Millisecond):
199+ }
200+ }
201+}
added configrepo/README.md +57 -0
new file mode 100644
@@ -0,0 +1,57 @@
1+# configrepo
2+
3+Copies a project's editor directory — `.turbo-go`, `.turbo-rust` — out of a repository somebody shares, from the URL a forge shows for the directory holding it.
4+
5+```
6+turbo-go -load-config https://rickub.com/turbo-editors/configs/tree/main/golang-init
7+```
8+
9+fetches `golang-init/.turbo-go` into the working directory.
10+
11+## Why git, not an API
12+
13+The URL can be on rickub, GitHub, GitLab or Codeberg. Each has a JSON API for reading a directory, each API is different, and rickub's wants a personal access token even to read a public repository. Git is the one thing all of them speak the same way, with no token: the fetch is `git ls-remote` to find the repository and its branches, then `git clone --depth 1 --branch <ref>` into a temporary directory, then a copy. The price is that `git` must be installed, which on the machine of somebody running a terminal IDE it is. `ErrNoGit` is what you get otherwise.
14+
15+## Reading the URL
16+
17+`Parse` understands what the browsers show:
18+
19+| Shape | Forge |
20+| --- | --- |
21+| `https://host/owner/repo/tree/main/dir` | rickub, GitHub |
22+| `https://host/group/sub/repo/-/tree/main/dir` | GitLab — the owner keeps its slashes |
23+| `https://host/owner/repo/src/branch/main/dir` | Codeberg, Forgejo, Gitea — also `src/tag/`, `src/commit/` |
24+| `https://host/owner/repo` | a repository: default branch, root |
25+
26+`blob/` is taken like `tree/`, so a URL to a file inside the directory works too.
27+
28+**A branch with a slash in it**`feature/x` — cannot be told from the path by the URL alone. `Parse` takes the first segment as the ref and the rest as the path; `Load` then asks the repository for its branches and tags (`git ls-remote`) and `resolveRef` picks the **longest** one that begins `ref/path`, giving the rest back to the path. Nothing matching leaves both as they were and the clone reports the missing branch in git's own words.
29+
30+**Where to clone from.** GitHub, GitLab and Codeberg serve git at the address the browser shows. rickub does not — pages on `rickub.com`, repositories on `git.rickub.com` — so `CloneURLs` offers `https://host/owner/repo.git` and then `https://git.host/owner/repo.git`, and `firstAnswering` takes the first whose `ls-remote` answers. The listing does two jobs at once: telling the candidates apart, and being what a slashed ref is resolved against.
31+
32+## What is copied, and where
33+
34+Inside the checkout, `locate` looks for `<path>/.turbo-go`, or takes `<path>` itself when its last segment already is `.turbo-go`. Every regular file under it is copied with `projectfile.Write` — same mode, same atomic rename, directories made on the way — so a loaded configuration is created exactly as the editor creates one itself. Symbolic links and anything else that is not a regular file stay behind: a configuration is files.
35+
36+The destination is `dest/.turbo-go`, `dest` being the working directory. **If it already exists, nothing is done and `ErrExists` says so.** A project's configuration is a decision the project made; the editor does not overwrite it because a URL was typed. Move it away first.
37+
38+`GIT_TERMINAL_PROMPT=0` is set on every git call: this runs from a command line that asked for a URL, and a private repository is reported, not waited on.
39+
40+## Public API
41+
42+| Name | What it does |
43+| --- | --- |
44+| `Parse(url) (Source, error)` | Reads a forge's URL into host, owner, repo, ref, path |
45+| `Source.CloneURLs() []string` | The clone addresses to try, most likely first |
46+| `Load(ctx, profile, url, dest) (Result, error)` | Parse, find the repository, clone the ref, copy the editor's directory into dest |
47+| `Result` | The remote that answered, the ref, the directory created, the files in it |
48+| `ErrNoGit`, `ErrExists`, `ErrNotFound` | No git; the project already has the directory; the repository has none where the URL points |
49+
50+## Tests
51+
52+```sh
53+go test ./configrepo/
54+TURBO_CORE_NETWORK=1 go test ./configrepo/ # also the real thing, against rickub
55+```
56+
57+`sampleRepo` builds a repository on disk with `git init` — two branches, one of them `feature/x` — and `loadFrom` is pointed at it as a remote, which git accepts as readily as a URL. The network test is skipped unless asked for: the suite must pass on a machine with no network.
new file mode 100644
@@ -0,0 +1,57 @@
1+# configrepo
2+
3+Copies a project's editor directory — `.turbo-go`, `.turbo-rust` — out of a repository somebody shares, from the URL a forge shows for the directory holding it.
4+
5+```
6+turbo-go -load-config https://rickub.com/turbo-editors/configs/tree/main/golang-init
7+```
8+
9+fetches `golang-init/.turbo-go` into the working directory.
10+
11+## Why git, not an API
12+
13+The URL can be on rickub, GitHub, GitLab or Codeberg. Each has a JSON API for reading a directory, each API is different, and rickub's wants a personal access token even to read a public repository. Git is the one thing all of them speak the same way, with no token: the fetch is `git ls-remote` to find the repository and its branches, then `git clone --depth 1 --branch <ref>` into a temporary directory, then a copy. The price is that `git` must be installed, which on the machine of somebody running a terminal IDE it is. `ErrNoGit` is what you get otherwise.
14+
15+## Reading the URL
16+
17+`Parse` understands what the browsers show:
18+
19+| Shape | Forge |
20+| --- | --- |
21+| `https://host/owner/repo/tree/main/dir` | rickub, GitHub |
22+| `https://host/group/sub/repo/-/tree/main/dir` | GitLab — the owner keeps its slashes |
23+| `https://host/owner/repo/src/branch/main/dir` | Codeberg, Forgejo, Gitea — also `src/tag/`, `src/commit/` |
24+| `https://host/owner/repo` | a repository: default branch, root |
25+
26+`blob/` is taken like `tree/`, so a URL to a file inside the directory works too.
27+
28+**A branch with a slash in it**`feature/x` — cannot be told from the path by the URL alone. `Parse` takes the first segment as the ref and the rest as the path; `Load` then asks the repository for its branches and tags (`git ls-remote`) and `resolveRef` picks the **longest** one that begins `ref/path`, giving the rest back to the path. Nothing matching leaves both as they were and the clone reports the missing branch in git's own words.
29+
30+**Where to clone from.** GitHub, GitLab and Codeberg serve git at the address the browser shows. rickub does not — pages on `rickub.com`, repositories on `git.rickub.com` — so `CloneURLs` offers `https://host/owner/repo.git` and then `https://git.host/owner/repo.git`, and `firstAnswering` takes the first whose `ls-remote` answers. The listing does two jobs at once: telling the candidates apart, and being what a slashed ref is resolved against.
31+
32+## What is copied, and where
33+
34+Inside the checkout, `locate` looks for `<path>/.turbo-go`, or takes `<path>` itself when its last segment already is `.turbo-go`. Every regular file under it is copied with `projectfile.Write` — same mode, same atomic rename, directories made on the way — so a loaded configuration is created exactly as the editor creates one itself. Symbolic links and anything else that is not a regular file stay behind: a configuration is files.
35+
36+The destination is `dest/.turbo-go`, `dest` being the working directory. **If it already exists, nothing is done and `ErrExists` says so.** A project's configuration is a decision the project made; the editor does not overwrite it because a URL was typed. Move it away first.
37+
38+`GIT_TERMINAL_PROMPT=0` is set on every git call: this runs from a command line that asked for a URL, and a private repository is reported, not waited on.
39+
40+## Public API
41+
42+| Name | What it does |
43+| --- | --- |
44+| `Parse(url) (Source, error)` | Reads a forge's URL into host, owner, repo, ref, path |
45+| `Source.CloneURLs() []string` | The clone addresses to try, most likely first |
46+| `Load(ctx, profile, url, dest) (Result, error)` | Parse, find the repository, clone the ref, copy the editor's directory into dest |
47+| `Result` | The remote that answered, the ref, the directory created, the files in it |
48+| `ErrNoGit`, `ErrExists`, `ErrNotFound` | No git; the project already has the directory; the repository has none where the URL points |
49+
50+## Tests
51+
52+```sh
53+go test ./configrepo/
54+TURBO_CORE_NETWORK=1 go test ./configrepo/ # also the real thing, against rickub
55+```
56+
57+`sampleRepo` builds a repository on disk with `git init` — two branches, one of them `feature/x` — and `loadFrom` is pointed at it as a remote, which git accepts as readily as a URL. The network test is skipped unless asked for: the suite must pass on a machine with no network.
added configrepo/configrepo.go +231 -0
new file mode 100644
@@ -0,0 +1,231 @@
1+// Package configrepo copies a project's editor directory — .turbo-go,
2+// .turbo-rust — out of a repository somebody shares, from the URL a forge
3+// shows for the directory holding it.
4+//
5+// A team keeps ready-made configurations in a repository: a directory per
6+// kind of project, each with the editor's own directory inside. Rather than
7+// cloning it and copying by hand, an editor is told the URL:
8+//
9+// turbo-go -load-config https://rickub.com/turbo-editors/configs/tree/main/golang-init
10+//
11+// and it fetches golang-init/.turbo-go into the working directory. The URL is
12+// the one in the browser's address bar — rickub, GitHub, GitLab and Codeberg
13+// are all understood — and the fetch is a shallow git clone, because git is
14+// the one thing every forge speaks the same way, with no token and no API.
15+package configrepo
16+
17+import (
18+ "context"
19+ "errors"
20+ "fmt"
21+ "net/url"
22+ "os"
23+ "os/exec"
24+ "path/filepath"
25+ "strings"
26+
27+ "rickub.com/turbo-editors/turbo-core/profile"
28+)
29+
30+// ErrNoGit is returned when git is not installed: the fetch is a clone, and
31+// there is nothing else to do it with.
32+var ErrNoGit = errors.New("configrepo: git is not installed")
33+
34+// ErrExists is returned when the working directory already has the editor's
35+// directory. A project's configuration is a decision the project made, and
36+// loading another over it is not something to do without being asked twice.
37+var ErrExists = errors.New("configrepo: the project already has a configuration directory")
38+
39+// ErrNotFound is returned when the repository is there but the editor's
40+// directory is not where the URL says.
41+var ErrNotFound = errors.New("configrepo: no configuration directory at that path")
42+
43+// Source is a directory in a repository, as a forge's URL names it.
44+//
45+// src, err := configrepo.Parse("https://github.com/acme/configs/tree/main/go-service")
46+// // Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"}
47+type Source struct {
48+ Host string // the forge, as in the URL
49+ Owner string // the user or organisation — or a GitLab group, slashes and all
50+ Repo string // the repository, without .git
51+ Ref string // the first segment after the tree marker; "" for the default branch
52+ Path string // the directory inside the repository; "" for its root
53+}
54+
55+// Parse reads a forge's URL for a directory, a file, or a repository.
56+//
57+// The shapes understood are the ones the browsers show:
58+//
59+// https://rickub.com/owner/repo/tree/main/dir rickub, GitHub
60+// https://gitlab.com/group/sub/repo/-/tree/main/dir GitLab
61+// https://codeberg.org/owner/repo/src/branch/main/dir Codeberg, Forgejo, Gitea
62+// https://github.com/owner/repo a repository: its default branch, its root
63+//
64+// A branch name with a slash in it cannot be told from the path here — the
65+// URL does not say where one ends — so Ref is the first segment and the rest
66+// is Path; Load asks the repository for its branches and settles it there.
67+func Parse(rawURL string) (Source, error) {
68+ parsed, err := url.Parse(strings.TrimSpace(rawURL))
69+ if err != nil {
70+ return Source{}, fmt.Errorf("configrepo: %q is not a URL: %w", rawURL, err)
71+ }
72+ if (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" {
73+ return Source{}, fmt.Errorf("configrepo: %q is not an https URL of a repository", rawURL)
74+ }
75+
76+ segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
77+ if len(segments) < 2 || segments[0] == "" || segments[1] == "" {
78+ return Source{}, fmt.Errorf("configrepo: %q names no repository (want https://host/owner/repo/…)", rawURL)
79+ }
80+
81+ repoAt, rest := treeMarker(segments)
82+ src := Source{
83+ Host: parsed.Host,
84+ Owner: strings.Join(segments[:repoAt], "/"),
85+ Repo: strings.TrimSuffix(segments[repoAt], ".git"),
86+ }
87+ if len(rest) > 0 {
88+ src.Ref = rest[0]
89+ src.Path = strings.Join(rest[1:], "/")
90+ }
91+ return src, nil
92+}
93+
94+// treeMarker finds where the repository ends and the tree begins: the index
95+// of the repository segment, and the segments after the marker, starting with
96+// the ref. With no marker the repository is the last segment and there is no
97+// tree.
98+func treeMarker(segments []string) (repoAt int, rest []string) {
99+ for i := 2; i < len(segments); i++ {
100+ switch segments[i] {
101+ case "tree", "blob":
102+ return i - 1, segments[i+1:]
103+ case "-":
104+ if i+1 < len(segments) && (segments[i+1] == "tree" || segments[i+1] == "blob") {
105+ return i - 1, segments[i+2:]
106+ }
107+ case "src":
108+ // Gitea and its forks say what kind of ref follows: src/branch/main,
109+ // src/tag/v1, src/commit/abc123. The kind is not part of the name.
110+ if i+1 < len(segments) && (segments[i+1] == "branch" || segments[i+1] == "tag" || segments[i+1] == "commit") {
111+ return i - 1, segments[i+2:]
112+ }
113+ }
114+ }
115+ return len(segments) - 1, nil
116+}
117+
118+// CloneURLs returns where to clone the repository from, most likely first.
119+//
120+// GitHub, GitLab and Codeberg serve git at the address the browser shows.
121+// rickub does not: its pages are on rickub.com and its repositories on
122+// git.rickub.com, so a second candidate puts "git." in front of the host.
123+// Load tries them in order and stops at the first that answers.
124+func (s Source) CloneURLs() []string {
125+ path := "/" + s.Owner + "/" + s.Repo + ".git"
126+ return []string{
127+ "https://" + s.Host + path,
128+ "https://git." + s.Host + path,
129+ }
130+}
131+
132+// Result says what Load did.
133+type Result struct {
134+ Remote string // the clone URL that answered
135+ Ref string // the branch or tag the files came from; "" for the default
136+ Dir string // the directory created
137+ Files []string // what it holds, relative to Dir, in walk order
138+}
139+
140+// Load fetches the editor's directory from a repository into a project.
141+//
142+// The URL names a directory; the editor's own directory — p.ProjectDir(),
143+// ".turbo-go" for Turbo Go — is looked for inside it, or the URL may name
144+// that directory itself. It is copied into dest as dest/.turbo-go, files
145+// and subdirectories alike. dest is normally the working directory.
146+//
147+// It refuses with ErrExists when dest already has such a directory, with
148+// ErrNoGit when there is no git to clone with, and with ErrNotFound when the
149+// repository holds no such directory where the URL points.
150+//
151+// result, err := configrepo.Load(ctx, golang.Profile(), url, ".")
152+// if err != nil {
153+// return err
154+// }
155+// fmt.Printf("Copied %s (%d files)\n", result.Dir, len(result.Files))
156+func Load(ctx context.Context, p profile.Profile, rawURL, dest string) (Result, error) {
157+ src, err := Parse(rawURL)
158+ if err != nil {
159+ return Result{}, err
160+ }
161+ return loadFrom(ctx, p, src.CloneURLs(), src, dest)
162+}
163+
164+// loadFrom is Load once the URL has been read: it takes the remotes to try,
165+// so that a test can point it at a repository on disk.
166+func loadFrom(ctx context.Context, p profile.Profile, remotes []string, src Source, dest string) (Result, error) {
167+ target := filepath.Join(dest, p.ProjectDir())
168+ g, err := ready(target)
169+ if err != nil {
170+ return Result{}, err
171+ }
172+
173+ checkout, err := os.MkdirTemp("", "configrepo-*")
174+ if err != nil {
175+ return Result{}, fmt.Errorf("configrepo: %w", err)
176+ }
177+ defer os.RemoveAll(checkout)
178+
179+ remote, ref, path, err := g.fetch(ctx, remotes, src, checkout)
180+ if err != nil {
181+ return Result{}, err
182+ }
183+ files, err := install(checkout, path, p.ProjectDir(), remote, target)
184+ if err != nil {
185+ return Result{}, err
186+ }
187+ return Result{Remote: remote, Ref: ref, Dir: target, Files: files}, nil
188+}
189+
190+// ready checks what has to be true before anything is fetched — no
191+// configuration directory in the way, a git to fetch with — and returns that
192+// git. Both are asked first because both are answered without the network.
193+func ready(target string) (git, error) {
194+ if _, err := os.Stat(target); err == nil {
195+ return git{}, fmt.Errorf("%w: %s", ErrExists, target)
196+ }
197+ gitPath, err := exec.LookPath("git")
198+ if err != nil {
199+ return git{}, ErrNoGit
200+ }
201+ return git{path: gitPath}, nil
202+}
203+
204+// install copies the editor's directory out of a checkout into the project,
205+// and returns what was copied.
206+func install(checkout, path, projectDir, remote, target string) ([]string, error) {
207+ from, err := locate(checkout, path, projectDir, remote)
208+ if err != nil {
209+ return nil, err
210+ }
211+ return copyTree(from, target)
212+}
213+
214+// locate returns the editor's directory inside a checkout: at path/.turbo-go,
215+// or at path itself when the URL already named it.
216+func locate(checkout, path, projectDir, remote string) (string, error) {
217+ candidate := filepath.Join(checkout, filepath.FromSlash(path))
218+ if filepath.Base(candidate) != projectDir {
219+ candidate = filepath.Join(candidate, projectDir)
220+ }
221+
222+ info, err := os.Stat(candidate)
223+ if err != nil || !info.IsDir() {
224+ where := path
225+ if where == "" {
226+ where = "the root"
227+ }
228+ return "", fmt.Errorf("%w: %s at %s of %s", ErrNotFound, projectDir, where, remote)
229+ }
230+ return candidate, nil
231+}
new file mode 100644
@@ -0,0 +1,231 @@
1+// Package configrepo copies a project's editor directory — .turbo-go,
2+// .turbo-rust — out of a repository somebody shares, from the URL a forge
3+// shows for the directory holding it.
4+//
5+// A team keeps ready-made configurations in a repository: a directory per
6+// kind of project, each with the editor's own directory inside. Rather than
7+// cloning it and copying by hand, an editor is told the URL:
8+//
9+// turbo-go -load-config https://rickub.com/turbo-editors/configs/tree/main/golang-init
10+//
11+// and it fetches golang-init/.turbo-go into the working directory. The URL is
12+// the one in the browser's address bar — rickub, GitHub, GitLab and Codeberg
13+// are all understood — and the fetch is a shallow git clone, because git is
14+// the one thing every forge speaks the same way, with no token and no API.
15+package configrepo
16+
17+import (
18+ "context"
19+ "errors"
20+ "fmt"
21+ "net/url"
22+ "os"
23+ "os/exec"
24+ "path/filepath"
25+ "strings"
26+
27+ "rickub.com/turbo-editors/turbo-core/profile"
28+)
29+
30+// ErrNoGit is returned when git is not installed: the fetch is a clone, and
31+// there is nothing else to do it with.
32+var ErrNoGit = errors.New("configrepo: git is not installed")
33+
34+// ErrExists is returned when the working directory already has the editor's
35+// directory. A project's configuration is a decision the project made, and
36+// loading another over it is not something to do without being asked twice.
37+var ErrExists = errors.New("configrepo: the project already has a configuration directory")
38+
39+// ErrNotFound is returned when the repository is there but the editor's
40+// directory is not where the URL says.
41+var ErrNotFound = errors.New("configrepo: no configuration directory at that path")
42+
43+// Source is a directory in a repository, as a forge's URL names it.
44+//
45+// src, err := configrepo.Parse("https://github.com/acme/configs/tree/main/go-service")
46+// // Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"}
47+type Source struct {
48+ Host string // the forge, as in the URL
49+ Owner string // the user or organisation — or a GitLab group, slashes and all
50+ Repo string // the repository, without .git
51+ Ref string // the first segment after the tree marker; "" for the default branch
52+ Path string // the directory inside the repository; "" for its root
53+}
54+
55+// Parse reads a forge's URL for a directory, a file, or a repository.
56+//
57+// The shapes understood are the ones the browsers show:
58+//
59+// https://rickub.com/owner/repo/tree/main/dir rickub, GitHub
60+// https://gitlab.com/group/sub/repo/-/tree/main/dir GitLab
61+// https://codeberg.org/owner/repo/src/branch/main/dir Codeberg, Forgejo, Gitea
62+// https://github.com/owner/repo a repository: its default branch, its root
63+//
64+// A branch name with a slash in it cannot be told from the path here — the
65+// URL does not say where one ends — so Ref is the first segment and the rest
66+// is Path; Load asks the repository for its branches and settles it there.
67+func Parse(rawURL string) (Source, error) {
68+ parsed, err := url.Parse(strings.TrimSpace(rawURL))
69+ if err != nil {
70+ return Source{}, fmt.Errorf("configrepo: %q is not a URL: %w", rawURL, err)
71+ }
72+ if (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" {
73+ return Source{}, fmt.Errorf("configrepo: %q is not an https URL of a repository", rawURL)
74+ }
75+
76+ segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
77+ if len(segments) < 2 || segments[0] == "" || segments[1] == "" {
78+ return Source{}, fmt.Errorf("configrepo: %q names no repository (want https://host/owner/repo/…)", rawURL)
79+ }
80+
81+ repoAt, rest := treeMarker(segments)
82+ src := Source{
83+ Host: parsed.Host,
84+ Owner: strings.Join(segments[:repoAt], "/"),
85+ Repo: strings.TrimSuffix(segments[repoAt], ".git"),
86+ }
87+ if len(rest) > 0 {
88+ src.Ref = rest[0]
89+ src.Path = strings.Join(rest[1:], "/")
90+ }
91+ return src, nil
92+}
93+
94+// treeMarker finds where the repository ends and the tree begins: the index
95+// of the repository segment, and the segments after the marker, starting with
96+// the ref. With no marker the repository is the last segment and there is no
97+// tree.
98+func treeMarker(segments []string) (repoAt int, rest []string) {
99+ for i := 2; i < len(segments); i++ {
100+ switch segments[i] {
101+ case "tree", "blob":
102+ return i - 1, segments[i+1:]
103+ case "-":
104+ if i+1 < len(segments) && (segments[i+1] == "tree" || segments[i+1] == "blob") {
105+ return i - 1, segments[i+2:]
106+ }
107+ case "src":
108+ // Gitea and its forks say what kind of ref follows: src/branch/main,
109+ // src/tag/v1, src/commit/abc123. The kind is not part of the name.
110+ if i+1 < len(segments) && (segments[i+1] == "branch" || segments[i+1] == "tag" || segments[i+1] == "commit") {
111+ return i - 1, segments[i+2:]
112+ }
113+ }
114+ }
115+ return len(segments) - 1, nil
116+}
117+
118+// CloneURLs returns where to clone the repository from, most likely first.
119+//
120+// GitHub, GitLab and Codeberg serve git at the address the browser shows.
121+// rickub does not: its pages are on rickub.com and its repositories on
122+// git.rickub.com, so a second candidate puts "git." in front of the host.
123+// Load tries them in order and stops at the first that answers.
124+func (s Source) CloneURLs() []string {
125+ path := "/" + s.Owner + "/" + s.Repo + ".git"
126+ return []string{
127+ "https://" + s.Host + path,
128+ "https://git." + s.Host + path,
129+ }
130+}
131+
132+// Result says what Load did.
133+type Result struct {
134+ Remote string // the clone URL that answered
135+ Ref string // the branch or tag the files came from; "" for the default
136+ Dir string // the directory created
137+ Files []string // what it holds, relative to Dir, in walk order
138+}
139+
140+// Load fetches the editor's directory from a repository into a project.
141+//
142+// The URL names a directory; the editor's own directory — p.ProjectDir(),
143+// ".turbo-go" for Turbo Go — is looked for inside it, or the URL may name
144+// that directory itself. It is copied into dest as dest/.turbo-go, files
145+// and subdirectories alike. dest is normally the working directory.
146+//
147+// It refuses with ErrExists when dest already has such a directory, with
148+// ErrNoGit when there is no git to clone with, and with ErrNotFound when the
149+// repository holds no such directory where the URL points.
150+//
151+// result, err := configrepo.Load(ctx, golang.Profile(), url, ".")
152+// if err != nil {
153+// return err
154+// }
155+// fmt.Printf("Copied %s (%d files)\n", result.Dir, len(result.Files))
156+func Load(ctx context.Context, p profile.Profile, rawURL, dest string) (Result, error) {
157+ src, err := Parse(rawURL)
158+ if err != nil {
159+ return Result{}, err
160+ }
161+ return loadFrom(ctx, p, src.CloneURLs(), src, dest)
162+}
163+
164+// loadFrom is Load once the URL has been read: it takes the remotes to try,
165+// so that a test can point it at a repository on disk.
166+func loadFrom(ctx context.Context, p profile.Profile, remotes []string, src Source, dest string) (Result, error) {
167+ target := filepath.Join(dest, p.ProjectDir())
168+ g, err := ready(target)
169+ if err != nil {
170+ return Result{}, err
171+ }
172+
173+ checkout, err := os.MkdirTemp("", "configrepo-*")
174+ if err != nil {
175+ return Result{}, fmt.Errorf("configrepo: %w", err)
176+ }
177+ defer os.RemoveAll(checkout)
178+
179+ remote, ref, path, err := g.fetch(ctx, remotes, src, checkout)
180+ if err != nil {
181+ return Result{}, err
182+ }
183+ files, err := install(checkout, path, p.ProjectDir(), remote, target)
184+ if err != nil {
185+ return Result{}, err
186+ }
187+ return Result{Remote: remote, Ref: ref, Dir: target, Files: files}, nil
188+}
189+
190+// ready checks what has to be true before anything is fetched — no
191+// configuration directory in the way, a git to fetch with — and returns that
192+// git. Both are asked first because both are answered without the network.
193+func ready(target string) (git, error) {
194+ if _, err := os.Stat(target); err == nil {
195+ return git{}, fmt.Errorf("%w: %s", ErrExists, target)
196+ }
197+ gitPath, err := exec.LookPath("git")
198+ if err != nil {
199+ return git{}, ErrNoGit
200+ }
201+ return git{path: gitPath}, nil
202+}
203+
204+// install copies the editor's directory out of a checkout into the project,
205+// and returns what was copied.
206+func install(checkout, path, projectDir, remote, target string) ([]string, error) {
207+ from, err := locate(checkout, path, projectDir, remote)
208+ if err != nil {
209+ return nil, err
210+ }
211+ return copyTree(from, target)
212+}
213+
214+// locate returns the editor's directory inside a checkout: at path/.turbo-go,
215+// or at path itself when the URL already named it.
216+func locate(checkout, path, projectDir, remote string) (string, error) {
217+ candidate := filepath.Join(checkout, filepath.FromSlash(path))
218+ if filepath.Base(candidate) != projectDir {
219+ candidate = filepath.Join(candidate, projectDir)
220+ }
221+
222+ info, err := os.Stat(candidate)
223+ if err != nil || !info.IsDir() {
224+ where := path
225+ if where == "" {
226+ where = "the root"
227+ }
228+ return "", fmt.Errorf("%w: %s at %s of %s", ErrNotFound, projectDir, where, remote)
229+ }
230+ return candidate, nil
231+}
added configrepo/configrepo_test.go +314 -0
new file mode 100644
@@ -0,0 +1,314 @@
1+package configrepo
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "reflect"
10+ "strings"
11+ "testing"
12+
13+ "rickub.com/turbo-editors/turbo-core/profile"
14+)
15+
16+func TestParseReadsTheURLsTheForgesShow(t *testing.T) {
17+ cases := []struct {
18+ url string
19+ want Source
20+ }{
21+ {"https://rickub.com/turbo-editors/configs/tree/main/golang-init",
22+ Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs", Ref: "main", Path: "golang-init"}},
23+ {"https://github.com/acme/configs/tree/main/go/service",
24+ Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go/service"}},
25+ {"https://gitlab.com/acme/platform/configs/-/tree/main/go-service",
26+ Source{Host: "gitlab.com", Owner: "acme/platform", Repo: "configs", Ref: "main", Path: "go-service"}},
27+ {"https://codeberg.org/acme/configs/src/branch/main/go-service",
28+ Source{Host: "codeberg.org", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"}},
29+ {"https://codeberg.org/acme/configs/src/tag/v1.0.0/go-service",
30+ Source{Host: "codeberg.org", Owner: "acme", Repo: "configs", Ref: "v1.0.0", Path: "go-service"}},
31+ {"https://github.com/acme/configs",
32+ Source{Host: "github.com", Owner: "acme", Repo: "configs"}},
33+ {"https://github.com/acme/configs.git",
34+ Source{Host: "github.com", Owner: "acme", Repo: "configs"}},
35+ {"https://github.com/acme/configs/tree/main",
36+ Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main"}},
37+ {"https://github.com/acme/configs/tree/main/go-service/",
38+ Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"}},
39+ {"https://github.com/acme/configs/blob/main/go-service/.turbo-go/settings.toml",
40+ Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service/.turbo-go/settings.toml"}},
41+ {" https://rickub.com/turbo-editors/configs/tree/main/golang-init-with-agents\n",
42+ Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs", Ref: "main", Path: "golang-init-with-agents"}},
43+ }
44+ for _, c := range cases {
45+ got, err := Parse(c.url)
46+ if err != nil {
47+ t.Errorf("Parse(%q) error = %v", c.url, err)
48+ continue
49+ }
50+ if got != c.want {
51+ t.Errorf("Parse(%q) = %+v, want %+v", c.url, got, c.want)
52+ }
53+ }
54+}
55+
56+func TestParseRefusesWhatIsNotARepository(t *testing.T) {
57+ for _, bad := range []string{"", "configs", "https://rickub.com", "https://rickub.com/turbo-editors", "ftp://x/y/z", "not a url at all ://"} {
58+ if _, err := Parse(bad); err == nil {
59+ t.Errorf("Parse(%q) accepted something that names no repository", bad)
60+ }
61+ }
62+}
63+
64+func TestCloneURLsTryTheHostThenItsGitSubdomain(t *testing.T) {
65+ // rickub's pages are on rickub.com and its repositories on git.rickub.com.
66+ src := Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs"}
67+
68+ want := []string{"https://rickub.com/turbo-editors/configs.git", "https://git.rickub.com/turbo-editors/configs.git"}
69+ if got := src.CloneURLs(); !reflect.DeepEqual(got, want) {
70+ t.Errorf("CloneURLs() = %v, want %v", got, want)
71+ }
72+}
73+
74+func TestResolveRefSettlesABranchWithASlashInIt(t *testing.T) {
75+ refs := []string{"main", "feature/x", "feature/x/deeper", "v1.0.0"}
76+ cases := []struct {
77+ ref, path, wantRef, wantPath string
78+ }{
79+ {"main", "golang-init", "main", "golang-init"},
80+ {"feature", "x/golang-init", "feature/x", "golang-init"},
81+ {"feature", "x/deeper/golang-init", "feature/x/deeper", "golang-init"},
82+ {"feature", "x", "feature/x", ""},
83+ {"v1.0.0", "", "v1.0.0", ""},
84+ {"nowhere", "golang-init", "nowhere", "golang-init"}, // left alone; the clone will say
85+ {"", "", "", ""}, // the default branch
86+ }
87+ for _, c := range cases {
88+ ref, path := resolveRef(refs, c.ref, c.path)
89+ if ref != c.wantRef || path != c.wantPath {
90+ t.Errorf("resolveRef(%q, %q) = %q, %q; want %q, %q", c.ref, c.path, ref, path, c.wantRef, c.wantPath)
91+ }
92+ }
93+}
94+
95+func TestParseRefsReadsBranchesAndTagsAndDropsPeeledTags(t *testing.T) {
96+ output := "abc\tHEAD\nabc\trefs/heads/main\ndef\trefs/heads/feature/x\n123\trefs/tags/v1.0.0\n456\trefs/tags/v1.0.0^{}\n"
97+
98+ want := []string{"main", "feature/x", "v1.0.0"}
99+ if got := parseRefs(output); !reflect.DeepEqual(got, want) {
100+ t.Errorf("parseRefs() = %v, want %v", got, want)
101+ }
102+}
103+
104+// testProfile is an editor whose project directory is .turbo-test.
105+func testProfile() profile.Profile {
106+ return profile.Profile{Name: "Turbo Test", Slug: "turbo-test", Language: "Test"}
107+}
108+
109+// sampleRepo makes a repository on disk shaped like turbo-editors/configs:
110+// golang-init/.turbo-test with three files and a nested one, on a branch
111+// called main, and the same on a branch called feature/x with a marker file.
112+func sampleRepo(t *testing.T) string {
113+ t.Helper()
114+ if _, err := exec.LookPath("git"); err != nil {
115+ t.Skip("git is not installed")
116+ }
117+
118+ repo := t.TempDir()
119+ run := func(args ...string) {
120+ t.Helper()
121+ cmd := exec.Command("git", append([]string{"-C", repo, "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false"}, args...)...)
122+ if out, err := cmd.CombinedOutput(); err != nil {
123+ t.Fatalf("git %v: %v\n%s", args, err, out)
124+ }
125+ }
126+ write := func(name, contents string) {
127+ t.Helper()
128+ path := filepath.Join(repo, filepath.FromSlash(name))
129+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
133+ t.Fatal(err)
134+ }
135+ }
136+
137+ run("init", "--quiet", "--initial-branch=main")
138+ write("README.md", "# configs\n")
139+ write("golang-init/README.md", "# golang-init\n")
140+ write("golang-init/.turbo-test/settings.toml", "theme = \"turbo-dark\"\n")
141+ write("golang-init/.turbo-test/snippets.toml", "[[snippet]]\n")
142+ write("golang-init/.turbo-test/tools.toml", "[[tool]]\n")
143+ write("golang-init/.turbo-test/agents/bob.yaml", "name: bob\n")
144+ write("empty-init/README.md", "nothing for the editor here\n")
145+ run("add", "-A")
146+ run("commit", "--quiet", "-m", "sample")
147+ run("checkout", "--quiet", "-b", "feature/x")
148+ write("golang-init/.turbo-test/feature.txt", "from the branch\n")
149+ run("add", "-A")
150+ run("commit", "--quiet", "-m", "branch")
151+ run("checkout", "--quiet", "main")
152+ return repo
153+}
154+
155+func TestLoadCopiesTheEditorsDirectoryIntoTheProject(t *testing.T) {
156+ repo := sampleRepo(t)
157+ dest := t.TempDir()
158+
159+ result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init"}, dest)
160+ if err != nil {
161+ t.Fatalf("loadFrom() error = %v", err)
162+ }
163+
164+ if result.Dir != filepath.Join(dest, ".turbo-test") || result.Remote != repo || result.Ref != "main" {
165+ t.Errorf("Result = %+v", result)
166+ }
167+ want := []string{"agents/bob.yaml", "settings.toml", "snippets.toml", "tools.toml"}
168+ if !reflect.DeepEqual(result.Files, want) {
169+ t.Errorf("Files = %v, want %v", result.Files, want)
170+ }
171+ data, err := os.ReadFile(filepath.Join(dest, ".turbo-test", "settings.toml"))
172+ if err != nil || string(data) != "theme = \"turbo-dark\"\n" {
173+ t.Errorf("settings.toml = %q, %v", data, err)
174+ }
175+ if _, err := os.Stat(filepath.Join(dest, "README.md")); err == nil {
176+ t.Error("the directory's README came along; only the editor's directory should")
177+ }
178+}
179+
180+func TestLoadTakesAURLThatAlreadyNamesTheEditorsDirectory(t *testing.T) {
181+ repo := sampleRepo(t)
182+ dest := t.TempDir()
183+
184+ result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init/.turbo-test"}, dest)
185+ if err != nil {
186+ t.Fatalf("loadFrom() error = %v", err)
187+ }
188+ if len(result.Files) != 4 {
189+ t.Errorf("Files = %v, want the four files", result.Files)
190+ }
191+}
192+
193+func TestLoadResolvesABranchWithASlashAgainstTheRepository(t *testing.T) {
194+ // The URL .../tree/feature/x/golang-init reads as ref "feature", path
195+ // "x/golang-init"; only the repository knows the branch is feature/x.
196+ repo := sampleRepo(t)
197+ dest := t.TempDir()
198+
199+ result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "feature", Path: "x/golang-init"}, dest)
200+ if err != nil {
201+ t.Fatalf("loadFrom() error = %v", err)
202+ }
203+ if result.Ref != "feature/x" {
204+ t.Errorf("Ref = %q, want feature/x", result.Ref)
205+ }
206+ if _, err := os.Stat(filepath.Join(dest, ".turbo-test", "feature.txt")); err != nil {
207+ t.Error("the file only the branch has did not come: the wrong ref was cloned")
208+ }
209+}
210+
211+func TestLoadUsesTheDefaultBranchWhenTheURLNamesNone(t *testing.T) {
212+ repo := sampleRepo(t)
213+ dest := t.TempDir()
214+
215+ result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Path: "golang-init"}, dest)
216+ if err != nil {
217+ t.Fatalf("loadFrom() error = %v", err)
218+ }
219+ if result.Ref != "" {
220+ t.Errorf("Ref = %q, want the default branch (empty)", result.Ref)
221+ }
222+ if _, err := os.Stat(filepath.Join(dest, ".turbo-test", "feature.txt")); err == nil {
223+ t.Error("a file from the feature branch came from the default branch")
224+ }
225+}
226+
227+func TestLoadRefusesToOverwriteAConfigurationTheProjectHas(t *testing.T) {
228+ repo := sampleRepo(t)
229+ dest := t.TempDir()
230+ if err := os.MkdirAll(filepath.Join(dest, ".turbo-test"), 0o755); err != nil {
231+ t.Fatal(err)
232+ }
233+ kept := filepath.Join(dest, ".turbo-test", "settings.toml")
234+ if err := os.WriteFile(kept, []byte("mine\n"), 0o644); err != nil {
235+ t.Fatal(err)
236+ }
237+
238+ _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init"}, dest)
239+
240+ if !errors.Is(err, ErrExists) {
241+ t.Fatalf("error = %v, want ErrExists", err)
242+ }
243+ if data, _ := os.ReadFile(kept); string(data) != "mine\n" {
244+ t.Errorf("the project's own settings were overwritten: %q", data)
245+ }
246+}
247+
248+func TestLoadSaysWhenTheDirectoryHasNoConfiguration(t *testing.T) {
249+ repo := sampleRepo(t)
250+
251+ _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "empty-init"}, t.TempDir())
252+
253+ if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), ".turbo-test at empty-init") {
254+ t.Errorf("error = %v, want ErrNotFound naming the directory and the path", err)
255+ }
256+}
257+
258+func TestLoadReportsABranchThatDoesNotExistInGitsWords(t *testing.T) {
259+ repo := sampleRepo(t)
260+
261+ _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "nowhere", Path: "golang-init"}, t.TempDir())
262+
263+ if err == nil || !strings.Contains(err.Error(), "nowhere") {
264+ t.Errorf("error = %v, want git's complaint about the branch", err)
265+ }
266+}
267+
268+func TestLoadFallsThroughToTheRemoteThatAnswers(t *testing.T) {
269+ // rickub.com does not serve git; git.rickub.com does. The first candidate
270+ // failing must cost a round trip, not the load.
271+ repo := sampleRepo(t)
272+ dest := t.TempDir()
273+
274+ result, err := loadFrom(context.Background(), testProfile(), []string{filepath.Join(t.TempDir(), "nowhere.git"), repo}, Source{Ref: "main", Path: "golang-init"}, dest)
275+ if err != nil {
276+ t.Fatalf("loadFrom() error = %v", err)
277+ }
278+ if result.Remote != repo {
279+ t.Errorf("Remote = %q, want the one that answered", result.Remote)
280+ }
281+}
282+
283+func TestLoadNamesEveryRemoteWhenNoneAnswers(t *testing.T) {
284+ sampleRepo(t) // for the git skip
285+
286+ _, err := loadFrom(context.Background(), testProfile(), []string{filepath.Join(t.TempDir(), "a.git"), filepath.Join(t.TempDir(), "b.git")}, Source{}, t.TempDir())
287+
288+ if err == nil || !strings.Contains(err.Error(), "a.git") || !strings.Contains(err.Error(), "b.git") {
289+ t.Errorf("error = %v, want both remotes named", err)
290+ }
291+}
292+
293+func TestLoadFromRickubOverTheNetwork(t *testing.T) {
294+ // The real thing, against the repository the feature was asked for. Off by
295+ // default: the suite must pass on a machine with no network.
296+ if os.Getenv("TURBO_CORE_NETWORK") == "" {
297+ t.Skip("TURBO_CORE_NETWORK is not set")
298+ }
299+ dest := t.TempDir()
300+ p := profile.Profile{Name: "Turbo Go", Slug: "turbo-go", Language: "Go"}
301+
302+ result, err := Load(context.Background(), p, "https://rickub.com/turbo-editors/configs/tree/main/golang-init", dest)
303+ if err != nil {
304+ t.Fatalf("Load() error = %v", err)
305+ }
306+ if result.Remote != "https://git.rickub.com/turbo-editors/configs.git" {
307+ t.Errorf("Remote = %q", result.Remote)
308+ }
309+ for _, name := range []string{"settings.toml", "snippets.toml", "tools.toml"} {
310+ if _, err := os.Stat(filepath.Join(dest, ".turbo-go", name)); err != nil {
311+ t.Errorf("%s did not arrive: %v", name, err)
312+ }
313+ }
314+}
new file mode 100644
@@ -0,0 +1,314 @@
1+package configrepo
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "reflect"
10+ "strings"
11+ "testing"
12+
13+ "rickub.com/turbo-editors/turbo-core/profile"
14+)
15+
16+func TestParseReadsTheURLsTheForgesShow(t *testing.T) {
17+ cases := []struct {
18+ url string
19+ want Source
20+ }{
21+ {"https://rickub.com/turbo-editors/configs/tree/main/golang-init",
22+ Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs", Ref: "main", Path: "golang-init"}},
23+ {"https://github.com/acme/configs/tree/main/go/service",
24+ Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go/service"}},
25+ {"https://gitlab.com/acme/platform/configs/-/tree/main/go-service",
26+ Source{Host: "gitlab.com", Owner: "acme/platform", Repo: "configs", Ref: "main", Path: "go-service"}},
27+ {"https://codeberg.org/acme/configs/src/branch/main/go-service",
28+ Source{Host: "codeberg.org", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"}},
29+ {"https://codeberg.org/acme/configs/src/tag/v1.0.0/go-service",
30+ Source{Host: "codeberg.org", Owner: "acme", Repo: "configs", Ref: "v1.0.0", Path: "go-service"}},
31+ {"https://github.com/acme/configs",
32+ Source{Host: "github.com", Owner: "acme", Repo: "configs"}},
33+ {"https://github.com/acme/configs.git",
34+ Source{Host: "github.com", Owner: "acme", Repo: "configs"}},
35+ {"https://github.com/acme/configs/tree/main",
36+ Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main"}},
37+ {"https://github.com/acme/configs/tree/main/go-service/",
38+ Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"}},
39+ {"https://github.com/acme/configs/blob/main/go-service/.turbo-go/settings.toml",
40+ Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service/.turbo-go/settings.toml"}},
41+ {" https://rickub.com/turbo-editors/configs/tree/main/golang-init-with-agents\n",
42+ Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs", Ref: "main", Path: "golang-init-with-agents"}},
43+ }
44+ for _, c := range cases {
45+ got, err := Parse(c.url)
46+ if err != nil {
47+ t.Errorf("Parse(%q) error = %v", c.url, err)
48+ continue
49+ }
50+ if got != c.want {
51+ t.Errorf("Parse(%q) = %+v, want %+v", c.url, got, c.want)
52+ }
53+ }
54+}
55+
56+func TestParseRefusesWhatIsNotARepository(t *testing.T) {
57+ for _, bad := range []string{"", "configs", "https://rickub.com", "https://rickub.com/turbo-editors", "ftp://x/y/z", "not a url at all ://"} {
58+ if _, err := Parse(bad); err == nil {
59+ t.Errorf("Parse(%q) accepted something that names no repository", bad)
60+ }
61+ }
62+}
63+
64+func TestCloneURLsTryTheHostThenItsGitSubdomain(t *testing.T) {
65+ // rickub's pages are on rickub.com and its repositories on git.rickub.com.
66+ src := Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs"}
67+
68+ want := []string{"https://rickub.com/turbo-editors/configs.git", "https://git.rickub.com/turbo-editors/configs.git"}
69+ if got := src.CloneURLs(); !reflect.DeepEqual(got, want) {
70+ t.Errorf("CloneURLs() = %v, want %v", got, want)
71+ }
72+}
73+
74+func TestResolveRefSettlesABranchWithASlashInIt(t *testing.T) {
75+ refs := []string{"main", "feature/x", "feature/x/deeper", "v1.0.0"}
76+ cases := []struct {
77+ ref, path, wantRef, wantPath string
78+ }{
79+ {"main", "golang-init", "main", "golang-init"},
80+ {"feature", "x/golang-init", "feature/x", "golang-init"},
81+ {"feature", "x/deeper/golang-init", "feature/x/deeper", "golang-init"},
82+ {"feature", "x", "feature/x", ""},
83+ {"v1.0.0", "", "v1.0.0", ""},
84+ {"nowhere", "golang-init", "nowhere", "golang-init"}, // left alone; the clone will say
85+ {"", "", "", ""}, // the default branch
86+ }
87+ for _, c := range cases {
88+ ref, path := resolveRef(refs, c.ref, c.path)
89+ if ref != c.wantRef || path != c.wantPath {
90+ t.Errorf("resolveRef(%q, %q) = %q, %q; want %q, %q", c.ref, c.path, ref, path, c.wantRef, c.wantPath)
91+ }
92+ }
93+}
94+
95+func TestParseRefsReadsBranchesAndTagsAndDropsPeeledTags(t *testing.T) {
96+ output := "abc\tHEAD\nabc\trefs/heads/main\ndef\trefs/heads/feature/x\n123\trefs/tags/v1.0.0\n456\trefs/tags/v1.0.0^{}\n"
97+
98+ want := []string{"main", "feature/x", "v1.0.0"}
99+ if got := parseRefs(output); !reflect.DeepEqual(got, want) {
100+ t.Errorf("parseRefs() = %v, want %v", got, want)
101+ }
102+}
103+
104+// testProfile is an editor whose project directory is .turbo-test.
105+func testProfile() profile.Profile {
106+ return profile.Profile{Name: "Turbo Test", Slug: "turbo-test", Language: "Test"}
107+}
108+
109+// sampleRepo makes a repository on disk shaped like turbo-editors/configs:
110+// golang-init/.turbo-test with three files and a nested one, on a branch
111+// called main, and the same on a branch called feature/x with a marker file.
112+func sampleRepo(t *testing.T) string {
113+ t.Helper()
114+ if _, err := exec.LookPath("git"); err != nil {
115+ t.Skip("git is not installed")
116+ }
117+
118+ repo := t.TempDir()
119+ run := func(args ...string) {
120+ t.Helper()
121+ cmd := exec.Command("git", append([]string{"-C", repo, "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false"}, args...)...)
122+ if out, err := cmd.CombinedOutput(); err != nil {
123+ t.Fatalf("git %v: %v\n%s", args, err, out)
124+ }
125+ }
126+ write := func(name, contents string) {
127+ t.Helper()
128+ path := filepath.Join(repo, filepath.FromSlash(name))
129+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
133+ t.Fatal(err)
134+ }
135+ }
136+
137+ run("init", "--quiet", "--initial-branch=main")
138+ write("README.md", "# configs\n")
139+ write("golang-init/README.md", "# golang-init\n")
140+ write("golang-init/.turbo-test/settings.toml", "theme = \"turbo-dark\"\n")
141+ write("golang-init/.turbo-test/snippets.toml", "[[snippet]]\n")
142+ write("golang-init/.turbo-test/tools.toml", "[[tool]]\n")
143+ write("golang-init/.turbo-test/agents/bob.yaml", "name: bob\n")
144+ write("empty-init/README.md", "nothing for the editor here\n")
145+ run("add", "-A")
146+ run("commit", "--quiet", "-m", "sample")
147+ run("checkout", "--quiet", "-b", "feature/x")
148+ write("golang-init/.turbo-test/feature.txt", "from the branch\n")
149+ run("add", "-A")
150+ run("commit", "--quiet", "-m", "branch")
151+ run("checkout", "--quiet", "main")
152+ return repo
153+}
154+
155+func TestLoadCopiesTheEditorsDirectoryIntoTheProject(t *testing.T) {
156+ repo := sampleRepo(t)
157+ dest := t.TempDir()
158+
159+ result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init"}, dest)
160+ if err != nil {
161+ t.Fatalf("loadFrom() error = %v", err)
162+ }
163+
164+ if result.Dir != filepath.Join(dest, ".turbo-test") || result.Remote != repo || result.Ref != "main" {
165+ t.Errorf("Result = %+v", result)
166+ }
167+ want := []string{"agents/bob.yaml", "settings.toml", "snippets.toml", "tools.toml"}
168+ if !reflect.DeepEqual(result.Files, want) {
169+ t.Errorf("Files = %v, want %v", result.Files, want)
170+ }
171+ data, err := os.ReadFile(filepath.Join(dest, ".turbo-test", "settings.toml"))
172+ if err != nil || string(data) != "theme = \"turbo-dark\"\n" {
173+ t.Errorf("settings.toml = %q, %v", data, err)
174+ }
175+ if _, err := os.Stat(filepath.Join(dest, "README.md")); err == nil {
176+ t.Error("the directory's README came along; only the editor's directory should")
177+ }
178+}
179+
180+func TestLoadTakesAURLThatAlreadyNamesTheEditorsDirectory(t *testing.T) {
181+ repo := sampleRepo(t)
182+ dest := t.TempDir()
183+
184+ result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init/.turbo-test"}, dest)
185+ if err != nil {
186+ t.Fatalf("loadFrom() error = %v", err)
187+ }
188+ if len(result.Files) != 4 {
189+ t.Errorf("Files = %v, want the four files", result.Files)
190+ }
191+}
192+
193+func TestLoadResolvesABranchWithASlashAgainstTheRepository(t *testing.T) {
194+ // The URL .../tree/feature/x/golang-init reads as ref "feature", path
195+ // "x/golang-init"; only the repository knows the branch is feature/x.
196+ repo := sampleRepo(t)
197+ dest := t.TempDir()
198+
199+ result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "feature", Path: "x/golang-init"}, dest)
200+ if err != nil {
201+ t.Fatalf("loadFrom() error = %v", err)
202+ }
203+ if result.Ref != "feature/x" {
204+ t.Errorf("Ref = %q, want feature/x", result.Ref)
205+ }
206+ if _, err := os.Stat(filepath.Join(dest, ".turbo-test", "feature.txt")); err != nil {
207+ t.Error("the file only the branch has did not come: the wrong ref was cloned")
208+ }
209+}
210+
211+func TestLoadUsesTheDefaultBranchWhenTheURLNamesNone(t *testing.T) {
212+ repo := sampleRepo(t)
213+ dest := t.TempDir()
214+
215+ result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Path: "golang-init"}, dest)
216+ if err != nil {
217+ t.Fatalf("loadFrom() error = %v", err)
218+ }
219+ if result.Ref != "" {
220+ t.Errorf("Ref = %q, want the default branch (empty)", result.Ref)
221+ }
222+ if _, err := os.Stat(filepath.Join(dest, ".turbo-test", "feature.txt")); err == nil {
223+ t.Error("a file from the feature branch came from the default branch")
224+ }
225+}
226+
227+func TestLoadRefusesToOverwriteAConfigurationTheProjectHas(t *testing.T) {
228+ repo := sampleRepo(t)
229+ dest := t.TempDir()
230+ if err := os.MkdirAll(filepath.Join(dest, ".turbo-test"), 0o755); err != nil {
231+ t.Fatal(err)
232+ }
233+ kept := filepath.Join(dest, ".turbo-test", "settings.toml")
234+ if err := os.WriteFile(kept, []byte("mine\n"), 0o644); err != nil {
235+ t.Fatal(err)
236+ }
237+
238+ _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init"}, dest)
239+
240+ if !errors.Is(err, ErrExists) {
241+ t.Fatalf("error = %v, want ErrExists", err)
242+ }
243+ if data, _ := os.ReadFile(kept); string(data) != "mine\n" {
244+ t.Errorf("the project's own settings were overwritten: %q", data)
245+ }
246+}
247+
248+func TestLoadSaysWhenTheDirectoryHasNoConfiguration(t *testing.T) {
249+ repo := sampleRepo(t)
250+
251+ _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "empty-init"}, t.TempDir())
252+
253+ if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), ".turbo-test at empty-init") {
254+ t.Errorf("error = %v, want ErrNotFound naming the directory and the path", err)
255+ }
256+}
257+
258+func TestLoadReportsABranchThatDoesNotExistInGitsWords(t *testing.T) {
259+ repo := sampleRepo(t)
260+
261+ _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "nowhere", Path: "golang-init"}, t.TempDir())
262+
263+ if err == nil || !strings.Contains(err.Error(), "nowhere") {
264+ t.Errorf("error = %v, want git's complaint about the branch", err)
265+ }
266+}
267+
268+func TestLoadFallsThroughToTheRemoteThatAnswers(t *testing.T) {
269+ // rickub.com does not serve git; git.rickub.com does. The first candidate
270+ // failing must cost a round trip, not the load.
271+ repo := sampleRepo(t)
272+ dest := t.TempDir()
273+
274+ result, err := loadFrom(context.Background(), testProfile(), []string{filepath.Join(t.TempDir(), "nowhere.git"), repo}, Source{Ref: "main", Path: "golang-init"}, dest)
275+ if err != nil {
276+ t.Fatalf("loadFrom() error = %v", err)
277+ }
278+ if result.Remote != repo {
279+ t.Errorf("Remote = %q, want the one that answered", result.Remote)
280+ }
281+}
282+
283+func TestLoadNamesEveryRemoteWhenNoneAnswers(t *testing.T) {
284+ sampleRepo(t) // for the git skip
285+
286+ _, err := loadFrom(context.Background(), testProfile(), []string{filepath.Join(t.TempDir(), "a.git"), filepath.Join(t.TempDir(), "b.git")}, Source{}, t.TempDir())
287+
288+ if err == nil || !strings.Contains(err.Error(), "a.git") || !strings.Contains(err.Error(), "b.git") {
289+ t.Errorf("error = %v, want both remotes named", err)
290+ }
291+}
292+
293+func TestLoadFromRickubOverTheNetwork(t *testing.T) {
294+ // The real thing, against the repository the feature was asked for. Off by
295+ // default: the suite must pass on a machine with no network.
296+ if os.Getenv("TURBO_CORE_NETWORK") == "" {
297+ t.Skip("TURBO_CORE_NETWORK is not set")
298+ }
299+ dest := t.TempDir()
300+ p := profile.Profile{Name: "Turbo Go", Slug: "turbo-go", Language: "Go"}
301+
302+ result, err := Load(context.Background(), p, "https://rickub.com/turbo-editors/configs/tree/main/golang-init", dest)
303+ if err != nil {
304+ t.Fatalf("Load() error = %v", err)
305+ }
306+ if result.Remote != "https://git.rickub.com/turbo-editors/configs.git" {
307+ t.Errorf("Remote = %q", result.Remote)
308+ }
309+ for _, name := range []string{"settings.toml", "snippets.toml", "tools.toml"} {
310+ if _, err := os.Stat(filepath.Join(dest, ".turbo-go", name)); err != nil {
311+ t.Errorf("%s did not arrive: %v", name, err)
312+ }
313+ }
314+}
added configrepo/copy.go +54 -0
new file mode 100644
@@ -0,0 +1,54 @@
1+// Copying the fetched directory into the project.
2+
3+package configrepo
4+
5+import (
6+ "fmt"
7+ "io/fs"
8+ "os"
9+ "path/filepath"
10+
11+ "rickub.com/turbo-editors/turbo-core/projectfile"
12+)
13+
14+// copyTree copies every regular file under from into to, keeping the
15+// layout, and returns their names relative to to.
16+//
17+// Each file goes through projectfile.Write, so it is created the way the
18+// editor creates a project file itself — same mode, same atomic rename, the
19+// directory made on the way. Anything that is not a regular file — a
20+// symbolic link, a socket — is left behind: a configuration is files.
21+func copyTree(from, to string) ([]string, error) {
22+ var files []string
23+ err := filepath.WalkDir(from, func(path string, entry fs.DirEntry, err error) error {
24+ if err != nil || !entry.Type().IsRegular() {
25+ return err
26+ }
27+ relative, err := copyFile(from, to, path)
28+ if err == nil {
29+ files = append(files, relative)
30+ }
31+ return err
32+ })
33+ if err != nil {
34+ return nil, fmt.Errorf("configrepo: copying into %s: %w", to, err)
35+ }
36+ return files, nil
37+}
38+
39+// copyFile copies one file from under from to the same place under to, and
40+// returns its name relative to both.
41+func copyFile(from, to, path string) (string, error) {
42+ relative, err := filepath.Rel(from, path)
43+ if err != nil {
44+ return "", err
45+ }
46+ data, err := os.ReadFile(path)
47+ if err != nil {
48+ return "", err
49+ }
50+ if err := projectfile.Write(filepath.Join(to, relative), data); err != nil {
51+ return "", err
52+ }
53+ return filepath.ToSlash(relative), nil
54+}
new file mode 100644
@@ -0,0 +1,54 @@
1+// Copying the fetched directory into the project.
2+
3+package configrepo
4+
5+import (
6+ "fmt"
7+ "io/fs"
8+ "os"
9+ "path/filepath"
10+
11+ "rickub.com/turbo-editors/turbo-core/projectfile"
12+)
13+
14+// copyTree copies every regular file under from into to, keeping the
15+// layout, and returns their names relative to to.
16+//
17+// Each file goes through projectfile.Write, so it is created the way the
18+// editor creates a project file itself — same mode, same atomic rename, the
19+// directory made on the way. Anything that is not a regular file — a
20+// symbolic link, a socket — is left behind: a configuration is files.
21+func copyTree(from, to string) ([]string, error) {
22+ var files []string
23+ err := filepath.WalkDir(from, func(path string, entry fs.DirEntry, err error) error {
24+ if err != nil || !entry.Type().IsRegular() {
25+ return err
26+ }
27+ relative, err := copyFile(from, to, path)
28+ if err == nil {
29+ files = append(files, relative)
30+ }
31+ return err
32+ })
33+ if err != nil {
34+ return nil, fmt.Errorf("configrepo: copying into %s: %w", to, err)
35+ }
36+ return files, nil
37+}
38+
39+// copyFile copies one file from under from to the same place under to, and
40+// returns its name relative to both.
41+func copyFile(from, to, path string) (string, error) {
42+ relative, err := filepath.Rel(from, path)
43+ if err != nil {
44+ return "", err
45+ }
46+ data, err := os.ReadFile(path)
47+ if err != nil {
48+ return "", err
49+ }
50+ if err := projectfile.Write(filepath.Join(to, relative), data); err != nil {
51+ return "", err
52+ }
53+ return filepath.ToSlash(relative), nil
54+}
added configrepo/git.go +139 -0
new file mode 100644
@@ -0,0 +1,139 @@
1+// Talking to git: which remote answers, what it calls its branches, and a
2+// shallow clone of one of them.
3+
4+package configrepo
5+
6+import (
7+ "context"
8+ "errors"
9+ "fmt"
10+ "os/exec"
11+ "strings"
12+)
13+
14+// git runs the git that was found on PATH.
15+type git struct {
16+ path string
17+}
18+
19+// fetch finds the repository among the remotes, settles which ref the URL
20+// meant, and clones that ref into dir. It returns the remote that answered,
21+// the ref, and the path inside the repository once the ref has taken its
22+// share of it.
23+func (g git) fetch(ctx context.Context, remotes []string, src Source, dir string) (remote, ref, path string, err error) {
24+ remote, refs, err := g.firstAnswering(ctx, remotes)
25+ if err != nil {
26+ return "", "", "", err
27+ }
28+ ref, path = resolveRef(refs, src.Ref, src.Path)
29+ if err := g.clone(ctx, remote, ref, dir); err != nil {
30+ return "", "", "", err
31+ }
32+ return remote, ref, path, nil
33+}
34+
35+// firstAnswering asks each remote for its refs and returns the first that
36+// answers, with what it said.
37+//
38+// The listing does two jobs: it tells the candidates apart — a forge whose
39+// pages and repositories live on different hosts fails fast here, not after
40+// a clone — and it is what a ref with a slash in it is resolved against.
41+func (g git) firstAnswering(ctx context.Context, remotes []string) (string, []string, error) {
42+ var failures []error
43+ for _, remote := range remotes {
44+ refs, err := g.lsRemote(ctx, remote)
45+ if err == nil {
46+ return remote, refs, nil
47+ }
48+ failures = append(failures, err)
49+ }
50+ return "", nil, fmt.Errorf("configrepo: no repository answered: %w", errors.Join(failures...))
51+}
52+
53+// lsRemote returns the names of a remote's branches and tags.
54+func (g git) lsRemote(ctx context.Context, remote string) ([]string, error) {
55+ out, err := g.run(ctx, "ls-remote", "--heads", "--tags", "--", remote)
56+ if err != nil {
57+ return nil, err
58+ }
59+ return parseRefs(out), nil
60+}
61+
62+// parseRefs reads ls-remote's output — a hash, a tab, a ref — into names.
63+// Peeled tags ("v1^{}") name the same tag twice and are dropped.
64+func parseRefs(output string) []string {
65+ var names []string
66+ for _, line := range strings.Split(output, "\n") {
67+ _, ref, ok := strings.Cut(line, "\t")
68+ if !ok || strings.HasSuffix(ref, "^{}") {
69+ continue
70+ }
71+ for _, prefix := range []string{"refs/heads/", "refs/tags/"} {
72+ if name, found := strings.CutPrefix(ref, prefix); found {
73+ names = append(names, name)
74+ }
75+ }
76+ }
77+ return names
78+}
79+
80+// resolveRef settles where the branch ends and the path begins.
81+//
82+// The URL gave a first segment and a rest; a branch called "feature/x" makes
83+// the first segment "feature", which names nothing. So the two are joined
84+// back together and the **longest** branch or tag that begins them wins,
85+// with the remainder as the path. Nothing matching leaves both as they were,
86+// and the clone then says, in git's own words, that there is no such branch.
87+func resolveRef(refs []string, ref, path string) (string, string) {
88+ if ref == "" {
89+ return ref, path
90+ }
91+ full := ref
92+ if path != "" {
93+ full += "/" + path
94+ }
95+
96+ best := ""
97+ for _, name := range refs {
98+ if name != full && !strings.HasPrefix(full, name+"/") {
99+ continue
100+ }
101+ if len(name) > len(best) {
102+ best = name
103+ }
104+ }
105+ if best == "" {
106+ return ref, path
107+ }
108+ return best, strings.TrimPrefix(strings.TrimPrefix(full, best), "/")
109+}
110+
111+// clone makes a shallow checkout of one ref — or of the default branch when
112+// ref is empty — into dir, which must exist and be empty.
113+func (g git) clone(ctx context.Context, remote, ref, dir string) error {
114+ args := []string{"clone", "--quiet", "--depth", "1"}
115+ if ref != "" {
116+ args = append(args, "--branch", ref)
117+ }
118+ args = append(args, "--", remote, dir)
119+ _, err := g.run(ctx, args...)
120+ return err
121+}
122+
123+// run executes git and returns what it printed, or what it complained about.
124+func (g git) run(ctx context.Context, args ...string) (string, error) {
125+ cmd := exec.CommandContext(ctx, g.path, args...)
126+ // Never a password prompt: this runs from a command line that asked for a
127+ // URL, and a repository that needs one is reported, not waited on.
128+ cmd.Env = append(cmd.Environ(), "GIT_TERMINAL_PROMPT=0")
129+
130+ out, err := cmd.Output()
131+ if err != nil {
132+ var exit *exec.ExitError
133+ if errors.As(err, &exit) && len(exit.Stderr) > 0 {
134+ return "", fmt.Errorf("configrepo: git %s: %s", args[0], strings.TrimSpace(string(exit.Stderr)))
135+ }
136+ return "", fmt.Errorf("configrepo: git %s: %w", args[0], err)
137+ }
138+ return string(out), nil
139+}
new file mode 100644
@@ -0,0 +1,139 @@
1+// Talking to git: which remote answers, what it calls its branches, and a
2+// shallow clone of one of them.
3+
4+package configrepo
5+
6+import (
7+ "context"
8+ "errors"
9+ "fmt"
10+ "os/exec"
11+ "strings"
12+)
13+
14+// git runs the git that was found on PATH.
15+type git struct {
16+ path string
17+}
18+
19+// fetch finds the repository among the remotes, settles which ref the URL
20+// meant, and clones that ref into dir. It returns the remote that answered,
21+// the ref, and the path inside the repository once the ref has taken its
22+// share of it.
23+func (g git) fetch(ctx context.Context, remotes []string, src Source, dir string) (remote, ref, path string, err error) {
24+ remote, refs, err := g.firstAnswering(ctx, remotes)
25+ if err != nil {
26+ return "", "", "", err
27+ }
28+ ref, path = resolveRef(refs, src.Ref, src.Path)
29+ if err := g.clone(ctx, remote, ref, dir); err != nil {
30+ return "", "", "", err
31+ }
32+ return remote, ref, path, nil
33+}
34+
35+// firstAnswering asks each remote for its refs and returns the first that
36+// answers, with what it said.
37+//
38+// The listing does two jobs: it tells the candidates apart — a forge whose
39+// pages and repositories live on different hosts fails fast here, not after
40+// a clone — and it is what a ref with a slash in it is resolved against.
41+func (g git) firstAnswering(ctx context.Context, remotes []string) (string, []string, error) {
42+ var failures []error
43+ for _, remote := range remotes {
44+ refs, err := g.lsRemote(ctx, remote)
45+ if err == nil {
46+ return remote, refs, nil
47+ }
48+ failures = append(failures, err)
49+ }
50+ return "", nil, fmt.Errorf("configrepo: no repository answered: %w", errors.Join(failures...))
51+}
52+
53+// lsRemote returns the names of a remote's branches and tags.
54+func (g git) lsRemote(ctx context.Context, remote string) ([]string, error) {
55+ out, err := g.run(ctx, "ls-remote", "--heads", "--tags", "--", remote)
56+ if err != nil {
57+ return nil, err
58+ }
59+ return parseRefs(out), nil
60+}
61+
62+// parseRefs reads ls-remote's output — a hash, a tab, a ref — into names.
63+// Peeled tags ("v1^{}") name the same tag twice and are dropped.
64+func parseRefs(output string) []string {
65+ var names []string
66+ for _, line := range strings.Split(output, "\n") {
67+ _, ref, ok := strings.Cut(line, "\t")
68+ if !ok || strings.HasSuffix(ref, "^{}") {
69+ continue
70+ }
71+ for _, prefix := range []string{"refs/heads/", "refs/tags/"} {
72+ if name, found := strings.CutPrefix(ref, prefix); found {
73+ names = append(names, name)
74+ }
75+ }
76+ }
77+ return names
78+}
79+
80+// resolveRef settles where the branch ends and the path begins.
81+//
82+// The URL gave a first segment and a rest; a branch called "feature/x" makes
83+// the first segment "feature", which names nothing. So the two are joined
84+// back together and the **longest** branch or tag that begins them wins,
85+// with the remainder as the path. Nothing matching leaves both as they were,
86+// and the clone then says, in git's own words, that there is no such branch.
87+func resolveRef(refs []string, ref, path string) (string, string) {
88+ if ref == "" {
89+ return ref, path
90+ }
91+ full := ref
92+ if path != "" {
93+ full += "/" + path
94+ }
95+
96+ best := ""
97+ for _, name := range refs {
98+ if name != full && !strings.HasPrefix(full, name+"/") {
99+ continue
100+ }
101+ if len(name) > len(best) {
102+ best = name
103+ }
104+ }
105+ if best == "" {
106+ return ref, path
107+ }
108+ return best, strings.TrimPrefix(strings.TrimPrefix(full, best), "/")
109+}
110+
111+// clone makes a shallow checkout of one ref — or of the default branch when
112+// ref is empty — into dir, which must exist and be empty.
113+func (g git) clone(ctx context.Context, remote, ref, dir string) error {
114+ args := []string{"clone", "--quiet", "--depth", "1"}
115+ if ref != "" {
116+ args = append(args, "--branch", ref)
117+ }
118+ args = append(args, "--", remote, dir)
119+ _, err := g.run(ctx, args...)
120+ return err
121+}
122+
123+// run executes git and returns what it printed, or what it complained about.
124+func (g git) run(ctx context.Context, args ...string) (string, error) {
125+ cmd := exec.CommandContext(ctx, g.path, args...)
126+ // Never a password prompt: this runs from a command line that asked for a
127+ // URL, and a repository that needs one is reported, not waited on.
128+ cmd.Env = append(cmd.Environ(), "GIT_TERMINAL_PROMPT=0")
129+
130+ out, err := cmd.Output()
131+ if err != nil {
132+ var exit *exec.ExitError
133+ if errors.As(err, &exit) && len(exit.Stderr) > 0 {
134+ return "", fmt.Errorf("configrepo: git %s: %s", args[0], strings.TrimSpace(string(exit.Stderr)))
135+ }
136+ return "", fmt.Errorf("configrepo: git %s: %w", args[0], err)
137+ }
138+ return string(out), nil
139+}
modified docs/en/reference/app.md +4 -0
@@ -32,6 +32,10 @@
3232 | `InsertLine()`, `DeleteLine()` | Turbo C's `Ctrl-N` and `Ctrl-Y`: a blank line opened above the cursor, and the cursor's own line removed. |
3333 | `ActiveView() *editor.View` | The editing view of the front window, or nil when it is a terminal, the tree, or nothing. |
3434
35+A file that another program rewrites while it is open — a formatter, a generator, a coding agent working in another terminal — is **re-read into its window without closing and reopening it**, and the status bar says `Reloaded main.go`. The cursor stays where it was, clamped into the new text. The disk is looked at once a second while the editor is idle, and on every turn of the event loop otherwise; the check is one `stat` per open window, so a file whose size and modification time have not moved is never read.
36+
37+A window **with unsaved changes is never reloaded**: the status bar says `main.go changed on disk; your unsaved changes are kept`, once per change, and the next save writes the window over the file. A file that disappears from disk is left as it is in the window, silently, and picked up again when it is back. A reload tells the language server what the file now says, as typing would have.
38+
3539 ## Settings
3640
3741 | Method | Description |
@@ -32,6 +32,10 @@
32 | `InsertLine()`, `DeleteLine()` | Turbo C's `Ctrl-N` and `Ctrl-Y`: a blank line opened above the cursor, and the cursor's own line removed. |32 | `InsertLine()`, `DeleteLine()` | Turbo C's `Ctrl-N` and `Ctrl-Y`: a blank line opened above the cursor, and the cursor's own line removed. |
33 | `ActiveView() *editor.View` | The editing view of the front window, or nil when it is a terminal, the tree, or nothing. |33 | `ActiveView() *editor.View` | The editing view of the front window, or nil when it is a terminal, the tree, or nothing. |
34 34
35+A file that another program rewrites while it is open — a formatter, a generator, a coding agent working in another terminal — is **re-read into its window without closing and reopening it**, and the status bar says `Reloaded main.go`. The cursor stays where it was, clamped into the new text. The disk is looked at once a second while the editor is idle, and on every turn of the event loop otherwise; the check is one `stat` per open window, so a file whose size and modification time have not moved is never read.
36+
37+A window **with unsaved changes is never reloaded**: the status bar says `main.go changed on disk; your unsaved changes are kept`, once per change, and the next save writes the window over the file. A file that disappears from disk is left as it is in the window, silently, and picked up again when it is back. A reload tells the language server what the file now says, as typing would have.
38+
35 ## Settings39 ## Settings
36 40
37 | Method | Description |41 | Method | Description |
modified docs/en/reference/packages.md +3 -1
@@ -2,7 +2,7 @@
22
33 > Neutral, exhaustive description of what turbo-core holds.
44
5-Eighteen packages. Dependencies run strictly downwards; there are no cycles and no interface indirection to prevent one.
5+Nineteen packages. Dependencies run strictly downwards; there are no cycles and no interface indirection to prevent one.
66
77 ```
88 app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}
@@ -12,6 +12,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools,
1212 snippets → {toml, projectfile, profile}
1313 settings → {toml, projectfile, profile}
1414 tools → {toml, projectfile, profile}
15+ configrepo → {projectfile, profile} ← runs the git command
1516 syntax → theme
1617 ui → theme
1718 theme → {profile-free; takes a directory}, tcell, toml
@@ -23,6 +24,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools,
2324 | `profile` | The editor's identity: name, slug, language server, starter templates, and the paths derived from them | **stdlib only** |
2425 | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search, load and save | **stdlib only** |
2526 | `projectfile` | Atomic writes of the TOML files a project keeps in the editor's own directory | **stdlib only** |
27+| `configrepo` | A shared configuration directory fetched from a forge URL (rickub, GitHub, GitLab, Codeberg) by a shallow git clone, for an editor's `-load-config` | `projectfile`, `profile`, the `git` command |
2628 | `version` | What build of itself a binary is: linker stamp, then Go build info | **stdlib only** |
2729 | `jsonrpc` | JSON-RPC 2.0 over a framed stream: calls, notifications, inbound requests answerable later | **stdlib only** |
2830 | `lsp` | LSP framing, the methods, and the child process | `jsonrpc`, `profile` |
@@ -2,7 +2,7 @@
2 2
3 > Neutral, exhaustive description of what turbo-core holds.3 > Neutral, exhaustive description of what turbo-core holds.
4 4
5-Eighteen packages. Dependencies run strictly downwards; there are no cycles and no interface indirection to prevent one.5+Nineteen packages. Dependencies run strictly downwards; there are no cycles and no interface indirection to prevent one.
6 6
7 ```7 ```
8 app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}8 app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}
@@ -12,6 +12,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools,
12 snippets → {toml, projectfile, profile}12 snippets → {toml, projectfile, profile}
13 settings → {toml, projectfile, profile}13 settings → {toml, projectfile, profile}
14 tools → {toml, projectfile, profile}14 tools → {toml, projectfile, profile}
15+ configrepo → {projectfile, profile} ← runs the git command
15 syntax → theme16 syntax → theme
16 ui → theme17 ui → theme
17 theme → {profile-free; takes a directory}, tcell, toml18 theme → {profile-free; takes a directory}, tcell, toml
@@ -23,6 +24,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools,
23 | `profile` | The editor's identity: name, slug, language server, starter templates, and the paths derived from them | **stdlib only** |24 | `profile` | The editor's identity: name, slug, language server, starter templates, and the paths derived from them | **stdlib only** |
24 | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search, load and save | **stdlib only** |25 | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search, load and save | **stdlib only** |
25 | `projectfile` | Atomic writes of the TOML files a project keeps in the editor's own directory | **stdlib only** |26 | `projectfile` | Atomic writes of the TOML files a project keeps in the editor's own directory | **stdlib only** |
27+| `configrepo` | A shared configuration directory fetched from a forge URL (rickub, GitHub, GitLab, Codeberg) by a shallow git clone, for an editor's `-load-config` | `projectfile`, `profile`, the `git` command |
26 | `version` | What build of itself a binary is: linker stamp, then Go build info | **stdlib only** |28 | `version` | What build of itself a binary is: linker stamp, then Go build info | **stdlib only** |
27 | `jsonrpc` | JSON-RPC 2.0 over a framed stream: calls, notifications, inbound requests answerable later | **stdlib only** |29 | `jsonrpc` | JSON-RPC 2.0 over a framed stream: calls, notifications, inbound requests answerable later | **stdlib only** |
28 | `lsp` | LSP framing, the methods, and the child process | `jsonrpc`, `profile` |30 | `lsp` | LSP framing, the methods, and the child process | `jsonrpc`, `profile` |
modified docs/fr/reference/app.md +4 -0
@@ -32,6 +32,10 @@
3232 | `InsertLine()`, `DeleteLine()` | Les `Ctrl-N` et `Ctrl-Y` de Turbo C : une ligne vide ouverte au-dessus du curseur, et la ligne du curseur supprimée. |
3333 | `ActiveView() *editor.View` | La vue d'édition de la fenêtre du dessus, ou nil quand c'est un terminal, l'arborescence, ou rien. |
3434
35+Un fichier qu'un autre programme réécrit pendant qu'il est ouvert — un formateur, un générateur, un agent de code dans un autre terminal — est **relu dans sa fenêtre sans qu'il faille la fermer et la rouvrir**, et la barre d'état dit `Reloaded main.go`. Le curseur reste où il était, ramené dans le nouveau texte. Le disque est regardé une fois par seconde quand l'éditeur est inactif, et à chaque tour de la boucle d'événements sinon ; la vérification coûte un `stat` par fenêtre ouverte, si bien qu'un fichier dont la taille et la date de modification n'ont pas bougé n'est jamais lu.
36+
37+Une fenêtre **qui a des modifications non enregistrées n'est jamais rechargée** : la barre d'état dit `main.go changed on disk; your unsaved changes are kept`, une fois par changement, et le prochain enregistrement écrit la fenêtre par-dessus le fichier. Un fichier qui disparaît du disque est laissé tel quel dans sa fenêtre, sans un mot, et repris quand il revient. Un rechargement dit au serveur de langage ce que le fichier contient désormais, comme l'aurait fait la frappe.
38+
3539 ## Réglages
3640
3741 | Méthode | Description |
@@ -32,6 +32,10 @@
32 | `InsertLine()`, `DeleteLine()` | Les `Ctrl-N` et `Ctrl-Y` de Turbo C : une ligne vide ouverte au-dessus du curseur, et la ligne du curseur supprimée. |32 | `InsertLine()`, `DeleteLine()` | Les `Ctrl-N` et `Ctrl-Y` de Turbo C : une ligne vide ouverte au-dessus du curseur, et la ligne du curseur supprimée. |
33 | `ActiveView() *editor.View` | La vue d'édition de la fenêtre du dessus, ou nil quand c'est un terminal, l'arborescence, ou rien. |33 | `ActiveView() *editor.View` | La vue d'édition de la fenêtre du dessus, ou nil quand c'est un terminal, l'arborescence, ou rien. |
34 34
35+Un fichier qu'un autre programme réécrit pendant qu'il est ouvert — un formateur, un générateur, un agent de code dans un autre terminal — est **relu dans sa fenêtre sans qu'il faille la fermer et la rouvrir**, et la barre d'état dit `Reloaded main.go`. Le curseur reste où il était, ramené dans le nouveau texte. Le disque est regardé une fois par seconde quand l'éditeur est inactif, et à chaque tour de la boucle d'événements sinon ; la vérification coûte un `stat` par fenêtre ouverte, si bien qu'un fichier dont la taille et la date de modification n'ont pas bougé n'est jamais lu.
36+
37+Une fenêtre **qui a des modifications non enregistrées n'est jamais rechargée** : la barre d'état dit `main.go changed on disk; your unsaved changes are kept`, une fois par changement, et le prochain enregistrement écrit la fenêtre par-dessus le fichier. Un fichier qui disparaît du disque est laissé tel quel dans sa fenêtre, sans un mot, et repris quand il revient. Un rechargement dit au serveur de langage ce que le fichier contient désormais, comme l'aurait fait la frappe.
38+
35 ## Réglages39 ## Réglages
36 40
37 | Méthode | Description |41 | Méthode | Description |
modified docs/fr/reference/packages.md +3 -1
@@ -2,7 +2,7 @@
22
33 > Description neutre et exhaustive de ce que contient turbo-core.
44
5-Dix-huit paquets. Les dépendances vont strictement vers le bas ; il n'y a ni cycle ni indirection par interface pour en empêcher un.
5+Dix-neuf paquets. Les dépendances vont strictement vers le bas ; il n'y a ni cycle ni indirection par interface pour en empêcher un.
66
77 ```
88 app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}
@@ -12,6 +12,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools,
1212 snippets → {toml, projectfile, profile}
1313 settings → {toml, projectfile, profile}
1414 tools → {toml, projectfile, profile}
15+ configrepo → {projectfile, profile} ← lance la commande git
1516 syntax → theme
1617 ui → theme
1718 theme → {sans profile ; reçoit un répertoire}, tcell, toml
@@ -23,6 +24,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools,
2324 | `profile` | L'identité de l'éditeur : nom, slug, serveur de langage, modèles de départ, et les chemins qui en dérivent | **bibliothèque standard seule** |
2425 | `buffer` | Le texte d'un fichier : lignes de runes, curseur, sélection, annulation, recherche, chargement et sauvegarde | **bibliothèque standard seule** |
2526 | `projectfile` | Écritures atomiques des fichiers TOML qu'un projet garde dans le répertoire de l'éditeur | **bibliothèque standard seule** |
27+| `configrepo` | Un répertoire de configuration partagé, rapporté depuis l'URL d'une forge (rickub, GitHub, GitLab, Codeberg) par un clone git superficiel, pour le `-load-config` d'un éditeur | `projectfile`, `profile`, la commande `git` |
2628 | `version` | De quelle construction de lui-même un binaire est issu : marquage par l'éditeur de liens, puis informations de build Go | **bibliothèque standard seule** |
2729 | `jsonrpc` | JSON-RPC 2.0 sur un flux cadré : appels, notifications, requêtes entrantes auxquelles on peut répondre plus tard | **bibliothèque standard seule** |
2830 | `lsp` | Le cadrage LSP, les méthodes, et le processus fils | `jsonrpc`, `profile` |
@@ -2,7 +2,7 @@
2 2
3 > Description neutre et exhaustive de ce que contient turbo-core.3 > Description neutre et exhaustive de ce que contient turbo-core.
4 4
5-Dix-huit paquets. Les dépendances vont strictement vers le bas ; il n'y a ni cycle ni indirection par interface pour en empêcher un.5+Dix-neuf paquets. Les dépendances vont strictement vers le bas ; il n'y a ni cycle ni indirection par interface pour en empêcher un.
6 6
7 ```7 ```
8 app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}8 app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}
@@ -12,6 +12,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools,
12 snippets → {toml, projectfile, profile}12 snippets → {toml, projectfile, profile}
13 settings → {toml, projectfile, profile}13 settings → {toml, projectfile, profile}
14 tools → {toml, projectfile, profile}14 tools → {toml, projectfile, profile}
15+ configrepo → {projectfile, profile} ← lance la commande git
15 syntax → theme16 syntax → theme
16 ui → theme17 ui → theme
17 theme → {sans profile ; reçoit un répertoire}, tcell, toml18 theme → {sans profile ; reçoit un répertoire}, tcell, toml
@@ -23,6 +24,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools,
23 | `profile` | L'identité de l'éditeur : nom, slug, serveur de langage, modèles de départ, et les chemins qui en dérivent | **bibliothèque standard seule** |24 | `profile` | L'identité de l'éditeur : nom, slug, serveur de langage, modèles de départ, et les chemins qui en dérivent | **bibliothèque standard seule** |
24 | `buffer` | Le texte d'un fichier : lignes de runes, curseur, sélection, annulation, recherche, chargement et sauvegarde | **bibliothèque standard seule** |25 | `buffer` | Le texte d'un fichier : lignes de runes, curseur, sélection, annulation, recherche, chargement et sauvegarde | **bibliothèque standard seule** |
25 | `projectfile` | Écritures atomiques des fichiers TOML qu'un projet garde dans le répertoire de l'éditeur | **bibliothèque standard seule** |26 | `projectfile` | Écritures atomiques des fichiers TOML qu'un projet garde dans le répertoire de l'éditeur | **bibliothèque standard seule** |
27+| `configrepo` | Un répertoire de configuration partagé, rapporté depuis l'URL d'une forge (rickub, GitHub, GitLab, Codeberg) par un clone git superficiel, pour le `-load-config` d'un éditeur | `projectfile`, `profile`, la commande `git` |
26 | `version` | De quelle construction de lui-même un binaire est issu : marquage par l'éditeur de liens, puis informations de build Go | **bibliothèque standard seule** |28 | `version` | De quelle construction de lui-même un binaire est issu : marquage par l'éditeur de liens, puis informations de build Go | **bibliothèque standard seule** |
27 | `jsonrpc` | JSON-RPC 2.0 sur un flux cadré : appels, notifications, requêtes entrantes auxquelles on peut répondre plus tard | **bibliothèque standard seule** |29 | `jsonrpc` | JSON-RPC 2.0 sur un flux cadré : appels, notifications, requêtes entrantes auxquelles on peut répondre plus tard | **bibliothèque standard seule** |
28 | `lsp` | Le cadrage LSP, les méthodes, et le processus fils | `jsonrpc`, `profile` |30 | `lsp` | Le cadrage LSP, les méthodes, et le processus fils | `jsonrpc`, `profile` |