Files
zero/internal/tui/session_title.go
KRATOS 6df5c2b4cb Improve /resume: interactive session picker + model-generated titles (#224)
* tui: interactive /resume session picker + resume fixes

- /resume (no arg) now opens an interactive picker like /model and /provider:
  one row per resumable session showing the title and session id (+ relative
  age); arrow to highlight, type to filter, enter to load. /resume <id> and
  /resume latest still resolve directly.
- Resume now loads the rehydrated (compaction-aware) event view instead of the
  raw log, so a resumed session honors a prior /compact — matching the CLI's
  `zero exec --resume` and the in-TUI /compact reload. Falls back to raw on a
  rehydrate error.
- The resume summary reports the model/provider the continuation will actually
  use (the active ones), noting the session's recorded model/provider when it
  differs, instead of implying a switch that doesn't happen.
- Drop the dead, self-contradictory resumeText(arg) branch (it was unreachable
  and its hint was circular); resumeText is now the no-session/error fallback.

Tests: session picker opens with title+id rows and hydrates on selection;
resume honors prior compaction (rehydrated, not raw events).

* tui: lead /resume picker rows with a precise timestamp

Same-titled sessions (e.g. the same first prompt run several times) looked like
duplicates because the row showed only the identical title plus a long id that
truncated the age away. Lead each row's label with a precise local timestamp
(HH:MM:SS today, "Jan _2 15:04" earlier this year, else the date) so distinct
sessions are obviously distinct; the full id stays as the right-aligned meta and
is what selection resolves. sessionWhen parses the RFC3339 UpdatedAt.

* tui: hide empty/no-output sessions from the /resume picker

Most of a heavily-retried prompt's sessions are empty failed runs — just the
prompt plus the no-output guardrail stop ("Agent stopped … with no output …"),
with no assistant text and no tool calls. They have nothing to resume and flood
the picker with identical-looking rows. Skip any session with no resumable
content (no tool call/result and no real non-user message) from the picker; they
stay on disk and are never deleted. Adds agent.IsNoProgressStop to recognize the
stop message from a recorded event, and a test that the picker hides an empty
run while keeping a real one.

* feat(tui): model-generated session titles (auto + /retitle backfill)

Sessions were titled from their first user message, so prompts that
started the same way (or empty/failed runs) looked identical in the
/resume picker. Generate a concise, specific title with the active
provider instead.

- sessions.Store.UpdateTitle(id, title): rewrites only Title under the
  per-session lock, re-reading the latest metadata first; leaves
  UpdatedAt untouched (a retitle is not activity and must not reorder
  the resumable list), rejects a blank title, and no-ops an unchanged
  one.
- Title generator: a bounded digest of the conversation (user/assistant
  text + tool names, per-message and total caps, skipping the no-output
  guardrail stop) is sent as a one-shot completion; the response is
  cleaned (first content line, quotes/markup/"Title:" label stripped,
  word- and rune-capped) before storing.
- Auto-title going forward: after a successful turn, a session still
  carrying its default first-message title gets a title in the
  background, at most once per session.
- /retitle: backfills existing resumable sessions that still have a
  first-message title, one at a time, skipping empty/failed runs and
  already-named sessions, with kickoff and completion status lines.

Generation runs off the Update goroutine; failures are non-fatal (the
first-message title simply stays). Adds store + TUI unit tests
(UpdateTitle semantics, digest/cleaning, auto-title one-shot, backfill
candidate selection). gofmt/vet/build(host+linux+windows)/test -race/
staticcheck all green.

* address review: tighten no-progress detection, fix fallback errors + nil guard

Three review findings on the /resume work:

- IsNoProgressStop matched any content CONTAINING the no-output marker
  substring (strings.Contains), so a legitimate assistant/tool message that
  quoted the marker could be misread as a failed empty run — wrongly hiding a
  real session from the /resume picker and skipping its title generation. Now
  it requires the full answer structure (prefix + marker + suffix); a quote no
  longer matches.
- resumeEvents returned the earlier rehydration error when the raw ReadEvents
  fallback itself failed, masking the actual fallback failure. Returns rawErr.
- resumeText dereferenced m.sessionStore unconditionally; a nil store (fallback/
  test paths) would panic. Now renders a safe "store unavailable" message.
  (newSessionPicker already guarded nil.)

Tests: IsNoProgressStop accepts the real answer and rejects a quoting message /
bare marker / prefix- or suffix-only; resumeText with a nil store returns the
safe message instead of panicking. Full gate green.

* address review (Vasanth): structural no-progress check, retryable auto-title, nil-store guard, deep resume assertion

- guardrails: IsNoProgressStop now matches the exact structure noOutputStopAnswer
  emits — prefix + "<int> turns " + marker + " " + suffix — instead of
  prefix && contains(marker) && suffix. A genuine message that merely quotes the
  marker amid other prose is no longer misclassified as a failed empty run (which
  would wrongly hide a real session from /resume and skip its title generation).
  Adds the hostile-input regression cases (quoted marker, text between
  marker/suffix, non-integer turn count).
- session_title: release the optimistic titledSessions gate when a generation
  FAILS (provider error, empty title, store write error) so a later turn or
  /retitle can retry; success keeps the one-shot gate. Kept the optimistic mark
  (rather than mark-on-success) to preserve the no-double-fire guarantee while a
  generation is in flight.
- session_title: guard maybeAutoTitleActiveSession against a nil sessionStore
  (the title cmd calls store.UpdateTitle) — mirrors the resume nil-store guards.
- model_test: assert resumed sessionEvents with reflect.DeepEqual against the
  rehydrated context, not just slice length, so a reordered/substituted-but-
  same-length regression is caught.
2026-06-17 12:41:20 +05:30

343 lines
12 KiB
Go

package tui
import (
"context"
"errors"
"fmt"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const (
// sessionTitleMaxMessageChars caps how much of any single message goes into
// the title prompt — a title only needs the gist, not the whole turn.
sessionTitleMaxMessageChars = 320
// sessionTitleMaxDigestChars bounds the whole digest so titling stays a cheap
// one-shot call regardless of how long the conversation was.
sessionTitleMaxDigestChars = 1600
// sessionTitleWordCap is the most words a cleaned title may keep.
sessionTitleWordCap = 8
// sessionTitleTimeout bounds a single title generation so a hung provider can
// never wedge the background command.
sessionTitleTimeout = 30 * time.Second
)
// titleTrimCutset is stripped from both ends of a model-produced title:
// whitespace, surrounding quotes/backticks, markdown emphasis/heading marks, and
// trailing sentence punctuation. ` is a backtick.
const titleTrimCutset = " \t\r\n\"'`*#.,:;!"
const sessionTitleSystemPrompt = "You write a short, specific title for a coding-assistant conversation so a user can tell it apart from others in a list. " +
"Reply with ONLY the title and nothing else: 3 to 6 words, Title Case, naming the concrete task or topic. " +
"No surrounding quotes, no trailing punctuation, no preamble, no explanation."
// errSessionTitleNoContent marks a session that has nothing worth titling, so the
// background command exits without a wasted provider call.
var errSessionTitleNoContent = errors.New("session has no content to title")
// sessionTitleGeneratedMsg carries the outcome of a background title generation
// back to the Update loop. backfill distinguishes a /retitle queue step (which
// advances the queue and updates a status row) from a silent auto-title.
type sessionTitleGeneratedMsg struct {
sessionID string
title string
backfill bool
err error
}
// sessionTitleDigest renders a compact, bounded transcript of a session for the
// title prompt: user/assistant text and tool names, each trimmed, the whole
// thing capped. The no-output guardrail stop is skipped so a failed run never
// becomes the "topic".
func sessionTitleDigest(events []sessions.Event) string {
var builder strings.Builder
total := 0
add := func(label, content string) bool {
content = strings.Join(strings.Fields(content), " ")
if content == "" {
return true
}
content = cutRunes(content, sessionTitleMaxMessageChars)
line := label + ": " + content + "\n"
if total > 0 && total+len(line) > sessionTitleMaxDigestChars {
return false // budget spent — keep what we have
}
builder.WriteString(line)
total += len(line)
return true
}
for _, event := range events {
payload := sessionPayload(event)
switch event.Type {
case sessions.EventMessage:
content := payloadString(payload, "content")
if agent.IsNoProgressStop(content) {
continue
}
switch strings.ToLower(strings.TrimSpace(payloadString(payload, "role"))) {
case "user":
if !add("User", content) {
return strings.TrimSpace(builder.String())
}
case "assistant":
if !add("Assistant", content) {
return strings.TrimSpace(builder.String())
}
}
case sessions.EventToolCall:
if name := strings.TrimSpace(payloadString(payload, "name")); name != "" {
if !add("Tool", name) {
return strings.TrimSpace(builder.String())
}
}
}
}
return strings.TrimSpace(builder.String())
}
// cleanGeneratedTitle normalizes a raw model response into a single short title
// line: first non-empty line only, surrounding quotes/markup and a leading
// "Title:" label removed, whitespace collapsed, word- and rune-capped. Returns ""
// when nothing usable remains so the caller keeps the existing title.
func cleanGeneratedTitle(raw string) string {
title := strings.TrimSpace(raw)
if title == "" {
return ""
}
// Use the first line with real content after trimming markup — a model may
// open with a bare code fence or blank line before the title itself.
for _, line := range strings.Split(title, "\n") {
if trimmed := strings.Trim(line, titleTrimCutset); trimmed != "" {
title = trimmed
break
}
}
title = strings.Trim(title, titleTrimCutset)
// Drop a leading "Title:" / "Title -" label the model sometimes prepends.
if idx := strings.IndexAny(title, ":-"); idx > 0 && idx <= 6 {
if strings.EqualFold(strings.TrimSpace(title[:idx]), "title") {
title = strings.TrimSpace(title[idx+1:])
}
}
title = strings.Trim(title, titleTrimCutset)
fields := strings.Fields(title)
if len(fields) == 0 {
return ""
}
if len(fields) > sessionTitleWordCap {
fields = fields[:sessionTitleWordCap]
}
return cutRunes(strings.Join(fields, " "), tuiSessionTitleLimit)
}
// generateSessionTitle asks the provider for a concise title for digest and
// returns the cleaned result. It is provider-shaped exactly like the one-shot
// summarization call: system instructions + a single user turn, no tools.
func generateSessionTitle(ctx context.Context, provider zeroruntime.Provider, digest string) (string, error) {
if provider == nil {
return "", errors.New("no provider configured")
}
if strings.TrimSpace(digest) == "" {
return "", errSessionTitleNoContent
}
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: sessionTitleSystemPrompt},
{Role: zeroruntime.MessageRoleUser, Content: "Conversation:\n\n" + digest + "\n\nTitle:"},
},
}
stream, err := provider.StreamCompletion(ctx, request)
if err != nil {
return "", err
}
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, zeroruntime.CollectOptions{})
if collected.Error != "" {
return "", errors.New(collected.Error)
}
title := cleanGeneratedTitle(collected.Text)
if title == "" {
return "", errors.New("model returned no usable title")
}
return title, nil
}
// generateSessionTitleCmd builds the background command that generates and
// persists a title for sessionID. When precomputedDigest is empty the command
// reads the session's events itself (the backfill path), keeping that I/O off the
// Update goroutine; the auto-title path passes the in-memory digest directly.
func (m model) generateSessionTitleCmd(sessionID string, precomputedDigest string, backfill bool) tea.Cmd {
provider := m.provider
store := m.sessionStore
return func() tea.Msg {
digest := precomputedDigest
if strings.TrimSpace(digest) == "" {
events, err := store.ReadEvents(sessionID)
if err != nil {
return sessionTitleGeneratedMsg{sessionID: sessionID, backfill: backfill, err: err}
}
digest = sessionTitleDigest(events)
}
ctx, cancel := context.WithTimeout(context.Background(), sessionTitleTimeout)
defer cancel()
title, err := generateSessionTitle(ctx, provider, digest)
if err != nil {
return sessionTitleGeneratedMsg{sessionID: sessionID, backfill: backfill, err: err}
}
updated, err := store.UpdateTitle(sessionID, title)
if err != nil {
return sessionTitleGeneratedMsg{sessionID: sessionID, title: title, backfill: backfill, err: err}
}
return sessionTitleGeneratedMsg{sessionID: sessionID, title: updated.Title, backfill: backfill}
}
}
// firstUserMessageTitle is the auto title a session would get from its first user
// message (the same derivation Create uses), or "" if it has no user message.
func firstUserMessageTitle(events []sessions.Event) string {
for _, event := range events {
if event.Type != sessions.EventMessage {
continue
}
payload := sessionPayload(event)
if !strings.EqualFold(payloadString(payload, "role"), "user") {
continue
}
if content := strings.TrimSpace(payloadString(payload, "content")); content != "" {
return tuiSessionTitle(content)
}
}
return ""
}
// sessionTitleIsAuto reports whether a session still carries its default
// first-message title (so it is worth replacing with a model-generated one). A
// title the model already produced differs from the first message and is left
// alone.
func sessionTitleIsAuto(currentTitle string, events []sessions.Event) bool {
trimmed := strings.TrimSpace(currentTitle)
if trimmed == "" || trimmed == tuiSessionTitle("") {
return true
}
if first := firstUserMessageTitle(events); first != "" && trimmed == first {
return true
}
return false
}
// maybeAutoTitleActiveSession fires a one-shot title generation for the active
// session after a successful turn, if it still has its default first-message
// title and we have not already attempted it this process. It is a no-op when
// there is no provider, no session, or nothing worth titling.
func (m model) maybeAutoTitleActiveSession() (model, tea.Cmd) {
// Both are required: the title cmd captures the provider AND calls
// store.UpdateTitle, so a nil store (e.g. a fallback model in tests) would
// nil-deref on the auto-title path. Mirrors the nil-store guards on resume.
if m.provider == nil || m.sessionStore == nil {
return m, nil
}
sessionID := m.activeSession.SessionID
if sessionID == "" || m.titledSessions[sessionID] {
return m, nil
}
if !sessionTitleIsAuto(m.activeSession.Title, m.sessionEvents) {
return m, nil
}
digest := sessionTitleDigest(m.sessionEvents)
if digest == "" {
return m, nil
}
if m.titledSessions == nil {
m.titledSessions = map[string]bool{}
}
m.titledSessions[sessionID] = true
return m, m.generateSessionTitleCmd(sessionID, digest, false)
}
// startSessionRetitle scans resumable sessions for ones still carrying their
// default first-message title and queues a model-generated title for each,
// firing them one at a time. It returns a status line for the transcript.
func (m model) startSessionRetitle() (model, tea.Cmd, string) {
if m.provider == nil {
return m, nil, "Cannot retitle sessions: no active provider is configured."
}
if m.retitleActive {
return m, nil, fmt.Sprintf("Already generating titles (%d/%d). Let it finish first.", m.retitleDone, m.retitleTotal)
}
list, err := m.sessionStore.ListResumable()
if err != nil {
return m, nil, "Sessions\nFailed to list sessions: " + err.Error()
}
candidates := make([]string, 0, len(list))
for _, session := range list {
events, err := m.sessionStore.ReadEvents(session.SessionID)
if err != nil {
continue
}
if !eventsHaveResumableContent(events) {
continue // empty/failed run — nothing worth titling
}
if !sessionTitleIsAuto(session.Title, events) {
continue // already has a model-generated title
}
candidates = append(candidates, session.SessionID)
}
if len(candidates) == 0 {
return m, nil, "All resumable sessions already have a generated title."
}
if m.titledSessions == nil {
m.titledSessions = map[string]bool{}
}
for _, id := range candidates {
m.titledSessions[id] = true
}
m.retitleQueue = append([]string(nil), candidates[1:]...)
m.retitleActive = true
m.retitleTotal = len(candidates)
m.retitleDone = 0
m.retitleOK = 0
cmd := m.generateSessionTitleCmd(candidates[0], "", true)
return m, cmd, fmt.Sprintf("Generating titles for %d session(s)… this runs in the background.", len(candidates))
}
// handleSessionTitleGenerated applies a finished title and, for the /retitle
// backfill, advances the sequential queue and reports completion.
func (m model) handleSessionTitleGenerated(msg sessionTitleGeneratedMsg) (model, tea.Cmd) {
titleOK := msg.err == nil && msg.title != ""
if titleOK {
if msg.sessionID == m.activeSession.SessionID {
m.activeSession.Title = msg.title
}
} else {
// titledSessions is marked optimistically when the cmd is scheduled (so a
// second turn can't double-fire a title for the same session while the
// first is in flight). A FAILED generation — provider error, empty title,
// or store write error — must not leave that gate set forever, so release
// it here; a later turn or /retitle can then retry. Success keeps the gate.
delete(m.titledSessions, msg.sessionID)
}
if !msg.backfill {
// Auto-title is silent: on failure the first-message title simply stays
// (and the retry gate above was released).
return m, nil
}
m.retitleDone++
if titleOK {
m.retitleOK++
}
if len(m.retitleQueue) > 0 {
next := m.retitleQueue[0]
m.retitleQueue = m.retitleQueue[1:]
return m, m.generateSessionTitleCmd(next, "", true)
}
m.retitleActive = false
summary := fmt.Sprintf("Generated titles for %d of %d session(s). Open /resume to see them.", m.retitleOK, m.retitleTotal)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "sessions", text: summary})
return m, nil
}