Files
superwork/tui/internal/tui/managed_sessions_panel.go
T
2026-06-23 05:02:15 +08:00

288 lines
9.2 KiB
Go

package tui
import (
"context"
"fmt"
"os"
"strings"
"time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"superwork-tui/internal/cc"
"superwork-tui/internal/config"
"superwork-tui/internal/logging"
"superwork-tui/internal/store"
)
type managedSessionsMode int
const (
sessionsModeCreate managedSessionsMode = iota
sessionsModeRename
)
// ── message types ─────────────────────────────────────────────────────────────
type managedSessionsLoadedMsg struct {
data store.ManagedSessionsData
err error
}
type managedSessionsSavedMsg struct {
err error
}
type managedSessionCapturedMsg struct {
name string
profilePath string
createdAt int64
sessionID string
}
var watchForNewSessionFn = func(ctx context.Context, opts cc.SessionWatchOpts) (string, error) {
return cc.WatchForNewSession(ctx, opts)
}
// ── commands ──────────────────────────────────────────────────────────────────
func loadManagedSessionsCmd() tea.Cmd {
return func() tea.Msg {
root, err := workspaceRootFn()
if err != nil {
return managedSessionsLoadedMsg{err: err}
}
data, err := store.ReadManagedSessions(root)
return managedSessionsLoadedMsg{data: data, err: err}
}
}
func saveManagedSessionsCmd(data store.ManagedSessionsData) tea.Cmd {
return func() tea.Msg {
root, err := workspaceRootFn()
if err != nil {
return managedSessionsSavedMsg{err: err}
}
err = store.WriteManagedSessions(root, data)
return managedSessionsSavedMsg{err: err}
}
}
func resumeManagedSessionCmd(sess store.ManagedSession, settings *config.Settings, mgr *SessionManager) tea.Cmd {
return func() tea.Msg {
if sess.ID == "" {
return sessionActionResultMsg{err: fmt.Errorf("会话 %q 尚无 ID,无法 resume", sess.Name)}
}
sysCmd := ""
if settings != nil {
sysCmd = settings.SystemPromptCommand
}
cmd := claudeResumeCmd(sess.ID, sess.ProfilePath, sysCmd)
cwd, _ := workspaceRootFn()
winName := "ms-" + sess.ID[:min(8, len(sess.ID))]
logging.Default.Info("sessions", fmt.Sprintf("resume 会话 %s (%s)", sess.Name, sess.ID))
err := mgr.RunInWindow(context.Background(), winName, cwd, cmd)
return sessionActionResultMsg{err: err}
}
}
func launchAndWatchManagedSessionCmd(name, profilePath string, createdAt int64, settings *config.Settings, mgr *SessionManager) tea.Cmd {
return func() tea.Msg {
cwd, err := workspaceRootFn()
if err != nil {
return managedSessionCapturedMsg{name: name, profilePath: profilePath, createdAt: createdAt}
}
sysCmd := ""
if settings != nil {
sysCmd = settings.SystemPromptCommand
}
cmd := claudeCmd(claudeCmdOpts{
ProfilePath: profilePath,
SystemPromptCommand: sysCmd,
})
winName := "ms-new-" + name
if len(winName) > 20 {
winName = winName[:20]
}
if err := mgr.RunInWindow(context.Background(), winName, cwd, cmd); err != nil {
logging.Default.Warn("sessions", fmt.Sprintf("managed session launch failed: %v", err))
}
home, err := os.UserHomeDir()
if err != nil {
return managedSessionCapturedMsg{name: name, profilePath: profilePath, createdAt: createdAt}
}
projectsDir := cc.ClaudeProjectsDir(home, cwd)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
sid, _ := watchForNewSessionFn(ctx, cc.SessionWatchOpts{
ProjectsDir: projectsDir,
Timeout: 120 * time.Second,
})
if sid == "" {
logging.Default.Warn("sessions", "managed session watch timeout (120s)")
return managedSessionCapturedMsg{name: name, profilePath: profilePath, createdAt: createdAt}
}
logging.Default.Info("sessions", fmt.Sprintf("managed session captured: %s", sid))
return managedSessionCapturedMsg{name: name, profilePath: profilePath, createdAt: createdAt, sessionID: sid}
}
}
// ── open ──────────────────────────────────────────────────────────────────────
func (m Model) openManagedSessions() (Model, tea.Cmd) {
m.state = stateManagedSessions
m.managedSessionsCursor = 0
m.managedSessionsInputActive = false
m.managedSessionsDeleteID = ""
return m, loadManagedSessionsCmd()
}
// ── key handling ──────────────────────────────────────────────────────────────
func (m Model) handleManagedSessionsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.managedSessionsDeleteID != "" {
switch msg.String() {
case "y":
id := m.managedSessionsDeleteID
m.managedSessionsDeleteID = ""
newData := store.DeleteSession(m.managedSessions, id)
m.managedSessions = newData
if m.managedSessionsCursor >= len(m.managedSessions.Sessions) && m.managedSessionsCursor > 0 {
m.managedSessionsCursor--
}
logging.Default.Info("sessions", fmt.Sprintf("已删除会话 %s", id))
return m, saveManagedSessionsCmd(newData)
case "n", "esc":
m.managedSessionsDeleteID = ""
}
return m, nil
}
if m.managedSessionsInputActive {
switch msg.String() {
case "enter":
name := strings.TrimSpace(m.managedSessionsInput.Value())
m.managedSessionsInputActive = false
if name == "" {
return m, nil
}
switch m.managedSessionsInputMode {
case sessionsModeCreate:
logging.Default.Info("sessions", fmt.Sprintf("新建会话 %q", name))
return m, launchAndWatchManagedSessionCmd(name, "", time.Now().Unix(), m.settings, m.sessionMgr)
case sessionsModeRename:
if m.managedSessionsCursor < len(m.managedSessions.Sessions) {
id := m.managedSessions.Sessions[m.managedSessionsCursor].ID
newData := store.RenameSession(m.managedSessions, id, name)
m.managedSessions = newData
logging.Default.Info("sessions", fmt.Sprintf("重命名会话 %s → %q", id, name))
return m, saveManagedSessionsCmd(newData)
}
}
case "esc":
m.managedSessionsInputActive = false
default:
var cmd tea.Cmd
m.managedSessionsInput, cmd = m.managedSessionsInput.Update(msg)
return m, cmd
}
return m, nil
}
switch msg.String() {
case "esc":
m.state = stateLoaded
case "up", "k":
if m.managedSessionsCursor > 0 {
m.managedSessionsCursor--
}
case "down", "j":
if m.managedSessionsCursor < len(m.managedSessions.Sessions)-1 {
m.managedSessionsCursor++
}
case "a":
ti := textinput.New()
ti.Placeholder = "会话名称"
ti.Focus()
m.managedSessionsInput = ti
m.managedSessionsInputMode = sessionsModeCreate
m.managedSessionsInputActive = true
case "r":
if m.managedSessionsCursor < len(m.managedSessions.Sessions) {
cur := m.managedSessions.Sessions[m.managedSessionsCursor]
ti := textinput.New()
ti.SetValue(cur.Name)
ti.Focus()
m.managedSessionsInput = ti
m.managedSessionsInputMode = sessionsModeRename
m.managedSessionsInputActive = true
}
case "d":
if m.managedSessionsCursor < len(m.managedSessions.Sessions) {
m.managedSessionsDeleteID = m.managedSessions.Sessions[m.managedSessionsCursor].ID
}
case "enter":
if m.managedSessionsCursor < len(m.managedSessions.Sessions) {
sess := m.managedSessions.Sessions[m.managedSessionsCursor]
return m, resumeManagedSessionCmd(sess, m.settings, m.sessionMgr)
}
}
return m, nil
}
// ── view ──────────────────────────────────────────────────────────────────────
func (m Model) managedSessionsView() string {
var sb strings.Builder
sb.WriteString(helpStyle.Render("受管理 Claude 会话") + "\n")
sb.WriteString(strings.Repeat("─", 70) + "\n")
if len(m.managedSessions.Sessions) == 0 {
sb.WriteString(" (无会话)\n")
} else {
header := fmt.Sprintf(" %-20s %-20s %-15s %s", "名称", "ID", "ProfilePath", "创建时间")
sb.WriteString(helpStyle.Render(header) + "\n")
for i, s := range m.managedSessions.Sessions {
ts := time.Unix(s.CreatedAt, 0).Format("2006-01-02 15:04")
idShort := s.ID
if len(idShort) > 20 {
idShort = idShort[:20]
}
line := fmt.Sprintf(" %-20s %-20s %-15s %s", s.Name, idShort, s.ProfilePath, ts)
if i == m.managedSessionsCursor {
sb.WriteString(selectedCardStyle.Render(line) + "\n")
} else {
sb.WriteString(normalCardStyle.Render(line) + "\n")
}
}
}
sb.WriteString("\n")
if m.managedSessionsDeleteID != "" {
deleteName := m.managedSessionsDeleteID
for _, s := range m.managedSessions.Sessions {
if s.ID == m.managedSessionsDeleteID {
deleteName = s.Name
break
}
}
sb.WriteString(confirmStyle.Render(fmt.Sprintf("确认删除会话 %q? (y/n)", deleteName)) + "\n")
} else if m.managedSessionsInputActive {
prompt := "新建名称: "
if m.managedSessionsInputMode == sessionsModeRename {
prompt = "新名称: "
}
sb.WriteString(confirmStyle.Render(prompt) + m.managedSessionsInput.View() + "\n")
} else {
sb.WriteString(helpStyle.Render("a new r rename d delete enter resume esc back"))
}
return sb.String()
}