localcoder 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.
localcoder/setup.py ADDED
@@ -0,0 +1,321 @@
1
+ """Interactive setup wizard — runs on first launch or `localcoder --setup`."""
2
+ import json, os, sys
3
+ from pathlib import Path
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+ from localcoder.backends import (
11
+ BACKENDS, MODELS, CONFIG_DIR, discover_all, get_system_ram_gb,
12
+ check_backend_installed, install_backend, download_model_hf,
13
+ download_model_ollama, find_model_file, start_llama_server,
14
+ start_ollama_serve, check_backend_running,
15
+ )
16
+
17
+ console = Console()
18
+ CONFIG_FILE = CONFIG_DIR / "config.json"
19
+
20
+
21
+ def load_config():
22
+ if CONFIG_FILE.exists():
23
+ return json.loads(CONFIG_FILE.read_text())
24
+ return {}
25
+
26
+
27
+ def save_config(cfg):
28
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
29
+ CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
30
+
31
+
32
+ def wizard():
33
+ """Interactive first-run setup wizard."""
34
+ console.print()
35
+ title = Text()
36
+ title.append("◆ ", style="bold magenta")
37
+ title.append("localcoder setup", style="bold white")
38
+ console.print(Panel(
39
+ "[bold]Welcome! Let's get your local AI coding agent running.[/]\n"
40
+ "[dim]This wizard will install a backend, download a model, and start serving.[/]",
41
+ title=title, title_align="left",
42
+ border_style="magenta", padding=(1, 2),
43
+ ))
44
+
45
+ ram = get_system_ram_gb()
46
+ console.print(f"\n [dim]System RAM:[/] [bold]{ram} GB[/]")
47
+
48
+ # ── Step 1: Detect backends ──
49
+ console.print(f"\n [bold magenta]Step 1:[/] Checking backends...\n")
50
+ discovery = discover_all()
51
+
52
+ table = Table(show_header=True, header_style="bold cyan", padding=(0, 2))
53
+ table.add_column("Backend")
54
+ table.add_column("Installed")
55
+ table.add_column("Running")
56
+ table.add_column("Models")
57
+
58
+ for d in discovery:
59
+ installed = "[green]✓[/]" if d["installed"] else "[red]✗[/]"
60
+ running = f"[green]:{d['port']}[/]" if d["running"] else "[dim]—[/]"
61
+ models = ", ".join(d["models"][:3]) if d["models"] else "[dim]none[/]"
62
+ table.add_row(d["name"], installed, running, models)
63
+
64
+ console.print(table)
65
+
66
+ # ── Step 2: Install backend if needed ──
67
+ any_installed = any(d["installed"] for d in discovery)
68
+ if not any_installed:
69
+ console.print(f"\n [bold magenta]Step 2:[/] No backend found. Install one:\n")
70
+ console.print(f" [bold]1.[/] llama.cpp via Unsloth [dim](recommended for 26B, best speed)[/]")
71
+ console.print(f" [bold]2.[/] Ollama [dim](easiest, good for E4B/E2B)[/]")
72
+ console.print(f" [bold]3.[/] Both")
73
+ console.print()
74
+
75
+ try:
76
+ choice = input(" Choose (1/2/3): ").strip()
77
+ except (EOFError, KeyboardInterrupt):
78
+ return None
79
+
80
+ if choice in ("1", "3"):
81
+ install_backend("llamacpp")
82
+ if choice in ("2", "3"):
83
+ install_backend("ollama")
84
+
85
+ # Re-discover
86
+ discovery = discover_all()
87
+
88
+ # ── Step 3: Choose model ──
89
+ console.print(f"\n [bold magenta]Step 3:[/] Choose a model:\n")
90
+
91
+ recommended = []
92
+ for mid, m in MODELS.items():
93
+ fits = ram >= m["ram_required"]
94
+ rec = " [green](recommended)[/]" if fits and mid == "gemma4-26b" and ram >= 24 else ""
95
+ if not fits:
96
+ rec = " [red](needs {m['ram_required']}GB+)[/]"
97
+ recommended.append((mid, m, fits, rec))
98
+
99
+ for i, (mid, m, fits, rec) in enumerate(recommended):
100
+ style = "bold" if fits else "dim"
101
+ console.print(f" [{style}]{i+1}. {m['name']}[/{style}] [dim]({m['size_gb']}GB, {m['description']})[/]{rec}")
102
+
103
+ console.print()
104
+ try:
105
+ choice = input(" Choose model (1-4): ").strip()
106
+ except (EOFError, KeyboardInterrupt):
107
+ return None
108
+
109
+ idx = int(choice) - 1 if choice.isdigit() else 0
110
+ if idx < 0 or idx >= len(recommended):
111
+ idx = 0
112
+
113
+ model_id, model_info, _, _ = recommended[idx]
114
+ console.print(f"\n [green]Selected: {model_info['name']}[/]")
115
+
116
+ # ── Step 4: Download model ──
117
+ console.print(f"\n [bold magenta]Step 4:[/] Downloading model...\n")
118
+
119
+ model_file = find_model_file(model_id)
120
+ if model_file:
121
+ console.print(f" [green]✓ Model already downloaded: {os.path.basename(model_file)}[/]")
122
+ else:
123
+ if model_info.get("backend") == "llamacpp" and model_info.get("hf_repo"):
124
+ download_model_hf(model_id)
125
+ elif model_info.get("ollama_tag"):
126
+ # Ensure Ollama is running
127
+ if not check_backend_running("ollama"):
128
+ start_ollama_serve()
129
+ download_model_ollama(model_id)
130
+
131
+ # ── Step 5: Determine backend + API URL ──
132
+ backend_id = model_info.get("backend", "ollama")
133
+ port = BACKENDS[backend_id]["default_port"]
134
+ api_url = f"http://127.0.0.1:{port}/v1"
135
+
136
+ # For llama.cpp models, start the server
137
+ if backend_id == "llamacpp":
138
+ if not check_backend_running("llamacpp"):
139
+ console.print(f"\n [bold magenta]Step 5:[/] Starting llama-server...\n")
140
+ proc = start_llama_server(model_id, port)
141
+ if not proc:
142
+ console.print(f" [yellow]Falling back to Ollama...[/]")
143
+ backend_id = "ollama"
144
+ port = 11434
145
+ api_url = f"http://127.0.0.1:{port}/v1"
146
+ if model_info.get("ollama_tag"):
147
+ start_ollama_serve()
148
+ download_model_ollama(model_id)
149
+ else:
150
+ console.print(f"\n [green]✓ llama-server already running on :{port}[/]")
151
+ else:
152
+ if not check_backend_running("ollama"):
153
+ console.print(f"\n [bold magenta]Step 5:[/] Starting Ollama...\n")
154
+ start_ollama_serve()
155
+
156
+ # ── Step 6: Save config ──
157
+ model_name = model_info.get("ollama_tag", model_id)
158
+ if backend_id == "llamacpp":
159
+ # Get actual model name from server
160
+ from localcoder.backends import get_running_models
161
+ running = get_running_models("llamacpp")
162
+ if running:
163
+ model_name = running[0]
164
+
165
+ cfg = {
166
+ "model": model_name,
167
+ "api_base": api_url,
168
+ "backend": backend_id,
169
+ "model_id": model_id,
170
+ "setup_complete": True,
171
+ }
172
+ save_config(cfg)
173
+
174
+ console.print(f"\n [bold magenta]Step 6:[/] Configuration saved.\n")
175
+
176
+ # ── Step 7: Configure OpenCode / OpenClaw (optional) ──
177
+ console.print(f" [bold magenta]Step 7:[/] Configure other tools?\n")
178
+
179
+ import shutil
180
+ has_opencode = shutil.which("opencode")
181
+ has_openclaw = shutil.which("openclaw")
182
+
183
+ if has_opencode or has_openclaw:
184
+ tools_found = []
185
+ if has_opencode:
186
+ tools_found.append("OpenCode")
187
+ if has_openclaw:
188
+ tools_found.append("OpenClaw")
189
+ console.print(f" [green]Found:[/] {', '.join(tools_found)}")
190
+ console.print(f" [dim]Auto-configure them to use your local model?[/]")
191
+ console.print(f" [bold]1.[/] Yes — configure all [dim](recommended)[/]")
192
+ console.print(f" [bold]2.[/] No — skip")
193
+
194
+ try:
195
+ ans = input("\n Choose (1/2): ").strip()
196
+ except (EOFError, KeyboardInterrupt):
197
+ ans = "2"
198
+
199
+ if ans == "1":
200
+ _configure_opencode(api_url, model_name, model_id, model_info)
201
+ _configure_openclaw(api_url, model_name, model_id, model_info)
202
+ else:
203
+ console.print(f" [dim]No OpenCode or OpenClaw found. Install with:[/]")
204
+ console.print(f" [dim]curl -fsSL https://opencode.ai/install | bash[/]")
205
+ console.print(f" [dim]brew install openclaw[/]")
206
+
207
+ # ── Done ──
208
+ console.print()
209
+ console.print(Panel(
210
+ Text.assemble(
211
+ ("Setup complete! ", "bold green"),
212
+ ("Run ", "dim"), ("localcoder", "bold cyan"), (" to start.\n\n", "dim"),
213
+ ("Model: ", "dim"), (f"{model_info['name']}\n", "bold cyan"),
214
+ ("Backend: ", "dim"), (f"{BACKENDS[backend_id]['name']} (:{port})\n", "green"),
215
+ ("API: ", "dim"), (f"{api_url}\n", "dim"),
216
+ ),
217
+ border_style="green", padding=(1, 2),
218
+ ))
219
+
220
+ return cfg
221
+
222
+
223
+ def _configure_opencode(api_url, model_name, model_id, model_info):
224
+ """Auto-configure OpenCode to use the local model."""
225
+ import shutil
226
+ if not shutil.which("opencode"):
227
+ return
228
+
229
+ config_path = Path.home() / ".config/opencode/opencode.json"
230
+ config_path.parent.mkdir(parents=True, exist_ok=True)
231
+
232
+ # Load existing or create new
233
+ existing = {}
234
+ if config_path.exists():
235
+ try:
236
+ existing = json.loads(config_path.read_text())
237
+ except:
238
+ pass
239
+
240
+ # Add/update llamacpp provider
241
+ if "provider" not in existing:
242
+ existing["provider"] = {}
243
+
244
+ existing["provider"]["llamacpp"] = {
245
+ "name": "llama.cpp (local)",
246
+ "npm": "@ai-sdk/openai-compatible",
247
+ "options": {"baseURL": api_url},
248
+ "models": {
249
+ model_id: {
250
+ "name": model_info.get("name", model_name),
251
+ "tool_call": True,
252
+ "reasoning": False,
253
+ "modalities": {"input": ["text", "image"], "output": ["text"]},
254
+ "limit": {"context": 131072, "output": 8192},
255
+ }
256
+ },
257
+ }
258
+ existing["$schema"] = "https://opencode.ai/config.json"
259
+ existing["model"] = f"llamacpp/{model_id}"
260
+
261
+ config_path.write_text(json.dumps(existing, indent=2))
262
+ console.print(f" [green]✓ OpenCode configured[/] [dim]({config_path})[/]")
263
+ console.print(f" [dim]Model: llamacpp/{model_id} → {api_url}[/]")
264
+
265
+
266
+ def _configure_openclaw(api_url, model_name, model_id, model_info):
267
+ """Auto-configure OpenClaw to use the local model."""
268
+ import shutil
269
+ if not shutil.which("openclaw"):
270
+ return
271
+
272
+ config_path = Path.home() / ".openclaw/openclaw.json"
273
+ if not config_path.exists():
274
+ console.print(f" [dim]OpenClaw config not found — run 'openclaw' first to initialize[/]")
275
+ return
276
+
277
+ try:
278
+ cfg = json.loads(config_path.read_text())
279
+ except:
280
+ console.print(f" [yellow]Could not parse OpenClaw config[/]")
281
+ return
282
+
283
+ # Add/update llamacpp provider
284
+ if "models" not in cfg:
285
+ cfg["models"] = {"mode": "merge", "providers": {}}
286
+ if "providers" not in cfg["models"]:
287
+ cfg["models"]["providers"] = {}
288
+
289
+ cfg["models"]["providers"]["llamacpp"] = {
290
+ "api": "openai-completions",
291
+ "baseUrl": api_url,
292
+ "apiKey": "dummy",
293
+ "models": [{
294
+ "id": model_name,
295
+ "name": model_info.get("name", model_name),
296
+ "reasoning": False,
297
+ "contextWindow": 131072,
298
+ "maxTokens": 8192,
299
+ }],
300
+ }
301
+
302
+ # Set as default model
303
+ if "agents" not in cfg:
304
+ cfg["agents"] = {"defaults": {}}
305
+ if "defaults" not in cfg["agents"]:
306
+ cfg["agents"]["defaults"] = {}
307
+ if "model" not in cfg["agents"]["defaults"]:
308
+ cfg["agents"]["defaults"]["model"] = {}
309
+ cfg["agents"]["defaults"]["model"]["primary"] = f"llamacpp/{model_name}"
310
+
311
+ config_path.write_text(json.dumps(cfg, indent=2))
312
+ console.print(f" [green]✓ OpenClaw configured[/] [dim]({config_path})[/]")
313
+ console.print(f" [dim]Model: llamacpp/{model_name} → {api_url}[/]")
314
+
315
+
316
+ def ensure_setup():
317
+ """Check if setup is done, run wizard if not."""
318
+ cfg = load_config()
319
+ if cfg.get("setup_complete"):
320
+ return cfg
321
+ return wizard()
localcoder/tui.py ADDED
@@ -0,0 +1,276 @@
1
+ """localcoder TUI — Textual-based fixed-layout GPU health dashboard."""
2
+ from textual.app import App, ComposeResult
3
+ from textual.containers import Horizontal, Vertical, Container
4
+ from textual.widgets import Static, Footer, Header, DataTable, LoadingIndicator
5
+ from textual.reactive import reactive
6
+ from textual import work
7
+
8
+
9
+ class StatusIndicator(Static):
10
+ """Top status bar: HEALTHY / DEGRADED / CRITICAL."""
11
+ status = reactive("scanning")
12
+
13
+ def render(self):
14
+ colors = {
15
+ "healthy": "green", "degraded": "yellow",
16
+ "critical": "red", "scanning": "cyan",
17
+ }
18
+ c = colors.get(self.status, "dim")
19
+ return f"[bold {c}] {self.status.upper()} [/]"
20
+
21
+
22
+ class GpuBar(Static):
23
+ """Visual VRAM usage bar."""
24
+ gpu_alloc = reactive(0)
25
+ gpu_total = reactive(16384)
26
+
27
+ def render(self):
28
+ pct = min(1.0, self.gpu_alloc / max(1, self.gpu_total))
29
+ w = 40
30
+ filled = int(pct * w)
31
+ color = "green" if pct < 0.7 else "yellow" if pct < 0.9 else "red"
32
+ bar = f"[{color}]{'█' * filled}[/{color}][dim]{'░' * (w - filled)}[/dim]"
33
+ return f" VRAM {bar} {self.gpu_alloc // 1024}/{self.gpu_total // 1024}GB"
34
+
35
+
36
+ class InfoCard(Static):
37
+ """A status card (Compute / KV Cache / Memory)."""
38
+ DEFAULT_CSS = """
39
+ InfoCard {
40
+ width: 1fr;
41
+ height: auto;
42
+ min-height: 7;
43
+ border: solid $primary;
44
+ padding: 0 1;
45
+ }
46
+ """
47
+
48
+
49
+ class BottomBar(Static):
50
+ """Pinned bottom status bar with GPU stats + shortcuts."""
51
+ DEFAULT_CSS = """
52
+ BottomBar {
53
+ dock: bottom;
54
+ height: 1;
55
+ background: $surface;
56
+ }
57
+ """
58
+ gpu_text = reactive("")
59
+
60
+ def render(self):
61
+ return self.gpu_text or " GPU --/-- SWAP -- MEM --"
62
+
63
+
64
+ class HealthDashboard(App):
65
+ """GPU Health Dashboard — fixed layout, no scrolling."""
66
+
67
+ CSS = """
68
+ Screen {
69
+ layout: vertical;
70
+ }
71
+ #header-bar {
72
+ height: 3;
73
+ padding: 0 1;
74
+ }
75
+ #cards-row {
76
+ height: auto;
77
+ min-height: 8;
78
+ max-height: 10;
79
+ }
80
+ #vram-bar {
81
+ height: 2;
82
+ padding: 0 1;
83
+ }
84
+ #procs-panel {
85
+ height: 1fr;
86
+ padding: 0 1;
87
+ }
88
+ #fixes-panel {
89
+ height: auto;
90
+ max-height: 8;
91
+ padding: 0 1;
92
+ border: solid $warning;
93
+ }
94
+ InfoCard {
95
+ border: solid $primary;
96
+ padding: 0 1;
97
+ width: 1fr;
98
+ }
99
+ #status-line {
100
+ height: 1;
101
+ dock: top;
102
+ }
103
+ DataTable {
104
+ height: 1fr;
105
+ }
106
+ """
107
+
108
+ BINDINGS = [
109
+ ("q", "quit", "Quit"),
110
+ ("c", "cleanup", "Cleanup"),
111
+ ("d", "debloat", "Debloat"),
112
+ ("s", "simulate", "Simulate"),
113
+ ("r", "refresh", "Refresh"),
114
+ ]
115
+
116
+ def compose(self) -> ComposeResult:
117
+ yield StatusIndicator(id="status-line")
118
+ yield Static(id="header-bar")
119
+
120
+ with Horizontal(id="cards-row"):
121
+ yield InfoCard(id="card-compute")
122
+ yield InfoCard(id="card-kv")
123
+ yield InfoCard(id="card-mem")
124
+
125
+ yield GpuBar(id="vram-bar")
126
+
127
+ yield Container(
128
+ DataTable(id="proc-table"),
129
+ id="procs-panel",
130
+ )
131
+
132
+ yield Static(id="fixes-panel")
133
+ yield BottomBar(id="bottom-bar")
134
+ yield Footer()
135
+
136
+ def on_mount(self) -> None:
137
+ """Start loading data."""
138
+ self.load_data()
139
+
140
+ @work(thread=True)
141
+ def load_data(self) -> None:
142
+ """Load GPU data in background thread."""
143
+ from localcoder.backends import (
144
+ get_machine_specs, diagnose_gpu_health, get_metal_gpu_stats,
145
+ get_top_memory_processes, get_swap_usage_mb, _detect_model_info,
146
+ )
147
+
148
+ specs = get_machine_specs()
149
+ diag = diagnose_gpu_health()
150
+ metal = get_metal_gpu_stats()
151
+ procs = get_top_memory_processes(min_mb=80, limit=8)
152
+ swap_mb = get_swap_usage_mb()
153
+ model_info = _detect_model_info(diag["server_config"], None)
154
+
155
+ # Update UI from worker thread
156
+ self.call_from_thread(self._update_ui, specs, diag, metal, procs, swap_mb, model_info)
157
+
158
+ def _update_ui(self, specs, diag, metal, procs, swap_mb, model_info):
159
+ """Update all widgets with loaded data."""
160
+ # Status
161
+ status = self.query_one("#status-line", StatusIndicator)
162
+ status.status = diag["status"]
163
+
164
+ # Header
165
+ header = self.query_one("#header-bar", Static)
166
+ model_str = ""
167
+ if model_info["name"]:
168
+ parts = [model_info["name"]]
169
+ if model_info["quant"]:
170
+ parts.append(model_info["quant"])
171
+ if model_info["size_gb"]:
172
+ parts.append(f"{model_info['size_gb']}GB")
173
+ model_str = f"\n [cyan]{' · '.join(parts)}[/cyan]"
174
+ header.update(
175
+ f" [bold]{specs['chip']}[/bold] · {specs['ram_gb']}GB RAM · "
176
+ f"{specs.get('gpu_cores', '?')} GPU cores{model_str}"
177
+ )
178
+
179
+ # Compute card
180
+ srv = diag["server_config"]
181
+ if srv.get("running"):
182
+ gpu_icon = "[green]●[/] GPU (Metal)" if diag["on_gpu"] else "[red]●[/] CPU — SLOW"
183
+ compute = (
184
+ f"[bold]Compute[/bold]\n"
185
+ f"{gpu_icon}\n"
186
+ f"Layers: {diag['gpu_layers']}/99\n"
187
+ f"Util: {diag['gpu_util_pct']}%\n"
188
+ f"Model: {srv.get('footprint_mb', 0)} MB"
189
+ )
190
+ else:
191
+ compute = "[bold]Compute[/bold]\n[dim]Server not running[/dim]"
192
+ self.query_one("#card-compute", InfoCard).update(compute)
193
+
194
+ # KV Cache card
195
+ kv_icon = "[green]●[/]" if diag["kv_quantized"] else "[red]●[/]"
196
+ kv_type = f"Type: {diag['kv_type']}\n" if diag["kv_type"] else ""
197
+ fa_icon = "[green]●[/]" if diag["flash_attn"] else "[yellow]●[/]"
198
+ self.query_one("#card-kv", InfoCard).update(
199
+ f"[bold]KV Cache[/bold]\n"
200
+ f"{kv_icon} {'Quantized' if diag['kv_quantized'] else 'Full (2x mem!)'}\n"
201
+ f"{kv_type}"
202
+ f"Size: ~{diag['kv_cache_est_mb']} MB\n"
203
+ f"Ctx: {diag['context_size'] // 1024}K\n"
204
+ f"{fa_icon} FlashAttn: {'on' if diag['flash_attn'] else 'off'}"
205
+ )
206
+
207
+ # Memory card
208
+ pc = {"normal": "green", "warn": "yellow", "critical": "red"}.get(diag["mem_pressure"], "dim")
209
+ sc = "red" if swap_mb > 4000 else "green"
210
+ headroom = diag["gpu_total_mb"] - diag["gpu_alloc_mb"]
211
+ hc = "green" if headroom > 2048 else "yellow" if headroom > 0 else "red"
212
+ self.query_one("#card-mem", InfoCard).update(
213
+ f"[bold]Memory[/bold]\n"
214
+ f"Pressure: [{pc}]{diag['mem_pressure']}[/{pc}]\n"
215
+ f"Swap: [{sc}]{swap_mb // 1024}GB[/{sc}]\n"
216
+ f"GPU: {diag['gpu_alloc_mb'] // 1024}/{diag['gpu_total_mb'] // 1024}GB\n"
217
+ f"Free: [{hc}]{headroom // 1024}GB[/{hc}]"
218
+ )
219
+
220
+ # VRAM bar
221
+ vram = self.query_one("#vram-bar", GpuBar)
222
+ vram.gpu_alloc = diag["gpu_alloc_mb"]
223
+ vram.gpu_total = diag["gpu_total_mb"]
224
+
225
+ # Process table
226
+ table = self.query_one("#proc-table", DataTable)
227
+ table.clear(columns=True)
228
+ table.add_columns("#", "Process", "Memory", "Type")
229
+ for i, p in enumerate(procs, 1):
230
+ mb = p["mb"]
231
+ size = f"{mb / 1024:.1f}G" if mb >= 1024 else f"{mb}M"
232
+ cat = {"ml": "ML", "app": "app", "system": "sys", "bloat": "bloat"}.get(p["category"], "?")
233
+ name = p["name"] + (f" ×{p['count']}" if p.get("count", 1) > 1 else "")
234
+ table.add_row(str(i), name, size, cat)
235
+
236
+ # Fixes
237
+ fixes = self.query_one("#fixes-panel", Static)
238
+ if diag["issues"]:
239
+ lines = []
240
+ for issue in diag["issues"]:
241
+ lines.append(f"[red]●[/] {issue}")
242
+ for fix in diag.get("fixes", []):
243
+ lines.append(f"[green]→[/] {fix}")
244
+ fixes.update("\n".join(lines))
245
+ else:
246
+ fixes.update("[green]All good — no issues detected[/]")
247
+
248
+ # Bottom bar
249
+ bar = self.query_one("#bottom-bar", BottomBar)
250
+ gc = "red" if diag["gpu_alloc_mb"] > diag["gpu_total_mb"] else "green"
251
+ bar.gpu_text = (
252
+ f" GPU [{gc}]{diag['gpu_alloc_mb'] // 1024}/{diag['gpu_total_mb'] // 1024}GB[/{gc}]"
253
+ f" SWAP [{sc}]{swap_mb // 1024}GB[/{sc}]"
254
+ f" MEM [{pc}]{diag['mem_pressure']}[/{pc}]"
255
+ )
256
+
257
+ def action_refresh(self) -> None:
258
+ status = self.query_one("#status-line", StatusIndicator)
259
+ status.status = "scanning"
260
+ self.load_data()
261
+
262
+ def action_cleanup(self) -> None:
263
+ self.exit(return_code=10) # Signal to CLI to run cleanup
264
+
265
+ def action_debloat(self) -> None:
266
+ self.exit(return_code=11)
267
+
268
+ def action_simulate(self) -> None:
269
+ self.exit(return_code=12)
270
+
271
+
272
+ def run_tui_dashboard():
273
+ """Launch the Textual TUI dashboard."""
274
+ app = HealthDashboard()
275
+ result = app.run()
276
+ return result