aline-ai 0.5.5__py3-none-any.whl → 0.5.7__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.
@@ -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
66
  }
68
67
 
69
- SessionsTable .summary-section Static {
70
- width: 1fr;
71
- height: auto;
72
- }
73
-
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
@@ -552,8 +865,7 @@ class SessionsTable(Container, can_focus=True):
552
865
 
553
866
  # Update section header
554
867
  header_widget = self.query_one("#section-header", Static)
555
- mode = "Wrap" if self.wrap_mode else "Compact"
556
- header_widget.update(f"[bold]Sessions[/bold] [dim]({mode})[/dim]")
868
+ header_widget.update("[bold]Sessions[/bold]")
557
869
 
558
870
  # Update table
559
871
  table = self.query_one("#sessions-table", DataTable)
@@ -565,44 +877,42 @@ class SessionsTable(Container, can_focus=True):
565
877
  )
566
878
  except Exception:
567
879
  selected_session_id = None
568
- if self.wrap_mode != self._last_wrap_mode:
569
- self._setup_table_columns(table)
570
- self._last_wrap_mode = bool(self.wrap_mode)
571
- else:
572
- table.clear()
880
+ table.clear()
573
881
 
574
- if self.wrap_mode:
575
- table.styles.overflow_x = "auto"
576
- table.show_horizontal_scrollbar = True
577
- else:
578
- table.styles.overflow_x = "hidden"
579
- table.show_horizontal_scrollbar = False
882
+ # Always enable horizontal scrollbar
883
+ table.styles.overflow_x = "auto"
884
+ table.show_horizontal_scrollbar = True
580
885
 
581
886
  for session in self._sessions:
887
+ sid = session["id"]
582
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"]
583
893
  if self.wrap_mode:
584
- title_cell = title
585
- session_id_cell = session["id"]
586
- else:
587
- title_cell = title[:40] + "..." if len(title) > 40 else title
588
- session_id_cell = session["short_id"]
894
+ display_id = sid
589
895
 
896
+ # Column order: ✓, #, Title, Project, Source, Turns, Session ID, Last Activity
590
897
  table.add_row(
591
- self._checkbox_cell(session["id"]),
592
- str(session["index"]),
593
- session_id_cell,
594
- session["source"],
595
- session["project"],
596
- str(session["turns"]),
597
- title_cell,
598
- session["last_activity"],
599
- 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,
600
907
  )
601
908
 
602
909
  if table.row_count > 0:
603
910
  if selected_session_id:
604
911
  try:
605
- 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
+ )
606
916
  except Exception:
607
917
  table.cursor_coordinate = (0, 0)
608
918
  else:
@@ -612,7 +922,7 @@ class SessionsTable(Container, can_focus=True):
612
922
  total_pages = self._get_total_pages()
613
923
  pagination_widget = self.query_one("#pagination-info", Static)
614
924
  pagination_widget.update(
615
- 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]"
616
926
  )
617
927
 
618
928
  def _shorten_session_id(self, session_id: str) -> str: