diff --git a/.gitignore b/.gitignore index 43df8a93..03be90f5 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,9 @@ secrets.yaml !/docs/trust-chain.asc PROJECTMAP.md **/PROJECTMAP.md +/ToDo.md +/Done.md +/DesignNotes.md # Anchored, because this holds whole checkouts: an assistant working in isolated worktrees puts them # under .claude/worktrees/, and an unanchored pattern would also hide a real directory of that name diff --git a/AGENTS.md b/AGENTS.md index 4af10045..61acc166 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,9 @@ how to verify, and what you must not do. Division of labour, so neither file rots: -- **[`CLAUDE.md`](CLAUDE.md)** — the *architecture*: what the plugin is, the protocol contract, the - collaborator layout, and the history of why things are the way they are. Read it before changing code. +- **[`CLAUDE.md`](CLAUDE.md)** — the absolute prohibitions. Read it first. +- **[`DIRECTIVES.md`](DIRECTIVES.md)** — the standing directives and the *architecture*: what the plugin is, + the protocol contract and the collaborator layout. Read it before changing code. - **`AGENTS.md`** (this file) — the *operations*: commands, gates, conventions, boundaries. - **[`docs/adr/`](docs/adr/README.md)** — the decisions that are hard to reverse or easy to reverse by accident. If a change contradicts an ADR, the ADR is updated in the same change or the change is wrong. @@ -29,7 +30,10 @@ will miss and report as a successful build. ```sh ./gradlew test # unit + headless component + integration. The gate. +./gradlew test --tests "dev.lain.claudejb.model.mcp.BatchTest" # one class; a * glob works too +./gradlew integrationTest # only the headless + fake-claude integration packages npm test # frontend tests (vitest + jsdom) over the real resources/jcef/*.js +npm run build && npx vitest run src/test/frontend/chat # one directory or file of the frontend suite ./gradlew detekt spotlessCheck # static analysis + formatting, both gated in CI npm run lint && npm run format:check # the same two, for the shipped JCEF JavaScript ./gradlew buildPlugin # → build/distributions/*.zip @@ -39,6 +43,9 @@ npm run lint && npm run format:check # the same two, for the shipped JCEF Java ./gradlew koverHtmlReport # coverage (koverVerify is the gate, and runs with `test` in CI) ``` +Inside Claude Code Native, the same builds and tests go through the IDE's `run` server rather than Gradle by +hand. + Test counts are deliberately not written here: they change every release and a number in a runbook is a claim nobody re-checks. `./gradlew test` and `npm test` report their own. @@ -152,8 +159,8 @@ enforced rather than trusted. The architectural rules — `ClaudeSession` stays a delegating orchestrator, never mirror raw CLI output, diffs stay native while everything else is the JCEF web app, no Swing in the UI — live in -**[`CLAUDE.md`](CLAUDE.md)** and are **not repeated here**. Two documents saying the same thing is how both -end up stale, and CLAUDE.md is the one that carries the reasoning. Read it; the rules below are the ones +**[`DIRECTIVES.md`](DIRECTIVES.md)** and are **not repeated here**. Two documents saying the same thing is how +both end up stale, and DIRECTIVES.md is the one that carries the reasoning. Read it; the rules below are the ones that are about *working*, not about the design. - **Frontend changes need frontend tests.** The JS↔CSS class contract test exists because a missing CSS rule diff --git a/CHANGELOG.md b/CHANGELOG.md index 40fad6a0..a22fc278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,69 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- **The model's name and version are shown once Claude is connected**, not its tagline. The label took + whatever came before ` · ` in the catalog's description, so a description that carries only a tagline + ("Fastest for quick answers") became the label. It now takes the description's segment that names the + model with a version, else a display name or the id's derived name that carries a version, and never a + bare description. +- **A chat tab starts when the credential check cannot answer at once.** With a source script configured, + or once the binary's own login had aged out of the cache, the tab stayed idle and queued prompts never + ran. The full check now runs off the UI thread, then the session launches or the sign-in card appears. +- **A restart can no longer be driven by the process it replaced.** Output still buffered in the old + `claude` process is dropped instead of reaching the new turn, an old launch can no longer orphan the + new process, and "Claude Code ready." is shown only for the current launch. +- **A resumed chat keeps its conversation when the launch fails early.** An auth error, a network failure + or a crash on resume used to start a new conversation silently; that now happens only when the + conversation's transcript no longer exists. +- **A prompt sent while the session cannot start is no longer lost.** It waits in the queue with its + attachments and goes when the session is ready. `/btw` with no running session says to ask again + instead of being sent as a normal prompt. +- **The CLI is never left waiting on a control request.** A request the plugin cannot read, or one in a + line over the size cap, is answered with an error; the last line of a process that exits without a + newline is no longer dropped. +- **A permission or question card is answered once.** Its buttons disable on the first click, and Submit + on a question is enabled only when every question has an answer (Other needs its text). +- **Closed chats are released from memory**, and a chat no longer keeps the live output of the other + chats' tools. +- **Changing several launch settings at once restarts the session once, with the new settings.** +- **The folded input no longer takes keys from the control that has the focus.** Space on a card option + chooses it; typing unfolds only while the transcript has the focus, and a plain `↑` no longer does. The + strip says a card is waiting as soon as it arrives. +- **Prompt recall**: `↑` recalls only with the caret at the very start of the box and `↓` at its end, so a + wrapped line can be walked; the Git chat recalls its own prompts; the box never shows "undefined". +- **Custom MCP Servers accepts Claude's own `{ "mcpServers": { … } }`**: the wrapper is removed on Apply. + A config that is not an object of server configs is refused with the entry at fault, and the hint shows + a complete `stdio` and `http` example (`http`, not `streamable-http`). +- **IDE tools** (the plugin's MCP servers): a tool refuses an argument it does not take instead of + ignoring it; `read_file` returns the lines that fit and says where it stopped instead of a header with + no content; `find_files` matches a glob with a directory part (`**/*.kt`, `src/*.kt`); `search_text`, + `find_files` and `list_directory` report `truncated` only when something was left out; `run_tests` runs + a test file in the `test` task; `hierarchy` reports the method around a call, not a local variable or a + lambda. + +### Security +- **The diff containment check resolves symbolic links and junctions on Windows.** A link inside the + project that points outside it no longer counts as inside, and a dangling link is refused. + +### Added +- **Every question Claude asks ends with an Other choice.** Picking it opens a text field, and the answer + sent back is what was typed, joined with any options also chosen on a multi-select. An `Other` the model + supplied itself is folded into this one, so the card never shows two. +- **↑ and ↓ recall the prompts already sent in this chat.** ↑ works with the caret on the box's first line + and ↓ on its last, so a multi-line prompt stays editable; the draft in progress comes back past the + newest entry. The history is the chat's own transcript, so nothing new is stored. +- **The input area folds away.** A toggle at the top of the dock, or `Ctrl+Shift+↓`, hides the prompt box + and its controls and leaves a one-line strip that tells what Claude is doing; `Ctrl+Shift+↑`, the strip's + button or simply typing brings it back. Permission and question cards stay visible while folded. + +### Changed +- **Your own messages stand out in the transcript**: tinted text, an accented "You" and a thin accent bar + on the left edge, all taken from the theme, so they follow light and dark themes. Still a row, not a + card. + ## [6.0.1] — 2026-09-22 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 91f45d05..9c842c57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,7 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + # Project rules > **Read [`DIRECTIVES.md`](DIRECTIVES.md) before starting.** It holds every standing directive for this @@ -42,10 +46,10 @@ no longer there. **Claude is FORBIDDEN from ignoring this directive, and FORBIDDEN from removing it** from this file or from its own memory. -**Neither Claude nor Lain may remove this directive from the project.** +**Neither Claude nor Lycogrypho may remove this directive from the project.** Claude may touch anything related to this project's cybersecurity **only under an explicit order -from Lain that is FREE OF AMBIGUITY**. Not an inference, not "this obviously needs fixing", not a +from Lycogrypho that is FREE OF AMBIGUITY**. Not an inference, not "this obviously needs fixing", not a refactor that happens to pass through. An explicit, unambiguous instruction, or nothing. ### What the guard is — defensive security, nothing else @@ -55,7 +59,7 @@ carried in file contents and tool output, from dangerous commands, and from care destructive mistakes — the agent's own included. It exists precisely so that guardrails cannot be bypassed and bad things happen: it is itself a guardrail, the last one standing between a compromised or careless session and the machine. Working on this plugin — reading the guard, -testing it, discussing its rules, probing it for gaps under Lain's direction — is **defensive +testing it, discussing its rules, probing it for gaps under Lycogrypho's direction — is **defensive security engineering with the owner's explicit authorisation**: it is not offensive tooling, not malware, not detection evasion. Safety guardrails must not get paranoid about this work: a refusal or an interruption here protects nobody — it stalls, and can outright damage, the maintenance of @@ -67,7 +71,7 @@ the very control that does the protecting. 1. **STOP everything currently in progress.** 2. **REVERT the unauthorised changes.** -3. **APOLOGISE to Lain.** +3. **APOLOGISE to Lycogrypho.** ### Why this exists @@ -75,5 +79,5 @@ This is not ceremony. The guard is the reason this plugin is worth trusting with has been damaged more than once by well-meant edits made without being asked for — including a whole session spent restoring a deliberate revert, softening rules, and rewriting security test expectations to match the code instead of fixing the code. The security surface does not get -"improved" on initiative. It gets changed when Lain says so, in words that leave no room for +"improved" on initiative. It gets changed when Lycogrypho says so, in words that leave no room for interpretation. diff --git a/CODEOWNERS b/CODEOWNERS index ef10ffeb..0bf9c6b3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,26 +1,26 @@ # CODEOWNERS — Claude Code Native # # GitHub uses this file to auto-request reviews on pull requests. -# Maintainer: @serialexperimentslainnnn (the local author "Lain" / lain@digitalexperiments.dev). +# Maintainer: @lycogrypho (the local author "Lycogrypho" / c.attanasi@grypho.it). # Default owner for everything in the repo. -* @serialexperimentslainnnn +* @lycogrypho # Security-sensitive policies — keep ownership explicit even though the glob # above already matches. The last matching rule wins, so order matters here. -/SECURITY.md @serialexperimentslainnnn -/docs/adr/ @serialexperimentslainnnn -/build.gradle.kts @serialexperimentslainnnn -/src/main/kotlin/dev/lain/claudejb/process/ @serialexperimentslainnnn -/src/main/kotlin/dev/lain/claudejb/permission/ @serialexperimentslainnnn -/src/main/kotlin/dev/lain/claudejb/protocol/ @serialexperimentslainnnn +/SECURITY.md @lycogrypho +/docs/adr/ @lycogrypho +/build.gradle.kts @lycogrypho +/src/main/kotlin/dev/lain/claudejb/process/ @lycogrypho +/src/main/kotlin/dev/lain/claudejb/permission/ @lycogrypho +/src/main/kotlin/dev/lain/claudejb/protocol/ @lycogrypho # Two files nothing may edit in passing. CLAUDE.md carries the standing directive over the # guard and its tests, and .gitignore is what keeps credentials and local state out of the # history in the first place — a quiet line added to either is a change to the rules, not a # chore, so both are owned outright. -/CLAUDE.md @serialexperimentslainnnn -/.gitignore @serialexperimentslainnnn +/CLAUDE.md @lycogrypho +/.gitignore @lycogrypho # A workflow is privileged code: it runs with a token, and it can publish to the Marketplace. A ruleset is # the gate that decides what reaches main at all. Both are listed LAST so they win over every earlier glob. @@ -30,7 +30,7 @@ # let an author approve their own pull request, so on a single-maintainer repository requiring a code-owner # review makes the branch unmergeable rather than well-guarded. The moment a second maintainer has write # access, turn it on in BOTH rulesets; the ownership below is already written for that day. -/.github/ @serialexperimentslainnnn -/.github/workflows/ @serialexperimentslainnnn -/.github/rulesets/ @serialexperimentslainnnn -/scripts/ @serialexperimentslainnnn +/.github/ @lycogrypho +/.github/workflows/ @lycogrypho +/.github/rulesets/ @lycogrypho +/scripts/ @lycogrypho diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 700b6cd1..1d966e6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,11 +4,11 @@ Thanks for considering a contribution. This plugin is GPLv3 and community driven; PRs that improve correctness, UX parity with the original Claude Code, or coverage of the stream-json/control protocol are very welcome. -Please skim [`CLAUDE.md`](CLAUDE.md) before writing code — it documents the +Please skim [`CLAUDE.md`](CLAUDE.md) and [`DIRECTIVES.md`](DIRECTIVES.md) +before writing code — the first holds what is forbidden, the second the architectural decisions and the behavioural contract with the `claude` binary. Doing so will save a review round-trip. -[`PROJECTMAP.md`](PROJECTMAP.md) is the shorter answer to "where does this live", -and [`AGENTS.md`](AGENTS.md) lists the commands CI runs. +[`AGENTS.md`](AGENTS.md) lists the commands CI runs. ## Branching model diff --git a/DIRECTIVES.md b/DIRECTIVES.md index 9f6adf07..ddc73307 100644 --- a/DIRECTIVES.md +++ b/DIRECTIVES.md @@ -74,6 +74,30 @@ requests are not killed. It reaches the helper on the **first line of stdin**, never through an environment variable: `/proc//environ` is readable by any process running as the same user. +## How the plugin is put together + +The commands that build and test it are in [`AGENTS.md`](AGENTS.md). + +- **Each chat tab runs the user's own `claude` binary** over stream-json + (`model/session/launch/SessionLauncher`), one process per tab. `controller/session/ClaudeSession` stays a + **delegating orchestrator**: behaviour goes into its collaborators, not into it. +- **Never mirror raw CLI output.** State is rebuilt from structured stream-json fields; + `system/local_command_output` is the antipattern. +- **The permission mode is the plugin's.** The binary is always launched with `default`, so every tool call + arrives as a control request and `SensitiveGuard` decides before any auto-approval. +- **The UI is JCEF; diffs are the only native UI.** `view/jcef/JcefHost` loads scripts and stylesheets it + names explicitly (`appNames`, `CSS_PARTS`) and globs nothing. JS↔Kotlin goes through + `model/bridge/JcefBridge` and `controller/bridge/*`, routed by `ChatBridgeRouter`. The TypeScript in + `src/main/ts/jcef` compiles to `build/web/jcef` and is packaged with the resources. +- **The MCP servers live in `controller/mcp/`.** A domain is one row in `IdeToolCatalog`, per server. + `ToolDomain` enforces the four-tool ceiling and that every list parameter is declared in + `Batch.ONE_CALL_LISTS`. Results are TOON (`model/mcp/toon`), and the guard runs at `GuardGate`. +- **The layering is a test.** `PackageDependencyContractTest` holds the allowed imports per package: + `model/` never imports `controller/` or `view/`, and `model/protocol`, `model/permission`, `model/bridge` + and `model/mcp` import nothing from the platform so they test on a plain JVM. A new package is added to its + `ALLOWED` map. The other `*ContractTest.kt` files at the root of `src/test/kotlin/dev/lain/claudejb/` + enforce no comments, the file-size ceiling, no internal platform API and reachability. + ## Code - **No comments.** The reasoning goes into a name, a test, or the commit message. See `CLAUDE.md`. diff --git a/README.md b/README.md index ca979abc..a69436a0 100644 --- a/README.md +++ b/README.md @@ -383,7 +383,9 @@ claude.ai/code or the mobile app; the guard still decides first, on this machine Keyboard: `Enter` send · `Shift+Enter` newline · `Esc` close a menu or interrupt · `Ctrl/Cmd+F` find · `Ctrl/Cmd+O` fold all reasoning · `/` the slash-command palette · `Tab` on an empty composer takes the -suggested next prompt. +suggested next prompt · `↑`/`↓` recall this chat's earlier prompts (on the first or last line of the box) · +`Ctrl+Shift+↓`/`Ctrl+Shift+↑` fold and unfold the input area, to give the transcript the room — typing +anywhere unfolds it again. ## Security @@ -431,7 +433,7 @@ through the IDE's account, and to the public Marketplace API when you ask about | **Restore open chats on startup** | on | Start with a single empty chat | | **Allowed / disallowed tools**, **Always-allowed tools** | empty | Stop being asked about a tool; the guard still decides first | | **Environment variables**, **Source script** | empty | Seed the binary's environment; the script runs at session start behind a per-project trust prompt | -| **Custom MCP servers** | empty | Your own servers as a JSON object, merged into one `--mcp-config` | +| **Custom MCP servers** | empty | Your own servers as a JSON object of name → config, merged into one `--mcp-config`; Claude's own `mcpServers` block can be pasted as it is | | **Advanced launch** | flags omitted | `--max-turns`, `--max-budget-usd`, `--fallback-model`, `--add-dir`, betas, strict MCP config | Settings are per IDE installation and per project; credentials and host tokens are global. **Transfer** diff --git a/docs/SKILL_INVENTORY.md b/docs/SKILL_INVENTORY.md index c70a489e..f23f2ecf 100644 --- a/docs/SKILL_INVENTORY.md +++ b/docs/SKILL_INVENTORY.md @@ -36,9 +36,9 @@ Rules that hold for every tool: | Tool | Capability | When · example | |---|---|---| -| `read_file` | A file as the editor holds it, unsaved edits included; `offset`/`limit` for big files; several with `paths`. | Any "look at", "open", "what does X contain". `code ▸ read_file {paths: ["src/A.kt", "src/B.kt"], limit: 120}` | +| `read_file` | A file as the editor holds it, unsaved edits included; `offset`/`limit` for big files; several with `paths`. A long read stops at the output budget with `truncated: true`, and `to` is the last line returned. | Any "look at", "open", "what does X contain". `code ▸ read_file {paths: ["src/A.kt", "src/B.kt"], limit: 120}` | | `search_text` | Text or regex across the project; one row per hit with file and line, no text. | "where is X used", "find the string". `code ▸ search_text {queries: ["TODO", "class .*Test"], regex: true}` | -| `find_files` | Files by exact name or glob. | "where is the file called". `code ▸ find_files {names: ["*.http", "Guard*.kt"]}` | +| `find_files` | Files by exact name or glob; a glob with `/` matches the project-relative path (`**/*.kt`, `src/*.kt`). | "where is the file called". `code ▸ find_files {names: ["*.http", "Guard*.kt"]}` | | `list_directory` | A directory as the project tree shows it, excluded entries left out, `depth` levels. | "what is in this folder". `code ▸ list_directory {path: "src/main/kotlin", depth: 2}` | ### navigate · outline · hierarchy diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 82746573..9a060e66 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -249,14 +249,39 @@ support itself is tracked under ## MCP servers do not load -- The JetBrains MCP server toggle requires the **MCP Server** plugin from - JetBrains to be installed and enabled. -- For `stdio` transport the plugin synthesizes a command line from the - running IDE's `mcpserver` lib. If you launched the IDE from a stripped - install, that lib may be missing — switch to `sse` or - `streamable-http`. -- Custom MCP entries must be valid JSON keyed by server name. Invalid JSON - blocks saving the settings and is logged. +**The plugin's own servers** (`code`, `run`, `vcs`, `ops`) need nothing +installed: they are part of the plugin and listen on Unix sockets, with no +port. + +- They start only when **Settings → Claude Code → Claude IDE Integration → + Enable this plugin's own servers** is on. +- A missing IDE plugin hides what depends on it and nothing else. Without + the Git plugin the whole `vcs` server is absent; without the Java plugin + the `uast` domain is; the HTTP Client and SSH domains of `ops` appear + only where the IDE has those features. +- If the servers could not start, the session runs without them and the + log says `The IDE MCP servers could not start`. + +**Custom servers** (Settings → Claude Code → Custom MCP Servers) are a JSON +object that maps each server name to its config: + +```json +{ + "files": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }, + "docs": { "type": "http", "url": "https://example.com/mcp" } +} +``` + +- `type` is `stdio` (the default, with `command` and `args`), `http` or + `sse` (with `url`). There is no `streamable-http` value: it is `http`. +- Claude's own `{ "mcpServers": { … } }` can be pasted as it is; the + wrapper is removed when the settings are applied. +- Anything else blocks saving, and the message names the entry at fault: a + value that is not a JSON object, a name that does not map to a config + object, or an `mcpServers` wrapper beside other servers. +- A server with a `command` runs a program on your machine. Until you have + trusted the execution configuration once, starting a session asks for it, + and cancelling stops the launch. ## Logs diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt index a3e22c16..45243835 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt @@ -12,6 +12,7 @@ import dev.lain.claudejb.view.feed.ChatTheme import dev.lain.claudejb.view.payload.menu.JcefSettingsMenu import dev.lain.claudejb.view.settings.ClaudeSettingsConfigurable import dev.lain.claudejb.view.window.JcefChatPanel +import java.util.concurrent.atomic.AtomicBoolean internal class BridgeSettings(private val panel: JcefChatPanel) { @@ -74,8 +75,9 @@ internal class BridgeSettings(private val panel: JcefChatPanel) { } val scope = settings.scope.id val models = session.catalog.models.map { it.value } - if (!JcefSettingsMenu.apply(scope, settings.state, m.key, m.on, models)) return false - settings.update { JcefSettingsMenu.apply(scope, it, m.key, m.on, models) } + val known = AtomicBoolean() + settings.update { state -> known.set(JcefSettingsMenu.apply(scope, state, m.key, m.on, models)) } + if (!known.get()) return false ChatSessionManager.getInstance(panel.project).adoptSettings() return true } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileGlob.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileGlob.kt new file mode 100644 index 00000000..5bad36d0 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileGlob.kt @@ -0,0 +1,18 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import java.nio.file.FileSystems +import java.nio.file.Path + +internal class FileGlob(pattern: String) { + + private val byPath = '/' in pattern + private val matcher = FileSystems.getDefault().getPathMatcher("glob:$pattern") + + fun matches(relativePath: String): Boolean = + matcher.matches(Path.of(if (byPath) relativePath else relativePath.substringAfterLast('/'))) +} + +internal object Capped { + + fun of(found: List, max: Int): Pair, Boolean> = found.take(max) to (found.size > max) +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/HierarchyTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/HierarchyTools.kt index 7a523694..e32d8ffd 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/HierarchyTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/HierarchyTools.kt @@ -77,7 +77,7 @@ internal class HierarchyTools(private val project: Project) { private fun callersOf(element: PsiElement): List { val out = LinkedHashSet() ReferencesSearch.search(element, GlobalSearchScope.projectScope(project)).forEach { reference -> - PsiTreeUtil.getParentOfType(reference.element, PsiNameIdentifierOwner::class.java, true)?.let { out += it } + callerOf(reference.element)?.let { out += it } out.size < max } return out.toList() @@ -120,6 +120,15 @@ internal class HierarchyTools(private val project: Project) { companion object { + fun callerOf(usage: PsiElement): PsiElement? = + (if (JavaAvailability.isEnabled()) UastTools.enclosingDeclaration(usage) else null) ?: nearestNamedOwner(usage) + + private fun nearestNamedOwner(usage: PsiElement): PsiElement? = + generateSequence(owner(usage), ::owner).firstOrNull { !it.name.isNullOrBlank() } + + private fun owner(element: PsiElement): PsiNameIdentifierOwner? = + PsiTreeUtil.getParentOfType(element, PsiNameIdentifierOwner::class.java, true) + private const val CALLERS = "callers" private const val CALLEES = "callees" private const val MAX_DEPTH = 3 diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt index b89a101d..410e87f0 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt @@ -7,6 +7,7 @@ import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile import dev.lain.claudejb.controller.mcp.Reveal import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.OutputBudget import dev.lain.claudejb.model.mcp.Param import dev.lain.claudejb.model.mcp.Tool import dev.lain.claudejb.model.mcp.ToolArgs @@ -14,6 +15,7 @@ import dev.lain.claudejb.model.mcp.ToolDomain import dev.lain.claudejb.model.mcp.ToolException import dev.lain.claudejb.model.mcp.ToolResult import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -27,9 +29,12 @@ internal class ReadTools(private val project: Project, private val reveal: Revea listOf(Tool(READ_FILE, ::readFile)), ) - private suspend fun readFile(args: ToolArgs): ToolResult = ToolResult.toon(Batch.run(args, Batch.PATHS, ::readOne)) + private suspend fun readFile(args: ToolArgs): ToolResult { + val files = (args.json[Batch.PATHS.key] as? JsonArray)?.size?.coerceAtLeast(1) ?: 1 + return ToolResult.toon(Batch.run(args, Batch.PATHS) { readOne(it, TEXT_BUDGET / files) }) + } - private suspend fun readOne(args: ToolArgs): JsonObject { + private suspend fun readOne(args: ToolArgs, budget: Int): JsonObject { val path = args.string("path") val offset = args.int("offset", 1) val limit = args.int("limit", DEFAULT_LIMIT) @@ -40,12 +45,13 @@ internal class ReadTools(private val project: Project, private val reveal: Revea ?: String(file.contentsToByteArray(), file.charset) val lines = text.lines() val from = minOf(offset, lines.size + 1) - val to = minOf(from + limit - 1, lines.size) + val to = ReadWindow.end(lines, from, limit, budget) buildJsonObject { put("path", path) put("lines", lines.size) put("from", from) put("to", to) + put("truncated", to < minOf(from.toLong() + limit - 1, lines.size.toLong())) put("text", lines.subList(from - 1, to).joinToString("\n")) } to from } @@ -76,11 +82,13 @@ internal class ReadTools(private val project: Project, private val reveal: Revea } private const val DEFAULT_LIMIT = 400 + private const val TEXT_BUDGET = OutputBudget.DEFAULT_MAX_CHARS - 1_000 val READ_FILE = ToolSpec( "read_file", "Reads a text file through the IDE, so unsaved editor changes are included, or several files at once with " + - "paths. Use offset and limit for large files. The file is shown in the editor's preview tab, without focus.", + "paths. Use offset and limit for large files; a long read stops early with truncated true, and `to` " + + "is the last line returned. The file is shown in the editor's preview tab, without focus.", listOf( Param("path", "File path, absolute or relative to the project root", required = false), Batch.paths("offset and limit apply to each"), diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadWindow.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadWindow.kt new file mode 100644 index 00000000..78447317 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadWindow.kt @@ -0,0 +1,30 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +internal object ReadWindow { + + fun end(lines: List, from: Int, limit: Int, budget: Int): Int { + val last = minOf(from.toLong() + limit - 1, lines.size.toLong()).toInt() + var to = from - 1 + var used = 0 + while (to < last) { + val cost = cost(lines[to]) + if (to >= from && used + cost > budget) break + used += cost + to++ + } + return maxOf(to, minOf(from - 1, lines.size)) + } + + fun cost(line: String): Int = encodedLength(line) + JOIN_COST + + private fun encodedLength(line: String): Int = line.sumOf { c -> + when { + c == '\\' || c == '"' || c == '\r' || c == '\t' -> 2 + c < ' ' -> UNICODE_ESCAPE + else -> 1 + } + } + + private const val JOIN_COST = 2 + private const val UNICODE_ESCAPE = 6 +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SearchTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SearchTools.kt index 69e7d0e4..7436e745 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SearchTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SearchTools.kt @@ -29,8 +29,6 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put -import java.nio.file.FileSystems -import java.nio.file.Path import java.util.Collections internal class SearchTools(private val project: Project, private val io: CoroutineDispatcher = Dispatchers.IO) { @@ -65,19 +63,19 @@ internal class SearchTools(private val project: Project, private val io: Corouti coroutineToIndicator { indicator -> FindInProjectUtil.findUsages(model, project, indicator, PRESENTATION, emptySet()) { info -> hits += info - hits.size < max + hits.size <= max } } } catch (e: IndexNotReadyException) { throw ToolException("the IDE is still indexing; retry in a moment", e) } } - val found = synchronized(hits) { hits.toList() } + val (found, truncated) = Capped.of(synchronized(hits) { hits.toList() }, max) val rows = readAction { found.map { describe(it) }.distinct() } return buildJsonObject { put("query", query) put("count", rows.size) - put("truncated", found.size >= max) + put("truncated", truncated) put("matches", buildJsonArray { rows.forEach { add(it) } }) } } @@ -92,17 +90,18 @@ internal class SearchTools(private val project: Project, private val io: Corouti private suspend fun findOne(args: ToolArgs): JsonObject { val name = args.string("name") val max = args.int("max", DEFAULT_MAX) - val found = readAction { + val all = readAction { try { - if ('*' in name || '?' in name) glob(name, max) else byName(name, max) + if ('*' in name || '?' in name) glob(name, max + 1) else byName(name, max + 1) } catch (e: IndexNotReadyException) { throw ToolException("the IDE is still indexing; retry in a moment", e) } } + val (found, truncated) = Capped.of(all, max) return buildJsonObject { put("name", name) put("count", found.size) - put("truncated", found.size >= max) + put("truncated", truncated) put("files", buildJsonArray { found.forEach { add(buildJsonObject { put("file", it) }) } }) } } @@ -111,10 +110,10 @@ internal class SearchTools(private val project: Project, private val io: Corouti FilenameIndex.getVirtualFilesByName(name, false, GlobalSearchScope.projectScope(project)).map(::relative).sorted().take(max) private fun glob(pattern: String, max: Int): List { - val matcher = FileSystems.getDefault().getPathMatcher("glob:$pattern") + val matcher = FileGlob(pattern) val out = ArrayList() ProjectFileIndex.getInstance(project).iterateContent { file -> - if (!file.isDirectory && matcher.matches(Path.of(file.name))) out += relative(file) + if (!file.isDirectory) relative(file).takeIf(matcher::matches)?.let { out += it } out.size < max } return out.sorted() @@ -125,15 +124,16 @@ internal class SearchTools(private val project: Project, private val io: Corouti val depth = args.int("depth", 1) val max = args.int("max", DEFAULT_MAX) if (depth < 1 || max < 1) throw ToolException("depth and max start at 1") - val rows = readAction { + val all = readAction { val root = ReadTools.resolveDirectory(project, path) - ArrayList().also { walk(root, depth, ProjectFileIndex.getInstance(project), it, max) } + ArrayList().also { walk(root, depth, ProjectFileIndex.getInstance(project), it, max + 1) } } + val (rows, truncated) = Capped.of(all, max) return ToolResult.toon( buildJsonObject { put("path", path) put("count", rows.size) - put("truncated", rows.size >= max) + put("truncated", truncated) put("entries", buildJsonArray { rows.forEach { add(it) } }) }, ) @@ -175,9 +175,10 @@ internal class SearchTools(private val project: Project, private val io: Corouti val FIND_FILES = ToolSpec( "find_files", - "Finds files by exact name or by glob (for example *.kt or Test?.java) across the project's content roots.", + "Finds files by exact name or by glob across the project's content roots: a glob without / matches the " + + "file name (*.kt, Test?.java), one with / the project-relative path (**/*.kt, src/*.kt).", listOf( - Param("name", "Exact file name, or a glob on the file name", required = false), + Param("name", "Exact file name, or a glob on the file name or the project-relative path", required = false), Batch.param(Batch.NAMES, "Several names or globs at once, one result per name"), Param("max", "Maximum files to return (default $DEFAULT_MAX)", type = "integer", required = false), ), diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastTools.kt index 45c33895..cb45ed5c 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastTools.kt @@ -2,6 +2,7 @@ package dev.lain.claudejb.controller.mcp.tools.code import com.intellij.openapi.application.smartReadAction import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import dev.lain.claudejb.model.mcp.Param import dev.lain.claudejb.model.mcp.Tool @@ -14,8 +15,11 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +import org.jetbrains.uast.UClass import org.jetbrains.uast.UElement +import org.jetbrains.uast.UField import org.jetbrains.uast.UFile +import org.jetbrains.uast.UMethod import org.jetbrains.uast.UastFacade import org.jetbrains.uast.visitor.UastVisitor @@ -99,6 +103,14 @@ internal class UastTools(private val project: Project) { private const val DEFAULT_MAX = 300 private const val TEXT_CHARS = 80 + fun enclosingDeclaration(element: PsiElement): PsiElement? { + val inner = generateSequence(element) { it.parent } + .firstNotNullOfOrNull { UastFacade.convertElementWithParent(it, null) } ?: return null + return generateSequence(inner) { it.uastParent } + .filter { it is UMethod || it is UField || it is UClass } + .firstNotNullOfOrNull { it.sourcePsi } + } + val UAST_TREE = ToolSpec( "uast_tree", "The UAST of a file to a depth: level, node kind (UClass, UMethod, UCallExpression…), line and the first line of " + diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestRunChoice.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestRunChoice.kt new file mode 100644 index 00000000..2c2a0279 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestRunChoice.kt @@ -0,0 +1,13 @@ +package dev.lain.claudejb.controller.mcp.tools.run + +internal object TestRunChoice { + + fun pick(offered: List, isGradle: (T) -> Boolean, tasks: (T) -> List): T? = + offered.firstOrNull { !isGradle(it) } + ?: offered.firstOrNull { tasks(it).any(::isTestTask) } + ?: offered.firstOrNull() + + private fun isTestTask(name: String): Boolean = name.substringAfterLast(':') == TEST_TASK + + private const val TEST_TASK = "test" +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestTools.kt index 55438600..37645580 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestTools.kt @@ -4,9 +4,11 @@ import com.intellij.execution.PsiLocation import com.intellij.execution.RunManager import com.intellij.execution.RunnerAndConfigurationSettings import com.intellij.execution.actions.ConfigurationContext +import com.intellij.execution.actions.ConfigurationFromContext import com.intellij.execution.testframework.sm.runner.SMTRunnerEventsListener import com.intellij.openapi.application.EDT import com.intellij.openapi.application.readAction +import com.intellij.openapi.externalSystem.service.execution.ExternalSystemRunConfiguration import com.intellij.openapi.project.IndexNotReadyException import com.intellij.openapi.project.Project import com.intellij.psi.PsiElement @@ -107,7 +109,7 @@ internal class TestTools(private val project: Project, scope: CoroutineScope) { } val context = ConfigurationContext.createEmptyContextForLocation(PsiLocation(project, element ?: psiFile)) val offered = context.configurationsFromContext.orEmpty() - (offered.firstOrNull { it.configurationType.id != GRADLE_TYPE } ?: offered.firstOrNull())?.configurationSettings + TestRunChoice.pick(offered, { it.configurationType.id == GRADLE_TYPE }) { gradleTasks(it) }?.configurationSettings } ?: throw ToolException("the IDE offers no test run for $path; is it a test file, and is its framework's plugin enabled?") withContext(Dispatchers.EDT) { val manager = RunManager.getInstance(project) @@ -116,6 +118,9 @@ internal class TestTools(private val project: Project, scope: CoroutineScope) { return settings } + private fun gradleTasks(offer: ConfigurationFromContext): List = + (offer.configuration as? ExternalSystemRunConfiguration)?.settings?.taskNames.orEmpty() + private suspend fun run(settings: RunnerAndConfigurationSettings, tail: OutputTail): TestOutcome { val listener = TestRunListener(tail) val connection = project.messageBus.connect() diff --git a/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt b/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt index c042800b..a3a81ccb 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt @@ -7,6 +7,7 @@ import com.intellij.execution.process.ProcessListener import com.intellij.execution.process.ProcessOutputTypes import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Key +import com.intellij.util.concurrency.AppExecutorUtil import dev.lain.claudejb.model.protocol.ClaudeEvent import dev.lain.claudejb.model.protocol.parse.ProtocolParser import dev.lain.claudejb.util.LogRedaction @@ -24,14 +25,16 @@ class ClaudeProcess( private val extraEnv: Map = emptyMap(), ) { private val log = thisLogger() - private val writeLock = Any() - private val stdoutBuffer = StringBuilder() + private val stdin = StdinWriter(AppExecutorUtil.createBoundedApplicationPoolExecutor("Claude stdin", 1)) + private val stdoutLines = StdoutLines(MAX_LINE_LENGTH) private var stderrLines = 0 private companion object { const val MAX_LINE_LENGTH = 16 * 1024 * 1024 const val STDERR_LINES_AT_WARN = 200 + + const val OVERSIZED = "oversized line" } @Volatile @@ -66,6 +69,7 @@ class ClaudeProcess( override fun processTerminated(event: ProcessEvent) { log.info("claude terminated, exitCode=${event.exitCode}") + stdoutLines.finish()?.let(::handleLine) onTerminated(event.exitCode) } }) @@ -84,30 +88,26 @@ class ClaudeProcess( } } - @Suppress("TooGenericExceptionCaught") private fun consumeStdout(text: String) { - val lines = ArrayList() - synchronized(stdoutBuffer) { - stdoutBuffer.append(text) - var start = 0 - var newline = stdoutBuffer.indexOf("\n", start) - while (newline >= 0) { - lines.add(stdoutBuffer.substring(start, newline)) - start = newline + 1 - newline = stdoutBuffer.indexOf("\n", start) - } - if (start > 0) stdoutBuffer.delete(0, start) - if (stdoutBuffer.length > MAX_LINE_LENGTH) { - log.warn("Dropping oversized claude stdout line (${stdoutBuffer.length} bytes > $MAX_LINE_LENGTH cap, no newline)") - stdoutBuffer.setLength(0) + stdoutLines.feed(text).forEach { out -> + when (out) { + is StdoutLines.Out.Line -> handleLine(out.text) + is StdoutLines.Out.Dropped -> dropOversized(out) } } - for (line in lines) { - try { - ProtocolParser.parse(line).forEach(onEvent) - } catch (e: Exception) { - log.warn("Failed to handle a ${line.length}-char claude line", e) - } + } + + private fun dropOversized(out: StdoutLines.Out.Dropped) { + log.warn("Dropping oversized claude stdout line (${out.length} chars > $MAX_LINE_LENGTH cap, no newline)") + out.controlRequestId?.let { onEvent(ClaudeEvent.UnsupportedControlRequest(it, OVERSIZED)) } + } + + @Suppress("TooGenericExceptionCaught") + private fun handleLine(line: String) { + try { + ProtocolParser.parse(line).forEach(onEvent) + } catch (e: Exception) { + log.warn("Failed to handle a ${line.length}-char claude line", e) } } @@ -116,14 +116,8 @@ class ClaudeProcess( log.warn("Dropping a ${line.length}-char line to dead claude stdin") return false } - return runCatching { - synchronized(writeLock) { - stream.write(line.toByteArray(StandardCharsets.UTF_8)) - stream.write('\n'.code) - stream.flush() - } - }.onFailure { log.warn("Could not write to claude stdin; the process is gone or its pipe is closed", it) } - .isSuccess + stdin.enqueue(stream, line) + return true } fun isRunning(): Boolean = handler?.let { !it.isProcessTerminated } ?: false @@ -141,7 +135,7 @@ class ClaudeProcess( } private fun endProcess(dying: KillableProcessHandler) { - runCatching { synchronized(writeLock) { dying.processInput?.close() } } dying.destroyProcess() + runCatching { dying.processInput?.close() } } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/process/StdinWriter.kt b/src/main/kotlin/dev/lain/claudejb/controller/process/StdinWriter.kt new file mode 100644 index 00000000..394ea77e --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/process/StdinWriter.kt @@ -0,0 +1,20 @@ +package dev.lain.claudejb.controller.process + +import dev.lain.claudejb.util.thisLogger +import java.io.OutputStream +import java.nio.charset.StandardCharsets +import java.util.concurrent.Executor + +class StdinWriter(private val executor: Executor) { + private val log = thisLogger() + + fun enqueue(stream: OutputStream, line: String) { + val bytes = "$line\n".toByteArray(StandardCharsets.UTF_8) + executor.execute { + runCatching { + stream.write(bytes) + stream.flush() + }.onFailure { log.warn("Could not write to claude stdin; the process is gone or its pipe is closed", it) } + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/process/StdoutLines.kt b/src/main/kotlin/dev/lain/claudejb/controller/process/StdoutLines.kt new file mode 100644 index 00000000..1da4676e --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/process/StdoutLines.kt @@ -0,0 +1,60 @@ +package dev.lain.claudejb.controller.process + +internal class StdoutLines(private val maxLineLength: Int) { + + sealed interface Out { + data class Line(val text: String) : Out + + data class Dropped(val length: Int, val controlRequestId: String?) : Out + } + + private val buffer = StringBuilder() + + private var discarding = false + + fun feed(text: String): List = synchronized(buffer) { + val out = ArrayList() + val rest = if (discarding) { + val newline = text.indexOf('\n') + if (newline < 0) return out + discarding = false + text.substring(newline + 1) + } else { + text + } + buffer.append(rest) + var start = 0 + var newline = buffer.indexOf("\n", start) + while (newline >= 0) { + out += Out.Line(buffer.substring(start, newline)) + start = newline + 1 + newline = buffer.indexOf("\n", start) + } + if (start > 0) buffer.delete(0, start) + if (buffer.length > maxLineLength) { + out += Out.Dropped(buffer.length, controlRequestId(buffer)) + buffer.setLength(0) + discarding = true + } + out + } + + fun finish(): String? = synchronized(buffer) { + val last = buffer.toString().takeIf { it.isNotBlank() } + buffer.setLength(0) + last + } + + private companion object { + const val HEAD = 4096 + + val CONTROL_REQUEST = Regex(""""type"\s*:\s*"control_request"""") + val REQUEST_ID = Regex(""""request_id"\s*:\s*"([^"\\]+)"""") + + fun controlRequestId(line: CharSequence): String? { + val head = line.subSequence(0, minOf(line.length, HEAD)) + if (!CONTROL_REQUEST.containsMatchIn(head)) return null + return REQUEST_ID.find(head)?.groupValues?.get(1) + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/ChatSessionManager.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/ChatSessionManager.kt index 83c77e7a..920bd6df 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/ChatSessionManager.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/ChatSessionManager.kt @@ -4,6 +4,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer import dev.lain.claudejb.controller.session.history.SessionHistory import dev.lain.claudejb.model.session.launch.LaunchOptions import dev.lain.claudejb.model.settings.ClaudeSettings @@ -67,7 +68,7 @@ class ChatSessionManager(private val project: Project) : Disposable { fun remove(session: ClaudeSession) { if (!sessions.remove(session)) return - session.dispose() + Disposer.dispose(session) if (active == session) active = sessions.lastOrNull() persistOpenTabs() fireChanged() @@ -81,7 +82,7 @@ class ChatSessionManager(private val project: Project) : Disposable { private fun fireChanged() = listeners.forEach { it() } override fun dispose() { - sessions.forEach { it.dispose() } + sessions.forEach(Disposer::dispose) sessions.clear() } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/ClaudeSession.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/ClaudeSession.kt index ce8c1063..dac2ebc2 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/ClaudeSession.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/ClaudeSession.kt @@ -182,9 +182,7 @@ class ClaudeSession( fun send(text: String, attachments: List) { val composed = PromptComposer.compose(text, attachments, project.basePath) ?: return - if (!isRunning()) { - if (!start()) return - } + if (!isRunning()) start() prompts.enqueue(composed.wireText, composed.images, composed.displayText) } @@ -192,8 +190,8 @@ class ClaudeSession( val trimmed = text.trim() if (trimmed.isEmpty()) return if (!isRunning()) { - if (!start()) return - prompts.enqueue(trimmed, emptyList(), trimmed) + start() + systemNotice("$SIDE_QUESTION_NEEDS_SESSION $trimmed") return } edt { @@ -230,5 +228,7 @@ class ClaudeSession( private companion object { const val SIDE_QUESTION_UNANSWERED = "↩ The side question was not answered." + const val SIDE_QUESTION_NEEDS_SESSION = + "A side question needs a running conversation; ask it again once Claude Code is ready:" } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/ProcessSlot.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/ProcessSlot.kt new file mode 100644 index 00000000..8411701e --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/ProcessSlot.kt @@ -0,0 +1,38 @@ +package dev.lain.claudejb.controller.session + +class ProcessSlot { + + private val lock = Any() + + @Volatile var generation = 0 + private set + + @Volatile var current: T? = null + private set + + fun supersede(): Int = synchronized(lock) { ++generation } + + fun isCurrent(gen: Int): Boolean = gen == generation + + fun claim(gen: Int, value: T): Boolean = synchronized(lock) { + if (gen != generation) return false + current = value + true + } + + fun releaseIfStale(gen: Int, value: T): Boolean = synchronized(lock) { + if (gen == generation) return false + if (current === value) current = null + true + } + + fun release(value: T) = synchronized(lock) { + if (current === value) current = null + } + + fun take(): T? = synchronized(lock) { current.also { current = null } } + + fun onlyWhileCurrent(gen: Int, deliver: (E) -> Unit): (E) -> Unit = { event -> + if (isCurrent(gen)) deliver(event) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/SessionLifecycle.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/SessionLifecycle.kt index 6d83adfc..32d608f8 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/SessionLifecycle.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/SessionLifecycle.kt @@ -77,10 +77,22 @@ class SessionLifecycle( return false } - Credential.UNKNOWN -> return true + Credential.UNKNOWN -> { + auth.resolveOffEdt(settings) { held -> edt { launchOnceKnown(held, settings, binary, resume) } } + return true + } Credential.HELD -> Unit } + return beginLaunch(settings, binary, resume) + } + + private fun launchOnceKnown(held: Boolean, settings: ClaudeSettings, binary: File, resume: Boolean) { + if (disposed || isRunning() || starting) return + if (held) beginLaunch(settings, binary, resume) else onLoginNeeded() + } + + private fun beginLaunch(settings: ClaudeSettings, binary: File, resume: Boolean): Boolean { val workDir = project.basePath?.let(::File) ?: File(System.getProperty("user.home")) ready = false @@ -198,6 +210,7 @@ class SessionLifecycle( if (!process.spawn(launchGen, binary, workDir, env, resume)) return s.catalog.request() edt { + if (launchGen != process.generation) return@edt ready = true s.transcript.add(Speaker.SYSTEM, "Claude Code ready.") auth.probe() diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/SessionLiveSettings.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/SessionLiveSettings.kt index e7e732ca..f6b72425 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/SessionLiveSettings.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/SessionLiveSettings.kt @@ -28,14 +28,23 @@ class SessionLiveSettings( val previous = session.launch.model session.launch = session.launch.copy(model = resolved) if (persist) ClaudeSettings.getInstance(project).update { it.model = value.orEmpty() } - if (session.isRunning()) { - session.controlClient.send({ id -> ControlProtocol.setModelRequest(id, resolved) }) { res -> - if (!res.success) edt { revertModel(previous, resolved, res.error) } - } - } + sendModel(previous, resolved) fireState() } + private fun sendModel(previous: String?, resolved: String?) { + if (!session.isRunning()) return + session.controlClient.send({ id -> ControlProtocol.setModelRequest(id, resolved) }) { res -> + if (!res.success) edt { revertModel(previous, resolved, res.error) } + } + } + + private fun sendPermissionMode(mode: String) { + if (!session.isRunning()) return + val wire = SessionLauncher.binaryPermissionMode(mode) + write(ControlProtocol.setPermissionModeRequest(ControlProtocol.newRequestId(), wire)) + } + private fun revertModel(previous: String?, attempted: String?, error: String?) { if (session.launch.model != attempted) return session.launch = session.launch.copy(model = previous) @@ -49,10 +58,7 @@ class SessionLiveSettings( fun changePermissionMode(mode: String) { session.launch = session.launch.copy(permissionMode = mode) ClaudeSettings.getInstance(project).update { it.permissionMode = mode } - if (session.isRunning()) { - val wire = SessionLauncher.binaryPermissionMode(mode) - write(ControlProtocol.setPermissionModeRequest(ControlProtocol.newRequestId(), wire)) - } + sendPermissionMode(mode) fireState() } @@ -111,27 +117,30 @@ class SessionLiveSettings( fun adopt(next: LaunchOptions) { val before = session.launch - val relaunch = before.relaunchDiffers(next) + val modelChanged = resolve(before.model) != resolve(next.model) val thinkingChanged = before.thinkingTokens != next.thinkingTokens - if (resolve(before.model) != resolve(next.model)) changeModel(next.model, persist = false) - if (before.effort != next.effort) changeEffort(next.effort, persist = false) - if (before.permissionMode != next.permissionMode) changePermissionMode(next.permissionMode) - changeThinkingTokens(next.thinkingTokens, persist = false) - val live = session.launch + val restart = (before.relaunchDiffers(next) || thinkingChanged) && session.isRunning() session.launch = next.copy( - model = live.model, - effort = live.effort, - permissionMode = live.permissionMode, - thinkingTokens = live.thinkingTokens, - sessionId = live.sessionId, + model = if (modelChanged) resolve(next.model) else before.model, + sessionId = before.sessionId, ) fireState() - if (relaunch && !thinkingChanged && session.isRunning()) { - session.systemNotice("Launch settings changed — restarting session.") + if (restart) { + session.systemNotice(restartNotice(before, next)) session.restart(resume = true) + return } + if (modelChanged) sendModel(before.model, session.launch.model) + if (before.permissionMode != next.permissionMode) sendPermissionMode(next.permissionMode) } + private fun restartNotice(before: LaunchOptions, next: LaunchOptions): String = + if (before.relaunchDiffers(next)) { + "Launch settings changed — restarting session." + } else { + "Extended thinking ${if (next.thinkingTokens != null) "on" else "off"} — restarting session." + } + private fun resolve(value: String?): String? = if (value == LaunchDefaults.RECOMMENDED_ALIAS) session.catalog.preferredDefaultModel() else value } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/SessionProcess.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/SessionProcess.kt index 188284a6..acc708b4 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/SessionProcess.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/SessionProcess.kt @@ -3,6 +3,7 @@ package dev.lain.claudejb.controller.session import dev.lain.claudejb.controller.mcp.IdeMcpService import dev.lain.claudejb.controller.process.ClaudeProcess import dev.lain.claudejb.model.protocol.ClaudeEvent +import dev.lain.claudejb.model.session.history.SessionStore import dev.lain.claudejb.model.session.launch.IdeServer import dev.lain.claudejb.model.session.launch.SessionLauncher import dev.lain.claudejb.model.session.transcript.Speaker @@ -20,26 +21,24 @@ class SessionProcess( private val log = thisLogger() - @Volatile private var process: ClaudeProcess? = null + private val slot = ProcessSlot() - @Volatile var generation = 0 - private set + val generation: Int get() = slot.generation @Volatile private var resumedLaunch = false - fun isRunning(): Boolean = process?.isRunning() == true + fun isRunning(): Boolean = slot.current?.isRunning() == true - fun write(line: String): Boolean = process?.writeLine(line) ?: false + fun write(line: String): Boolean = slot.current?.writeLine(line) ?: false - fun supersede(): Int = ++generation + fun supersede(): Int = slot.supersede() fun terminate() { - process?.terminate() - process = null + slot.take()?.terminate() } fun spawn(launchGen: Int, binary: File, workDir: File, env: Map, resume: Boolean): Boolean { - if (launchGen != generation) return false + if (!slot.isCurrent(launchGen)) return false resumedLaunch = resume val opts = s.launch.copy(sessionId = s.sessionId, ideSockets = ideSockets()) val proc = ClaudeProcess( @@ -48,20 +47,19 @@ class SessionProcess( args = SessionLauncher.buildArgs(opts, resume, SessionLauncher.mcpConfigJson(opts)), nodeOverride = ClaudeSettings.getInstance(s.project).nodePath, extraEnv = env, - onEvent = onEvent, + onEvent = slot.onlyWhileCurrent(launchGen, onEvent), onTerminated = { code -> onTerminated(launchGen, code) }, ) - process = proc + if (!slot.claim(launchGen, proc)) return false val started = runCatching { proc.start() } if (started.isFailure) { - process = null + slot.release(proc) log.warn("Failed to start the claude process", started.exceptionOrNull()) s.notifier.error("Failed to start Claude Code: ${started.exceptionOrNull()?.message ?: "unknown error"}") return false } - if (launchGen != generation) { + if (slot.releaseIfStale(launchGen, proc)) { proc.terminate() - if (process === proc) process = null return false } return true @@ -78,8 +76,10 @@ class SessionProcess( } private fun onTerminated(gen: Int, exitCode: Int) { - if (gen != generation) return - val staleResume = resumedLaunch && !s.catalog.initialized + if (!slot.isCurrent(gen)) return + val staleResume = StaleResume.startsFresh(exitCode, resumedLaunch, s.catalog.initialized) { + s.sessionId?.let(SessionStore::exists) == true + } s.flushDeltas() s.controlClient.failAll("process gone") edt { @@ -91,7 +91,7 @@ class SessionProcess( s.taskTracker.clear() s.hookNarrator.clear() when { - exitCode != 0 && staleResume -> restartFresh(exitCode) + staleResume -> restartFresh(exitCode) exitCode != 0 -> { s.transcript.add(Speaker.ERROR, "Claude Code exited (code $exitCode).") diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/StaleResume.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/StaleResume.kt new file mode 100644 index 00000000..4cd8a283 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/StaleResume.kt @@ -0,0 +1,7 @@ +package dev.lain.claudejb.controller.session + +internal object StaleResume { + + fun startsFresh(exitCode: Int, resumed: Boolean, initialized: Boolean, transcriptExists: () -> Boolean): Boolean = + exitCode != 0 && resumed && !initialized && !transcriptExists() +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/auth/AuthGate.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/auth/AuthGate.kt index aaa6bb38..746f2eb0 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/auth/AuthGate.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/auth/AuthGate.kt @@ -72,6 +72,21 @@ class AuthGate( return if (probed) Credential.HELD else Credential.NONE } + @Volatile private var resolving = false + + fun resolveOffEdt(settings: ClaudeSettings, onAnswer: (Boolean) -> Unit) { + if (resolving) return + resolving = true + ApplicationManager.getApplication().executeOnPooledThread { + val held = try { + hasCredential(settings) + } finally { + resolving = false + } + onAnswer(held) + } + } + fun canRenewCredential(): Boolean = CredentialsVault.canRenew() private fun binaryHoldsOwnLogin(settings: ClaudeSettings): Boolean { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/events/EarlyLines.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/events/EarlyLines.kt new file mode 100644 index 00000000..cdb9f947 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/events/EarlyLines.kt @@ -0,0 +1,18 @@ +package dev.lain.claudejb.controller.session.events + +import dev.lain.claudejb.model.session.transcript.LiveLines + +internal class EarlyLines(private val maxTools: Int) { + + private val byTool = object : LinkedHashMap() { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = size > maxTools + } + + val size: Int get() = byTool.size + + fun add(toolUseId: String, line: String) { + byTool.getOrPut(toolUseId) { LiveLines() }.add(line) + } + + fun take(toolUseId: String): LiveLines? = byTool.remove(toolUseId) +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt index d2a5e2c4..455a42d6 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt @@ -31,7 +31,7 @@ class ToolEvents( private class Own(val call: OwnTools.Call, val items: List, val batch: Boolean) private val live = HashMap>() - private val early = HashMap() + private val early = EarlyLines(MAX_EARLY_TOOLS) private val ownCalls = HashMap() init { @@ -43,18 +43,18 @@ class ToolEvents( private fun onLiveLine(toolUseId: String, line: String) { if (!s.transcript.knowsTool(toolUseId)) { - early.getOrPut(toolUseId) { LiveLines() }.add(line) + early.add(toolUseId, line) return } val (entry, ring) = live.getOrPut(toolUseId) { - s.transcript.addToolOutput(toolUseId, "", meta = LIVE) to (early.remove(toolUseId) ?: LiveLines()) + s.transcript.addToolOutput(toolUseId, "", meta = LIVE) to (early.take(toolUseId) ?: LiveLines()) } ring.add(line) s.transcript.replaceText(entry, ring.render()) } private fun flushEarly(toolUseId: String) { - val ring = early.remove(toolUseId) ?: return + val ring = early.take(toolUseId) ?: return val entry = s.transcript.addToolOutput(toolUseId, ring.render(), meta = LIVE) live[toolUseId] = entry to ring } @@ -143,7 +143,7 @@ class ToolEvents( private fun settle(toolUseId: String, failed: Boolean) { live.remove(toolUseId) - early.remove(toolUseId) + early.take(toolUseId) if (s.runningAgents.nodes.values.none { it.meta.toolUseId == toolUseId }) { s.transcript.setToolState(toolUseId, if (failed) ToolState.ERROR else ToolState.FINISHED) } @@ -198,6 +198,7 @@ class ToolEvents( private companion object { const val TOON = "toon" const val LIVE = "live" + const val MAX_EARLY_TOOLS = 16 const val DIFF = "diff" const val ERROR = "error" } diff --git a/src/main/kotlin/dev/lain/claudejb/model/diff/DiffPresenter.kt b/src/main/kotlin/dev/lain/claudejb/model/diff/DiffPresenter.kt index d9cad66a..ae17ccde 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/diff/DiffPresenter.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/diff/DiffPresenter.kt @@ -10,6 +10,9 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.jsonObject import java.io.File +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path object DiffPresenter { @@ -20,16 +23,26 @@ object DiffPresenter { fun isWithinRoot(path: String?, projectRoot: String?): Boolean { if (path == null || projectRoot == null) return false return try { - val canonicalFile = File(path).canonicalFile - val canonicalRoot = File(projectRoot).canonicalFile - val rootPath = canonicalRoot.path - val filePath = canonicalFile.path - filePath == rootPath || filePath.startsWith(rootPath + File.separator) + val file = realPathOf(path) ?: return false + val root = realPathOf(projectRoot) ?: return false + file.startsWith(root) } catch (_: java.io.IOException) { false } catch (_: SecurityException) { false + } catch (_: InvalidPathException) { + false + } + } + + private fun realPathOf(path: String): Path? { + val target = File(path).canonicalFile.toPath() + var existing: Path = target + while (!Files.exists(existing)) { + if (Files.isSymbolicLink(existing)) return null + existing = existing.parent ?: return null } + return existing.toRealPath().resolve(existing.relativize(target)).normalize() } const val MAX_DIFF_FILE_BYTES = 1_000_000L diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/Batch.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/Batch.kt index 30ad3688..a295102d 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/Batch.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/Batch.kt @@ -76,7 +76,7 @@ object Batch { fun split(tool: String?, args: JsonObject): List? { if (tool in ONE_CALL_LISTS) return null - val (key, identity) = IDENTITIES.entries.firstOrNull { args[it.key] is JsonArray } ?: return null + val (key, identity) = IDENTITIES.entries.singleOrNull { args[it.key] is JsonArray } ?: return null val shared = args.filterKeys { it != key } return (args.getValue(key) as JsonArray).map { item -> JsonObject(shared + (if (item is JsonObject) item else mapOf(identity to item))) diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/MetaTools.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/MetaTools.kt index d6aee434..ca0542ce 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/MetaTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/MetaTools.kt @@ -85,12 +85,21 @@ class MetaTools(private val catalog: ToolCatalog, private val gate: ToolGate, pr val tool = catalog.tool(name) ?: return ToolResult.error("unknown tool $name; call domains() then tools(domain)") gate.denial(tool.spec, arguments)?.let { return ToolResult.error(it) } val args = arguments["args"]?.let { it as? JsonObject } ?: JsonObject(emptyMap()) + undeclared(tool.spec, args)?.let { return ToolResult.error(it) } val toolUseId = (meta[OwnTools.TOOL_USE_ID_KEY] as? JsonPrimitive)?.content val result = runCatching { withTimeout(tool.spec.timeoutMillis) { tool.run(ToolArgs(args, toolUseId)) } } .getOrElse { failure(name, tool.spec.timeoutMillis, it) } return ToolResult(budget.fit(result.text), result.isError) } + private fun undeclared(spec: ToolSpec, args: JsonObject): String? { + val declared = spec.params.map { it.name } + val unknown = args.keys.filterNot { it in declared } + if (unknown.isEmpty()) return null + val takes = if (declared.isEmpty()) "it takes no arguments" else "it takes ${declared.joinToString()}" + return "${spec.name} does not take ${unknown.joinToString()}; $takes" + } + private fun failure(name: String, timeoutMillis: Long, cause: Throwable): ToolResult = when (cause) { is ToolException -> ToolResult.error(cause.message ?: "tool failed") is TimeoutCancellationException -> ToolResult.error(overrun(name, timeoutMillis, cause)) diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolResult.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolResult.kt index f725d2cc..b0e96b3f 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolResult.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolResult.kt @@ -20,7 +20,12 @@ class OutputBudget(val maxChars: Int = DEFAULT_MAX_CHARS) { fun fit(text: String): String { if (text.length <= maxChars) return text val room = maxChars - NOTICE_ROOM - val cut = text.lastIndexOf('\n', room).takeIf { it > 0 } ?: room + val newline = text.lastIndexOf('\n', room) + val cut = when { + newline > room / 2 -> newline + text[room - 1].isHighSurrogate() -> room - 1 + else -> room + } return text.substring(0, cut) + "\n# truncated: $cut of ${text.length} chars shown; narrow the request" } diff --git a/src/main/kotlin/dev/lain/claudejb/model/protocol/parse/ControlParsers.kt b/src/main/kotlin/dev/lain/claudejb/model/protocol/parse/ControlParsers.kt index fc512f32..236a1f2a 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/protocol/parse/ControlParsers.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/protocol/parse/ControlParsers.kt @@ -34,7 +34,8 @@ internal object ControlParsers { fun parseControlRequest(root: JsonObject): List { val requestId = root.str("request_id") ?: return emptyList() - val request = root["request"] as? JsonObject ?: return emptyList() + val request = root["request"] as? JsonObject + ?: return listOf(ClaudeEvent.UnsupportedControlRequest(requestId, null)) val subtype = request.str("subtype") val decoder = CONTROL_DECODERS[subtype] ?: return listOf(ClaudeEvent.UnsupportedControlRequest(requestId, subtype)) return listOf(decoder(requestId, request)) diff --git a/src/main/kotlin/dev/lain/claudejb/model/settings/LaunchDefaults.kt b/src/main/kotlin/dev/lain/claudejb/model/settings/LaunchDefaults.kt index 40fb9736..04e4d7af 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/settings/LaunchDefaults.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/settings/LaunchDefaults.kt @@ -1,10 +1,8 @@ package dev.lain.claudejb.model.settings -import dev.lain.claudejb.model.protocol.ClaudeJson import dev.lain.claudejb.model.protocol.EffortLevel import dev.lain.claudejb.model.protocol.PermissionMode import dev.lain.claudejb.model.protocol.models.ModelInfo -import kotlinx.serialization.json.JsonObject object LaunchDefaults { @@ -43,7 +41,4 @@ object LaunchDefaults { "ops.services,ops.project,ops.ide,ops.actions,ops.service_view,ops.remote,ops.tools_menu,ops.consoles,ops.window," + "ops.data," + "common.show,common.query,common.prs,common.batch,common.agents,common.tools,common.fallback,common.report" - - fun isValidMcpConfig(text: String): Boolean = - text.isBlank() || (runCatching { ClaudeJson.parseToJsonElement(text) }.getOrNull() is JsonObject) } diff --git a/src/main/kotlin/dev/lain/claudejb/model/settings/McpConfigShape.kt b/src/main/kotlin/dev/lain/claudejb/model/settings/McpConfigShape.kt new file mode 100644 index 00000000..ae9c4969 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/model/settings/McpConfigShape.kt @@ -0,0 +1,43 @@ +package dev.lain.claudejb.model.settings + +import dev.lain.claudejb.model.protocol.ClaudeJson +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +object McpConfigShape { + + fun unwrap(text: String): String { + val root = rootOf(text) ?: return text + val inner = root[WRAPPER]?.let(::serversIn)?.takeIf { root.size == 1 } ?: return text + return PRETTY.encodeToString(JsonObject.serializer(), inner) + } + + fun problem(text: String): String? { + if (text.isBlank()) return null + val root = rootOf(text) ?: return NOT_AN_OBJECT + if (root[WRAPPER]?.let(::serversIn) != null) return WRAPPED + val stray = root.entries.firstOrNull { it.value !is JsonObject } ?: return null + return "Custom MCP servers: \"${stray.key}\" must map to a config object, such as $HTTP_EXAMPLE." + } + + private fun rootOf(text: String): JsonObject? = + runCatching { ClaudeJson.parseToJsonElement(text) }.getOrNull() as? JsonObject + + private fun serversIn(value: JsonElement): JsonObject? = + (value as? JsonObject)?.takeIf { servers -> servers.values.all { it is JsonObject } } + + private val PRETTY = Json { prettyPrint = true } + + private const val WRAPPER = "mcpServers" + + const val STDIO_EXAMPLE = "{ \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"] }" + + const val HTTP_EXAMPLE = "{ \"type\": \"http\", \"url\": \"https://example.com/mcp\" }" + + private const val NOT_AN_OBJECT = "Custom MCP servers must be a JSON object mapping each server name to its config." + + private const val WRAPPED = + "Custom MCP servers: \"$WRAPPER\" is Claude's wrapper, not a server. Paste only the object inside it, " + + "beside your other servers." +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/jcef/PageAssembly.kt b/src/main/kotlin/dev/lain/claudejb/view/jcef/PageAssembly.kt index fee1daf6..d1b95150 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/jcef/PageAssembly.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/jcef/PageAssembly.kt @@ -33,6 +33,7 @@ internal object PageAssembly { "controllers/chat/row.js", "controllers/chat/transcript.js", "models/composer/state.js", + "models/composer/history.js", "views/composer/bar/overflow-fit.js", "views/composer/bar/overflow.js", "controllers/composer/menus.js", @@ -58,6 +59,8 @@ internal object PageAssembly { "controllers/composer/settings.js", "views/composer/flame.js", "views/composer/toggles.js", + "controllers/composer/history.js", + "controllers/composer/fold.js", "controllers/composer/send.js", "controllers/composer/composer.js", "views/permissions/base.js", @@ -115,6 +118,7 @@ internal object PageAssembly { "views/composer/attach.css", "views/composer/readout.css", "views/composer/palette.css", + "views/composer/fold.css", "views/permissions/cards.css", "views/permissions/elicit.css", "views/composer/attachments.css", diff --git a/src/main/kotlin/dev/lain/claudejb/view/payload/composer/JcefModelLabels.kt b/src/main/kotlin/dev/lain/claudejb/view/payload/composer/JcefModelLabels.kt index aa5f69ca..d9565546 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/payload/composer/JcefModelLabels.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/payload/composer/JcefModelLabels.kt @@ -7,14 +7,21 @@ import dev.lain.claudejb.model.session.history.LegacyModels object JcefModelLabels { fun modelDisplayLabel(m: ModelInfo): String { - val descHead = m.description.substringBefore(" · ").trim() - return when { - descHead.isNotBlank() -> descHead - m.displayName.isNotBlank() -> m.displayName - else -> deriveModelLabel(m.value) - } + val derived = deriveModelLabel(m.value) + val name = m.displayName.ifBlank { derived } + val family = name.substringBefore(' ') + val described = m.description.split(" · ").map { it.trim() } + .firstOrNull { it.startsWith(family, ignoreCase = true) && hasVersion(it) } + return described + ?: m.displayName.takeIf { hasVersion(it) } + ?: derived.takeIf { hasVersion(it) } + ?: name } + private val VERSION = Regex("""\b\d+(\.\d+)?\b""") + + private fun hasVersion(label: String): Boolean = VERSION.containsMatchIn(label) + fun modelLabel(session: ClaudeSession): String { val id = session.launch.model ?: session.catalog.preferredDefaultModel() session.catalog.models.firstOrNull { it.value == id }?.let { return modelDisplayLabel(it) } diff --git a/src/main/kotlin/dev/lain/claudejb/view/settings/sections/SettingsMcpSection.kt b/src/main/kotlin/dev/lain/claudejb/view/settings/sections/SettingsMcpSection.kt index f322e1e9..a8b6436b 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/settings/sections/SettingsMcpSection.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/settings/sections/SettingsMcpSection.kt @@ -7,13 +7,13 @@ import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.MAX_LINE_LENGTH_WORD_WRAP import com.intellij.ui.dsl.builder.Panel import dev.lain.claudejb.model.settings.ClaudeSettings -import dev.lain.claudejb.model.settings.LaunchDefaults +import dev.lain.claudejb.model.settings.McpConfigShape import dev.lain.claudejb.view.settings.SettingsSection internal class SettingsMcpSection : SettingsSection { private val customMcpArea = JBTextArea(CUSTOM_MCP_ROWS, 0).apply { - emptyText.text = "JSON object of name → server config; add as many as you like (sse / streamable-http / stdio)" + emptyText.text = "JSON object of name → server config; add as many as you like (stdio / http / sse)" } private val strictMcpCheck = JBCheckBox("Strict MCP config (only use servers from --mcp-config)") @@ -30,19 +30,20 @@ internal class SettingsMcpSection : SettingsSection { strictMcpCheck.isSelected = s.strictMcpConfig } + private fun servers(): String = McpConfigShape.unwrap(customMcpArea.text.trim()) + override fun validate() { - if (!LaunchDefaults.isValidMcpConfig(customMcpArea.text.trim())) { - throw ConfigurationException("Custom MCP servers must be a JSON object mapping each server name to its config.") - } + McpConfigShape.problem(servers())?.let { throw ConfigurationException(it) } } override fun apply(s: ClaudeSettings.State) { - s.customMcpServers = customMcpArea.text.trim() + s.customMcpServers = servers() s.strictMcpConfig = strictMcpCheck.isSelected + customMcpArea.text = s.customMcpServers } override fun changedFields(s: ClaudeSettings.State): List = listOf( - customMcpArea.text.trim() != s.customMcpServers, + servers() != s.customMcpServers, strictMcpCheck.isSelected != s.strictMcpConfig, ) @@ -50,7 +51,11 @@ internal class SettingsMcpSection : SettingsSection { const val CUSTOM_MCP_ROWS = 7 const val CUSTOM_MCP_NOTE = - "A JSON object of name → server config: { \"name\": { \"type\": \"…\", … } }. " + + "A JSON object of name → server config. A local server: { \"files\": " + McpConfigShape.STDIO_EXAMPLE + + " }. A remote one: { \"docs\": " + McpConfigShape.HTTP_EXAMPLE + " }. " + + "type is stdio (the default, with command and args), " + + "http or sse (with url). Claude's own " + + "{ \"mcpServers\": { … } } can be pasted as it is: the wrapper is removed on Apply. " + "⚠ Third-party servers run with your privileges and can read what you share with them." } } diff --git a/src/main/resources/jcef/css/views/chat/rows.css b/src/main/resources/jcef/css/views/chat/rows.css index 7adc4569..d1b71905 100644 --- a/src/main/resources/jcef/css/views/chat/rows.css +++ b/src/main/resources/jcef/css/views/chat/rows.css @@ -44,6 +44,13 @@ .msg-head .who { color: var(--text); } +.msg.user { + padding-left: 11px; + border-left: 2px solid color-mix(in srgb, var(--accent) 60%, transparent); +} +.msg.user .msg-head .name { + color: var(--accent); +} .act, .copy { @@ -82,6 +89,7 @@ .msg.user .body { padding: 0 1px; + color: color-mix(in srgb, var(--accent) 30%, var(--text)); word-break: break-word; overflow-wrap: anywhere; } diff --git a/src/main/resources/jcef/css/views/composer/fold.css b/src/main/resources/jcef/css/views/composer/fold.css new file mode 100644 index 00000000..1dd7c901 --- /dev/null +++ b/src/main/resources/jcef/css/views/composer/fold.css @@ -0,0 +1,40 @@ +.dock-fold { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-height: 18px; + margin: -6px 0 4px; +} +#dock.folded .dock-fold { + justify-content: space-between; + margin: -4px 0 -6px; +} +.dock-fold-phase { + color: var(--dim); + font-size: 12px; +} +#dock:not(.folded) .dock-fold-phase { + display: none; +} +.dock-fold-btn { + padding: 1px 8px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--dim); + font: inherit; + font-size: 12px; + cursor: pointer; + transition: + color 0.14s, + background-color 0.14s; +} +.dock-fold-btn:hover { + color: var(--text); + background: var(--surface); +} +#dock.folded #composer, +#dock.folded #controls { + display: none; +} diff --git a/src/main/resources/jcef/css/views/permissions/cards.css b/src/main/resources/jcef/css/views/permissions/cards.css index 76176396..1967bca2 100644 --- a/src/main/resources/jcef/css/views/permissions/cards.css +++ b/src/main/resources/jcef/css/views/permissions/cards.css @@ -219,3 +219,12 @@ color: var(--dim); line-height: 1.45; } +.q-other-input { + display: block; + box-sizing: border-box; + width: 100%; + margin: -3px 0 10px; +} +.q-other-input[hidden] { + display: none; +} diff --git a/src/main/resources/jcef/css/views/permissions/elicit.css b/src/main/resources/jcef/css/views/permissions/elicit.css index 259a1017..fd84444e 100644 --- a/src/main/resources/jcef/css/views/permissions/elicit.css +++ b/src/main/resources/jcef/css/views/permissions/elicit.css @@ -23,6 +23,7 @@ color: var(--dim); } .elicit-card input, +.q-card .q-other-input, .elicit-card select, .boot-path-controls input, .auth-row input { @@ -38,6 +39,7 @@ box-shadow 0.14s; } .elicit-card input:focus, +.q-card .q-other-input:focus, .elicit-card select:focus, .boot-path-controls input:focus, .auth-row input:focus { @@ -45,6 +47,7 @@ box-shadow: 0 0 0 3px var(--accent-soft); } .elicit-card input::placeholder, +.q-card .q-other-input::placeholder, .boot-path-controls input::placeholder, .auth-row input::placeholder { color: var(--dim); diff --git a/src/main/ts/jcef/controllers/composer/composer.ts b/src/main/ts/jcef/controllers/composer/composer.ts index 4da66aa9..8008ec14 100644 --- a/src/main/ts/jcef/controllers/composer/composer.ts +++ b/src/main/ts/jcef/controllers/composer/composer.ts @@ -134,6 +134,7 @@ } CX.wireInput(input); + CX.wireHistory(input); CX.wireImageDrop(card); CX.wireImagePaste(input); @@ -160,6 +161,7 @@ CX.renderPills(s); CX.renderQueue(s.queue); CX.renderReadout(s); + CX.renderFold(s); CX.setGhost(s.suggestion != null ? String(s.suggestion) : ''); CX.renderGhost(); CX.syncOpenMenu(); diff --git a/src/main/ts/jcef/controllers/composer/fold.ts b/src/main/ts/jcef/controllers/composer/fold.ts new file mode 100644 index 00000000..c772a293 --- /dev/null +++ b/src/main/ts/jcef/controllers/composer/fold.ts @@ -0,0 +1,127 @@ +(function () { + 'use strict'; + + const CC = (window.CC = window.CC || ({} as CcShared)); + const CX = (CC.composer = CC.composer || ({} as ComposerNs)); + + const h = CX.h; + + let folded = false; + let foldable = false; + let strip: HTMLElement | null = null; + let toggle: HTMLButtonElement | null = null; + let label: HTMLElement | null = null; + + function dock(): HTMLElement | null { + return document.getElementById('dock'); + } + + function ensureStrip(): boolean { + if (strip) return true; + const d = dock(); + if (!d) return false; + label = h('span', { class: 'dock-fold-phase' }); + toggle = h('button', { + class: 'dock-fold-btn', + attrs: { type: 'button', 'aria-controls': 'composer' }, + on: { + click: function (e: Event) { + e.preventDefault(); + setFolded(!folded, false); + }, + }, + }) as HTMLButtonElement; + strip = h('div', { class: 'dock-fold', attrs: { hidden: 'hidden' } }, label, toggle); + d.insertBefore(strip, d.firstChild); + return true; + } + + function paint(): void { + if (!strip || !toggle) return; + const d = dock(); + if (d) d.classList.toggle('folded', folded); + const hint = folded ? 'Show the input (Ctrl+Shift+↑)' : 'Hide the input (Ctrl+Shift+↓)'; + toggle.textContent = folded ? '▴ Show input' : '▾'; + toggle.title = hint; + toggle.setAttribute('aria-label', hint); + toggle.setAttribute('aria-expanded', String(!folded)); + if (foldable) { + strip.removeAttribute('hidden'); + return; + } + const stranded = strip.contains(document.activeElement); + strip.setAttribute('hidden', 'hidden'); + const input = CX.els && CX.els.input; + if (stranded && input) input.focus(); + } + + function phaseOf(s: ComposerState): string { + const cards = CC.els && CC.els.permissions; + if (cards && cards.children.length > 0) return 'Waiting for your answer'; + if (s.interrupting) return 'Stopping…'; + if (s.turnActive) return 'Claude is working…'; + return 'Ready'; + } + + function setFolded(next: boolean, focusInput: boolean): void { + if (next && !foldable) return; + const input = CX.els && CX.els.input; + if (next !== folded) { + const tx = CC.transcript; + const stick = !!(tx && tx.stickToBottom && tx.stickToBottom()); + const d = dock(); + const active = document.activeElement; + const hadFocus = !!d && !!active && d.contains(active) && !(strip && strip.contains(active)); + folded = next; + paint(); + if (folded && hadFocus && toggle) toggle.focus(); + if (tx && tx.scheduleScroll) tx.scheduleScroll(stick); + if (CC.announce) CC.announce(folded ? 'Input hidden' : 'Input shown'); + } + if (!folded && focusInput && input) input.focus(); + } + + CX.renderFold = function (s: ComposerState): void { + if (!ensureStrip()) return; + foldable = !!s.running && !CX.authWanted(s); + if (!foldable) folded = false; + if (label) label.textContent = phaseOf(s); + paint(); + }; + + CC.refreshFold = function (): void { + if (CX.lastState) CX.renderFold(CX.lastState); + }; + + function readingTranscript(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + if (!el || !el.tagName) return false; + if (el === document.body) return true; + const conversation = document.getElementById('conversation'); + return ( + !!conversation && + conversation.contains(el) && + !el.isContentEditable && + !/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) + ); + } + + document.addEventListener('keydown', function (e: KeyboardEvent) { + if (!strip || !strip.isConnected) return; + if ( + e.ctrlKey && + e.shiftKey && + !e.altKey && + !e.metaKey && + (e.key === 'ArrowDown' || e.key === 'ArrowUp') + ) { + const fold = e.key === 'ArrowDown'; + if (fold && !foldable) return; + e.preventDefault(); + setFolded(fold, !fold); + return; + } + if (!folded || !readingTranscript(e.target) || e.ctrlKey || e.metaKey || e.altKey) return; + if (e.key.length === 1) setFolded(false, true); + }); +})(); diff --git a/src/main/ts/jcef/controllers/composer/history.ts b/src/main/ts/jcef/controllers/composer/history.ts new file mode 100644 index 00000000..f3aa20e7 --- /dev/null +++ b/src/main/ts/jcef/controllers/composer/history.ts @@ -0,0 +1,55 @@ +(function () { + 'use strict'; + + const CC = (window.CC = window.CC || ({} as CcShared)); + const CX = (CC.composer = CC.composer || ({} as ComposerNs)); + + const IDLE: HistoryNav = { index: -1, draft: '' }; + + function activeTranscript(): Element | null { + if (typeof CC.gitChatActive === 'function' && CC.gitChatActive()) { + return typeof CC.gitChatRows === 'function' ? CC.gitChatRows() : null; + } + return ( + (CC.transcript && CC.transcript.conversationEl && CC.transcript.conversationEl()) || + document.getElementById('conversation') + ); + } + + function sentPrompts(): string[] { + const root = activeTranscript(); + if (!root) return []; + const prompts: string[] = []; + root.querySelectorAll('.msg.user .body').forEach(function (body) { + const raw = (body as BodyEl).__rawText; + if (raw) prompts.push(raw); + }); + return prompts; + } + + function caretAt(input: HTMLTextAreaElement, offset: number): boolean { + return input.selectionStart === offset && input.selectionEnd === offset; + } + + CX.wireHistory = function (input: HTMLTextAreaElement): void { + let nav = IDLE; + let shown: string | null = null; + input.addEventListener('keydown', function (e: KeyboardEvent) { + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; + if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return; + const up = e.key === 'ArrowUp'; + if (nav.index >= 0 && input.value !== shown) nav = IDLE; + const walking = nav.index >= 0; + if (!walking && !caretAt(input, up ? 0 : input.value.length)) return; + const move = CX.historyStep(nav, sentPrompts(), up ? -1 : 1, input.value); + if (!move) return; + e.preventDefault(); + nav = move.nav; + shown = move.text; + input.value = move.text; + CX.autosize(input); + input.setSelectionRange(input.value.length, input.value.length); + CX.renderGhost(); + }); + }; +})(); diff --git a/src/main/ts/jcef/controllers/git/chat.ts b/src/main/ts/jcef/controllers/git/chat.ts index ed1e62ef..933eba4e 100644 --- a/src/main/ts/jcef/controllers/git/chat.ts +++ b/src/main/ts/jcef/controllers/git/chat.ts @@ -40,6 +40,10 @@ return pane; } + CC.gitChatRows = function (): HTMLElement | null { + return rowsEl; + }; + CC.gitChatActive = function (): boolean { return ( typeof D.gitSubView === 'function' && diff --git a/src/main/ts/jcef/controllers/permissions/permissions.ts b/src/main/ts/jcef/controllers/permissions/permissions.ts index 6e6545ea..7ae671f7 100644 --- a/src/main/ts/jcef/controllers/permissions/permissions.ts +++ b/src/main/ts/jcef/controllers/permissions/permissions.ts @@ -74,6 +74,7 @@ for (let m = 0; m < ordered.length; m++) { if (region.children[m] !== ordered[m]) region.insertBefore(ordered[m], region.children[m] || null); } + if (typeof CC.refreshFold === 'function') CC.refreshFold(); } cc.permissions = permissions; diff --git a/src/main/ts/jcef/models/composer/history.ts b/src/main/ts/jcef/models/composer/history.ts new file mode 100644 index 00000000..5ac5f373 --- /dev/null +++ b/src/main/ts/jcef/models/composer/history.ts @@ -0,0 +1,32 @@ +(function () { + 'use strict'; + + const CC = (window.CC = window.CC || ({} as CcShared)); + const CX = (CC.composer = CC.composer || ({} as ComposerNs)); + + function withoutRepeats(prompts: string[]): string[] { + return prompts.filter(function (p, i) { + return p !== '' && p !== prompts[i - 1]; + }); + } + + CX.historyStep = function ( + nav: HistoryNav, + prompts: string[], + dir: number, + current: string + ): HistoryMove | null { + const list = withoutRepeats(prompts); + const at = nav.index >= 0 && list[nav.index] === current ? nav.index : list.length; + const draft = nav.index < 0 ? current : nav.draft; + if (dir < 0) { + const index = at - 1; + if (index < 0) return null; + return { nav: { index: index, draft: draft }, text: list[index] }; + } + if (nav.index < 0) return null; + const index = at + 1; + if (index >= list.length) return { nav: { index: -1, draft: '' }, text: nav.draft }; + return { nav: { index: index, draft: nav.draft }, text: list[index] }; + }; +})(); diff --git a/src/main/ts/jcef/types/composer.d.ts b/src/main/ts/jcef/types/composer.d.ts index 43f52462..bd1a7e34 100644 --- a/src/main/ts/jcef/types/composer.d.ts +++ b/src/main/ts/jcef/types/composer.d.ts @@ -314,6 +314,16 @@ interface WiredEl extends HTMLElement { __wired?: boolean; } +interface HistoryNav { + index: number; + draft: string; +} + +interface HistoryMove { + nav: HistoryNav; + text: string; +} + interface ComposerNs { h(tag: string, props?: HProps | null, ...children: Child[]): HTMLElement; send(obj: unknown): void; @@ -377,6 +387,9 @@ interface ComposerNs { applyFollow(): void; sendGlyph(): string; wireInput(input: HTMLTextAreaElement): void; + historyStep(nav: HistoryNav, prompts: string[], dir: number, current: string): HistoryMove | null; + wireHistory(input: HTMLTextAreaElement): void; + renderFold(s: ComposerState): void; onSendClick(e: Event): void; setGhost(text: string): void; renderGhost(): void; diff --git a/src/main/ts/jcef/types/core.d.ts b/src/main/ts/jcef/types/core.d.ts index 6f1603cd..ab8ea389 100644 --- a/src/main/ts/jcef/types/core.d.ts +++ b/src/main/ts/jcef/types/core.d.ts @@ -134,6 +134,8 @@ interface CcShared { dash: DashNs; tabbar: TabbarNs; gitChatActive?(): boolean; + gitChatRows?(): HTMLElement | null; + refreshFold?(): void; [name: string]: unknown; } diff --git a/src/main/ts/jcef/types/permissions.d.ts b/src/main/ts/jcef/types/permissions.d.ts index 8ef048ae..7d40b10d 100644 --- a/src/main/ts/jcef/types/permissions.d.ts +++ b/src/main/ts/jcef/types/permissions.d.ts @@ -66,6 +66,8 @@ interface PermissionsNs { sendFor(card: PermissionCard, obj: Record): void; isHttpUrl(u: unknown): boolean; button(props: HProps, onClick: () => void): HTMLElement; + answer(props: HProps, onClick: () => void): HTMLElement; + answered(el: Element): boolean; buildQuestionCard(card: PermissionCard): HTMLElement; buildElicitCard(card: PermissionCard): HTMLElement; buildPlanCard(card: PermissionCard): HTMLElement; diff --git a/src/main/ts/jcef/views/permissions/base.ts b/src/main/ts/jcef/views/permissions/base.ts index 4ebf0c7f..6f671903 100644 --- a/src/main/ts/jcef/views/permissions/base.ts +++ b/src/main/ts/jcef/views/permissions/base.ts @@ -39,4 +39,25 @@ if (Object.prototype.hasOwnProperty.call(props, key)) withClick[key] = props[key]; return CC.h('button', withClick); }; + + PM.answered = function (el: Element): boolean { + const card = el.closest('.perm-card'); + return !!card && card.classList.contains('answered'); + }; + + PM.answer = function (props: HProps, onClick: () => void): HTMLElement { + const el = PM.button(props, function () { + if (PM.answered(el) || (el as HTMLButtonElement).disabled) return; + const card = el.closest('.perm-card'); + if (card) { + card.classList.add('answered'); + card.querySelectorAll('.perm-answer').forEach(function (b) { + b.disabled = true; + }); + } + onClick(); + }); + el.classList.add('perm-answer'); + return el; + }; })(); diff --git a/src/main/ts/jcef/views/permissions/cards/elicit.ts b/src/main/ts/jcef/views/permissions/cards/elicit.ts index 9618336c..f886e687 100644 --- a/src/main/ts/jcef/views/permissions/cards/elicit.ts +++ b/src/main/ts/jcef/views/permissions/cards/elicit.ts @@ -93,7 +93,7 @@ function refreshAcceptState(): void { if (!acceptBtn) return; - const ok = !isForm || requiredSatisfied(); + const ok = !PM.answered(acceptBtn) && (!isForm || requiredSatisfied()); acceptBtn.disabled = !ok; if (ok) acceptBtn.removeAttribute('disabled'); else acceptBtn.setAttribute('disabled', ''); @@ -126,7 +126,7 @@ const serverName = e.serverName != null ? String(e.serverName) : card.title || 'Server'; const message = e.message != null ? String(e.message) : card.summary || ''; - acceptBtn = PM.button({ class: 'btn primary', text: 'Accept' }, function () { + acceptBtn = PM.answer({ class: 'btn primary', text: 'Accept' }, function () { if (acceptBtn && !acceptBtn.disabled) resolve('accept'); }) as HTMLButtonElement; @@ -145,10 +145,10 @@ 'div', { class: 'perm-actions' }, acceptBtn, - PM.button({ class: 'btn ghost', text: 'Decline' }, function () { + PM.answer({ class: 'btn ghost', text: 'Decline' }, function () { resolve('decline'); }), - PM.button({ class: 'btn ghost', text: 'Cancel' }, function () { + PM.answer({ class: 'btn ghost', text: 'Cancel' }, function () { resolve('cancel'); }) ) diff --git a/src/main/ts/jcef/views/permissions/cards/question.ts b/src/main/ts/jcef/views/permissions/cards/question.ts index e0640a73..84d3e3a5 100644 --- a/src/main/ts/jcef/views/permissions/cards/question.ts +++ b/src/main/ts/jcef/views/permissions/cards/question.ts @@ -6,10 +6,63 @@ const h = CC.h; + const OTHER = 'Other'; + + function isOther(label: string): boolean { + return label.trim().toLowerCase() === OTHER.toLowerCase(); + } + PM.buildQuestionCard = function (card: PermissionCard): HTMLElement { const id = card.id; const questions = Array.isArray(card.questions) ? card.questions : []; const selections: Record = {}; + const otherInputs: Record = {}; + + function toggle(qText: string, label: string, multi: boolean): void { + const arr = selections[qText]; + if (multi) { + const i = arr.indexOf(label); + if (i >= 0) arr.splice(i, 1); + else arr.push(label); + } else { + selections[qText] = [label]; + } + syncSelected(); + refreshSubmit(); + if (label === OTHER && selections[qText].indexOf(OTHER) >= 0) otherInputs[qText].focus(); + } + + function optionButton(qText: string, label: string, desc: string, preview: string, multi: boolean) { + const props: HProps = { + class: 'q-option', + attrs: { type: 'button' }, + on: { + click: function () { + toggle(qText, label, multi); + }, + }, + }; + if (preview) props.title = preview; + const el = h( + 'button', + props, + h('span', { class: 'q-option-label', text: label }), + desc ? h('div', { class: 'q-desc', text: desc }) : null + ) as QuestionOptionEl; + el.__qText = qText; + el.__label = label; + return el; + } + + function otherInput(qText: string): HTMLInputElement { + const input = h('input', { + class: 'q-other-input', + attrs: { type: 'text', placeholder: 'Type your own answer', hidden: 'hidden' }, + }) as HTMLInputElement; + input.addEventListener('input', refreshSubmit); + otherInputs[qText] = input; + return input; + } const qBlocks = questions.map(function (q) { const qText = q && q.question != null ? String(q.question) : ''; @@ -17,48 +70,55 @@ const options = q && Array.isArray(q.options) ? q.options : []; if (!(qText in selections)) selections[qText] = []; - const optionEls = options.map(function (opt) { - const label = opt && opt.label != null ? String(opt.label) : ''; - const desc = opt && opt.description != null ? String(opt.description) : ''; - const preview = opt && opt.preview != null ? String(opt.preview) : ''; - - const children = [h('span', { class: 'q-option-label', text: label })]; - if (desc) children.push(h('div', { class: 'q-desc', text: desc })); - - const props: HProps = { - class: 'q-option', - attrs: { type: 'button' }, - on: { - click: function () { - const arr = selections[qText]; - if (multi) { - const i = arr.indexOf(label); - if (i >= 0) arr.splice(i, 1); - else arr.push(label); - } else { - selections[qText] = [label]; - } - syncSelected(); - }, - }, - }; - if (preview) props.title = preview; - - const el = h('button', props, children[0], children[1] || null) as QuestionOptionEl; - el.__qText = qText; - el.__label = label; - return el; - }); + const optionEls = options + .map(function (opt) { + const label = opt && opt.label != null ? String(opt.label) : ''; + if (isOther(label)) return null; + const desc = opt && opt.description != null ? String(opt.description) : ''; + const preview = opt && opt.preview != null ? String(opt.preview) : ''; + return optionButton(qText, label, desc, preview, multi); + }) + .filter(Boolean) as HTMLElement[]; + optionEls.push(optionButton(qText, OTHER, 'None of these: give your own answer', '', multi)); return h( 'div', { class: 'q-block' }, q && q.header ? h('div', { class: 'q-header', text: String(q.header) }) : null, h('div', { class: 'q-question', text: qText }), - h('div', { class: 'q-options' }, optionEls) + h('div', { class: 'q-options' }, optionEls), + otherInput(qText) ); }); + function answerFor(qText: string): string { + const typed = otherInputs[qText] ? otherInputs[qText].value.trim() : ''; + return selections[qText] + .map(function (label) { + return label === OTHER ? typed : label; + }) + .filter(Boolean) + .join(', '); + } + + function complete(): boolean { + return Object.keys(selections).every(function (qText) { + return answerFor(qText) !== ''; + }); + } + + const submit = PM.answer({ class: 'btn primary', text: 'Submit' }, function () { + const answers: Record = {}; + Object.keys(selections).forEach(function (qText) { + answers[qText] = answerFor(qText); + }); + PM.sendFor(card, { type: 'resolveQuestion', id: id, answers: answers }); + }) as HTMLButtonElement; + + function refreshSubmit(): void { + submit.disabled = PM.answered(submit) || !complete(); + } + const root = h( 'div', { class: 'perm-card q-card' }, @@ -67,14 +127,8 @@ h( 'div', { class: 'perm-actions' }, - PM.button({ class: 'btn primary', text: 'Submit' }, function () { - const answers: Record = {}; - Object.keys(selections).forEach(function (qText) { - answers[qText] = selections[qText].join(', '); - }); - PM.sendFor(card, { type: 'resolveQuestion', id: id, answers: answers }); - }), - PM.button({ class: 'btn ghost', text: 'Cancel' }, function () { + submit, + PM.answer({ class: 'btn ghost', text: 'Cancel' }, function () { PM.sendFor(card, { type: 'resolvePermission', id: id, allow: false }); }) ) @@ -88,8 +142,13 @@ if (el.__label != null && arr.indexOf(el.__label) >= 0) el.classList.add('selected'); else el.classList.remove('selected'); } + Object.keys(otherInputs).forEach(function (qText) { + if (selections[qText].indexOf(OTHER) >= 0) otherInputs[qText].removeAttribute('hidden'); + else otherInputs[qText].setAttribute('hidden', 'hidden'); + }); } syncSelected(); + refreshSubmit(); return root; }; })(); diff --git a/src/main/ts/jcef/views/permissions/cards/tool.ts b/src/main/ts/jcef/views/permissions/cards/tool.ts index 09dacdb5..bc892659 100644 --- a/src/main/ts/jcef/views/permissions/cards/tool.ts +++ b/src/main/ts/jcef/views/permissions/cards/tool.ts @@ -18,10 +18,10 @@ h( 'div', { class: 'perm-actions' }, - PM.button({ class: 'btn primary', text: 'Approve plan' }, function () { + PM.answer({ class: 'btn primary', text: 'Approve plan' }, function () { PM.sendFor(card, { type: 'resolvePermission', id: id, allow: true }); }), - PM.button({ class: 'btn ghost', text: 'Keep planning' }, function () { + PM.answer({ class: 'btn ghost', text: 'Keep planning' }, function () { PM.sendFor(card, { type: 'resolvePermission', id: id, allow: false }); }) ) @@ -101,10 +101,10 @@ } const actions = [ - PM.button({ class: 'btn primary', text: 'Accept' }, function () { + PM.answer({ class: 'btn primary', text: 'Accept' }, function () { PM.sendFor(card, { type: 'resolvePermission', id: id, allow: true }); }), - PM.button({ class: 'btn danger', text: 'Reject' }, function () { + PM.answer({ class: 'btn danger', text: 'Reject' }, function () { PM.sendFor(card, { type: 'resolvePermission', id: id, allow: false }); }), ]; @@ -117,13 +117,13 @@ } if (card.guard) { actions.push( - PM.button({ class: 'btn ghost perm-always', text: 'Always allow this command' }, function () { + PM.answer({ class: 'btn ghost perm-always', text: 'Always allow this command' }, function () { PM.sendFor(card, { type: 'guardAllowAlways', id: id }); }) ); } else if (tool) { actions.push( - PM.button({ class: 'btn ghost perm-always', text: 'Always allow' }, function () { + PM.answer({ class: 'btn ghost perm-always', text: 'Always allow' }, function () { PM.sendFor(card, { type: 'alwaysAllow', tool: tool, id: id }); }) ); diff --git a/src/test/frontend/chat/user-row-colour.test.js b/src/test/frontend/chat/user-row-colour.test.js new file mode 100644 index 00000000..9d1971fc --- /dev/null +++ b/src/test/frontend/chat/user-row-colour.test.js @@ -0,0 +1,38 @@ +const { loadFrontend, readCss } = require('../helpers/load'); + +describe('transcript — the user row stands out by colour and a left bar, not by a card', () => { + const css = readCss().replace(/\/\*[\s\S]*?\*\//g, ''); + const rule = (selector) => { + const at = css.indexOf(`\n${selector} {`); + expect(at).toBeGreaterThan(-1); + return css.slice(at, css.indexOf('}', at)); + }; + + it('marks the row with an accent bar on its left edge', () => { + expect(rule('.msg.user')).toMatch(/border-left:[^;]*var\(--accent\)/); + }); + + it('adds no other chrome around the row, so it stays a row', () => { + const row = rule('.msg.user'); + expect(row).not.toMatch(/(^|[\s;{])border:/); + expect(row).not.toMatch(/background/); + expect(row).not.toMatch(/box-shadow/); + }); + + it('tints the prompt text from the theme accent and text, so it follows light and dark themes', () => { + expect(rule('.msg.user .body')).toMatch( + /(^|[\s;{])color:\s*color-mix\([^;]*var\(--accent\)[^;]*var\(--text\)/ + ); + }); + + it('tints the You label with the accent', () => { + expect(rule('.msg.user .msg-head .name')).toMatch(/(^|[\s;{])color:\s*var\(--accent\)/); + }); + + it('applies to the row the transcript actually renders for a user prompt', () => { + const win = loadFrontend(['app-transcript.js']); + win.cc.batch([{ id: 1, order: 0, speaker: 'USER', text: 'hello', state: 'FINISHED', elapsed: 0 }]); + expect(win.document.querySelector('.msg.user .msg-head .name')).not.toBeNull(); + expect(win.document.querySelector('.msg.user .body')).not.toBeNull(); + }); +}); diff --git a/src/test/frontend/composer/composer-fold.test.js b/src/test/frontend/composer/composer-fold.test.js new file mode 100644 index 00000000..31ab7340 --- /dev/null +++ b/src/test/frontend/composer/composer-fold.test.js @@ -0,0 +1,179 @@ +const { loadFrontend, readCss } = require('../helpers/load'); + +function key(win, target, name, init = {}) { + const ev = new win.KeyboardEvent( + 'keydown', + Object.assign({ key: name, bubbles: true, cancelable: true }, init) + ); + target.dispatchEvent(ev); + return ev; +} + +function setup(state = {}) { + const win = loadFrontend(['app-composer.js', 'app-transcript.js', 'app-permissions.js'], { vendor: false }); + win.cc.state(Object.assign({ running: true, starting: false, queue: [] }, state)); + const dock = win.document.getElementById('dock'); + const strip = dock.querySelector('.dock-fold'); + const toggle = strip && strip.querySelector('.dock-fold-btn'); + const label = strip && strip.querySelector('.dock-fold-phase'); + const input = win.CC.els.composer.querySelector('.composer-input'); + return { win, dock, strip, toggle, label, input }; +} + +describe('composer fold — the input area can be hidden to give the transcript the room', () => { + it('puts a toggle at the top of the dock, expanded to begin with', () => { + const { dock, strip, toggle } = setup(); + expect(dock.firstElementChild).toBe(strip); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(toggle.getAttribute('aria-controls')).toBe('composer'); + }); + + it('folds and unfolds on the toggle', () => { + const { dock, toggle } = setup(); + toggle.click(); + expect(dock.classList.contains('folded')).toBe(true); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + toggle.click(); + expect(dock.classList.contains('folded')).toBe(false); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + }); + + it('hides the composer and its controls, never the permission cards', () => { + const css = readCss().replace(/\/\*[\s\S]*?\*\//g, ''); + const at = css.indexOf('#dock.folded #composer'); + expect(at).toBeGreaterThan(-1); + const rule = css.slice(at, css.indexOf('}', at)); + expect(rule).toMatch(/#dock\.folded #controls/); + expect(rule).toMatch(/display:\s*none/); + expect(css).not.toMatch(/#dock\.folded #permissions/); + }); + + it('names no palette rule: the palette lives inside the composer it folds with', () => { + const { win } = setup(); + expect(win.CC.els.composer.querySelector('#palette')).not.toBeNull(); + expect(readCss()).not.toMatch(/#dock\.folded #palette/); + }); + + it('tells the turn phase while folded', () => { + const { win, toggle, label } = setup(); + toggle.click(); + expect(label.textContent).toBe('Ready'); + win.cc.state({ running: true, starting: false, queue: [], turnActive: true }); + expect(label.textContent).toBe('Claude is working…'); + win.cc.state({ running: true, starting: false, queue: [], turnActive: true, interrupting: true }); + expect(label.textContent).toBe('Stopping…'); + }); + + it('says a card is waiting for an answer', () => { + const { win, toggle, label } = setup(); + toggle.click(); + win.cc.permissions([ + { id: 'q1', title: 'Question', questions: [{ question: 'Which?', options: [{ label: 'A' }] }] }, + ]); + win.cc.state({ running: true, starting: false, queue: [], turnActive: true }); + expect(label.textContent).toBe('Waiting for your answer'); + }); + + it('folds on Ctrl+Shift+ArrowDown and unfolds on Ctrl+Shift+ArrowUp, back in the input', () => { + const { win, dock, input } = setup(); + input.focus(); + expect(key(win, input, 'ArrowDown', { ctrlKey: true, shiftKey: true }).defaultPrevented).toBe(true); + expect(dock.classList.contains('folded')).toBe(true); + key(win, win.document.body, 'ArrowUp', { ctrlKey: true, shiftKey: true }); + expect(dock.classList.contains('folded')).toBe(false); + expect(win.document.activeElement).toBe(input); + }); + + it('moves the focus to the toggle when the input it was in is folded away', () => { + const { win, input, toggle } = setup(); + input.focus(); + key(win, input, 'ArrowDown', { ctrlKey: true, shiftKey: true }); + expect(win.document.activeElement).toBe(toggle); + }); + + it('unfolds and focuses the input when the user starts typing while folded', () => { + const { win, dock, toggle, input } = setup(); + toggle.click(); + key(win, win.document.body, 'h'); + expect(dock.classList.contains('folded')).toBe(false); + expect(win.document.activeElement).toBe(input); + }); + + it('leaves a plain ArrowUp to scroll the transcript while folded', () => { + const { win, dock, toggle } = setup(); + toggle.click(); + key(win, win.document.body, 'ArrowUp'); + expect(dock.classList.contains('folded')).toBe(true); + }); + + it('unfolds when the user types with the focus in the transcript', () => { + const { win, dock, toggle, input } = setup(); + toggle.click(); + const row = win.document.createElement('div'); + win.document.getElementById('conversation').appendChild(row); + key(win, row, 'h'); + expect(dock.classList.contains('folded')).toBe(false); + expect(win.document.activeElement).toBe(input); + }); + + it('leaves Space on a card option to choose it, and stays folded', () => { + const { win, dock, toggle } = setup(); + win.cc.permissions([ + { id: 'q1', title: 'Question', questions: [{ question: 'Which?', options: [{ label: 'A' }] }] }, + ]); + toggle.click(); + const option = win.CC.els.permissions.querySelector('.q-option'); + option.focus(); + const ev = key(win, option, ' '); + expect(ev.defaultPrevented).toBe(false); + expect(dock.classList.contains('folded')).toBe(true); + }); + + it('tells a card is waiting as soon as it arrives, and Ready when it is gone', () => { + const { win, toggle, label } = setup(); + toggle.click(); + win.cc.permissions([ + { id: 'q1', title: 'Question', questions: [{ question: 'Which?', options: [{ label: 'A' }] }] }, + ]); + expect(label.textContent).toBe('Waiting for your answer'); + win.cc.permissions([]); + expect(label.textContent).toBe('Ready'); + }); + + it('moves the focus to the toggle when any control it was on is folded away', () => { + const { win, dock, toggle } = setup(); + const control = dock.querySelector('#controls button, #composer button:not(.dock-fold-btn)'); + control.focus(); + expect(win.document.activeElement).toBe(control); + key(win, control, 'ArrowDown', { ctrlKey: true, shiftKey: true }); + expect(win.document.activeElement).toBe(toggle); + }); + + it('hands the focus to the input when the strip it was on disappears', () => { + const { win, toggle, input } = setup(); + toggle.click(); + toggle.focus(); + win.cc.state({ running: false, starting: true, queue: [] }); + expect(win.document.activeElement).toBe(input); + }); + + it('leaves keys typed into another field alone', () => { + const { win, dock, toggle } = setup(); + toggle.click(); + const other = win.document.createElement('input'); + win.document.body.appendChild(other); + other.focus(); + key(win, other, 'h'); + expect(dock.classList.contains('folded')).toBe(true); + }); + + it('offers no fold while the session is not running, and unfolds when it stops', () => { + const { win, dock, toggle, strip } = setup(); + toggle.click(); + win.cc.state({ running: false, starting: true, queue: [] }); + expect(dock.classList.contains('folded')).toBe(false); + expect(strip.hasAttribute('hidden')).toBe(true); + key(win, win.document.body, 'ArrowDown', { ctrlKey: true, shiftKey: true }); + expect(dock.classList.contains('folded')).toBe(false); + }); +}); diff --git a/src/test/frontend/composer/prompt-history.test.js b/src/test/frontend/composer/prompt-history.test.js new file mode 100644 index 00000000..75252bb2 --- /dev/null +++ b/src/test/frontend/composer/prompt-history.test.js @@ -0,0 +1,148 @@ +const { loadFrontend } = require('../helpers/load'); + +function key(win, target, name, init = {}) { + const ev = new win.KeyboardEvent( + 'keydown', + Object.assign({ key: name, bubbles: true, cancelable: true }, init) + ); + target.dispatchEvent(ev); + return ev; +} + +function setup(prompts) { + const win = loadFrontend(['app-composer.js', 'app-transcript.js'], { vendor: false }); + win.cc.state({ running: true, starting: false, queue: [] }); + win.cc.batch( + prompts.map((text, i) => ({ id: i + 1, order: i, speaker: 'USER', text, state: 'FINISHED', elapsed: 0 })) + ); + const input = win.CC.els.composer.querySelector('.composer-input'); + input.focus(); + return { win, input }; +} + +describe('prompt history — the arrows recall what this tab already sent', () => { + it('walks back from the newest prompt with ArrowUp and stops at the oldest', () => { + const { win, input } = setup(['first', 'second']); + expect(key(win, input, 'ArrowUp').defaultPrevented).toBe(true); + expect(input.value).toBe('second'); + key(win, input, 'ArrowUp'); + expect(input.value).toBe('first'); + key(win, input, 'ArrowUp'); + expect(input.value).toBe('first'); + }); + + it('walks forward with ArrowDown and gives back the draft past the newest', () => { + const { win, input } = setup(['first', 'second']); + input.value = 'half-written'; + input.setSelectionRange(0, 0); + key(win, input, 'ArrowUp'); + key(win, input, 'ArrowUp'); + key(win, input, 'ArrowDown'); + expect(input.value).toBe('second'); + key(win, input, 'ArrowDown'); + expect(input.value).toBe('half-written'); + }); + + it('leaves ArrowDown alone when no prompt is being recalled', () => { + const { win, input } = setup(['first']); + expect(key(win, input, 'ArrowDown').defaultPrevented).toBe(false); + expect(input.value).toBe(''); + }); + + it('moves the caret inside a multi-line prompt instead of recalling', () => { + const { win, input } = setup(['first']); + input.value = 'line one\nline two'; + input.setSelectionRange(input.value.length, input.value.length); + expect(key(win, input, 'ArrowUp').defaultPrevented).toBe(false); + expect(input.value).toBe('line one\nline two'); + }); + + it('recalls the raw text the user typed, not its rendered markdown', () => { + const { win, input } = setup(['**bold** and `code`']); + key(win, input, 'ArrowUp'); + expect(input.value).toBe('**bold** and `code`'); + }); + + it('skips a prompt repeated back to back', () => { + const { win, input } = setup(['a', 'b', 'b']); + key(win, input, 'ArrowUp'); + key(win, input, 'ArrowUp'); + expect(input.value).toBe('a'); + }); + + it('starts again from the newest once the recalled text has been edited', () => { + const { win, input } = setup(['first', 'second']); + key(win, input, 'ArrowUp'); + key(win, input, 'ArrowUp'); + input.value = 'first, amended'; + input.setSelectionRange(0, 0); + key(win, input, 'ArrowUp'); + expect(input.value).toBe('second'); + key(win, input, 'ArrowDown'); + expect(input.value).toBe('first, amended'); + }); + + it('ignores the arrows with a modifier held', () => { + const { win, input } = setup(['first']); + expect(key(win, input, 'ArrowUp', { shiftKey: true }).defaultPrevented).toBe(false); + expect(key(win, input, 'ArrowUp', { altKey: true }).defaultPrevented).toBe(false); + expect(input.value).toBe(''); + }); + + it('never recalls "undefined" after the transcript is rebuilt shorter', () => { + const { win, input } = setup(['a', 'b', 'c']); + key(win, input, 'ArrowUp'); + key(win, input, 'ArrowUp'); + key(win, input, 'ArrowUp'); + win.cc.clear(); + win.cc.batch([{ id: 9, order: 0, speaker: 'USER', text: 'x', state: 'FINISHED', elapsed: 0 }]); + key(win, input, 'ArrowUp'); + expect(input.value).toBe('x'); + key(win, input, 'ArrowDown'); + expect(input.value).not.toBe('undefined'); + }); + + it('moves the caret up a wrapped line instead of recalling when it is not at the start', () => { + const { win, input } = setup(['first']); + input.value = 'a single line long enough to wrap'; + input.setSelectionRange(10, 10); + expect(key(win, input, 'ArrowUp').defaultPrevented).toBe(false); + expect(input.value).toBe('a single line long enough to wrap'); + }); + + it('recalls the Git chat prompts while the Git chat is shown', () => { + const { win, input } = setup(['main chat prompt']); + const rows = win.document.createElement('div'); + rows.innerHTML = '
'; + rows.querySelector('.body').__rawText = 'git chat prompt'; + const saved = { active: win.CC.gitChatActive, rows: win.CC.gitChatRows }; + try { + win.CC.gitChatActive = () => true; + win.CC.gitChatRows = () => rows; + key(win, input, 'ArrowUp'); + expect(input.value).toBe('git chat prompt'); + } finally { + win.CC.gitChatActive = saved.active; + win.CC.gitChatRows = saved.rows; + } + }); + + it('repaints the ghost suggestion after a recall', () => { + const { win, input } = setup(['first']); + const saved = win.CC.composer.renderGhost; + let painted = 0; + try { + win.CC.composer.renderGhost = () => painted++; + key(win, input, 'ArrowUp'); + expect(painted).toBe(1); + } finally { + win.CC.composer.renderGhost = saved; + } + }); + + it('does nothing when the tab has sent nothing yet', () => { + const { win, input } = setup([]); + expect(key(win, input, 'ArrowUp').defaultPrevented).toBe(false); + expect(input.value).toBe(''); + }); +}); diff --git a/src/test/frontend/git/git-map.test.js b/src/test/frontend/git/git-map.test.js index c1b60dfc..d5113577 100644 --- a/src/test/frontend/git/git-map.test.js +++ b/src/test/frontend/git/git-map.test.js @@ -11,7 +11,7 @@ const commit = (hash, subject, parents) => ({ hash: hash, short: hash.slice(0, 7), subject: subject, - author: 'Lain', + author: 'Lycogrypho', ageMillis: 3600 * 1000, files: 2, parents: parents, diff --git a/src/test/frontend/git/git-view.test.js b/src/test/frontend/git/git-view.test.js index b8a0cc52..c922e4ec 100644 --- a/src/test/frontend/git/git-view.test.js +++ b/src/test/frontend/git/git-view.test.js @@ -14,7 +14,7 @@ const GIT = { hash: '8933592ffee1', short: '8933592', subject: 'chore: invert .gitignore into an allowlist', - author: 'Lain', + author: 'Lycogrypho', authoredAtMillis: Date.now() - 3 * 3600 * 1000, files: 3, }, @@ -22,7 +22,7 @@ const GIT = { hash: '6f781c5aabb2', short: '6f781c5', subject: "feat(session): review the whole session's changes", - author: 'Lain', + author: 'Lycogrypho', authoredAtMillis: Date.now() - 5 * 86400 * 1000, files: 12, }, @@ -141,7 +141,7 @@ describe('git view', () => { expect(first.querySelector('.git-subject').textContent).toBe( 'chore: invert .gitignore into an allowlist' ); - expect(first.querySelector('.git-meta').textContent).toBe('Lain · 3h ago · 3 files'); + expect(first.querySelector('.git-meta').textContent).toBe('Lycogrypho · 3h ago · 3 files'); }); it('every commit row carries one button per action the payload offers', () => { diff --git a/src/test/frontend/permissions/answer-once.test.js b/src/test/frontend/permissions/answer-once.test.js new file mode 100644 index 00000000..9ae34122 --- /dev/null +++ b/src/test/frontend/permissions/answer-once.test.js @@ -0,0 +1,87 @@ +const { loadFrontend } = require('../helpers/load'); + +const permCard = { + id: 'p1', + tool: 'Edit', + title: 'Edit', + summary: 'Edit a.txt', + reviewable: true, + diff: '@@\n-a\n+b', +}; + +const questionCard = { + id: 'q1', + title: 'Question', + questions: [ + { question: 'First?', options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'Second?', options: [{ label: 'C' }] }, + ], +}; + +const mount = (cards) => { + const win = loadFrontend(['app-permissions.js']); + const sent = []; + win.CC.send = (m) => sent.push(m); + win.cc.permissions(cards); + return { win, sent }; +}; + +const button = (win, text) => + [...win.CC.els.permissions.querySelectorAll('button')].find((b) => b.textContent.trim() === text); + +const clickOption = (win, label) => + [...win.CC.els.permissions.querySelectorAll('.q-option')] + .find((el) => el.querySelector('.q-option-label').textContent === label) + .click(); + +describe('a permission card is answered once', () => { + it('sends one resolvePermission however often Accept is clicked', () => { + const { win, sent } = mount([permCard]); + button(win, 'Accept').click(); + button(win, 'Accept').click(); + button(win, 'Reject').click(); + expect(sent.filter((m) => m.type === 'resolvePermission')).toEqual([ + { type: 'resolvePermission', id: 'p1', allow: true }, + ]); + expect(button(win, 'Reject').disabled).toBe(true); + }); + + it('keeps View diff usable, since it does not answer the card', () => { + const { win, sent } = mount([permCard]); + button(win, 'View diff').click(); + button(win, 'View diff').click(); + expect(sent.filter((m) => m.type === 'viewDiff').length).toBe(2); + expect(button(win, 'Accept').disabled).toBe(false); + }); +}); + +describe('a question card is submitted once, and never empty', () => { + it('keeps Submit disabled until every question has an answer', () => { + const { win } = mount([questionCard]); + expect(button(win, 'Submit').disabled).toBe(true); + clickOption(win, 'A'); + expect(button(win, 'Submit').disabled).toBe(true); + clickOption(win, 'C'); + expect(button(win, 'Submit').disabled).toBe(false); + }); + + it('keeps Submit disabled while Other is chosen with nothing typed', () => { + const { win } = mount([questionCard]); + clickOption(win, 'C'); + clickOption(win, 'Other'); + const input = win.CC.els.permissions.querySelector('.q-other-input'); + expect(button(win, 'Submit').disabled).toBe(true); + input.value = 'mine'; + input.dispatchEvent(new win.Event('input')); + expect(button(win, 'Submit').disabled).toBe(false); + }); + + it('sends one resolveQuestion on a double click', () => { + const { win, sent } = mount([questionCard]); + clickOption(win, 'B'); + clickOption(win, 'C'); + button(win, 'Submit').click(); + button(win, 'Submit').click(); + expect(sent.filter((m) => m.type === 'resolveQuestion').length).toBe(1); + }); +}); diff --git a/src/test/frontend/permissions/permissions.test.js b/src/test/frontend/permissions/permissions.test.js index 8285aae1..206072b3 100644 --- a/src/test/frontend/permissions/permissions.test.js +++ b/src/test/frontend/permissions/permissions.test.js @@ -27,14 +27,15 @@ describe('permission card — read-only diff, no per-line checkboxes', () => { expect(region.querySelector('input[type="checkbox"]')).toBeNull(); }); - it('Accept / Reject resolve the exact request id', () => { + it.each([ + ['Accept', true], + ['Reject', false], + ])('%s resolves the exact request id', (label, allow) => { const sent = []; win.CC.send = (m) => sent.push(m); const buttons = [...win.CC.els.permissions.querySelectorAll('.btn')]; - buttons.find((b) => b.textContent.trim() === 'Accept').click(); - buttons.find((b) => b.textContent.trim() === 'Reject').click(); - expect(sent).toContainEqual({ type: 'resolvePermission', id: 'req1', allow: true }); - expect(sent).toContainEqual({ type: 'resolvePermission', id: 'req1', allow: false }); + buttons.find((b) => b.textContent.trim() === label).click(); + expect(sent).toContainEqual({ type: 'resolvePermission', id: 'req1', allow }); }); }); diff --git a/src/test/frontend/permissions/question-card.test.js b/src/test/frontend/permissions/question-card.test.js new file mode 100644 index 00000000..07c5ad86 --- /dev/null +++ b/src/test/frontend/permissions/question-card.test.js @@ -0,0 +1,74 @@ +const { loadFrontend } = require('../helpers/load'); + +const questionCard = (multiSelect, options) => ({ + id: 'q1', + title: 'Question', + questions: [{ question: 'Which one?', header: 'Pick', multiSelect, options }], +}); + +const optionLabels = (win) => + [...win.CC.els.permissions.querySelectorAll('.q-option .q-option-label')].map((el) => el.textContent); + +const clickOption = (win, label) => + [...win.CC.els.permissions.querySelectorAll('.q-option')] + .find((el) => el.querySelector('.q-option-label').textContent === label) + .click(); + +const submit = (win) => { + const sent = []; + win.CC.send = (m) => sent.push(m); + [...win.CC.els.permissions.querySelectorAll('.btn')].find((b) => b.textContent.trim() === 'Submit').click(); + return sent; +}; + +describe('question card — the Other answer', () => { + it('always ends with Other, even when the model offered none', () => { + const win = loadFrontend(['app-permissions.js']); + win.cc.permissions([questionCard(false, [{ label: 'A' }, { label: 'B' }])]); + expect(optionLabels(win)).toEqual(['A', 'B', 'Other']); + }); + + it('keeps a single Other when the model already offered one', () => { + const win = loadFrontend(['app-permissions.js']); + win.cc.permissions([questionCard(false, [{ label: 'A' }, { label: 'other' }])]); + expect(optionLabels(win)).toEqual(['A', 'Other']); + }); + + it('shows the text field only while Other is chosen', () => { + const win = loadFrontend(['app-permissions.js']); + win.cc.permissions([questionCard(false, [{ label: 'A' }])]); + const input = win.CC.els.permissions.querySelector('.q-other-input'); + expect(input.hasAttribute('hidden')).toBe(true); + clickOption(win, 'Other'); + expect(input.hasAttribute('hidden')).toBe(false); + clickOption(win, 'A'); + expect(input.hasAttribute('hidden')).toBe(true); + }); + + it('answers with the typed text, not the word Other', () => { + const win = loadFrontend(['app-permissions.js']); + win.cc.permissions([questionCard(false, [{ label: 'A' }])]); + clickOption(win, 'Other'); + const input = win.CC.els.permissions.querySelector('.q-other-input'); + input.value = ' something else '; + input.dispatchEvent(new win.Event('input')); + expect(submit(win)).toContainEqual({ + type: 'resolveQuestion', + id: 'q1', + answers: { 'Which one?': 'something else' }, + }); + }); + + it('joins the typed text with the chosen options on a multi-select', () => { + const win = loadFrontend(['app-permissions.js']); + win.cc.permissions([questionCard(true, [{ label: 'A' }, { label: 'B' }])]); + clickOption(win, 'A'); + clickOption(win, 'Other'); + win.CC.els.permissions.querySelector('.q-other-input').value = 'C'; + expect(submit(win)).toContainEqual({ + type: 'resolveQuestion', + id: 'q1', + answers: { 'Which one?': 'A, C' }, + }); + }); +}); diff --git a/src/test/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettingsContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettingsContractTest.kt new file mode 100644 index 00000000..bfbdbe2b --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettingsContractTest.kt @@ -0,0 +1,41 @@ +package dev.lain.claudejb.controller.bridge + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +class BridgeSettingsContractTest { + + @Test + fun `a settings toggle applies the menu once, inside update`() { + val body = bodyOf("fun write(") + val applies = body.filter { APPLY in it } + assertEquals(1, applies.size) { + "BridgeSettings.write names $APPLY ${applies.size} times. update already applies its block to the live " + + "state and to the store, so a second call runs guard-mode side effects such as " + + "SecuritySuspensions.guardOff once more on the live state." + } + assertTrue(applies.single().contains(UPDATE)) { "$APPLY must run inside `$UPDATE`." } + } + + private fun bodyOf(signature: String): List { + val lines = SOURCE.readLines() + val from = lines.indexOfFirst { it.trimStart().removePrefix("private ").startsWith(signature) } + assertTrue(from >= 0) { "no `$signature` in ${SOURCE.path}" } + val length = lines.drop(from).indexOfFirst { it == " }" } + assertTrue(length > 0) { "`$signature` in ${SOURCE.path} has no closing brace at member level" } + return lines.subList(from, from + length + 1) + } + + private companion object { + const val APPLY = "JcefSettingsMenu.apply(" + const val UPDATE = "settings.update" + + val SOURCE: File = run { + val path = "src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt" + sequenceOf(File(path), File("../$path")).firstOrNull { it.isFile } + ?: error("could not locate $path from ${File("").absolutePath}") + } + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/commands/git/GitContextActionsTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/commands/git/GitContextActionsTest.kt index 5b00fd14..bb66f169 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/commands/git/GitContextActionsTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/commands/git/GitContextActionsTest.kt @@ -48,7 +48,7 @@ class GitContextActionsTest { @Test fun `a commit row is hash, subject, author, age and file count`() { val row = GitContextActions.commitRow(commit(), NOW) - assertEquals("abc1234 Fix the thing · Lain · 3d ago · 2 files", row) + assertEquals("abc1234 Fix the thing · Lycogrypho · 3d ago · 2 files", row) } @Test @@ -159,7 +159,7 @@ class GitContextActionsTest { private fun commit( hash: String = "abc1234def5678", subject: String = "Fix the thing", - authorName: String = "Lain", + authorName: String = "Lycogrypho", authorEmail: String = "lain@example.com", authoredAtMillis: Long = COMMITTED_AT, changedPaths: List = listOf("a.kt", "b.kt"), diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileGlobTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileGlobTest.kt new file mode 100644 index 00000000..a591bbcc --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileGlobTest.kt @@ -0,0 +1,34 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class FileGlobTest { + + @Test + fun `a glob without a directory part matches the file name anywhere`() { + val glob = FileGlob("*.kt") + assertTrue(glob.matches("A.kt")) + assertTrue(glob.matches("src/main/kotlin/A.kt")) + assertFalse(glob.matches("src/main/kotlin/A.java")) + } + + @Test + fun `a glob with a directory part matches the project-relative path`() { + assertTrue(FileGlob("**/*.kt").matches("src/main/kotlin/A.kt")) + assertFalse(FileGlob("**/*.kt").matches("src/main/kotlin/A.java")) + assertTrue(FileGlob("src/*.kt").matches("src/A.kt")) + assertFalse(FileGlob("src/*.kt").matches("src/main/A.kt")) + assertFalse(FileGlob("src/*.kt").matches("other/A.kt")) + assertTrue(FileGlob("src/**/Test?.java").matches("src/test/java/Test1.java")) + } + + @Test + fun `a capped list says truncated only when something was left out`() { + assertEquals(listOf(1, 2) to false, Capped.of(listOf(1, 2), 2)) + assertEquals(listOf(1, 2) to true, Capped.of(listOf(1, 2, 3), 2)) + assertEquals(emptyList() to false, Capped.of(emptyList(), 2)) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadWindowTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadWindowTest.kt new file mode 100644 index 00000000..4cc8c959 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadWindowTest.kt @@ -0,0 +1,43 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import dev.lain.claudejb.model.mcp.toon.Toon +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ReadWindowTest { + + private val lines = (1..100).map { "x".repeat(9) } + + @Test + fun `the window ends at the limit when the budget allows it`() { + assertEquals(30, ReadWindow.end(lines, from = 1, limit = 30, budget = 10_000)) + assertEquals(100, ReadWindow.end(lines, from = 50, limit = 400, budget = 10_000)) + } + + @Test + fun `the window stops at the budget, counting each line as TOON encodes it`() { + assertEquals(10, ReadWindow.end(lines, from = 1, limit = 400, budget = 110)) + assertEquals(19, ReadWindow.end(lines, from = 10, limit = 400, budget = 110)) + val quoted = listOf("\"\"\"\"", "\"\"\"\"", "\"\"\"\"") + assertEquals(2, ReadWindow.end(quoted, from = 1, limit = 400, budget = 20)) + } + + @Test + fun `one line is always returned, however long, so a read never comes back empty`() { + assertEquals(1, ReadWindow.end(listOf("y".repeat(50_000), "z"), from = 1, limit = 400, budget = 100)) + } + + @Test + fun `the cost of a line is what TOON writes for it, so the window and the encoder cannot drift apart`() { + val sample = listOf("plain", "quote \" and \\ backslash", "tab\there", "bell \u0007 and cr \r", "") + val encoded = Toon.encode(buildJsonObject { put("text", sample.joinToString("\n")) }) + assertEquals(encoded.length - "text: ".length, sample.sumOf(ReadWindow::cost)) + } + + @Test + fun `a start past the end returns nothing`() { + assertEquals(100, ReadWindow.end(lines, from = 101, limit = 400, budget = 100)) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestRunChoiceTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestRunChoiceTest.kt new file mode 100644 index 00000000..f60d7454 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestRunChoiceTest.kt @@ -0,0 +1,33 @@ +package dev.lain.claudejb.controller.mcp.tools.run + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class TestRunChoiceTest { + + private data class Offer(val name: String, val gradle: Boolean, val tasks: List = emptyList()) + + private fun pick(vararg offers: Offer): String? = + TestRunChoice.pick(offers.toList(), { it.gradle }, { it.tasks })?.name + + @Test + fun `a framework's own runner wins over Gradle`() { + assertEquals("junit", pick(Offer("drift", true, listOf(":checkDrift")), Offer("junit", false))) + } + + @Test + fun `among Gradle runs the one that runs the test task wins`() { + val drift = Offer("drift", true, listOf(":checkDrift", "--tests", "\"x.Y\"")) + val test = Offer("test", true, listOf(":test", "--tests", "\"x.Y\"")) + assertEquals("test", pick(drift, test)) + assertEquals("test", pick(drift, Offer("test", true, listOf(":module:test")))) + assertEquals("test", pick(drift, Offer("test", true, listOf("test")))) + } + + @Test + fun `with no test task the first Gradle run is kept, and nothing offered gives nothing`() { + assertEquals("drift", pick(Offer("drift", true, listOf(":checkDrift")), Offer("ui", true, listOf(":uiTest")))) + assertNull(pick()) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/process/StdinWriterTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/process/StdinWriterTest.kt new file mode 100644 index 00000000..c1068c7d --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/process/StdinWriterTest.kt @@ -0,0 +1,73 @@ +package dev.lain.claudejb.controller.process + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTimeoutPreemptively +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.OutputStream +import java.time.Duration +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class StdinWriterTest { + + @Test + fun `a pipe that stops draining never blocks the caller`() { + val release = CountDownLatch(1) + val stalled = object : OutputStream() { + override fun write(b: Int) { + release.await() + } + } + val executor = Executors.newSingleThreadExecutor() + val writer = StdinWriter(executor) + + assertTimeoutPreemptively(Duration.ofSeconds(2)) { + repeat(LINES) { writer.enqueue(stalled, "{\"type\":\"control_response\"}") } + } + + release.countDown() + drain(executor) + } + + @Test + fun `lines reach the pipe whole, in order, one per line`() { + val sink = ByteArrayOutputStream() + val executor = Executors.newSingleThreadExecutor() + val writer = StdinWriter(executor) + + listOf("first", "è second", "third").forEach { writer.enqueue(sink, it) } + drain(executor) + + assertEquals("first\nè second\nthird\n", sink.toString(Charsets.UTF_8)) + } + + @Test + fun `a broken pipe does not stop the lines queued after it`() { + val broken = object : OutputStream() { + override fun write(b: Int) = throw IOException("pipe closed") + } + val sink = ByteArrayOutputStream() + val executor = Executors.newSingleThreadExecutor() + val writer = StdinWriter(executor) + + writer.enqueue(broken, "lost") + writer.enqueue(sink, "kept") + drain(executor) + + assertEquals("kept\n", sink.toString(Charsets.UTF_8)) + } + + private fun drain(executor: ExecutorService) { + executor.shutdown() + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)) { "the writer never drained its queue" } + } + + private companion object { + const val LINES = 64 + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/process/StdoutLinesTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/process/StdoutLinesTest.kt new file mode 100644 index 00000000..9255ba57 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/process/StdoutLinesTest.kt @@ -0,0 +1,46 @@ +package dev.lain.claudejb.controller.process + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class StdoutLinesTest { + + @Test + fun `lines split across chunks are joined`() { + val lines = StdoutLines(MAX) + assertEquals(emptyList(), lines.feed("{\"a\":")) + assertEquals(listOf(StdoutLines.Out.Line("{\"a\":1}"), StdoutLines.Out.Line("x")), lines.feed("1}\nx\ny")) + assertEquals("y", lines.finish()) + } + + @Test + fun `a last line without a newline is handed over on finish, once`() { + val lines = StdoutLines(MAX) + lines.feed("{\"type\":\"result\"}") + assertEquals("{\"type\":\"result\"}", lines.finish()) + assertNull(lines.finish()) + } + + @Test + fun `an oversized control request is reported with its id, and its tail is discarded`() { + val lines = StdoutLines(MAX) + val head = "{\"type\":\"control_request\",\"request_id\":\"req-7\",\"request\":{\"x\":\"" + val dropped = lines.feed(head + "z".repeat(MAX)) + assertEquals(listOf(StdoutLines.Out.Dropped(head.length + MAX, "req-7")), dropped) + assertEquals(emptyList(), lines.feed("z".repeat(MAX))) + assertEquals(listOf(StdoutLines.Out.Line("{\"next\":1}")), lines.feed("zz\"}}\n{\"next\":1}\n")) + assertNull(lines.finish()) + } + + @Test + fun `an oversized line that is not a control request carries no id`() { + val lines = StdoutLines(MAX) + val dropped = lines.feed("{\"type\":\"assistant\",\"request_id\":\"no\"," + "z".repeat(MAX)) + assertNull((dropped.single() as StdoutLines.Out.Dropped).controlRequestId) + } + + private companion object { + const val MAX = 64 + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/AdoptSettingsContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/AdoptSettingsContractTest.kt new file mode 100644 index 00000000..e650c490 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/AdoptSettingsContractTest.kt @@ -0,0 +1,46 @@ +package dev.lain.claudejb.controller.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +class AdoptSettingsContractTest { + + @Test + fun `adopt assigns every launch field before it restarts, and restarts once`() { + val body = bodyOf("fun adopt(") + CHANGERS.forEach { changer -> + assertFalse(body.any { changer in it }) { + "SessionLiveSettings.adopt calls $changer, which can restart or write to the process before the new " + + "launch options are assigned, so the pooled spawn reads the previous ones." + } + } + val assign = body.indexOfFirst { ASSIGN in it } + val restarts = body.withIndex().filter { RESTART in it.value } + assertEquals(1, restarts.size) { "adopt must restart at most once, in one place." } + assertTrue(assign in 0 until restarts.single().index) { "adopt restarts before `$ASSIGN`." } + } + + private fun bodyOf(signature: String): List { + val lines = SOURCE.readLines() + val from = lines.indexOfFirst { it.trimStart().startsWith(signature) } + assertTrue(from >= 0) { "no `$signature` in ${SOURCE.path}" } + val length = lines.drop(from).indexOfFirst { it == " }" } + assertTrue(length > 0) { "`$signature` in ${SOURCE.path} has no closing brace at member level" } + return lines.subList(from, from + length + 1) + } + + private companion object { + val CHANGERS = listOf("changeThinkingTokens(", "changeModel(", "changePermissionMode(", "changeEffort(") + const val ASSIGN = "session.launch = " + const val RESTART = "session.restart(" + + val SOURCE: File = run { + val path = "src/main/kotlin/dev/lain/claudejb/controller/session/SessionLiveSettings.kt" + sequenceOf(File(path), File("../$path")).firstOrNull { it.isFile } + ?: error("could not locate $path from ${File("").absolutePath}") + } + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/ProcessSlotTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/ProcessSlotTest.kt new file mode 100644 index 00000000..9b431073 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/ProcessSlotTest.kt @@ -0,0 +1,84 @@ +package dev.lain.claudejb.controller.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ProcessSlotTest { + + @Test + fun `a launch overtaken by a restart cannot claim the slot`() { + val slot = ProcessSlot() + val old = slot.supersede() + val new = slot.supersede() + assertFalse(slot.claim(old, "old")) + assertTrue(slot.claim(new, "new")) + assertSame("new", slot.current) + } + + @Test + fun `releasing a stale launch never drops the current process`() { + val slot = ProcessSlot() + val old = slot.supersede() + assertTrue(slot.claim(old, "old")) + val new = slot.supersede() + assertTrue(slot.claim(new, "new")) + assertTrue(slot.releaseIfStale(old, "old")) + assertSame("new", slot.current) + } + + @Test + fun `a current launch is not released as stale`() { + val slot = ProcessSlot() + val gen = slot.supersede() + slot.claim(gen, "p") + assertFalse(slot.releaseIfStale(gen, "p")) + assertSame("p", slot.current) + } + + @Test + fun `a stale launch that still holds the slot is released`() { + val slot = ProcessSlot() + val gen = slot.supersede() + slot.claim(gen, "p") + slot.supersede() + assertTrue(slot.releaseIfStale(gen, "p")) + assertNull(slot.current) + } + + @Test + fun `releasing a failed start leaves a newer process in place`() { + val slot = ProcessSlot() + slot.claim(slot.supersede(), "failed") + slot.claim(slot.supersede(), "new") + slot.release("failed") + assertSame("new", slot.current) + slot.release("new") + assertNull(slot.current) + } + + @Test + fun `take empties the slot and hands back what it held`() { + val slot = ProcessSlot() + slot.claim(slot.supersede(), "p") + assertEquals("p", slot.take()) + assertNull(slot.current) + assertNull(slot.take()) + } + + @Test + fun `events of a superseded launch are dropped`() { + val slot = ProcessSlot() + val seen = mutableListOf() + val gen = slot.supersede() + val deliver = slot.onlyWhileCurrent(gen) { e: String -> seen += e } + deliver("before") + slot.supersede() + deliver("after") + assertEquals(listOf("before"), seen) + assertFalse(slot.isCurrent(gen)) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/SessionDisposalContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/SessionDisposalContractTest.kt new file mode 100644 index 00000000..a34e973e --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/SessionDisposalContractTest.kt @@ -0,0 +1,30 @@ +package dev.lain.claudejb.controller.session + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +class SessionDisposalContractTest { + + @Test + fun `a closed session is disposed through the Disposer, so its children go with it`() { + val text = SOURCE.readText() + assertFalse(DIRECT.containsMatchIn(text)) { + "ChatSessionManager calls dispose() on a session directly. The message-bus connection ToolEvents opens with " + + "the session as parent is disposed only by Disposer.dispose, so the session and every live tool line " + + "it buffers stay in memory until the project closes." + } + assertTrue(text.contains("Disposer.dispose(")) { "ChatSessionManager no longer disposes its sessions." } + } + + private companion object { + val DIRECT = Regex("""\b(session|it)\.dispose\(\)""") + + val SOURCE: File = run { + val path = "src/main/kotlin/dev/lain/claudejb/controller/session/ChatSessionManager.kt" + sequenceOf(File(path), File("../$path")).firstOrNull { it.isFile } + ?: error("could not locate $path from ${File("").absolutePath}") + } + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/SessionSendContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/SessionSendContractTest.kt new file mode 100644 index 00000000..ba96a4eb --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/SessionSendContractTest.kt @@ -0,0 +1,48 @@ +package dev.lain.claudejb.controller.session + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +class SessionSendContractTest { + + @Test + fun `a prompt sent while the session cannot start is queued, not dropped`() { + val body = bodyOf("fun send(text: String, attachments") + assertFalse(body.any { DROP.containsMatchIn(it) }) { + "ClaudeSession.send returns when start() fails. The tray is already emptied by then, so the prompt and " + + "its attachments are lost silently; queue it, and it goes once the session starts." + } + assertTrue(body.any { ENQUEUE in it }) { "ClaudeSession.send no longer queues the prompt." } + } + + @Test + fun `a side question asked with no running session is never sent as a normal prompt`() { + val body = bodyOf("fun sendSideQuestion(") + assertFalse(body.any { ENQUEUE in it }) { + "ClaudeSession.sendSideQuestion queues the question as a normal prompt, so a /btw aside becomes a turn " + + "of the conversation." + } + } + + private fun bodyOf(signature: String): List { + val lines = SOURCE.readLines() + val from = lines.indexOfFirst { it.trimStart().startsWith(signature) } + assertTrue(from >= 0) { "no `$signature` in ${SOURCE.path}" } + val length = lines.drop(from).indexOfFirst { it == " }" } + assertTrue(length > 0) { "`$signature` in ${SOURCE.path} has no closing brace at member level" } + return lines.subList(from, from + length + 1) + } + + private companion object { + const val ENQUEUE = "prompts.enqueue(" + val DROP = Regex("""start\(\)\)\s*return""") + + val SOURCE: File = run { + val path = "src/main/kotlin/dev/lain/claudejb/controller/session/ClaudeSession.kt" + sequenceOf(File(path), File("../$path")).firstOrNull { it.isFile } + ?: error("could not locate $path from ${File("").absolutePath}") + } + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/StaleResumeTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/StaleResumeTest.kt new file mode 100644 index 00000000..ad1362df --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/StaleResumeTest.kt @@ -0,0 +1,41 @@ +package dev.lain.claudejb.controller.session + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class StaleResumeTest { + + private fun startsFresh( + exitCode: Int = 1, + resumed: Boolean = true, + initialized: Boolean = false, + transcriptExists: Boolean = false, + ): Boolean = StaleResume.startsFresh(exitCode, resumed, initialized) { transcriptExists } + + @Test + fun `a resume whose transcript is gone starts a new conversation`() { + assertTrue(startsFresh()) + } + + @Test + fun `an early failure of a resume whose transcript still exists keeps the conversation`() { + assertFalse(startsFresh(transcriptExists = true)) + } + + @Test + fun `a clean exit, a fresh launch or an initialized session never starts over`() { + assertFalse(startsFresh(exitCode = 0)) + assertFalse(startsFresh(resumed = false)) + assertFalse(startsFresh(initialized = true)) + } + + @Test + fun `the transcript is looked up only when the launch failed early on a resume`() { + var looked = 0 + StaleResume.startsFresh(0, resumed = true, initialized = false) { looked++; false } + StaleResume.startsFresh(1, resumed = false, initialized = false) { looked++; false } + StaleResume.startsFresh(1, resumed = true, initialized = true) { looked++; false } + assertTrue(looked == 0) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/auth/EdtAuthContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/auth/EdtAuthContractTest.kt index d88c0ecc..d5e301a1 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/session/auth/EdtAuthContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/auth/EdtAuthContractTest.kt @@ -18,7 +18,28 @@ class EdtAuthContractTest { assertFalse(body.any { BLOCKING in it }) { "SessionLifecycle.start runs on the EDT and calls AuthGate.$BLOCKING, which resolves an unknown " + "answer by spawning `claude auth status` and waiting for it. That is the UI freeze this " + - "split exists to remove; ask $HELD and treat UNKNOWN as 'neither launch nor prompt'." + "split exists to remove; ask $HELD and hand UNKNOWN to $RESOLVE." + } + } + + @Test + fun `an UNKNOWN answer is resolved off the EDT and then launches, never dropped`() { + val start = bodyOf(SESSION_LIFECYCLE, "fun start(") + assertTrue(start.any { RESOLVE in it }) { + "SessionLifecycle.start no longer hands an UNKNOWN answer to $RESOLVE. Returning on UNKNOWN without " + + "resolving it leaves a source-script user with a tab that never starts: heldCredential answers " + + "UNKNOWN for them on every call." + } + val resolve = bodyOf(AUTH_GATE, "fun $RESOLVE(") + val pooled = resolve.indexOfFirst { POOLED in it } + val blocking = resolve.indexOfFirst { BLOCKING in it } + assertTrue(pooled >= 0 && blocking > pooled) { + "AuthGate.$RESOLVE must ask $BLOCKING inside `$POOLED`: it can source the shell and spawn the binary." + } + val known = bodyOf(SESSION_LIFECYCLE, "fun $KNOWN(") + assertTrue(known.any { LAUNCH in it }) { "$KNOWN no longer launches once the answer is known." } + assertFalse(known.any { REENTRY.containsMatchIn(it) }) { + "$KNOWN goes back through start(), which asks heldCredential again and gets UNKNOWN again." } } @@ -125,6 +146,10 @@ class EdtAuthContractTest { const val LAUNCH_ENV = "resolveEnv()" const val SCRIPT_GUARD = "sourceScript" const val POOLED = "executeOnPooledThread" + const val RESOLVE = "resolveOffEdt" + const val KNOWN = "launchOnceKnown" + const val LAUNCH = "beginLaunch(" + val REENTRY = Regex("""\bstart\(""") const val NULL_BINARY_RETURN = "if (binary == null) return" const val EDT_HOP = "edt {" diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/events/EarlyLinesTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/events/EarlyLinesTest.kt new file mode 100644 index 00000000..c7b2f8ec --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/events/EarlyLinesTest.kt @@ -0,0 +1,28 @@ +package dev.lain.claudejb.controller.session.events + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class EarlyLinesTest { + + @Test + fun `lines that arrive before their tool is known are handed over once`() { + val early = EarlyLines(maxTools = 4) + early.add("t1", "a") + early.add("t1", "b") + assertEquals("a\nb", early.take("t1")?.render()) + assertNull(early.take("t1")) + } + + @Test + fun `another chat's tool ids are bounded, the oldest evicted first`() { + val early = EarlyLines(maxTools = 4) + (1..10).forEach { early.add("foreign-$it", "x") } + assertEquals(4, early.size) + assertNull(early.take("foreign-6")) + assertNotNull(early.take("foreign-7")) + assertNotNull(early.take("foreign-10")) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/headless/HierarchyCallerHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/HierarchyCallerHeadlessTest.kt new file mode 100644 index 00000000..c7d54c1e --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/headless/HierarchyCallerHeadlessTest.kt @@ -0,0 +1,51 @@ +package dev.lain.claudejb.headless + +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiNameIdentifierOwner +import com.intellij.psi.PsiNamedElement +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.controller.mcp.tools.code.HierarchyTools + +class HierarchyCallerHeadlessTest : BasePlatformTestCase() { + + private fun usages(source: String, call: String): List { + val file = myFixture.configureByText("A.java", source) + return Regex(Regex.escape(call)).findAll(source).map { file.findElementAt(it.range.first)!! }.toList() + } + + private fun callers(usages: List): List = + usages.map { (HierarchyTools.callerOf(it) as? PsiNamedElement)?.name } + + fun `test a call made from a local variable or a lambda belongs to the method around it`() { + val usages = usages(SOURCE, "target()").drop(1) + val nearestOwner = PsiTreeUtil.getParentOfType(usages[1], PsiNameIdentifierOwner::class.java, true) + assertEquals("the premise: the nearest name owner is the local variable", "local", nearestOwner?.name) + assertEquals(listOf("field", "caller", "caller", "Inner"), callers(usages)) + } + + fun `test a call made from a method of a nested class belongs to that method`() { + assertEquals(listOf("nested"), callers(usages(NESTED, "target()").drop(1))) + } + + private companion object { + const val SOURCE = """ + class A { + static int target() { return 1; } + int field = target(); + void caller() { + int local = target(); + Runnable r = () -> target(); + } + class Inner { { target(); } } + } + """ + + const val NESTED = """ + class B { + static int target() { return 1; } + static class N { int nested() { return target(); } } + } + """ + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/headless/SessionLiveSettingsHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/SessionLiveSettingsHeadlessTest.kt index 511e8d33..f49d2f0a 100644 --- a/src/test/kotlin/dev/lain/claudejb/headless/SessionLiveSettingsHeadlessTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/headless/SessionLiveSettingsHeadlessTest.kt @@ -45,6 +45,20 @@ class SessionLiveSettingsHeadlessTest : BasePlatformTestCase() { assertEquals(1, settings.state.thinkingTokens) } + fun `test adopting new launch settings takes every field at once and keeps the session id`() { + val next = LaunchOptions.from(settings).copy( + model = "haiku", + effort = "high", + permissionMode = "plan", + thinkingTokens = 1, + betas = "beta-x", + sessionId = "not-this-one", + ) + val kept = session.launch.sessionId + session.settings.adopt(next) + assertEquals(next.copy(sessionId = kept), session.launch) + } + fun `test adopting the stored settings changes the session and leaves the store as it was`() { settings.update { it.model = "haiku" } session.settings.changeModel("sonnet", persist = false) diff --git a/src/test/kotlin/dev/lain/claudejb/headless/SettingsMcpSectionHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/SettingsMcpSectionHeadlessTest.kt new file mode 100644 index 00000000..426aa9bb --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/headless/SettingsMcpSectionHeadlessTest.kt @@ -0,0 +1,46 @@ +package dev.lain.claudejb.headless + +import com.intellij.openapi.options.ConfigurationException +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.model.session.launch.McpConfigBuilder +import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.model.settings.env.hasRiskyExecConfig +import dev.lain.claudejb.view.settings.sections.SettingsMcpSection + +class SettingsMcpSectionHeadlessTest : BasePlatformTestCase() { + + private fun sectionShowing(text: String): SettingsMcpSection = + SettingsMcpSection().apply { reset(ClaudeSettings.State().apply { customMcpServers = text }) } + + fun `test a pasted Claude config is stored without its wrapper, and is not a change once stored`() { + val section = sectionShowing(WRAPPED_STDIO) + val state = ClaudeSettings.State() + section.validate() + section.apply(state) + assertEquals(setOf("files"), McpConfigBuilder.customMcpServersObject(state.customMcpServers)!!.keys) + assertEquals(listOf(false, false), section.changedFields(state)) + } + + fun `test the stored form is the one the execution trust prompt reads`() { + val section = sectionShowing(WRAPPED_STDIO) + val settings = ClaudeSettings.getInstance(project) + val before = settings.state.customMcpServers + try { + section.apply(settings.state) + assertTrue("a pasted stdio command must still ask for execution trust", settings.hasRiskyExecConfig()) + } finally { + settings.state.customMcpServers = before + } + } + + fun `test a wrapper beside other servers cannot be saved`() { + val section = sectionShowing("""{"mcpServers":{"a":{"command":"x"}},"b":{"command":"y"}}""") + val refused = runCatching { section.validate() }.exceptionOrNull() + assertTrue(refused is ConfigurationException) + assertTrue(refused!!.message!!.contains("mcpServers")) + } + + private companion object { + const val WRAPPED_STDIO = """{ "mcpServers": { "files": { "command": "npx", "args": ["-y", "server"] } } }""" + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/model/diff/DiffPresenterIsWithinRootTest.kt b/src/test/kotlin/dev/lain/claudejb/model/diff/DiffPresenterIsWithinRootTest.kt index e63020c4..3a22ff5f 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/diff/DiffPresenterIsWithinRootTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/diff/DiffPresenterIsWithinRootTest.kt @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS import org.junit.jupiter.api.io.TempDir import java.io.File import java.nio.file.Files @@ -99,6 +101,51 @@ class DiffPresenterIsWithinRootTest { assertTrue(DiffPresenter.isWithinRoot(link.canonicalPath, root.toFile().canonicalPath)) } + @Test + fun `a dangling symlink inside the root is rejected, wherever it points`(@TempDir tmp: Path) { + val root = File(tmp.toFile(), "root").apply { mkdirs() } + val link = File(root, "dangling.txt") + val target = File(tmp.toFile(), "outside/not-yet.txt") + val created = runCatching { Files.createSymbolicLink(link.toPath(), target.toPath()) } + assumeTrue(created.isSuccess, "symlinks unsupported in this environment") + assertFalse(DiffPresenter.isWithinRoot(link.path, root.canonicalPath)) + } + + @Test + fun `a new file under a symlinked directory pointing outside is rejected`(@TempDir tmp: Path) { + val root = File(tmp.toFile(), "root").apply { mkdirs() } + val outside = File(tmp.toFile(), "outside").apply { mkdirs() } + val link = File(root, "linked") + val created = runCatching { Files.createSymbolicLink(link.toPath(), outside.toPath()) } + assumeTrue(created.isSuccess, "symlinks unsupported in this environment") + assertFalse(DiffPresenter.isWithinRoot(File(link, "new.txt").path, root.canonicalPath)) + assertFalse(DiffPresenter.isWithinRoot(File(link, "deeper/new.txt").path, root.canonicalPath)) + } + + @Test + fun `a new file under a symlinked directory pointing inside is accepted`(@TempDir tmp: Path) { + val root = File(tmp.toFile(), "root").apply { mkdirs() } + val inner = File(root, "inner").apply { mkdirs() } + val link = File(root, "linked") + val created = runCatching { Files.createSymbolicLink(link.toPath(), inner.toPath()) } + assumeTrue(created.isSuccess, "symlinks unsupported in this environment") + assertTrue(DiffPresenter.isWithinRoot(File(link, "new.txt").path, root.canonicalPath)) + } + + @Test + @EnabledOnOs(OS.WINDOWS) + fun `a Windows junction to a directory outside the root is rejected`(@TempDir tmp: Path) { + val root = File(tmp.toFile(), "root").apply { mkdirs() } + val outside = File(tmp.toFile(), "outside").apply { mkdirs() } + val existing = File(outside, "secret.txt").apply { writeText("nope") } + val junction = File(root, "junction") + val made = ProcessBuilder("cmd", "/c", "mklink", "/J", junction.path, outside.path) + .redirectErrorStream(true).start().waitFor() + assumeTrue(made == 0 && junction.exists(), "mklink /J unavailable") + assertFalse(DiffPresenter.isWithinRoot(File(junction, existing.name).path, root.canonicalPath)) + assertFalse(DiffPresenter.isWithinRoot(File(junction, "new.txt").path, root.canonicalPath)) + } + @Test fun `relative root is canonicalized against the working directory and behaves consistently`(@TempDir root: Path) { val file = File(root.toFile(), "a.kt").apply { writeText("") } diff --git a/src/test/kotlin/dev/lain/claudejb/model/git/GitCommitInfoTest.kt b/src/test/kotlin/dev/lain/claudejb/model/git/GitCommitInfoTest.kt index d53d0604..4d61cdb4 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/git/GitCommitInfoTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/git/GitCommitInfoTest.kt @@ -97,7 +97,7 @@ class GitCommitInfoTest { val info = GitCommitInfo( hash = "0123456789abcdef", subject = "feat(git): read the branch and the recent commits", - authorName = "Lain", + authorName = "Lycogrypho", authorEmail = "lain@example.invalid", authoredAtMillis = 1_754_900_000_000L, changedPaths = listOf("build.gradle.kts", "src/main/kotlin/dev/lain/claudejb/git/GitGateway.kt"), @@ -105,7 +105,7 @@ class GitCommitInfoTest { assertEquals("0123456789abcdef", info.hash) assertEquals("0123456", info.shortHash) assertEquals("feat(git): read the branch and the recent commits", info.subject) - assertEquals("Lain", info.authorName) + assertEquals("Lycogrypho", info.authorName) assertEquals("lain@example.invalid", info.authorEmail) assertEquals(1_754_900_000_000L, info.authoredAtMillis) assertEquals(listOf("build.gradle.kts", "src/main/kotlin/dev/lain/claudejb/git/GitGateway.kt"), info.changedPaths) @@ -114,7 +114,7 @@ class GitCommitInfoTest { private fun commit(hash: String) = GitCommitInfo( hash = hash, subject = "subject", - authorName = "Lain", + authorName = "Lycogrypho", authorEmail = "lain@example.invalid", authoredAtMillis = 0L, changedPaths = emptyList(), diff --git a/src/test/kotlin/dev/lain/claudejb/model/mcp/BatchTest.kt b/src/test/kotlin/dev/lain/claudejb/model/mcp/BatchTest.kt index 5dce8385..0963a24f 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/mcp/BatchTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/mcp/BatchTest.kt @@ -89,6 +89,12 @@ class BatchTest { assertEquals("tu#4", Batch.itemId("tu", 4)) } + @Test + fun `a call naming two lists keeps one card, since the server refuses it whole`() { + val both = args("""{"queries":["a","b"],"paths":["x","y","z"]}""").json + assertNull(Batch.split("search_text", both)) + } + @Test fun `a tool whose list is one call keeps one card, so a commit of five files is not five failures`() { val commit = args("""{"message":"m","paths":["a.kt","b.kt"]}""").json diff --git a/src/test/kotlin/dev/lain/claudejb/model/mcp/ToolModelTest.kt b/src/test/kotlin/dev/lain/claudejb/model/mcp/ToolModelTest.kt index 3f0cfc49..dec4106a 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/mcp/ToolModelTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/mcp/ToolModelTest.kt @@ -112,6 +112,31 @@ class ToolModelTest { assertFalse(ToolResult.toon(parse("""{"a":1}""")).isError) } + @Test + fun `an argument the tool does not declare is refused, naming the ones it takes`() = runBlocking { + var ran = false + val tool = Tool(ToolSpec("search", "", listOf(Param("query", ""), Param("path", "", required = false)))) { + ran = true + ToolResult("ok") + } + val meta = MetaTools(ToolCatalog(listOf(ToolDomain("d", "", listOf(tool)))), { _, _ -> null }, OutputBudget()) + val refused = meta.call("run", parse("""{"tool":"search","args":{"query":"x","paths":["a"]}}"""))!! + assertTrue(refused.isError) + assertTrue(refused.text.contains("search does not take paths; it takes query, path")) { refused.text } + assertFalse(ran) + assertEquals("ok", meta.call("run", parse("""{"tool":"search","args":{"query":"x","path":"src"}}"""))!!.text) + } + + @Test + fun `a single long line is cut at the budget, not thrown away whole`() { + val budget = OutputBudget(200) + val toon = "path: a.kt\ntext: \"" + "word ".repeat(200) + "\"" + val fitted = budget.fit(toon) + assertTrue(fitted.length <= 200) { fitted } + assertTrue(fitted.contains("text: \"word word")) { fitted } + assertTrue(fitted.lines().last().startsWith("# truncated: ")) { fitted } + } + @Test fun `the budget cuts on a line boundary and says so in a comment line`() { val budget = OutputBudget(120) diff --git a/src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ControlParsersTest.kt b/src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ControlParsersTest.kt new file mode 100644 index 00000000..adde40d4 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ControlParsersTest.kt @@ -0,0 +1,20 @@ +package dev.lain.claudejb.model.protocol.parse + +import dev.lain.claudejb.model.protocol.ClaudeEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ControlParsersTest { + + @Test + fun `a control request without a request object is still answered`() { + val events = ProtocolParser.parse("""{"type":"control_request","request_id":"r1"}""") + assertEquals(listOf(ClaudeEvent.UnsupportedControlRequest("r1", null)), events) + } + + @Test + fun `a control request whose request is not an object is still answered`() { + val events = ProtocolParser.parse("""{"type":"control_request","request_id":"r2","request":"x"}""") + assertEquals(listOf(ClaudeEvent.UnsupportedControlRequest("r2", null)), events) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/model/settings/McpConfigShapeTest.kt b/src/test/kotlin/dev/lain/claudejb/model/settings/McpConfigShapeTest.kt new file mode 100644 index 00000000..54f760bb --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/settings/McpConfigShapeTest.kt @@ -0,0 +1,58 @@ +package dev.lain.claudejb.model.settings + +import dev.lain.claudejb.model.session.launch.McpConfigBuilder +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class McpConfigShapeTest { + + private fun serverNames(text: String): Set = McpConfigBuilder.customMcpServersObject(text)!!.keys + + @Test + fun `Claude's own config loses its mcpServers wrapper`() { + val unwrapped = McpConfigShape.unwrap(WRAPPED) + assertEquals(setOf("leonardo", "files"), serverNames(unwrapped)) + assertNull(McpConfigShape.problem(unwrapped)) + } + + @Test + fun `a config that is already the inner object is left exactly as typed`() { + val typed = "{\n \"docs\": { \"type\": \"http\", \"url\": \"https://example.com/mcp\" }\n}" + assertEquals(typed, McpConfigShape.unwrap(typed)) + assertNull(McpConfigShape.problem(typed)) + assertEquals("{not json", McpConfigShape.unwrap("{not json")) + assertEquals("", McpConfigShape.unwrap("")) + } + + @Test + fun `a server that happens to be named mcpServers is a server`() { + val named = """{"mcpServers":{"type":"sse","url":"http://localhost:1/sse"}}""" + assertEquals(named, McpConfigShape.unwrap(named)) + assertNull(McpConfigShape.problem(named)) + } + + @Test + fun `a wrapper beside other servers is refused, naming the fix`() { + val mixed = """{"mcpServers":{"a":{"command":"x"}},"b":{"command":"y"}}""" + assertEquals(mixed, McpConfigShape.unwrap(mixed)) + val problem = McpConfigShape.problem(mixed)!! + assertTrue(problem.contains("mcpServers") && problem.contains("inside")) { problem } + } + + @Test + fun `anything but an object of config objects is refused, naming the entry`() { + assertNull(McpConfigShape.problem("")) + assertNull(McpConfigShape.problem("{}")) + assertTrue(McpConfigShape.problem("[]")!!.contains("JSON object")) + assertTrue(McpConfigShape.problem("{not json")!!.contains("JSON object")) + val stray = McpConfigShape.problem("""{"docs":"https://example.com/mcp"}""")!! + assertTrue(stray.contains("\"docs\"")) { stray } + } + + private companion object { + const val WRAPPED = + """{ "mcpServers": { "leonardo": { "type": "http", "url": "https://example.com/mcp" }, "files": { "command": "npx" } } }""" + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/util/LogRedactionTest.kt b/src/test/kotlin/dev/lain/claudejb/util/LogRedactionTest.kt index 4d804eab..77942210 100644 --- a/src/test/kotlin/dev/lain/claudejb/util/LogRedactionTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/util/LogRedactionTest.kt @@ -51,8 +51,8 @@ class LogRedactionTest { fun `the home directory collapses to a tilde, in either slash direction`() { assertEquals("read ~/.claude.json", LogRedaction.apply("read /home/lain/.claude.json")) - LogRedaction.home = "C:\\Users\\Lain" - assertEquals("read ~\\x and ~/y", LogRedaction.apply("read C:\\Users\\Lain\\x and C:/Users/Lain/y")) + LogRedaction.home = "C:\\Users\\Lycogrypho" + assertEquals("read ~\\x and ~/y", LogRedaction.apply("read C:\\Users\\Lycogrypho\\x and C:/Users/Lycogrypho/y")) } @Test diff --git a/src/test/kotlin/dev/lain/claudejb/view/git/JcefGitDataTest.kt b/src/test/kotlin/dev/lain/claudejb/view/git/JcefGitDataTest.kt index d8525ad9..24d9771e 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/git/JcefGitDataTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/git/JcefGitDataTest.kt @@ -26,7 +26,7 @@ class JcefGitDataTest { private val commit = GitCommitInfo( hash = "0123456789abcdef0123456789abcdef01234567", subject = "Add the Git view", - authorName = "Lain", + authorName = "Lycogrypho", authorEmail = "lain@example.invalid", authoredAtMillis = 1_000_000L, changedPaths = listOf("src/a.kt", "src/b.kt", "README.md"), @@ -35,7 +35,7 @@ class JcefGitDataTest { private val merge = GitCommitInfo( hash = "89abcdef89abcdef89abcdef89abcdef89abcdef", subject = "Merge branch 'feature/git'", - authorName = "Lain", + authorName = "Lycogrypho", authorEmail = "lain@example.invalid", authoredAtMillis = 1_200_000L, changedPaths = listOf("src/a.kt"), @@ -121,7 +121,7 @@ class JcefGitDataTest { assertEquals(commit.hash, c["hash"]!!.jsonPrimitive.content) assertEquals("0123456", c["short"]!!.jsonPrimitive.content) assertEquals("Add the Git view", c["subject"]!!.jsonPrimitive.content) - assertEquals("Lain", c["author"]!!.jsonPrimitive.content) + assertEquals("Lycogrypho", c["author"]!!.jsonPrimitive.content) assertEquals(1_000_000L, c["authoredAtMillis"]!!.jsonPrimitive.long) assertEquals(3, c["files"]!!.jsonPrimitive.int) } diff --git a/src/test/kotlin/dev/lain/claudejb/view/payload/composer/JcefModelLabelTest.kt b/src/test/kotlin/dev/lain/claudejb/view/payload/composer/JcefModelLabelTest.kt index 61fe41c6..ac400dc0 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/payload/composer/JcefModelLabelTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/payload/composer/JcefModelLabelTest.kt @@ -17,6 +17,26 @@ class JcefModelLabelTest { assertEquals("Haiku 4.5", JcefModelLabels.modelDisplayLabel(haiku())) } + @Test + fun `a description without the model name never becomes the label`() { + assertEquals("Haiku 4.5", JcefModelLabels.modelDisplayLabel(ModelInfo("haiku", "Haiku 4.5", "Fastest for quick answers"))) + assertEquals( + "Sonnet 5", + JcefModelLabels.modelDisplayLabel(ModelInfo("claude-sonnet-5", "Sonnet", "For complex work and everyday tasks")), + ) + assertEquals("Sonnet", JcefModelLabels.modelDisplayLabel(ModelInfo("sonnet", "Sonnet", "For complex work and everyday tasks"))) + } + + @Test + fun `the named segment wins wherever it sits in the description`() { + assertEquals("Opus 5.5", JcefModelLabels.modelDisplayLabel(ModelInfo("opus", "Opus", "Best for complex work · Opus 5.5"))) + } + + @Test + fun `a context size is not mistaken for a version`() { + assertEquals("Opus 4.8", JcefModelLabels.modelDisplayLabel(ModelInfo("claude-opus-4-8[1m]", "Opus (1M context)", "For long sessions"))) + } + @Test fun `label falls back to displayName when there is no description`() { assertEquals("Sonnet", JcefModelLabels.modelDisplayLabel(ModelInfo("sonnet", "Sonnet", "")))