webbee 0.3.11__tar.gz → 0.3.12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {webbee-0.3.11 → webbee-0.3.12}/CHANGELOG.md +12 -0
  2. {webbee-0.3.11 → webbee-0.3.12}/PKG-INFO +1 -1
  3. {webbee-0.3.11 → webbee-0.3.12}/pyproject.toml +1 -1
  4. webbee-0.3.12/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/commands.py +19 -0
  6. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/frames.py +23 -0
  7. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/render.py +57 -0
  8. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/repl.py +14 -1
  9. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/session.py +15 -8
  10. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/tui.py +79 -30
  11. {webbee-0.3.11 → webbee-0.3.12}/tests/test_commands.py +34 -0
  12. {webbee-0.3.11 → webbee-0.3.12}/tests/test_marathon.py +64 -1
  13. {webbee-0.3.11 → webbee-0.3.12}/tests/test_render.py +92 -0
  14. {webbee-0.3.11 → webbee-0.3.12}/tests/test_repl.py +12 -0
  15. {webbee-0.3.11 → webbee-0.3.12}/tests/test_session.py +7 -0
  16. {webbee-0.3.11 → webbee-0.3.12}/tests/test_tui.py +226 -0
  17. webbee-0.3.11/src/webbee/__init__.py +0 -1
  18. {webbee-0.3.11 → webbee-0.3.12}/.github/workflows/publish.yml +0 -0
  19. {webbee-0.3.11 → webbee-0.3.12}/.gitignore +0 -0
  20. {webbee-0.3.11 → webbee-0.3.12}/LICENSE +0 -0
  21. {webbee-0.3.11 → webbee-0.3.12}/README.md +0 -0
  22. {webbee-0.3.11 → webbee-0.3.12}/install.sh +0 -0
  23. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/account.py +0 -0
  24. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/banner_art.py +0 -0
  25. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/boot.py +0 -0
  26. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/checkpoints.py +0 -0
  27. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/cli.py +0 -0
  28. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/clipboard.py +0 -0
  29. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/coding_context.py +0 -0
  30. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/config.py +0 -0
  31. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/consent.py +0 -0
  32. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/details.py +0 -0
  33. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/events.py +0 -0
  34. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/__init__.py +0 -0
  35. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/chunker.py +0 -0
  36. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/embed.py +0 -0
  37. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/graph.py +0 -0
  38. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/indexer.py +0 -0
  39. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/models.py +0 -0
  40. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/query.py +0 -0
  41. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/service.py +0 -0
  42. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/store.py +0 -0
  43. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/vectors.py +0 -0
  44. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/watch.py +0 -0
  45. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/output_pane.py +0 -0
  46. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/remote.py +0 -0
  47. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/repo.py +0 -0
  48. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/sessions.py +0 -0
  49. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/steer.py +0 -0
  50. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/stream.py +0 -0
  51. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/thread.py +0 -0
  52. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/tokens.py +0 -0
  53. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/tools.py +0 -0
  54. {webbee-0.3.11 → webbee-0.3.12}/src/webbee/update.py +0 -0
  55. {webbee-0.3.11 → webbee-0.3.12}/tests/__init__.py +0 -0
  56. {webbee-0.3.11 → webbee-0.3.12}/tests/test_account.py +0 -0
  57. {webbee-0.3.11 → webbee-0.3.12}/tests/test_checkpoints.py +0 -0
  58. {webbee-0.3.11 → webbee-0.3.12}/tests/test_cli.py +0 -0
  59. {webbee-0.3.11 → webbee-0.3.12}/tests/test_clipboard.py +0 -0
  60. {webbee-0.3.11 → webbee-0.3.12}/tests/test_config.py +0 -0
  61. {webbee-0.3.11 → webbee-0.3.12}/tests/test_cpc_contract_stable.py +0 -0
  62. {webbee-0.3.11 → webbee-0.3.12}/tests/test_details.py +0 -0
  63. {webbee-0.3.11 → webbee-0.3.12}/tests/test_events.py +0 -0
  64. {webbee-0.3.11 → webbee-0.3.12}/tests/test_freeze_fix.py +0 -0
  65. {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_chunker.py +0 -0
  66. {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_embed.py +0 -0
  67. {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_graph.py +0 -0
  68. {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_indexer.py +0 -0
  69. {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_no_numpy.py +0 -0
  70. {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_query.py +0 -0
  71. {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_service.py +0 -0
  72. {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_store.py +0 -0
  73. {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_vectors.py +0 -0
  74. {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_watch.py +0 -0
  75. {webbee-0.3.11 → webbee-0.3.12}/tests/test_packaging.py +0 -0
  76. {webbee-0.3.11 → webbee-0.3.12}/tests/test_repo.py +0 -0
  77. {webbee-0.3.11 → webbee-0.3.12}/tests/test_sessions.py +0 -0
  78. {webbee-0.3.11 → webbee-0.3.12}/tests/test_steer.py +0 -0
  79. {webbee-0.3.11 → webbee-0.3.12}/tests/test_stream.py +0 -0
  80. {webbee-0.3.11 → webbee-0.3.12}/tests/test_thread.py +0 -0
  81. {webbee-0.3.11 → webbee-0.3.12}/tests/test_tokens.py +0 -0
  82. {webbee-0.3.11 → webbee-0.3.12}/tests/test_tools.py +0 -0
  83. {webbee-0.3.11 → webbee-0.3.12}/tests/test_tui_hardening.py +0 -0
  84. {webbee-0.3.11 → webbee-0.3.12}/tests/test_update.py +0 -0
  85. {webbee-0.3.11 → webbee-0.3.12}/tests/test_version.py +0 -0
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.12
4
+
5
+ - Queued messages are now visible — when you type while Webbee is working,
6
+ your message shows as `⋯ queued: …` right away, the toolbar shows the count,
7
+ and it runs (clearly) after the current turn. New `/queue` and `/queue clear`
8
+ to see and manage what's waiting — they work even mid-turn.
9
+ - Stopping a turn (Esc/Ctrl-C) no longer auto-runs the queue: it stays put,
10
+ visible, until you clear it or a next turn finishes naturally.
11
+ - Task lists now render in full — every to-do with its status (✓ done ·
12
+ ▶ doing · ○ next), redrawn on each update, so you always see the whole plan
13
+ and where Webbee is in it.
14
+
3
15
  ## 0.3.11
4
16
 
5
17
  - Type-ahead queue: send follow-ups while Webbee is working — they queue and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.11
3
+ Version: 0.3.12
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.3.11"
3
+ version = "0.3.12"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.12"
@@ -10,6 +10,7 @@ _HELP = """Commands:
10
10
  /mode [default|plan|autopilot] consent mode (no arg — show current)
11
11
  /cost (=/usage) tokens + credits this session
12
12
  /status cwd · git · surface · tokens · version
13
+ /queue [clear] messages queued while Webbee works (clear drops them all)
13
14
  /steps [N] last turn's steps; N expands one (also: Up/Down + Enter)
14
15
  /checkpoints the reversibility time machine — list workspace checkpoints
15
16
  /rollback <ref> restore the workspace to a checkpoint (id, cp-N or N)
@@ -30,6 +31,7 @@ class CommandContext:
30
31
  session_tokens: int
31
32
  session_credits: int
32
33
  git_branch: str
34
+ queued: tuple = () # snapshot of the dock's type-ahead queue, oldest first
33
35
 
34
36
 
35
37
  @dataclass(frozen=True)
@@ -78,6 +80,23 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
78
80
  return SlashResult(handled=True, action="sessions")
79
81
  if cmd == "/logout-others":
80
82
  return SlashResult(handled=True, action="logout_others")
83
+ if cmd == "/queue":
84
+ # ctx.queued is the live deque's snapshot (threaded by the repl the
85
+ # same way /status reads session state); the repl clears the actual
86
+ # deque on action="queue_clear" — the message here already carries
87
+ # the honest drop count.
88
+ if args and args[0].lower() == "clear":
89
+ n = len(ctx.queued)
90
+ return SlashResult(handled=True, action="queue_clear",
91
+ message=(f"Queue cleared ({n} dropped)." if n
92
+ else "Queue is already empty."))
93
+ if not ctx.queued:
94
+ return SlashResult(handled=True, action="queue",
95
+ message="Queue is empty — press Enter while Webbee is working to queue a message.")
96
+ lines = "\n".join(f" {i}. {t}" for i, t in enumerate(ctx.queued, 1))
97
+ return SlashResult(handled=True, action="queue",
98
+ message=(f"Queued ({len(ctx.queued)}) — runs in order, "
99
+ f"one per finished turn:\n{lines}"))
81
100
  if cmd == "/steps":
82
101
  if args:
83
102
  return SlashResult(handled=True, action="step_detail", arg=args[0])
@@ -250,3 +250,26 @@ def marathon_note(frame: dict) -> str:
250
250
  head = f"📋 Todos {done}/{total}"
251
251
  return f"{head} — now: {current}" if current else head
252
252
  return str(frame.get("type", ""))
253
+
254
+
255
+ def render_todo_frame(frame: dict, sink) -> None:
256
+ """Full-checklist render for a `todo` FACT (I-WEBBEE-CODE-TODO-SCRATCHPAD).
257
+ The kernel republishes the WHOLE list on every todo_write, so route it to
258
+ sink.todos(items, total, done) — the sink redraws the complete current
259
+ checklist inline (Claude-Code-style: the latest full list always sits in
260
+ the scrollback). Guarded like the other optional sink hooks: a minimal
261
+ sink without `todos` degrades to the old one-line marathon_note, and a
262
+ render error must never break the frame loop in session.run()."""
263
+ todos = frame.get("todos") if isinstance(frame.get("todos"), list) else []
264
+ total = frame.get("total", len(todos))
265
+ done = frame.get("completed", 0)
266
+ render = getattr(sink, "todos", None)
267
+ try:
268
+ if render is not None:
269
+ render(todos, total, done)
270
+ else:
271
+ note = getattr(sink, "note", None)
272
+ if note is not None:
273
+ note(marathon_note(frame))
274
+ except Exception:
275
+ pass
@@ -215,6 +215,40 @@ class RichSink:
215
215
  self.console.print(_pad(Text(_clean(message), style=_BEE)))
216
216
  self._nudge()
217
217
 
218
+ def todos(self, items: list, total: int, done: int) -> None:
219
+ """Claude-Code-style checklist for the coding TODO scratchpad. The
220
+ kernel republishes the FULL list on every todo_write, so each update
221
+ re-renders the whole current plan inline — the latest complete list
222
+ always sits in the scrollback (deliberately NOT a pinned sticky panel:
223
+ inline re-render can't fight the dock layout). Order preserved, one
224
+ line per item: ✓ completed (dim, struck) · ▶ in progress (bold, so
225
+ "what's happening now" pops) · ○ pending (muted). Defensive: malformed
226
+ items are skipped, unknown statuses render as pending, bad counts
227
+ degrade to a bare header — never raises."""
228
+ try:
229
+ head = f"📋 Todos ({int(done)}/{int(total)})"
230
+ except (TypeError, ValueError):
231
+ head = "📋 Todos"
232
+ body = Text() # empty base style — segments below
233
+ body.append(head, style=f"bold {_BEE}") # carry their OWN, no bleed-through
234
+ for item in (items if isinstance(items, (list, tuple)) else ()):
235
+ if not isinstance(item, dict):
236
+ continue # malformed entry — skip, never raise
237
+ content = _clean(item.get("content", "")).strip()
238
+ if not content:
239
+ continue
240
+ status = str(item.get("status", "") or "")
241
+ if status == "completed":
242
+ glyph, g_style, t_style = "✓", "green", "dim strike"
243
+ elif status == "in_progress":
244
+ glyph, g_style, t_style = "▶", f"bold {_BEE}", "bold"
245
+ else: # pending / unknown -> not started yet
246
+ glyph, g_style, t_style = "○", "grey66", "grey66"
247
+ body.append(f"\n {glyph} ", style=g_style)
248
+ body.append(content, style=t_style)
249
+ self.console.print(_pad(body))
250
+ self._nudge()
251
+
218
252
  def user_echo(self, text: str) -> None:
219
253
  """Commit the just-sent user message as its own clearly-readable line
220
254
  with a background bar (NOT boxed like the live input) so it stands out
@@ -222,6 +256,29 @@ class RichSink:
222
256
  self.console.print(_pad(Text(" ❯ " + _clean(text) + " ", style="bold white on grey30")))
223
257
  self._nudge()
224
258
 
259
+ def queued_echo(self, text: str) -> None:
260
+ """Type-ahead visibility (the #1 queue fix): the line you pressed
261
+ Enter on while a turn was running, committed to the scrollback the
262
+ MOMENT it queues — so you SEE exactly what's waiting, in order.
263
+ Muted-but-visible (quieter than user_echo's bar, louder than dim
264
+ chrome). When it drains, queued_run + the normal user_echo mark it
265
+ as the active turn."""
266
+ self.console.print(_pad(Text.assemble(
267
+ ("⋯ queued: ", f"bold {_BEE}"),
268
+ (_clean(text), "italic grey66"))))
269
+ self._nudge()
270
+
271
+ def queued_run(self, remaining: int) -> None:
272
+ """The tiny lifecycle marker printed right before a drained queued
273
+ line starts: `▶ running queued message` (+ how many still wait) — a
274
+ drain is never a silent start; the drained text's normal ❯ user_echo
275
+ follows immediately."""
276
+ tail = f" · {remaining} still queued" if remaining else ""
277
+ self.console.print(_pad(Text.assemble(
278
+ ("▶ ", f"bold {_BEE}"),
279
+ (f"running queued message{tail}", "italic grey66"))))
280
+ self._nudge()
281
+
225
282
  def foreign_turn(self, surface: str, role: str, text: str) -> None:
226
283
  """One tagged line for a turn that lives on ANOTHER surface (a
227
284
  Telegram/panel-steered turn on the shared stream, or a replayed prior
@@ -2,6 +2,7 @@ import asyncio
2
2
  import contextlib
3
3
  import os
4
4
  import sys
5
+ from collections import deque
5
6
 
6
7
  from webbee import __version__
7
8
  from webbee.account import login_device_flow
@@ -80,6 +81,10 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
80
81
  # inject sink/read_line and take the plain fallback loop.
81
82
  use_dock = sink is None and read_line is input and sys.stdin.isatty()
82
83
  state = {"mode": mode, "logged_in": False}
84
+ # Type-ahead queue: OWNED here and shared with tui.run_session, so /queue
85
+ # and /queue clear (dispatched through CommandContext.queued, same
86
+ # mechanism /status uses for session state) see the live deque.
87
+ pending_queue: deque = deque()
83
88
  _sink = None # assigned by _boot
84
89
  agent = None # assigned by _boot
85
90
  intel = None # assigned by _boot -- IntelService, or None (off/base-install/boot failure)
@@ -96,7 +101,8 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
96
101
  surface="terminal", logged_in=state["logged_in"],
97
102
  session_tokens=getattr(_sink, "session_tokens", 0),
98
103
  session_credits=getattr(_sink, "session_credits", 0),
99
- git_branch=state.get("git_branch", "-"))
104
+ git_branch=state.get("git_branch", "-"),
105
+ queued=tuple(pending_queue))
100
106
 
101
107
  async def _handle(line: str) -> str:
102
108
  """Process one input line. Returns 'exit' or 'continue'."""
@@ -198,6 +204,11 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
198
204
  except Exception as e:
199
205
  _sink.note(f"Remote control unavailable: {type(e).__name__}")
200
206
  return "continue"
207
+ if res.action == "queue_clear":
208
+ # dispatch built the message (with the drop count) from the
209
+ # ctx snapshot; here we drop the live deque — the toolbar
210
+ # count follows on the sink's redraw below.
211
+ pending_queue.clear()
201
212
  if res.action == "clear":
202
213
  _sink.clear()
203
214
  _sink.note(res.message)
@@ -314,6 +325,8 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
314
325
  "expand": lambda i: _handle(f"/steps {i + 1}"),
315
326
  },
316
327
  stop_turn=lambda: agent.stop(),
328
+ pending=pending_queue, queued_echo=_sink.queued_echo,
329
+ queued_run=_sink.queued_run,
317
330
  )
318
331
  finally:
319
332
  _cancel_background()
@@ -15,6 +15,7 @@ from webbee.frames import (
15
15
  handle_tool_request,
16
16
  marathon_note,
17
17
  render_foreign_frame,
18
+ render_todo_frame,
18
19
  )
19
20
 
20
21
 
@@ -226,14 +227,20 @@ class AgentSession:
226
227
  elif ftype == "marathon_complete": # U4 — the whole GOAL is done: terminal
227
228
  return frame.get("text", "")
228
229
 
229
- elif ftype in _MARATHON_FACT_TYPES: # U4 — marathon plan/milestone/pause
230
- # Facts-only; render ONE human-readable line. Guarded: a
231
- # minimal sink (no `note`) simply drops the fact rather than
232
- # crashing the turn (the stream reader already tolerates
233
- # unknown frame types by ignoring them).
234
- _note = getattr(sink, "note", None)
235
- if _note is not None:
236
- _note(marathon_note(frame))
230
+ elif ftype in _MARATHON_FACT_TYPES: # U4 — marathon plan/milestone/pause/todo
231
+ # Facts-only. A `todo` fact carries the FULL list -> the
232
+ # dedicated full-checklist render (falls back to the old
233
+ # one-line note on a minimal sink). The other facts render
234
+ # ONE human-readable line. Guarded: a minimal sink (no
235
+ # `note`) simply drops the fact rather than crashing the
236
+ # turn (the stream reader already tolerates unknown frame
237
+ # types by ignoring them).
238
+ if ftype == "todo":
239
+ render_todo_frame(frame, sink)
240
+ else:
241
+ _note = getattr(sink, "note", None)
242
+ if _note is not None:
243
+ _note(marathon_note(frame))
237
244
  if ftype == "marathon_paused":
238
245
  # Parked (out-of-credits / consent / runaway) -> end the
239
246
  # turn so the dock leaves "working"; the note shows why, and
@@ -4,8 +4,10 @@ the very bottom and never move while the output scrolls (mouse wheel /
4
4
  PageUp). `run_session` also drives step-navigation (Up/Down + Enter) over the
5
5
  pinned box when the input is empty and no turn is running; in every other
6
6
  state Up/Down recall submitted lines (readline-style), and Enter while a turn
7
- runs QUEUES the line (Claude-Code type-ahead: it runs after the current
8
- turn). Pure helpers (next_mode/build_toolbar/the *_action functions) are
7
+ runs QUEUES the line (Claude-Code type-ahead: echoed AT ONCE as `⋯ queued:`,
8
+ counted in the toolbar, run after the current turn — natural completion only,
9
+ a user STOP preserves the queue; /queue lists it, /queue clear drops it).
10
+ Pure helpers (next_mode/build_toolbar/the *_action functions) are
9
11
  unit-tested; the Application is TTY/headless-smoke verified. Grounded in
10
12
  prompt_toolkit 3.0.52."""
11
13
  import asyncio
@@ -75,9 +77,10 @@ def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
75
77
  accent, so it pops, not grey), and idle (mode value coloured PER MODE —
76
78
  default cyan / plan purple / autopilot yellow — + SESSION spend + the
77
79
  Shift + TAB hint). `queued` = type-ahead lines waiting to run after the
78
- current turn; when >0 a subtle `⋯N queued` segment shows in the busy AND
79
- idle states. Style classes are defined in run_session's Style."""
80
- q = f" · ⋯{queued} queued" if queued else ""
80
+ current turn; when >0 the `⋯N queued` segment renders in the ACCENT class
81
+ (tb.working, NOT dim) in the busy AND idle states, so the depth is
82
+ noticeable at a glance. Style classes are defined in run_session's Style."""
83
+ q = [("class:tb.working", f" · ⋯{queued} queued")] if queued else []
81
84
  if consent:
82
85
  return [("class:tb.consent", " approve? type y / n / a reply · Enter to send")]
83
86
  if busy:
@@ -86,13 +89,15 @@ def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
86
89
  if current:
87
90
  frags += [("class:tb.dim", " · "), ("class:tb.action", current)]
88
91
  frags.append(("class:tb.dim",
89
- f" · {elapsed:.0f}s · {tools} · {_fmt_tokens(tokens)} tok{q}"
90
- f" · Esc/Ctrl-C to stop"))
92
+ f" · {elapsed:.0f}s · {tools} · {_fmt_tokens(tokens)} tok"))
93
+ frags += q
94
+ frags.append(("class:tb.dim", " · Esc/Ctrl-C to stop"))
91
95
  return frags
92
96
  return [("class:tb.dim", " mode: "),
93
97
  (f"class:tb.mode.{mode}", mode),
94
- ("class:tb.dim",
95
- f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits{q} · Shift + TAB: switch mode")]
98
+ ("class:tb.dim", f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits"),
99
+ *q,
100
+ ("class:tb.dim", " · Shift + TAB: switch mode")]
96
101
 
97
102
 
98
103
  def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event, buf=None) -> None:
@@ -112,6 +117,7 @@ def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event, buf=None) -
112
117
  if t is not None and not t.done():
113
118
  if stop_turn is not None:
114
119
  event.app.create_background_task(stop_turn())
120
+ turn["stopped"] = True # user STOP → the type-ahead queue must NOT auto-run
115
121
  t.cancel() # cancel the running turn; dock survives
116
122
  return
117
123
  sel["i"] = None
@@ -126,35 +132,56 @@ def _interrupt_action(turn: dict, is_busy, stop_turn, event) -> None:
126
132
  if t is not None and not t.done():
127
133
  if is_busy() and stop_turn is not None:
128
134
  event.app.create_background_task(stop_turn())
135
+ turn["stopped"] = True # user STOP → the type-ahead queue must NOT auto-run
129
136
  t.cancel() # cancel the running turn; dock survives
130
137
 
131
138
 
132
- def _submit_line(text: str, buf, pending, busy: bool, start) -> str:
133
- """Route ONE non-empty submitted line (dependency-injected, same testing
134
- philosophy as _escape_action). Records it into the buffer's history first
135
- (up-arrow recall), then: busy → QUEUE it (Claude-Code type-ahead — the
136
- line is never erased or dropped; it runs after the current turn); idle →
137
- `start(text)` (today's normal submit). Returns "queued" | "started"."""
139
+ def _submit_line(text: str, buf, pending, busy: bool, start, queued_echo=None) -> str:
140
+ """Route ONE submitted line (dependency-injected, same testing philosophy
141
+ as _escape_action). Whitespace never queues nor starts ("ignored"). A real
142
+ line is recorded into the buffer's history first (up-arrow recall), then:
143
+ busy QUEUE it (Claude-Code type-ahead the line is never erased or
144
+ dropped; it runs after the current turn) and echo it into the transcript
145
+ via `queued_echo` (sink.queued_echo, EXACTLY once per queue action — the
146
+ queue must be visible, never a silent deque); idle → `start(text)`
147
+ (today's normal submit). Returns "ignored" | "queued" | "started"."""
148
+ if not text.strip():
149
+ return "ignored"
138
150
  buf.history.append_string(text)
139
151
  if busy:
140
152
  pending.append(text)
153
+ if queued_echo is not None:
154
+ queued_echo(text)
141
155
  return "queued"
142
156
  start(text)
143
157
  return "started"
144
158
 
145
159
 
146
- def _drain_pending(pending, start) -> bool:
160
+ def _drain_pending(pending, start, mark=None) -> bool:
147
161
  """Turn-completion drain: pop the OLDEST queued type-ahead line and hand
148
162
  it to `start` — the SAME path a typed line takes, so a persistent
149
163
  marathon receives it as a `new_task` into the running session. ONE item
150
164
  per completion; the rest stay queued FIFO (each finished turn drains the
151
- next). Returns True when a drain happened."""
165
+ next). `mark` (sink.queued_run) announces the handoff — `▶ running queued
166
+ message` — right before the drained line's normal ❯ user-echo, so a drain
167
+ is never a silent start. Returns True when a drain happened."""
152
168
  if not pending:
153
169
  return False
154
- start(pending.popleft())
170
+ text = pending.popleft()
171
+ if mark is not None:
172
+ mark(len(pending))
173
+ start(text)
155
174
  return True
156
175
 
157
176
 
177
+ def _is_queue_command(text: str) -> bool:
178
+ """PURE. `/queue` and its subcommands MANAGE the type-ahead queue, so they
179
+ must run exactly when the queue matters — mid-turn. The Enter handler
180
+ routes them past the busy gate (they never type-ahead-queue themselves)."""
181
+ parts = (text or "").strip().lower().split()
182
+ return bool(parts) and parts[0] == "/queue"
183
+
184
+
158
185
  def _arrow_up_action(event, buf, sel: dict, n: int, busy: bool) -> None:
159
186
  """Up key. Step-navigation EXACTLY as before when steps exist + input is
160
187
  empty + idle; in EVERY other state (busy, or text present, or no steps)
@@ -180,13 +207,18 @@ def _arrow_down_action(event, buf, sel: dict, n: int, busy: bool) -> None:
180
207
 
181
208
  async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
182
209
  is_busy, consent_pending, resolve_consent, steps_nav=None,
183
- stop_turn=None) -> bool:
210
+ stop_turn=None, pending=None, queued_echo=None,
211
+ queued_run=None) -> bool:
184
212
  """The full-screen dock: `pane` fills the top (scrollable), a bordered input
185
213
  box + toolbar are FIXED at the bottom. Enter either resolves a pending
186
214
  consent reply (ICNLI: raw verbatim) or starts a turn as a BACKGROUND task
187
215
  (the box stays fixed during it); while a turn runs, Enter QUEUES the line
188
- (type-ahead — it starts right after the current turn; the toolbar shows
189
- `⋯N queued`). When `steps_nav` is given and the input is empty and no turn
216
+ (type-ahead — `queued_echo` commits it to the transcript at once, the
217
+ toolbar shows `⋯N queued` in accent, and it starts right after the current
218
+ turn with a `queued_run` marker). `pending` is the queue deque — the repl
219
+ passes its OWN so /queue and /queue clear (dispatched through the normal
220
+ command layer) see and manage the live queue; /queue itself always runs
221
+ immediately, even mid-turn. When `steps_nav` is given and the input is empty and no turn
190
222
  is running, Up/Down move a step selection (toolbar shows `step k/N`) and
191
223
  Enter expands it via `steps_nav["expand"]`; Esc clears it. In every other
192
224
  state Up/Down recall submitted lines (readline-style history).
@@ -206,9 +238,11 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
206
238
 
207
239
  buf = Buffer(multiline=False)
208
240
  turn = {"task": None}
209
- pending: deque = deque() # type-ahead queue: lines submitted while a turn runs
241
+ if pending is None:
242
+ pending = deque() # type-ahead queue: lines submitted while a turn runs
210
243
 
211
244
  def _start_turn(text):
245
+ turn.pop("stopped", None) # a stale stop flag must never eat the next natural drain
212
246
  turn["task"] = get_app().create_background_task(_run_turn(text))
213
247
 
214
248
  async def _run_turn(text):
@@ -218,12 +252,18 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
218
252
  done = True
219
253
  finally:
220
254
  turn["task"] = None
221
- if done:
222
- # Type-ahead drain: the turn finished normally (an Esc/Ctrl-C
223
- # stop counts repl._run_turn absorbs the cancel) submit the
224
- # oldest queued line through the SAME path a typed line takes.
225
- # A propagating exception (dock teardown) leaves the queue be.
226
- _drain_pending(pending, _start_turn)
255
+ # DRAIN RULE: natural completion ONLY. A user STOP (Esc/Ctrl-C sets
256
+ # turn["stopped"] before cancelling; repl._run_turn absorbs the
257
+ # cancel so on_line still returns) means "I'm taking control" the
258
+ # queue is PRESERVED, stays visible (toolbar accent + /queue), and
259
+ # never auto-runs; /queue clear drops it, and the next NATURAL
260
+ # completion resumes draining. A propagating exception (dock
261
+ # teardown) also leaves the queue be.
262
+ stopped = turn.pop("stopped", False)
263
+ if done and not stopped:
264
+ # Submit the oldest queued line through the SAME path a typed
265
+ # line takes; queued_run announces it — never a silent start.
266
+ _drain_pending(pending, _start_turn, mark=queued_run)
227
267
  get_app().invalidate()
228
268
 
229
269
  kb = KeyBindings()
@@ -251,9 +291,18 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
251
291
  return
252
292
  if not text.strip():
253
293
  return
294
+ if _is_queue_command(text):
295
+ # Queue MANAGEMENT runs NOW, even mid-turn (it never queues
296
+ # itself): a display-only background task — the handler only
297
+ # reads/clears the shared deque and prints, so it can't collide
298
+ # with the live turn and never touches turn["task"].
299
+ buf.history.append_string(text)
300
+ event.app.create_background_task(on_line(text))
301
+ return
254
302
  # Non-empty line: record for up-arrow recall, then queue-while-busy
255
- # (type-ahead — runs after the current turn) or start a turn now.
256
- if _submit_line(text, buf, pending, _busy_live(), _start_turn) == "queued":
303
+ # (type-ahead — echoed AT ONCE as `⋯ queued:` + accent toolbar depth,
304
+ # runs after the current turn) or start a turn now.
305
+ if _submit_line(text, buf, pending, _busy_live(), _start_turn, queued_echo) == "queued":
257
306
  event.app.invalidate() # toolbar shows the new depth
258
307
 
259
308
  @kb.add("s-tab")
@@ -152,3 +152,37 @@ def test_notify_command_dispatches():
152
152
  def test_help_mentions_notify():
153
153
  from webbee.commands import dispatch
154
154
  assert "/notify" in dispatch("/help", _anyctx()).message
155
+
156
+
157
+ # ── /queue — manage the dock's type-ahead queue (0.3.12) ─────────────────────
158
+ # The queue snapshot rides in CommandContext.queued (threaded by the repl the
159
+ # same way /status reads session state); dispatch stays pure.
160
+
161
+
162
+ def test_queue_lists_pending_numbered_in_order():
163
+ r = dispatch("/queue", _ctx(queued=("fix the tests", "deploy it")))
164
+ assert r.handled and r.action == "queue"
165
+ assert "1. fix the tests" in r.message and "2. deploy it" in r.message
166
+ assert r.message.index("fix the tests") < r.message.index("deploy it")
167
+ assert not NO_CYRILLIC.search(r.message)
168
+
169
+
170
+ def test_queue_empty_shows_hint():
171
+ r = dispatch("/queue", _ctx())
172
+ assert r.handled and r.action == "queue" and "empty" in r.message.lower()
173
+
174
+
175
+ def test_queue_clear_reports_drop_count():
176
+ r = dispatch("/queue clear", _ctx(queued=("a", "b", "c")))
177
+ assert r.handled and r.action == "queue_clear"
178
+ assert "3 dropped" in r.message
179
+
180
+
181
+ def test_queue_clear_when_already_empty():
182
+ r = dispatch("/queue clear", _ctx())
183
+ assert r.handled and r.action == "queue_clear"
184
+ assert "already empty" in r.message.lower()
185
+
186
+
187
+ def test_help_mentions_queue():
188
+ assert "/queue" in dispatch("/help", _ctx()).message
@@ -152,8 +152,9 @@ def _run_marathon_capture_post(monkeypatch, tmp_path, goal="build X", frames=Non
152
152
  monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
153
153
 
154
154
  class NoteSink:
155
- def __init__(self): self.notes = []
155
+ def __init__(self): self.notes = []; self.todo_lists = []
156
156
  def note(self, msg): self.notes.append(msg)
157
+ def todos(self, items, total, done): self.todo_lists.append((items, total, done))
157
158
  def thinking(self, msg): self.thinks = getattr(self, "thinks", []) + [msg]
158
159
  def tool_start(self, *a): ...
159
160
  def tool_result(self, *a): ...
@@ -293,6 +294,7 @@ def test_normal_run_omits_marathon_fields(monkeypatch, tmp_path):
293
294
  def panel_release(self, *a): ...
294
295
  def progress(self, *a): ...
295
296
  def usage(self, *a): ...
297
+ def todos(self, items, total, done): self.todo_lists = getattr(self, "todo_lists", []) + [(items, total, done)]
296
298
 
297
299
  async def token_provider(): return "tok"
298
300
 
@@ -339,3 +341,64 @@ def test_todo_fact_renders_progress_line():
339
341
  def test_todo_fact_degrades_on_missing_fields():
340
342
  from webbee.frames import marathon_note
341
343
  assert "0/0" in marathon_note({"type": "todo"}) # never crashes
344
+
345
+
346
+ # --- todo full-checklist routing (render_todo_frame) --------------------------
347
+
348
+
349
+ def test_todo_fact_streams_full_list_to_sink_todos(monkeypatch, tmp_path):
350
+ # A streamed `todo` FACT routes to sink.todos with the FULL ordered list +
351
+ # correct done/total — NOT the old one-line note.
352
+ items = [
353
+ {"content": "map the repo", "status": "completed"},
354
+ {"content": "fix the bug", "status": "in_progress"},
355
+ {"content": "run tests", "status": "pending"},
356
+ ]
357
+ _, _, sink = _run_marathon_capture_post(monkeypatch, tmp_path, frames=[
358
+ {"type": "todo", "task_id": "OURS", "total": 3, "completed": 1, "todos": items},
359
+ {"type": "marathon_complete", "task_id": "OURS", "text": "done"},
360
+ ])
361
+ assert sink.todo_lists == [(items, 3, 1)]
362
+ assert not any("📋" in n for n in sink.notes) # no duplicate one-liner
363
+
364
+
365
+ def test_render_todo_frame_falls_back_to_note_without_todos_hook():
366
+ # Back-compat: a minimal sink WITHOUT `todos` still gets the old one-line note.
367
+ from webbee.frames import render_todo_frame
368
+
369
+ class NoteOnly:
370
+ def __init__(self): self.notes = []
371
+ def note(self, m): self.notes.append(m)
372
+
373
+ s = NoteOnly()
374
+ render_todo_frame({"type": "todo", "total": 2, "completed": 1, "todos": [
375
+ {"content": "x", "status": "completed"},
376
+ {"content": "y", "status": "in_progress"}]}, s)
377
+ assert s.notes == ["📋 Todos 1/2 — now: y"]
378
+
379
+
380
+ def test_render_todo_frame_never_breaks_the_frame_loop():
381
+ from webbee.frames import render_todo_frame
382
+
383
+ class Boom:
384
+ def todos(self, *a): raise RuntimeError("ui bug")
385
+
386
+ render_todo_frame({"type": "todo"}, Boom()) # must not raise
387
+
388
+ class Bare: # neither todos nor note
389
+ ...
390
+
391
+ render_todo_frame({"type": "todo", "todos": "junk"}, Bare()) # must not raise
392
+
393
+
394
+ def test_marathon_plan_and_milestone_still_one_line_notes(monkeypatch, tmp_path):
395
+ # The split is todo-ONLY: plan/milestone/pause stay on the note path even
396
+ # when the sink HAS a todos hook.
397
+ _, _, sink = _run_marathon_capture_post(monkeypatch, tmp_path, frames=[
398
+ {"type": "marathon_plan", "task_id": "OURS", "milestone_count": 1, "goal": "g"},
399
+ {"type": "milestone", "task_id": "OURS", "index": 1, "title": "t", "status": "done"},
400
+ {"type": "marathon_complete", "task_id": "OURS", "text": "done"},
401
+ ])
402
+ assert any("Marathon plan" in n for n in sink.notes)
403
+ assert any("Milestone" in n for n in sink.notes)
404
+ assert sink.todo_lists == []