Skip to content
Agentmart
Submit a tool

acp-thinking-spinner

Add a "Thinking..." spinner card to an ACP server's tool-call stack so ACP clients (Zed Agent Panel, openCode TUI, JetBrains/VS Code ACP UI) show a working…

Skill by humanerd-drew

IndexedMIT★ 3
Write a review

Install

Works with Claude Code, Claude apps, Codex, Cursor, Gemini CLI, GitHub Copilot, VS Code, OpenCode, Goose, Windsurf. Read the repository before you give any tool access to your data.

npx agentmarket install skill/humanerd-drew-acp-thinking-spinner

Asks which of your tools to add it to (Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI, Claude Desktop) and shows the change first. See https://agentmart.dev/cli.

Get the weekly diff

The weekly diff: one email a week with new agent tools, notable updates and grade changes, plus security alerts for tools you follow. Double opt-in, one-click unsubscribe.

We use your email only for this newsletter. Privacy

About

Add a "Thinking..." spinner card to an ACP server's tool-call stack so ACP clients (Zed Agent Panel, openCode TUI, JetBrains/VS Code ACP UI) show a working indicator during the LLM API call phase. Triggers when working on an Agent Client Protocol (ACP) server and the user wants LLM-thinking visibility in the client's tool-call stack.

Use cases: design, backend, ml, agent-building

No reviews yet. Installed it? Reviews come from verified installers only. How reviews work

Permissions

What this skill can do and what it asks for, from its SKILL.md. Checked Oct 25, 2026.

Tools
No tools found
Secrets
None asked for
Install scripts
None found

Network hosts it talks to 1

  • codeload.github.com · found in install command

Files it touches

  • ~/.claude/skills/acp-thinking-spinner

What's inside

Skill name
acp-thinking-spinner
Description
Add a "Thinking..." spinner card to an ACP server's tool-call stack so ACP clients (Zed Agent Panel, openCode TUI, JetBrains/VS Code ACP UI) show a working indicator during the LLM API call phase. Triggers when working on an Agent Client Protocol (ACP) server and the user wants LLM-thinking visibility in the client's tool-call stack.
License
MIT
Instructions
129 lines in SKILL.md · view on GitHub

Files (1)

  • SKILL.md

SKILL.md (first lines)

# ACP Thinking Spinner — Tool-Card Pattern

When an ACP server ({{AGENT_NAME}}'s `acp_adapter/`, Hermes, or any Agent Client Protocol implementation) needs to show a working indicator during the LLM API call phase, the standard approach is to **synthesize a virtual tool call** with `kind="think"`. This places a "Thinking..." card in the client's tool-call stack — the same place real tool calls appear, which is the most reliable UI surface.

## Why this pattern (root-cause: ACP spec gap)

ACP has no dedicated channel for "agent is generating a response." It only has:

| Channel | Where it renders | Problem |
|---------|------------------|---------|
| `update_agent_thought_text` | Client's "Thinking" area | Most clients render this as **static text** — JetBrains got stuck on "Thinking" with rawInput updates, others don't blink. Unreliable for "is it stuck?" UX. |
| `update_agent_message_text` | Answer streaming area | Only fires once streaming actually starts; doesn't cover the 0-30s gap before first delta. |
| `ToolCallStart` / `ToolCallUpdate` | **Tool-call stack** (openCode-style) | The most reliable UI surface — clients render an animated spinner here for any tool call. Synthesizing a virtual think-kind call works. |
| Status bar | Client-specific | **Not standardized in ACP** — see `agentclientprotocol.com/rfds/session-usage` (RFD, not yet spec). Cannot trigger from the server. |

**Conclusion**: For a "is it stuck or working?" signal in ACP, the only reliable channel is the tool-call stack. Use a virtual `kind="think"` card.

## State machine

```
start     → ToolCallStart(kind="think", title="Thinking...")
heartbeat → ToolCallUpdate(status="in_progress", elapsed text)   # at 30s, 60s, ...
complete  → ToolCallUpdate(status="completed")
```

Hook sites in the LLM call lifecycle:

1. **Before** `_interruptible_api_call()` / `_interruptible_streaming_api_call()` → `start`
2. **First streamed delta** (`on_first_delta` callback) → `complete`
3. **Long-running timer** (e.g. `threading.Timer(30.0, ...)`) → `heartbeat`; cancel on complete

If a real tool call is dispatched before the LLM finishes, the thinking card is implicitly replaced by the real tool's card (ACP client stack semantics). No need to manually close.

## {{AGENT_NAME}} reference implementation

Files modified ({{AGENT_NAME}} ACP server at `~/.{{AGENT_NAME_LOWER}}/source/{{AGENT_NAME_LOWER}}-agent/`):

### `acp_adapter/tools.py` — three helpers
- `build_thinking_start(tool_call_id) -> ToolCallStart`
- `build_thinking_progress(tool_call_id, elapsed) -> ToolCallProgress`  
- `build_thinking_complete(tool_call_id) -> ToolCallProgress`

All use `kind="think"` and `acp.start_tool_call` / `acp.update_tool_call`.

### `acp_adapter/events.py` — callback factory
`make_agent_progress_cb(conn, session_id, loop)` returns a callable matching the signature:
```python
agent_progress_callback(state: str, name: str, args: dict = None)
```
Stores `name → tool_call_id` in a closure dict. State machine: start/heartbeat/complete.

### `acp_adapter/server.py` — wire
Alongside existing `thinking_cb`, add:
```python
agent_progress_cb = make_agent_progress_cb(conn, session_id, loop)
agent.agent_progress_callback = agent_progress_cb
```

### `run_agent.py` — three hook sites + callback attr
1. Add `agent_progress_callback: callable = None` to `AIAgent.__init__` signature
2. `self.agent_progress_callback = agent_progress_callback` in the body
3. **Hook A** (LLM call start, right before `_interruptible_*_api_call`):
   ```python
   if self.agent_progress_callback:
       try: self.agent_progress_callback("start", "llm_response", {})
       except Exception: pass
   ```
4. **Hook B** (first delta, inside the `on_first_delta` closure):
   ```python
   if self.agent_progress_callback:
       try: self.agent_progress_callback("complete", "llm_response", {})
       except Exception: pass
   ```
5. **Hook C** (30s+ timer callback):
   ```python
   def _llm_heartbeat():
       elapsed = time.time() - api_start_time
       if self.agent_progress_callback:
           try: self.agent_progress_callback("heartbeat", "llm_response", {"elapsed": elapsed})
           except Exception: pass
       # fallback: thinking_callback (text), _vprint (raw stdout)
   _llm_heartbeat_timer = threading.Timer(30.0, _llm_heartbeat)
   ```

## Channel routing rule (stdout breaks ACP)

ACP is **JSON-RPC over stdio**. Writing raw text to stdout (e.g. `print()` or `_vprint`) breaks the protocol. Three channels, in priority order:

| Channel | Path | When |
|---------|------|------|
| `agent_progress_callback` | `ToolCallStart`/`Update` (tool card) | ACP client available — preferred |
| `thinking_callback` | `update_agent_thought_text` | Fallback for non-ACP paths; some clients (JetBrains) render this as static "Thinking..." |
| `_vprint` (raw stdout) | Plain text | Last-resort for non-ACP environments (gateway streaming, plain CLI) |

Always check `agent_progress_callback` first, then `thinking_callback`, then `_vprint`. Never `print()` directly in agent code — it leaks through the JSON-RPC pipe.

## Env var

`DREW_LLM_HEARTBEAT_DISABLED=1` disables the 30s+ timer. Use for tests / CI to avoid timing flakiness.

## Verification

After patching:
1. `python3 -c "import ast; ast.parse(open('<file>').read())"` for each modified file
2. AST check that the 3 hook sites are present:
   ```python
   hook_count = body_src.count('self.agent_progress_callback(')
   assert hook_count == 3, f"expected 3 hook sites, got {hook_count}"
   ```
3. Restart the ACP server (e.g. exit the CLI process; new `{{AGENT_NAME_LOWER}}` invocation picks up the patch)
4. Trigger a long-running LLM call and confirm the client shows the spinner card in the tool-call stack

## Pitfalls

- **Don't put text content in `update_agent_thought_text` thinking channel** if the user said "I don't want text — I want an indicator." Use the tool-card channel instead.
- **Don't write to `sys.stdout` directly** in ACP mode — breaks JSON-RPC. Always go through a registered callback or the tool-card helper.
- **Don't fire `heartbeat` without a prior `start`** — the closure dict won't have a tool_call_id, the call is a no-op. This is intentional safety.
- **Mock client skips streaming** — `if isinstance(client, Mock): _use_streaming = False` already exists; heartbeat only fires on streaming, so mock tests are unaffected.

From humanerd-drew/opencode-drewgent, MIT.

More from humanerd-drew/opencode-drewgent

Signals

Stars
3
Forks
8
Open issues
0
Last commit (this folder)
Jul 9, 2026 (3 months ago)

From GitHub (fetched Oct 24, 2026).

Details

License
MIT
First seen
Oct 24, 2026
Last checked
Oct 24, 2026

More from humanerd-drew

Cite this page

acp-thinking-spinner skill. Agent Mart. https://agentmart.dev/skills/humanerd-drew-acp-thinking-spinner (last updated 2026-10-24)