webbee 0.3.14__tar.gz → 0.3.16__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 (88) hide show
  1. {webbee-0.3.14 → webbee-0.3.16}/CHANGELOG.md +14 -0
  2. {webbee-0.3.14 → webbee-0.3.16}/PKG-INFO +1 -1
  3. {webbee-0.3.14 → webbee-0.3.16}/pyproject.toml +1 -1
  4. webbee-0.3.16/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/render.py +93 -18
  6. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/repl.py +37 -2
  7. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/session.py +11 -10
  8. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/thread.py +23 -1
  9. webbee-0.3.16/src/webbee/todo_panel.py +91 -0
  10. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/tui.py +114 -22
  11. {webbee-0.3.14 → webbee-0.3.16}/tests/test_packaging.py +2 -1
  12. {webbee-0.3.14 → webbee-0.3.16}/tests/test_render.py +131 -0
  13. {webbee-0.3.14 → webbee-0.3.16}/tests/test_repl.py +82 -0
  14. {webbee-0.3.14 → webbee-0.3.16}/tests/test_session.py +17 -10
  15. {webbee-0.3.14 → webbee-0.3.16}/tests/test_thread.py +71 -0
  16. webbee-0.3.16/tests/test_todo_panel.py +87 -0
  17. {webbee-0.3.14 → webbee-0.3.16}/tests/test_tui.py +222 -0
  18. webbee-0.3.14/src/webbee/__init__.py +0 -1
  19. {webbee-0.3.14 → webbee-0.3.16}/.github/workflows/publish.yml +0 -0
  20. {webbee-0.3.14 → webbee-0.3.16}/.gitignore +0 -0
  21. {webbee-0.3.14 → webbee-0.3.16}/LICENSE +0 -0
  22. {webbee-0.3.14 → webbee-0.3.16}/README.md +0 -0
  23. {webbee-0.3.14 → webbee-0.3.16}/install.sh +0 -0
  24. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/account.py +0 -0
  25. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/banner_art.py +0 -0
  26. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/boot.py +0 -0
  27. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/checkpoints.py +0 -0
  28. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/cli.py +0 -0
  29. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/clipboard.py +0 -0
  30. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/coding_context.py +0 -0
  31. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/commands.py +0 -0
  32. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/config.py +0 -0
  33. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/consent.py +0 -0
  34. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/details.py +0 -0
  35. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/events.py +0 -0
  36. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/frames.py +0 -0
  37. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/intel/__init__.py +0 -0
  38. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/intel/chunker.py +0 -0
  39. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/intel/embed.py +0 -0
  40. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/intel/graph.py +0 -0
  41. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/intel/indexer.py +0 -0
  42. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/intel/models.py +0 -0
  43. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/intel/query.py +0 -0
  44. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/intel/service.py +0 -0
  45. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/intel/store.py +0 -0
  46. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/intel/vectors.py +0 -0
  47. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/intel/watch.py +0 -0
  48. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/output_pane.py +0 -0
  49. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/queue_panel.py +0 -0
  50. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/remote.py +0 -0
  51. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/repo.py +0 -0
  52. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/sessions.py +0 -0
  53. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/steer.py +0 -0
  54. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/stream.py +0 -0
  55. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/tokens.py +0 -0
  56. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/tools.py +0 -0
  57. {webbee-0.3.14 → webbee-0.3.16}/src/webbee/update.py +0 -0
  58. {webbee-0.3.14 → webbee-0.3.16}/tests/__init__.py +0 -0
  59. {webbee-0.3.14 → webbee-0.3.16}/tests/test_account.py +0 -0
  60. {webbee-0.3.14 → webbee-0.3.16}/tests/test_checkpoints.py +0 -0
  61. {webbee-0.3.14 → webbee-0.3.16}/tests/test_cli.py +0 -0
  62. {webbee-0.3.14 → webbee-0.3.16}/tests/test_clipboard.py +0 -0
  63. {webbee-0.3.14 → webbee-0.3.16}/tests/test_commands.py +0 -0
  64. {webbee-0.3.14 → webbee-0.3.16}/tests/test_config.py +0 -0
  65. {webbee-0.3.14 → webbee-0.3.16}/tests/test_cpc_contract_stable.py +0 -0
  66. {webbee-0.3.14 → webbee-0.3.16}/tests/test_details.py +0 -0
  67. {webbee-0.3.14 → webbee-0.3.16}/tests/test_events.py +0 -0
  68. {webbee-0.3.14 → webbee-0.3.16}/tests/test_freeze_fix.py +0 -0
  69. {webbee-0.3.14 → webbee-0.3.16}/tests/test_intel_chunker.py +0 -0
  70. {webbee-0.3.14 → webbee-0.3.16}/tests/test_intel_embed.py +0 -0
  71. {webbee-0.3.14 → webbee-0.3.16}/tests/test_intel_graph.py +0 -0
  72. {webbee-0.3.14 → webbee-0.3.16}/tests/test_intel_indexer.py +0 -0
  73. {webbee-0.3.14 → webbee-0.3.16}/tests/test_intel_no_numpy.py +0 -0
  74. {webbee-0.3.14 → webbee-0.3.16}/tests/test_intel_query.py +0 -0
  75. {webbee-0.3.14 → webbee-0.3.16}/tests/test_intel_service.py +0 -0
  76. {webbee-0.3.14 → webbee-0.3.16}/tests/test_intel_store.py +0 -0
  77. {webbee-0.3.14 → webbee-0.3.16}/tests/test_intel_vectors.py +0 -0
  78. {webbee-0.3.14 → webbee-0.3.16}/tests/test_intel_watch.py +0 -0
  79. {webbee-0.3.14 → webbee-0.3.16}/tests/test_marathon.py +0 -0
  80. {webbee-0.3.14 → webbee-0.3.16}/tests/test_repo.py +0 -0
  81. {webbee-0.3.14 → webbee-0.3.16}/tests/test_sessions.py +0 -0
  82. {webbee-0.3.14 → webbee-0.3.16}/tests/test_steer.py +0 -0
  83. {webbee-0.3.14 → webbee-0.3.16}/tests/test_stream.py +0 -0
  84. {webbee-0.3.14 → webbee-0.3.16}/tests/test_tokens.py +0 -0
  85. {webbee-0.3.14 → webbee-0.3.16}/tests/test_tools.py +0 -0
  86. {webbee-0.3.14 → webbee-0.3.16}/tests/test_tui_hardening.py +0 -0
  87. {webbee-0.3.14 → webbee-0.3.16}/tests/test_update.py +0 -0
  88. {webbee-0.3.14 → webbee-0.3.16}/tests/test_version.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.16
4
+
5
+ - A message typed while Webbee is working now shows in the queue panel
6
+ exactly once: the queue reconciles every entry by its id, so a retried
7
+ delivery or a send that only looked failed no longer produces a duplicate
8
+ row.
9
+
10
+ ## 0.3.15
11
+
12
+ - Type a follow-up while Webbee is working and it now flies into the current
13
+ run within seconds (not after it finishes), shown in the queue tagged by
14
+ where it came from. The task list is now a pinned panel that stays visible
15
+ and updates live.
16
+
3
17
  ## 0.3.14
4
18
 
5
19
  - See queued messages from Telegram/panel right in the terminal queue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.14
3
+ Version: 0.3.16
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.14"
3
+ version = "0.3.16"
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.16"
@@ -139,6 +139,21 @@ class RichSink:
139
139
  # queue panel reads THIS list object every redraw — mutate in place
140
140
  # only (append/del/clear), never rebind.
141
141
  self.remote_pending: list = []
142
+ # The repl's OWN type-ahead deque (the SAME object tui mutates),
143
+ # shared by reference at boot. remote_queued reconciles against it:
144
+ # a kernel task_queued echo carrying a local QueuedLine's steer_iid
145
+ # is positive proof the inject landed — the local twin is removed so
146
+ # one message is one row with ONE owner (the kernel). None until the
147
+ # repl wires it (minimal sinks / unit tests without a queue).
148
+ self.local_pending = None
149
+ # The current coding checklist (sticky todo panel, 0.3.15) — the twin
150
+ # of remote_pending: todos() replaces its contents in place on every
151
+ # todo frame; the dock's todo panel reads THIS list object every
152
+ # redraw. Sticky: it persists across turn ends (the panel stays up)
153
+ # and clears only on /clear.
154
+ self.current_todos: list = []
155
+ self._todo_counts = (0, 0) # kernel-reported (done, total)
156
+ self._todos_dirty = False # a turn touched the list -> end_turn records it
142
157
 
143
158
  def _nudge(self) -> None:
144
159
  """After output/state changes: let the pane follow the tail + redraw."""
@@ -213,6 +228,14 @@ class RichSink:
213
228
  # would linger as phantoms. Clear them (in place — the panel holds
214
229
  # this list object).
215
230
  self.remote_pending.clear()
231
+ # Sticky-todo scrollback record (0.3.15): in the dock the live panel
232
+ # replaced the inline re-renders, so print the FINAL checklist state
233
+ # ONCE per turn that touched it — the transcript keeps the history
234
+ # while the panel itself persists (sticky) into idle.
235
+ if self._on_output is not None and self._todos_dirty and self.current_todos:
236
+ done, total = self._todo_counts
237
+ self._todos_inline(self.current_todos, total, done)
238
+ self._todos_dirty = False
216
239
  final_text = _clean(final_text)
217
240
  if final_text:
218
241
  self.console.print() # separation before the focus block
@@ -237,15 +260,41 @@ class RichSink:
237
260
  self._nudge()
238
261
 
239
262
  def todos(self, items: list, total: int, done: int) -> None:
240
- """Claude-Code-style checklist for the coding TODO scratchpad. The
241
- kernel republishes the FULL list on every todo_write, so each update
242
- re-renders the whole current plan inline the latest complete list
243
- always sits in the scrollback (deliberately NOT a pinned sticky panel:
244
- inline re-render can't fight the dock layout). Order preserved, one
245
- line per item: ✓ completed (dim, struck) · in progress (bold, so
246
- "what's happening now" pops) · pending (muted). Defensive: malformed
247
- items are skipped, unknown statuses render as pending, bad counts
248
- degrade to a bare header — never raises."""
263
+ """Checklist state for the coding TODO scratchpad. The kernel
264
+ republishes the FULL list on every todo_write; each update replaces
265
+ `current_todos` IN PLACE (the dock's sticky todo panel holds this
266
+ list object and re-reads it every redraw 0.3.15, no more inline
267
+ scroll-away in the dock; end_turn prints the final checklist ONCE as
268
+ the scrollback record). Headless / non-dock sinks (no on_output pane)
269
+ keep today's full inline render. Defensive both ways: malformed items
270
+ are skipped, bad counts degrade, never raises."""
271
+ rows = []
272
+ for item in (items if isinstance(items, (list, tuple)) else ()):
273
+ if not isinstance(item, dict):
274
+ continue # malformed entry — skip, never raise
275
+ content = _clean(item.get("content", "")).strip()
276
+ if not content:
277
+ continue
278
+ rows.append({"content": content,
279
+ "status": str(item.get("status", "") or "")})
280
+ self.current_todos[:] = rows # in place — the panel holds this list
281
+ try:
282
+ self._todo_counts = (int(done), int(total))
283
+ except (TypeError, ValueError):
284
+ self._todo_counts = (sum(1 for r in rows if r["status"] == "completed"),
285
+ len(rows))
286
+ self._todos_dirty = True
287
+ if self._on_output is not None: # dock: the sticky panel renders it live
288
+ self._nudge()
289
+ return
290
+ self._todos_inline(items, total, done)
291
+
292
+ def _todos_inline(self, items, total, done) -> None:
293
+ """The Claude-Code-style INLINE checklist render (the pre-0.3.15
294
+ behavior, verbatim): one line per item — ✓ completed (dim, struck) ·
295
+ ▶ in progress (bold, so "what's happening now" pops) · ○ pending
296
+ (muted), order preserved. Used by the headless/non-dock path on every
297
+ todo frame and by end_turn's one-shot scrollback record in the dock."""
249
298
  try:
250
299
  head = f"📋 Todos ({int(done)}/{int(total)})"
251
300
  except (TypeError, ValueError):
@@ -283,25 +332,46 @@ class RichSink:
283
332
  queue panel the instant it queues — tagged `[origin]`, "as if typed".
284
333
  DISPLAY-ONLY: it renders above the local rows but is never pullable
285
334
  (↑/click) — the kernel owns it; only a `task_dequeued` (or turn end)
286
- removes it."""
335
+ removes it. This is the ONE reconciliation chokepoint (0.3.16): the
336
+ steer_iid minted at enqueue time dedups the display too — a frame
337
+ delivered twice (publish retry / SSE resume) never doubles a row, and
338
+ the echo of a terminal-injected line whose POST merely LOOKED failed
339
+ removes its local fallback twin (the echo is positive proof it
340
+ landed; kernel-owned = one row). Empty iid (legacy kernels) always
341
+ appends — text is never a dedup key."""
342
+ iid = str(iid or "")
343
+ if iid:
344
+ if any(r.get("iid") == iid for r in self.remote_pending):
345
+ self._nudge()
346
+ return
347
+ lp = self.local_pending
348
+ if lp is not None:
349
+ twin = next((q for q in lp if getattr(q, "iid", "") == iid), None)
350
+ if twin is not None:
351
+ try:
352
+ lp.remove(twin)
353
+ except ValueError:
354
+ pass
287
355
  self.remote_pending.append({"origin": _clean(str(origin or "")),
288
356
  "text": _clean(str(text or "")),
289
- "iid": str(iid or "")})
357
+ "iid": iid})
290
358
  self._nudge()
291
359
 
292
360
  def remote_dequeued(self, origin: str, iid: str) -> None:
293
361
  """The kernel drained (or dedup-dropped) one queued item
294
- (`task_dequeued` frame): remove its panel row by `iid` when it
295
- matches, else the OLDEST row of that origin (the kernel queue is
296
- FIFO, so when an iid is missing/lost the oldest same-origin row is
297
- the one that just started). Nothing matched no-op (the row was
298
- already cleared or its announce was never seen)."""
362
+ (`task_dequeued` frame): remove its panel row by `iid`. The
363
+ oldest-same-origin fallback is LEGACY-ONLY (empty iid the kernel
364
+ queue is FIFO, so the oldest row of that origin is the one that just
365
+ started): a NON-empty unmatched iid is a no-op, because the kernel
366
+ emits one dequeue per pop INCLUDING dedup-dropped twins, and a twin's
367
+ second dequeue must never eat a different same-origin row. Nothing
368
+ matched → no-op (the row was already cleared or never announced)."""
299
369
  iid = str(iid or "")
300
370
  origin = _clean(str(origin or ""))
301
371
  rows = self.remote_pending
302
372
  idx = next((i for i, r in enumerate(rows) if iid and r.get("iid") == iid),
303
373
  None)
304
- if idx is None:
374
+ if idx is None and not iid:
305
375
  idx = next((i for i, r in enumerate(rows) if r.get("origin") == origin),
306
376
  None)
307
377
  if idx is None:
@@ -346,7 +416,10 @@ class RichSink:
346
416
  self._nudge()
347
417
 
348
418
  def clear(self) -> None:
349
- """/clear: wipe the pane/screen + reset the session counters."""
419
+ """/clear: wipe the pane/screen + reset the session counters. The
420
+ sticky todo panel resets with it (in place — the panel holds the
421
+ list object); a mid-run abort deliberately does NOT (the last known
422
+ plan state is still true and the panel is always-on)."""
350
423
  self.console.clear()
351
424
  self.tokens = 0
352
425
  self.credits = 0
@@ -354,6 +427,8 @@ class RichSink:
354
427
  self.session_credits = 0
355
428
  self._tools = 0
356
429
  self._current = ""
430
+ self.current_todos.clear()
431
+ self._todos_dirty = False
357
432
  self._nudge()
358
433
 
359
434
  def abort(self) -> None:
@@ -54,6 +54,30 @@ async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
54
54
  return text
55
55
 
56
56
 
57
+ async def _inject_via_gateway(cfg, token_provider, agent, sink,
58
+ text: str, steer_iid: str) -> bool:
59
+ """The gateway leg of the dock's Enter-while-busy fly-in (mid-turn inject,
60
+ 0.3.15): POST the line straight into the agent's LIVE running session so
61
+ the marathon absorbs it at the next brain step (seconds), instead of
62
+ holding it client-side until the turn ends. On ok the line is kernel-owned
63
+ — the ❯ echo records it as sent and the kernel's task_queued[terminal]
64
+ echo drives the panel row. False on ANY failure (no live session yet,
65
+ network, auth, gateway refusal) — the dock then falls back to today's
66
+ local type-ahead queue (tui._inject_or_queue), carrying the same iid so
67
+ the kernel ring dedups a twin. Module-level so tests drive it directly."""
68
+ sid = getattr(agent, "session_id", "")
69
+ if not sid:
70
+ return False
71
+ try:
72
+ from webbee.thread import inject_to_session
73
+ ok = await inject_to_session(cfg, token_provider, sid, text, steer_iid)
74
+ except Exception:
75
+ return False
76
+ if ok:
77
+ sink.user_echo(text) # the transcript records the message as sent
78
+ return ok
79
+
80
+
57
81
  async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None, read_line=input,
58
82
  agent_factory=None, auth=None, account_fetcher=None,
59
83
  sessions_client=None, intel_factory=None, shadow_factory=None) -> None:
@@ -220,9 +244,12 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
220
244
  _sink.note(res.message)
221
245
  return "continue"
222
246
 
223
- # A task for the agent.
247
+ # A task for the agent. A drained type-ahead line minted for a
248
+ # failed mid-turn inject still carries its steer_iid (tui.QueuedLine)
249
+ # -- thread it so the kernel's dedup ring drops the twin if the
250
+ # inject actually landed server-side (a plain typed line has none).
224
251
  _sink.user_echo(line)
225
- await _run_turn(line)
252
+ await _run_turn(line, steer_iid=getattr(line, "iid", ""))
226
253
  return "continue"
227
254
 
228
255
  async def _run_turn(line: str, surface: str = "", steer_iid: str = "") -> None:
@@ -317,6 +344,11 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
317
344
  async def _boot(s) -> None:
318
345
  nonlocal _sink, agent, intel, watcher_task, shadow, steer_task
319
346
  _sink = s
347
+ # Queue-panel single-source dedup (0.3.16): hand the sink the SAME
348
+ # type-ahead deque tui mutates, so a kernel task_queued echo can
349
+ # promote a landed local twin (matched by steer_iid) into the one
350
+ # kernel-owned row. Reference share — never a copy.
351
+ s.local_pending = pending_queue
320
352
  # Cache git branch OFF the event loop (subprocess.run blocks it). Only
321
353
  # /status reads it; recomputing it per input line froze the dock.
322
354
  state["git_branch"] = await asyncio.to_thread(_git_branch, workspace)
@@ -382,6 +414,9 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
382
414
  stop_turn=lambda: agent.stop(),
383
415
  pending=pending_queue, queued_run=_sink.queued_run,
384
416
  remote_pending=getattr(_sink, "remote_pending", None),
417
+ todos=getattr(_sink, "current_todos", None),
418
+ inject=lambda text, iid: _inject_via_gateway(
419
+ cfg, token_provider, agent, _sink, text, iid),
385
420
  )
386
421
  finally:
387
422
  _cancel_background()
@@ -226,19 +226,20 @@ class AgentSession:
226
226
 
227
227
  elif ftype in ("task_queued", "task_dequeued"):
228
228
  # Full-queue-layer K1: a follow-up queued into the RUNNING
229
- # kernel session from another surface shows in the live
230
- # queue panel the instant it queues (tagged by origin) and
231
- # leaves the panel when the kernel drains it. These frames
232
- # carry NO task_id (they belong to the session, not a
233
- # turn), so the C7 filter never eats them; getattr-guarded
234
- # like todos/queued_run — a minimal sink drops them, a
235
- # render error never breaks the loop. The terminal's own
236
- # follow-ups already sit in the LOCAL panel — skip
237
- # terminal-origin twins.
229
+ # kernel session shows in the live queue panel the instant
230
+ # it queues (tagged by origin) and leaves the panel when
231
+ # the kernel drains it. These frames carry NO task_id
232
+ # (they belong to the session, not a turn), so the C7
233
+ # filter never eats them; getattr-guarded like todos/
234
+ # queued_run — a minimal sink drops them, a render error
235
+ # never breaks the loop. Terminal-origin rows render TOO
236
+ # (mid-turn inject, 0.3.15): an injected line never sits
237
+ # in the LOCAL panel — the kernel's echo is its only row,
238
+ # and task_dequeued clears it when the turn absorbs it.
238
239
  _origin = str(frame.get("origin", "") or "")
239
240
  _hook = getattr(sink, "remote_queued" if ftype == "task_queued"
240
241
  else "remote_dequeued", None)
241
- if _hook is not None and _origin != "terminal":
242
+ if _hook is not None:
242
243
  _iid = str(frame.get("steer_iid", "") or "")
243
244
  try:
244
245
  if ftype == "task_queued":
@@ -8,7 +8,8 @@ itself -- `_boot` wraps the whole replay in one try/except so a network
8
8
  failure never blocks/delays boot beyond the timeout, it just skips the
9
9
  replay (same division of labor as remote.py + the /notify call site).
10
10
  Also home to the /thread endpoint's pending-steer sibling read (liveness v2
11
- §B) -- the drain webbee.steer polls while the REPL is idle."""
11
+ §B) -- the drain webbee.steer polls while the REPL is idle -- and the
12
+ mid-turn inject POST (0.3.15) the dock fires on Enter-while-busy."""
12
13
  from __future__ import annotations
13
14
 
14
15
  _DISPLAY_LIMIT = 400
@@ -67,6 +68,27 @@ async def fetch_pending_steer(cfg, token_provider, session_id: str) -> dict:
67
68
  return r.json() or {}
68
69
 
69
70
 
71
+ async def inject_to_session(cfg, token_provider, session_id: str, text: str,
72
+ steer_iid: str) -> bool:
73
+ """Mid-turn inject (0.3.15): POST an Enter-while-busy line straight into
74
+ the user's OWN running session — `/v1/agent/sessions/{id}/inject`, body
75
+ `{text, steer_iid}`. The gateway signals a task_id-LESS new_task, so the
76
+ kernel's mid-turn fly-in absorbs it at the next brain step under the
77
+ running turn's own task_id (frames stay visible in this terminal), and
78
+ the given steer_iid rides the kernel's dedup ring. Returns True only when
79
+ the gateway accepted it ({ok: true}). Non-swallowing like its siblings
80
+ above — the repl wiring wraps it and falls back to the local type-ahead
81
+ queue on any failure."""
82
+ import httpx
83
+ token = await token_provider()
84
+ async with httpx.AsyncClient(base_url=cfg.api_url, timeout=10) as c:
85
+ r = await c.post(f"/v1/agent/sessions/{session_id}/inject",
86
+ json={"text": text, "steer_iid": steer_iid},
87
+ headers={"Authorization": f"Bearer {token}"})
88
+ r.raise_for_status()
89
+ return bool((r.json() or {}).get("ok"))
90
+
91
+
70
92
  def truncate_for_display(text, limit: int = _DISPLAY_LIMIT) -> str:
71
93
  """Cap one replayed message's text so a single huge assistant reply can't
72
94
  flood the boot screen -- foreign_turn renders one line per message."""
@@ -0,0 +1,91 @@
1
+ """The STICKY todo panel pinned above the queue panel (0.3.15 — replaces the
2
+ inline scroll-away checklist in the dock: the plan now stays visible and
3
+ updates in place on every `todo` frame, mirroring the proven queue-panel
4
+ pattern). The builders here are PURE (unit-tested without an Application);
5
+ tui.run_session mounts them in a ConditionalContainer that occupies ZERO rows
6
+ while the list is empty, so the todo-less dock is pixel-identical to before.
7
+ The panel reads the sink-owned `RichSink.current_todos` list object every
8
+ redraw (in-place mutation, the twin of `remote_pending`); the scrollback
9
+ record is the ONE inline checklist RichSink.end_turn prints per turn. Split
10
+ into its own file like queue_panel.py to stay far under the size ceiling."""
11
+
12
+ from webbee.queue_panel import one_line
13
+
14
+ TP_MAX_ITEMS = 6 # item rows shown; completed collapse first, then `… +K more`
15
+
16
+
17
+ def _rows(todos) -> list:
18
+ """PURE. Normalize to (status, content) pairs: dict items with a
19
+ non-empty content only — malformed entries are skipped, never raised on
20
+ (same defensive contract as the inline checklist render)."""
21
+ out = []
22
+ for t in (todos if isinstance(todos, (list, tuple)) else ()):
23
+ if not isinstance(t, dict):
24
+ continue
25
+ content = str(t.get("content", "") or "").strip()
26
+ if not content:
27
+ continue
28
+ out.append((str(t.get("status", "") or ""), content))
29
+ return out
30
+
31
+
32
+ def todo_fragments(todos, width: int = 0):
33
+ """PURE builder: the panel as prompt_toolkit formatted text, re-invoked
34
+ every redraw (same live mechanics as the queue panel) so every todo_write
35
+ republish shows at once. Layout, top→bottom = plan order:
36
+
37
+ 📋 Todos (done/total)
38
+ … ✓K done ← completed collapse FIRST when over the cap
39
+ ✓ finished item ← green glyph, dim struck text
40
+ ▶ current item ← bold bee-yellow — NEVER hidden by the cap
41
+ ○ pending item ← muted
42
+ … +K more ← pending overflow beyond TP_MAX_ITEMS
43
+
44
+ Counts derive from the items themselves (the kernel republishes the FULL
45
+ list on every todo_write). Empty/malformed-only list → [] (panel hidden)."""
46
+ rows = _rows(todos)
47
+ if not rows:
48
+ return []
49
+ done = sum(1 for s, _ in rows if s == "completed")
50
+ frags = [("class:tp.header", f" 📋 Todos ({done}/{len(rows)})")]
51
+ show = rows
52
+ hidden_done = 0
53
+ if len(show) > TP_MAX_ITEMS:
54
+ # History compresses first: collapse the completed items into one
55
+ # summary row so the live part of the plan keeps its rows.
56
+ active = [r for r in show if r[0] != "completed"]
57
+ hidden_done = len(show) - len(active)
58
+ show = active
59
+ extra = 0
60
+ if len(show) > TP_MAX_ITEMS:
61
+ cut = show[TP_MAX_ITEMS:]
62
+ extra = len(cut)
63
+ show = show[:TP_MAX_ITEMS]
64
+ cur = next((r for r in cut if r[0] == "in_progress"), None)
65
+ if cur is not None:
66
+ show[-1] = cur # what's happening NOW is never hidden by the cap
67
+ if hidden_done:
68
+ frags.append(("class:tp.done", f"\n … ✓{hidden_done} done"))
69
+ for status, content in show:
70
+ text = one_line(content, width - 5 if width > 0 else 0)
71
+ if status == "completed":
72
+ frags.append(("class:tp.done", "\n ✓ "))
73
+ frags.append(("class:tp.done.text", text))
74
+ elif status == "in_progress":
75
+ frags.append(("class:tp.now", "\n ▶ " + text))
76
+ else: # pending / unknown -> not started yet
77
+ frags.append(("class:tp.item", "\n ○ " + text))
78
+ if extra:
79
+ frags.append(("class:tp.item", f"\n … +{extra} more"))
80
+ return frags
81
+
82
+
83
+ def todo_height(todos) -> int:
84
+ """PURE. Rows the panel needs — derived from the SAME fragments the panel
85
+ renders (header line + one per newline), so height can never desync from
86
+ the visible rows. 0 when the list is empty (the ConditionalContainer
87
+ hides the panel then anyway)."""
88
+ frags = todo_fragments(todos)
89
+ if not frags:
90
+ return 0
91
+ return 1 + sum(f[1].count("\n") for f in frags)