webbee 0.3.11__tar.gz → 0.3.13__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.11 → webbee-0.3.13}/CHANGELOG.md +22 -0
  2. {webbee-0.3.11 → webbee-0.3.13}/PKG-INFO +1 -1
  3. {webbee-0.3.11 → webbee-0.3.13}/pyproject.toml +1 -1
  4. webbee-0.3.13/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/commands.py +19 -0
  6. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/frames.py +23 -0
  7. webbee-0.3.13/src/webbee/queue_panel.py +101 -0
  8. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/render.py +77 -22
  9. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/repl.py +13 -1
  10. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/session.py +15 -8
  11. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/tui.py +132 -38
  12. {webbee-0.3.11 → webbee-0.3.13}/tests/test_commands.py +34 -0
  13. {webbee-0.3.11 → webbee-0.3.13}/tests/test_marathon.py +64 -1
  14. {webbee-0.3.11 → webbee-0.3.13}/tests/test_render.py +128 -6
  15. {webbee-0.3.11 → webbee-0.3.13}/tests/test_repl.py +11 -0
  16. {webbee-0.3.11 → webbee-0.3.13}/tests/test_session.py +7 -0
  17. {webbee-0.3.11 → webbee-0.3.13}/tests/test_tui.py +470 -0
  18. webbee-0.3.11/src/webbee/__init__.py +0 -1
  19. {webbee-0.3.11 → webbee-0.3.13}/.github/workflows/publish.yml +0 -0
  20. {webbee-0.3.11 → webbee-0.3.13}/.gitignore +0 -0
  21. {webbee-0.3.11 → webbee-0.3.13}/LICENSE +0 -0
  22. {webbee-0.3.11 → webbee-0.3.13}/README.md +0 -0
  23. {webbee-0.3.11 → webbee-0.3.13}/install.sh +0 -0
  24. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/account.py +0 -0
  25. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/banner_art.py +0 -0
  26. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/boot.py +0 -0
  27. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/checkpoints.py +0 -0
  28. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/cli.py +0 -0
  29. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/clipboard.py +0 -0
  30. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/coding_context.py +0 -0
  31. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/config.py +0 -0
  32. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/consent.py +0 -0
  33. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/details.py +0 -0
  34. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/events.py +0 -0
  35. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/intel/__init__.py +0 -0
  36. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/intel/chunker.py +0 -0
  37. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/intel/embed.py +0 -0
  38. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/intel/graph.py +0 -0
  39. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/intel/indexer.py +0 -0
  40. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/intel/models.py +0 -0
  41. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/intel/query.py +0 -0
  42. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/intel/service.py +0 -0
  43. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/intel/store.py +0 -0
  44. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/intel/vectors.py +0 -0
  45. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/intel/watch.py +0 -0
  46. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/output_pane.py +0 -0
  47. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/remote.py +0 -0
  48. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/repo.py +0 -0
  49. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/sessions.py +0 -0
  50. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/steer.py +0 -0
  51. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/stream.py +0 -0
  52. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/thread.py +0 -0
  53. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/tokens.py +0 -0
  54. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/tools.py +0 -0
  55. {webbee-0.3.11 → webbee-0.3.13}/src/webbee/update.py +0 -0
  56. {webbee-0.3.11 → webbee-0.3.13}/tests/__init__.py +0 -0
  57. {webbee-0.3.11 → webbee-0.3.13}/tests/test_account.py +0 -0
  58. {webbee-0.3.11 → webbee-0.3.13}/tests/test_checkpoints.py +0 -0
  59. {webbee-0.3.11 → webbee-0.3.13}/tests/test_cli.py +0 -0
  60. {webbee-0.3.11 → webbee-0.3.13}/tests/test_clipboard.py +0 -0
  61. {webbee-0.3.11 → webbee-0.3.13}/tests/test_config.py +0 -0
  62. {webbee-0.3.11 → webbee-0.3.13}/tests/test_cpc_contract_stable.py +0 -0
  63. {webbee-0.3.11 → webbee-0.3.13}/tests/test_details.py +0 -0
  64. {webbee-0.3.11 → webbee-0.3.13}/tests/test_events.py +0 -0
  65. {webbee-0.3.11 → webbee-0.3.13}/tests/test_freeze_fix.py +0 -0
  66. {webbee-0.3.11 → webbee-0.3.13}/tests/test_intel_chunker.py +0 -0
  67. {webbee-0.3.11 → webbee-0.3.13}/tests/test_intel_embed.py +0 -0
  68. {webbee-0.3.11 → webbee-0.3.13}/tests/test_intel_graph.py +0 -0
  69. {webbee-0.3.11 → webbee-0.3.13}/tests/test_intel_indexer.py +0 -0
  70. {webbee-0.3.11 → webbee-0.3.13}/tests/test_intel_no_numpy.py +0 -0
  71. {webbee-0.3.11 → webbee-0.3.13}/tests/test_intel_query.py +0 -0
  72. {webbee-0.3.11 → webbee-0.3.13}/tests/test_intel_service.py +0 -0
  73. {webbee-0.3.11 → webbee-0.3.13}/tests/test_intel_store.py +0 -0
  74. {webbee-0.3.11 → webbee-0.3.13}/tests/test_intel_vectors.py +0 -0
  75. {webbee-0.3.11 → webbee-0.3.13}/tests/test_intel_watch.py +0 -0
  76. {webbee-0.3.11 → webbee-0.3.13}/tests/test_packaging.py +0 -0
  77. {webbee-0.3.11 → webbee-0.3.13}/tests/test_repo.py +0 -0
  78. {webbee-0.3.11 → webbee-0.3.13}/tests/test_sessions.py +0 -0
  79. {webbee-0.3.11 → webbee-0.3.13}/tests/test_steer.py +0 -0
  80. {webbee-0.3.11 → webbee-0.3.13}/tests/test_stream.py +0 -0
  81. {webbee-0.3.11 → webbee-0.3.13}/tests/test_thread.py +0 -0
  82. {webbee-0.3.11 → webbee-0.3.13}/tests/test_tokens.py +0 -0
  83. {webbee-0.3.11 → webbee-0.3.13}/tests/test_tools.py +0 -0
  84. {webbee-0.3.11 → webbee-0.3.13}/tests/test_tui_hardening.py +0 -0
  85. {webbee-0.3.11 → webbee-0.3.13}/tests/test_update.py +0 -0
  86. {webbee-0.3.11 → webbee-0.3.13}/tests/test_version.py +0 -0
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.13
4
+
5
+ - The message queue is now a live panel above the input — you see exactly
6
+ what's waiting, press ↑ to pull the last one back for editing (it leaves
7
+ the queue and returns when you resend), or click one to edit it. The
8
+ transcript stays clean.
9
+ - Cleaner welcome screen — just the essentials (who you are, your plan, how
10
+ to start) plus a clear word on privacy: your work is never sold and never
11
+ used to train models, and PII is masked before it reaches the model.
12
+
13
+ ## 0.3.12
14
+
15
+ - Queued messages are now visible — when you type while Webbee is working,
16
+ your message shows as `⋯ queued: …` right away, the toolbar shows the count,
17
+ and it runs (clearly) after the current turn. New `/queue` and `/queue clear`
18
+ to see and manage what's waiting — they work even mid-turn.
19
+ - Stopping a turn (Esc/Ctrl-C) no longer auto-runs the queue: it stays put,
20
+ visible, until you clear it or a next turn finishes naturally.
21
+ - Task lists now render in full — every to-do with its status (✓ done ·
22
+ ▶ doing · ○ next), redrawn on each update, so you always see the whole plan
23
+ and where Webbee is in it.
24
+
3
25
  ## 0.3.11
4
26
 
5
27
  - 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.13
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.13"
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.13"
@@ -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
@@ -0,0 +1,101 @@
1
+ """The LIVE pending-queue panel pinned between the output pane and the input
2
+ box (0.3.13 — replaces the static `⋯ queued:` scrollback echoes, which
3
+ scrolled away, duplicated and never updated). The builders here are PURE
4
+ (unit-tested without an Application); tui.run_session mounts them in a
5
+ ConditionalContainer that occupies ZERO rows while the queue is empty, so
6
+ the empty-queue dock is pixel-identical to before. Item rows carry a
7
+ per-fragment mouse handler (prompt_toolkit 3.0.52 3-tuple fragments,
8
+ verified in venv) firing on MOUSE_UP only — a PASSIVE consumer of the
9
+ clicks that already flow under the ?1000/?1002 button-event tracking; it
10
+ enables nothing at the terminal level, so the 0.3.3 mouse-flood fix
11
+ (?1003 any-event stays off, tui.configure_mouse_modes) is untouched.
12
+ Split out of tui.py to keep both files under the file-size ceiling."""
13
+
14
+ QP_MAX_ITEMS = 5 # newest items shown; deeper queues add one `… +K more` row
15
+
16
+
17
+ def one_line(text: str, width: int) -> str:
18
+ """PURE. Collapse whitespace/newlines so a queued item costs EXACTLY one
19
+ panel row, truncating with `…` when it would overflow `width` columns
20
+ (width<=0 = no truncation — headless/unknown terminal)."""
21
+ t = " ".join((text or "").split())
22
+ if width > 0 and len(t) > width:
23
+ t = t[:max(1, width - 1)] + "…"
24
+ return t
25
+
26
+
27
+ def queue_height(pending) -> int:
28
+ """PURE. Rows the panel needs: 1 header + one per SHOWN item + one
29
+ `… +K more` row when the queue is deeper than QP_MAX_ITEMS. 0 when empty
30
+ (the ConditionalContainer hides the panel then anyway). The cap keeps the
31
+ output pane dominant on small terminals; the toolbar's `⋯N queued`
32
+ segment stays the truth-teller for the full depth."""
33
+ n = len(pending)
34
+ if not n:
35
+ return 0
36
+ return 1 + min(n, QP_MAX_ITEMS) + (1 if n > QP_MAX_ITEMS else 0)
37
+
38
+
39
+ def pull_item(pending, buf, index: int) -> bool:
40
+ """The ONE pull-to-edit implementation (serves BOTH the ↑ key — newest,
41
+ index len(pending)-1 — and a panel-row click — that row's index): move
42
+ pending[index] OUT of the queue and into the input buffer for editing,
43
+ cursor at the end. Guards, identical on both paths: a buffer with ANY
44
+ text is never clobbered, and a stale index (the queue drained between
45
+ render and click) is ignored. Returns True when a pull happened (the
46
+ caller invalidates)."""
47
+ if buf.text or not (0 <= index < len(pending)):
48
+ return False
49
+ item = pending[index]
50
+ del pending[index]
51
+ buf.text = item
52
+ buf.cursor_position = len(item)
53
+ return True
54
+
55
+
56
+ def _item_handler(pull, index: int):
57
+ """One row's mouse handler: MOUSE_UP (a click, not a drag/press) pulls
58
+ THAT queued item into the input via `pull(index)`; every other event
59
+ falls through (NotImplemented) so wheel scroll etc. keep today's
60
+ behavior. Mirrors OutputPane._SelectControl's event discipline."""
61
+ def _h(mouse_event):
62
+ from prompt_toolkit.mouse_events import MouseEventType
63
+ if mouse_event.event_type == MouseEventType.MOUSE_UP:
64
+ pull(index)
65
+ return None
66
+ return NotImplemented
67
+ return _h
68
+
69
+
70
+ def queue_fragments(pending, pull=None, width: int = 0):
71
+ """PURE builder: the panel as prompt_toolkit formatted text, re-invoked
72
+ every redraw (same live mechanics as the toolbar) so every queue
73
+ add/edit/drain shows at once. Layout, top→bottom = drain order (FIFO —
74
+ the TOP row runs next; the BOTTOM row is the newest, the one ↑ pulls,
75
+ sitting right above the input):
76
+
77
+ ⋯ queued (N) · ↑ edit last · click to edit
78
+ … +K more ← only when N > QP_MAX_ITEMS (the OLDEST hide)
79
+ older item ← muted (qp.item)
80
+ newest item ← accent (qp.last)
81
+
82
+ When `pull` is given each item row is a 3-tuple fragment carrying a
83
+ mouse handler that pulls exactly that item (see _item_handler). Empty
84
+ queue → [] (the panel is hidden)."""
85
+ items = list(pending)
86
+ n = len(items)
87
+ if not n:
88
+ return []
89
+ frags = [("class:qp.header", f" ⋯ queued ({n})"),
90
+ ("class:qp.item", " · ↑ edit last · click to edit")]
91
+ start = max(0, n - QP_MAX_ITEMS)
92
+ if start:
93
+ frags.append(("class:qp.item", f"\n … +{start} more"))
94
+ for i in range(start, n):
95
+ style = "class:qp.last" if i == n - 1 else "class:qp.item"
96
+ row = "\n " + one_line(items[i], width - 4 if width > 0 else 0)
97
+ if pull is None:
98
+ frags.append((style, row))
99
+ else:
100
+ frags.append((style, row, _item_handler(pull, i)))
101
+ return frags
@@ -44,6 +44,18 @@ _ICON = {"read_file": "📖", "grep": "🔎", "glob": "🗂️", "write_file": "
44
44
  _BEE = "yellow" # bee-yellow brand accent — logo / 🐝 / notes / busy dot ONLY
45
45
  _ACCENT = "cyan" # interactive chrome ONLY — live caret / mode / panel url
46
46
 
47
+ # ---- welcome copy (single source of truth for the splash + its tests) ------
48
+ # The privacy promise is claims-disciplined: every clause is TRUE and enforced
49
+ # today — user data is never sold and never used to train any model (ours or
50
+ # third-party; documented in the DPA), and PII is masked before tool output
51
+ # re-enters the model (platform-enforced and covered by the platform's own
52
+ # test suite). Deliberately NOT said: "we collect nothing" (sessions ARE
53
+ # stored so Webbee can resume your work) and "guaranteed" (we say what is
54
+ # enforced, not what is promised). Change the wording only with a source.
55
+ WELCOME_PRIVACY = "🔒 Your work stays yours — never sold, never training data."
56
+ WELCOME_PRIVACY_DETAIL = "PII is masked before it reaches the model."
57
+ WELCOME_HINT = "Type a task — Webbee runs it to completion · /help · Ctrl-D to exit"
58
+
47
59
 
48
60
  def _fmt_tokens(n: int) -> str:
49
61
  """Compact count for the live toolbar: 900 -> '900', 2_100 -> '2.1k',
@@ -132,10 +144,13 @@ class RichSink:
132
144
 
133
145
  # ---- welcome ------------------------------------------------------------
134
146
  def welcome(self, account, cwd: str, surface: str) -> None:
135
- """One-time launch splash: a centered WEBBEE CODE logo + imperal.io + an
136
- honest account panel (who/plan/tier/member-since). Runs BEFORE the dock
137
- starts. Clears the screen ONLY in the non-pane path (the full-screen
138
- dock owns its own alternate screen clearing there would corrupt it)."""
147
+ """One-time launch splash, trimmed to what a human actually needs:
148
+ the WEBBEE CODE logo (the brand), ONE identity line (who am I / plan —
149
+ plan status shows only when it needs attention), the privacy promise
150
+ (true, enforced claims only see WELCOME_PRIVACY above), and one hint
151
+ line to get going. Runs BEFORE the dock starts. Clears the screen ONLY
152
+ in the non-pane path (the full-screen dock owns its own alternate
153
+ screen — clearing there would corrupt it)."""
139
154
  if self._on_output is None:
140
155
  self.console.clear()
141
156
  w = self.console.width
@@ -152,29 +167,24 @@ class RichSink:
152
167
  self.console.print(Text("ICNLI AI Cloud OS · Agent".center(w), style=f"bold {_ACCENT}"))
153
168
  self.console.print(Text("· i m p e r a l . i o ·".center(w), style="dim"))
154
169
  self.console.print()
155
- rows = []
156
170
  if account.signed_in:
157
- who = account.email + (f" · @{account.nickname}" if account.nickname else "")
158
- rows.append(("Signed in as", who))
171
+ who = account.email or ""
172
+ if account.nickname:
173
+ who += f" · @{account.nickname}"
159
174
  if account.plan:
160
- plan = account.plan + (f" · {account.plan_status}" if account.plan_status else "")
161
- plan += (f" · renews {account.plan_renews}" if account.plan_renews else "")
162
- rows.append(("Plan", plan))
163
- if account.dev_tier:
164
- rows.append(("Developer", f"{account.dev_tier} tier"))
165
- if account.member_since:
166
- rows.append(("Member since", account.member_since))
175
+ who += f" · {account.plan} plan"
176
+ if account.plan_status and account.plan_status != "active":
177
+ who += f" ({account.plan_status})"
178
+ label = "Signed in as "
179
+ pad = " " * max(0, (w - len(label) - len(who)) // 2)
180
+ self.console.print(Text.assemble((pad + label, "dim"), (who, "white")))
167
181
  else:
168
- rows.append(("", "not signed in — /login"))
169
- bw = max((len(label.ljust(14) + value) for label, value in rows), default=0)
170
- pad = " " * max(0, (w - bw) // 2)
171
- for label, value in rows:
172
- self.console.print(Text.assemble((pad + label.ljust(14), "dim"), (value, "white")))
182
+ self.console.print(Text("not signed in — /login".center(w), style="dim"))
173
183
  self.console.print()
174
- tip = "Type a task and Webbee runs it to completion · pauses if credits run low · --once = single turn"
175
- self.console.print(Text(tip.center(w), style="dim"))
184
+ self.console.print(Text(WELCOME_PRIVACY.center(w), style="white"))
185
+ self.console.print(Text(WELCOME_PRIVACY_DETAIL.center(w), style="dim"))
176
186
  self.console.print()
177
- self.console.print(Text("/help · Ctrl-D to exit".center(w), style="dim"))
187
+ self.console.print(Text(WELCOME_HINT.center(w), style="dim"))
178
188
  self.console.print()
179
189
  self._nudge()
180
190
 
@@ -215,6 +225,40 @@ class RichSink:
215
225
  self.console.print(_pad(Text(_clean(message), style=_BEE)))
216
226
  self._nudge()
217
227
 
228
+ def todos(self, items: list, total: int, done: int) -> None:
229
+ """Claude-Code-style checklist for the coding TODO scratchpad. The
230
+ kernel republishes the FULL list on every todo_write, so each update
231
+ re-renders the whole current plan inline — the latest complete list
232
+ always sits in the scrollback (deliberately NOT a pinned sticky panel:
233
+ inline re-render can't fight the dock layout). Order preserved, one
234
+ line per item: ✓ completed (dim, struck) · ▶ in progress (bold, so
235
+ "what's happening now" pops) · ○ pending (muted). Defensive: malformed
236
+ items are skipped, unknown statuses render as pending, bad counts
237
+ degrade to a bare header — never raises."""
238
+ try:
239
+ head = f"📋 Todos ({int(done)}/{int(total)})"
240
+ except (TypeError, ValueError):
241
+ head = "📋 Todos"
242
+ body = Text() # empty base style — segments below
243
+ body.append(head, style=f"bold {_BEE}") # carry their OWN, no bleed-through
244
+ for item in (items if isinstance(items, (list, tuple)) else ()):
245
+ if not isinstance(item, dict):
246
+ continue # malformed entry — skip, never raise
247
+ content = _clean(item.get("content", "")).strip()
248
+ if not content:
249
+ continue
250
+ status = str(item.get("status", "") or "")
251
+ if status == "completed":
252
+ glyph, g_style, t_style = "✓", "green", "dim strike"
253
+ elif status == "in_progress":
254
+ glyph, g_style, t_style = "▶", f"bold {_BEE}", "bold"
255
+ else: # pending / unknown -> not started yet
256
+ glyph, g_style, t_style = "○", "grey66", "grey66"
257
+ body.append(f"\n {glyph} ", style=g_style)
258
+ body.append(content, style=t_style)
259
+ self.console.print(_pad(body))
260
+ self._nudge()
261
+
218
262
  def user_echo(self, text: str) -> None:
219
263
  """Commit the just-sent user message as its own clearly-readable line
220
264
  with a background bar (NOT boxed like the live input) so it stands out
@@ -222,6 +266,17 @@ class RichSink:
222
266
  self.console.print(_pad(Text(" ❯ " + _clean(text) + " ", style="bold white on grey30")))
223
267
  self._nudge()
224
268
 
269
+ def queued_run(self, remaining: int) -> None:
270
+ """The tiny lifecycle marker printed right before a drained queued
271
+ line starts: `▶ running queued message` (+ how many still wait) — a
272
+ drain is never a silent start; the drained text's normal ❯ user_echo
273
+ follows immediately."""
274
+ tail = f" · {remaining} still queued" if remaining else ""
275
+ self.console.print(_pad(Text.assemble(
276
+ ("▶ ", f"bold {_BEE}"),
277
+ (f"running queued message{tail}", "italic grey66"))))
278
+ self._nudge()
279
+
225
280
  def foreign_turn(self, surface: str, role: str, text: str) -> None:
226
281
  """One tagged line for a turn that lives on ANOTHER surface (a
227
282
  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,7 @@ 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_run=_sink.queued_run,
317
329
  )
318
330
  finally:
319
331
  _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