samagotchi 0.57.0 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -1
- data/docs/configuration.md +5 -3
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +2 -2
- data/lib/samagotchi/model_profile.rb +14 -3
- data/lib/samagotchi/redacted_inspect.rb +21 -6
- data/lib/samagotchi/session_commands.rb +5 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -3
- data/lib/samagotchi/terminal_ui.rb +14 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +8 -2
- data/lib/samagotchi/web/public/app.js +7 -6
- data/lib/samagotchi/web/public/index.html +20 -7
- data/lib/samagotchi/web/public/ref_menu.js +188 -0
- data/lib/samagotchi/web/public/ref_scan.js +11 -1
- data/lib/samagotchi/web/public/refs.js +271 -51
- data/lib/samagotchi/web/public/stage_view.js +21 -0
- data/lib/samagotchi/worker.rb +2 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6049cfd1f36b6aa77b8dfdfd9d437693f1d0c08257eb327838ccf9134db9fa27
|
|
4
|
+
data.tar.gz: d10c78ddf6e21e39689c3012214d4362844e090cac3624863a7a26195cc09769
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 240fb5fc4a45ae15088c690cc45c034f8ced20a9b6844649719ebfe25b9cf8801ecc90601d2c464d8e24a9fe75feadc0d541c8fe3e2c9784a2bd65fce9c31767
|
|
7
|
+
data.tar.gz: 64353c0287ded0c59631dec4af9319592d354ae1dc6c3d8609c65fffb60554a841453c060580233884cc1d8f4ed93dcfdb4434bd2fe5bfedeb57fb9f51c9cc3a
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,23 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.58.0] - 2026-10-11
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Web: a ref's menu. A ref with more than one target shows a `▾` on hover (a tap on a touch screen, ↓ on a focused
|
|
16
|
+
ref) with **Open in editor**, **Show in turn**, **Copy path** and, on github-pr's link, **PR diff**; the plain click
|
|
17
|
+
stays the first. **Show in turn** scrolls to the row that touched the file, opens its diff at the line and flashes
|
|
18
|
+
it. It needs no editor, so a phone on the LAN now gets an answer's refs too.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- A session with its own model resumes when the config names no default.model: `chi send`, attached, the plain REPL
|
|
23
|
+
(`chi --resume ID --no-shared`) and `-p … --non-interactive` crashed with "no model configured". `/model clear`
|
|
24
|
+
then says there is no default.
|
|
25
|
+
- Web: the LAN access token no longer shows when the web app or a stream proxy is inspected (an error message, a log
|
|
26
|
+
line).
|
|
27
|
+
|
|
11
28
|
## [0.57.0] - 2026-10-11
|
|
12
29
|
|
|
13
30
|
### Added
|
|
@@ -2523,7 +2540,8 @@ and long-lived sessions.
|
|
|
2523
2540
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
2524
2541
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
2525
2542
|
|
|
2526
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
2543
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.58.0...HEAD
|
|
2544
|
+
[0.58.0]: https://github.com/dm1try/samagotchi/compare/v0.57.0...v0.58.0
|
|
2527
2545
|
[0.57.0]: https://github.com/dm1try/samagotchi/compare/v0.56.0...v0.57.0
|
|
2528
2546
|
[0.56.0]: https://github.com/dm1try/samagotchi/compare/v0.55.0...v0.56.0
|
|
2529
2547
|
[0.55.0]: https://github.com/dm1try/samagotchi/compare/v0.54.0...v0.55.0
|
data/docs/configuration.md
CHANGED
|
@@ -1191,9 +1191,11 @@ web:
|
|
|
1191
1191
|
and Chrome ask per origin, so another `chi web` port asks again), and VS Code asks to allow opening local files.
|
|
1192
1192
|
- It opens the file as it is now, not the agent's copy: after later edits a diff line's number can point elsewhere,
|
|
1193
1193
|
and a file in a worktree that was removed won't open. The row's diff and output are what the agent saw.
|
|
1194
|
-
- Only a viewer on this machine
|
|
1195
|
-
(`web.host: lan`, the LAN URL or its QR code opened here). A phone
|
|
1196
|
-
|
|
1194
|
+
- Only a viewer on this machine opens the editor: a loopback request, or this machine on its own LAN address
|
|
1195
|
+
(`web.host: lan`, the LAN URL or its QR code opened here). A phone gets the targets that need no editor (an
|
|
1196
|
+
answer's ref shows its row in the turn; see the refs in [sessions.md](sessions.md)); a row's title stays plain
|
|
1197
|
+
text there. An SSH tunnel or a local reverse proxy (`tailscale serve`) looks like loopback, so a remote viewer
|
|
1198
|
+
behind one gets an editor target that can't open.
|
|
1197
1199
|
- `GET /api/sessions/:id` (and its `?tail=1`) carries `capabilities.editor` (true for such a viewer) and
|
|
1198
1200
|
`editor_url`, the template (null otherwise).
|
|
1199
1201
|
|
data/docs/sessions.md
CHANGED
|
@@ -68,9 +68,9 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
68
68
|
**Ordering:**
|
|
69
69
|
|
|
70
70
|
- `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
|
|
71
|
-
- Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links (a github-pr link to the PR stays the PR's), bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
71
|
+
- Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
72
72
|
- Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
|
|
73
|
-
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, the
|
|
73
|
+
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
|
|
74
74
|
- Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
|
|
75
75
|
|
|
76
76
|
**Test-session hygiene:**
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -266,8 +266,8 @@ module Samagotchi
|
|
|
266
266
|
def wire_kernel(kernel, muted_memories)
|
|
267
267
|
# A given kernel (specs) gets the Engine's store, hooks and tools, as
|
|
268
268
|
# the one built here does.
|
|
269
|
-
@kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile,
|
|
270
|
-
tools: @tools)
|
|
269
|
+
@kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, model_name: @effective_model_name,
|
|
270
|
+
hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
|
|
271
271
|
@kernel.reminder_store = @reminder_store
|
|
272
272
|
@kernel.hooks = @hooks
|
|
273
273
|
@kernel.tools = @tools
|
|
@@ -142,13 +142,24 @@ module Samagotchi
|
|
|
142
142
|
end
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
+
# The model the config names as the default, or nil when it names none.
|
|
146
|
+
# It never raises.
|
|
147
|
+
# @return [String, nil]
|
|
148
|
+
def self.default_model_name
|
|
149
|
+
require_relative "config"
|
|
150
|
+
value = Samagotchi::Config.get("default.model")
|
|
151
|
+
return nil unless value
|
|
152
|
+
|
|
153
|
+
stripped = value.to_s.strip
|
|
154
|
+
stripped.empty? ? nil : stripped
|
|
155
|
+
end
|
|
156
|
+
|
|
145
157
|
def self.required_model_name(model_name = nil)
|
|
146
158
|
value = model_name.to_s.strip
|
|
147
159
|
if value.empty?
|
|
148
|
-
|
|
149
|
-
value = Samagotchi::Config.get("default.model").to_s.strip
|
|
160
|
+
value = default_model_name
|
|
150
161
|
end
|
|
151
|
-
raise MissingModel, missing_model_message if value.empty?
|
|
162
|
+
raise MissingModel, missing_model_message if value.to_s.strip.empty?
|
|
152
163
|
|
|
153
164
|
value
|
|
154
165
|
end
|
|
@@ -2,21 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
# A single redacted #inspect for the classes that hold the process
|
|
5
|
-
# environment (@env, every API key) or the web token (@token
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
5
|
+
# environment (@env, every API key) or the web token (@token, or a :token
|
|
6
|
+
# key inside a Hash ivar, as Web::App's @lan): instead of dumping every
|
|
7
|
+
# instance variable (which would print the keys), it shows the rest with
|
|
8
|
+
# each value's own inspect and prints the others as [redacted]. A failing
|
|
9
|
+
# spec, a debugger or an error message that inspects one of them used to
|
|
10
|
+
# leak every key.
|
|
10
11
|
module RedactedInspect
|
|
11
12
|
# The instance variables never printed.
|
|
12
13
|
REDACTED = %i[@env @token].freeze
|
|
14
|
+
# The keys of a Hash ivar whose values are never printed; the rest of the
|
|
15
|
+
# Hash is, so Web::App's @lan still shows its :ip.
|
|
16
|
+
REDACTED_KEYS = %i[token].freeze
|
|
17
|
+
|
|
18
|
+
# Stands in for a redacted Hash value: inspects as [redacted], unquoted.
|
|
19
|
+
HIDDEN = Object.new.tap { |hidden| hidden.define_singleton_method(:inspect) { "[redacted]" } }.freeze
|
|
13
20
|
|
|
14
21
|
def inspect
|
|
15
22
|
body = instance_variables.map do |ivar|
|
|
16
23
|
if REDACTED.include?(ivar)
|
|
17
24
|
"#{ivar}=[redacted]"
|
|
18
25
|
else
|
|
19
|
-
"#{ivar}=#{instance_variable_get(ivar).inspect}"
|
|
26
|
+
"#{ivar}=#{redacted_value(instance_variable_get(ivar)).inspect}"
|
|
20
27
|
end
|
|
21
28
|
end.join(", ")
|
|
22
29
|
"#<#{self.class.name} #{body}>"
|
|
@@ -24,5 +31,13 @@ module Samagotchi
|
|
|
24
31
|
|
|
25
32
|
# pp otherwise ignores inspect.
|
|
26
33
|
def pretty_print(printer) = printer.text(inspect)
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def redacted_value(value)
|
|
38
|
+
return value unless value.is_a?(Hash) && REDACTED_KEYS.any? { |key| value.key?(key) }
|
|
39
|
+
|
|
40
|
+
value.to_h { |key, item| [key, REDACTED_KEYS.include?(key) ? HIDDEN : item] }
|
|
41
|
+
end
|
|
27
42
|
end
|
|
28
43
|
end
|
|
@@ -188,12 +188,12 @@ module Samagotchi
|
|
|
188
188
|
# @return [String, nil]
|
|
189
189
|
def self.builtin_name(line) = builtin_registry.lookup(line)&.name
|
|
190
190
|
|
|
191
|
-
# @return [String] the model /model clear goes back to
|
|
191
|
+
# @return [String, nil] the model /model clear goes back to; nil when none
|
|
192
192
|
attr_reader :default_model
|
|
193
193
|
|
|
194
|
-
# @param default_model [String] the config default, which /model clear
|
|
194
|
+
# @param default_model [String, nil] the config default, which /model clear
|
|
195
195
|
# restores and /model names (the Engine may have started elsewhere, e.g.
|
|
196
|
-
# on a resumed session's model)
|
|
196
|
+
# on a resumed session's model); nil when the config names no default
|
|
197
197
|
# @param save [#call] saves a session (a worker passes its state dir)
|
|
198
198
|
# @param registry [Commands::Registry] the commands #run looks lines up in
|
|
199
199
|
def initialize(engine:, turn_flow:, default_model:, save: ->(session) { session.save },
|
|
@@ -484,7 +484,7 @@ module Samagotchi
|
|
|
484
484
|
def model_command(input)
|
|
485
485
|
suffix = input.delete_prefix(MODEL_COMMAND).strip
|
|
486
486
|
if suffix.empty?
|
|
487
|
-
return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if model_name == @engine.model_ref_for(@default_model)
|
|
487
|
+
return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if @default_model.nil? || model_name == @engine.model_ref_for(@default_model)
|
|
488
488
|
|
|
489
489
|
return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false]
|
|
490
490
|
end
|
|
@@ -554,6 +554,7 @@ module Samagotchi
|
|
|
554
554
|
|
|
555
555
|
if ConfigFile::RESERVED_MODEL_ALIASES.include?(arg.downcase)
|
|
556
556
|
return ["--default cannot be combined with clear/default/none/off", false] if persist_default
|
|
557
|
+
raise ModelProfile::MissingModel, "no default model is configured (set default.model in config.yml, or /model <name> --default)" if @default_model.nil?
|
|
557
558
|
|
|
558
559
|
switch_model(@default_model)
|
|
559
560
|
return ["runtime model reset to #{model_name}#{model_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", true]
|
|
@@ -1456,9 +1456,7 @@ module Samagotchi
|
|
|
1456
1456
|
|
|
1457
1457
|
# The config's default model, as the worker's /model names it.
|
|
1458
1458
|
def default_model_name
|
|
1459
|
-
@default_model_name ||= ModelProfile.
|
|
1460
|
-
rescue StandardError
|
|
1461
|
-
nil
|
|
1459
|
+
@default_model_name ||= ModelProfile.default_model_name
|
|
1462
1460
|
end
|
|
1463
1461
|
|
|
1464
1462
|
def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
|
|
@@ -98,7 +98,7 @@ module Samagotchi
|
|
|
98
98
|
@thinking_word = thinking
|
|
99
99
|
@prompt = prompt
|
|
100
100
|
@llm_context_words = llm_context
|
|
101
|
-
@default_model_name = ModelProfile.
|
|
101
|
+
@default_model_name = ModelProfile.default_model_name
|
|
102
102
|
# As typed: the Engine applies an alias where it resolves the model.
|
|
103
103
|
flag_model_name = model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(model_name)
|
|
104
104
|
@effective_model_name = flag_model_name || @default_model_name
|
|
@@ -127,6 +127,12 @@ module Samagotchi
|
|
|
127
127
|
# Re-resolve client after resume may change effective model
|
|
128
128
|
@client = @host_registry.resolve(@effective_model_name).client
|
|
129
129
|
end
|
|
130
|
+
# No --model, no session model and no default: nothing to run.
|
|
131
|
+
raise ModelProfile::MissingModel, ModelProfile.missing_model_message if @effective_model_name.to_s.strip.empty?
|
|
132
|
+
|
|
133
|
+
# With no config default the Engine starts on the model it runs.
|
|
134
|
+
no_default = @default_model_name.nil?
|
|
135
|
+
|
|
130
136
|
# The model it starts on names a configured host (a bad default.model
|
|
131
137
|
# doesn't matter when --model or the session picks another).
|
|
132
138
|
ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
|
|
@@ -166,8 +172,10 @@ module Samagotchi
|
|
|
166
172
|
session_id: session_id,
|
|
167
173
|
no_interrupt: no_interrupt,
|
|
168
174
|
# nil: the config default, unchecked here (the model the REPL starts
|
|
169
|
-
# on is checked above, and --model may make the default moot).
|
|
170
|
-
|
|
175
|
+
# on is checked above, and --model may make the default moot). With
|
|
176
|
+
# no default, the model it runs (the switch below is then skipped).
|
|
177
|
+
model_name: no_default ? @effective_model_name : nil,
|
|
178
|
+
model_typed: no_default ? @resumed_model_typed : nil,
|
|
171
179
|
memories: @requested_memories,
|
|
172
180
|
muted_memories: @muted_memory_names,
|
|
173
181
|
recap: scratch ? nil : recap_config,
|
|
@@ -199,8 +207,9 @@ module Samagotchi
|
|
|
199
207
|
@commands = SessionCommands.new(engine: @engine, turn_flow: @turn_flow, default_model: @default_model_name,
|
|
200
208
|
registry: @engine.command_registry)
|
|
201
209
|
# Runtime --model flag or resumed session: switch the Engine (client,
|
|
202
|
-
# kernel profile) without persisting the default.
|
|
203
|
-
|
|
210
|
+
# kernel profile) without persisting the default. Skipped when there's
|
|
211
|
+
# no config default (the Engine was built on the session model).
|
|
212
|
+
@engine.switch_model!(@effective_model_name, typed: @resumed_model_typed) if !no_default && @effective_model_name != @default_model_name
|
|
204
213
|
# Render an idle session-recap via the cursor-safe background writer; the
|
|
205
214
|
# detector itself is Engine-owned (see Engine#recap) and opt-in.
|
|
206
215
|
@recap_handle = @engine.subscribe(observer: ->(event) { @between_turns.take_recap(event) })
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -45,6 +45,7 @@ require_relative "editor"
|
|
|
45
45
|
require_relative "message_parts"
|
|
46
46
|
require_relative "session_summary"
|
|
47
47
|
require_relative "../log"
|
|
48
|
+
require_relative "../redacted_inspect"
|
|
48
49
|
|
|
49
50
|
module Samagotchi
|
|
50
51
|
module Web
|
|
@@ -56,6 +57,9 @@ module Samagotchi
|
|
|
56
57
|
# (the single live client transport); history for any session — including
|
|
57
58
|
# dead ones — is served by GET /api/sessions/:id/output from output/ files.
|
|
58
59
|
class App
|
|
60
|
+
# @lan holds the LAN access token.
|
|
61
|
+
include RedactedInspect
|
|
62
|
+
|
|
59
63
|
DEFAULT_HOST = BridgeClient::HOST
|
|
60
64
|
BRIDGE_WAIT_TIMEOUT = 10.0
|
|
61
65
|
# An uploaded image may be this big before it is downscaled.
|
|
@@ -2130,10 +2134,12 @@ module Samagotchi
|
|
|
2130
2134
|
# before returning, and a read blocked on a quiet worker would hang
|
|
2131
2135
|
# Ctrl-C of chi web. The bytes go through unchanged.
|
|
2132
2136
|
class ProxyStreamBody
|
|
2137
|
+
# @param headers [Hash] the request's Rack env; only its Last-Event-ID
|
|
2138
|
+
# is kept (the env carries the token cookie, never held or inspected)
|
|
2133
2139
|
def initialize(host:, port:, session_id:, query:, headers:, server_running: -> { true })
|
|
2134
2140
|
@client = BridgeClient.new(session_id: session_id, port: port, host: host)
|
|
2135
2141
|
@query = query
|
|
2136
|
-
@
|
|
2142
|
+
@last_event_id = headers["HTTP_LAST_EVENT_ID"]
|
|
2137
2143
|
@server_running = server_running
|
|
2138
2144
|
end
|
|
2139
2145
|
|
|
@@ -2142,7 +2148,7 @@ module Samagotchi
|
|
|
2142
2148
|
# Last-Event-ID header over ?from_seq, and the reconnect URL carries a
|
|
2143
2149
|
# stale initial cursor — without this the bridge would replay content
|
|
2144
2150
|
# already delivered (duplicate bubbles).
|
|
2145
|
-
@client.stream(query: @query, last_event_id: @
|
|
2151
|
+
@client.stream(query: @query, last_event_id: @last_event_id, running: @server_running, &)
|
|
2146
2152
|
end
|
|
2147
2153
|
end
|
|
2148
2154
|
end
|
|
@@ -65,7 +65,7 @@ import {
|
|
|
65
65
|
appendAboveLiveTiming, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
|
|
66
66
|
} from "./timing.js";
|
|
67
67
|
import { createLiveTurn } from "./live_turn.js";
|
|
68
|
-
import { installRefClicks
|
|
68
|
+
import { installRefClicks } from "./refs.js";
|
|
69
69
|
import { linkRefs, touchedFiles } from "./ref_scan.js";
|
|
70
70
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
71
71
|
import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
|
|
@@ -855,13 +855,11 @@ function setMarkdownWarning(warning) {
|
|
|
855
855
|
markdownWarningEl.hidden = false;
|
|
856
856
|
}
|
|
857
857
|
|
|
858
|
-
// Both from one payload, together; the body's has-editor
|
|
859
|
-
// classes follow (the refs' hover styling keys on refs-<kind>).
|
|
858
|
+
// Both from one payload, together; the body's has-editor class follows.
|
|
860
859
|
function setCapabilities(capabilities, editorUrl) {
|
|
861
860
|
pageCapabilities = capabilities && typeof capabilities === "object" ? capabilities : {};
|
|
862
861
|
pageEditorUrl = pageCapabilities.editor === true && typeof editorUrl === "string" && editorUrl ? editorUrl : null;
|
|
863
862
|
document.body.classList.toggle("has-editor", pageEditorUrl !== null);
|
|
864
|
-
setRefKinds(document.body, pageViewer());
|
|
865
863
|
}
|
|
866
864
|
|
|
867
865
|
// What this viewer can do with a ref (refs.js REF_ACTIONS).
|
|
@@ -869,8 +867,11 @@ function pageViewer() {
|
|
|
869
867
|
return { editor: pageCapabilities.editor === true, editorUrl: pageEditorUrl };
|
|
870
868
|
}
|
|
871
869
|
|
|
872
|
-
// Refs anywhere on the page (a tool row's file, its diff lines
|
|
873
|
-
|
|
870
|
+
// Refs anywhere on the page (a tool row's file, its diff lines, an
|
|
871
|
+
// answer's path:line): one listener set; "Copy path" says so in a toast.
|
|
872
|
+
installRefClicks(document.body, pageViewer, {
|
|
873
|
+
copy: async (text) => showToast((await copyText(text)) ? "Path copied" : "Couldn't copy the path"),
|
|
874
|
+
});
|
|
874
875
|
|
|
875
876
|
async function readTurnEnd() {
|
|
876
877
|
if (!selected) return;
|
|
@@ -380,13 +380,26 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
380
380
|
.diff{margin:0;padding:4px 0;background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;font-size:12px;line-height:1.45;overflow:auto;max-height:420px}
|
|
381
381
|
.diff code{display:block;min-width:max-content}
|
|
382
382
|
.diff-line{display:block;padding:0 10px;white-space:pre;color:var(--text-2)}
|
|
383
|
-
/* Refs (refs.js): text until hovered, and only
|
|
384
|
-
.
|
|
385
|
-
.
|
|
386
|
-
.
|
|
387
|
-
|
|
388
|
-
.
|
|
389
|
-
.
|
|
383
|
+
/* Refs (refs.js): text until hovered or focused, and only with a target for this viewer (refs.js marks it: data-ref-main, the main one). A link ref keeps its link look. */
|
|
384
|
+
.ref[data-ref-main]:not(a):is(:hover,:focus-visible){color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
|
|
385
|
+
.ref[data-ref-main="editor"]:not(a):is(:hover,:focus-visible)::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
|
|
386
|
+
.ref[data-ref-kind]:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px}
|
|
387
|
+
:is(.activity-row,.bubble.approval) .diff-line[data-ref-main]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
|
|
388
|
+
:is(.activity-row,.bubble.approval) .diff-line[data-ref-main]:hover::after{content:"\2197 " attr(data-line);float:right;padding-left:12px;color:var(--text-faint)}
|
|
389
|
+
.activity-row .read-line[data-ref-main]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
|
|
390
|
+
.activity-row .read-line[data-ref-main]:hover::before{color:var(--accent)}
|
|
391
|
+
/* A ref's ▾ and its menu (ref_menu.js): one each, placed next to the ref. */
|
|
392
|
+
.ref-more{position:fixed;z-index:60;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-panel);color:var(--text-muted);font-size:12px;line-height:1;cursor:pointer;box-shadow:0 1px 3px var(--shadow-strong)}
|
|
393
|
+
.ref-more[hidden]{display:none}
|
|
394
|
+
.ref-more:hover{color:var(--accent);border-color:var(--accent)}
|
|
395
|
+
.ref-menu{position:fixed;z-index:61;min-width:160px;max-width:calc(100vw - 16px);padding:4px;border:1px solid var(--border-strong);border-radius:8px;background:var(--bg-panel);box-shadow:var(--toast-shadow);animation:fadeIn .12s ease-out}
|
|
396
|
+
.ref-menu [role="menuitem"]{display:block;width:100%;padding:6px 10px;border:0;border-radius:5px;background:none;color:var(--text);font:inherit;font-size:13px;text-align:left;cursor:pointer;white-space:nowrap}
|
|
397
|
+
.ref-menu [role="menuitem"]:is(:hover,:focus-visible){background:var(--accent-bg);color:var(--text-bright);outline:none}
|
|
398
|
+
@media (pointer:coarse){.ref-menu [role="menuitem"]{padding:11px 14px;font-size:15px}}
|
|
399
|
+
/* Show in turn: the row (and its line) flash once. */
|
|
400
|
+
@keyframes ref-flash{0%,35%{background:color-mix(in srgb,var(--accent) 22%,transparent)}100%{background:transparent}}
|
|
401
|
+
.ref-flash{animation:ref-flash 1.6s ease-out}
|
|
402
|
+
@media (prefers-reduced-motion:reduce){.ref-menu{animation:none}.ref-flash{animation:none;outline:2px solid var(--accent);outline-offset:2px}}
|
|
390
403
|
.diff-add{background:var(--diff-add-bg);color:var(--diff-add-text)}
|
|
391
404
|
.diff-del{background:var(--diff-del-bg);color:var(--diff-del-text)}
|
|
392
405
|
.diff-hunk{color:var(--text-faint)}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// A ref's menu (refs.js): the small ▾ that shows next to a hovered or
|
|
2
|
+
// focused ref with more than one target, and the menu of its targets it
|
|
3
|
+
// opens (a tap and ↓ open it too). One of each per page, placed next to
|
|
4
|
+
// the ref (position: fixed); a scroll, a resize, a click elsewhere, Esc or
|
|
5
|
+
// Tab closes them. Keyboard: ↑/↓ (Home/End) move, Enter picks, Esc closes
|
|
6
|
+
// back to the ref.
|
|
7
|
+
|
|
8
|
+
// How long the ▾ stays after the pointer leaves the ref: long enough to
|
|
9
|
+
// reach it.
|
|
10
|
+
const HIDE_MS = 300;
|
|
11
|
+
// Kept from the viewport's edges (a phone's width).
|
|
12
|
+
const EDGE = 8;
|
|
13
|
+
|
|
14
|
+
// +doc+'s ▾ and menu. open(el, items, {focus}) shows +items+ ({id, label,
|
|
15
|
+
// run}) under +el+; showMore(el, onOpen) puts the ▾ after +el+, whose click
|
|
16
|
+
// calls +onOpen+; hideMoreSoon(el) takes it away unless the pointer reaches
|
|
17
|
+
// it; close({refocus}) closes the menu.
|
|
18
|
+
export function createRefMenu(doc = globalThis.document) {
|
|
19
|
+
let more = null;
|
|
20
|
+
let menu = null;
|
|
21
|
+
let moreFor = null;
|
|
22
|
+
let menuFor = null;
|
|
23
|
+
let onMore = null;
|
|
24
|
+
let hideTimer = null;
|
|
25
|
+
const win = doc.defaultView || globalThis;
|
|
26
|
+
|
|
27
|
+
function moreButton() {
|
|
28
|
+
if (more) return more;
|
|
29
|
+
more = doc.createElement("button");
|
|
30
|
+
more.type = "button";
|
|
31
|
+
more.className = "ref-more";
|
|
32
|
+
more.textContent = "▾";
|
|
33
|
+
more.title = "More for this ref";
|
|
34
|
+
more.setAttribute("aria-label", "More for this ref");
|
|
35
|
+
more.setAttribute("aria-haspopup", "menu");
|
|
36
|
+
more.tabIndex = -1;
|
|
37
|
+
more.hidden = true;
|
|
38
|
+
more.addEventListener("mouseenter", () => clearTimeout(hideTimer));
|
|
39
|
+
more.addEventListener("mouseleave", () => hideMoreSoon(moreFor));
|
|
40
|
+
more.addEventListener("mousedown", (e) => e.preventDefault()); // keeps the ref's focus
|
|
41
|
+
more.addEventListener("click", (e) => {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
e.stopPropagation();
|
|
44
|
+
onMore?.();
|
|
45
|
+
});
|
|
46
|
+
doc.body.appendChild(more);
|
|
47
|
+
return more;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function showMore(el, onOpen) {
|
|
51
|
+
clearTimeout(hideTimer);
|
|
52
|
+
const button = moreButton();
|
|
53
|
+
moreFor = el;
|
|
54
|
+
onMore = onOpen;
|
|
55
|
+
button.hidden = false;
|
|
56
|
+
const rect = lastRect(el);
|
|
57
|
+
const top = rect.top + (rect.height - (button.offsetHeight || rect.height)) / 2;
|
|
58
|
+
button.style.left = `${Math.min(rect.right + 2, win.innerWidth - (button.offsetWidth || 16) - 2)}px`;
|
|
59
|
+
button.style.top = `${top}px`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function hideMore() {
|
|
63
|
+
clearTimeout(hideTimer);
|
|
64
|
+
if (more) more.hidden = true;
|
|
65
|
+
moreFor = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hideMoreSoon(el) {
|
|
69
|
+
if (!more || (el && el !== moreFor)) return;
|
|
70
|
+
clearTimeout(hideTimer);
|
|
71
|
+
hideTimer = setTimeout(() => {
|
|
72
|
+
if (menu && menuFor === moreFor) return;
|
|
73
|
+
if (doc.activeElement && doc.activeElement === moreFor) return;
|
|
74
|
+
hideMore();
|
|
75
|
+
}, HIDE_MS);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function open(el, items, { focus = true } = {}) {
|
|
79
|
+
close();
|
|
80
|
+
menuFor = el;
|
|
81
|
+
menu = doc.createElement("div");
|
|
82
|
+
menu.className = "ref-menu";
|
|
83
|
+
menu.setAttribute("role", "menu");
|
|
84
|
+
for (const item of items) {
|
|
85
|
+
const button = doc.createElement("button");
|
|
86
|
+
button.type = "button";
|
|
87
|
+
button.setAttribute("role", "menuitem");
|
|
88
|
+
button.tabIndex = -1;
|
|
89
|
+
button.dataset.target = item.id;
|
|
90
|
+
button.textContent = item.label;
|
|
91
|
+
button.addEventListener("click", (e) => {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
e.stopPropagation();
|
|
94
|
+
close({ refocus: false });
|
|
95
|
+
hideMore();
|
|
96
|
+
item.run();
|
|
97
|
+
});
|
|
98
|
+
menu.appendChild(button);
|
|
99
|
+
}
|
|
100
|
+
menu.addEventListener("keydown", onMenuKey);
|
|
101
|
+
menu.addEventListener("focusout", (e) => {
|
|
102
|
+
if (menu && !menu.contains(e.relatedTarget) && e.relatedTarget) close({ refocus: false });
|
|
103
|
+
});
|
|
104
|
+
doc.body.appendChild(menu);
|
|
105
|
+
place(menu, el);
|
|
106
|
+
el.setAttribute("aria-expanded", "true");
|
|
107
|
+
doc.addEventListener("pointerdown", onOutside, true);
|
|
108
|
+
win.addEventListener("scroll", onScroll, true);
|
|
109
|
+
win.addEventListener("resize", onScroll);
|
|
110
|
+
if (focus) menuItems()[0]?.focus();
|
|
111
|
+
return menu;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function close({ refocus = false } = {}) {
|
|
115
|
+
if (!menu) return;
|
|
116
|
+
const el = menuFor;
|
|
117
|
+
menu.remove();
|
|
118
|
+
menu = null;
|
|
119
|
+
menuFor = null;
|
|
120
|
+
el?.removeAttribute("aria-expanded");
|
|
121
|
+
doc.removeEventListener("pointerdown", onOutside, true);
|
|
122
|
+
win.removeEventListener("scroll", onScroll, true);
|
|
123
|
+
win.removeEventListener("resize", onScroll);
|
|
124
|
+
if (refocus) el?.focus?.();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function menuItems() {
|
|
128
|
+
return menu ? [...menu.querySelectorAll('[role="menuitem"]')] : [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function onMenuKey(e) {
|
|
132
|
+
const items = menuItems();
|
|
133
|
+
const at = items.indexOf(doc.activeElement);
|
|
134
|
+
const go = (index) => items[(index + items.length) % items.length]?.focus();
|
|
135
|
+
if (e.key === "ArrowDown") go(at + 1);
|
|
136
|
+
else if (e.key === "ArrowUp") go(at < 0 ? -1 : at - 1);
|
|
137
|
+
else if (e.key === "Home") go(0);
|
|
138
|
+
else if (e.key === "End") go(-1);
|
|
139
|
+
else if (e.key === "Escape") close({ refocus: true });
|
|
140
|
+
else if (e.key === "Tab") { close({ refocus: true }); return; }
|
|
141
|
+
else return;
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function onOutside(e) {
|
|
147
|
+
if (menu?.contains(e.target) || more?.contains(e.target)) return;
|
|
148
|
+
close();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function onScroll(e) {
|
|
152
|
+
if (menu?.contains(e?.target)) return;
|
|
153
|
+
close();
|
|
154
|
+
hideMore();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Under the ref's last line, flipped above it when there is no room
|
|
158
|
+
// below, kept inside the viewport.
|
|
159
|
+
function place(box, el) {
|
|
160
|
+
const rect = lastRect(el);
|
|
161
|
+
const first = el.getClientRects?.()[0] || rect;
|
|
162
|
+
const width = box.offsetWidth;
|
|
163
|
+
const height = box.offsetHeight;
|
|
164
|
+
const left = Math.max(EDGE, Math.min(rect.left, win.innerWidth - width - EDGE));
|
|
165
|
+
const below = rect.bottom + 4;
|
|
166
|
+
const top = below + height > win.innerHeight - EDGE && first.top - height - 4 >= EDGE ? first.top - height - 4 : below;
|
|
167
|
+
box.style.left = `${left}px`;
|
|
168
|
+
box.style.top = `${top}px`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
open,
|
|
173
|
+
close,
|
|
174
|
+
showMore,
|
|
175
|
+
hideMore,
|
|
176
|
+
hideMoreSoon,
|
|
177
|
+
get isOpen() { return !!menu; },
|
|
178
|
+
get element() { return menu; },
|
|
179
|
+
get moreButton() { return more; },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The ref's last line box (a ref wrapped over two lines ends on the
|
|
184
|
+
// second); its whole box when it has none.
|
|
185
|
+
function lastRect(el) {
|
|
186
|
+
const rects = el.getClientRects?.() || [];
|
|
187
|
+
return rects.length ? rects[rects.length - 1] : el.getBoundingClientRect();
|
|
188
|
+
}
|
|
@@ -118,10 +118,20 @@ export function touchedFiles(roots) {
|
|
|
118
118
|
// Wraps the refs in +el+'s text in span.ref (refs.js clicks and
|
|
119
119
|
// hover them as it does a row title). Text inside pre, a link or a ref is
|
|
120
120
|
// left alone; an inline code element is wrapped whole when it is a ref
|
|
121
|
-
// alone.
|
|
121
|
+
// alone. A link whose text is a ref alone (github-pr's PR diff link) is
|
|
122
|
+
// that ref itself: its href stays the plain click's, the ref's other
|
|
123
|
+
// targets join it in the menu (refs.js). Runs again over the same element
|
|
124
|
+
// without wrapping twice.
|
|
122
125
|
export function linkRefs(el, touched, cwd) {
|
|
123
126
|
if (!el || !touched?.size) return;
|
|
124
127
|
const doc = el.ownerDocument;
|
|
128
|
+
for (const link of [...el.querySelectorAll("a[href]:not([data-ref-kind])")]) {
|
|
129
|
+
if (link.parentElement?.closest("pre, a, [data-ref-kind]")) continue;
|
|
130
|
+
const ref = aloneRef(link.textContent, touched, cwd);
|
|
131
|
+
if (!ref) continue;
|
|
132
|
+
link.classList.add("ref");
|
|
133
|
+
for (const [name, value] of Object.entries(refSpan(doc, ref).dataset)) link.dataset[name] = value;
|
|
134
|
+
}
|
|
125
135
|
for (const code of [...el.querySelectorAll("code")]) {
|
|
126
136
|
if (code.closest(SKIP)) continue;
|
|
127
137
|
const ref = aloneRef(code.textContent, touched, cwd);
|
|
@@ -2,16 +2,24 @@
|
|
|
2
2
|
// a kind and a target: today a file tool's row title (the server's FileRef,
|
|
3
3
|
// {kind: "file", path, line?, end_line?}), that row's diff lines and a
|
|
4
4
|
// read row's numbered lines (the same file at a line), and an edit/write
|
|
5
|
-
// approval card's paths and its dry run's diff lines
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
5
|
+
// approval card's paths and its dry run's diff lines, and a `path:line` in
|
|
6
|
+
// an answer or a step's text (ref_scan.js; a link whose text is one too).
|
|
7
|
+
// The DOM carries data-ref-kind and the target's attributes, never what a
|
|
8
|
+
// click does: those are the ref's targets, kept per kind in REF_ACTIONS.
|
|
9
|
+
// A plain click runs the first one available (the main target); a ref
|
|
10
|
+
// with more shows a ▾ on hover that opens a menu of all of them
|
|
11
|
+
// (ref_menu.js), and a tap opens it. A ref with a target is a tab stop
|
|
12
|
+
// (syncRefTabs): Enter is its click, ↓ its menu. A ref with none (a phone's row title:
|
|
13
|
+
// no editor, and the row is the ref's own) stays plain text; refs.js marks
|
|
14
|
+
// a hovered or focused ref with its main target (data-ref-main), which the
|
|
15
|
+
// hover styling keys on.
|
|
11
16
|
|
|
12
17
|
import { escapeHtml } from "./format.js";
|
|
18
|
+
import { copyText } from "./copy.js";
|
|
19
|
+
import { createRefMenu } from "./ref_menu.js";
|
|
13
20
|
|
|
14
|
-
// The data attributes a ref's element carries
|
|
21
|
+
// The data attributes a ref's element carries (its tab stop comes from
|
|
22
|
+
// syncRefTabs, for the viewer).
|
|
15
23
|
export function refAttrs(ref) {
|
|
16
24
|
if (!ref?.kind) return "";
|
|
17
25
|
const attr = (name, value) => (value ? ` data-ref-${name}="${escapeHtml(String(value))}"` : "");
|
|
@@ -27,29 +35,59 @@ export function editorUrl(template, path, line) {
|
|
|
27
35
|
return template.replaceAll("{path}", encoded).replaceAll("{line}", String(line || 1));
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
//
|
|
31
|
-
// click's
|
|
32
|
-
//
|
|
38
|
+
// A ref's targets, per kind, in order: the first available one is a plain
|
|
39
|
+
// click's (the main target), all of them are the menu's. Each says when
|
|
40
|
+
// it's available for +viewer+ (what this page's viewer can do: {editor,
|
|
41
|
+
// editorUrl}, the server's capabilities.editor and editor_url) and
|
|
42
|
+
// +target+ (refTarget's). A secondary one (copy) joins a ref that has
|
|
43
|
+
// another, never alone. A line (a diff or read line) has its click only.
|
|
33
44
|
export const REF_ACTIONS = Object.freeze({
|
|
34
45
|
file: Object.freeze([
|
|
46
|
+
// A link whose text is the ref (github-pr's PR diff): its href stays
|
|
47
|
+
// the plain click's, which the browser follows.
|
|
48
|
+
Object.freeze({
|
|
49
|
+
id: "link",
|
|
50
|
+
label: (target) => (/\/pull\/\d+\/files\b/.test(target.href) ? "PR diff" : "Open link"),
|
|
51
|
+
available: (_viewer, target) => !!target.href,
|
|
52
|
+
run: (target, _viewer, env) => env.openLink(target.href),
|
|
53
|
+
}),
|
|
35
54
|
Object.freeze({
|
|
36
55
|
id: "editor",
|
|
56
|
+
label: () => "Open in editor",
|
|
37
57
|
available: (viewer) => viewer?.editor === true && !!viewer.editorUrl,
|
|
38
58
|
url: (target, viewer) => editorUrl(viewer.editorUrl, target.path, target.line),
|
|
59
|
+
run(target, viewer, env) {
|
|
60
|
+
const url = this.url(target, viewer);
|
|
61
|
+
if (url) env.open(url);
|
|
62
|
+
},
|
|
39
63
|
// The hover hint: it opens the file as it is now.
|
|
40
64
|
hint: (target) => `Open the current file in your editor${target.line ? ` at line ${target.line}` : ""}`,
|
|
41
65
|
}),
|
|
66
|
+
// The row that touched the file, in the turn: not for a row's own
|
|
67
|
+
// title or lines (they are in the turn already).
|
|
68
|
+
Object.freeze({
|
|
69
|
+
id: "show",
|
|
70
|
+
label: () => "Show in turn",
|
|
71
|
+
available: (_viewer, target) => !target.isLine && !target.owner?.matches?.(".activity-row") && !!turnRowFor(target),
|
|
72
|
+
run: (target, _viewer, env) => env.show(target),
|
|
73
|
+
hint: () => "Show the row that touched it in the turn",
|
|
74
|
+
}),
|
|
75
|
+
Object.freeze({
|
|
76
|
+
id: "copy",
|
|
77
|
+
label: () => "Copy path",
|
|
78
|
+
secondary: true,
|
|
79
|
+
available: (_viewer, target) => !target.isLine && !!target.path,
|
|
80
|
+
run: (target, _viewer, env) => env.copy(target.path),
|
|
81
|
+
}),
|
|
42
82
|
]),
|
|
43
83
|
});
|
|
44
84
|
|
|
45
|
-
// The
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
export function availableKinds(viewer, actions = REF_ACTIONS) {
|
|
52
|
-
return Object.keys(actions).filter((kind) => refAction(kind, viewer, actions));
|
|
85
|
+
// The targets +viewer+ has for +target+ (refTarget's), in order; none
|
|
86
|
+
// when only a secondary one is.
|
|
87
|
+
export function refActions(target, viewer, actions = REF_ACTIONS) {
|
|
88
|
+
if (!target) return [];
|
|
89
|
+
const found = (actions[target.kind] || []).filter((action) => action.available(viewer, target));
|
|
90
|
+
return found.some((action) => !action.secondary) ? found : [];
|
|
53
91
|
}
|
|
54
92
|
|
|
55
93
|
// What owns a ref and its diff lines: a tool row, or an edit's approval
|
|
@@ -59,23 +97,25 @@ const OWNERS = ".activity-row, .bubble.approval";
|
|
|
59
97
|
// A line of the owner's file: a diff line, or a read row's numbered line.
|
|
60
98
|
const LINES = ".diff-line[data-line], .read-line[data-line]";
|
|
61
99
|
|
|
62
|
-
// What a click on +el+ points at: {kind, path, line, owner
|
|
63
|
-
// title (or an approval card's path) opens its file
|
|
64
|
-
// named (an edit without any at its first changed
|
|
65
|
-
// hunk header) or a read row's numbered line, in
|
|
66
|
-
// ref, opens that file at that line.
|
|
100
|
+
// What a click on +el+ points at: {kind, path, line, owner, el, isLine,
|
|
101
|
+
// href} or null. A row title (or an approval card's path) opens its file
|
|
102
|
+
// at the lines the call named (an edit without any at its first changed
|
|
103
|
+
// line); a diff line (or a hunk header) or a read row's numbered line, in
|
|
104
|
+
// a row or a card with a ref, opens that file at that line. +el+ is the
|
|
105
|
+
// ref's element (the line for a line), +href+ a link ref's.
|
|
67
106
|
// A diff line elsewhere is none.
|
|
68
107
|
export function refTarget(el) {
|
|
69
108
|
const title = el?.closest?.("[data-ref-kind]");
|
|
70
109
|
if (title) {
|
|
71
110
|
const owner = title.closest(OWNERS);
|
|
72
|
-
|
|
111
|
+
const href = title.matches("a[href]") ? title.getAttribute("href") : null;
|
|
112
|
+
return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title, el: title, isLine: false, href };
|
|
73
113
|
}
|
|
74
114
|
const diffLine = el?.closest?.(LINES);
|
|
75
115
|
const owner = diffLine?.closest(OWNERS);
|
|
76
116
|
const ref = owner?.querySelector("[data-ref-kind]");
|
|
77
117
|
if (!ref) return null;
|
|
78
|
-
return { ...targetOf(ref), line: Number(diffLine.dataset.line), owner };
|
|
118
|
+
return { ...targetOf(ref), line: Number(diffLine.dataset.line), owner, el: diffLine, isLine: true, href: null };
|
|
79
119
|
}
|
|
80
120
|
|
|
81
121
|
function targetOf(el) {
|
|
@@ -89,6 +129,99 @@ function firstChange(row) {
|
|
|
89
129
|
return line ? Number(line.dataset.line) : null;
|
|
90
130
|
}
|
|
91
131
|
|
|
132
|
+
// The tool row that touched +target+'s file, for "show in turn", or null:
|
|
133
|
+
// a row with a ref to the same file that isn't the ref's own. Looked for
|
|
134
|
+
// in the ref's own step (a step's text comes before its rows), then its
|
|
135
|
+
// own turn (turnBlockOf), then the rows before it, then the nearest after
|
|
136
|
+
// it; in the first of those that has any, the last one showing the ref's
|
|
137
|
+
// line, else the last one (after it: the nearest).
|
|
138
|
+
export function turnRowFor(target) {
|
|
139
|
+
const el = target?.el;
|
|
140
|
+
if (!el?.ownerDocument || !target.path) return null;
|
|
141
|
+
const rows = [...el.ownerDocument.querySelectorAll(".activity-row")].filter((row) => {
|
|
142
|
+
if (row.contains(el)) return false;
|
|
143
|
+
const ref = row.querySelector("[data-ref-kind]");
|
|
144
|
+
return ref?.dataset.refKind === target.kind && ref.dataset.refPath === target.path;
|
|
145
|
+
});
|
|
146
|
+
const step = el.closest("details.gen");
|
|
147
|
+
const turn = turnBlockOf(el);
|
|
148
|
+
const before = rows.filter((row) => precedes(row, el));
|
|
149
|
+
const pools = [
|
|
150
|
+
step ? rows.filter((row) => step.contains(row)) : [],
|
|
151
|
+
turn ? rows.filter((row) => turn.contains(row)) : [],
|
|
152
|
+
before,
|
|
153
|
+
rows.filter((row) => !before.includes(row)).reverse(),
|
|
154
|
+
];
|
|
155
|
+
const pool = pools.find((found) => found.length);
|
|
156
|
+
if (!pool) return null;
|
|
157
|
+
const showing = target.line ? pool.filter((row) => lineIn(row, target.line)) : [];
|
|
158
|
+
return showing.at(-1) || pool.at(-1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// What holds +el+'s turn's rows: the steps block it is in, the stage (a
|
|
162
|
+
// live turn: its answer comes before its cloud), else the nearest steps
|
|
163
|
+
// block before it with no prompt between them (an answer, a card, in the
|
|
164
|
+
// history). null when there is none.
|
|
165
|
+
function turnBlockOf(el) {
|
|
166
|
+
const own = el.closest(".turn-work, .turn-stage");
|
|
167
|
+
if (own) return own;
|
|
168
|
+
const doc = el.ownerDocument;
|
|
169
|
+
const block = [...doc.querySelectorAll(".turn-work")].filter((b) => precedes(b, el)).at(-1);
|
|
170
|
+
if (!block) return null;
|
|
171
|
+
const prompt = [...doc.querySelectorAll(".bubble.user")].some((u) => precedes(block, u) && precedes(u, el));
|
|
172
|
+
return prompt ? null : block;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// +a+ comes before +b+ in the page (and doesn't hold it).
|
|
176
|
+
function precedes(a, b) {
|
|
177
|
+
return !a.contains(b) && !!(a.compareDocumentPosition(b) & 4 /* Node.DOCUMENT_POSITION_FOLLOWING */);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// +row+'s line +line+: an added or context diff line first (a removed one
|
|
181
|
+
// shares its number), then a read line; null when it doesn't show it.
|
|
182
|
+
function lineIn(row, line) {
|
|
183
|
+
for (const selector of [".diff-line.diff-add", ".diff-line.diff-ctx", ".read-line", ".diff-line"]) {
|
|
184
|
+
const found = row.querySelector(`${selector}[data-line="${Number(line)}"]`);
|
|
185
|
+
if (found) return found;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// "Show in turn": opens what hides the row that touched +target+'s file
|
|
191
|
+
// (the turn's steps, its step), its diff (or the read that shows the
|
|
192
|
+
// line), scrolls to the line (else the row) and flashes both. A view that
|
|
193
|
+
// hides its rows its own way (the stage's cloud) opens on the row's
|
|
194
|
+
// ref:reveal event, which bubbles. The row, or null when there is none.
|
|
195
|
+
export function showInTurn(target, { behavior = smoothScroll() } = {}) {
|
|
196
|
+
const row = turnRowFor(target);
|
|
197
|
+
if (!row) return null;
|
|
198
|
+
const view = row.ownerDocument.defaultView || globalThis;
|
|
199
|
+
row.dispatchEvent(new view.CustomEvent("ref:reveal", { bubbles: true }));
|
|
200
|
+
const line = target.line ? lineIn(row, target.line) : null;
|
|
201
|
+
const fold = line?.closest("details.activity-diff, details.activity-read") || row.querySelector("details.activity-diff");
|
|
202
|
+
for (let details = (line || fold || row).closest("details"); details; details = details.parentElement?.closest("details")) {
|
|
203
|
+
details.open = true;
|
|
204
|
+
}
|
|
205
|
+
if (fold) fold.open = true;
|
|
206
|
+
(line || row).scrollIntoView?.({ block: "center", behavior });
|
|
207
|
+
for (const el of [row, line].filter(Boolean)) flash(el);
|
|
208
|
+
return row;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const FLASH_MS = 1600;
|
|
212
|
+
|
|
213
|
+
function flash(el) {
|
|
214
|
+
el.classList.remove("ref-flash");
|
|
215
|
+
void el.offsetWidth; // restarts the animation on a second show
|
|
216
|
+
el.classList.add("ref-flash");
|
|
217
|
+
clearTimeout(el._refFlash);
|
|
218
|
+
el._refFlash = setTimeout(() => el.classList.remove("ref-flash"), FLASH_MS);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function smoothScroll() {
|
|
222
|
+
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
|
|
223
|
+
}
|
|
224
|
+
|
|
92
225
|
// A drag that selected text in the clicked ref's row isn't a click on the
|
|
93
226
|
// ref. A selection elsewhere on the page (or a collapsed one) doesn't count.
|
|
94
227
|
function selectingIn(owner, selection) {
|
|
@@ -102,42 +235,129 @@ function defaultOpen(url) {
|
|
|
102
235
|
else window.location.href = url;
|
|
103
236
|
}
|
|
104
237
|
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
238
|
+
// A link ref's href from the menu: a new tab, as the answer's links open.
|
|
239
|
+
function defaultOpenLink(href) {
|
|
240
|
+
window.open(href, "_blank", "noopener");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// One delegated listener set on +root+ for every ref on the page.
|
|
244
|
+
// +viewer+() is read per click, key and hover, so a capabilities change
|
|
245
|
+
// (another session, a reload) is seen. The targets run through: +open+(url)
|
|
246
|
+
// (the editor's URL), +openLink+(href), +show+(target) and +copy+(text).
|
|
247
|
+
// +menu+ is the ▾ and its menu (ref_menu.js).
|
|
248
|
+
export function installRefClicks(root, viewer, {
|
|
249
|
+
open = defaultOpen, openLink = defaultOpenLink, show = (target) => showInTurn(target), copy = copyText,
|
|
250
|
+
selection = () => window.getSelection?.(), menu = createRefMenu(root.ownerDocument),
|
|
251
|
+
} = {}) {
|
|
252
|
+
const env = { open, openLink, show, copy };
|
|
253
|
+
const run = (action, target) => action.run(target, viewer(), env);
|
|
254
|
+
const items = (actions, target) => actions.map((action) => ({ id: action.id, label: action.label(target), run: () => run(action, target) }));
|
|
255
|
+
const openMenu = (target, actions, options) => menu.open(target.el, items(actions, target), options);
|
|
256
|
+
// The last pointer's type: a tap opens the menu, a mouse click runs the
|
|
257
|
+
// main target.
|
|
258
|
+
let pointer = "mouse";
|
|
259
|
+
root.addEventListener("pointerdown", (e) => { pointer = e.pointerType || "mouse"; }, true);
|
|
260
|
+
// A key press: the keyboard leads now (Enter on a link after a tap is
|
|
261
|
+
// the link's own click); Tab works out the tab stops before it moves.
|
|
262
|
+
root.addEventListener("keydown", (e) => {
|
|
263
|
+
pointer = "keyboard";
|
|
264
|
+
if (e.key === "Tab") syncRefTabs(root, viewer());
|
|
265
|
+
}, true);
|
|
266
|
+
|
|
109
267
|
root.addEventListener("click", (e) => {
|
|
110
268
|
if (e.button !== 0 || e.defaultPrevented) return;
|
|
111
269
|
const target = refTarget(e.target);
|
|
112
270
|
if (!target) return;
|
|
113
|
-
const
|
|
114
|
-
if (!
|
|
271
|
+
const actions = refActions(target, viewer());
|
|
272
|
+
if (!actions.length) return;
|
|
115
273
|
if (selectingIn(target.owner, selection())) return;
|
|
116
|
-
|
|
117
|
-
|
|
274
|
+
if (pointer === "touch" && !target.isLine && actions.length > 1) {
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
e.stopPropagation();
|
|
277
|
+
openMenu(target, actions, { focus: false });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// A link's own click: the browser follows it.
|
|
281
|
+
if (actions[0].id === "link") return;
|
|
118
282
|
e.preventDefault();
|
|
119
283
|
e.stopPropagation();
|
|
120
|
-
|
|
284
|
+
run(actions[0], target);
|
|
121
285
|
});
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
root.addEventListener("
|
|
286
|
+
|
|
287
|
+
// A focused ref: Enter is its main target (a link's Enter is its own
|
|
288
|
+
// click), ↓ opens the menu with the first target focused, Esc hides the ▾.
|
|
289
|
+
root.addEventListener("keydown", (e) => {
|
|
290
|
+
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
|
291
|
+
const el = e.target;
|
|
292
|
+
if (!el?.matches?.("[data-ref-kind]")) return;
|
|
293
|
+
if (e.key === "Escape") menu.hideMore();
|
|
294
|
+
if (e.key !== "Enter" && e.key !== "ArrowDown") return;
|
|
295
|
+
const target = refTarget(el);
|
|
296
|
+
const actions = refActions(target, viewer());
|
|
297
|
+
if (!actions.length) return;
|
|
298
|
+
if (e.key === "Enter") {
|
|
299
|
+
if (actions[0].id === "link") return;
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
run(actions[0], target);
|
|
302
|
+
} else if (actions.length > 1) {
|
|
303
|
+
e.preventDefault();
|
|
304
|
+
openMenu(target, actions, { focus: true });
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// On hover or focus: the ref's main target on it (data-ref-main, for the
|
|
309
|
+
// styling), its hover hint, and the ▾ when it has more than one (not
|
|
310
|
+
// after a tap, which opens the menu itself).
|
|
311
|
+
const mark = (e) => {
|
|
126
312
|
const target = refTarget(e.target);
|
|
127
313
|
if (!target) return;
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
314
|
+
const actions = refActions(target, viewer());
|
|
315
|
+
const el = target.el;
|
|
316
|
+
if (actions.length) el.dataset.refMain = actions[0].id;
|
|
317
|
+
else delete el.dataset.refMain;
|
|
318
|
+
if (!target.isLine) setRefRole(el, actions);
|
|
319
|
+
hint(el, target, actions[0]);
|
|
320
|
+
if (!target.isLine && actions.length > 1 && pointer !== "touch") menu.showMore(el, () => openMenu(refTarget(el), refActions(refTarget(el), viewer()), { focus: true }));
|
|
321
|
+
};
|
|
322
|
+
root.addEventListener("mouseover", mark);
|
|
323
|
+
root.addEventListener("focusin", mark);
|
|
324
|
+
const leave = (e) => {
|
|
325
|
+
const el = e.target?.closest?.("[data-ref-kind]");
|
|
326
|
+
if (el && !el.contains(e.relatedTarget)) menu.hideMoreSoon(el);
|
|
327
|
+
};
|
|
328
|
+
root.addEventListener("mouseout", leave);
|
|
329
|
+
root.addEventListener("focusout", leave);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// The tab stops under +root+ for +viewer+: a ref is one (a button, with
|
|
333
|
+
// aria-haspopup when it has a menu) only while it has a target, so a
|
|
334
|
+
// phone's or an editorless viewer's row titles aren't. Worked out before a
|
|
335
|
+
// Tab moves the focus and on a ref's hover or focus; a line never is one.
|
|
336
|
+
export function syncRefTabs(root, viewer) {
|
|
337
|
+
for (const el of root.querySelectorAll("[data-ref-kind]")) setRefRole(el, refActions(refTarget(el), viewer));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function setRefRole(el, actions) {
|
|
341
|
+
if (actions.length > 1) el.setAttribute("aria-haspopup", "menu");
|
|
342
|
+
else el.removeAttribute("aria-haspopup");
|
|
343
|
+
// A link is focusable and a link already.
|
|
344
|
+
if (el.matches("a[href]")) return;
|
|
345
|
+
if (actions.length) {
|
|
346
|
+
el.tabIndex = 0;
|
|
347
|
+
el.setAttribute("role", "button");
|
|
348
|
+
} else {
|
|
349
|
+
el.removeAttribute("tabindex");
|
|
350
|
+
el.removeAttribute("role");
|
|
351
|
+
}
|
|
137
352
|
}
|
|
138
353
|
|
|
139
|
-
// The
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
354
|
+
// The hover hint, on the ref's own element, worked out on each hover (the
|
|
355
|
+
// viewer can change): its own title (a row title's hover) first, then the
|
|
356
|
+
// main target's hint when it has one.
|
|
357
|
+
function hint(el, target, action) {
|
|
358
|
+
if (el.dataset.refTitle === undefined) el.dataset.refTitle = el.getAttribute("title") || "";
|
|
359
|
+
const own = el.dataset.refTitle;
|
|
360
|
+
const title = [own, action?.hint ? action.hint(target) : ""].filter(Boolean).join("\n");
|
|
361
|
+
if (title) el.setAttribute("title", title);
|
|
362
|
+
else el.removeAttribute("title");
|
|
143
363
|
}
|
|
@@ -106,6 +106,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
106
106
|
let flash = null; // { kind, text, until } a notice or nudge in the trail
|
|
107
107
|
let collapsed = readCollapsed();
|
|
108
108
|
let cloudOpen = false;
|
|
109
|
+
// A ref's "show in turn" opened a collapsed stage (not kept).
|
|
110
|
+
let revealed = false;
|
|
109
111
|
let rafPending = false;
|
|
110
112
|
let foldTimer = null;
|
|
111
113
|
let watchTimer = null;
|
|
@@ -326,6 +328,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
326
328
|
|
|
327
329
|
function setCollapsed(value) {
|
|
328
330
|
collapsed = value;
|
|
331
|
+
revealed = false;
|
|
329
332
|
try { localStorage.setItem(COLLAPSE_KEY, value ? "1" : "0"); } catch (_) {}
|
|
330
333
|
applyCollapsed();
|
|
331
334
|
}
|
|
@@ -370,6 +373,19 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
370
373
|
else setCloud(!cloudOpen);
|
|
371
374
|
});
|
|
372
375
|
headline.addEventListener("click", peek);
|
|
376
|
+
// A ref's "show in turn" (refs.js) names a row in the cloud: open the
|
|
377
|
+
// stage and the cloud to it, as a hand toggle would.
|
|
378
|
+
// A collapsed stage opens for this turn only: the kept choice
|
|
379
|
+
// (chi_stage_collapsed) is left alone and comes back at the release.
|
|
380
|
+
stage.addEventListener("ref:reveal", (e) => {
|
|
381
|
+
if (!cloudBody.contains(e.target)) return;
|
|
382
|
+
if (collapsed) {
|
|
383
|
+
collapsed = false;
|
|
384
|
+
revealed = true;
|
|
385
|
+
applyCollapsed();
|
|
386
|
+
}
|
|
387
|
+
setCloud(true);
|
|
388
|
+
});
|
|
373
389
|
headline.addEventListener("keydown", (e) => {
|
|
374
390
|
if (e.key === "Enter" || e.key === " ") {
|
|
375
391
|
e.preventDefault();
|
|
@@ -491,6 +507,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
491
507
|
settled = true;
|
|
492
508
|
view.reset();
|
|
493
509
|
emptySlots();
|
|
510
|
+
if (revealed) {
|
|
511
|
+
revealed = false;
|
|
512
|
+
collapsed = readCollapsed();
|
|
513
|
+
applyCollapsed();
|
|
514
|
+
}
|
|
494
515
|
}
|
|
495
516
|
|
|
496
517
|
// Everything the stage holds, into the history's end in the turn view's
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -219,8 +219,8 @@ module Samagotchi
|
|
|
219
219
|
@bridge.queue_command(line, client_id: client_id, after_file: after_file)
|
|
220
220
|
})
|
|
221
221
|
# /model's default is the config's, as in the REPL; the Engine started
|
|
222
|
-
# on the session's model.
|
|
223
|
-
@default_model = ModelProfile.
|
|
222
|
+
# on the session's model. nil when there is no default.model.
|
|
223
|
+
@default_model = ModelProfile.default_model_name
|
|
224
224
|
@commands = SessionCommands.new(engine: @engine, turn_flow: @turn_flow,
|
|
225
225
|
default_model: @default_model, registry: @engine.command_registry,
|
|
226
226
|
save: ->(session) { session.save(state_dir: @state_dir) })
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.58.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -562,6 +562,7 @@ files:
|
|
|
562
562
|
- lib/samagotchi/web/public/question_cards.js
|
|
563
563
|
- lib/samagotchi/web/public/read_view.js
|
|
564
564
|
- lib/samagotchi/web/public/reasoning_history_chip.js
|
|
565
|
+
- lib/samagotchi/web/public/ref_menu.js
|
|
565
566
|
- lib/samagotchi/web/public/ref_scan.js
|
|
566
567
|
- lib/samagotchi/web/public/refs.js
|
|
567
568
|
- lib/samagotchi/web/public/restore.js
|