feat: add session rename functionality to TUI modal (#1821)

Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
This commit is contained in:
spoons-and-mirrors
2025-08-12 22:22:03 +02:00
committed by GitHub
parent 81583cddbd
commit 47c327641b
5 changed files with 231 additions and 41 deletions

View File

@@ -296,6 +296,47 @@ export namespace Server {
return c.json(true) 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( .post(
"/session/:id/init", "/session/:id/init",
describeRoute({ describeRoute({

View File

@@ -66,6 +66,18 @@ func (r *SessionService) Delete(ctx context.Context, id string, opts ...option.R
return 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 // Abort a session
func (r *SessionService) Abort(ctx context.Context, id string, opts ...option.RequestOption) (res *bool, err error) { func (r *SessionService) Abort(ctx context.Context, id string, opts ...option.RequestOption) (res *bool, err error) {
opts = append(r.Options[:], opts...) opts = append(r.Options[:], opts...)
@@ -2356,3 +2368,11 @@ type SessionSummarizeParams struct {
func (r SessionSummarizeParams) MarshalJSON() (data []byte, err error) { func (r SessionSummarizeParams) MarshalJSON() (data []byte, err error) {
return apijson.MarshalRoot(r) 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)
}

View File

@@ -760,6 +760,17 @@ func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
return nil 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) { func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
response, err := a.Client.Session.Messages(ctx, sessionId) response, err := a.Client.Session.Messages(ctx, sessionId)
if err != nil { if err != nil {

View File

@@ -6,6 +6,7 @@ import (
"slices" "slices"
"github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/muesli/reflow/truncate" "github.com/muesli/reflow/truncate"
"github.com/sst/opencode-sdk-go" "github.com/sst/opencode-sdk-go"
@@ -110,6 +111,9 @@ type sessionDialog struct {
list list.List[sessionItem] list list.List[sessionItem]
app *app.App app *app.App
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index 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 { 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.height = msg.Height
s.list.SetMaxWidth(layout.Current.Container.Width - 12) s.list.SetMaxWidth(layout.Current.Container.Width - 12)
case tea.KeyPressMsg: case tea.KeyPressMsg:
switch msg.String() { if s.renameMode {
case "enter": switch msg.String() {
if s.deleteConfirmation >= 0 { case "enter":
s.deleteConfirmation = -1 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() s.updateListItems()
return s, nil 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) { } else {
selectedSession := s.sessions[idx] 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( return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}), util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)), util.CmdHandler(app.SessionClearedMsg{}),
) )
} case "r":
case "n": if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
return s, tea.Sequence( s.renameMode = true
util.CmdHandler(modal.CloseModalMsg{}), s.renameIndex = idx
util.CmdHandler(app.SessionClearedMsg{}), s.setupRenameInput(s.sessions[idx].Title)
) s.modal.SetTitle("Rename Session")
case "x", "delete", "backspace": s.updateListItems()
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) { return s, textinput.Blink
if s.deleteConfirmation == idx { }
// Second press - actually delete the session case "x", "delete", "backspace":
sessionToDelete := s.sessions[idx] if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
return s, tea.Sequence( if s.deleteConfirmation == idx {
func() tea.Msg { // Second press - actually delete the session
s.sessions = slices.Delete(s.sessions, idx, idx+1) sessionToDelete := s.sessions[idx]
s.deleteConfirmation = -1 return s, tea.Sequence(
s.updateListItems() func() tea.Msg {
return nil s.sessions = slices.Delete(s.sessions, idx, idx+1)
}, s.deleteConfirmation = -1
s.deleteSession(sessionToDelete.ID), s.updateListItems()
) return nil
} else { },
// First press - enter delete confirmation mode s.deleteSession(sessionToDelete.ID),
s.deleteConfirmation = idx )
} 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() s.updateListItems()
return s, nil return s, nil
} }
} }
case "esc":
if s.deleteConfirmation >= 0 {
s.deleteConfirmation = -1
s.updateListItems()
return s, nil
}
} }
} }
var cmd tea.Cmd if !s.renameMode {
listModel, cmd := s.list.Update(msg) var cmd tea.Cmd
s.list = listModel.(list.List[sessionItem]) listModel, cmd := s.list.Update(msg)
return s, cmd s.list = listModel.(list.List[sessionItem])
return s, cmd
}
return s, nil
} }
func (s *sessionDialog) Render(background string) string { 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() listView := s.list.View()
t := theme.CurrentTheme() t := theme.CurrentTheme()
keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).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") rightHelp := keyStyle("x/del") + mutedStyle(" delete session")
bgColor := t.BackgroundPanel() bgColor := t.BackgroundPanel()
@@ -203,6 +266,39 @@ func (s *sessionDialog) Render(background string) string {
return s.modal.Render(content, background) 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() { func (s *sessionDialog) updateListItems() {
_, currentIdx := s.list.GetSelectedItem() _, 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 { 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 return nil
} }
@@ -272,6 +383,8 @@ func NewSessionDialog(app *app.App) SessionDialog {
list: listComponent, list: listComponent,
app: app, app: app,
deleteConfirmation: -1, deleteConfirmation: -1,
renameMode: false,
renameIndex: -1,
modal: modal.New( modal: modal.New(
modal.WithTitle("Switch Session"), modal.WithTitle("Switch Session"),
modal.WithMaxWidth(layout.Current.Container.Width-8), modal.WithMaxWidth(layout.Current.Container.Width-8),

View File

@@ -357,6 +357,11 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
a.modal = nil a.modal = nil
return a, cmd 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: case commands.ExecuteCommandMsg:
updated, cmd := a.executeCommand(commands.Command(msg)) updated, cmd := a.executeCommand(commands.Command(msg))
return updated, cmd return updated, cmd