diff --git a/internal/agentstore/index.go b/internal/agentstore/index.go
index e9c86a1..672c87a 100644
--- a/internal/agentstore/index.go
+++ b/internal/agentstore/index.go
@@ -594,6 +594,38 @@ func (x *Index) Resolve(tool Tool, id string) (string, Summary, bool) {
return "", Summary{}, false
}
+// MatchSession finds the newest live transcript in cwd whose file was
+// written since the shell was created. It is the fallback link between a PTY
+// session and an agent conversation when no agent hook supplied an exact id.
+//
+// The match deliberately uses file mtime rather than EndedAt. Timestamps
+// inside vendor-owned files are less trustworthy than the filesystem fact
+// that the agent just appended to it. The index sweep is poked first; callers
+// receive the best snapshot available now and naturally see a newly
+// discovered transcript on their next session-list poll.
+func (x *Index) MatchSession(cwd string, since time.Time) (Tool, string, bool) {
+ if cwd == "" {
+ return "", "", false
+ }
+ x.poke()
+ x.mu.Lock()
+ defer x.mu.Unlock()
+ var best *entry
+ for _, e := range x.entries {
+ if e == nil || e.ParentPath != "" || e.Missing || e.Summary.MessageCount == 0 ||
+ e.Summary.Cwd != cwd || e.MtimeNs < since.UnixNano() {
+ continue
+ }
+ if best == nil || e.MtimeNs > best.MtimeNs {
+ best = e
+ }
+ }
+ if best == nil {
+ return "", "", false
+ }
+ return best.Summary.Tool, best.Summary.ID, true
+}
+
func toolAllowed(tool Tool, filter []string) bool {
if len(filter) == 0 {
return true
diff --git a/internal/agentstore/index_test.go b/internal/agentstore/index_test.go
index fedab9d..981464c 100644
--- a/internal/agentstore/index_test.go
+++ b/internal/agentstore/index_test.go
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
+ "time"
)
const (
@@ -111,6 +112,33 @@ func TestSnapshotFilters(t *testing.T) {
}
}
+func TestMatchSessionPicksNewestTranscriptSinceShellStart(t *testing.T) {
+ home, paths := fakeHome(t)
+ base := time.Now().Add(-time.Hour).Truncate(time.Second)
+ for tool, at := range map[Tool]time.Time{
+ ToolClaude: base.Add(10 * time.Minute),
+ ToolCodex: base.Add(20 * time.Minute),
+ ToolPi: base.Add(30 * time.Minute),
+ } {
+ if err := os.Chtimes(paths[tool], at, at); err != nil {
+ t.Fatalf("Chtimes(%s): %v", tool, err)
+ }
+ }
+ x := New(t.TempDir(), home)
+ x.sweep()
+
+ tool, id, ok := x.MatchSession("/home/dev/proj", base.Add(15*time.Minute))
+ if !ok || tool != ToolPi || id != piFixtureID {
+ t.Fatalf("MatchSession = (%s, %s, %v), want newest pi transcript", tool, id, ok)
+ }
+ if _, _, ok := x.MatchSession("/home/dev/proj", base.Add(40*time.Minute)); ok {
+ t.Fatal("MatchSession linked a transcript older than the shell")
+ }
+ if _, _, ok := x.MatchSession("/somewhere/else", base); ok {
+ t.Fatal("MatchSession linked a transcript from another cwd")
+ }
+}
+
// TestSnapshotHidesEmptySessions pins the stub filter: a transcript whose
// lines are all bookkeeping (a real store is full of them — hook-only
// sessions, bare last-prompt stubs) is indexed but never listed. Its zero
diff --git a/internal/daemon/agents.go b/internal/daemon/agents.go
index c3b8008..e4905d9 100644
--- a/internal/daemon/agents.go
+++ b/internal/daemon/agents.go
@@ -2,8 +2,10 @@ package daemon
import (
"errors"
+ "time"
"github.com/karnstack/flue/internal/agentstore"
+ "github.com/karnstack/flue/internal/session"
"github.com/karnstack/flue/internal/wire"
)
@@ -46,6 +48,17 @@ import (
// goroutine to cap.
const maxAgentWork = 4
+// maxAgentFollows bounds the durable file tails one connection can hold.
+// Eight covers a densely split workspace while preventing a malformed client
+// from turning one socket into an unbounded transcript poller.
+const maxAgentFollows = 8
+
+// agentFollowPoll is deliberately much faster than the old viewer's five
+// second timer while still coalescing the burst of filesystem writes that
+// makes one completed agent message. The read itself advances by byte offset,
+// so an unchanged file costs an open and stat rather than a reparse.
+const agentFollowPoll = 400 * time.Millisecond
+
// SetAgentIndex installs the transcript index. Nil — a Server nobody wired,
// which is every test's default and a daemon whose home directory could not
// be resolved — leaves the verbs answering bad_message and keeps the
@@ -63,6 +76,22 @@ func (s *Server) agentIndex() *agentstore.Index {
return s.agentIdx
}
+// withAgent enriches one session-list row with the best live transcript link
+// the index can prove from cwd and recency. Hooks can make this exact later;
+// keeping the fallback here means both WebSocket and loopback HTTP lists use
+// the same answer and no session implementation has to know agent stores.
+func (s *Server) withAgent(info session.Info) session.Info {
+ idx := s.agentIndex()
+ if idx == nil || info.State != "running" {
+ return info
+ }
+ tool, id, ok := idx.MatchSession(info.Cwd, info.CreatedAt)
+ if ok {
+ info.Agent, info.AgentSession = string(tool), id
+ }
+ return info
+}
+
// handleAgents answers the agents verb. A snapshot of the in-memory index —
// no file work — so it runs on the read loop like list does.
func (c *conn) handleAgents(m wire.Agents) {
@@ -155,6 +184,99 @@ func (c *conn) handleAgentRead(m wire.AgentRead) {
}()
}
+func agentFollowKey(tool, id string) string { return tool + "\x00" + id }
+
+// handleAgentFollow starts (or moves) a pushed transcript tail. Pages carry
+// no reqId: they are events rather than answers, and the client routes them by
+// (tool, id). Reads remain bounded by agentstore's ordinary page limits; a
+// large append drains page by page without waiting another tick.
+func (c *conn) handleAgentFollow(m wire.AgentFollow) {
+ idx := c.srv.agentIndex()
+ if idx == nil {
+ c.sendError("bad_message", "agents unavailable")
+ return
+ }
+ tool := agentstore.Tool(m.Tool)
+ if _, _, ok := idx.Resolve(tool, m.ID); !ok {
+ c.sendError("not_found", "cannot follow that transcript")
+ return
+ }
+ if m.Offset < 0 {
+ m.Offset = 0
+ }
+ key := agentFollowKey(m.Tool, m.ID)
+ done := make(chan struct{})
+ c.mu.Lock()
+ previous := c.agentFollows[key]
+ if previous == nil && len(c.agentFollows) >= maxAgentFollows {
+ c.mu.Unlock()
+ c.sendError("busy", "too many followed transcripts")
+ return
+ }
+ c.agentFollows[key] = done
+ c.mu.Unlock()
+ if previous != nil {
+ close(previous)
+ }
+
+ c.pumps.Add(1)
+ go func(offset int64) {
+ defer c.pumps.Done()
+ defer func() {
+ c.mu.Lock()
+ if c.agentFollows[key] == done {
+ delete(c.agentFollows, key)
+ }
+ c.mu.Unlock()
+ }()
+
+ timer := time.NewTimer(0)
+ defer timer.Stop()
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ case <-done:
+ return
+ case <-timer.C:
+ }
+
+ for {
+ page, err := idx.ReadPage(tool, m.ID, offset, "forward", 100)
+ if err != nil {
+ return
+ }
+ moved := page.Next != offset
+ offset = page.Next
+ if len(page.Messages) > 0 {
+ if err := c.sendControl(wire.AgentPage{
+ Tool: m.Tool, ID: m.ID, Messages: page.Messages,
+ Start: page.Start, Next: page.Next, Eof: page.Eof,
+ FileSize: page.FileSize,
+ }); err != nil {
+ return
+ }
+ }
+ if page.Eof || !moved {
+ break
+ }
+ }
+ timer.Reset(agentFollowPoll)
+ }
+ }(m.Offset)
+}
+
+func (c *conn) handleAgentUnfollow(m wire.AgentUnfollow) {
+ key := agentFollowKey(m.Tool, m.ID)
+ c.mu.Lock()
+ done := c.agentFollows[key]
+ delete(c.agentFollows, key)
+ c.mu.Unlock()
+ if done != nil {
+ close(done)
+ }
+}
+
// handleAgentSearch answers agentSearch on its own goroutine, for the reason
// agentRead gets one with less argument: a search's budget is sixty-four
// megabytes of other tools' files.
diff --git a/internal/daemon/agents_test.go b/internal/daemon/agents_test.go
index 916c1c0..5dcc81e 100644
--- a/internal/daemon/agents_test.go
+++ b/internal/daemon/agents_test.go
@@ -13,6 +13,7 @@ import (
"github.com/coder/websocket"
"github.com/karnstack/flue/internal/agentstore"
+ "github.com/karnstack/flue/internal/session"
"github.com/karnstack/flue/internal/wire"
)
@@ -174,6 +175,41 @@ func TestAgentsListsTranscriptsNewestFirst(t *testing.T) {
})
}
+func TestSessionInfoIsEnrichedWithLiveAgentTranscript(t *testing.T) {
+ ts, _, srv := newTestServerUI(t, http.NotFoundHandler())
+ home := agentFixtureHome(t)
+ claudePath := filepath.Join(home, ".claude", "projects", "-home-dev-proj", agentClaudeID+".jsonl")
+ codexPath := filepath.Join(home, ".codex", "sessions", "2026", "07", "01", "rollout-2026-07-01T10-00-00-"+agentCodexID+".jsonl")
+ piPath := filepath.Join(home, ".pi", "agent", "sessions", "--home-dev-proj--", "2026-03-27T12-35-33_"+agentPiID+".jsonl")
+ older := time.Now().Add(-2 * time.Minute)
+ for _, path := range []string{codexPath, piPath} {
+ if err := os.Chtimes(path, older, older); err != nil {
+ t.Fatalf("Chtimes older transcript: %v", err)
+ }
+ }
+ newest := time.Now().Add(-time.Minute)
+ if err := os.Chtimes(claudePath, newest, newest); err != nil {
+ t.Fatalf("Chtimes claude transcript: %v", err)
+ }
+ idx := agentstore.New(t.TempDir(), home)
+ srv.SetAgentIndex(idx)
+ c := dial(t, ts)
+ settleAgents(t, c)
+
+ got := srv.withAgent(session.Info{
+ State: "running", Cwd: "/home/dev/proj", CreatedAt: time.Now().Add(-time.Hour),
+ })
+ if got.Agent != "claude" || got.AgentSession != agentClaudeID {
+ t.Fatalf("withAgent = (%s, %s), want newest claude transcript", got.Agent, got.AgentSession)
+ }
+ exited := srv.withAgent(session.Info{
+ State: "exited", Cwd: "/home/dev/proj", CreatedAt: time.Now().Add(-time.Hour),
+ })
+ if exited.Agent != "" || exited.AgentSession != "" {
+ t.Fatalf("exited session was linked: %+v", exited)
+ }
+}
+
func TestAgentReadPagesForwardAndBackward(t *testing.T) {
ts := newAgentTestServer(t)
c := dial(t, ts)
@@ -235,6 +271,53 @@ func TestAgentReadPagesForwardAndBackward(t *testing.T) {
})
}
+func TestAgentFollowPushesCompletedMessages(t *testing.T) {
+ ts, _, srv := newTestServerUI(t, http.NotFoundHandler())
+ home := agentFixtureHome(t)
+ idx := agentstore.New(t.TempDir(), home)
+ srv.SetAgentIndex(idx)
+ c := dial(t, ts)
+ writeControl(t, c, wire.Hello{Ver: "test"})
+ settleAgents(t, c)
+
+ path, summary, ok := idx.Resolve(agentstore.ToolClaude, agentClaudeID)
+ if !ok {
+ t.Fatal("Resolve missed claude fixture")
+ }
+ writeControl(t, c, wire.AgentFollow{
+ Tool: "claude", ID: agentClaudeID, Offset: summary.FileSize,
+ })
+
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ line := `{"parentUuid":"x","isSidechain":false,"cwd":"/home/dev/proj","sessionId":"` + agentClaudeID + `","type":"assistant","requestId":"follow","message":{"id":"follow","type":"message","role":"assistant","model":"claude-fable-5","content":[{"type":"text","text":"Followed live."}],"usage":{"input_tokens":1,"output_tokens":1}},"uuid":"follow","timestamp":"2026-08-10T09:00:30.000Z"}` + "\n"
+ if _, err := f.WriteString(line); err != nil {
+ f.Close()
+ t.Fatal(err)
+ }
+ if err := f.Close(); err != nil {
+ t.Fatal(err)
+ }
+ direct, err := idx.ReadPage(agentstore.ToolClaude, agentClaudeID, summary.FileSize, "forward", 100)
+ if err != nil || len(direct.Messages) != 1 {
+ t.Fatalf("direct read after append = (%+v, %v), want one message", direct, err)
+ }
+
+ readUntil(t, c, func(msg any, _ []byte) bool {
+ page, ok := msg.(wire.AgentPage)
+ if !ok || page.ReqID != 0 || page.ID != agentClaudeID {
+ return false
+ }
+ if len(page.Messages) != 1 || page.Messages[0].Text != "Followed live." {
+ t.Errorf("pushed page = %+v", page.Messages)
+ }
+ return true
+ })
+ writeControl(t, c, wire.AgentUnfollow{Tool: "claude", ID: agentClaudeID})
+}
+
func TestAgentSearchFindsAndBounds(t *testing.T) {
ts := newAgentTestServer(t)
c := dial(t, ts)
diff --git a/internal/daemon/conn.go b/internal/daemon/conn.go
index 3100406..a560dd3 100644
--- a/internal/daemon/conn.go
+++ b/internal/daemon/conn.go
@@ -259,6 +259,10 @@ type conn struct {
// cancel by name: each goroutine replies once and ends. The cap it
// enforces is maxAgentWork; see the comment there.
agentWork int
+ // agentFollows are the live transcript tails this connection asked for.
+ // Each value is closed to replace or stop that tail; connection teardown
+ // also cancels the shared context every follower selects on.
+ agentFollows map[string]chan struct{}
// pumps counts the file pumps still running. closeAll waits on it, so no
// goroutine holding a file descriptor outlives serve.
@@ -272,16 +276,17 @@ type conn struct {
func newConn(ctx context.Context, cancel context.CancelFunc, mc MessageConn, srv *Server, peer, origin string, deviceKey []byte) *conn {
return &conn{
- ctx: ctx,
- cancel: cancel,
- mc: mc,
- srv: srv,
- peer: peer,
- origin: origin,
- deviceKey: deviceKey,
- out: make(chan frame, outboxDepth),
- attach: map[uint32]*attachment{},
- reads: map[uint32]*fileRead{},
+ ctx: ctx,
+ cancel: cancel,
+ mc: mc,
+ srv: srv,
+ peer: peer,
+ origin: origin,
+ deviceKey: deviceKey,
+ out: make(chan frame, outboxDepth),
+ attach: map[uint32]*attachment{},
+ reads: map[uint32]*fileRead{},
+ agentFollows: map[string]chan struct{}{},
}
}
@@ -604,7 +609,7 @@ func (c *conn) refsFor(id string) []uint32 {
func (c *conn) sendSessions() {
infos := []session.Info{}
for _, s := range c.srv.reg.List() {
- infos = append(infos, s.Info())
+ infos = append(infos, c.srv.withAgent(s.Info()))
}
_ = c.sendControl(wire.Sessions{Sessions: infos})
}
@@ -817,6 +822,12 @@ func (c *conn) handleControl(msg any) {
case wire.AgentRead:
c.handleAgentRead(m)
+ case wire.AgentFollow:
+ c.handleAgentFollow(m)
+
+ case wire.AgentUnfollow:
+ c.handleAgentUnfollow(m)
+
case wire.AgentSearch:
c.handleAgentSearch(m)
@@ -1158,6 +1169,17 @@ func (c *conn) closeAll() {
for _, ref := range reads {
c.endRead(ref)
}
+
+ c.mu.Lock()
+ follows := make([]chan struct{}, 0, len(c.agentFollows))
+ for key, done := range c.agentFollows {
+ delete(c.agentFollows, key)
+ follows = append(follows, done)
+ }
+ c.mu.Unlock()
+ for _, done := range follows {
+ close(done)
+ }
// Ending each read first is what makes this wait finite, and it is finite on
// its own terms. Every wait a pump can be in selects on that read's done
// channel — waiting for outbox room, waiting for the writer to have written
diff --git a/internal/daemon/server.go b/internal/daemon/server.go
index c257e6d..ea77de3 100644
--- a/internal/daemon/server.go
+++ b/internal/daemon/server.go
@@ -986,7 +986,7 @@ func writeAuthError(w http.ResponseWriter, err error) {
func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
infos := []session.Info{}
for _, sess := range s.reg.List() {
- infos = append(infos, sess.Info())
+ infos = append(infos, s.withAgent(sess.Info()))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"sessions": infos})
diff --git a/internal/session/session.go b/internal/session/session.go
index c5f723b..bbcd316 100644
--- a/internal/session/session.go
+++ b/internal/session/session.go
@@ -105,6 +105,13 @@ type Info struct {
// session that carries neither serialises exactly as it always has.
Group string `json:"group,omitempty"`
Ephemeral bool `json:"ephemeral,omitempty"`
+ // Agent and AgentSession identify the live coding-agent transcript most
+ // plausibly owned by this shell. They are enriched by the daemon from the
+ // transcript index rather than stored on the session: the fallback match
+ // follows the shell's current cwd and the newest transcript written since
+ // it was created, so it can move when a shell starts a different agent.
+ Agent string `json:"agent,omitempty"`
+ AgentSession string `json:"agentSession,omitempty"`
}
// MetaPatch is a partial update to a session's human-owned metadata: a nil
diff --git a/internal/wire/control.go b/internal/wire/control.go
index bf2fccd..54557d2 100644
--- a/internal/wire/control.go
+++ b/internal/wire/control.go
@@ -341,6 +341,22 @@ type AgentRead struct {
ReqID uint64 `json:"reqId,omitempty"`
}
+// AgentFollow starts a live tail of one transcript at Offset. Completed
+// messages appended after that point arrive as unsolicited agentPage frames;
+// a second follow for the same (tool, id) replaces the first. AgentUnfollow
+// stops it. The stream is connection-scoped and therefore rides the same
+// encrypted relay leg as every other session interaction.
+type AgentFollow struct {
+ Tool string `json:"tool"`
+ ID string `json:"id"`
+ Offset int64 `json:"offset"`
+}
+
+type AgentUnfollow struct {
+ Tool string `json:"tool"`
+ ID string `json:"id"`
+}
+
// AgentSearch asks for the messages matching a query, case-insensitive,
// newest sessions first. Tools and Cwd filter as on Agents; Limit caps the
// hits. Answered by agentHits.
@@ -758,6 +774,10 @@ func typeName(msg any) (string, bool) {
return "agents", true
case AgentRead:
return "agentRead", true
+ case AgentFollow:
+ return "agentFollow", true
+ case AgentUnfollow:
+ return "agentUnfollow", true
case AgentSearch:
return "agentSearch", true
case Welcome:
@@ -874,6 +894,10 @@ func DecodeControl(b []byte) (any, error) {
return *t, nil
case *AgentRead:
return *t, nil
+ case *AgentFollow:
+ return *t, nil
+ case *AgentUnfollow:
+ return *t, nil
case *AgentSearch:
return *t, nil
case *Welcome:
@@ -973,6 +997,10 @@ func DecodeControl(b []byte) (any, error) {
return deref(into(&Agents{}))
case "agentRead":
return deref(into(&AgentRead{}))
+ case "agentFollow":
+ return deref(into(&AgentFollow{}))
+ case "agentUnfollow":
+ return deref(into(&AgentUnfollow{}))
case "agentSearch":
return deref(into(&AgentSearch{}))
case "agentIndex":
diff --git a/internal/wire/wire_test.go b/internal/wire/wire_test.go
index 8dc0009..638cb6e 100644
--- a/internal/wire/wire_test.go
+++ b/internal/wire/wire_test.go
@@ -690,6 +690,25 @@ func TestAgentRepliesEncodeEmptyListsAsArrays(t *testing.T) {
}
}
+func TestAgentFollowMessagesRoundTrip(t *testing.T) {
+ for _, want := range []any{
+ AgentFollow{Tool: "codex", ID: "thread-1", Offset: 42},
+ AgentUnfollow{Tool: "codex", ID: "thread-1"},
+ } {
+ b, err := EncodeControl(want)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := DecodeControl(b)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("round trip = %#v, want %#v", got, want)
+ }
+ }
+}
+
// deepEqual recursively compares two any values for equality, handling
// nested maps and slices. Used by TestGoldenControlMessages to verify
// round-trip fidelity while tolerating type differences between JSON
diff --git a/web/src/agents/prompt.test.ts b/web/src/agents/prompt.test.ts
new file mode 100644
index 0000000..89c1e9a
--- /dev/null
+++ b/web/src/agents/prompt.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+
+import { parseTerminalPrompt, terminalNeedsAttention, terminalPlainText } from './prompt'
+
+describe('terminal prompt fallback', () => {
+ it('turns a numbered question into safe inputs', () => {
+ expect(
+ parseTerminalPrompt('\x1b[1mHow should I continue?\x1b[0m\r\n 1. Apply the change\r\n❯ 2. Cancel'),
+ ).toEqual({
+ question: 'How should I continue?',
+ options: [
+ { label: 'Apply the change', input: '1' },
+ { label: 'Cancel', input: '2' },
+ ],
+ })
+ })
+
+ it('recognises yes/no prompts', () => {
+ expect(parseTerminalPrompt('Allow this command? [y/N]')).toEqual({
+ question: 'Allow this command?',
+ options: [
+ { label: 'Yes', input: 'y' },
+ { label: 'No', input: 'n' },
+ ],
+ })
+ })
+
+ it('refuses unrelated numbered output', () => {
+ expect(parseTerminalPrompt('Files changed\n1. app.ts\n2. app.test.ts')).toBeNull()
+ })
+
+ it('strips controls and spots an unrenderable permission prompt', () => {
+ expect(terminalPlainText('\x1b[31mApprove command?\x1b[0m')).toBe('Approve command?')
+ expect(terminalNeedsAttention('Permission required: approve in the terminal?')).toBe(true)
+ })
+})
diff --git a/web/src/agents/prompt.ts b/web/src/agents/prompt.ts
new file mode 100644
index 0000000..4dbbd32
--- /dev/null
+++ b/web/src/agents/prompt.ts
@@ -0,0 +1,68 @@
+export interface TerminalPromptOption {
+ label: string
+ input: string
+}
+
+export interface TerminalPrompt {
+ question: string
+ options: TerminalPromptOption[]
+}
+
+/** Flatten the terminal control sequences that can occur around a prompt. */
+export function terminalPlainText(raw: string): string {
+ return raw
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
+ .replace(/\x1b[()][A-Z0-9]/g, '')
+ .replace(/[^\S\n]*\r/g, '\n')
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
+}
+
+/**
+ * Parse the small prompt shapes shared by coding-agent TUIs. Exact hook
+ * payloads can replace this when available; this fallback intentionally
+ * refuses ambiguous prose rather than turning arbitrary numbered output into
+ * buttons that type into a live shell.
+ */
+export function parseTerminalPrompt(raw: string): TerminalPrompt | null {
+ const lines = terminalPlainText(raw)
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .slice(-14)
+
+ const last = lines.at(-1) ?? ''
+ const yesNo = last.match(/^(.*?)(?:\s+)?\[([yYnN])\/([yYnN])\]\s*$/)
+ if (yesNo !== null) {
+ const question = yesNo[1]?.trim() || 'Continue?'
+ return {
+ question,
+ options: [
+ { label: 'Yes', input: 'y' },
+ { label: 'No', input: 'n' },
+ ],
+ }
+ }
+
+ const options: TerminalPromptOption[] = []
+ let firstOption = -1
+ for (let index = 0; index < lines.length; index++) {
+ const match = lines[index]!.match(/^(?:[>›❯]\s*)?(\d{1,2})[.)]\s+(.{1,120})$/)
+ if (match === null) continue
+ firstOption = firstOption < 0 ? index : firstOption
+ options.push({ label: match[2]!.trim(), input: match[1]! })
+ }
+ if (options.length < 2 || firstOption < 1 || options.length > 8) return null
+
+ const before = lines.slice(Math.max(0, firstOption - 4), firstOption)
+ const question = [...before].reverse().find((line) => /[?:]$/.test(line))
+ if (question === undefined) return null
+ return { question, options }
+}
+
+export function terminalNeedsAttention(raw: string): boolean {
+ const tail = terminalPlainText(raw).split('\n').slice(-8).join(' ')
+ return /(?:allow|approve|permission|continue|proceed|choose|select).{0,80}(?:\?|\[y\/n\])/i.test(
+ tail,
+ )
+}
diff --git a/web/src/client/client.test.ts b/web/src/client/client.test.ts
index 0139eff..bb498d4 100644
--- a/web/src/client/client.test.ts
+++ b/web/src/client/client.test.ts
@@ -3399,6 +3399,37 @@ describe('FlueClient agent transcripts', () => {
await answer
})
+ it('streams pushed pages and resumes the advanced offset after reconnect', async () => {
+ vi.useFakeTimers()
+ vi.spyOn(Math, 'random').mockReturnValue(0)
+ const { c, sock, sockets } = connected()
+ const pushed: AgentPageMsg[] = []
+ c.onAgentPage((message) => pushed.push(message))
+
+ c.followAgent('claude', 'a1', 20)
+ expect(sock.sentControl().filter((m) => m.type === 'agentFollow')).toStrictEqual([
+ { type: 'agentFollow', tool: 'claude', id: 'a1', offset: 20 },
+ ])
+ const event: AgentPageMsg = {
+ type: 'agentPage', tool: 'claude', id: 'a1', messages: [],
+ start: 0, next: 44, eof: true, fileSize: 44,
+ }
+ sock.emitControl(event)
+ expect(pushed).toStrictEqual([event])
+
+ sock.close()
+ await vi.advanceTimersByTimeAsync(125)
+ sockets[1]!.open()
+ expect(sockets[1]!.sentControl().filter((m) => m.type === 'agentFollow')).toStrictEqual([
+ { type: 'agentFollow', tool: 'claude', id: 'a1', offset: 44 },
+ ])
+
+ c.unfollowAgent('claude', 'a1')
+ expect(sockets[1]!.sentControl().at(-1)).toStrictEqual({
+ type: 'agentUnfollow', tool: 'claude', id: 'a1',
+ })
+ })
+
it('resolves agentSearch with the hits that echo its reqId', async () => {
const { c, sock } = connected()
diff --git a/web/src/client/client.ts b/web/src/client/client.ts
index a15e559..aea411e 100644
--- a/web/src/client/client.ts
+++ b/web/src/client/client.ts
@@ -348,6 +348,9 @@ export class FlueClient {
private agentPageAsks = new Map>()
private agentHitsAsks = new Map>()
+ /** Live transcript tails, kept across reconnects like PTY attachments. */
+ private agentFollows = new Map()
+
/** A `list` asked for while the socket was down. See `list`. */
private listOwed = false
@@ -370,6 +373,7 @@ export class FlueClient {
private pairingListeners = new Emitter<[Pairing]>()
private revokedListeners = new Emitter<[string]>()
private welcomeListeners = new Emitter<[Welcome]>()
+ private agentPageListeners = new Emitter<[AgentPageMsg]>()
/**
* The last welcome this client was handed, kept across reconnects rather
@@ -417,6 +421,9 @@ export class FlueClient {
onStatus(cb: (s: ConnStatus) => void) {
return this.statusListeners.add(cb)
}
+ onAgentPage(cb: (page: AgentPageMsg) => void) {
+ return this.agentPageListeners.add(cb)
+ }
/**
* A session the daemon answered `not_found` for. The client has already
@@ -838,6 +845,24 @@ export class FlueClient {
}))
}
+ /**
+ * Keep receiving completed messages appended after `offset`. The daemon
+ * pushes ordinary agentPage frames with no reqId; the intent survives a
+ * reconnect and advances as pages arrive, so a brief outage neither drops
+ * nor duplicates the tail.
+ */
+ followAgent(tool: AgentTool, id: string, offset: number) {
+ const key = `${tool}\u0000${id}`
+ const follow = { tool, id, offset }
+ this.agentFollows.set(key, follow)
+ this.send({ type: 'agentFollow', ...follow })
+ }
+
+ unfollowAgent(tool: AgentTool, id: string) {
+ this.agentFollows.delete(`${tool}\u0000${id}`)
+ this.send({ type: 'agentUnfollow', tool, id })
+ }
+
/** Search the indexed transcripts, narrowed and capped as the caller asks. */
agentSearch(
query: string,
@@ -1106,6 +1131,9 @@ export class FlueClient {
this.send({ type: 'hello', ver: PROTOCOL_VERSION, caps: [...CAPS] })
for (const [id, lastSeq] of this.wanted) this.sendAttach(id, lastSeq)
+ for (const follow of this.agentFollows.values()) {
+ this.send({ type: 'agentFollow', ...follow })
+ }
if (this.listOwed) {
this.listOwed = false
this.send({ type: 'list' })
@@ -1291,7 +1319,16 @@ export class FlueClient {
break
case 'agentPage':
- this.settleAgentAsk(this.agentPageAsks, msg)
+ if (msg.reqId !== undefined) {
+ this.settleAgentAsk(this.agentPageAsks, msg as AgentPageMsg & { reqId: number })
+ break
+ }
+ {
+ const follow = this.agentFollows.get(`${msg.tool}\u0000${msg.id}`)
+ if (follow === undefined) break
+ follow.offset = msg.next
+ this.agentPageListeners.emit(msg)
+ }
break
case 'agentHits':
diff --git a/web/src/client/protocol.ts b/web/src/client/protocol.ts
index 4268c0f..7c29813 100644
--- a/web/src/client/protocol.ts
+++ b/web/src/client/protocol.ts
@@ -107,6 +107,10 @@ export interface SessionInfo {
* its UI detaches — the shell keeps running until then. Absent means false.
*/
ephemeral?: boolean
+ /** Coding-agent transcript currently linked to this live shell. */
+ agent?: AgentTool
+ /** Transcript id addressable by `agentRead`. */
+ agentSession?: string
}
/**
@@ -403,6 +407,19 @@ export interface AgentReadMsg {
reqId: number
}
+export interface AgentFollowMsg {
+ type: 'agentFollow'
+ tool: AgentTool
+ id: string
+ offset: number
+}
+
+export interface AgentUnfollowMsg {
+ type: 'agentUnfollow'
+ tool: AgentTool
+ id: string
+}
+
/**
* Search every indexed transcript for a substring, case-insensitively.
* Answered by `agentHits` echoing `reqId`. The filters are `agents`'s two,
@@ -438,6 +455,8 @@ export type ClientMessage =
| PairCancelMsg
| AgentsMsg
| AgentReadMsg
+ | AgentFollowMsg
+ | AgentUnfollowMsg
| AgentSearchMsg
// Server -> client.
@@ -878,7 +897,8 @@ export interface AgentPageMsg {
eof: boolean
/** The file's size at parse time — where a jump-to-end reads back from. */
fileSize: number
- reqId: number
+ /** Present on an `agentRead` answer; absent on a live-follow push. */
+ reqId?: number
}
/**
diff --git a/web/src/components/agent-chat.test.tsx b/web/src/components/agent-chat.test.tsx
new file mode 100644
index 0000000..6065d41
--- /dev/null
+++ b/web/src/components/agent-chat.test.tsx
@@ -0,0 +1,61 @@
+import { act, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it } from 'vitest'
+
+import { FlueClientProvider } from '@/client/provider'
+import { attached, fakeClient } from '@/testing/socket'
+import { AgentChat } from './agent-chat'
+
+describe('AgentChat', () => {
+ it('writes messages and fallback prompt choices to the attached PTY', async () => {
+ const user = userEvent.setup()
+ const { client, sockets } = fakeClient()
+ const view = render({null} )
+
+ act(() => sockets[0]!.open())
+ view.rerender(
+
+ {}}
+ />
+ ,
+ )
+
+ const socket = sockets[0]!
+ const attach = socket.ofType('attach')[0]!
+ const read = socket.ofType('agentRead')[0]!
+ await act(async () => {
+ socket.emitControl(attached({
+ id: 'shell-1',
+ ref: 7,
+ reqId: attach.reqId as number,
+ }))
+ socket.emitControl({
+ type: 'agentPage',
+ tool: 'claude',
+ id: 'agent-1',
+ messages: [],
+ start: 0,
+ next: 0,
+ eof: true,
+ fileSize: 0,
+ reqId: read.reqId,
+ })
+ })
+
+ const composer = screen.getByRole('textbox', { name: 'Message Claude' })
+ await user.type(composer, 'Ship it')
+ await user.click(screen.getByRole('button', { name: 'Send message' }))
+ expect(socket.input()).toContainEqual({ ref: 7, text: 'Ship it\r' })
+
+ await user.click(screen.getByRole('button', { name: 'Interrupt' }))
+ expect(socket.input()).toContainEqual({ ref: 7, text: '\x1b' })
+
+ act(() => socket.emitOutput(7, 'Allow command? [y/N]'))
+ await user.click(await screen.findByRole('button', { name: 'Yes' }))
+ expect(socket.input()).toContainEqual({ ref: 7, text: 'y\r' })
+ })
+})
diff --git a/web/src/components/agent-chat.tsx b/web/src/components/agent-chat.tsx
new file mode 100644
index 0000000..1404bd9
--- /dev/null
+++ b/web/src/components/agent-chat.tsx
@@ -0,0 +1,465 @@
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type CSSProperties,
+ type FormEvent,
+ type KeyboardEvent,
+} from 'react'
+import { useVirtualizer } from '@tanstack/react-virtual'
+import {
+ ArrowDownIcon,
+ CommandLineIcon,
+ PaperAirplaneIcon,
+ StopIcon,
+} from '@heroicons/react/16/solid'
+
+import { keyMessages, type KeyedAgentMessage } from '@/agents/view'
+import {
+ parseTerminalPrompt,
+ terminalNeedsAttention,
+ type TerminalPrompt,
+} from '@/agents/prompt'
+import type { AgentMessage, AgentTool } from '@/client/protocol'
+import { useFlueClient } from '@/client/provider'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import { AgentMessageBlock } from './agent-message'
+
+const PAGE_LIMIT = 100
+const NEAR_BOTTOM_PX = 80
+const ACTIVITY_QUIET_MS = 1_400
+const TOOL_LABEL: Record = { claude: 'Claude', codex: 'Codex', pi: 'Pi' }
+
+export type AgentSessionView = 'chat' | 'terminal'
+
+export function AgentViewBar({
+ tool,
+ view,
+ onView,
+}: {
+ tool: AgentTool
+ view: AgentSessionView
+ onView(next: AgentSessionView): void
+}) {
+ return (
+
+
+
+ {TOOL_LABEL[tool]}
+
+
+ {(['chat', 'terminal'] as const).map((item) => (
+ onView(item)}
+ className={cn(
+ 'relative rounded-sm px-3 py-1 text-base/5 capitalize outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring sm:px-2.5 sm:py-0.5 sm:text-sm/5',
+ view === item
+ ? 'bg-white text-zinc-950 shadow-low ring-1 ring-zinc-950/5 dark:bg-zinc-800 dark:text-white dark:shadow-none dark:ring-white/8'
+ : 'text-zinc-500 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-white',
+ )}
+ >
+ {item}
+
+
+ ))}
+
+
+ )
+}
+
+interface PendingMessage {
+ id: number
+ text: string
+ queued: boolean
+}
+
+export function AgentChat({
+ sessionId,
+ tool,
+ transcriptId,
+ onTerminal,
+}: {
+ sessionId: string
+ tool: AgentTool
+ transcriptId: string
+ onTerminal(): void
+}) {
+ const client = useFlueClient()
+ const [messages, setMessages] = useState([])
+ const [start, setStart] = useState(0)
+ const [phase, setPhase] = useState<'loading' | 'ready' | 'failed' | 'exited'>('loading')
+ const [loadingOlder, setLoadingOlder] = useState(false)
+ const [expanded, setExpanded] = useState>(() => new Set())
+ const [draft, setDraft] = useState('')
+ const [pending, setPending] = useState([])
+ const [working, setWorking] = useState(false)
+ const [newBelow, setNewBelow] = useState(false)
+ const [prompt, setPrompt] = useState(null)
+ const [needsTerminal, setNeedsTerminal] = useState(false)
+ const [ref, setRef] = useState(null)
+ const refRef = useRef(null)
+ const replayHead = useRef(0)
+ const generation = useRef(0)
+ const activityTimer = useRef | null>(null)
+ const terminalTail = useRef('')
+ const decoder = useRef(new TextDecoder())
+ const nextPending = useRef(1)
+ const scrollRef = useRef(null)
+ const stickBottom = useRef(true)
+
+ const virtualizer = useVirtualizer({
+ count: messages.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 72,
+ overscan: 8,
+ })
+
+ const scrollToEnd = useCallback(() => {
+ if (messages.length === 0) return
+ virtualizer.scrollToIndex(messages.length - 1, { align: 'end' })
+ }, [messages.length, virtualizer])
+
+ const receiveMessages = useCallback((incoming: AgentMessage[]) => {
+ if (incoming.length === 0) return
+ const keyed = keyMessages(incoming)
+ setPending((current) => {
+ const next = [...current]
+ for (const message of incoming) {
+ if (message.role !== 'user') continue
+ const at = next.findIndex((item) => item.text.trim() === message.text.trim())
+ if (at >= 0) next.splice(at, 1)
+ }
+ return next
+ })
+ setMessages((current) => {
+ const seen = new Set(current.map((message) => message.key))
+ const fresh = keyed.filter((message) => !seen.has(message.key))
+ return fresh.length === 0 ? current : [...current, ...fresh]
+ })
+ }, [])
+
+ useEffect(() => {
+ const mine = ++generation.current
+ let attachedRef: number | null = null
+ const offs = [
+ client.onAttached((attached) => {
+ if (attached.id !== sessionId) return
+ attachedRef = attached.ref
+ refRef.current = attached.ref
+ replayHead.current = attached.head
+ setRef(attached.ref)
+ }),
+ client.onOutput((outputRef, bytes) => {
+ if (outputRef !== refRef.current) return
+ const nextSeq = client.lastSeqFor(outputRef) ?? 0
+ if (nextSeq <= replayHead.current) return
+ terminalTail.current = (terminalTail.current + decoder.current.decode(bytes, { stream: true })).slice(
+ -48_000,
+ )
+ const parsed = parseTerminalPrompt(terminalTail.current)
+ setPrompt(parsed)
+ setNeedsTerminal(parsed === null && terminalNeedsAttention(terminalTail.current))
+ setWorking(true)
+ if (activityTimer.current !== null) clearTimeout(activityTimer.current)
+ activityTimer.current = setTimeout(() => setWorking(false), ACTIVITY_QUIET_MS)
+ }),
+ client.onAgentPage((page) => {
+ if (page.tool !== tool || page.id !== transcriptId) return
+ const el = scrollRef.current
+ const atBottom =
+ el === null || el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX
+ stickBottom.current = atBottom
+ if (!atBottom) setNewBelow(true)
+ receiveMessages(page.messages)
+ }),
+ client.onExit((exitRef) => {
+ if (exitRef === refRef.current) setPhase('exited')
+ }),
+ ]
+
+ client.attach(sessionId, 0)
+ client.agentRead(tool, transcriptId, Number.MAX_SAFE_INTEGER, { dir: 'backward', limit: PAGE_LIMIT }).then(
+ (page) => {
+ if (generation.current !== mine) return
+ setMessages(keyMessages(page.messages))
+ setStart(page.start)
+ setPhase('ready')
+ stickBottom.current = true
+ client.followAgent(tool, transcriptId, page.next)
+ },
+ () => {
+ if (generation.current === mine) setPhase('failed')
+ },
+ )
+
+ return () => {
+ generation.current++
+ for (const off of offs) off()
+ client.unfollowAgent(tool, transcriptId)
+ if (activityTimer.current !== null) clearTimeout(activityTimer.current)
+ if (attachedRef !== null) client.detach(attachedRef)
+ else client.forget(sessionId)
+ refRef.current = null
+ }
+ }, [client, receiveMessages, sessionId, tool, transcriptId])
+
+ useLayoutEffect(() => {
+ if (!stickBottom.current || messages.length === 0) return
+ const frame = requestAnimationFrame(scrollToEnd)
+ return () => cancelAnimationFrame(frame)
+ }, [messages.length, scrollToEnd])
+
+ const loadOlder = () => {
+ if (start <= 0 || loadingOlder) return
+ setLoadingOlder(true)
+ const beforeHeight = scrollRef.current?.scrollHeight ?? 0
+ const beforeTop = scrollRef.current?.scrollTop ?? 0
+ client.agentRead(tool, transcriptId, start, { dir: 'backward', limit: PAGE_LIMIT }).then(
+ (page) => {
+ setLoadingOlder(false)
+ setStart(page.start)
+ setMessages((current) => [...keyMessages(page.messages), ...current])
+ requestAnimationFrame(() => {
+ const el = scrollRef.current
+ if (el !== null) el.scrollTop = beforeTop + el.scrollHeight - beforeHeight
+ })
+ },
+ () => setLoadingOlder(false),
+ )
+ }
+
+ const sendRaw = useCallback(
+ (text: string) => {
+ const activeRef = refRef.current
+ if (activeRef === null) return false
+ client.sendInput(activeRef, new TextEncoder().encode(`${text}\r`))
+ terminalTail.current = ''
+ setPrompt(null)
+ setNeedsTerminal(false)
+ return true
+ },
+ [client],
+ )
+
+ const submit = (event?: FormEvent) => {
+ event?.preventDefault()
+ const text = draft.trim()
+ if (text === '' || phase === 'exited' || !sendRaw(text)) return
+ setPending((current) => [
+ ...current,
+ { id: nextPending.current++, text, queued: working },
+ ])
+ setDraft('')
+ stickBottom.current = true
+ }
+
+ const interrupt = () => {
+ const activeRef = refRef.current
+ if (activeRef === null) return
+ client.sendInput(activeRef, new Uint8Array([0x1b]))
+ }
+
+ const onComposerKey = (event: KeyboardEvent) => {
+ if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
+ event.preventDefault()
+ submit()
+ }
+
+ const status = useMemo(() => {
+ if (phase === 'exited') return 'Session ended'
+ if (ref === null) return 'Connecting'
+ if (working) return `${TOOL_LABEL[tool]} is working`
+ return 'Ready for your message'
+ }, [phase, ref, tool, working])
+
+ return (
+
+
{
+ const el = event.currentTarget
+ stickBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX
+ if (stickBottom.current) setNewBelow(false)
+ }}
+ className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain"
+ >
+
+ {phase === 'loading' ? (
+
+
+
+ Opening the conversation.
+
+
+ ) : phase === 'failed' ? (
+
+
+ The transcript could not be read right now.
+
+
Open terminal
+
+ ) : (
+ <>
+ {start > 0 && (
+
+
+ {loadingOlder ? 'Loading…' : 'Load earlier'}
+
+
+ )}
+ {messages.length === 0 && (
+
+
+
+ Waiting for the first completed message.
+
+
+ )}
+
+ {virtualizer.getVirtualItems().map((item) => {
+ const message = messages[item.index]!
+ return (
+
+
0 ? messages[item.index - 1] : undefined}
+ open={expanded.has(message.key)}
+ onToggle={() =>
+ setExpanded((current) => {
+ const next = new Set(current)
+ if (!next.delete(message.key)) next.add(message.key)
+ return next
+ })
+ }
+ now={Date.now()}
+ />
+
+ )
+ })}
+
+ {pending.map((message) => (
+
+
+
You
+
+ {message.text}
+
+
{message.queued ? 'Queued' : 'Sending'}
+
+
+ ))}
+ {working && (
+
+
+
+
+
+
+ {TOOL_LABEL[tool]} is working
+
+ )}
+ >
+ )}
+
+
+
+ {newBelow && (
+
{
+ setNewBelow(false)
+ stickBottom.current = true
+ scrollToEnd()
+ }}
+ >
+
+ New messages
+
+ )}
+
+
+
+ {prompt !== null && (
+
+
{prompt.question}
+
+ {prompt.options.map((option) => (
+ sendRaw(option.input)}
+ >
+ {option.label}
+
+ ))}
+
+
+ )}
+ {needsTerminal && prompt === null && (
+
+ This prompt needs the terminal
+
+ )}
+
+
+
+ Interrupt
+
+ {['/compact', '/clear'].map((command) => (
+ sendRaw(command)}>
+ {command}
+
+ ))}
+
+
+
{status}
+
+
+
+ )
+}
diff --git a/web/src/components/agent-message.tsx b/web/src/components/agent-message.tsx
new file mode 100644
index 0000000..e6f7b9c
--- /dev/null
+++ b/web/src/components/agent-message.tsx
@@ -0,0 +1,196 @@
+import type { ReactNode } from 'react'
+import { ChevronRightIcon } from '@heroicons/react/16/solid'
+
+import { dayLabel, dayStartMs } from '@/agents/view'
+import type { AgentMessage } from '@/client/protocol'
+import { cn } from '@/lib/utils'
+
+/** One normalized Claude, Codex, or Pi message, shared by history and chat. */
+export function AgentMessageBlock({
+ message,
+ previous,
+ open,
+ onToggle,
+ flashed = false,
+ now,
+}: {
+ message: AgentMessage
+ previous?: AgentMessage
+ open: boolean
+ onToggle(): void
+ flashed?: boolean
+ now: number
+}) {
+ const crossing = crossesDay(previous, message)
+ const sidechainStart = message.sidechain === true && previous?.sidechain !== true
+ return (
+
+ {crossing !== null && (
+
+
+
+ {dayLabel(crossing, now)}
+
+
+
+ )}
+
+ {sidechainStart && (
+
+ subagent
+
+ )}
+
+
+
+ )
+}
+
+function crossesDay(previous: AgentMessage | undefined, message: AgentMessage): number | null {
+ if (previous?.ts === undefined || message.ts === undefined) return null
+ const before = Date.parse(previous.ts)
+ const after = Date.parse(message.ts)
+ if (Number.isNaN(before) || Number.isNaN(after)) return null
+ const day = dayStartMs(after)
+ return dayStartMs(before) === day ? null : day
+}
+
+function MessageBody({
+ message,
+ open,
+ onToggle,
+}: {
+ message: AgentMessage
+ open: boolean
+ onToggle(): void
+}) {
+ if (message.role === 'system') {
+ return (
+
+ system
+ {message.text}
+
+ )
+ }
+ switch (message.kind) {
+ case 'thinking':
+ return (
+ Thinking}
+ >
+
+ {message.text}
+
+
+ )
+ case 'tool_call':
+ return
+ case 'tool_result':
+ return (
+
+
+
+ )
+ default:
+ if (message.role === 'user') {
+ return (
+
+
You
+
+ {message.text}
+
+
+ )
+ }
+ return (
+
+ {message.text}
+
+ )
+ }
+}
+
+function Foldaway({
+ open,
+ onToggle,
+ summary,
+ children,
+}: {
+ open: boolean
+ onToggle(): void
+ summary: ReactNode
+ children: ReactNode
+}) {
+ return (
+
+
+
+ {summary}
+
+ {open &&
{children}
}
+
+ )
+}
+
+function ToolBlock({
+ message,
+ open,
+ onToggle,
+ fallbackName,
+}: {
+ message: AgentMessage
+ open: boolean
+ onToggle(): void
+ fallbackName?: string
+}) {
+ const name = message.toolName ?? fallbackName ?? 'tool'
+ return (
+
+
+ {name}
+
+ {!open && (
+
+ {firstLine(message.text)}
+
+ )}
+ >
+ }
+ >
+
+ {message.text}
+
+ {message.truncated === true && (
+ · clipped by the daemon
+ )}
+
+ )
+}
+
+function firstLine(text: string): string {
+ const cut = text.indexOf('\n')
+ return cut < 0 ? text : text.slice(0, cut)
+}
diff --git a/web/src/routes/agent-viewer.tsx b/web/src/routes/agent-viewer.tsx
index 5f6a464..1685e5f 100644
--- a/web/src/routes/agent-viewer.tsx
+++ b/web/src/routes/agent-viewer.tsx
@@ -6,7 +6,6 @@ import {
useRef,
useState,
type CSSProperties,
- type ReactNode,
} from 'react'
import { Link, useNavigate, useParams, useSearch } from '@tanstack/react-router'
import { useVirtualizer } from '@tanstack/react-virtual'
@@ -14,15 +13,12 @@ import {
AdjustmentsHorizontalIcon,
ArrowDownIcon,
ChevronLeftIcon,
- ChevronRightIcon,
} from '@heroicons/react/16/solid'
import {
AGENT_TOOLS,
compactCost,
compactTokens,
- dayLabel,
- dayStartMs,
displayTitle,
keyMessages,
retryMissingTranscript,
@@ -31,13 +27,14 @@ import {
type KeyedAgentMessage,
} from '@/agents/view'
import { ToolDot } from '@/components/agent-rows'
+import { AgentMessageBlock } from '@/components/agent-message'
import { midCut, since } from '@/components/session-table'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Skeleton } from '@/components/ui/skeleton'
import type { FlueClient } from '@/client/client'
-import type { AgentMessage, AgentSummary, AgentTool } from '@/client/protocol'
+import type { AgentSummary, AgentTool } from '@/client/protocol'
import { useFleet } from '@/fleet/provider'
import { cn } from '@/lib/utils'
@@ -650,9 +647,9 @@ export function AgentViewerRoute() {
className="absolute top-0 left-0 w-full translate-y-(--row-y)"
style={{ '--row-y': `${item.start}px` } as CSSProperties}
>
-
setExpanded((prevSet) => {
@@ -785,200 +782,3 @@ function RetryRow({ onRetry }: { onRetry(): void }) {
)
}
-
-/**
- * One message, rendered by kind. The day separator and the subagent chip both
- * need the previous message, which the virtual list hands over by index —
- * they belong to the crossing, not to either side of it.
- */
-function MessageBlock({
- m,
- prev,
- open,
- onToggle,
- flashed,
- now,
-}: {
- m: AgentMessage
- prev?: AgentMessage
- open: boolean
- onToggle(): void
- flashed: boolean
- now: number
-}) {
- const crossing = crossesDay(prev, m)
- const sidechainStart = m.sidechain === true && prev?.sidechain !== true
- return (
-
- {crossing !== null && (
-
-
-
- {dayLabel(crossing, now)}
-
-
-
- )}
-
- {sidechainStart && (
-
- subagent
-
- )}
-
-
-
- )
-}
-
-/** The day the pair crosses into, or null when they share one. */
-function crossesDay(prev: AgentMessage | undefined, m: AgentMessage): number | null {
- if (prev?.ts === undefined || m.ts === undefined) return null
- const before = Date.parse(prev.ts)
- const after = Date.parse(m.ts)
- if (Number.isNaN(before) || Number.isNaN(after)) return null
- const day = dayStartMs(after)
- return dayStartMs(before) === day ? null : day
-}
-
-function MessageBody({
- m,
- open,
- onToggle,
-}: {
- m: AgentMessage
- open: boolean
- onToggle(): void
-}) {
- if (m.role === 'system') {
- return (
-
- system
- {m.text}
-
- )
- }
- switch (m.kind) {
- case 'thinking':
- return (
- Thinking}
- >
-
- {m.text}
-
-
- )
- case 'tool_call':
- return
- case 'tool_result':
- return (
-
-
-
- )
- default:
- if (m.role === 'user') {
- return (
-
- )
- }
- return (
-
- {m.text}
-
- )
- }
-}
-
-/** A disclosure line and, opened, whatever it was keeping short. */
-function Foldaway({
- open,
- onToggle,
- summary,
- children,
-}: {
- open: boolean
- onToggle(): void
- summary: ReactNode
- children: ReactNode
-}) {
- return (
-
-
-
- {summary}
-
- {open &&
{children}
}
-
- )
-}
-
-/** A tool call or result: name, first line shut, the whole payload open. */
-function ToolBlock({
- m,
- open,
- onToggle,
- fallbackName,
-}: {
- m: AgentMessage
- open: boolean
- onToggle(): void
- fallbackName?: string
-}) {
- const name = m.toolName ?? fallbackName ?? 'tool'
- return (
-
-
- {name}
-
- {!open && (
-
- {firstLine(m.text)}
-
- )}
- >
- }
- >
-
- {m.text}
-
- {m.truncated === true && (
- · clipped by the daemon
- )}
-
- )
-}
-
-function firstLine(text: string): string {
- const cut = text.indexOf('\n')
- return cut < 0 ? text : text.slice(0, cut)
-}
diff --git a/web/src/routes/terminal.test.tsx b/web/src/routes/terminal.test.tsx
index 00e1f46..aff042b 100644
--- a/web/src/routes/terminal.test.tsx
+++ b/web/src/routes/terminal.test.tsx
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import { machineChipFor } from './terminal'
+import { loadAgentView, machineChipFor } from './terminal'
/**
* The fleet as the terminal route reads it: the machines that are answering
@@ -54,3 +54,19 @@ describe('machineChipFor', () => {
expect(machineChipFor([MESA, ATTIC], 'loft-9f9f', true)).toBeUndefined()
})
})
+
+describe('agent session view preference', () => {
+ it('starts with chat on a phone and terminal on a desktop', () => {
+ localStorage.clear()
+ expect(loadAgentView(true)).toBe('chat')
+ expect(loadAgentView(false)).toBe('terminal')
+ })
+
+ it('lets the saved choice outrank the viewport default', () => {
+ localStorage.setItem('flue.agent-session-view', 'terminal')
+ expect(loadAgentView(true)).toBe('terminal')
+ localStorage.setItem('flue.agent-session-view', 'chat')
+ expect(loadAgentView(false)).toBe('chat')
+ localStorage.clear()
+ })
+})
diff --git a/web/src/routes/terminal.tsx b/web/src/routes/terminal.tsx
index 468de18..1b0cf44 100644
--- a/web/src/routes/terminal.tsx
+++ b/web/src/routes/terminal.tsx
@@ -1,4 +1,12 @@
-import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ useSyncExternalStore,
+ type ReactNode,
+} from 'react'
import { useNavigate, useParams } from '@tanstack/react-router'
import type { FlueClient } from '@/client/client'
@@ -6,6 +14,7 @@ import { FlueClientContext } from '@/client/provider'
import { NewSessionDialog } from '@/components/new-session-dialog'
import { SessionGroup } from '@/components/session-group'
import { Terminal } from '@/components/terminal'
+import { AgentChat, AgentViewBar, type AgentSessionView } from '@/components/agent-chat'
import { useFleet, useLoopbackTab } from '@/fleet/provider'
import { LOCAL_MACHINE_ID, type FleetSession, type MachineState } from '@/fleet/types'
import {
@@ -371,17 +380,17 @@ export function TerminalRoute() {
active={active}
onActivate={setActive}
onNewTab={canMultiplex ? () => split(shownTab, null, 'tabs') : undefined}
- renderPane={(id, viewportInset, fit) => (
+ renderPane={(id, viewportInset, fit) => {
// Keyed by machine, session, inset and pinning, so navigating
// between two sessions — or the tab strip appearing above one, or
// a pane moving between a split and a lone rendering — builds a
// new terminal rather than feeding one emulator two sessions'
// scrollback. The key also resets the state React holds: the
// phase pill, the keyboard mode.
- setCreating({ machineId: deviceId, cwd: cwd ?? '' })}
onSplit={canMultiplex ? (cwd, verb) => split(id, cwd, verb) : undefined}
/>
- )}
+ const row = rows.find((session) => session.id === id)
+ if (row?.agent === undefined || row.agentSession === undefined) return terminal()
+ const barHeight = isMobile ? 44 : 36
+ return (
+
+ )
+ }}
/>
+ transcriptId: string
+ terminal: ReactNode
+ isMobile: boolean
+}) {
+ const [view, setViewState] = useState(() => loadAgentView(isMobile))
+ const setView = (next: AgentSessionView) => {
+ setViewState(next)
+ saveAgentView(next)
+ }
+ return (
+
+
+
+ {view === 'chat' ? (
+
setView('terminal')}
+ />
+ ) : terminal}
+
+
+ )
+}
+
/**
* What the terminal's corner chip should say about which machine this is, or
* undefined for a screen where that question has one answer.
@@ -503,7 +560,26 @@ function sameRows(a: FleetSession[], b: FleetSession[]): boolean {
}
function rowSig(s: FleetSession): string {
- return `${s.id}|${s.group ?? ''}|${s.state}|${s.cwd}|${displayName(s)}`
+ return `${s.id}|${s.group ?? ''}|${s.state}|${s.cwd}|${s.agent ?? ''}|${s.agentSession ?? ''}|${displayName(s)}`
+}
+
+const AGENT_VIEW_KEY = 'flue.agent-session-view'
+
+export function loadAgentView(isMobile: boolean, storage: Storage = localStorage): AgentSessionView {
+ try {
+ const saved = storage.getItem(AGENT_VIEW_KEY)
+ return saved === 'chat' || saved === 'terminal' ? saved : isMobile ? 'chat' : 'terminal'
+ } catch {
+ return isMobile ? 'chat' : 'terminal'
+ }
+}
+
+function saveAgentView(view: AgentSessionView, storage: Storage = localStorage) {
+ try {
+ storage.setItem(AGENT_VIEW_KEY, view)
+ } catch {
+ // A blocked storage area costs persistence, not the view switch itself.
+ }
}
/** Whether `client` has announced a capability, kept current across welcomes. */