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
28 changes: 26 additions & 2 deletions internal/sessions/session_title_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestUpdateTitle(t *testing.T) {
t.Fatalf("title not trimmed/stored: %q", updated.Title)
}
if updated.UpdatedAt != before.UpdatedAt {
t.Fatalf("UpdatedAt must not change on retitle: before=%q after=%q", before.UpdatedAt, updated.UpdatedAt)
t.Fatalf("UpdatedAt must not change on rename: before=%q after=%q", before.UpdatedAt, updated.UpdatedAt)
}
if updated.EventCount != before.EventCount {
t.Fatalf("EventCount changed: before=%d after=%d", before.EventCount, updated.EventCount)
Expand All @@ -67,7 +67,7 @@ func TestUpdateTitle(t *testing.T) {

// An unchanged title is a no-op (still succeeds).
if _, err := store.UpdateTitle(session.SessionID, "Clean Generated Title"); err != nil {
t.Fatalf("no-op retitle should succeed: %v", err)
t.Fatalf("no-op rename should succeed: %v", err)
}

// An invalid session id is rejected.
Expand All @@ -76,6 +76,30 @@ func TestUpdateTitle(t *testing.T) {
}
}

func TestUpdateTitleIfCurrent(t *testing.T) {
store := newTitleTestStore(t)
session, err := store.Create(CreateInput{Title: "Automatic title"})
if err != nil {
t.Fatalf("create: %v", err)
}

updated, applied, err := store.UpdateTitleIfCurrent(session.SessionID, "Automatic title", "Generated title")
if err != nil || !applied || updated.Title != "Generated title" {
t.Fatalf("matching update = (%#v, %v, %v)", updated, applied, err)
}

if _, err := store.UpdateTitle(session.SessionID, "Manual title"); err != nil {
t.Fatalf("manual rename: %v", err)
}
updated, applied, err = store.UpdateTitleIfCurrent(session.SessionID, "Generated title", "Late generated title")
if err != nil {
t.Fatalf("stale update: %v", err)
}
if applied || updated.Title != "Manual title" {
t.Fatalf("stale update should preserve manual title: (%#v, %v)", updated, applied)
}
}

func TestUpdateModel(t *testing.T) {
store := newTitleTestStore(t)
session, err := store.Create(CreateInput{ModelID: "model-a"})
Expand Down
36 changes: 35 additions & 1 deletion internal/sessions/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,7 @@ func (store *Store) appendPreparedEventsLocked(sessionID string, inputs []prepar
// serialized under the same per-session lock as AppendEvent and re-reads the
// latest metadata under that lock before rewriting, so a concurrent append can't
// clobber the new title (nor the title clobber a concurrent append's event
// count/timestamp). UpdatedAt is deliberately left untouched: a retitle is not
// count/timestamp). UpdatedAt is deliberately left untouched: a rename is not
// activity, so it must not reorder the session in the resumable list. A blank
// title is rejected so a failed model generation can never erase a useful
// first-message title, and an unchanged title is a no-op (no rewrite/fsync).
Expand Down Expand Up @@ -751,6 +751,40 @@ func (store *Store) UpdateTitle(sessionID string, title string) (Metadata, error
return session, nil
}

// UpdateTitleIfCurrent replaces a session title only when it still matches
// expected. It lets background automatic naming avoid overwriting a newer manual
// rename while keeping the check and write under the same per-session lock.
func (store *Store) UpdateTitleIfCurrent(sessionID string, expected string, title string) (Metadata, bool, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, false, fmt.Errorf("invalid zero session id %q", sessionID)
}
trimmed := strings.TrimSpace(title)
if trimmed == "" {
return Metadata{}, false, fmt.Errorf("zero session title is required")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, false, err
}
defer unlock()

session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, false, err
}
if session.Title != strings.TrimSpace(expected) {
return session, false, nil
}
if session.Title == trimmed {
return session, true, nil
}
session.Title = trimmed
if err := store.writeMetadata(session); err != nil {
return Metadata{}, false, err
}
return session, true, nil
}

// UpdateModel replaces a session's selected model without changing its activity
// timestamp or event counters. An empty model clears the session override.
func (store *Store) UpdateModel(sessionID string, modelID string) (Metadata, error) {
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/btw.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ func (m model) leaveBTW() (model, tea.Cmd) {
func btwCommandUnavailable(command parsedCommand) bool {
arg := strings.ToLower(strings.TrimSpace(command.text))
switch command.kind {
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop, commandGoal,
case commandNew, commandResume, commandRename, commandSpec, commandLoop, commandGoal,
commandRewind, commandCompact, commandSTTModel, commandMCP:
return true
case commandModel:
Expand Down
11 changes: 10 additions & 1 deletion internal/tui/clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ func pasteFromClipboardCmd() tea.Cmd {
// right-click paste (clipboardReadMsg) so a bracketed paste and a right-click
// paste behave identically. Surfaces with no editable text field (a permission/
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
// the paste; empty content is a no-op.
// the paste. The session rename editor accepts text only; empty content is a
// no-op there rather than an image probe.
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
// A paste is a deliberate action, same as a keypress or click — it means
// the user moved on to something else, so it disarms a stale Esc
Expand All @@ -75,6 +76,14 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
// toggle instead of going solid.
m.lastCharTime = m.now()
m.composerCursorVisible = true
if m.renamePrompt != nil {
if content == "" {
return m, nil
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(tea.PasteMsg{Content: sanitizeComposerInput(content)})
return m, cmd
}
if content == "" {
// Empty text clipboard — the user may have pasted a screenshot.
// Probe the OS clipboard for image content asynchronously.
Expand Down
10 changes: 5 additions & 5 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const (
commandPlan
commandSearch
commandResume
commandRetitle
commandRename
commandSpec
commandInit
commandCompact
Expand Down Expand Up @@ -226,11 +226,11 @@ var commandDefinitions = []commandDefinition{
kind: commandResume,
},
{
name: "/retitle",
usage: "/retitle",
name: "/rename",
usage: "/rename [title]",
group: commandGroupSession,
description: "Generate concise titles for resumable sessions.",
kind: commandRetitle,
description: "Rename the current session (no arg opens an editor).",
kind: commandRename,
},
{
name: "/spec",
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {

func (m model) composerMouseSelectionBlocked() bool {
return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.suggestionsActive()
}

func (m model) composerPositionAtVisualCell(x int, y int, width int) (int, bool) {
Expand Down
40 changes: 17 additions & 23 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ type model struct {
doctorInFlight bool
doctorFrame int
activeSession sessions.Metadata
pendingSessionTitle string
sessionEvents []sessions.Event
btw btwState
// btwRunIDSeq is the highest run ID issued by any completed or abandoned BTW
Expand All @@ -112,14 +113,8 @@ type model struct {
// already been attempted this process, so a finished turn re-fires the title
// generator at most once per session (even before its async result lands).
// Lazily initialized.
titledSessions map[string]bool
// retitle* drive the sequential /retitle backfill: queued session ids still
// awaiting a title, whether a backfill is running, and its progress counters.
retitleQueue []string
retitleActive bool
retitleTotal int
retitleDone int
retitleOK int
titledSessions map[string]bool
renamePrompt *sessionRenamePrompt
usageTracker *usage.Tracker
sessionCompactor SessionCompactor
prService *PrService
Expand Down Expand Up @@ -1069,7 +1064,7 @@ func (m *model) stopPRWatcher() {
func (m model) noBlockingModal() bool {
return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil &&
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil &&
m.sttKeyPrompt == nil
m.sttKeyPrompt == nil && m.renamePrompt == nil
}

func (m model) quit() (tea.Model, tea.Cmd) {
Expand Down Expand Up @@ -1352,6 +1347,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.sttKeyPrompt != nil {
return m.handleSTTKeyPromptKey(msg)
}
if m.renamePrompt != nil {
return m.handleSessionRenameKey(msg)
}
m.transcriptSelection = transcriptSelectionState{}
m.composerSelection = composerSelectionState{}
m.clearMouseSelection()
Expand Down Expand Up @@ -2948,6 +2946,12 @@ func (m model) pinnedTitleBar(width int) string {

func (m model) footerView(width int) string {
var footer strings.Builder
if m.renamePrompt != nil {
footer.WriteString(m.sessionRenamePromptView(width))
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
return footer.String()
}
// While an ask-user questionnaire is active it REPLACES the composer box (the
// text box becomes the questionnaire): render the tabbed prompt + status line and
// skip the plan panel / idle hints / composer for a focused modal.
Expand Down Expand Up @@ -4520,21 +4524,11 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
return m, nil
case commandRetitle:
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "Cannot retitle sessions while a run is active.",
})
return m, nil
}
text := ""
var retitleCmd tea.Cmd
m, retitleCmd, text = m.startSessionRetitle()
if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
case commandRename:
if title := strings.TrimSpace(command.text); title != "" {
return m.renameActiveSession(title), nil
}
return m, retitleCmd
return m.openSessionRenamePrompt(), nil
case commandSpec:
return m.handleSpecCommand(command.text)
case commandInit:
Expand Down
48 changes: 42 additions & 6 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -886,8 +886,9 @@ func TestResumeCommandListsRecentSessions(t *testing.T) {
if next.picker == nil || next.picker.kind != pickerSession {
t.Fatalf("expected /resume to open the session picker, got picker=%#v", next.picker)
}
// Every row carries the session title (in the Label, after the timestamp) and
// resolves to / shows the session id (Value + Meta), for both sessions.
// Every row carries the session title after the timestamp and resolves by its
// hidden session id. Raw ids are implementation detail and must not consume
// the visible title width.
findByID := func(id string) (pickerItem, bool) {
for _, item := range next.picker.items {
if item.Value == id {
Expand All @@ -904,17 +905,52 @@ func TestResumeCommandListsRecentSessions(t *testing.T) {
if !strings.Contains(item.Label, want.title) {
t.Fatalf("picker Label %q should contain the title %q", item.Label, want.title)
}
if !strings.Contains(item.Meta, want.id) {
t.Fatalf("picker %q Meta should show the id %q, got %q", want.title, want.id, item.Meta)
if item.Meta != "" {
t.Fatalf("picker %q should not expose raw session id metadata, got %q", want.title, item.Meta)
}
}
// The picker overlay renders the titles and ids.
// The picker overlay renders clean title rows plus a position indicator.
view := viewString(next.View())
for _, want := range []string{"Resume a session", "Newer", "Older", first.SessionID, second.SessionID} {
for _, want := range []string{"Resume a session", "Newer", "Older", "1 / 2"} {
if !strings.Contains(view, want) {
t.Fatalf("session picker view missing %q:\n%s", want, view)
}
}
for _, id := range []string{first.SessionID, second.SessionID} {
if strings.Contains(view, id) {
t.Fatalf("session picker view should not expose raw id %q:\n%s", id, view)
}
}

// IDs remain searchable even though they are hidden from normal rows.
searchByID := *next.picker
searchByID.query = first.SessionID
searchByID.applyQuery()
if len(searchByID.items) != 1 || searchByID.items[0].Value != first.SessionID {
t.Fatalf("hidden session id should remain searchable, got %#v", searchByID.items)
}

// An empty result set reports an unambiguous zero position.
noResults := next
noResults.picker.query = "__missing_session__"
noResults.picker.applyQuery()
if view := viewString(noResults.View()); !strings.Contains(view, "0 / 0") {
t.Fatalf("empty session search should show 0 / 0:\n%s", view)
}
}

func TestSessionPickerLabelAlignsTitles(t *testing.T) {
today := sessionPickerLabel("20:47:50", "Today title")
older := sessionPickerLabel("Jul 24 10:47", "Older title")

todayColumn := strings.Index(today, "Today title")
olderColumn := strings.Index(older, "Older title")
if todayColumn < 0 || olderColumn < 0 {
t.Fatalf("sessionPickerLabel omitted a title: today=%q older=%q", today, older)
}
if todayColumn != olderColumn {
t.Fatalf("title columns differ: today=%d (%q), older=%d (%q)", todayColumn, today, olderColumn, older)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

func TestResumePickerSelectionHydratesSession(t *testing.T) {
Expand Down
Loading
Loading