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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Resolve conflicts, Fix CI errors and a worktree started from an issue send their message to a new Claude Code session as soon as it is at its prompt, where it could sit unsent for up to a minute, until you pressed Enter. lich read the terminal's own answers to Claude Code's startup questions as you typing at that prompt, and held the message back for you.
- A long Markdown file, such as lich's own CHANGELOG.md, previews in the Code and Review tabs without freezing the window: the preview draws only the part on screen, where it used to lay out the whole file on every frame.
- A Markdown preview keeps the words apart where the source wraps a line, where they used to run together, and a table no longer squeezes a narrow column into broken words: each column is at least as wide as its longest word.

Expand Down
7 changes: 7 additions & 0 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,13 @@ work when nobody knows it and that the call site never shows. The mechanism and
Claude Code's `SendMessage`. Settings › Providers › Claude Code turns it off with "Subagents as lich sessions"
(`store.SubagentCards`), which reaches the mod as `LICH_SUBAGENT_CARDS=off` at spawn, so a session already
running keeps the value it started with.
- **A managed plugin can hide the Stop a worker's answer waits on**: on a machine with managed settings, Claude
Code's builtin `cc-plugin-sec-default` sends `classic.Stop` on past the plugins a person installs (measured on
2.1.306), and no mod call says so. The worker's mod then answers from `turn.complete` alone, held back only while
`$.agent.list()` shows a subagent at work (lich-plugin's `docs/mod-answer.md`). A turn that handed a background
shell, monitor or workflow off answers with what it said when it did, and the errand takes that first answer, so
the report of the turn that resumes later is lost. A lich-plugin older than the release carrying that fallback
never answers such a worker, and its errand stays open until the worker is closed.

## Git, worktrees and review

Expand Down
23 changes: 23 additions & 0 deletions internal/terminal/draft.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,19 @@ func (sess *session) noteBracket(span []byte) {
// and with mouse tracking on (?1003h) so does every pointer movement over the
// terminal. Counting the printable bytes inside those as typing would leave a
// session whose prompt never looks free again.
//
// The terminal's answers to a TUI's queries come in on this path too, as
// control strings (OSC, DCS, APC) that run to ST or BEL. One arrives in a single
// write, so a string with no terminator in it is Alt and a key instead.
func escapeSpan(data []byte) (int, bool) {
if len(data) < 2 {
return len(data), false
}
if bytes.IndexByte([]byte("]P_^X"), data[1]) >= 0 {
if end := controlStringEnd(data); end > 0 {
return end, true
}
}
if data[1] != '[' && data[1] != 'O' {
return 2, true // ESC + one key, which is how Alt+<key> arrives.
}
Expand All @@ -217,6 +226,20 @@ func escapeSpan(data []byte) (int, bool) {
return len(data), false
}

// controlStringEnd is how many bytes the control string at data[0] takes up,
// through its ST (ESC \) or BEL, or 0 when data holds no terminator.
func controlStringEnd(data []byte) int {
for i := 2; i < len(data); i++ {
if data[i] == '\a' {
return i + 1
}
if data[i] == esc && i+1 < len(data) && data[i+1] == '\\' {
return i + 2
}
}
return 0
}

// drafting is whether the user has unsent input at this prompt as of now.
// Called under the service's mu.
func (sess *session) drafting(now time.Time) bool {
Expand Down
22 changes: 22 additions & 0 deletions internal/terminal/draft_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ func TestPointerAndCursorInputIsNotADraft(t *testing.T) {
}
}

// The window's terminal answers the queries a TUI makes at startup through the
// same input path as the keyboard. Claude Code asks for the background colour
// (OSC 11), the terminal's name (XTVERSION) and kitty graphics support, and
// every answer is a control string full of printable bytes. Read as typing,
// they held a fresh session's prompt for a minute, and a handoff sent there
// waited until the user pressed Enter.
func TestTerminalRepliesAreNotADraft(t *testing.T) {
t.Parallel()
svc, _ := settled(t)

svc.noteInput("s1", []byte("\x1b]11;rgb:2e2e/3434/4040\x1b\\"))
svc.noteInput("s1", []byte("\x1b]11;rgb:2e2e/3434/4040\a"))
svc.noteInput("s1", []byte("\x1bP>|libghostty 1.2.0\x1b\\"))
svc.noteInput("s1", []byte("\x1b_Gi=31;OK\x1b\\"))
if !svc.Ready("s1") {
t.Error("the terminal answering a query took the prompt away")
}
}

// AtPrompt is the question for text that is pasted and not sent: the person's
// half-written line is where it belongs, so it must not count against the
// prompt, while a session that is not at one at all still does.
Expand Down Expand Up @@ -137,6 +156,9 @@ func TestEscapeSpan(t *testing.T) {
{"csi with a tail after it", "\x1b[Bx", 3, true},
{"ss3 function key", "\x1bOP", 3, true},
{"alt and a letter", "\x1bv", 2, true},
{"osc reply ended by st", "\x1b]11;rgb:0/0/0\x1b\\x", 16, true},
{"osc reply ended by bel", "\x1b]11;rgb:0/0/0\ax", 15, true},
{"alt and a string introducer", "\x1bPx", 2, true},
{"truncated csi", "\x1b[<35;10", 8, false},
{"escape alone", "\x1b", 1, false},
}
Expand Down
Loading