From 95328d33f72cf1eeb930b4a8bafa13003f5c52c4 Mon Sep 17 00:00:00 2001 From: Cosimo Attanasi Date: Sun, 11 Oct 2026 21:14:54 +0200 Subject: [PATCH 1/4] Bug Fix - an IDE freeze caused by a stdin/stdout pipe deadlock between the plugin and the claude process. Restructure the agent documentation so its cross-references resolve. PROBLEM During long sessions IntelliJ IDEA froze, sometimes for over an hour, and only recovered when the claude process was restarted. idea.log records 41 UI freezes. All the long ones are blocked in ClaudeProcess.writeLine / FileOutputStream.writeBytes. ROOT CAUSE The stdout reader thread wrote auto-approved permission replies to claude's stdin synchronously, while processing claude's output. When the stdin pipe was full, that thread blocked and stopped draining stdout. claude then blocked on its own stdout and stopped reading stdin, so neither side could proceed. The UI thread, sending a periodic usage query, then blocked on the same write lock and froze the IDE. Teardown also closed stdin under that lock, so it could stall behind the stuck write. CHANGES - New controller/process/StdinWriter.kt: writes newline-terminated UTF-8 lines to stdin on a single dedicated thread, preserving order. Write failures are logged and do not stop later lines. - controller/process/ClaudeProcess.kt: writeLine queues the line on a bounded single-thread application pool executor and returns immediately; it still returns false when the process is dead. The write lock is removed. Teardown destroys the process before closing stdin, so a write stuck on a full pipe fails instead of stalling the shutdown. - New test controller/process/StdinWriterTest.kt: a pipe that stops draining does not block the caller; lines arrive whole and in order; a broken pipe does not stop later lines. - No security code was changed. The fix sits below PermissionBroker, which is untouched. - Documentation: CLAUDE.md gains the standard header. DIRECTIVES.md gains a "How the plugin is put together" architecture section. AGENTS.md points to DIRECTIVES.md for architecture and adds commands for a single test class, integration tests, a single frontend test file, and running builds and tests through the IDE's run server. CONTRIBUTING.md points to CLAUDE.md and DIRECTIVES.md and drops the link to the missing PROJECTMAP.md. BEHAVIOUR CHANGE writeLine now reports that a line was queued, not that it was written. A later pipe failure is logged and handled by the existing process-termination path. VERIFICATION Built with JBR 21.0.11 on Windows. StdinWriterTest (3), EdtProcessTeardownContractTest (4), and the NoComments, PackageDependency and FileSize contract tests pass. spotlessCheck and detekt pass. Full JVM suite: 4881 tests, 21 failed, 8 skipped. The same 21 fail on the original code: they depend on a POSIX environment (the bin/fake-claude script, Unix socket permissions and path length, symlinks, path separators). buildPlugin produced build/distributions/claude-code-native-6.0.1.zip, which contains StdinWriter and the updated ClaudeProcess. OPEN POINTS Not yet verified end to end in a running IDE. The integration tests that drive a real process cannot run on Windows. Manual check: install the zip and run a long task that previously froze. --- AGENTS.md | 15 +++- CONTRIBUTING.md | 6 +- DIRECTIVES.md | 24 ++++++ .../controller/process/ClaudeProcess.kt | 15 ++-- .../controller/process/StdinWriter.kt | 20 +++++ .../controller/process/StdinWriterTest.kt | 73 +++++++++++++++++++ 6 files changed, 136 insertions(+), 17 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/process/StdinWriter.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/process/StdinWriterTest.kt 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/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/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt b/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt index c042800b..e6801189 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,7 +25,7 @@ class ClaudeProcess( private val extraEnv: Map = emptyMap(), ) { private val log = thisLogger() - private val writeLock = Any() + private val stdin = StdinWriter(AppExecutorUtil.createBoundedApplicationPoolExecutor("Claude stdin", 1)) private val stdoutBuffer = StringBuilder() private var stderrLines = 0 @@ -116,14 +117,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 +136,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/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 + } +} From 8b18d82c6a1f7054a0c710fbd988894774b24c0e Mon Sep 17 00:00:00 2001 From: Cosimo Attanasi Date: Mon, 26 Oct 2026 11:48:25 +0100 Subject: [PATCH 2/4] Bug Fix - The model label shows the model's name and version, and every question card offers an Other answer with a free-text field. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model label could show a tagline from the catalog description instead of the model's name, because it took whatever came before " · ". It now uses only a segment that names the model with a version. Question cards had no way to give an answer outside the offered options; each question now ends with an Other choice that opens a text field, and the typed text is what gets sent. src/main/kotlin/dev/lain/claudejb/view/payload/composer/JcefModelLabels.kt - modelDisplayLabel picks the first description segment that starts with the model family and carries a version. Failing that, it uses the display name, then the name derived from the model id, but only if they carry a version. Failing that, it uses the bare name. A description segment without the family and a version is never used, because the catalog sometimes puts only a tagline there. The version pattern needs a word boundary after the digits, so a context size such as "1M" is not read as a version. src/main/ts/jcef/views/permissions/cards/question.ts - Every question ends with one fixed Other option. An Other supplied by the model (matched case-insensitively) is dropped rather than shown twice. Choosing Other reveals the text field and focuses it, and deselecting it hides the field. On submit, Other is replaced by the trimmed text typed into the field, so the model gets the answer and not the word Other. If the field is empty, Other is left out. On a multi-select the typed text is joined with the other chosen options. The click logic and option construction were moved into toggle, optionButton, otherInput and answerFor, keeping the builder readable and inside the file-size ceiling. src/main/resources/jcef/css/views/permissions/cards.css - Layout and hidden state of .q-other-input: full width, sitting under the options. src/main/resources/jcef/css/views/permissions/elicit.css - .q-other-input joins the existing input, focus and placeholder selectors, reusing the existing field style instead of defining a second one. src/test/kotlin/dev/lain/claudejb/view/payload/composer/JcefModelLabelTest.kt src/test/frontend/permissions/question-card.test.js Verification: npm test 46 files / 686 tests passed; eslint and prettier --check clean; gradlew test on JcefModelLabelTest and all *ContractTest passed; spotlessCheck passed. Not verified: detekt fails before analysing anything (Gradle appears to run on JDK 25 instead of JBR 21); the IDE's own analysis of the files had not finished; the keyboard pass on the new field has not been done. --- CHANGELOG.md | 14 +++ CLAUDE.md | 4 + .../view/payload/composer/JcefModelLabels.kt | 19 ++- .../jcef/css/views/permissions/cards.css | 9 ++ .../jcef/css/views/permissions/elicit.css | 3 + .../jcef/views/permissions/cards/question.ts | 112 ++++++++++++------ .../permissions/question-card.test.js | 72 +++++++++++ .../payload/composer/JcefModelLabelTest.kt | 20 ++++ 8 files changed, 213 insertions(+), 40 deletions(-) create mode 100644 src/test/frontend/permissions/question-card.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 40fad6a0..dc371f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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. + +### 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. + ## [6.0.1] — 2026-09-22 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 91f45d05..8c6fbe17 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 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/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/views/permissions/cards/question.ts b/src/main/ts/jcef/views/permissions/cards/question.ts index e0640a73..4c94937e 100644 --- a/src/main/ts/jcef/views/permissions/cards/question.ts +++ b/src/main/ts/jcef/views/permissions/cards/question.ts @@ -6,10 +6,61 @@ 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(); + 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; + otherInputs[qText] = input; + return input; + } const qBlocks = questions.map(function (q) { const qText = q && q.question != null ? String(q.question) : ''; @@ -17,48 +68,37 @@ 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(', '); + } + const root = h( 'div', { class: 'perm-card q-card' }, @@ -70,7 +110,7 @@ PM.button({ class: 'btn primary', text: 'Submit' }, function () { const answers: Record = {}; Object.keys(selections).forEach(function (qText) { - answers[qText] = selections[qText].join(', '); + answers[qText] = answerFor(qText); }); PM.sendFor(card, { type: 'resolveQuestion', id: id, answers: answers }); }), @@ -88,6 +128,10 @@ 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(); return root; 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..f9654ac9 --- /dev/null +++ b/src/test/frontend/permissions/question-card.test.js @@ -0,0 +1,72 @@ +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'); + win.CC.els.permissions.querySelector('.q-other-input').value = ' something else '; + 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/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", ""))) From e49b3aaadd0fa906ac1a5a2c2ad494af5e6c2faa Mon Sep 17 00:00:00 2001 From: Cosimo Attanasi Date: Wed, 11 Nov 2026 22:34:54 +0100 Subject: [PATCH 3/4] New Feature - F_0003, F_0004, F_0005 - Your messages stand out in the transcript, the up and down arrows recall earlier prompts, and the input area folds away. Three chat-page improvements: User rows get an accent tint and a left bar but stay rows, not cards. Up and down recall the prompts already sent in the tab, read from the transcript so no prompt text is stored anywhere new. The input area folds into a one-line strip that shows the turn's phase, and permission and question cards stay answerable while folded. src/main/resources/jcef/css/views/chat/rows.css - .msg.user gets a 2px accent left border, its body a color-mix of --accent and --text, and its "You" label --accent, all from theme variables so every theme follows. A background or shadow was rejected: it would bring back the card the existing transcript test forbids. src/main/ts/jcef/models/composer/history.ts - Pure step over the sent prompts, skipping repeats sent back to back. Going up from no recall saves the box's current text as the draft; going down past the newest restores it. src/main/ts/jcef/controllers/composer/history.ts - Reads the prompts from the conversation's .msg.user .body rows (raw text, so the typed markdown) rather than recording them on send, which would miss resumed sessions. Acts only with the caret on the first line (up) or the last line (down) and without modifiers, so a multi-line prompt stays editable. Restarts from the newest once the box no longer holds the text it last recalled, which covers both edits and sends. Kept out of send.ts, which is near the 250-line ceiling. src/main/ts/jcef/controllers/composer/fold.ts - A strip first in #dock with a phase label and a toggle carrying aria-expanded and aria-controls. Folding sets #dock.folded. The fold is offered only while the session runs and no sign-in is wanted. Ctrl+Shift+Down folds and Ctrl+Shift+Up unfolds (the owner's choice over Alt+Shift, which the editor uses to move lines). While folded, a printable key or Up outside an editable unfolds and focuses the input. Focus moves to the toggle when the input it was in is folded away. Stick-to-bottom is sampled before the toggle and re-applied after. Keys are ignored once the strip is detached from the page. src/main/resources/jcef/css/views/composer/fold.css - Hides #composer, #controls and #palette when folded, never #permissions: a pending card blocks the turn, and answering it needs no input. src/main/ts/jcef/controllers/composer/composer.ts - Wires the history on the input and renders the fold on every state push. src/main/ts/jcef/types/composer.d.ts - Types for the history step and the two new composer entry points. src/main/kotlin/dev/lain/claudejb/view/jcef/PageAssembly.kt - Registers the two history modules and the fold module before send.js and composer.js, which call them while the page loads, and fold.css in CSS_PARTS. .gitignore - Keeps the local working documents ToDo.md, Done.md and DesignNotes.md out of the repository; the root *.md allowlist would otherwise track them. src/test/frontend/chat/user-row-colour.test.js src/test/frontend/composer/prompt-history.test.js src/test/frontend/composer/composer-fold.test.js --- .gitignore | 3 + CHANGELOG.md | 11 ++ README.md | 4 +- .../lain/claudejb/view/jcef/PageAssembly.kt | 4 + .../resources/jcef/css/views/chat/rows.css | 8 ++ .../jcef/css/views/composer/fold.css | 41 ++++++ .../ts/jcef/controllers/composer/composer.ts | 2 + src/main/ts/jcef/controllers/composer/fold.ts | 110 ++++++++++++++++ .../ts/jcef/controllers/composer/history.ts | 54 ++++++++ src/main/ts/jcef/models/composer/history.ts | 30 +++++ src/main/ts/jcef/types/composer.d.ts | 13 ++ .../frontend/chat/user-row-colour.test.js | 38 ++++++ .../frontend/composer/composer-fold.test.js | 123 ++++++++++++++++++ .../frontend/composer/prompt-history.test.js | 97 ++++++++++++++ 14 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/jcef/css/views/composer/fold.css create mode 100644 src/main/ts/jcef/controllers/composer/fold.ts create mode 100644 src/main/ts/jcef/controllers/composer/history.ts create mode 100644 src/main/ts/jcef/models/composer/history.ts create mode 100644 src/test/frontend/chat/user-row-colour.test.js create mode 100644 src/test/frontend/composer/composer-fold.test.js create mode 100644 src/test/frontend/composer/prompt-history.test.js 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/CHANGELOG.md b/CHANGELOG.md index dc371f80..4906cfd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,17 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **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 diff --git a/README.md b/README.md index ca979abc..e0351a65 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 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/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..03473020 --- /dev/null +++ b/src/main/resources/jcef/css/views/composer/fold.css @@ -0,0 +1,41 @@ +.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, +#dock.folded #palette { + display: none; +} 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..a8fa6788 --- /dev/null +++ b/src/main/ts/jcef/controllers/composer/fold.ts @@ -0,0 +1,110 @@ +(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'); + else strip.setAttribute('hidden', 'hidden'); + } + + 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 hadFocus = !!input && document.activeElement === input; + 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(); + }; + + function editable(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + if (!el || !el.tagName) return false; + return ( + el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable + ); + } + + 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 || e.target === toggle || editable(e.target) || e.ctrlKey || e.metaKey || e.altKey) return; + if (e.key.length === 1 || e.key === 'ArrowUp') 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..5fa0430b --- /dev/null +++ b/src/main/ts/jcef/controllers/composer/history.ts @@ -0,0 +1,54 @@ +(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 sentPrompts(): string[] { + const conversation = + (CC.transcript && CC.transcript.conversationEl && CC.transcript.conversationEl()) || + document.getElementById('conversation'); + if (!conversation) return []; + const prompts: string[] = []; + conversation.querySelectorAll('.msg.user .body').forEach(function (body) { + const raw = (body as BodyEl).__rawText; + if (raw) prompts.push(raw); + }); + return prompts; + } + + function caretOnFirstLine(input: HTMLTextAreaElement): boolean { + return ( + input.selectionStart === input.selectionEnd && + input.value.slice(0, input.selectionStart).indexOf('\n') < 0 + ); + } + + function caretOnLastLine(input: HTMLTextAreaElement): boolean { + return ( + input.selectionStart === input.selectionEnd && input.value.slice(input.selectionEnd).indexOf('\n') < 0 + ); + } + + 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 (up ? !caretOnFirstLine(input) : !caretOnLastLine(input)) return; + if (nav.index >= 0 && input.value !== shown) nav = IDLE; + 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); + }); + }; +})(); 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..d8054966 --- /dev/null +++ b/src/main/ts/jcef/models/composer/history.ts @@ -0,0 +1,30 @@ +(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); + if (dir < 0) { + const index = nav.index < 0 ? list.length - 1 : nav.index - 1; + if (index < 0) return null; + return { nav: { index: index, draft: nav.index < 0 ? current : nav.draft }, text: list[index] }; + } + if (nav.index < 0) return null; + const index = nav.index + 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/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..179500a2 --- /dev/null +++ b/src/test/frontend/composer/composer-fold.test.js @@ -0,0 +1,123 @@ +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, its controls and the palette, 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(/#dock\.folded #palette/); + expect(rule).toMatch(/display:\s*none/); + expect(css).not.toMatch(/#dock\.folded #permissions/); + }); + + 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('unfolds on a plain ArrowUp while folded, so history is one key away', () => { + const { win, dock, toggle } = setup(); + toggle.click(); + key(win, win.document.body, 'ArrowUp'); + expect(dock.classList.contains('folded')).toBe(false); + }); + + 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..69ce7a5f --- /dev/null +++ b/src/test/frontend/composer/prompt-history.test.js @@ -0,0 +1,97 @@ +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('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(''); + }); +}); From 33e8e95907735cffa2c0ce45941098f519c94175 Mon Sep 17 00:00:00 2001 From: Cosimo Attanasi Date: Fri, 13 Nov 2026 06:36:05 +0100 Subject: [PATCH 4/4] Bug Fix - F_0002, F_0006 to F_0020, F_0038, F_0039, D_0043 - Every Priority 1 entry of the 2026-11-12 audit is closed. Eighteen defects fixed tests-first across the session lifecycle, the chat page, the plugin's MCP tools and the settings form, plus two security entries changed on the owner's explicit order (F_0038, F_0039). Nothing under model/permission and no guard test is touched. src/main/kotlin/dev/lain/claudejb/model/diff/DiffPresenter.kt - F_0038, on order. isWithinRoot resolves the nearest existing ancestor with toRealPath and re-appends the missing tail, because File.canonicalFile does not resolve links or junctions on Windows and the check also runs on files not created yet. A dangling symlink is refused. src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt - F_0039, on order. JcefSettingsMenu.apply is called once, inside settings.update, with the verdict taken from it. It ran three times per toggle (twice on the live state, once on the store copy); update's own two runs remain. No duplicated effect was observed: every effect reached is idempotent. src/main/kotlin/dev/lain/claudejb/controller/session/SessionLifecycle.kt src/main/kotlin/dev/lain/claudejb/controller/session/auth/AuthGate.kt - F_0006. An UNKNOWN credential answer is resolved on a pooled thread and then launches or asks for login directly; going back through start() would get UNKNOWN again. - F_0007. The ready block checks the launch generation. src/main/kotlin/dev/lain/claudejb/controller/session/ProcessSlot.kt src/main/kotlin/dev/lain/claudejb/controller/session/SessionProcess.kt src/main/kotlin/dev/lain/claudejb/controller/session/StaleResume.kt - F_0007. Generation and process live in one slot claimed under one lock; events of a superseded launch are dropped. - F_0008. A failed resume starts a new conversation only when the transcript file is gone. Matching the error text was not possible: stderr is logged, not kept. src/main/kotlin/dev/lain/claudejb/controller/session/ClaudeSession.kt - F_0010. send queues the prompt whatever start() answers, since the attachment tray is already empty by then. A /btw with no running session asks to be repeated instead of becoming a normal prompt. src/main/kotlin/dev/lain/claudejb/controller/session/ChatSessionManager.kt src/main/kotlin/dev/lain/claudejb/controller/session/events/EarlyLines.kt src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt - F_0009. Sessions are disposed through Disposer, so the message-bus connection goes with them. Early tool output is bounded to 16 tool ids; filtering by session was rejected because the dispatcher does not know which chat a client belongs to. src/main/kotlin/dev/lain/claudejb/controller/session/SessionLiveSettings.kt - F_0011. adopt assigns session.launch once, then restarts once or sends set_model and set_permission_mode. It no longer writes the permission mode back to the settings it just read. src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt src/main/kotlin/dev/lain/claudejb/controller/process/StdoutLines.kt src/main/kotlin/dev/lain/claudejb/model/protocol/parse/ControlParsers.kt - F_0012. A control request without a request object, or inside a line over the size cap, is answered with an error. The rest of an oversized line is discarded up to its newline, and the last line of a dying process is parsed. src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestRunChoice.kt src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestTools.kt - F_0013. run_tests prefers a framework's own runner, then the Gradle configuration whose tasks include test, so a test file no longer runs in checkDrift. src/main/kotlin/dev/lain/claudejb/model/mcp/ToolResult.kt src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadWindow.kt src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt - F_0014. read_file stops adding lines at a 15 000-character budget shared by the files of one call and reports to and truncated. fit cuts at the room when the newline cut would discard most of the text. src/main/kotlin/dev/lain/claudejb/model/mcp/MetaTools.kt src/main/kotlin/dev/lain/claudejb/model/mcp/Batch.kt - F_0015. An undeclared argument is refused after the guard has spoken, naming the valid ones. A call naming two lists keeps one card: the host has no tool specs, and such a call is now refused whole. src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileGlob.kt src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SearchTools.kt - F_0016. A glob containing / matches the project-relative path. - F_0015. search_text, find_files and list_directory collect max + 1 and report truncated only when something was left out. src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/HierarchyTools.kt src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastTools.kt - F_0017. A caller is the nearest UMethod, UField or UClass where the Java plugin is enabled, else the nearest named PSI owner. The UAST code stays in UastTools.kt, the only file allowed to name org.jetbrains.uast. src/main/kotlin/dev/lain/claudejb/model/settings/McpConfigShape.kt src/main/kotlin/dev/lain/claudejb/model/settings/LaunchDefaults.kt src/main/kotlin/dev/lain/claudejb/view/settings/sections/SettingsMcpSection.kt - F_0002. Claude's mcpServers wrapper is removed when the form stores the text, never at launch: the execution-trust check reads the stored shape, so a launch-only unwrap would have run a pasted stdio server without the trust prompt. Other shapes are refused naming the entry. isValidMcpConfig is deleted; the hint shows stdio and http examples. src/main/ts/jcef/views/permissions/base.ts src/main/ts/jcef/views/permissions/cards/tool.ts src/main/ts/jcef/views/permissions/cards/elicit.ts src/main/ts/jcef/views/permissions/cards/question.ts src/main/ts/jcef/types/permissions.d.ts - F_0020. PM.answer builds the buttons that answer a card and disables them all on the first click. Submit is enabled only when every question has a non-empty answer. src/main/ts/jcef/controllers/composer/fold.ts src/main/ts/jcef/controllers/permissions/permissions.ts src/main/resources/jcef/css/views/composer/fold.css src/main/ts/jcef/types/core.d.ts - F_0018. While folded, a key unfolds only from body or the transcript, and plain ArrowUp no longer does. The phase label follows the cards, focus is rescued from anywhere in the dock, and the dead palette selector is removed. src/main/ts/jcef/controllers/composer/history.ts src/main/ts/jcef/models/composer/history.ts src/main/ts/jcef/controllers/git/chat.ts - F_0019. The history index is trusted only while its entry is still the text in the box. Prompts come from the transcript that is shown, a recall starts only at offset 0 or at the end, and the ghost is repainted. src/test/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettingsContractTest.kt src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileGlobTest.kt src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadWindowTest.kt src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestRunChoiceTest.kt src/test/kotlin/dev/lain/claudejb/controller/process/StdoutLinesTest.kt src/test/kotlin/dev/lain/claudejb/controller/session/AdoptSettingsContractTest.kt src/test/kotlin/dev/lain/claudejb/controller/session/ProcessSlotTest.kt src/test/kotlin/dev/lain/claudejb/controller/session/SessionDisposalContractTest.kt src/test/kotlin/dev/lain/claudejb/controller/session/SessionSendContractTest.kt src/test/kotlin/dev/lain/claudejb/controller/session/StaleResumeTest.kt src/test/kotlin/dev/lain/claudejb/controller/session/auth/EdtAuthContractTest.kt src/test/kotlin/dev/lain/claudejb/controller/session/events/EarlyLinesTest.kt src/test/kotlin/dev/lain/claudejb/headless/HierarchyCallerHeadlessTest.kt src/test/kotlin/dev/lain/claudejb/headless/SessionLiveSettingsHeadlessTest.kt src/test/kotlin/dev/lain/claudejb/headless/SettingsMcpSectionHeadlessTest.kt src/test/kotlin/dev/lain/claudejb/model/diff/DiffPresenterIsWithinRootTest.kt src/test/kotlin/dev/lain/claudejb/model/mcp/BatchTest.kt src/test/kotlin/dev/lain/claudejb/model/mcp/ToolModelTest.kt src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ControlParsersTest.kt src/test/kotlin/dev/lain/claudejb/model/settings/McpConfigShapeTest.kt src/test/frontend/composer/composer-fold.test.js src/test/frontend/composer/prompt-history.test.js src/test/frontend/permissions/answer-once.test.js src/test/frontend/permissions/permissions.test.js src/test/frontend/permissions/question-card.test.js --- CHANGELOG.md | 38 ++++++++ CLAUDE.md | 10 +-- CODEOWNERS | 28 +++--- README.md | 2 +- docs/SKILL_INVENTORY.md | 4 +- docs/TROUBLESHOOTING.md | 41 +++++++-- .../controller/bridge/BridgeSettings.kt | 6 +- .../controller/mcp/tools/code/FileGlob.kt | 18 ++++ .../mcp/tools/code/HierarchyTools.kt | 11 ++- .../controller/mcp/tools/code/ReadTools.kt | 16 +++- .../controller/mcp/tools/code/ReadWindow.kt | 30 +++++++ .../controller/mcp/tools/code/SearchTools.kt | 31 +++---- .../controller/mcp/tools/code/UastTools.kt | 12 +++ .../controller/mcp/tools/run/TestRunChoice.kt | 13 +++ .../controller/mcp/tools/run/TestTools.kt | 7 +- .../controller/process/ClaudeProcess.kt | 43 +++++---- .../controller/process/StdoutLines.kt | 60 +++++++++++++ .../controller/session/ChatSessionManager.kt | 5 +- .../controller/session/ClaudeSession.kt | 10 +-- .../controller/session/ProcessSlot.kt | 38 ++++++++ .../controller/session/SessionLifecycle.kt | 15 +++- .../controller/session/SessionLiveSettings.kt | 53 ++++++----- .../controller/session/SessionProcess.kt | 34 ++++---- .../controller/session/StaleResume.kt | 7 ++ .../controller/session/auth/AuthGate.kt | 15 ++++ .../controller/session/events/EarlyLines.kt | 18 ++++ .../controller/session/events/ToolEvents.kt | 11 +-- .../lain/claudejb/model/diff/DiffPresenter.kt | 23 +++-- .../dev/lain/claudejb/model/mcp/Batch.kt | 2 +- .../dev/lain/claudejb/model/mcp/MetaTools.kt | 9 ++ .../dev/lain/claudejb/model/mcp/ToolResult.kt | 7 +- .../model/protocol/parse/ControlParsers.kt | 3 +- .../claudejb/model/settings/LaunchDefaults.kt | 5 -- .../claudejb/model/settings/McpConfigShape.kt | 43 +++++++++ .../settings/sections/SettingsMcpSection.kt | 21 +++-- .../jcef/css/views/composer/fold.css | 3 +- src/main/ts/jcef/controllers/composer/fold.ts | 31 +++++-- .../ts/jcef/controllers/composer/history.ts | 35 ++++---- src/main/ts/jcef/controllers/git/chat.ts | 4 + .../controllers/permissions/permissions.ts | 1 + src/main/ts/jcef/models/composer/history.ts | 8 +- src/main/ts/jcef/types/core.d.ts | 2 + src/main/ts/jcef/types/permissions.d.ts | 2 + src/main/ts/jcef/views/permissions/base.ts | 21 +++++ .../ts/jcef/views/permissions/cards/elicit.ts | 8 +- .../jcef/views/permissions/cards/question.ts | 31 +++++-- .../ts/jcef/views/permissions/cards/tool.ts | 12 +-- .../frontend/composer/composer-fold.test.js | 62 ++++++++++++- .../frontend/composer/prompt-history.test.js | 51 +++++++++++ src/test/frontend/git/git-map.test.js | 2 +- src/test/frontend/git/git-view.test.js | 6 +- .../frontend/permissions/answer-once.test.js | 87 +++++++++++++++++++ .../frontend/permissions/permissions.test.js | 11 +-- .../permissions/question-card.test.js | 4 +- .../bridge/BridgeSettingsContractTest.kt | 41 +++++++++ .../commands/git/GitContextActionsTest.kt | 4 +- .../controller/mcp/tools/code/FileGlobTest.kt | 34 ++++++++ .../mcp/tools/code/ReadWindowTest.kt | 43 +++++++++ .../mcp/tools/run/TestRunChoiceTest.kt | 33 +++++++ .../controller/process/StdoutLinesTest.kt | 46 ++++++++++ .../session/AdoptSettingsContractTest.kt | 46 ++++++++++ .../controller/session/ProcessSlotTest.kt | 84 ++++++++++++++++++ .../session/SessionDisposalContractTest.kt | 30 +++++++ .../session/SessionSendContractTest.kt | 48 ++++++++++ .../controller/session/StaleResumeTest.kt | 41 +++++++++ .../session/auth/EdtAuthContractTest.kt | 27 +++++- .../session/events/EarlyLinesTest.kt | 28 ++++++ .../headless/HierarchyCallerHeadlessTest.kt | 51 +++++++++++ .../SessionLiveSettingsHeadlessTest.kt | 14 +++ .../SettingsMcpSectionHeadlessTest.kt | 46 ++++++++++ .../diff/DiffPresenterIsWithinRootTest.kt | 47 ++++++++++ .../claudejb/model/git/GitCommitInfoTest.kt | 6 +- .../dev/lain/claudejb/model/mcp/BatchTest.kt | 6 ++ .../lain/claudejb/model/mcp/ToolModelTest.kt | 25 ++++++ .../protocol/parse/ControlParsersTest.kt | 20 +++++ .../model/settings/McpConfigShapeTest.kt | 58 +++++++++++++ .../lain/claudejb/util/LogRedactionTest.kt | 4 +- .../lain/claudejb/view/git/JcefGitDataTest.kt | 6 +- 78 files changed, 1639 insertions(+), 219 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileGlob.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadWindow.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestRunChoice.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/process/StdoutLines.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/session/ProcessSlot.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/session/StaleResume.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/session/events/EarlyLines.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/model/settings/McpConfigShape.kt create mode 100644 src/test/frontend/permissions/answer-once.test.js create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettingsContractTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileGlobTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadWindowTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestRunChoiceTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/process/StdoutLinesTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/session/AdoptSettingsContractTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/session/ProcessSlotTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/session/SessionDisposalContractTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/session/SessionSendContractTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/session/StaleResumeTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/session/events/EarlyLinesTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/headless/HierarchyCallerHeadlessTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/headless/SettingsMcpSectionHeadlessTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ControlParsersTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/model/settings/McpConfigShapeTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4906cfd8..a22fc278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,44 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ("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 diff --git a/CLAUDE.md b/CLAUDE.md index 8c6fbe17..9c842c57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,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 @@ -59,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 @@ -71,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 @@ -79,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/README.md b/README.md index e0351a65..a69436a0 100644 --- a/README.md +++ b/README.md @@ -433,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 e6801189..a3a81ccb 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt @@ -26,13 +26,15 @@ class ClaudeProcess( ) { private val log = thisLogger() private val stdin = StdinWriter(AppExecutorUtil.createBoundedApplicationPoolExecutor("Claude stdin", 1)) - private val stdoutBuffer = StringBuilder() + 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 @@ -67,6 +69,7 @@ class ClaudeProcess( override fun processTerminated(event: ProcessEvent) { log.info("claude terminated, exitCode=${event.exitCode}") + stdoutLines.finish()?.let(::handleLine) onTerminated(event.exitCode) } }) @@ -85,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) } } 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/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/composer/fold.css b/src/main/resources/jcef/css/views/composer/fold.css index 03473020..1dd7c901 100644 --- a/src/main/resources/jcef/css/views/composer/fold.css +++ b/src/main/resources/jcef/css/views/composer/fold.css @@ -35,7 +35,6 @@ background: var(--surface); } #dock.folded #composer, -#dock.folded #controls, -#dock.folded #palette { +#dock.folded #controls { display: none; } diff --git a/src/main/ts/jcef/controllers/composer/fold.ts b/src/main/ts/jcef/controllers/composer/fold.ts index a8fa6788..c772a293 100644 --- a/src/main/ts/jcef/controllers/composer/fold.ts +++ b/src/main/ts/jcef/controllers/composer/fold.ts @@ -45,8 +45,14 @@ toggle.title = hint; toggle.setAttribute('aria-label', hint); toggle.setAttribute('aria-expanded', String(!folded)); - if (foldable) strip.removeAttribute('hidden'); - else strip.setAttribute('hidden', 'hidden'); + 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 { @@ -63,7 +69,9 @@ if (next !== folded) { const tx = CC.transcript; const stick = !!(tx && tx.stickToBottom && tx.stickToBottom()); - const hadFocus = !!input && document.activeElement === input; + 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(); @@ -81,11 +89,20 @@ paint(); }; - function editable(target: EventTarget | null): boolean { + 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 ( - el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable + !!conversation && + conversation.contains(el) && + !el.isContentEditable && + !/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) ); } @@ -104,7 +121,7 @@ setFolded(fold, !fold); return; } - if (!folded || e.target === toggle || editable(e.target) || e.ctrlKey || e.metaKey || e.altKey) return; - if (e.key.length === 1 || e.key === 'ArrowUp') setFolded(false, true); + 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 index 5fa0430b..f3aa20e7 100644 --- a/src/main/ts/jcef/controllers/composer/history.ts +++ b/src/main/ts/jcef/controllers/composer/history.ts @@ -6,30 +6,29 @@ const IDLE: HistoryNav = { index: -1, draft: '' }; - function sentPrompts(): string[] { - const conversation = + 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'); - if (!conversation) return []; + document.getElementById('conversation') + ); + } + + function sentPrompts(): string[] { + const root = activeTranscript(); + if (!root) return []; const prompts: string[] = []; - conversation.querySelectorAll('.msg.user .body').forEach(function (body) { + root.querySelectorAll('.msg.user .body').forEach(function (body) { const raw = (body as BodyEl).__rawText; if (raw) prompts.push(raw); }); return prompts; } - function caretOnFirstLine(input: HTMLTextAreaElement): boolean { - return ( - input.selectionStart === input.selectionEnd && - input.value.slice(0, input.selectionStart).indexOf('\n') < 0 - ); - } - - function caretOnLastLine(input: HTMLTextAreaElement): boolean { - return ( - input.selectionStart === input.selectionEnd && input.value.slice(input.selectionEnd).indexOf('\n') < 0 - ); + function caretAt(input: HTMLTextAreaElement, offset: number): boolean { + return input.selectionStart === offset && input.selectionEnd === offset; } CX.wireHistory = function (input: HTMLTextAreaElement): void { @@ -39,8 +38,9 @@ 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 (up ? !caretOnFirstLine(input) : !caretOnLastLine(input)) return; 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(); @@ -49,6 +49,7 @@ 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 index d8054966..5ac5f373 100644 --- a/src/main/ts/jcef/models/composer/history.ts +++ b/src/main/ts/jcef/models/composer/history.ts @@ -17,13 +17,15 @@ 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 = nav.index < 0 ? list.length - 1 : nav.index - 1; + const index = at - 1; if (index < 0) return null; - return { nav: { index: index, draft: nav.index < 0 ? current : nav.draft }, text: list[index] }; + return { nav: { index: index, draft: draft }, text: list[index] }; } if (nav.index < 0) return null; - const index = nav.index + 1; + 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/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 4c94937e..84d3e3a5 100644 --- a/src/main/ts/jcef/views/permissions/cards/question.ts +++ b/src/main/ts/jcef/views/permissions/cards/question.ts @@ -28,6 +28,7 @@ selections[qText] = [label]; } syncSelected(); + refreshSubmit(); if (label === OTHER && selections[qText].indexOf(OTHER) >= 0) otherInputs[qText].focus(); } @@ -58,6 +59,7 @@ 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; } @@ -99,6 +101,24 @@ .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' }, @@ -107,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] = answerFor(qText); - }); - 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 }); }) ) @@ -134,6 +148,7 @@ }); } 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/composer/composer-fold.test.js b/src/test/frontend/composer/composer-fold.test.js index 179500a2..31ab7340 100644 --- a/src/test/frontend/composer/composer-fold.test.js +++ b/src/test/frontend/composer/composer-fold.test.js @@ -38,17 +38,22 @@ describe('composer fold — the input area can be hidden to give the transcript expect(toggle.getAttribute('aria-expanded')).toBe('true'); }); - it('hides the composer, its controls and the palette, never the permission cards', () => { + 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(/#dock\.folded #palette/); 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(); @@ -94,11 +99,62 @@ describe('composer fold — the input area can be hidden to give the transcript expect(win.document.activeElement).toBe(input); }); - it('unfolds on a plain ArrowUp while folded, so history is one key away', () => { + 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', () => { diff --git a/src/test/frontend/composer/prompt-history.test.js b/src/test/frontend/composer/prompt-history.test.js index 69ce7a5f..75252bb2 100644 --- a/src/test/frontend/composer/prompt-history.test.js +++ b/src/test/frontend/composer/prompt-history.test.js @@ -89,6 +89,57 @@ describe('prompt history — the arrows recall what this tab already sent', () = 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); 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 index f9654ac9..07c5ad86 100644 --- a/src/test/frontend/permissions/question-card.test.js +++ b/src/test/frontend/permissions/question-card.test.js @@ -49,7 +49,9 @@ describe('question card — the Other answer', () => { const win = loadFrontend(['app-permissions.js']); win.cc.permissions([questionCard(false, [{ label: 'A' }])]); clickOption(win, 'Other'); - win.CC.els.permissions.querySelector('.q-other-input').value = ' something else '; + 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', 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/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) }