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.
- {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/METADATA +1 -1
- aline_ai-0.5.6.dist-info/RECORD +95 -0
- realign/__init__.py +1 -1
- realign/adapters/antigravity.py +28 -20
- realign/adapters/base.py +46 -50
- realign/adapters/claude.py +14 -14
- realign/adapters/codex.py +7 -7
- realign/adapters/gemini.py +11 -11
- realign/adapters/registry.py +14 -10
- realign/claude_detector.py +2 -2
- realign/claude_hooks/__init__.py +3 -3
- realign/claude_hooks/permission_request_hook_installer.py +31 -32
- realign/claude_hooks/stop_hook.py +4 -1
- realign/claude_hooks/stop_hook_installer.py +30 -31
- realign/cli.py +23 -4
- realign/codex_detector.py +11 -11
- realign/commands/add.py +88 -65
- realign/commands/config.py +3 -12
- realign/commands/context.py +3 -1
- realign/commands/export_shares.py +86 -127
- realign/commands/import_shares.py +145 -155
- realign/commands/init.py +166 -30
- realign/commands/restore.py +18 -6
- realign/commands/search.py +14 -42
- realign/commands/upgrade.py +155 -11
- realign/commands/watcher.py +98 -219
- realign/commands/worker.py +29 -6
- realign/config.py +25 -20
- realign/context.py +1 -3
- realign/dashboard/app.py +34 -24
- realign/dashboard/screens/__init__.py +10 -1
- realign/dashboard/screens/create_agent.py +244 -0
- realign/dashboard/screens/create_event.py +3 -1
- realign/dashboard/screens/event_detail.py +14 -6
- realign/dashboard/screens/help_screen.py +114 -0
- realign/dashboard/screens/session_detail.py +3 -1
- realign/dashboard/screens/share_import.py +7 -3
- realign/dashboard/tmux_manager.py +54 -9
- realign/dashboard/widgets/config_panel.py +85 -1
- realign/dashboard/widgets/events_table.py +314 -70
- realign/dashboard/widgets/header.py +2 -1
- realign/dashboard/widgets/search_panel.py +37 -27
- realign/dashboard/widgets/sessions_table.py +404 -85
- realign/dashboard/widgets/terminal_panel.py +155 -175
- realign/dashboard/widgets/watcher_panel.py +6 -2
- realign/dashboard/widgets/worker_panel.py +10 -1
- realign/db/__init__.py +1 -1
- realign/db/base.py +5 -15
- realign/db/locks.py +0 -1
- realign/db/migration.py +82 -76
- realign/db/schema.py +2 -6
- realign/db/sqlite_db.py +23 -41
- realign/events/__init__.py +0 -1
- realign/events/event_summarizer.py +27 -15
- realign/events/session_summarizer.py +29 -15
- realign/file_lock.py +1 -0
- realign/hooks.py +150 -60
- realign/logging_config.py +12 -15
- realign/mcp_server.py +30 -51
- realign/mcp_watcher.py +0 -1
- realign/models/event.py +29 -20
- realign/prompts/__init__.py +7 -7
- realign/prompts/presets.py +15 -11
- realign/redactor.py +99 -59
- realign/triggers/__init__.py +9 -9
- realign/triggers/antigravity_trigger.py +30 -28
- realign/triggers/base.py +4 -3
- realign/triggers/claude_trigger.py +104 -85
- realign/triggers/codex_trigger.py +15 -5
- realign/triggers/gemini_trigger.py +57 -47
- realign/triggers/next_turn_trigger.py +3 -1
- realign/triggers/registry.py +6 -2
- realign/triggers/turn_status.py +3 -1
- realign/watcher_core.py +306 -131
- realign/watcher_daemon.py +8 -8
- realign/worker_core.py +3 -1
- realign/worker_daemon.py +3 -1
- aline_ai-0.5.4.dist-info/RECORD +0 -93
- {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/WHEEL +0 -0
- {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
|
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 .
|
|
75
|
-
width:
|
|
76
|
-
margin-
|
|
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:
|
|
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:
|
|
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
|
|
124
|
-
yield
|
|
125
|
-
|
|
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(
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
375
|
+
stats_parts = [f"Total: {total}"]
|
|
314
376
|
if claude > 0:
|
|
315
|
-
|
|
377
|
+
stats_parts.append(f"Claude: {claude}")
|
|
316
378
|
if codex > 0:
|
|
317
|
-
|
|
379
|
+
stats_parts.append(f"Codex: {codex}")
|
|
318
380
|
if gemini > 0:
|
|
319
|
-
|
|
381
|
+
stats_parts.append(f"Gemini: {gemini}")
|
|
320
382
|
|
|
321
383
|
selected_count = len(self._selected_session_ids)
|
|
322
384
|
if selected_count:
|
|
323
|
-
|
|
385
|
+
stats_parts.append(f"Selected: {selected_count}")
|
|
324
386
|
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
""",
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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(
|
|
583
|
-
str(session["index"]),
|
|
584
|
-
|
|
585
|
-
session["
|
|
586
|
-
session["
|
|
587
|
-
str(session["turns"]),
|
|
588
|
-
|
|
589
|
-
session["last_activity"],
|
|
590
|
-
key=
|
|
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 = (
|
|
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
|
|
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:
|