diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc40a4..e557347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added +- patterm can now keep multiple local projects loaded in one loopback + daemon core, with command-palette entries to switch the current + client view or open another project without tearing down processes + in the previous project. +- The status line now shows the current project name when multiple + projects are loaded, and the MCP startup greeting includes + `project_key` for diagnostics and future daemon routing. - MCP clients can now call `scratchpad_delete` with a scratchpad name to remove a shared project scratchpad. diff --git a/internal/app/app.go b/internal/app/app.go index 3b6757a..052bfe8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "os/signal" + "path/filepath" "strings" "sync" "sync/atomic" @@ -18,7 +19,6 @@ import ( "golang.org/x/term" "github.com/hjbdev/patterm/internal/mcp" - "github.com/hjbdev/patterm/internal/persist" "github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/scratchpad" "github.com/hjbdev/patterm/internal/trust" @@ -60,27 +60,6 @@ func Run(ctx context.Context, opts Options) error { logf("settings load: %v", err) } - // Ensure the per-project scratchpad dir exists so MCP and the UI - // can read/write into it. SPEC §3. - pads, err := scratchpad.Open(opts.ProjectKey) - if err != nil { - return fmt.Errorf("app: scratchpad init: %w", err) - } - - // Per-project trust store for command-preset trust gating (SPEC §7). - trustStore, err := trust.Open(opts.ProjectKey) - if err != nil { - return fmt.Errorf("app: trust init: %w", err) - } - - // Per-project persisted-process store. Survives across patterm - // restarts so user-created top-level command processes come back - // after a relaunch. - persistStore, err := persist.Open(opts.ProjectKey) - if err != nil { - return fmt.Errorf("app: persist init: %w", err) - } - // In-process MCP server bound to the per-PID socket. Children that // support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`. // SPEC §10. @@ -90,48 +69,10 @@ func Run(ctx context.Context, opts Options) error { } defer mcpSrv.Close() - sess := NewSession(opts.ProjectDir, opts.ProjectKey) - defer sess.Shutdown() - - // Debug capture: when --debug= is set, write a verbose log - // (patterm.log), per-child raw PTY output (.raw), and a - // JSONL event stream (events.jsonl). Installed before the TUI - // listener so the very first OnChildSpawned / OnPTYOut event - // is captured. - if opts.DebugDir != "" { - dc, err := openDebugCapture(opts.DebugDir) - if err != nil { - return fmt.Errorf("app: debug capture: %w", err) - } - os.Setenv("PATTERM_DEBUG_LOG", dc.LogPath()) - sess.Subscribe(dc) - defer dc.Close() - logf("debug capture enabled at %s", opts.DebugDir) - } - // Snapshot persisted processes BEFORE attaching the store: Spawn - // mints fresh ids, so the old records would otherwise linger - // alongside the new ones. Drop them up front; the restore loop - // below re-saves each entry under its new id. - savedProcesses := persistStore.List() - for _, e := range savedProcesses { - _ = persistStore.Remove(e.ID) - } - sess.SetPersistStore(persistStore) - cols, rows := hostSize() layout := newTerminalLayout(cols, rows) - // Launcher handles preset → child translation, including MCP - // config injection for agent presets. - launcher := NewLauncher(sess, mcpSrv.Socket(), layout.childCols(), layout.childRows()) - - // Wire the tool host into MCP. Spawns through MCP use the host - // terminal's viewport grid for their initial PTY size; SIGWINCH paths - // resize them later. - host := newToolHost(sess, pads, launcher, presets, trustStore, layout.childCols(), layout.childRows()) - mcpSrv.SetHost(host) - var restoreState *term.State if term.IsTerminal(int(os.Stdin.Fd())) { st, err := term.MakeRaw(int(os.Stdin.Fd())) @@ -156,41 +97,43 @@ func Run(ctx context.Context, opts Options) error { defer metrics.close() } - // Per-session idle-detection classifier. One goroutine ticks every - // 250ms over every live child and updates IdleState. It stops when - // ctx is cancelled. - go sess.runClassifier(ctx) - - core := &headlessCore{ - projectDir: opts.ProjectDir, - projectKey: opts.ProjectKey, - presets: presets, - settings: appSettings, - pads: pads, - trustStore: trustStore, - persistStore: persistStore, - mcpSrv: mcpSrv, - sess: sess, - launcher: launcher, - host: host, + registry := newProjectRegistry(presets, appSettings, mcpSrv, layout.childCols(), layout.childRows()) + project, err := registry.Open(ctx, opts.ProjectDir) + if err != nil { + return err + } + defer registry.Shutdown() + mcpSrv.SetHost(registry) + + if opts.DebugDir != "" { + dc, err := openDebugCapture(opts.DebugDir) + if err != nil { + return fmt.Errorf("app: debug capture: %w", err) + } + os.Setenv("PATTERM_DEBUG_LOG", dc.LogPath()) + project.Session.Subscribe(dc) + defer dc.Close() + logf("debug capture enabled at %s", opts.DebugDir) } - _ = core st := &uiState{ - sess: sess, + registry: registry, + project: project, + sess: project.Session, presets: presets, - launcher: launcher, - pads: pads, + launcher: project.Launcher, + pads: project.Pads, chromeWake: make(chan struct{}, 1), - trust: trustStore, - timers: host.timers, + trust: project.Trust, + timers: project.Host.timers, hostCols: cols, hostRows: rows, view: ClientView{ - ID: "loopback", - ProjectKey: opts.ProjectKey, - Cols: cols, - Rows: rows, + ID: "loopback", + ProjectKey: project.Key, + ProjectName: project.Name, + Cols: cols, + Rows: rows, }, stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), metrics: metrics, @@ -198,7 +141,7 @@ func Run(ctx context.Context, opts Options) error { settingsPath: settingsPath, ctx: ctx, } - st.summaries = newSummaryManager(sess, opts.ProjectDir, presets, func() autoSummarySettings { + st.summaries = newSummaryManager(project.Session, project.Dir, presets, func() autoSummarySettings { st.settingsMu.Lock() defer st.settingsMu.Unlock() return st.settings.AutoSummary.clone() @@ -210,13 +153,10 @@ func Run(ctx context.Context, opts Options) error { st.flashError(fmt.Sprintf("summary: %v", result.Error)) } }) - sess.SetMetrics(metrics) - host.attention = st - host.focus = st - host.prompter = st - host.scratch = st + project.Session.SetMetrics(metrics) + st.attachProjectSinks(project) st.lastExit.Store(-1) - sess.Subscribe(st) + project.Session.Subscribe(st) go st.summaries.run(ctx) st.enterScreen() @@ -227,15 +167,13 @@ func Run(ctx context.Context, opts Options) error { // Set initial PTY grid for any future child. The child gets the // computed main viewport, excluding tab bar, sidebar, and status. - sess.ResizeAll(layout.childCols(), layout.childRows()) - launcher.SetSize(layout.childCols(), layout.childRows()) - host.SetSize(layout.childCols(), layout.childRows()) + registry.ResizeAll(layout.childCols(), layout.childRows()) // Replay persisted top-level command processes. Failures are // logged and skipped so a stale entry (preset deleted, binary // missing) doesn't block startup. - for _, e := range savedProcesses { - c, err := launcher.RestoreCommand(e, presets) + for _, e := range project.savedProcess { + c, err := project.Launcher.RestoreCommand(e, presets) if err != nil { st.dbgf("restore process %s (%s): %v", e.Name, e.ID, err) continue @@ -281,9 +219,7 @@ func Run(ctx context.Context, opts Options) error { st.renderer.SetLayout(l) } st.mu.Unlock() - sess.ResizeAll(l.childCols(), l.childRows()) - launcher.SetSize(l.childCols(), l.childRows()) - host.SetSize(l.childCols(), l.childRows()) + registry.ResizeAll(l.childCols(), l.childRows()) st.clearScreen() st.drawTabBar() st.drawSidebar() @@ -420,6 +356,8 @@ func Run(ctx context.Context, opts Options) error { // uiState is the shared state between the SIGWINCH loop, the stdin // loop, and the session listener callbacks. type uiState struct { + registry *ProjectRegistry + project *Project sess *Session presets preset.Set launcher *Launcher @@ -532,6 +470,97 @@ type uiState struct { lastExit atomic.Int32 } +func (st *uiState) attachProjectSinks(p *Project) { + p.Host.attention = st + p.Host.focus = st + p.Host.prompter = st + p.Host.scratch = st +} + +func (st *uiState) detachProjectSinks(p *Project) { + if p == nil || p.Host == nil { + return + } + if p.Host.attention == st { + p.Host.attention = nil + } + if p.Host.focus == st { + p.Host.focus = nil + } + if p.Host.prompter == st { + p.Host.prompter = nil + } + if p.Host.scratch == st { + p.Host.scratch = nil + } +} + +func (st *uiState) switchProject(p *Project) { + if p == nil || p.Session == nil { + return + } + oldProject := st.project + old := st.sess + if old != nil && old != p.Session { + old.Unsubscribe(st) + st.detachProjectSinks(oldProject) + } + st.attachProjectSinks(p) + p.Session.SetMetrics(st.metrics) + if old != p.Session { + p.Session.Subscribe(st) + } + layout := st.layoutSnapshot() + p.Session.ResizeAll(layout.childCols(), layout.childRows()) + p.Launcher.SetSize(layout.childCols(), layout.childRows()) + p.Host.SetSize(layout.childCols(), layout.childRows()) + + children := p.Session.Children() + next := firstRunningTopLevel(children) + active := firstRunningAgentID(children) + + st.mu.Lock() + st.project = p + st.sess = p.Session + st.launcher = p.Launcher + st.pads = p.Pads + st.trust = p.Trust + st.timers = p.Host.timers + st.view.ProjectKey = p.Key + st.view.ProjectName = p.Name + st.view.FocusedID = "" + st.view.FocusedPad = "" + st.view.ActiveAgentID = active + st.focusedID = "" + st.focusedPad = "" + st.focusedName = "" + st.activeAgentID = active + st.padOffset = 0 + st.padOffsetName = "" + st.view.PadOffset = 0 + st.view.PadOffsetName = "" + st.renderer = nil + if next != nil { + st.focusChildLocked(next) + st.updateActiveAgentLocked(next) + st.renderer = newViewportRenderer(layout) + } + st.palette = nil + st.mu.Unlock() + + st.invalidateScratchpadsCache() + st.invalidateChromeCache() + st.clearScreen() + if next != nil { + st.repaintFocused() + } else { + st.renderEmptyState() + } + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() +} + func (st *uiState) dbgf(format string, args ...any) { logf(format, args...) } @@ -1300,6 +1329,10 @@ func (st *uiState) drawStatusLine() { palOpen := st.palette != nil focusID := st.focusedID focusName := st.focusedName + projectName := "" + if st.project != nil && st.registry != nil && st.registry.Count() > 1 { + projectName = st.project.Name + } var trustMsg string if st.pendingTrust != nil { trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName) @@ -1328,9 +1361,13 @@ func (st *uiState) drawStatusLine() { owner = "you have control" } } - left := "" + left := projectName if focusName != "" { - left = focusName + if left != "" { + left = left + " · " + focusName + } else { + left = focusName + } } if owner != "" { if left != "" { @@ -2003,6 +2040,20 @@ func (st *uiState) openPaletteLocked() { appSettings := st.settings.clone() st.settingsMu.Unlock() st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets, appSettings) + if st.registry != nil { + projects := st.registry.Summaries(st.view.ProjectKey) + palProjects := make([]paletteProject, 0, len(projects)) + for _, p := range projects { + palProjects = append(palProjects, paletteProject{ + Key: p.Key, + Dir: p.Dir, + Name: p.Name, + TabCount: p.TabCount, + IsCurrent: p.IsCurrent, + }) + } + st.palette.setProjects(st.view.ProjectKey, palProjects) + } // Push a "no kitty flags" entry onto the host terminal's keyboard // stack so palette input arrives in plain legacy form regardless of // what the focused child pushed. Codex/ratatui enables kitty mode @@ -2138,6 +2189,42 @@ func (st *uiState) closePalette(action paletteAction) { st.drawSidebar() st.drawStatusLine() + case "project-switch": + if st.registry == nil || action.projectKey == "" { + restoreView() + return + } + if p := st.registry.Project(action.projectKey); p != nil { + st.switchProject(p) + return + } + restoreView() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() + + case "project-open-submit": + if st.registry == nil || strings.TrimSpace(action.projectPath) == "" { + restoreView() + return + } + path := strings.TrimSpace(action.projectPath) + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + path = filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + } + p, err := st.registry.Open(st.ctx, path) + if err != nil { + st.flashError(fmt.Sprintf("open project: %v", err)) + restoreView() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() + return + } + st.switchProject(p) + case "kill": // User-initiated kill cancels any pending auto-restart so the // process doesn't immediately come back. diff --git a/internal/app/chrome_model.go b/internal/app/chrome_model.go index 0504635..cbbf74c 100644 --- a/internal/app/chrome_model.go +++ b/internal/app/chrome_model.go @@ -6,6 +6,7 @@ import "github.com/hjbdev/patterm/internal/scratchpad" // ANSI output; this model is the serializable shape a client can draw locally. type chromeModel struct { ProjectKey string `json:"project_key"` + ProjectName string `json:"project_name,omitempty"` FocusedID string `json:"focused_id,omitempty"` FocusedPad string `json:"focused_pad,omitempty"` ActiveAgentID string `json:"active_agent_id,omitempty"` @@ -41,6 +42,7 @@ func buildChromeModel(projectKey string, view ClientView, children []*Child, pad } model := chromeModel{ ProjectKey: projectKey, + ProjectName: view.ProjectName, FocusedID: view.FocusedID, FocusedPad: view.FocusedPad, ActiveAgentID: active, diff --git a/internal/app/client_view.go b/internal/app/client_view.go index 4f9d88c..ba7939f 100644 --- a/internal/app/client_view.go +++ b/internal/app/client_view.go @@ -6,6 +6,7 @@ package app type ClientView struct { ID string ProjectKey string + ProjectName string FocusedID string FocusedPad string ActiveAgentID string diff --git a/internal/app/daemon_core.go b/internal/app/daemon_core.go index 33b3ca6..a5e145b 100644 --- a/internal/app/daemon_core.go +++ b/internal/app/daemon_core.go @@ -1,29 +1,447 @@ package app import ( + "context" + "fmt" + "path/filepath" + "sort" + "sync" + "syscall" + "time" + "github.com/hjbdev/patterm/internal/mcp" "github.com/hjbdev/patterm/internal/persist" "github.com/hjbdev/patterm/internal/preset" + "github.com/hjbdev/patterm/internal/projectkey" "github.com/hjbdev/patterm/internal/scratchpad" "github.com/hjbdev/patterm/internal/trust" ) -// headlessCore is the daemon-owned half of today's single-process app. It is -// intentionally small for the foundation phase: it groups process/project -// state while the existing loopback client still renders in-process. -type headlessCore struct { - projectDir string - projectKey string +type Project struct { + Key string + Dir string + Name string - presets preset.Set - settings settings + Session *Session + Pads *scratchpad.Store + Trust *trust.Store + Persist *persist.Store + Launcher *Launcher + Host *toolHost + savedProcess []persist.Entry - pads *scratchpad.Store - trustStore *trust.Store - persistStore *persist.Store - - mcpSrv *mcp.Server - sess *Session - launcher *Launcher - host *toolHost + lastActive time.Time +} + +type projectSummary struct { + Key string + Dir string + Name string + TabCount int + IsCurrent bool +} + +// ProjectRegistry is the daemon-owned project map. Phase 1 still runs in one +// local process, but every project already has isolated stores, session, +// launcher, and tool host so future clients can attach to different projects. +type ProjectRegistry struct { + mu sync.Mutex + projects map[string]*Project + + defaultProjectKey string + presets preset.Set + settings settings + mcpSrv *mcp.Server + cols, rows uint16 +} + +func newProjectRegistry(presets preset.Set, settings settings, mcpSrv *mcp.Server, cols, rows uint16) *ProjectRegistry { + return &ProjectRegistry{ + projects: make(map[string]*Project), + presets: presets, + settings: settings, + mcpSrv: mcpSrv, + cols: cols, + rows: rows, + } +} + +func (r *ProjectRegistry) Open(ctx context.Context, dir string) (*Project, error) { + key, err := projectkey.Key(dir) + if err != nil { + return nil, err + } + abs, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + + r.mu.Lock() + if p := r.projects[key]; p != nil { + p.lastActive = time.Now() + r.mu.Unlock() + return p, nil + } + r.mu.Unlock() + + pads, err := scratchpad.Open(key) + if err != nil { + return nil, fmt.Errorf("app: scratchpad init: %w", err) + } + trustStore, err := trust.Open(key) + if err != nil { + return nil, fmt.Errorf("app: trust init: %w", err) + } + persistStore, err := persist.Open(key) + if err != nil { + return nil, fmt.Errorf("app: persist init: %w", err) + } + sess := NewSession(abs, key) + savedProcesses := persistStore.List() + for _, e := range savedProcesses { + _ = persistStore.Remove(e.ID) + } + sess.SetPersistStore(persistStore) + socket := "" + if r.mcpSrv != nil { + socket = r.mcpSrv.Socket() + } + launcher := NewLauncher(sess, socket, r.cols, r.rows) + host := newToolHost(sess, pads, launcher, r.presets, trustStore, r.cols, r.rows) + go sess.runClassifier(ctx) + + p := &Project{ + Key: key, + Dir: abs, + Name: filepath.Base(abs), + Session: sess, + Pads: pads, + Trust: trustStore, + Persist: persistStore, + Launcher: launcher, + Host: host, + savedProcess: savedProcesses, + lastActive: time.Now(), + } + + r.mu.Lock() + if existing := r.projects[key]; existing != nil { + r.mu.Unlock() + sess.Shutdown() + return existing, nil + } + r.projects[key] = p + if r.defaultProjectKey == "" { + r.defaultProjectKey = key + } + r.mu.Unlock() + return p, nil +} + +func (r *ProjectRegistry) Project(key string) *Project { + r.mu.Lock() + defer r.mu.Unlock() + return r.projects[key] +} + +func (r *ProjectRegistry) Count() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.projects) +} + +func (r *ProjectRegistry) Shutdown() { + r.mu.Lock() + projects := make([]*Project, 0, len(r.projects)) + for _, p := range r.projects { + projects = append(projects, p) + } + r.mu.Unlock() + for _, p := range projects { + p.Session.Shutdown() + } +} + +func (r *ProjectRegistry) ResizeAll(cols, rows uint16) { + r.mu.Lock() + r.cols, r.rows = cols, rows + projects := make([]*Project, 0, len(r.projects)) + for _, p := range r.projects { + projects = append(projects, p) + } + r.mu.Unlock() + for _, p := range projects { + p.Session.ResizeAll(cols, rows) + p.Launcher.SetSize(cols, rows) + p.Host.SetSize(cols, rows) + } +} + +func (r *ProjectRegistry) Summaries(currentKey string) []projectSummary { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]projectSummary, 0, len(r.projects)) + for _, p := range r.projects { + out = append(out, projectSummary{ + Key: p.Key, + Dir: p.Dir, + Name: p.Name, + TabCount: len(runningTopLevels(p.Session.Children())), + IsCurrent: p.Key == currentKey, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].IsCurrent != out[j].IsCurrent { + return out[i].IsCurrent + } + return out[i].Name < out[j].Name + }) + return out +} + +func (r *ProjectRegistry) findProjectByChild(id string) (*Project, *Child) { + if id == "" { + return nil, nil + } + r.mu.Lock() + projects := make([]*Project, 0, len(r.projects)) + for _, p := range r.projects { + projects = append(projects, p) + } + r.mu.Unlock() + for _, p := range projects { + if c := p.Session.FindChild(id); c != nil { + return p, c + } + } + return nil, nil +} + +func (r *ProjectRegistry) projectForCaller(callerID string) *Project { + if p, _ := r.findProjectByChild(callerID); p != nil { + return p + } + r.mu.Lock() + defer r.mu.Unlock() + return r.projects[r.defaultProjectKey] +} + +func (r *ProjectRegistry) hostForCaller(callerID string) *toolHost { + if p := r.projectForCaller(callerID); p != nil { + return p.Host + } + return nil +} + +func (r *ProjectRegistry) hostForProcess(processID string) *toolHost { + if p, _ := r.findProjectByChild(processID); p != nil { + return p.Host + } + return nil +} + +func (r *ProjectRegistry) ResolveCallerIdentity(identity string) string { + r.mu.Lock() + projects := make([]*Project, 0, len(r.projects)) + for _, p := range r.projects { + projects = append(projects, p) + } + r.mu.Unlock() + for _, p := range projects { + if c := p.Session.FindChildByIdentity(identity); c != nil { + return c.ID + } + } + return "" +} + +func (r *ProjectRegistry) CallerRole(processID string) mcp.CallerRole { + if h := r.hostForCaller(processID); h != nil { + return h.CallerRole(processID) + } + return mcp.RoleOrchestrator +} + +func (r *ProjectRegistry) SpawnAgent(callerID string, args mcp.SpawnAgentArgs) (mcp.ProcessInfo, error) { + return r.hostForCaller(callerID).SpawnAgent(callerID, args) +} + +func (r *ProjectRegistry) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp.ProcessInfo, error) { + return r.hostForCaller(callerID).SpawnProcess(callerID, args) +} + +func (r *ProjectRegistry) StartProcess(callerID, processID string) (mcp.ProcessInfo, error) { + if h := r.hostForProcess(processID); h != nil { + return h.StartProcess(callerID, processID) + } + return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) RestartProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) { + if h := r.hostForProcess(processID); h != nil { + return h.RestartProcess(callerID, processID, sig) + } + return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) StopProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) { + if h := r.hostForProcess(processID); h != nil { + return h.StopProcess(callerID, processID, sig) + } + return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) CloseProcess(callerID, processID string) error { + if h := r.hostForProcess(processID); h != nil { + return h.CloseProcess(callerID, processID) + } + return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) RenameProcess(callerID, processID, name string) error { + if h := r.hostForProcess(processID); h != nil { + return h.RenameProcess(callerID, processID, name) + } + return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) SelectProcess(callerID, processID string) error { + if h := r.hostForProcess(processID); h != nil { + return h.SelectProcess(callerID, processID) + } + return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) ListProcesses(callerID, kindFilter string) []mcp.ProcessInfo { + if h := r.hostForCaller(callerID); h != nil { + return h.ListProcesses(callerID, kindFilter) + } + return nil +} + +func (r *ProjectRegistry) GetProcessStatus(callerID, processID string) (mcp.ProcessStatus, error) { + if h := r.hostForProcess(processID); h != nil { + return h.GetProcessStatus(callerID, processID) + } + return mcp.ProcessStatus{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) { + return r.hostForCaller(callerID).GetProjectStatus(callerID) +} + +func (r *ProjectRegistry) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) { + if h := r.hostForProcess(processID); h != nil { + return h.GetProcessOutput(callerID, processID, mode, sinceOffset) + } + return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) { + if h := r.hostForProcess(processID); h != nil { + return h.GetProcessRawOutput(callerID, processID, sinceOffset) + } + return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) { + if h := r.hostForProcess(processID); h != nil { + return h.SearchOutput(callerID, processID, pattern, kind, limit) + } + return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (bool, string, error) { + if h := r.hostForProcess(processID); h != nil { + return h.WaitForPattern(callerID, processID, pattern, timeoutSeconds, scope) + } + return false, "", mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) { + if h := r.hostForProcess(processID); h != nil { + return h.GetProcessPorts(callerID, processID) + } + return nil, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendInputResult, error) { + if h := r.hostForProcess(args.ProcessID); h != nil { + return h.SendInput(callerID, args) + } + return mcp.SendInputResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID) +} + +func (r *ProjectRegistry) SendMessage(callerID, targetID, message string) error { + if h := r.hostForProcess(targetID); h != nil { + return h.SendMessage(callerID, targetID, message) + } + return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", targetID) +} + +func (r *ProjectRegistry) RequestHumanAttention(callerID, processID, reason string) error { + if h := r.hostForProcess(processID); h != nil { + return h.RequestHumanAttention(callerID, processID, reason) + } + return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) +} + +func (r *ProjectRegistry) TimerWait(callerID string, seconds float64, label string) (string, error) { + return r.hostForCaller(callerID).TimerWait(callerID, seconds, label) +} + +func (r *ProjectRegistry) TimerSet(callerID string, args mcp.TimerSetArgs) (mcp.TimerHandle, error) { + return r.hostForCaller(callerID).TimerSet(callerID, args) +} + +func (r *ProjectRegistry) TimerFireWhenIdleAny(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) { + return r.hostForCaller(callerID).TimerFireWhenIdleAny(callerID, args) +} + +func (r *ProjectRegistry) TimerFireWhenIdleAll(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) { + return r.hostForCaller(callerID).TimerFireWhenIdleAll(callerID, args) +} + +func (r *ProjectRegistry) TimerCancel(callerID, id string) error { + return r.hostForCaller(callerID).TimerCancel(callerID, id) +} + +func (r *ProjectRegistry) TimerPause(callerID, id string) error { + return r.hostForCaller(callerID).TimerPause(callerID, id) +} + +func (r *ProjectRegistry) TimerResume(callerID, id string) error { + return r.hostForCaller(callerID).TimerResume(callerID, id) +} + +func (r *ProjectRegistry) TimerList(callerID string) ([]mcp.TimerInfo, error) { + return r.hostForCaller(callerID).TimerList(callerID) +} + +func (r *ProjectRegistry) ScratchpadList() ([]scratchpad.Entry, error) { + return r.hostForCaller("").ScratchpadList() +} + +func (r *ProjectRegistry) ScratchpadRead(name string) (string, string, error) { + return r.hostForCaller("").ScratchpadRead(name) +} + +func (r *ProjectRegistry) ScratchpadWrite(name, content, expectedRevision string) (string, error) { + return r.hostForCaller("").ScratchpadWrite(name, content, expectedRevision) +} + +func (r *ProjectRegistry) ScratchpadAppend(name, content string) error { + return r.hostForCaller("").ScratchpadAppend(name, content) +} + +func (r *ProjectRegistry) ScratchpadDelete(name string) error { + return r.hostForCaller("").ScratchpadDelete(name) +} + +func (r *ProjectRegistry) WhoAmI(callerID string) mcp.WhoAmI { + return r.hostForCaller(callerID).WhoAmI(callerID) +} + +func (r *ProjectRegistry) Help(callerID, topic string) mcp.HelpResponse { + return r.hostForCaller(callerID).Help(callerID, topic) } diff --git a/internal/app/palette.go b/internal/app/palette.go index 76bb8ce..f3888ac 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -40,6 +40,9 @@ type paletteAction struct { // For settings actions, the updated settings snapshot to persist. settings *settings + + projectKey string + projectPath string } // Group ids order the section bands the palette renders when no query @@ -48,6 +51,7 @@ type paletteAction struct { // an equally tight Spawn-section hit. const ( groupFocused = iota + groupProject groupOpen groupSpawn groupSettings @@ -64,6 +68,14 @@ type paletteItem struct { matches []int } +type paletteProject struct { + Key string + Dir string + Name string + TabCount int + IsCurrent bool +} + // paletteMode toggles the palette between its fuzzy-picker UI and the // freeform "spawn process" form. The form lives inside the palette so // it shares the same modal-input contract (every byte intercepted; no @@ -120,10 +132,12 @@ type paletteState struct { items []paletteItem - mode paletteMode - form *spawnProcessForm - renameForm *renameForm - settingsInput *settingsInputForm + mode paletteMode + form *spawnProcessForm + renameForm *renameForm + settingsInput *settingsInputForm + projects []paletteProject + currentProject string // showHelp swaps the item list for a static keybinding cheat-sheet // until the next keystroke. Toggled by `?` in picker mode. @@ -189,6 +203,12 @@ func newPalette(children []*Child, focused, focusedPad string, presets preset.Se return p } +func (p *paletteState) setProjects(current string, projects []paletteProject) { + p.currentProject = current + p.projects = append(p.projects[:0], projects...) + p.rebuild() +} + func (p *paletteState) rebuild() { // Macro is resolved on the *original-case* query; the returned rest // keeps the user's casing intact (useful when Tab cycles chips). @@ -294,7 +314,33 @@ func (p *paletteState) buildItems(macro string) []paletteItem { } } - // Group 1: Open — switch entries for every running child *other than* + if p.projects != nil { + // Group 1: Project — move the current client view without tearing + // down processes owned by the previous project. + for _, pr := range p.projects { + if pr.IsCurrent || pr.Key == p.currentProject { + continue + } + hint := pr.Dir + if pr.TabCount > 0 { + hint = fmt.Sprintf("%s · %d tabs", hint, pr.TabCount) + } + out = append(out, paletteItem{ + label: "Switch project: " + pr.Name, + hint: hint, + action: paletteAction{kind: "project-switch", projectKey: pr.Key}, + group: groupProject, + }) + } + out = append(out, paletteItem{ + label: "Open project…", + hint: "attach this client view to another local directory", + action: paletteAction{kind: "project-open-form"}, + group: groupProject, + }) + } + + // Group 2: Open — switch entries for every running child *other than* // the one already focused (no point offering a no-op switch). Dead // agents are filtered out (no restart path); dead command processes // remain so they can be restarted. @@ -655,6 +701,9 @@ func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) { p.cursor = 0 p.rebuildSettings() return paletteAction{}, false, adv + case "project-open-form": + p.enterRenameForm("project", "", "", "project path") + return paletteAction{}, false, adv case "pad-rename-form": p.enterRenameForm("pad", a.padName, a.padName, "scratchpad: "+a.padName) return paletteAction{}, false, adv @@ -913,6 +962,9 @@ func (p *paletteState) submitRename() paletteAction { return paletteAction{kind: "cancel"} } newName := strings.TrimSpace(string(p.renameForm.name)) + if p.renameForm.subject == "project" { + return paletteAction{kind: "project-open-submit", projectPath: newName} + } if newName == "" { return paletteAction{kind: "cancel"} } diff --git a/internal/app/project_registry_test.go b/internal/app/project_registry_test.go new file mode 100644 index 0000000..a26f000 --- /dev/null +++ b/internal/app/project_registry_test.go @@ -0,0 +1,100 @@ +package app + +import ( + "context" + "syscall" + "testing" + + "github.com/hjbdev/patterm/internal/preset" +) + +func TestSwitchProjectPreservesProjectProcessTrees(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + reg := newProjectRegistry(preset.Set{}, defaultSettings(), nil, 80, 24) + defer reg.Shutdown() + + projectA, err := reg.Open(ctx, t.TempDir()) + if err != nil { + t.Fatalf("open project A: %v", err) + } + projectB, err := reg.Open(ctx, t.TempDir()) + if err != nil { + t.Fatalf("open project B: %v", err) + } + + a, err := projectA.Session.Spawn(SpawnSpec{ + Kind: KindCommand, + Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"}, + Name: "a-loop", + }, 80, 24) + if err != nil { + t.Fatalf("spawn project A command: %v", err) + } + b, err := projectB.Session.Spawn(SpawnSpec{ + Kind: KindCommand, + Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"}, + Name: "b-loop", + }, 80, 24) + if err != nil { + t.Fatalf("spawn project B command: %v", err) + } + t.Cleanup(func() { + _ = projectA.Session.Kill(a.ID, syscall.SIGTERM) + _ = projectB.Session.Kill(b.ID, syscall.SIGTERM) + }) + waitUntilLive(t, a) + waitUntilLive(t, b) + + st := &uiState{ + registry: reg, + project: projectA, + sess: projectA.Session, + launcher: projectA.Launcher, + pads: projectA.Pads, + trust: projectA.Trust, + timers: projectA.Host.timers, + chromeWake: make(chan struct{}, 1), + view: ClientView{ + ID: "test", + ProjectKey: projectA.Key, + ProjectName: projectA.Name, + Cols: 80, + Rows: 24, + }, + } + st.focusChildLocked(a) + projectA.Session.Subscribe(st) + + st.switchProject(projectB) + if st.view.ProjectKey != projectB.Key { + t.Fatalf("view project key = %q, want %q", st.view.ProjectKey, projectB.Key) + } + if st.sess != projectB.Session { + t.Fatalf("ui session did not move to project B") + } + if projectA.Session.FindChild(a.ID) == nil { + t.Fatalf("project A child disappeared after switch") + } + if projectB.Session.FindChild(b.ID) == nil { + t.Fatalf("project B child disappeared after switch") + } + if !a.IsLive() { + t.Fatalf("project A child stopped after switch") + } + if !b.IsLive() { + t.Fatalf("project B child stopped after switch") + } + + st.switchProject(projectA) + if st.view.ProjectKey != projectA.Key { + t.Fatalf("view project key after switching back = %q, want %q", st.view.ProjectKey, projectA.Key) + } + if projectA.Session.FindChild(a.ID) == nil || projectB.Session.FindChild(b.ID) == nil { + t.Fatalf("switching back should preserve both project process trees") + } +} diff --git a/internal/app/session.go b/internal/app/session.go index fabcc4f..df8d899 100644 --- a/internal/app/session.go +++ b/internal/app/session.go @@ -266,6 +266,9 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) { if spec.Env == nil { spec.Env = s.ChildEnv() } + if spec.WorkDir == "" { + spec.WorkDir = s.projectDir + } s.mu.Lock() id := s.mintUniqueIDLocked() diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index c1df603..2193bb0 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -188,6 +188,9 @@ func RunStdioProxy(socket, identity string) error { // ""} + newline. Real protocol handshake is a later // milestone. greeting := map[string]string{"patterm_identity": identity} + if key := os.Getenv("PATTERM_PROJECT_KEY"); key != "" { + greeting["project_key"] = key + } gb, _ := json.Marshal(greeting) gb = append(gb, '\n') if _, err := conn.Write(gb); err != nil {