Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion termstory/insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,9 @@ def detect_late_night_chaotic_sessions(db=None) -> List[Dict]:
c_s_id, cmd, exit_code = row
commands_by_session[c_s_id].append((cmd, exit_code))

# 3. Evaluate chaos scoring in memory
# 3. Evaluate chaos scoring in memory
chaotic_candidates = []
project_ids_needing_commits = set()
for row in candidate_sessions:
s_id, start, end, duration, p_id, hour = row
cmd_rows = commands_by_session.get(s_id, [])
Expand Down Expand Up @@ -442,6 +443,55 @@ def detect_late_night_chaotic_sessions(db=None) -> List[Dict]:
"failed_commands": failed_cmds,
"hour": hour
})
if p_id is not None:
project_ids_needing_commits.add(p_id)

# 4. Bulk-fetch commits within precise session windows to avoid over-fetching
commits_by_project = defaultdict(list)
sessions_with_project = [s for s in chaotic_candidates if s["project_id"] is not None]
if sessions_with_project:
# Chunking sessions_with_project to keep parameter count below SQLite limits (e.g., max 250 sessions = 750 parameters)
chunk_size = 250
for i in range(0, len(sessions_with_project), chunk_size):
chunk = sessions_with_project[i:i + chunk_size]
clauses = []
query_args = []
for session in chunk:
start = session["start_time"]
end = session["end_time"]
min_ts = start - 300
max_ts = end + 600 if end is not None else start + 3600
clauses.append("(project_id = ? AND timestamp >= ? AND timestamp <= ?)")
query_args.extend([session["project_id"], min_ts, max_ts])

if clauses:
cursor.execute(f"""
SELECT project_id, timestamp, message
FROM commits
WHERE {" OR ".join(clauses)}
ORDER BY timestamp ASC
""", query_args)
for c_row in cursor.fetchall():
c_pid, c_ts, c_msg = c_row
commits_by_project[c_pid].append((c_ts, c_msg))

# 5. Filter and associate commits in memory
for session in chaotic_candidates:
p_id = session["project_id"]
start = session["start_time"]
end = session["end_time"]

commits = []
if p_id is not None:
max_ts = end + 600 if end is not None else start + 3600
min_ts = start - 300
for c_ts, c_msg in commits_by_project[p_id]:
if min_ts <= c_ts <= max_ts:
commits.append(c_msg)

session["commits"] = commits
del session["project_id"]
chaotic_sessions.append(session)

# 4. Bulk-fetch commits within precise session windows to avoid over-fetching
commits_by_project = defaultdict(list)
Expand Down
63 changes: 36 additions & 27 deletions termstory/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,28 @@ def get_session_memory_str(session: Session) -> str:
# 2. TUI WIDGETS & SCREENS
# ==========================================

class HelpScreen(ModalScreen[None]):
class _DeferredDismissMixin:
"""Mixin providing a deferred screen dismissal to avoid Textual 8.x modal freeze.

Calling dismiss() directly during a button/action handler can trigger a
ZeroDivisionError inside Textual 8.x's pre_await machinery. Scheduling the
dismiss on the next event-loop tick via set_timer(0.001, ...) works around
the bug while preserving the same user-visible behavior.
"""
def dismiss_later(self, result=None) -> None:
"""Dismiss this modal on the next event-loop tick.

Uses a **0.001 s** (not 0.0 s) delay. In Textual 8.x,
``set_timer(0.0, ...)`` re-enters ``pre_await`` during modal
dismissal and crashes with a ``ZeroDivisionError``; a non-zero
delay ensures the timer fires on a clean tick.
"""
def _do_dismiss():
self.dismiss(result)
self.set_timer(0.001, _do_dismiss)


class HelpScreen(_DeferredDismissMixin, ModalScreen[None]):
"""Modal screen displaying all keyboard shortcuts."""

BINDINGS = [
Expand Down Expand Up @@ -404,18 +425,13 @@ def compose(self) -> ComposeResult:

def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-close-help":
# set_timer(0.0, ...) schedules the dismiss on the next event
# loop tick, fully outside the Button.Pressed dispatch chain.
# Textual 8.x raises ScreenError if AwaitComplete.pre_await runs
# inside the screen's message pump, which call_after_refresh
# can't escape.
self.set_timer(0.0, self.dismiss)
self.dismiss_later()

def action_dismiss_none(self) -> None:
self.set_timer(0.0, self.dismiss)
self.dismiss_later()
Comment thread
greptile-apps[bot] marked this conversation as resolved.


class OnboardingScreen(ModalScreen[dict]):
class OnboardingScreen(_DeferredDismissMixin, ModalScreen[dict]):
"""Modal screen displaying trust warning and AI configuration options."""

BINDINGS = [
Expand Down Expand Up @@ -539,10 +555,10 @@ def action_choose_disabled(self) -> None:
self.config["ai_enabled"] = False
self.config["active_provider"] = "disabled"
self.config["has_seen_onboarding"] = True
self.set_timer(0.0, lambda: self.dismiss(self.config))
self.dismiss_later(self.config)

def action_dismiss_none(self) -> None:
self.set_timer(0.0, lambda: self.dismiss(None))
self.dismiss_later(None)

def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id
Expand Down Expand Up @@ -599,21 +615,14 @@ def on_button_pressed(self, event: Button.Pressed) -> None:
self.config["ai_enabled"] = True
self.config["active_provider"] = self.selected_provider
self.config["has_seen_onboarding"] = True

# Schedule dismiss out-of-band of this message handler. Using
# call_after_refresh(self.dismiss, ...) is not enough by itself
# because the AwaitComplete's pre_await callback raises
# ScreenError if awaited from inside the screen's message context
# (Textual 8.x). set_timer runs on the next tick outside the
# Button.Pressed dispatch chain.
self.set_timer(0.0, lambda: self.dismiss(self.config))
self.dismiss_later(self.config)
elif button_id == "btn-disable-ai":
github_username = self.query_one("#input-github-username").value.strip().lstrip('@')
self.config["github_username"] = github_username
self.config["ai_enabled"] = False
self.config["active_provider"] = "disabled"
self.config["has_seen_onboarding"] = True
self.set_timer(0.0, lambda: self.dismiss(self.config))
self.dismiss_later(self.config)



Expand Down Expand Up @@ -1615,7 +1624,7 @@ def render_session_details(self, project: Optional[Project], session: Session) -
# 3. RESET CONFIRMATION MODAL
# ==========================================

class ResetConfirmScreen(ModalScreen):
class ResetConfirmScreen(_DeferredDismissMixin, ModalScreen):
"""Confirmation dialog before resetting TermStory data."""

BINDINGS = [
Expand All @@ -1639,21 +1648,21 @@ def compose(self) -> ComposeResult:
)

def action_confirm_reset(self) -> None:
self.set_timer(0.0, lambda: self.dismiss(True))
self.dismiss_later(True)

def action_cancel_reset(self) -> None:
self.set_timer(0.0, lambda: self.dismiss(False))
self.dismiss_later(False)


class MatrixDefragScreen(ModalScreen[None]):
class MatrixDefragScreen(_DeferredDismissMixin, ModalScreen[None]):
"""Cyberpunk Matrix Defrag animation overlay."""
BINDINGS = [
Binding("escape", "close_matrix", "Close", show=True),
Binding("q", "close_matrix", "Close", show=True),
]

def action_close_matrix(self) -> None:
self.set_timer(0.0, self.dismiss)
self.dismiss_later()

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand Down Expand Up @@ -1736,15 +1745,15 @@ def step_animation(self) -> None:
self.set_timer(0.8, self.dismiss)


class GhostTyperScreen(ModalScreen[None]):
class GhostTyperScreen(_DeferredDismissMixin, ModalScreen[None]):
"""Cyberpunk Ghost Typer playback simulator."""
BINDINGS = [
Binding("escape", "close_typing", "Stop Playback", show=True),
Binding("q", "close_typing", "Stop Playback", show=True),
]

def action_close_typing(self) -> None:
self.set_timer(0.0, self.dismiss)
self.dismiss_later()

def __init__(self, commands: List[str], *args, **kwargs):
super().__init__(*args, **kwargs)
Expand Down
48 changes: 0 additions & 48 deletions tests/test_insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,54 +425,6 @@ def counting_get_connection():
assert query_count <= 5, f"Expected small constant query count, got {query_count} queries"


def test_detect_late_night_chaotic_sessions_no_duplicate_commits(tmp_path):
from datetime import datetime
from termstory.database import Database
from termstory.insights import detect_late_night_chaotic_sessions

db_file = tmp_path / "test_dedup_commits.db"
db = Database(str(db_file))
db.init_db()

conn = db.get_connection()
cursor = conn.cursor()
cursor.execute("INSERT INTO projects (id, name, path) VALUES (1, 'Project Alpha', '~/alpha')")

# We need > 250 sessions to trigger chunking (chunk_size = 250)
# Let's insert 260 chaotic sessions
now_dt = datetime(2026, 6, 17, 2, 0, 0)
base_ts = int(now_dt.timestamp())

for s_id in range(1, 261):
start_ts = base_ts + s_id * 15
cursor.execute("INSERT INTO sessions (id, start_time, end_time, duration_seconds, project_id) VALUES (?, ?, ?, ?, ?)",
(s_id, start_ts, start_ts + 10, 10, 1))
# Insert commands to make it chaotic
for cmd_id in range(10):
cursor.execute("INSERT INTO commands (timestamp, command, exit_code, session_id, project_id) VALUES (?, ?, ?, ?, ?)",
(start_ts + cmd_id, f"echo command_{cmd_id}", 1, s_id, 1))

# Insert a commit that overlaps with all sessions
# Since the sessions span from base_ts + 15 to base_ts + 260 * 15 + 10,
# let's set the commit timestamp to base_ts + 1000, which falls in the range of many sessions.
cursor.execute("INSERT INTO commits (hash, timestamp, message, cleaned_message, project_id) VALUES (?, ?, ?, ?, ?)",
("hash1", base_ts + 1000, "overlapping commit message", "overlapping commit message", 1))

conn.commit()
conn.close()

sessions = detect_late_night_chaotic_sessions(db)

# Assert that all chaotic sessions are returned
assert len(sessions) == 260

# Assert that the commit list of any session has no duplicates
for s in sessions:
assert s["commits"].count("overlapping commit message") <= 1

# Verify that the commit was associated with at least some sessions
total_matches = sum(s["commits"].count("overlapping commit message") for s in sessions)
assert total_matches > 0



Loading
Loading