Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions DIRECTIVES.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,30 @@ requests are not killed.
It reaches the helper on the **first line of stdin**, never through an environment variable:
`/proc/<pid>/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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,7 +25,7 @@ class ClaudeProcess(
private val extraEnv: Map<String, String> = 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

Expand Down Expand Up @@ -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
Expand All @@ -141,7 +136,7 @@ class ClaudeProcess(
}

private fun endProcess(dying: KillableProcessHandler) {
runCatching { synchronized(writeLock) { dying.processInput?.close() } }
dying.destroyProcess()
runCatching { dying.processInput?.close() }
}
}
Original file line number Diff line number Diff line change
@@ -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) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
9 changes: 9 additions & 0 deletions src/main/resources/jcef/css/views/permissions/cards.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
3 changes: 3 additions & 0 deletions src/main/resources/jcef/css/views/permissions/elicit.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -38,13 +39,15 @@
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 {
border-color: color-mix(in srgb, var(--accent) 60%, var(--border));
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);
Expand Down
Loading