288 lines
9.2 KiB
Go
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()
|
|
}
|