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
+118 -17
View File
@@ -7,6 +7,7 @@ import (
"unicode/utf8"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/task"
)
// paletteAction is what the palette returns when the user picks an item.
@@ -35,7 +36,10 @@ type paletteAction struct {
// For pad-* actions, the scratchpad name to operate on.
padName string
// For *-rename-submit actions, the user-typed new name.
// For task-* actions, the task to operate on.
taskID string
// For *-rename-submit and task-create-submit actions, the user-typed name.
newName string
// For settings actions, the updated settings snapshot to persist.
@@ -90,12 +94,12 @@ type spawnProcessForm struct {
// renameForm is a one-field inline form used by the "Rename scratchpad /
// agent / process" context palette entries. The submit action kind
// determines what gets renamed; the target name (pad name or child id)
// determines what gets renamed; the target name (pad name, task id, or child id)
// is carried alongside so closePalette knows what to apply the new
// name to.
type renameForm struct {
name []rune
subject string // "pad" | "agent" | "proc"
subject string // "pad" | "task" | "task-create" | "agent" | "proc"
target string // padName for "pad"; childID for "agent"/"proc"
title string // e.g. "Rename"
subjectLine string // e.g. "scratchpad: notes.md" rendered above the input
@@ -110,13 +114,16 @@ type settingsInputForm struct {
// paletteState is the in-memory model for the overlay. SPEC §4: a
// single fuzzy-searchable list of commands scoped to the current focus.
type paletteState struct {
query []rune
cursor int
children []*Child
focused string
focusedPad string
presets preset.Set
settings settings
query []rune
cursor int
children []*Child
focused string
focusedPad string
focusedTaskID string
tasksEnabled bool
tasks []task.Task
presets preset.Set
settings settings
items []paletteItem
@@ -135,9 +142,9 @@ type paletteState struct {
// macro is active. Typing `sw <query>` filters to switch entries only,
// `k <query>` to close entries, `sp <query>` to spawn entries.
var macroPrefixes = map[string][]string{
"sw": {"switch"},
"sw": {"switch", "task-switch"},
"k": {"kill", "agent-close", "proc-stop", "proc-delete"},
"sp": {"spawn-agent", "spawn-process", "spawn-terminal", "spawn-process-form"},
"sp": {"spawn-agent", "spawn-process", "spawn-terminal", "spawn-process-form", "task-start-agent"},
}
// chipOrder is the cycle order for Tab / Shift-Tab when the user
@@ -179,12 +186,35 @@ func findChildByID(children []*Child, id string) *Child {
return nil
}
func (p *paletteState) taskByID(id string) (task.Task, bool) {
if id == "" {
return task.Task{}, false
}
for _, t := range p.tasks {
if t.ID == id {
return t, true
}
}
return task.Task{}, false
}
func taskHint(t task.Task) string {
if len(t.Worktrees) == 0 {
return t.ID
}
return fmt.Sprintf("%s · %d worktrees", t.ID, len(t.Worktrees))
}
func newPalette(children []*Child, focused, focusedPad string, presets preset.Set, appSettings ...settings) *paletteState {
return newPaletteWithTasks(children, focused, focusedPad, "", nil, presets, appSettings...)
}
func newPaletteWithTasks(children []*Child, focused, focusedPad, focusedTaskID string, tasks []task.Task, presets preset.Set, appSettings ...settings) *paletteState {
st := defaultSettings()
if len(appSettings) > 0 {
st = appSettings[0].clone()
}
p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, presets: presets, settings: st}
p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, focusedTaskID: focusedTaskID, tasksEnabled: tasks != nil || focusedTaskID != "", tasks: tasks, presets: presets, settings: st}
p.rebuild()
return p
}
@@ -259,6 +289,21 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
paletteItem{label: "Delete", hint: "delete scratchpad · " + name,
action: paletteAction{kind: "pad-delete", padName: name}, group: groupFocused},
)
case p.focusedTaskID != "":
if t, ok := p.taskByID(p.focusedTaskID); ok {
out = append(out,
paletteItem{label: "Rename task", hint: "rename task · " + t.Title,
action: paletteAction{kind: "task-rename-form", taskID: t.ID}, group: groupFocused},
)
for _, pr := range p.presets.Agents {
out = append(out, paletteItem{
label: "Start agent for task: " + pr.Name,
hint: t.Title + " · task-scoped",
action: paletteAction{kind: "task-start-agent", taskID: t.ID, preset: pr},
group: groupFocused,
})
}
}
case p.focused != "":
if c := findChildByID(p.children, p.focused); c != nil {
name := c.DisplayName()
@@ -291,13 +336,46 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
action: paletteAction{kind: "proc-delete", childID: c.ID}, group: groupFocused},
)
}
if c.TaskID != "" {
if t, ok := p.taskByID(c.TaskID); ok {
out = append(out, paletteItem{label: "Open task", hint: "open task · " + t.Title,
action: paletteAction{kind: "task-switch", taskID: t.ID}, group: groupFocused})
for _, pr := range p.presets.Agents {
out = append(out, paletteItem{
label: "Start another agent for task: " + pr.Name,
hint: t.Title + " · task-scoped",
action: paletteAction{kind: "task-start-agent", taskID: t.ID, preset: pr},
group: groupFocused,
})
}
}
}
}
}
// Group 1: Open — switch entries for every running child *other than*
// Group 1: Open — tasks first, then 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.
if p.tasksEnabled {
out = append(out, paletteItem{
label: "Create task...",
hint: "manual project task",
action: paletteAction{kind: "task-create-form"},
group: groupOpen,
})
for _, t := range p.tasks {
if t.ID == p.focusedTaskID {
continue
}
out = append(out, paletteItem{
label: "Open task: " + t.Title,
hint: taskHint(t),
action: paletteAction{kind: "task-switch", taskID: t.ID},
group: groupOpen,
})
}
}
for _, c := range p.children {
if c.ID == p.focused {
continue
@@ -649,6 +727,16 @@ func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) {
p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{}
return paletteAction{}, false, adv
case "task-create-form":
p.enterNameForm("task-create", "", "", "new task", "Create task")
return paletteAction{}, false, adv
case "task-rename-form":
current := ""
if t, ok := p.taskByID(a.taskID); ok {
current = t.Title
}
p.enterNameForm("task", a.taskID, current, "task: "+current, "Rename task")
return paletteAction{}, false, adv
case "settings-open":
p.mode = paletteModeSettings
p.query = nil
@@ -676,12 +764,16 @@ func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) {
}
func (p *paletteState) enterRenameForm(subject, target, current, subjectLine string) {
p.enterNameForm(subject, target, current, subjectLine, "Rename")
}
func (p *paletteState) enterNameForm(subject, target, current, subjectLine, title string) {
p.mode = paletteModeRenameForm
p.renameForm = &renameForm{
name: []rune(current),
subject: subject,
target: target,
title: "Rename",
title: title,
subjectLine: subjectLine,
}
}
@@ -921,6 +1013,10 @@ func (p *paletteState) submitRename() paletteAction {
case "pad":
kind = "pad-rename-submit"
return paletteAction{kind: kind, padName: p.renameForm.target, newName: newName}
case "task-create":
return paletteAction{kind: "task-create-submit", newName: newName}
case "task":
return paletteAction{kind: "task-rename-submit", taskID: p.renameForm.target, newName: newName}
case "agent":
kind = "agent-rename-submit"
case "proc":
@@ -1151,12 +1247,17 @@ func (p *paletteState) selectableIndex() int {
}
// focusedSubject returns the short context string shown in the title
// bar — "on: <child>" / "pad: <name>" / "" — so the user knows which
// focus the context-section is targeting.
// bar — "on: <child>" / "pad: <name>" / "task: <title>" / "" — so the
// user knows which focus the context-section is targeting.
func (p *paletteState) focusedSubject() string {
if p.focusedPad != "" {
return "pad: " + p.focusedPad
}
if p.focusedTaskID != "" {
if t, ok := p.taskByID(p.focusedTaskID); ok {
return "task: " + t.Title
}
}
if p.focused != "" {
if c := findChildByID(p.children, p.focused); c != nil {
return "on: " + c.DisplayName()