Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/egg/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,13 @@ func (s *Server) RunSession(ctx context.Context, rc RunConfig) (runErr error) {

log.Printf("egg: session %s kind=%s agent=%s command=%q pid=%d network=%s fs=%d", sessionID, rc.Kind, rc.Agent, rc.Command, cmd.Process.Pid, networkSummary, len(rc.FS))

// The agent's own terminal title labels the session in browser lists.
sess.vterm.OnTitle(func(title string) {
if err := WriteSessionTitle(s.dir, title); err != nil {
log.Printf("egg: save session title: %v", err)
}
})

// VTerm async processing goroutine — must start before readPTY
go runVTermLoop(sess.vterm, sess.vtermCh, sess.done)

Expand Down
88 changes: 88 additions & 0 deletions internal/egg/session_title.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package egg

import (
"errors"
"os"
"path/filepath"
"strings"
"unicode"
)

// SessionTitleFile holds the latest title the agent set for its terminal.
const SessionTitleFile = "session.title"

const maxSessionTitleRunes = 60

// Program names agents set before they have a topic say nothing about the session.
var genericSessionTitles = map[string]bool{"claude code": true, "claude": true, "codex": true}

// CleanSessionTitle turns an OSC terminal title into a display label: it drops
// the leading spinner or status glyph (Claude uses ✳, ◐ and ◑), keeps letters,
// digits and plain punctuation only, and returns "" for generic program names.
func CleanSessionTitle(raw string) string {
var b strings.Builder
started, space := false, false
for _, r := range raw {
keep := unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsMark(r) || strings.ContainsRune(".,:;-_/#()+?!@%=", r)
if !started && !unicode.IsLetter(r) && !unicode.IsDigit(r) {
continue
}
started = true
if !keep {
space = space || unicode.IsSpace(r)
continue
}
if space && b.Len() > 0 {
b.WriteByte(' ')
}
space = false
b.WriteRune(r)
}
title := []rune(b.String())
if len(title) > maxSessionTitleRunes {
title = title[:maxSessionTitleRunes]
}
cleaned := strings.TrimSpace(string(title))
if genericSessionTitles[strings.ToLower(cleaned)] {
return ""
}
return cleaned
}

// WriteSessionTitle replaces the egg's title file atomically; "" removes it.
func WriteSessionTitle(dir, title string) error {
path := filepath.Join(dir, SessionTitleFile)
if title == "" {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
tmp, err := os.CreateTemp(dir, ".session-title-*")
if err != nil {
return err
}
if _, err := tmp.WriteString(title + "\n"); err != nil {
tmp.Close()
os.Remove(tmp.Name())
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmp.Name())
return err
}
if err := os.Rename(tmp.Name(), path); err != nil {
os.Remove(tmp.Name())
return err
}
return nil
}

// ReadSessionTitle returns the saved title, cleaned again on the way out.
func ReadSessionTitle(dir string) string {
data, err := os.ReadFile(filepath.Join(dir, SessionTitleFile))
if err != nil {
return ""
}
return CleanSessionTitle(string(data))
}
87 changes: 87 additions & 0 deletions internal/egg/session_title_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package egg

import (
"os"
"path/filepath"
"testing"
"time"
)

func TestCleanSessionTitle(t *testing.T) {
cases := map[string]string{
"◐ Math question": "Math question",
"✳ Arithmetic calculation": "Arithmetic calculation",
"✳ Claude Code": "",
"codex": "",
" SLIDE-1234 refund check": "SLIDE-1234 refund check",
"<img src=x onerror=alert(1)>": "img src=x onerror=alert(1)",
"case \"42\" & 'quotes'": "case 42 quotes",
"\x1b[31mred\x07": "31mred",
"": "",
}
for raw, want := range cases {
if got := CleanSessionTitle(raw); got != want {
t.Errorf("CleanSessionTitle(%q) = %q, want %q", raw, got, want)
}
}
long := CleanSessionTitle("Investigate a very long support case title that keeps going well past the sidebar width")
if n := len([]rune(long)); n > maxSessionTitleRunes {
t.Errorf("title has %d runes, want at most %d", n, maxSessionTitleRunes)
}
}

func TestVTermReportsTitleChangesOnce(t *testing.T) {
h := newAsyncVTermHarness(80, 24, 256)
defer h.close()
titles := make(chan string, 8)
h.vterm.OnTitle(func(title string) { titles <- title })

// A title split across PTY reads, the next spinner frame, then Claude's idle
// frame, whose ✳ the parser truncates at its 0x9C byte, then a new topic.
h.writeAndEnqueue([]byte("\x1b]0;◐ Math qu"))
h.writeAndEnqueue([]byte("estion\x07"))
h.writeAndEnqueue([]byte("\x1b]0;◑ Math question\x07"))
h.writeAndEnqueue([]byte("\x1b]0;✳ Math question\x07"))
h.writeAndEnqueue([]byte("\x1b]2;◐ Refund lookup\x07"))
h.fence()

var got []string
for {
select {
case title := <-titles:
got = append(got, title)
continue
case <-time.After(50 * time.Millisecond):
}
break
}
if len(got) != 2 || got[0] != "Math question" || got[1] != "Refund lookup" {
t.Fatalf("title changes = %q, want [Math question Refund lookup]", got)
}
}

func TestSessionTitleFileRoundTrip(t *testing.T) {
dir := t.TempDir()
if got := ReadSessionTitle(dir); got != "" {
t.Fatalf("missing title = %q, want empty", got)
}
if err := WriteSessionTitle(dir, "Math question"); err != nil {
t.Fatal(err)
}
if got := ReadSessionTitle(dir); got != "Math question" {
t.Fatalf("title = %q, want Math question", got)
}
// A tampered file is cleaned on read like a live title.
if err := os.WriteFile(filepath.Join(dir, SessionTitleFile), []byte("✳ <b>bold</b>\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := ReadSessionTitle(dir); got != "bbold/b" {
t.Fatalf("tampered title = %q, want bbold/b", got)
}
if err := WriteSessionTitle(dir, ""); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, SessionTitleFile)); !os.IsNotExist(err) {
t.Fatalf("empty title left the file behind: %v", err)
}
}
52 changes: 52 additions & 0 deletions internal/egg/vterm.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package egg

import (
"fmt"
"io"
"log"
"strings"
"sync"
"unicode/utf8"

uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/vt"
Expand All @@ -24,6 +26,12 @@ type VTerm struct {
altScreen bool
cursorHidden bool
cols, rows int

title string
titleChanged bool
onTitle func(string)

drained chan struct{} // closed when the reply drain exits
}

// NewVTerm creates a VTerm with the given dimensions.
Expand Down Expand Up @@ -69,7 +77,26 @@ func NewVTerm(cols, rows int) *VTerm {
// mu already held by caller (Write)
v.cursorHidden = !visible
},
Title: func(raw string) {
// mu already held by caller (Write)
// The parser ends an OSC at byte 0x9C (C1 ST), which cuts Claude's idle
// glyph ✳ (e2 9c b3) to one byte; keep the last whole title instead.
if !utf8.ValidString(raw) {
return
}
if title := CleanSessionTitle(raw); title != v.title {
v.title, v.titleChanged = title, true
}
},
})
// The emulator answers terminal queries (DA, XTVERSION, mode reports) into
// a pipe; unread, the first query blocks Write forever. The browser's real
// terminal already answers them, so the shadow copy's replies are dropped.
v.drained = make(chan struct{})
go func() {
defer close(v.drained)
_, _ = io.Copy(io.Discard, v.emu)
}()
return v
}

Expand All @@ -80,6 +107,22 @@ func (v *VTerm) Write(p []byte) (int, error) {
return v.emu.Write(p)
}

// OnTitle registers fn to receive each change of the title the agent sets
// for its terminal. fn runs on the VTerm goroutine, outside the lock.
func (v *VTerm) OnTitle(fn func(string)) {
v.mu.Lock()
defer v.mu.Unlock()
v.onTitle = fn
}

func (v *VTerm) takeTitleChange() (string, func(string), bool) {
v.mu.Lock()
defer v.mu.Unlock()
changed := v.titleChanged
v.titleChanged = false
return v.title, v.onTitle, changed
}

// Resize changes the terminal dimensions.
func (v *VTerm) Resize(cols, rows int) {
v.mu.Lock()
Expand Down Expand Up @@ -139,6 +182,12 @@ func (v *VTerm) ScrollbackLen() int {

// Close releases the emulator resources.
func (v *VTerm) Close() error {
// End the reply drain before the emulator marks itself closed; its Read
// checks that flag without a lock.
if pw, ok := v.emu.InputPipe().(*io.PipeWriter); ok {
_ = pw.Close()
}
<-v.drained
v.mu.Lock()
defer v.mu.Unlock()
return v.emu.Close()
Expand Down Expand Up @@ -194,6 +243,9 @@ func runVTermLoop(vt *VTerm, ch <-chan vtermMsg, done <-chan struct{}) {
return
}
lastOffset = msg.offset
if title, fn, changed := vt.takeTitleChange(); changed && fn != nil {
fn(title)
}
}
if msg.resize != nil {
vt.Resize(msg.resize.cols, msg.resize.rows)
Expand Down
20 changes: 20 additions & 0 deletions internal/egg/vterm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -849,3 +849,23 @@ func TestAsyncVTermConcurrentFenceAndWrites(t *testing.T) {
}
}
}

func TestVTermSurvivesTerminalQueries(t *testing.T) {
v := NewVTerm(80, 24)
defer v.Close()
done := make(chan struct{})
go func() {
// Claude Code 2.1.292's startup queries: XTVERSION, kitty keyboard, DA1.
_, _ = v.Write([]byte("\x1b[>0q\x1b[?u\x1b[c\x1b[6n"))
_, _ = v.Write([]byte("after queries"))
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("VTerm.Write blocked on a terminal query reply")
}
if !strings.Contains(string(v.Snapshot()), "after queries") {
t.Fatal("output after the queries is missing from the snapshot")
}
}
17 changes: 17 additions & 0 deletions internal/wing/wing.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -1406,6 +1407,7 @@ func ListAliveEggSessions(cfg *config.Config) []ws.SessionInfo {
}

var out []ws.SessionInfo
started := make(map[string]int64)
for _, e := range entries {
if !e.IsDir() {
continue
Expand Down Expand Up @@ -1456,6 +1458,7 @@ func ListAliveEggSessions(cfg *config.Config) []ws.SessionInfo {
info := ws.SessionInfo{
SessionID: sessionID,
Name: eggclient.ReadSessionName(dir),
Title: egg.ReadSessionTitle(dir),
Agent: agent,
CWD: sessionCWD,
EggConfig: renderedConfig,
Expand All @@ -1475,11 +1478,25 @@ func ListAliveEggSessions(cfg *config.Config) []ws.SessionInfo {
if _, err := os.Stat(filepath.Join(dir, "chat.jsonl.gz")); err == nil {
info.Chat = true
}
started[sessionID], _ = strconv.ParseInt(eggclient.ReadEggMetaValues(dir)["started_at"], 10, 64)
out = append(out, info)
}
sortSessionsByStart(out, started)
return out
}

// sortSessionsByStart lists sessions oldest first, so a browser reload keeps a
// new session where it appeared instead of sorting it in by its random ID.
func sortSessionsByStart(sessions []ws.SessionInfo, started map[string]int64) {
sort.SliceStable(sessions, func(i, j int) bool {
a, b := started[sessions[i].SessionID], started[sessions[j].SessionID]
if a != b {
return a < b
}
return sessions[i].SessionID < sessions[j].SessionID
})
}

type pendingReattachAuth struct {
attach ws.PTYAttach
challenge []byte
Expand Down
13 changes: 13 additions & 0 deletions internal/wing/wing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,3 +512,16 @@ func TestForgetAttentionStateRemovesAllSessionEntries(t *testing.T) {
t.Fatal("attention nonce was retained")
}
}

func TestSortSessionsByStartKeepsLaunchOrder(t *testing.T) {
sessions := []ws.SessionInfo{{SessionID: "11bb"}, {SessionID: "8e2e"}, {SessionID: "21da"}, {SessionID: "aa00"}}
started := map[string]int64{"11bb": 300, "8e2e": 200, "21da": 100, "aa00": 200}
sortSessionsByStart(sessions, started)
var got []string
for _, s := range sessions {
got = append(got, s.SessionID)
}
if strings.Join(got, ",") != "21da,8e2e,aa00,11bb" {
t.Fatalf("order = %v, want oldest first with ID tie-break", got)
}
}
1 change: 1 addition & 0 deletions internal/ws/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ type SessionInfo struct {
Lifecycle map[string]any `json:"lifecycle,omitempty"`
SessionID string `json:"session_id"`
Name string `json:"name,omitempty"`
Title string `json:"title,omitempty"` // agent-set terminal title, display only
Agent string `json:"agent"`
CWD string `json:"cwd,omitempty"`
EggConfig string `json:"-"` // server-only effective YAML config snapshot
Expand Down
Loading
Loading