webbee 0.3.10__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 (86) hide show
  1. {webbee-0.3.10 → webbee-0.3.12}/CHANGELOG.md +18 -0
  2. {webbee-0.3.10 → webbee-0.3.12}/PKG-INFO +1 -1
  3. {webbee-0.3.10 → webbee-0.3.12}/pyproject.toml +1 -1
  4. webbee-0.3.12/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/commands.py +19 -0
  6. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/frames.py +23 -0
  7. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/render.py +57 -0
  8. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/repl.py +14 -1
  9. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/session.py +15 -8
  10. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/tui.py +142 -24
  11. {webbee-0.3.10 → webbee-0.3.12}/tests/test_commands.py +34 -0
  12. {webbee-0.3.10 → webbee-0.3.12}/tests/test_marathon.py +64 -1
  13. {webbee-0.3.10 → webbee-0.3.12}/tests/test_render.py +92 -0
  14. {webbee-0.3.10 → webbee-0.3.12}/tests/test_repl.py +12 -0
  15. {webbee-0.3.10 → webbee-0.3.12}/tests/test_session.py +7 -0
  16. webbee-0.3.12/tests/test_tui.py +859 -0
  17. webbee-0.3.10/src/webbee/__init__.py +0 -1
  18. webbee-0.3.10/tests/test_tui.py +0 -414
  19. {webbee-0.3.10 → webbee-0.3.12}/.github/workflows/publish.yml +0 -0
  20. {webbee-0.3.10 → webbee-0.3.12}/.gitignore +0 -0
  21. {webbee-0.3.10 → webbee-0.3.12}/LICENSE +0 -0
  22. {webbee-0.3.10 → webbee-0.3.12}/README.md +0 -0
  23. {webbee-0.3.10 → webbee-0.3.12}/install.sh +0 -0
  24. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/account.py +0 -0
  25. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/banner_art.py +0 -0
  26. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/boot.py +0 -0
  27. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/checkpoints.py +0 -0
  28. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/cli.py +0 -0
  29. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/clipboard.py +0 -0
  30. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/coding_context.py +0 -0
  31. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/config.py +0 -0
  32. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/consent.py +0 -0
  33. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/details.py +0 -0
  34. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/events.py +0 -0
  35. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/__init__.py +0 -0
  36. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/chunker.py +0 -0
  37. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/embed.py +0 -0
  38. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/graph.py +0 -0
  39. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/indexer.py +0 -0
  40. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/models.py +0 -0
  41. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/query.py +0 -0
  42. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/service.py +0 -0
  43. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/store.py +0 -0
  44. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/vectors.py +0 -0
  45. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/watch.py +0 -0
  46. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/output_pane.py +0 -0
  47. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/remote.py +0 -0
  48. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/repo.py +0 -0
  49. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/sessions.py +0 -0
  50. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/steer.py +0 -0
  51. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/stream.py +0 -0
  52. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/thread.py +0 -0
  53. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/tokens.py +0 -0
  54. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/tools.py +0 -0
  55. {webbee-0.3.10 → webbee-0.3.12}/src/webbee/update.py +0 -0
  56. {webbee-0.3.10 → webbee-0.3.12}/tests/__init__.py +0 -0
  57. {webbee-0.3.10 → webbee-0.3.12}/tests/test_account.py +0 -0
  58. {webbee-0.3.10 → webbee-0.3.12}/tests/test_checkpoints.py +0 -0
  59. {webbee-0.3.10 → webbee-0.3.12}/tests/test_cli.py +0 -0
  60. {webbee-0.3.10 → webbee-0.3.12}/tests/test_clipboard.py +0 -0
  61. {webbee-0.3.10 → webbee-0.3.12}/tests/test_config.py +0 -0
  62. {webbee-0.3.10 → webbee-0.3.12}/tests/test_cpc_contract_stable.py +0 -0
  63. {webbee-0.3.10 → webbee-0.3.12}/tests/test_details.py +0 -0
  64. {webbee-0.3.10 → webbee-0.3.12}/tests/test_events.py +0 -0
  65. {webbee-0.3.10 → webbee-0.3.12}/tests/test_freeze_fix.py +0 -0
  66. {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_chunker.py +0 -0
  67. {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_embed.py +0 -0
  68. {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_graph.py +0 -0
  69. {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_indexer.py +0 -0
  70. {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_no_numpy.py +0 -0
  71. {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_query.py +0 -0
  72. {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_service.py +0 -0
  73. {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_store.py +0 -0
  74. {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_vectors.py +0 -0
  75. {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_watch.py +0 -0
  76. {webbee-0.3.10 → webbee-0.3.12}/tests/test_packaging.py +0 -0
  77. {webbee-0.3.10 → webbee-0.3.12}/tests/test_repo.py +0 -0
  78. {webbee-0.3.10 → webbee-0.3.12}/tests/test_sessions.py +0 -0
  79. {webbee-0.3.10 → webbee-0.3.12}/tests/test_steer.py +0 -0
  80. {webbee-0.3.10 → webbee-0.3.12}/tests/test_stream.py +0 -0
  81. {webbee-0.3.10 → webbee-0.3.12}/tests/test_thread.py +0 -0
  82. {webbee-0.3.10 → webbee-0.3.12}/tests/test_tokens.py +0 -0
  83. {webbee-0.3.10 → webbee-0.3.12}/tests/test_tools.py +0 -0
  84. {webbee-0.3.10 → webbee-0.3.12}/tests/test_tui_hardening.py +0 -0
  85. {webbee-0.3.10 → webbee-0.3.12}/tests/test_update.py +0 -0
  86. {webbee-0.3.10 → webbee-0.3.12}/tests/test_version.py +0 -0
@@ -1,5 +1,23 @@
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
+
15
+ ## 0.3.11
16
+
17
+ - Type-ahead queue: send follow-ups while Webbee is working — they queue and
18
+ run after the current turn; the toolbar shows how many are waiting.
19
+ - Up-arrow recalls your last message to edit and resend.
20
+
3
21
  ## 0.3.10
4
22
 
5
23
  - Remote instructions picked up by an idle terminal now carry a dedup id, so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.10
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.10"
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
@@ -2,11 +2,17 @@
2
2
  output_pane.py) fills the top; a bordered input box + toolbar are pinned at
3
3
  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
- pinned box when the input is empty and no turn is running. Pure helpers
6
- (next_mode/build_toolbar) are unit-tested; the Application is TTY/headless-
7
- smoke verified. Grounded in prompt_toolkit 3.0.52."""
5
+ pinned box when the input is empty and no turn is running; in every other
6
+ state Up/Down recall submitted lines (readline-style), and Enter while a turn
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
11
+ unit-tested; the Application is TTY/headless-smoke verified. Grounded in
12
+ prompt_toolkit 3.0.52."""
8
13
  import asyncio
9
14
  import re
15
+ from collections import deque
10
16
 
11
17
  from webbee.output_pane import OutputPane # noqa: F401 — re-exported (webbee.tui.OutputPane)
12
18
  from webbee.render import _fmt_tokens
@@ -64,13 +70,17 @@ def next_mode(mode: str) -> str:
64
70
 
65
71
  def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
66
72
  current: str = "", elapsed: float = 0.0, tools: int = 0,
67
- consent: bool = False) -> list:
73
+ consent: bool = False, queued: int = 0) -> list:
68
74
  """The status line under the pinned input box, as prompt_toolkit formatted
69
75
  text (per-segment styled). Three states: consent (awaiting a reply), busy
70
76
  (a turn is running — an ANIMATED coloured spinner + the current action in
71
77
  accent, so it pops, not grey), and idle (mode value coloured PER MODE —
72
78
  default cyan / plan purple / autopilot yellow — + SESSION spend + the
73
- Shift + TAB hint). Style classes are defined in run_session's Style."""
79
+ Shift + TAB hint). `queued` = type-ahead lines waiting to run after the
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 []
74
84
  if consent:
75
85
  return [("class:tb.consent", " approve? type y / n / a reply · Enter to send")]
76
86
  if busy:
@@ -79,13 +89,15 @@ def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
79
89
  if current:
80
90
  frags += [("class:tb.dim", " · "), ("class:tb.action", current)]
81
91
  frags.append(("class:tb.dim",
82
- f" · {elapsed:.0f}s · {tools} · {_fmt_tokens(tokens)} tok"
83
- 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"))
84
95
  return frags
85
96
  return [("class:tb.dim", " mode: "),
86
97
  (f"class:tb.mode.{mode}", mode),
87
- ("class:tb.dim",
88
- f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits · 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")]
89
101
 
90
102
 
91
103
  def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event, buf=None) -> None:
@@ -105,6 +117,7 @@ def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event, buf=None) -
105
117
  if t is not None and not t.done():
106
118
  if stop_turn is not None:
107
119
  event.app.create_background_task(stop_turn())
120
+ turn["stopped"] = True # user STOP → the type-ahead queue must NOT auto-run
108
121
  t.cancel() # cancel the running turn; dock survives
109
122
  return
110
123
  sel["i"] = None
@@ -119,18 +132,96 @@ def _interrupt_action(turn: dict, is_busy, stop_turn, event) -> None:
119
132
  if t is not None and not t.done():
120
133
  if is_busy() and stop_turn is not None:
121
134
  event.app.create_background_task(stop_turn())
135
+ turn["stopped"] = True # user STOP → the type-ahead queue must NOT auto-run
122
136
  t.cancel() # cancel the running turn; dock survives
123
137
 
124
138
 
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"
150
+ buf.history.append_string(text)
151
+ if busy:
152
+ pending.append(text)
153
+ if queued_echo is not None:
154
+ queued_echo(text)
155
+ return "queued"
156
+ start(text)
157
+ return "started"
158
+
159
+
160
+ def _drain_pending(pending, start, mark=None) -> bool:
161
+ """Turn-completion drain: pop the OLDEST queued type-ahead line and hand
162
+ it to `start` — the SAME path a typed line takes, so a persistent
163
+ marathon receives it as a `new_task` into the running session. ONE item
164
+ per completion; the rest stay queued FIFO (each finished turn drains the
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."""
168
+ if not pending:
169
+ return False
170
+ text = pending.popleft()
171
+ if mark is not None:
172
+ mark(len(pending))
173
+ start(text)
174
+ return True
175
+
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
+
185
+ def _arrow_up_action(event, buf, sel: dict, n: int, busy: bool) -> None:
186
+ """Up key. Step-navigation EXACTLY as before when steps exist + input is
187
+ empty + idle; in EVERY other state (busy, or text present, or no steps)
188
+ recall an older submitted line into the buffer (readline-style)."""
189
+ if n and not buf.text and not busy:
190
+ sel["i"] = (n - 1) if sel["i"] is None else max(0, sel["i"] - 1)
191
+ event.app.invalidate()
192
+ return
193
+ buf.history_backward()
194
+ event.app.invalidate()
195
+
196
+
197
+ def _arrow_down_action(event, buf, sel: dict, n: int, busy: bool) -> None:
198
+ """Down key — mirror of _arrow_up_action: step-nav on the same exact
199
+ gate, otherwise cycle history forward."""
200
+ if n and not buf.text and not busy:
201
+ sel["i"] = 0 if sel["i"] is None else min(n - 1, sel["i"] + 1)
202
+ event.app.invalidate()
203
+ return
204
+ buf.history_forward()
205
+ event.app.invalidate()
206
+
207
+
125
208
  async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
126
209
  is_busy, consent_pending, resolve_consent, steps_nav=None,
127
- stop_turn=None) -> bool:
210
+ stop_turn=None, pending=None, queued_echo=None,
211
+ queued_run=None) -> bool:
128
212
  """The full-screen dock: `pane` fills the top (scrollable), a bordered input
129
213
  box + toolbar are FIXED at the bottom. Enter either resolves a pending
130
214
  consent reply (ICNLI: raw verbatim) or starts a turn as a BACKGROUND task
131
- (the box stays fixed during it). When `steps_nav` is given and the input is
132
- empty and no turn is running, Up/Down move a step selection (toolbar shows
133
- `step k/N`) and Enter expands it via `steps_nav["expand"]`; Esc clears it.
215
+ (the box stays fixed during it); while a turn runs, Enter QUEUES the line
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
222
+ is running, Up/Down move a step selection (toolbar shows `step k/N`) and
223
+ Enter expands it via `steps_nav["expand"]`; Esc clears it. In every other
224
+ state Up/Down recall submitted lines (readline-style history).
134
225
  Returns True on clean exit; False if prompt_toolkit is unavailable (caller
135
226
  uses the plain fallback loop)."""
136
227
  try:
@@ -147,12 +238,32 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
147
238
 
148
239
  buf = Buffer(multiline=False)
149
240
  turn = {"task": None}
241
+ if pending is None:
242
+ pending = deque() # type-ahead queue: lines submitted while a turn runs
243
+
244
+ def _start_turn(text):
245
+ turn.pop("stopped", None) # a stale stop flag must never eat the next natural drain
246
+ turn["task"] = get_app().create_background_task(_run_turn(text))
150
247
 
151
248
  async def _run_turn(text):
249
+ done = False
152
250
  try:
153
251
  await on_line(text)
252
+ done = True
154
253
  finally:
155
254
  turn["task"] = None
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)
156
267
  get_app().invalidate()
157
268
 
158
269
  kb = KeyBindings()
@@ -178,9 +289,21 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
178
289
  idx, sel["i"] = sel["i"], None
179
290
  event.app.create_background_task(steps_nav["expand"](idx))
180
291
  return
181
- if _busy_live() or not text.strip():
292
+ if not text.strip():
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))
182
301
  return
183
- turn["task"] = event.app.create_background_task(_run_turn(text))
302
+ # Non-empty line: record for up-arrow recall, then queue-while-busy
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":
306
+ event.app.invalidate() # toolbar shows the new depth
184
307
 
185
308
  @kb.add("s-tab")
186
309
  def _cycle(event):
@@ -206,17 +329,11 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
206
329
 
207
330
  @kb.add("up")
208
331
  def _step_up(event):
209
- n = _nav_count()
210
- if n and not buf.text and not _busy_live():
211
- sel["i"] = (n - 1) if sel["i"] is None else max(0, sel["i"] - 1)
212
- event.app.invalidate()
332
+ _arrow_up_action(event, buf, sel, _nav_count(), _busy_live())
213
333
 
214
334
  @kb.add("down")
215
335
  def _step_down(event):
216
- n = _nav_count()
217
- if n and not buf.text and not _busy_live():
218
- sel["i"] = 0 if sel["i"] is None else min(n - 1, sel["i"] + 1)
219
- event.app.invalidate()
336
+ _arrow_down_action(event, buf, sel, _nav_count(), _busy_live())
220
337
 
221
338
  @kb.add("escape")
222
339
  def _step_clear(event):
@@ -239,7 +356,8 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
239
356
  st = status()
240
357
  return build_toolbar(mode_getter(), st["tokens"], st["credits"], busy=st["busy"],
241
358
  current=st["current"], elapsed=st["elapsed"],
242
- tools=st["tools"], consent=st["consent"])
359
+ tools=st["tools"], consent=st["consent"],
360
+ queued=len(pending))
243
361
 
244
362
  # Dynamic height: EXACTLY the rows the wrapped input needs (1→10), so the box
245
363
  # grows as you type and shrinks back — never a fixed huge block. Enter still
@@ -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 == []