diff --git a/CHANGELOG.md b/CHANGELOG.md index b4d7363fc..bfe9d73ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 author's, a line above the list names the accounts they landed under and the addresses no account owns, while the branch can still be fixed. +- **The README says what each agent actually gives you.** All eight providers run + the same way, but what lich can read back out of a session depends on what that + CLI writes down, and until now the only record of the difference was a + contributor document. A Provider support table now sits beside the provider + list, in what you see: the footer's context window, cost and plan gauge, the + card's spinner and bell, whether the machine stays awake, the Review tab's last + turn, palette search, and forking a conversation into a worktree. + ### Changed - **The two sandbox grants say what they hand over.** In Settings › Sandbox, the @@ -31,6 +39,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 regains focus, so a key added with `ssh-add` after the pane was opened is no longer handed over by a switch that never named it. +- **The Review tab says when a provider has no last turn, instead of hiding the + switch.** On a Crush or Cursor CLI session the Working tree / Last turn control + simply never appeared, which reads the same as a bug. It is now drawn with + Last turn dead and a line saying that the provider reports neither the start + nor the end of a turn. A session whose provider does report is unchanged: its + switch appears as soon as it says anything. + ### Fixed - **A session name that wrapped, or followed a wide character, stopped being a diff --git a/CLAUDE.md b/CLAUDE.md index d60e6ec2a..2e1d6ffd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,8 +73,10 @@ Non-negotiable rules. A violation means the work is not done. Claude Code, Codex, Antigravity, opencode, oh-my-pi, Crush, Cursor CLI, Kiro CLI. Anything a session touches (spawn flags, hooks, resume, transcripts, plugin install, MCP) is designed against all eight, and `docs/adding-a-provider.md` is the map of every file one lands in. Equal behaviour is not always possible — - but the gap must be deliberate and written down in the same PR: a `docs/ceilings.md` bullet naming which - providers are out and why. A feature that silently works on a single provider is not done. + but the gap must be deliberate and written down in the same PR, in both places: a `docs/ceilings.md` bullet + naming which providers are out and why, and a row in the Provider support table of `README.md` and + `README.zh-CN.md`, written in what the user sees. A feature that silently works on a single provider is not + done, and neither is one whose gap only a contributor can find. ## Releases diff --git a/README.md b/README.md index 17cf8057f..c21590542 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,10 @@ lich lets you: [opencode](https://github.com/sst/opencode), oh-my-pi, [Crush](https://github.com/charmbracelet/crush), the [Cursor CLI](https://cursor.com/docs/cli) and the - [Kiro CLI](https://kiro.dev/docs/cli/) are all first-class. Point lich at - each binary once, then pick the default or choose per session. + [Kiro CLI](https://kiro.dev/docs/cli/) all run here the same way. Point lich + at each binary once, then pick the default or choose per session. What lich + can read back *out* of a session differs by provider, and + [Provider support](#provider-support) is the table. - **Keep a real terminal.** PTY-backed shells, several per project, rendered on the GPU — searchable scrollback that survives a full page reload. Give one an entrypoint — `lazygit`, `k9s`, `pnpm dev` — and it opens straight into that @@ -87,6 +89,36 @@ Development is active: bugs and feature requests belong in [Issues](https://github.com/omartelo/lich/issues), and what changed in each version is in [CHANGELOG.md](CHANGELOG.md). +## Provider support + +Every provider is spawned in a real terminal, resumed by conversation id, +confined by the same sandbox and handed the same way of reaching your other +sessions. What differs is what lich can *read* back out of a session, because it +reads what each CLI writes down and no two of them write down the same things. + +| What you get | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | +| --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | +| Context window in the footer | yes | yes | no | no | no | no | no | yes | +| Cost in the footer | yes | yes | no | yes | yes | yes | no | credits | +| How much of your plan is left | yes | yes | no | no | no | no | no | no | +| Spinner while a turn runs, ring when it ends | yes | yes | yes | yes | yes | no | no | yes | +| Bell when the agent is blocked on you | yes | yes | no | yes | no | no | no | no | +| Machine kept out of idle sleep while a turn runs | yes | yes | yes | yes | yes | no | no | yes | +| Review tab's **Last turn**, with the agent's recap | yes | yes | yes | yes | yes | no | no | yes | +| Search the conversation from the palette | yes | yes | yes | yes | yes | yes | no | yes | +| Fork a conversation into a new worktree | yes | yes | no | yes | no | no | no | no | + +Crush and Cursor CLI report neither the start nor the end of a turn, and that is +where four of those rows go at once: nothing opens a window for the card's +spinner, for the bell, for the Review tab's last turn, or for the hold that keeps +the machine awake. The Review tab says so on the session itself rather than +leaving you to notice the switch never appeared. Kiro CLI meters spend in credits +rather than dollars, so its own footer is the only place that figure can be read. + +Every gap is deliberate, none of them is lich withholding something the CLI +reports, and [`docs/ceilings.md`](docs/ceilings.md) says what was measured behind +each one. + ## Install One line — detects your distro, verifies the checksum, and installs the native @@ -181,7 +213,7 @@ Package a Linux release locally (needs task package # .deb + .rpm + Arch .pkg.tar.zst in bin/ ``` -Adding another agent CLI to the seven lich runs is the one change that lands in a +Adding another agent CLI to the eight lich runs is the one change that lands in a dozen files across two repositories: [`docs/adding-a-provider.md`](docs/adding-a-provider.md) is the map. diff --git a/README.zh-CN.md b/README.zh-CN.md index d19016df0..9082ce471 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,8 +37,10 @@ [opencode](https://github.com/sst/opencode)、oh-my-pi、 [Crush](https://github.com/charmbracelet/crush)、 [Cursor CLI](https://cursor.com/docs/cli) 和 - [Kiro CLI](https://kiro.dev/docs/cli/) 都是一等公民。把 lich - 指向各自的二进制文件,这只需一次,之后选一个默认的,或者逐个会话单独指定。 + [Kiro CLI](https://kiro.dev/docs/cli/) 在这里都以同样的方式运行。把 lich + 指向各自的二进制文件,这只需一次,之后选一个默认的,或者逐个会话单独指定。而 + lich 能从一个会话里*读*回什么,则因智能体而异, + [各智能体支持到什么程度](#各智能体支持到什么程度)是那张表。 - **留住一个真正的终端。** 由 PTY 支撑的 shell,每个项目可以开好几个,在 GPU 上渲染 —— 滚动缓冲区可以搜索,还能挺过整页刷新。给其中一个设一个入口命令 —— `lazygit`、 `k9s`、`pnpm dev` —— 它每次启动都会直接进到那个工具里。底栏跟随 `cd` 并标明分支 @@ -74,6 +76,32 @@ [Issues](https://github.com/omartelo/lich/issues),每个版本改了什么见 [CHANGELOG.md](CHANGELOG.md)。 +## 各智能体支持到什么程度 + +每个智能体的启动方式都一样:在真实终端里拉起,按会话 id 恢复,用同一套沙箱隔离,也用 +同一套办法去找你的其他会话。不一样的是 lich 能从一个会话里**读**回什么,因为它读的是 +各家 CLI 自己写下来的东西,而没有两家写下的是同一批。 + +| 你能看到什么 | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | +| --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | +| 底栏里的上下文窗口 | 有 | 有 | 无 | 无 | 无 | 无 | 无 | 有 | +| 底栏里的花费 | 有 | 有 | 无 | 有 | 有 | 有 | 无 | 按点数计 | +| 套餐额度还剩多少 | 有 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | +| 一轮进行中转圈,结束后成环 | 有 | 有 | 有 | 有 | 有 | 无 | 无 | 有 | +| 智能体卡在等你时响铃 | 有 | 有 | 无 | 有 | 无 | 无 | 无 | 无 | +| 一轮跑着时不让机器进入空闲休眠 | 有 | 有 | 有 | 有 | 有 | 无 | 无 | 有 | +| Review 页的 **Last turn**,带智能体自己的收尾话 | 有 | 有 | 有 | 有 | 有 | 无 | 无 | 有 | +| 从命令面板搜索对话里说过的内容 | 有 | 有 | 有 | 有 | 有 | 有 | 无 | 有 | +| 把一段对话分叉到新的 worktree | 有 | 有 | 无 | 有 | 无 | 无 | 无 | 无 | + +Crush 和 Cursor CLI 既不报告一轮的开始,也不报告结束,上面有四行是因此一起掉的:没有 +东西去开启那个窗口,卡片的转圈、响铃、Review 页的上一轮,以及让机器不休眠的那次占用, +都无从谈起。Review 页会在会话上直接说明这一点,而不是让你自己去发现那个切换从来没出现 +过。Kiro CLI 按点数而不是按美元计费,所以那个数字只有它自己的底栏能看到。 + +每一处缺口都是刻意的,没有一处是 lich 藏起了 CLI 已经报告的东西, +[`docs/ceilings.md`](docs/ceilings.md) 写着每一处背后量到了什么。 + ## 安装 一行命令 —— 识别你的发行版、校验校验和,并通过你的包管理器安装原生软件包及其依赖: @@ -155,7 +183,7 @@ task test # Go 与前端测试套件 task package # bin/ 下生成 .deb + .rpm + Arch .pkg.tar.zst ``` -在 lich 已经跑着的七个之外再加一个智能体 CLI,是唯一一处会落到两个仓库、十几个文件里 +在 lich 已经跑着的八个之外再加一个智能体 CLI,是唯一一处会落到两个仓库、十几个文件里 的改动:[`docs/adding-a-provider.md`](docs/adding-a-provider.md) 就是那张地图。 ## 赞助 diff --git a/docs/adding-a-provider.md b/docs/adding-a-provider.md index 191a7d346..097ed15b5 100644 --- a/docs/adding-a-provider.md +++ b/docs/adding-a-provider.md @@ -157,6 +157,13 @@ Not optional, and all in the same PR: released build. - **`README.md`, `README.zh-CN.md`, `CLAUDE.md`** — the provider list, which is also the checklist rule 5 points at. +- **The Provider support table in both READMEs** — a column, and an answer in + every row of it. That table is what a stranger reads before choosing a binary, + and nothing derives it from the code, so a column left out of it reads as a + provider that gives you nothing. `turnUnavailableReason` + (`frontend/src/lib/git/last-turn.ts`) is a hand-written list of the same shape: + a provider that reports no session state belongs in it, and one that starts + reporting has to leave. ## The gate diff --git a/docs/ceilings.md b/docs/ceilings.md index caa582aba..af8f292cc 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -187,9 +187,12 @@ work when nobody knows it and that the call site never shows. The mechanism and no ref reaches, so a `git gc --prune` in that checkout between one run and the next leaves the panel reporting a failure rather than an absent turn. And the boundary is the session-state contract, so **Crush and Cursor CLI have no last turn at all**: neither reports a state - (`docs/hooks/session-state.md`), so nothing ever opens or closes a window there, and neither the switch nor - the recap band beside it is ever drawn — a rule read off the session's own reports, not a list of providers, - so it corrects itself the day either one starts reporting. + (`docs/hooks/session-state.md`), so nothing ever opens or closes a window there, and the recap band beside it + is never drawn either. Whether the switch is *offered* is read off the session's own reports and corrects + itself the day either one starts reporting (`turnSwitchable`), but the sentence under the dead switch naming + the provider is a hand-written list (`turnUnavailableReason`, + `frontend/src/lib/git/last-turn.ts`): the two disagree, and the panel names a provider that has since started + reporting, until that list is moved with the contract. - **A finished turn is unread until its own card is watched** (`frontend/src/lib/session/session-status-store.ts`, `frontend/src/providers/projects.tsx`): the solid emerald ring means "back from the agent, not read yet", and it fades only for the session whose terminal is on screen **while the window has focus**. A card left focused in a diff --git a/frontend/src/components/diff/ReviewPanel.tsx b/frontend/src/components/diff/ReviewPanel.tsx index 58a19ef88..35829534a 100644 --- a/frontend/src/components/diff/ReviewPanel.tsx +++ b/frontend/src/components/diff/ReviewPanel.tsx @@ -6,7 +6,12 @@ import type { LastSaid, LastTurn } from "@/lib/api-types" import { onAppEvent } from "@/lib/app-events" import { readDiffSource, writeDiffSource, type DiffSource } from "@/lib/dock-prefs" import { discardTargets, parseDiff, type DiffFile } from "@/lib/git/diff" -import { lastTurnNotice, saidNote, turnSwitchable } from "@/lib/git/last-turn" +import { + lastTurnNotice, + saidNote, + turnSwitchable, + turnUnavailableReason, +} from "@/lib/git/last-turn" import { addReviewComment } from "@/lib/review-comments" import { ProjectService, Terminal } from "@/lib/rpc" import { useActiveSession } from "@/lib/session/use-active-session" @@ -45,13 +50,17 @@ const LAST_TURN_HINT = // the project root. The dock (RightDock) owns the surrounding chrome: width, // full screen, the tab bar and the close button. export function ReviewPanel({ bulk }: { bulk: DiffBulk }) { - const { sessionId, path, hasLastTurn } = useActiveSession() + const { sessionId, path, kind, hasLastTurn } = useActiveSession() const inject = useInject(sessionId) const status = useGitStatus(path) // The source switch is earned, not assumed: a provider that never reports its // state and holds no record has no turn to bracket, so it is offered the // working tree alone (turnSwitchable). const reported = useSessionEverReported(sessionId) + // Where that is standing rather than momentary, the strip is still drawn and + // the dead option carries the reason, so the absence is read once instead of + // being inferred from a control that never appears (turnUnavailableReason). + const unavailable = turnUnavailableReason(kind) // The recap band reads the last thing the agent said, which mid-turn is the // previous turn's, and the band says so rather than leaving the reader to the // card's spinner (saidNote). @@ -209,7 +218,14 @@ export function ReviewPanel({ bulk }: { bulk: DiffBulk }) { return (
- {switchable && } + {(switchable || unavailable !== "") && ( + + )} {source === "turn" && said !== "" && }
void /** When the shown turn's window closed (unix ms), or null when none is. */ endedAt: number | null + /** Why "Last turn" is dead here, "" while it is live (turnUnavailableReason). */ + unavailable: string } // SourceRow is the strip above the file list holding the source switch — the // same shape the Code tab gives its filter field, so the two tabs read as // siblings rather than as two different panels. -function SourceRow({ source, onSource, endedAt }: SourceRowProps) { +function SourceRow({ source, onSource, endedAt, unavailable }: SourceRowProps) { const age = useAge(source === "turn" ? endedAt : null) + const dead = unavailable !== "" return ( -
- next[0] && onSource(next[0] as DiffSource)} - spacing={1} - aria-label="Which changes to show" - className="border border-border p-[0.1875rem]" - > - - Working tree - - +
+ next[0] && onSource(next[0] as DiffSource)} + spacing={1} + aria-label="Which changes to show" + className="border border-border p-[0.1875rem]" > - Last turn - - - {/* The one thing the panel can state without claiming authorship: when - the window closed. Absent while there is no window to date, which is - also what tells an empty turn from an unrecorded one at a glance. */} - {age !== "" && ( - - ended {age} ago - + + Working tree + + + Last turn + + + {/* The one thing the panel can state without claiming authorship: when + the window closed. Absent while there is no window to date, which is + also what tells an empty turn from an unrecorded one at a glance. */} + {age !== "" && ( + + ended {age} ago + + )} +
+ {dead && ( + {unavailable} )}
) diff --git a/frontend/src/lib/git/last-turn.test.ts b/frontend/src/lib/git/last-turn.test.ts index 5742dde55..21533c29c 100644 --- a/frontend/src/lib/git/last-turn.test.ts +++ b/frontend/src/lib/git/last-turn.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { lastTurnNotice, saidNote, turnSwitchable } from "./last-turn" +import { lastTurnNotice, saidNote, turnSwitchable, turnUnavailableReason } from "./last-turn" describe("lastTurnNotice", () => { it("draws the diff only when there is one to draw", () => { @@ -48,6 +48,36 @@ describe("turnSwitchable", () => { }) }) +describe("turnUnavailableReason", () => { + // The two the session-state contract leaves out, each named so the panel says + // whose limit this is rather than reporting a blank of its own. + it("names the provider on the two that report no turn", () => { + expect(turnUnavailableReason("crush")).toContain("Crush") + expect(turnUnavailableReason("cursor")).toContain("Cursor CLI") + }) + + // The sentence has to say what is missing, not only who is missing it: a name + // alone beside a dead control explains nothing. + it("says what the absence costs", () => { + expect(turnUnavailableReason("crush")).toContain("no window to bracket") + }) + + // A provider that does report is silent here even before its first report: + // its switch is seconds away, and a sentence blaming it would be wrong by the + // time it was read. + it("says nothing for a provider that reports", () => { + expect(turnUnavailableReason("claude")).toBe("") + expect(turnUnavailableReason("kiro")).toBe("") + }) + + // A shell has no agent whose turns could be missed, and no active session at + // all is not a provider limit either. + it("says nothing where there is no provider to blame", () => { + expect(turnUnavailableReason("shell")).toBe("") + expect(turnUnavailableReason("")).toBe("") + }) +}) + describe("saidNote", () => { // Mid-turn the band is the only thing on the panel that can say which turn is // speaking: the diff beside it has no window to draw yet. diff --git a/frontend/src/lib/git/last-turn.ts b/frontend/src/lib/git/last-turn.ts index d22c79b2e..0ae67d039 100644 --- a/frontend/src/lib/git/last-turn.ts +++ b/frontend/src/lib/git/last-turn.ts @@ -1,5 +1,6 @@ import type { LastTurn } from "@/lib/api-types" import type { SessionStatus } from "@/lib/session/session-events" +import type { SessionKind } from "@/lib/session/sessions" // What the Review panel draws for the session's last finished turn. The three // answers are kept apart because conflating them is the one mistake this @@ -35,6 +36,37 @@ export function turnSwitchable(everReported: boolean, hasLastTurn: boolean): boo return everReported || hasLastTurn } +// The two providers whose CLI reports neither the start nor the end of a turn, +// under the name a user reads in Settings, mirrored from +// docs/hooks/session-state.md (keep in sync). Crush registers no state at all, +// and Cursor CLI's reports are dropped by closableState, so on both of them +// nothing ever opens a window for the panel to bracket. +// +// Written out one by one rather than derived, for the same reason +// NO_FORK_PROVIDERS is (sessions.ts): a provider added to lich has to answer +// this question on purpose instead of inheriting a claim nobody measured. +const NO_TURN_PROVIDERS: Partial> = { + crush: "Crush", + cursor: "Cursor CLI", +} + +// turnUnavailableReason is the sentence the Review panel wears where the "Last +// turn" source is dead, and "" wherever the absence is not the provider's: a +// shell, and a session whose provider does report but has yet to say anything. +// That second one is a switch about to appear, and naming a provider there +// would be a lie half a second long. +// +// The panel draws the dead switch under this rather than dropping the strip, +// exactly as the card's Fork item does (SessionForkItem): a user who reviews a +// Claude Code turn and finds no such control on their Crush card has no way to +// learn the offer was withheld rather than missing. +export function turnUnavailableReason(kind: SessionKind | ""): string { + const name = kind === "" ? undefined : NO_TURN_PROVIDERS[kind] + return name === undefined + ? "" + : `${name} reports neither the start nor the end of a turn, so there is no window to bracket.` +} + // saidNote is what the recap band says about whose words it is showing. The // band reads the last thing the agent said, which is not always the turn the // diff beside it brackets: while a turn is running those are still the previous