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 cmd/contr/e2e_harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand Down
6 changes: 6 additions & 0 deletions cmd/contr/e2e_see_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions cmd/contr/e2e_titlebar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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". |
9 changes: 9 additions & 0 deletions docs/spec/ChatView.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/app/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions internal/app/chatview/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 18 additions & 2 deletions internal/app/chatview/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"

"github.com/egoist/mygo/transfer"
"github.com/egoist/mygo/ui"
Expand Down Expand Up @@ -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) }).
Expand Down Expand Up @@ -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("") })
Expand Down
67 changes: 67 additions & 0 deletions internal/app/chatview/composer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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())
}
}
19 changes: 12 additions & 7 deletions internal/app/chatview/sheet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
11 changes: 10 additions & 1 deletion internal/app/frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os/exec"
"slices"
"sync"
"sync/atomic"
"time"

"github.com/egoist/mygo"
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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}, ""
Expand All @@ -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
Expand All @@ -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)
Expand Down
21 changes: 14 additions & 7 deletions internal/app/ghostty/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
18 changes: 18 additions & 0 deletions internal/app/ghostty/conn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"io"
"sync"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -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)
}
}
Loading
Loading