detached 6237dd2

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() {
882882 cursorCol = c
883883 }
884884 }
885+ if i.sessionDialog.Active() {
886+ if r, c := i.sessionDialog.CursorPos(); r >= 0 {
887+ cursorRow = len(visibleChat) + r
888+ cursorCol = c
889+ }
890+ }
885891 if i.extPanel.Active() {
886892 cursorRow = -1
887893 cursorCol = 0
@@ -882,6 +882,12 @@ func (i *Interactive) redraw() {
882 cursorCol = c882 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 = -1892 cursorRow = -1
887 cursorCol = 0893 cursorCol = 0
M internal/agent/modes/session_dialog.go +74 -5
@@ -14,6 +14,8 @@ type sessionDialog struct {
1414 active bool
1515 sessions []core.SessionSummary
1616 cursor int
17+ renaming bool
18+ rename string
1719
1820 // MaxRows is the maximum number of session rows the dialog
1921 // will render in a single frame. Set by the host right before
@@ -32,9 +34,10 @@ type sessionDialog struct {
3234
3335 // sessionDialogAction is returned by HandleKey.
3436 type sessionDialogAction struct {
35- Select bool
36- Path string
37- Close bool
37+ Select bool
38+ Path string
39+ Close bool
40+ Renamed bool
3841 }
3942
4043 func newSessionDialog() *sessionDialog { return &sessionDialog{} }
@@ -59,6 +62,17 @@ func (d *sessionDialog) Open(root, cwd string) {
5962 d.active = true
6063 }
6164
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+
6276 // Close hides the dialog.
6377 func (d *sessionDialog) Close() { d.active = false }
6478
@@ -78,7 +92,13 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
7892 lines = append(lines, frameRule(th, width))
7993 return lines
8094 }
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)"))
82102
83103 // Viewport: windowed slice of d.sessions around d.cursor so a
84104 // list taller than the terminal still scrolls. Caller sets
@@ -155,7 +175,10 @@ func clampViewTop(viewTop, cursor, window, total int) int {
155175 // full-row selection highlight.
156176 func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
157177 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+ }
159182 if summary == "" {
160183 summary = "(empty)"
161184 }
@@ -193,6 +216,41 @@ func formatRelative(t time.Time) string {
193216
194217 // HandleKey advances the dialog and returns an action to apply, if any.
195218 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+
196254 page := d.MaxRows
197255 if page <= 0 {
198256 page = 10
@@ -239,6 +297,17 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
239297 s := d.sessions[d.cursor]
240298 d.Close()
241299 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+ }
242311 }
243312 return sessionDialogAction{}
244313 }
@@ -14,6 +14,8 @@ type sessionDialog struct {
14 active bool14 active bool
15 sessions []core.SessionSummary15 sessions []core.SessionSummary
16 cursor int16 cursor int
17+ renaming bool
18+ rename string
17 19
18 // MaxRows is the maximum number of session rows the dialog20 // MaxRows is the maximum number of session rows the dialog
19 // will render in a single frame. Set by the host right before21 // 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 bool37+ Select bool
36- Path string38+ Path string
37- Close bool39+ 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 = true62 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 lines93 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 a103 // Viewport: windowed slice of d.sessions around d.cursor so a
84 // list taller than the terminal still scrolls. Caller sets104 // 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.MaxRows254 page := d.MaxRows
197 if page <= 0 {255 if page <= 0 {
198 page = 10256 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 {
4545 Provider string `json:"provider"`
4646 Started time.Time `json:"started"`
4747 Version string `json:"version"`
48+ Title string `json:"title,omitempty"`
4849
4950 // Parent is the ID of the session this one was forked from, or
5051 // empty for top-level sessions. The tree picker walks parents
@@ -260,6 +261,25 @@ type SessionSummary struct {
260261 MessageCount int
261262 FirstUserText string
262263 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
263283 }
264284
265285 // DescribeSessions returns lightweight summaries for every session in
@@ -297,12 +317,20 @@ func describeSession(path string) SessionSummary {
297317 s.Started = row.Meta.Started
298318 s.Model = row.Meta.Model
299319 s.Provider = row.Meta.Provider
320+ s.Title = row.Meta.Title
300321 }
301322 case "message":
302323 s.MessageCount++
303324 if s.FirstUserText == "" {
304325 s.FirstUserText = firstUserText(sc.Bytes())
305326 }
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+ }
306334 case "usage":
307335 var row struct {
308336 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, or50 // Parent is the ID of the session this one was forked from, or
50 // empty for top-level sessions. The tree picker walks parents51 // empty for top-level sessions. The tree picker walks parents
@@ -260,6 +261,25 @@ type SessionSummary struct {
260 MessageCount int261 MessageCount int
261 FirstUserText string262 FirstUserText string
262 TotalCost float64263 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 in285 // DescribeSessions returns lightweight summaries for every session in
@@ -297,12 +317,20 @@ func describeSession(path string) SessionSummary {
297 s.Started = row.Meta.Started317 s.Started = row.Meta.Started
298 s.Model = row.Meta.Model318 s.Model = row.Meta.Model
299 s.Provider = row.Meta.Provider319 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"`

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close