diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70e86a4..9178534 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,6 +85,9 @@ jobs: license "MIT" on_macos do + # RemoteUI popup helper (own repo/formula): A3S-Lab/WebView. Pulled + # in automatically so the inline OS popup works out of the box. + depends_on "a3s-lab/tap/a3s-webview" on_arm do url "${base}/a3s-${tag}-aarch64-apple-darwin.tar.gz" sha256 "${mac_arm}" diff --git a/Cargo.lock b/Cargo.lock index 9ebf357..59109f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,13 +4,14 @@ version = 4 [[package]] name = "a3s" -version = "0.5.12" +version = "0.5.13" dependencies = [ "a3s-code-core", "a3s-tui", "anyhow", "async-trait", "base64 0.22.1", + "chrono", "futures", "image", "rand 0.8.6", @@ -171,11 +172,12 @@ dependencies = [ [[package]] name = "a3s-tui" version = "0.1.4" -source = "git+https://github.com/A3S-Lab/TUI.git?rev=be0ed1864a9a9f27ecf9999a98e731e51e7fa569#be0ed1864a9a9f27ecf9999a98e731e51e7fa569" +source = "git+https://github.com/A3S-Lab/TUI.git?rev=091f6ba172bf47a9c38c57d96f19a53cbb74c6a3#091f6ba172bf47a9c38c57d96f19a53cbb74c6a3" dependencies = [ "comrak", "crossterm", "futures-util", + "similar", "syntect", "taffy", "tokio", @@ -4042,9 +4044,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", "serde_spanned", @@ -4054,33 +4056,26 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ "indexmap", "serde", "serde_spanned", "toml_datetime", - "toml_write", "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "tower" version = "0.5.3" @@ -4860,9 +4855,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.15" +version = "0.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 5914c10..1af2e5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s" -version = "0.5.12" +version = "0.5.13" edition = "2021" description = "a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI" license = "MIT" @@ -15,11 +15,12 @@ path = "src/main.rs" [dependencies] a3s-code-core = { git = "https://github.com/AI45Lab/Code.git", rev = "e1b65e8fd5dc3cf392d073173c37213669d0f782" } -a3s-tui = { git = "https://github.com/A3S-Lab/TUI.git", rev = "be0ed1864a9a9f27ecf9999a98e731e51e7fa569" } +a3s-tui = { git = "https://github.com/A3S-Lab/TUI.git", rev = "091f6ba172bf47a9c38c57d96f19a53cbb74c6a3" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "net"] } anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } similar = "2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] } # Codex account client (custom LlmClient over the ChatGPT backend). diff --git a/docs/knowledge-base-design.md b/docs/knowledge-base-design.md new file mode 100644 index 0000000..3ae67b7 --- /dev/null +++ b/docs/knowledge-base-design.md @@ -0,0 +1,282 @@ +# a3s code TUI — Knowledge Base ("Vault") design + +> Status: design proposal (for maintainer review). Audience: a3s maintainers. +> Method: synthesized from a 4-way design judge-panel (minimal / Obsidian-faithful / agent-native / hybrid), grounded in the live codebase. +> Scope: a project-scoped, human+agent-shared markdown knowledge base surfaced in the a3s code TUI, built as an **Extension** (CLAUDE.md Rule 2) on top of the existing `/ide` panel, the comrak markdown renderer, the skills frontmatter parser, and the agent file tools. No new core subsystem, no new root crate. +> Populated by: [knowledge-compilation.md](./knowledge-compilation.md) — the LLM-wiki compiler (the `okf` skill, invoked as `/okf`) that auto-generates cross-linked OKF pages into the vault. +> Format: **Google Open Knowledge Format (OKF v0.1)** — the *single* knowledge format throughout (markdown + a required `type` frontmatter field + standard markdown links). There is **no** parallel Obsidian-`[[wikilink]]` mechanism; the "Obsidian-like" framing below means "a browsable markdown vault," realized as OKF. + +--- + +## Motivation & first-principles scope + +### Why a coding agent needs a KB at all + +A coding agent accumulates durable, project-specific knowledge that does not belong in code comments and does not belong in episodic memory logs: architecture rationale, "why we did it this way" decisions, gotchas, and the map between concepts and the files that implement them. Today that knowledge has two bad homes — it is either lost between sessions or dumped into `~/.a3s/memory/` (per-user, append-only, header-keyed, not human-curated). A KB gives it a **third home that is project-scoped, git-committable, human-editable, and agent-readable**: a folder of plain markdown notes in the workspace. + +The differentiator versus Obsidian: the same `.a3s/kb/*.md` files are a **shared substrate** — human-edited in the TUI, agent-written via existing file tools, agent-read as prompt context. It is not a human-only notebook, and it is not the agent's private memory. That dual-use, plus OKF markdown links that connect notes to real workspace code files, is the only reason this feature earns its place against Rule 1. + +### First-principles gate (Rule 1) + +1. **Core mission:** a coding agent that is trustworthy and effective in a real workspace. +2. **Does this serve it?** Yes — durable, searchable, linkable project knowledge that both the human and the agent read/write, with note↔code links no other store has. +3. **Architecture impact:** *strengthens* it only if it stays an Extension composed from existing pieces. It *weakens* it the moment it grows a bespoke graph DB, a plugin runtime, or its own editor — those are hard stops. +4. **Real or hypothetical?** Real: there is no project-scoped, human-curated, linkable note store today (verified — see "what we drop"). +5. **Simpler alternative?** The simplest useful version is "a folder of `.md` + a markdown read-mode bolted onto the panel we already have." That is precisely the MVP. Everything beyond it is phased and independently gate-able. + +### Obsidian features we deliberately DROP, and why + +| Dropped feature | Reason | +|---|---| +| **Global / force-directed graph view** | A string-grid TEA terminal cannot legibly render it (`render_ide` budgets a 1/3-width tree + a width-truncated right pane). An agent navigates by search + tree + backlinks, not by staring at a node cloud. Backlinks answer every "what connects to this" question. | +| **1-hop ASCII "local graph"** | Considered and **rejected**. It is honestly just a neighbor list with arrows — net-new overlay code for marginal value. The backlinks list is the honest, sufficient form. | +| **Canvas / whiteboard** | Pure spatial GUI affordance with no ANSI representation. Orthogonal to the mission. | +| **Live-preview WYSIWYG** | We already inherit a cleaner split from `/ide`: rendered read-mode (`Markdown::render`) ⇄ raw vim edit-mode (the `IdeFile` engine), toggled by one key. Re-running comrak per keystroke and merging it with cursor-accurate editing is a large build for negative clarity. | +| **Daily notes / calendar / periodic notes** | **DRY violation.** The agent memory layer already writes journal-style `memory/YYYY-MM-DD.md` daily logs (`crates/memory/src/sqlite/markdown.rs`). A coding KB is topic-keyed (architecture, gotchas), not date-keyed. We do not duplicate the journal. | +| **Templates / Templater JS** | Arbitrary templating is a scope+security hole. If a note needs scaffolding, ask the agent — it already authors files. | +| **Community plugin ecosystem** | a3s already has a skills/plugins system. A second plugin runtime violates Minimal-Core. KB extensibility is the `MemoryStore` / `ContextProvider` traits + skills. | +| **Sync / publish / encryption** | The vault is a git-tracked workspace folder; `git` (and the existing `/git` panel) is the sync, versioning, and publish mechanism. | +| **Dataview / properties query DSL** | A whole sub-product. Tag filter (P1) + full-text/semantic search (P2) covers the real need. | +| **Embeds / transclusion `![[ ]]`** | Recursive render + cycle handling for marginal benefit. Following a link covers reuse-of-content. YAGNI until asked. | +| **Themes / per-note CSS** | `/theme` already cycles the syntect theme the comrak renderer respects (`SYNTAX_THEME`, `syntax.rs:156`). Per-note CSS is meaningless in ANSI rows. | +| **PDF / media / attachments** | Coding notes are text. Only the half-block image preview path (`ide.rs:121-128`) exists, and that is enough. | + +--- + +## Architecture + +### Where the vault lives + +**Default vault root: `/.a3s/kb/`.** + +This is a deliberate, verified choice: + +- **Inside the workspace backend** so the agent's sandboxed file tools can write it. Agent file I/O is normalized through `ctx.resolve_workspace_path` (`crates/code/core/src/tools/types.rs:121`); a vault *outside* the workspace backend is silently unreachable by the agent. This is a hard constraint. +- **Under `.a3s/`** (alongside the already-committed `.a3s/agents/` and `.a3s/skills/`, discovered by the same cwd walk-up at `crates/cli/src/tui/config.rs:56-79`) so the feature is opt-in, project-scoped, **committable/team-shared**, and does not pollute the repo root. +- **Chosen over `~/.a3s/`** so it is project-scoped and survives clone, and **kept strictly separate from `~/.a3s/memory/`** (the per-user, append-only agent memory). Different formats, different owners. **Do not fuse them.** +- Path is overridable via a `kb_dir` key in `.a3s/config.acl` (HCL/`.acl` preferred over TOML, per AGENTS.md). Created on first `/kb` if missing. + +The `.a3s/kb/` directory is visible to the `/ide` tree walker (`ide_children`, `mod.rs:630`, which seeds from cwd and does not skip `.a3s/`) with **zero new wiring**. + +### On-disk layout & note format + +``` +.a3s/kb/ +├── architecture.md +├── gotchas/ +│ └── libkrun-env-quoting.md +└── decisions/ + └── why-microvm-not-container.md +``` + +- One plain CommonMark `.md` file per concept. Nesting allowed (the tree handles it for free). +- **The vault format is Google's Open Knowledge Format (OKF v0.1)** — the single, + vendor-neutral knowledge format for this feature (see + [knowledge-compilation.md](./knowledge-compilation.md)). OKF requires exactly one + frontmatter field — `type` — plus standard optional fields, parsed by the + **existing** skills technique (`splitn(3, "---")` + `serde_yaml`, + `crates/code/core/src/skills/mod.rs:145`): + +```yaml +--- +type: Architecture Decision # REQUIRED by OKF +title: Why microVM, not container +description: Rationale for libkrun MicroVMs over containers. +resource: crates/box/src/runtime.rs # the concept's canonical source (path or URL) +tags: [architecture, security] +timestamp: 2026-06-30T12:00:00Z # ISO 8601 +source: compiled | user # provenance (see Risks) +--- +``` + +The body is normal CommonMark with **standard markdown links** (the OKF graph). + +### Links model — OKF standard markdown links + +OKF turns the directory into a graph via **normal markdown links** — there is no +`[[wikilink]]` syntax, so comrak renders the links natively (no separate +extraction pass, no renderer fork). A `[[wikilink]]` engine is explicitly **not** +built — one knowledge mechanism, OKF. + +- **Concept links**: `[name](/dir/other.md)` (bundle-relative) connect concepts. + "The file path is the concept's identity" (OKF), so resolution is just the path; + an `index.md` per directory provides hierarchical navigation (OKF reserved name). +- **Note↔code links** (the coding-agent differentiator): a markdown link to a + workspace file, e.g. `[runtime.rs](crates/box/src/runtime.rs#L42)`, opens that + file **read-only** in the same viewer. No other knowledge store has this edge. +- **Backlinks & search are computed on demand with `rg`, NOT a persistent index.** + A backlink index is a cache you must invalidate on every edit; `rg` over a + personal-scale vault is sub-100 ms. Backlinks = `rg -l --fixed-strings + ".md" .a3s/kb/`; search = `rg -n --color=never .a3s/kb/`. Reach for + a stored, feature-gated index only when a **profiler** (not a hypothesis) demands it. +- **Following links from the rendered view**: read-mode ANSI rows are + width-wrapped and lose source byte positions, so link-follow runs off a parallel + extraction of the raw body, surfaced as a **numbered strip** followed with + **digit keys** (reusing the existing HITL digit-key precedent) — sidestepping + cursor hit-testing in width-wrapped CJK ANSI. +- **Tags**: frontmatter `tags:` plus inline `#tag` as plain text. `rg '#tag' .a3s/kb/` + enumerates them. No tag DB, no tag pane in P0/P1. + +--- + +## TUI integration + +The KB **is** the `/ide` panel, re-seeded at the vault root, with a markdown read-mode. Reuse is near-total. + +### Command plumbing + +- Add one entry to `SLASH_COMMANDS` (`crates/cli/src/tui/mod.rs:103`): + `("/kb", "browse/edit the project knowledge base")`. +- Add a `"/kb" =>` arm to the `match trimmed` dispatch (`mod.rs:2730`), mirroring the ~12-line `/ide` arm at `mod.rs:2898`. It calls a new `open_kb_in_ide(root)` helper cloned from `open_config_in_ide` (`mod.rs:3641`) / `open_readonly_in_ide` (`mod.rs:3674`), seeding `entries` from `.a3s/kb/` (created if absent) instead of `self.cwd`. +- `/kb` is read/edit-only and does not mutate the conversation, so it need **not** be added to `IDLE_ONLY` (`mod.rs:145`). + +### Reused code (exact references) + +| What | Where | Reused for | +|---|---|---| +| `Ide` / `IdeFile` / `IdeEntry` / `EditMode` structs | `mod.rs:619 / :360 / :343 / :354` | The note browser + buffer model, verbatim | +| `ide_key` (tree + editor dispatch) | `panels/ide.rs:7` | Tree nav, Esc layering, Tab-to-editor, open branch | +| `IdeFile::edit_key` vim engine (Normal/Insert, motions, `dd`/`yy`/`p`, undo, yank, multibyte-safe) | `panels/ide.rs:398` (impl `:265-779`, tests `:781-935`) | The note editor — **no new editing code** | +| `render_ide` split-pane renderer (1/3 tree clamped 16..38 cols + right viewer + footer hint) | `panels/ide.rs:154` | The two-pane vault UI | +| Ctrl+S save + `touch_workspace_file_path_for_manifest` | `panels/ide.rs:50-72`, `mod.rs:1143` | Note save; auto-registers in the "recently touched" manifest → free "recent notes" affordance | +| `ide_children` dir walker (skips `.git`/`target`/etc., dirs-first sort) | `mod.rs:630` | The vault tree source | +| `a3s_tui::markdown::Markdown::render` (comrak + syntect; tables/tasklist/headings) | `crates/tui/src/markdown.rs:40` (`with_width :30`, `with_theme :35`) | **Read-mode rendering** — closes the gap where `/ide` shows `.md` as plain text | + +### The single behavioral delta vs `/ide` + +`lang_of` (`syntax.rs:5`) has **no markdown case**, so `/ide` shows `.md` as plain highlighted text. The KB open branch instead routes the `.md` body through `Markdown::render` once on open. + +To get read-mode behavior with **the most surgical change possible** (grafted from the Agent-native proposal — cleaner than a whole new `NoteView` mode enum): add **one field** to `IdeFile` (`mod.rs:360`): + +```rust +rendered: bool, // pre-rendered ANSI read-mode buffer (markdown), not editable +``` + +OR it into the **two existing `f.image` checks**: +1. the "show raw rows / no line numbers" render branch (`panels/ide.rs:228-230`), so the rendered ANSI lines display as-is; and +2. the "block edits" key branch (`panels/ide.rs:76`), so the buffer is read-only. + +A rendered markdown buffer then behaves exactly like the image preview without being one. `e` (or Tab) re-creates the buffer from the raw file as a normal editable `IdeFile` to drop into vim edit-mode; Esc returns to the tree. + +**Render once, never per frame.** (Grafted, to avoid the known O(render-per-frame) jitter trap on resize/large notes.) On open we compute `Markdown::render(raw)` a single time into the read-only buffer — the lines **are** the ANSI. We do not re-render in the draw loop. (Re-render only on explicit re-open or width change.) + +### New code, contained + +A new file `crates/cli/src/tui/panels/kb.rs` (sibling of `panels/ide.rs`, well under the 500-line limit per CLAUDE.md:377) holds `open_kb_in_ide`, the OKF link-follow pass (resolve a standard markdown link under the cursor to a bundle file or code path; `index.md` handling), and the `rg`-backed `backlinks`/`search`. `mod.rs` gains only the `SLASH_COMMANDS` entry, the dispatch arm, the `rendered` field, and `mod kb;`. P1 link rendering / numbered strips live in `kb.rs` (or a sibling `kb_links.rs` if it grows). All output is pre-wrapped width-aware ANSI — no HTML, no graph. + +### Graceful degradation + +If `rg` is not on PATH, backlinks/search set a one-line `flash` footer ("install ripgrep") rather than reinventing a recursive walker. (`rg` is already the project's search primitive — the `grep` builtin requires it.) + +--- + +## Agent integration + +Three layers, **phased**, each independently shippable. The agent needs **zero new tools**. + +### Write path (P0/P1) — convention + one skill, no new capability + +The vault is just `.a3s/kb/*.md` inside the workspace sandbox, so the agent **already** has read/write/edit/grep/glob over it via the capability-gated builtin file tools (`crates/code/core/src/tools/builtin/{write,read,edit,grep,glob_tool}.rs`, registered `builtin/mod.rs:37-58`, all routed through `ctx.resolve_workspace_path`). `WriteTool` creates the note + parent dirs. + +The only addition is a shipped built-in **`kb` SKILL.md**, loaded by the existing skills registry (`registry.rs:124` `load_from_dir`), mirroring the `crates/box/integrations/skills/` pattern. It teaches the agent the **convention**, not a capability: + +- where the vault is (`.a3s/kb/`); +- the OKF note format (required `type` frontmatter + CommonMark + standard markdown links); +- *when* to capture (a settled decision, a gotcha hit, an architecture map) and to link related notes with `[name](/dir/other.md)` and the code it touched with `[runtime.rs](crates/box/src/runtime.rs#L42)`. + +This captures the auto-curation value with **zero new tool**, which is why the skill ships in **P0/P1, not P2**. Because the links are standard markdown (which P1 follows + backlinks via `rg`), the agent participates in the OKF link graph using only `WriteTool`. + +### Read path (P2) — retrieval into context via the existing trait + +A `KbContextProvider` implements the **existing** `ContextProvider` trait (`crates/code/core/src/memory.rs:328`, exactly like `MemoryContextProvider` at `:294`). Each turn it surfaces the top 3–5 task-relevant notes into the prompt (substring + recency for the default impl; reuse `MemoryContextProvider`'s relevance/freshness shape and item→context conversion), tagged `kb://`, capped and threshold-gated to avoid token bloat. It is registered next to `MemoryContextProvider` only when `.a3s/kb/` exists, so notes auto-surface without manual `@`-mention. + +Per the typed-extension-options rule (CLAUDE.md:401), it takes a **typed `KbStore` object** (default `FsKbStore` scanning the dir; a feature-gated `MemoryStore`-backed FTS5/`sqlite-vec` index as the swap-in), never a raw `kbDir: string`. + +### Relation to the existing memory system — keep the boundary + +There are **two distinct stores and they stay distinct**: + +| | `.a3s/kb/` (this feature) | `~/.a3s/memory/` (existing) | +|---|---|---| +| Owner | human-curated, agent-assisted | agent auto-curated | +| Scope | project, git-committed | per-user, cross-project | +| Format | OKF: `type` frontmatter + CommonMark + standard markdown links | append-only `## ts · type · importance` blocks (`crates/memory/src/sqlite/markdown.rs`), **no** frontmatter, **no** links | +| Authority | the `.md` files themselves | SQLite is authoritative; `.md` is a mirror | + +**Do not fuse them.** Auto-promoting memory items into KB notes is explicitly out of scope (it would duplicate the append-only writer and risk link-less dumps). The `kb` SKILL.md and docs must state this boundary so neither the human nor the agent writes to the wrong place. + +--- + +## MVP (P0) — smallest shippable slice + +P0 is essentially "the minimal `/ide` reskin" and ships value with **zero new agent code**. Concrete task list: + +1. **`crates/cli/src/tui/mod.rs`** + - Add `("/kb", "browse/edit the project knowledge base")` to `SLASH_COMMANDS` (`:103`). + - Add a `"/kb" =>` dispatch arm (next to `/ide` at `:2898`) that resolves the vault root (`.a3s/kb/`, `kb_dir` override from `.a3s/config.acl`), `create_dir_all`s it, and calls `open_kb_in_ide(root)`. + - Add `open_kb_in_ide` (clone of `open_config_in_ide`, `:3641`) seeding `entries = ide_children(root, 0)`. + - Add the `rendered: bool` field to `IdeFile` (`:360`) and OR it into the two `f.image` checks (`panels/ide.rs:228-230` render branch, `:76` block-edits branch). + - `mod kb;` next to `mod ide;`. + +2. **`crates/cli/src/tui/panels/kb.rs`** (new file) + - `open_kb_in_ide(root)` helper + a thin `kb_children` wrapper over `ide_children` filtering to dirs + `.md` (single point of change). + - Read-mode open branch: when a `.md` is opened in the KB panel, build a `rendered`/`readonly` `IdeFile` from `Markdown::render(raw)` **once**; bind `e`/Tab to re-open the raw editable buffer; Esc returns to the tree. + - In-panel `/` substring filter over note titles + bodies (no index, no deps). + +3. **Built-in `kb` SKILL.md** (e.g. `crates/cli/.../skills/kb/SKILL.md` shipped + installable like `crates/box/integrations/skills/`) + - Teaches vault path, note format, the human-KB vs agent-memory boundary, when to capture, how to link. Agent writes via existing `WriteTool` — **no new tool**. + +4. **Footer hint**: branch the `render_ide` footer (`panels/ide.rs` hint string) on the KB panel to advertise `e edit · / filter` (and, once P1 lands, `b backlinks`). + +5. **Tests** (`cargo test -p a3s-cli`, fmt + clippy clean): `kb_children` filters to dirs+`.md`; frontmatter parse round-trips; read-mode renders a sample note; Ctrl+S writes + touches the manifest. + +**P0 explicitly has NO link-following, NO backlinks, NO graph, NO new agent tools, NO context provider** — just an OKF-vault browser/editor. + +--- + +## Roadmap (P1 / P2) — trait-based extensions + +### P1 — links & navigation (the only substantial net-new code) + +- **OKF link resolution** in `kb.rs`: parse the standard markdown links in a + concept's **raw body** (`[text](/dir/other.md)` and `[text](path/to/code.rs#Lnn)`); + resolve bundle-relative paths to files (the path is the concept identity — no + alias/title fuzzy-matching needed). comrak already renders the links; this pass + is only for *following* them. +- **Note↔code links**: `[foo](crates/box/src/runtime.rs#L42)` opens the workspace + file read-only in the same viewer. +- **Follow via numbered strip + digit keys** from the raw buffer (reuse HITL + digit-key precedent); reading view stays display-only. +- **Backlinks on demand via `rg`** (no stored index): a `b` toggle lists + `rg -l --fixed-strings ".md" .a3s/kb/` results, opened read-only; graceful + `rg`-missing flash. +- **Search on demand via `rg`**: `/kb ` → `rg -n` over the vault; Enter on a + hit opens the note. +- Unit tests for the resolver (a link span at the cursor, multiple links per line, + bundle-relative vs. code paths). + +### P2 — agent retrieval & provenance (feature-gated) + +- **`KbContextProvider`** implementing `ContextProvider` (`memory.rs:328`), registered beside `MemoryContextProvider`, top-N capped + threshold-gated, taking a typed `KbStore`. +- **Feature-gated index**: back search with a `MemoryStore` (`crates/memory/src/lib.rs:201`) — `FileMemoryStore` default (dep-free), `sqlite` FTS5 / `sqlite-vec` semantic behind Cargo features so embedders pay nothing. Only build this when a profiler shows `rg` is too slow at real vault scale. +- **Provenance & anti-reward-hacking** (carried from the Agent-native caveat): write `source: agent|user` frontmatter; expose agent-authored notes for user review via `/kb`; **keep the KB out of any self-evolution fitness signal**, since an agent that both edits and is fed the KB is a reward-hacking surface. This is a documented constraint, not optional. + +--- + +## Risks & open questions + +1. **Render-vs-source mapping (top correctness risk).** Rendered ANSI rows lose source byte positions. **Mitigated by decision:** follow links from the raw buffer only via a numbered strip; reading view is display-only. Must hold this line or link hit-testing in width-wrapped CJK ANSI gets fiddly. +2. **Render jitter.** Re-rendering comrak per frame would jitter on large notes/resize. **Mitigated:** render once into the read-only buffer on open; never per frame. +3. **`rg` dependency.** Backlinks/search shell out to `rg`. **Mitigated:** one-line "install ripgrep" flash; never reinvent a walker. +4. **OKF link edge cases:** a renamed/moved concept file leaves dangling relative links. **Mitigated:** links are plain file paths (no fuzzy stem/alias matching to get wrong), the compiler's verify step drops danglers, and `rg` finds referrers. Rename-with-link-rewrite is **not** built — the agent does it more cheaply with grep+edit on request. +5. **Context-feed noise / token bloat (P2).** Auto-surfacing notes can pad/distract. **Mitigated:** cap top-3–5, relevance threshold + freshness weighting, same tuning as `MemoryContextProvider`. +6. **Two near-identical panels (`/ide` vs `/kb`).** DRY pressure + user confusion. **Mitigated:** `kb.rs` reuses `Ide`/`IdeFile`/`ide_key`/`render_ide`; the only intended deltas are the `.md` filter, the `rendered` field, and the link passes. +7. **Two markdown "memory" systems.** Risk of writing to the wrong store. **Mitigated:** document the `.a3s/kb/` ↔ `~/.a3s/memory/` boundary in the `kb` SKILL.md and docs; never fuse. +8. **Co-curation write conflict.** An agent edit landing while the user has a note open could clobber unsaved changes. Low risk (single user); rely on the dirty/manifest model and warn on external change. +9. **Reward-hacking surface (P2).** Agent edits what it is later fed. **Mitigated:** `source` provenance + user review + KB excluded from fitness signals. +10. **Scope discipline.** The pull toward daily notes / templates / graph / dataview once the panel exists is exactly what Rule 1 guards against. Re-run the pruning audit (CLAUDE.md:367-375) after each phase; if the KB starts growing a bespoke graph DB, plugin host, or its own editor, it has failed Rule 1/2 and must be cut back. + +**Open questions for maintainers:** +- Confirm `.a3s/kb/` (committed, team-shared) over a gitignored agent-private variant — current recommendation is committed-by-default with `kb_dir` override. +- Confirm the `kb` SKILL.md ships built-in/auto-installed vs opt-in install. +- P2 only: pick `FsKbStore` (rg/scan) as the permanent default and gate the SQLite/vec index strictly behind a profiler-proven need. diff --git a/docs/knowledge-compilation.md b/docs/knowledge-compilation.md new file mode 100644 index 0000000..757297e --- /dev/null +++ b/docs/knowledge-compilation.md @@ -0,0 +1,135 @@ +# a3s code TUI — Knowledge Compilation ("LLM Wiki") + +> Status: design + shipped capability (the `okf` skill, invoked as `/okf`). Audience: a3s maintainers. +> Companion to [knowledge-base-design.md](./knowledge-base-design.md). The KB is the *store*; this is how it gets *populated* from the codebase. + +## What it is + +An **LLM-driven wiki compiler**, in the spirit of DeepWiki and conforming to +Google's **[Open Knowledge Format (OKF v0.1)](https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing)** — +which formalizes exactly this LLM-wiki pattern as "a directory of markdown files +with YAML frontmatter." The coding agent reads the project's code (and any +existing `.a3s/kb/` notes) and **compiles** an **OKF bundle** under +`.a3s/kb/wiki/`: one markdown *concept* file per module / decision / abstraction +(its file path is the concept's identity), each with a required `type` frontmatter +field, standard markdown links forming the concept graph, `[code](src/foo.rs#L42)` +links to the real source, and a per-directory `index.md` — every claim grounded in +a file the agent actually read. + +It is a **compile**, not a one-shot dump: each concept records the source files +(and a digest of them) it was built from, so a re-run only regenerates concepts +whose sources changed — a dependency-tracked, incremental rebuild. + +### Why OKF + +OKF is the natural fit because it *is* the standardized form of what this feature +already produced: vendor-neutral, agent- and human-friendly markdown — "just +markdown, just files, just YAML frontmatter." Emitting OKF makes the compiled +bundle portable (shippable as a tarball / git repo, indexable by any tool) and +interoperable with other OKF consumers. The only hard OKF requirement is a `type` +field on every concept; everything else is convention. Spec + sample bundles: +`GoogleCloudPlatform/knowledge-catalog`. + +## Why this serves a coding agent (first-principles) + +- A fresh, navigable wiki of the codebase is exactly the durable project knowledge + the [KB design](./knowledge-base-design.md) wants — **auto-generated** instead of + hand-written, so the vault is useful on day one and stays current as code moves. +- It is dual-use: the human reads it (in `/kb` or any editor), and the agent reads + it back as context (via the KB's P2 `KbContextProvider`). The agent that wrote + the wiki works better because it has the wiki. +- The note↔code links (`[name](src/…#L…)`, standard markdown) are the + coding-specific edge no general wiki tool has. + +## Architecture — the agent IS the compiler + +There is **no new core subsystem**. Compilation is a structured agent task, +driven by a bundled skill, using the agent's existing tools. The cli only +provides the skill and a trigger. + +| Piece | What | Where | +|---|---|---| +| **`okf` skill** | The compilation pipeline (survey → plan → generate → index → verify; incremental; anti-hallucination rules). A `kind: instruction` skill — this *is* the capability. | `crates/cli/skills/okf.md` | +| **Skill loader** | Always-materialized to `~/.a3s/cli-skills/okf/SKILL.md` and added to the session skill dirs — so the capability is available in every project (not login-gated, not project-local). | `src/tui/skills.rs` `ensure_builtin_skills_dir` → `skill_dirs()` (`mod.rs`) | +| **`/okf` trigger** | The skill itself surfaces in the `/` menu as **`/okf`** (selecting it asks the agent to apply the skill); it also auto-applies when the user asks for the wiki/docs in prose. *No separate slash command* — that would just duplicate the skill's menu entry. | the slash menu's skill listing (`panels/menu.rs`) | +| **Fan-out** | Pages generate concurrently via `parallel_task` when available, else sequentially. | the agent's existing `parallel_task`/`task` tools | +| **Output** | `.a3s/kb/wiki/*.md` — the KB vault's compiled subtree. | the agent's `write` tool, routed through `ctx.resolve_workspace_path` | + +## Pipeline + +1. **Survey** — map the repo (top-level dirs, manifests, module entry points, + existing `.a3s/kb/` notes). In a monorepo, each crate/package is a module concept. +2. **Plan the bundle** — a bounded concept set + directory layout (e.g. `modules/`, + `concepts/`, `decisions/`), each directory with an `index.md`, plus the root + `index.md`. Deterministic kebab-case slugs. The layout is shown to the user first. +3. **Generate** — per concept, read its sources then write an OKF file (required + `type`, the standard fields, an *explanation* with `[file](path#Lline)` code + links and `[name](/dir/other.md)` concept links), grounded entirely in what was + read. Fan out with `parallel_task`. +4. **Index** — each directory's `index.md` and the root `index.md` link every + concept (OKF's hierarchical navigation); concepts also link each other. +5. **Verify** — every markdown link must resolve; dangling links fixed or dropped. + +### Concept file format (OKF) + +```yaml +--- +type: Architecture Decision # REQUIRED by OKF (exactly one type per concept) +title: Why microVM, not container +description: Rationale for libkrun MicroVMs over containers. +resource: crates/box/src/runtime.rs # the concept's canonical source (path or URL) +tags: [architecture, security] +timestamp: 2026-06-30T12:00:00Z # ISO 8601 +# OKF permits extra fields; we keep these for incremental recompile + provenance: +source: compiled # agent-generated, NOT a human note +sources: [crates/box/src/runtime.rs, crates/box/README.md] +source_digest: +--- +``` + +Body: synthesized prose + **standard markdown links** between concepts +(`[name](/dir/other.md)`) and to code (`[runtime.rs](crates/box/src/runtime.rs#L42)`) ++ a final `## Sources` list. Navigation is OKF's reserved `index.md` per directory; +an optional top-level `log.md` holds the chronological compile history. + +### Incremental recompile + +Before regenerating a concept, the skill recomputes the digest of its `sources` +and compares it to the stored `source_digest`. Unchanged ⇒ skip; changed ⇒ rebuild +(plus the affected `index.md`). This is the "compile" semantics — cheap re-runs, a +bundle that tracks the code. + +## Guardrails + +- **Anti-hallucination** — state only what the code says, cite file+line, mark + uncertainty, never invent APIs. A hallucinated page is worse than no page. +- **Provenance & boundaries** — `source: compiled` on every page; the agent owns + `.a3s/kb/wiki/`, the human owns `.a3s/kb/*.md`; neither clobbers the other; the + agent memory (`~/.a3s/memory/`) is untouched. +- **No secrets** — never copy tokens/keys/`.env` into a page. +- **Bounded** — document modules + concepts, not every file; compile large repos + by area and report coverage. +- **Reward-hacking** — the agent generates knowledge it is later fed. Provenance + + user review (in `/kb`) mitigate it, and the compiled wiki is kept **out of any + self-evolution fitness signal** (carried from the KB P2 caveat). + +## Relation to the KB phases + +- Works **today** as the `okf` skill — invoke it via **`/okf`** (the menu entry) or + by asking in prose; it writes plain `.md`, browsable with `/ide` or any editor — + independent of the KB panel. +- **One format, end to end:** both the compiled bundle (`.a3s/kb/wiki/`) and any + human-authored notes (`.a3s/kb/*.md`) are **OKF** — standard markdown links, a + required `type` field. There is no second link syntax to reconcile; the KB's P1 + resolver follows OKF markdown links only. +- Gets better as the KB lands: P1 backlinks/search index the bundle's links so it + is navigable in `/kb`; P2 `KbContextProvider` feeds compiled concepts into the + agent's context. + +## Open questions + +- Bundle the skill always-on (current choice) vs. opt-in install? +- Should `/okf` recompile-all by default, or only stale pages? (Skill defaults to + incremental; ask in prose for a full rebuild.) +- A periodic/scheduled recompile via the agent's cron — later, once usage shows the + cadence. diff --git a/skills/a3s-os-capabilities.md b/skills/a3s-os-capabilities.md index ab83bb4..4d948fa 100644 --- a/skills/a3s-os-capabilities.md +++ b/skills/a3s-os-capabilities.md @@ -63,7 +63,7 @@ signed-in user may access. | `list` | — | every module you can access (name, description, path, operationCount) | | `search` | `query` | matching operations across modules | | `describe` | `module` (+ optional `operation`) | the module's sub-modules + its operations; or, with `operation`, just that ONE operation's full input/output schema | -| `execute` | `module`, `operation`, `params` | the operation result (`data`), plus an optional `viewUrl` deep link and `ui` agent-ui directive | +| `execute` | `module`, `operation`, `params` | the operation result (`data`), plus an optional `view` object (a console deep link + suggested popup size) and `ui` agent-ui directive | ## Rules @@ -89,17 +89,43 @@ signed-in user may access. - Prefer read/`GET`-style operations for discovery; write operations (create / update / delete) run with the user's real platform permissions — confirm intent before mutating platform state. -- **Always surface `viewUrl`.** Many responses include a `viewUrl` — a deep link - to the console page for exactly what the user asked about. WHENEVER the response - contains one, extract it robustly (it may be top-level or nested, e.g. - `jq -r '.. | .viewUrl? // empty'` over the response) and present it to the user - as a clearly labeled, clickable link on its own line — e.g. - `🔗 在控制台查看: ` (write the label in the user's language). The TUI - renders bare URLs as clickable, so include the full URL. Never fabricate a - `viewUrl` that wasn't returned, and never drop one that was. +- **Offer the `view` as an inline link — never auto-open.** Some `execute` + responses include a `view` object — `{ "url": "…?embed=1", "width": N, "height": + N }` — a focused console page sized for a popup. Two things must happen: + 1. **Keep `.view` in your command's JSON stdout** so the host can capture it — + do **not** narrow it away with `jq`; emit the full response or keep it in + your projection (e.g. `... | jq '{ data, view }'`). Never fabricate or drop + a returned `view`. + 2. **End your reply with the link on its own line, exactly:** `🔗 打开渐进式UI` + — the host turns any reply line containing `打开渐进式UI` into a one-click + trigger that opens the view in the authenticated **渐进式UI** popup (the + user's current OS login is injected — no re-login). RemoteUI is + **user-triggered**: the popup is NOT opened automatically; the user clicks + that link (or runs `/view`). Do **not** print the raw URL yourself — the link + line is the affordance. - The `ui` field (`protocol: "agent-ui"`) is a host-rendered remote component — note that it exists if present, but don't try to render it yourself. +## Learned shortcuts — shorten the chain on repeat tasks + +The `list → search → describe → execute` walk is for *discovering* an operation. +Once you've resolved one, remember it so the next similar task skips discovery. + +- **Cache:** `~/.a3s/os-learned.md` (per-user). At the **start** of an OS task, + read it — `cat ~/.a3s/os-learned.md 2>/dev/null`. If it already maps a task like + the user's to a `module`/`operation`, **skip `list`/`search`**: go straight to + `describe` that operation (to confirm its current schema) → `execute`. That turns + a 4-step walk into 1–2 steps. +- **After** you successfully `execute` a NEWLY-resolved operation, append one terse + line so the next run is faster: + ```bash + echo '- → {module}/{operation} (params: )' >> ~/.a3s/os-learned.md + ``` + Don't duplicate an existing entry; don't cache failed, ambiguous, or one-off calls. +- The cache is a **hint, not gospel**: if `describe` shows the schema changed or + `execute` errors with the cached operation, fall back to `list`/`search` to + re-discover and fix the stale entry. + ## Examples ```bash @@ -110,6 +136,9 @@ post() { curl -s -X POST "$API" -H "Authorization: Bearer $A3S_OS_TOKEN" -H 'Con post '{"action":"list"}' # 1. what modules exist post '{"action":"search","query":"ocr"}' # 2. find operations post '{"action":"describe","module":"kernel","operation":"runOcr"}' # 3. exact schema + +# execute: keep `.view` in the projection so the host can offer the 渐进式UI link +post '{"action":"execute","module":"assets","operation":"listAssets"}' | jq '{data, view}' ``` ```json diff --git a/skills/okf.md b/skills/okf.md new file mode 100644 index 0000000..20c3b91 --- /dev/null +++ b/skills/okf.md @@ -0,0 +1,98 @@ +--- +name: okf +description: "Compile the project's knowledge into an Open Knowledge Format (OKF) bundle — a directory of cross-linked markdown 'concept' files under .a3s/kb/wiki/ (the LLM-wiki pattern, DeepWiki-style, per Google's OKF v0.1). Use when the user asks to build / compile / refresh the knowledge base, the wiki, or project docs, or runs /okf. Reads the codebase plus existing notes and writes OKF concept files (required `type` frontmatter, standard markdown links, per-directory index.md); recompiles incrementally (only concepts whose sources changed)." +kind: instruction +allowed-tools: "read(*), grep(*), glob(*), ls(*), write(*), edit(*), bash(*), parallel_task(*), task(*)" +--- + +# Knowledge compilation → Open Knowledge Format (OKF) + +Compile this project's knowledge — its code plus any existing `.a3s/kb/` notes — +into an **OKF v0.1 bundle**: a directory of markdown *concept* files the human +browses (in `/kb` or any editor) and that you read back as context. Google's Open +Knowledge Format formalizes exactly this LLM-wiki pattern — "just markdown, just +files, just YAML frontmatter." It is a *compile*: sources in, a cross-linked +bundle out, rebuilt incrementally — not a one-shot dump. + +## Output contract — an OKF bundle, written ONLY here + +- The bundle root is **`.a3s/kb/wiki/`** (create it if missing); everything you + write lives under it. NEVER touch human-authored notes (`.a3s/kb/` *outside* + `wiki/`) or the agent memory — link to them, don't rewrite them. +- **One file per concept.** A *concept* is anything worth capturing: a module, + crate/package, data model, key abstraction, architecture decision, runbook, or + API. The **file path is the concept's identity** (`modules/box-runtime.md`). + Group related concepts in subdirectories. +- **Every concept file is markdown with YAML frontmatter. OKF requires exactly one + field — `type` — plus these standard optional fields:** + ```yaml + --- + type: Rust Crate # REQUIRED: the concept's kind (free-form string) + title: a3s-box # optional + description: Docker-like MicroVM runtime for Linux OCI workloads. # optional + resource: crates/box/ # optional: the concept's canonical source (path or URL) + tags: [runtime, microvm] # optional + timestamp: 2026-06-30T12:00:00Z # optional, ISO 8601 + # OKF permits extra fields — we keep provenance for the incremental recompile: + source: compiled # marks it agent-generated, not a human note + sources: [crates/box/src/runtime.rs, crates/box/README.md] + source_digest: + --- + ``` +- **Links are STANDARD markdown links, NOT `[[wikilinks]]`.** OKF turns the + directory into a graph via normal links: reference another concept with + `[a3s-box-cri](/modules/box-cri.md)` (bundle-relative) and reference code with + `[runtime.rs](crates/box/src/runtime.rs#L42)`. End every file with a `## Sources` + list of the files it was built from. +- **Reserved filenames:** every directory gets an **`index.md`** — its overview + + links to the concepts under it (this is OKF's hierarchical navigation; the bundle + root `index.md` is the wiki home). Optionally keep a top-level **`log.md`** + (chronological compile history). These two names are OKF-reserved — don't use + them for ordinary concepts. + +## Pipeline + +1. **Survey.** Map the repo before writing a word: `ls`/`glob` the top level and + key dirs; read the root README + manifest(s) (`Cargo.toml`, `package.json`, …) + and each module's entry point + README. In a monorepo, each crate/package is a + module concept. Read existing `.a3s/kb/` notes so the bundle complements and + links to them — never duplicates. +2. **Plan the bundle.** Choose a BOUNDED concept set + directory layout (e.g. + `modules/`, `concepts/`, `decisions/`), each directory with an `index.md`, plus + the root `index.md`. Deterministic kebab-case slugs. Show the planned layout to + the user before generating. +3. **Generate concepts.** Per concept: read its sources, then write an OKF file — + required `type`, the standard fields, and a synthesized explanation grounded + entirely in what you read (key types/functions with `[file](path#Lline)` links, + connections to other concepts with `[name](/dir/other.md)`), ending in + `## Sources`. Fill `sources`/`source_digest` honestly. **Fan out with + `parallel_task`** (one concept per subtask) when available; else do them one at + a time. +4. **Index.** Write each directory's `index.md` and the root `index.md` last, + linking every concept with a one-line summary, so the bundle is a navigable + graph, not a flat pile. +5. **Verify.** Every markdown link must resolve to a file you wrote (or a real code + path+line). Fix or drop dangling links. Report the concept count and any gaps. + +## Incremental recompile (this is what makes it a *compile*) + +On a re-run, before regenerating a concept read its frontmatter `source_digest` +and recompute the digest of its `sources` (e.g. `cat | shasum -a 256`). +Unchanged ⇒ **SKIP** it. Only regenerate concepts whose sources changed, plus the +affected `index.md` files. Report rebuilt vs. skipped — a dependency-tracked +rebuild that keeps recompiling cheap and the bundle fresh after code changes. + +## Rules + +- **Ground every claim** in a file you read; link file+line; mark genuine + uncertainty ("appears to …"); never invent an API, type, or flow. A hallucinated + concept is worse than none. +- **No secrets** — never copy tokens/keys/`.env` values into a file. +- **Bound the run** — document concepts + modules, not every file; for a very + large repo compile by area and report what you covered. +- **Stay in your lane** — `source: compiled` on every file; you own + `.a3s/kb/wiki/`, the human owns `.a3s/kb/*.md`. Don't clobber either. + +> OKF v0.1 — spec, conformance criteria, and sample bundles: +> `GoogleCloudPlatform/knowledge-catalog`. The only hard requirement is the `type` +> field on every concept; everything else above is convention. diff --git a/src/a3s_os.rs b/src/a3s_os.rs index 19cf295..abe54e5 100644 --- a/src/a3s_os.rs +++ b/src/a3s_os.rs @@ -144,18 +144,26 @@ pub(crate) fn logout(config: &OsConfig) -> Result { /// available to every command. pub(crate) const OS_ENV_BASE_URL: &str = "A3S_OS_BASE_URL"; pub(crate) const OS_ENV_TOKEN: &str = "A3S_OS_TOKEN"; +pub(crate) const OS_ENV_REFRESH_TOKEN: &str = "A3S_OS_REFRESH_TOKEN"; -/// Export the signed-in platform endpoint + token to the process env so the -/// agent's shell can use them directly. Called on login and on startup restore. +/// Export the signed-in platform endpoint + tokens to the process env so the +/// agent's shell — and the RemoteUI webview helper, which inherits this env — +/// can use them directly. Called on login and on startup restore. The refresh +/// token lets the webview's seeded session survive an edge-expired access token. pub(crate) fn export_os_env(session: &StoredOsSession) { std::env::set_var(OS_ENV_BASE_URL, &session.address); std::env::set_var(OS_ENV_TOKEN, &session.access_token); + match &session.refresh_token { + Some(rt) => std::env::set_var(OS_ENV_REFRESH_TOKEN, rt), + None => std::env::remove_var(OS_ENV_REFRESH_TOKEN), + } } /// Clear the exported platform env (called on /logout). pub(crate) fn clear_os_env() { std::env::remove_var(OS_ENV_BASE_URL); std::env::remove_var(OS_ENV_TOKEN); + std::env::remove_var(OS_ENV_REFRESH_TOKEN); } /// The stored session for the configured OS address, if the user logged in @@ -459,6 +467,58 @@ async fn exchange_refresh_token(address: &str, refresh_token: &str) -> Result String { + match address.find("://") { + Some(scheme_end) => { + let after = &address[scheme_end + 3..]; + let end = after + .find('/') + .map_or(address.len(), |j| scheme_end + 3 + j); + address[..end].to_string() + } + None => address.trim_end_matches('/').to_string(), + } +} + +/// List the 书安OS unified AI gateway's model ids via the OpenAI-compatible +/// `GET {origin}/v1/models` (Bearer = the OS token). The gateway is +/// "gateway-managed" (it holds the real provider keys; callers send only the OS +/// token + a model id). Best-effort → empty `Vec` on any error, so the `/model` +/// picker can show the gateway as unavailable rather than failing. +pub(crate) async fn fetch_gateway_models(address: &str, token: &str) -> Vec { + let url = format!("{}/v1/models", os_origin(address)); + let Ok(client) = reqwest::Client::builder().timeout(HTTP_TIMEOUT).build() else { + return Vec::new(); + }; + let Ok(resp) = client + .get(&url) + .bearer_auth(token) + .header("accept", "application/json") + .send() + .await + else { + return Vec::new(); + }; + if !resp.status().is_success() { + return Vec::new(); + } + let Ok(json) = resp.json::().await else { + return Vec::new(); + }; + // OpenAI shape: { "data": [ { "id": "..." }, ... ] }. + json.get("data") + .and_then(|d| d.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default() +} + fn build_authorization_url( address: &str, redirect_uri: &str, @@ -661,6 +721,25 @@ fn now_ms() -> u64 { mod tests { use super::*; + #[test] + fn os_origin_strips_any_path_for_the_gateway() { + // The gateway endpoint is host-absolute (/v1/chat/completions), so the + // OpenAI base must be the bare origin regardless of the platform path. + assert_eq!( + os_origin("https://os.example.com"), + "https://os.example.com" + ); + assert_eq!( + os_origin("https://os.example.com/"), + "https://os.example.com" + ); + assert_eq!( + os_origin("https://os.example.com/api/v1"), + "https://os.example.com" + ); + assert_eq!(os_origin("http://10.0.0.1:3888/x"), "http://10.0.0.1:3888"); + } + #[test] fn authorization_url_uses_oauth2_code_flow_with_pkce() { let url = build_authorization_url( diff --git a/src/tui/memutil.rs b/src/tui/memutil.rs new file mode 100644 index 0000000..e5d5ded --- /dev/null +++ b/src/tui/memutil.rs @@ -0,0 +1,221 @@ +//! `/memory` panel data: read the agent's long-term memory store +//! (`~/.a3s/memory`, an `a3s-memory` FileMemoryStore) for a GitLens-style +//! timeline. The store keeps an `index.json` (an array of lightweight entries) +//! plus one `items/{id}.json` per memory; we read the index for the list and +//! lazily read a single item for the detail pane — so opening the panel parses +//! one file, not hundreds. + +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use std::path::Path; + +/// A memory as listed in the timeline (from the store's `index.json`). +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct MemEntry { + pub id: String, + /// Lowercased content — the index only stores this; used for the preview. + #[serde(default)] + pub content_lower: String, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub importance: f32, + pub timestamp: DateTime, + /// `episodic` | `semantic` | `procedural` | `working`. + #[serde(default)] + pub memory_type: String, +} + +/// A memory's full content + metadata (lazily read for the detail pane). +#[derive(Debug, Clone, Default, Deserialize)] +pub(crate) struct MemDetail { + #[serde(default)] + pub content: String, + /// BTreeMap so the detail pane shows metadata in a stable order. + #[serde(default)] + pub metadata: std::collections::BTreeMap, + #[serde(default)] + pub access_count: u32, + #[serde(default)] + pub last_accessed: Option>, +} + +/// Read the store index, newest first. Empty if the store is absent/unreadable. +pub(crate) fn load_timeline(dir: &Path) -> Vec { + let mut v: Vec = std::fs::read_to_string(dir.join("index.json")) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + v.sort_by_key(|e| std::cmp::Reverse(e.timestamp)); + v +} + +/// Lazily read one memory's full item file for the detail pane. +pub(crate) fn load_detail(dir: &Path, id: &str) -> Option { + // ids are UUIDs; reject anything that could escape the items dir. + if id.contains('/') || id.contains('\\') || id.contains("..") { + return None; + } + let path = dir.join("items").join(format!("{id}.json")); + serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok() +} + +/// Compact "time since" for a node (`now`, `5m`, `3h`, `2d`, `4w`, `5mo`, `1y`). +pub(crate) fn rel_time(ts: DateTime, now: DateTime) -> String { + let secs = (now - ts).num_seconds().max(0); + match secs { + 0..=59 => "now".to_string(), + 60..=3_599 => format!("{}m", secs / 60), + 3_600..=86_399 => format!("{}h", secs / 3_600), + 86_400..=604_799 => format!("{}d", secs / 86_400), + 604_800..=2_591_999 => format!("{}w", secs / 604_800), + 2_592_000..=31_535_999 => format!("{}mo", secs / 2_592_000), + _ => format!("{}y", secs / 31_536_000), + } +} + +/// Day-bucket header for the timeline (`Today` / `Yesterday` / `YYYY-MM-DD`). +/// Buckets by UTC date — a memory near midnight may land a day off in the +/// viewer's local zone, which is fine for a coarse timeline. +pub(crate) fn day_label(ts: DateTime, now: DateTime) -> String { + match (now.date_naive() - ts.date_naive()).num_days() { + d if d <= 0 => "Today".to_string(), + 1 => "Yesterday".to_string(), + _ => ts.format("%Y-%m-%d").to_string(), + } +} + +/// One rendered timeline row: a day-bucket header, or a memory node (the index +/// of the entry in `MemPanel::entries`). +pub(crate) enum TlRow { + Day(String), + Node(usize), +} + +/// Build the timeline rows — day headers interleaved with nodes, newest first. +pub(crate) fn timeline_rows(entries: &[MemEntry], now: DateTime) -> Vec { + let mut rows = Vec::with_capacity(entries.len() + 8); + let mut last = String::new(); + for (i, e) in entries.iter().enumerate() { + let d = day_label(e.timestamp, now); + if d != last { + rows.push(TlRow::Day(d.clone())); + last = d; + } + rows.push(TlRow::Node(i)); + } + rows +} + +/// Panel state for `/memory`. +pub(crate) struct MemPanel { + pub entries: Vec, + pub sel: usize, + /// Full content + metadata of `entries[sel]`, lazily loaded on selection. + pub detail: MemDetail, + pub detail_scroll: usize, + pub dir: std::path::PathBuf, + pub note: String, +} + +impl MemPanel { + /// Reload the selected entry's full detail (called on open + selection move). + pub fn refresh_detail(&mut self) { + self.detail_scroll = 0; + self.detail = self + .entries + .get(self.sel) + .and_then(|e| load_detail(&self.dir, &e.id)) + .unwrap_or_default(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) + } + + #[test] + fn rel_time_buckets() { + let now = ts("2026-06-30T12:00:00Z"); + assert_eq!(rel_time(ts("2026-06-30T11:59:30Z"), now), "now"); + assert_eq!(rel_time(ts("2026-06-30T11:30:00Z"), now), "30m"); + assert_eq!(rel_time(ts("2026-06-30T09:00:00Z"), now), "3h"); + assert_eq!(rel_time(ts("2026-06-27T12:00:00Z"), now), "3d"); + assert_eq!(rel_time(ts("2026-06-10T12:00:00Z"), now), "2w"); + } + + #[test] + fn day_labels() { + let now = ts("2026-06-30T12:00:00Z"); + assert_eq!(day_label(ts("2026-06-30T01:00:00Z"), now), "Today"); + assert_eq!(day_label(ts("2026-06-29T23:00:00Z"), now), "Yesterday"); + assert_eq!(day_label(ts("2026-06-20T10:00:00Z"), now), "2026-06-20"); + } + + #[test] + fn load_timeline_reads_index_newest_first() { + let dir = std::env::temp_dir().join(format!("a3s-mem-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let index = r#"[ + {"id":"a","content_lower":"older","tags":[],"importance":0.5,"timestamp":"2026-06-20T10:00:00Z","memory_type":"episodic"}, + {"id":"b","content_lower":"newer","tags":["x"],"importance":0.9,"timestamp":"2026-06-29T10:00:00Z","memory_type":"semantic"} + ]"#; + std::fs::write(dir.join("index.json"), index).unwrap(); + let tl = load_timeline(&dir); + assert_eq!(tl.len(), 2); + assert_eq!(tl[0].id, "b"); // newest first + assert_eq!(tl[1].id, "a"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn load_detail_reads_item_and_rejects_traversal() { + let dir = std::env::temp_dir().join(format!("a3s-memd-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("items")).unwrap(); + std::fs::write( + dir.join("items/abc.json"), + r#"{"content":"Hello World","metadata":{"k":"v"},"access_count":3,"last_accessed":null}"#, + ) + .unwrap(); + let d = load_detail(&dir, "abc").unwrap(); + assert_eq!(d.content, "Hello World"); // original case preserved + assert_eq!(d.access_count, 3); + assert_eq!(d.metadata.get("k").unwrap(), "v"); + assert!(load_detail(&dir, "../secret").is_none()); // traversal rejected + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn timeline_rows_group_by_day() { + let now = ts("2026-06-30T12:00:00Z"); + let entries = vec![ + mk("a", "2026-06-30T11:00:00Z"), + mk("b", "2026-06-30T09:00:00Z"), + mk("c", "2026-06-29T09:00:00Z"), + ]; + let rows = timeline_rows(&entries, now); + // Day(Today), Node(0), Node(1), Day(Yesterday), Node(2) + assert!(matches!(&rows[0], TlRow::Day(d) if d == "Today")); + assert!(matches!(rows[1], TlRow::Node(0))); + assert!(matches!(rows[2], TlRow::Node(1))); + assert!(matches!(&rows[3], TlRow::Day(d) if d == "Yesterday")); + assert!(matches!(rows[4], TlRow::Node(2))); + } + + fn mk(id: &str, ts_s: &str) -> MemEntry { + MemEntry { + id: id.into(), + content_lower: String::new(), + tags: vec![], + importance: 0.5, + timestamp: ts(ts_s), + memory_type: "episodic".into(), + } + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index bad65b3..2d2f71f 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -39,7 +39,9 @@ use crate::top::{collect_processes, render_process_table, ProcessRow, ProcessTab mod config; mod gitutil; mod image; +mod memutil; mod panels; +mod remote_ui; mod render; pub(crate) mod skills; mod syntax; @@ -48,6 +50,7 @@ mod util; use config::*; use gitutil::*; use image::*; +use memutil::*; use render::*; use skills::*; use syntax::*; @@ -91,10 +94,10 @@ Go broad→narrow: `list` (modules) → `describe`/`search` for the one operatio Pipe responses through `jq` to extract ONLY the fields you need (e.g. \ `| jq -r '.data.modules[].name'`) so output stays a few lines; summarize the result for the user \ in a few lines and do NOT paste the whole raw JSON back. \ -If a response contains a `viewUrl` (deep link to the console page for what was asked; extract \ -robustly e.g. `jq -r '.. | .viewUrl? // empty'`), ALWAYS show it to the user as a labeled \ -clickable link on its own line (e.g. `🔗 在控制台查看: `). The `a3s-os-capabilities` \ -skill has full examples." +If a response contains a `view` object (a console page sized for a popup), keep `.view` in your \ +JSON output and END your reply with the link on its own line, exactly `🔗 打开渐进式UI` — the host \ +turns it into a one-click trigger that opens the authenticated 渐进式UI popup (the user's OS login \ +is injected, no re-login). Do not print the raw URL. The `a3s-os-capabilities` skill has full examples." ) } @@ -107,16 +110,17 @@ const SLASH_COMMANDS: &[(&str, &str)] = &[ ("/init", "analyze the project and generate AGENTS.md"), ("/config", "edit .a3s/config.acl in your editor"), ("/theme", "cycle the code-highlight theme (Atom One Dark …)"), - ( - "/mouse", - "toggle wheel-scroll (on) vs native text selection (off)", - ), ( "/workflow", "view the latest ultracode dynamic workflow (read-only)", ), + ( + "/output", + "view every tool call this session (name · args · result)", + ), ("/login", "sign in to the configured OS account"), ("/logout", "sign out from the configured OS account"), + ("/view", "open the last OS view in a native window"), ("/plugin", "enable/disable Claude skills & plugins"), ("/reload", "re-scan skills/plugins (hot-reload the / menu)"), ("/update", "upgrade a3s to the latest release"), @@ -124,6 +128,10 @@ const SLASH_COMMANDS: &[(&str, &str)] = &[ ("/top", "live process monitor (highlights coding agents)"), ("/ide", "file tree + code viewer for the workspace"), ("/git", "git status / diff / stage / commit (gitui-style)"), + ( + "/memory", + "browse the agent's long-term memory (GitLens-style timeline)", + ), ("/effort", "adjust model effort (low … max)"), ("/compact", "summarize + compact the conversation context"), ("/goal", "set a north-star goal the agent keeps in mind"), @@ -346,16 +354,279 @@ struct IdeEntry { expanded: bool, } +/// Editor input mode — vim-aligned: Normal navigates/operates, Insert types. +/// Freshly opened buffers start in Normal. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum EditMode { + Normal, + Insert, +} + /// An open, editable file in the `/ide` panel. struct IdeFile { path: std::path::PathBuf, lines: Vec, // text rows, or pre-rendered half-block rows if `image` - scroll: usize, - row: usize, // cursor line - col: usize, // cursor column (char index) + scroll: usize, // top visible row (vertical scroll) + hscroll: usize, // leftmost visible column (horizontal scroll; display columns) + row: usize, // cursor line + col: usize, // cursor column (char index) dirty: bool, image: bool, // read-only image preview readonly: bool, // view-only (e.g. a dynamic-workflow artifact) — edits blocked + mode: EditMode, // vim Normal/Insert (see `ide_key`) + /// A pending operator/prefix awaiting its second keystroke (`d`, `c`, `g`, `y`). + pending: Option, + /// Undo snapshots (lines + cursor) for `u`; bounded — configs are small. + undo: Vec<(Vec, usize, usize)>, + clip: String, // yank/delete register for p / P + clip_linewise: bool, // register holds whole lines (dd/yy) vs an inline span +} + +impl IdeFile { + /// A freshly opened buffer: cursor at the top, Normal mode, empty undo. + fn new(path: std::path::PathBuf, lines: Vec, image: bool, readonly: bool) -> Self { + IdeFile { + path, + lines: if lines.is_empty() { + vec![String::new()] + } else { + lines + }, + scroll: 0, + hscroll: 0, + row: 0, + col: 0, + dirty: false, + image, + readonly, + mode: EditMode::Normal, + pending: None, + undo: Vec::new(), + clip: String::new(), + clip_linewise: false, + } + } +} + +/// One completed tool call this session, retained for `/output`. +struct ToolCallRecord { + name: String, + args: Option, + output: String, + exit_code: i32, +} + +/// Render the `/output` viewer body: one `#n · name · status` header per call, +/// then its args and indented output. None when nothing has run. +fn format_tool_log_records(records: &[ToolCallRecord]) -> Option { + if records.is_empty() { + return None; + } + let mut out = String::new(); + for (i, rec) in records.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + let status = if rec.exit_code == 0 { + "ok".to_string() + } else { + format!("exit {}", rec.exit_code) + }; + out.push_str(&format!("#{} · {} · {}\n", i + 1, rec.name, status)); + if let Some(args) = &rec.args { + out.push_str(&format!( + " args: {}\n", + serde_json::to_string(args).unwrap_or_default() + )); + } + let trimmed = rec.output.trim_end(); + if !trimmed.is_empty() { + out.push_str(" output:\n"); + for line in trimmed.lines() { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + } + } + } + Some(out) +} + +/// The directive sent to the agent for a `?` deep-research turn: decompose the +/// question, search and read multiple sources, cross-check, and synthesize a +/// cited report. The user's query is appended. +fn deep_research_prompt(query: &str) -> String { + format!( + "Conduct deep research to answer the query below. Be thorough:\n\ + 1. Break it into the key sub-questions worth investigating.\n\ + 2. Use web search across those sub-questions, then read the most relevant \ + sources in full with web_fetch — don't rely on result snippets alone.\n\ + 3. Cross-check claims across multiple independent sources; call out any \ + disagreement, uncertainty, or recency caveats.\n\ + 4. Synthesize a comprehensive, well-structured answer with inline \ + citations and a final \"Sources\" list of the URLs you used.\n\n\ + Query: {query}" + ) +} + +/// The persistent `/goal` north-star for a `?` deep-research task. Kept short +/// since it is prepended to every continuation turn of the long-horizon loop. +fn deep_research_goal(query: &str) -> String { + format!("Deep research — deliver a comprehensive, well-cited report answering: {query}") +} + +/// Append a one-column vertical scrollbar to the right of the viewport's visible +/// rows. The viewport is sized to `inner_width` (= screen width − 1, see +/// `relayout`) so the bar — a `│` track with an `█` thumb sized and positioned +/// from the scroll state — never clips content. The gutter stays blank when +/// nothing overflows the window. +fn append_scrollbar(view: &str, inner_width: usize, total: usize, scroll_percent: u8) -> String { + let rows: Vec<&str> = view.split('\n').collect(); + let h = rows.len(); + let overflow = total > h && h > 0; + // Thumb length proportional to the visible fraction; positioned by percent. + let thumb_len = if overflow { (h * h / total).max(1) } else { 0 }; + let thumb_start = if overflow { + (h - thumb_len) * scroll_percent as usize / 100 + } else { + 0 + }; + let track = Style::new().fg(TN_GRAY); + let thumb = Style::new().fg(ACCENT); + rows.iter() + .enumerate() + .map(|(i, row)| { + let bar = if !overflow { + " ".to_string() + } else if i >= thumb_start && i < thumb_start + thumb_len { + thumb.render("█") + } else { + track.render("│") + }; + format!("{}{}", pad_to(row, inner_width), bar) + }) + .collect::>() + .join("\n") +} + +/// The OSC 52 escape that asks the terminal to set the system clipboard to +/// `text` (base64). Works over SSH on terminals that support OSC 52. Capped so a +/// long reply can't blow past a terminal's OSC 52 size limit. +fn osc52_copy(text: &str) -> String { + use base64::Engine; + let capped: String = text.chars().take(64_000).collect(); + let b64 = base64::engine::general_purpose::STANDARD.encode(capped.as_bytes()); + format!("\x1b]52;c;{b64}\x07") +} + +/// Marker the agent puts inline in its reply to offer the RemoteUI popup. The +/// host recognises a mouse click on any reply line containing it and opens the +/// remembered view (`/view` does the same). The link lives in the message text — +/// the host renders no button of its own. +const VIEW_BUTTON_MARKER: &str = "打开渐进式UI"; + +/// Put `text` on the system clipboard: OSC 52 (portable, survives SSH on +/// supporting terminals) plus the native tool where we have one (macOS pbcopy). +fn copy_to_clipboard(text: &str) { + use std::io::Write; + let mut out = std::io::stdout(); + let _ = out.write_all(osc52_copy(text).as_bytes()); + let _ = out.flush(); + #[cfg(target_os = "macos")] + { + if let Ok(mut child) = std::process::Command::new("pbcopy") + .stdin(std::process::Stdio::piped()) + .spawn() + { + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(text.as_bytes()); + } + let _ = child.wait(); + } + } +} + +/// Background of an active text selection in the transcript. +const SELECTION_BG: Color = Color::Rgb(58, 64, 88); + +/// An in-progress mouse text-selection in the transcript viewport, in screen +/// cells (visible row, column). `anchor` = drag start, `head` = current point. +#[derive(Clone, Copy)] +struct Selection { + anchor: (u16, u16), + head: (u16, u16), +} + +impl Selection { + fn is_empty(&self) -> bool { + self.anchor == self.head + } + /// (top_row, top_col, bottom_row, bottom_col), as usize. + fn ordered(&self) -> (usize, usize, usize, usize) { + let (a, b) = if self.anchor <= self.head { + (self.anchor, self.head) + } else { + (self.head, self.anchor) + }; + (a.0 as usize, a.1 as usize, b.0 as usize, b.1 as usize) + } +} + +/// Substring of `s` spanning visible columns `[from, to)` (wide chars counted by +/// display width). A char straddling the start is dropped; one straddling the +/// end is kept. +fn slice_cols(s: &str, from: usize, to: usize) -> String { + let mut col = 0usize; + let mut out = String::new(); + for ch in s.chars() { + if col >= to { + break; + } + if col >= from { + out.push(ch); + } + col += a3s_tui::style::visible_len(&ch.to_string()); + } + out +} + +/// Plain text of a selection over the rendered viewport `view`: screen rows +/// `r1..=r2`, columns `[c1, c2)` clipped on the first/last rows. Rows are +/// ANSI-stripped and trailing padding trimmed. +fn selection_to_text(view: &str, r1: usize, c1: usize, r2: usize, c2: usize) -> String { + let rows: Vec<&str> = view.split('\n').collect(); + let mut out: Vec = Vec::new(); + for r in r1..=r2 { + let Some(row) = rows.get(r) else { break }; + let plain = a3s_tui::style::strip_ansi(row); + let from = if r == r1 { c1 } else { 0 }; + let to = if r == r2 { c2 } else { usize::MAX }; + out.push(slice_cols(&plain, from, to).trim_end().to_string()); + } + out.join("\n") +} + +/// Re-render the viewport `view` with the selected span highlighted: selected +/// rows render in plain text (no syntax colour, transiently) with the selected +/// columns on `SELECTION_BG`; other rows keep their styling. +fn highlight_selection(view: &str, r1: usize, c1: usize, r2: usize, c2: usize) -> String { + let bg = Style::new().bg(SELECTION_BG).fg(TN_FG); + view.split('\n') + .enumerate() + .map(|(i, row)| { + if i < r1 || i > r2 { + return row.to_string(); + } + let plain = a3s_tui::style::strip_ansi(row); + let from = if i == r1 { c1 } else { 0 }; + let to = if i == r2 { c2 } else { usize::MAX }; + let before = slice_cols(&plain, 0, from); + let sel = slice_cols(&plain, from, to); + let after = slice_cols(&plain, to, usize::MAX); + format!("{before}{}{after}", bg.render(&sel)) + }) + .collect::>() + .join("\n") } /// State of the `/ide` panel: the file tree, selection, and the open file. @@ -812,6 +1083,8 @@ enum Msg { OsLogin(Result), /// OS access token was refreshed (or refresh failed) in the background. OsRefreshed(Result), + /// 书安OS unified-gateway model ids fetched for the `/model` picker. + OsGatewayModels(Vec), /// Answer from a `/btw` background side-thread. SideNote(String), /// Refreshed process snapshot for the `/top` panel. @@ -824,6 +1097,8 @@ enum Msg { GitStatus(Vec, Vec), /// `/git` diff for the selected file. GitDiff(Vec), + /// `/memory` timeline loaded (the store index, newest first). + MemoryLoaded(Vec), /// Inactivity auto-review summary text. AutoReview(String), /// `/compact` produced this conversation summary; reseed a fresh session. @@ -939,6 +1214,14 @@ struct App { /// True while an OS access-token refresh is in flight (guards the BannerTick /// trigger from spawning a second refresh before the first resolves). os_refreshing: bool, + /// 书安OS unified-gateway models for the `/model` picker, lazily fetched on + /// first `/model` while signed in. `None` = not fetched yet; `Some([])` = the + /// gateway is unavailable/unconfigured. + os_gateway_models: Option>, + /// Last 书安OS view seen in a tool result. RemoteUI is user-triggered: `/view` + /// or clicking the agent's inline "打开渐进式UI" link opens it in the native + /// a3s-webview window — it is never auto-opened. + last_view: Option, /// Current model effort (index into EFFORT_LEVELS). effort: usize, /// `/effort` slider panel: temp selection while open. @@ -958,9 +1241,13 @@ struct App { auto_reviewed: bool, /// Shell mode: a leading `!` becomes the prompt, the rest is the command. shell_mode: bool, - /// Mouse capture on → wheel-scroll works but native text selection is off. - /// Off by default so select/copy works; toggled with `/mouse`. - mouse_scroll: bool, + /// Deep-research mode: a leading `?` turns the input into a deep-research + /// query — sent to the agent with a multi-source research directive. Box + /// turns cyan. + research_mode: bool, + /// Active transcript text-selection (mouse drag → highlight → copy on + /// release); `None` when there's no selection. + selection: Option, /// Latest dynamic-workflow artifact (ultracode parallel_task dispatch), /// shown collapsed in the transcript and openable read-only via `/workflow`. last_workflow: Option, @@ -1039,6 +1326,8 @@ struct App { /// Live stdout of the in-progress tool (e.g. a running command), shown /// dimmed under the action and cleared when the tool completes. tool_output: String, + /// Every completed tool call this session (name/args/output), shown by `/output`. + tool_log: Vec, /// When the current run started, for the live elapsed-time indicator. stream_started: Option, /// Name of the tool currently executing (shown live with a blinking dot). @@ -1072,6 +1361,8 @@ struct App { ide: Option, /// `/git` full-screen panel (Some when open). git: Option, + /// `/memory` full-screen timeline panel (Some when open). + memory: Option, /// `/help` overlay panel is showing. help_open: bool, /// Turns completed this session, for the status-bar task counter. @@ -1135,6 +1426,7 @@ impl Model for App { fn update(&mut self, msg: Msg) -> Option> { match msg { Msg::Term(Event::Resize { width, height }) => { + self.selection = None; // screen-coord selection is stale after resize self.width = width; self.height = height; self.relayout(); @@ -1162,7 +1454,8 @@ impl Model for App { Msg::Term(Event::Key(key)) => { self.last_activity = Instant::now(); self.auto_reviewed = false; - // Ctrl+C: arm on the first press, exit on a second within 2s. + self.selection = None; // any keypress dismisses the copy highlight + // Ctrl+C: arm on the first press, exit on a second within 2s. if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { match self.quit_armed { Some(t) if t.elapsed() < Duration::from_secs(2) => return Some(cmd::quit()), @@ -1191,6 +1484,11 @@ impl Model for App { if self.git.is_some() { return self.git_key(&key); } + // /memory panel takes all keys while open. + if self.memory.is_some() { + self.memory_key(&key); + return None; + } // /ide panel takes all keys while open. if self.ide.is_some() { self.ide_key(&key); @@ -1371,10 +1669,11 @@ impl Model for App { self.viewport.set_auto_scroll(self.viewport.at_bottom()); return None; } - // Esc leaves shell mode first (discarding the partial command), - // taking priority over the streaming interrupt below. - if self.shell_mode && key.code == KeyCode::Esc { + // Esc leaves shell/research mode first (discarding the partial + // input), taking priority over the streaming interrupt below. + if (self.shell_mode || self.research_mode) && key.code == KeyCode::Esc { self.shell_mode = false; + self.research_mode = false; self.textarea.clear(); return None; } @@ -1420,20 +1719,81 @@ impl Model for App { if let Some(TextareaMsg::Submit(text)) = self.textarea.handle_key(&key) { return Some(cmd::msg(Msg::Submit(text))); } - // Shell mode: a leading `!` becomes the prompt (stripped from the - // text). It stays on until Esc or a submit (handled elsewhere). + // A leading `!` enters shell mode, a leading `?` enters + // deep-research mode (the prefix is stripped). Both stay on until + // Esc or a submit (handled elsewhere). let val = self.textarea.value(); - if !self.shell_mode && val.starts_with('!') { - self.shell_mode = true; - self.textarea.set_value(val.strip_prefix('!').unwrap_or("")); + if !self.shell_mode && !self.research_mode { + if let Some(rest) = val.strip_prefix('!') { + self.shell_mode = true; + self.textarea.set_value(rest); + } else if let Some(rest) = val.strip_prefix('?') { + self.research_mode = true; + self.textarea.set_value(rest); + } } } Msg::Term(Event::Mouse(m)) => { - use a3s_tui::event::MouseEventKind; + use a3s_tui::event::{MouseButton, MouseEventKind}; + let vp_rows = self.viewport_rows(); + // Content columns exclude the rightmost scrollbar column. + let max_col = (self.width as usize).saturating_sub(2) as u16; match m.kind { - MouseEventKind::ScrollUp => self.viewport.update(ViewportMsg::ScrollUp(3)), - MouseEventKind::ScrollDown => self.viewport.update(ViewportMsg::ScrollDown(3)), + MouseEventKind::ScrollUp => { + self.selection = None; + self.viewport.update(ViewportMsg::ScrollUp(3)); + } + MouseEventKind::ScrollDown => { + self.selection = None; + self.viewport.update(ViewportMsg::ScrollDown(3)); + } + // Drag to select transcript text. Capture stays on so the wheel + // still scrolls; the app owns selection, so scroll + copy work + // together (no mode toggle). Release copies to the clipboard. + MouseEventKind::Down(MouseButton::Left) => { + self.selection = if (m.row as usize) < vp_rows { + let p = (m.row, m.column.min(max_col)); + Some(Selection { anchor: p, head: p }) + } else { + None + }; + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(s) = self.selection.as_mut() { + let row = m.row.min(vp_rows.saturating_sub(1) as u16); + s.head = (row, m.column.min(max_col)); + } + } + MouseEventKind::Up(MouseButton::Left) => { + if let Some(s) = self.selection { + if s.is_empty() { + // A plain click: open the OS view if it landed on + // the agent's inline "打开渐进式UI" link; else just clear. + let view = self.viewport.view(); + let clicked = a3s_tui::style::strip_ansi( + view.split('\n') + .nth(s.anchor.0 as usize) + .unwrap_or_default(), + ); + self.selection = None; + if clicked.contains(VIEW_BUTTON_MARKER) { + if let Some(spec) = self.last_view.clone() { + self.open_remote_view(&spec); + } + } + } else { + let (r1, c1, r2, c2) = s.ordered(); + let text = selection_to_text(&self.viewport.view(), r1, c1, r2, c2); + if text.trim().is_empty() { + self.selection = None; + } else { + // Keep the highlight visible as "copied" feedback. + copy_to_clipboard(&text); + } + } + } + } _ => {} } // Pause auto-follow while scrolled up (so streaming output won't @@ -1506,6 +1866,7 @@ impl Model for App { && self.top.is_none() && self.ide.is_none() && self.git.is_none() + && self.memory.is_none() && !self.help_open { self.anim = self.anim.wrapping_add(1); @@ -1763,6 +2124,13 @@ impl Model for App { } } + Msg::OsGatewayModels(models) => { + // Cache the fetched gateway models, then open the /model picker + // (the "书安OS" tab now lists them — or shows it unavailable). + self.os_gateway_models = Some(models); + self.open_model_menu(); + } + Msg::SideNote(text) => { if let Some((q, _)) = self.btw.take() { self.btw = Some((q, Some(text.trim().to_string()))); @@ -1802,6 +2170,14 @@ impl Model for App { g.diff_scroll = 0; } } + Msg::MemoryLoaded(entries) => { + if let Some(m) = &mut self.memory { + m.note = format!("{} entries", entries.len()); + m.entries = entries; + m.sel = 0; + m.refresh_detail(); + } + } _ => {} } @@ -1815,6 +2191,9 @@ impl Model for App { if let Some(g) = &self.git { return self.render_git(g); } + if let Some(m) = &self.memory { + return self.render_memory(m); + } if let Some(ide) = &self.ide { return self.render_ide(ide); } @@ -1822,12 +2201,28 @@ impl Model for App { return self.render_top_panel(); } let width = self.width as usize; - let viewport_view = self.viewport.view(); - // Input mode hint: `!` = shell command (pink), `/btw` = side-channel - // (yellow), otherwise the normal prompt (accent blue). + let raw_view = self.viewport.view(); + // Paint an active text-selection over the visible rows, then add the bar. + let shown = match &self.selection { + Some(s) if !s.is_empty() => { + let (r1, c1, r2, c2) = s.ordered(); + highlight_selection(&raw_view, r1, c1, r2, c2) + } + _ => raw_view, + }; + let viewport_view = append_scrollbar( + &shown, + width.saturating_sub(1), + self.viewport.total_lines(), + self.viewport.scroll_percent(), + ); + // Input mode hint: `!` = shell command (pink), `?` = deep research (cyan), + // `/btw` = side-channel (yellow), otherwise the normal prompt (accent blue). let inp = self.textarea.value(); let (sym, icolor, border): (&str, Color, Color) = if self.shell_mode { ("!", Color::Rgb(255, 105, 180), Color::Rgb(255, 105, 180)) + } else if self.research_mode { + ("?", TN_CYAN, TN_CYAN) } else if inp.starts_with("/btw") { ("❯", TN_YELLOW, TN_YELLOW) } else { @@ -1935,7 +2330,7 @@ impl Model for App { let prompt = Style::new().fg(icolor).bold().render(&format!("{sym} ")); let typed = self.textarea.view(); - let typed = if sym == "!" || inp.starts_with("/btw") { + let typed = if sym == "!" || sym == "?" || inp.starts_with("/btw") { Style::new().fg(icolor).render(&typed) } else { typed @@ -2091,6 +2486,7 @@ impl Model for App { if self.state == State::Awaiting || self.top.is_some() || self.git.is_some() + || self.memory.is_some() || self.help_open { return None; @@ -2150,6 +2546,48 @@ impl App { Msg::ShellOutput(text) })); } + // Deep-research mode (`?`) is a long-horizon task: it anchors the work + // with the `/goal` mechanism (a persistent north-star prepended to every + // turn) AND auto-continues via the `/loop` mechanism until the agent + // reports completion (or Esc). The first turn carries the full + // decompose → search + read → cross-check → synthesize directive. + if self.research_mode { + self.research_mode = false; + let query = trimmed.trim_start_matches('?').trim().to_string(); + if query.is_empty() { + self.textarea.clear(); + return None; + } + self.history.push(trimmed.to_string()); + self.history_pos = None; + self.textarea.clear(); + self.goal = Some(deep_research_goal(&query)); + self.messages.push(gutter( + TN_CYAN, + &Style::new() + .bold() + .render(&format!("🔬 deep research: {query}")), + )); + self.push_line(&Style::new().fg(TN_GRAY).render( + " 🎯 goal set · ↻ auto-continues until done (Esc stops · /goal clear drops it)", + )); + let prompt = deep_research_prompt(&query); + let display = format!("🔬 {query}"); + // Long-horizon budget: keep researching across turns toward the goal. + self.loop_remaining = 8; + if self.state == State::Idle { + return self.start_stream_inner(prompt, display, true, true, false); + } + self.seq += 1; + self.queue.push(Queued { + prio: 1, + seq: self.seq, + text: prompt, + }); + self.push_line(&Style::new().fg(TN_GRAY).render(" ⋯ queued")); + self.relayout(); + return None; + } // Block session-mutating commands while a turn is streaming. if self.state != State::Idle { let cmd0 = trimmed.split_whitespace().next().unwrap_or(""); @@ -2466,6 +2904,17 @@ impl App { self.help_open = true; return None; } + "/view" => { + self.textarea.clear(); + if let Some(spec) = self.last_view.clone() { + self.open_remote_view(&spec); + } else { + self.push_line(&Style::new().fg(TN_GRAY).render( + " no OS view yet — run an OS query that returns a viewUrl, then /view", + )); + } + return None; + } "/auto" => { self.mode = Mode::Auto; self.textarea.clear(); @@ -2493,6 +2942,19 @@ impl App { } "/model" => { self.textarea.clear(); + // Signed in to 书安OS + gateway models not fetched yet → fetch them + // once (OpenAI-compatible /v1/models) so the picker can offer the + // unified gateway, then open. Otherwise open immediately. + if let Some(s) = self.os_session.clone() { + if self.os_gateway_models.is_none() { + let (addr, token) = (s.address.clone(), s.access_token.clone()); + return Some(cmd::cmd(move || async move { + Msg::OsGatewayModels( + crate::a3s_os::fetch_gateway_models(&addr, &token).await, + ) + })); + } + } self.open_model_menu(); return None; } @@ -2539,18 +3001,6 @@ impl App { self.theme_panel = Some(cur.min(THEMES.len() - 1)); return None; } - "/mouse" => { - self.textarea.clear(); - self.mouse_scroll = !self.mouse_scroll; - a3s_tui::terminal::set_mouse_capture(self.mouse_scroll); - let msg = if self.mouse_scroll { - "🖱 wheel-scroll on — text selection paused (run /mouse again to select/copy)" - } else { - "🖱 wheel-scroll off — native text selection / copy enabled (PgUp/PgDn still scroll)" - }; - self.push_line(&Style::new().fg(TN_CYAN).render(&format!(" {msg}"))); - return None; - } "/workflow" => { self.textarea.clear(); match self.last_workflow.clone() { @@ -2561,6 +3011,18 @@ impl App { } return None; } + "/output" => { + self.textarea.clear(); + match self.format_tool_log() { + Some(content) => self.open_readonly_in_ide("tool-calls.txt", &content), + None => self.push_line( + &Style::new() + .fg(TN_GRAY) + .render(" no tool calls yet this session"), + ), + } + return None; + } "/reload" => { self.textarea.clear(); // Hot-reload: re-discover skill dirs, refresh the UI catalog, @@ -2622,6 +3084,26 @@ impl App { Msg::GitStatus(files, log) })); } + "/memory" => { + self.textarea.clear(); + // Open immediately ("loading…"); parse the store index off the UI + // thread (it can be multi-MB) so the panel never janks on open. + let dir = memory_dir(); + self.memory = Some(MemPanel { + entries: Vec::new(), + sel: 0, + detail: memutil::MemDetail::default(), + detail_scroll: 0, + dir: dir.clone(), + note: "loading…".into(), + }); + return Some(cmd::cmd(move || async move { + let entries = tokio::task::spawn_blocking(move || memutil::load_timeline(&dir)) + .await + .unwrap_or_default(); + Msg::MemoryLoaded(entries) + })); + } "/relay" => { self.textarea.clear(); // Open immediately (tabs show right away); scan off the UI thread @@ -2861,6 +3343,31 @@ impl App { self.width as usize, )); self.capture_workflow(&name, args.as_ref()); + // RemoteUI: a 书安OS viewUrl in the tool output is openable. Remember + // it for `/view`, and if the API marked it embeddable (sized popup), + // open it now in the native a3s-webview window (auth via $A3S_OS_TOKEN). + if let Some(spec) = remote_ui::find_view_url(&output) { + // RemoteUI is user-triggered — never auto-open. Remember the + // view; the agent offers it via an inline "打开渐进式UI" link + // (clicking that line, or `/view`, opens the popup). + self.last_view = Some(spec); + } + // Retain the call for `/output`. Cap each output so a huge build + // log can't bloat the in-memory record. + // ponytail: 8 KB/call cap; the transcript already holds the full text + let logged = if output.len() > 8192 { + let mut s: String = output.chars().take(8000).collect(); + s.push_str("\n… (output truncated — see transcript)"); + s + } else { + output + }; + self.tool_log.push(ToolCallRecord { + name, + args, + output: logged, + exit_code, + }); self.tool_args.clear(); self.tool_output.clear(); } @@ -3182,10 +3689,26 @@ impl App { self.rebuild_viewport(); } + /// Open a 书安OS viewUrl in the native `a3s-webview` window. Silent on success + /// (the window appearing is feedback enough); only a missing helper binary + /// leaves a transcript hint. + fn open_remote_view(&mut self, spec: &remote_ui::ViewSpec) { + if remote_ui::open_window(spec).is_err() { + self.push_line(&Style::new().fg(TN_GRAY).render(&format!( + " 🔗 {} (install a3s-webview for an in-app window)", + spec.url + ))); + } + } + /// Skill dirs for the session: the discovered Claude/Codex dirs plus the /// login-gated built-in OS `a3s-os-capabilities` skill when signed in. pub(crate) fn skill_dirs(&self) -> Vec { let mut dirs = agent_skill_dirs(&self.cwd); + // Always-available built-in skills (the `okf` LLM-wiki / knowledge compiler). + if let Some(d) = ensure_builtin_skills_dir() { + dirs.push(d); + } if self.os_session.is_some() { if let Some(cfg) = &self.os_config { if let Some(d) = crate::a3s_os::ensure_capability_skill_dir(cfg) { @@ -3223,20 +3746,7 @@ impl App { entries: ide_children(dir, 0), sel: 0, tree_scroll: 0, - file: Some(IdeFile { - path: path.to_path_buf(), - lines: if lines.is_empty() { - vec![String::new()] - } else { - lines - }, - scroll: 0, - row: 0, - col: 0, - dirty: false, - image: false, - readonly: false, - }), + file: Some(IdeFile::new(path.to_path_buf(), lines, false, false)), focus_editor: true, flash: None, }); @@ -3263,25 +3773,24 @@ impl App { entries: ide_children(std::path::Path::new(&self.cwd), 0), sel: 0, tree_scroll: 0, - file: Some(IdeFile { - path: std::path::PathBuf::from(title), - lines: if lines.is_empty() { - vec![String::new()] - } else { - lines - }, - scroll: 0, - row: 0, - col: 0, - dirty: false, - image: false, - readonly: true, - }), + file: Some(IdeFile::new( + std::path::PathBuf::from(title), + lines, + false, + true, + )), focus_editor: true, flash: Some("read-only".to_string()), }); } + /// Format every retained tool call for the `/output` viewer: a header line + /// per call (index · name · status) followed by its args and output. Returns + /// None when nothing has run yet. + fn format_tool_log(&self) -> Option { + format_tool_log_records(&self.tool_log) + } + /// Move through prompt history and load the entry into the input. Going /// forward past the newest entry returns to a fresh, empty input. fn history_recall(&mut self, up: bool) { @@ -3377,6 +3886,7 @@ impl App { } fn rebuild_viewport(&mut self) { + self.selection = None; // content changed → screen-coord selection is stale let full = self.messages.join("\n\n"); self.viewport.set_content(&format!("\n{full}\n")); // top padding } @@ -3725,16 +4235,20 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { .filter(|t| !t.is_empty()) .collect(); - // Quiet confirmation that the persisted login was restored (so the user - // isn't asked to /login again) and the login-gated skill is active. + // Quiet confirmation that the persisted login was restored. Only when + // RESUMING an existing conversation — on a fresh start, leaving the transcript + // empty lets the welcome banner show (it notes the signed-in account itself); + // inserting this line here is what was suppressing the banner after OS login. if let Some(s) = &os_session { - initial_messages.insert( - 0, - Style::new().fg(TN_GRAY).render(&format!( - " ✓ signed in to OS as {} · capabilities skill active · /logout to sign out", - s.display_label() - )), - ); + if !initial_messages.is_empty() { + initial_messages.insert( + 0, + Style::new().fg(TN_GRAY).render(&format!( + " ✓ signed in to OS as {} · capabilities skill active · /logout to sign out", + s.display_label() + )), + ); + } } let session = Arc::new(session); @@ -3790,6 +4304,8 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { os_config, os_session, os_refreshing: false, + os_gateway_models: None, + last_view: None, effort: 2, // high effort_panel: None, theme_panel: None, @@ -3797,7 +4313,8 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { last_activity: Instant::now(), auto_reviewed: false, shell_mode: false, - mouse_scroll: false, + research_mode: false, + selection: None, last_workflow: None, pending_images: Vec::new(), goal: None, @@ -3818,7 +4335,7 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { effort_anim: None, compact_summary: None, btw: None, - viewport: Viewport::new(width, height.saturating_sub(7)), + viewport: Viewport::new(width.saturating_sub(1), height.saturating_sub(7)), textarea: Textarea::new() .with_height(1) .with_auto_grow(8) // box grows with Shift+Enter newlines (no scroll) @@ -3842,6 +4359,7 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { output_tokens: 0, tool_args: String::new(), tool_output: String::new(), + tool_log: Vec::new(), stream_started: None, running_tool: None, blink_tick: 0, @@ -3858,6 +4376,7 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { top_kill: None, ide: None, git: None, + memory: None, help_open: false, completed: 0, branch: git_branch(&workspace), @@ -3890,10 +4409,13 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { ProgramBuilder::new(app) .with_alt_screen() - // No mouse capture: native click-drag selection / copy must work in every - // terminal (capturing the mouse breaks it). Scroll the transcript with - // PgUp/PgDn/Shift+End/Ctrl+End. (Re-enable behind a toggle if wheel-scroll - // is wanted — it would trade selection while active.) + // Capture the mouse so the wheel scrolls the transcript (alt-screen has no + // terminal scrollback, so capture is the only way to get wheel events). + // Copy is preserved: most terminals still do native selection on + // Shift+drag (Fn/⌥ on macOS Terminal) even with capture on, plus `/copy` + // yanks the last reply via OSC52, and `/mouse` drops capture entirely for + // pure native selection. + .with_mouse_support() .with_fps(30) .run() .await?; @@ -4090,6 +4612,145 @@ mod tests { ); } + // ── `/output` formatting ─────────────────────────────────────────────── + #[test] + fn format_tool_log_empty_is_none() { + assert!(format_tool_log_records(&[]).is_none()); + } + + #[test] + fn format_tool_log_renders_header_args_and_output() { + let recs = vec![ + ToolCallRecord { + name: "read".into(), + args: Some(serde_json::json!({"file_path": "/x"})), + output: "hello\n".into(), + exit_code: 0, + }, + ToolCallRecord { + name: "bash".into(), + args: None, + output: String::new(), + exit_code: 2, + }, + ]; + let out = format_tool_log_records(&recs).unwrap(); + assert!(out.contains("#1 · read · ok"), "{out}"); + assert!(out.contains("args: {\"file_path\":\"/x\"}"), "{out}"); + assert!( + out.contains(" hello"), + "output should be indented: {out}" + ); + assert!(out.contains("#2 · bash · exit 2"), "{out}"); + } + + // ── `?` deep-research mode ───────────────────────────────────────────── + #[test] + fn deep_research_prompt_directs_research_and_keeps_query() { + let p = deep_research_prompt("rust async runtimes"); + assert!(p.contains("rust async runtimes"), "{p}"); + let lo = p.to_lowercase(); + assert!(lo.contains("deep research"), "{p}"); + assert!(lo.contains("web search") && lo.contains("web_fetch"), "{p}"); + assert!(lo.contains("source"), "should ask to cite sources: {p}"); + } + + #[test] + fn deep_research_goal_is_a_research_north_star_with_query() { + let g = deep_research_goal("rust async runtimes"); + assert!(g.contains("rust async runtimes"), "{g}"); + assert!(g.to_lowercase().contains("research"), "{g}"); + } + + // ── scroll + copy ────────────────────────────────────────────────────── + #[test] + fn scrollbar_blank_when_content_fits() { + let out = append_scrollbar("a\nb\nc", 5, 3, 100); + assert_eq!(out.lines().count(), 3); + for line in out.lines() { + assert!(line.ends_with(' '), "no-overflow gutter blank: {line:?}"); + assert!(!line.contains('█') && !line.contains('│')); + } + } + + #[test] + fn scrollbar_thumb_tracks_position() { + let view = "r0\nr1\nr2\nr3"; // 4 visible rows, far more total + let top = append_scrollbar(view, 4, 40, 0); + assert!(top.lines().next().unwrap().contains('█'), "thumb at top"); + let bottom = append_scrollbar(view, 4, 40, 100); + assert!( + bottom.lines().last().unwrap().contains('█'), + "thumb at bottom" + ); + // every row carries the bar (thumb or track) once content overflows + assert!(top.lines().all(|l| l.contains('█') || l.contains('│'))); + } + + #[test] + fn osc52_wraps_base64_in_envelope() { + let s = osc52_copy("hi"); + assert!(s.starts_with("\u{1b}]52;c;") && s.ends_with('\u{7}')); + assert!(s.contains("aGk=")); // base64("hi") + } + + #[test] + fn slice_cols_handles_ascii_and_wide() { + assert_eq!(slice_cols("hello", 1, 4), "ell"); + assert_eq!(slice_cols("hello", 0, 100), "hello"); + // CJK glyphs are width-2: "你好" spans columns 0..4. + assert_eq!(slice_cols("你好", 0, 2), "你"); + assert_eq!(slice_cols("你好", 2, 4), "好"); + } + + #[test] + fn selection_to_text_extracts_span_across_rows() { + let view = " hello world\n second line\n third"; + // row0 col2..end, through row1 col0..8 — trailing padding trimmed. + let t = selection_to_text(view, 0, 2, 1, 8); + assert_eq!(t, "hello world\n second"); + } + + #[test] + fn highlight_selection_touches_only_selected_rows() { + let view = "row zero\nrow one\nrow two"; + let out = highlight_selection(view, 1, 0, 1, 7); + let lines: Vec<&str> = out.split('\n').collect(); + assert_eq!(lines[0], "row zero"); // untouched + assert_eq!(lines[2], "row two"); // untouched + assert!(lines[1].contains("row one")); // selected text preserved + assert!(lines[1].contains('\u{1b}')); // wrapped in a style escape + } + + /// `?` deep research is only meaningful if the agent actually has the web + /// tools to call — guard that they're registered in the session surface. + #[tokio::test] + async fn web_tools_registered_for_q_research_mode() { + let dir = std::env::temp_dir().join(format!( + "a3s-research-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = dir.join("config.acl"); + test_config(&cfg); + let agent = a3s_code_core::Agent::new(cfg.to_string_lossy().to_string()) + .await + .unwrap(); + let session = agent + .session(dir.to_string_lossy().to_string(), None) + .unwrap(); + let names = session.tool_names(); + let _ = std::fs::remove_dir_all(&dir); + assert!( + names.contains(&"web_search".to_string()) && names.contains(&"web_fetch".to_string()), + "the `?` deep-research mode relies on web_search + web_fetch; got {names:?}" + ); + } + #[tokio::test] async fn claude_session_surface_passes_system_tools_and_skills_to_llm() { let dir = std::env::temp_dir().join(format!( diff --git a/src/tui/panels/banner.rs b/src/tui/panels/banner.rs index 1f29216..0624409 100644 --- a/src/tui/panels/banner.rs +++ b/src/tui/panels/banner.rs @@ -61,8 +61,12 @@ impl App { } else { String::new() }; + let os = match &self.os_session { + Some(s) => format!(" · OS: {}", s.display_label()), + None => String::new(), + }; let meta = Style::new().fg(TN_GRAY).render(&format!( - "{margin}a3s-code v{} · {model}{skills} · {}", + "{margin}a3s-code v{} · {model}{skills}{os} · {}", env!("CARGO_PKG_VERSION"), self.cwd )); diff --git a/src/tui/panels/git.rs b/src/tui/panels/git.rs index 3b00f71..4a08c44 100644 --- a/src/tui/panels/git.rs +++ b/src/tui/panels/git.rs @@ -1,6 +1,9 @@ //! `/git` panel: status/diff/log views, staging, and commit input. use super::super::*; +use a3s_tui::components::{ + GitPanel, GitPanelView as TuiGitPanelView, GitStatusFile as TuiGitStatusFile, +}; impl App { /// Spawn a diff fetch for the currently selected `/git` file. @@ -180,169 +183,34 @@ impl App { /// Full-screen `/git` panel (gitui-style): status + diff / log + commit. pub(crate) fn render_git(&self, g: &Git) -> String { - let width = self.width as usize; - let h = self.height as usize; - let branch = self.branch.as_deref().unwrap_or("(detached)"); - let tab = |label: &str, active: bool| { - if active { - Style::new() - .fg(Color::Black) - .bg(ACCENT) - .bold() - .render(&format!(" {label} ")) - } else { - Style::new().fg(TN_GRAY).render(&format!(" {label} ")) - } - }; - let logtab = if g.log.is_empty() { - "Log".to_string() - } else { - format!("Log ({})", g.log.len()) + let view = match g.view { + GitView::Status => TuiGitPanelView::Status, + GitView::Log => TuiGitPanelView::Log, }; - let header = format!( - " git · {branch} {} {} {} {}", - tab("Status", g.view == GitView::Status), - tab(&logtab, g.view == GitView::Log), - Style::new() - .fg(ACCENT) - .render("⇄ Tab to switch · commits in Log"), - Style::new().fg(TN_GRAY).render(&g.note) - ); - let mut out = vec![ - pad_to(&header, width), - pad_to(&Style::new().fg(TN_GRAY).render(&"─".repeat(width)), width), - ]; - let body = h.saturating_sub(3); + let files = g + .files + .iter() + .map(|file| TuiGitStatusFile::new(file.x, file.y, file.path.clone())) + .collect::>(); + let mut panel = GitPanel::new(self.branch.as_deref().unwrap_or("(detached)")) + .files(files) + .selected_file(g.sel) + .log_entries(g.log.clone()) + .selected_log(g.log_sel) + .active_view(view) + .diff_lines(g.diff.clone()) + .diff_scroll(g.diff_scroll) + .note(g.note.as_str()) + .accent_color(ACCENT) + .muted_color(TN_GRAY) + .status_colors(TN_GREEN, TN_YELLOW, TN_RED) + .diff_colors(TN_CYAN, TN_GREEN, TN_RED, TN_GRAY) + .fill_height(true); - if g.view == GitView::Log { - if g.log.is_empty() { - let msg = if g.note.is_empty() { - " no commits in this repository yet" - } else { - " loading commits…" - }; - out.push(pad_to(&Style::new().fg(TN_GRAY).render(msg), width)); - out.truncate(h); - while out.len() < h { - out.push(String::new()); - } - return out.join("\n"); - } - // Two columns: the commit list (selectable) + the selected commit's - // details (`git show`) on the right. - let tw = (width / 3).clamp(20, 46); - let sep = Style::new().fg(TN_GRAY).render(" │ "); - // keep the selected commit visible - let start = g.log_sel.saturating_sub(body.saturating_sub(1)); - for i in 0..body { - let ci = start + i; - let left = if let Some(line) = g.log.get(ci) { - let (hash, rest) = line.split_once(' ').unwrap_or((line.as_str(), "")); - let raw = pad_to(&truncate(&format!(" {hash} {rest}"), tw), tw); - if ci == g.log_sel { - Style::new().fg(Color::Black).bg(TN_YELLOW).render(&raw) - } else { - format!( - "{}{}", - Style::new() - .fg(TN_YELLOW) - .render(&pad_to(&format!(" {hash} "), hash.len() + 2)), - truncate(rest, tw.saturating_sub(hash.len() + 3)) - ) - } - } else { - " ".repeat(tw) - }; - let right = if let Some(line) = g.diff.get(g.diff_scroll + i) { - let st = if line.starts_with("@@") { - Style::new().fg(TN_CYAN) - } else if line.starts_with("commit ") { - Style::new().fg(TN_YELLOW).bold() - } else if line.starts_with('+') { - Style::new().fg(TN_GREEN) - } else if line.starts_with('-') { - Style::new().fg(TN_RED) - } else if line.starts_with("diff ") || line.starts_with("index ") { - Style::new().fg(TN_GRAY) - } else { - Style::new() - }; - st.render(&truncate(line, width.saturating_sub(tw + 4))) - } else { - String::new() - }; - out.push(format!("{left}{sep}{right}")); - } - } else { - let tw = (width / 3).clamp(20, 46); - let sep = Style::new().fg(TN_GRAY).render(" │ "); - // Scroll the file list so the selection stays visible (mirrors the Log - // view); previously it rendered from index 0 and the highlight could - // scroll off the bottom and become unreachable. - let start = g.sel.saturating_sub(body.saturating_sub(1)); - for i in 0..body { - let fi = start + i; - // left: file list - let left = if let Some(f) = g.files.get(fi) { - let mark = format!("{}{}", f.x, f.y); - let raw = pad_to(&truncate(&format!(" {mark} {}", f.path), tw), tw); - let color = if f.untracked() { - TN_RED - } else if f.staged() { - TN_GREEN - } else { - TN_YELLOW - }; - if fi == g.sel { - Style::new().fg(Color::Black).bg(color).render(&raw) - } else { - Style::new().fg(color).render(&raw) - } - } else if fi == 0 && g.files.is_empty() { - pad_to(&Style::new().fg(TN_GRAY).render(" working tree clean"), tw) - } else { - " ".repeat(tw) - }; - // right: diff - let right = if let Some(line) = g.diff.get(g.diff_scroll + i) { - let st = if line.starts_with("@@") { - Style::new().fg(TN_CYAN) - } else if line.starts_with('+') { - Style::new().fg(TN_GREEN) - } else if line.starts_with('-') { - Style::new().fg(TN_RED) - } else if line.starts_with("diff ") - || line.starts_with("index ") - || line.starts_with("--- ") - || line.starts_with("+++ ") - { - Style::new().fg(TN_GRAY) - } else { - Style::new() - }; - st.render(&truncate(line, width.saturating_sub(tw + 4))) - } else { - String::new() - }; - out.push(format!("{left}{sep}{right}")); - } + if let Some(input) = g.commit_input.as_deref() { + panel = panel.commit_input(input); } - // Bottom row: commit input, or the key hints. - let bottom = if let Some(msg) = &g.commit_input { - Style::new().fg(TN_YELLOW).bold().render(&format!( - " commit message: {msg}_ (Enter commit · Esc cancel)" - )) - } else { - Style::new().fg(TN_GRAY).render( - " ↑↓ select · Space/s stage · u unstage · a stage-all · c commit · Tab log · r refresh · Esc", - ) - }; - while out.len() + 1 < h { - out.push(String::new()); - } - out.push(pad_to(&bottom, width)); - out.truncate(h); - out.join("\n") + panel.view(self.width, self.height as usize) } } diff --git a/src/tui/panels/help.rs b/src/tui/panels/help.rs index dbd04fd..9a47d8b 100644 --- a/src/tui/panels/help.rs +++ b/src/tui/panels/help.rs @@ -22,6 +22,11 @@ impl App { row("/model", "pick the model"), row("/config", "open config.acl in your editor"), row("/ide", "file tree + code viewer"), + row("/git", "status / diff / stage / commit"), + row( + "/memory", + "browse long-term memory (GitLens-style timeline)", + ), row("/top", "live process monitor (Enter to force-kill)"), row( "/relay", @@ -35,14 +40,22 @@ impl App { String::new(), head(" Input modes"), row("! ", "run a shell command (pink) · Esc leaves"), + row( + "? ", + "deep research — goal-driven, auto-continues (cyan)", + ), row("/btw ", "side-channel question, kept out of the chat"), String::new(), head(" Keys"), row("Enter", "send · while busy, the message is queued"), row("Shift+Tab", "cycle run mode: default → plan → auto"), row("↑ / ↓", "recall input history"), - row("PgUp / PgDn", "scroll the transcript"), + row( + "wheel / PgUp / PgDn", + "scroll the transcript (scrollbar on the right)", + ), row("Shift+End", "jump to the latest output"), + row("drag", "select transcript text — auto-copies on release"), row("Esc", "interrupt the running turn"), row("Ctrl+C ×2", "quit"), String::new(), diff --git a/src/tui/panels/ide.rs b/src/tui/panels/ide.rs index dc3d1b2..5446344 100644 --- a/src/tui/panels/ide.rs +++ b/src/tui/panels/ide.rs @@ -8,8 +8,23 @@ impl App { if self.ide.is_none() { return false; } - // Esc leaves the editor first (back to the tree), then closes the panel. + // Esc: in Insert mode (or with a pending operator) it returns to a clean + // Normal mode and stays in the editor; in Normal it leaves the editor back + // to the tree; from the tree it closes the panel. if key.code == KeyCode::Esc { + if let Some(f) = self + .ide + .as_mut() + .filter(|i| i.focus_editor) + .and_then(|i| i.file.as_mut()) + { + if f.mode == EditMode::Insert || f.pending.is_some() { + f.mode = EditMode::Normal; + f.pending = None; + f.clamp_col(); + return true; + } + } let editing = self.ide.as_ref().is_some_and(|i| i.focus_editor); if editing { if let Some(i) = self.ide.as_mut() { @@ -57,94 +72,16 @@ impl App { return true; } ide.flash = None; // any edit/nav key dismisses the save flash + // Editor content width = right pane minus the line-number gutter; + // must match `render_ide` so the cursor's horizontal scroll tracks. + let tw = (w / 3).clamp(16, 38); + let content_w = w.saturating_sub(tw + 8).max(8); let f = ide.file.as_mut().unwrap(); if f.image { return true; // image preview is read-only } - let readonly = f.readonly; // view-only artifact: nav ok, edits blocked - let nlines = f.lines.len(); - match key.code { - KeyCode::Up => f.row = f.row.saturating_sub(1), - KeyCode::Down => f.row = (f.row + 1).min(nlines.saturating_sub(1)), - KeyCode::Left => { - if f.col > 0 { - f.col -= 1; - } else if f.row > 0 { - f.row -= 1; - f.col = f.lines[f.row].chars().count(); - } - } - KeyCode::Right => { - let len = f.lines.get(f.row).map_or(0, |l| l.chars().count()); - if f.col < len { - f.col += 1; - } else if f.row + 1 < nlines { - f.row += 1; - f.col = 0; - } - } - KeyCode::Home => f.col = 0, - KeyCode::End => f.col = f.lines.get(f.row).map_or(0, |l| l.chars().count()), - KeyCode::PageUp => f.row = f.row.saturating_sub(body), - KeyCode::PageDown => f.row = (f.row + body).min(nlines.saturating_sub(1)), - KeyCode::Char(c) if !readonly => { - let b = char_byte(&f.lines[f.row], f.col); - f.lines[f.row].insert(b, c); - f.col += 1; - f.dirty = true; - } - KeyCode::Tab if !readonly => { - let b = char_byte(&f.lines[f.row], f.col); - f.lines[f.row].insert_str(b, " "); - f.col += 4; - f.dirty = true; - } - KeyCode::Enter if !readonly => { - let b = char_byte(&f.lines[f.row], f.col); - let right = f.lines[f.row].split_off(b); - f.lines.insert(f.row + 1, right); - f.row += 1; - f.col = 0; - f.dirty = true; - } - KeyCode::Backspace if !readonly => { - if f.col > 0 { - let b0 = char_byte(&f.lines[f.row], f.col - 1); - let b1 = char_byte(&f.lines[f.row], f.col); - f.lines[f.row].replace_range(b0..b1, ""); - f.col -= 1; - f.dirty = true; - } else if f.row > 0 { - let cur = f.lines.remove(f.row); - f.row -= 1; - f.col = f.lines[f.row].chars().count(); - f.lines[f.row].push_str(&cur); - f.dirty = true; - } - } - KeyCode::Delete if !readonly => { - let len = f.lines[f.row].chars().count(); - if f.col < len { - let b0 = char_byte(&f.lines[f.row], f.col); - let b1 = char_byte(&f.lines[f.row], f.col + 1); - f.lines[f.row].replace_range(b0..b1, ""); - f.dirty = true; - } else if f.row + 1 < nlines { - let next = f.lines.remove(f.row + 1); - f.lines[f.row].push_str(&next); - f.dirty = true; - } - } - _ => {} - } - // Clamp cursor column + scroll the cursor into view. - let len = f.lines.get(f.row).map_or(0, |l| l.chars().count()); - f.col = f.col.min(len); - if f.row < f.scroll { - f.scroll = f.row; - } else if f.row >= f.scroll + body { - f.scroll = f.row + 1 - body; - } + // Vim-aligned editing/navigation lives on the buffer itself. + f.edit_key(key, body, content_w); return true; } // Tree focused: Tab enters the editor. @@ -191,16 +128,7 @@ impl App { render_image_file(&path, w.saturating_sub(tw + 4), h.saturating_sub(3)) .unwrap_or_else(|| vec!["".into()]); touch_workspace_file_path_for_manifest(&workspace_manifest, &workspace, &path); - ide.file = Some(IdeFile { - path, - lines, - scroll: 0, - row: 0, - col: 0, - dirty: false, - image: true, - readonly: true, - }); + ide.file = Some(IdeFile::new(path, lines, true, true)); ide.focus_editor = false; // read-only; keep tree focus } else { let lines: Vec = std::fs::read_to_string(&path) @@ -210,20 +138,7 @@ impl App { .map(String::from) .collect(); touch_workspace_file_path_for_manifest(&workspace_manifest, &workspace, &path); - ide.file = Some(IdeFile { - path, - lines: if lines.is_empty() { - vec![String::new()] - } else { - lines - }, - scroll: 0, - row: 0, - col: 0, - dirty: false, - image: false, - readonly: false, - }); + ide.file = Some(IdeFile::new(path, lines, false, false)); ide.focus_editor = true; } } @@ -263,14 +178,19 @@ impl App { }) .unwrap_or_else(|| "(no file)".into()); let readonly = ide.file.as_ref().is_some_and(|f| f.readonly); - let hint = if let Some(flash) = ide.flash.as_deref() { - flash + let mode = ide.file.as_ref().map(|f| f.mode); + let hint: String = if let Some(flash) = ide.flash.as_deref() { + flash.to_string() } else if readonly { - "read-only · ↑↓/PgUp/PgDn scroll · Esc back" + "read-only · NORMAL · hjkl/↑↓ move · gg/G top/bottom · Esc back".to_string() } else if ide.focus_editor { - "edit · Ctrl+S save · Esc back to tree" + match mode { + Some(EditMode::Insert) => "-- INSERT -- · Esc normal · Ctrl+S save".to_string(), + _ => "-- NORMAL -- · i insert · dd/dw/x cut · u undo · Ctrl+S save · Esc tree" + .to_string(), + } } else { - "Tab edit · ↑↓ nav · Enter open · Esc close" + "Tab edit · ↑↓ nav · Enter open · Esc close".to_string() }; let mut out = vec![ pad_to( @@ -314,16 +234,23 @@ impl App { f.lines.get(f.scroll + i).cloned().unwrap_or_default() } else if let Some(line) = f.lines.get(f.scroll + i) { let lineno = f.scroll + i; + let cur_row = ide.focus_editor && lineno == f.row; let num = Style::new() - .fg(if ide.focus_editor && lineno == f.row { - TN_YELLOW - } else { - TN_GRAY - }) + .fg(if cur_row { TN_YELLOW } else { TN_GRAY }) .render(&format!("{:>4} ", lineno + 1)); - // Truncate the plain line first, then syntax-highlight it. - let plain = truncate(line, width.saturating_sub(tw + 8).max(8)); - format!("{num}{}", highlight_code(&plain, lang_of(&f.path))) + let cw = width.saturating_sub(tw + 8).max(8); + // Horizontal scroll: render only the window [hscroll, hscroll+cw) + // of the line so long lines scroll sideways instead of being + // truncated off-screen. + let window = slice_cols(line, f.hscroll, f.hscroll + cw); + let body_str = if cur_row { + // Active line renders plain + a block cursor so the column + // (and the horizontal scroll position) stays visible. + render_cursor_line(&window, f.display_col().saturating_sub(f.hscroll)) + } else { + highlight_code(&window, lang_of(&f.path)) + }; + format!("{num}{body_str}") } else { String::new() } @@ -341,3 +268,810 @@ impl App { out.join("\n") } } + +/// Vim-aligned editing for an open `IdeFile`. Normal mode navigates and operates; +/// Insert mode types. Arrows / Home / End / PgUp / PgDn work in both modes, and +/// Insert mode also honours the readline shortcuts traditional editors bind +/// (Ctrl+A/E/K/U/W). Read-only buffers allow navigation but block every edit. +impl IdeFile { + /// Char count of the current line. + fn cur_len(&self) -> usize { + self.lines.get(self.row).map_or(0, |l| l.chars().count()) + } + + /// Clamp the cursor column into the current line. Normal mode rests *on* the + /// last char (col ≤ len-1); Insert may sit *after* it (col ≤ len). + fn clamp_col(&mut self) { + let len = self.cur_len(); + let max = if self.mode == EditMode::Insert { + len + } else { + len.saturating_sub(1) + }; + self.col = self.col.min(max); + } + + /// Column of the first non-blank char on the current line (vim `^`). + fn first_nonblank(&self) -> usize { + self.lines.get(self.row).map_or(0, |l| { + l.chars().position(|c| !c.is_whitespace()).unwrap_or(0) + }) + } + + /// vim word classes: 0 = whitespace, 1 = word char, 2 = punctuation. + fn char_class(c: char) -> u8 { + if c.is_whitespace() { + 0 + } else if c.is_alphanumeric() || c == '_' { + 1 + } else { + 2 + } + } + + fn row_chars(&self) -> Vec { + self.lines + .get(self.row) + .map_or(Vec::new(), |l| l.chars().collect()) + } + + /// Start of the next word on this line (vim `w`, within-line). + fn next_word(&self) -> usize { + let line = self.row_chars(); + let n = line.len(); + let mut c = self.col; + if c >= n { + return n; + } + let cls = Self::char_class(line[c]); + if cls != 0 { + while c < n && Self::char_class(line[c]) == cls { + c += 1; + } + } + while c < n && Self::char_class(line[c]) == 0 { + c += 1; + } + c + } + + /// Start of the previous word on this line (vim `b`, within-line). + fn prev_word(&self) -> usize { + let line = self.row_chars(); + let mut c = self.col; + if c == 0 { + return 0; + } + c -= 1; + while c > 0 && Self::char_class(line[c]) == 0 { + c -= 1; + } + let cls = Self::char_class(line[c]); + while c > 0 && Self::char_class(line[c - 1]) == cls { + c -= 1; + } + c + } + + /// End of the current/next word on this line (vim `e`, within-line). + fn word_end(&self) -> usize { + let line = self.row_chars(); + let n = line.len(); + if n == 0 { + return 0; + } + let mut c = self.col; + if c + 1 >= n { + return n - 1; + } + c += 1; + while c < n && Self::char_class(line[c]) == 0 { + c += 1; + } + if c >= n { + return n - 1; + } + let cls = Self::char_class(line[c]); + while c + 1 < n && Self::char_class(line[c + 1]) == cls { + c += 1; + } + c + } + + /// Leading whitespace of the current line (for `o`/`O` auto-indent). + fn leading_ws(&self) -> String { + self.lines.get(self.row).map_or(String::new(), |l| { + l.chars().take_while(|c| c.is_whitespace()).collect() + }) + } + + /// Snapshot the buffer + cursor for `u`. + // ponytail: whole-buffer snapshots, bounded — fine for config-sized files + fn snapshot(&mut self) { + if self.undo.len() >= 200 { + self.undo.remove(0); + } + self.undo.push((self.lines.clone(), self.row, self.col)); + } + + fn undo(&mut self) { + if let Some((lines, row, col)) = self.undo.pop() { + self.lines = lines; + self.row = row.min(self.lines.len().saturating_sub(1)); + self.col = col; + self.dirty = true; + self.clamp_col(); + } + } + + /// Handle one key in the focused editor. Ctrl+S (save) is handled by the + /// caller before this; Esc (Insert→Normal / leave) likewise. + pub(crate) fn edit_key(&mut self, key: &KeyEvent, body: usize, content_w: usize) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let nlines = self.lines.len(); + // A pending Normal-mode operator (d/c/g/y) consumes the next key — even an + // arrow — instead of the shared navigation below. + let pending = self.mode == EditMode::Normal && self.pending.is_some(); + let mut handled = false; + if !pending { + handled = true; + match key.code { + KeyCode::Up => self.row = self.row.saturating_sub(1), + KeyCode::Down => self.row = (self.row + 1).min(nlines.saturating_sub(1)), + KeyCode::Left => self.move_left(), + KeyCode::Right => self.move_right(), + KeyCode::Home => self.col = 0, + KeyCode::End => self.col = self.cur_len(), + KeyCode::PageUp => self.row = self.row.saturating_sub(body), + KeyCode::PageDown => self.row = (self.row + body).min(nlines.saturating_sub(1)), + _ => handled = false, + } + } + if !handled { + match self.mode { + EditMode::Insert => self.insert_key(key, ctrl), + EditMode::Normal => self.normal_key(key), + } + } + // Clamp the cursor + scroll it into view, vertically AND horizontally. + self.clamp_col(); + if self.row < self.scroll { + self.scroll = self.row; + } else if body > 0 && self.row >= self.scroll + body { + self.scroll = self.row + 1 - body; + } + // Horizontal: keep the cursor's display column within the content width, + // so long lines scroll sideways instead of being truncated off-screen. + let cur_x = self.display_col(); + if cur_x < self.hscroll { + self.hscroll = cur_x; + } else if content_w > 0 && cur_x >= self.hscroll + content_w { + self.hscroll = cur_x + 1 - content_w; + } + } + + /// The cursor's display column (sum of glyph widths before `col`), so + /// horizontal scrolling and the block cursor land correctly with wide (CJK) + /// chars, not just ASCII. + fn display_col(&self) -> usize { + self.lines.get(self.row).map_or(0, |l| { + l.chars() + .take(self.col) + .map(|c| a3s_tui::style::visible_len(&c.to_string())) + .sum() + }) + } + + fn move_left(&mut self) { + if self.col > 0 { + self.col -= 1; + } else if self.row > 0 { + self.row -= 1; + self.col = self.cur_len(); + } + } + + fn move_right(&mut self) { + let len = self.cur_len(); + if self.col < len { + self.col += 1; + } else if self.row + 1 < self.lines.len() { + self.row += 1; + self.col = 0; + } + } + + // ── Insert mode ────────────────────────────────────────────────────────── + fn insert_key(&mut self, key: &KeyEvent, ctrl: bool) { + if self.readonly { + return; + } + if ctrl { + // readline-style shortcuts traditional editors bind in insert mode. + match key.code { + KeyCode::Char('a' | 'A') => self.col = 0, + KeyCode::Char('e' | 'E') => self.col = self.cur_len(), + KeyCode::Char('k' | 'K') => { + self.snapshot(); + self.kill_to_eol(); + } + KeyCode::Char('u' | 'U') => { + self.snapshot(); + self.kill_to_bol(); + } + KeyCode::Char('w' | 'W') => { + self.snapshot(); + self.delete_word_back(); + } + _ => {} + } + return; + } + match key.code { + KeyCode::Char(c) => self.insert_char(c), + KeyCode::Tab => self.insert_str(" "), + KeyCode::Enter => self.split_line(), + KeyCode::Backspace => self.backspace(), + KeyCode::Delete => self.delete_forward(), + _ => {} + } + } + + fn insert_char(&mut self, c: char) { + let b = char_byte(&self.lines[self.row], self.col); + self.lines[self.row].insert(b, c); + self.col += 1; + self.dirty = true; + } + fn insert_str(&mut self, s: &str) { + let b = char_byte(&self.lines[self.row], self.col); + self.lines[self.row].insert_str(b, s); + self.col += s.chars().count(); + self.dirty = true; + } + fn split_line(&mut self) { + let b = char_byte(&self.lines[self.row], self.col); + let right = self.lines[self.row].split_off(b); + self.lines.insert(self.row + 1, right); + self.row += 1; + self.col = 0; + self.dirty = true; + } + fn backspace(&mut self) { + if self.col > 0 { + let b0 = char_byte(&self.lines[self.row], self.col - 1); + let b1 = char_byte(&self.lines[self.row], self.col); + self.lines[self.row].replace_range(b0..b1, ""); + self.col -= 1; + self.dirty = true; + } else if self.row > 0 { + let cur = self.lines.remove(self.row); + self.row -= 1; + self.col = self.cur_len(); + self.lines[self.row].push_str(&cur); + self.dirty = true; + } + } + fn delete_forward(&mut self) { + let len = self.cur_len(); + if self.col < len { + let b0 = char_byte(&self.lines[self.row], self.col); + let b1 = char_byte(&self.lines[self.row], self.col + 1); + self.lines[self.row].replace_range(b0..b1, ""); + self.dirty = true; + } else if self.row + 1 < self.lines.len() { + let next = self.lines.remove(self.row + 1); + self.lines[self.row].push_str(&next); + self.dirty = true; + } + } + fn kill_to_eol(&mut self) { + let b = char_byte(&self.lines[self.row], self.col); + if !self.lines[self.row].split_off(b).is_empty() { + self.dirty = true; + } + } + fn kill_to_bol(&mut self) { + let b = char_byte(&self.lines[self.row], self.col); + self.lines[self.row].replace_range(0..b, ""); + self.col = 0; + self.dirty = true; + } + fn delete_word_back(&mut self) { + let start = self.prev_word(); + let from = if start < self.col { + start + } else { + self.col.saturating_sub(1) + }; + if from < self.col { + let b0 = char_byte(&self.lines[self.row], from); + let b1 = char_byte(&self.lines[self.row], self.col); + self.lines[self.row].replace_range(b0..b1, ""); + self.col = from; + self.dirty = true; + } + } + + // ── Normal mode ────────────────────────────────────────────────────────── + fn normal_key(&mut self, key: &KeyEvent) { + if let Some(op) = self.pending.take() { + self.apply_operator(op, key); + return; + } + let ch = match key.code { + KeyCode::Char(c) => c, + _ => return, + }; + let ro = self.readonly; + match ch { + // motions + 'h' => self.col = self.col.saturating_sub(1), + 'l' => { + if self.col + 1 < self.cur_len() { + self.col += 1; + } + } + 'j' => self.row = (self.row + 1).min(self.lines.len().saturating_sub(1)), + 'k' => self.row = self.row.saturating_sub(1), + 'w' => self.col = self.next_word(), + 'b' => self.col = self.prev_word(), + 'e' => self.col = self.word_end(), + '0' => self.col = 0, + '^' => self.col = self.first_nonblank(), + '$' => self.col = self.cur_len().saturating_sub(1), + 'G' => self.row = self.lines.len().saturating_sub(1), + // operator / prefix — wait for the second key (g/d/c/y; r = replace one char) + 'g' | 'd' | 'c' | 'y' => self.pending = Some(ch), + 'r' if !ro => self.pending = Some('r'), + // inline edits + 'x' if !ro => { + self.snapshot(); + self.delete_char_under(); + } + 'J' if !ro => { + self.snapshot(); + self.join_line(); + } + '~' if !ro => { + self.snapshot(); + self.toggle_case(); + } + 'D' if !ro => { + self.snapshot(); + self.delete_to_eol(); + } + 'C' if !ro => { + self.snapshot(); + self.delete_to_eol(); + self.mode = EditMode::Insert; + } + 'p' if !ro => { + self.snapshot(); + self.paste(true); + } + 'P' if !ro => { + self.snapshot(); + self.paste(false); + } + 'u' if !ro => self.undo(), + // enter insert + 'i' if !ro => { + self.snapshot(); + self.mode = EditMode::Insert; + } + 'a' if !ro => { + self.snapshot(); + self.col = (self.col + 1).min(self.cur_len()); + self.mode = EditMode::Insert; + } + 'I' if !ro => { + self.snapshot(); + self.col = self.first_nonblank(); + self.mode = EditMode::Insert; + } + 'A' if !ro => { + self.snapshot(); + self.col = self.cur_len(); + self.mode = EditMode::Insert; + } + 'o' if !ro => { + self.snapshot(); + self.open_line(true); + } + 'O' if !ro => { + self.snapshot(); + self.open_line(false); + } + _ => {} + } + } + + /// Second key of a two-stroke Normal command (the operator/prefix is `op`). + fn apply_operator(&mut self, op: char, key: &KeyEvent) { + let ch = match key.code { + KeyCode::Char(c) => c, + _ => return, // arrow/other after an operator just cancels it + }; + let ro = self.readonly; + match (op, ch) { + ('g', 'g') => self.row = 0, + ('d', 'd') if !ro => { + self.snapshot(); + self.delete_line(true); + } + ('d', 'w') if !ro => { + self.snapshot(); + self.delete_word(); + } + ('d', '$') if !ro => { + self.snapshot(); + self.delete_to_eol(); + } + ('c', 'c') if !ro => { + self.snapshot(); + self.clear_line(); + self.mode = EditMode::Insert; + } + ('c', 'w') if !ro => { + self.snapshot(); + self.delete_word(); + self.mode = EditMode::Insert; + } + ('c', '$') if !ro => { + self.snapshot(); + self.delete_to_eol(); + self.mode = EditMode::Insert; + } + ('y', 'y') => self.yank_line(), + // `r` — replace the char under the cursor in place. + ('r', c) if !ro => { + self.snapshot(); + self.replace_char(c); + } + _ => {} + } + } + + /// `r` — replace the glyph under the cursor (vim, stays in Normal). + fn replace_char(&mut self, ch: char) { + let len = self.cur_len(); + if self.col < len { + let b0 = char_byte(&self.lines[self.row], self.col); + let b1 = char_byte(&self.lines[self.row], self.col + 1); + self.lines[self.row].replace_range(b0..b1, &ch.to_string()); + self.dirty = true; + } + } + + /// `J` — join the next line onto this one with a single separating space + /// (cursor lands at the join, vim-style). + fn join_line(&mut self) { + if self.row + 1 < self.lines.len() { + let next = self.lines.remove(self.row + 1); + let cur = &mut self.lines[self.row]; + self.col = cur.chars().count(); + if !cur.is_empty() && !next.trim_start().is_empty() { + cur.push(' '); + } + cur.push_str(next.trim_start()); + self.dirty = true; + } + } + + /// `~` — toggle the case of the glyph under the cursor and advance. + fn toggle_case(&mut self) { + let len = self.cur_len(); + if self.col < len { + if let Some(ch) = self.lines[self.row].chars().nth(self.col) { + let flipped: String = if ch.is_uppercase() { + ch.to_lowercase().collect() + } else { + ch.to_uppercase().collect() + }; + let b0 = char_byte(&self.lines[self.row], self.col); + let b1 = char_byte(&self.lines[self.row], self.col + 1); + self.lines[self.row].replace_range(b0..b1, &flipped); + self.col = (self.col + 1).min(self.cur_len().saturating_sub(1)); + self.dirty = true; + } + } + } + + fn delete_char_under(&mut self) { + let len = self.cur_len(); + if self.col < len { + let b0 = char_byte(&self.lines[self.row], self.col); + let b1 = char_byte(&self.lines[self.row], self.col + 1); + self.clip = self.lines[self.row][b0..b1].to_string(); + self.clip_linewise = false; + self.lines[self.row].replace_range(b0..b1, ""); + self.dirty = true; + } + } + fn delete_to_eol(&mut self) { + let b = char_byte(&self.lines[self.row], self.col); + let removed = self.lines[self.row].split_off(b); + if !removed.is_empty() { + self.clip = removed; + self.clip_linewise = false; + self.dirty = true; + } + } + fn delete_line(&mut self, yank: bool) { + if yank { + self.clip = self.lines[self.row].clone(); + self.clip_linewise = true; + } + if self.lines.len() > 1 { + self.lines.remove(self.row); + if self.row >= self.lines.len() { + self.row = self.lines.len() - 1; + } + } else { + self.lines[0].clear(); + } + self.col = 0; + self.dirty = true; + } + fn delete_word(&mut self) { + let to = self.next_word().min(self.cur_len()); + if to > self.col { + let b0 = char_byte(&self.lines[self.row], self.col); + let b1 = char_byte(&self.lines[self.row], to); + self.clip = self.lines[self.row][b0..b1].to_string(); + self.clip_linewise = false; + self.lines[self.row].replace_range(b0..b1, ""); + self.dirty = true; + } + } + fn clear_line(&mut self) { + self.clip = self.lines[self.row].clone(); + self.clip_linewise = true; + self.lines[self.row].clear(); + self.col = 0; + self.dirty = true; + } + fn yank_line(&mut self) { + self.clip = self.lines[self.row].clone(); + self.clip_linewise = true; + } + fn paste(&mut self, after: bool) { + if self.clip.is_empty() && !self.clip_linewise { + return; + } + if self.clip_linewise { + let at = if after { self.row + 1 } else { self.row }; + self.lines.insert(at, self.clip.clone()); + self.row = at; + self.col = 0; + } else { + let col = if after && self.cur_len() > 0 { + (self.col + 1).min(self.cur_len()) + } else { + self.col + }; + let b = char_byte(&self.lines[self.row], col); + self.lines[self.row].insert_str(b, &self.clip); + self.col = col + self.clip.chars().count().saturating_sub(1); + } + self.dirty = true; + } + fn open_line(&mut self, below: bool) { + let indent = self.leading_ws(); + let at = if below { self.row + 1 } else { self.row }; + self.lines.insert(at, indent); + self.row = at; + self.col = self.cur_len(); + self.mode = EditMode::Insert; + self.dirty = true; + } +} + +/// Render an editor line's visible window in plain text with a block cursor at +/// `cursor_col` (display columns; a space when the cursor sits past end-of-line). +/// Plain — no syntax colour — so the inverse cursor cell is unambiguous on the +/// active line. +fn render_cursor_line(window: &str, cursor_col: usize) -> String { + let before = slice_cols(window, 0, cursor_col); + let at = slice_cols(window, cursor_col, cursor_col + 1); + let at = if at.is_empty() { " ".to_string() } else { at }; + let after = slice_cols(window, cursor_col + 1, usize::MAX); + format!( + "{before}{}{after}", + Style::new().fg(Color::Black).bg(TN_FG).render(&at) + ) +} + +#[cfg(test)] +mod vim_tests { + use super::*; + + fn buf(lines: &[&str]) -> IdeFile { + IdeFile::new( + std::path::PathBuf::from("t"), + lines.iter().map(|s| s.to_string()).collect(), + false, + false, + ) + } + fn ro(lines: &[&str]) -> IdeFile { + IdeFile::new( + std::path::PathBuf::from("t"), + lines.iter().map(|s| s.to_string()).collect(), + false, + true, + ) + } + fn k(c: char) -> KeyEvent { + KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::NONE, + } + } + /// Feed a sequence of plain Char keys (covers multi-key ops like dd/gg/dw). + fn feed(f: &mut IdeFile, s: &str) { + for c in s.chars() { + f.edit_key(&k(c), 20, 80); + } + } + + #[test] + fn opens_in_normal_and_navigates_without_typing() { + let mut f = buf(&["foo", "bar"]); + assert_eq!(f.mode, EditMode::Normal); + feed(&mut f, "j"); + assert_eq!(f.row, 1); + feed(&mut f, "$"); + assert_eq!(f.col, 2); // last char of "bar" + feed(&mut f, "0"); + assert_eq!(f.col, 0); + assert_eq!(f.lines, vec!["foo", "bar"]); // motions never inserted + } + + #[test] + fn word_motions_w_e_b() { + let mut f = buf(&["foo bar baz"]); + feed(&mut f, "w"); + assert_eq!(f.col, 4); // start of "bar" + feed(&mut f, "e"); + assert_eq!(f.col, 6); // end of "bar" + feed(&mut f, "b"); + assert_eq!(f.col, 4); // back to start of "bar" + } + + #[test] + fn gg_and_capital_g_jump() { + let mut f = buf(&["a", "b", "c"]); + feed(&mut f, "G"); + assert_eq!(f.row, 2); + feed(&mut f, "gg"); + assert_eq!(f.row, 0); + } + + #[test] + fn insert_mode_types_literally() { + let mut f = buf(&["bc"]); + feed(&mut f, "i"); + assert_eq!(f.mode, EditMode::Insert); + feed(&mut f, "a"); // literal char in Insert, not "append" + assert_eq!(f.lines[0], "abc"); + assert_eq!(f.col, 1); + } + + #[test] + fn append_at_end_of_line() { + let mut f = buf(&["ab"]); + feed(&mut f, "A"); + assert_eq!(f.mode, EditMode::Insert); + assert_eq!(f.col, 2); + feed(&mut f, "c"); + assert_eq!(f.lines[0], "abc"); + } + + #[test] + fn o_opens_indented_line_below_in_insert() { + let mut f = buf(&[" foo"]); + feed(&mut f, "o"); + assert_eq!(f.mode, EditMode::Insert); + assert_eq!(f.row, 1); + assert_eq!(f.lines[1], " "); // indent carried + feed(&mut f, "x"); + assert_eq!(f.lines[1], " x"); + } + + #[test] + fn x_deletes_char_under_cursor() { + let mut f = buf(&["abc"]); + feed(&mut f, "x"); + assert_eq!(f.lines[0], "bc"); + assert_eq!(f.col, 0); + } + + #[test] + fn dw_deletes_word() { + let mut f = buf(&["foo bar"]); + feed(&mut f, "dw"); + assert_eq!(f.lines[0], "bar"); + } + + #[test] + fn dd_then_p_moves_a_line() { + let mut f = buf(&["one", "two", "three"]); + feed(&mut f, "dd"); + assert_eq!(f.lines, vec!["two", "three"]); + feed(&mut f, "p"); // paste "one" below current line + assert_eq!(f.lines, vec!["two", "one", "three"]); + } + + #[test] + fn u_undoes_a_change() { + let mut f = buf(&["abc"]); + feed(&mut f, "x"); + assert_eq!(f.lines[0], "bc"); + feed(&mut f, "u"); + assert_eq!(f.lines[0], "abc"); + } + + #[test] + fn ctrl_w_kills_word_back_in_insert() { + let mut f = buf(&["foo bar"]); + feed(&mut f, "A"); // insert mode at EOL + f.edit_key( + &KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::CONTROL, + }, + 20, + 80, + ); + assert_eq!(f.lines[0], "foo "); + } + + #[test] + fn readonly_navigates_but_blocks_edits() { + let mut f = ro(&["abc", "def"]); + feed(&mut f, "j"); + assert_eq!(f.row, 1); + feed(&mut f, "x"); // edit blocked + assert_eq!(f.lines, vec!["abc", "def"]); + feed(&mut f, "i"); // can't enter insert on a read-only buffer + assert_eq!(f.mode, EditMode::Normal); + } + + #[test] + fn horizontal_scroll_follows_the_cursor() { + let mut f = buf(&["abcdefghijklmnopqrst"]); // 20 cols + // content width 8 → moving to EOL (col 19) scrolls the window right. + f.edit_key(&k('$'), 20, 8); + assert_eq!(f.col, 19); + assert_eq!(f.hscroll, 12); // 19 + 1 - 8 + // back to col 0 → scrolled fully left again. + f.edit_key(&k('0'), 20, 8); + assert_eq!(f.hscroll, 0); + } + + #[test] + fn render_cursor_line_marks_the_active_column() { + let out = render_cursor_line("hello", 1); + assert!(out.starts_with('h')); // text before the cursor is plain + assert!(out.contains('\u{1b}')); // the cursor cell is styled (inverse) + // cursor past end-of-line still renders a block (a styled space). + assert!(render_cursor_line("hi", 5).contains('\u{1b}')); + } + + #[test] + fn vim_join_replace_and_toggle_case() { + let mut f = buf(&["foo", "bar"]); + feed(&mut f, "J"); // join the next line with a space + assert_eq!(f.lines, vec!["foo bar"]); + + let mut f = buf(&["abc"]); + feed(&mut f, "rx"); // replace the char under the cursor + assert_eq!(f.lines[0], "xbc"); + + let mut f = buf(&["aB"]); + feed(&mut f, "~"); // toggle case + advance + assert_eq!(f.lines[0], "AB"); + } +} diff --git a/src/tui/panels/memory.rs b/src/tui/panels/memory.rs new file mode 100644 index 0000000..13cb104 --- /dev/null +++ b/src/tui/panels/memory.rs @@ -0,0 +1,239 @@ +//! `/memory` panel: a GitLens-style timeline of the agent's long-term memory. +//! +//! Left column is the timeline — memories newest-first, bucketed by day, each a +//! node tinted by its memory type. Right column is the selected memory's full +//! content + metadata (lazily read from its item file). Read-only. + +use super::super::*; + +/// Short badge + accent colour for a memory type. +fn mem_type_style(t: &str) -> (&'static str, Color) { + match t { + "semantic" => ("sem", TN_CYAN), + "procedural" => ("proc", TN_GREEN), + "working" => ("work", TN_GRAY), + _ => ("epis", TN_YELLOW), // episodic / unknown + } +} + +/// Importance as a 5-cell bar, e.g. `▰▰▰▰▱`. +fn imp_bar(importance: f32) -> String { + let filled = (importance.clamp(0.0, 1.0) * 5.0).round() as usize; + format!("{}{}", "▰".repeat(filled), "▱".repeat(5 - filled)) +} + +impl App { + /// Handle a key while the `/memory` panel is open. + pub(crate) fn memory_key(&mut self, key: &KeyEvent) { + if key.code == KeyCode::Esc { + self.memory = None; + return; + } + let body = (self.height as usize).saturating_sub(3); + let Some(m) = self.memory.as_mut() else { + return; + }; + let last = m.entries.len().saturating_sub(1); + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + m.sel = m.sel.saturating_sub(1); + m.refresh_detail(); + } + KeyCode::Down | KeyCode::Char('j') => { + m.sel = (m.sel + 1).min(last); + m.refresh_detail(); + } + KeyCode::Char('g') => { + m.sel = 0; + m.refresh_detail(); + } + KeyCode::Char('G') => { + m.sel = last; + m.refresh_detail(); + } + // Page keys scroll the detail pane (long memories). + KeyCode::PageUp => { + m.detail_scroll = m + .detail_scroll + .saturating_sub(body.saturating_sub(1).max(1)); + } + KeyCode::PageDown => m.detail_scroll += body.saturating_sub(1).max(1), + // Reload from disk (new memories may have been recorded mid-session). + KeyCode::Char('r') => { + m.entries = memutil::load_timeline(&m.dir); + m.sel = m.sel.min(m.entries.len().saturating_sub(1)); + m.note = format!("{} entries", m.entries.len()); + m.refresh_detail(); + } + _ => {} + } + } + + /// Full-screen `/memory` panel: timeline (left) + selected detail (right). + pub(crate) fn render_memory(&self, m: &MemPanel) -> String { + let width = self.width as usize; + let h = self.height as usize; + let now = chrono::Utc::now(); + + let header = format!( + " memory · {} {} {}", + m.entries.len(), + Style::new().fg(TN_GRAY).render("entries · ~/.a3s/memory"), + Style::new().fg(TN_GRAY).render(&m.note), + ); + let mut out = vec![ + pad_to(&header, width), + pad_to(&Style::new().fg(TN_GRAY).render(&"─".repeat(width)), width), + ]; + let body = h.saturating_sub(3); + + if m.entries.is_empty() { + out.push(pad_to( + &Style::new().fg(TN_GRAY).render( + " no memories yet — the agent records them as you work (success/failure patterns, facts)", + ), + width, + )); + while out.len() + 1 < h { + out.push(String::new()); + } + out.push(pad_to( + &Style::new().fg(TN_GRAY).render(" Esc close"), + width, + )); + out.truncate(h); + return out.join("\n"); + } + + let tw = (width / 3).clamp(26, 52); + let sep = Style::new().fg(TN_GRAY).render(" │ "); + let prefix = 15; // " ● " + time(4) + " " + badge(4) + " " + + // Left: timeline rows (day buckets + nodes); keep the selection visible. + let rows = timeline_rows(&m.entries, now); + let sel_row = rows + .iter() + .position(|r| matches!(r, TlRow::Node(i) if *i == m.sel)) + .unwrap_or(0); + let start = sel_row.saturating_sub(body.saturating_sub(1)); + + // Right: the selected memory's detail, scrollable. + let right_lines = self.memory_detail_lines(m, now, width.saturating_sub(tw + 4)); + + for i in 0..body { + let left = match rows.get(start + i) { + Some(TlRow::Day(label)) => { + let head = format!(" ── {label} "); + let bar = "─".repeat(tw.saturating_sub(a3s_tui::style::visible_len(&head))); + Style::new() + .fg(TN_GRAY) + .render(&pad_to(&format!("{head}{bar}"), tw)) + } + Some(TlRow::Node(idx)) => { + let e = &m.entries[*idx]; + let (badge, color) = mem_type_style(&e.memory_type); + let time = rel_time(e.timestamp, now); + let preview = e.content_lower.lines().next().unwrap_or(""); + let preview = truncate(preview, tw.saturating_sub(prefix)); + if *idx == m.sel { + let plain = format!(" ● {time:>4} {badge:<4} {preview}"); + Style::new() + .fg(Color::Black) + .bg(color) + .render(&pad_to(&plain, tw)) + } else { + // Truncate the plain preview first, then style segments, so + // we never cut an escape sequence mid-byte. + let rail = Style::new().fg(color).render(" ●"); + let t = Style::new().fg(TN_GRAY).render(&format!(" {time:>4}")); + let b = Style::new().fg(color).render(&format!(" {badge:<4}")); + let pv = Style::new().fg(TN_FG).render(&format!(" {preview}")); + pad_to(&format!("{rail}{t}{b}{pv}"), tw) + } + } + None => " ".repeat(tw), + }; + let right = right_lines + .get(m.detail_scroll + i) + .cloned() + .unwrap_or_default(); + out.push(format!("{left}{sep}{right}")); + } + + let hint = + " ↑↓/jk select · g/G top/bottom · PgUp/PgDn scroll detail · r refresh · Esc close"; + while out.len() + 1 < h { + out.push(String::new()); + } + out.push(pad_to(&Style::new().fg(TN_GRAY).render(hint), width)); + out.truncate(h); + out.join("\n") + } + + /// Build the right-pane lines for the selected memory: a metadata block, a + /// rule, then the full original-case content (word-wrapped to `w`). + fn memory_detail_lines( + &self, + m: &MemPanel, + now: chrono::DateTime, + w: usize, + ) -> Vec { + let mut lines = Vec::new(); + let Some(e) = m.entries.get(m.sel) else { + return lines; + }; + let (_, color) = mem_type_style(&e.memory_type); + let ty = if e.memory_type.is_empty() { + "memory" + } else { + &e.memory_type + }; + lines.push(format!( + "{} {} {}", + Style::new().fg(color).bold().render(&format!("● {ty}")), + Style::new().fg(color).render(&imp_bar(e.importance)), + Style::new().fg(TN_GRAY).render(&format!( + "importance {:.2} · {}", + e.importance, + rel_time(e.timestamp, now) + )), + )); + if !e.tags.is_empty() { + lines.push( + Style::new() + .fg(TN_CYAN) + .render(&format!("tags: {}", e.tags.join(", "))), + ); + } + let created = e.timestamp.format("%Y-%m-%d %H:%M").to_string(); + let accessed = match m.detail.last_accessed { + Some(la) => format!(" · last {}", la.format("%Y-%m-%d %H:%M")), + None => String::new(), + }; + lines.push(Style::new().fg(TN_GRAY).render(&format!( + "created {created} · {}× accessed{accessed}", + m.detail.access_count + ))); + for (k, v) in &m.detail.metadata { + let v = truncate(v, w.saturating_sub(k.len() + 2)); + lines.push(Style::new().fg(TN_GRAY).render(&format!("{k}: {v}"))); + } + lines.push(Style::new().fg(TN_GRAY).render(&"─".repeat(w.min(48)))); + // Prefer the full original-case content; fall back to the index preview. + let content = if m.detail.content.is_empty() { + e.content_lower.as_str() + } else { + m.detail.content.as_str() + }; + for raw in content.lines() { + if raw.is_empty() { + lines.push(String::new()); + } else { + for wl in wrap_words(raw, w.max(8)) { + lines.push(Style::new().fg(TN_FG).render(&wl)); + } + } + } + lines + } +} diff --git a/src/tui/panels/mod.rs b/src/tui/panels/mod.rs index f6dac40..b364f16 100644 --- a/src/tui/panels/mod.rs +++ b/src/tui/panels/mod.rs @@ -12,6 +12,7 @@ mod git; mod help; mod ide; pub(crate) mod login; +mod memory; mod menu; mod model; mod plan; diff --git a/src/tui/panels/model.rs b/src/tui/panels/model.rs index 3ccc583..8ecb2c1 100644 --- a/src/tui/panels/model.rs +++ b/src/tui/panels/model.rs @@ -9,6 +9,7 @@ struct ModelTab { color: Color, models: Vec, provider: Option, // None = config.acl + os_gateway: bool, // the 书安OS unified AI gateway tab } fn selected_model_location(tabs: &[ModelTab], current: Option<&str>) -> (usize, usize) { @@ -52,6 +53,7 @@ impl App { color: A3S_COLOR, models: self.models.clone(), provider: None, + os_gateway: false, }]; if has_local_login(AuthProvider::Claude) { tabs.push(ModelTab { @@ -59,6 +61,7 @@ impl App { color: CLAUDE_COLOR, models: claude_models(), // from ~/.claude.json provider: Some(AuthProvider::Claude), + os_gateway: false, }); } if has_local_login(AuthProvider::Codex) { @@ -67,6 +70,23 @@ impl App { color: CODEX_COLOR, models: crate::codex::codex_models(), // from ~/.codex/models_cache.json provider: Some(AuthProvider::Codex), + os_gateway: false, + }); + } + // Signed in to 书安OS → offer its unified AI gateway (gateway-managed: + // we send the OS token + a model id; the gateway holds provider keys). + if self.os_session.is_some() { + let models = match &self.os_gateway_models { + Some(m) if !m.is_empty() => m.clone(), + Some(_) => vec!["(gateway unavailable)".to_string()], + None => vec!["(loading…)".to_string()], + }; + tabs.push(ModelTab { + label: "OS网关", + color: TN_CYAN, + models, + provider: None, + os_gateway: true, }); } tabs @@ -118,7 +138,14 @@ impl App { KeyCode::Enter => { let model = tabs[t].models.get(sel.min(last)).cloned(); let provider = tabs[t].provider; + let os_gateway = tabs[t].os_gateway; self.model_menu = None; + if os_gateway { + if let Some(model) = model { + self.use_os_gateway(&model); + } + return Some(None); + } match provider { None => { self.llm_override = None; // config.acl credentials @@ -233,6 +260,58 @@ impl App { } } + /// Route the agent's LLM through the 书安OS **unified AI gateway**: an + /// OpenAI-compatible client at `{OS origin}/v1/chat/completions`, authed with + /// the OS Bearer token (the gateway is "gateway-managed" — it holds the real + /// provider keys). `model` is a gateway model id from its `/v1/models`. + fn use_os_gateway(&mut self, model: &str) { + if model.starts_with('(') { + // a placeholder row ("(loading…)" / "(gateway unavailable)"). + self.push_line( + &Style::new() + .fg(TN_YELLOW) + .render(" OS网关暂无可用模型(确认 OS 已配置统一 AI 网关后重试 /model)"), + ); + return; + } + if self.state != State::Idle { + self.push_line( + &Style::new() + .fg(TN_YELLOW) + .render(" finish the current turn before switching models"), + ); + return; + } + let Some(session) = self.os_session.clone() else { + return; + }; + let origin = crate::a3s_os::os_origin(&session.address); + let client = + a3s_code_core::llm::OpenAiClient::new(session.access_token.clone(), model.to_string()) + .with_base_url(origin) + .with_provider_name("OS网关"); + self.llm_override = Some(Arc::new(client)); + self.model = Some(model.to_string()); + match self.rebuild_session(Some(model)) { + Ok((s, _)) => { + self.session = Arc::new(s); + self.push_line( + &Style::new() + .fg(TN_GREEN) + .render(&format!(" ⇄ OS网关 · {model}")), + ); + } + Err(e) => { + self.llm_override = None; + self.push_line( + &Style::new() + .fg(TN_RED) + .render(&format!(" failed to switch: {e}")), + ); + } + } + } + /// Switch the active model by resuming the session under it (history kept). /// Base session options carrying the current effort. `ultracode` adds a /// planning + goal tracking + a wider tool-round budget so a turn plans, @@ -499,12 +578,14 @@ mod tests { color: A3S_COLOR, models: vec!["openai/gpt-5".into()], provider: None, + os_gateway: false, }, ModelTab { label: "Claude Code", color: CLAUDE_COLOR, models: vec!["claude-sonnet-4".into()], provider: Some(AuthProvider::Claude), + os_gateway: false, }, ]; diff --git a/src/tui/panels/plan.rs b/src/tui/panels/plan.rs index 66f6b54..796a867 100644 --- a/src/tui/panels/plan.rs +++ b/src/tui/panels/plan.rs @@ -44,15 +44,21 @@ impl App { lines } - /// Resize the viewport so the pinned plan panel and the bottom task panel - /// both fit without covering the transcript. - pub(crate) fn relayout(&mut self) { + /// Visible transcript rows = the viewport height, mirroring the layout chrome + /// (separators + status + input + pinned plan/task/subagent rows). Single + /// source of truth shared by `relayout` and mouse hit-testing. + pub(crate) fn viewport_rows(&self) -> usize { let n = (self.task_lines().len() + self.plan_lines().len() + self.subagent_lines().len()) as u16; - self.viewport.resize( - self.width, - self.height.saturating_sub(6 + self.input_height() + n), - ); + self.height.saturating_sub(6 + self.input_height() + n) as usize + } + + /// Resize the viewport so the pinned plan panel and the bottom task panel + /// both fit without covering the transcript. Width reserves one column on the + /// right for the scrollbar (`append_scrollbar`), so content never clips it. + pub(crate) fn relayout(&mut self) { + self.viewport + .resize(self.width.saturating_sub(1), self.viewport_rows() as u16); } /// Replace the pinned plan from a planning-mode task list. diff --git a/src/tui/remote_ui.rs b/src/tui/remote_ui.rs new file mode 100644 index 0000000..7309a57 --- /dev/null +++ b/src/tui/remote_ui.rs @@ -0,0 +1,242 @@ +//! RemoteUI: surface the `view` (a sized embed widget) that 书安OS's progressive +//! API returns for a task. +//! +//! A `view` is a partial, chrome-less console page meant for a *sized popup* +//! rather than a full browser tab. We can't embed a WebView in the terminal, so +//! we spawn the sibling `a3s-webview` helper — a native window that seeds the OS +//! token into localStorage (from `A3S_OS_TOKEN`, which the TUI exports) and loads +//! the page authenticated. Plain links still go to the user's browser. + +use std::process::Command; + +/// A `viewUrl` (+ optional size / embeddable hint) extracted from a tool result. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ViewSpec { + pub url: String, + pub width: Option, + pub height: Option, + /// The API explicitly marked this view as a sized popup (or returned a size). + pub embeddable: bool, +} + +/// Find a renderable view in a tool's JSON output. Prefers the current `view` +/// object `{ url, width, height }`; falls back to a legacy top-level `viewUrl` +/// (+ optional `viewSize` / `embeddable`). The capabilities API nests it under +/// `data` too, so we walk recursively and take the first match. +pub(crate) fn find_view_url(output: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(output).ok()?; + find_in(&value) +} + +fn find_in(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Object(obj) => { + // Current 书安OS shape: a `view` object `{ url, width, height }` — a + // focused, chrome-less embed widget at a suggested size. + if let Some(spec) = obj.get("view").and_then(parse_view_object) { + return Some(spec); + } + // Back-compat: a bare top-level `viewUrl` (+ optional `viewSize` / + // `embeddable`), the shape the API returned before the `view` object. + if let Some(spec) = parse_legacy_view_url(obj) { + return Some(spec); + } + obj.values().find_map(find_in) + } + serde_json::Value::Array(arr) => arr.iter().find_map(find_in), + _ => None, + } +} + +/// Read a JSON number (int or float) as a pixel dimension. +fn px(obj: &serde_json::Map, key: &str) -> Option { + obj.get(key) + .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f.round() as u64))) + .map(|n| n as u32) +} + +/// Parse the current `view` object `{ url, width, height }`. The API only emits +/// it for sized popups, so a parsed `view` is always embeddable. +fn parse_view_object(v: &serde_json::Value) -> Option { + let obj = v.as_object()?; + let url = obj + .get("url") + .and_then(|u| u.as_str()) + .filter(|u| u.starts_with("http://") || u.starts_with("https://"))?; + Some(ViewSpec { + url: url.to_string(), + width: px(obj, "width"), + height: px(obj, "height"), + embeddable: true, + }) +} + +/// Back-compat: the older top-level `viewUrl` string with an optional `viewSize` +/// `{width,height}` sibling and `embeddable` flag. +fn parse_legacy_view_url(obj: &serde_json::Map) -> Option { + let url = obj + .get("viewUrl") + .and_then(|u| u.as_str()) + .filter(|u| u.starts_with("http://") || u.starts_with("https://"))?; + let size = obj.get("viewSize").and_then(|s| s.as_object()); + let width = size.and_then(|s| px(s, "width")); + let height = size.and_then(|s| px(s, "height")); + let embeddable = obj + .get("embeddable") + .and_then(|e| e.as_bool()) + .unwrap_or(false) + || width.is_some(); + Some(ViewSpec { + url: url.to_string(), + width, + height, + embeddable, + }) +} + +/// Locate the `a3s-webview` binary: prefer a sibling of the running `a3s` +/// executable (how it ships), else fall back to the bare name on `PATH`. +fn webview_bin() -> std::path::PathBuf { + let name = if cfg!(windows) { + "a3s-webview.exe" + } else { + "a3s-webview" + }; + if let Ok(exe) = std::env::current_exe() { + if let Some(sibling) = exe.parent().map(|d| d.join(name)) { + if sibling.exists() { + return sibling; + } + } + } + std::path::PathBuf::from(name) +} + +/// Build the `a3s-webview` argv for a view (url + optional size). Split out from +/// spawning so the spec→argv mapping is unit-testable. +fn webview_args(spec: &ViewSpec) -> Vec { + let mut args = vec!["--url".to_string(), spec.url.clone()]; + if let Some(w) = spec.width { + args.push("--width".to_string()); + args.push(w.to_string()); + } + if let Some(h) = spec.height { + args.push("--height".to_string()); + args.push(h.to_string()); + } + args +} + +/// Open a view's url in the native `a3s-webview` window (detached). Inherits the +/// process env so the helper reads `A3S_OS_TOKEN` for auth. Returns Err if the +/// helper binary isn't present/launchable (caller surfaces a hint). +pub(crate) fn open_window(spec: &ViewSpec) -> std::io::Result<()> { + Command::new(webview_bin()) + .args(webview_args(spec)) + .spawn() + .map(|_child| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_top_level_view_url() { + let out = r#"{"success":true,"viewUrl":"https://os.x/p","data":{"items":[]}}"#; + let s = find_view_url(out).unwrap(); + assert_eq!(s.url, "https://os.x/p"); + assert!(!s.embeddable); // no size / flag + } + + #[test] + fn finds_nested_view_url_with_size_marks_embeddable() { + let out = + r#"{"data":{"viewUrl":"https://os.x/embed","viewSize":{"width":720,"height":520}}}"#; + let s = find_view_url(out).unwrap(); + assert_eq!((s.width, s.height), (Some(720), Some(520))); + assert!(s.embeddable); // size present ⇒ embeddable + } + + #[test] + fn embeddable_flag_without_size() { + let out = r#"{"viewUrl":"https://os.x/p","embeddable":true}"#; + assert!(find_view_url(out).unwrap().embeddable); + } + + #[test] + fn finds_view_object_marks_embeddable() { + let out = r#"{"success":true,"view":{"url":"https://os.x/p?embed=1","width":720,"height":520},"modules":[]}"#; + let s = find_view_url(out).unwrap(); + assert_eq!(s.url, "https://os.x/p?embed=1"); + assert_eq!((s.width, s.height), (Some(720), Some(520))); + assert!(s.embeddable); // a `view` object is always a sized popup + } + + #[test] + fn finds_nested_view_object() { + let out = r#"{"data":{"view":{"url":"https://os.x/embed","width":400,"height":300}}}"#; + assert_eq!(find_view_url(out).unwrap().width, Some(400)); + } + + #[test] + fn view_object_takes_precedence_over_legacy_url() { + let out = r#"{"viewUrl":"https://old/x","view":{"url":"https://new/y","width":300,"height":200}}"#; + assert_eq!(find_view_url(out).unwrap().url, "https://new/y"); + } + + #[test] + fn ignores_non_http_and_absent() { + assert!(find_view_url(r#"{"viewUrl":"file:///x"}"#).is_none()); + assert!(find_view_url(r#"{"view":{"url":"file:///x","width":10,"height":10}}"#).is_none()); + assert!(find_view_url(r#"{"data":{"items":[1,2]}}"#).is_none()); + assert!(find_view_url("not json").is_none()); + } + + #[test] + fn webview_args_pass_url_and_size() { + let spec = ViewSpec { + url: "https://os.x/p?embed=1".into(), + width: Some(720), + height: Some(520), + embeddable: true, + }; + assert_eq!( + webview_args(&spec), + vec![ + "--url", + "https://os.x/p?embed=1", + "--width", + "720", + "--height", + "520" + ] + ); + let no_size = ViewSpec { + url: "https://os.x/p".into(), + width: None, + height: None, + embeddable: false, + }; + assert_eq!(webview_args(&no_size), vec!["--url", "https://os.x/p"]); + } + + /// End-to-end: a progressive-API `execute` response carrying a `view` object + /// parses into a ViewSpec whose url + size reach the a3s-webview argv — i.e. + /// the view's url is what gets opened in the webview. + #[test] + fn progressive_api_view_flows_to_webview_args() { + let resp = r#"{"success":true, + "view":{"url":"https://os.example.com/admin/kernel/assets?embed=1","width":900,"height":680}, + "data":{"items":[]}}"#; + let spec = find_view_url(resp).expect("view object should parse"); + assert!(spec.embeddable); // a `view` is always a sized popup → auto-opens + let args = webview_args(&spec); + assert_eq!(args[0], "--url"); + assert_eq!( + args[1], + "https://os.example.com/admin/kernel/assets?embed=1" + ); + assert!(args.contains(&"900".to_string()) && args.contains(&"680".to_string())); + } +} diff --git a/src/tui/skills.rs b/src/tui/skills.rs index 2976252..b9b41b4 100644 --- a/src/tui/skills.rs +++ b/src/tui/skills.rs @@ -105,6 +105,50 @@ pub(crate) fn count_skill_files(dirs: &[std::path::PathBuf]) -> usize { n } +/// Bundled built-in skills the cli always ships (not gated, not project-local). +const OKF_SKILL: &str = include_str!("../../skills/okf.md"); + +/// Materialize the always-available built-in cli skills (currently `okf`, the +/// LLM-wiki knowledge compiler that emits an Open Knowledge Format bundle) under +/// `~/.a3s/cli-skills//SKILL.md` and return that root so the session can add +/// it to its skill dirs. Best-effort — returns `None` on any I/O error. +pub(crate) fn ensure_builtin_skills_dir() -> Option { + let root = std::path::PathBuf::from(std::env::var_os("HOME")?) + .join(".a3s") + .join("cli-skills"); + ensure_builtin_skills_dir_at(&root).ok()?; + Some(root) +} + +/// The built-in cli skills materialized under `~/.a3s/cli-skills/`. Any other +/// directory there is a stale leftover from an earlier version (e.g. the old +/// `kb-compile`, which was renamed to `okf`) and is pruned below. +const BUILTIN_SKILLS: &[(&str, &str)] = &[("okf", OKF_SKILL)]; + +fn ensure_builtin_skills_dir_at(root: &std::path::Path) -> std::io::Result<()> { + for (name, body) in BUILTIN_SKILLS { + let dir = root.join(name); + std::fs::create_dir_all(&dir)?; + std::fs::write(dir.join("SKILL.md"), body)?; + } + // The cli-skills root is cli-owned (only built-ins materialize here), so prune + // any directory that isn't a current built-in — otherwise a renamed skill like + // the old `kb-compile` lingers and resurfaces as a duplicate `/` command. + if let Ok(rd) = std::fs::read_dir(root) { + for entry in rd.flatten() { + let p = entry.path(); + let is_builtin = p + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| BUILTIN_SKILLS.iter().any(|(name, _)| *name == n)); + if p.is_dir() && !is_builtin { + let _ = std::fs::remove_dir_all(&p); + } + } + } + Ok(()) +} + pub(crate) fn agent_skill_dirs(workspace: &str) -> Vec { let mut dirs: Vec = Vec::new(); // Claude Code and Codex both keep skills under `/skills` (same SKILL.md @@ -160,3 +204,45 @@ fn collect_skills_dirs( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn okf_skill_materializes_and_parses() { + let dir = std::env::temp_dir().join(format!("a3s-okf-skill-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // Seed a stale leftover (the pre-rename `kb-compile`) — it must be pruned. + std::fs::create_dir_all(dir.join("kb-compile")).unwrap(); + std::fs::write(dir.join("kb-compile/SKILL.md"), "stale").unwrap(); + ensure_builtin_skills_dir_at(&dir).unwrap(); + assert!( + !dir.join("kb-compile").exists(), + "stale kb-compile dir should be pruned so /kb-compile can't resurface" + ); + + // The cli loader discovers it by name → it shows in the `/` menu as `/okf`. + let skills = load_skills(std::slice::from_ref(&dir)); + assert!( + skills.iter().any(|(n, _)| n == "okf"), + "okf skill not discovered: {skills:?}" + ); + assert!( + !skills.iter().any(|(n, _)| n == "kb-compile"), + "stale kb-compile must not be discovered: {skills:?}" + ); + + // The stricter CORE loader (validates kind + fail-secure allowed-tools + + // 10KiB body cap) must accept it, else it would silently fail to load. + let md = std::fs::read_to_string(dir.join("okf/SKILL.md")).unwrap(); + let skill = a3s_code_core::skills::Skill::parse(&md) + .expect("core skill loader must accept the bundled okf SKILL.md"); + assert_eq!(skill.name, "okf"); + assert!( + skill.allowed_tools.is_some(), + "allowed-tools must parse (fail-secure) so the skill is usable" + ); + let _ = std::fs::remove_dir_all(&dir); + } +}