mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-20 17:24:22 +01:00
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:
committed by
GitHub
parent
81583cddbd
commit
47c327641b
@@ -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({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,6 +127,39 @@ 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:
|
||||||
|
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
|
||||||
|
}
|
||||||
|
} else {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "enter":
|
case "enter":
|
||||||
if s.deleteConfirmation >= 0 {
|
if s.deleteConfirmation >= 0 {
|
||||||
@@ -142,6 +179,15 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
util.CmdHandler(modal.CloseModalMsg{}),
|
util.CmdHandler(modal.CloseModalMsg{}),
|
||||||
util.CmdHandler(app.SessionClearedMsg{}),
|
util.CmdHandler(app.SessionClearedMsg{}),
|
||||||
)
|
)
|
||||||
|
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":
|
case "x", "delete", "backspace":
|
||||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
||||||
if s.deleteConfirmation == idx {
|
if s.deleteConfirmation == idx {
|
||||||
@@ -171,21 +217,38 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.renameMode {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
listModel, cmd := s.list.Update(msg)
|
listModel, cmd := s.list.Update(msg)
|
||||||
s.list = listModel.(list.List[sessionItem])
|
s.list = listModel.(list.List[sessionItem])
|
||||||
return s, cmd
|
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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user