Files
zero/internal/specialist/task_tool.go
Vasanth T b237e9849a feat(tui): sticky plan panel + specialist task cards + task table overlay (#255)
* feat(tui): sticky plan panel + specialist task cards + task table overlay

Three new TUI surfaces that give users live visibility into agent progress
and specialist delegation, so long turns and subagent work never look frozen.

Sticky plan panel (plan_panel.go):
- Pinned between the title bar and transcript body, shows the agent's
  step-by-step plan from the update_plan tool as a live checklist.
- Header with spinner + progress count + elapsed; text progress bar;
  per-step list with status icons (✓ completed, ⠹ in_progress, ○ pending,
  ✗ failed) and individual step timings.
- Auto-collapses to a one-line green summary 30s after completion.
  Ctrl+P toggles expand/collapse.
- Plan state synced from update_plan tool results via CurrentPlan();
  timestamps preserved across the tool's full-replacement updates by
  matching on content.

Specialist cards (specialist_card.go):
- New rowSpecialist transcript row kind; EventSpecialistStart/Stop
  now handled in session.go hydration (previously fell through to
  default and were silently dropped on resume).
- Animated card with spinner + name + task + elapsed + token count +
  tool call count. Blue while running, green when done, red on error.
- specialistTracker tracks live state; Task tool calls/returns are
  intercepted in the OnToolCall/OnToolResult callbacks to start/complete
  tracking and emit card rows.
- Height cache marks running specialist rows unstable so the spinner
  animates.

Task table overlay (task_table.go):
- Ctrl+G toggles a full-width overlay showing all specialists in the
  session as a sortable table: #, Specialist, Task, Status, Time,
  Tokens, Tools.
- Summary bar with running/completed/error counts, total tokens,
  aggregate elapsed.
- Arrow keys navigate rows; Esc closes the overlay.

Tests: plan_panel_test.go (7 tests), specialist_card_test.go (11 tests),
task_table_test.go (8 tests). All existing TUI tests pass.

* fix(update_plan): expose structured nested item schema so models stop sending bad arguments

The plan property's Items schema was omitted because of an outdated comment
claiming the serializer drops nested Properties/Required. The serializer
(propertyToRuntimeMap) already handles them recursively. Without the nested
schema, weaker models guessed the item structure from a text description and
sent malformed arguments, hitting the 4-strike guardrail halt.

Now exposes Items as an object with content (string, required), status (enum),
and notes (string).

* fix(tui): route plan + specialist updates through message sink, not stale closures

The OnToolCall/OnToolResult callbacks captured model by value, so
m.plan.updateFromItems() and m.specialists.start/complete() mutated stale
copies that never reached the live model. Introduced planUpdateMsg,
specialistStartMsg, and specialistCompleteMsg tea.Msg types sent through
runtimeMessageSink, handled in updateModel() where they mutate the live model.

* feat(tui): subchat drill-in for specialist sessions

Enter on a task table row loads the child session's events and swaps the
transcript view to show the subagent's full conversation (user messages,
agent text, tool calls, results). A nav bar at the top shows the specialist
name/task and a back hint. ArrowUp or Esc pops back to the main chat at the
saved scroll position.

- subchat.go: subchatState with enter/exit, renderSubchatNavBar
- transcript_selection.go: transcriptBodyItemsFromRows renders arbitrary
  rows (reuses the same rendering pipeline as the main transcript)
- model.go: Enter on task table row triggers drill-in; ArrowUp/Esc exits;
  transcriptView() short-circuits to child rows when subchat is active

* fix(ci): strip CRLF line endings and BOM from plan_tool_test.go and update_plan.go

gofmt and git diff --check failed on ubuntu CI because PowerShell's
Set-Content wrote UTF-8 with BOM and CRLF. Stripped both to plain LF
without BOM.

* feat(tui): add renderLeftRuleCard helper for borderless specialist cards

* feat(tui): switch specialist cards to left-rule rendering, drop hint line

* feat(tui): add renderSpecialistSummary one-line rollup for specialist cards

* fix(tui): correct slice offset in renderSpecialistSummary and add pluralization test

The muted tail was sliced at summary[len(spinnerView)] but the spinner
sits at byte offset 2 (after the 2-space indent), so the slice cut into
the spinner's UTF-8 continuation bytes and dropped the indent. Slice at
2+len(spinnerView) instead. Also add a test asserting '2 errors' is
present and '2 error' (singular) is absent when two specialists errored.

* refactor(tui): remove task table overlay — cards carry everything now

* feat(tui): render specialist summary line above cards in transcript

* feat(tui): mark specialist card lines as clickable selectable rows

* feat(tui): click specialist card to drill into subchat, Enter as keyboard fallback

* tui: clear selectedSpecialistID after Enter drill-in

Fixes Task 7 review issue: selectedSpecialistID persisted after
Enter drill-in, causing a subsequent Enter to re-drill into the
specialist's subchat instead of submitting the composer. Clear it
immediately after subchat.enter succeeds so the keyboard fallback
is one-shot per click; click re-sets it, so click->Enter still works.

* chore: gofmt model.go + test files

* chore: gitignore .superpowers/ and docs/superpowers/ — local skill artifacts

* fix(tui): remove Enter keyboard fallback for subchat drill-in — mouse click only

* fix(tui): reconcile specialist childSessionID from tool call ID to real session ID on completion

* feat(specialist): add Progress callback field to RunOptions and TaskRunOptions

* feat(specialist): stream child stdout line-by-line via Progress callback

* feat(agent): wire OnToolProgress callback for specialist child events

* feat(tui): show live tool-call progress in specialist cards

* fix(swarm): update RunChild mock signature for new progress callback param

* fix(tui): render plan panel inline in transcript, not pinned at top

* chore: gofmt model.go, specialist_card.go, specialist_click_test.go

* fix(tui): address PR review - plan panel without specialists, stream reader safety, resume dedup + payload keys
2026-06-19 20:35:08 +05:30

194 lines
5.4 KiB
Go

package specialist
import (
"context"
"fmt"
"strings"
"github.com/Gitlawb/zero/internal/tools"
)
const TaskToolName = "Task"
type TaskTool struct {
executor Executor
}
func NewTaskTool(executor Executor) *TaskTool {
return &TaskTool{executor: executor}
}
func (tool *TaskTool) Name() string {
return TaskToolName
}
func (tool *TaskTool) Description() string {
return "Launch a Zero specialist sub-agent for a focused delegated task."
}
func (tool *TaskTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"name": {
Type: "string",
Description: "Specialist name to invoke, such as worker, explorer, or code-review.",
},
"prompt": {
Type: "string",
Description: "The focused task to give the specialist.",
},
"description": {
Type: "string",
Description: "Short label for the child session.",
},
"run_in_background": {
Type: "boolean",
Description: "Run the specialist in the background and return a task_id immediately.",
Default: false,
},
"resume": {
Type: "string",
Description: "Existing specialist session id to resume.",
},
},
Required: []string{"prompt"},
AdditionalProperties: false,
}
}
func (tool *TaskTool) Safety() tools.Safety {
return tools.Safety{
SideEffect: tools.SideEffectShell,
Permission: tools.PermissionPrompt,
Reason: "Spawns a Zero specialist sub-agent process.",
AdvertiseInAuto: true,
}
}
// PermissionForArgs auto-approves delegating to a READ-ONLY specialist — it can
// only read the workspace, so spawning it is harmless — while keeping the static
// prompt for write-capable specialists (e.g. worker) and for resume. This lets
// the orchestrator delegate exploration/review off a normal prompt without an
// approval wall. Implements tools.ArgsPermissioner.
func (tool *TaskTool) PermissionForArgs(args map[string]any) tools.Permission {
params, err := parseTaskParameters(args)
if err != nil {
return tools.PermissionPrompt
}
// Resuming could continue a previously write-capable session; never auto-allow.
if params.Resume != "" {
return tools.PermissionPrompt
}
if tool.executor.IsReadOnlySpecialist(params.Name) {
return tools.PermissionAllow
}
return tools.PermissionPrompt
}
func (tool *TaskTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tool.RunWithOptions(ctx, args, tools.RunOptions{})
}
func (tool *TaskTool) RunWithOptions(ctx context.Context, args map[string]any, options tools.RunOptions) tools.Result {
params, err := parseTaskParameters(args)
if err != nil {
return taskError(err)
}
result, err := tool.executor.Run(ctx, params, TaskRunOptions{
ToolCallID: options.ToolCallID,
ParentSessionID: options.SessionID,
ParentModel: options.Model,
ParentReasoningEffort: options.ReasoningEffort,
CurrentDepth: options.Depth,
Cwd: options.Cwd,
// Propagate the parent's permission mode so the child never gains more
// authority than the parent: a non-unsafe parent yields a non-unsafe
// child (exec.go specialistAutonomy). Dropping this made every Task
// sub-agent run at "--auto high" (unsafe) regardless of the parent.
PermissionMode: options.PermissionMode,
Progress: options.Progress,
})
if err != nil {
return taskError(err)
}
if result.Result.Meta == nil {
result.Result.Meta = map[string]string{}
}
if result.SessionID != "" {
result.Result.Meta["session_id"] = result.SessionID
}
return result.Result
}
func parseTaskParameters(args map[string]any) (TaskParameters, error) {
name, err := optionalTaskString(args, "name")
if err != nil {
return TaskParameters{}, err
}
prompt, err := optionalTaskString(args, "prompt")
if err != nil {
return TaskParameters{}, err
}
description, err := optionalTaskString(args, "description")
if err != nil {
return TaskParameters{}, err
}
resume, err := optionalTaskString(args, "resume")
if err != nil {
return TaskParameters{}, err
}
runInBackground, err := optionalTaskBool(args, "run_in_background")
if err != nil {
return TaskParameters{}, err
}
params := TaskParameters{
Name: strings.TrimSpace(name),
Prompt: strings.TrimSpace(prompt),
Description: strings.TrimSpace(description),
RunInBackground: runInBackground,
Resume: strings.TrimSpace(resume),
}
if params.Name == "" && params.Resume == "" {
return TaskParameters{}, fmt.Errorf("task requires name or resume")
}
if params.Prompt == "" {
return TaskParameters{}, fmt.Errorf("task requires prompt")
}
return params, nil
}
func optionalTaskString(args map[string]any, key string) (string, error) {
if args == nil {
return "", nil
}
value, ok := args[key]
if !ok || value == nil {
return "", nil
}
text, ok := value.(string)
if !ok {
return "", fmt.Errorf("task %s must be a string", key)
}
return text, nil
}
func optionalTaskBool(args map[string]any, key string) (bool, error) {
if args == nil {
return false, nil
}
value, ok := args[key]
if !ok || value == nil {
return false, nil
}
flag, ok := value.(bool)
if !ok {
return false, fmt.Errorf("task %s must be a boolean", key)
}
return flag, nil
}
func taskError(err error) tools.Result {
return tools.Result{Status: tools.StatusError, Output: "Error: " + err.Error()}
}