diff --git a/CHANGELOG.md b/CHANGELOG.md index c2d0eaa..68f2a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The prompt footer shows the session's errands.** Who it owes an answer, the + tasks it handed out that are still running (and how many wait on a human), + and the answers waiting to be collected: `lich: owes "docs" an answer, 2 + tasks out (1 waiting), 1 answer to collect`. Reading never collects an + answer. While it shows, the worker count is left out, since it already counts + every worker. Needs a lich that takes the mod-status contract; an older one + shows nothing. + - **The edit guard tells you too, not only the model.** When a file you edit - was also edited by another lich session in the last 10 minutes, the status - line under the prompt reads `lich: app.js also edited by "quiet-comet"` until - that edit leaves the window. It shares the line with the worker count: + was also edited by another lich session in the last 10 minutes, the prompt + footer reads `lich: app.js also edited by "quiet-comet"` until that edit + leaves the window. It shares the line with the worker count: `lich: 2 workers · app.js also edited by "quiet-comet"`. +### Changed + +- **The lich line moved from the yellow warning to the prompt footer.** The + worker count and the notes above are drawn dim at the footer's right, beside + Claude Code's own mode labels, instead of as a `⚠` warning under the prompt. + ## [0.17.0] - 2026-10-05 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 7d43366..0ae7379 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,13 +17,14 @@ hooks/crush-hooks.json # hook registration, Crush (hand-merged into c hooks/ # hook scripts (all four run them; only how they are addressed differs) hooks/detail.jq # what a tool call acts on, shared by two of those scripts hooks/win-run.cmd # runs one of those scripts on Windows (Codex) -hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the five mods below +hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the six mods below hooks/mod-control.js # Claude Code mod: lich drives the session hooks/agent-cards.js # Claude Code mod: a general-purpose subagent runs as a lich session hooks/mod-usage.js # Claude Code mod: the session reports its own context, rate limits and cost hooks/edit-guard.js # Claude Code mod: an edit says which other lich session edited the file hooks/worker-answer.js # Claude Code mod: a lich subagent worker's last message answers its task -hooks/status-line.js # the one status line Claude Code gives the plugin, shared by the mods above +hooks/mod-status.js # Claude Code mod: the session's relay errands in the prompt footer +hooks/status-line.js # the lich line in the prompt footer, shared by the mods above opencode/lich.js # opencode client: a module, not a command omp/lich.js # omp client: a module, not a command docs/ # lich ⇄ plugin communication contracts @@ -63,6 +64,7 @@ Contracts are **canonical in the lich repository** (`docs/hooks/` there); this p - [docs/agent-cards.md](docs/agent-cards.md): a general-purpose subagent Claude Code starts runs as a lich session, through the `lich open` CLI rather than an HTTP contract; Claude Code only - [docs/mod-usage.md](docs/mod-usage.md): a Claude Code session reports the context window, rate limits and cost it measured itself, through a mod hooking `session.measure`; Claude Code only - [docs/mod-answer.md](docs/mod-answer.md): a lich subagent worker answers the task it was handed with its turn's final message, through a mod reading the main loop's `classic.Stop` and `turn.complete`; Claude Code only +- [docs/mod-status.md](docs/mod-status.md): a Claude Code session shows the relay errands it is part of (owed, out, ready) in its prompt footer, through a mod reading `/mod/status`, which never collects; Claude Code only - [docs/edit-guard.md](docs/edit-guard.md): an edit to a file another lich session edited in the last minutes carries a note naming it, through markers in the checkout's git dir rather than an HTTP contract; Claude Code only - [docs/providers.md](docs/providers.md) — the per-harness map, including what installing on omp takes and what it cannot report @@ -97,14 +99,16 @@ lich it subscribes to nothing at all. Claude Code mod, handing it a fake `$` and asserting its acks against the mod-control fixtures and its polls against the commands lich sends; [tests/mod-usage.test.mjs](tests/mod-usage.test.mjs) does the same for its -usage reports against the mod-usage fixtures, and +usage reports against the mod-usage fixtures, [tests/worker-answer.test.mjs](tests/worker-answer.test.mjs) for a worker's -answer against the mod-answer fixtures. +answer against the mod-answer fixtures, and +[tests/mod-status.test.mjs](tests/mod-status.test.mjs) for the footer line it +draws from the mod-status response. [tests/edit-guard.test.mjs](tests/edit-guard.test.mjs) hands the edit guard a fake `$` with an in-memory git dir and asserts the markers it leaves and the note another session's marker adds, and [tests/status-line.test.mjs](tests/status-line.test.mjs) that the mods share -the one status line Claude Code gives the plugin. +one lich line in the prompt footer. [tests/agent-cards.test.mjs](tests/agent-cards.test.mjs) hands the agent-cards mod the same kind of fake `$` and asserts the `lich open` argv it runs, the Agent result it answers each outcome with, and that a call it does @@ -115,6 +119,7 @@ dependencies. [tests/mod-control.engine.test.ts](tests/mod-control.engine.test.ts) and [tests/agent-cards.engine.test.ts](tests/agent-cards.engine.test.ts) and [tests/mod-usage.engine.test.ts](tests/mod-usage.engine.test.ts) and +[tests/mod-status.engine.test.ts](tests/mod-status.engine.test.ts) and [tests/worker-answer.engine.test.ts](tests/worker-answer.engine.test.ts) and [tests/edit-guard.engine.test.ts](tests/edit-guard.engine.test.ts) run the mods under Claude Code's own engine (`claude plugin test .`); they need a @@ -168,6 +173,6 @@ Same standard as the `lich` repository (Keep a Changelog + SemVer). Releases are 1. Move the `[Unreleased]` entries in `CHANGELOG.md` under the new version heading (with date) and refresh the compare links at the bottom. 2. Pick the number by what changed: a release that sends anything lich's contract did not already accept (a new endpoint, header, field or value) bumps the **minor** (the major, from 1.0); anything else bumps the **patch**. lich accepts every release below the next minor of the contract it implements, so a contract change released as a patch reaches a lich that refuses it. `docs/hooks/README.md` in lich, Versioning, is the rule. -3. Align `version` in `.claude-plugin/plugin.json` **and** `.codex-plugin/plugin.json` with the tag, and `plugin_version` in each `hooks/report-*.sh` and `PLUGIN_VERSION` in `opencode/lich.js`, `omp/lich.js`, `hooks/mod-control.js`, `hooks/mod-usage.js` and `hooks/worker-answer.js`, the header every report sends. The suite fails while any of them disagrees with the Claude Code manifest. The root `plugin.json` is not a third one to bump: Antigravity's manifest takes only a `name`, and lich writes its own carrying the tag when it installs a release. +3. Align `version` in `.claude-plugin/plugin.json` **and** `.codex-plugin/plugin.json` with the tag, and `plugin_version` in each `hooks/report-*.sh` and `PLUGIN_VERSION` in `opencode/lich.js`, `omp/lich.js`, `hooks/mod-control.js`, `hooks/mod-usage.js`, `hooks/mod-status.js` and `hooks/worker-answer.js`, the header every report sends. The suite fails while any of them disagrees with the Claude Code manifest. The root `plugin.json` is not a third one to bump: Antigravity's manifest takes only a `name`, and lich writes its own carrying the tag when it installs a release. 4. Annotated tag `vX.Y.Z` + push with the tag. 5. `gh release create vX.Y.Z` with the notes taken from the matching `CHANGELOG.md` section. diff --git a/README.md b/README.md index b21469c..e38ee19 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,13 @@ hooks/report-tool.sh # session-state hook: the tool a turn is runni hooks/report-session-start.sh # session-start hook hooks/report-title.sh # session-title hook hooks/report-touched.sh # session-touched hook -hooks/lich.js # the Claude Code mods' entry, which registers the five below +hooks/lich.js # the Claude Code mods' entry, which registers the six below hooks/mod-control.js # mod-control client, a Claude Code mod hooks/agent-cards.js # a general-purpose subagent as a lich session, a Claude Code mod hooks/mod-usage.js # mod-usage client, a Claude Code mod hooks/edit-guard.js # names the other lich session that edited a file, a Claude Code mod hooks/worker-answer.js # mod-answer client: a lich worker's last message answers its task, a Claude Code mod +hooks/mod-status.js # mod-status client: the session's errands in the prompt footer, a Claude Code mod opencode/lich.js # opencode client: all four reports plus the seven tools, one module omp/lich.js # omp client: the reports, one module docs/ # client-side docs, one per contract diff --git a/docs/agent-cards.md b/docs/agent-cards.md index 4d9f8ed..e002770 100644 --- a/docs/agent-cards.md +++ b/docs/agent-cards.md @@ -54,7 +54,7 @@ Nothing runs after that: the delivery `lich open` printed decides the answer. | delivery | the Agent call answers | |-------------------------------|---------------------------------------------------------------------| -| `pending` | `async_launched` at once, plus a note on how the report comes back; the worker is counted in the status line | +| `pending` | `async_launched` at once, plus a note on how the report comes back; the worker is counted in the footer | | `answered` | `completed`: the report, then the checkout (and branch) it is on | | `unanswered` | `async_launched`, as for `pending`: a turn that ended unanswered usually left a background command running, and the report still arrives as a [lich] note | | `unread`, `undelivered` | denied: the task never reached the card | @@ -110,10 +110,11 @@ for an isolated call. committed.** Uncommitted changes are not in the worker's checkout. On a detached HEAD, or when git cannot answer, the base is left to lich, which uses the project's current branch. -- **Claude Code's status line counts the workers still running**, as its own - "N agents" hint counts background agents: `N workers` under the prompt - (`$.ui.status`), which Claude Code shows after the plugin's name as - `lich: N workers`, cleared at none. Every 5 seconds while any runs, the mod reads +- **The prompt footer counts the workers still running**, as Claude Code's own + "N agents" hint counts background agents: `lich: N workers` in the lich line + of the footer (`hooks/status-line.js`, see [mod-status.md](mod-status.md)), + cleared at none, and left out while that line shows lich's errands, which + count every worker. Every 5 seconds while any runs, the mod reads `$LICH_BIN sessions --json`. A worker in this checkout is finished once lich no longer lists it: lich closes it after it reported and its turn ended (`lich open --subagent` in lich's docs/cli.md), while a turn it ended with a diff --git a/docs/edit-guard.md b/docs/edit-guard.md index e4e022f..204849f 100644 --- a/docs/edit-guard.md +++ b/docs/edit-guard.md @@ -52,12 +52,11 @@ unless `LICH_SESSION_ID` is set. after the tool's result: the path, the session, the time and how long ago, and that `send_to_session` or `lich send` reaches that session. A refused call carries the line too, since it explains the refusal. -6. The same edit puts ` also edited by ` in the status - line under the prompt, for the user, who never reads the `context` line. +6. The same edit puts ` also edited by ` in the lich line + of the prompt footer, for the user, who never reads the `context` line. It stays until the marker leaves the 10-minute window, or a newer one - replaces it. Claude Code keeps one status line per plugin, so it shares the - line with the worker count of [agent-cards.md](agent-cards.md), through - `hooks/status-line.js`. + replaces it. The line is shared with the other mods through + `hooks/status-line.js` ([mod-status.md](mod-status.md), What the line reads). The line names the session by the label on its card, the name the user and `lich send` know it by. lich puts no label in a session's environment and no diff --git a/docs/mod-status.md b/docs/mod-status.md new file mode 100644 index 0000000..2589b99 --- /dev/null +++ b/docs/mod-status.md @@ -0,0 +1,62 @@ +# Mod: status + +Client side of the [mod-status contract](https://github.com/omartelo/lich/blob/main/docs/hooks/mod-status.md) +(the endpoint and the response are defined in the lich repository; this plugin +only implements them). + +Shows, in the prompt footer, the relay errands the session is +part of: who it owes an answer, the tasks it handed out that are still running, +and the answers waiting to be collected. The card shows these in the window; +this puts them in front of the person at the terminal. + +| client | Claude Code | Codex | Antigravity | opencode | omp | Crush | +|-----------------------|---------------------------------------------------------|---------------------|---------------------|---------------------|---------------------|---------------------| +| `hooks/mod-status.js` | mod, registered by `hooks/lich.js`, the `modules` entry | none: no mod system | none: no mod system | none: no mod system | none: no mod system | none: no mod system | + +| when | read | +|---------------------------------------------|----------------------------------------------------------| +| `session.start`, then every 5 seconds | `GET /mod/status?token=…&session_id=…`, `X-Lich-Plugin` | + +## What the line reads + +Each list that is not empty adds a piece, in this order, joined by commas: + +| list | piece | +|---------|-----------------------------------------------------------------------------------------| +| `owed` | `owes "Session 26" an answer` for one asked by a session, `owes N answers` otherwise | +| `open` | `N tasks out`, plus `(M waiting)` for those whose session waits on a human | +| `ready` | `N answers to collect` | + +The line reads `lich: 1 task out, 1 answer to collect`. With all three lists +empty the piece is gone. + +The mods share one lich line, built by `hooks/status-line.js`: the errands, +then the worker count of [agent-cards.md](agent-cards.md), then the note of +[edit-guard.md](edit-guard.md), joined by ` · `. While the errands show, the +worker count is left out: every worker that mod opens is an errand of this +session, so `open` already counts it. The line is drawn dim among the mode +labels at the right of the prompt footer, by a `ui.render` hook on +`SessionMode` that adds it after Claude Code's own labels. `$.ui.status` is +not used: Claude Code draws it as a yellow warning under the prompt, and keeps +only one per plugin. + +## How it behaves + +- **Outside lich it never reads.** It reads the three variables in + `session.start` and stops there when one is missing. +- **Only an interactive session reads.** A `claude -p` run from a tool inside a + lich session inherits that session's variables, and would show the parent's + errands. Its `session.start` hook matches `isInteractive: true`. +- **Reading never collects.** An answer under `ready` stays in the inbox for + the agent's `wait_for_answer`. +- **A failed read is not retried.** The piece is cleared until a read works + again, 5 seconds later. +- **A `404` stops the reads for the session**: that lich predates the contract. + +## Limits + +- **Up to 5 seconds late.** lich pushes nothing; an errand that opens or + closes shows at the next read. +- **Labels are the ones at send time**, as lich keeps them. +- **Claude Code only.** Every other harness has no mod system; there the card + is the only place these errands show. diff --git a/docs/providers.md b/docs/providers.md index a29e8d6..5ba374b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -28,6 +28,7 @@ This repository is one plugin, packaged for each harness it supports: | `hooks/mod-control.js` | Claude Code | the mod-control client, a mod | | `hooks/agent-cards.js` | Claude Code | runs a general-purpose subagent as a lich session, a mod | | `hooks/mod-usage.js` | Claude Code | the mod-usage client, a mod | +| `hooks/mod-status.js` | Claude Code | the mod-status client, a mod | | `hooks/edit-guard.js` | Claude Code | names the other lich session that edited a file, a mod | | `hooks/worker-answer.js` | Claude Code | the mod-answer client: a worker's last message answers its task, a mod | | `opencode/lich.js` | opencode | the whole client, as a module | diff --git a/hooks/agent-cards.js b/hooks/agent-cards.js index aeac4a4..bc7a785 100644 --- a/hooks/agent-cards.js +++ b/hooks/agent-cards.js @@ -22,7 +22,7 @@ // Every function that takes `$` is declared at the top level: the loader // refuses a module that hands `$` to a nested function or keeps it in a variable. -import { statusLineWith } from "./status-line.js" +import { setStatusPart } from "./status-line.js" // lich's `promptLimit` (internal/relay). lich checks it only after the session // was opened, so a task over it would leave an empty card behind. @@ -326,17 +326,16 @@ async function runAsSession($, state, e, next) { } /** - * Pins how many workers this session waits on under its prompt, as Claude - * Code's own hint does for its background agents, and clears it at none. - * Claude Code puts the plugin's name before the line, so it reads - * "lich: 2 workers" (measured on 2.1.289). + * Shows how many workers this session waits on in the footer's lich line, as + * Claude Code's own hint does for its background agents, and clears it at none. * * @param {Engine} $ * @param {State} state */ function showWorkers($, state) { const count = state.workers.size - $.ui.status(statusLineWith("workers", count === 0 ? undefined : `${count} worker${count === 1 ? "" : "s"}`)) + setStatusPart("workers", count === 0 ? undefined : `${count} worker${count === 1 ? "" : "s"}`) + $.ui.invalidate("ui.render") } /** diff --git a/hooks/edit-guard.js b/hooks/edit-guard.js index c9204eb..2ca231c 100644 --- a/hooks/edit-guard.js +++ b/hooks/edit-guard.js @@ -11,7 +11,7 @@ // Every function that takes `$` is declared here at the top level: the loader // refuses a module that hands `$` to a nested function. -import { statusLineWith } from "./status-line.js" +import { setStatusPart } from "./status-line.js" // Long enough to span the other session's turn that made the edit, short // enough that a marker from work long finished stops raising notes. @@ -147,7 +147,8 @@ let shownMarker function showEditBy($, marker, who) { shownMarker = marker const name = marker.path.slice(Math.max(marker.path.lastIndexOf("/"), marker.path.lastIndexOf("\\")) + 1) - $.ui.status(statusLineWith("edits", `${name} also edited by ${who}`)) + setStatusPart("edits", `${name} also edited by ${who}`) + $.ui.invalidate("ui.render") } /** @@ -157,7 +158,8 @@ function showEditBy($, marker, who) { function clearEditBy($, marker) { if (shownMarker !== marker) return shownMarker = undefined - $.ui.status(statusLineWith("edits", undefined)) + setStatusPart("edits", undefined) + $.ui.invalidate("ui.render") } /** @param {Engine} $ @param {unknown} error */ diff --git a/hooks/lich.js b/hooks/lich.js index c947564..5f3247a 100644 --- a/hooks/lich.js +++ b/hooks/lich.js @@ -5,7 +5,9 @@ import { register as registerAgentCards } from "./agent-cards.js" import { register as registerEditGuard } from "./edit-guard.js" import { register as registerModControl } from "./mod-control.js" +import { register as registerModStatus } from "./mod-status.js" import { register as registerModUsage } from "./mod-usage.js" +import { register as registerStatusLine } from "./status-line.js" import { register as registerWorkerAnswer } from "./worker-answer.js" /** @param {import('claude-code').On} on */ @@ -15,4 +17,6 @@ export function register(on) { registerModUsage(on) registerEditGuard(on) registerWorkerAnswer(on) + registerModStatus(on) + registerStatusLine(on) } diff --git a/hooks/mod-status.js b/hooks/mod-status.js new file mode 100644 index 0000000..d195be5 --- /dev/null +++ b/hooks/mod-status.js @@ -0,0 +1,107 @@ +// Shows the relay errands a Claude Code session is part of in its status line: +// who it owes an answer, the tasks it handed out that are still running, and +// the answers waiting to be collected. Contract: ../docs/mod-status.md, +// canonical in https://github.com/omartelo/lich/blob/main/docs/hooks/mod-status.md +// +// The one contract that reads rather than reports, and reading never collects: +// an answer listed as ready stays in the inbox for the agent. +// +// Every function that takes `$` is declared here at the top level: the loader +// refuses a module that hands `$` to a nested function. + +import { setStatusPart } from "./status-line.js" + +// Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release). +const PLUGIN_VERSION = "0.17.0" + +// The contract asks for a read at most every few seconds; the worker count in +// agent-cards.js polls lich on the same period. +const READ_EVERY_MS = 5000 + +/** + * @typedef {import('claude-code').EngineInterface} Engine + * @typedef {{ ticket: string, from: string, asked: string }} Owed + * @typedef {{ ticket: string, target: string, state: string }} Open + * @typedef {{ ticket: string, target: string, status: string }} Ready + * @typedef {{ owed: Owed[], open: Open[], ready: Ready[] }} Status + * @typedef {{ url: string, timer?: import('claude-code').Timer, reading: boolean }} State + */ + +/** @param {number} count @param {string} noun */ +function counted(count, noun) { + return `${count} ${noun}${count === 1 ? "" : "s"}` +} + +/** + * The status line's text for `status`, or undefined when there is nothing to + * show. A task out on a session waiting for a human is called out: nobody but + * the user can move it. + * + * @param {Status} status + */ +function errandsLine({ owed, open, ready }) { + const pieces = [] + if (owed.length === 1 && owed[0].from) pieces.push(`owes "${owed[0].from}" an answer`) + else if (owed.length > 0) pieces.push(`owes ${counted(owed.length, "answer")}`) + if (open.length > 0) { + const waiting = open.filter((errand) => errand.state === "waiting").length + pieces.push(`${counted(open.length, "task")} out${waiting > 0 ? ` (${waiting} waiting)` : ""}`) + } + if (ready.length > 0) pieces.push(`${counted(ready.length, "answer")} to collect`) + return pieces.length === 0 ? undefined : pieces.join(", ") +} + +/** + * @param {Engine} $ + * @returns {Promise} + */ +async function statusUrlFromEnv($) { + const [port, token, session] = await Promise.all([ + $.env.get("LICH_PORT"), + $.env.get("LICH_TOKEN"), + $.env.get("LICH_SESSION_ID"), + ]) + if (!port || !token || !session) return undefined + return `http://127.0.0.1:${port}/mod/status?token=${encodeURIComponent(token)}&session_id=${encodeURIComponent(session)}` +} + +/** + * One read. A failed one draws nothing from lich until a read works again, + * and is never retried; a 404 is a lich older than the contract, which stops + * the reads for the session. + * + * @param {Engine} $ + * @param {State} state + */ +async function read($, state) { + if (state.reading) return + state.reading = true + /** @type {string | undefined} */ + let line + try { + const response = await $.http.fetch(state.url, { headers: { "X-Lich-Plugin": PLUGIN_VERSION } }) + if (response.status === 404) state.timer?.cancel() + if (response.ok) line = errandsLine(JSON.parse(response.text)) + } catch { + // Unreachable or unreadable: the line goes until the next read works. + } + state.reading = false + setStatusPart("errands", line) + $.ui.invalidate("ui.render") +} + +/** @param {import('claude-code').On} on */ +export function register(on) { + // Interactive sessions only: a `claude -p` started from a tool inside a lich + // session inherits its variables, and would read the parent's errands. + on("session.start", { isInteractive: true }, async ($, e, next) => { + const url = await statusUrlFromEnv($) + if (url) { + /** @type {State} */ + const state = { url, reading: false } + state.timer = $.clock.every(READ_EVERY_MS, () => read($, state)) + read($, state) + } + return next(e) + }) +} diff --git a/hooks/status-line.js b/hooks/status-line.js index 53c17c8..fb5aad5 100644 --- a/hooks/status-line.js +++ b/hooks/status-line.js @@ -1,28 +1,46 @@ -// The lich status line under the prompt, shared by the mods. Claude Code keeps -// one `$.ui.status` line per plugin and every mod here is the plugin `lich`, so -// a mod that set it directly would wipe what another mod put there. Each mod -// owns one part instead, and the line is the parts joined in a fixed order. +// The lich line in Claude Code's prompt footer, shared by the mods. It is drawn +// dim among the mode labels at the footer's right (`SessionMode`), not through +// `$.ui.status`, which Claude Code draws as a yellow warning. Each mod owns one +// part of the line, and the line is the parts joined in a fixed order. +// +// The errands lich lists (mod-status.js) include every worker agent-cards.js +// opened, so while that part shows, the worker count is left out of the line. +// +// The parts live in this module, not in `$.state`: every part is redrawn by +// its own mod within seconds, except an edit note, which a hot reload of the +// plugin drops early. -/** @typedef {"workers" | "edits"} Part */ +/** @typedef {"errands" | "workers" | "edits"} Part */ /** @type {Part[]} */ -const ORDER = ["workers", "edits"] +const ORDER = ["errands", "workers", "edits"] const SEPARATOR = " · " /** @type {Map} */ const parts = new Map() /** - * The line with this mod's part set to `text`, undefined removing it; the - * caller passes it to `$.ui.status` itself, since the loader refuses `$` - * handed across an import. Undefined when no part is left, which clears it. + * Sets this mod's part of the line; undefined removes it. The caller then + * redraws the footer with `$.ui.invalidate("ui.render")`, since the loader + * refuses `$` handed across an import. * * @param {Part} part * @param {string | undefined} text */ -export function statusLineWith(part, text) { +export function setStatusPart(part, text) { if (text === undefined) parts.delete(part) else parts.set(part, text) - const shown = ORDER.filter((p) => parts.has(p)).map((p) => parts.get(p)) - return shown.length === 0 ? undefined : shown.join(SEPARATOR) +} + +function statusLine() { + const shown = ORDER.filter((p) => parts.has(p) && !(p === "workers" && parts.has("errands"))) + return shown.length === 0 ? undefined : `lich: ${shown.map((p) => parts.get(p)).join(SEPARATOR)}` +} + +/** @param {import('claude-code').On} on */ +export function register(on) { + on("ui.render", { component: "SessionMode" }, ($, e, next) => { + const line = statusLine() + return line === undefined ? next(e) : next({ ...e, props: { ...e.props, modes: [...e.props.modes, line] } }) + }) } diff --git a/tests/agent-cards.engine.test.ts b/tests/agent-cards.engine.test.ts index de2b78e..be88631 100644 --- a/tests/agent-cards.engine.test.ts +++ b/tests/agent-cards.engine.test.ts @@ -45,12 +45,13 @@ function worldWith(on: On, env: Record, ...lich: ProcessRunResul const native: string[] = [] const argvs: string[][] = [] const toasts: string[] = [] - const statuses: (string | undefined)[] = [] + const footer = { modes: [] as readonly string[] } mock.env(on, env) const clock = mock.clock(on) - on('ui.status', async (_$, e) => { - statuses.push(e.text) - return { value: undefined } + on('ui.render', { component: 'SessionMode' }, async ($, e) => { + footer.modes = e.props.modes + const { Text } = $.ui.resolve(e) + return h(Text, {}, e.props.modes.join(' & ')) }) on('session.start', async (_$, e) => ({ cwd: e.cwd })) on('process.run', async (_$, e) => { @@ -72,7 +73,13 @@ function worldWith(on: On, env: Record, ...lich: ProcessRunResul native.push(`TaskStop ${e.task_id}`) return { deny: 'the native TaskStop ran' } }) - return { native, argvs, toasts, statuses, clock } + return { native, argvs, toasts, footer, clock } +} + +/** The lich line the footer draws now, Claude Code's own modes being none. */ +async function footerLine($: { ui: { render: (input: object) => Promise } }, w: { footer: { modes: readonly string[] } }) { + await $.ui.render({ surface: 'terminal', component: 'SessionMode', requestId: 'footer', props: { modes: [] } }) + return w.footer.modes.at(-1) } test('a task lich took comes back at once as a backgrounded agent', async ($, on) => { @@ -138,7 +145,7 @@ test('turned off in lich, every subagent stays native and lich is never run', as expect(w.argvs).toEqual([]) }) -test('a running worker shows in the status line until lich closes it, and TaskStop closes one', async ($, on) => { +test('a running worker shows in the footer until lich closes it, and TaskStop closes one', async ($, on) => { const pending = { ...OPENED, delivery: { ticket: 't1', target: BRANCH, status: 'pending', answer: '' } } const peer = (name: string, state: string) => ({ label: name, name, project: 'lich', kind: 'claude', state }) const w = world( @@ -152,19 +159,19 @@ test('a running worker shows in the status line until lich closes it, and TaskSt await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) await $.tool.call(CALL) await $.tool.call({ ...CALL, tool_use_id: 'toolu_second' }) - expect(w.statuses).toEqual(['1 worker', '2 workers']) + expect(await footerLine($, w)).toBe('lich: 2 workers') await w.clock.advance(5000) expect(w.argvs.at(-1)).toEqual(['/opt/lich/bin/lich', 'sessions', '--json']) - expect(w.statuses.at(-1)).toBe('2 workers') + expect(await footerLine($, w)).toBe('lich: 2 workers') const stopped = await $.tool.call({ tool: 'TaskStop', task_id: 'second-1a2b' }) expect(stopped.result).toEqual(expect.objectContaining({ task_id: 'second-1a2b', task_type: 'local_agent' })) expect(w.argvs.at(-1)).toEqual(['/opt/lich/bin/lich', 'close', 'second-1a2b']) - expect(w.statuses.at(-1)).toBe('1 worker') + expect(await footerLine($, w)).toBe('lich: 1 worker') await w.clock.advance(5000) - expect(w.statuses.at(-1)).toBe(undefined) + expect(await footerLine($, w)).toBe(undefined) await $.tool.call({ tool: 'TaskStop', task_id: 'a712043a56e1aafb1' }) expect(w.native).toEqual(['TaskStop a712043a56e1aafb1']) diff --git a/tests/agent-cards.test.mjs b/tests/agent-cards.test.mjs index ad4f98a..e674618 100644 --- a/tests/agent-cards.test.mjs +++ b/tests/agent-cards.test.mjs @@ -14,6 +14,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' +import { footerLine } from './contract.mjs' import { register } from '../hooks/agent-cards.js' const LICH_BIN = '/opt/lich/bin/lich' @@ -98,7 +99,7 @@ function load({ env = ENV, runs = [], times = [1000, 4000] } = {}) { return answer(argv, init) }, }, - ui: { toast: (text) => toasts.push(text), status: (text) => statuses.push(text) }, + ui: { toast: (text) => toasts.push(text), invalidate: () => statuses.push(footerLine()) }, } return { @@ -479,17 +480,17 @@ const SESSIONS = [LICH_BIN, 'sessions', '--json'] // done but not finished. test('a worker in this checkout shows in the status line until lich closes it', async () => { const { mod } = await delegate([exits(2, openedHere('pending'))]) - assert.deepEqual(mod.statuses, ['1 worker']) + assert.deepEqual(mod.statuses, ['lich: 1 worker']) assert.equal(mod.timers.length, 1) await mod.tick(listed(peer(SHARED, 'busy'))) assert.deepEqual(mod.ran.at(-1).argv, SESSIONS) await mod.tick(listed(peer(SHARED, 'done'))) - assert.deepEqual(mod.statuses, ['1 worker']) + assert.deepEqual(mod.statuses, ['lich: 1 worker']) assert.equal(mod.timers.length, 1) await mod.tick(listed()) - assert.deepEqual(mod.statuses, ['1 worker', undefined]) + assert.deepEqual(mod.statuses, ['lich: 1 worker', undefined]) assert.equal(mod.timers.length, 0) }) @@ -498,23 +499,23 @@ test('a worker in this checkout shows in the status line until lich closes it', test('an isolated worker shows in the status line until lich lists its turn done', async () => { const { mod } = await delegate([onBranch('main'), exits(2, opened('pending'))], ISOLATED) await mod.tick(listed(peer(OPENED, 'busy'))) - assert.deepEqual(mod.statuses, ['1 worker']) + assert.deepEqual(mod.statuses, ['lich: 1 worker']) await mod.tick(listed(peer(OPENED, 'done'))) - assert.deepEqual(mod.statuses, ['1 worker', undefined]) + assert.deepEqual(mod.statuses, ['lich: 1 worker', undefined]) }) test('a worker waiting on a permission, or not reported yet, is still running', async () => { const { mod } = await delegate([exits(2, openedHere('pending'))]) await mod.tick(listed(peer(SHARED, 'waiting'))) await mod.tick(listed(peer(SHARED, ''))) - assert.deepEqual(mod.statuses, ['1 worker']) + assert.deepEqual(mod.statuses, ['lich: 1 worker']) assert.equal(mod.timers.length, 1) }) test('a worker lich no longer lists, closed once it reported, is finished', async () => { const { mod } = await delegate([exits(2, openedHere('pending'))]) await mod.tick(listed()) - assert.deepEqual(mod.statuses, ['1 worker', undefined]) + assert.deepEqual(mod.statuses, ['lich: 1 worker', undefined]) assert.equal(mod.timers.length, 0) }) @@ -523,18 +524,18 @@ test('two workers count as two, and one finishing leaves one', async () => { await mod.start() await mod.call(AGENT) await mod.call(ISOLATED) - assert.deepEqual(mod.statuses, ['1 worker', '2 workers']) + assert.deepEqual(mod.statuses, ['lich: 1 worker', 'lich: 2 workers']) assert.equal(mod.timers.length, 1, 'one watch for every worker') await mod.tick(listed(peer(OPENED, 'busy'))) - assert.deepEqual(mod.statuses.at(-1), '1 worker') + assert.deepEqual(mod.statuses.at(-1), 'lich: 1 worker') }) test('a list lich could not give keeps the count and asks again', async () => { const { mod } = await delegate([exits(2, openedHere('pending'))]) await mod.tick(exits(1, '', 'lich: no lich is running\n')) await mod.tick(rejects('$.process.run(lich) failed')) - assert.deepEqual(mod.statuses, ['1 worker']) + assert.deepEqual(mod.statuses, ['lich: 1 worker']) assert.equal(mod.timers.length, 1) }) @@ -565,7 +566,7 @@ test('TaskStop on a worker in this checkout closes its session, which keeps noth assert.deepEqual(mod.ran.at(-1).argv, [LICH_BIN, 'close', SHARED.name]) assert.deepEqual(passed, []) assert.deepEqual(answer, stopped(SHARED.name)) - assert.deepEqual(mod.statuses, ['1 worker', undefined]) + assert.deepEqual(mod.statuses, ['lich: 1 worker', undefined]) }) test('TaskStop on an isolated worker stops its turn and leaves its worktree and card', async () => { @@ -593,5 +594,5 @@ test('TaskStop lich could not carry out is refused with its reason, and the work const { answer, passed } = await mod.call(taskStop(SHARED.name)) assert.deepEqual(passed, []) assert.deepEqual(answer, { deny: `lich could not stop "${SHARED.label}": lich: no session named "${SHARED.name}"` }) - assert.deepEqual(mod.statuses, ['1 worker']) + assert.deepEqual(mod.statuses, ['lich: 1 worker']) }) diff --git a/tests/contract.mjs b/tests/contract.mjs index c5a949e..5a17a5e 100644 --- a/tests/contract.mjs +++ b/tests/contract.mjs @@ -283,3 +283,23 @@ export const lichEnv = (port) => ({ LICH_TOKEN: TOKEN, LICH_SESSION_ID, }) + +// ------------------------------------------------------------ status line -- + +import { register as registerStatusLine } from '../hooks/status-line.js' + +let drawFooter +registerStatusLine((event, matcher, hook) => { + drawFooter = hook +}) + +/** + * The lich line the footer draws now, as Claude Code would draw it with no + * mode of its own on; undefined when it draws none. A fake engine calls it + * from `$.ui.invalidate`, where a mod asks for the redraw. + */ +export function footerLine() { + const e = { surface: 'terminal', component: 'SessionMode', requestId: 'footer', props: { modes: [] } } + const drawn = drawFooter({}, e, (next) => next) + return drawn.props.modes.at(-1) +} diff --git a/tests/edit-guard.engine.test.ts b/tests/edit-guard.engine.test.ts index e4973c9..93b4bd0 100644 --- a/tests/edit-guard.engine.test.ts +++ b/tests/edit-guard.engine.test.ts @@ -69,12 +69,13 @@ test('the note names the other session by the label on its card', async ($, on) expect(ran.context?.[0]).toContain('lich session "quiet-comet" at') }) -test('the user sees the other session in the status line', async ($, on) => { +test('the user sees the other session in the footer', async ($, on) => { const w = world(on, 'lich-a', [{ label: 'quiet-comet', name: 'repo-7c0e', project: 'repo', kind: 'claude', state: 'busy', id: B_ID }]) - const statuses: (string | undefined)[] = [] - on('ui.status', async (_$, e) => { - statuses.push(e.text) - return { value: undefined } + let modes: readonly string[] = [] + on('ui.render', { component: 'SessionMode' }, async (_$, e) => { + modes = e.props.modes + const { Text } = _$.ui.resolve(e) + return h(Text, {}, e.props.modes.join(' & ')) }) await $.tool.call(EDIT) const [path] = markerOf(w.disk) ?? [] @@ -82,7 +83,8 @@ test('the user sees the other session in the status line', async ($, on) => { await $.tool.call(EDIT) - expect(statuses).toEqual(['app.js also edited by "quiet-comet"']) + await $.ui.render({ surface: 'terminal', component: 'SessionMode', requestId: 'footer', props: { modes: [] } }) + expect(modes).toEqual(['lich: app.js also edited by "quiet-comet"']) }) test('outside lich the edit runs untouched and nothing is recorded', async ($, on) => { diff --git a/tests/edit-guard.test.mjs b/tests/edit-guard.test.mjs index b9ba042..e0e9d46 100644 --- a/tests/edit-guard.test.mjs +++ b/tests/edit-guard.test.mjs @@ -12,6 +12,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' +import { footerLine } from './contract.mjs' import { register } from '../hooks/edit-guard.js' import { register as registerEntry } from '../hooks/lich.js' @@ -67,7 +68,7 @@ function session({ return { cancel: () => {} } }, }, - ui: { log: (text) => logs.push(text), status: (text) => statuses.push(text) }, + ui: { log: (text) => logs.push(text), invalidate: () => statuses.push(footerLine()) }, process: { run: async (argv, options) => { if (argv[0] === LICH) { @@ -319,10 +320,10 @@ test('the user sees the other session in the status line until its marker leaves roster: async () => ({ exitCode: 0, stdout: `${JSON.stringify([A_PEER])}\n`, stderr: '' }), }) - assert.deepEqual(b.statuses, ['app.js also edited by "quiet-comet"']) + assert.deepEqual(b.statuses, ['lich: app.js also edited by "quiet-comet"']) assert.deepEqual(b.timers.map((t) => t.ms), [9 * MINUTE], 'cleared when the 10-minute window of A\'s edit ends') b.timers[0].fn() - assert.deepEqual(b.statuses, ['app.js also edited by "quiet-comet"', undefined]) + assert.deepEqual(b.statuses, ['lich: app.js also edited by "quiet-comet"', undefined]) }) test('an older edit leaving the window does not clear a newer one from the status line', async () => { @@ -339,7 +340,7 @@ test('an older edit leaving the window does not clear a newer one from the statu await b.edit(other) b.timers[0].fn() - assert.deepEqual(b.statuses, ['app.js also edited by lich-a', 'other.js also edited by lich-a']) + assert.deepEqual(b.statuses, ['lich: app.js also edited by lich-a', 'lich: other.js also edited by lich-a']) }) test('no note, no status line', async () => { diff --git a/tests/fixtures/mod-status.json b/tests/fixtures/mod-status.json new file mode 100644 index 0000000..88c0055 --- /dev/null +++ b/tests/fixtures/mod-status.json @@ -0,0 +1,16 @@ +{ + "owed": [ + {"ticket": "b19cb405", "from": "Session 26", "asked": "Sou a sessão Claude do lich-plugin"}, + {"ticket": "e2d4f6a8", "from": "", "asked": "If a < b && c > d, see ."} + ], + "open": [ + {"ticket": "4f0c1a2e", "target": "docs", "state": "busy"}, + {"ticket": "5a6b7c8d", "target": "lint", "state": "waiting"}, + {"ticket": "6e7f8091", "target": "build", "state": ""}, + {"ticket": "9d3e77b0", "target": "tests", "state": "queued"} + ], + "ready": [ + {"ticket": "c7a91e04", "target": "review", "status": "answered"}, + {"ticket": "d1e2f3a4", "target": "a ({ status: 200, ok: true, headers: /** * Answers the first polls with `batches`, one each, parks the rest, and records - * every ack; also stands for the engine's own session start, which the kit + * every ack; reads of /mod/status (mod-status.js, loaded beside this mod) find + * no errands. Also stands for the engine's own session start, which the kit * leaves to the test. */ function lich(on: On, ...batches: unknown[][]) { @@ -24,6 +25,7 @@ function lich(on: On, ...batches: unknown[][]) { let polls = 0 on('session.start', async (_$, e) => ({ cwd: e.cwd })) on('http.fetch', async (_$, e) => { + if (e.url.includes('/mod/status')) return { value: json({ owed: [], open: [], ready: [] }) } if (e.url.includes('/mod/acks')) { acks.push(JSON.parse(e.init?.body ?? '{}')) return { value: { status: 204, ok: true, headers: {}, text: '' } } diff --git a/tests/mod-status.engine.test.ts b/tests/mod-status.engine.test.ts new file mode 100644 index 0000000..18983e3 --- /dev/null +++ b/tests/mod-status.engine.test.ts @@ -0,0 +1,59 @@ +// The status reads under Claude Code's own engine: `claude plugin test .` loads +// hooks/lich.js, which registers hooks/mod-status.js, the test's hooks standing +// for lich (`http.fetch`) and for the engine's own footer (`ui.render` of `SessionMode`). +// tests/mod-status.test.mjs is the suite CI runs against the contract fixture; +// this one proves the module loads beside the other mods and its line reaches +// the status line through the engine. + +import { expect, mock, test } from 'claude-code/testing' +import type { On } from 'claude-code' + +const ENV = { LICH_PORT: '47999', LICH_TOKEN: 'tok', LICH_SESSION_ID: 'lich-1' } +const STATUS = { + owed: [], + open: [{ ticket: '4f0c1a2e', target: 'docs', state: 'busy' }], + ready: [{ ticket: 'c7a91e04', target: 'review', status: 'answered' }], +} + +/** + * Answers every status read with STATUS and parks every other request, as lich + * does a poll with nothing queued; records the modes the footer is drawn with. + */ +function lich(on: On) { + const reads: string[] = [] + const footer = { modes: [] as readonly string[] } + on('session.start', async (_$, e) => ({ cwd: e.cwd })) + on('ui.render', { component: 'SessionMode' }, async ($, e) => { + footer.modes = e.props.modes + const { Text } = $.ui.resolve(e) + return h(Text, {}, e.props.modes.join(' & ')) + }) + on('http.fetch', async (_$, e) => { + if (e.url.includes('/mod/status')) { + reads.push(e.url) + return { value: { status: 200, ok: true, headers: {}, text: JSON.stringify(STATUS) } } + } + return new Promise(() => {}) + }) + return { reads, footer } +} + +test("lich's errands reach the footer", async ($, on) => { + mock.env(on, ENV) + const clock = mock.clock(on) + const world = lich(on) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + await clock.settle() + expect(world.reads[0]).toContain('/mod/status?token=tok&session_id=lich-1') + await $.ui.render({ surface: 'terminal', component: 'SessionMode', requestId: 'footer', props: { modes: [] } }) + expect(world.footer.modes).toEqual(['lich: 1 task out, 1 answer to collect']) +}) + +test('a non-interactive run reads nothing', async ($, on) => { + mock.env(on, ENV) + const clock = mock.clock(on) + const world = lich(on) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: false }) + await clock.settle() + expect(world.reads).toHaveLength(0) +}) diff --git a/tests/mod-status.test.mjs b/tests/mod-status.test.mjs new file mode 100644 index 0000000..d957130 --- /dev/null +++ b/tests/mod-status.test.mjs @@ -0,0 +1,150 @@ +// Pins the Claude Code mod's status reads (hooks/mod-status.js) to lich's +// mod-status contract: the request it sends, and the status line it draws +// from the response lich answers with (mod-status.json). +// +// Like the other mod suites it imports the module and hands it a fake engine: +// `$.http.fetch` records each read and answers with what a test hands it, and +// `$.clock.every` keeps the timer for the test to fire. +// +// Run: node --test tests/ + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' + +import { LICH_SESSION_ID, PLUGIN_VERSION, ROOT, TOKEN, footerLine, lichEnv } from './contract.mjs' +import { register } from '../hooks/mod-status.js' + +const PORT = 47997 +const STATUS = JSON.parse(readFileSync(path.join(ROOT, 'tests', 'fixtures', 'mod-status.json'), 'utf8')) +const NONE = { owed: [], open: [], ready: [] } + +const answering = (body, status = 200) => async () => ({ status, ok: status < 300, headers: {}, text: JSON.stringify(body) }) + +/** Registers the module against a fake engine; `answer` answers each read. */ +function load({ env = lichEnv(PORT), answer = answering(NONE) } = {}) { + let start + register((event, matcher, hook) => { + assert.equal(event, 'session.start') + assert.deepEqual(matcher, { isInteractive: true }) + start = hook + }) + const reads = [] + const statuses = [] + const timers = [] + const $ = { + env: { get: async (name) => env[name] }, + clock: { + every: (ms, fn) => { + const timer = { ms, fn, cancelled: false, cancel: () => (timer.cancelled = true) } + timers.push(timer) + return timer + }, + }, + ui: { invalidate: () => statuses.push(footerLine()) }, + http: { + fetch: async (url, init = {}) => { + reads.push({ url, method: init.method ?? 'GET', headers: init.headers ?? {} }) + return answer() + }, + }, + } + const settled = () => new Promise((r) => setTimeout(r, 10)) + return { + reads, + statuses, + timers, + start: async (isInteractive = true) => { + if (isInteractive) await start($, { cwd: '/w', surface: 'terminal', isInteractive }, async (e) => e) + await settled() + }, + tick: async () => { + timers[0].fn() + await settled() + }, + } +} + +test('a read is the GET the contract spells, repeated every few seconds', async () => { + const mod = load() + await mod.start() + + const url = new URL(mod.reads[0].url) + assert.equal(mod.reads[0].method, 'GET') + assert.equal(`${url.host}${url.pathname}`, `127.0.0.1:${PORT}/mod/status`) + assert.equal(url.searchParams.get('token'), TOKEN) + assert.equal(url.searchParams.get('session_id'), LICH_SESSION_ID) + assert.equal(mod.reads[0].headers['X-Lich-Plugin'], PLUGIN_VERSION) + assert.equal(mod.timers.length, 1) + assert.equal(mod.timers[0].ms, 5000) + await mod.tick() + assert.equal(mod.reads.length, 2) +}) + +test("lich's status draws as one line", async () => { + const mod = load({ answer: answering(STATUS) }) + await mod.start() + + assert.deepEqual(mod.statuses, ['lich: owes 2 answers, 4 tasks out (1 waiting), 2 answers to collect']) +}) + +test('one answer owed names the session that asked, unless it came from the command line', async () => { + const [fromSession, fromCli] = STATUS.owed + const named = load({ answer: answering({ ...NONE, owed: [fromSession] }) }) + const cli = load({ answer: answering({ ...NONE, owed: [fromCli] }) }) + await named.start() + await cli.start() + + assert.deepEqual(named.statuses, ['lich: owes "Session 26" an answer']) + assert.deepEqual(cli.statuses, ['lich: owes 1 answer']) +}) + +test('nothing to show clears the line', async () => { + const mod = load() + await mod.start() + + assert.deepEqual(mod.statuses, [undefined]) +}) + +test('a failed read clears the line, is not retried, and the next one still runs', async () => { + let answer = answering(STATUS, 500) + const mod = load({ answer: () => answer() }) + await mod.start() + assert.deepEqual(mod.statuses, [undefined]) + assert.equal(mod.reads.length, 1) + + answer = async () => { + throw new Error('connect ECONNREFUSED') + } + await mod.tick() + answer = answering(STATUS) + await mod.tick() + + assert.deepEqual(mod.statuses.at(-1), 'lich: owes 2 answers, 4 tasks out (1 waiting), 2 answers to collect') + assert.equal(mod.timers[0].cancelled, false) +}) + +test('a lich older than the contract stops the reads for the session', async () => { + const mod = load({ answer: answering({}, 404) }) + await mod.start() + + assert.equal(mod.timers[0].cancelled, true) + assert.deepEqual(mod.statuses, [undefined]) +}) + +test('outside lich nothing is read', async () => { + const mod = load({ env: {} }) + await mod.start() + + assert.equal(mod.reads.length, 0) + assert.equal(mod.timers.length, 0) +}) + +test("a non-interactive run never reads the card that started it", async () => { + const mod = load() + await mod.start(false) + + assert.equal(mod.reads.length, 0) + assert.equal(mod.timers.length, 0) +}) diff --git a/tests/refresh-fixtures.sh b/tests/refresh-fixtures.sh index ad7b841..8b38088 100755 --- a/tests/refresh-fixtures.sh +++ b/tests/refresh-fixtures.sh @@ -23,3 +23,11 @@ for file in session-state.jsonl session-start.jsonl session-title.jsonl \ curl -fsSL "$base/$file" -o "$here/fixtures/$file" echo "refreshed $file at $ref" done + +# The mod-status contract is in no lich release yet: its fixture is read at the +# commit of lich's feat/mod-status branch that adds it, and folds into the list +# above once a release ships it. +status=6c207ba176a36ae2149977dfc5f9568d7410fb9b +curl -fsSL "https://raw.githubusercontent.com/omartelo/lich/$status/docs/hooks/fixtures/mod-status.json" \ + -o "$here/fixtures/mod-status.json" +echo "refreshed mod-status.json at $status (unreleased)" diff --git a/tests/status-line.test.mjs b/tests/status-line.test.mjs index 2ad636f..c2e3484 100644 --- a/tests/status-line.test.mjs +++ b/tests/status-line.test.mjs @@ -1,25 +1,58 @@ -// Pins hooks/status-line.js: Claude Code keeps one status line per plugin, so -// the mods' parts share it instead of overwriting each other. +// Pins hooks/status-line.js: the mods' parts share one lich line, drawn among +// the mode labels at the right of Claude Code's prompt footer. // // Run: node --test tests/ import { test } from 'node:test' import assert from 'node:assert/strict' -import { statusLineWith } from '../hooks/status-line.js' +import { footerLine } from './contract.mjs' +import { register, setStatusPart } from '../hooks/status-line.js' test('parts share the one line in a fixed order, and the line goes with the last part', () => { - const statuses = [ - statusLineWith('edits', 'app.js also edited by "docs"'), - statusLineWith('workers', '2 workers'), - statusLineWith('edits', undefined), - statusLineWith('workers', undefined), - ] - - assert.deepEqual(statuses, [ - 'app.js also edited by "docs"', - '2 workers · app.js also edited by "docs"', - '2 workers', + const lines = [] + for (const [part, text] of [ + ['edits', 'app.js also edited by "docs"'], + ['workers', '2 workers'], + ['edits', undefined], + ['workers', undefined], + ]) { + setStatusPart(part, text) + lines.push(footerLine()) + } + + assert.deepEqual(lines, [ + 'lich: app.js also edited by "docs"', + 'lich: 2 workers · app.js also edited by "docs"', + 'lich: 2 workers', undefined, ]) }) + +test("the worker count is left out while lich's errands show, which include every worker", () => { + setStatusPart('workers', '1 worker') + setStatusPart('errands', '1 task out') + const withErrands = footerLine() + setStatusPart('errands', undefined) + const without = footerLine() + setStatusPart('workers', undefined) + + assert.equal(withErrands, 'lich: 1 task out') + assert.equal(without, 'lich: 1 worker') +}) + +test("the line goes after Claude Code's own modes, which are kept", () => { + let draw + register((event, matcher, hook) => { + assert.equal(event, 'ui.render') + assert.deepEqual(matcher, { component: 'SessionMode' }) + draw = hook + }) + const e = { surface: 'terminal', component: 'SessionMode', requestId: 'footer', props: { modes: ['focus'] } } + const passOn = (rewritten) => rewritten + + assert.equal(draw({}, e, passOn), e, 'with no part the footer is passed on untouched') + setStatusPart('workers', '1 worker') + assert.deepEqual(draw({}, e, passOn).props.modes, ['focus', 'lich: 1 worker']) + setStatusPart('workers', undefined) +})