Add task sidebar workflow

This commit is contained in:
2026-06-24 12:28:56 +01:00
parent 45263d59f8
commit d133839194
33 changed files with 1890 additions and 134 deletions
+333 -17
View File
@@ -21,6 +21,7 @@ import (
"github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/task"
"github.com/hjbdev/patterm/internal/trust"
"github.com/hjbdev/patterm/internal/vt"
)
@@ -81,6 +82,11 @@ func Run(ctx context.Context, opts Options) error {
return fmt.Errorf("app: persist init: %w", err)
}
taskStore, err := task.Open(opts.ProjectKey)
if err != nil {
return fmt.Errorf("app: task 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.
@@ -129,7 +135,7 @@ func Run(ctx context.Context, opts Options) error {
// 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())
host := newToolHost(sess, pads, taskStore, launcher, presets, trustStore, layout.childCols(), layout.childRows())
mcpSrv.SetHost(host)
var restoreState *term.State
@@ -166,6 +172,7 @@ func Run(ctx context.Context, opts Options) error {
presets: presets,
launcher: launcher,
pads: pads,
tasks: taskStore,
chromeWake: make(chan struct{}, 1),
trust: trustStore,
timers: host.timers,
@@ -194,6 +201,7 @@ func Run(ctx context.Context, opts Options) error {
host.focus = st
host.prompter = st
host.scratch = st
host.taskUI = st
st.lastExit.Store(-1)
sess.Subscribe(st)
go st.summaries.run(ctx)
@@ -402,6 +410,7 @@ type uiState struct {
presets preset.Set
launcher *Launcher
pads *scratchpad.Store
tasks *task.Store
trust *trust.Store
timers *timerManager
@@ -417,6 +426,11 @@ type uiState struct {
// exclusive with focusedID. The palette also reads this to surface
// scratchpad-specific actions at the top of the command list.
focusedPad string
// focusedTaskID names the task currently rendered in the main viewport.
// It is mutually exclusive with focusedID and focusedPad. Task selection
// is not ambient spawn context; only explicit task actions launch
// task-bound agents/processes.
focusedTaskID string
// padOffset is the index of the top-most rendered row in the
// markdown-formatted view of focusedPad. Reset when focus moves to
// a different pad; preserved across content changes for the same
@@ -503,8 +517,10 @@ type uiState struct {
// repaint; the cache invalidates in scratchpadsChanged() which is
// the canonical "pads mutated" signal from MCP write/append. nil
// means "never read yet" — next caller refreshes.
padsCacheMu sync.Mutex
padsCache []scratchpad.Entry
padsCacheMu sync.Mutex
padsCache []scratchpad.Entry
tasksCacheMu sync.Mutex
tasksCache []task.Task
lastExit atomic.Int32
}
@@ -585,8 +601,9 @@ func (st *uiState) focusProcess(processID string) {
layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c)
st.mu.Lock()
leavingPad := st.focusedPad != ""
leavingStaticView := st.focusedPad != "" || st.focusedTaskID != ""
st.focusedPad = ""
st.focusedTaskID = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
@@ -597,7 +614,7 @@ func (st *uiState) focusProcess(processID string) {
st.syncHostMouseForChild(onAlt)
// Wipe whatever the previous focus (PTY child or pad view) left in
// the viewport before painting the new child's snapshot.
if leavingPad {
if leavingStaticView {
st.clearViewportArea()
}
st.repaintFocused()
@@ -656,6 +673,7 @@ func (st *uiState) focusScratchpad(name string) {
st.padOffsetName = name
}
st.focusedPad = name
st.focusedTaskID = ""
st.focusedID = ""
st.focusedName = name
st.renderer = nil
@@ -667,6 +685,29 @@ func (st *uiState) focusScratchpad(name string) {
st.drawStatusLine()
}
func (st *uiState) focusTask(taskID string) {
if taskID == "" || st.tasks == nil {
return
}
t, ok := st.tasks.Get(taskID)
if !ok {
return
}
st.marquee.reset()
st.mu.Lock()
st.focusedTaskID = t.ID
st.focusedPad = ""
st.focusedID = ""
st.focusedName = t.Title
st.renderer = nil
st.mu.Unlock()
st.clearViewportArea()
st.repaintFocusedTask()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// clearViewportArea wipes the rectangle the focused-child PTY (or pad
// view) paints into so the next paint starts on a clean canvas. Used
// when transitioning between pad and child focus.
@@ -691,8 +732,11 @@ func (st *uiState) clearViewportArea() {
func (st *uiState) repaintFocusedWithChrome() {
st.mu.Lock()
padFocused := st.focusedPad != ""
taskFocused := st.focusedTaskID != ""
st.mu.Unlock()
if padFocused {
if taskFocused {
st.repaintFocusedTask()
} else if padFocused {
st.repaintFocusedPad()
} else {
st.repaintFocused()
@@ -786,6 +830,18 @@ func (st *uiState) scratchpadsChanged() {
}
}
func (st *uiState) tasksChanged() {
st.invalidateTasksCache()
st.drawSidebar()
st.mu.Lock()
focusedTask := st.focusedTaskID
st.mu.Unlock()
if focusedTask != "" {
st.repaintFocusedTask()
st.drawStatusLine()
}
}
func (st *uiState) invalidateScratchpadsCache() {
st.padsCacheMu.Lock()
st.padsCache = nil
@@ -795,6 +851,15 @@ func (st *uiState) invalidateScratchpadsCache() {
st.chromeCacheMu.Unlock()
}
func (st *uiState) invalidateTasksCache() {
st.tasksCacheMu.Lock()
st.tasksCache = nil
st.tasksCacheMu.Unlock()
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
}
// OnChildSpawned auto-focuses the new child when the spawn came from
// the user (palette, persistence restore, or an external MCP client with
// no resolved identity). When ParentID is set — meaning a patterm-managed
@@ -810,6 +875,10 @@ func (st *uiState) OnChildSpawned(c *Child) {
if st.palette != nil {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
st.palette.focusedPad = st.focusedPad
st.palette.focusedTaskID = st.focusedTaskID
st.palette.tasksEnabled = st.tasks != nil
st.palette.tasks = st.tasksList()
st.palette.rebuild()
st.renderPaletteLocked()
}
@@ -823,6 +892,7 @@ func (st *uiState) OnChildSpawned(c *Child) {
onAlt := childIsOnAlt(c)
st.mu.Lock()
st.focusedPad = ""
st.focusedTaskID = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
@@ -833,6 +903,10 @@ func (st *uiState) OnChildSpawned(c *Child) {
if palOpen {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
st.palette.focusedPad = st.focusedPad
st.palette.focusedTaskID = st.focusedTaskID
st.palette.tasksEnabled = st.tasks != nil
st.palette.tasks = st.tasksList()
st.palette.rebuild()
st.renderPaletteLocked()
}
@@ -915,6 +989,10 @@ func (st *uiState) OnChildExited(c *Child) {
if st.palette != nil {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
st.palette.focusedPad = st.focusedPad
st.palette.focusedTaskID = st.focusedTaskID
st.palette.tasksEnabled = st.tasks != nil
st.palette.tasks = st.tasksList()
st.palette.rebuild()
st.renderPaletteLocked()
}
@@ -1158,6 +1236,40 @@ func (st *uiState) padsList() []scratchpad.Entry {
return entries
}
// tasksList returns the cached task listing. Callers must not mutate the
// returned slice — it is shared until invalidateTasksCache runs.
func (st *uiState) tasksList() []task.Task {
if st.tasks == nil {
return nil
}
st.tasksCacheMu.Lock()
if st.tasksCache != nil {
out := st.tasksCache
st.tasksCacheMu.Unlock()
return out
}
st.tasksCacheMu.Unlock()
entries := st.tasks.List()
st.tasksCacheMu.Lock()
st.tasksCache = entries
st.tasksCacheMu.Unlock()
return entries
}
func (st *uiState) taskByID(id string) (task.Task, bool) {
if id == "" || st.tasks == nil {
return task.Task{}, false
}
return st.tasks.Get(id)
}
func (st *uiState) taskTitle(id string) string {
if t, ok := st.taskByID(id); ok {
return t.Title
}
return ""
}
// markChromeDirty schedules a chrome (tab bar + status line) repaint
// on the next ticker frame. Cheap to call from the per-PTY-chunk hot
// path. Latency-sensitive sites (focus change, owner flip, attention,
@@ -1269,6 +1381,7 @@ func (st *uiState) drawStatusLine() {
palOpen := st.palette != nil
focusID := st.focusedID
focusName := st.focusedName
focusTaskID := st.focusedTaskID
var trustMsg string
if st.pendingTrust != nil {
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
@@ -1297,10 +1410,24 @@ func (st *uiState) drawStatusLine() {
owner = "you have control"
}
}
if focusTaskID != "" && focusName == "" {
focusName = st.taskTitle(focusTaskID)
}
left := ""
if focusName != "" {
if focusTaskID != "" && focusName != "" {
left = "task: " + focusName
} else if focusName != "" {
left = focusName
}
if focusedChild != nil && focusedChild.TaskID != "" {
if title := st.taskTitle(focusedChild.TaskID); title != "" {
if left != "" {
left = left + " · task: " + title
} else {
left = "task: " + title
}
}
}
if owner != "" {
if left != "" {
left = left + " · " + owner
@@ -1369,7 +1496,7 @@ func (st *uiState) drawStatusLine() {
// child is focused.
func (st *uiState) renderEmptyState() {
layout := st.layoutSnapshot()
line := "Press Ctrl-K to spawn an agent or process"
line := "Press Ctrl-K to create a task or spawn an agent/process"
row := int(layout.mainTop) + (int(layout.childRows()) / 2)
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
if row < int(layout.mainTop) {
@@ -1790,13 +1917,13 @@ func (st *uiState) processStdin(chunk []byte) {
}
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
flushForward()
pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.activeAgentID, st.padsList(), -1)
pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.focusedTaskID, st.activeAgentID, st.tasksList(), st.padsList(), -1)
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
flushForward()
pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.activeAgentID, st.padsList(), +1)
pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.focusedTaskID, st.activeAgentID, st.tasksList(), st.padsList(), +1)
i += adv
break
}
@@ -1884,6 +2011,8 @@ func (st *uiState) processStdin(chunk []byte) {
}
if !pendingNav.empty() {
switch {
case pendingNav.isTask():
st.focusTask(pendingNav.taskID)
case pendingNav.isPad():
st.focusScratchpad(pendingNav.pad)
case pendingNav.isChild():
@@ -1965,7 +2094,7 @@ func (st *uiState) openPaletteLocked() {
st.settingsMu.Lock()
appSettings := st.settings.clone()
st.settingsMu.Unlock()
st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets, appSettings)
st.palette = newPaletteWithTasks(st.sess.Children(), st.focusedID, st.focusedPad, st.focusedTaskID, st.tasksList(), st.presets, appSettings)
// 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
@@ -2001,7 +2130,12 @@ func (st *uiState) closePalette(action paletteAction) {
restoreView := func() {
st.mu.Lock()
padFocused := st.focusedPad != ""
taskFocused := st.focusedTaskID != ""
st.mu.Unlock()
if taskFocused {
st.repaintFocusedTask()
return
}
if padFocused {
st.repaintFocusedPad()
return
@@ -2025,7 +2159,7 @@ func (st *uiState) closePalette(action paletteAction) {
st.launcher.SetSize(l.childCols(), l.childRows())
// LaunchAgent fires OnChildSpawned synchronously; it will draw
// chrome and set focus.
if _, err := st.launcher.LaunchAgent(action.preset, action.preset.Name, "", ""); err != nil {
if _, err := st.launcher.LaunchAgent(action.preset, action.preset.Name, "", LaunchContext{}); err != nil {
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
}
@@ -2036,14 +2170,14 @@ func (st *uiState) closePalette(action paletteAction) {
}
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
if _, err := st.launcher.LaunchCommandPreset(action.preset, action.preset.Name, ""); err != nil {
if _, err := st.launcher.LaunchCommandPreset(action.preset, action.preset.Name, LaunchContext{}); err != nil {
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
}
case "spawn-terminal":
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
if _, err := st.launcher.LaunchTerminal(nil, "terminal", "", "", nil); err != nil {
if _, err := st.launcher.LaunchTerminal(nil, "terminal", LaunchContext{}, nil); err != nil {
st.flashError(fmt.Sprintf("spawn terminal: %v", err))
}
@@ -2061,7 +2195,7 @@ func (st *uiState) closePalette(action paletteAction) {
// shell=true so multi-word commands like "bun run dev" pass
// through `sh -lc` and the user's PATH resolves binaries the
// way they expect from an interactive shell.
c, err := st.launcher.LaunchCommandArgv([]string{action.command}, display, "", "", nil, true)
c, err := st.launcher.LaunchCommandArgv([]string{action.command}, display, LaunchContext{}, nil, true)
if err != nil {
st.flashError(fmt.Sprintf("spawn: %v", err))
return
@@ -2077,6 +2211,18 @@ func (st *uiState) closePalette(action paletteAction) {
st.drawSidebar()
}
case "task-create-submit":
st.handleTaskCreate(action.newName)
case "task-switch":
st.focusTask(action.taskID)
case "task-rename-submit":
st.handleTaskRename(action.taskID, action.newName)
case "task-start-agent":
st.handleTaskStartAgent(action.taskID, action.preset)
case "switch":
c := st.sess.FindChild(action.childID)
if c == nil || (c.Kind == KindAgent && c.Status() != StatusRunning) {
@@ -2085,8 +2231,9 @@ func (st *uiState) closePalette(action paletteAction) {
}
layout := st.layoutSnapshot()
st.mu.Lock()
leavingPad := st.focusedPad != ""
leavingStaticView := st.focusedPad != "" || st.focusedTaskID != ""
st.focusedPad = ""
st.focusedTaskID = ""
st.focusedID = action.childID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
@@ -2095,7 +2242,7 @@ func (st *uiState) closePalette(action paletteAction) {
// Switching from a pad to a child: wipe the pad body so the
// child's snapshot paints onto a clean canvas, mirroring
// focusProcess.
if leavingPad {
if leavingStaticView {
st.clearViewportArea()
}
st.repaintFocused()
@@ -2162,6 +2309,70 @@ func (st *uiState) closePalette(action paletteAction) {
}
}
func (st *uiState) handleTaskCreate(title string) {
if st.tasks == nil {
st.flashError("tasks unavailable")
return
}
t, err := st.tasks.Create(title)
if err != nil {
st.flashError(fmt.Sprintf("create task: %v", err))
return
}
st.invalidateTasksCache()
st.focusTask(t.ID)
}
func (st *uiState) handleTaskRename(taskID, title string) {
if st.tasks == nil || taskID == "" {
st.repaintFocused()
return
}
t, err := st.tasks.Rename(taskID, title)
if err != nil {
st.flashError(fmt.Sprintf("rename task: %v", err))
return
}
st.invalidateTasksCache()
st.mu.Lock()
wasFocused := st.focusedTaskID == taskID
if wasFocused {
st.focusedName = t.Title
}
st.mu.Unlock()
if wasFocused {
st.focusTask(taskID)
return
}
st.drawSidebar()
st.drawStatusLine()
}
func (st *uiState) handleTaskStartAgent(taskID string, p *preset.Preset) {
if p == nil {
st.repaintFocused()
return
}
t, ok := st.taskByID(taskID)
if !ok {
st.flashError("start task agent: task not found")
return
}
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
display := p.Name + " · " + t.Title
ctx := LaunchContext{TaskID: t.ID, WorkDir: st.sess.projectDir}
if _, err := st.launcher.LaunchAgent(p, display, taskAgentPrompt(t), ctx); err != nil {
st.flashError(fmt.Sprintf("start task agent: %v", err))
}
}
func taskAgentPrompt(t task.Task) string {
title := strings.NewReplacer("\r", " ", "\n", " ", `"`, "'").Replace(t.Title)
id := strings.NewReplacer("\r", " ", "\n", " ").Replace(t.ID)
return fmt.Sprintf("[system: you are working on patterm task %q (%s). If you create or use git worktrees for this task, call task_register_worktree with the path and branch.]", title, id)
}
func (st *uiState) applySettingsAction(action paletteAction) {
if action.settings == nil {
return
@@ -2459,9 +2670,14 @@ func (st *uiState) repaintFocused() {
layout := st.layoutSnapshot()
st.mu.Lock()
id := st.focusedID
taskID := st.focusedTaskID
renderer := st.renderer
st.mu.Unlock()
if id == "" {
if taskID != "" {
st.repaintFocusedTask()
return
}
st.renderEmptyState()
return
}
@@ -2492,6 +2708,106 @@ func (st *uiState) repaintFocused() {
st.renderToasts()
}
func (st *uiState) repaintFocusedTask() {
st.mu.Lock()
taskID := st.focusedTaskID
st.mu.Unlock()
if taskID == "" {
return
}
t, ok := st.taskByID(taskID)
if !ok {
st.renderEmptyState()
return
}
out := st.renderTaskView(t, st.sess.Children(), st.layoutSnapshot())
if len(out) == 0 {
return
}
st.outMu.Lock()
_, _ = os.Stdout.Write(out)
st.outMu.Unlock()
st.renderToasts()
}
func (st *uiState) renderTaskView(t task.Task, children []*Child, layout terminalLayout) []byte {
mainBottom := int(layout.statusRow) - statusRows
width := int(layout.childCols())
if mainBottom < int(layout.mainTop) || width < 1 {
return nil
}
contentWidth := width - 2
if contentWidth < 1 {
contentWidth = 1
}
var b strings.Builder
fmt.Fprintf(&b, "\x1b[0m\x1b[?6l\x1b[%d;%dr\x1b[?25l\x1b[%d;%dH",
int(layout.mainTop), mainBottom,
int(layout.mainTop), int(layout.mainLeft))
row := int(layout.mainTop)
writeRow := func(text, style string) {
if row > mainBottom {
return
}
if visibleLen(text) > contentWidth {
text = clampVisible(text, contentWidth)
}
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", row, int(layout.mainLeft), width)
fmt.Fprintf(&b, "\x1b[%d;%dH%s %s%s", row, int(layout.mainLeft), style, text, styleReset)
row++
}
writeRow("task: "+t.Title, styleActive+styleBold)
writeRow("id: "+t.ID, styleDim)
if width > 2 {
writeRow(strings.Repeat("─", contentWidth), styleBorder)
}
writeRow("worktrees", styleHint)
if len(t.Worktrees) == 0 {
writeRow("(none registered yet)", styleDim)
} else {
for _, wt := range t.Worktrees {
line := wt.Path
if wt.Branch != "" {
line += " [" + wt.Branch + "]"
}
writeRow(line, "")
}
}
bound := taskBoundChildren(children, t.ID)
if row+1 <= mainBottom {
writeRow("", "")
writeRow("processes", styleHint)
if len(bound) == 0 {
writeRow("(none running for this task)", styleDim)
} else {
for _, c := range bound {
writeRow(fmt.Sprintf("%s %s %s", c.ID, c.Kind, c.DisplayName()), "")
}
}
}
if row+1 <= mainBottom {
writeRow("", "")
writeRow("Ctrl-K task actions · Ctrl-W/S navigate", styleDim)
}
for row <= mainBottom {
writeRow("", "")
}
return []byte(b.String())
}
func taskBoundChildren(children []*Child, taskID string) []*Child {
out := make([]*Child, 0, 4)
for _, c := range children {
if c.TaskID == taskID {
out = append(out, c)
}
}
return out
}
// repaintFocusedPad paints the focused scratchpad's content into the
// main viewport, honouring the per-pad scroll offset and clamping it
// to the rendered body size so a shrunk pad doesn't leave the view