Просмотр исходного кода

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

Co-authored-by: opencode <[email protected]>
Co-authored-by: Dax Raad <[email protected]>
Co-authored-by: Dax <[email protected]>
spoons-and-mirrors 6 месяцев назад
Родитель
Сommit
47c327641b

+ 41 - 0
packages/opencode/src/server/server.ts

@@ -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({

+ 20 - 0
packages/sdk/go/session.go

@@ -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)
+}

+ 11 - 0
packages/tui/internal/app/app.go

@@ -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 {

+ 154 - 41
packages/tui/internal/components/dialog/session.go

@@ -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() {
-		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()
 				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) {
-				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(
 				return s, tea.Sequence(
 					util.CmdHandler(modal.CloseModalMsg{}),
 					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()
 					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
-	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 {
 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),

+ 5 - 0
packages/tui/internal/tui/tui.go

@@ -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