11
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user