From b85eaadb179dbc30f8364bf8b92a14485a86e4d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:54:39 +0000 Subject: [PATCH 1/5] fix(client): tls for wss, idle thinking flush, slow consolidate dialWS never wrapped wss:// in TLS, so attaching to an https odek serve always failed. Pending thinking deltas now flush after 50ms of silence instead of waiting for the next frame, and the LLM-backed memory consolidation uses the 30s client rather than the 3s interactive one. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GozYvJdU9Yx4uLnCKCcTEg --- internal/client/bughunt_regress_test.go | 100 ++++++++++++++++++++++++ internal/client/client.go | 83 +++++++++++++++++--- internal/client/manage.go | 26 +++++- 3 files changed, 195 insertions(+), 14 deletions(-) create mode 100644 internal/client/bughunt_regress_test.go diff --git a/internal/client/bughunt_regress_test.go b/internal/client/bughunt_regress_test.go new file mode 100644 index 0000000..8283647 --- /dev/null +++ b/internal/client/bughunt_regress_test.go @@ -0,0 +1,100 @@ +package client + +import ( + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + ws "golang.org/x/net/websocket" +) + +// wss:// must start a TLS handshake (ClientHello record type 0x16). +func TestRegressWSSStartsTLS(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + first := make(chan byte, 1) + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = c.Close() }() + _ = c.SetReadDeadline(time.Now().Add(3 * time.Second)) + b := make([]byte, 1) + if _, err := c.Read(b); err == nil { + first <- b[0] + } + }() + addr := ln.Addr().String() + go func() { _, _ = Dial("wss://"+addr+"/ws", "https://"+addr, "https://"+addr, "tok") }() + select { + case b := <-first: + if b != 0x16 { + t.Fatalf("wss dial sent %q (0x%02x) instead of a TLS ClientHello", b, b) + } + case <-time.After(4 * time.Second): + t.Fatal("no bytes received") + } +} + +func validateServer(t *testing.T, onWS func(*ws.Conn), mux func(*http.ServeMux)) string { + t.Helper() + m := http.NewServeMux() + m.Handle("/ws", ws.Server{ + Handshake: func(*ws.Config, *http.Request) error { return nil }, + Handler: ws.Handler(onWS), + }) + if mux != nil { + mux(m) + } + srv := httptest.NewServer(m) + t.Cleanup(srv.Close) + return srv.URL +} + +// The trailing <16 thinking deltas must surface without a later frame. +func TestRegressThinkingTailFlushesWhenIdle(t *testing.T) { + base := validateServer(t, func(c *ws.Conn) { + for range 3 { + _ = ws.Message.Send(c, `{"type":"thinking_delta","content":"x"}`) + } + time.Sleep(3 * time.Second) + }, nil) + c, err := Dial("ws"+strings.TrimPrefix(base, "http")+"/ws", base, base, "tok") + if err != nil { + t.Fatal(err) + } + defer func() { _ = c.Close() }() + select { + case ev := <-c.Events: + if ev.Type != "thinking_delta" { + t.Fatalf("got %v", ev.Type) + } + case <-time.After(1500 * time.Millisecond): + t.Fatal("pending thinking deltas were held back while the stream idled") + } +} + +// Consolidation is LLM-backed; it must not be cut at the 3s interactive budget. +func TestRegressConsolidateOutlivesInteractiveTimeout(t *testing.T) { + base := validateServer(t, func(c *ws.Conn) { time.Sleep(6 * time.Second) }, func(m *http.ServeMux) { + m.HandleFunc("/api/memory/consolidate", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(3500 * time.Millisecond) + _, _ = w.Write([]byte(`{}`)) + }) + }) + c, err := Dial("ws"+strings.TrimPrefix(base, "http")+"/ws", base, base, "tok") + if err != nil { + t.Fatal(err) + } + defer func() { _ = c.Close() }() + if err := c.ConsolidateMemory("user"); err != nil { + t.Fatalf("consolidate failed although the server completes it: %v", err) + } +} diff --git a/internal/client/client.go b/internal/client/client.go index 6a34e83..dd609ae 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -8,6 +8,7 @@ package client import ( + "crypto/tls" "encoding/json" "fmt" "net" @@ -310,12 +311,32 @@ func dialWS(cfg *ws.Config) (*ws.Conn, error) { _ = raw.Close() return nil, err } - conn, err := ws.NewClient(cfg, raw) + // ws.NewClient speaks plain WS over whatever it is handed; a wss:// URL + // needs the TLS handshake done here (ws.DialConfig would, but has no + // dial timeout). + var nc net.Conn = raw + if cfg.Location.Scheme == "wss" { + tc := cfg.TlsConfig + if tc == nil { + tc = &tls.Config{MinVersion: tls.VersionTLS12} + } + if tc.ServerName == "" { + tc = tc.Clone() + tc.ServerName = cfg.Location.Hostname() + } + tlsConn := tls.Client(raw, tc) + if err := tlsConn.Handshake(); err != nil { + _ = raw.Close() + return nil, err + } + nc = tlsConn + } + conn, err := ws.NewClient(cfg, nc) if err != nil { - _ = raw.Close() + _ = nc.Close() return nil, err } - _ = raw.SetDeadline(time.Time{}) // live stream: no deadline + _ = nc.SetDeadline(time.Time{}) // live stream: no deadline return conn, nil } @@ -363,12 +384,23 @@ func (c *Client) Resources(query string, limit int) ([]Resource, error) { // only happens on a dead link. A test hook; do not shrink further. var readIdleTimeout = 45 * time.Second +// thinkingIdleFlush bounds how long a merged thinking_delta run may sit +// pending when the stream goes quiet: the coalescer only flushes on the next +// frame, so the tail of a reasoning burst would otherwise stay invisible +// until the server's next heartbeat. +var thinkingIdleFlush = 50 * time.Millisecond + func (c *Client) readLoop() { defer close(c.Events) defer func() { _ = c.conn.Close() }() // release the fd even when the sender never closes (reconnect swap) - var pending *Event - n := 0 - flush := func() { + var ( + mu sync.Mutex // guards pending/n/closed and orders every emit + pending *Event + n int + closed bool + timer *time.Timer + ) + flush := func() { // caller holds mu if pending == nil { return } @@ -376,12 +408,22 @@ func (c *Client) readLoop() { pending = nil n = 0 } + defer func() { // runs before close(c.Events): the idle timer must not emit after it + mu.Lock() + closed = true + if timer != nil { + timer.Stop() + } + mu.Unlock() + }() for { var data []byte _ = c.conn.SetReadDeadline(time.Now().Add(readIdleTimeout)) if err := ws.Message.Receive(c.conn, &data); err != nil { + mu.Lock() flush() c.emit(Event{Type: EventDisconnected}) + mu.Unlock() return } _ = c.conn.SetReadDeadline(time.Time{}) // received: drop the deadline while decoding @@ -389,24 +431,39 @@ func (c *Client) readLoop() { if err := json.Unmarshal(data, &ev); err != nil { continue // ignore malformed frames } + mu.Lock() if ev.Type == "thinking_delta" { if pending != nil && pending.Type == ev.Type && pending.TurnID == ev.TurnID { pending.Content += ev.Content n++ - if n < deltaCoalesceMax { - continue + if n >= deltaCoalesceMax { + flush() } + } else { flush() - continue + e := ev + pending = &e + n = 1 } - flush() - e := ev - pending = &e - n = 1 + if pending != nil { + if timer == nil { + timer = time.AfterFunc(thinkingIdleFlush, func() { + mu.Lock() + defer mu.Unlock() + if !closed { + flush() + } + }) + } else { + timer.Reset(thinkingIdleFlush) + } + } + mu.Unlock() continue } flush() c.emit(ev) + mu.Unlock() } } diff --git a/internal/client/manage.go b/internal/client/manage.go index cb23ea8..bc1b8be 100644 --- a/internal/client/manage.go +++ b/internal/client/manage.go @@ -1,6 +1,7 @@ package client import ( + "bytes" "encoding/json" "fmt" "net/http" @@ -73,7 +74,30 @@ func (c *Client) PromoteEpisode(sessionID string) error { // ConsolidateMemory merges similar facts through the LLM. func (c *Client) ConsolidateMemory(target string) error { - return c.postAction("/api/memory/consolidate", map[string]string{"target": target}) + payload, err := json.Marshal(map[string]string{"target": target}) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/memory/consolidate", bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + c.authHeaders(req, "") + // An LLM-backed merge outlives the 3s interactive budget. + h := c.slowHTTP + if h == nil { + h = c.http + } + resp, err := h.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("/api/memory/consolidate: status %s", resp.Status) + } + return nil } // Skill is one discovered skill with its provenance state. From 9e3750f89ba0f1076cce46968fa68dab0049ace2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:54:39 +0000 Subject: [PATCH 2/5] fix(server): keep attach-url query out of the rest base Extra query parameters on an attach URL ended up before /ws and in the REST base. Derive both endpoints from the path and keep the parameters on the ws URL only. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GozYvJdU9Yx4uLnCKCcTEg --- internal/server/bughunt_regress_test.go | 25 ++++++++++++++++++++++++ internal/server/server.go | 26 +++++++++++++++++++++---- 2 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 internal/server/bughunt_regress_test.go diff --git a/internal/server/bughunt_regress_test.go b/internal/server/bughunt_regress_test.go new file mode 100644 index 0000000..0620571 --- /dev/null +++ b/internal/server/bughunt_regress_test.go @@ -0,0 +1,25 @@ +package server + +import ( + "net/url" + "testing" +) + +func TestRegressAttachEndpointsKeepUsablePaths(t *testing.T) { + base, tok := splitTokenURL("http://127.0.0.1:8080/?token=abc&foo=1") + if tok != "abc" { + t.Fatalf("token = %q", tok) + } + baseURL, wsURL := attachEndpoints(base) + if baseURL != "http://127.0.0.1:8080" { + t.Fatalf("REST base = %q", baseURL) + } + u, err := url.Parse(wsURL) + if err != nil || u.Path != "/ws" || u.Scheme != "ws" || u.Query().Get("foo") != "1" { + t.Fatalf("ws url %q parsed to path %q scheme %q (err %v)", wsURL, u.Path, u.Scheme, err) + } + _, wss := attachEndpoints("https://h.example/odek/") + if wss != "wss://h.example/odek/ws" { + t.Fatalf("wss url = %q", wss) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 52ddc6b..1068c70 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -145,10 +145,8 @@ func Connect(opts Options) (*Conn, error) { if opts.URL != "" { base, urlToken := splitTokenURL(opts.URL) - base = strings.TrimRight(base, "/") - c.BaseURL = base - c.Origin = base - c.WSURL = "ws" + strings.TrimPrefix(base, "http") + "/ws" + c.BaseURL, c.WSURL = attachEndpoints(base) + c.Origin = c.BaseURL if token == "" { token = urlToken } @@ -373,6 +371,26 @@ func (c *Conn) Stop() { } } +// attachEndpoints derives the REST base and the WebSocket URL from an attach +// URL whose token has been split off. Surviving query parameters stay on the +// WS URL (after the /ws path) and never leak into the REST base, which later +// code extends by path concatenation. +func attachEndpoints(base string) (baseURL, wsURL string) { + u, err := url.Parse(base) + if err != nil { + b := strings.TrimRight(base, "/") + return b, "ws" + strings.TrimPrefix(b, "http") + "/ws" + } + u.Path = strings.TrimRight(u.Path, "/") + u.RawPath = "" + rest := *u + rest.RawQuery = "" + w := *u + w.Path += "/ws" + w.Scheme = "ws" + strings.TrimPrefix(u.Scheme, "http") + return rest.String(), w.String() +} + // splitTokenURL separates an attach URL into its base and an optional // "?token=" value, so users can paste the exact URL odek serve prints. func splitTokenURL(raw string) (base, token string) { From 8e0ea7e12c1a98b3f70d0ef3a6733733153645e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:54:39 +0000 Subject: [PATCH 3/5] fix(bodek): protect server log and merge settings on save The server log carries odek's WS token; remove and exclusively create it (0600) instead of reusing a possibly pre-created file. Settings saves now write only the fields this instance changed over the file's current contents so another running instance's choices are not lost. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GozYvJdU9Yx4uLnCKCcTEg --- cmd/bodek/bughunt_regress_test.go | 62 +++++++++++++++++++++++++++++++ cmd/bodek/log.go | 10 ++++- cmd/bodek/main.go | 22 ++++++++++- 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 cmd/bodek/bughunt_regress_test.go diff --git a/cmd/bodek/bughunt_regress_test.go b/cmd/bodek/bughunt_regress_test.go new file mode 100644 index 0000000..b22bde0 --- /dev/null +++ b/cmd/bodek/bughunt_regress_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/BackendStack21/bodek/internal/settings" +) + +// Another instance's saved preference must survive this instance's save. +func TestRegressSettingsSaveKeepsOtherInstanceChange(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + t.Setenv("BODEK_CONFIG", path) + cfg, err := parseConfig(nil, io.Discard) + if err != nil { + t.Fatal(err) + } + // Instance A saves a theme after this instance started. + if err := settings.Save(settings.Settings{Theme: "ember-light"}); err != nil { + t.Fatal(err) + } + // This instance changes only verbosity. + cfg.persist.Verbosity = "quiet" + if err := cfg.save(); err != nil { + t.Fatal(err) + } + got, err := settings.Load() + if err != nil { + t.Fatal(err) + } + if got.Theme != "ember-light" { + t.Fatalf("theme saved by the other instance was lost: %+v", got) + } +} + +// The log holds odek's `WS token:` line; a pre-existing permissive file must +// not be reused as-is. +func TestRegressServerLogNotReusedWhenPermissive(t *testing.T) { + dir := t.TempDir() + t.Setenv("TMPDIR", dir) + p := filepath.Join(dir, serverLogName) + if err := os.WriteFile(p, []byte("x"), 0o666); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, 0o666); err != nil { + t.Fatal(err) + } + _, path, closeFn := openServerLog() + defer closeFn() + if path == "" { + return // refusing the file is acceptable + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm()&0o077 != 0 { + t.Fatalf("token-bearing log is group/world accessible: %v", fi.Mode().Perm()) + } +} diff --git a/cmd/bodek/log.go b/cmd/bodek/log.go index 44e0671..9fbb1c1 100644 --- a/cmd/bodek/log.go +++ b/cmd/bodek/log.go @@ -14,11 +14,17 @@ import ( // so it never grows without bound. const serverLogName = "bodek-odek-serve.log" -// openServerLog opens (truncating) the server log file. On failure it falls +// openServerLog (re)creates the server log file. On failure it falls // back to a discarding writer so a missing temp dir never breaks startup. func openServerLog() (w io.Writer, path string, closeFn func()) { path = filepath.Join(os.TempDir(), serverLogName) - f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + // The log receives odek's "WS token:" line. Never reuse an existing + // file: another user may have pre-created it (or a symlink to a file of + // theirs) with open permissions. Remove it and create exclusively; if the + // remove is refused (not ours, sticky dir) the create fails and logging + // is skipped rather than leaking the token. + _ = os.Remove(path) + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return io.Discard, "", func() {} } diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index f9ff1ec..68d0c81 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -9,6 +9,7 @@ import ( "io" "os" "os/signal" + "reflect" "runtime" "strings" "syscall" @@ -50,6 +51,10 @@ type config struct { extraArgs []string persist settings.Settings // the loaded file, re-saved when /theme switches + // persisted is the last state read from or written to disk; save writes + // only the fields that differ from it, merged over the file's current + // contents, so a change made by another running instance survives. + persisted settings.Settings // persistDisabled is set when the settings file could not be read; // a full-replace Save would then overwrite the (unparsed) file with // a near-empty struct, so all persistence is refused for the run. @@ -67,7 +72,21 @@ func (c *config) save() error { if c.persistDisabled { return fmt.Errorf("settings file could not be parsed; changes are not saved this run") } - return settings.Save(c.persist) + cur, err := settings.Load() + if err != nil { + cur = c.persisted // file vanished or changed shape: fall back to our baseline + } + rb, rc, rd := reflect.ValueOf(c.persisted), reflect.ValueOf(c.persist), reflect.ValueOf(&cur).Elem() + for i := range rc.NumField() { + if !reflect.DeepEqual(rb.Field(i).Interface(), rc.Field(i).Interface()) { + rd.Field(i).Set(rc.Field(i)) + } + } + if err := settings.Save(cur); err != nil { + return err + } + c.persisted = c.persist + return nil } func parseConfig(args []string, output io.Writer) (config, error) { @@ -89,6 +108,7 @@ func parseConfig(args []string, output io.Writer) (config, error) { _, _ = fmt.Fprintln(output, "bodek: settings changes will not be saved this run (file could not be parsed)") } cfg.persist = st + cfg.persisted = st fs := flag.NewFlagSet("bodek", flag.ContinueOnError) if output != nil { fs.SetOutput(output) From 21dabb5be63510becb6dd8900f79712b823273a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:54:40 +0000 Subject: [PATCH 4/5] fix(store): never replace an unreadable tokens or workspace file Only a missing file is a fresh start. Any other read error disables persistence for the run instead of overwriting the bytes. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GozYvJdU9Yx4uLnCKCcTEg --- internal/tokens/bughunt_regress_test.go | 29 ++++++++++++++++++++++ internal/tokens/tokens.go | 8 ++++++ internal/workspace/bughunt_regress_test.go | 29 ++++++++++++++++++++++ internal/workspace/workspace.go | 7 ++++++ 4 files changed, 73 insertions(+) create mode 100644 internal/tokens/bughunt_regress_test.go create mode 100644 internal/workspace/bughunt_regress_test.go diff --git a/internal/tokens/bughunt_regress_test.go b/internal/tokens/bughunt_regress_test.go new file mode 100644 index 0000000..0da497f --- /dev/null +++ b/internal/tokens/bughunt_regress_test.go @@ -0,0 +1,29 @@ +package tokens + +import ( + "os" + "path/filepath" + "testing" +) + +// An unreadable (not missing) store must not be replaced by an empty one. +func TestRegressUnreadableStoreNotOverwritten(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores file modes") + } + dir := t.TempDir() + p := filepath.Join(dir, "sessions.json") + if err := os.WriteFile(p, []byte(`{"old":"tok"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, 0); err != nil { + t.Fatal(err) + } + s := openAt(p) + s.Set("new", "tok2") + _ = os.Chmod(p, 0o600) + data, _ := os.ReadFile(p) + if string(data) != `{"old":"tok"}` { + t.Fatalf("unreadable store was replaced: %q", data) + } +} diff --git a/internal/tokens/tokens.go b/internal/tokens/tokens.go index 0c99b33..518238e 100644 --- a/internal/tokens/tokens.go +++ b/internal/tokens/tokens.go @@ -10,7 +10,9 @@ package tokens import ( "encoding/json" + "errors" "fmt" + "io/fs" "os" "path/filepath" "sync" @@ -42,6 +44,12 @@ func openAt(path string) *Store { s := &Store{m: map[string]string{}, path: path} data, err := os.ReadFile(path) if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + // Present but unreadable (e.g. root-owned after sudo): never + // replace bytes we could not read. + warnPersist(fmt.Errorf("store unreadable, not persisting: %w", err)) + s.path = "" + } return s // missing store: fresh start } if err := json.Unmarshal(data, &s.m); err != nil { diff --git a/internal/workspace/bughunt_regress_test.go b/internal/workspace/bughunt_regress_test.go new file mode 100644 index 0000000..f4ee190 --- /dev/null +++ b/internal/workspace/bughunt_regress_test.go @@ -0,0 +1,29 @@ +package workspace + +import ( + "os" + "testing" +) + +func TestRegressUnreadableWorkspaceNotOverwritten(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores file modes") + } + t.Setenv("HOME", t.TempDir()) + p := Path() + if err := os.MkdirAll(p[:len(p)-len("/workspace.json")], 0o700); err != nil { + t.Fatal(err) + } + orig := `{"workspaces":{"/a":{"draft":"keep me"}}}` + if err := os.WriteFile(p, []byte(orig), 0o600); err != nil { + t.Fatal(err) + } + _ = os.Chmod(p, 0) + s := Open() + s.Patch("/b", func(st *State) { st.Draft = "new" }) + _ = os.Chmod(p, 0o600) + data, _ := os.ReadFile(p) + if string(data) != orig { + t.Fatalf("unreadable workspace file replaced: %q", data) + } +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index d5cd349..d3cfcd5 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -6,7 +6,9 @@ package workspace import ( "encoding/json" + "errors" "fmt" + "io/fs" "os" "path/filepath" "sync" @@ -55,6 +57,11 @@ func Open() *Store { } data, err := os.ReadFile(s.path) if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + // Present but unreadable: never replace bytes we could not read. + fmt.Fprintf(os.Stderr, "bodek: warning: %s unreadable, not persisting: %v\n", s.path, err) + s.path = "" + } return s } var f fileFormat From 24299c3dc881a7d070916fd8536f45080135b17c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:54:40 +0000 Subject: [PATCH 5/5] fix(tui): keep batched follow-ups, sanitize wire fields, bound layouts - carry sendQueued/plan/attention/kick cmds out of ingestWireBatch so a queued prompt, done bell and tab refreshes are not dropped in a burst - sanitize sub-agent phase/status and job/agent status in drawers - window the cockpit popover and bound drawer detail rows by cells - rune-safe palette backspace and plainClip - delete reply removes the right session after the selection moved - reset the stale-age clock per turn, ignore key sentinels in find, reset copyMark on clear Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GozYvJdU9Yx4uLnCKCcTEg --- AGENTS.md | 5 +- internal/tui/bughunt_regress_test.go | 301 +++++++++++++++++++++++++++ internal/tui/cockpit.go | 27 ++- internal/tui/events.go | 20 +- internal/tui/find.go | 4 + internal/tui/input.go | 1 + internal/tui/jobs_tab.go | 4 +- internal/tui/mgmt.go | 21 +- internal/tui/model.go | 20 +- internal/tui/palette.go | 4 +- internal/tui/panels.go | 13 +- internal/tui/plain.go | 3 +- internal/tui/progress_notes_test.go | 7 +- internal/tui/subagents.go | 4 +- 14 files changed, 402 insertions(+), 32 deletions(-) create mode 100644 internal/tui/bughunt_regress_test.go diff --git a/AGENTS.md b/AGENTS.md index b103310..8665e72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,10 @@ feat(tui): compact tool steps with Ctrl+E details toggle cannot run View per fragment (that filled the 256-deep Events buffer and tripped odek's 30s write timeout — connection lost after any prompt). `ingestWireBatch` re-arms `listen()` as a top-level cmd, never - nested inside handleEvent's Batch. The client also merges consecutive + nested inside handleEvent's Batch. While a batch drains, `handleEvent` parks its + state-consuming follow-ups (`sendQueued`, `planFollowup`, attention, `flushKicks`) + in `batchCarry` instead of returning them, so the flat batch never drops a + queue pop, bell, or tab refresh. The client also merges consecutive thinking deltas (16 at a time) before enqueueing. Assistant text deltas enqueue immediately so short progress notes cannot wait for a later frame; their redraws still coalesce in the TUI. The header diff --git a/internal/tui/bughunt_regress_test.go b/internal/tui/bughunt_regress_test.go new file mode 100644 index 0000000..634f03c --- /dev/null +++ b/internal/tui/bughunt_regress_test.go @@ -0,0 +1,301 @@ +package tui + +import ( + "fmt" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// runCmdTree executes a command tree (expanding batches) and returns the type +// names of every message produced within the window. +func runCmdTree(cmd tea.Cmd) []string { + var mu sync.Mutex + var got []string + var wg sync.WaitGroup + var walk func(c tea.Cmd) + walk = func(c tea.Cmd) { + if c == nil { + return + } + wg.Add(1) + go func() { + defer wg.Done() + done := make(chan tea.Msg, 1) + go func() { done <- c() }() + select { + case msg := <-done: + if b, ok := msg.(tea.BatchMsg); ok { + for _, sub := range b { + walk(sub) + } + return + } + mu.Lock() + got = append(got, fmt.Sprintf("%T", msg)) + mu.Unlock() + case <-time.After(1500 * time.Millisecond): + } + }() + } + walk(cmd) + wg.Wait() + return got +} + +func drainStandInEvents(m *Model) { + time.Sleep(150 * time.Millisecond) + for { + select { + case <-m.events: + default: + return + } + } +} + +func countMsgs(types []string, sub string) int { + n := 0 + for _, t := range types { + if strings.Contains(t, sub) { + n++ + } + } + return n +} + +// F1a: a queued prompt drained by a batched `done` must still be sent. +func TestRegressBatchSendsQueuedPrompt(t *testing.T) { + run := func(batch bool) (int, int) { + m := wired(t) + drainStandInEvents(m) + streamingTurn(m) + m.queue = []string{"next"} + evs := []client.Event{{Type: "token", Content: "hi"}, {Type: "done"}} + var cmd tea.Cmd + if batch { + _, cmd = m.ingestWireBatch(evs) + } else { + var cmds []tea.Cmd + for _, e := range evs { + _, c := m.ingestWireEvent(e) + cmds = append(cmds, c) + } + cmd = tea.Batch(cmds...) + } + types := runCmdTree(cmd) + return len(m.queue), countMsgs(types, "eventMsg") + countMsgs(types, "eventBatchMsg") + } + qs, sentS := run(false) + qb, sentB := run(true) + t.Logf("single: queue=%d replies=%d; batch: queue=%d replies=%d", qs, sentS, qb, sentB) + if qs != 0 || qb != 0 { + t.Fatalf("queue should drain in both paths") + } + if sentS == 0 { + t.Skip("stand-in produced no observable reply on the single path") + } + if sentB == 0 { + t.Fatal("batch path popped the queue and went busy, but never sent the prompt") + } +} + +// F1c: bg_job frames inside a batch must trigger a jobs fetch. +func TestRegressBatchKickFetches(t *testing.T) { + m := wired(t) + m.sessionID, m.authToken = "s1", "a1" + drainStandInEvents(m) + _, sc := m.ingestWireEvent(client.Event{Type: "bg_job"}) + if countMsgs(runCmdTree(sc), "jobsFetchedMsg") == 0 { + t.Skip("single path does not fetch either; test is vacuous") + } + drainStandInEvents(m) + _, cmd := m.ingestWireBatch([]client.Event{{Type: "bg_job"}}) + types := runCmdTree(cmd) + if countMsgs(types, "jobsFetchedMsg") == 0 { + t.Fatalf("bg_job in a batch never fetched /api/jobs; msgs=%v", types) + } +} + +// F1b: done bell/attention inside a batch. +func TestRegressBatchDoneAttention(t *testing.T) { + run := func(batch bool) int { + m := wired(t) + drainStandInEvents(m) + m.bell = true + streamingTurn(m) + evs := []client.Event{{Type: "token", Content: "hi"}, {Type: "done"}} + var cmd tea.Cmd + if batch { + _, cmd = m.ingestWireBatch(evs) + } else { + var cmds []tea.Cmd + for _, e := range evs { + _, c := m.ingestWireEvent(e) + cmds = append(cmds, c) + } + cmd = tea.Batch(cmds...) + } + types := runCmdTree(cmd) + return countMsgs(types, "exec") + } + s, b := run(false), run(true) + t.Logf("single exec msgs=%d batch=%d", s, b) + if s == 0 { + t.Skip("single path produced no observable attention cmd") + } + if b == 0 { + t.Fatal("done bell lost when the frame arrives in a batch") + } +} + +// F4: sub-agent phase/status must be sanitized. +func TestRegressSubagentPhaseSanitized(t *testing.T) { + m := newTestModel() + m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + streamingTurn(m) + cur := &m.msgs[m.cur()] + cur.steps = append(cur.steps, step{name: "delegate_tasks", subagent: true}) + cur.items = append(cur.items, turnItem{stepIdx: 0}) + ok := m.attachSubState(m.cur(), client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, + Phase: "x\x1b]0;PWN\x07y", Status: "e\x1b[2Jz"}) + if !ok { + t.Fatal("attachSubState did not attach") + } + for _, c := range cur.steps[0].agents { + if b := agentBeatLine(c); strings.Contains(b, "\x1b]0;PWN") || strings.Contains(b, "\x1b[2Jz") { + t.Fatalf("raw escape in beat line: %q", b) + } + if strings.Contains(c.phase, "\x1b") || strings.Contains(c.status, "\x1b") { + t.Fatalf("card stores raw escapes: %q %q", c.phase, c.status) + } + } +} + +// F5: drawer detail meta must be sanitized. +func TestRegressDrawerDetailSanitized(t *testing.T) { + m := newTestModel() + m.panel = panelJobs + m.jobs = []client.Job{{ID: "j1", Command: "c", Status: "x\x1b]52;c;AAAA\x07"}} + out := strings.Join(m.mgmtDetailLines(60), "\n") + if strings.Contains(out, "\x1b]52") { + t.Fatalf("jobs detail leaked OSC 52: %q", out) + } + m.panel = panelAgents + m.agentsReg = []client.SubagentEntry{{TaskID: "t", Goal: "g", Phase: "a\x1b[2Jb", Status: "s\x1b]52;c;QQ\x07"}} + out = strings.Join(m.mgmtDetailLines(60), "\n") + if strings.Contains(out, "\x1b[2J") || strings.Contains(out, "\x1b]52") { + t.Fatalf("agents detail leaked escapes: %q", out) + } +} + +func TestRegressJobExitNoteSanitized(t *testing.T) { + n := jobExitNote(client.Job{ID: "j", Command: "c", Status: "x\x1b]52;c;AA\x07"}) + if strings.Contains(n, "\x1b]52") { + t.Fatalf("job exit note leaked escapes: %q", n) + } +} + +// F7: popover must fit the terminal. +func TestRegressPopoverFitsTerminal(t *testing.T) { + m := newTestModel() + m.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + if n := strings.Count(m.View(), "\n") + 1; n > 20 { + t.Fatalf("baseline view already %d rows", n) + } + m.popover = true + v := m.View() + if n := strings.Count(v, "\n") + 1; n > 20 { + t.Fatalf("popover view is %d rows on a 20-row terminal", n) + } +} + +// F8: wide runes / newlines in drawer detail must stay within the panel. +func TestRegressDrawerDetailWidthBound(t *testing.T) { + m := newTestModel() + m.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + m.panel = panelJobs + m.panelSel = 0 + m.jobs = []client.Job{{ID: "j1", Command: "one line", Status: "running"}} + m.panelDetail = true + if n := strings.Count(m.View(), "\n") + 1; n > 20 { + t.Fatalf("baseline detail view already %d rows", n) + } + m.jobs[0].Command = strings.Repeat("a\n", 40) + v := m.View() + if n := strings.Count(v, "\n") + 1; n > 20 { + t.Fatalf("job detail view is %d rows on a 20-row terminal", n) + } +} + +// F9: palette backspace must be rune-safe. +func TestRegressPaletteBackspaceRune(t *testing.T) { + m := newTestModel() + m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m.Update(tea.KeyMsg{Type: tea.KeyCtrlK}) + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("é")}) + m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + if !utf8.ValidString(m.pal.query) || m.pal.query != "" { + t.Fatalf("palette query after backspace = %q", m.pal.query) + } +} + +// F10: plainClip must not split a multibyte character. +func TestRegressPlainClipUTF8(t *testing.T) { + for _, s := range []string{strings.Repeat("a", 159) + "é" + "zzz", strings.Repeat("a", 158) + "日本"} { + if got := plainClip(s); !utf8.ValidString(got) { + t.Fatalf("plainClip produced invalid UTF-8: %q", got[len(got)-6:]) + } + } +} + +// F11: delete reply must remove the right row even if the selection moved. +func TestRegressSessionDeletedAfterSelectionMoved(t *testing.T) { + m := wired(t) + m.sessions = []client.Session{{ID: "a"}, {ID: "b"}, {ID: "c"}} + m.panelSel = 1 + m.handleSessionDeleted(sessionDeletedMsg{id: "a"}) + for _, s := range m.sessions { + if s.ID == "a" { + t.Fatalf("deleted session still listed: %v", m.sessions) + } + } +} + +// F12: a brand-new turn must not show a stale age. +func TestRegressStaleAgeNewTurn(t *testing.T) { + m := wired(t) + m.lastEvent = time.Now().Add(-10 * time.Minute) + m.sendPrompt("hello") + if got := m.staleAge(); got != "" { + t.Fatalf("fresh event shows stale age %q", got) + } +} + +// F13: shift+enter sentinel must not be typed into the find query. +func TestRegressFindIgnoresShiftEnter(t *testing.T) { + m := newTestModel() + m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m.msgs = append(m.msgs, message{role: roleAsst, content: "hello"}) + m.Update(key("alt+f")) + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("shift+enter")}) + if q := string(m.find.query); q != "" { + t.Fatalf("find query = %q", q) + } +} + +// F14: clearing the conversation resets copyMark. +func TestRegressClearResetsCopyMark(t *testing.T) { + m := newTestModel() + m.copyMark = copySpan{msgIdx: 3, set: true} + m.clearConversation() + if m.copyMark != (copySpan{}) { + t.Fatalf("copyMark survived clear: %+v", m.copyMark) + } +} diff --git a/internal/tui/cockpit.go b/internal/tui/cockpit.go index b0e2bf6..7379e1b 100644 --- a/internal/tui/cockpit.go +++ b/internal/tui/cockpit.go @@ -22,6 +22,7 @@ import ( // fields render immediately; these fill in a moment later. func (m *Model) openCockpit() tea.Cmd { m.popover = true + m.popScroll = 0 m.refresh() return m.cockpitFetch() } @@ -70,15 +71,15 @@ func (m *Model) handlePopoverKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "r": return m, m.cockpitFetch() case "up", "ctrl+p", "k": - m.vp.ScrollUp(1) + m.popScroll = max(m.popScroll-1, 0) case "down", "ctrl+n", "j": - m.vp.ScrollDown(1) + m.popScroll++ // clamped against the card height when drawn case "pgup", "ctrl+u": - m.vp.HalfPageUp() + m.popScroll = max(m.popScroll-popoverPage(m.vp.Height), 0) case "pgdown", "ctrl+d": - m.vp.HalfPageDown() + m.popScroll += popoverPage(m.vp.Height) case "ctrl+g": - m.vp.GotoBottom() + m.popScroll = 1 << 20 } m.refresh() return m, nil @@ -100,11 +101,23 @@ func (m *Model) popoverView(w, h int) string { } // The session section renders un-boxed — no nested card inside a card. b.WriteString("\n\n" + m.statsBody()) - b.WriteString("\n\n" + th.acDetail.Render("r refresh · esc close")) - return th.acBox.Width(boxWidth(w)).Height(h - 2).Render(b.String()) + // Window the card to the transcript area (the box height is only a + // minimum): the hint row stays pinned, the rest scrolls. + lines := strings.Split(b.String(), "\n") + visible := max(h-4, 1) + m.popScroll = min(max(m.popScroll, 0), max(len(lines)-visible, 0)) + if len(lines) > visible { + lines = lines[m.popScroll : m.popScroll+visible] + } + lines = append(lines, "", th.acDetail.Render("r refresh · esc close")) + + return th.acBox.Width(boxWidth(w)).Height(h - 2).MaxHeight(h).Render(strings.Join(lines, "\n")) } +// popoverPage is the half-page step for the cockpit card. +func popoverPage(h int) int { return max((h-4)/2, 1) } + // cockpitServerSection is the server/link card: identity and liveness from // the server_info/pong snapshot plus the heartbeat round-trip. func (m *Model) cockpitServerSection() string { diff --git a/internal/tui/events.go b/internal/tui/events.go index 1f9dd76..0b68797 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -38,6 +38,7 @@ type noticeExpireMsg struct{} // effects that used to live next to the eventMsg case (plain print, jobs // watcher bind). func (m *Model) ingestWireEvent(ev client.Event) (tea.Model, tea.Cmd) { + m.lastEvent = time.Now() mm, cmd := m.handleEvent(ev) pm := mm.(*Model) if pm.plain { @@ -61,6 +62,11 @@ func (m *Model) ingestWireBatch(events []client.Event) (tea.Model, tea.Cmd) { m.lastEvent = time.Now() // (R5) the head's last-event age resets on each batch var model tea.Model = m var plainLines []string + // handleEvent consumes its follow-ups' state (queue pop, kick flags, plan + // triggers, the done bell) while building them; in a batch it parks the + // resulting cmds in batchCarry instead of dropping them. + m.inBatch, m.batchCarry = true, nil + defer func() { m.inBatch, m.batchCarry = false, nil }() for _, ev := range events { var cmd tea.Cmd model, cmd = model.(*Model).handleEvent(ev) @@ -74,7 +80,7 @@ func (m *Model) ingestWireBatch(events []client.Event) (tea.Model, tea.Cmd) { cmd = tea.Batch(cmd, pm.armJobsWatch()) } if ev.Type == client.EventDisconnected { - return model, tea.Batch(cmd, plainLinesCmd(plainLines)) + return model, tea.Batch(cmd, tea.Batch(pm.batchCarry...), plainLinesCmd(plainLines)) } } pm := model.(*Model) @@ -89,6 +95,7 @@ func (m *Model) ingestWireBatch(events []client.Event) (tea.Model, tea.Cmd) { pm.sendQueued(), pm.planFollowup(), pm.flushKicks(), + tea.Batch(pm.batchCarry...), ) } @@ -613,6 +620,16 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { return m, m.noticeSweep() } + if m.inBatch { + if stream { + _ = m.queueRender() // flag only; rearmRenderFlush arms the one flush + m.batchCarry = append(m.batchCarry, m.flushKicks()) + return m, nil + } + m.refresh() + m.batchCarry = append(m.batchCarry, m.sendQueued(), m.planFollowup(), attn, m.flushKicks()) + return m, nil + } if stream { return m, tea.Batch(listen(m.events), m.noticeSweep(), m.approvalSweep(), m.queueRender(), m.flushKicks()) } @@ -683,6 +700,7 @@ func (m *Model) ensureWireTurn() { // The viewport is left alone: refresh() already sticks when the reader // is at the bottom; a forced GotoBottom would yank scrollback. func (m *Model) beginWireTurn(wake bool) { + m.lastEvent = time.Now() // a fresh turn must not inherit the idle gap as a stale age m.msgs = append(m.msgs, message{role: roleAsst, streaming: true, systemWake: wake}) m.curIdx = len(m.msgs) - 1 m.busy = true diff --git a/internal/tui/find.go b/internal/tui/find.go index 9aa572e..4d2a77e 100644 --- a/internal/tui/find.go +++ b/internal/tui/find.go @@ -72,6 +72,10 @@ func (m *Model) handleFindKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } if msg.Type == tea.KeyRunes && len(msg.Runes) > 0 { + // Enhanced-key sentinels ride KeyRunes; their literal text is not input. + if s := msg.String(); newlineChord(s) || s == "shift+delete" { + return m, nil + } m.find.query = append(m.find.query, msg.Runes...) m.findRescan() m.refresh() diff --git a/internal/tui/input.go b/internal/tui/input.go index a1fc846..4631234 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -503,6 +503,7 @@ func (m *Model) submit() tea.Cmd { // sendPrompt appends the user/assistant pair to the transcript, records the // prompt in the history ring, and dispatches it to the server. func (m *Model) sendPrompt(text string) tea.Cmd { + m.lastEvent = time.Now() // a fresh turn must not inherit the idle gap as a stale age m.lastPrompt = text m.recordHistory(text) shown := sanitize(text) // pasted payloads can carry escapes — same rule as resumed transcripts diff --git a/internal/tui/jobs_tab.go b/internal/tui/jobs_tab.go index 7b9c0eb..5dab0a0 100644 --- a/internal/tui/jobs_tab.go +++ b/internal/tui/jobs_tab.go @@ -381,9 +381,9 @@ func jobStatusGlyph(status string) string { func jobExitNote(j client.Job) string { s := jobStatusGlyph(j.Status) + " " + sanitize(j.ID) + " · " + truncate(sanitize(j.Command), 48) if j.ExitCode != nil { - s += " — " + j.Status + fmt.Sprintf(" %d", *j.ExitCode) + s += " — " + sanitize(j.Status) + fmt.Sprintf(" %d", *j.ExitCode) } else { - s += " — " + j.Status + s += " — " + sanitize(j.Status) } return s + " · " + fmtRuntime(j.RuntimeS) } diff --git a/internal/tui/mgmt.go b/internal/tui/mgmt.go index cfee8d8..04bc30e 100644 --- a/internal/tui/mgmt.go +++ b/internal/tui/mgmt.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/BackendStack21/bodek/internal/client" ) @@ -840,7 +841,7 @@ func (m *Model) mgmtDetailLines(w int) []string { } j := m.jobs[m.panelSel] out = append(out, th.acSel.Render("› "+jobStatusGlyph(j.Status)+" "+sanitize(j.ID))) - meta := []string{j.Status, fmtRuntime(j.RuntimeS)} + meta := []string{sanitize(j.Status), fmtRuntime(j.RuntimeS)} if j.ExitCode != nil { meta = append(meta, fmt.Sprintf("exit %d", *j.ExitCode)) } @@ -876,7 +877,7 @@ func (m *Model) mgmtDetailLines(w int) []string { } e := m.agentsReg[m.panelSel] out = append(out, th.acSel.Render("› "+agentStatusGlyph(e.Phase, e.Status)+" "+sanitize(e.Goal))) - meta := []string{e.Phase, e.Status, "task " + sanitize(e.TaskID)} + meta := []string{sanitize(e.Phase), sanitize(e.Status), "task " + sanitize(e.TaskID)} if e.LastTool != "" { meta = append(meta, "last "+sanitize(e.LastTool)) } @@ -983,5 +984,19 @@ func (m *Model) mgmtDetailLines(w int) []string { out = append(out, wrapText(sanitize(r.raw), w)...) } } - return out + return fitDetailLines(out, w) +} + +// fitDetailLines bounds every detail row by display cells: embedded +// newlines split, tabs expand, and wide runes wrap at the cell edge, so the +// windowed panel's row count is the rendered row count. +func fitDetailLines(lines []string, w int) []string { + w = max(w, 1) + fit := make([]string, 0, len(lines)) + for _, ln := range lines { + for _, sub := range strings.Split(strings.ReplaceAll(ln, "\t", " "), "\n") { + fit = append(fit, strings.Split(ansi.Hardwrap(sub, w, true), "\n")...) + } + } + return fit } diff --git a/internal/tui/model.go b/internal/tui/model.go index a6a8632..6ebdd6a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -215,14 +215,16 @@ type Model struct { sp spinner.Model glam *glamour.TermRenderer - msgs []message - curIdx int // index of the streaming assistant message, -1 when idle - busy bool - wakeArmed bool // bg_wake seen but its turn not carded yet: arms the lazy wake marker - runStart time.Time - lastEvent time.Time // (R5) last eventBatchMsg arrival; drives the stale-age head segment - lastTool string - lastArg string + msgs []message + curIdx int // index of the streaming assistant message, -1 when idle + busy bool + wakeArmed bool // bg_wake seen but its turn not carded yet: arms the lazy wake marker + runStart time.Time + inBatch bool // ingestWireBatch is draining frames: handleEvent parks follow-ups in batchCarry + batchCarry []tea.Cmd // follow-up cmds collected during the batch + lastEvent time.Time // (R5) last eventBatchMsg arrival; drives the stale-age head segment + lastTool string + lastArg string failBellFired bool // (A3) the failure BEL rang for this turn — guard against double-fire apprBells []bool // per-approval urgent-window BEL latch, parallel to apprDeadlines @@ -288,6 +290,7 @@ type Model struct { // can see what they are gating. panelDetail bool // management tab: detail view open detailScroll int // detail view top-line scroll offset + popScroll int // cockpit card top-line offset popover bool // cockpit overlay (h): server/link/budget/session consolidation // Sessions panel state: server-side search plus paged "load more". @@ -1226,6 +1229,7 @@ func (m *Model) clearConversation() tea.Cmd { m.resetMsgBlocks() m.convPrefixRefs = nil m.find = findState{} // nothing left to search + m.copyMark = copySpan{} m.stepLineIndex = nil m.replyLineIndex = nil m.turnStats = nil diff --git a/internal/tui/palette.go b/internal/tui/palette.go index 4725906..40f4252 100644 --- a/internal/tui/palette.go +++ b/internal/tui/palette.go @@ -314,8 +314,8 @@ func (m *Model) handlePaletteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.refresh() return m, cmd case "backspace": - if n := len(m.pal.query); n > 0 { - m.pal.query = m.pal.query[:n-1] + if q := []rune(m.pal.query); len(q) > 0 { + m.pal.query = string(q[:len(q)-1]) m.filterPalette() m.refresh() } diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 1f791ed..7900d34 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -1208,12 +1208,17 @@ func (m *Model) handleSessionDeleted(msg sessionDeletedMsg) tea.Cmd { return nil } m.tokens.Delete(msg.id) - if m.panelSel < len(m.sessions) && m.sessions[m.panelSel].ID == msg.id { - m.sessions = append(m.sessions[:m.panelSel], m.sessions[m.panelSel+1:]...) - if m.panelSel >= len(m.sessions) && m.panelSel > 0 { - m.panelSel-- + for i := range m.sessions { + if m.sessions[i].ID != msg.id { + continue + } + m.sessions = append(m.sessions[:i], m.sessions[i+1:]...) + if i < m.panelSel { + m.panelSel-- // the selected row shifted up with the removal } + break } + m.panelSel = clampSel(m.panelSel, len(m.sessions)) if len(m.sessions) == 0 { m.panelMsg = "no saved sessions yet" } diff --git a/internal/tui/plain.go b/internal/tui/plain.go index c7fc11e..a257c5e 100644 --- a/internal/tui/plain.go +++ b/internal/tui/plain.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "unicode/utf8" tea "github.com/charmbracelet/bubbletea" @@ -159,7 +160,7 @@ func plainClip(s string) string { return s } cut := max - for cut > 0 && r[cut-1] >= 0x80 && r[cut-1] < 0xC0 { + for cut > 0 && !utf8.RuneStart(r[cut]) { cut-- // never split a UTF-8 sequence } return string(r[:cut]) + "…" diff --git a/internal/tui/progress_notes_test.go b/internal/tui/progress_notes_test.go index 30b9c54..d4357c3 100644 --- a/internal/tui/progress_notes_test.go +++ b/internal/tui/progress_notes_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "reflect" + "regexp" "strings" "testing" "time" @@ -92,7 +93,9 @@ func TestPlainBatchProgressPrintsInWireOrder(t *testing.T) { {Type: "token", Content: "Second turn complete."}, {Type: "done"}, }) - out := capturePlainPrint(t, cmd) + // The done title/bell (OSC) now fires from a batch too; only the + // scrollback text is under test here. + out := oscRe.ReplaceAllString(capturePlainPrint(t, cmd), "") previous := -1 for _, text := range []string{"Inspecting now.", "▸ read_file", "▪ read_file", "Checking the result.", "▸ shell", "▪ shell", "First turn complete.", "Second turn complete."} { index := strings.Index(out, text) @@ -236,3 +239,5 @@ func TestBufferedProgressNotesVisibleWithoutReasoning(t *testing.T) { } } } + +var oscRe = regexp.MustCompile(`\x1b\][^\x07]*\x07`) diff --git a/internal/tui/subagents.go b/internal/tui/subagents.go index c69317d..1900b4b 100644 --- a/internal/tui/subagents.go +++ b/internal/tui/subagents.go @@ -120,10 +120,10 @@ func (m *Model) attachSubState(i int, ev client.Event) bool { s.agents = append(s.agents, card) } if ev.Phase != "" { - card.phase = ev.Phase + card.phase = collapse(ev.Phase) } if ev.Status != "" { - card.status = ev.Status + card.status = collapse(ev.Status) } card.step = ev.Step if ev.Tool != "" {