summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/server/server.ts41
-rw-r--r--packages/sdk/go/session.go20
-rw-r--r--packages/tui/internal/app/app.go11
-rw-r--r--packages/tui/internal/components/dialog/session.go195
-rw-r--r--packages/tui/internal/tui/tui.go5
5 files changed, 231 insertions, 41 deletions
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 60433248c..79be29d28 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 bb5cecf57..f377b2e4e 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 345726483..48b7d67f9 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 daf7a142b..08ff22a22 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 6b91cbfeb..0262f5077 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