aline-ai 0.5.4__py3-none-any.whl → 0.5.6__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 (82) hide show
  1. {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/METADATA +1 -1
  2. aline_ai-0.5.6.dist-info/RECORD +95 -0
  3. realign/__init__.py +1 -1
  4. realign/adapters/antigravity.py +28 -20
  5. realign/adapters/base.py +46 -50
  6. realign/adapters/claude.py +14 -14
  7. realign/adapters/codex.py +7 -7
  8. realign/adapters/gemini.py +11 -11
  9. realign/adapters/registry.py +14 -10
  10. realign/claude_detector.py +2 -2
  11. realign/claude_hooks/__init__.py +3 -3
  12. realign/claude_hooks/permission_request_hook_installer.py +31 -32
  13. realign/claude_hooks/stop_hook.py +4 -1
  14. realign/claude_hooks/stop_hook_installer.py +30 -31
  15. realign/cli.py +23 -4
  16. realign/codex_detector.py +11 -11
  17. realign/commands/add.py +88 -65
  18. realign/commands/config.py +3 -12
  19. realign/commands/context.py +3 -1
  20. realign/commands/export_shares.py +86 -127
  21. realign/commands/import_shares.py +145 -155
  22. realign/commands/init.py +166 -30
  23. realign/commands/restore.py +18 -6
  24. realign/commands/search.py +14 -42
  25. realign/commands/upgrade.py +155 -11
  26. realign/commands/watcher.py +98 -219
  27. realign/commands/worker.py +29 -6
  28. realign/config.py +25 -20
  29. realign/context.py +1 -3
  30. realign/dashboard/app.py +34 -24
  31. realign/dashboard/screens/__init__.py +10 -1
  32. realign/dashboard/screens/create_agent.py +244 -0
  33. realign/dashboard/screens/create_event.py +3 -1
  34. realign/dashboard/screens/event_detail.py +14 -6
  35. realign/dashboard/screens/help_screen.py +114 -0
  36. realign/dashboard/screens/session_detail.py +3 -1
  37. realign/dashboard/screens/share_import.py +7 -3
  38. realign/dashboard/tmux_manager.py +54 -9
  39. realign/dashboard/widgets/config_panel.py +85 -1
  40. realign/dashboard/widgets/events_table.py +314 -70
  41. realign/dashboard/widgets/header.py +2 -1
  42. realign/dashboard/widgets/search_panel.py +37 -27
  43. realign/dashboard/widgets/sessions_table.py +404 -85
  44. realign/dashboard/widgets/terminal_panel.py +155 -175
  45. realign/dashboard/widgets/watcher_panel.py +6 -2
  46. realign/dashboard/widgets/worker_panel.py +10 -1
  47. realign/db/__init__.py +1 -1
  48. realign/db/base.py +5 -15
  49. realign/db/locks.py +0 -1
  50. realign/db/migration.py +82 -76
  51. realign/db/schema.py +2 -6
  52. realign/db/sqlite_db.py +23 -41
  53. realign/events/__init__.py +0 -1
  54. realign/events/event_summarizer.py +27 -15
  55. realign/events/session_summarizer.py +29 -15
  56. realign/file_lock.py +1 -0
  57. realign/hooks.py +150 -60
  58. realign/logging_config.py +12 -15
  59. realign/mcp_server.py +30 -51
  60. realign/mcp_watcher.py +0 -1
  61. realign/models/event.py +29 -20
  62. realign/prompts/__init__.py +7 -7
  63. realign/prompts/presets.py +15 -11
  64. realign/redactor.py +99 -59
  65. realign/triggers/__init__.py +9 -9
  66. realign/triggers/antigravity_trigger.py +30 -28
  67. realign/triggers/base.py +4 -3
  68. realign/triggers/claude_trigger.py +104 -85
  69. realign/triggers/codex_trigger.py +15 -5
  70. realign/triggers/gemini_trigger.py +57 -47
  71. realign/triggers/next_turn_trigger.py +3 -1
  72. realign/triggers/registry.py +6 -2
  73. realign/triggers/turn_status.py +3 -1
  74. realign/watcher_core.py +306 -131
  75. realign/watcher_daemon.py +8 -8
  76. realign/worker_core.py +3 -1
  77. realign/worker_daemon.py +3 -1
  78. aline_ai-0.5.4.dist-info/RECORD +0 -93
  79. {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/WHEEL +0 -0
  80. {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/entry_points.txt +0 -0
  81. {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/licenses/LICENSE +0 -0
  82. {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,11 @@
1
1
  """Sessions Table Widget with keyboard pagination."""
2
2
 
3
+ import contextlib
4
+ import io
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
3
9
  from datetime import datetime
4
10
  from pathlib import Path
5
11
  from typing import List, Optional, Set
@@ -7,7 +13,7 @@ from typing import List, Optional, Set
7
13
  from textual import events
8
14
  from textual.app import ComposeResult
9
15
  from textual.binding import Binding
10
- from textual.containers import Container, Horizontal
16
+ from textual.containers import Container, Horizontal, Vertical
11
17
  from textual.reactive import reactive
12
18
  from textual.worker import Worker, WorkerState
13
19
  from textual.widgets import Button, DataTable, Static
@@ -44,7 +50,7 @@ class SessionsListTable(OpenableDataTable):
44
50
  self.owner.apply_mouse_selection(row_index, shift=event.shift, meta=event.meta)
45
51
 
46
52
 
47
- class SessionsTable(Container, can_focus=True):
53
+ class SessionsTable(Container):
48
54
  """Table displaying sessions with keyboard pagination support."""
49
55
 
50
56
  DEFAULT_CSS = """
@@ -54,26 +60,14 @@ class SessionsTable(Container, can_focus=True):
54
60
  overflow: hidden;
55
61
  }
56
62
 
57
- SessionsTable:focus {
58
- border: solid $accent;
59
- }
60
-
61
- SessionsTable .summary-section {
63
+ SessionsTable .action-section {
62
64
  height: auto;
63
65
  margin-bottom: 1;
64
- padding: 1;
65
- background: $surface-darken-1;
66
- border: solid $primary-darken-2;
67
- }
68
-
69
- SessionsTable .summary-section Static {
70
- width: 1fr;
71
- height: auto;
72
66
  }
73
67
 
74
- SessionsTable .summary-section Button {
75
- width: 12;
76
- margin-left: 1;
68
+ SessionsTable .action-section Button {
69
+ width: auto;
70
+ margin-bottom: 1;
77
71
  }
78
72
 
79
73
  SessionsTable .section-header {
@@ -83,13 +77,15 @@ class SessionsTable(Container, can_focus=True):
83
77
 
84
78
  SessionsTable .table-container {
85
79
  height: auto;
86
- overflow: hidden;
80
+ overflow-x: auto;
81
+ overflow-y: hidden;
87
82
  }
88
83
 
89
84
  SessionsTable DataTable {
90
85
  height: auto;
91
86
  max-height: 100%;
92
- overflow: hidden;
87
+ overflow-x: auto;
88
+ overflow-y: hidden;
93
89
  }
94
90
 
95
91
  SessionsTable .pagination-info {
@@ -98,6 +94,12 @@ class SessionsTable(Container, can_focus=True):
98
94
  color: $text-muted;
99
95
  text-align: center;
100
96
  }
97
+
98
+ SessionsTable .stats-info {
99
+ height: 1;
100
+ color: $text-muted;
101
+ text-align: center;
102
+ }
101
103
  """
102
104
 
103
105
  # Reactive properties
@@ -108,25 +110,38 @@ class SessionsTable(Container, can_focus=True):
108
110
  def __init__(self) -> None:
109
111
  super().__init__()
110
112
  self._sessions: list = []
113
+ self._sessions_by_id: dict = {} # Index sessions by id for quick lookup
111
114
  self._total_sessions: int = 0
112
115
  self._stats: dict = {}
113
116
  self._selected_session_ids: Set[str] = set()
114
117
  self._selection_anchor_row: Optional[int] = None
115
118
  self._last_wrap_mode: bool = bool(self.wrap_mode)
116
119
  self._refresh_worker: Optional[Worker] = None
120
+ self._share_export_worker: Optional[Worker] = None
117
121
  self._refresh_timer = None
118
122
  self._active_refresh_snapshot: Optional[tuple[int, int]] = None
119
123
  self._pending_refresh_snapshot: Optional[tuple[int, int]] = None
120
124
 
121
125
  def compose(self) -> ComposeResult:
122
126
  """Compose the sessions table layout."""
123
- with Horizontal(classes="summary-section"):
124
- yield Static(id="sessions-summary")
125
- yield Button("Load ctx (l)", id="load-context-btn", variant="primary")
127
+ with Vertical(classes="action-section"):
128
+ yield Button(
129
+ "Load selected context to current agent",
130
+ id="load-context-btn",
131
+ variant="primary",
132
+ disabled=True,
133
+ )
134
+ yield Button(
135
+ "Share selected contexts to others",
136
+ id="share-context-btn",
137
+ variant="primary",
138
+ disabled=True,
139
+ )
126
140
  yield Static(id="section-header", classes="section-header")
127
141
  with Container(classes="table-container"):
128
142
  yield SessionsListTable(id="sessions-table")
129
143
  yield Static(id="pagination-info", classes="pagination-info")
144
+ yield Static(id="stats-info", classes="stats-info")
130
145
 
131
146
  def on_mount(self) -> None:
132
147
  """Set up the table on mount."""
@@ -171,7 +186,9 @@ class SessionsTable(Container, can_focus=True):
171
186
  except Exception:
172
187
  pass
173
188
 
174
- def on_openable_data_table_row_activated(self, event: OpenableDataTable.RowActivated) -> None:
189
+ def on_openable_data_table_row_activated(
190
+ self, event: OpenableDataTable.RowActivated
191
+ ) -> None:
175
192
  if event.data_table.id != "sessions-table":
176
193
  return
177
194
 
@@ -191,7 +208,13 @@ class SessionsTable(Container, can_focus=True):
191
208
  def _setup_table_columns(self, table: DataTable) -> None:
192
209
  table.clear(columns=True)
193
210
  table.add_column("✓", key="sel", width=2)
194
- table.add_columns("#", "Session ID", "Source", "Project", "Turns", "Title", "Last Activity")
211
+ table.add_column("#", key="index", width=3)
212
+ table.add_column("Title", key="title") # Auto width for full title
213
+ table.add_column("Project", key="project", width=15)
214
+ table.add_column("Source", key="source", width=10)
215
+ table.add_column("Turns", key="turns", width=6)
216
+ table.add_column("Session ID", key="session_id", width=20)
217
+ table.add_column("Last Activity", key="last_activity", width=12)
195
218
  table.cursor_type = "row"
196
219
 
197
220
  def _sync_to_available_height(self) -> None:
@@ -220,7 +243,9 @@ class SessionsTable(Container, can_focus=True):
220
243
  if table.row_count == 0:
221
244
  return
222
245
  try:
223
- session_id = str(table.coordinate_to_cell_key(table.cursor_coordinate)[0].value)
246
+ session_id = str(
247
+ table.coordinate_to_cell_key(table.cursor_coordinate)[0].value
248
+ )
224
249
  except Exception:
225
250
  return
226
251
 
@@ -269,19 +294,28 @@ class SessionsTable(Container, can_focus=True):
269
294
  self._selected_session_ids.update(ids_in_range)
270
295
  else:
271
296
  self._selected_session_ids = set(ids_in_range)
272
- elif meta:
297
+ else:
298
+ # Toggle selection on click (no modifier keys needed)
273
299
  if clicked_id in self._selected_session_ids:
274
300
  self._selected_session_ids.remove(clicked_id)
275
301
  else:
276
302
  self._selected_session_ids.add(clicked_id)
277
- else:
278
- self._selected_session_ids = {clicked_id}
279
303
 
280
304
  self._selection_anchor_row = row_index
281
305
  self._refresh_checkboxes_only()
282
306
 
283
307
  def _checkbox_cell(self, session_id: str) -> str:
284
- return "[green]☑[/green]" if session_id in self._selected_session_ids else "☐"
308
+ return (
309
+ "[bold green]●[/bold green]"
310
+ if session_id in self._selected_session_ids
311
+ else "○"
312
+ )
313
+
314
+ def _format_cell(self, value: str, session_id: str) -> str:
315
+ """Format cell value with bold if selected."""
316
+ if session_id in self._selected_session_ids:
317
+ return f"[bold]{value}[/bold]"
318
+ return value
285
319
 
286
320
  def _refresh_checkboxes_only(self) -> None:
287
321
  table = self.query_one("#sessions-table", DataTable)
@@ -296,40 +330,80 @@ class SessionsTable(Container, can_focus=True):
296
330
  continue
297
331
  if not sid:
298
332
  continue
333
+ session = self._sessions_by_id.get(sid)
334
+ if not session:
335
+ continue
299
336
  try:
337
+ # Update all cells in the row with proper formatting
300
338
  table.update_cell(sid, "sel", self._checkbox_cell(sid))
339
+ table.update_cell(
340
+ sid, "index", self._format_cell(str(session["index"]), sid)
341
+ )
342
+ table.update_cell(
343
+ sid, "title", self._format_cell(session["title"], sid)
344
+ )
345
+ table.update_cell(
346
+ sid, "project", self._format_cell(session["project"], sid)
347
+ )
348
+ table.update_cell(
349
+ sid, "source", self._format_cell(session["source"], sid)
350
+ )
351
+ table.update_cell(
352
+ sid, "turns", self._format_cell(str(session["turns"]), sid)
353
+ )
354
+ table.update_cell(
355
+ sid, "session_id", self._format_cell(session["short_id"], sid)
356
+ )
357
+ table.update_cell(
358
+ sid,
359
+ "last_activity",
360
+ self._format_cell(session["last_activity"], sid),
361
+ )
301
362
  except Exception:
302
363
  continue
303
364
 
304
365
  self._update_summary_widget()
305
366
 
306
367
  def _update_summary_widget(self) -> None:
307
- summary_widget = self.query_one("#sessions-summary", Static)
368
+ # Update stats at the bottom
369
+ stats_widget = self.query_one("#stats-info", Static)
308
370
  total = self._stats.get("total", 0)
309
371
  claude = self._stats.get("claude", 0)
310
372
  codex = self._stats.get("codex", 0)
311
373
  gemini = self._stats.get("gemini", 0)
312
374
 
313
- summary_text = f"[bold]Total Sessions:[/bold] {total}"
375
+ stats_parts = [f"Total: {total}"]
314
376
  if claude > 0:
315
- summary_text += f" | [cyan]Claude:[/cyan] {claude}"
377
+ stats_parts.append(f"Claude: {claude}")
316
378
  if codex > 0:
317
- summary_text += f" | [magenta]Codex:[/magenta] {codex}"
379
+ stats_parts.append(f"Codex: {codex}")
318
380
  if gemini > 0:
319
- summary_text += f" | [yellow]Gemini:[/yellow] {gemini}"
381
+ stats_parts.append(f"Gemini: {gemini}")
320
382
 
321
383
  selected_count = len(self._selected_session_ids)
322
384
  if selected_count:
323
- summary_text += f" | [bold]Selected:[/bold] {selected_count}"
385
+ stats_parts.append(f"Selected: {selected_count}")
324
386
 
325
- summary_widget.update(summary_text)
387
+ stats_widget.update(f"[dim]{' | '.join(stats_parts)}[/dim]")
388
+
389
+ # Enable/disable buttons based on selection
390
+ load_btn = self.query_one("#load-context-btn", Button)
391
+ load_btn.disabled = selected_count == 0
392
+ share_btn = self.query_one("#share-context-btn", Button)
393
+ share_btn.disabled = selected_count == 0
326
394
 
327
395
  async def on_button_pressed(self, event: Button.Pressed) -> None:
328
- if (event.button.id or "") != "load-context-btn":
396
+ button_id = event.button.id or ""
397
+
398
+ if button_id == "load-context-btn":
399
+ action = getattr(self.app, "action_load_context", None)
400
+ if callable(action):
401
+ await action()
402
+ return
403
+
404
+ if button_id == "share-context-btn":
405
+ self._start_share_export()
329
406
  return
330
- action = getattr(self.app, "action_load_context", None)
331
- if callable(action):
332
- await action()
333
407
 
334
408
  def _calculate_rows_per_page(self) -> None:
335
409
  """Calculate rows per page based on available height."""
@@ -407,6 +481,15 @@ class SessionsTable(Container, can_focus=True):
407
481
  self.refresh_data()
408
482
 
409
483
  def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
484
+ # Handle share export worker
485
+ if (
486
+ self._share_export_worker is not None
487
+ and event.worker is self._share_export_worker
488
+ ):
489
+ self._on_share_export_worker_changed(event)
490
+ return
491
+
492
+ # Handle refresh worker
410
493
  if self._refresh_worker is None or event.worker is not self._refresh_worker:
411
494
  return
412
495
 
@@ -434,13 +517,243 @@ class SessionsTable(Container, can_focus=True):
434
517
  self._stats = {}
435
518
  try:
436
519
  self._sessions = list(result.get("sessions") or [])
520
+ self._sessions_by_id = {s["id"]: s for s in self._sessions}
437
521
  except Exception:
438
522
  self._sessions = []
523
+ self._sessions_by_id = {}
439
524
  self._update_display()
440
525
 
441
526
  if self._pending_refresh_snapshot is not None:
442
527
  self.refresh_data()
443
528
 
529
+ def _start_share_export(self) -> None:
530
+ """Generate an event from selected sessions and export as share link."""
531
+ selected_ids = list(self._selected_session_ids)
532
+ if not selected_ids:
533
+ return
534
+
535
+ if (
536
+ self._share_export_worker is not None
537
+ and self._share_export_worker.state
538
+ in (
539
+ WorkerState.PENDING,
540
+ WorkerState.RUNNING,
541
+ )
542
+ ):
543
+ return
544
+
545
+ def work() -> dict:
546
+ # Step 1: Generate event from selected sessions
547
+ session_selector = ",".join(selected_ids)
548
+ stdout_gen = io.StringIO()
549
+ stderr_gen = io.StringIO()
550
+
551
+ with (
552
+ contextlib.redirect_stdout(stdout_gen),
553
+ contextlib.redirect_stderr(stderr_gen),
554
+ ):
555
+ from ...commands.watcher import watcher_event_generate_command
556
+
557
+ gen_exit_code = watcher_event_generate_command(
558
+ session_selector=session_selector,
559
+ show_sessions=False,
560
+ )
561
+
562
+ if gen_exit_code != 0:
563
+ return {
564
+ "step": "generate",
565
+ "exit_code": gen_exit_code,
566
+ "stderr": stderr_gen.getvalue().strip(),
567
+ }
568
+
569
+ # Extract event_id from output
570
+ gen_output = stdout_gen.getvalue().strip()
571
+ event_id = None
572
+ for line in gen_output.split("\n"):
573
+ if line.startswith("Event ID:"):
574
+ event_id = line.split(":", 1)[1].strip()
575
+ break
576
+
577
+ if not event_id:
578
+ # Try to find the most recent event
579
+ try:
580
+ from ...db import get_database
581
+
582
+ db = get_database()
583
+ conn = db._get_connection()
584
+ row = conn.execute(
585
+ "SELECT id FROM events ORDER BY created_at DESC LIMIT 1"
586
+ ).fetchone()
587
+ if row:
588
+ event_id = row[0]
589
+ except Exception:
590
+ pass
591
+
592
+ if not event_id:
593
+ return {
594
+ "step": "generate",
595
+ "exit_code": 1,
596
+ "stderr": "Could not determine event ID",
597
+ }
598
+
599
+ # Step 2: Export the event as a share link
600
+ stdout_exp = io.StringIO()
601
+ stderr_exp = io.StringIO()
602
+
603
+ with (
604
+ contextlib.redirect_stdout(stdout_exp),
605
+ contextlib.redirect_stderr(stderr_exp),
606
+ ):
607
+ from ...commands import export_shares
608
+
609
+ exp_exit_code = export_shares.export_shares_interactive_command(
610
+ indices=event_id,
611
+ password=None,
612
+ enable_preview=False,
613
+ json_output=True,
614
+ compact=True,
615
+ )
616
+
617
+ exp_output = stdout_exp.getvalue().strip()
618
+ result: dict = {
619
+ "step": "export",
620
+ "exit_code": exp_exit_code,
621
+ "output": exp_output,
622
+ "stderr": stderr_exp.getvalue().strip(),
623
+ "event_id": event_id,
624
+ }
625
+
626
+ if exp_output:
627
+ # Try to extract JSON from output (may contain other text)
628
+ try:
629
+ result["json"] = json.loads(exp_output)
630
+ except Exception:
631
+ # Try to find JSON object in output
632
+ json_start = exp_output.find("{")
633
+ json_end = exp_output.rfind("}") + 1
634
+ if json_start >= 0 and json_end > json_start:
635
+ try:
636
+ result["json"] = json.loads(exp_output[json_start:json_end])
637
+ except Exception:
638
+ result["json"] = None
639
+ else:
640
+ result["json"] = None
641
+ else:
642
+ result["json"] = None
643
+
644
+ return result
645
+
646
+ self.app.notify("Creating share link...", title="Share", timeout=2)
647
+ self._share_export_worker = self.run_worker(
648
+ work, thread=True, exit_on_error=False
649
+ )
650
+
651
+ def _on_share_export_worker_changed(self, event: Worker.StateChanged) -> None:
652
+ """Handle share export worker state changes."""
653
+ if event.state == WorkerState.ERROR:
654
+ err = (
655
+ self._share_export_worker.error
656
+ if self._share_export_worker
657
+ else "Unknown error"
658
+ )
659
+ self.app.notify(
660
+ f"Share export failed: {err}", title="Share", severity="error"
661
+ )
662
+ return
663
+
664
+ if event.state != WorkerState.SUCCESS:
665
+ return
666
+
667
+ result = self._share_export_worker.result if self._share_export_worker else {}
668
+ if not result:
669
+ result = {}
670
+
671
+ step = result.get("step", "")
672
+ exit_code = int(result.get("exit_code", 1))
673
+
674
+ if step == "generate" and exit_code != 0:
675
+ stderr = result.get("stderr", "")
676
+ self.app.notify(
677
+ f"Failed to generate event: {stderr}"
678
+ if stderr
679
+ else "Failed to generate event",
680
+ title="Share",
681
+ severity="error",
682
+ )
683
+ return
684
+
685
+ if exit_code != 0:
686
+ stderr = result.get("stderr", "")
687
+ self.app.notify(
688
+ f"Share export failed: {stderr}" if stderr else "Share export failed",
689
+ title="Share",
690
+ severity="error",
691
+ )
692
+ return
693
+
694
+ payload = result.get("json") or {}
695
+ share_link = payload.get("share_link") or payload.get("share_url")
696
+ slack_message = (
697
+ payload.get("slack_message") if isinstance(payload, dict) else None
698
+ )
699
+ event_id = result.get("event_id")
700
+
701
+ # Try to fetch share_link and slack_message from database
702
+ if event_id:
703
+ try:
704
+ from ...db import get_database
705
+
706
+ db = get_database()
707
+ event = db.get_event_by_id(event_id)
708
+ if event:
709
+ if not share_link:
710
+ share_link = getattr(event, "share_url", None)
711
+ if not slack_message:
712
+ slack_message = getattr(event, "slack_message", None)
713
+ except Exception:
714
+ pass
715
+
716
+ if not share_link:
717
+ self.app.notify(
718
+ "Share export completed but no link generated",
719
+ title="Share",
720
+ severity="warning",
721
+ )
722
+ return
723
+
724
+ # Build copy text
725
+ if slack_message:
726
+ copy_text = str(slack_message) + "\n\n" + str(share_link)
727
+ else:
728
+ copy_text = str(share_link)
729
+
730
+ # Copy to clipboard
731
+ copied = False
732
+ if os.environ.get("TMUX") and shutil.which("pbcopy"):
733
+ try:
734
+ copied = (
735
+ subprocess.run(
736
+ ["pbcopy"],
737
+ input=copy_text,
738
+ text=True,
739
+ capture_output=False,
740
+ check=False,
741
+ ).returncode
742
+ == 0
743
+ )
744
+ except Exception:
745
+ copied = False
746
+
747
+ if not copied:
748
+ try:
749
+ self.app.copy_to_clipboard(copy_text)
750
+ copied = True
751
+ except Exception:
752
+ copied = False
753
+
754
+ suffix = " (copied to clipboard)" if copied else ""
755
+ self.app.notify(f"Share link created{suffix}", title="Share", timeout=4)
756
+
444
757
  def _collect_snapshot(self, page: int, rows_per_page: int) -> dict:
445
758
  """Collect sessions + stats for a single page (background thread)."""
446
759
  total_sessions: int = 0
@@ -461,14 +774,16 @@ class SessionsTable(Container, can_focus=True):
461
774
  total_sessions = int(row[0]) if row else 0
462
775
 
463
776
  # Get stats
464
- stats_row = conn.execute("""
777
+ stats_row = conn.execute(
778
+ """
465
779
  SELECT
466
780
  COUNT(*) AS total,
467
781
  COUNT(CASE WHEN session_type = 'claude' THEN 1 END) AS claude,
468
782
  COUNT(CASE WHEN session_type = 'codex' THEN 1 END) AS codex,
469
783
  COUNT(CASE WHEN session_type = 'gemini' THEN 1 END) AS gemini
470
784
  FROM sessions
471
- """).fetchone()
785
+ """
786
+ ).fetchone()
472
787
 
473
788
  stats = {
474
789
  "total": stats_row[0] if stats_row else 0,
@@ -479,7 +794,8 @@ class SessionsTable(Container, can_focus=True):
479
794
 
480
795
  # Get paginated sessions
481
796
  offset = (int(page) - 1) * int(rows_per_page)
482
- rows = conn.execute("""
797
+ rows = conn.execute(
798
+ """
483
799
  SELECT
484
800
  s.id,
485
801
  s.session_type,
@@ -490,7 +806,9 @@ class SessionsTable(Container, can_focus=True):
490
806
  FROM sessions s
491
807
  ORDER BY s.last_activity_at DESC
492
808
  LIMIT ? OFFSET ?
493
- """, (int(rows_per_page), int(offset))).fetchall()
809
+ """,
810
+ (int(rows_per_page), int(offset)),
811
+ ).fetchall()
494
812
 
495
813
  for i, row in enumerate(rows):
496
814
  session_id = row[0]
@@ -517,16 +835,18 @@ class SessionsTable(Container, can_focus=True):
517
835
  except Exception:
518
836
  activity_str = last_activity
519
837
 
520
- sessions.append({
521
- "index": offset + i + 1,
522
- "id": session_id,
523
- "short_id": self._shorten_session_id(session_id),
524
- "source": source,
525
- "project": project,
526
- "turns": turn_count,
527
- "title": title,
528
- "last_activity": activity_str,
529
- })
838
+ sessions.append(
839
+ {
840
+ "index": offset + i + 1,
841
+ "id": session_id,
842
+ "short_id": self._shorten_session_id(session_id),
843
+ "source": source,
844
+ "project": project,
845
+ "turns": turn_count,
846
+ "title": title,
847
+ "last_activity": activity_str,
848
+ }
849
+ )
530
850
  except Exception:
531
851
  total_sessions = 0
532
852
  stats = {}
@@ -545,55 +865,54 @@ class SessionsTable(Container, can_focus=True):
545
865
 
546
866
  # Update section header
547
867
  header_widget = self.query_one("#section-header", Static)
548
- mode = "Wrap" if self.wrap_mode else "Compact"
549
- header_widget.update(f"[bold]Sessions[/bold] [dim]({mode})[/dim]")
868
+ header_widget.update("[bold]Sessions[/bold]")
550
869
 
551
870
  # Update table
552
871
  table = self.query_one("#sessions-table", DataTable)
553
872
  selected_session_id: Optional[str] = None
554
873
  try:
555
874
  if table.row_count > 0:
556
- selected_session_id = str(table.coordinate_to_cell_key(table.cursor_coordinate)[0].value)
875
+ selected_session_id = str(
876
+ table.coordinate_to_cell_key(table.cursor_coordinate)[0].value
877
+ )
557
878
  except Exception:
558
879
  selected_session_id = None
559
- if self.wrap_mode != self._last_wrap_mode:
560
- self._setup_table_columns(table)
561
- self._last_wrap_mode = bool(self.wrap_mode)
562
- else:
563
- table.clear()
880
+ table.clear()
564
881
 
565
- if self.wrap_mode:
566
- table.styles.overflow_x = "auto"
567
- table.show_horizontal_scrollbar = True
568
- else:
569
- table.styles.overflow_x = "hidden"
570
- table.show_horizontal_scrollbar = False
882
+ # Always enable horizontal scrollbar
883
+ table.styles.overflow_x = "auto"
884
+ table.show_horizontal_scrollbar = True
571
885
 
572
886
  for session in self._sessions:
887
+ sid = session["id"]
573
888
  title = session["title"]
889
+ if not self.wrap_mode and len(title) > 60:
890
+ title = title[:57] + "..."
891
+
892
+ display_id = session["short_id"]
574
893
  if self.wrap_mode:
575
- title_cell = title
576
- session_id_cell = session["id"]
577
- else:
578
- title_cell = title[:40] + "..." if len(title) > 40 else title
579
- session_id_cell = session["short_id"]
894
+ display_id = sid
580
895
 
896
+ # Column order: ✓, #, Title, Project, Source, Turns, Session ID, Last Activity
581
897
  table.add_row(
582
- self._checkbox_cell(session["id"]),
583
- str(session["index"]),
584
- session_id_cell,
585
- session["source"],
586
- session["project"],
587
- str(session["turns"]),
588
- title_cell,
589
- session["last_activity"],
590
- key=session["id"],
898
+ self._checkbox_cell(sid),
899
+ self._format_cell(str(session["index"]), sid),
900
+ self._format_cell(title, sid),
901
+ self._format_cell(session["project"], sid),
902
+ self._format_cell(session["source"], sid),
903
+ self._format_cell(str(session["turns"]), sid),
904
+ self._format_cell(display_id, sid),
905
+ self._format_cell(session["last_activity"], sid),
906
+ key=sid,
591
907
  )
592
908
 
593
909
  if table.row_count > 0:
594
910
  if selected_session_id:
595
911
  try:
596
- table.cursor_coordinate = (table.get_row_index(selected_session_id), 0)
912
+ table.cursor_coordinate = (
913
+ table.get_row_index(selected_session_id),
914
+ 0,
915
+ )
597
916
  except Exception:
598
917
  table.cursor_coordinate = (0, 0)
599
918
  else:
@@ -603,7 +922,7 @@ class SessionsTable(Container, can_focus=True):
603
922
  total_pages = self._get_total_pages()
604
923
  pagination_widget = self.query_one("#pagination-info", Static)
605
924
  pagination_widget.update(
606
- f"[dim]Page {self.current_page}/{total_pages} ({self._total_sessions} total) │ (p) prev (n) next (s) wrap[/dim]"
925
+ f"[dim]Page {self.current_page}/{total_pages} ({self._total_sessions} total) │ (p) prev (n) next[/dim]"
607
926
  )
608
927
 
609
928
  def _shorten_session_id(self, session_id: str) -> str: