Skip to content
Merged
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: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
34 changes: 31 additions & 3 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 并标明分支
Expand Down Expand Up @@ -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) 写着每一处背后量到了什么。

## 安装

一行命令 —— 识别你的发行版、校验校验和,并通过你的包管理器安装原生软件包及其依赖:
Expand Down Expand Up @@ -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) 就是那张地图。

## 赞助
Expand Down
7 changes: 7 additions & 0 deletions docs/adding-a-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 6 additions & 3 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 58 additions & 30 deletions frontend/src/components/diff/ReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -209,7 +218,14 @@ export function ReviewPanel({ bulk }: { bulk: DiffBulk }) {

return (
<div className="flex h-full flex-col">
{switchable && <SourceRow source={source} onSource={changeSource} endedAt={endedAt} />}
{(switchable || unavailable !== "") && (
<SourceRow
source={source}
onSource={changeSource}
endedAt={endedAt}
unavailable={switchable ? "" : unavailable}
/>
)}
{source === "turn" && said !== "" && <SaidBand text={said} note={saidNote(sessionStatus)} />}
<div className="flex-1 overflow-y-auto">
<PanelBody
Expand Down Expand Up @@ -268,41 +284,53 @@ interface SourceRowProps {
onSource: (source: DiffSource) => 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 (
<div className="flex shrink-0 items-center gap-2 border-b border-border p-1.5">
<ToggleGroup
value={[source]}
onValueChange={(next) => next[0] && onSource(next[0] as DiffSource)}
spacing={1}
aria-label="Which changes to show"
className="border border-border p-[0.1875rem]"
>
<ToggleGroupItem value="worktree" size="sm" className="h-6 px-2.5 text-xs">
Working tree
</ToggleGroupItem>
<ToggleGroupItem
value="turn"
size="sm"
className="h-6 px-2.5 text-xs"
title={LAST_TURN_HINT}
// The reason goes on a line of its own rather than beside the switch: the
// dock resizes down to MIN_REM, where a sentence sharing the row with the
// toggle group has nowhere to go but through it.
<div className="flex shrink-0 flex-col gap-1 border-b border-border p-1.5">
<div className="flex items-center gap-2">
<ToggleGroup
value={[source]}
onValueChange={(next) => next[0] && onSource(next[0] as DiffSource)}
spacing={1}
aria-label="Which changes to show"
className="border border-border p-[0.1875rem]"
>
Last turn
</ToggleGroupItem>
</ToggleGroup>
{/* 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 !== "" && (
<span className="ml-auto shrink-0 pr-1 text-[0.6875rem] text-muted-foreground">
ended {age} ago
</span>
<ToggleGroupItem value="worktree" size="sm" className="h-6 px-2.5 text-xs">
Working tree
</ToggleGroupItem>
<ToggleGroupItem
value="turn"
size="sm"
className="h-6 px-2.5 text-xs"
disabled={dead}
title={dead ? unavailable : LAST_TURN_HINT}
>
Last turn
</ToggleGroupItem>
</ToggleGroup>
{/* 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 !== "" && (
<span className="ml-auto shrink-0 pr-1 text-[0.6875rem] text-muted-foreground">
ended {age} ago
</span>
)}
</div>
{dead && (
<span className="px-1 pb-0.5 text-[0.6875rem] text-muted-foreground">{unavailable}</span>
)}
</div>
)
Expand Down
32 changes: 31 additions & 1 deletion frontend/src/lib/git/last-turn.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading