webbee 0.1.0__py3-none-any.whl

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.
webbee/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
webbee/account.py ADDED
@@ -0,0 +1,78 @@
1
+ import asyncio
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Account:
8
+ signed_in: bool = False
9
+ email: str = ""
10
+ nickname: str = ""
11
+ plan: str = ""
12
+ plan_status: str = ""
13
+ plan_renews: str = ""
14
+ dev_tier: str = ""
15
+ member_since: str = ""
16
+
17
+
18
+ def _fmt_month(iso: str) -> str:
19
+ """'2026-04-27T10:00:00Z' -> 'Apr 2026'; '' on any failure."""
20
+ try:
21
+ return datetime.fromisoformat(iso.replace("Z", "+00:00")).strftime("%b %Y")
22
+ except Exception:
23
+ return ""
24
+
25
+
26
+ def _fmt_day(iso: str) -> str:
27
+ """'2026-08-01T00:00:00Z' -> '2026-08-01'; '' on any failure."""
28
+ try:
29
+ return datetime.fromisoformat(iso.replace("Z", "+00:00")).strftime("%Y-%m-%d")
30
+ except Exception:
31
+ return ""
32
+
33
+
34
+ async def _default_get(cfg, token: str, path: str) -> dict:
35
+ import httpx
36
+ async with httpx.AsyncClient(base_url=cfg.api_url, timeout=3.0) as c:
37
+ r = await c.get(path, headers={"Authorization": f"Bearer {token}"})
38
+ r.raise_for_status()
39
+ return r.json()
40
+
41
+
42
+ async def fetch_account(cfg, token_provider, *, get=None) -> Account:
43
+ """Best-effort account summary for the welcome screen. NEVER raises: no
44
+ token or a failed /v1/auth/me -> Account(signed_in=False); a failed
45
+ billing/developer call just omits those fields."""
46
+ try:
47
+ token = await token_provider()
48
+ except Exception:
49
+ return Account(signed_in=False)
50
+
51
+ async def getter(path: str) -> dict:
52
+ if get is not None:
53
+ return await get(path)
54
+ return await _default_get(cfg, token, path)
55
+
56
+ async def _try(path):
57
+ try:
58
+ return await getter(path)
59
+ except Exception:
60
+ return None
61
+
62
+ me, sub, dev = await asyncio.gather(
63
+ _try("/v1/auth/me"), _try("/v1/billing/subscription"), _try("/v1/developer/profile"))
64
+ if not me:
65
+ return Account(signed_in=False)
66
+ attrs = me.get("attributes") or {}
67
+ sub = sub or {}
68
+ dev = dev or {}
69
+ return Account(
70
+ signed_in=True,
71
+ email=str(me.get("email", "") or ""),
72
+ nickname=str(dev.get("nickname", "") or ""),
73
+ plan=str(sub.get("plan", "") or ""),
74
+ plan_status=str(sub.get("status", "") or ""),
75
+ plan_renews=_fmt_day(str(sub.get("expires_at", "") or "")),
76
+ dev_tier=str(dev.get("tier", "") or attrs.get("developer_tier", "") or ""),
77
+ member_since=_fmt_month(str(me.get("created_at", "") or dev.get("registered_at", "") or "")),
78
+ )
webbee/banner_art.py ADDED
@@ -0,0 +1,9 @@
1
+ # Webbee Code logo โ€” pyfiglet "big" font (generated). Rendered bee-yellow + centered by render.py.
2
+ WEBBEE_CODE = r"""__ __ _ _ _____ _
3
+ \ \ / / | | | | / ____| | |
4
+ \ \ /\ / /__| |__ | |__ ___ ___ | | ___ __| | ___
5
+ \ \/ \/ / _ \ '_ \| '_ \ / _ \/ _ \ | | / _ \ / _` |/ _ \
6
+ \ /\ / __/ |_) | |_) | __/ __/ | |___| (_) | (_| | __/
7
+ \/ \/ \___|_.__/|_.__/ \___|\___| \_____\___/ \__,_|\___|
8
+
9
+ """
webbee/cli.py ADDED
@@ -0,0 +1,56 @@
1
+ import argparse
2
+ import asyncio
3
+ import os
4
+
5
+ from webbee import __version__
6
+ from webbee.config import Config
7
+
8
+
9
+ def build_parser() -> argparse.ArgumentParser:
10
+ p = argparse.ArgumentParser(prog="webbee", description="Webbee ๐Ÿ โ€” coding agent in your terminal")
11
+ p.add_argument("--version", action="version", version=f"webbee {__version__}")
12
+ p.add_argument("--mode", choices=["default", "plan", "autopilot"], default="default")
13
+ sub = p.add_subparsers(dest="cmd")
14
+ sub.add_parser("login", help="Log in to your Imperal account in the browser")
15
+ sub.add_parser("logout", help="Log out and remove local credentials")
16
+ return p
17
+
18
+
19
+ def main(argv=None) -> None:
20
+ args = build_parser().parse_args(argv)
21
+ cfg = Config.from_env()
22
+
23
+ if args.cmd == "login":
24
+ from imperal_mcp import auth
25
+ print(f"Logged in as {auth.login(cfg)}.")
26
+ return
27
+ if args.cmd == "logout":
28
+ from imperal_mcp import auth
29
+ asyncio.run(auth.logout(cfg))
30
+ print("Logged out.")
31
+ return
32
+
33
+ # Default: the polished REPL. Fire a non-blocking update-check first.
34
+ from webbee.repl import run_repl
35
+ try:
36
+ _maybe_print_update_notice()
37
+ asyncio.run(run_repl(cfg, args.mode))
38
+ except KeyboardInterrupt:
39
+ # Ctrl-C during the update-check fetch, or at the read_line() prompt,
40
+ # unwinds here โ€” exit clean, no traceback. (repl.py itself now cancels
41
+ # a Ctrl-C mid-turn internally and returns to the prompt instead of
42
+ # propagating โ€” see run_repl.)
43
+ print("\nBye ๐Ÿ")
44
+
45
+
46
+ def _maybe_print_update_notice() -> None:
47
+ try:
48
+ from pathlib import Path
49
+ import time
50
+ from webbee.update import check_for_update, default_fetch
51
+ cache = Path(os.path.expanduser("~/.cache/webbee/update.json"))
52
+ notice = check_for_update(__version__, cache_path=cache, now=time.time(), fetch=default_fetch)
53
+ if notice:
54
+ print(notice)
55
+ except Exception:
56
+ pass # update-check must never block or crash startup
webbee/commands.py ADDED
@@ -0,0 +1,77 @@
1
+ from dataclasses import dataclass
2
+
3
+ _MODES = ("default", "plan", "autopilot")
4
+
5
+ _HELP = """Commands:
6
+ /help show this help
7
+ /login sign in to your Imperal account (browser)
8
+ /logout sign out and remove local credentials
9
+ /clear clear the screen + reset session counters
10
+ /mode [default|plan|autopilot] consent mode (no arg โ€” show current)
11
+ /cost (=/usage) tokens + $ cost this session
12
+ /status cwd ยท git ยท surface ยท tokens ยท version
13
+ /exit (=/quit) quit"""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class CommandContext:
18
+ mode: str
19
+ workspace: str
20
+ version: str
21
+ surface: str
22
+ logged_in: bool
23
+ session_tokens: int
24
+ session_cost: float
25
+ git_branch: str
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SlashResult:
30
+ handled: bool
31
+ exit: bool = False
32
+ message: str = ""
33
+ action: str = ""
34
+ new_mode: "str | None" = None
35
+
36
+
37
+ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
38
+ """Parse one input line. Non-slash lines return handled=False (the REPL
39
+ then sends them to the agent). Slash lines are fully handled here."""
40
+ text = line.strip()
41
+ if not text.startswith("/"):
42
+ return SlashResult(handled=False)
43
+
44
+ parts = text.split()
45
+ cmd, args = parts[0].lower(), parts[1:]
46
+
47
+ if cmd in ("/exit", "/quit"):
48
+ return SlashResult(handled=True, exit=True)
49
+ if cmd == "/help":
50
+ return SlashResult(handled=True, action="help", message=_HELP)
51
+ if cmd == "/login":
52
+ return SlashResult(handled=True, action="login")
53
+ if cmd == "/logout":
54
+ return SlashResult(handled=True, action="logout")
55
+ if cmd == "/clear":
56
+ return SlashResult(handled=True, action="clear", message="Screen cleared, counters reset.")
57
+ if cmd in ("/cost", "/usage"):
58
+ return SlashResult(handled=True, action="cost",
59
+ message=f"This session: {ctx.session_tokens} tokens (~${ctx.session_cost:.4f}). "
60
+ f"LLM turns don't spend credits.")
61
+ if cmd == "/status":
62
+ auth = "signed in" if ctx.logged_in else "not signed in (/login)"
63
+ msg = (f"surface: {ctx.surface} mode: {ctx.mode} {auth}\n"
64
+ f"cwd: {ctx.workspace} git: {ctx.git_branch}\n"
65
+ f"tokens: {ctx.session_tokens} (~${ctx.session_cost:.4f}) webbee v{ctx.version}")
66
+ return SlashResult(handled=True, action="status", message=msg)
67
+ if cmd == "/mode":
68
+ if not args:
69
+ return SlashResult(handled=True, action="mode", new_mode=None,
70
+ message=f"Current mode: {ctx.mode}. Available: {', '.join(_MODES)}.")
71
+ want = args[0].lower()
72
+ if want not in _MODES:
73
+ return SlashResult(handled=True, action="mode", new_mode=None,
74
+ message=f"Unknown mode '{want}'. Available: {', '.join(_MODES)}.")
75
+ return SlashResult(handled=True, action="mode", new_mode=want,
76
+ message=f"Mode โ†’ {want}.")
77
+ return SlashResult(handled=True, message=f"Unknown command '{cmd}'. /help for the list.")
webbee/config.py ADDED
@@ -0,0 +1,14 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+
4
+ @dataclass(frozen=True)
5
+ class Config:
6
+ api_url: str
7
+ panel_url: str
8
+
9
+ @classmethod
10
+ def from_env(cls) -> "Config":
11
+ return cls(
12
+ api_url=os.environ.get("IMPERAL_API_URL", "https://auth.imperal.io").rstrip("/"),
13
+ panel_url=os.environ.get("IMPERAL_PANEL_URL", "https://panel.imperal.io").rstrip("/"),
14
+ )
webbee/events.py ADDED
@@ -0,0 +1,17 @@
1
+ from typing import Protocol, runtime_checkable
2
+
3
+
4
+ @runtime_checkable
5
+ class TurnSink(Protocol):
6
+ """Everything a running coding turn tells the UI. The renderer (render.py)
7
+ implements this with Rich; tests implement it with a recording fake.
8
+ session.py imports ONLY this โ€” never Rich โ€” so the SSE loop stays testable."""
9
+
10
+ def tool_start(self, tool: str, args: dict) -> None: ...
11
+ def tool_result(self, tool: str, ok: bool, summary: str) -> None: ...
12
+ async def ask_consent(self, app_id: str, tool: str, args: dict) -> str: ...
13
+ def panel_release(self, panel_url: str, summary: str) -> None: ...
14
+ def progress(self, text: str) -> None: ...
15
+ def usage(self, tokens: int, cost_usd: float) -> None: ...
16
+ def plan_blocked(self, tool: str) -> None: ...
17
+ def user_echo(self, text: str) -> None: ...
webbee/render.py ADDED
@@ -0,0 +1,313 @@
1
+ import asyncio
2
+ import time
3
+
4
+ from rich.console import Console
5
+ from rich.markdown import Markdown
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+
9
+ from webbee.banner_art import WEBBEE_CODE
10
+
11
+ _ICON = {"read_file": "๐Ÿ“–", "grep": "๐Ÿ”Ž", "glob": "๐Ÿ—‚๏ธ", "write_file": "โœŽ",
12
+ "edit_file": "๐Ÿ”ง", "bash": "โšก"}
13
+ _BEE = "yellow" # bee-yellow brand accent โ€” logo / ๐Ÿ / notes / busy dot ONLY
14
+ _ACCENT = "cyan" # interactive chrome ONLY โ€” live caret / mode / panel url
15
+
16
+
17
+ def _fmt_tokens(n: int) -> str:
18
+ """Compact token count: 2100 -> '2.1k', 900 -> '900'."""
19
+ return f"{n / 1000:.1f}k" if n >= 1000 else str(int(n))
20
+
21
+
22
+ def _salient_arg(args: dict) -> str:
23
+ """The one human-readable arg to show for a tool/consent call (PURE) โ€” a
24
+ title/path/command/name over an opaque id; falls back to the first id-ish
25
+ value. Keeps the feed readable instead of dumping a raw args dict."""
26
+ if not isinstance(args, dict):
27
+ return ""
28
+ for k in ("title", "name", "path", "pattern", "command", "query", "q"):
29
+ v = args.get(k)
30
+ if v:
31
+ return str(v)
32
+ for k in ("id", "note_id", "folder_id", "task_id"):
33
+ v = args.get(k)
34
+ if v:
35
+ return str(v)
36
+ return ""
37
+
38
+
39
+ def _invalidate() -> None:
40
+ """Redraw the running prompt_toolkit app if any. No-op when none is active
41
+ (tests / non-tty fallback) โ€” the fallback when no output pane is wired."""
42
+ try:
43
+ from prompt_toolkit.application import get_app_or_none
44
+ app = get_app_or_none()
45
+ if app is not None:
46
+ app.invalidate()
47
+ except Exception:
48
+ pass
49
+
50
+
51
+ class RichSink:
52
+ """Rich implementation of TurnSink. Turn output (action feed, ๐Ÿ answer,
53
+ footer) is rendered by the Rich Console. In the full-screen dock the Console
54
+ writes ANSI into the OutputPane's buffer (repl passes console=pane.console +
55
+ on_output=pane.notify) and the pane shows it in a scrollable region above
56
+ the fixed input; after each render this sink calls on_output() so the pane
57
+ follows the tail. The live status (busy/elapsed/tokens) is the dock toolbar,
58
+ fed by status(). Consent runs through the dock via an asyncio.Future
59
+ (resolve_consent), with a sync input fallback for non-tty.
60
+
61
+ Tests pass live_enabled=False + inject input_fn/clock and no on_output; then
62
+ _nudge() is a harmless no-op and consent uses the injected input_fn."""
63
+
64
+ def __init__(self, console=None, *, live_enabled=True, input_fn=input,
65
+ clock=time.monotonic, on_output=None):
66
+ self.console = console or Console()
67
+ self._live_enabled = live_enabled
68
+ self._input = input_fn
69
+ self._clock = clock
70
+ self._on_output = on_output # pane.notify (scroll+redraw) in the dock
71
+ self.tokens = 0
72
+ self.cost_usd = 0.0
73
+ self.session_tokens = 0
74
+ self.session_cost = 0.0
75
+ self._tools = 0
76
+ self._started = None
77
+ self._busy = False
78
+ self._current = ""
79
+ self._pending = ("", "")
80
+ self._consent = None # asyncio.Future while awaiting a reply
81
+ self._consent_summary = ""
82
+
83
+ def _nudge(self) -> None:
84
+ """After output/state changes: let the pane follow the tail + redraw."""
85
+ if self._on_output is not None:
86
+ self._on_output()
87
+ else:
88
+ _invalidate()
89
+
90
+ # ---- welcome ------------------------------------------------------------
91
+ def welcome(self, account, cwd: str, surface: str) -> None:
92
+ """One-time launch splash: a centered WEBBEE CODE logo + imperal.io + an
93
+ honest account panel (who/plan/tier/member-since). Runs BEFORE the dock
94
+ starts. Clears the screen ONLY in the non-pane path (the full-screen
95
+ dock owns its own alternate screen โ€” clearing there would corrupt it)."""
96
+ if self._on_output is None:
97
+ self.console.clear()
98
+ w = self.console.width
99
+
100
+ def _center_block(text: str) -> str:
101
+ lines = text.splitlines()
102
+ bw = max((len(line) for line in lines), default=0)
103
+ pad = " " * max(0, (w - bw) // 2)
104
+ return "\n".join(pad + line for line in lines)
105
+
106
+ self.console.print()
107
+ self.console.print(Text(_center_block(WEBBEE_CODE), style=f"bold {_BEE}"))
108
+ self.console.print(Text("๐Ÿ".center(w), style=f"bold {_BEE}"))
109
+ self.console.print(Text("ICNLI AI Cloud OS ยท Agent".center(w), style=f"bold {_ACCENT}"))
110
+ self.console.print(Text("ยท i m p e r a l . i o ยท".center(w), style="dim"))
111
+ self.console.print()
112
+ rows = []
113
+ if account.signed_in:
114
+ who = account.email + (f" ยท @{account.nickname}" if account.nickname else "")
115
+ rows.append(("Signed in as", who))
116
+ if account.plan:
117
+ plan = account.plan + (f" ยท {account.plan_status}" if account.plan_status else "")
118
+ plan += (f" ยท renews {account.plan_renews}" if account.plan_renews else "")
119
+ rows.append(("Plan", plan))
120
+ if account.dev_tier:
121
+ rows.append(("Developer", f"{account.dev_tier} tier"))
122
+ if account.member_since:
123
+ rows.append(("Member since", account.member_since))
124
+ else:
125
+ rows.append(("", "not signed in โ€” /login"))
126
+ bw = max((len(label.ljust(14) + value) for label, value in rows), default=0)
127
+ pad = " " * max(0, (w - bw) // 2)
128
+ for label, value in rows:
129
+ self.console.print(Text.assemble((pad + label.ljust(14), "dim"), (value, "white")))
130
+ self.console.print()
131
+ self.console.print(Text("/help ยท Ctrl-D to exit".center(w), style="dim"))
132
+ self.console.print()
133
+ self._nudge()
134
+
135
+ # ---- turn lifecycle -------------------------------------------------
136
+ def begin_turn(self) -> None:
137
+ self._tools = 0
138
+ self._started = self._clock()
139
+ self._current = ""
140
+ self._pending = ("", "")
141
+ self.tokens = 0 # per-turn live counters (usage frames are per-turn cumulative)
142
+ self.cost_usd = 0.0
143
+ self._busy = True
144
+ self.console.print() # breathing room between the user's message and the response
145
+ self._nudge()
146
+
147
+ def end_turn(self, final_text: str) -> None:
148
+ self._busy = False
149
+ if final_text:
150
+ self.console.print() # separation before the focus block
151
+ self.console.print(Text(" ๐Ÿ Webbee", style=f"bold {_BEE}"))
152
+ self.console.print(Markdown(final_text))
153
+ elapsed = self._elapsed()
154
+ self.session_tokens += self.tokens
155
+ self.session_cost += self.cost_usd
156
+ noun = "action" if self._tools == 1 else "actions"
157
+ self.console.print(Text(
158
+ f" {elapsed:.1f}s ยท {self._tools} {noun} ยท {_fmt_tokens(self.tokens)} tok"
159
+ f" ยท session {_fmt_tokens(self.session_tokens)} tok",
160
+ style="dim"))
161
+ self.console.print() # breathing room before the next prompt
162
+ self._nudge()
163
+
164
+ def note(self, message: str) -> None:
165
+ self.console.print(Text(" " + message, style=_BEE))
166
+ self._nudge()
167
+
168
+ def user_echo(self, text: str) -> None:
169
+ """Commit the just-sent user message as its own clearly-readable line
170
+ with a background bar (NOT boxed like the live input) so it stands out
171
+ as 'what I sent' in the scrollback."""
172
+ self.console.print(Text.assemble(
173
+ (" ", ""), (" โฏ " + text + " ", "bold white on grey30")))
174
+ self._nudge()
175
+
176
+ def clear(self) -> None:
177
+ """/clear: wipe the pane/screen + reset the session counters."""
178
+ self.console.clear()
179
+ self.tokens = 0
180
+ self.cost_usd = 0.0
181
+ self.session_tokens = 0
182
+ self.session_cost = 0.0
183
+ self._tools = 0
184
+ self._current = ""
185
+ self._nudge()
186
+
187
+ def abort(self) -> None:
188
+ """Ctrl-C mid-turn: clear busy so the toolbar drops back to idle. No
189
+ printing โ€” the caller (repl.py) prints the note."""
190
+ self._busy = False
191
+ self._nudge()
192
+
193
+ # ---- TurnSink -------------------------------------------------------
194
+ def tool_start(self, tool: str, args: dict) -> None:
195
+ self._tools += 1
196
+ arg = args.get("path") or args.get("pattern") or args.get("command") or ""
197
+ self._pending = (tool, str(arg))
198
+ self._current = f"{tool} {str(arg)[:40]}".strip()
199
+ self._nudge() # the completed line is printed in tool_result
200
+
201
+ def tool_result(self, tool: str, ok: bool, summary: str) -> None:
202
+ # One calm dim line: icon + tool (+arg), then the โœ“/โœ— RIGHT NEXT TO the
203
+ # action (not pinned to the far right), then a dim summary. Only the
204
+ # โœ“/โœ— carries colour โ€” the rest recedes (dim).
205
+ _tool, arg = self._pending if self._pending[0] else (tool, "")
206
+ icon = _ICON.get(_tool, "โšก")
207
+ mark = "โœ“" if ok else "โœ—"
208
+ self.console.print(Text.assemble(
209
+ (" " + icon + " ", "dim"),
210
+ (_tool, "dim"),
211
+ ((" " + arg[:40]) if arg else "", "dim"),
212
+ (" ", ""),
213
+ (mark + " ", "green" if ok else "red"),
214
+ (str(summary)[:50], "dim"),
215
+ ))
216
+ self._pending = ("", "")
217
+ self._nudge()
218
+
219
+ async def ask_consent(self, app_id: str, tool: str, args: dict) -> str:
220
+ """Ask for consent and return the user's RAW reply (trimmed only) โ€”
221
+ NEVER interpret (the kernel decides, ICNLI). When the dock is running
222
+ the reply comes through the pinned box via an asyncio.Future (no
223
+ blocking input on the event loop); otherwise fall back to the injected
224
+ sync reader (tests / non-tty)."""
225
+ label = f"{app_id}ยท{tool}" if app_id else tool
226
+ sal = _salient_arg(args)
227
+ self.console.print(Text.assemble((" ? approve ", "yellow"), (label, "dim"),
228
+ ((" " + sal[:60]) if sal else "", "dim")))
229
+ fut = self._arm_consent(label, sal)
230
+ if fut is None: # non-tty / no running app
231
+ raw = self._input(" ")
232
+ else:
233
+ self._nudge()
234
+ raw = await fut
235
+ self._consent = None
236
+ self._consent_summary = ""
237
+ raw = (raw or "").strip()
238
+ self.console.print(Text(" โ†ณ " + raw, style="dim")) # quiet echo of the reply
239
+ self._nudge()
240
+ return raw
241
+
242
+ def panel_release(self, panel_url: str, summary: str) -> None:
243
+ body = Text.assemble(
244
+ (summary + "\n\n" if summary else "", "white"),
245
+ ("Approve it in your browser:\n", "white"),
246
+ (f" {panel_url}\n", f"bold {_ACCENT}"),
247
+ ("Then ask again โ€” you weren't charged.", "dim"),
248
+ )
249
+ self.console.print(Panel(body, title="๐Ÿ’ณ This costs money", border_style="magenta"))
250
+ self._nudge()
251
+
252
+ def progress(self, text: str) -> None:
253
+ if text:
254
+ self.console.print(Text(" " + text, style="dim italic"))
255
+ self._nudge()
256
+
257
+ def plan_blocked(self, tool: str) -> None:
258
+ """Plan mode auto-declines writes/destructive. Tell the user WHY and how
259
+ to allow it (Shift+Tab). Autopilot and default never reach this."""
260
+ self.console.print(Text.assemble(
261
+ (" โ›” plan mode", _BEE),
262
+ (f" โ€” {tool} blocked. " if tool else " โ€” action blocked. ", "dim"),
263
+ ("Press Shift+Tab to switch to default or autopilot to allow it.", "dim")))
264
+ self._nudge()
265
+
266
+ def usage(self, tokens: int, cost_usd: float) -> None:
267
+ # Cumulative frame โ€” trust the server's running totals verbatim.
268
+ self.tokens = tokens
269
+ self.cost_usd = cost_usd
270
+ self._nudge()
271
+
272
+ # ---- dock bridge (read by tui.run_session's toolbar + Enter binding) ---
273
+ def status(self) -> dict:
274
+ """Live state for the dock toolbar. The bottom counter shows the SESSION
275
+ TOTAL (not per-turn): the running session total, plus the in-flight
276
+ turn's spend while busy (so it grows live), with no double-count at idle
277
+ (end_turn has already folded the finished turn into the session)."""
278
+ return {"busy": self._busy, "current": self._current,
279
+ "elapsed": self._elapsed(), "tools": self._tools,
280
+ "tokens": self.session_tokens + (self.tokens if self._busy else 0),
281
+ "cost": self.session_cost + (self.cost_usd if self._busy else 0.0),
282
+ "consent": self.consent_pending()}
283
+
284
+ def is_busy(self) -> bool:
285
+ return self._busy
286
+
287
+ def consent_pending(self) -> bool:
288
+ return self._consent is not None and not self._consent.done()
289
+
290
+ def resolve_consent(self, raw: str) -> None:
291
+ """Called by the dock's Enter binding when a consent reply is awaited โ€”
292
+ hands the RAW reply verbatim to the awaiting ask_consent (ICNLI)."""
293
+ if self.consent_pending():
294
+ self._consent.set_result(raw)
295
+
296
+ # ---- internals ------------------------------------------------------
297
+ def _arm_consent(self, label: str, summary: str):
298
+ """Create the consent Future iff a dock is running; else None (caller
299
+ falls back to sync input)."""
300
+ try:
301
+ from prompt_toolkit.application import get_app_or_none
302
+ if get_app_or_none() is None:
303
+ return None
304
+ self._consent = asyncio.get_running_loop().create_future()
305
+ self._consent_summary = summary or label
306
+ return self._consent
307
+ except Exception:
308
+ return None
309
+
310
+ def _elapsed(self) -> float:
311
+ if self._started is None:
312
+ return 0.0
313
+ return self._clock() - self._started