glaip-sdk 0.5.5__py3-none-any.whl → 0.6.1__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.
Files changed (49) hide show
  1. glaip_sdk/__init__.py +4 -1
  2. glaip_sdk/agents/__init__.py +27 -0
  3. glaip_sdk/agents/base.py +996 -0
  4. glaip_sdk/cli/commands/common_config.py +36 -0
  5. glaip_sdk/cli/commands/tools.py +2 -5
  6. glaip_sdk/cli/config.py +13 -2
  7. glaip_sdk/cli/main.py +20 -0
  8. glaip_sdk/cli/slash/accounts_controller.py +217 -0
  9. glaip_sdk/cli/slash/accounts_shared.py +19 -0
  10. glaip_sdk/cli/slash/session.py +57 -7
  11. glaip_sdk/cli/slash/tui/accounts.tcss +54 -0
  12. glaip_sdk/cli/slash/tui/accounts_app.py +379 -0
  13. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  14. glaip_sdk/cli/slash/tui/loading.py +58 -0
  15. glaip_sdk/cli/slash/tui/remote_runs_app.py +9 -12
  16. glaip_sdk/client/_agent_payloads.py +10 -9
  17. glaip_sdk/client/agents.py +70 -8
  18. glaip_sdk/client/base.py +1 -0
  19. glaip_sdk/client/main.py +12 -4
  20. glaip_sdk/client/mcps.py +112 -10
  21. glaip_sdk/client/tools.py +151 -7
  22. glaip_sdk/mcps/__init__.py +21 -0
  23. glaip_sdk/mcps/base.py +345 -0
  24. glaip_sdk/models/__init__.py +65 -31
  25. glaip_sdk/models/agent.py +47 -0
  26. glaip_sdk/models/agent_runs.py +0 -1
  27. glaip_sdk/models/common.py +42 -0
  28. glaip_sdk/models/mcp.py +33 -0
  29. glaip_sdk/models/tool.py +33 -0
  30. glaip_sdk/registry/__init__.py +55 -0
  31. glaip_sdk/registry/agent.py +164 -0
  32. glaip_sdk/registry/base.py +139 -0
  33. glaip_sdk/registry/mcp.py +251 -0
  34. glaip_sdk/registry/tool.py +238 -0
  35. glaip_sdk/tools/__init__.py +22 -0
  36. glaip_sdk/tools/base.py +435 -0
  37. glaip_sdk/utils/__init__.py +50 -9
  38. glaip_sdk/utils/bundler.py +267 -0
  39. glaip_sdk/utils/client.py +111 -0
  40. glaip_sdk/utils/client_utils.py +26 -7
  41. glaip_sdk/utils/discovery.py +78 -0
  42. glaip_sdk/utils/import_resolver.py +492 -0
  43. glaip_sdk/utils/instructions.py +101 -0
  44. glaip_sdk/utils/sync.py +142 -0
  45. {glaip_sdk-0.5.5.dist-info → glaip_sdk-0.6.1.dist-info}/METADATA +5 -3
  46. {glaip_sdk-0.5.5.dist-info → glaip_sdk-0.6.1.dist-info}/RECORD +48 -22
  47. glaip_sdk/models.py +0 -241
  48. {glaip_sdk-0.5.5.dist-info → glaip_sdk-0.6.1.dist-info}/WHEEL +0 -0
  49. {glaip_sdk-0.5.5.dist-info → glaip_sdk-0.6.1.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  """Shared helpers for configuration/account flows."""
2
2
 
3
3
  import click
4
+ import logging
4
5
  from rich.console import Console
5
6
  from rich.text import Text
6
7
 
@@ -63,3 +64,38 @@ def check_connection(
63
64
  finally:
64
65
  if client is not None:
65
66
  client.close()
67
+
68
+
69
+ def check_connection_with_reason(
70
+ api_url: str,
71
+ api_key: str,
72
+ *,
73
+ abort_on_error: bool = False,
74
+ ) -> tuple[bool, str]:
75
+ """Test connectivity and return structured reason."""
76
+ client: Client | None = None
77
+ try:
78
+ # Import lazily so test patches targeting glaip_sdk.Client are honored
79
+ from importlib import import_module # noqa: PLC0415
80
+
81
+ client_module = import_module("glaip_sdk")
82
+ client = client_module.Client(api_url=api_url, api_key=api_key)
83
+ try:
84
+ client.list_agents()
85
+ return True, ""
86
+ except Exception as exc: # pragma: no cover - API failures depend on network
87
+ if abort_on_error:
88
+ raise click.Abort() from exc
89
+ return False, f"api_failed: {exc}"
90
+ except Exception as exc:
91
+ # Log unexpected exceptions in debug while keeping CLI-friendly messaging
92
+ logging.getLogger(__name__).debug("Unexpected connection error", exc_info=exc)
93
+ if abort_on_error:
94
+ raise click.Abort() from exc
95
+ return False, f"connection_failed: {exc}"
96
+ finally:
97
+ if client is not None:
98
+ try:
99
+ client.close()
100
+ except Exception:
101
+ pass
@@ -10,8 +10,6 @@ from pathlib import Path
10
10
  from typing import Any
11
11
 
12
12
  import click
13
- from rich.console import Console
14
-
15
13
  from glaip_sdk.branding import (
16
14
  ACCENT_STYLE,
17
15
  ERROR_STYLE,
@@ -29,9 +27,7 @@ from glaip_sdk.cli.display import (
29
27
  handle_json_output,
30
28
  handle_rich_output,
31
29
  )
32
- from glaip_sdk.cli.io import (
33
- fetch_raw_resource_details,
34
- )
30
+ from glaip_sdk.cli.io import fetch_raw_resource_details
35
31
  from glaip_sdk.cli.io import (
36
32
  load_resource_from_file_with_validation as load_resource_from_file,
37
33
  )
@@ -49,6 +45,7 @@ from glaip_sdk.cli.utils import (
49
45
  )
50
46
  from glaip_sdk.icons import ICON_TOOL
51
47
  from glaip_sdk.utils.import_export import merge_import_with_cli_args
48
+ from rich.console import Console
52
49
 
53
50
  console = Console()
54
51
 
glaip_sdk/cli/config.py CHANGED
@@ -12,15 +12,26 @@ from typing import Any
12
12
  import yaml
13
13
 
14
14
  _ENV_CONFIG_DIR = os.getenv("AIP_CONFIG_DIR")
15
- _TEST_ENV = os.getenv("PYTEST_CURRENT_TEST") or os.getenv("PYTEST_XDIST_WORKER")
15
+ # Detect pytest environment: check for pytest markers or test session
16
+ # This provides automatic test isolation even if conftest.py doesn't set AIP_CONFIG_DIR
17
+ # Note: conftest.py sets AIP_CONFIG_DIR before imports, which takes precedence
18
+ _TEST_ENV = os.getenv("PYTEST_CURRENT_TEST") or os.getenv("PYTEST_XDIST_WORKER") or os.getenv("_PYTEST_RAISE")
16
19
 
17
20
  if _ENV_CONFIG_DIR:
21
+ # Explicit override via environment variable (highest priority)
22
+ # This is set by conftest.py before imports, ensuring test isolation
18
23
  CONFIG_DIR = Path(_ENV_CONFIG_DIR)
19
24
  elif _TEST_ENV:
20
25
  # Isolate test runs (including xdist workers) from the real user config directory
26
+ # Use a per-process unique temp directory to avoid conflicts in parallel test runs
21
27
  import tempfile
28
+ import uuid
22
29
 
23
- CONFIG_DIR = Path(tempfile.gettempdir()) / "aip-test-config"
30
+ # Create a unique temp dir per test process to avoid conflicts
31
+ temp_base = Path(tempfile.gettempdir())
32
+ test_config_dir = temp_base / f"aip-test-config-{os.getpid()}-{uuid.uuid4().hex[:8]}"
33
+ CONFIG_DIR = test_config_dir
34
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
24
35
  else: # pragma: no cover - default path used outside test runs
25
36
  CONFIG_DIR = Path.home() / ".aip"
26
37
 
glaip_sdk/cli/main.py CHANGED
@@ -49,6 +49,24 @@ from glaip_sdk.config.constants import (
49
49
  from glaip_sdk.icons import ICON_AGENT
50
50
  from glaip_sdk.rich_components import AIPPanel, AIPTable
51
51
 
52
+
53
+ def _suppress_chatty_loggers() -> None:
54
+ """Silence noisy SDK/httpx logs for CLI output."""
55
+ noisy_loggers = [
56
+ "glaip_sdk.client",
57
+ "httpx",
58
+ "httpcore",
59
+ ]
60
+ for name in noisy_loggers:
61
+ logger = logging.getLogger(name)
62
+ # Respect existing configuration: only raise level when unset,
63
+ # and avoid changing propagation if a custom handler is already attached.
64
+ if logger.level == logging.NOTSET:
65
+ logger.setLevel(logging.WARNING)
66
+ if not logger.handlers:
67
+ logger.propagate = False
68
+
69
+
52
70
  # Import SlashSession for potential mocking in tests
53
71
  try:
54
72
  from glaip_sdk.cli.slash import SlashSession
@@ -123,6 +141,8 @@ def main(
123
141
  ctx.obj["view"] = view
124
142
  ctx.obj["account_name"] = account_name
125
143
 
144
+ _suppress_chatty_loggers()
145
+
126
146
  ctx.obj["tty"] = not no_tty
127
147
 
128
148
  launching_slash = (
@@ -0,0 +1,217 @@
1
+ """Accounts controller for the /accounts slash command.
2
+
3
+ Provides a lightweight Textual list with fallback Rich snapshot to switch
4
+ between stored accounts using the shared AccountStore and CLI validation.
5
+
6
+ Authors:
7
+ Raymond Christopher (raymond.christopher@gdplabs.id)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from collections.abc import Iterable
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from rich.console import Console
18
+
19
+ from glaip_sdk.branding import ERROR_STYLE, INFO_STYLE, SUCCESS_STYLE, WARNING_STYLE
20
+ from glaip_sdk.cli.account_store import AccountStore, AccountStoreError, get_account_store
21
+ from glaip_sdk.cli.commands.common_config import check_connection_with_reason
22
+ from glaip_sdk.cli.masking import mask_api_key_display
23
+ from glaip_sdk.cli.slash.accounts_shared import build_account_status_string
24
+ from glaip_sdk.cli.slash.tui.accounts_app import TEXTUAL_SUPPORTED, AccountsTUICallbacks, run_accounts_textual
25
+ from glaip_sdk.rich_components import AIPPanel, AIPTable
26
+
27
+ if TYPE_CHECKING: # pragma: no cover
28
+ from glaip_sdk.cli.slash.session import SlashSession
29
+
30
+ TEXTUAL_AVAILABLE = bool(TEXTUAL_SUPPORTED)
31
+
32
+
33
+ class AccountsController:
34
+ """Controller for listing and switching accounts inside the palette."""
35
+
36
+ def __init__(self, session: SlashSession) -> None:
37
+ """Initialize the accounts controller.
38
+
39
+ Args:
40
+ session: The slash session context.
41
+ """
42
+ self.session = session
43
+ self.console: Console = session.console
44
+ self.ctx = session.ctx
45
+
46
+ def handle_accounts_command(self, args: list[str]) -> bool:
47
+ """Handle `/accounts` with optional `/accounts <name>` quick switch."""
48
+ store = get_account_store()
49
+ env_lock = bool(os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"))
50
+ accounts = store.list_accounts()
51
+
52
+ if not accounts:
53
+ self.console.print(f"[{WARNING_STYLE}]No accounts found. Use `/login` to add credentials.[/]")
54
+ return self.session._continue_session()
55
+
56
+ if args:
57
+ name = args[0]
58
+ self._switch_account(store, name, env_lock)
59
+ return self.session._continue_session()
60
+
61
+ rows = self._build_rows(accounts, store.get_active_account(), env_lock)
62
+
63
+ if self._should_use_textual():
64
+ self._render_textual(rows, store, env_lock)
65
+ else:
66
+ self._render_rich(rows, env_lock)
67
+
68
+ return self.session._continue_session()
69
+
70
+ def _should_use_textual(self) -> bool:
71
+ """Return whether Textual UI should be used."""
72
+ if not TEXTUAL_AVAILABLE:
73
+ return False
74
+
75
+ def _is_tty(stream: Any) -> bool:
76
+ isatty = getattr(stream, "isatty", None)
77
+ if not callable(isatty):
78
+ return False
79
+ try:
80
+ return bool(isatty())
81
+ except Exception:
82
+ return False
83
+
84
+ return _is_tty(sys.stdin) and _is_tty(sys.stdout)
85
+
86
+ def _build_rows(
87
+ self,
88
+ accounts: dict[str, dict[str, str]],
89
+ active_account: str | None,
90
+ env_lock: bool,
91
+ ) -> list[dict[str, str | bool]]:
92
+ """Normalize account rows for display."""
93
+ rows: list[dict[str, str | bool]] = []
94
+ for name, account in sorted(accounts.items()):
95
+ rows.append(
96
+ {
97
+ "name": name,
98
+ "api_url": account.get("api_url", ""),
99
+ "masked_key": mask_api_key_display(account.get("api_key", "")),
100
+ "active": name == active_account,
101
+ "env_lock": env_lock,
102
+ }
103
+ )
104
+ return rows
105
+
106
+ def _render_rich(self, rows: Iterable[dict[str, str | bool]], env_lock: bool) -> None:
107
+ """Render a Rich snapshot with columns matching TUI."""
108
+ if env_lock:
109
+ self.console.print(
110
+ f"[{WARNING_STYLE}]Env credentials detected (AIP_API_URL/AIP_API_KEY); switching is disabled.[/]"
111
+ )
112
+
113
+ table = AIPTable(title="AIP Accounts")
114
+ table.add_column("Name", style=INFO_STYLE, width=20)
115
+ table.add_column("API URL", style=SUCCESS_STYLE, width=40)
116
+ table.add_column("Key (masked)", style="dim", width=20)
117
+ table.add_column("Status", style=SUCCESS_STYLE, width=14)
118
+
119
+ for row in rows:
120
+ status = build_account_status_string(row, use_markup=True)
121
+ # pylint: disable=duplicate-code
122
+ # Similar to accounts_app.py but uses Rich AIPTable API
123
+ table.add_row(
124
+ str(row.get("name", "")),
125
+ str(row.get("api_url", "")),
126
+ str(row.get("masked_key", "")),
127
+ status,
128
+ )
129
+
130
+ self.console.print(table)
131
+
132
+ def _render_textual(self, rows: list[dict[str, str | bool]], store: AccountStore, env_lock: bool) -> None:
133
+ """Launch the Textual accounts browser."""
134
+ callbacks = AccountsTUICallbacks(switch_account=lambda name: self._switch_account(store, name, env_lock))
135
+ active = next((row["name"] for row in rows if row.get("active")), None)
136
+ run_accounts_textual(rows, active_account=active, env_lock=env_lock, callbacks=callbacks)
137
+ # Exit snapshot: show active account + host after closing the TUI
138
+ active_after = store.get_active_account() or "default"
139
+ host_after = ""
140
+ account_after = store.get_account(active_after) if hasattr(store, "get_account") else None
141
+ if account_after:
142
+ host_after = account_after.get("api_url", "")
143
+ host_suffix = f" • {host_after}" if host_after else ""
144
+ self.console.print(f"[dim]Active account: {active_after}{host_suffix}[/]")
145
+ # Surface a success banner when a switch occurred inside the TUI
146
+ if active_after != active:
147
+ self.console.print(
148
+ AIPPanel(
149
+ f"[{SUCCESS_STYLE}]Active account ➜ {active_after}[/]{host_suffix}",
150
+ title="✅ Account Switched",
151
+ border_style=SUCCESS_STYLE,
152
+ )
153
+ )
154
+
155
+ def _switch_account(self, store: AccountStore, name: str, env_lock: bool) -> tuple[bool, str]:
156
+ """Validate and switch active account; returns (success, message)."""
157
+ if env_lock:
158
+ msg = "Env credentials detected (AIP_API_URL/AIP_API_KEY); switching is disabled."
159
+ self.console.print(f"[{WARNING_STYLE}]{msg}[/]")
160
+ return False, msg
161
+
162
+ account = store.get_account(name)
163
+ if not account:
164
+ msg = f"Account '{name}' not found."
165
+ self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
166
+ return False, msg
167
+
168
+ api_url = account.get("api_url", "")
169
+ api_key = account.get("api_key", "")
170
+ if not api_url or not api_key:
171
+ edit_cmd = f"aip accounts edit {name}"
172
+ msg = f"Account '{name}' is missing credentials. Use `/login` or `{edit_cmd}`."
173
+ self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
174
+ return False, msg
175
+
176
+ ok, error_reason = check_connection_with_reason(api_url, api_key, abort_on_error=False)
177
+ if not ok:
178
+ code, detail = self._parse_error_reason(error_reason)
179
+ if code == "connection_failed":
180
+ msg = f"Switch aborted: cannot reach {api_url}. Check URL or network."
181
+ elif code == "api_failed":
182
+ msg = f"Switch aborted: API error for '{name}'. Check credentials."
183
+ else:
184
+ detail_suffix = f": {detail}" if detail else ""
185
+ msg = f"Switch aborted: {code or 'Validation failed'}{detail_suffix}"
186
+ self.console.print(f"[{WARNING_STYLE}]{msg}[/]")
187
+ return False, msg
188
+
189
+ try:
190
+ store.set_active_account(name)
191
+ masked_key = mask_api_key_display(api_key)
192
+ self.console.print(
193
+ AIPPanel(
194
+ f"[{SUCCESS_STYLE}]Active account ➜ {name}[/]\nAPI URL: {api_url}\nKey: {masked_key}",
195
+ title="✅ Account Switched",
196
+ border_style=SUCCESS_STYLE,
197
+ )
198
+ )
199
+ return True, f"Switched to '{name}'."
200
+ except AccountStoreError as exc:
201
+ msg = f"Failed to set active account: {exc}"
202
+ self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
203
+ return False, msg
204
+ except Exception as exc: # NOSONAR(S1045) - catch-all needed for unexpected errors
205
+ msg = f"Unexpected error while switching to '{name}': {exc}"
206
+ self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
207
+ return False, msg
208
+
209
+ @staticmethod
210
+ def _parse_error_reason(reason: str | None) -> tuple[str, str]:
211
+ """Parse error reason into (code, detail) to avoid fragile substring checks."""
212
+ if not reason:
213
+ return "", ""
214
+ if ":" in reason:
215
+ code, _, detail = reason.partition(":")
216
+ return code.strip(), detail.strip()
217
+ return reason.strip(), ""
@@ -0,0 +1,19 @@
1
+ """Shared helpers for palette `/accounts`.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ def build_account_status_string(row: dict[str, Any], *, use_markup: bool = False) -> str:
13
+ """Build status string for an account row (active/env-lock)."""
14
+ status_parts: list[str] = []
15
+ if row.get("active"):
16
+ status_parts.append("[bold green]● active[/]" if use_markup else "● active")
17
+ if row.get("env_lock"):
18
+ status_parts.append("[yellow]🔒 env-lock[/]" if use_markup else "🔒 env-lock")
19
+ return " · ".join(status_parts)
@@ -34,10 +34,12 @@ from glaip_sdk.branding import (
34
34
  AIPBranding,
35
35
  )
36
36
  from glaip_sdk.cli.auth import resolve_api_url_from_context
37
+ from glaip_sdk.cli.account_store import get_account_store
37
38
  from glaip_sdk.cli.commands import transcripts as transcripts_cmd
38
- from glaip_sdk.cli.commands.configure import configure_command, load_config
39
+ from glaip_sdk.cli.commands.configure import _configure_interactive, load_config
39
40
  from glaip_sdk.cli.commands.update import update_command
40
41
  from glaip_sdk.cli.hints import format_command_hint
42
+ from glaip_sdk.cli.slash.accounts_controller import AccountsController
41
43
  from glaip_sdk.cli.slash.agent_session import AgentRunSession
42
44
  from glaip_sdk.cli.slash.prompt import (
43
45
  FormattedText,
@@ -98,6 +100,12 @@ NEW_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
98
100
 
99
101
 
100
102
  DEFAULT_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
103
+ {
104
+ "cli": None,
105
+ "slash": "accounts",
106
+ "description": "Switch account profile",
107
+ "priority": 5,
108
+ },
101
109
  {
102
110
  "cli": "status",
103
111
  "slash": "status",
@@ -452,7 +460,8 @@ class SlashSession:
452
460
  """
453
461
  self.console.print(f"[{ACCENT_STYLE}]Launching configuration wizard...[/]")
454
462
  try:
455
- self.ctx.invoke(configure_command)
463
+ # Use the modern account-aware wizard directly (bypasses legacy config gating)
464
+ _configure_interactive(account_name=None)
456
465
  self._config_cache = None
457
466
  if self._suppress_login_layout:
458
467
  self._welcome_rendered = False
@@ -655,6 +664,11 @@ class SlashSession:
655
664
  controller = RemoteRunsController(self)
656
665
  return controller.handle_runs_command(args)
657
666
 
667
+ def _cmd_accounts(self, args: list[str], _invoked_from_agent: bool) -> bool:
668
+ """Handle the /accounts command for listing and switching accounts."""
669
+ controller = AccountsController(self)
670
+ return controller.handle_accounts_command(args)
671
+
658
672
  def _cmd_agents(self, args: list[str], _invoked_from_agent: bool) -> bool:
659
673
  """Handle the /agents command.
660
674
 
@@ -742,6 +756,7 @@ class SlashSession:
742
756
  hints.append((f"/agents {agent_id}", f"Reopen {agent_label}"))
743
757
  hints.extend(
744
758
  [
759
+ ("/accounts", "Switch account"),
745
760
  (self.AGENTS_COMMAND, "Browse agents"),
746
761
  (self.STATUS_COMMAND, "Check connection"),
747
762
  ]
@@ -796,6 +811,13 @@ class SlashSession:
796
811
  handler=SlashSession._cmd_status,
797
812
  )
798
813
  )
814
+ self._register(
815
+ SlashCommand(
816
+ name="accounts",
817
+ help="✨ NEW · Browse and switch stored accounts (Textual with Rich fallback).",
818
+ handler=SlashSession._cmd_accounts,
819
+ )
820
+ )
799
821
  self._register(
800
822
  SlashCommand(
801
823
  name="transcripts",
@@ -1262,13 +1284,26 @@ class SlashSession:
1262
1284
  """Render the main AIP environment header."""
1263
1285
  config = self._load_config()
1264
1286
 
1287
+ account_name, account_host, env_lock = self._get_account_context()
1265
1288
  api_url = self._get_api_url(config)
1266
- status = "Configured" if config.get("api_key") else "Not configured"
1267
1289
 
1268
- segments = [
1269
- f"[dim]Base URL[/dim] • {api_url or 'Not configured'}",
1270
- f"[dim]Credentials[/dim] • {status}",
1271
- ]
1290
+ host_display = account_host or "Not configured"
1291
+ account_segment = f"[dim]Account[/dim] • {account_name} ({host_display})"
1292
+ if env_lock:
1293
+ account_segment += " 🔒"
1294
+
1295
+ segments = [account_segment]
1296
+
1297
+ if api_url:
1298
+ base_label = "[dim]Base URL[/dim]"
1299
+ if env_lock:
1300
+ base_label = "[dim]Base URL (env)[/dim]"
1301
+ # Always show Base URL when env-lock is active to reveal overrides
1302
+ if env_lock or api_url != account_host:
1303
+ segments.append(f"{base_label} • {api_url}")
1304
+ elif not api_url:
1305
+ segments.append("[dim]Base URL[/dim] • Not configured")
1306
+
1272
1307
  agent_info = self._build_agent_status_line(active_agent)
1273
1308
  if agent_info:
1274
1309
  segments.append(agent_info)
@@ -1295,6 +1330,21 @@ class SlashSession:
1295
1330
  """Get the API URL from context or account store (CLI/palette ignores env credentials)."""
1296
1331
  return resolve_api_url_from_context(self.ctx)
1297
1332
 
1333
+ def _get_account_context(self) -> tuple[str, str, bool]:
1334
+ """Return active account name, host, and env-lock flag."""
1335
+ try:
1336
+ store = get_account_store()
1337
+ active = store.get_active_account() or "default"
1338
+ account = store.get_account(active) if hasattr(store, "get_account") else None
1339
+ host = ""
1340
+ if account:
1341
+ host = account.get("api_url", "")
1342
+ env_lock = bool(os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"))
1343
+ return active, host, env_lock
1344
+ except Exception:
1345
+ env_lock = bool(os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"))
1346
+ return "default", "", env_lock
1347
+
1298
1348
  def _build_agent_status_line(self, active_agent: Any | None) -> str | None:
1299
1349
  """Return a short status line about the active or recent agent."""
1300
1350
  if active_agent is not None:
@@ -0,0 +1,54 @@
1
+ /* Styling for /accounts Textual UI
2
+ *
3
+ * Keep layout compact: filter sits tight above the table; header shows active account.
4
+ */
5
+
6
+ #header-info {
7
+ padding: 0 1 0 1;
8
+ margin: 0;
9
+ height: 1;
10
+ }
11
+
12
+ #env-lock {
13
+ padding: 0 1 0 1;
14
+ color: yellow;
15
+ height: 1;
16
+ }
17
+
18
+ #filter-container {
19
+ padding: 0 1 0 1;
20
+ margin: 0 0 0 0;
21
+ height: auto;
22
+ }
23
+
24
+ #filter-label {
25
+ height: 1;
26
+ }
27
+
28
+ #filter-input {
29
+ padding: 0 1 0 1;
30
+ margin: 0;
31
+ height: 3;
32
+ }
33
+
34
+ #accounts-table {
35
+ padding: 0 1 0 1;
36
+ margin: 0 0 0 0;
37
+ height: 1fr;
38
+ }
39
+
40
+ #status-bar {
41
+ height: 3;
42
+ padding: 0 1;
43
+ }
44
+
45
+ #accounts-loading {
46
+ width: 8;
47
+ display: none;
48
+ }
49
+
50
+ #status {
51
+ padding: 0 1 0 1;
52
+ margin: 0;
53
+ color: cyan;
54
+ }