diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 60433248..79be29d2 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -296,6 +296,47 @@ export namespace Server { return c.json(true) }, ) + .patch( + "/session/:id", + describeRoute({ + description: "Update session properties", + operationId: "session.update", + responses: { + 200: { + description: "Successfully updated session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + }, + }), + zValidator( + "param", + z.object({ + id: z.string(), + }), + ), + zValidator( + "json", + z.object({ + title: z.string().optional(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").id + const updates = c.req.valid("json") + + const updatedSession = await Session.update(sessionID, (session) => { + if (updates.title !== undefined) { + session.title = updates.title + } + }) + + return c.json(updatedSession) + }, + ) .post( "/session/:id/init", describeRoute({ diff --git a/packages/sdk/go/session.go b/packages/sdk/go/session.go index bb5cecf5..f377b2e4 100644 --- a/packages/sdk/go/session.go +++ b/packages/sdk/go/session.go @@ -66,6 +66,18 @@ func (r *SessionService) Delete(ctx context.Context, id string, opts ...option.R return } +// Update session properties +func (r *SessionService) Update(ctx context.Context, id string, body SessionUpdateParams, opts ...option.RequestOption) (res *Session, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("session/%s", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPatch, path, body, &res, opts...) + return +} + // Abort a session func (r *SessionService) Abort(ctx context.Context, id string, opts ...option.RequestOption) (res *bool, err error) { opts = append(r.Options[:], opts...) @@ -2356,3 +2368,11 @@ type SessionSummarizeParams struct { func (r SessionSummarizeParams) MarshalJSON() (data []byte, err error) { return apijson.MarshalRoot(r) } + +type SessionUpdateParams struct { + Title param.Field[string] `json:"title"` +} + +func (r SessionUpdateParams) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 34572648..48b7d67f 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -760,6 +760,17 @@ func (a *App) DeleteSession(ctx context.Context, sessionID string) error { return nil } +func (a *App) UpdateSession(ctx context.Context, sessionID string, title string) error { + _, err := a.Client.Session.Update(ctx, sessionID, opencode.SessionUpdateParams{ + Title: opencode.F(title), + }) + if err != nil { + slog.Error("Failed to update session", "error", err) + return err + } + return nil +} + func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) { response, err := a.Client.Session.Messages(ctx, sessionId) if err != nil { diff --git a/packages/tui/internal/components/dialog/session.go b/packages/tui/internal/components/dialog/session.go index daf7a142..08ff22a2 100644 --- a/packages/tui/internal/components/dialog/session.go +++ b/packages/tui/internal/components/dialog/session.go @@ -6,6 +6,7 @@ import ( "slices" + "github.com/charmbracelet/bubbles/v2/textinput" tea "github.com/charmbracelet/bubbletea/v2" "github.com/muesli/reflow/truncate" "github.com/sst/opencode-sdk-go" @@ -110,6 +111,9 @@ type sessionDialog struct { list list.List[sessionItem] app *app.App deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index + renameMode bool + renameInput textinput.Model + renameIndex int // index of session being renamed } func (s *sessionDialog) Init() tea.Cmd { @@ -123,69 +127,128 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.height = msg.Height s.list.SetMaxWidth(layout.Current.Container.Width - 12) case tea.KeyPressMsg: - switch msg.String() { - case "enter": - if s.deleteConfirmation >= 0 { - s.deleteConfirmation = -1 + if s.renameMode { + switch msg.String() { + case "enter": + if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) && idx == s.renameIndex { + newTitle := s.renameInput.Value() + if strings.TrimSpace(newTitle) != "" { + sessionToUpdate := s.sessions[idx] + return s, tea.Sequence( + func() tea.Msg { + ctx := context.Background() + err := s.app.UpdateSession(ctx, sessionToUpdate.ID, newTitle) + if err != nil { + return toast.NewErrorToast("Failed to rename session: " + err.Error())() + } + s.sessions[idx].Title = newTitle + s.renameMode = false + s.modal.SetTitle("Switch Session") + s.updateListItems() + return toast.NewSuccessToast("Session renamed successfully")() + }, + ) + } + } + s.renameMode = false + s.modal.SetTitle("Switch Session") s.updateListItems() return s, nil + default: + var cmd tea.Cmd + s.renameInput, cmd = s.renameInput.Update(msg) + return s, cmd } - if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) { - selectedSession := s.sessions[idx] + } else { + switch msg.String() { + case "enter": + if s.deleteConfirmation >= 0 { + s.deleteConfirmation = -1 + s.updateListItems() + return s, nil + } + if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) { + selectedSession := s.sessions[idx] + return s, tea.Sequence( + util.CmdHandler(modal.CloseModalMsg{}), + util.CmdHandler(app.SessionSelectedMsg(&selectedSession)), + ) + } + case "n": return s, tea.Sequence( util.CmdHandler(modal.CloseModalMsg{}), - util.CmdHandler(app.SessionSelectedMsg(&selectedSession)), + util.CmdHandler(app.SessionClearedMsg{}), ) - } - case "n": - return s, tea.Sequence( - util.CmdHandler(modal.CloseModalMsg{}), - util.CmdHandler(app.SessionClearedMsg{}), - ) - case "x", "delete", "backspace": - if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) { - if s.deleteConfirmation == idx { - // Second press - actually delete the session - sessionToDelete := s.sessions[idx] - return s, tea.Sequence( - func() tea.Msg { - s.sessions = slices.Delete(s.sessions, idx, idx+1) - s.deleteConfirmation = -1 - s.updateListItems() - return nil - }, - s.deleteSession(sessionToDelete.ID), - ) - } else { - // First press - enter delete confirmation mode - s.deleteConfirmation = idx + case "r": + if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) { + s.renameMode = true + s.renameIndex = idx + s.setupRenameInput(s.sessions[idx].Title) + s.modal.SetTitle("Rename Session") + s.updateListItems() + return s, textinput.Blink + } + case "x", "delete", "backspace": + if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) { + if s.deleteConfirmation == idx { + // Second press - actually delete the session + sessionToDelete := s.sessions[idx] + return s, tea.Sequence( + func() tea.Msg { + s.sessions = slices.Delete(s.sessions, idx, idx+1) + s.deleteConfirmation = -1 + s.updateListItems() + return nil + }, + s.deleteSession(sessionToDelete.ID), + ) + } else { + // First press - enter delete confirmation mode + s.deleteConfirmation = idx + s.updateListItems() + return s, nil + } + } + case "esc": + if s.deleteConfirmation >= 0 { + s.deleteConfirmation = -1 s.updateListItems() return s, nil } } - case "esc": - if s.deleteConfirmation >= 0 { - s.deleteConfirmation = -1 - s.updateListItems() - return s, nil - } } } - var cmd tea.Cmd - listModel, cmd := s.list.Update(msg) - s.list = listModel.(list.List[sessionItem]) - return s, cmd + if !s.renameMode { + var cmd tea.Cmd + listModel, cmd := s.list.Update(msg) + s.list = listModel.(list.List[sessionItem]) + return s, cmd + } + return s, nil } func (s *sessionDialog) Render(background string) string { + if s.renameMode { + // Show rename input instead of list + t := theme.CurrentTheme() + renameView := s.renameInput.View() + + mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render + helpText := mutedStyle("Enter to confirm, Esc to cancel") + helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText) + + content := strings.Join([]string{renameView, helpText}, "\n") + return s.modal.Render(content, background) + } + listView := s.list.View() t := theme.CurrentTheme() keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render - leftHelp := keyStyle("n") + mutedStyle(" new session") + leftHelp := keyStyle("n") + mutedStyle(" new session") + " " + keyStyle("r") + mutedStyle(" rename") rightHelp := keyStyle("x/del") + mutedStyle(" delete session") bgColor := t.BackgroundPanel() @@ -203,6 +266,39 @@ func (s *sessionDialog) Render(background string) string { return s.modal.Render(content, background) } +func (s *sessionDialog) setupRenameInput(currentTitle string) { + t := theme.CurrentTheme() + bgColor := t.BackgroundPanel() + textColor := t.Text() + textMutedColor := t.TextMuted() + + s.renameInput = textinput.New() + s.renameInput.SetValue(currentTitle) + s.renameInput.Focus() + s.renameInput.CharLimit = 100 + s.renameInput.SetWidth(layout.Current.Container.Width - 20) + + s.renameInput.Styles.Blurred.Placeholder = styles.NewStyle(). + Foreground(textMutedColor). + Background(bgColor). + Lipgloss() + s.renameInput.Styles.Blurred.Text = styles.NewStyle(). + Foreground(textColor). + Background(bgColor). + Lipgloss() + s.renameInput.Styles.Focused.Placeholder = styles.NewStyle(). + Foreground(textMutedColor). + Background(bgColor). + Lipgloss() + s.renameInput.Styles.Focused.Text = styles.NewStyle(). + Foreground(textColor). + Background(bgColor). + Lipgloss() + s.renameInput.Styles.Focused.Prompt = styles.NewStyle(). + Background(bgColor). + Lipgloss() +} + func (s *sessionDialog) updateListItems() { _, currentIdx := s.list.GetSelectedItem() @@ -229,7 +325,22 @@ func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd { } } +// ReopenSessionModalMsg is emitted when the session modal should be reopened +type ReopenSessionModalMsg struct{} + func (s *sessionDialog) Close() tea.Cmd { + if s.renameMode { + // If in rename mode, exit rename mode and return a command to reopen the modal + s.renameMode = false + s.modal.SetTitle("Switch Session") + s.updateListItems() + + // Return a command that will reopen the session modal + return func() tea.Msg { + return ReopenSessionModalMsg{} + } + } + // Normal close behavior return nil } @@ -272,6 +383,8 @@ func NewSessionDialog(app *app.App) SessionDialog { list: listComponent, app: app, deleteConfirmation: -1, + renameMode: false, + renameIndex: -1, modal: modal.New( modal.WithTitle("Switch Session"), modal.WithMaxWidth(layout.Current.Container.Width-8), diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 6b91cbfe..0262f507 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -357,6 +357,11 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.modal = nil return a, cmd + case dialog.ReopenSessionModalMsg: + // Reopen the session modal (used when exiting rename mode) + sessionDialog := dialog.NewSessionDialog(a.app) + a.modal = sessionDialog + return a, nil case commands.ExecuteCommandMsg: updated, cmd := a.executeCommand(commands.Command(msg)) return updated, cmd