Skip to content

Commit 47c3276

Browse files
spoons-and-mirrorsopencodethdxr
authored
feat: add session rename functionality to TUI modal (anomalyco#1821)
Co-authored-by: opencode <noreply@opencode.ai> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Dax <mail@thdxr.com>
1 parent 81583cd commit 47c3276

5 files changed

Lines changed: 231 additions & 41 deletions

File tree

packages/opencode/src/server/server.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,47 @@ export namespace Server {
296296
return c.json(true)
297297
},
298298
)
299+
.patch(
300+
"/session/:id",
301+
describeRoute({
302+
description: "Update session properties",
303+
operationId: "session.update",
304+
responses: {
305+
200: {
306+
description: "Successfully updated session",
307+
content: {
308+
"application/json": {
309+
schema: resolver(Session.Info),
310+
},
311+
},
312+
},
313+
},
314+
}),
315+
zValidator(
316+
"param",
317+
z.object({
318+
id: z.string(),
319+
}),
320+
),
321+
zValidator(
322+
"json",
323+
z.object({
324+
title: z.string().optional(),
325+
}),
326+
),
327+
async (c) => {
328+
const sessionID = c.req.valid("param").id
329+
const updates = c.req.valid("json")
330+
331+
const updatedSession = await Session.update(sessionID, (session) => {
332+
if (updates.title !== undefined) {
333+
session.title = updates.title
334+
}
335+
})
336+
337+
return c.json(updatedSession)
338+
},
339+
)
299340
.post(
300341
"/session/:id/init",
301342
describeRoute({

packages/sdk/go/session.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,18 @@ func (r *SessionService) Delete(ctx context.Context, id string, opts ...option.R
6666
return
6767
}
6868

69+
// Update session properties
70+
func (r *SessionService) Update(ctx context.Context, id string, body SessionUpdateParams, opts ...option.RequestOption) (res *Session, err error) {
71+
opts = append(r.Options[:], opts...)
72+
if id == "" {
73+
err = errors.New("missing required id parameter")
74+
return
75+
}
76+
path := fmt.Sprintf("session/%s", id)
77+
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPatch, path, body, &res, opts...)
78+
return
79+
}
80+
6981
// Abort a session
7082
func (r *SessionService) Abort(ctx context.Context, id string, opts ...option.RequestOption) (res *bool, err error) {
7183
opts = append(r.Options[:], opts...)
@@ -2356,3 +2368,11 @@ type SessionSummarizeParams struct {
23562368
func (r SessionSummarizeParams) MarshalJSON() (data []byte, err error) {
23572369
return apijson.MarshalRoot(r)
23582370
}
2371+
2372+
type SessionUpdateParams struct {
2373+
Title param.Field[string] `json:"title"`
2374+
}
2375+
2376+
func (r SessionUpdateParams) MarshalJSON() (data []byte, err error) {
2377+
return apijson.MarshalRoot(r)
2378+
}

packages/tui/internal/app/app.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,17 @@ func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
760760
return nil
761761
}
762762

763+
func (a *App) UpdateSession(ctx context.Context, sessionID string, title string) error {
764+
_, err := a.Client.Session.Update(ctx, sessionID, opencode.SessionUpdateParams{
765+
Title: opencode.F(title),
766+
})
767+
if err != nil {
768+
slog.Error("Failed to update session", "error", err)
769+
return err
770+
}
771+
return nil
772+
}
773+
763774
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
764775
response, err := a.Client.Session.Messages(ctx, sessionId)
765776
if err != nil {

packages/tui/internal/components/dialog/session.go

Lines changed: 154 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"slices"
88

9+
"github.com/charmbracelet/bubbles/v2/textinput"
910
tea "github.com/charmbracelet/bubbletea/v2"
1011
"github.com/muesli/reflow/truncate"
1112
"github.com/sst/opencode-sdk-go"
@@ -110,6 +111,9 @@ type sessionDialog struct {
110111
list list.List[sessionItem]
111112
app *app.App
112113
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
114+
renameMode bool
115+
renameInput textinput.Model
116+
renameIndex int // index of session being renamed
113117
}
114118

115119
func (s *sessionDialog) Init() tea.Cmd {
@@ -123,69 +127,128 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
123127
s.height = msg.Height
124128
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
125129
case tea.KeyPressMsg:
126-
switch msg.String() {
127-
case "enter":
128-
if s.deleteConfirmation >= 0 {
129-
s.deleteConfirmation = -1
130+
if s.renameMode {
131+
switch msg.String() {
132+
case "enter":
133+
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) && idx == s.renameIndex {
134+
newTitle := s.renameInput.Value()
135+
if strings.TrimSpace(newTitle) != "" {
136+
sessionToUpdate := s.sessions[idx]
137+
return s, tea.Sequence(
138+
func() tea.Msg {
139+
ctx := context.Background()
140+
err := s.app.UpdateSession(ctx, sessionToUpdate.ID, newTitle)
141+
if err != nil {
142+
return toast.NewErrorToast("Failed to rename session: " + err.Error())()
143+
}
144+
s.sessions[idx].Title = newTitle
145+
s.renameMode = false
146+
s.modal.SetTitle("Switch Session")
147+
s.updateListItems()
148+
return toast.NewSuccessToast("Session renamed successfully")()
149+
},
150+
)
151+
}
152+
}
153+
s.renameMode = false
154+
s.modal.SetTitle("Switch Session")
130155
s.updateListItems()
131156
return s, nil
157+
default:
158+
var cmd tea.Cmd
159+
s.renameInput, cmd = s.renameInput.Update(msg)
160+
return s, cmd
132161
}
133-
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
134-
selectedSession := s.sessions[idx]
162+
} else {
163+
switch msg.String() {
164+
case "enter":
165+
if s.deleteConfirmation >= 0 {
166+
s.deleteConfirmation = -1
167+
s.updateListItems()
168+
return s, nil
169+
}
170+
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
171+
selectedSession := s.sessions[idx]
172+
return s, tea.Sequence(
173+
util.CmdHandler(modal.CloseModalMsg{}),
174+
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
175+
)
176+
}
177+
case "n":
135178
return s, tea.Sequence(
136179
util.CmdHandler(modal.CloseModalMsg{}),
137-
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
180+
util.CmdHandler(app.SessionClearedMsg{}),
138181
)
139-
}
140-
case "n":
141-
return s, tea.Sequence(
142-
util.CmdHandler(modal.CloseModalMsg{}),
143-
util.CmdHandler(app.SessionClearedMsg{}),
144-
)
145-
case "x", "delete", "backspace":
146-
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
147-
if s.deleteConfirmation == idx {
148-
// Second press - actually delete the session
149-
sessionToDelete := s.sessions[idx]
150-
return s, tea.Sequence(
151-
func() tea.Msg {
152-
s.sessions = slices.Delete(s.sessions, idx, idx+1)
153-
s.deleteConfirmation = -1
154-
s.updateListItems()
155-
return nil
156-
},
157-
s.deleteSession(sessionToDelete.ID),
158-
)
159-
} else {
160-
// First press - enter delete confirmation mode
161-
s.deleteConfirmation = idx
182+
case "r":
183+
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
184+
s.renameMode = true
185+
s.renameIndex = idx
186+
s.setupRenameInput(s.sessions[idx].Title)
187+
s.modal.SetTitle("Rename Session")
188+
s.updateListItems()
189+
return s, textinput.Blink
190+
}
191+
case "x", "delete", "backspace":
192+
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
193+
if s.deleteConfirmation == idx {
194+
// Second press - actually delete the session
195+
sessionToDelete := s.sessions[idx]
196+
return s, tea.Sequence(
197+
func() tea.Msg {
198+
s.sessions = slices.Delete(s.sessions, idx, idx+1)
199+
s.deleteConfirmation = -1
200+
s.updateListItems()
201+
return nil
202+
},
203+
s.deleteSession(sessionToDelete.ID),
204+
)
205+
} else {
206+
// First press - enter delete confirmation mode
207+
s.deleteConfirmation = idx
208+
s.updateListItems()
209+
return s, nil
210+
}
211+
}
212+
case "esc":
213+
if s.deleteConfirmation >= 0 {
214+
s.deleteConfirmation = -1
162215
s.updateListItems()
163216
return s, nil
164217
}
165218
}
166-
case "esc":
167-
if s.deleteConfirmation >= 0 {
168-
s.deleteConfirmation = -1
169-
s.updateListItems()
170-
return s, nil
171-
}
172219
}
173220
}
174221

175-
var cmd tea.Cmd
176-
listModel, cmd := s.list.Update(msg)
177-
s.list = listModel.(list.List[sessionItem])
178-
return s, cmd
222+
if !s.renameMode {
223+
var cmd tea.Cmd
224+
listModel, cmd := s.list.Update(msg)
225+
s.list = listModel.(list.List[sessionItem])
226+
return s, cmd
227+
}
228+
return s, nil
179229
}
180230

181231
func (s *sessionDialog) Render(background string) string {
232+
if s.renameMode {
233+
// Show rename input instead of list
234+
t := theme.CurrentTheme()
235+
renameView := s.renameInput.View()
236+
237+
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
238+
helpText := mutedStyle("Enter to confirm, Esc to cancel")
239+
helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
240+
241+
content := strings.Join([]string{renameView, helpText}, "\n")
242+
return s.modal.Render(content, background)
243+
}
244+
182245
listView := s.list.View()
183246

184247
t := theme.CurrentTheme()
185248
keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
186249
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
187250

188-
leftHelp := keyStyle("n") + mutedStyle(" new session")
251+
leftHelp := keyStyle("n") + mutedStyle(" new session") + " " + keyStyle("r") + mutedStyle(" rename")
189252
rightHelp := keyStyle("x/del") + mutedStyle(" delete session")
190253

191254
bgColor := t.BackgroundPanel()
@@ -203,6 +266,39 @@ func (s *sessionDialog) Render(background string) string {
203266
return s.modal.Render(content, background)
204267
}
205268

269+
func (s *sessionDialog) setupRenameInput(currentTitle string) {
270+
t := theme.CurrentTheme()
271+
bgColor := t.BackgroundPanel()
272+
textColor := t.Text()
273+
textMutedColor := t.TextMuted()
274+
275+
s.renameInput = textinput.New()
276+
s.renameInput.SetValue(currentTitle)
277+
s.renameInput.Focus()
278+
s.renameInput.CharLimit = 100
279+
s.renameInput.SetWidth(layout.Current.Container.Width - 20)
280+
281+
s.renameInput.Styles.Blurred.Placeholder = styles.NewStyle().
282+
Foreground(textMutedColor).
283+
Background(bgColor).
284+
Lipgloss()
285+
s.renameInput.Styles.Blurred.Text = styles.NewStyle().
286+
Foreground(textColor).
287+
Background(bgColor).
288+
Lipgloss()
289+
s.renameInput.Styles.Focused.Placeholder = styles.NewStyle().
290+
Foreground(textMutedColor).
291+
Background(bgColor).
292+
Lipgloss()
293+
s.renameInput.Styles.Focused.Text = styles.NewStyle().
294+
Foreground(textColor).
295+
Background(bgColor).
296+
Lipgloss()
297+
s.renameInput.Styles.Focused.Prompt = styles.NewStyle().
298+
Background(bgColor).
299+
Lipgloss()
300+
}
301+
206302
func (s *sessionDialog) updateListItems() {
207303
_, currentIdx := s.list.GetSelectedItem()
208304

@@ -229,7 +325,22 @@ func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
229325
}
230326
}
231327

328+
// ReopenSessionModalMsg is emitted when the session modal should be reopened
329+
type ReopenSessionModalMsg struct{}
330+
232331
func (s *sessionDialog) Close() tea.Cmd {
332+
if s.renameMode {
333+
// If in rename mode, exit rename mode and return a command to reopen the modal
334+
s.renameMode = false
335+
s.modal.SetTitle("Switch Session")
336+
s.updateListItems()
337+
338+
// Return a command that will reopen the session modal
339+
return func() tea.Msg {
340+
return ReopenSessionModalMsg{}
341+
}
342+
}
343+
// Normal close behavior
233344
return nil
234345
}
235346

@@ -272,6 +383,8 @@ func NewSessionDialog(app *app.App) SessionDialog {
272383
list: listComponent,
273384
app: app,
274385
deleteConfirmation: -1,
386+
renameMode: false,
387+
renameIndex: -1,
275388
modal: modal.New(
276389
modal.WithTitle("Switch Session"),
277390
modal.WithMaxWidth(layout.Current.Container.Width-8),

packages/tui/internal/tui/tui.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,11 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
357357
}
358358
a.modal = nil
359359
return a, cmd
360+
case dialog.ReopenSessionModalMsg:
361+
// Reopen the session modal (used when exiting rename mode)
362+
sessionDialog := dialog.NewSessionDialog(a.app)
363+
a.modal = sessionDialog
364+
return a, nil
360365
case commands.ExecuteCommandMsg:
361366
updated, cmd := a.executeCommand(commands.Command(msg))
362367
return updated, cmd

0 commit comments

Comments
 (0)