feat(tui-v3): implement modular TUI architecture and CLI selector

Scaffolded TUI v3 from scratch with modular directory structure. Created
RibbonHeader, ProfileTree, EntryDataTable, and BoardContainer widgets.
Implemented full-screen SettingsScreen and MaximizedInspectorScreen.
Updated CLI with unified '--tui [legacy|v2|v3]' option and 'tui3' command.
Reused existing service hooks for zero-logic migration.
This commit is contained in:
thePR0M3TH3AN committed 2026-03-02 23:01:00 -05:00
1 parent ac4b3b468b
commit 18a637d3a2
9 files changed
+864 -3

No files matched your search

+48 -3
View File
@@ -13,6 +13,7 @@ from .common import _get_entry_service, _get_services
from seedpass.core.errors import SeedPassError
from constants import GUI_BACKEND_CONFIG
from seedpass.tui_v2.app import check_tui2_runtime, launch_tui2
from seedpass.tui_v3 import launch_tui3
app = typer.Typer(
help=(
@@ -50,6 +51,12 @@ legacy_tui_option = typer.Option(
help="Use legacy interactive TUI (temporary fallback during TUI v2 cutover)",
)
tui_version_option = typer.Option(
"v2",
"--tui",
help="Select TUI version: legacy, v2, v3",
)
# Sub command groups
from . import entry, vault, nostr, config, fingerprint, util, api, agent, semantic
from .capabilities import register_capabilities_command
@@ -131,6 +138,7 @@ def main(
no_clipboard: bool = no_clipboard_option,
deterministic_totp: bool = deterministic_totp_option,
legacy_tui: bool = legacy_tui_option,
tui_version: str = tui_version_option,
) -> None:
"""SeedPass CLI entry point.
@@ -143,19 +151,34 @@ def main(
"no_clipboard": no_clipboard,
"deterministic_totp": deterministic_totp,
"legacy_tui": legacy_tui,
"tui_version": tui_version,
}
if ctx.invoked_subcommand is None:
if legacy_tui:
if legacy_tui or tui_version == "legacy":
raise _launch_legacy_tui(fingerprint=fingerprint)
services, preflight_error = _prime_tui2_service(ctx)
if preflight_error is not None:
typer.echo(
"TUI v2 preflight failed; falling back to legacy TUI. "
f"TUI {tui_version} preflight failed; falling back to legacy TUI. "
f"Details: {preflight_error}"
)
raise _launch_legacy_tui(fingerprint=fingerprint)
if tui_version == "v3":
launch_tui3(
fingerprint=fingerprint,
entry_service_factory=lambda: services["entry"],
profile_service_factory=lambda: services["profile"],
config_service_factory=lambda: services["config"],
nostr_service_factory=lambda: services["nostr"],
sync_service_factory=lambda: services["sync"],
utility_service_factory=lambda: services["utility"],
vault_service_factory=lambda: services["vault"],
semantic_service_factory=lambda: services["semantic"],
)
return
launched = launch_tui2(
fingerprint=fingerprint,
entry_service_factory=lambda: services["entry"],
@@ -171,7 +194,7 @@ def main(
return
typer.echo(
"TUI v2 runtime unavailable; falling back to legacy TUI. "
f"TUI {tui_version} runtime unavailable; falling back to legacy TUI. "
"Run `seedpass tui2 --check` for diagnostics."
)
raise _launch_legacy_tui(fingerprint=fingerprint)
@@ -247,6 +270,28 @@ def tui2(
raise typer.Exit(1)
@app.command("tui3")
def tui3(ctx: typer.Context) -> None:
"""Launch the experimental TUI v3 scratch architecture."""
fingerprint = (ctx.obj or {}).get("fingerprint")
services, preflight_error = _prime_tui2_service(ctx)
if preflight_error is not None:
typer.echo(f"TUI v3 preflight failed: {preflight_error}", err=True)
raise typer.Exit(1)
launch_tui3(
fingerprint=fingerprint,
entry_service_factory=lambda: services["entry"],
profile_service_factory=lambda: services["profile"],
config_service_factory=lambda: services["config"],
nostr_service_factory=lambda: services["nostr"],
sync_service_factory=lambda: services["sync"],
utility_service_factory=lambda: services["utility"],
vault_service_factory=lambda: services["vault"],
semantic_service_factory=lambda: services["semantic"],
)
@app.command("legacy")
def legacy_tui(ctx: typer.Context) -> None:
"""Launch the legacy interactive TUI explicitly."""
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import importlib.util
from typing import Any
def check_tui3_runtime() -> dict[str, Any]:
"""Return runtime capability diagnostics for TUI v3."""
textual_available = importlib.util.find_spec("textual") is not None
return {
"status": "ok" if textual_available else "unavailable",
"backend": "textual-v3",
"textual_available": textual_available,
"message": (
"Textual runtime available."
if textual_available
else "Textual is not installed. Install `textual` to run tui3."
),
}
def launch_tui3(
*,
fingerprint: str | None = None,
entry_service_factory: Any | None = None,
profile_service_factory: Any | None = None,
config_service_factory: Any | None = None,
nostr_service_factory: Any | None = None,
sync_service_factory: Any | None = None,
utility_service_factory: Any | None = None,
vault_service_factory: Any | None = None,
semantic_service_factory: Any | None = None,
app_hook: Any | None = None,
) -> bool:
"""Launch TUI v3 from scratch architecture."""
runtime = check_tui3_runtime()
if not runtime["textual_available"]:
return False
from .app import SeedPassTuiV3
app = SeedPassTuiV3(
fingerprint=fingerprint,
entry_service_factory=entry_service_factory,
profile_service_factory=profile_service_factory,
config_service_factory=config_service_factory,
nostr_service_factory=nostr_service_factory,
sync_service_factory=sync_service_factory,
utility_service_factory=utility_service_factory,
vault_service_factory=vault_service_factory,
semantic_service_factory=semantic_service_factory,
)
if callable(app_hook):
app_hook(app)
return True
app.run()
return True
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import time
from typing import Any, Callable
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.reactive import reactive
from textual.screen import Screen
from textual.widgets import Header, Footer, Static
from textual.containers import Horizontal, Vertical
from .widgets.header import RibbonHeader
from .widgets.sidebar import SidebarContainer
from .widgets.grid import GridContainer
from .widgets.inspector import BoardContainer
from .screens.settings import SettingsScreen
from .screens.inspector import MaximizedInspectorScreen
class MainScreen(Screen):
def compose(self) -> ComposeResult:
yield Static("SeedPass ◈ UI v3", id="brand-strip")
yield RibbonHeader(id="top-ribbon")
with Vertical(id="body"):
with Horizontal(id="top-work"):
yield SidebarContainer(id="left")
with Vertical(id="center"):
yield GridContainer(id="grid-work")
with Vertical(id="right"):
yield Static("Inspector Board", id="inspector-heading")
yield BoardContainer(id="board-container")
yield Static("Ready", id="status")
yield Footer()
class SeedPassTuiV3(App[None]):
"""
SeedPass TUI v3 - Rebuilt from scratch for modularity and mockup fidelity.
"""
CSS = """
#brand-strip {
background: #0b0f13;
color: #58f29d;
text-style: bold;
border: solid #2abf75;
margin: 0 1;
padding: 0 1;
height: 3;
content-align: center middle;
}
#body { height: 1fr; margin: 0 1; }
#top-work { height: 6fr; }
#left { width: 31; border: solid #1a3024; background: #0d1114; }
#center { width: 1fr; border: solid #1a3024; background: #0d1114; margin-left: 1; }
#right { height: 5fr; border: heavy #3ce79c; background: #0d1114; margin-top: 1; }
#status {
height: 3;
padding: 0 1;
border: heavy #58f29d;
background: #11191f;
color: #e4fff2;
margin: 0 1;
}
#sidebar-placeholder, #grid-placeholder, #inspector-placeholder {
height: 1fr;
content-align: center middle;
color: #3ce79c;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit", show=True),
Binding("ctrl+p", "open_palette", "Palette", show=True),
Binding("shift+s", "toggle_settings", "Settings", show=True),
Binding("z", "maximize_inspector", "Maximize", show=True),
]
# Shared Reactive State
active_fingerprint = reactive("")
selected_entry_id = reactive[int | None](None)
session_locked = reactive(True)
def __init__(
self,
fingerprint: str | None = None,
entry_service_factory: Callable | None = None,
profile_service_factory: Callable | None = None,
config_service_factory: Callable | None = None,
nostr_service_factory: Callable | None = None,
sync_service_factory: Callable | None = None,
utility_service_factory: Callable | None = None,
vault_service_factory: Callable | None = None,
semantic_service_factory: Callable | None = None,
) -> None:
super().__init__()
# Store factories
self.factories = {
"entry": entry_service_factory,
"profile": profile_service_factory,
"config": config_service_factory,
"nostr": nostr_service_factory,
"sync": sync_service_factory,
"utility": utility_service_factory,
"vault": vault_service_factory,
"semantic": semantic_service_factory,
}
# Initialized services
self.services: dict[str, Any] = {}
self._initial_fingerprint = fingerprint
def on_mount(self) -> None:
"""Initialize services and push the main screen."""
for name, factory in self.factories.items():
if factory:
try:
self.services[name] = factory()
except Exception as e:
self.log(f"Failed to init service {name}: {e}")
self.active_fingerprint = self._initial_fingerprint or ""
self.push_screen(MainScreen())
def watch_active_fingerprint(self, old_fp: str, new_fp: str) -> None:
"""Refresh components when the profile changes."""
if not new_fp:
return
# Notify sidebar and grid to refresh
try:
self.query_one("#profile-tree")._refresh_tree()
self.query_one("#entry-data-table")._refresh_data()
except Exception:
pass
def watch_selected_entry_id(self, old_id: int | None, new_id: int | None) -> None:
"""Update inspectors when an entry is selected."""
try:
self.query_one("#board-container").update_entry(new_id)
except Exception:
pass
def action_refresh(self) -> None:
"""Force a global UI refresh."""
self.query_one("#profile-tree")._refresh_tree()
self.query_one("#entry-data-table")._refresh_data()
self.notify("UI Refreshed")
def action_open_palette(self) -> None:
# Placeholder for unified command palette
self.notify("Palette coming soon in v3")
def action_toggle_settings(self) -> None:
"""Push the full-screen settings screen."""
self.push_screen(SettingsScreen())
def action_maximize_inspector(self) -> None:
"""Push the full-screen maximized entry detail screen."""
if self.selected_entry_id is None:
self.notify("Select an entry to maximize", severity="warning")
return
self.push_screen(MaximizedInspectorScreen())
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from typing import Any
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, Footer
from textual.containers import Vertical, Horizontal
class MaximizedInspectorScreen(Screen):
"""
Full-screen detailed view for a single entry.
Optimized for long documents, SSH keys, or PGP blocks.
"""
BINDINGS = [
("escape", "app.pop_screen", "Back to Vault"),
("v", "reveal", "Reveal Secret"),
("g", "qr", "Show QR"),
]
DEFAULT_CSS = """
MaximizedInspectorScreen {
background: #080a0c;
}
#max-inspector-title {
background: #0b0f13;
color: #58f29d;
text-style: bold;
text-align: center;
height: 3;
border: double #2abf75;
padding: 0 1;
margin: 1 2;
}
#max-inspector-container {
height: 1fr;
margin: 0 2;
border: solid #1a3024;
padding: 1;
overflow: auto;
}
#max-inspector-footer {
height: 3;
background: #11191f;
color: #daf2e5;
text-align: center;
border: double #2abf75;
padding: 0 1;
margin: 1 2;
}
"""
def compose(self) -> ComposeResult:
yield Static("SeedPass ◈ Detailed Inspection", id="max-inspector-title")
with Vertical(id="max-inspector-container"):
yield Static("Loading details...", id="max-inspector-content")
yield Static("ESC: Back | v: Reveal | g: QR", id="max-inspector-footer")
def on_mount(self) -> None:
self._refresh_detail()
def _refresh_detail(self) -> None:
app = self.app
eid = app.selected_entry_id
if eid is None:
self.query_one("#max-inspector-content", Static).update("No entry selected.")
return
if "entry" not in app.services:
return
entry = app.services["entry"].retrieve_entry(eid)
if not entry:
return
# Simple render for the full screen view
label = entry.get('label', 'Unknown')
kind = entry.get('kind', 'Unknown')
# Build a large detail view
header = f"[b]ENTRY #{eid} - {label.upper()} ({kind})[/b]
"
divider = "=" * 60 + "
"
# Meta info
meta_lines = [
f"Username : {entry.get('username', '(none)')}",
f"URL : {entry.get('url', '(none)')}",
f"Created : {entry.get('created_at', '(unknown)')}",
f"Modified : {entry.get('modified_at', '(unknown)')}",
]
# Content (if note/doc)
content = entry.get('content', '')
content_block = f"
[b]CONTENT / NOTES:[/b]
{divider}{content}
" if content else ""
final_text = header + divider + "
".join(meta_lines) + content_block
self.query_one("#max-inspector-content", Static).update(final_text)
def action_reveal(self) -> None:
self.app.notify("Reveal triggered (Coming Soon in v3)")
def action_qr(self) -> None:
self.app.notify("QR triggered (Coming Soon in v3)")
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
from typing import Any
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, Footer
from textual.containers import Vertical
class SettingsScreen(Screen):
"""
A dedicated, full-screen settings management interface.
Categorizes configuration into Security, Storage, and Connectivity.
"""
BINDINGS = [
("escape", "app.pop_screen", "Back to Vault"),
("r", "refresh", "Refresh Settings"),
]
DEFAULT_CSS = """
SettingsScreen {
background: #080a0c;
}
#settings-title {
background: #0b0f13;
color: #58f29d;
text-style: bold;
text-align: center;
height: 3;
border: double #2abf75;
padding: 0 1;
margin: 1 2;
}
#settings-container {
height: 1fr;
margin: 0 2;
border: solid #1a3024;
padding: 1;
overflow: auto;
}
#settings-footer {
height: 3;
background: #11191f;
color: #daf2e5;
text-align: center;
border: double #2abf75;
padding: 0 1;
margin: 1 2;
}
"""
def compose(self) -> ComposeResult:
yield Static("SeedPass ◈ System Configuration", id="settings-title")
with Vertical(id="settings-container"):
yield Static("Loading configuration...", id="settings-content")
yield Static("ESC: Exit Settings | R: Reload | Use Palette (Ctrl+P) to change values", id="settings-footer")
def on_mount(self) -> None:
self.action_refresh()
def action_refresh(self) -> None:
"""Fetch live settings and render them into the content panel."""
app = self.app
if "config" not in app.services:
self.query_one("#settings-content", Static).update("[red]Config Service Offline[/red]")
return
service = app.services["config"]
def get_val(key, default=""):
try:
val = service.get(key)
return val if val is not None else default
except Exception:
return "[red](error)[/red]"
# Categorized Rows
security_rows = [
f"Secret Mode : {get_val('secret_mode_enabled', False)} [dim](setting-secret on|off)[/dim]",
f"Quick Unlock : {get_val('quick_unlock', False)} [dim](setting-quick-unlock on|off)[/dim]",
f"KDF Iterations : {get_val('kdf_iterations', 100000)} [dim](setting-kdf-iterations <n>)[/dim]",
f"KDF Mode : {get_val('kdf_mode', 'argon2id')} [dim](setting-kdf-mode <mode>)[/dim]",
f"Lock Timeout : {get_val('inactivity_timeout', 300)}s [dim](setting-timeout <s>)[/dim]",
]
backup_rows = [
f"Backup Path : {get_val('additional_backup_path', '(none)')} [dim](db-export <path>)[/dim]",
f"Backup Interval: {get_val('backup_interval', 3600)}s",
]
nostr_rows = [
f"Sync Mode : {get_val('semantic_search_mode', 'keyword')} [dim](search-mode ...)[/dim]",
f"Relays : {len(get_val('relays', []))} connected [dim](relay-list)[/dim]",
]
# Use the app's card rendering utility if available, or fallback
# In v3, we keep card logic in a central place or widget.
# For now, we'll implement a clean local version.
def render_card(title: str, rows: list[str]) -> str:
width = 60
top = f"┌─ {title} " + "─" * (width - len(title) - 4) + "┐"
lines = [top]
for row in rows:
lines.append(f"│ {row:<{width-4}} │")
lines.append("└" + "─" * (width - 2) + "┘")
return "
".join(lines)
rendered_lines = [
render_card("SECURITY & AUTHENTICATION", security_rows),
"",
render_card("STORAGE & BACKUP POLICY", backup_rows),
"",
render_card("CONNECTIVITY & NOSTR SYNC", nostr_rows),
]
self.query_one("#settings-content", Static).update("
".join(rendered_lines))
self.app.notify("Settings reloaded")
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from typing import Any
from textual.app import ComposeResult
from textual.widgets import Static, DataTable
from textual.reactive import reactive
class GridMetrics(Static):
"""
Displays pagination, row counts, and search mode state.
Matches the 'Entry Grid | Pg X/X ...' line in the mockups.
"""
DEFAULT_CSS = """
GridMetrics {
height: 1;
background: #0b0f13;
color: #daf2e5;
padding: 0 1;
}
"""
def render(self) -> str:
# These would eventually come from App reactives
pg = "1/1"
rows = "0/0"
density = "compact"
# Search mode highlighting
search = "[b](KEYWORD)[/b] hybrid semantic"
return f"Entry Grid | Pg {pg} Rows {rows} Density {density} Search {search}"
class EntryDataTable(DataTable):
"""
High-fidelity data table for entry browsing.
"""
def on_mount(self) -> None:
self.cursor_type = "row"
self.add_columns(
"Sel", "Id", "Entry#", "Label", "Kind", "Meta", "Arch"
)
self._refresh_data()
def _refresh_data(self) -> None:
self.clear()
app = self.app
if "entry" not in app.services:
return
# Fetch real data from service
entries = app.services["entry"].search_entries("")
for i, (eid, label, user, url, arch, kind) in enumerate(entries):
marker = "▶" if eid == app.selected_entry_id else " "
arch_status = "🔒" if arch else " "
# 'Meta' column logic from mockup
meta = user or url or ""
self.add_row(
marker,
str(i+1),
f"#{eid}",
label,
kind.value,
meta,
arch_status,
key=str(eid)
)
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle entry selection."""
if event.row_key.value:
self.app.selected_entry_id = int(event.row_key.value)
self.app.notify(f"Selected Entry #{event.row_key.value}")
class GridContainer(Static):
"""
The central workspace container.
"""
def compose(self) -> ComposeResult:
yield GridMetrics()
# Header divider
yield Static("-" * 120, id="grid-divider")
yield EntryDataTable(id="entry-data-table")
DEFAULT_CSS = """
GridContainer {
height: 1fr;
background: #0d1114;
}
#grid-divider {
color: #1a3024;
height: 1;
padding: 0 1;
}
#entry-data-table {
background: transparent;
color: #97b8a6;
border: none;
height: 1fr;
}
#entry-data-table > .datatable--header {
background: #0b0f13;
color: #58f29d;
text-style: bold;
}
#entry-data-table > .datatable--cursor {
background: #122019;
color: #58f29d;
}
"""
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from textual.app import ComposeResult
from textual.widgets import Static
from textual.reactive import reactive
class RibbonHeader(Static):
"""
A high-density status ribbon that displays active profile,
session state, and vault metrics.
Matches the 'UI Board' mockup top ribbon layout.
"""
DEFAULT_CSS = """
RibbonHeader {
height: 3;
background: #10181f;
color: #daf2e5;
border: heavy #2abf75;
padding: 0 1;
margin: 0 1;
text-style: bold;
}
"""
def render(self) -> str:
app = self.app
fp = app.active_fingerprint or "No Profile"
lock_icon = "🔒" if app.session_locked else "🔓"
status = "LOCKED" if app.session_locked else "UNLOCKED"
# In a real app, we'd fetch stats from the service.
# For the shell, we'll use placeholders that look like the mockup.
stats = "Entries: 0 | PWD: 0 | 2FA: 0 | Keys: 0"
sync = "Sync: Never"
# Layout: [Icon] Status | Fingerprint | Stats | Sync
left = f"{lock_icon} {status} | Seed: {fp}"
right = f"{stats} | {sync}"
# Calculate spacing to push sync to the right
available_width = self.size.width - len(left) - len(right) - 4
spacer = " " * max(1, available_width)
return f"{left}{spacer}{right}"
def on_mount(self) -> None:
# Refresh the header whenever important app state changes
self.watch(self.app, "active_fingerprint", self.refresh)
self.watch(self.app, "session_locked", self.refresh)
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from typing import Any
from textual.app import ComposeResult
from textual.widgets import Static, Label
from textual.containers import Vertical, Horizontal
from textual.reactive import reactive
class BaseBoard(Static):
"""Base class for all specialized entry boards."""
entry_data = reactive[dict[str, Any]]({})
def _render_card(self, title: str, rows: list[str]) -> str:
"""Utility to render ASCII cards matching mockup style."""
width = 40
top = f"┌─ {title} " + "─" * (width - len(title) - 4) + "┐"
bottom = "└" + "─" * (width - 2) + "┘"
lines = [top]
for row in rows:
lines.append(f"│ {row:<{width-4}} │")
lines.append(bottom)
return "
".join(lines)
class IdleBoard(Static):
"""Shown when no entry is selected."""
def render(self) -> str:
return "
[b]Inspector Idle[/b]
Select an entry from the grid
to view details."
class PasswordBoard(BaseBoard):
"""Matches 'Password Board.png' mockup."""
def render(self) -> str:
d = self.entry_data
cred_rows = [
f"Label : {d.get('label', '')}",
f"Username : {d.get('username', '')}",
f"URL : {d.get('url', '')}",
"Password : [HIDDEN]",
]
action_rows = [
"▣ Reveal (v) ▣ QR (g)",
"▣ Edit (e) ▣ Archive (a)",
]
return "
".join([
self._render_card("Credentials", cred_rows),
"",
self._render_card("Quick Actions", action_rows)
])
class NoteBoard(BaseBoard):
"""Matches 'Note Board.png' mockup."""
def render(self) -> str:
d = self.entry_data
content = d.get('content', '')
# Truncate for preview
preview = (content[:200] + '...') if len(content) > 200 else content
content_rows = preview.splitlines()[:10]
action_rows = [
"▣ Edit Doc (e) ▣ Export",
"▣ Archive (a)",
]
return "
".join([
self._render_card("Content", content_rows),
"",
self._render_card("Quick Actions", action_rows)
])
class BoardContainer(Vertical):
"""
Reactive container that swaps specialized boards based on entry kind.
"""
DEFAULT_CSS = """
BoardContainer {
height: 1fr;
padding: 1;
content-align: center top;
}
"""
def on_mount(self) -> None:
self._show_board("idle")
def update_entry(self, entry_id: int | None) -> None:
if entry_id is None:
self._show_board("idle")
return
app = self.app
if "entry" not in app.services:
return
entry = app.services["entry"].retrieve_entry(entry_id)
if not entry:
self._show_board("idle")
return
kind = str(entry.get("kind") or entry.get("type") or "").lower()
# Select appropriate board
if kind in {"password", "stored_password"}:
board = self._show_board("password")
board.entry_data = entry
elif kind in {"document", "note"}:
board = self._show_board("note")
board.entry_data = entry
else:
# Fallback for now
board = self._show_board("password")
board.entry_data = entry
def _show_board(self, board_type: str) -> Any:
self.remove_children()
if board_type == "idle":
new_board = IdleBoard()
elif board_type == "password":
new_board = PasswordBoard()
elif board_type == "note":
new_board = NoteBoard()
else:
new_board = IdleBoard()
self.mount(new_board)
return new_board
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
from typing import Any
from textual.app import ComposeResult
from textual.widgets import Tree, Static
from textual.widgets.tree import TreeNode
class ProfileTree(Tree):
"""
A hierarchical sidebar that manages Seed Profiles, Managed Accounts, and Agents.
Structure:
Root (Hidden)
├── Fingerprint (Parent Seed)
│ ├── Managed Account 1
│ │ └── Agent 1
│ └── Managed Account 2
└── External Fingerprint
"""
def on_mount(self) -> None:
self.root.expand()
self._refresh_tree()
def _refresh_tree(self) -> None:
self.clear()
app = self.app
if "profile" not in app.services:
self.root.add_leaf("Profile Service Offline")
return
service = app.services["profile"]
profiles = service.list_profiles()
# In v3, we will properly fetch nested accounts.
# For this initial scaffold, we populate the top-level fingerprints.
for fp in profiles:
icon = "■ " if fp == app.active_fingerprint else "□ "
label = f"{icon}{fp[:12]}..." if len(fp) > 12 else f"{icon}{fp}"
node = self.root.add(label, data=fp, expand=True)
# Placeholder for child accounts - in a real run,
# we would query the service for sub-entries of this FP.
# node.add_leaf(" ├─ Managed Account 1")
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
"""Handle profile selection."""
fp = event.node.data
if fp and isinstance(fp, str):
self.app.active_fingerprint = fp
self.app.notify(f"Switched to profile: {fp[:8]}")
class SidebarContainer(Static):
"""Container for the sidebar tree and toggle."""
def compose(self) -> ComposeResult:
yield Static("PROFILES", id="sidebar-title")
yield ProfileTree("Profiles", id="profile-tree")
DEFAULT_CSS = """
SidebarContainer {
width: 31;
background: #0d1114;
border-right: solid #1a3024;
}
#sidebar-title {
background: #1a3024;
color: #58f29d;
text-style: bold;
padding: 0 1;
height: 1;
}
#profile-tree {
background: transparent;
color: #97b8a6;
border: none;
padding: 0;
}
#profile-tree > .tree--guides {
color: #1a3024;
}
#profile-tree > .tree--cursor {
background: #122019;
color: #58f29d;
text-style: bold;
}
"""