From 8603633b240a09853b4be0963517eb006928a940 Mon Sep 17 00:00:00 2001 From: Ryan SVIHLA Date: Sat, 26 Sep 2026 16:24:56 +0200 Subject: [PATCH 1/2] Add provider steering and a visible editable prompt queue --- README.md | 16 +- docs/acp-support.md | 3 +- internal/client/client.go | 14 ++ internal/client/steering.go | 207 ++++++++++++++++++++ internal/client/steering_test.go | 315 +++++++++++++++++++++++++++++++ internal/client/updates.go | 5 + internal/store/store.go | 2 + internal/tui/commands.go | 44 ++--- internal/tui/model.go | 53 ++++-- internal/tui/picker.go | 28 +-- internal/tui/queue.go | 182 ++++++++++++++++++ internal/tui/queue_test.go | 172 +++++++++++++++++ internal/tui/transcript.go | 9 + internal/tui/view.go | 19 +- internal/tui/workflow_test.go | 6 +- terminal_test.go | 2 + 16 files changed, 1009 insertions(+), 68 deletions(-) create mode 100644 internal/client/steering.go create mode 100644 internal/client/steering_test.go create mode 100644 internal/tui/queue.go create mode 100644 internal/tui/queue_test.go diff --git a/README.md b/README.md index 4c02f66..fd3f520 100644 --- a/README.md +++ b/README.md @@ -176,24 +176,28 @@ Form requests support strings, numbers, integers, booleans, single choices, and | Key | Action | | --- | --- | -| Enter | Send prompt or select a list entry. | +| Enter | Send a prompt; steer the active turn when supported, otherwise queue it. Select a list entry or save an edited queue entry. | | Alt+Enter / Ctrl+J | Insert a newline. Shift+Enter also works in compatible terminals. | | Ctrl+P | Compact command search. | | Ctrl+O | Expand full transcript and tool details; press again or Esc to return. | | Ctrl+G | Agent picker. | | Ctrl+S | Session picker. | | Ctrl+N | New session. | -| Tab | Complete the selected command, setting, or `@file` suggestion. | -| Up / Alt+Up, Down / Alt+Down | Recall prompt history; Up works on the first input line. | +| Tab | Complete the selected suggestion; otherwise queue a follow-up while the agent works. | +| Alt+Up | Edit the latest queued prompt, keeping the current draft for later; otherwise recall prompt history. | +| Up, Down / Alt+Down | Recall prompt history; Up works on the first input line. | | Terminal scrollback / mouse wheel | Scroll completed output; mouse selection is available. | | Page Up / Page Down | Scroll expanded details and dialog explanations. | -| Esc | Dismiss suggestions/dialogs first; otherwise stop the active turn. | +| Esc | Restore an edited queue entry and the previous draft; otherwise dismiss suggestions/dialogs or stop the active turn. | | Ctrl+C | Stop an active turn; otherwise clear a draft, or press twice on an empty prompt to exit. | | Ctrl+D | Exit from an empty, idle prompt. | | Type in a selector | Filter immediately; one Enter selects, Esc returns to the draft. | | Ctrl+D in the session picker | Confirm deletion, or local removal if remote deletion is unsupported. | +| Ctrl+D in the queue picker | Remove the selected queued prompt. | -You can compose the next prompt while the agent works. Enter queues it, and queued prompts run in order after a successful turn. Stopping or failing a turn pauses the queue; `/queue` lets you edit an entry, and `/queue send` continues it. Queued prompts remain in memory and stay tied to their original session; they are not saved across restarts. Multiline paste stays in the draft until you send it. +You can compose while the agent works. **Enter steers** an active turn when the agent advertises steering, including current Codex and Claude ACP adapters. **Tab queues** a separate follow-up, after any visible completion suggestion is handled. Agents without steering support queue Enter submissions too. Pending prompts appear above the composer and run in order after a successful turn. + +**Alt+Up** edits the latest queued prompt; `/queue` selects any entry. Editing pauses dispatch, retains the entry's position and attachments, and saves the draft you were typing. Enter saves the edit; Esc restores the original entry. Both return to your previous draft. Use Ctrl+D in `/queue` to remove an entry, `/queue clear` to remove queued work, and `/queue send` to resume after a stop or failure. An unconfirmed steering delivery stays in a paused queue for review instead of being resent automatically. Queues stay tied to their original session and remain in memory; they are not saved across restarts. Multiline paste stays in the draft until you send it. Other commands: `/agents`, `/help`, `/info`, `/auth`, `/logout`, `/refresh`, `/quit`. `/info` displays advertised capabilities and configuration. `/logs` shows recent agent stderr and the last error; `/reconnect` restarts the agent and reloads the saved session when supported. `/logout` requires the agent's logout capability. @@ -226,7 +230,7 @@ Under `MICRO_ACP_HOME`, the paths are `config.json`, `data/sessions/`, and then overrides only the configuration file. Demo history lives under `demo/` in the state directory (`data/demo/` with `MICRO_ACP_HOME`). -The client targets stable **ACP v1** using `BrokkAi/acp-go`, pinned to commit `5b2c77c673e0` (v0.10.0 plus the cancellation transport fix). Native forks additionally use the SDK's opt-in unstable v1 schema. Draft ACP v2 and editor-specific experimental extensions are not advertised. The [support matrix](docs/acp-support.md) maps protocol methods to UI flows and tests. +The client targets stable **ACP v1** using `BrokkAi/acp-go`, pinned to commit `5b2c77c673e0` (v0.10.0 plus the cancellation transport fix). Native forks additionally use the SDK's opt-in unstable v1 schema. Steering uses the advertised `_session/steering` extension. Draft ACP v2 and editor-specific experimental capabilities are not advertised. The [support matrix](docs/acp-support.md) maps protocol methods to UI flows and tests. ## Development diff --git a/docs/acp-support.md b/docs/acp-support.md index 4913495..c1c0f24 100644 --- a/docs/acp-support.md +++ b/docs/acp-support.md @@ -47,7 +47,7 @@ Terminal authentication is a separate process flow rather than an `authenticate` | `available_commands_update` | Refreshes inline slash suggestions; resolves client command collisions through `/agent`. | | `current_mode_update` | Updates the current legacy mode. | | `config_option_update` | Refreshes open selectors, completions, and the persistent status line, including custom options, On/Off values, and model-dependent choices. | -| `session_info_update` | Updates title and timestamp. | +| `session_info_update` | Updates title and timestamp; Codex thread-status metadata also tracks detached steering continuations. | | `usage_update` | Stores used/capacity and reported cost; the status line displays the percentage of context remaining. | Prompts can carry text, resource links, images, audio, embedded text resources and embedded binary resources. Capability checks happen before sending. Full content blocks persist in local session files. The TUI renders text/resources and labels images, audio and binary data; it does not render bitmap images or play audio. Tool content supports text/resources, diffs and terminal references. `TestRichContentRoundTripAndPersistence` verifies these survive the actual stdio transport and disk storage. @@ -56,6 +56,7 @@ Form fields support the restricted flat schema: strings, integers, numbers, bool ## Extensions and boundaries +- `_session/steering`: enabled only by top-level `initialize._meta.steering.supported`. Enter injects guidance; Tab retains a local FIFO follow-up. The client requests `idleBehavior: promptRequired`, so Claude can return unconsumed late input for one normal `session/prompt`. Codex's `startedNewTurn` is already consumed: its `_meta.codex.threadStatus` updates keep the turn busy until completion. Accepted steering is recorded once; rejected or ambiguous deliveries remain in a paused queue. See the [Claude example](https://github.com/agentclientprotocol/claude-agent-acp/blob/main/examples/steering.ts) and [Codex extension](https://github.com/agentclientprotocol/codex-acp/blob/main/src/AcpExtensions.ts). In-memory ACP transport tests cover acceptance, non-delivery, unknown outcomes, and detached completion before/after acknowledgment; TUI tests cover queue order, editing, deletion, draft/context retention, and fallback. - `session/fork`: implemented through the SDK's unstable v1 schema only when advertised. Replayed history replaces the inherited transcript; agents that do not replay retain the saved history. Tested by `TestNativeForkUsesAdvertisedCapability` and `TestNativeForkReplayReplacesInheritedHistory`. `/fork --context` is a separately labeled text-context fallback. - MCP configuration: stdio, HTTP and SSE definitions are forwarded during session operations. The agent connects to and operates those servers. This client is not an MCP server or an MCP-over-ACP proxy. - Additional directories: passed only through supported session operations, restored with saved sessions, and included in client callback routing. diff --git a/internal/client/client.go b/internal/client/client.go index c044cb3..68cc72f 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -54,6 +54,8 @@ type Client struct { shutdownOnce sync.Once shutdownErr error op sync.Mutex + steerOp sync.Mutex + steering steeringState mu sync.Mutex current store.Session wire acp.Session @@ -214,6 +216,13 @@ func (c *Client) Save() error { if s.ID == "" || s.RemoteID == "" { return nil } + messages := s.Messages[:0] + for _, message := range s.Messages { + if !message.Pending { + messages = append(messages, message) + } + } + s.Messages = messages return c.store.Save(s) } @@ -545,6 +554,7 @@ func (c *Client) PromptContent(blocks []acp.Content) (reason schema.StopReason, c.mu.Lock() c.turnCancel = cancel c.turnDone = done + c.steering = steeringState{wake: make(chan struct{}), disabled: c.steering.disabled, sequence: c.steering.sequence} c.permissionCtx = permissions c.permissionCancel = stopPermissions c.cancelRequested = false @@ -580,6 +590,9 @@ func (c *Client) PromptContent(blocks []acp.Content) (reason schema.StopReason, blocks = append([]acp.Content{acp.NewTextContent(s.PendingContext)}, blocks...) } reason, err = c.conn.PromptContent(ctx, c.Init, c.session(), blocks) + if steerErr := c.finishSteering(ctx); steerErr != nil { + err = errors.Join(err, steerErr) + } c.mu.Lock() cancelled := c.cancelRequested if reason == schema.StopReasonCancelled { @@ -607,6 +620,7 @@ func (c *Client) Cancel() error { stopPermissions := c.permissionCancel if c.turnDone != nil { c.cancelRequested = true + c.wakeSteering() c.cancelTurnTools() } id := c.wire.SessionID diff --git a/internal/client/steering.go b/internal/client/steering.go new file mode 100644 index 0000000..6f2a2f9 --- /dev/null +++ b/internal/client/steering.go @@ -0,0 +1,207 @@ +package client + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + acp "github.com/BrokkAi/acp-go" + "github.com/BrokkAi/acp-go/schema" + "github.com/BrokkAi/micro-acp/internal/store" +) + +type SteeringOutcome string + +const ( + SteeringInjected SteeringOutcome = "injected" + SteeringStartedNewTurn SteeringOutcome = "startedNewTurn" + SteeringPromptRequired SteeringOutcome = "promptRequired" +) + +type steeringState struct { + pending int + sequence uint64 + closing, disabled bool + ready, promptReturned bool + wake chan struct{} + status string + generation, detachedGeneration uint64 +} + +func (c *Client) CanSteer() bool { + c.mu.Lock() + defer c.mu.Unlock() + capability, _ := c.Init.Meta["steering"].(map[string]any) + return capability["supported"] == true && !c.steering.disabled +} + +// SteerContent joins an active prompt without cancelling it or taking the +// session-operation lock. Only explicit non-delivery permits automatic fallback. +func (c *Client) SteerContent(sessionID string, blocks []acp.Content) (outcome SteeringOutcome, err error) { + c.steerOp.Lock() + defer c.steerOp.Unlock() + if err := c.validateContent(blocks); err != nil { + return "", err + } + if !c.CanSteer() { + return SteeringPromptRequired, nil + } + c.mu.Lock() + if c.current.ID != sessionID { + c.mu.Unlock() + return "", errors.New("steering belongs to another session") + } + if c.turnDone == nil || c.steering.closing { + c.mu.Unlock() + return SteeringPromptRequired, nil + } + if c.cancelRequested { + c.mu.Unlock() + return "", errors.New("turn is stopping; follow-up retained in queue") + } + c.steering.pending++ + c.steering.sequence++ + id := fmt.Sprintf("steer-%d", c.steering.sequence) + c.mu.Unlock() + defer func() { + c.mu.Lock() + defer c.mu.Unlock() + for i := range c.current.Messages { + message := &c.current.Messages[i] + if message.Pending && message.ID == id { + if err == nil && (outcome == SteeringInjected || outcome == SteeringStartedNewTurn) { + message.Pending = false + } else { + c.current.Messages = append(c.current.Messages[:i], c.current.Messages[i+1:]...) + } + break + } + } + c.steering.pending-- + c.current.UpdatedAt = time.Now().UTC() + c.revision++ + c.wakeSteering() + }() + // The prompt may still be saving its initial history before writing the RPC. + // Wait for turn activity or its response so steering cannot overtake it. + for { + c.mu.Lock() + s := c.steering + stopping := c.cancelRequested + if s.ready || s.promptReturned || stopping { + c.mu.Unlock() + if stopping { + return "", errors.New("turn is stopping; follow-up retained in queue") + } + if !s.ready { + return SteeringPromptRequired, nil + } + break + } + c.mu.Unlock() + select { + case <-s.wake: + case <-c.conn.Done(): + return "", errors.New("agent disconnected before steering could be sent") + case <-c.ctx.Done(): + return "", c.ctx.Err() + } + } + c.mu.Lock() + nextGeneration := max(uint64(1), c.steering.generation) + 1 + var text []string + for _, block := range blocks { + text = append(text, ContentText(block)) + } + c.current.Messages = append(c.current.Messages, store.Message{Role: "user", ID: id, Text: strings.Join(text, "\n"), Content: blocks, Pending: true}) + remoteID := c.wire.SessionID + c.revision++ + c.mu.Unlock() + request := schema.PromptRequest{SessionID: remoteID, Prompt: blocks, Meta: schema.Meta{"steering": map[string]any{"idleBehavior": "promptRequired"}}} + var response struct { + Outcome SteeringOutcome `json:"outcome"` + } + if err = c.conn.Call(c.ctx, "_session/steering", request, &response); err != nil { + var rpcErr *acp.RPCError + if errors.As(err, &rpcErr) && rpcErr.Code == -32601 { + c.mu.Lock() + c.steering.disabled = true + c.mu.Unlock() + return SteeringPromptRequired, nil + } + return "", fmt.Errorf("steering delivery was not confirmed; check the transcript before retrying: %w", err) + } + switch response.Outcome { + case SteeringInjected, SteeringPromptRequired: + return response.Outcome, nil + case SteeringStartedNewTurn: + // Codex starts a detached continuation if the original turn settled. + // Its thread-status updates own completion; never send the input again. + c.mu.Lock() + c.steering.detachedGeneration = nextGeneration + stopping := c.cancelRequested + c.mu.Unlock() + if stopping { + _ = c.Cancel() + } + return response.Outcome, nil + case "failed": + return "", errors.New("agent rejected steering; follow-up retained in queue") + default: + return "", fmt.Errorf("unknown steering outcome %q; check the transcript before retrying", response.Outcome) + } +} + +// Called with mu held. These metadata updates describe Codex's detached turn +// lifecycle; Claude honors the promptRequired opt-in instead. +func (c *Client) steeringStatus(meta schema.Meta) { + codex, _ := meta["codex"].(map[string]any) + status, _ := codex["threadStatus"].(map[string]any) + value, _ := status["type"].(string) + if value != "active" && value != "idle" && value != "systemError" { + return + } + if value == "active" && c.steering.status != "active" { + c.steering.generation++ + c.steering.ready = true + } + c.steering.status = value + c.wakeSteering() +} + +func (c *Client) wakeSteering() { + if c.steering.wake != nil { + close(c.steering.wake) + c.steering.wake = make(chan struct{}) + } +} + +func (c *Client) finishSteering(ctx context.Context) error { + c.mu.Lock() + c.steering.promptReturned = true + c.wakeSteering() + c.mu.Unlock() + for { + c.mu.Lock() + s := c.steering + detachedDone := s.detachedGeneration == 0 || (s.generation >= s.detachedGeneration && (s.status == "idle" || s.status == "systemError")) + if s.pending == 0 && detachedDone { + c.steering.closing = true + c.mu.Unlock() + if s.detachedGeneration != 0 && s.status == "systemError" { + return errors.New("steered continuation failed") + } + return nil + } + c.mu.Unlock() + select { + case <-s.wake: + case <-c.conn.Done(): + return fmt.Errorf("agent disconnected during steering: %w", c.conn.Err()) + case <-ctx.Done(): + return ctx.Err() + } + } +} diff --git a/internal/client/steering_test.go b/internal/client/steering_test.go new file mode 100644 index 0000000..2af1564 --- /dev/null +++ b/internal/client/steering_test.go @@ -0,0 +1,315 @@ +package client + +import ( + "context" + "encoding/json" + "net" + "reflect" + "strings" + "testing" + "time" + + acp "github.com/BrokkAi/acp-go" + "github.com/BrokkAi/acp-go/schema" + "github.com/BrokkAi/micro-acp/internal/store" +) + +func steeringClient(t *testing.T, handler acp.Handler) (*Client, *acp.Connection) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + left, right := net.Pipe() + c := &Client{ctx: ctx, cancel: cancel, store: store.Store{Directory: t.TempDir()}, current: store.NewSession("test", "remote", t.TempDir()), wire: acp.Session{SessionID: "remote"}} + c.Init.Meta = schema.Meta{"steering": map[string]any{"supported": true}} + c.conn = acp.Connect(left, left, nil, c.notification) + agent := acp.Connect(right, right, handler, nil) + t.Cleanup(func() { cancel(); _ = c.conn.Close(); _ = agent.Close() }) + return c, agent +} + +func steeringStatusUpdate(t *testing.T, c *acp.Connection, status string) { + t.Helper() + err := c.Notify(context.Background(), "session/update", map[string]any{"sessionId": "remote", "update": map[string]any{"sessionUpdate": "session_info_update", "_meta": map[string]any{"codex": map[string]any{"threadStatus": map[string]any{"type": status}}}}}) + if err != nil { + t.Error(err) + } +} + +func TestSteeringAcceptanceKeepsOrderingAndDoesNotCancel(t *testing.T) { + started, received, acknowledge, finish := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) + var agent *acp.Connection + blocks := []acp.Content{acp.NewTextContent("Change direction"), acp.NewResourceLinkContent("Spec", "https://example.com/spec")} + c, remote := steeringClient(t, func(ctx context.Context, method string, raw json.RawMessage) (any, error) { + switch method { + case "session/prompt": + steeringStatusUpdate(t, agent, "active") + close(started) + select { + case <-finish: + case <-ctx.Done(): + return nil, ctx.Err() + } + return schema.PromptResponse{StopReason: schema.StopReasonEndTurn}, nil + case "_session/steering": + var request schema.PromptRequest + if err := json.Unmarshal(raw, &request); err != nil { + return nil, err + } + got, _ := json.Marshal(request.Prompt) + want, _ := json.Marshal(blocks) + if string(got) != string(want) || request.SessionID != "remote" || request.Meta["steering"].(map[string]any)["idleBehavior"] != "promptRequired" { + t.Errorf("incorrect steering request: %s", raw) + } + _ = agent.Notify(ctx, "session/update", acp.NewAgentMessageChunkUpdate("remote", schema.ContentChunk{Content: acp.NewTextContent("Following the new direction")})) + _ = agent.Notify(ctx, "session/update", acp.NewUserMessageChunkUpdate("remote", schema.ContentChunk{Content: blocks[0]})) + close(received) + select { + case <-acknowledge: + case <-ctx.Done(): + return nil, ctx.Err() + } + return map[string]string{"outcome": "injected"}, nil + default: + t.Errorf("unexpected request (steering must not cancel): %s", method) + return nil, &acp.RPCError{Code: -32601, Message: "unsupported"} + } + }) + agent = remote + promptDone := make(chan error, 1) + go func() { _, err := c.Prompt("Original task"); promptDone <- err }() + <-started + steerDone := make(chan error, 1) + go func() { + outcome, err := c.SteerContent(c.current.ID, blocks) + if outcome != SteeringInjected && err == nil { + t.Errorf("outcome = %s", outcome) + } + steerDone <- err + }() + <-received + if err := c.Save(); err != nil { + t.Fatal(err) + } + saved, err := c.store.Load(c.current.ID) + if err != nil { + t.Fatal(err) + } + for _, message := range saved.Messages { + if message.Role == "user" && message.Text != "Original task" { + t.Fatal("unacknowledged steering persisted as accepted") + } + } + close(acknowledge) + if err := <-steerDone; err != nil { + t.Fatal(err) + } + close(finish) + if err := <-promptDone; err != nil { + t.Fatal(err) + } + snapshot, _ := c.Snapshot() + if len(snapshot.Messages) != 3 || snapshot.Messages[1].Pending || snapshot.Messages[1].Role != "user" || !reflect.DeepEqual(snapshot.Messages[1].Content, blocks) || snapshot.Messages[2].Text != "Following the new direction" { + t.Fatalf("steering was lost, duplicated, or reordered: %+v", snapshot.Messages) + } +} + +func TestSteeringNonDeliveryAndFailures(t *testing.T) { + for _, outcome := range []string{"promptRequired", "failed", "futureOutcome", "unsupported"} { + t.Run(outcome, func(t *testing.T) { + started, finish := make(chan struct{}), make(chan struct{}) + var agent *acp.Connection + c, remote := steeringClient(t, func(ctx context.Context, method string, raw json.RawMessage) (any, error) { + if method == "session/prompt" { + steeringStatusUpdate(t, agent, "active") + close(started) + select { + case <-finish: + case <-ctx.Done(): + return nil, ctx.Err() + } + return schema.PromptResponse{StopReason: schema.StopReasonEndTurn}, nil + } + if outcome == "unsupported" { + return nil, &acp.RPCError{Code: -32601, Message: "unsupported"} + } + return map[string]string{"outcome": outcome}, nil + }) + agent = remote + done := make(chan error, 1) + go func() { _, err := c.Prompt("Original"); done <- err }() + <-started + result, err := c.SteerContent(c.current.ID, []acp.Content{acp.NewTextContent("Follow-up")}) + fallback := outcome == "promptRequired" || outcome == "unsupported" + if fallback && (err != nil || result != SteeringPromptRequired) || !fallback && err == nil { + t.Fatalf("outcome=%s err=%v", result, err) + } + if c.CanSteer() == (outcome == "unsupported") { + t.Fatal("incorrect capability after fallback") + } + close(finish) + if err := <-done; err != nil { + t.Fatal(err) + } + snapshot, _ := c.Snapshot() + if len(snapshot.Messages) != 1 { + t.Fatalf("undelivered steer left in transcript: %+v", snapshot.Messages) + } + }) + } +} + +func TestSteeringDetachedContinuationOwnsCompletion(t *testing.T) { + for _, earlyFinish := range []bool{false, true} { + t.Run(map[bool]string{false: "streaming", true: "finished-before-ack"}[earlyFinish], func(t *testing.T) { + started, originalDone, originalIdle := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var agent *acp.Connection + c, remote := steeringClient(t, func(ctx context.Context, method string, raw json.RawMessage) (any, error) { + if method == "session/prompt" { + steeringStatusUpdate(t, agent, "active") + close(started) + select { + case <-originalDone: + case <-ctx.Done(): + return nil, ctx.Err() + } + steeringStatusUpdate(t, agent, "idle") + close(originalIdle) + return schema.PromptResponse{StopReason: schema.StopReasonEndTurn}, nil + } + close(originalDone) + <-originalIdle + steeringStatusUpdate(t, agent, "active") + if earlyFinish { + steeringStatusUpdate(t, agent, "idle") + } + return map[string]string{"outcome": "startedNewTurn"}, nil + }) + agent = remote + done := make(chan error, 1) + go func() { _, err := c.Prompt("Original"); done <- err }() + <-started + outcome, err := c.SteerContent(c.current.ID, []acp.Content{acp.NewTextContent("Late guidance")}) + if err != nil || outcome != SteeringStartedNewTurn { + t.Fatalf("%s: %v", outcome, err) + } + if !earlyFinish { + select { + case err := <-done: + t.Fatalf("returned while detached turn still running: %v", err) + case <-time.After(20 * time.Millisecond): + } + steeringStatusUpdate(t, agent, "idle") + } + if err := <-done; err != nil { + t.Fatal(err) + } + }) + } +} + +func TestSteeringWithoutActiveTurnLeavesInputWithCaller(t *testing.T) { + c, _ := steeringClient(t, func(context.Context, string, json.RawMessage) (any, error) { + t.Error("idle steering sent a request") + return nil, nil + }) + result, err := c.SteerContent(c.current.ID, []acp.Content{acp.NewTextContent("next")}) + if err != nil || result != SteeringPromptRequired { + t.Fatalf("%s: %v", result, err) + } + _, err = c.SteerContent("wrong-session", []acp.Content{acp.NewTextContent("next")}) + if err == nil || !strings.Contains(err.Error(), "another session") { + t.Fatalf("session guard: %v", err) + } +} + +func TestSteeringWaitsForPromptActivity(t *testing.T) { + for _, streams := range []bool{false, true} { + t.Run(map[bool]string{false: "no-output", true: "streaming"}[streams], func(t *testing.T) { + started, finish := make(chan struct{}), make(chan struct{}) + called := make(chan struct{}, 1) + c, remote := steeringClient(t, func(ctx context.Context, method string, raw json.RawMessage) (any, error) { + if method == "session/prompt" { + close(started) + select { + case <-finish: + case <-ctx.Done(): + return nil, ctx.Err() + } + return schema.PromptResponse{StopReason: schema.StopReasonEndTurn}, nil + } + called <- struct{}{} + return map[string]string{"outcome": "injected"}, nil + }) + promptDone := make(chan error, 1) + go func() { _, err := c.Prompt("Original"); promptDone <- err }() + <-started + steerDone := make(chan SteeringOutcome, 1) + go func() { + outcome, err := c.SteerContent(c.current.ID, []acp.Content{acp.NewTextContent("Fast follow-up")}) + if err != nil { + t.Error(err) + } + steerDone <- outcome + }() + select { + case <-called: + t.Fatal("steering overtook prompt readiness") + case <-time.After(20 * time.Millisecond): + } + want := SteeringPromptRequired + if streams { + steeringStatusUpdate(t, remote, "active") + want = SteeringInjected + } else { + close(finish) + } + if outcome := <-steerDone; outcome != want { + t.Fatalf("got %s, want %s", outcome, want) + } + if streams { + close(finish) + } + if err := <-promptDone; err != nil { + t.Fatal(err) + } + }) + } +} + +func TestDetachedSteeringDisconnectUnblocksTurn(t *testing.T) { + started, finish, idle := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var agent *acp.Connection + c, remote := steeringClient(t, func(ctx context.Context, method string, raw json.RawMessage) (any, error) { + if method == "session/prompt" { + steeringStatusUpdate(t, agent, "active") + close(started) + select { + case <-finish: + case <-ctx.Done(): + return nil, ctx.Err() + } + steeringStatusUpdate(t, agent, "idle") + close(idle) + return schema.PromptResponse{StopReason: schema.StopReasonEndTurn}, nil + } + close(finish) + <-idle + steeringStatusUpdate(t, agent, "active") + return map[string]string{"outcome": "startedNewTurn"}, nil + }) + agent = remote + done := make(chan error, 1) + go func() { _, err := c.Prompt("Original"); done <- err }() + <-started + if _, err := c.SteerContent(c.current.ID, []acp.Content{acp.NewTextContent("late")}); err != nil { + t.Fatal(err) + } + _ = remote.Close() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "disconnected") { + t.Fatalf("missing disconnect: %v", err) + } + case <-time.After(time.Second): + t.Fatal("detached continuation stayed busy after disconnect") + } +} diff --git a/internal/client/updates.go b/internal/client/updates.go index c38213d..9e11936 100644 --- a/internal/client/updates.go +++ b/internal/client/updates.go @@ -84,6 +84,10 @@ func (c *Client) notification(method string, raw json.RawMessage) error { return nil } u := update.Update + if c.turnDone != nil && !c.steering.ready && (u.AgentMessageChunk != nil || u.AgentThoughtChunk != nil || u.ToolCall != nil || u.Plan != nil) { + c.steering.ready = true + c.wakeSteering() + } if c.current.RemoteID == "" && update.SessionID != "" { c.current.RemoteID = string(update.SessionID) c.wire.SessionID = update.SessionID @@ -150,6 +154,7 @@ func (c *Client) notification(method string, raw json.RawMessage) error { } c.current.Messages = append(c.current.Messages, store.Message{Role: "plan", Text: strings.Join(lines, "\n")}) case u.SessionInfoUpdate != nil: + c.steeringStatus(u.SessionInfoUpdate.Meta) if u.SessionInfoUpdate.Title != nil { c.current.Title = *u.SessionInfoUpdate.Title } diff --git a/internal/store/store.go b/internal/store/store.go index 1e56adf..9c01561 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -24,6 +24,8 @@ type Message struct { Tool *schema.ToolCall `json:"tool,omitempty"` // Cancelled is a local display state; ACP v1 has no cancelled tool status. Cancelled bool `json:"cancelled,omitempty"` + // Pending steering is withheld from scrollback and saved history until accepted. + Pending bool `json:"-"` } type Session struct { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 4116cfc..5ed6881 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -1,7 +1,6 @@ package tui import ( - "fmt" "net/url" "strings" @@ -15,7 +14,7 @@ var commands = []item{ {title: "Mode", description: "Choose the agent's operating mode", id: "/mode"}, {title: "Reasoning effort", description: "Choose how much the model reasons", id: "/effort"}, {title: "Tool details", description: "Expand tools, reasoning and full output · Ctrl+O", id: "/details"}, - {title: "Queued prompts", description: "Edit queued prompts; /queue send continues a paused queue", id: "/queue"}, + {title: "Queued prompts", description: "Edit or remove prompts · /queue send resumes · /queue clear removes all", id: "/queue"}, {title: "New session", description: "Start a fresh conversation", id: "/new"}, {title: "Close session", description: "Close the active session while keeping its history", id: "/close"}, @@ -186,8 +185,17 @@ func (m *model) command(text string) tea.Cmd { m.viewport.GotoBottom() return nil case "/queue": - if arg == "send" && !m.busy && len(m.queued) > 0 { - return m.sendQueued() + if arg == "send" { + m.queuePaused = false + return m.dispatchQueue() + } + if arg == "clear" { + m.queued = nil + m.queuePaused = false + if m.editing != nil { + m.restoreQueueDraft() + } + return nil } m.openPicker("queue", m.queueItems()) return nil @@ -233,31 +241,5 @@ func (m *model) allCommands() []item { } func (m *model) sendPrompt(text string) tea.Cmd { - if m.client == nil { - m.lastError = "Choose an agent with /agents first" - return nil - } - if m.busy && !m.prompting { - m.lastError = "Wait for the agent connection to finish" - return nil - } - blocks, err := m.promptBlocks(text) - if err != nil { - m.lastError = err.Error() - return nil - } - s, _ := m.client.Snapshot() - m.queueSequence++ - q := queuedPrompt{id: m.queueSequence, text: text, blocks: blocks, attachments: m.attachments, resources: m.resources, sessionID: s.ID} - m.input.Reset() - m.completion = nil - m.attachments = nil - m.resources = nil - if m.prompting { - m.queued = append(m.queued, q) - m.status = fmt.Sprintf("%d prompt(s) queued", len(m.queued)) - return nil - } - m.queuePaused = false - return m.startPrompt(q) + return m.submitPrompt(text, false) } diff --git a/internal/tui/model.go b/internal/tui/model.go index b1ae8e9..f00455d 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -57,12 +57,14 @@ type permissionMsg struct{ permission client.Permission } type pulseMsg time.Time type printedMsg struct{} type queuedPrompt struct { - id uint64 - text string - blocks []acp.Content - attachments []string - resources []acp.Content - sessionID string + id uint64 + text string + blocks []acp.Content + attachments []string + resources []acp.Content + sessionID string + steer bool + deliveryError string } type model struct { @@ -111,6 +113,9 @@ type model struct { queued []queuedPrompt queueSequence uint64 queuePaused bool + steering *queuedPrompt + editing *queuedPrompt + savedDraft *queuedPrompt printedSession string printedIndex, streamPrefix int live string @@ -402,6 +407,9 @@ func (m *model) Update(msg tea.Msg) (updated tea.Model, cmd tea.Cmd) { case resultMsg: m.busy = false m.prompting = false + if m.steering != nil { + m.busy = true + } if msg.prompt { m.filesLoaded = false } @@ -439,9 +447,11 @@ func (m *model) Update(msg tea.Msg) (updated tea.Model, cmd tea.Cmd) { } m.syncTranscript() m.rebuildHistory() - if msg.prompt && msg.err == nil && !m.queuePaused && len(m.queued) > 0 { - return m, m.sendQueued() + if msg.prompt && msg.err == nil { + return m, m.dispatchQueue() } + case steeredMsg: + return m, m.steeringResult(msg) case sessionsMsg: m.busy = false m.status = "Ready" @@ -599,6 +609,11 @@ func (m *model) key(msg tea.KeyPressMsg) tea.Cmd { m.viewport, cmd = m.viewport.Update(msg) return cmd } + if k == "esc" && m.editing != nil { + m.insertQueued(*m.editing) + m.restoreQueueDraft() + return m.dispatchQueue() + } if handled, cmd := m.completionKey(msg); handled { return cmd } @@ -635,6 +650,10 @@ func (m *model) key(msg tea.KeyPressMsg) tea.Cmd { } return nil case "alt+up", "up": + if k == "alt+up" && len(m.queued) > 0 && m.editing == nil { + m.editQueued(len(m.queued) - 1) + return nil + } if (k == "alt+up" || m.input.Line() == 0) && len(m.history) > 0 { if m.historyIndex == len(m.history) { m.draft = m.input.Value() @@ -655,14 +674,23 @@ func (m *model) key(msg tea.KeyPressMsg) tea.Cmd { m.input.MoveToEnd() return nil } + case "tab": + if m.prompting || m.steering != nil || len(m.queued) > 0 || m.editing != nil { + return m.submitPrompt(strings.TrimSpace(m.input.Value()), true) + } case "enter": text := strings.TrimSpace(m.input.Value()) if text == "" && len(m.attachments) == 0 && len(m.resources) == 0 { return nil } - if strings.HasPrefix(text, "/") { + if strings.HasPrefix(text, "/") && m.editing == nil { m.input.Reset() - return m.command(text) + cmd := m.command(text) + if m.lastError != "" && m.input.Value() == "" { + m.input.SetValue(text) + m.input.MoveToEnd() + } + return cmd } return m.sendPrompt(text) } @@ -759,11 +787,14 @@ func (m *model) startPrompt(q queuedPrompt) tea.Cmd { } func (m *model) sendQueued() tea.Cmd { + if len(m.queued) == 0 || m.busy || m.steering != nil || m.editing != nil { + return nil + } q := m.queued[0] s, _ := m.client.Snapshot() if q.sessionID != s.ID { m.queuePaused = true - m.lastError = "Queued prompts belong to another session. Resume it or use /queue to edit them." + m.lastError = "Queued prompts belong to another session. Resume it or remove them with /queue." return nil } m.queued = m.queued[1:] diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 7736921..01bcbe5 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -93,10 +93,6 @@ func (m *model) pickerKey(msg tea.KeyPressMsg) tea.Cmd { c, id, value := m.client, m.selector.ID, selected.id return m.perform("Updating "+m.selector.Name, func() error { return c.SetConfig(id, value) }) case "queue": - if m.input.Value() != "" || len(m.attachments)+len(m.resources) > 0 { - m.lastError = "Send or clear the current draft before editing a queued prompt" - return nil - } i := -1 for index, q := range m.queued { if q.id == selected.value.(uint64) { @@ -108,15 +104,21 @@ func (m *model) pickerKey(msg tea.KeyPressMsg) tea.Cmd { m.lastError = "This prompt has already been sent" return nil } - q := m.queued[i] - m.queued = append(m.queued[:i], m.queued[i+1:]...) - m.input.SetValue(q.text) - m.input.MoveToEnd() - m.attachments = q.attachments - m.resources = q.resources + m.editQueued(i) } return nil case "ctrl+d": + if p.kind == "queue" && len(p.matches) > 0 { + id := p.matches[p.index].value.(uint64) + for i, q := range m.queued { + if q.id == id { + m.queued = append(m.queued[:i], m.queued[i+1:]...) + break + } + } + m.refreshQueuePicker() + return nil + } if p.kind == "sessions" && len(p.matches) > 0 { s := p.matches[p.index].value.(store.Session) m.confirm = &s @@ -134,7 +136,11 @@ func (m *model) pickerKey(msg tea.KeyPressMsg) tea.Cmd { func (m *model) queueItems() []item { var entries []item for _, q := range m.queued { - entries = append(entries, item{title: q.text, description: "Enter to edit this queued prompt", id: strconv.FormatUint(q.id, 10), value: q.id}) + description := "Enter edit · Ctrl+D remove" + if q.deliveryError != "" { + description = q.deliveryError + } + entries = append(entries, item{title: q.text, description: description, id: strconv.FormatUint(q.id, 10), value: q.id}) } return entries } diff --git a/internal/tui/queue.go b/internal/tui/queue.go new file mode 100644 index 0000000..238bf64 --- /dev/null +++ b/internal/tui/queue.go @@ -0,0 +1,182 @@ +package tui + +import ( + "fmt" + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/BrokkAi/micro-acp/internal/client" +) + +type steeredMsg struct { + client *client.Client + prompt queuedPrompt + outcome client.SteeringOutcome + err error +} + +func (m *model) submitPrompt(text string, queue bool) tea.Cmd { + if strings.TrimSpace(text) == "" && len(m.attachments)+len(m.resources) == 0 { + return nil + } + if m.client == nil { + m.lastError = "Choose an agent with /agents first" + return nil + } + if m.busy && !m.prompting && m.steering == nil { + m.lastError = "Wait for the agent connection to finish" + return nil + } + s, _ := m.client.Snapshot() + if m.editing != nil && m.editing.sessionID != s.ID { + m.lastError = "Resume the queued prompt's session before sending it" + return nil + } + blocks, err := m.promptBlocks(text) + if err != nil { + m.lastError = err.Error() + return nil + } + m.queueSequence++ + q := queuedPrompt{id: m.queueSequence, text: text, blocks: blocks, attachments: m.attachments, resources: m.resources, sessionID: s.ID} + m.lastError = "" + m.input.Reset() + m.completion = nil + m.attachments, m.resources = nil, nil + if m.editing != nil { + q.id = m.editing.id + m.insertQueued(q) + m.restoreQueueDraft() + return m.dispatchQueue() + } + q.steer = !queue && m.prompting && !m.queuePaused && m.client.CanSteer() + if m.prompting || m.steering != nil || len(m.queued) > 0 { + m.insertQueued(q) + return m.dispatchQueue() + } + m.queuePaused = false + return m.startPrompt(q) +} + +func (m *model) insertQueued(q queuedPrompt) { + m.queued = append(m.queued, q) + // Editing or restoring a failed submission retains its original FIFO slot. + slices.SortStableFunc(m.queued, func(a, b queuedPrompt) int { + if a.id < b.id { + return -1 + } + if a.id > b.id { + return 1 + } + return 0 + }) +} + +func (m *model) dispatchQueue() tea.Cmd { + if m.client == nil || m.steering != nil || m.editing != nil || m.queuePaused { + return nil + } + if !m.prompting { + return m.sendQueued() + } + s, _ := m.client.Snapshot() + for i, q := range m.queued { + if !q.steer || q.sessionID != s.ID { + continue + } + if !m.client.CanSteer() { + m.queued[i].steer = false + continue + } + m.queued = append(m.queued[:i], m.queued[i+1:]...) + m.steering = &q + c := m.client + return func() tea.Msg { + outcome, err := c.SteerContent(q.sessionID, q.blocks) + return steeredMsg{client: c, prompt: q, outcome: outcome, err: err} + } + } + return nil +} + +func (m *model) steeringResult(msg steeredMsg) tea.Cmd { + if msg.client != m.client || m.steering == nil || msg.prompt.id != m.steering.id { + return nil + } + m.steering = nil + if !m.prompting { + m.busy = false + } + if msg.err != nil || msg.outcome == client.SteeringPromptRequired { + q := msg.prompt + q.steer = false + if msg.err != nil { + q.deliveryError = msg.err.Error() + m.queuePaused = true + m.lastError = msg.err.Error() + } + m.insertQueued(q) + } + return m.dispatchQueue() +} + +func (m *model) editQueued(index int) { + if m.editing != nil || index < 0 || index >= len(m.queued) { + return + } + q := m.queued[index] + m.queued = append(m.queued[:index], m.queued[index+1:]...) + m.savedDraft = &queuedPrompt{text: m.input.Value(), attachments: m.attachments, resources: m.resources} + m.editing = &q + m.input.SetValue(q.text) + m.input.MoveToEnd() + m.attachments, m.resources = q.attachments, q.resources + m.completion = nil + m.lastError = "" +} + +func (m *model) restoreQueueDraft() { + if m.savedDraft != nil { + m.input.SetValue(m.savedDraft.text) + m.input.MoveToEnd() + m.attachments, m.resources = m.savedDraft.attachments, m.savedDraft.resources + } + m.editing, m.savedDraft = nil, nil + m.completion = nil +} + +func (m *model) queueView(width int) string { + var rows []string + if m.steering != nil { + rows = append(rows, m.theme.amber.Render(line("› Sending guidance: "+m.steering.text, width))) + } + if m.editing != nil { + rows = append(rows, m.theme.amber.Render(line("Editing queued prompt · Enter save · Esc restore", width))) + } + if len(m.queued) > 0 { + header := fmt.Sprintf("%d queued · Alt+↑ edit last · /queue", len(m.queued)) + if m.queuePaused { + header = fmt.Sprintf("%d queued · /queue send to continue", len(m.queued)) + } + rows = append(rows, m.theme.muted.Render(line(header, width))) + limit := min(3, max(1, m.height/8)) + for i, q := range m.queued[:min(limit, len(m.queued))] { + label := strings.Join(strings.Fields(q.text), " ") + if label == "" { + label = "Attached context" + } + if q.steer { + label = "steer · " + label + } + if q.deliveryError != "" { + label = "check delivery · " + label + } + rows = append(rows, m.theme.cyan.Render(fmt.Sprintf(" %d ", i+1))+line(label, width-4)) + } + if len(m.queued) > limit { + rows = append(rows, m.theme.muted.Render(fmt.Sprintf(" +%d more", len(m.queued)-limit))) + } + } + return strings.Join(rows, "\n") +} diff --git a/internal/tui/queue_test.go b/internal/tui/queue_test.go new file mode 100644 index 0000000..1e78c19 --- /dev/null +++ b/internal/tui/queue_test.go @@ -0,0 +1,172 @@ +package tui + +import ( + "errors" + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + acp "github.com/BrokkAi/acp-go" + "github.com/BrokkAi/acp-go/schema" + "github.com/BrokkAi/micro-acp/internal/client" + "github.com/charmbracelet/x/ansi" +) + +func queueComposer(t *testing.T, steering bool) *model { + t.Helper() + m := composer(t) + m.client = &client.Client{Agent: "test", Init: acp.Initialization{Meta: schema.Meta{"steering": map[string]any{"supported": steering}}}} + m.busy, m.prompting = true, true + return m +} + +func TestEnterSteersAndTabQueuesWithoutCancelling(t *testing.T) { + m := queueComposer(t, true) + m.input.SetValue("Follow up later") + m.key(keyPress(tea.KeyTab, 0)) + if len(m.queued) != 1 || m.queued[0].steer || m.steering != nil || m.input.Value() != "" { + t.Fatal("Tab did not queue the draft") + } + m.input.SetValue("Change direction now") + if cmd := m.key(keyPress(tea.KeyEnter, 0)); cmd == nil || m.steering == nil || m.steering.text != "Change direction now" || len(m.queued) != 1 || !m.prompting { + t.Fatal("Enter did not steer independently of queued work") + } + pending := *m.steering + m.input.SetValue("More guidance") + if cmd := m.key(keyPress(tea.KeyEnter, 0)); cmd != nil || len(m.queued) != 2 { + t.Fatal("concurrent steers were not serialized") + } + if cmd := m.steeringResult(steeredMsg{client: m.client, prompt: pending, outcome: client.SteeringInjected}); cmd == nil || m.steering.text != "More guidance" || len(m.queued) != 1 { + t.Fatal("next steering message did not retain submission order") + } + plain := queueComposer(t, false) + plain.input.SetValue("Follow-up") + if cmd := plain.key(keyPress(tea.KeyEnter, 0)); cmd != nil || len(plain.queued) != 1 || plain.steering != nil { + t.Fatal("agent without steering did not use the local queue") + } +} + +func TestQueueEditPreservesPositionAndExistingDraft(t *testing.T) { + m := queueComposer(t, false) + for _, text := range []string{"first", "second", "third"} { + m.sendPrompt(text) + } + m.input.SetValue("unfinished draft") + m.resources = []acp.Content{acp.NewResourceLinkContent("draft context", "https://example.com/draft")} + m.editQueued(1) + if m.input.Value() != "second" || m.editing == nil { + t.Fatal("queued prompt did not open for editing") + } + // The first task finishing while a prompt is edited must not drain past it. + m.Update(resultMsg{prompt: true, reason: schema.StopReasonEndTurn}) + if m.prompting || len(m.queued) != 2 { + t.Fatal("queue drained during editing") + } + m.input.SetValue("edited second") + cmd := m.sendPrompt(m.input.Value()) + if cmd == nil || !m.prompting || len(m.queued) != 2 || m.queued[0].text != "edited second" || m.queued[1].text != "third" { + t.Fatalf("edit reordered queue: %+v", m.queued) + } + if m.input.Value() != "unfinished draft" || len(m.resources) != 1 || m.editing != nil { + t.Fatal("editing lost the previous draft or its attachment") + } + m.key(keyPress(tea.KeyUp, tea.ModAlt)) + if m.input.Value() != "third" { + t.Fatal("Alt+Up did not edit the latest queued prompt") + } + m.input.SetValue("discard this edit") + m.key(keyPress(tea.KeyEscape, 0)) + if m.input.Value() != "unfinished draft" || len(m.queued) != 2 || m.queued[1].text != "third" { + t.Fatal("Esc did not restore the original queue entry and draft") + } +} + +func TestQueuedPromptsCannotBeOvertakenByNewIdleSubmission(t *testing.T) { + m := queueComposer(t, false) + m.sendPrompt("first") + m.busy, m.prompting = false, false + m.sendPrompt("second") + if !m.prompting || len(m.queued) != 1 || m.queued[0].text != "second" { + t.Fatalf("new input bypassed FIFO: %+v", m.queued) + } +} + +func TestFreshPromptAfterStopDoesNotNeedQueueResume(t *testing.T) { + m := queueComposer(t, false) + m.Update(resultMsg{prompt: true, reason: schema.StopReasonCancelled}) + if cmd := m.sendPrompt("new direction"); cmd == nil || !m.prompting || m.queuePaused || len(m.queued) != 0 { + t.Fatal("a fresh prompt was stranded in an empty paused queue") + } +} + +func TestSteeringFallbackAndAmbiguousFailureRetainPrompt(t *testing.T) { + for _, failed := range []bool{false, true} { + for _, turnFinished := range []bool{false, true} { + m := queueComposer(t, true) + m.sendPrompt("guidance") + pending := *m.steering + if turnFinished { + m.Update(resultMsg{prompt: true, reason: schema.StopReasonEndTurn}) + if !m.busy || m.prompting { + t.Fatal("unresolved steering must prevent queue dispatch") + } + } + msg := steeredMsg{client: m.client, prompt: pending, outcome: client.SteeringPromptRequired} + if failed { + msg.err = errors.New("delivery unconfirmed") + } + cmd := m.steeringResult(msg) + if failed { + if !m.queuePaused || len(m.queued) != 1 || cmd != nil || m.lastError != "delivery unconfirmed" { + t.Fatal("ambiguous steering failure was lost or resent automatically") + } + } else if turnFinished { + if cmd == nil || !m.prompting || len(m.queued) != 0 { + t.Fatal("explicit promptRequired did not deliver one normal follow-up") + } + } else if len(m.queued) != 1 || m.queued[0].steer || cmd != nil { + t.Fatal("fallback overlapped the current turn") + } + } + } +} + +func TestQueueRemoveAndClearKeepDraftAndPendingSteering(t *testing.T) { + m := queueComposer(t, true) + m.submitPrompt("first", true) + m.submitPrompt("second", true) + m.sendPrompt("guidance") + m.input.SetValue("draft") + m.openPicker("queue", m.queueItems()) + m.pickerKey(keyPress('d', tea.ModCtrl)) + if len(m.queued) != 1 || m.queued[0].text != "second" || len(m.picker.matches) != 1 { + t.Fatal("remove did not update queue picker") + } + m.command("/queue clear") + if len(m.queued) != 0 || m.input.Value() != "draft" || m.steering == nil { + t.Fatal("clearing queued work damaged the draft or in-flight steering") + } +} + +func TestQueuePreviewAndPendingTranscriptFit(t *testing.T) { + m := queueComposer(t, false) + for _, text := range []string{"first visible prompt", "second visible prompt", "third", "fourth"} { + m.sendPrompt(text) + } + for _, size := range [][2]int{{35, 14}, {80, 24}} { + m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) + view := m.View().Content + if lipgloss.Width(view) > size[0] || lipgloss.Height(view) > size[1] || !strings.Contains(ansi.Strip(view), "first visible prompt") { + t.Fatalf("bad queue preview at %v:\n%s", size, view) + } + } + before := append([]queuedPrompt(nil), m.queued...) + m.queuePaused = true + m.sendQueued() + // Explicit send is allowed, but calling with an active turn cannot overlap it. + if !reflect.DeepEqual(before, m.queued) { + t.Fatal("queue dispatched while another turn was active") + } +} diff --git a/internal/tui/transcript.go b/internal/tui/transcript.go index 5010f5d..cf5e18e 100644 --- a/internal/tui/transcript.go +++ b/internal/tui/transcript.go @@ -144,6 +144,9 @@ func (m *model) syncTranscript() { for m.printedIndex < len(s.Messages) { i := m.printedIndex message := s.Messages[i] + if message.Pending { + break + } if message.Role == "plan" { // Plans update the live checklist; retain snapshots only in details. m.printedIndex++ @@ -189,6 +192,9 @@ func (m *model) syncTranscript() { var live []string for i := m.printedIndex; i < len(s.Messages); i++ { message := s.Messages[i] + if message.Pending { + break + } if message.Role == "plan" { continue } @@ -210,6 +216,9 @@ func (m *model) refreshDetails() { bottom := m.viewport.AtBottom() var parts []string for _, message := range s.Messages { + if message.Pending { + break + } parts = append(parts, m.messageView(message, true, false)) } m.viewport.SetContent(strings.Join(parts, "\n")) diff --git a/internal/tui/view.go b/internal/tui/view.go index a0bac29..004a192 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -122,6 +122,9 @@ func (m *model) pickerView(height int) string { if p.kind == "sessions" { hint += " · Ctrl+D delete" } + if p.kind == "queue" { + hint = "Enter edit · Ctrl+D remove · Esc back" + } return result + "\n" + m.theme.muted.Render(line(hint, w)) } func (m *model) permissionView(height int) string { @@ -176,17 +179,19 @@ func (m *model) View() tea.View { if n := len(m.attachments) + len(m.resources); n > 0 { badges = append(badges, m.theme.muted.Render(fmt.Sprintf("%d attachment(s) · /detach to clear", n))) } - if len(m.queued) > 0 { - hint := fmt.Sprintf("%d queued · /queue to edit", len(m.queued)) - if m.queuePaused { - hint += " · /queue send to continue" - } - badges = append(badges, m.theme.muted.Render(line(hint, w))) + if queue := m.queueView(w); queue != "" { + badges = append(badges, queue) } } footer := "/ commands · @ files · Ctrl+O details · Alt+Enter newline" if m.prompting && m.input.Value() != "" { - footer = "Enter queue prompt · Esc stop · Alt+Enter newline" + footer = "Enter/Tab queue · Esc stop · Alt+Enter newline" + if m.client != nil && m.client.CanSteer() && !m.queuePaused { + footer = "Enter steer · Tab queue · Esc stop · Alt+Enter newline" + } + } + if m.editing != nil { + footer = "Enter save queued prompt · Esc restore draft" } footer = m.theme.muted.Render(line(footer, w)) if m.client != nil { diff --git a/internal/tui/workflow_test.go b/internal/tui/workflow_test.go index 64706ed..cd415af 100644 --- a/internal/tui/workflow_test.go +++ b/internal/tui/workflow_test.go @@ -101,8 +101,12 @@ func TestQueuedPromptEditRetainsContextWithoutDuplicatingFiles(t *testing.T) { m.command("/queue") m.input.SetValue("unfinished draft") m.Update(keyPress(tea.KeyEnter, 0)) + if m.input.Value() != "Read @main.go" || m.savedDraft == nil || m.savedDraft.text != "unfinished draft" { + t.Fatal("editing did not preserve the current draft") + } + m.Update(keyPress(tea.KeyEscape, 0)) if m.input.Value() != "unfinished draft" || len(m.queued) != 1 { - t.Fatal("editing discarded the current draft") + t.Fatal("cancelling queue edit did not restore the draft and queued prompt") } } diff --git a/terminal_test.go b/terminal_test.go index ce4a1fb..7d9a4f4 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -194,6 +194,8 @@ func TestTerminalWorkflow(t *testing.T) { contains("Queued prompts") send("\r") contains("❯ queued followup") + send("\x1b") + wait("queue edit cancelled", func(s string) bool { return !strings.Contains(s, "Editing queued prompt") }) send("\x15/new\r") waitSessions(2) send("/sessions\r") From 25a07fd7ccab5c5f84e1c2b70cc7f3074fd4ca78 Mon Sep 17 00:00:00 2001 From: Ryan SVIHLA Date: Sat, 26 Sep 2026 16:28:46 +0200 Subject: [PATCH 2/2] Handle prompt startup failures and late steering safely --- internal/client/client.go | 3 +++ internal/client/steering.go | 2 +- internal/client/steering_test.go | 14 ++++++++++++++ terminal_test.go | 8 +++++++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index 68cc72f..a0e9809 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -584,6 +584,9 @@ func (c *Client) PromptContent(blocks []acp.Content) (reason schema.StopReason, } }() if err = c.Save(); err != nil { + // A follow-up can already be waiting for this prompt's first activity. + // Release it as unconsumed even when the initial save prevents dispatch. + err = errors.Join(err, c.finishSteering(ctx)) return "", err } if s.PendingContext != "" { diff --git a/internal/client/steering.go b/internal/client/steering.go index 6f2a2f9..7f1bffc 100644 --- a/internal/client/steering.go +++ b/internal/client/steering.go @@ -95,7 +95,7 @@ func (c *Client) SteerContent(sessionID string, blocks []acp.Content) (outcome S if stopping { return "", errors.New("turn is stopping; follow-up retained in queue") } - if !s.ready { + if !s.ready || (s.promptReturned && s.detachedGeneration == 0) { return SteeringPromptRequired, nil } break diff --git a/internal/client/steering_test.go b/internal/client/steering_test.go index 2af1564..3c612fe 100644 --- a/internal/client/steering_test.go +++ b/internal/client/steering_test.go @@ -221,6 +221,20 @@ func TestSteeringWithoutActiveTurnLeavesInputWithCaller(t *testing.T) { } } +func TestSteeringAfterPromptResponseDoesNotStartDetachedWork(t *testing.T) { + c, _ := steeringClient(t, func(context.Context, string, json.RawMessage) (any, error) { + t.Error("completed prompt was steered again") + return nil, nil + }) + // Another delivery can keep the local turn open briefly after its RPC ends. + c.turnDone = make(chan struct{}) + c.steering = steeringState{ready: true, promptReturned: true, wake: make(chan struct{})} + outcome, err := c.SteerContent(c.current.ID, []acp.Content{acp.NewTextContent("follow-up")}) + if err != nil || outcome != SteeringPromptRequired || c.steering.pending != 0 || len(c.current.Messages) != 0 { + t.Fatalf("completed turn retained or consumed input: %s %v", outcome, err) + } +} + func TestSteeringWaitsForPromptActivity(t *testing.T) { for _, streams := range []bool{false, true} { t.Run(map[bool]string{false: "no-output", true: "streaming"}[streams], func(t *testing.T) { diff --git a/terminal_test.go b/terminal_test.go index 7d9a4f4..327a321 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -181,10 +181,16 @@ func TestTerminalWorkflow(t *testing.T) { contains("hello, Ada!") send("slow\r") contains("Esc to stop") - send("queued followup\r") + send("queued followup\t") contains("1 queued") contains("Brief") contains("✓ Stream words") + send("unsent draft\x1b[1;3A") + contains("Editing queued prompt") + contains("❯ queued followup") + send("\x1b") + contains("❯ unsent draft") + send("\x15") send("\x1b") contains("Stopped") contains("Brief")