diff --git a/CHANGELOG.md b/CHANGELOG.md index bf14c0c3..1681f9f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/ceilings.md b/docs/ceilings.md index 9b041499..970e0252 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -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 diff --git a/internal/terminal/draft.go b/internal/terminal/draft.go index 9df74e1d..27b244cd 100644 --- a/internal/terminal/draft.go +++ b/internal/terminal/draft.go @@ -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+ arrives. } @@ -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 { diff --git a/internal/terminal/draft_test.go b/internal/terminal/draft_test.go index 502ba38a..806e0590 100644 --- a/internal/terminal/draft_test.go +++ b/internal/terminal/draft_test.go @@ -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. @@ -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}, }