Add task sidebar workflow
This commit is contained in:
+118
-17
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user