webbee 0.3.36__tar.gz → 0.3.37__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 (127) hide show
  1. {webbee-0.3.36 → webbee-0.3.37}/CHANGELOG.md +33 -0
  2. {webbee-0.3.36 → webbee-0.3.37}/PKG-INFO +1 -1
  3. {webbee-0.3.36 → webbee-0.3.37}/pyproject.toml +1 -1
  4. webbee-0.3.37/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/active_sessions.py +79 -0
  6. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/commands.py +19 -1
  7. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/queue_panel.py +50 -3
  8. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/repl.py +227 -1
  9. webbee-0.3.37/src/webbee/tab_store.py +123 -0
  10. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/tui.py +113 -8
  11. webbee-0.3.37/tests/test_queue_manage_0337.py +138 -0
  12. webbee-0.3.37/tests/test_tab_restore_0337.py +248 -0
  13. {webbee-0.3.36 → webbee-0.3.37}/tests/test_tui.py +2 -1
  14. webbee-0.3.37/tests/test_version_badge_corner.py +71 -0
  15. webbee-0.3.36/src/webbee/__init__.py +0 -1
  16. {webbee-0.3.36 → webbee-0.3.37}/.github/workflows/publish.yml +0 -0
  17. {webbee-0.3.36 → webbee-0.3.37}/.gitignore +0 -0
  18. {webbee-0.3.36 → webbee-0.3.37}/LICENSE +0 -0
  19. {webbee-0.3.36 → webbee-0.3.37}/README.md +0 -0
  20. {webbee-0.3.36 → webbee-0.3.37}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md +0 -0
  21. {webbee-0.3.36 → webbee-0.3.37}/install.sh +0 -0
  22. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/account.py +0 -0
  23. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/banner_art.py +0 -0
  24. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/boot.py +0 -0
  25. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/checkpoints.py +0 -0
  26. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/cli.py +0 -0
  27. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/clipboard.py +0 -0
  28. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/clipboard_read.py +0 -0
  29. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/coding_context.py +0 -0
  30. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/config.py +0 -0
  31. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/consent.py +0 -0
  32. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/details.py +0 -0
  33. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/events.py +0 -0
  34. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/frames.py +0 -0
  35. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/home.py +0 -0
  36. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/home_view.py +0 -0
  37. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/http.py +0 -0
  38. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/instance_lock.py +0 -0
  39. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/__init__.py +0 -0
  40. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/chunker.py +0 -0
  41. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/embed.py +0 -0
  42. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/graph.py +0 -0
  43. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/indexer.py +0 -0
  44. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/models.py +0 -0
  45. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/query.py +0 -0
  46. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/service.py +0 -0
  47. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/store.py +0 -0
  48. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/vectors.py +0 -0
  49. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/watch.py +0 -0
  50. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/mode_store.py +0 -0
  51. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/newtab_mode.py +0 -0
  52. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/output_pane.py +0 -0
  53. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/reflow.py +0 -0
  54. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/remote.py +0 -0
  55. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/render.py +0 -0
  56. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/repo.py +0 -0
  57. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/selection.py +0 -0
  58. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/session.py +0 -0
  59. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/sessions.py +0 -0
  60. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/sizing.py +0 -0
  61. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/slots.py +0 -0
  62. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/steer.py +0 -0
  63. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/stream.py +0 -0
  64. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/tabs.py +0 -0
  65. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/thread.py +0 -0
  66. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/todo_panel.py +0 -0
  67. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/tokens.py +0 -0
  68. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/tools.py +0 -0
  69. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/update.py +0 -0
  70. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/urlopen.py +0 -0
  71. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/wallet.py +0 -0
  72. {webbee-0.3.36 → webbee-0.3.37}/src/webbee/worktrees.py +0 -0
  73. {webbee-0.3.36 → webbee-0.3.37}/tests/__init__.py +0 -0
  74. {webbee-0.3.36 → webbee-0.3.37}/tests/conftest.py +0 -0
  75. {webbee-0.3.36 → webbee-0.3.37}/tests/test_account.py +0 -0
  76. {webbee-0.3.36 → webbee-0.3.37}/tests/test_active_sessions.py +0 -0
  77. {webbee-0.3.36 → webbee-0.3.37}/tests/test_audit_2026_07_25_findings.py +0 -0
  78. {webbee-0.3.36 → webbee-0.3.37}/tests/test_checkpoints.py +0 -0
  79. {webbee-0.3.36 → webbee-0.3.37}/tests/test_cli.py +0 -0
  80. {webbee-0.3.36 → webbee-0.3.37}/tests/test_clipboard.py +0 -0
  81. {webbee-0.3.36 → webbee-0.3.37}/tests/test_clipboard_read.py +0 -0
  82. {webbee-0.3.36 → webbee-0.3.37}/tests/test_coding_context.py +0 -0
  83. {webbee-0.3.36 → webbee-0.3.37}/tests/test_commands.py +0 -0
  84. {webbee-0.3.36 → webbee-0.3.37}/tests/test_config.py +0 -0
  85. {webbee-0.3.36 → webbee-0.3.37}/tests/test_cpc_contract_stable.py +0 -0
  86. {webbee-0.3.36 → webbee-0.3.37}/tests/test_details.py +0 -0
  87. {webbee-0.3.36 → webbee-0.3.37}/tests/test_events.py +0 -0
  88. {webbee-0.3.36 → webbee-0.3.37}/tests/test_freeze_fix.py +0 -0
  89. {webbee-0.3.36 → webbee-0.3.37}/tests/test_home.py +0 -0
  90. {webbee-0.3.36 → webbee-0.3.37}/tests/test_home_view.py +0 -0
  91. {webbee-0.3.36 → webbee-0.3.37}/tests/test_instance_lock.py +0 -0
  92. {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_chunker.py +0 -0
  93. {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_embed.py +0 -0
  94. {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_graph.py +0 -0
  95. {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_indexer.py +0 -0
  96. {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_no_numpy.py +0 -0
  97. {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_query.py +0 -0
  98. {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_service.py +0 -0
  99. {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_store.py +0 -0
  100. {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_vectors.py +0 -0
  101. {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_watch.py +0 -0
  102. {webbee-0.3.36 → webbee-0.3.37}/tests/test_marathon.py +0 -0
  103. {webbee-0.3.36 → webbee-0.3.37}/tests/test_mode_store.py +0 -0
  104. {webbee-0.3.36 → webbee-0.3.37}/tests/test_newtab_mode.py +0 -0
  105. {webbee-0.3.36 → webbee-0.3.37}/tests/test_packaging.py +0 -0
  106. {webbee-0.3.36 → webbee-0.3.37}/tests/test_reflow.py +0 -0
  107. {webbee-0.3.36 → webbee-0.3.37}/tests/test_render.py +0 -0
  108. {webbee-0.3.36 → webbee-0.3.37}/tests/test_repl.py +0 -0
  109. {webbee-0.3.36 → webbee-0.3.37}/tests/test_repo.py +0 -0
  110. {webbee-0.3.36 → webbee-0.3.37}/tests/test_session.py +0 -0
  111. {webbee-0.3.36 → webbee-0.3.37}/tests/test_sessions.py +0 -0
  112. {webbee-0.3.36 → webbee-0.3.37}/tests/test_sizing.py +0 -0
  113. {webbee-0.3.36 → webbee-0.3.37}/tests/test_slots.py +0 -0
  114. {webbee-0.3.36 → webbee-0.3.37}/tests/test_steer.py +0 -0
  115. {webbee-0.3.36 → webbee-0.3.37}/tests/test_stream.py +0 -0
  116. {webbee-0.3.36 → webbee-0.3.37}/tests/test_tabs.py +0 -0
  117. {webbee-0.3.36 → webbee-0.3.37}/tests/test_terminal_ux_0336.py +0 -0
  118. {webbee-0.3.36 → webbee-0.3.37}/tests/test_thread.py +0 -0
  119. {webbee-0.3.36 → webbee-0.3.37}/tests/test_todo_panel.py +0 -0
  120. {webbee-0.3.36 → webbee-0.3.37}/tests/test_tokens.py +0 -0
  121. {webbee-0.3.36 → webbee-0.3.37}/tests/test_tools.py +0 -0
  122. {webbee-0.3.36 → webbee-0.3.37}/tests/test_tui_hardening.py +0 -0
  123. {webbee-0.3.36 → webbee-0.3.37}/tests/test_update.py +0 -0
  124. {webbee-0.3.36 → webbee-0.3.37}/tests/test_urlopen.py +0 -0
  125. {webbee-0.3.36 → webbee-0.3.37}/tests/test_version.py +0 -0
  126. {webbee-0.3.36 → webbee-0.3.37}/tests/test_wallet.py +0 -0
  127. {webbee-0.3.36 → webbee-0.3.37}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.37
4
+
5
+ Your tabs come back, the queue does what you tell it, and the version sits
6
+ where you asked for it.
7
+
8
+ - **Closing the terminal no longer throws your work away.** Three tabs of live
9
+ work used to become one empty tab on the next start. Your layout is
10
+ remembered per repo and restored on boot — and it is reconciled against what
11
+ is *actually* still running, so a tab that really ended comes back as a
12
+ labelled tab rather than a dead one pretending to be alive. An accidental
13
+ exit, Ctrl-D and a crash all remember equally. Autopilot is deliberately
14
+ never remembered: a mode that acts on its own has to be turned on each time.
15
+ - **A queue you can actually manage.** Every queued line now has its own **✕**,
16
+ so you can remove *any* item — previously the only way out was ↑, which
17
+ hijacked your input and could only ever reach the newest row, leaving item 1
18
+ of 3 stuck. `/queue drop N` and `/queue edit N` do the same from the keyboard
19
+ when your terminal reports no mouse. Editing an item *removes* it from the
20
+ queue as it moves into your input, so a line can never both sit in the queue
21
+ and still fire.
22
+ - **Version badge, bottom-right of the window.** Pinned to the corner of the
23
+ whole window rather than floating inside the toolbar's content. On a narrow
24
+ terminal the badge is dropped instead of truncating real information.
25
+ - **A live-session indicator that keeps telling you the truth.** The old boot
26
+ note fired once and scrolled away. The toolbar now shows "● live",
27
+ "● live · needs approval" or "○ N elsewhere", refreshed every 30 seconds from
28
+ what the server actually reports — and it stays empty when nothing is
29
+ running, so an idle dock gains no permanent noise.
30
+ - **✕, Ctrl+W and `/close` all really do close a tab** — they always shared one
31
+ teardown path; what was missing was saying so, and the busy-close note now
32
+ names all three ways out.
33
+ - Fixed two latent crashes that would only have fired on a click (a queue panel
34
+ and a row renderer were each used without being imported).
35
+
3
36
  ## 0.3.36
4
37
 
5
38
  Write on more than one line, and see what a session actually costs.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.36
3
+ Version: 0.3.37
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.36"
3
+ version = "0.3.37"
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.37"
@@ -39,6 +39,85 @@ async def fetch_active_sessions(cfg, token_provider, *, client=None) -> list[dic
39
39
  return []
40
40
 
41
41
 
42
+ def live_session_ids(sessions: list[dict]) -> set:
43
+ """PURE. The set of session ids the gateway currently reports as RUNNING.
44
+ One helper so the tab-restore reconciliation and the status indicator read
45
+ liveness from the SAME shape (0.3.37)."""
46
+ return {str(s.get("session_id") or "") for s in (sessions or [])
47
+ if str(s.get("session_id") or "")}
48
+
49
+
50
+ def session_indicator(sessions: list[dict], repo_key: str) -> str:
51
+ """PURE. The PERSISTENT live-session indicator for the toolbar (0.3.37).
52
+
53
+ Before this, a running Temporal workflow was mentioned exactly once, in a
54
+ boot note that scrolled away -- so after an accidental exit and reopen the
55
+ terminal silently re-attached with nothing on screen ever confirming that
56
+ it had seen a running session, or that one existed at all.
57
+
58
+ Returns a SHORT string for the status line, or "" when there is nothing
59
+ to say (no running sessions at all -- silence is correct then, the dock
60
+ must not grow permanent noise):
61
+ * this repo has a live session -> "● live"
62
+ * ...and it is parked on an approval -> "● live · needs approval"
63
+ * only OTHER repos have live ones -> "○ 2 elsewhere"
64
+ Honest by construction: it reports what the gateway's Temporal listing
65
+ actually says, never a guess.
66
+ """
67
+ suffix = f"-r{repo_key}"
68
+ own, own_parked, others = False, False, 0
69
+ for s in (sessions or []):
70
+ sid = str(s.get("session_id") or "")
71
+ if not sid:
72
+ continue
73
+ if sid.endswith(suffix):
74
+ own = True
75
+ if s.get("pending_consent"):
76
+ own_parked = True
77
+ else:
78
+ others += 1
79
+ if own:
80
+ return "● live · needs approval" if own_parked else "● live"
81
+ if others:
82
+ return f"○ {others} elsewhere"
83
+ return ""
84
+
85
+
86
+ def plan_tab_restore(saved: list, sessions: list[dict]) -> list:
87
+ """PURE. Reconcile the REMEMBERED tab layout (tab_store.load_tabs) against
88
+ the sessions Temporal reports as Running, and decide per tab what reopening
89
+ it means (0.3.37).
90
+
91
+ Each returned dict is the saved record plus:
92
+ * `attach`: True -> its workflow is STILL RUNNING; the tab must re-attach
93
+ and pick the live run up where it left off.
94
+ `attach`: False -> nothing is running under that id any more; restore
95
+ the tab's state (label/mode/draft) only.
96
+ * `pending_consent`: carried through from the live listing so a restored
97
+ tab can immediately show it is parked on an approval.
98
+
99
+ A saved tab with no session_id is legal (a tab you never sent anything in)
100
+ and simply restores as state-only. Never raises; unknown/extra keys on the
101
+ saved record are ignored rather than trusted.
102
+ """
103
+ live = {}
104
+ for s in (sessions or []):
105
+ sid = str(s.get("session_id") or "")
106
+ if sid:
107
+ live[sid] = s
108
+ out = []
109
+ for rec in (saved or []):
110
+ if not isinstance(rec, dict):
111
+ continue
112
+ sid = str(rec.get("session_id") or "")
113
+ s = live.get(sid)
114
+ item = dict(rec)
115
+ item["attach"] = bool(sid and s is not None)
116
+ item["pending_consent"] = bool((s or {}).get("pending_consent"))
117
+ out.append(item)
118
+ return out
119
+
120
+
42
121
  def boot_reattach_notice(sessions: list[dict], repo_key: str) -> list[str]:
43
122
  """Decide what, if anything, to tell the user about their OTHER running
44
123
  sessions once THIS terminal's own boot replay has finished. `sessions`
@@ -111,6 +111,23 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
111
111
  # same way /status reads session state); the repl clears the actual
112
112
  # deque on action="queue_clear" — the message here already carries
113
113
  # the honest drop count.
114
+ # 0.3.37: drop/edit ONE queued item by its 1-based /queue number --
115
+ # the KEYBOARD twin of the panel's per-row ✕ and click-to-edit, so
116
+ # managing a 3-deep queue works on terminals with no mouse reporting
117
+ # (tmux without mouse on, restrictive SSH clients) too. The repl owns
118
+ # the actual deque mutation; this layer only validates + routes.
119
+ if args and args[0].lower() in ("drop", "remove", "rm", "edit"):
120
+ verb = "queue_drop" if args[0].lower() != "edit" else "queue_edit"
121
+ if len(args) < 2:
122
+ return SlashResult(handled=True, action="queue",
123
+ message=f"Usage: /queue {args[0].lower()} <number> "
124
+ f"(see /queue for the numbers).")
125
+ raw = args[1]
126
+ if not raw.isdigit() or int(raw) < 1 or int(raw) > len(ctx.queued):
127
+ return SlashResult(handled=True, action="queue",
128
+ message=(f"No queued item #{raw}. "
129
+ f"Queue holds {len(ctx.queued)}."))
130
+ return SlashResult(handled=True, action=verb, arg=str(int(raw)))
114
131
  if args and args[0].lower() == "clear":
115
132
  n = len(ctx.queued)
116
133
  return SlashResult(handled=True, action="queue_clear",
@@ -122,7 +139,8 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
122
139
  lines = "\n".join(f" {i}. {t}" for i, t in enumerate(ctx.queued, 1))
123
140
  return SlashResult(handled=True, action="queue",
124
141
  message=(f"Queued ({len(ctx.queued)}) — runs in order, "
125
- f"one per finished turn:\n{lines}"))
142
+ f"one per finished turn:\n{lines}\n"
143
+ f" /queue drop <n> · /queue edit <n> · /queue clear"))
126
144
  if cmd == "/steps":
127
145
  if args:
128
146
  return SlashResult(handled=True, action="step_detail", arg=args[0])
@@ -68,6 +68,43 @@ def pull_item(pending, buf, index: int):
68
68
  return item
69
69
 
70
70
 
71
+ def drop_item(pending, index: int):
72
+ """The ONE queue-REMOVE implementation (0.3.37) — serves the per-row ✕ in
73
+ the panel. Deletes pending[index] WITHOUT touching the input buffer, so
74
+ removing item #1 of 3 no longer forces you to pull it into the input just
75
+ to get rid of it (before this, the only exit from the queue was
76
+ `pull_item`, which is edit-the-line, hijacks the buffer and — bound to ↑ —
77
+ only ever reached the NEWEST row).
78
+
79
+ Guards mirror `pull_item`: a stale index (the queue drained between render
80
+ and click) is ignored rather than raising or eating the wrong row. Returns
81
+ the removed item, or None when nothing was removed.
82
+ """
83
+ if not (0 <= index < len(pending)):
84
+ return None
85
+ item = pending[index]
86
+ del pending[index]
87
+ return item
88
+
89
+
90
+ def _drop_handler(drop, index: int, forward=None):
91
+ """One row's ✕ mouse handler: MOUSE_UP removes THAT queued item via
92
+ `drop(index)`. Same event discipline as `_item_handler` (MOUSE_UP only,
93
+ `forward` gets first refusal so a drag released here is not mistaken for
94
+ a click) — the difference is purely WHICH action fires, so a ✕ click can
95
+ never be confused with the row-body click that pulls for editing.
96
+ """
97
+ def _h(mouse_event):
98
+ from prompt_toolkit.mouse_events import MouseEventType
99
+ if forward is not None and forward(mouse_event):
100
+ return None
101
+ if mouse_event.event_type == MouseEventType.MOUSE_UP:
102
+ drop(index)
103
+ return None
104
+ return NotImplemented
105
+ return _h
106
+
107
+
71
108
  def _item_handler(pull, index: int, forward=None):
72
109
  """One row's mouse handler: MOUSE_UP (a click, not a drag/press) pulls
73
110
  THAT queued item into the input via `pull(index)`; every other event
@@ -109,7 +146,7 @@ def _toggle_handler(toggle, forward=None):
109
146
 
110
147
  def queue_fragments(pending, pull=None, width: int = 0, remote=None,
111
148
  collapsed=False, toggle=None, max_items=QP_MAX_ITEMS,
112
- forward=None):
149
+ forward=None, drop=None):
113
150
  """PURE builder: the panel as prompt_toolkit formatted text, re-invoked
114
151
  every redraw (same live mechanics as the toolbar) so every queue
115
152
  add/edit/drain shows at once. Layout, top→bottom = drain order (FIFO —
@@ -159,7 +196,9 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None,
159
196
  if collapsed:
160
197
  return frags
161
198
  if n:
162
- frags.append(("class:qp.item", " · ↑ edit last · click to edit"))
199
+ hint = (" · ↑ edit last · click to edit · ✕ remove" if drop is not None
200
+ else " · ↑ edit last · click to edit")
201
+ frags.append(("class:qp.item", hint))
163
202
  rstart = max(0, len(rem) - max_items)
164
203
  if rstart:
165
204
  frags.append(("class:qp.remote", f"\n … +{rstart} more"))
@@ -177,9 +216,17 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None,
177
216
  frags.append(("class:qp.item", f"\n … +{start} more"))
178
217
  for i in range(start, n):
179
218
  style = "class:qp.last" if i == n - 1 else "class:qp.item"
180
- row = "\n " + one_line(items[i], width - 4 if width > 0 else 0)
219
+ # 0.3.37: reserve the column so the row text truncates BEFORE the
220
+ # button rather than shoving it off-screen on a narrow terminal.
221
+ avail = (width - 4 - (2 if drop is not None else 0)) if width > 0 else 0
222
+ row = "\n " + one_line(items[i], avail)
181
223
  if pull is None:
182
224
  frags.append((style, row))
183
225
  else:
184
226
  frags.append((style, row, _item_handler(pull, i, forward)))
227
+ if drop is not None:
228
+ # Its OWN fragment + OWN handler: clicking ✕ removes the item,
229
+ # clicking the row body still pulls it for editing. Two adjacent
230
+ # targets, zero ambiguity.
231
+ frags.append(("class:qp.drop", " ✕", _drop_handler(drop, i, forward)))
185
232
  return frags
@@ -174,7 +174,7 @@ def _slot_ctx(slot: SessionSlot, *, logged_in: bool, slots=None) -> CommandConte
174
174
  # number the Home toolbar and dashboard show) instead of Home's own zeros.
175
175
  _HOME_GATED_ACTIONS = frozenset({
176
176
  "steps", "step_detail", "checkpoints", "rollback", "notify", "mode",
177
- "queue", "queue_clear", "rename",
177
+ "queue", "queue_clear", "queue_drop", "queue_edit", "rename",
178
178
  })
179
179
 
180
180
  _HOME_GATE_NOTE = "open a session tab first — Ctrl+T or type a task"
@@ -437,6 +437,116 @@ def _resolve_agent_factory(agent_factory, bundle: dict):
437
437
  return lambda c, tp, ws, m: AgentSession(c, tp, ws, m, intel=bundle["intel"], shadow=bundle["shadow"])
438
438
 
439
439
 
440
+ async def restore_tabs(workspace: str, slots, make_slot, *, sessions_fn=None,
441
+ note=None, spawn_poller=None, limit: int = 0) -> int:
442
+ """Reopen the tabs remembered from the last run (0.3.37).
443
+
444
+ Closing the terminal used to be silently destructive: three tabs of live
445
+ work became one empty tab, and the still-Running Temporal workflows behind
446
+ them were reachable only by remembering what you had been doing. Now the
447
+ layout is remembered (`tab_store`), reconciled against what Temporal
448
+ actually still has Running (`active_sessions.plan_tab_restore`) and rebuilt
449
+ through the SAME `_make_session_slot` factory `/new` uses -- so a restored
450
+ tab is a completely ordinary tab, not a special second kind.
451
+
452
+ The FIRST session tab already exists when this runs (the boot builds it),
453
+ so the first remembered record is skipped: it is that tab. Each further
454
+ record becomes a real tab, labelled as it was, and told honestly whether it
455
+ re-attached to a live run or restored state only.
456
+
457
+ Best-effort by construction: a missing layout file, a gateway that cannot
458
+ list sessions, or a single tab that fails to build all degrade to "fewer
459
+ tabs restored", never to a failed boot. Returns how many tabs it opened.
460
+ """
461
+ from webbee.active_sessions import plan_tab_restore
462
+ from webbee.tab_store import load_tabs
463
+ opened = 0
464
+ try:
465
+ saved = load_tabs(workspace)
466
+ except Exception:
467
+ return 0
468
+ if len(saved) <= 1:
469
+ return 0 # nothing beyond the tab the boot already made
470
+ try:
471
+ sessions = await sessions_fn() if sessions_fn is not None else []
472
+ except Exception:
473
+ sessions = [] # no listing -> every tab restores state-only
474
+ plan = plan_tab_restore(saved, sessions)
475
+ live_n = 0
476
+ for rec in plan[1:]:
477
+ if limit and opened >= limit:
478
+ break
479
+ try:
480
+ slot = await make_slot(rec)
481
+ except Exception:
482
+ continue # one bad tab must not cost the whole layout
483
+ if slot is None:
484
+ continue
485
+ label = str(rec.get("label") or "")
486
+ if label:
487
+ slot.label = label
488
+ slot.label_pinned = True
489
+ draft = str(rec.get("draft") or "")
490
+ if draft:
491
+ slot.draft = draft
492
+ slot.draft_cursor = len(draft)
493
+ slots.add(slot)
494
+ if spawn_poller is not None:
495
+ try:
496
+ spawn_poller(slot)
497
+ except Exception:
498
+ pass
499
+ opened += 1
500
+ if rec.get("attach"):
501
+ live_n += 1
502
+ if note is not None and opened:
503
+ try:
504
+ if live_n:
505
+ note(f"restored {opened} tab(s) — {live_n} still running server-side")
506
+ else:
507
+ note(f"restored {opened} tab(s) — their earlier runs have finished")
508
+ except Exception:
509
+ pass
510
+ return opened
511
+
512
+
513
+ def snapshot_tabs(slots) -> list:
514
+ """Build the persistable tab layout from the LIVE slots (0.3.37).
515
+
516
+ SESSION tabs only -- Home is a fixture the dock always builds itself, so
517
+ persisting it would duplicate it on restore. The re-attach handle is the
518
+ agent's own `session_id` (the gateway id it streamed under); a tab that
519
+ never ran anything has none and restores as state-only. Reads defensively
520
+ (getattr with defaults) so a half-built slot during shutdown can never
521
+ turn saving the layout into a crash on exit.
522
+ """
523
+ from webbee.tab_store import tab_record
524
+ out = []
525
+ for slot in list(getattr(slots, "slots", []) or []):
526
+ if getattr(slot, "kind", "") == "home":
527
+ continue
528
+ agent = getattr(slot, "agent", None)
529
+ out.append(tab_record(
530
+ session_id=str(getattr(agent, "session_id", "") or ""),
531
+ label=str(getattr(slot, "label", "") or ""),
532
+ mode=str(getattr(slot, "mode", "default") or "default"),
533
+ workspace=str(getattr(slot, "workspace", "") or ""),
534
+ draft=str(getattr(slot, "draft", "") or ""),
535
+ ))
536
+ return out
537
+
538
+
539
+ def _save_tab_layout(workspace: str, slots) -> None:
540
+ """Persist the tab layout, best-effort (0.3.37). Called on the way out of
541
+ the dock: losing the memory is a far smaller problem than failing to exit
542
+ cleanly, so EVERY error is swallowed -- same posture as mode_store."""
543
+ try:
544
+ from webbee.tab_store import save_tabs
545
+ save_tabs(workspace, snapshot_tabs(slots))
546
+ except Exception:
547
+ pass
548
+
549
+
440
550
  async def _note_reattach(cfg, token_provider, workspace, sink) -> None:
441
551
  """Boot reattach notice (T6.3, coding-remote flow perfection): best-
442
552
  effort, entirely swallowed on any failure -- same division of labor as
@@ -461,6 +571,44 @@ async def _note_reattach(cfg, token_provider, workspace, sink) -> None:
461
571
  pass
462
572
 
463
573
 
574
+ async def _live_session_watch(cfg, token_provider, workspace, live: dict,
575
+ invalidate=None, interval: float = 30.0,
576
+ sessions_fn=None, sleep=None) -> None:
577
+ """Keep the toolbar's PERSISTENT live-session indicator honest (0.3.37).
578
+
579
+ The boot note fires once and scrolls away; this refreshes the indicator on
580
+ a slow cadence (default 30s -- it answers "is a workflow running", which
581
+ does not need per-second polling) so an accidental exit + reopen, or a
582
+ session that ends while you watch, is always reflected.
583
+
584
+ Writes ONLY into `live["text"]`, which `tui`'s toolbar reads every redraw:
585
+ no new UI ownership, no second source of truth. Fail-soft like every other
586
+ background poller here -- ANY error just leaves the previous value and
587
+ tries again next tick, and a gateway without the route means the indicator
588
+ stays empty (today's behaviour). `sessions_fn`/`sleep` are test seams.
589
+ """
590
+ from webbee.active_sessions import fetch_active_sessions, session_indicator
591
+ from webbee.repo import compute_repo_key, find_repo_root
592
+ _sleep = sleep or asyncio.sleep
593
+ fetch = sessions_fn or (lambda: fetch_active_sessions(cfg, token_provider))
594
+ try:
595
+ repo_key = await asyncio.to_thread(
596
+ lambda: compute_repo_key(find_repo_root(workspace)))
597
+ except Exception:
598
+ repo_key = ""
599
+ while True:
600
+ try:
601
+ sessions = await fetch()
602
+ text = session_indicator(sessions, repo_key)
603
+ if text != live.get("text"):
604
+ live["text"] = text
605
+ if invalidate is not None:
606
+ invalidate()
607
+ except Exception:
608
+ pass
609
+ await _sleep(interval)
610
+
611
+
464
612
  async def _finish_slot(cfg, token_provider, workspace, mode, *, resources: WorkspaceResources,
465
613
  agent_factory, intel_factory, shadow_factory, pane, sink, first: bool,
466
614
  account, slot_id: str = "", label: "str | None" = None) -> "tuple[SessionSlot, int]":
@@ -1080,6 +1228,33 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1080
1228
  # count follows on the sink's redraw below. (Home never
1081
1229
  # reaches here -- "queue_clear" is in _HOME_GATED_ACTIONS.)
1082
1230
  slot.pending.clear()
1231
+ if res.action in ("queue_drop", "queue_edit"):
1232
+ # 0.3.37 keyboard queue management. dispatch already validated
1233
+ # the number against the SAME snapshot, but re-check here: the
1234
+ # running turn can drain an item between the command and this
1235
+ # line, and a stale index must never delete the wrong message.
1236
+ idx = int(res.arg) - 1
1237
+ if not (0 <= idx < len(slot.pending)):
1238
+ _say(slot, "that queued item just ran — nothing removed.")
1239
+ return "continue"
1240
+ if res.action == "queue_drop":
1241
+ from webbee.queue_panel import drop_item as _drop_item
1242
+ item = _drop_item(slot.pending, idx)
1243
+ _say(slot, f"removed from queue: {item}")
1244
+ else:
1245
+ # edit = move it OUT of the queue and INTO the input, so it
1246
+ # can never both sit in the queue AND still fire (the exact
1247
+ # bug ↑ had). Reuses the dock's own `_pull_at` -- the SAME
1248
+ # code the panel's click-to-edit runs -- so the live buffer
1249
+ # is filled correctly and `pulled` carries the steer_iid
1250
+ # for dedup. No dock (headless/tests) = honest no-op note.
1251
+ pull_fn = ui_hooks.get("pull_queued")
1252
+ if pull_fn is None:
1253
+ _say(slot, "editing a queued item needs the interactive dock.")
1254
+ return "continue"
1255
+ pull_fn(idx)
1256
+ _say(slot, "queued item moved into the input — edit it and press Enter.")
1257
+ return "continue"
1083
1258
  if res.action == "clear":
1084
1259
  # FIX4: works fully on Home too -- clears Home's OWN pane
1085
1260
  # (there are no session counters to reset there, so only the
@@ -1261,6 +1436,19 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1261
1436
  if agent is not None:
1262
1437
  await agent.stop()
1263
1438
 
1439
+ def _invalidate_dock() -> None:
1440
+ """Ask the dock to redraw (0.3.37 live-session indicator). Fail-soft:
1441
+ before the Application exists -- or in the headless fallback loop that
1442
+ has no dock at all -- there is simply nothing to invalidate, and a
1443
+ background poller must never die over a missing UI."""
1444
+ try:
1445
+ from prompt_toolkit.application.current import get_app_or_none
1446
+ app = get_app_or_none()
1447
+ if app is not None:
1448
+ app.invalidate()
1449
+ except Exception:
1450
+ pass
1451
+
1264
1452
  def _cancel_background() -> None:
1265
1453
  # Task 7: the actual walk moved to the module-level, directly-tested
1266
1454
  # `_cancel_all_background` (every slot's bg_tasks, .done()-guarded --
@@ -1464,6 +1652,36 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1464
1652
  # is very likely already done (or well under way) by the
1465
1653
  # time anyone actually switches to it.
1466
1654
  home_slot.bg_tasks.append(asyncio.ensure_future(home.fill_home(home_slot, **home_fill_kwargs)))
1655
+ # 0.3.37: keep the toolbar's live-session indicator fresh. Parked
1656
+ # in Home's bg_tasks so the EXISTING teardown
1657
+ # (_cancel_all_background) cancels it -- no new lifecycle to get
1658
+ # wrong, and Home outlives every session tab.
1659
+ # 0.3.37: reopen the tabs remembered from the last run, through
1660
+ # the SAME factory /new uses. Awaited (not fire-and-forget) so
1661
+ # the tab bar is already complete on the first frame instead of
1662
+ # visibly popping in; every failure mode degrades to fewer tabs.
1663
+ try:
1664
+ await restore_tabs(
1665
+ workspace, slots,
1666
+ lambda rec: _make_session_slot(
1667
+ cfg, token_provider,
1668
+ str(rec.get("workspace") or workspace),
1669
+ str(rec.get("mode") or mode), resources=resources,
1670
+ shared_client=shared_client, agent_factory=agent_factory,
1671
+ intel_factory=intel_factory, shadow_factory=shadow_factory,
1672
+ first=False, account=account),
1673
+ sessions_fn=lambda: __import__(
1674
+ "webbee.active_sessions", fromlist=["x"]
1675
+ ).fetch_active_sessions(cfg, token_provider),
1676
+ note=session_slot.sink.note if session_slot.sink else None,
1677
+ spawn_poller=_spawn_slot_poller)
1678
+ except Exception:
1679
+ pass
1680
+
1681
+ _live_state: dict = {"text": ""}
1682
+ home_slot.bg_tasks.append(asyncio.ensure_future(
1683
+ _live_session_watch(cfg, token_provider, workspace, _live_state,
1684
+ invalidate=_invalidate_dock)))
1467
1685
 
1468
1686
  async def _on_line(text: str, slot: SessionSlot) -> None:
1469
1687
  if await _handle(text, slot) == "exit":
@@ -1519,8 +1737,15 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1519
1737
  on_switch=lambda idx: _schedule_home_refill(slots, idx, home_fill_kwargs),
1520
1738
  on_new=lambda: _open_new_tab(),
1521
1739
  on_paste=_on_paste,
1740
+ live=_live_state,
1522
1741
  )
1523
1742
  finally:
1743
+ # 0.3.37: remember the tab layout BEFORE teardown, while the
1744
+ # slots (and their agents' session ids) are still intact.
1745
+ # In `finally` so an accidental exit, Ctrl-D and a crash all
1746
+ # remember equally -- the whole point is that closing the
1747
+ # terminal stops being destructive.
1748
+ _save_tab_layout(workspace, slots)
1524
1749
  _cancel_background()
1525
1750
  # Part C: release the per-repo instance lock (if this
1526
1751
  # process actually acquired one -- None on a secondary
@@ -1593,6 +1818,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
1593
1818
  if await _handle(line, slots.active()) == "exit":
1594
1819
  return
1595
1820
  finally:
1821
+ _save_tab_layout(workspace, slots) # 0.3.37, see the dock path above
1596
1822
  _cancel_background()
1597
1823
  if shared_client is not None:
1598
1824
  try:
@@ -0,0 +1,123 @@
1
+ """Tab persistence per-repo (0.3.37): remembers WHICH tabs were open so
2
+ closing the terminal is no longer destructive -- reopen it and your 3 tabs
3
+ come back, each one re-attaching to its own still-Running Temporal workflow
4
+ when there is one, or restoring just its label/mode/draft when there is not.
5
+
6
+ Deliberately modelled on `mode_store` (same house pattern, same failure
7
+ posture) with one difference: a tab is a RECORD, not a single string, so the
8
+ file is JSONL -- one JSON object per line, newest write replaces the file.
9
+ JSONL (not one big JSON doc) so a single corrupt line costs ONE tab instead
10
+ of the whole layout.
11
+
12
+ Fail-soft in BOTH directions, by design:
13
+ * `load_tabs` -- missing file, unreadable dir, corrupt/garbage lines all
14
+ degrade to [] (or skip just the bad line). A bad cache is exactly as safe
15
+ as no cache: you get today's behaviour, a single fresh tab.
16
+ * `save_tabs` -- a write failure (read-only home, disk full) is silently
17
+ dropped. Losing the memory is far smaller than crashing the terminal over
18
+ a nice-to-have.
19
+
20
+ WHAT IS PERSISTED, and what deliberately is NOT:
21
+ * persisted: `session_id` (the re-attach handle -- the whole point),
22
+ `label`, `mode`, `workspace`, `draft`.
23
+ * NEVER persisted: the transcript (it lives server-side and replays on
24
+ re-attach -- duplicating it here would risk showing a stale copy), and
25
+ `autopilot` as a mode. Autopilot auto-approves every tool call, so it is
26
+ downgraded to 'default' on write for EXACTLY the reason mode_store
27
+ downgrades it: resuming it silently from a stale file is unsafe.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import os
33
+
34
+ from webbee.repo import compute_repo_key, find_repo_root
35
+
36
+ _CACHE_DIR = os.path.expanduser("~/.cache/webbee") # test seam: monkeypatch this name
37
+
38
+ MAX_TABS = 12 # a sane ceiling: a corrupt/huge file can never spawn tabs forever
39
+
40
+ _KEY_CACHE: dict[str, str] = {}
41
+
42
+
43
+ def _repo_key_for(workspace: str) -> str:
44
+ """Memoised exactly like mode_store's twin: `compute_repo_key` shells out
45
+ to git, and this is reached from UI paths where a stall would be felt."""
46
+ key = _KEY_CACHE.get(workspace)
47
+ if key is None:
48
+ key = compute_repo_key(find_repo_root(workspace))
49
+ _KEY_CACHE[workspace] = key
50
+ return key
51
+
52
+
53
+ def _path_for(workspace: str) -> str:
54
+ return os.path.join(_CACHE_DIR, f"tabs-{_repo_key_for(workspace)}.jsonl")
55
+
56
+
57
+ def tab_record(session_id: str = "", label: str = "", mode: str = "default",
58
+ workspace: str = "", draft: str = "") -> dict:
59
+ """PURE. Build ONE normalised tab record. Central so the writer and the
60
+ tests agree on the shape, and so the autopilot downgrade can never be
61
+ forgotten at a call site."""
62
+ return {
63
+ "session_id": str(session_id or ""),
64
+ "label": str(label or ""),
65
+ "mode": "default" if str(mode or "") == "autopilot" else (str(mode or "") or "default"),
66
+ "workspace": str(workspace or ""),
67
+ "draft": str(draft or ""),
68
+ }
69
+
70
+
71
+ def load_tabs(workspace: str) -> list:
72
+ """The remembered tabs for `workspace`'s repo, oldest-first, or [] on no
73
+ file / ANY error. Individual corrupt lines are SKIPPED, not fatal: one bad
74
+ line must never cost you the other two tabs. Capped at MAX_TABS."""
75
+ out: list = []
76
+ try:
77
+ with open(_path_for(workspace), "r", encoding="utf-8") as f:
78
+ for line in f:
79
+ line = line.strip()
80
+ if not line:
81
+ continue
82
+ try:
83
+ rec = json.loads(line)
84
+ except Exception:
85
+ continue # one unreadable tab, not a broken layout
86
+ if isinstance(rec, dict):
87
+ out.append(tab_record(**{k: rec.get(k, "") for k in
88
+ ("session_id", "label", "mode",
89
+ "workspace", "draft")}))
90
+ if len(out) >= MAX_TABS:
91
+ break
92
+ except Exception:
93
+ return []
94
+ return out
95
+
96
+
97
+ def save_tabs(workspace: str, tabs: list) -> None:
98
+ """Remember `tabs` (a list of dicts/records, oldest-first) for this repo.
99
+ Written whole (replace, not append) so the file always mirrors the CURRENT
100
+ layout -- a closed tab genuinely disappears. Never raises."""
101
+ try:
102
+ os.makedirs(_CACHE_DIR, exist_ok=True)
103
+ lines = []
104
+ for t in list(tabs)[:MAX_TABS]:
105
+ if not isinstance(t, dict):
106
+ continue
107
+ lines.append(json.dumps(tab_record(**{k: t.get(k, "") for k in
108
+ ("session_id", "label", "mode",
109
+ "workspace", "draft")}),
110
+ ensure_ascii=False))
111
+ with open(_path_for(workspace), "w", encoding="utf-8") as f:
112
+ f.write("\n".join(lines) + ("\n" if lines else ""))
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ def clear_tabs(workspace: str) -> None:
118
+ """Forget this repo's remembered layout (a clean `/exit` of the LAST tab,
119
+ or a user who wants a fresh dock). Never raises."""
120
+ try:
121
+ os.remove(_path_for(workspace))
122
+ except Exception:
123
+ pass