diff --git a/cmd/contr/e2e_harness_test.go b/cmd/contr/e2e_harness_test.go index 389620a7..750d80c8 100644 --- a/cmd/contr/e2e_harness_test.go +++ b/cmd/contr/e2e_harness_test.go @@ -118,6 +118,7 @@ func newHarness(t *testing.T) *harness { cfg.Profiles["sizer"] = agents.Profile{Argv: []string{"sh", "-c", `draw() { printf "\033[2J\033[H"; set -- $(stty size); echo "grid $2x$1"; i=0; while [ $i -lt $2 ]; do printf %d $((i % 10)); i=$((i+1)); done; echo; }; trap draw WINCH; draw; while :; do sleep 1; done`}} cfg.Profiles["claude-idle"] = agents.Profile{Argv: []string{claude, "idle"}} + cfg.Profiles["claude-next"] = agents.Profile{Argv: []string{claude, "idle:next=commit%20this%20and%20open%20a%20PR"}} cfg.Profiles["claude-working"] = agents.Profile{Argv: []string{claude, "working"}} cfg.Profiles["claude-blocked"] = agents.Profile{Argv: []string{claude, "blocked:kind=permission:msg=Allow%20rm"}} cfg.Profiles["claude-error"] = agents.Profile{Argv: []string{claude, "error"}} diff --git a/cmd/contr/e2e_see_test.go b/cmd/contr/e2e_see_test.go index f70a26cd..6fa5d599 100644 --- a/cmd/contr/e2e_see_test.go +++ b/cmd/contr/e2e_see_test.go @@ -197,6 +197,12 @@ var seeFixtures = map[string]seeFixture{ ready: "lead, ", press: []string{"Chat"}, }, + "chat-next": { + about: "the chat fixture with lead idle and a suggested next prompt in its terminal's input line (D281): the composer shows it in place of the placeholder", + setup: func(t *testing.T, h *harness) { seeChatAs(t, h, "claude-next") }, + ready: "lead, ", + press: []string{"Chat"}, + }, "chat-blocks": { about: "the orchestrator lead in Chat mode with an older status board (frozen: the agent's three columns, its answer's time), the live board (state, PR and checks from a fake gh: CI running 2 of 3, CI passed 3 of 3, building, waiting, and a worker that is not found) and a merge queue (merged, CI running, blocked: 1 check failed, a note) (D267)", setup: seeChatBlocks, diff --git a/cmd/contr/e2e_titlebar_test.go b/cmd/contr/e2e_titlebar_test.go index d9de931b..8fdb8c42 100644 --- a/cmd/contr/e2e_titlebar_test.go +++ b/cmd/contr/e2e_titlebar_test.go @@ -23,7 +23,8 @@ import ( // fakeClaude is a program that looks like Claude Code in a terminal: the welcome box, the // conversation, a status line and the prompt box. It tells hc its state with the OSC the hook -// sends (last argument: working, idle, error or blocked:kind=...:msg=..., which also prints a permission menu). +// sends (last argument: working, idle, error or blocked:kind=...:msg=..., which also prints a permission menu; +// idle:next=... prints a suggested next prompt, faint, in the input line). const fakeClaude = `package main import ( @@ -57,8 +58,17 @@ func main() { fmt.Printf("\x1b]7501;state=%s\x07", state) time.Sleep(time.Hour) } + next := "" + if i := strings.Index(state, ":next="); i >= 0 { + // The suggested next prompt, faint after the mark as Claude Code draws it (D281). + state, next = state[:i], strings.ReplaceAll(state[i+len(":next="):], "%20", " ") + } fmt.Print("╭───────────────────────────────────────────────────╮\n") - fmt.Print("│ > │\n") + if next != "" { + fmt.Printf("❯ \x1b[2m%s\x1b[0m\n", next) + } else { + fmt.Print("│ > │\n") + } fmt.Print("╰───────────────────────────────────────────────────╯\n") fmt.Print(" ? for shortcuts\n") fmt.Printf("\x1b]7501;state=%s\x07", state) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5809388b..cad683f8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -255,4 +255,5 @@ replaces it. | D278 | 2026-11-11 | Settings → Setup: a read-only setup checklist (#713, S1). `Setup` is the first Settings tab: `Setup`, `General`, `Agents`, `Updates` (changes the tab list of D186). Package `internal/setup` checks hc (`hc --version`), the hc core (the frame's live state), the contr plugin (`claude plugin list --json`), the Claude meter (data under 24 h old), gh (`gh auth status`), and the optional codex and `contr` command. Each command has a 5 s limit and runs off the UI thread when Settings opens, on Recheck, and on activation while the tab shows; nothing runs while Settings is closed. No check reads files in `~/.claude` or `~/.codex`. A step shows a line with a Copy button; contr runs no command here (S3 adds a confirmed plugin install, D65) and never edits Claude Code's settings (the meter stays copy only, D66). The Setup tab and the sidebar Settings button get the D146 dot while a required check is not ready. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing in one place. | | D279 | 2026-11-11 | The composer completes file paths with "@" (#708, tracker #634; docs/spec/ChatView.md, Path popup). "@" at the caret, at the start or after a space, opens the popup of D273 with the paths of the session's working folder: `git ls-files --cached --others --exclude-standard` (no ignored files, no deleted ones) with their folders, or a plain read of the typed folder outside git. Parts that start with the text first, then paths that hold it, at most 50. A folder goes in as "@dir/" and keeps the popup open. The popup logic takes a query at the caret (`QueryAt`) for both "/" and "@", so there is one popup with two sources. | | D280 | 2026-11-11 | The first launch opens Settings → Setup (#713, S2). The setup checks of D278 run once at start, off the UI thread, after the window shows, so the D146 dot shows without opening Settings; then only when Settings opens, on Recheck and on activation while the tab shows; no timer. When the start pass finds a required check not ready (the hc core not counted: it can still be connecting) and `setupSeen` in the settings file is false, Settings opens on the Setup tab once and `setupSeen` is stored. It never opens by itself again. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing without a search. | +| D281 | 2026-11-11 | The composer shows Claude Code's suggested next prompt (#727, tracker #634). Claude Code does not write the suggestion to the transcript (it is made with the transcript off) and `terminal.read` gives text without styles, so contr reads the selected terminal's screen as escape sequences (the MyGo terminal's `Snapshot`) and takes the text after the last `❯` only when all of it is faint (SGR 2), as a real session draws it; typed text, a sent prompt or a menu choice is not faint. The read runs 300 ms after output, off the UI thread, through a new `OnOutput` hook of `ghostty.Conn`. While the field is empty, the suggestion is the field's placeholder; Tab or Right takes it, after the slash popup's Tab. Only the selected terminal has a screen, so other sessions show none (docs/spec/ChatView.md, Composer). | Owner in #727. | | D282 | 2026-11-11 | A Claude worker gets the PATH of the process that spawns it (#728). `hc update` restarts the core under launchd, whose PATH is short, and each terminal inherits the PATH of the core, so `claude` was not found and the worker ended at once. Spawn now sets `CONTR_PATH` in the terminal: the PATH of the spawning process, then `~/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin`, without doubles. The clean wrapper (D216) adds these folders after the PATH of the core and removes the variable, so the PATH of the core still comes first. A program that does not start through the clean wrapper (not Claude) gets no change. | Seen on 2026-11-11 after the first `Update hc` (D277): two spawns ended with "claude: not found". | diff --git a/docs/spec/ChatView.md b/docs/spec/ChatView.md index bae45e34..3497d695 100644 --- a/docs/spec/ChatView.md +++ b/docs/spec/ChatView.md @@ -143,6 +143,15 @@ the circle. - The field takes the focus as it appears: when the chat shows (the switch, ⌘E). It keeps the focus after a send, and takes it back after a click on Send or Stop. +- Suggested next prompt (D281): when a turn ends, Claude Code shows a + suggested next prompt as faint text after `❯` in its input line. contr + reads it from the selected terminal's screen (the faint text, SGR 2, after + the last `❯`), 300 ms after output, off the UI thread. While the field is + empty, the suggestion takes the place of the placeholder, in the same + `ink-48`. Tab or Right puts it into the field (the slash popup takes Tab + first while it shows). Enter with an empty field sends nothing. Typed text + hides it; a new turn clears the input line, so it goes. No suggestion: the + placeholder. - Enter sends, and so does a click on Send; ⇧Enter breaks the line. A blank field sends nothing. Send types the text into the selected session's own terminal and presses Enter through the agent source's `Send`, as the orchestrate pill does. The field diff --git a/internal/app/chat.go b/internal/app/chat.go index 8a16ba91..b01f9372 100644 --- a/internal/app/chat.go +++ b/internal/app/chat.go @@ -117,6 +117,7 @@ func (f *Frame) attachChat(env view.Env, src agentstate.AgentSource) (stop func( f.Chat = chatview.New(env, f.Session.StateDir, nil) f.Chat.Worker = f.agentAt f.Chat.Meta = f.chatMeta + f.Chat.NextPrompt = f.nextPrompt f.Chat.Team = f.chatTeam f.Chat.OpenNeeds = f.openNeeds // The notch is made after the chat: it is read when the pinned prompt builds. diff --git a/internal/app/chatview/chat.go b/internal/app/chatview/chat.go index 7a67c70d..09cc6233 100644 --- a/internal/app/chatview/chat.go +++ b/internal/app/chatview/chat.go @@ -57,6 +57,11 @@ type Chat struct { // Path gives the files and folders of the session at addr for the "@" popup (D279); nil // shows none. Path PathSource + // NextPrompt is Claude Code's suggested next prompt in the input line of the terminal at addr, + // "" for none (D281). It runs on the UI thread. Nil: the placeholder shows. + NextPrompt func(addr string) string + // next is the suggestion the composer shows in its empty field, for Tab. + next string // sg is the state of the composer's suggestion popup. sg suggest diff --git a/internal/app/chatview/composer.go b/internal/app/chatview/composer.go index 52361d5e..16c0cdaa 100644 --- a/internal/app/chatview/composer.go +++ b/internal/app/chatview/composer.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/egoist/mygo/transfer" "github.com/egoist/mygo/ui" @@ -138,7 +139,14 @@ func (ch *Chat) composer(c *ui.Context, id string, st liveState) { field = field.Margin(fieldGap, 0, 0, 0) } field.Children(func() { - in := ui.TextAreaBase(c.Key("composer:"+id), draft).Label(LabelComposer).Placeholder(Placeholder(ch.name(id))). + hint := Placeholder(ch.name(id)) + if ch.next = ""; *draft == "" && ch.NextPrompt != nil { + // Claude Code's suggested next prompt takes the place of the placeholder (D281). + if ch.next = ch.NextPrompt(id); ch.next != "" { + hint = ch.next + } + } + in := ui.TextAreaBase(c.Key("composer:"+id), draft).Label(LabelComposer).Placeholder(hint). PlaceholderColor(pal.Ink48).TextColor(pal.Ink100).FontSize(ct.Size).LineHeight(ct.Line/ct.Size). Lines(1, composerLines).Grow(1).MinWidth(0).Padding((actionSize-ct.Line)/2, 0, (actionSize-ct.Line)/2, theme.Space6).AutoFocus().Bind(&ch.field). OnTextKey(func(k ui.TextKey) bool { return ch.textKey(id, st, k) }). @@ -261,13 +269,21 @@ func (ch *Chat) action(c *ui.Context, id string, st liveState, a Action) { }) } -// textKey is a key of the field that types no text. Enter sends; ⇧Enter goes on to the field, +// textKey is a key of the field that types no text. Tab or Right in an empty field takes the +// suggested next prompt (D281). Enter sends; ⇧Enter goes on to the field, // which breaks the line. While the suggestion popup shows, it takes its keys first (D273). Esc interrupts the agent while it works and does nothing else: it closes // and switches nothing. func (ch *Chat) textKey(id string, st liveState, k ui.TextKey) bool { if ch.suggestKey(id, k) { return true } + if (k.Key == ui.KeyTab || k.Key == ui.KeyRight) && k.Mods == 0 && ch.next != "" && k.Text() == "" { + // Tab or Right takes the suggested next prompt into the empty field (D281). + k.SetText(ch.next) + ch.sg.caret = utf8.RuneCountInString(ch.next) + ch.next = "" + return true + } switch { case k.Key == ui.KeyEnter && k.Mods == 0: ch.submit(id, st, k.Text(), func() { k.SetText("") }) diff --git a/internal/app/chatview/composer_test.go b/internal/app/chatview/composer_test.go index 69e179de..13fdb5bb 100644 --- a/internal/app/chatview/composer_test.go +++ b/internal/app/chatview/composer_test.go @@ -51,6 +51,16 @@ func newSession(t *testing.T, status string) *sess { return s } +// hint is the composer field's placeholder. +func (s *sess) hint() string { + s.t.Helper() + a, ok := s.tt.Access(LabelComposer) + if !ok { + s.t.Fatal("no composer field") + } + return a.Placeholder +} + // typeIn types text into the composer, which has the focus. func (s *sess) typeIn(text string) { s.t.Helper() @@ -446,3 +456,60 @@ func (r *rig) footerWorks() bool { } return false } + +func TestComposerShowsTheNextPrompt(t *testing.T) { + s := newSession(t, "idle") + next := "commit this" + s.ch.NextPrompt = func(addr string) string { + if addr != term { + return "" + } + return next + } + s.tt.Frame() + if h := s.hint(); h != "commit this" { + t.Fatalf("the field shows %q, want the suggestion", h) + } + // Enter with an empty field sends nothing, as before. + s.key(0, ui.KeyEnter) + if len(s.sent) != 0 { + t.Errorf("Enter sent %v", s.sent) + } + // Tab takes it into the field. + s.key(0, ui.KeyTab) + if s.draft() != "commit this" { + t.Fatalf("Tab gave the draft %q", s.draft()) + } + s.key(0, ui.KeyEnter) + if len(s.sent) != 1 || s.sent[0] != "commit this" { + t.Errorf("sent %v", s.sent) + } +} + +func TestComposerTypingHidesTheNextPrompt(t *testing.T) { + s := newSession(t, "idle") + s.ch.NextPrompt = func(string) string { return "commit this" } + s.tt.Frame() + s.typeIn("x") + if h := s.hint(); h == "commit this" { + t.Errorf("the suggestion stays with text in the field") + } + // Tab with text in the field does not replace it. + s.key(0, ui.KeyTab) + if s.draft() != "x" { + t.Errorf("Tab changed the draft to %q", s.draft()) + } +} + +func TestComposerWithoutNextPromptShowsThePlaceholder(t *testing.T) { + s := newSession(t, "idle") + s.ch.NextPrompt = func(string) string { return "" } + s.tt.Frame() + if h := s.hint(); h != "Message lead…" { + t.Errorf("the field shows %q, want the placeholder", h) + } + s.key(0, ui.KeyTab) + if s.draft() != "" { + t.Errorf("Tab with no suggestion gave %q", s.draft()) + } +} diff --git a/internal/app/chatview/sheet_test.go b/internal/app/chatview/sheet_test.go index 8a37f2db..af252851 100644 --- a/internal/app/chatview/sheet_test.go +++ b/internal/app/chatview/sheet_test.go @@ -180,18 +180,20 @@ type composerState struct { team *Team text string h float32 + next string // Claude Code's suggested next prompt (D281) } var composerStates = []composerState{ - {"Idle, empty: no button, the keys in the footer", "idle", &Team{Agents: 2, Idle: 2}, "", 150}, - {"Idle, text: Send", "idle", &Team{Agents: 2, Idle: 2}, "Merge #611 first, then tell b-632 to merge main.", 150}, - {"Working, empty: Stop", "working", &Team{Agents: 2, NeedsYou: 1, Working: 1}, "", 150}, - {"Working, text, no team: Working in the footer, Send (the message queues)", "working", nil, "And run the review on its exact head.", 150}, - {"Many lines: the field grows to 8 lines, then scrolls", "idle", &Team{Agents: 2, Working: 2}, strings.TrimSuffix(strings.Repeat("A line of the message\n", 11), "\n"), 330}, - {"No team, idle: the field alone", "idle", nil, "", 130}, + {"Idle, empty: no button, the keys in the footer", "idle", &Team{Agents: 2, Idle: 2}, "", 150, ""}, + {"Idle, text: Send", "idle", &Team{Agents: 2, Idle: 2}, "Merge #611 first, then tell b-632 to merge main.", 150, ""}, + {"Working, empty: Stop", "working", &Team{Agents: 2, NeedsYou: 1, Working: 1}, "", 150, ""}, + {"Working, text, no team: Working in the footer, Send (the message queues)", "working", nil, "And run the review on its exact head.", 150, ""}, + {"Many lines: the field grows to 8 lines, then scrolls", "idle", &Team{Agents: 2, Working: 2}, strings.TrimSuffix(strings.Repeat("A line of the message\n", 11), "\n"), 330, ""}, + {"No team, idle: the field alone", "idle", nil, "", 130, ""}, + {"Idle, empty, a suggested next prompt: it takes the placeholder's place (D281)", "idle", nil, "", 130, "commit this and open a PR"}, } -const composerSheetH = 1230 +const composerSheetH = 1380 // sheetComposer stacks the composer card in each of composerStates, each under its caption, at the // bottom of a short chat. @@ -203,6 +205,9 @@ func sheetComposer(t *testing.T) func(c *ui.Context) { teamOf(v, *st.team) } *v.s.ch.draft(term) = st.text + if next := st.next; next != "" { + v.s.ch.NextPrompt = func(string) string { return next } + } views[i] = v } return func(c *ui.Context) { diff --git a/internal/app/frame.go b/internal/app/frame.go index 839cc40f..29719852 100644 --- a/internal/app/frame.go +++ b/internal/app/frame.go @@ -12,6 +12,7 @@ import ( "os/exec" "slices" "sync" + "sync/atomic" "time" "github.com/egoist/mygo" @@ -207,6 +208,10 @@ type Frame struct { termSet bool termState ghostty.State termErr string + // next is the suggested next prompt read from the terminal at + // termAddr (D281); nextCheck is set while a read of it waits. + next string + nextCheck atomic.Bool // find is the find bar over the terminal (termfind.go). find termFind // hints tells when the key chips show: ⌘ held alone (docs/spec/KeyHints.md). @@ -1120,6 +1125,7 @@ func (f *Frame) syncTerminal() { } f.closeTerminal() f.resetFind() + f.next = "" f.termAddr, f.termSet = addr, true f.syncOutlineFocus() f.termState, f.termErr = ghostty.State{Kind: ghostty.Connecting}, "" @@ -1129,7 +1135,9 @@ func (f *Frame) syncTerminal() { } post, invalidate := f.post, f.invalidate var conn *ghostty.Conn - conn = ghostty.NewConn(f.deps.TerminalSource(addr), ghostty.Options{Watch: true, OnState: func(s ghostty.State) { + var screen atomic.Pointer[gterm.Terminal] + onOutput := func() { f.checkNext(&screen, func() bool { return f.conn == conn }) } + conn = ghostty.NewConn(f.deps.TerminalSource(addr), ghostty.Options{Watch: true, OnOutput: onOutput, OnState: func(s ghostty.State) { post(func() { if f.conn == conn { f.termState = s @@ -1150,6 +1158,7 @@ func (f *Frame) syncTerminal() { return } conn.SetScreen(t) + screen.Store(t) f.term, f.conn = t, conn f.autoFocus = !f.keyNav f.setTerminalFocus(f.winFocused) diff --git a/internal/app/ghostty/conn.go b/internal/app/ghostty/conn.go index 317c2a1b..65af932e 100644 --- a/internal/app/ghostty/conn.go +++ b/internal/app/ghostty/conn.go @@ -86,6 +86,9 @@ type Options struct { // OnState gets each change of the state, on any goroutine. It must not // wait for the UI thread. OnState func(State) + // OnOutput is called on the plugin's read goroutine as it takes output + // from the program. It must not wait for the UI thread. May be nil. + OnOutput func() // Cols and Rows are the size of the first attach: 80 by 24 when zero. // The view's size replaces it as soon as the view tells it. Cols, Rows uint16 @@ -115,12 +118,13 @@ type chunk struct { // again with backoff and with Resumed, and holds what is typed meanwhile. Its // Read ends only when Close is called, so the plugin keeps its screen. type Conn struct { - src Source - on func(State) - ctx context.Context - end context.CancelFunc - wg sync.WaitGroup - out chan chunk // output and sizes from the pump to Read + src Source + on func(State) + onOut func() // Options.OnOutput + ctx context.Context + end context.CancelFunc + wg sync.WaitGroup + out chan chunk // output and sizes from the pump to Read rmu sync.Mutex // Read's leftover buf []byte @@ -163,7 +167,7 @@ type Conn struct { // firstWait. Close ends it. func NewConn(src Source, o Options) *Conn { ctx, end := context.WithCancel(context.Background()) - c := &Conn{src: src, on: o.OnState, ctx: ctx, end: end, out: make(chan chunk), + c := &Conn{src: src, on: o.OnState, onOut: o.OnOutput, ctx: ctx, end: end, out: make(chan chunk), visible: true, cols: o.Cols, rows: o.Rows, state: State{Kind: Connecting}, watching: o.Watch, wake: make(chan struct{}, 1)} if c.cols == 0 || c.rows == 0 { @@ -232,6 +236,9 @@ func (c *Conn) Read(p []byte) (int, error) { continue } c.buf = ch.data + if c.onOut != nil && len(ch.data) > 0 { + c.onOut() + } case <-c.ctx.Done(): return 0, io.EOF } diff --git a/internal/app/ghostty/conn_test.go b/internal/app/ghostty/conn_test.go index 9d0ad743..ce60060a 100644 --- a/internal/app/ghostty/conn_test.go +++ b/internal/app/ghostty/conn_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "sync" + "sync/atomic" "testing" "time" @@ -1314,3 +1315,20 @@ func TestWriteDropsSchemeReports(t *testing.T) { } waitAttaches(t, src, 2) } + +func TestOnOutputIsCalledForEachTakenOutput(t *testing.T) { + fast(t) + src := newFakeSource() + var n atomic.Int32 + c := NewConn(src, Options{OnOutput: func() { n.Add(1) }}) + t.Cleanup(func() { _ = c.Close() }) + s := src.stream(t, 0) + s.data("hello ") + s.data("world") + if got := readN(t, c, 11); got != "hello world" { + t.Errorf("read %q", got) + } + if got := n.Load(); got != 2 { + t.Errorf("OnOutput called %d times, want 2", got) + } +} diff --git a/internal/app/nextprompt.go b/internal/app/nextprompt.go new file mode 100644 index 00000000..58e352be --- /dev/null +++ b/internal/app/nextprompt.go @@ -0,0 +1,52 @@ +package app + +import ( + "time" + + gterm "github.com/egoist/mygo/plugins/terminal" + + "github.com/contursofran/contr/internal/prompt" +) + +// nextDelay is how long after output the terminal's screen is read for the +// suggested next prompt: the plugin applies the output first, and a burst of +// output gives one read. +const nextDelay = 300 * time.Millisecond + +// nextTail is how much of the end of the screen's escape sequences is read: +// the input line is near the bottom. +const nextTail = 256 << 10 + +// checkNext reads Claude Code's suggested next prompt (D281) from the +// terminal in screen a little after output came, off the UI thread, and +// gives a change to the frame for the composer. current tells, on the UI +// thread, that the terminal is still the one shown. Any goroutine. +func (f *Frame) checkNext(screen interface{ Load() *gterm.Terminal }, current func() bool) { + if !f.nextCheck.CompareAndSwap(false, true) { + return + } + time.AfterFunc(nextDelay, func() { + f.nextCheck.Store(false) + t := screen.Load() + if t == nil { + return + } + snap := t.Snapshot() + next := prompt.Suggestion(snap[max(0, len(snap)-nextTail):]) + f.post(func() { + if current() && f.next != next { + f.next = next + f.invalidate() + } + }) + }) +} + +// nextPrompt is the suggested next prompt of the terminal at addr for the +// composer, "" when that terminal is not the one shown. UI thread. +func (f *Frame) nextPrompt(addr string) string { + if addr != f.termAddr { + return "" + } + return f.next +} diff --git a/internal/prompt/suggestion.go b/internal/prompt/suggestion.go new file mode 100644 index 00000000..140c9ace --- /dev/null +++ b/internal/prompt/suggestion.go @@ -0,0 +1,111 @@ +package prompt + +import ( + "bytes" + "strconv" + "strings" +) + +// promptMark is the mark at the start of Claude Code's input line. +const promptMark = "❯" + +// Suggestion is the next prompt that Claude Code suggests in its input line +// when a turn ends (D281), read from the terminal's screen as escape +// sequences (the MyGo terminal's Snapshot). Claude Code writes the +// suggestion as faint text (SGR 2) after the prompt mark. Only the last line +// with the mark counts. It is "" when that line has no text after the mark, +// or when any of that text is not faint: typed text, a sent prompt or a menu +// choice. +func Suggestion(screen []byte) string { + lines := bytes.Split(screen, []byte("\n")) + for i := len(lines) - 1; i >= 0; i-- { + if bytes.Contains(lines[i], []byte(promptMark)) { + return faintAfterMark(string(lines[i])) + } + } + return "" +} + +// faintAfterMark reads line after its first prompt mark: the text if all +// of it is faint, else "". +func faintAfterMark(line string) string { + faint, seen := false, false + var b strings.Builder + for i := 0; i < len(line); { + if line[i] == 0x1b { + n, f, ok := csi(line[i:], faint) + if !ok { + return "" + } + faint, i = f, i+n + continue + } + if !seen { + if strings.HasPrefix(line[i:], promptMark) { + seen = true + i += len(promptMark) + } else { + i++ + } + continue + } + c := line[i] + i++ + if c == '\r' || c < 0x20 { + continue + } + if c != ' ' && !faint { + return "" + } + b.WriteByte(c) + } + return strings.TrimSpace(b.String()) +} + +// csi reads the escape sequence at the start of s: its length and the faint +// state after it. Only SGR changes the state. ok is false for an escape that +// is not a CSI sequence. +func csi(s string, faint bool) (n int, after bool, ok bool) { + if len(s) < 2 || s[1] != '[' { + return 0, faint, false + } + end := 2 + for end < len(s) && (s[end] < 0x40 || s[end] > 0x7e) { + end++ + } + if end == len(s) { + return 0, faint, false + } + if s[end] != 'm' { + return end + 1, faint, true + } + params := strings.Split(s[2:end], ";") + for j := 0; j < len(params); j++ { + p := params[j] + if k := strings.IndexByte(p, ':'); k >= 0 { + p = p[:k] // a colon form (38:2:r:g:b) is one parameter + } + v, err := strconv.Atoi(p) + if p == "" { + v, err = 0, nil + } + if err != nil { + continue + } + switch { + case v == 0 || v == 22: + faint = false + case v == 2: + faint = true + case (v == 38 || v == 48 || v == 58) && !strings.Contains(params[j], ":") && j+1 < len(params): + // Skip the color's own numbers: 5;n or 2;r;g;b. + switch params[j+1] { + case "5": + j += 2 + case "2": + j += 4 + } + } + } + return end + 1, faint, true +} diff --git a/internal/prompt/suggestion_test.go b/internal/prompt/suggestion_test.go new file mode 100644 index 00000000..0ad3c428 --- /dev/null +++ b/internal/prompt/suggestion_test.go @@ -0,0 +1,30 @@ +package prompt + +import "testing" + +func TestSuggestion(t *testing.T) { + const rule = "\x1b[0m\x1b[38;2;136;136;136m────\x1b[0m\r\n" + // history is a sent prompt above the input line, as Claude Code draws it. + const history = "❯ \x1b[0m\x1b[38;2;255;255;255m\x1b[48;2;55;55;55mfix the test\x1b[0m\r\n" + cases := []struct { + name, screen, want string + }{ + // From a real session: the suggestion after a turn ended. + {"suggestion", history + rule + "❯ \x1b[0m\x1b[2mcommit this\x1b[0m\r\n" + rule, "commit this"}, + {"faint with a color", rule + "❯ \x1b[2;38;5;244mrun the tests\x1b[0m\r\n", "run the tests"}, + {"none", history + rule + "❯ \x1b[0m\r\n" + rule, ""}, + {"no mark", "plain output\r\n", ""}, + {"typed text", rule + "❯ \x1b[0mcommit this\r\n", ""}, + {"faint ends", rule + "❯ \x1b[2mcommit\x1b[22m this\r\n", ""}, + // A new prompt went in: the input line is empty, the old suggestion is gone and the + // sent prompt sits above it. + {"stale after a new prompt", "❯ \x1b[2mcommit this\x1b[0m\r\n" + history + rule + "❯ \x1b[0m\r\n", ""}, + {"menu choice", " ❯ 1. Yes\r\n 2. No\r\n", ""}, + {"bad escape", "❯ \x1b]0;title\x07\x1b[2mx\r\n", ""}, + } + for _, c := range cases { + if got := Suggestion([]byte(c.screen)); got != c.want { + t.Errorf("%s: got %q, want %q", c.name, got, c.want) + } + } +}