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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,17 @@ SESSION` is only an inactivity inference, fresh activity anywhere in the group
suppresses a stale sibling's check; definite input and approval are never
suppressed this way.

Named sessions show `<short session ID> // <name>` in the left telemetry panel's border
title, with the directory path inside the box beneath model information when
space permits. Names come from the local
Codex session index and refresh after
renames; unnamed sessions retain their ID/directory presentation. The web Sessions
view uses the same names in selectable headings, with the directory inside the
panel; full detail also identifies the named session. Attention pills use the
same short ID and session name (directory fallback when unnamed), shortening
their labels as space tightens. Click a highlighted session status to open its
full detail and any native approval/input request; this only navigates.

Session rows prioritise the root session's latest observed model, reasoning effort
and Fast setting directly below the token count, for example
`gpt-6-astra medium fast`. These are observed selections from persisted turn contexts
Expand Down
7 changes: 7 additions & 0 deletions internal/codex/live_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type LiveUsageSnapshot struct {
type LiveUsageSession struct {
ModelSettings SessionModelSettings
ID string
Name string
WorkingDirectory string
StartedAt time.Time
TotalTokens int64
Expand Down Expand Up @@ -108,6 +109,8 @@ type LiveTurnTiming struct {
// sessions. It also extracts bounded display-only replies and request context;
// reasoning and arbitrary tool output are never retained.
type LiveUsageReader struct {
sessionNames map[string]string
nameIndexInfo os.FileInfo
daemonContexts map[string]SessionContext
SessionsRoot string
WriterLocksRoot string
Expand Down Expand Up @@ -376,6 +379,10 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover

liveWriters, writerLocksSupported := r.liveWriterThreads()
sessions, activeSessions, sessionWorking := r.sessionSnapshots(now, liveWriters, writerLocksSupported, exactStatuses)
r.refreshSessionNames()
for i := range sessions {
sessions[i].Name = r.sessionNames[sessions[i].ID]
}
codexStatusKnown, codexUp, codexWorking := codexRuntimeHealth(
appServerUp, len(liveWriters) > 0, sessionWorking, writerLocksSupported,
)
Expand Down
47 changes: 47 additions & 0 deletions internal/codex/session_names.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package codex

import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
)

// Codex's append-only name index stores renames as later entries. Metadata is
// display-only and optional: an unavailable index must not fail token polling.
// Called under the LiveUsageReader lock.
func (r *LiveUsageReader) refreshSessionNames() {
if r.SessionsRoot == "" {
return
}
file, err := os.Open(filepath.Join(filepath.Dir(r.SessionsRoot), "session_index.jsonl"))
if err != nil {
return
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
return
}
if old := r.nameIndexInfo; old != nil && os.SameFile(old, info) && old.Size() == info.Size() && old.ModTime() == info.ModTime() {
return
}
names := map[string]string{}
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 4096), 1024*1024)
for scanner.Scan() {
var entry struct {
ID string `json:"id"`
Name string `json:"thread_name"`
}
if json.Unmarshal(scanner.Bytes(), &entry) != nil || entry.ID == "" {
continue
}
names[entry.ID] = strings.Join(strings.Fields(SanitizeSessionContext(entry.Name)), " ")
}
if scanner.Err() != nil {
return
}
r.sessionNames, r.nameIndexInfo = names, info
}
32 changes: 32 additions & 0 deletions internal/codex/session_names_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package codex

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

func TestSessionNamesFollowRenamesAndIndexReplacement(t *testing.T) {
home := t.TempDir()
r := &LiveUsageReader{SessionsRoot: filepath.Join(home, "sessions")}
path := filepath.Join(home, "session_index.jsonl")
write := func(data string) {
t.Helper()
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatal(err)
}
r.refreshSessionNames()
}
write("{\"id\":\"one\",\"thread_name\":\"First name\"}\n{\"id\":\"child\",\"thread_name\":\"Agent name\"}\n")
if r.sessionNames["one"] != "First name" {
t.Fatal(r.sessionNames)
}
write("{\"id\":\"one\",\"thread_name\":\"First name\"}\ninvalid\n{\"id\":\"one\",\"thread_name\":\"New name\\nline\"}\n")
if r.sessionNames["one"] != "New name line" {
t.Fatal(r.sessionNames)
}
write("{\"id\":\"one\",\"thread_name\":\"Final\"}\n")
if r.sessionNames["one"] != "Final" || r.sessionNames["child"] != "" {
t.Fatal(r.sessionNames)
}
}
15 changes: 9 additions & 6 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ type monitorSessionDismissal struct {
}

type monitorSession struct {
name string
averageRate int64
modelSettings codex.SessionModelSettings
preview codex.SessionContext
Expand Down Expand Up @@ -2446,6 +2447,7 @@ func (m *Model) resumeMonitorSessions(usage codex.LiveUsageSnapshot, observedAt
session.attention = update.Attention
session.preview = update.Context
session.modelSettings = update.ModelSettings
session.name = update.Name
session.callSequence = latestModelCallSequence(update.ModelCalls)
session.turnSequence = latestTurnTimingSequence(update.TurnTimings)
if update.WorkingDirectory != "" {
Expand All @@ -2464,8 +2466,8 @@ func (m *Model) resumeMonitorSessions(usage codex.LiveUsageSnapshot, observedAt
for _, update := range updates {
m.monitorSessionData = append(m.monitorSessionData, monitorSession{
id: update.ID, workingDirectory: update.WorkingDirectory,
modelSettings: update.ModelSettings,
baseline: update.TotalTokens, latest: update.TotalTokens, graphStart: update.TotalTokens,
name: update.Name, modelSettings: update.ModelSettings,
baseline: update.TotalTokens, latest: update.TotalTokens, graphStart: update.TotalTokens,
startedAt: observedAt, lastActivity: update.LastActivity, agentCount: update.AgentCount,
active: update.Active, working: update.Working, attention: update.Attention, preview: update.Context, displayed: update.Active,
unattributed: update.Unattributed, callSequence: latestModelCallSequence(update.ModelCalls),
Expand Down Expand Up @@ -2625,8 +2627,8 @@ func (m *Model) startMonitorSessions(usage codex.LiveUsageSnapshot, observedAt t
for _, session := range usage.Sessions {
m.monitorSessionData = append(m.monitorSessionData, monitorSession{
id: session.ID, workingDirectory: session.WorkingDirectory,
modelSettings: session.ModelSettings,
baseline: session.TotalTokens, latest: session.TotalTokens, graphStart: session.TotalTokens,
name: session.Name, modelSettings: session.ModelSettings,
baseline: session.TotalTokens, latest: session.TotalTokens, graphStart: session.TotalTokens,
startedAt: observedAt,
lastActivity: session.LastActivity, agentCount: session.AgentCount,
active: session.Active, working: session.Working, attention: session.Attention,
Expand Down Expand Up @@ -2660,8 +2662,8 @@ func (m *Model) syncMonitorSessions(usage codex.LiveUsageSnapshot, observedAt ti
}
created := monitorSession{
id: update.ID, workingDirectory: update.WorkingDirectory,
modelSettings: update.ModelSettings,
latest: update.TotalTokens, graphStart: 0, startedAt: startedAt,
name: update.Name, modelSettings: update.ModelSettings,
latest: update.TotalTokens, graphStart: 0, startedAt: startedAt,
lastActivity: update.LastActivity, agentCount: update.AgentCount,
active: update.Active, working: update.Working, attention: update.Attention,
preview: update.Context,
Expand All @@ -2685,6 +2687,7 @@ func (m *Model) syncMonitorSessions(usage codex.LiveUsageSnapshot, observedAt ti
session.attention = update.Attention
session.preview = update.Context
session.modelSettings = update.ModelSettings
session.name = update.Name
session.displayed = session.displayed || update.Active || update.TotalTokens > session.baseline ||
len(update.ModelCalls) > 0 || len(update.TurnTimings) > 0
if update.WorkingDirectory != "" {
Expand Down
9 changes: 9 additions & 0 deletions internal/ui/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,9 @@ func (m Model) renderMonitorSessionMetrics(width, height int, session monitorSes
if session.workingDirectory != "" {
title = shortSessionID(session.id) + " // " + strings.ToUpper(filepath.Base(terminalLabel(session.workingDirectory)))
}
if session.name != "" {
title = shortSessionID(session.id) + " // " + terminalLabel(session.name)
}
if session.unattributed {
title = "UNATTRIBUTED // INTERNAL"
}
Expand Down Expand Up @@ -333,6 +336,9 @@ func (m Model) renderMonitorSessionMetrics(width, height int, session monitorSes
if badge == "" {
appendLine(status)
}
if session.name != "" {
appendLine(terminalLabel(session.workingDirectory))
}
appendLine(memberLabel)
appendLine(formatMonitorCallActivity(session, time.Now()))
if session.latestTTFTOK || session.peakTTFTOK {
Expand Down Expand Up @@ -420,6 +426,9 @@ func (m Model) renderMonitorSessionBadge(session monitorSession, width int, colo
badgeColor = paletteFor(m.theme).primary
}
badge := lipgloss.NewStyle().Bold(true).Foreground(colors.background).Background(badgeColor)
if m.monitorContextHover == "badge:"+session.id {
badge = badge.Underline(true)
}
ball := "●"
if session.attention == codex.SessionAttentionNone && m.phase%2 == 1 {
ball = " " // Blink only WORKING, reserving its cell to avoid layout movement.
Expand Down
9 changes: 9 additions & 0 deletions internal/ui/monitor_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,10 @@ func (m Model) monitorContextAt(x, y int) string {
rowY := a.topHeight + a.gap - 1
mw, rightWidth, _ := monitorSessionColumnWidths(a.width)
for i, s := range sessions {
badge := m.renderMonitorSessionBadge(s, max(mw-4, 1), paletteFor(m.theme))
if badge != "" && y == rowY+1 && x >= 2 && x < 2+lipgloss.Width(badge) && x < mw-2 {
return "badge:" + s.id
}
boxX, boxWidth := mw+1, rightWidth
if m.rowContextMode(s.id) == contextSplit {
_, cw, gw := m.contextColumns(a.width, s)
Expand Down Expand Up @@ -451,6 +455,11 @@ func (m Model) updateMonitorContextMouse(msg tea.MouseMsg) (Model, tea.Cmd, bool
m.monitorAttentionPage, _ = strconv.Atoi(page)
} else if strings.HasPrefix(m.monitorContextHover, "attention:") || strings.HasPrefix(m.monitorContextHover, "attention-profile:") {
m.openMonitorAttention(m.monitorContextHover)
} else if id, ok := strings.CutPrefix(m.monitorContextHover, "badge:"); ok {
m.setRowContext(id, contextFull)
row := m.monitorContextRows[id]
row.review = "context"
m.monitorContextRows[id] = row
} else if id, ok := strings.CutPrefix(m.monitorContextHover, "detail:"); ok {
m.openMonitorContext(id)
} else if id, ok := strings.CutPrefix(m.monitorContextHover, "less:"); ok {
Expand Down
100 changes: 100 additions & 0 deletions internal/ui/monitor_name_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package ui

import (
"strings"
"testing"
"time"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/merefield/codexometer/internal/codex"
)

func TestMonitorSessionNameDisplayAndRename(t *testing.T) {
m := Model{}
u := codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{{ID: "root", Name: "Fix dashboard layout", WorkingDirectory: "/work/dashboard", Active: true}}}
m.startMonitorSessions(u, time.Now())
for _, height := range []int{4, 8, 12} {
out := m.renderMonitorSessionMetrics(64, height, m.monitorSessionData[0], "", paletteFor(themeHacker))
if !strings.Contains(ansi.Strip(out), "ROOT // Fix dashboard layout") || strings.Count(ansi.Strip(out), "Fix dashboard layout") != 1 || lipgloss.Height(out) > height || lipgloss.Width(out) > 64 {
t.Fatalf("name missing or oversized: %s", out)
}
if height >= 8 && !strings.Contains(ansi.Strip(out), "/work/dashboard") {
t.Fatal("directory missing from session body")
}
}
u.Sessions[0].Name = "Renamed session"
m.syncMonitorSessions(u, time.Now())
if m.monitorSessionData[0].name != "Renamed session" {
t.Fatal("rename not propagated")
}
}

func TestNamedSessionPillAndBadgeNavigation(t *testing.T) {
for _, attention := range []codex.SessionAttention{codex.SessionAttentionComplete, codex.SessionAttentionApproval, codex.SessionAttentionInput} {
m := Model{meterView: viewMonitor, width: 140, height: 40, snapshot: codex.DemoSnapshot(), monitorState: monitorRunning}
s := monitorSession{id: "session-ABCDE", name: "Fix dashboard", workingDirectory: "/work/project", displayed: true, active: true, attention: attention}
m.monitorSessionData = []monitorSession{s}
buttons, _ := m.monitorAttentionButtons(136, 1)
if len(buttons) == 0 || !strings.Contains(buttons[0].label, "ABCDE // Fix dashboard") {
t.Fatalf("pill identity differs: %+v", buttons)
}
found := false
for y := 0; y < m.height && !found; y++ {
for x := 0; x < m.width; x++ {
if m.monitorContextAt(x, y) != "badge:"+s.id {
continue
}
next, _, handled := m.updateMonitorContextMouse(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
if !handled || next.monitorContextDetail != s.id || next.monitorContextRows[s.id].review != "context" {
t.Fatal("badge did not open native session detail")
}
found = true
break
}
}
if !found {
t.Fatal("badge has no click surface")
}
}
}

func TestSessionPillLiteralDotNameAndDirectoryFallback(t *testing.T) {
for _, tc := range []struct{ name, directory, suffix string }{
{".", "/work/project", " // ."},
{".", "", " // ."},
{"", "", ""},
{"", ".", ""},
{"", "/work/project", " // project"},
} {
m := Model{monitorState: monitorRunning, monitorSessionData: []monitorSession{{
id: "session-ABCDE", name: tc.name, workingDirectory: tc.directory,
displayed: true, active: true, attention: codex.SessionAttentionComplete,
}}}
buttons, _ := m.monitorAttentionButtons(136, 1)
want := "[TURN COMPLETE ABCDE" + tc.suffix + "]"
if len(buttons) != 1 || buttons[0].label != want {
t.Fatalf("name=%q directory=%q: got %+v, want %q", tc.name, tc.directory, buttons, want)
}
}
}

func TestNamedSessionDirectoryDoesNotDisplaceStatus(t *testing.T) {
for _, active := range []bool{false, true} {
s := monitorSession{id: "root", name: "Named session", workingDirectory: "/work/dashboard", active: active}
want := "IDLE"
if active {
want = "ACTIVE"
}
m := Model{}
short := ansi.Strip(m.renderMonitorSessionMetrics(64, 4, s, "", paletteFor(themeHacker)))
if !strings.Contains(short, want) || !strings.Contains(short, "TOKENS") || strings.Contains(short, s.workingDirectory) {
t.Fatalf("short row must retain tokens and %s before directory:\n%s", want, short)
}
tall := ansi.Strip(m.renderMonitorSessionMetrics(64, 8, s, "", paletteFor(themeHacker)))
if !strings.Contains(tall, want) || !strings.Contains(tall, s.workingDirectory) {
t.Fatalf("tall row should show status and directory:\n%s", tall)
}
}
}
8 changes: 7 additions & 1 deletion internal/ui/monitor_summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,13 @@ func (m Model) layoutMonitorAttention(sessions []monitorAttentionItem, width, ro
}
caption := state + " " + id
name := filepath.Base(terminalLabel(s.workingDirectory))
if compact < 2 && name != "." && name != "" {
if name == "." {
name = ""
}
if s.name != "" {
name = terminalLabel(s.name)
}
if compact < 2 && name != "" {
separator := " // "
if compact == 1 {
separator = " "
Expand Down

Large diffs are not rendered by default.

Loading
Loading