feat: session rename with r key in /sessions
Press r to rename the selected session. Uses append-based rename lines so the active session is not corrupted. Native terminal cursor in rename input. Title shown in picker if set.
- commit
- 6237dd2e809ee88064c29c99f057bf34f5a551d3
- author
- patriceckhart · · 2026-04-25 18:01 UTC
- parent
- 205e362
- signature
- unsigned
- refs
- tag: v0.1.15
3 files · +108 -5
M internal/agent/modes/interactive.go +6 -0
| @@ -882,6 +882,12 @@ func (i *Interactive) redraw() { | |||
| 882 | 882 | cursorCol = c | |
| 883 | 883 | } | |
| 884 | 884 | } | |
| 885 | + | if i.sessionDialog.Active() { | |
| 886 | + | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | |
| 887 | + | cursorRow = len(visibleChat) + r | |
| 888 | + | cursorCol = c | |
| 889 | + | } | |
| 890 | + | } | |
| 885 | 891 | if i.extPanel.Active() { | |
| 886 | 892 | cursorRow = -1 | |
| 887 | 893 | cursorCol = 0 | |
| @@ -882,6 +882,12 @@ func (i *Interactive) redraw() { | |||||
| 882 | cursorCol = c | 882 | cursorCol = c | ||
| 883 | } | 883 | } | ||
| 884 | } | 884 | } | ||
| 885 | + | if i.sessionDialog.Active() { | |||
| 886 | + | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | |||
| 887 | + | cursorRow = len(visibleChat) + r | |||
| 888 | + | cursorCol = c | |||
| 889 | + | } | |||
| 890 | + | } | |||
| 885 | if i.extPanel.Active() { | 891 | if i.extPanel.Active() { | ||
| 886 | cursorRow = -1 | 892 | cursorRow = -1 | ||
| 887 | cursorCol = 0 | 893 | cursorCol = 0 | ||
M internal/agent/modes/session_dialog.go +74 -5
| @@ -14,6 +14,8 @@ type sessionDialog struct { | |||
| 14 | 14 | active bool | |
| 15 | 15 | sessions []core.SessionSummary | |
| 16 | 16 | cursor int | |
| 17 | + | renaming bool | |
| 18 | + | rename string | |
| 17 | 19 | ||
| 18 | 20 | // MaxRows is the maximum number of session rows the dialog | |
| 19 | 21 | // will render in a single frame. Set by the host right before | |
| @@ -32,9 +34,10 @@ type sessionDialog struct { | |||
| 32 | 34 | ||
| 33 | 35 | // sessionDialogAction is returned by HandleKey. | |
| 34 | 36 | type sessionDialogAction struct { | |
| 35 | - | Select bool | |
| 36 | - | Path string | |
| 37 | - | Close bool | |
| 37 | + | Select bool | |
| 38 | + | Path string | |
| 39 | + | Close bool | |
| 40 | + | Renamed bool | |
| 38 | 41 | } | |
| 39 | 42 | ||
| 40 | 43 | func newSessionDialog() *sessionDialog { return &sessionDialog{} } | |
| @@ -59,6 +62,17 @@ func (d *sessionDialog) Open(root, cwd string) { | |||
| 59 | 62 | d.active = true | |
| 60 | 63 | } | |
| 61 | 64 | ||
| 65 | + | // CursorPos returns the row/col for the terminal cursor when in | |
| 66 | + | // rename mode. Returns -1, -1 otherwise. | |
| 67 | + | func (d *sessionDialog) CursorPos() (row, col int) { | |
| 68 | + | if !d.renaming { | |
| 69 | + | return -1, -1 | |
| 70 | + | } | |
| 71 | + | // Row: frameHeader(1) + rename hint(1) = row 2 (0-indexed) | |
| 72 | + | // Col: 2 spaces indent + text length | |
| 73 | + | return 2, 2 + len([]rune(d.rename)) | |
| 74 | + | } | |
| 75 | + | ||
| 62 | 76 | // Close hides the dialog. | |
| 63 | 77 | func (d *sessionDialog) Close() { d.active = false } | |
| 64 | 78 | ||
| @@ -78,7 +92,13 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 78 | 92 | lines = append(lines, frameRule(th, width)) | |
| 79 | 93 | return lines | |
| 80 | 94 | } | |
| 81 | - | lines = append(lines, th.FG256(th.Muted, "pick a session to resume (↑/↓, pgup/pgdn, enter, esc to cancel)")) | |
| 95 | + | if d.renaming { | |
| 96 | + | lines = append(lines, th.FG256(th.Muted, "rename session (enter to save, esc to cancel):")) | |
| 97 | + | lines = append(lines, " "+th.FG256(th.FG, d.rename)) | |
| 98 | + | lines = append(lines, frameRule(th, width)) | |
| 99 | + | return lines | |
| 100 | + | } | |
| 101 | + | lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)")) | |
| 82 | 102 | ||
| 83 | 103 | // Viewport: windowed slice of d.sessions around d.cursor so a | |
| 84 | 104 | // list taller than the terminal still scrolls. Caller sets | |
| @@ -155,7 +175,10 @@ func clampViewTop(viewTop, cursor, window, total int) int { | |||
| 155 | 175 | // full-row selection highlight. | |
| 156 | 176 | func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |
| 157 | 177 | when := formatRelative(s.Started) | |
| 158 | - | summary := strings.TrimSpace(s.FirstUserText) | |
| 178 | + | summary := strings.TrimSpace(s.Title) | |
| 179 | + | if summary == "" { | |
| 180 | + | summary = strings.TrimSpace(s.FirstUserText) | |
| 181 | + | } | |
| 159 | 182 | if summary == "" { | |
| 160 | 183 | summary = "(empty)" | |
| 161 | 184 | } | |
| @@ -193,6 +216,41 @@ func formatRelative(t time.Time) string { | |||
| 193 | 216 | ||
| 194 | 217 | // HandleKey advances the dialog and returns an action to apply, if any. | |
| 195 | 218 | func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |
| 219 | + | // Rename mode: type the new name. | |
| 220 | + | if d.renaming { | |
| 221 | + | switch k.Kind { | |
| 222 | + | case tui.KeyEnter: | |
| 223 | + | title := strings.TrimSpace(d.rename) | |
| 224 | + | if title != "" && d.cursor < len(d.sessions) { | |
| 225 | + | path := d.sessions[d.cursor].Path | |
| 226 | + | _ = core.RenameSession(path, title) | |
| 227 | + | d.sessions[d.cursor].Title = title | |
| 228 | + | } | |
| 229 | + | d.renaming = false | |
| 230 | + | d.rename = "" | |
| 231 | + | return sessionDialogAction{Renamed: true} | |
| 232 | + | case tui.KeyEsc: | |
| 233 | + | d.renaming = false | |
| 234 | + | d.rename = "" | |
| 235 | + | return sessionDialogAction{} | |
| 236 | + | case tui.KeyBackspace: | |
| 237 | + | if len(d.rename) > 0 { | |
| 238 | + | r := []rune(d.rename) | |
| 239 | + | d.rename = string(r[:len(r)-1]) | |
| 240 | + | } | |
| 241 | + | return sessionDialogAction{} | |
| 242 | + | case tui.KeyPaste: | |
| 243 | + | d.rename += k.Paste | |
| 244 | + | return sessionDialogAction{} | |
| 245 | + | case tui.KeyRune: | |
| 246 | + | if k.Rune != 0 { | |
| 247 | + | d.rename += string(k.Rune) | |
| 248 | + | } | |
| 249 | + | return sessionDialogAction{} | |
| 250 | + | } | |
| 251 | + | return sessionDialogAction{} | |
| 252 | + | } | |
| 253 | + | ||
| 196 | 254 | page := d.MaxRows | |
| 197 | 255 | if page <= 0 { | |
| 198 | 256 | page = 10 | |
| @@ -239,6 +297,17 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 239 | 297 | s := d.sessions[d.cursor] | |
| 240 | 298 | d.Close() | |
| 241 | 299 | return sessionDialogAction{Select: true, Path: s.Path} | |
| 300 | + | case tui.KeyRune: | |
| 301 | + | if k.Rune == 'r' && len(d.sessions) > 0 { | |
| 302 | + | s := d.sessions[d.cursor] | |
| 303 | + | d.renaming = true | |
| 304 | + | if s.Title != "" { | |
| 305 | + | d.rename = s.Title | |
| 306 | + | } else { | |
| 307 | + | d.rename = "" | |
| 308 | + | } | |
| 309 | + | return sessionDialogAction{} | |
| 310 | + | } | |
| 242 | 311 | } | |
| 243 | 312 | return sessionDialogAction{} | |
| 244 | 313 | } | |
| @@ -14,6 +14,8 @@ type sessionDialog struct { | |||||
| 14 | active bool | 14 | active bool | ||
| 15 | sessions []core.SessionSummary | 15 | sessions []core.SessionSummary | ||
| 16 | cursor int | 16 | cursor int | ||
| 17 | + | renaming bool | |||
| 18 | + | rename string | |||
| 17 | 19 | ||||
| 18 | // MaxRows is the maximum number of session rows the dialog | 20 | // MaxRows is the maximum number of session rows the dialog | ||
| 19 | // will render in a single frame. Set by the host right before | 21 | // will render in a single frame. Set by the host right before | ||
| @@ -32,9 +34,10 @@ type sessionDialog struct { | |||||
| 32 | 34 | ||||
| 33 | // sessionDialogAction is returned by HandleKey. | 35 | // sessionDialogAction is returned by HandleKey. | ||
| 34 | type sessionDialogAction struct { | 36 | type sessionDialogAction struct { | ||
| 35 | - | Select bool | 37 | + | Select bool |
| 36 | - | Path string | 38 | + | Path string |
| 37 | - | Close bool | 39 | + | Close bool |
| 40 | + | Renamed bool | |||
| 38 | } | 41 | } | ||
| 39 | 42 | ||||
| 40 | func newSessionDialog() *sessionDialog { return &sessionDialog{} } | 43 | func newSessionDialog() *sessionDialog { return &sessionDialog{} } | ||
| @@ -59,6 +62,17 @@ func (d *sessionDialog) Open(root, cwd string) { | |||||
| 59 | d.active = true | 62 | d.active = true | ||
| 60 | } | 63 | } | ||
| 61 | 64 | ||||
| 65 | + | // CursorPos returns the row/col for the terminal cursor when in | |||
| 66 | + | // rename mode. Returns -1, -1 otherwise. | |||
| 67 | + | func (d *sessionDialog) CursorPos() (row, col int) { | |||
| 68 | + | if !d.renaming { | |||
| 69 | + | return -1, -1 | |||
| 70 | + | } | |||
| 71 | + | // Row: frameHeader(1) + rename hint(1) = row 2 (0-indexed) | |||
| 72 | + | // Col: 2 spaces indent + text length | |||
| 73 | + | return 2, 2 + len([]rune(d.rename)) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 62 | // Close hides the dialog. | 76 | // Close hides the dialog. | ||
| 63 | func (d *sessionDialog) Close() { d.active = false } | 77 | func (d *sessionDialog) Close() { d.active = false } | ||
| 64 | 78 | ||||
| @@ -78,7 +92,13 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 78 | lines = append(lines, frameRule(th, width)) | 92 | lines = append(lines, frameRule(th, width)) | ||
| 79 | return lines | 93 | return lines | ||
| 80 | } | 94 | } | ||
| 81 | - | lines = append(lines, th.FG256(th.Muted, "pick a session to resume (↑/↓, pgup/pgdn, enter, esc to cancel)")) | 95 | + | if d.renaming { |
| 96 | + | lines = append(lines, th.FG256(th.Muted, "rename session (enter to save, esc to cancel):")) | |||
| 97 | + | lines = append(lines, " "+th.FG256(th.FG, d.rename)) | |||
| 98 | + | lines = append(lines, frameRule(th, width)) | |||
| 99 | + | return lines | |||
| 100 | + | } | |||
| 101 | + | lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)")) | |||
| 82 | 102 | ||||
| 83 | // Viewport: windowed slice of d.sessions around d.cursor so a | 103 | // Viewport: windowed slice of d.sessions around d.cursor so a | ||
| 84 | // list taller than the terminal still scrolls. Caller sets | 104 | // list taller than the terminal still scrolls. Caller sets | ||
| @@ -155,7 +175,10 @@ func clampViewTop(viewTop, cursor, window, total int) int { | |||||
| 155 | // full-row selection highlight. | 175 | // full-row selection highlight. | ||
| 156 | func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | 176 | func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | ||
| 157 | when := formatRelative(s.Started) | 177 | when := formatRelative(s.Started) | ||
| 158 | - | summary := strings.TrimSpace(s.FirstUserText) | 178 | + | summary := strings.TrimSpace(s.Title) |
| 179 | + | if summary == "" { | |||
| 180 | + | summary = strings.TrimSpace(s.FirstUserText) | |||
| 181 | + | } | |||
| 159 | if summary == "" { | 182 | if summary == "" { | ||
| 160 | summary = "(empty)" | 183 | summary = "(empty)" | ||
| 161 | } | 184 | } | ||
| @@ -193,6 +216,41 @@ func formatRelative(t time.Time) string { | |||||
| 193 | 216 | ||||
| 194 | // HandleKey advances the dialog and returns an action to apply, if any. | 217 | // HandleKey advances the dialog and returns an action to apply, if any. | ||
| 195 | func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | 218 | func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | ||
| 219 | + | // Rename mode: type the new name. | |||
| 220 | + | if d.renaming { | |||
| 221 | + | switch k.Kind { | |||
| 222 | + | case tui.KeyEnter: | |||
| 223 | + | title := strings.TrimSpace(d.rename) | |||
| 224 | + | if title != "" && d.cursor < len(d.sessions) { | |||
| 225 | + | path := d.sessions[d.cursor].Path | |||
| 226 | + | _ = core.RenameSession(path, title) | |||
| 227 | + | d.sessions[d.cursor].Title = title | |||
| 228 | + | } | |||
| 229 | + | d.renaming = false | |||
| 230 | + | d.rename = "" | |||
| 231 | + | return sessionDialogAction{Renamed: true} | |||
| 232 | + | case tui.KeyEsc: | |||
| 233 | + | d.renaming = false | |||
| 234 | + | d.rename = "" | |||
| 235 | + | return sessionDialogAction{} | |||
| 236 | + | case tui.KeyBackspace: | |||
| 237 | + | if len(d.rename) > 0 { | |||
| 238 | + | r := []rune(d.rename) | |||
| 239 | + | d.rename = string(r[:len(r)-1]) | |||
| 240 | + | } | |||
| 241 | + | return sessionDialogAction{} | |||
| 242 | + | case tui.KeyPaste: | |||
| 243 | + | d.rename += k.Paste | |||
| 244 | + | return sessionDialogAction{} | |||
| 245 | + | case tui.KeyRune: | |||
| 246 | + | if k.Rune != 0 { | |||
| 247 | + | d.rename += string(k.Rune) | |||
| 248 | + | } | |||
| 249 | + | return sessionDialogAction{} | |||
| 250 | + | } | |||
| 251 | + | return sessionDialogAction{} | |||
| 252 | + | } | |||
| 253 | + | ||||
| 196 | page := d.MaxRows | 254 | page := d.MaxRows | ||
| 197 | if page <= 0 { | 255 | if page <= 0 { | ||
| 198 | page = 10 | 256 | page = 10 | ||
| @@ -239,6 +297,17 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 239 | s := d.sessions[d.cursor] | 297 | s := d.sessions[d.cursor] | ||
| 240 | d.Close() | 298 | d.Close() | ||
| 241 | return sessionDialogAction{Select: true, Path: s.Path} | 299 | return sessionDialogAction{Select: true, Path: s.Path} | ||
| 300 | + | case tui.KeyRune: | |||
| 301 | + | if k.Rune == 'r' && len(d.sessions) > 0 { | |||
| 302 | + | s := d.sessions[d.cursor] | |||
| 303 | + | d.renaming = true | |||
| 304 | + | if s.Title != "" { | |||
| 305 | + | d.rename = s.Title | |||
| 306 | + | } else { | |||
| 307 | + | d.rename = "" | |||
| 308 | + | } | |||
| 309 | + | return sessionDialogAction{} | |||
| 310 | + | } | |||
| 242 | } | 311 | } | ||
| 243 | return sessionDialogAction{} | 312 | return sessionDialogAction{} | ||
| 244 | } | 313 | } | ||
M internal/core/session.go +28 -0
| @@ -45,6 +45,7 @@ type SessionMeta struct { | |||
| 45 | 45 | Provider string `json:"provider"` | |
| 46 | 46 | Started time.Time `json:"started"` | |
| 47 | 47 | Version string `json:"version"` | |
| 48 | + | Title string `json:"title,omitempty"` | |
| 48 | 49 | ||
| 49 | 50 | // Parent is the ID of the session this one was forked from, or | |
| 50 | 51 | // empty for top-level sessions. The tree picker walks parents | |
| @@ -260,6 +261,25 @@ type SessionSummary struct { | |||
| 260 | 261 | MessageCount int | |
| 261 | 262 | FirstUserText string | |
| 262 | 263 | TotalCost float64 | |
| 264 | + | Title string | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // RenameSession updates the title field in the session's meta line. | |
| 268 | + | // It rewrites the first line of the file (the meta line) with the | |
| 269 | + | // updated title. | |
| 270 | + | // RenameSession appends a rename line to the session file. This is | |
| 271 | + | // safe even for the currently active session because it opens the | |
| 272 | + | // file independently and appends (doesn't rewrite). | |
| 273 | + | func RenameSession(path, title string) error { | |
| 274 | + | f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644) | |
| 275 | + | if err != nil { | |
| 276 | + | return err | |
| 277 | + | } | |
| 278 | + | defer f.Close() | |
| 279 | + | line, _ := json.Marshal(map[string]string{"type": "rename", "title": title}) | |
| 280 | + | line = append(line, '\n') | |
| 281 | + | _, err = f.Write(line) | |
| 282 | + | return err | |
| 263 | 283 | } | |
| 264 | 284 | ||
| 265 | 285 | // DescribeSessions returns lightweight summaries for every session in | |
| @@ -297,12 +317,20 @@ func describeSession(path string) SessionSummary { | |||
| 297 | 317 | s.Started = row.Meta.Started | |
| 298 | 318 | s.Model = row.Meta.Model | |
| 299 | 319 | s.Provider = row.Meta.Provider | |
| 320 | + | s.Title = row.Meta.Title | |
| 300 | 321 | } | |
| 301 | 322 | case "message": | |
| 302 | 323 | s.MessageCount++ | |
| 303 | 324 | if s.FirstUserText == "" { | |
| 304 | 325 | s.FirstUserText = firstUserText(sc.Bytes()) | |
| 305 | 326 | } | |
| 327 | + | case "rename": | |
| 328 | + | var row struct { | |
| 329 | + | Title string `json:"title"` | |
| 330 | + | } | |
| 331 | + | if err := json.Unmarshal(sc.Bytes(), &row); err == nil && row.Title != "" { | |
| 332 | + | s.Title = row.Title | |
| 333 | + | } | |
| 306 | 334 | case "usage": | |
| 307 | 335 | var row struct { | |
| 308 | 336 | Cumulative provider.Usage `json:"cumulative"` | |
| @@ -45,6 +45,7 @@ type SessionMeta struct { | |||||
| 45 | Provider string `json:"provider"` | 45 | Provider string `json:"provider"` | ||
| 46 | Started time.Time `json:"started"` | 46 | Started time.Time `json:"started"` | ||
| 47 | Version string `json:"version"` | 47 | Version string `json:"version"` | ||
| 48 | + | Title string `json:"title,omitempty"` | |||
| 48 | 49 | ||||
| 49 | // Parent is the ID of the session this one was forked from, or | 50 | // Parent is the ID of the session this one was forked from, or | ||
| 50 | // empty for top-level sessions. The tree picker walks parents | 51 | // empty for top-level sessions. The tree picker walks parents | ||
| @@ -260,6 +261,25 @@ type SessionSummary struct { | |||||
| 260 | MessageCount int | 261 | MessageCount int | ||
| 261 | FirstUserText string | 262 | FirstUserText string | ||
| 262 | TotalCost float64 | 263 | TotalCost float64 | ||
| 264 | + | Title string | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // RenameSession updates the title field in the session's meta line. | |||
| 268 | + | // It rewrites the first line of the file (the meta line) with the | |||
| 269 | + | // updated title. | |||
| 270 | + | // RenameSession appends a rename line to the session file. This is | |||
| 271 | + | // safe even for the currently active session because it opens the | |||
| 272 | + | // file independently and appends (doesn't rewrite). | |||
| 273 | + | func RenameSession(path, title string) error { | |||
| 274 | + | f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644) | |||
| 275 | + | if err != nil { | |||
| 276 | + | return err | |||
| 277 | + | } | |||
| 278 | + | defer f.Close() | |||
| 279 | + | line, _ := json.Marshal(map[string]string{"type": "rename", "title": title}) | |||
| 280 | + | line = append(line, '\n') | |||
| 281 | + | _, err = f.Write(line) | |||
| 282 | + | return err | |||
| 263 | } | 283 | } | ||
| 264 | 284 | ||||
| 265 | // DescribeSessions returns lightweight summaries for every session in | 285 | // DescribeSessions returns lightweight summaries for every session in | ||
| @@ -297,12 +317,20 @@ func describeSession(path string) SessionSummary { | |||||
| 297 | s.Started = row.Meta.Started | 317 | s.Started = row.Meta.Started | ||
| 298 | s.Model = row.Meta.Model | 318 | s.Model = row.Meta.Model | ||
| 299 | s.Provider = row.Meta.Provider | 319 | s.Provider = row.Meta.Provider | ||
| 320 | + | s.Title = row.Meta.Title | |||
| 300 | } | 321 | } | ||
| 301 | case "message": | 322 | case "message": | ||
| 302 | s.MessageCount++ | 323 | s.MessageCount++ | ||
| 303 | if s.FirstUserText == "" { | 324 | if s.FirstUserText == "" { | ||
| 304 | s.FirstUserText = firstUserText(sc.Bytes()) | 325 | s.FirstUserText = firstUserText(sc.Bytes()) | ||
| 305 | } | 326 | } | ||
| 327 | + | case "rename": | |||
| 328 | + | var row struct { | |||
| 329 | + | Title string `json:"title"` | |||
| 330 | + | } | |||
| 331 | + | if err := json.Unmarshal(sc.Bytes(), &row); err == nil && row.Title != "" { | |||
| 332 | + | s.Title = row.Title | |||
| 333 | + | } | |||
| 306 | case "usage": | 334 | case "usage": | ||
| 307 | var row struct { | 335 | var row struct { | ||
| 308 | Cumulative provider.Usage `json:"cumulative"` | 336 | Cumulative provider.Usage `json:"cumulative"` | ||