delimit-cli 4.1.43 → 4.1.44

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.
package/gateway/ai/tui.py CHANGED
@@ -1,7 +1,8 @@
1
- """Delimit TUI — Terminal User Interface (Phase 2 of Delimit OS).
1
+ """Delimit TUI — Terminal User Interface (Phase 5 of Delimit OS).
2
2
 
3
3
  The proprietary terminal experience. Type 'delimit' and get an OS-like
4
- environment with panels for ledger, swarm, memory, and live logs.
4
+ environment with panels for ledger, swarm, notifications, filesystem,
5
+ process manager, and live logs.
5
6
 
6
7
  Enterprise-ready: zero JS, pure Python, works over SSH, sub-2s boot.
7
8
  Designed for devs who hate browser-based tools.
@@ -12,29 +13,40 @@ Usage:
12
13
  """
13
14
 
14
15
  from textual.app import App, ComposeResult
15
- from textual.containers import Container, Horizontal, Vertical
16
+ from textual.containers import Container, Horizontal, Vertical, VerticalScroll
16
17
  from textual.widgets import (
17
18
  Header, Footer, Static, DataTable, Log, TabbedContent, TabPane,
18
- Label, ProgressBar, Button, Input,
19
+ Label, ProgressBar, Button, Input, Tree, RichLog,
19
20
  )
20
21
  from textual.timer import Timer
21
22
  from textual import work
23
+ from textual.binding import Binding
22
24
  import json
25
+ import os
26
+ import subprocess
23
27
  import time
28
+ from datetime import datetime, timezone
24
29
  from pathlib import Path
25
- from typing import Any, Dict, List
30
+ from typing import Any, Dict, List, Optional, Tuple
26
31
 
27
32
 
28
- # ── Data loaders ─────────────────────────────────────────────────────
33
+ # -- Data paths ---------------------------------------------------------------
29
34
 
30
- LEDGER_DIR = Path.home() / ".delimit" / "ledger"
31
- SWARM_DIR = Path.home() / ".delimit" / "swarm"
32
- MEMORY_DIR = Path.home() / ".delimit" / "memory"
33
- SESSIONS_DIR = Path.home() / ".delimit" / "sessions"
35
+ DELIMIT_HOME = Path.home() / ".delimit"
36
+ LEDGER_DIR = DELIMIT_HOME / "ledger"
37
+ SWARM_DIR = DELIMIT_HOME / "swarm"
38
+ MEMORY_DIR = DELIMIT_HOME / "memory"
39
+ SESSIONS_DIR = DELIMIT_HOME / "sessions"
40
+ NOTIFICATIONS_FILE = DELIMIT_HOME / "notifications.jsonl"
41
+ DAEMON_STATE_FILE = DELIMIT_HOME / "daemon" / "state.json"
42
+ DAEMON_LOG_FILE = DELIMIT_HOME / "daemon" / "daemon.log.jsonl"
43
+ ALERTS_DIR = DELIMIT_HOME / "alerts"
34
44
 
35
45
 
46
+ # -- Data loaders -------------------------------------------------------------
47
+
36
48
  def _load_ledger_items(status: str = "open", limit: int = 20) -> List[Dict]:
37
- # Deduplicate by ID last entry wins (append-only JSONL)
49
+ """Load deduplicated ledger items (append-only JSONL, last entry wins)."""
38
50
  by_id: Dict[str, Dict] = {}
39
51
  for fname in ("operations.jsonl", "strategy.jsonl"):
40
52
  path = LEDGER_DIR / fname
@@ -82,10 +94,185 @@ def _load_recent_sessions(limit: int = 5) -> List[Dict]:
82
94
  return sessions
83
95
 
84
96
 
85
- # ── Widgets ──────────────────────────────────────────────────────────
97
+ def _load_notifications(limit: int = 50) -> List[Dict]:
98
+ """Load recent notifications from JSONL, newest first."""
99
+ if not NOTIFICATIONS_FILE.exists():
100
+ return []
101
+ # Read last N lines efficiently (tail)
102
+ lines: List[str] = []
103
+ try:
104
+ with open(NOTIFICATIONS_FILE, "rb") as f:
105
+ # Seek from end to find last `limit` lines
106
+ f.seek(0, 2)
107
+ fsize = f.tell()
108
+ # Read at most 64KB from the end — enough for 50 notifications
109
+ read_size = min(fsize, 65536)
110
+ f.seek(fsize - read_size)
111
+ data = f.read().decode("utf-8", errors="replace")
112
+ lines = data.strip().split("\n")
113
+ except (OSError, UnicodeDecodeError):
114
+ return []
115
+
116
+ notifications = []
117
+ for line in reversed(lines[-limit:]):
118
+ try:
119
+ notifications.append(json.loads(line))
120
+ except json.JSONDecodeError:
121
+ continue
122
+ return notifications
123
+
124
+
125
+ def _load_daemon_state() -> Dict[str, Any]:
126
+ """Load inbox daemon state."""
127
+ if not DAEMON_STATE_FILE.exists():
128
+ return {"status": "unknown"}
129
+ try:
130
+ return json.loads(DAEMON_STATE_FILE.read_text())
131
+ except (json.JSONDecodeError, OSError):
132
+ return {"status": "unknown"}
133
+
134
+
135
+ def _load_process_list() -> List[Dict[str, Any]]:
136
+ """Build a list of known daemons with status from state files and alerts."""
137
+ processes = []
138
+
139
+ # 1. Inbox daemon — primary daemon
140
+ daemon = _load_daemon_state()
141
+ started = daemon.get("started_at", "")
142
+ last_loop = daemon.get("last_loop_at", "")
143
+ loops = daemon.get("loops", 0)
144
+ items_proc = daemon.get("items_processed", 0)
145
+ status = daemon.get("status", "unknown")
146
+
147
+ # Check for alert overrides
148
+ alert_file = ALERTS_DIR / "inbox_daemon.json"
149
+ if alert_file.exists():
150
+ try:
151
+ alert = json.loads(alert_file.read_text())
152
+ if alert.get("alert") == "inbox_daemon_stopped":
153
+ status = "stopped (alert)"
154
+ except (json.JSONDecodeError, OSError):
155
+ pass
156
+
157
+ uptime = ""
158
+ if started and status in ("running", "idle"):
159
+ try:
160
+ start_dt = datetime.fromisoformat(started)
161
+ delta = datetime.now(timezone.utc) - start_dt
162
+ hours = int(delta.total_seconds() // 3600)
163
+ minutes = int((delta.total_seconds() % 3600) // 60)
164
+ uptime = f"{hours}h {minutes}m"
165
+ except (ValueError, TypeError):
166
+ uptime = "?"
167
+
168
+ processes.append({
169
+ "name": "inbox_daemon",
170
+ "label": "Inbox Daemon",
171
+ "status": status,
172
+ "uptime": uptime,
173
+ "detail": f"loops={loops} processed={items_proc}",
174
+ "last_action": last_loop[:19] if last_loop else "",
175
+ })
176
+
177
+ # 2. Social scanner — check cron.log and social_drafts for activity
178
+ social_status = "inactive"
179
+ social_last = ""
180
+ social_detail = ""
181
+ cron_log = DELIMIT_HOME / "cron.log"
182
+ if cron_log.exists():
183
+ try:
184
+ # Read last 2KB to find recent social scan entries
185
+ with open(cron_log, "rb") as f:
186
+ f.seek(0, 2)
187
+ fsize = f.tell()
188
+ read_size = min(fsize, 2048)
189
+ f.seek(fsize - read_size)
190
+ tail = f.read().decode("utf-8", errors="replace")
191
+ # Look for social scan references
192
+ for line in reversed(tail.strip().split("\n")):
193
+ if "social" in line.lower() or "scan" in line.lower():
194
+ social_status = "active"
195
+ social_last = line[:19] if len(line) > 19 else line
196
+ social_detail = line.strip()[:60]
197
+ break
198
+ except (OSError, UnicodeDecodeError):
199
+ pass
200
+
201
+ processes.append({
202
+ "name": "social_scanner",
203
+ "label": "Social Scanner",
204
+ "status": social_status,
205
+ "uptime": "",
206
+ "detail": social_detail,
207
+ "last_action": social_last,
208
+ })
209
+
210
+ # 3. Ledger watcher — check if ledger files were recently modified
211
+ ledger_status = "inactive"
212
+ ledger_last = ""
213
+ for fname in ("operations.jsonl", "strategy.jsonl"):
214
+ lpath = LEDGER_DIR / fname
215
+ if lpath.exists():
216
+ mtime = lpath.stat().st_mtime
217
+ age_hours = (time.time() - mtime) / 3600
218
+ if age_hours < 1:
219
+ ledger_status = "active"
220
+ ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
221
+ if not ledger_last or ts > ledger_last:
222
+ ledger_last = ts
223
+
224
+ processes.append({
225
+ "name": "ledger_watcher",
226
+ "label": "Ledger Watcher",
227
+ "status": ledger_status,
228
+ "uptime": "",
229
+ "detail": "monitors operations + strategy",
230
+ "last_action": ledger_last,
231
+ })
232
+
233
+ # 4. Notification router
234
+ notif_status = "inactive"
235
+ notif_last = ""
236
+ if NOTIFICATIONS_FILE.exists():
237
+ mtime = NOTIFICATIONS_FILE.stat().st_mtime
238
+ age_hours = (time.time() - mtime) / 3600
239
+ if age_hours < 1:
240
+ notif_status = "active"
241
+ notif_last = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
242
+
243
+ processes.append({
244
+ "name": "notify_router",
245
+ "label": "Notification Router",
246
+ "status": notif_status,
247
+ "uptime": "",
248
+ "detail": f"routing via {DELIMIT_HOME / 'notify_routing.yaml'}",
249
+ "last_action": notif_last,
250
+ })
251
+
252
+ return processes
253
+
254
+
255
+ def _build_dir_tree(root: Path, max_depth: int = 3, _depth: int = 0) -> List[Tuple[str, Path, bool]]:
256
+ """Build a flat list of (name, path, is_dir) for the tree, respecting depth."""
257
+ if _depth > max_depth or not root.is_dir():
258
+ return []
259
+ entries = []
260
+ try:
261
+ children = sorted(root.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
262
+ except PermissionError:
263
+ return []
264
+ for child in children:
265
+ # Skip very large directories and hidden internals
266
+ if child.name.startswith("__") or child.name == "venv":
267
+ continue
268
+ entries.append((child.name, child, child.is_dir()))
269
+ return entries
270
+
271
+
272
+ # -- Widgets ------------------------------------------------------------------
86
273
 
87
274
  class LedgerPanel(Static):
88
- """Live ledger view shows open items sorted by priority."""
275
+ """Live ledger view -- shows open items sorted by priority."""
89
276
 
90
277
  def compose(self) -> ComposeResult:
91
278
  yield DataTable(id="ledger-table")
@@ -110,7 +297,7 @@ class LedgerPanel(Static):
110
297
 
111
298
 
112
299
  class SwarmPanel(Static):
113
- """Swarm status agents, ventures, health."""
300
+ """Swarm status -- agents, ventures, health."""
114
301
 
115
302
  def compose(self) -> ComposeResult:
116
303
  yield Static(id="swarm-content")
@@ -132,7 +319,7 @@ class SwarmPanel(Static):
132
319
 
133
320
 
134
321
  class SessionPanel(Static):
135
- """Recent sessions handoff history."""
322
+ """Recent sessions -- handoff history."""
136
323
 
137
324
  def compose(self) -> ComposeResult:
138
325
  yield Static(id="session-content")
@@ -151,14 +338,14 @@ class SessionPanel(Static):
151
338
  ts = s.get("timestamp", s.get("closed_at", ""))[:16]
152
339
  summary = s.get("summary", "")[:80]
153
340
  completed = len(s.get("items_completed", []))
154
- lines.append(f"[dim]{ts}[/] {summary}")
341
+ lines.append(f"[dim]{ts}[/] -- {summary}")
155
342
  if completed:
156
- lines.append(f" [green]{completed} items completed[/]")
343
+ lines.append(f" [green]{completed} items completed[/]")
157
344
  content.update("\n".join(lines))
158
345
 
159
346
 
160
347
  class VenturesPanel(Static):
161
- """Ventures as app tiles each venture is an 'app' in the OS."""
348
+ """Ventures as app tiles -- each venture is an 'app' in the OS."""
162
349
 
163
350
  def compose(self) -> ComposeResult:
164
351
  yield Static(id="ventures-content")
@@ -176,7 +363,6 @@ class VenturesPanel(Static):
176
363
  content.update("[dim]No ventures registered. Run delimit_swarm(action='register').[/]")
177
364
  return
178
365
 
179
- # Count open items per venture
180
366
  all_items = _load_ledger_items("open", 999)
181
367
  venture_items = {}
182
368
  for item in all_items:
@@ -184,11 +370,11 @@ class VenturesPanel(Static):
184
370
  venture_items[v] = venture_items.get(v, 0) + 1
185
371
 
186
372
  lines = [
187
- "[bold]Ventures[/] each venture is an app in Delimit OS\n",
373
+ "[bold]Ventures[/] -- each venture is an app in Delimit OS\n",
188
374
  ]
189
375
  for venture, agent_count in sorted(by_venture.items()):
190
376
  open_count = venture_items.get(venture, venture_items.get(f"{venture}-mcp", 0))
191
- status_icon = "[green][/]" if agent_count > 0 else "[red][/]"
377
+ status_icon = "[green]>[/]" if agent_count > 0 else "[red]o[/]"
192
378
  lines.append(
193
379
  f" {status_icon} [bold cyan]{venture}[/]"
194
380
  f" | {agent_count} agents"
@@ -199,8 +385,330 @@ class VenturesPanel(Static):
199
385
  content.update("\n".join(lines))
200
386
 
201
387
 
388
+ class NotificationPanel(Static):
389
+ """Notification drawer -- recent events from notifications.jsonl."""
390
+
391
+ DEFAULT_CSS = """
392
+ NotificationPanel {
393
+ height: 1fr;
394
+ }
395
+ #notif-log {
396
+ height: 1fr;
397
+ padding: 0 1;
398
+ }
399
+ """
400
+
401
+ def compose(self) -> ComposeResult:
402
+ yield Static("[bold]Notifications[/] [dim]Auto-refreshes every 30s[/]\n", id="notif-header")
403
+ yield RichLog(id="notif-log", highlight=True, markup=True, wrap=True)
404
+
405
+ def on_mount(self) -> None:
406
+ self._refresh_data()
407
+ self.set_interval(30, self._refresh_data)
408
+
409
+ def _refresh_data(self) -> None:
410
+ log = self.query_one("#notif-log", RichLog)
411
+ log.clear()
412
+ notifications = _load_notifications(50)
413
+ if not notifications:
414
+ log.write("[dim]No notifications yet.[/]")
415
+ return
416
+
417
+ for n in notifications:
418
+ ts = n.get("timestamp", "")[:19].replace("T", " ")
419
+ channel = n.get("channel", "?")
420
+ subject = n.get("subject", n.get("event_type", ""))
421
+ success = n.get("success", None)
422
+ reason = n.get("reason", "")
423
+
424
+ # Color-code by status
425
+ if success is True:
426
+ icon = "[green]OK[/]"
427
+ elif success is False:
428
+ icon = "[red]FAIL[/]"
429
+ else:
430
+ icon = "[yellow]--[/]"
431
+
432
+ line = f"[dim]{ts}[/] {icon} [{_channel_color(channel)}]{channel}[/]"
433
+ if subject:
434
+ line += f" {subject[:50]}"
435
+ if reason:
436
+ line += f" [dim]({reason})[/]"
437
+ log.write(line)
438
+
439
+ @staticmethod
440
+ def get_unread_count() -> int:
441
+ """Count notifications from the last hour."""
442
+ if not NOTIFICATIONS_FILE.exists():
443
+ return 0
444
+ try:
445
+ mtime = NOTIFICATIONS_FILE.stat().st_mtime
446
+ age_hours = (time.time() - mtime) / 3600
447
+ if age_hours > 1:
448
+ return 0
449
+ # Count lines in last 4KB
450
+ with open(NOTIFICATIONS_FILE, "rb") as f:
451
+ f.seek(0, 2)
452
+ fsize = f.tell()
453
+ read_size = min(fsize, 4096)
454
+ f.seek(fsize - read_size)
455
+ data = f.read().decode("utf-8", errors="replace")
456
+ count = 0
457
+ cutoff = time.time() - 3600
458
+ for line in reversed(data.strip().split("\n")):
459
+ try:
460
+ n = json.loads(line)
461
+ ts = n.get("timestamp", "")
462
+ if ts:
463
+ dt = datetime.fromisoformat(ts)
464
+ if dt.timestamp() < cutoff:
465
+ break
466
+ count += 1
467
+ except (json.JSONDecodeError, ValueError):
468
+ continue
469
+ return count
470
+ except (OSError, UnicodeDecodeError):
471
+ return 0
472
+
473
+
474
+ def _channel_color(channel: str) -> str:
475
+ """Return a rich color name for a notification channel."""
476
+ colors = {
477
+ "email": "cyan",
478
+ "social": "magenta",
479
+ "github": "white",
480
+ "deploy": "green",
481
+ "security": "red",
482
+ "test": "yellow",
483
+ }
484
+ return colors.get(channel, "white")
485
+
486
+
487
+ class FilesystemPanel(Static):
488
+ """Filesystem browser -- navigate .delimit/ directory tree."""
489
+
490
+ DEFAULT_CSS = """
491
+ FilesystemPanel {
492
+ height: 1fr;
493
+ }
494
+ #fs-container {
495
+ height: 1fr;
496
+ }
497
+ #fs-tree {
498
+ width: 1fr;
499
+ min-width: 30;
500
+ height: 1fr;
501
+ }
502
+ #fs-preview {
503
+ width: 2fr;
504
+ height: 1fr;
505
+ padding: 0 1;
506
+ border-left: solid $primary;
507
+ }
508
+ """
509
+
510
+ def compose(self) -> ComposeResult:
511
+ with Horizontal(id="fs-container"):
512
+ yield Tree("[bold].delimit/[/]", id="fs-tree")
513
+ yield RichLog(id="fs-preview", highlight=True, markup=True, wrap=True)
514
+
515
+ def on_mount(self) -> None:
516
+ tree = self.query_one("#fs-tree", Tree)
517
+ tree.root.expand()
518
+ self._populate_tree(tree.root, DELIMIT_HOME, depth=0)
519
+ tree.root.expand()
520
+
521
+ def _populate_tree(self, node, path: Path, depth: int) -> None:
522
+ """Populate tree nodes lazily up to depth 2."""
523
+ if depth > 2 or not path.is_dir():
524
+ return
525
+ entries = _build_dir_tree(path, max_depth=0)
526
+ for name, child_path, is_dir in entries:
527
+ if is_dir:
528
+ branch = node.add(f"[bold cyan]{name}/[/]", data=child_path)
529
+ # Add a placeholder so it shows as expandable
530
+ if depth < 2:
531
+ self._populate_tree(branch, child_path, depth + 1)
532
+ else:
533
+ # Show file size hint
534
+ try:
535
+ size = child_path.stat().st_size
536
+ size_str = _human_size(size)
537
+ except OSError:
538
+ size_str = "?"
539
+ node.add_leaf(f"{name} [dim]({size_str})[/]", data=child_path)
540
+
541
+ def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
542
+ """Preview file contents on selection."""
543
+ preview = self.query_one("#fs-preview", RichLog)
544
+ preview.clear()
545
+
546
+ path = event.node.data
547
+ if path is None:
548
+ return
549
+
550
+ if isinstance(path, Path) and path.is_file():
551
+ preview.write(f"[bold]{path.name}[/] [dim]{_human_size(path.stat().st_size)}[/]\n")
552
+ preview.write(f"[dim]{path}[/]\n")
553
+ preview.write("[dim]" + "-" * 60 + "[/]\n")
554
+
555
+ # Read file with size guard
556
+ try:
557
+ size = path.stat().st_size
558
+ if size > 102400: # 100KB limit
559
+ preview.write(f"[yellow]File too large to preview ({_human_size(size)}). Showing first 4KB.[/]\n\n")
560
+ content = path.read_bytes()[:4096].decode("utf-8", errors="replace")
561
+ elif path.suffix in (".json", ".jsonl", ".yml", ".yaml", ".txt", ".md", ".py", ".log", ".sh"):
562
+ content = path.read_text(errors="replace")
563
+ else:
564
+ preview.write(f"[dim]Binary file ({path.suffix}). Size: {_human_size(size)}[/]")
565
+ return
566
+ # For JSONL, show last 20 lines
567
+ if path.suffix == ".jsonl":
568
+ lines = content.strip().split("\n")
569
+ if len(lines) > 20:
570
+ preview.write(f"[dim]Showing last 20 of {len(lines)} lines[/]\n\n")
571
+ content = "\n".join(lines[-20:])
572
+ # Pretty-print JSON
573
+ if path.suffix == ".json":
574
+ try:
575
+ parsed = json.loads(content)
576
+ content = json.dumps(parsed, indent=2)
577
+ except json.JSONDecodeError:
578
+ pass
579
+ preview.write(content)
580
+ except (OSError, UnicodeDecodeError) as e:
581
+ preview.write(f"[red]Error reading file: {e}[/]")
582
+ elif isinstance(path, Path) and path.is_dir():
583
+ preview.write(f"[bold]{path.name}/[/]\n")
584
+ try:
585
+ children = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
586
+ for c in children[:50]:
587
+ if c.is_dir():
588
+ preview.write(f" [cyan]{c.name}/[/]\n")
589
+ else:
590
+ preview.write(f" {c.name} [dim]({_human_size(c.stat().st_size)})[/]\n")
591
+ total = len(list(path.iterdir()))
592
+ if total > 50:
593
+ preview.write(f"\n[dim]... and {total - 50} more[/]")
594
+ except PermissionError:
595
+ preview.write("[red]Permission denied[/]")
596
+
597
+
598
+ def _human_size(size: int) -> str:
599
+ """Convert bytes to human-readable string."""
600
+ for unit in ("B", "KB", "MB", "GB"):
601
+ if size < 1024:
602
+ return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
603
+ size /= 1024
604
+ return f"{size:.1f}TB"
605
+
606
+
607
+ class ProcessPanel(Static):
608
+ """Process manager -- show running daemons with status and controls."""
609
+
610
+ DEFAULT_CSS = """
611
+ ProcessPanel {
612
+ height: 1fr;
613
+ }
614
+ #proc-table {
615
+ height: auto;
616
+ max-height: 50%;
617
+ }
618
+ #proc-detail {
619
+ height: 1fr;
620
+ padding: 1;
621
+ }
622
+ """
623
+
624
+ def compose(self) -> ComposeResult:
625
+ yield DataTable(id="proc-table")
626
+ yield Static(id="proc-detail")
627
+
628
+ def on_mount(self) -> None:
629
+ table = self.query_one("#proc-table", DataTable)
630
+ table.add_columns("Name", "Status", "Uptime", "Last Action", "Detail")
631
+ table.cursor_type = "row"
632
+ self._refresh_data()
633
+ self.set_interval(15, self._refresh_data)
634
+
635
+ def _refresh_data(self) -> None:
636
+ table = self.query_one("#proc-table", DataTable)
637
+ detail = self.query_one("#proc-detail", Static)
638
+ table.clear()
639
+
640
+ processes = _load_process_list()
641
+ for proc in processes:
642
+ status = proc["status"]
643
+ if status in ("running", "active"):
644
+ status_display = f"[green]{status}[/]"
645
+ elif status in ("stopped", "stopped (alert)", "unknown"):
646
+ status_display = f"[red]{status}[/]"
647
+ else:
648
+ status_display = f"[yellow]{status}[/]"
649
+
650
+ table.add_row(
651
+ proc["label"],
652
+ status_display,
653
+ proc.get("uptime", ""),
654
+ proc.get("last_action", ""),
655
+ proc.get("detail", "")[:40],
656
+ )
657
+
658
+ # Show daemon log tail in detail area
659
+ lines = ["[bold]Recent Daemon Activity[/]\n"]
660
+ if DAEMON_LOG_FILE.exists():
661
+ try:
662
+ with open(DAEMON_LOG_FILE, "rb") as f:
663
+ f.seek(0, 2)
664
+ fsize = f.tell()
665
+ read_size = min(fsize, 4096)
666
+ f.seek(fsize - read_size)
667
+ tail = f.read().decode("utf-8", errors="replace")
668
+ for log_line in tail.strip().split("\n")[-10:]:
669
+ try:
670
+ entry = json.loads(log_line)
671
+ ts = entry.get("ts", "")[:19].replace("T", " ")
672
+ action = entry.get("action", "")
673
+ item_id = entry.get("item_id", "")
674
+ log_detail = entry.get("detail", "")[:50]
675
+ risk = entry.get("risk", "")
676
+ risk_color = "red" if risk == "high" else "yellow" if risk == "medium" else "green"
677
+ lines.append(
678
+ f" [dim]{ts}[/] {action:<15} {item_id:<10} "
679
+ f"[{risk_color}]{risk}[/] [dim]{log_detail}[/]"
680
+ )
681
+ except json.JSONDecodeError:
682
+ continue
683
+ except (OSError, UnicodeDecodeError):
684
+ lines.append(" [dim]Could not read daemon log.[/]")
685
+ else:
686
+ lines.append(" [dim]No daemon log found.[/]")
687
+
688
+ # Show alerts
689
+ lines.append("\n[bold]Active Alerts[/]\n")
690
+ alert_count = 0
691
+ if ALERTS_DIR.exists():
692
+ for alert_file in sorted(ALERTS_DIR.glob("*.json")):
693
+ try:
694
+ alert = json.loads(alert_file.read_text())
695
+ alert_name = alert.get("alert", alert_file.stem)
696
+ reason = alert.get("reason", "")[:60]
697
+ alert_ts = alert.get("timestamp", "")[:19].replace("T", " ")
698
+ lines.append(f" [red]![/] [bold]{alert_name}[/] [dim]{alert_ts}[/]")
699
+ if reason:
700
+ lines.append(f" {reason}")
701
+ alert_count += 1
702
+ except (json.JSONDecodeError, OSError):
703
+ continue
704
+ if alert_count == 0:
705
+ lines.append(" [green]No active alerts.[/]")
706
+
707
+ detail.update("\n".join(lines))
708
+
709
+
202
710
  class GovernanceBar(Static):
203
- """Top status bar governance health at a glance."""
711
+ """Top status bar -- governance health at a glance."""
204
712
 
205
713
  def compose(self) -> ComposeResult:
206
714
  yield Static(id="gov-bar")
@@ -216,19 +724,24 @@ class GovernanceBar(Static):
216
724
  mode_file = Path.home() / ".delimit" / "enforcement_mode"
217
725
  mode = mode_file.read_text().strip() if mode_file.exists() else "default"
218
726
 
727
+ # Notification badge
728
+ notif_count = NotificationPanel.get_unread_count()
729
+ notif_badge = f" | [yellow]Notif:[/] {notif_count}" if notif_count > 0 else ""
730
+
219
731
  bar.update(
220
732
  f" [bold magenta]</>[/] [bold]Delimit OS[/] | "
221
733
  f"[cyan]Ledger:[/] {ledger_count} open | "
222
734
  f"[cyan]Swarm:[/] {swarm['agents']} agents / {swarm['ventures']} ventures | "
223
- f"[cyan]Mode:[/] {mode} | "
735
+ f"[cyan]Mode:[/] {mode}"
736
+ f"{notif_badge} | "
224
737
  f"[dim]{time.strftime('%H:%M')}[/]"
225
738
  )
226
739
 
227
740
 
228
- # ── Main App ─────────────────────────────────────────────────────────
741
+ # -- Main App -----------------------------------------------------------------
229
742
 
230
743
  class DelimitOS(App):
231
- """Delimit OS the AI developer operating system."""
744
+ """Delimit OS -- the AI developer operating system."""
232
745
 
233
746
  CSS = """
234
747
  Screen {
@@ -246,7 +759,7 @@ class DelimitOS(App):
246
759
  DataTable {
247
760
  height: 1fr;
248
761
  }
249
- #swarm-content, #session-content {
762
+ #swarm-content, #session-content, #ventures-content {
250
763
  padding: 1;
251
764
  }
252
765
  """
@@ -255,14 +768,17 @@ class DelimitOS(App):
255
768
  SUB_TITLE = "AI Developer Operating System"
256
769
 
257
770
  BINDINGS = [
258
- ("q", "quit", "Quit"),
259
- ("l", "focus_ledger", "Ledger"),
260
- ("s", "focus_swarm", "Swarm"),
261
- ("v", "focus_ventures", "Ventures"),
262
- ("h", "focus_sessions", "History"),
263
- ("r", "refresh", "Refresh"),
264
- ("t", "think", "Think"),
265
- ("b", "build", "Build"),
771
+ Binding("q", "quit", "Quit", key_display="Q"),
772
+ Binding("l", "focus_ledger", "Ledger", key_display="L"),
773
+ Binding("s", "focus_swarm", "Swarm", key_display="S"),
774
+ Binding("n", "focus_notifications", "Notifications", key_display="N"),
775
+ Binding("f", "focus_files", "Files", key_display="F"),
776
+ Binding("p", "focus_processes", "Processes", key_display="P"),
777
+ Binding("v", "focus_ventures", "Ventures", key_display="V"),
778
+ Binding("h", "focus_sessions", "History", key_display="H"),
779
+ Binding("t", "think", "Think", key_display="T"),
780
+ Binding("b", "build", "Build", key_display="B"),
781
+ Binding("r", "refresh", "Refresh", key_display="R"),
266
782
  ]
267
783
 
268
784
  def compose(self) -> ComposeResult:
@@ -272,32 +788,59 @@ class DelimitOS(App):
272
788
  yield LedgerPanel()
273
789
  with TabPane("Swarm", id="tab-swarm"):
274
790
  yield SwarmPanel()
791
+ with TabPane("Notifications", id="tab-notifications"):
792
+ yield NotificationPanel()
793
+ with TabPane("Files", id="tab-files"):
794
+ yield FilesystemPanel()
795
+ with TabPane("Processes", id="tab-processes"):
796
+ yield ProcessPanel()
275
797
  with TabPane("Ventures", id="tab-ventures"):
276
798
  yield VenturesPanel()
277
799
  with TabPane("Sessions", id="tab-sessions"):
278
800
  yield SessionPanel()
279
801
  yield Footer()
280
802
 
803
+ # -- Tab focus actions -----------------------------------------------------
804
+
281
805
  def action_focus_ledger(self) -> None:
282
806
  self.query_one(TabbedContent).active = "tab-ledger"
283
807
 
284
808
  def action_focus_swarm(self) -> None:
285
809
  self.query_one(TabbedContent).active = "tab-swarm"
286
810
 
811
+ def action_focus_notifications(self) -> None:
812
+ self.query_one(TabbedContent).active = "tab-notifications"
813
+
814
+ def action_focus_files(self) -> None:
815
+ self.query_one(TabbedContent).active = "tab-files"
816
+
817
+ def action_focus_processes(self) -> None:
818
+ self.query_one(TabbedContent).active = "tab-processes"
819
+
287
820
  def action_focus_ventures(self) -> None:
288
821
  self.query_one(TabbedContent).active = "tab-ventures"
289
822
 
290
823
  def action_focus_sessions(self) -> None:
291
824
  self.query_one(TabbedContent).active = "tab-sessions"
292
825
 
826
+ # -- Global actions --------------------------------------------------------
827
+
293
828
  def action_refresh(self) -> None:
829
+ """Refresh all panels."""
294
830
  for panel in self.query(LedgerPanel):
295
831
  panel._refresh_data()
296
832
  for panel in self.query(SwarmPanel):
297
833
  panel._refresh_data()
298
834
  for panel in self.query(SessionPanel):
299
835
  panel._refresh_data()
836
+ for panel in self.query(NotificationPanel):
837
+ panel._refresh_data()
838
+ for panel in self.query(ProcessPanel):
839
+ panel._refresh_data()
840
+ for panel in self.query(VenturesPanel):
841
+ panel._refresh_data()
300
842
  self.query_one(GovernanceBar)._refresh()
843
+ self.notify("All panels refreshed", title="Refresh")
301
844
 
302
845
  @work(thread=True)
303
846
  def action_think(self) -> None:
@@ -333,14 +876,14 @@ class DelimitOS(App):
333
876
  timeout=10,
334
877
  )
335
878
  else:
336
- self.notify("Ledger is clear nothing to build!", title="Build")
879
+ self.notify("Ledger is clear -- nothing to build!", title="Build")
337
880
 
338
881
 
339
882
  def main():
340
883
  """Entry point for 'delimit' command."""
341
884
  import sys
342
885
  if "--quick" in sys.argv:
343
- # Quick status mode no interactive TUI
886
+ # Quick status mode -- no interactive TUI
344
887
  from rich.console import Console
345
888
  from rich.table import Table
346
889
 
@@ -367,6 +910,21 @@ def main():
367
910
  item.get("venture", "")[:15],
368
911
  )
369
912
  console.print(table)
913
+
914
+ # Quick notification summary
915
+ notif_count = NotificationPanel.get_unread_count()
916
+ if notif_count > 0:
917
+ console.print(f"\n[yellow]Notifications:[/] {notif_count} in the last hour")
918
+
919
+ # Quick process summary
920
+ processes = _load_process_list()
921
+ running = [p for p in processes if p["status"] in ("running", "active")]
922
+ stopped = [p for p in processes if p["status"] not in ("running", "active", "inactive")]
923
+ if running:
924
+ console.print(f"[green]Running:[/] {', '.join(p['label'] for p in running)}")
925
+ if stopped:
926
+ console.print(f"[red]Stopped:[/] {', '.join(p['label'] for p in stopped)}")
927
+
370
928
  return
371
929
 
372
930
  app = DelimitOS()