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