From c5600a835163c94f0d85d98da83ee9ea9bc5ab3d Mon Sep 17 00:00:00 2001 From: Deepam Mhatre Date: Fri, 17 Jul 2026 13:48:51 +0530 Subject: [PATCH 01/11] fix: prevent onboarding modal freeze on Textual 8.x --- termstory/tui.py | 48 ++++++++++--------- tests/test_tui.py | 115 ++++++++++++++++++++++++---------------------- 2 files changed, 86 insertions(+), 77 deletions(-) diff --git a/termstory/tui.py b/termstory/tui.py index 10791f9..a8baa94 100644 --- a/termstory/tui.py +++ b/termstory/tui.py @@ -404,15 +404,10 @@ 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() class OnboardingScreen(ModalScreen[dict]): @@ -427,6 +422,10 @@ class OnboardingScreen(ModalScreen[dict]): ("escape", "dismiss_none", "Close"), ] + def dismiss_later(self, result=None) -> None: + """Dismiss this modal screen on the next tick with a non-zero delay.""" + self.set_timer(0.001, lambda: (self.dismiss(result), None)[1]) + def __init__(self, current_config: dict): super().__init__() self.config = json.loads(json.dumps(current_config)) @@ -539,10 +538,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 @@ -599,21 +598,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) @@ -1624,6 +1616,10 @@ class ResetConfirmScreen(ModalScreen): Binding("escape", "cancel_reset", "Cancel"), ] + def dismiss_later(self, result=None) -> None: + """Dismiss this modal screen on the next tick with a non-zero delay.""" + self.set_timer(0.001, lambda: (self.dismiss(result), None)[1]) + def compose(self) -> ComposeResult: yield Static( "\n\n" @@ -1639,10 +1635,10 @@ 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]): @@ -1652,8 +1648,12 @@ class MatrixDefragScreen(ModalScreen[None]): Binding("q", "close_matrix", "Close", show=True), ] + def dismiss_later(self, result=None) -> None: + """Dismiss this modal screen on the next tick with a non-zero delay.""" + self.set_timer(0.001, lambda: (self.dismiss(result), None)[1]) + 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) @@ -1743,8 +1743,12 @@ class GhostTyperScreen(ModalScreen[None]): Binding("q", "close_typing", "Stop Playback", show=True), ] + def dismiss_later(self, result=None) -> None: + """Dismiss this modal screen on the next tick with a non-zero delay.""" + self.set_timer(0.001, lambda: (self.dismiss(result), None)[1]) + 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) diff --git a/tests/test_tui.py b/tests/test_tui.py index e5b5fc0..329af95 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1042,61 +1042,66 @@ async def test_tui_deep_search_scope_escape(): @pytest.mark.asyncio async def test_tui_api_key_validation(): - """Verify OnboardingScreen's API key validation: empty key shows error, - selecting ollama (which doesn't need API key) allows screen dismissal. - - Uses structural invocation (call save flow functions directly) rather - than pilot.click("btn-save") to avoid Textual 8.x AwaitComplete - pre_await callback chain raising ScreenError. Push screen without - awaiting so push_screen's underlying future doesn't block.""" - from termstory.tui import OnboardingScreen - # Provide a config that defaults to groq with no api key - screen = OnboardingScreen({"active_provider": "groq", "providers": {}}) - - # We need an app context to mount the screen - from textual.app import App - class DummyApp(App): - pass - - app = DummyApp() - async with app.run_test() as pilot: - # Push the screen. Do not await push_screen so the underlying - # dismiss future isn't awaited downstream (Textual 8.x pre_await - # chain). - app.push_screen(screen) - await pilot.pause() - - # Verify initial state of error label - error_label = screen.query_one("#error-api-key") - assert error_label.styles.display == "none" - - # Run the save flow directly with empty API key (groq branch). - # Simulates what on_button_pressed would do. - screen.selected_provider = "groq" - # Drive validation path directly: come from on_button_pressed - # without going through ActionChain — set the saved flag manually - # for visibility check. - error_label.update("API Key cannot be empty.") - error_label.styles.display = "block" - await pilot.pause() - - # Verify error label visible - assert error_label.styles.display == "block" - assert len(app.screen_stack) > 1 # Screen did not dismiss - - # Switch to ollama — set selected_provider to bypass API key check - screen.selected_provider = "ollama" - # Mutate config as the save branch would, but DON'T call dismiss - # (Textual 8.x AwaitComplete hang). The test's purpose is to verify - # the validation flow, not the dismiss mechanics. - screen.config["active_provider"] = "ollama" - await pilot.pause() - - # Manually pop the screen from test context (works fine — we proved - # this in probe). Verifies the dismissal post-condition. - screen.dismiss() - await pilot.pause() - assert len(app.screen_stack) == 1 + """End-to-end regression test for Issue #288: verify the real Save & Enable + button path works under Textual 8.x without freezing. + + Uses a fake non-empty API key; the onboarding code only validates that + the field is non-empty and never contacts the provider during save.""" + import tempfile + import os + from termstory.database import Database + from termstory.models import Project, Session, Command + + with tempfile.TemporaryDirectory() as tmp_dir: + db_path = os.path.join(tmp_dir, "test.db") + db = Database(db_path) + db.init_db() + + now_ts = int(time.time()) + p = Project(id=1, name="Proj A", path="~/proj-a", first_seen=now_ts, last_seen=now_ts, session_count=1, total_time=0) + cmd = Command(timestamp=now_ts, command="git diff", exit_code=0, session_id=1, project_id=1) + s = Session(id=1, start_time=now_ts, end_time=now_ts, duration_seconds=0, project_id=1, commands=[cmd], ai_summary=None) + db.save_data([p], [s], [cmd]) + + app = TermStoryWorkspace( + db, + days_limit=30, + config_override={ + "has_seen_onboarding": False, + "ai_enabled": False, + "active_provider": "groq", + "providers": {} + } + ) + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + # OnboardingScreen should be visible because has_seen_onboarding=False + assert isinstance(app.screen, OnboardingScreen) + onboarding = app.screen + + # Set a fake non-empty API key — no real network call is made. + api_key_input = onboarding.query_one("#input-api-key") + api_key_input.value = "gsk_test_key" + await pilot.pause() + + # Press the actual Save & Enable button directly. + save_btn = onboarding.query_one("#btn-save") + save_btn.press() + + # Wait for the dismissal timer to fire and the screen to pop. + for _ in range(50): + if not isinstance(app.screen, OnboardingScreen): + break + await pilot.pause() + + # Verify the modal dismissed and the result reached the app. + assert not isinstance(app.screen, OnboardingScreen) + assert app.config["has_seen_onboarding"] is True + assert app.config["ai_enabled"] is True + assert app.config["active_provider"] == "groq" + assert app.config["providers"]["groq"]["api_key"] == "gsk_test_key" @pytest.mark.asyncio async def test_tui_worker_cancellation(monkeypatch): From 897730998973ab9413a3de0d030ed77d49d56385 Mon Sep 17 00:00:00 2001 From: Deepam Mhatre Date: Fri, 17 Jul 2026 14:33:45 +0530 Subject: [PATCH 02/11] fix: prevent onboarding modal freeze on Textual 8.x --- termstory/tui.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/termstory/tui.py b/termstory/tui.py index a8baa94..157cf09 100644 --- a/termstory/tui.py +++ b/termstory/tui.py @@ -409,6 +409,10 @@ def on_button_pressed(self, event: Button.Pressed) -> None: def action_dismiss_none(self) -> None: self.dismiss_later() + def dismiss_later(self, result=None) -> None: + """Dismiss this modal screen on the next tick with a non-zero delay.""" + self.set_timer(0.001, lambda: (self.dismiss(result), None)[1]) + class OnboardingScreen(ModalScreen[dict]): """Modal screen displaying trust warning and AI configuration options.""" From fd8fc08d8883735bef756a7d4d7b1fbc34b96544 Mon Sep 17 00:00:00 2001 From: Deepam Mhatre Date: Fri, 17 Jul 2026 11:07:55 +0530 Subject: [PATCH 03/11] test: add regression test for project path callback failures --- tests/test_timestamp_detective.py | 308 +++++++++++++++++++++--------- 1 file changed, 218 insertions(+), 90 deletions(-) diff --git a/tests/test_timestamp_detective.py b/tests/test_timestamp_detective.py index 5cb9b0f..2f7dce6 100644 --- a/tests/test_timestamp_detective.py +++ b/tests/test_timestamp_detective.py @@ -17,12 +17,10 @@ import os import sys -import glob -import stat import time import tempfile import unittest -from unittest.mock import patch, MagicMock, call +from unittest.mock import patch, MagicMock from datetime import datetime, timezone, timedelta # Ensure the project root is on the path @@ -34,6 +32,7 @@ # Helpers # --------------------------------------------------------------------------- + class ParserBugDateTime: @staticmethod def strptime(*_args, **_kwargs): @@ -42,8 +41,8 @@ def strptime(*_args, **_kwargs): NOW = int(datetime.now().timestamp()) FOUR_YEARS_AGO = NOW - (4 * 365 * 24 * 60 * 60) -SIX_YEARS_AGO = NOW - (6 * 365 * 24 * 60 * 60) -TOMORROW = NOW + 86400 +SIX_YEARS_AGO = NOW - (6 * 365 * 24 * 60 * 60) +TOMORROW = NOW + 86400 def make_detective(**kwargs) -> TimestampDetective: @@ -51,7 +50,7 @@ def make_detective(**kwargs) -> TimestampDetective: return TimestampDetective( search_root=kwargs.pop("search_root", tempfile.gettempdir()), project_paths=kwargs.pop("project_paths", []), - **kwargs + **kwargs, ) @@ -64,8 +63,8 @@ def make_items(*commands) -> list: # A. Virtual CWD Tracker # --------------------------------------------------------------------------- -class TestVirtualCWDTracker(unittest.TestCase): +class TestVirtualCWDTracker(unittest.TestCase): def setUp(self): self.home = os.path.expanduser("~") self.d = make_detective(search_root=self.home) @@ -80,8 +79,8 @@ def test_cd_absolute(self): items = make_items("ls", "cd /tmp", "echo hi") cwd_map = self.d._build_virtual_cwd_map(items) self.assertEqual(cwd_map[0], self.home) - self.assertEqual(cwd_map[1], "/tmp") # after the cd command itself - self.assertEqual(cwd_map[2], "/tmp") # subsequent command inherits + self.assertEqual(cwd_map[1], "/tmp") # after the cd command itself + self.assertEqual(cwd_map[2], "/tmp") # subsequent command inherits def test_cd_relative(self): items = make_items("cd /tmp", "cd subdir", "ls") @@ -113,8 +112,8 @@ def test_cd_bare(self): def test_pushd_popd(self): items = make_items("cd /tmp", "pushd /var", "ls", "popd", "ls") cwd_map = self.d._build_virtual_cwd_map(items) - self.assertEqual(cwd_map[2], "/var") # inside pushd - self.assertEqual(cwd_map[4], "/tmp") # after popd + self.assertEqual(cwd_map[2], "/var") # inside pushd + self.assertEqual(cwd_map[4], "/tmp") # after popd def test_popd_empty_stack_is_safe(self): """popd with nothing on the stack should not crash.""" @@ -128,8 +127,8 @@ def test_popd_empty_stack_is_safe(self): # B. Git Commit Message Extraction # --------------------------------------------------------------------------- -class TestGitCommitExtraction(unittest.TestCase): +class TestGitCommitExtraction(unittest.TestCase): def setUp(self): self.d = make_detective() @@ -140,7 +139,9 @@ def test_double_quoted(self): self.assertEqual(self._extract('git commit -m "fix auth bug"'), "fix auth bug") def test_single_quoted(self): - self.assertEqual(self._extract("git commit -m 'add login page'"), "add login page") + self.assertEqual( + self._extract("git commit -m 'add login page'"), "add login page" + ) def test_combined_flag_am(self): self.assertEqual(self._extract('git commit -am "quick fix"'), "quick fix") @@ -148,7 +149,7 @@ def test_combined_flag_am(self): def test_long_flag(self): self.assertEqual( self._extract('git commit --message "refactor: clean up"'), - "refactor: clean up" + "refactor: clean up", ) def test_no_message_flag(self): @@ -168,8 +169,8 @@ def test_message_normalisation(self): # C. Git Commit Detector (fuzzy matching against mocked git log) # --------------------------------------------------------------------------- -class TestDetectGitCommit(unittest.TestCase): +class TestDetectGitCommit(unittest.TestCase): def setUp(self): self.d = make_detective(search_root=tempfile.gettempdir()) @@ -180,9 +181,16 @@ def _mock_log(self, repo_path, commits): @patch.object(TimestampDetective, "_find_git_root", return_value="/repos/myapp") def test_exact_match_returns_commit_timestamp(self, _mock_root): """A commit message with ratio = 1.0 should return the commit's timestamp.""" - self._mock_log("/repos/myapp", [ - {"hash": "abc1234", "timestamp": FOUR_YEARS_AGO, "message": "add express middleware"} - ]) + self._mock_log( + "/repos/myapp", + [ + { + "hash": "abc1234", + "timestamp": FOUR_YEARS_AGO, + "message": "add express middleware", + } + ], + ) result = self.d.detect_git_commit( 'git commit -m "add express middleware"', "/repos/myapp" ) @@ -199,25 +207,42 @@ def test_fuzzy_match_above_threshold(self, _mock_root): 'fix auth bug' vs 'fix auth debug' → ratio ≈ 0.923, well above threshold. We use messages that are genuinely close, not merely related. """ - self._mock_log("/repos/myapp", [ - {"hash": "def5678", "timestamp": FOUR_YEARS_AGO, "message": "fix auth debug"} - ]) + self._mock_log( + "/repos/myapp", + [ + { + "hash": "def5678", + "timestamp": FOUR_YEARS_AGO, + "message": "fix auth debug", + } + ], + ) result = self.d.detect_git_commit( 'git commit -m "fix auth bug"', "/repos/myapp" ) # "fix auth bug" vs "fix auth debug" — ratio ≈ 0.923, always above the 0.85 threshold. # Verify the result is returned unconditionally. import difflib + ratio = difflib.SequenceMatcher(None, "fix auth bug", "fix auth debug").ratio() - self.assertGreaterEqual(ratio, 0.85, f"Sanity check: ratio {ratio:.3f} should be ≥ 0.85") + self.assertGreaterEqual( + ratio, 0.85, f"Sanity check: ratio {ratio:.3f} should be ≥ 0.85" + ) self.assertIsNotNone(result) @patch.object(TimestampDetective, "_find_git_root", return_value="/repos/myapp") def test_below_threshold_returns_none(self, _mock_root): """A very different commit message should not match.""" - self._mock_log("/repos/myapp", [ - {"hash": "aaa0000", "timestamp": FOUR_YEARS_AGO, "message": "update README documentation"} - ]) + self._mock_log( + "/repos/myapp", + [ + { + "hash": "aaa0000", + "timestamp": FOUR_YEARS_AGO, + "message": "update README documentation", + } + ], + ) result = self.d.detect_git_commit( 'git commit -m "add express middleware"', "/repos/myapp" ) @@ -227,12 +252,26 @@ def test_below_threshold_returns_none(self, _mock_root): def test_cwd_repo_searched_first(self, _mock_root): """CWD-derived repo should be searched before fallback project paths.""" # Put a match in the CWD repo and a different match in a fallback - self._mock_log("/repos/myapp", [ - {"hash": "cwd1111", "timestamp": FOUR_YEARS_AGO + 100, "message": "fix typo in readme"} - ]) - self._mock_log("/repos/other", [ - {"hash": "other222", "timestamp": FOUR_YEARS_AGO + 200, "message": "fix typo in readme"} - ]) + self._mock_log( + "/repos/myapp", + [ + { + "hash": "cwd1111", + "timestamp": FOUR_YEARS_AGO + 100, + "message": "fix typo in readme", + } + ], + ) + self._mock_log( + "/repos/other", + [ + { + "hash": "other222", + "timestamp": FOUR_YEARS_AGO + 200, + "message": "fix typo in readme", + } + ], + ) self.d.project_paths = ["/repos/other"] result = self.d.detect_git_commit( @@ -246,9 +285,10 @@ def test_cwd_repo_searched_first(self, _mock_root): @patch.object(TimestampDetective, "_find_git_root", return_value="/repos/myapp") def test_future_timestamp_rejected(self, _mock_root): """A commit with a future timestamp should not be returned.""" - self._mock_log("/repos/myapp", [ - {"hash": "future0", "timestamp": TOMORROW, "message": "add feature x"} - ]) + self._mock_log( + "/repos/myapp", + [{"hash": "future0", "timestamp": TOMORROW, "message": "add feature x"}], + ) result = self.d.detect_git_commit( 'git commit -m "add feature x"', "/repos/myapp" ) @@ -257,9 +297,16 @@ def test_future_timestamp_rejected(self, _mock_root): @patch.object(TimestampDetective, "_find_git_root", return_value="/repos/myapp") def test_too_old_timestamp_rejected(self, _mock_root): """A commit older than 5 years should be rejected by the validity guard.""" - self._mock_log("/repos/myapp", [ - {"hash": "old1111", "timestamp": SIX_YEARS_AGO, "message": "initial commit"} - ]) + self._mock_log( + "/repos/myapp", + [ + { + "hash": "old1111", + "timestamp": SIX_YEARS_AGO, + "message": "initial commit", + } + ], + ) result = self.d.detect_git_commit( 'git commit -m "initial commit"', "/repos/myapp" ) @@ -270,43 +317,59 @@ def test_too_old_timestamp_rejected(self, _mock_root): # C2. Inline Date Strings # --------------------------------------------------------------------------- -class TestDetectInlineDate(unittest.TestCase): +class TestDetectInlineDate(unittest.TestCase): def setUp(self): self.d = make_detective(search_root=tempfile.gettempdir()) def test_git_commit_date_flag_iso(self): - result = self.d.detect_inline_date('git commit --date="2024-01-15T10:30:00"', "/tmp") + result = self.d.detect_inline_date( + 'git commit --date="2024-01-15T10:30:00"', "/tmp" + ) self.assertIsNotNone(result) ts, label = result self.assertIn("2024-01-15T10:30:00", label) import datetime + dt = datetime.datetime.strptime("2024-01-15T10:30:00", "%Y-%m-%dT%H:%M:%S") dt = dt.astimezone() self.assertEqual(ts, int(dt.timestamp())) def test_git_commit_amend_date_flag_git_format(self): - result = self.d.detect_inline_date('git commit --amend --date="Mon Jan 15 10:30:00 2024 +0000"', "/tmp") + result = self.d.detect_inline_date( + 'git commit --amend --date="Mon Jan 15 10:30:00 2024 +0000"', "/tmp" + ) self.assertIsNotNone(result) ts, label = result import datetime - dt = datetime.datetime.strptime("Mon Jan 15 10:30:00 2024 +0000", "%a %b %d %H:%M:%S %Y %z") + + dt = datetime.datetime.strptime( + "Mon Jan 15 10:30:00 2024 +0000", "%a %b %d %H:%M:%S %Y %z" + ) self.assertEqual(ts, int(dt.timestamp())) def test_git_committer_date_env_var(self): - result = self.d.detect_inline_date('GIT_COMMITTER_DATE="2024-01-15T10:30:00" git commit -m "fix"', "/tmp") + result = self.d.detect_inline_date( + 'GIT_COMMITTER_DATE="2024-01-15T10:30:00" git commit -m "fix"', "/tmp" + ) self.assertIsNotNone(result) def test_tar_create_with_date(self): - result = self.d.detect_inline_date("tar -czf backup_2024-01-15.tar.gz src/", "/tmp") + result = self.d.detect_inline_date( + "tar -czf backup_2024-01-15.tar.gz src/", "/tmp" + ) self.assertIsNotNone(result) def test_mysqldump_with_date(self): - result = self.d.detect_inline_date("mysqldump mydb > dump_2024-01-15.sql", "/tmp") + result = self.d.detect_inline_date( + "mysqldump mydb > dump_2024-01-15.sql", "/tmp" + ) self.assertIsNotNone(result) def test_mv_with_date(self): - result = self.d.detect_inline_date("mv report.pdf report_2024-01-15.pdf", "/tmp") + result = self.d.detect_inline_date( + "mv report.pdf report_2024-01-15.pdf", "/tmp" + ) self.assertIsNotNone(result) def test_git_log_since_returns_none(self): @@ -322,7 +385,7 @@ def test_grep_date_returns_none(self): self.assertIsNone(result) def test_cat_date_file_returns_none(self): - result = self.d.detect_inline_date('cat file_2024-01-15.txt', "/tmp") + result = self.d.detect_inline_date("cat file_2024-01-15.txt", "/tmp") self.assertIsNone(result) def test_invalid_date_returns_none(self): @@ -330,7 +393,9 @@ def test_invalid_date_returns_none(self): self.assertIsNone(result) def test_too_old_timestamp_filtered(self): - result = self.d.detect_inline_date('git commit --date="1990-01-01T10:00:00"', "/tmp") + result = self.d.detect_inline_date( + 'git commit --date="1990-01-01T10:00:00"', "/tmp" + ) self.assertIsNone(result) @@ -338,14 +403,15 @@ def test_too_old_timestamp_filtered(self): # D. File Stat Detector # --------------------------------------------------------------------------- -class TestDetectFileStat(unittest.TestCase): +class TestDetectFileStat(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp() self.d = make_detective(search_root=self.tmp) def tearDown(self): import shutil + shutil.rmtree(self.tmp, ignore_errors=True) def test_touch_existing_file(self): @@ -427,14 +493,15 @@ def test_future_file_timestamp_rejected(self): # E. Package Manager Detector (mocked filesystem) # --------------------------------------------------------------------------- -class TestDetectPackageManager(unittest.TestCase): +class TestDetectPackageManager(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp() self.d = make_detective(search_root=self.tmp) def tearDown(self): import shutil + shutil.rmtree(self.tmp, ignore_errors=True) def test_npm_local_install_package_lock(self): @@ -460,7 +527,9 @@ def test_brew_install_cellar_found(self): def test_brew_formula_not_installed_returns_none(self): """If the Cellar directory doesn't exist, return None.""" self.d._brew_prefix = self.tmp # Valid prefix, but no Cellar/nonexistent - result = self.d.detect_package_manager("brew install nonexistent_formula_xyz", self.tmp) + result = self.d.detect_package_manager( + "brew install nonexistent_formula_xyz", self.tmp + ) self.assertIsNone(result) def test_npm_global_install(self): @@ -480,7 +549,9 @@ def test_cargo_add(self): We mock the helper directly rather than glob.glob so the test doesn't depend on the actual ~/.cargo directory layout. """ - with patch.object(self.d, "_get_cargo_crate_timestamp", return_value=FOUR_YEARS_AGO): + with patch.object( + self.d, "_get_cargo_crate_timestamp", return_value=FOUR_YEARS_AGO + ): result = self.d.detect_package_manager("cargo add serde", self.tmp) self.assertIsNotNone(result) _, label = result @@ -503,8 +574,8 @@ def test_gem_install(self): # F. Docker Image Detector # --------------------------------------------------------------------------- -class TestDetectDocker(unittest.TestCase): +class TestDetectDocker(unittest.TestCase): def setUp(self): self.d = make_detective() @@ -524,7 +595,9 @@ def test_docker_build_tag_long_flag(self, mock_run): """docker build --tag api:latest . should also be detected.""" iso_ts = datetime.fromtimestamp(FOUR_YEARS_AGO, tz=timezone.utc).isoformat() mock_run.return_value = MagicMock(returncode=0, stdout=iso_ts) - result = self.d.detect_docker("docker build --tag api:latest .", tempfile.gettempdir()) + result = self.d.detect_docker( + "docker build --tag api:latest .", tempfile.gettempdir() + ) self.assertIsNotNone(result) _, label = result self.assertIn("api:latest", label) @@ -533,7 +606,9 @@ def test_docker_build_tag_long_flag(self, mock_run): def test_docker_inspect_failure_returns_none(self, mock_run): """If docker inspect returns non-zero, result should be None.""" mock_run.return_value = MagicMock(returncode=1, stdout="") - result = self.d.detect_docker("docker build -t bad-build .", tempfile.gettempdir()) + result = self.d.detect_docker( + "docker build -t bad-build .", tempfile.gettempdir() + ) self.assertIsNone(result) def test_non_build_command_returns_none(self): @@ -546,14 +621,15 @@ def test_non_build_command_returns_none(self): # G. Venv / Lockfile Detector # --------------------------------------------------------------------------- -class TestDetectVenvLockfile(unittest.TestCase): +class TestDetectVenvLockfile(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp() self.d = make_detective(search_root=self.tmp) def tearDown(self): import shutil + shutil.rmtree(self.tmp, ignore_errors=True) def test_bundle_install_gemfile_lock(self): @@ -607,8 +683,8 @@ def test_ssh_keygen(self): # H. Anchor Interpolation Engine # --------------------------------------------------------------------------- -class TestInterpolation(unittest.TestCase): +class TestInterpolation(unittest.TestCase): def setUp(self): self.d = make_detective() @@ -622,19 +698,23 @@ def _make_enriched(self, *specs): items = [] for spec in specs: if spec is not None: - items.append({ - "command": f"cmd_{spec}", - "detected_ts": spec, - "detected_source": f"mock@{spec}", - "is_legacy_still": False - }) + items.append( + { + "command": f"cmd_{spec}", + "detected_ts": spec, + "detected_source": f"mock@{spec}", + "is_legacy_still": False, + } + ) else: - items.append({ - "command": "unresolved", - "detected_ts": None, - "detected_source": None, - "is_legacy_still": True - }) + items.append( + { + "command": "unresolved", + "detected_ts": None, + "detected_source": None, + "is_legacy_still": True, + } + ) return items def test_gap_between_two_anchors_interpolated(self): @@ -645,9 +725,9 @@ def test_gap_between_two_anchors_interpolated(self): items = self._make_enriched(1000, None, None, None, 2000) result = self.d._interpolate(items) # Anchors at idx 0 and 4; gaps at idx 1, 2, 3 - self.assertEqual(result[1]["detected_ts"], int(1000 + (2000 - 1000) * 1/4)) - self.assertEqual(result[2]["detected_ts"], int(1000 + (2000 - 1000) * 2/4)) - self.assertEqual(result[3]["detected_ts"], int(1000 + (2000 - 1000) * 3/4)) + self.assertEqual(result[1]["detected_ts"], int(1000 + (2000 - 1000) * 1 / 4)) + self.assertEqual(result[2]["detected_ts"], int(1000 + (2000 - 1000) * 2 / 4)) + self.assertEqual(result[3]["detected_ts"], int(1000 + (2000 - 1000) * 3 / 4)) # All interpolated items must remain is_legacy_still=True self.assertTrue(result[1]["is_legacy_still"]) self.assertTrue(result[2]["is_legacy_still"]) @@ -715,14 +795,15 @@ def test_multiple_gaps_between_multiple_anchors(self): # I. Full Pipeline — resolve_all() # --------------------------------------------------------------------------- -class TestResolveAll(unittest.TestCase): +class TestResolveAll(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp() self.d = make_detective(search_root=self.tmp) def tearDown(self): import shutil + shutil.rmtree(self.tmp, ignore_errors=True) def test_empty_list(self): @@ -773,7 +854,9 @@ def test_interpolation_applied_between_resolved_commands(self): # npm install → resolved (anchor A) # ls → unresolvable → interpolated # git clone … → resolved (anchor B) - items = make_items("npm install", "ls -la", "git clone https://github.com/u/repo.git repo") + items = make_items( + "npm install", "ls -la", "git clone https://github.com/u/repo.git repo" + ) result = self.d.resolve_all(items) ts_a = result[0].get("detected_ts") @@ -786,13 +869,36 @@ def test_interpolation_applied_between_resolved_commands(self): self.assertGreaterEqual(ts_mid, min(ts_a, ts_b)) self.assertLessEqual(ts_mid, max(ts_a, ts_b)) + def test_project_paths_callback_raises_still_resolves_legacy(self): + """Simulates the parser-post-callback-failure scenario: project_paths=[]. + + When the parser's project_paths callback raises, it catches the exception + and passes an empty list to TimestampDetective. resolve_all() must still + return all commands without dropping or crashing. + """ + detective = make_detective(search_root=self.tmp, project_paths=[]) + items = make_items("ls -la", "echo hello", "pwd") + result = detective.resolve_all(items) + + # All commands must still be returned + self.assertEqual(len(result), 3) + self.assertEqual(result[0]["command"], "ls -la") + self.assertEqual(result[1]["command"], "echo hello") + self.assertEqual(result[2]["command"], "pwd") + + # No real evidence was found, so they remain legacy + for item in result: + self.assertIn("detected_ts", item) + self.assertIn("detected_source", item) + self.assertIn("is_legacy_still", item) + # --------------------------------------------------------------------------- # H. macOS System Log Detector (Detector 6) # --------------------------------------------------------------------------- -class TestDetectMacosSystemLog(unittest.TestCase): +class TestDetectMacosSystemLog(unittest.TestCase): def setUp(self): self.d = make_detective() @@ -808,7 +914,9 @@ def test_brew_install_brew_log(self, mock_run): " jq 1.6\n" ) mock_run.return_value = MagicMock(returncode=0, stdout=log_output) - result = self.d.detect_macos_system_log("brew install jq", tempfile.gettempdir()) + result = self.d.detect_macos_system_log( + "brew install jq", tempfile.gettempdir() + ) self.assertIsNotNone(result) ts, label = result self.assertEqual(ts, FOUR_YEARS_AGO) @@ -821,10 +929,11 @@ def test_brew_install_strips_tap_and_version(self, mock_run): dt = datetime.fromtimestamp(FOUR_YEARS_AGO) git_date = dt.astimezone().strftime("%a %b %d %H:%M:%S %Y %z") mock_run.return_value = MagicMock( - returncode=0, - stdout=f"commit a\nDate: {git_date}\n\n msg\n" + returncode=0, stdout=f"commit a\nDate: {git_date}\n\n msg\n" + ) + result = self.d.detect_macos_system_log( + "brew install homebrew/core/jq@1.6", tempfile.gettempdir() ) - result = self.d.detect_macos_system_log("brew install homebrew/core/jq@1.6", tempfile.gettempdir()) self.assertIsNotNone(result) _, label = result self.assertIn("jq", label) @@ -841,9 +950,11 @@ def test_last_login_anchor(self, mock_run): last_line = target.strftime("%a %b %d %H:%M") mock_run.return_value = MagicMock( returncode=0, - stdout=f"alice ttys000 192.168.0.1 {last_line} still logged in\n" + stdout=f"alice ttys000 192.168.0.1 {last_line} still logged in\n", + ) + result = self.d.detect_macos_system_log( + "sudo systemctl restart x", tempfile.gettempdir() ) - result = self.d.detect_macos_system_log("sudo systemctl restart x", tempfile.gettempdir()) self.assertIsNotNone(result) ts, label = result @@ -872,11 +983,15 @@ def test_parse_git_log_date_ignores_format_mismatches_only(self): self.d._parse_git_log_date("commit x\nDate: not a date\n") def test_parse_last_login_ignores_format_mismatches_only(self): - self.assertIsNone(self.d._parse_last_login("alice ttys000 Tue Jun 99 09:14 still logged in")) + self.assertIsNone( + self.d._parse_last_login("alice ttys000 Tue Jun 99 09:14 still logged in") + ) with patch("termstory.timestamp_detective.datetime", ParserBugDateTime): with self.assertRaises(TypeError): - self.d._parse_last_login("alice ttys000 Thu Jun 5 09:14 still logged in") + self.d._parse_last_login( + "alice ttys000 Thu Jun 5 09:14 still logged in" + ) def test_detect_macos_syslog_missing_file(self): """When install.log doesn't exist (e.g. on Linux), return None.""" @@ -890,13 +1005,16 @@ def test_detect_macos_syslog_parses_install_line(self): stamp = dt.strftime("%Y-%m-%d %H:%M:%S") with open(log_path, "w") as f: f.write(f"{stamp}+00 host installd[1]: Installed: jq\n") - with patch("os.path.exists", return_value=True), \ - patch("builtins.open", return_value=open(log_path)): + with ( + patch("os.path.exists", return_value=True), + patch("builtins.open", return_value=open(log_path)), + ): ts = self.d.detect_macos_syslog("jq") self.assertIsNotNone(ts) self.assertEqual(ts, int(dt.timestamp())) finally: import shutil + shutil.rmtree(tmp, ignore_errors=True) def test_detect_macos_syslog_timezone_offsets(self): @@ -905,14 +1023,19 @@ def test_detect_macos_syslog_timezone_offsets(self): log_path = os.path.join(tmp, "install.log") # 2026-04-01 04:22:04-07:00 is 1775042524 with open(log_path, "w") as f: - f.write("2026-04-01 04:22:04-07 MacBook-Pro system_installd[584]: Installed \"jq\" (1.6)\n") - with patch("os.path.exists", return_value=True), \ - patch("builtins.open", return_value=open(log_path)): + f.write( + '2026-04-01 04:22:04-07 MacBook-Pro system_installd[584]: Installed "jq" (1.6)\n' + ) + with ( + patch("os.path.exists", return_value=True), + patch("builtins.open", return_value=open(log_path)), + ): ts = self.d.detect_macos_syslog("jq") self.assertIsNotNone(ts) self.assertEqual(ts, 1775042524) finally: import shutil + shutil.rmtree(tmp, ignore_errors=True) def test_detect_macos_syslog_package_filter_strict(self): @@ -921,13 +1044,18 @@ def test_detect_macos_syslog_package_filter_strict(self): try: log_path = os.path.join(tmp, "install.log") with open(log_path, "w") as f: - f.write("2026-04-01 04:22:04-07 MacBook-Pro system_installd[584]: Installed: brew-cask google-chrome\n") - with patch("os.path.exists", return_value=True), \ - patch("builtins.open", return_value=open(log_path)): + f.write( + "2026-04-01 04:22:04-07 MacBook-Pro system_installd[584]: Installed: brew-cask google-chrome\n" + ) + with ( + patch("os.path.exists", return_value=True), + patch("builtins.open", return_value=open(log_path)), + ): ts = self.d.detect_macos_syslog("jq") self.assertIsNone(ts) finally: import shutil + shutil.rmtree(tmp, ignore_errors=True) From d04cb628339fd7620225a32106fc9b30ebe5fb89 Mon Sep 17 00:00:00 2001 From: Deepam Mhatre Date: Fri, 17 Jul 2026 11:32:02 +0530 Subject: [PATCH 04/11] test: strengthen empty project paths regression test --- tests/test_timestamp_detective.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_timestamp_detective.py b/tests/test_timestamp_detective.py index 2f7dce6..b45548b 100644 --- a/tests/test_timestamp_detective.py +++ b/tests/test_timestamp_detective.py @@ -869,12 +869,12 @@ def test_interpolation_applied_between_resolved_commands(self): self.assertGreaterEqual(ts_mid, min(ts_a, ts_b)) self.assertLessEqual(ts_mid, max(ts_a, ts_b)) - def test_project_paths_callback_raises_still_resolves_legacy(self): - """Simulates the parser-post-callback-failure scenario: project_paths=[]. + def test_empty_project_paths_still_resolves_legacy(self): + """Ensure resolve_all() still returns legacy commands when project_paths is empty. - When the parser's project_paths callback raises, it catches the exception - and passes an empty list to TimestampDetective. resolve_all() must still - return all commands without dropping or crashing. + This mirrors the parser's post-callback-failure state: after catching a + project_paths callback exception, the parser passes an empty list to + TimestampDetective. The Detective must not drop or crash on any commands. """ detective = make_detective(search_root=self.tmp, project_paths=[]) items = make_items("ls -la", "echo hello", "pwd") @@ -891,6 +891,7 @@ def test_project_paths_callback_raises_still_resolves_legacy(self): self.assertIn("detected_ts", item) self.assertIn("detected_source", item) self.assertIn("is_legacy_still", item) + self.assertTrue(item["is_legacy_still"]) # --------------------------------------------------------------------------- From 2401939bc9e4328e9b3fe664c2f5af6a166bb07f Mon Sep 17 00:00:00 2001 From: Diwakar-odds <170966675+Diwakar-odds@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:20:53 +0530 Subject: [PATCH 05/11] fix(cli): use case-insensitive standard input for reset confirmation to match hermes_obs.py (fixes #258) --- termstory/cli.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/termstory/cli.py b/termstory/cli.py index 795c5c4..e58acd0 100644 --- a/termstory/cli.py +++ b/termstory/cli.py @@ -884,8 +884,13 @@ def perform_reset(auto_confirm: bool = False, dry_run: bool = False): if not sys.stdin.isatty(): console.print("[bold red]Refusing to reset in a non-interactive shell without --yes.[/bold red]") raise typer.Exit(code=1) - response = Prompt.ask("\nAre you sure you want to delete all TermStory data?", choices=["y", "yes", "n", "no"], default="n") - if response.lower() not in ["y", "yes"]: + try: + response = input("\nAre you sure you want to delete all TermStory data? (y/n): ").strip().lower() + except (KeyboardInterrupt, EOFError): + console.print() + response = "n" + + if response not in ("y", "yes"): console.print("[yellow]Reset aborted.[/yellow]") return From 3e4f66553c1f6b012746a4edc56fe26d374f2583 Mon Sep 17 00:00:00 2001 From: Diwakar-odds <170966675+Diwakar-odds@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:58:12 +0530 Subject: [PATCH 06/11] chore(cli): add inline comment for interruption handling (fixes #258) --- termstory/cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/termstory/cli.py b/termstory/cli.py index e58acd0..20a082a 100644 --- a/termstory/cli.py +++ b/termstory/cli.py @@ -888,8 +888,7 @@ def perform_reset(auto_confirm: bool = False, dry_run: bool = False): response = input("\nAre you sure you want to delete all TermStory data? (y/n): ").strip().lower() except (KeyboardInterrupt, EOFError): console.print() - response = "n" - + response = "n" # Treat interruption as abort if response not in ("y", "yes"): console.print("[yellow]Reset aborted.[/yellow]") return From 530a9b887a427b050252ea4c2817b2640c7c9000 Mon Sep 17 00:00:00 2001 From: Diwakar-odds <170966675+Diwakar-odds@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:29:54 +0530 Subject: [PATCH 07/11] fix(cli): handle reset confirmation interruption with non-zero exit code --- termstory/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/termstory/cli.py b/termstory/cli.py index 20a082a..6bc4669 100644 --- a/termstory/cli.py +++ b/termstory/cli.py @@ -887,8 +887,8 @@ def perform_reset(auto_confirm: bool = False, dry_run: bool = False): try: response = input("\nAre you sure you want to delete all TermStory data? (y/n): ").strip().lower() except (KeyboardInterrupt, EOFError): - console.print() - response = "n" # Treat interruption as abort + console.print("\n[red]Reset cancelled by user interruption.[/red]") + raise typer.Exit(code=1) if response not in ("y", "yes"): console.print("[yellow]Reset aborted.[/yellow]") return From 7c4f39d95341413b3dc5480130979d05f27e94fd Mon Sep 17 00:00:00 2001 From: vishva Date: Sat, 18 Jul 2026 15:04:23 +0530 Subject: [PATCH 08/11] docs: add AGENTS.md for termstory package --- termstory/AGENTS.md | 105 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 termstory/AGENTS.md diff --git a/termstory/AGENTS.md b/termstory/AGENTS.md new file mode 100644 index 0000000..c9d3a37 --- /dev/null +++ b/termstory/AGENTS.md @@ -0,0 +1,105 @@ +# termstory/ — Source Package + +This directory contains the core implementation of TermStory, including the command-line interface, data storage, parsing pipeline, AI integrations, terminal user interface, and supporting utilities. + +--- + +# Entry Points + +- `cli.py` — Main Typer-based command-line interface coordinating application features. +- `__main__.py` — Executes the package as `python -m termstory` by invoking the CLI entry point. +- `__init__.py` — Package initialization and version information. + +--- + +# Core Infrastructure + +- `config.py` — Loads, saves, and manages application configuration and data directories. +- `database.py` — SQLite database layer responsible for persistence and connection management. +- `models.py` — Shared data models used across the application. +- `date_utils.py` — Common date and time helper utilities. +- `sanitizer.py` — Utilities for sanitizing terminal history and sensitive information. + +--- + +# History Processing + +- `parser.py` — Parses terminal history into application data structures. +- `session.py` — Session grouping and session-related logic. +- `timestamp_detective.py` — Timestamp detection and recovery utilities. +- `archive.py` — Archive management and archive-related database operations. + +--- + +# AI & Analysis + +- `ai.py` — AI provider integration and language model communication. +- `ask.py` — AI-powered querying of stored terminal history. +- `rag.py` — Retrieval-augmented generation functionality. +- `predict.py` — Prediction-related functionality. +- `insights.py` — Insight generation utilities. + +--- + +# User Interface + +- `tui.py` — Textual terminal user interface. +- `formatter.py` — Formatting and rendering helpers. +- `timeline.py` — Timeline-related functionality. +- `replay.py` — Replay-related functionality. + +--- + +# Search & Organization + +- `search.py` — Search-related functionality. +- `project.py` — Project-related utilities. +- `tags.py` — Tag management utilities. +- `stats.py` — Statistics and analytics utilities. +- `notebook.py` — Notebook-related functionality. +- `reminder.py` — Reminder-related functionality. + +--- + +# Integrations + +- `git_integration.py` — Git integration utilities. +- `web.py` — Web-related functionality. +- `exporter.py` — Export functionality. +- `backup.py` — Database backup utilities. +- `mcp_snapshot.py` — MCP snapshot utilities. +- `hermes_obs.py` — Hermes observability and integration utilities. + +--- + +# Data Flow + +A typical processing flow is: + +1. CLI entry points initialize the application. +2. Configuration is loaded. +3. Terminal history is parsed. +4. Sessions are grouped. +5. Timestamps are recovered when necessary. +6. Data is stored in the SQLite database. +7. Search, analytics, AI, and visualization operate on stored data. +8. Results are presented through the CLI, TUI, or export features. + +--- + +# Key Conventions + +- The application uses a SQLite database for persistent storage. +- Configuration is managed through `config.py` and stored in the application's configuration directory. +- The Textual interface uses `call_after_refresh(...)` when UI updates must occur after screen refresh. +- Background tasks use Textual's `@work` decorator where appropriate. +- Shared models and utilities are designed to be reused across CLI, TUI, AI, and analysis components. + +--- + +# Notes for Contributors + +- Keep module responsibilities focused and cohesive. +- Reuse shared helpers from `config.py`, `database.py`, `date_utils.py`, and `models.py` where appropriate. +- Prefer extending existing modules before introducing new abstractions. +- Follow existing project structure and naming conventions when adding features. \ No newline at end of file From 5544bef529b3865a6c62cfb8620066419dcf3946 Mon Sep 17 00:00:00 2001 From: nayanraj864-cmyk Date: Sat, 18 Jul 2026 16:17:03 +0530 Subject: [PATCH 09/11] [Predict] Reuse canonical is_noise_command from formatter --- termstory/predict.py | 29 ++-------------------------- tests/test_predict.py | 44 +++++++++++++++++++++++++++++-------------- 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/termstory/predict.py b/termstory/predict.py index 88ef234..4c7ecf0 100644 --- a/termstory/predict.py +++ b/termstory/predict.py @@ -18,32 +18,7 @@ from typing import List, Dict, Optional, Tuple from termstory.database import Database - -# --------------------------------------------------------------------------- -# Noise commands that are excluded from prediction signals -# (mirrors formatter.py's _is_noise_command heuristic) -# --------------------------------------------------------------------------- -_NOISE_PREFIXES = ( - "cd ", "ls", "pwd", "echo ", "cat ", "man ", - "history", "clear", "exit", "which ", "type ", - "git status", "git log", "git diff", "git branch", - "docker ps", "docker logs", "docker inspect", - "grep ", "awk ", "sed ", "head ", "tail ", - "ping ", "curl -I", "wget --spider", -) - -_NOISE_EXACT = {"ls", "pwd", "clear", "exit", "history", "cd"} - - -def _is_noise(cmd: str) -> bool: - """Return True if the command is routine navigation / inspection noise.""" - stripped = cmd.strip() - if stripped in _NOISE_EXACT: - return True - for prefix in _NOISE_PREFIXES: - if stripped.startswith(prefix): - return True - return False +from termstory.formatter import _is_noise_command # --------------------------------------------------------------------------- @@ -158,7 +133,7 @@ def _load_sessions(self, cutoff_time: Optional[int] = None) -> List[Dict]: if cmd_rows and all(bool(r[1]) for r in cmd_rows): continue - cmds = [r[0] for r in cmd_rows if not _is_noise(r[0])] + cmds = [r[0] for r in cmd_rows if not _is_noise_command(r[0])] project_name = p_name if p_name and p_name not in ("General / No Project", "") else "Other" sessions.append({ diff --git a/tests/test_predict.py b/tests/test_predict.py index 302a74e..00dad06 100644 --- a/tests/test_predict.py +++ b/tests/test_predict.py @@ -22,10 +22,10 @@ from termstory.predict import ( Predictor, format_predict_output, - _is_noise, _hour_bucket, _day_label, ) +from termstory.formatter import _is_noise_command # ─── Helpers ──────────────────────────────────────────────────────────────── @@ -132,24 +132,40 @@ def _make_db(sessions_spec: list) -> str: class TestIsNoise: def test_exact_noise(self): for cmd in ["ls", "pwd", "clear", "exit", "history", "cd"]: - assert _is_noise(cmd), f"Expected '{cmd}' to be noise" + assert _is_noise_command(cmd), f"Expected '{cmd}' to be noise" def test_prefix_noise(self): - assert _is_noise("cd /home/user/projects") - assert _is_noise("git status") - assert _is_noise("docker ps -a") - assert _is_noise("grep -r 'TODO'") + assert _is_noise_command("cd /home/user/projects") + assert _is_noise_command("git status") + assert _is_noise_command("docker logs my-container") + assert _is_noise_command("grep -r 'TODO'") def test_not_noise(self): - assert not _is_noise("python -m pytest") - assert not _is_noise("cargo build --release") - assert not _is_noise("npm run dev") - assert not _is_noise("git commit -m 'feat: add predict'") - assert not _is_noise("docker build -t myapp .") + assert not _is_noise_command("python -m pytest") + assert not _is_noise_command("cargo build --release") + assert not _is_noise_command("npm run dev") + assert not _is_noise_command("git commit -m 'feat: add predict'") + assert not _is_noise_command("docker build -t myapp .") def test_whitespace_stripped(self): - assert _is_noise(" ls ") - assert not _is_noise(" npm install ") + assert _is_noise_command(" ls ") + assert not _is_noise_command(" npm install ") + + def test_noise_classification_equivalence(self): + """Regression test: verify both formatter and predict treat identical commands the same way.""" + from termstory.formatter import _is_noise_command as formatter_noise + from termstory.predict import _is_noise_command as predict_noise + + test_commands = [ + "ls", "pwd", "clear", "exit", "history", "cd", + "cd /home/user/projects", "git status", "docker logs container", "grep -r 'TODO'", + "python -m pytest", "cargo build --release", "npm run dev", + "git commit -m 'feat: add predict'", "docker build -t myapp .", + " ls ", " npm install ", "docker images", "top", "htop", "whoami", + "ssh user@host", "mkdir mydir", "echo 'hello'", "# comment", "```python" + ] + for cmd in test_commands: + assert formatter_noise(cmd) == predict_noise(cmd), f"Mismatch for command: {cmd}" # ─── Time bucketing tests ──────────────────────────────────────────────────── @@ -326,7 +342,7 @@ def test_suggested_commands_excludes_noise(self): result = p.predict(top_n=1, now=now) if result["top_projects"]: for cmd in result["top_projects"][0]["suggested_commands"]: - assert not _is_noise(cmd), f"Noise cmd in suggestions: {cmd}" + assert not _is_noise_command(cmd), f"Noise cmd in suggestions: {cmd}" finally: os.unlink(db_path) From 3c4995164001b5e445dd556add8e3df7ab1f0795 Mon Sep 17 00:00:00 2001 From: nayanraj864-cmyk Date: Sat, 18 Jul 2026 22:24:30 +0530 Subject: [PATCH 10/11] [Insights] Optimize detect_late_night_chaotic_sessions database queries Optimize detect_late_night_chaotic_sessions to bulk-fetch commands and commits while preserving per-session filtering semantics. --- termstory/insights.py | 105 +++++++++++++++++++++++++++++++---------- tests/test_insights.py | 73 ++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 25 deletions(-) diff --git a/termstory/insights.py b/termstory/insights.py index 28c1c16..88aef4a 100644 --- a/termstory/insights.py +++ b/termstory/insights.py @@ -373,6 +373,9 @@ def detect_late_night_chaotic_sessions(db=None) -> List[Dict]: chaotic_sessions = [] + # 1. Filter out candidate late-night sessions in memory + candidate_sessions = [] + candidate_ids = [] for row in session_rows: s_id, start, end, duration, p_id = row try: @@ -381,19 +384,38 @@ def detect_late_night_chaotic_sessions(db=None) -> List[Dict]: continue hour = dt.hour - # Late night check: 11 PM (23) to 5 AM (5) is_late_night = (hour >= 23 or hour < 5) if not is_late_night: continue - # Fetch commands for this session - cursor.execute(""" - SELECT command, exit_code + candidate_sessions.append((s_id, start, end, duration, p_id, hour)) + candidate_ids.append(s_id) + + if not candidate_ids: + return [] + + # 2. Bulk fetch commands for all candidate sessions + commands_by_session = defaultdict(list) + _SQLITE_IN_CHUNK_SIZE = 900 + for i in range(0, len(candidate_ids), _SQLITE_IN_CHUNK_SIZE): + chunk = candidate_ids[i:i + _SQLITE_IN_CHUNK_SIZE] + placeholders = ",".join("?" for _ in chunk) + cursor.execute(f""" + SELECT session_id, command, exit_code FROM commands - WHERE session_id = ? + WHERE session_id IN ({placeholders}) ORDER BY timestamp ASC - """, (s_id,)) - cmd_rows = cursor.fetchall() + """, chunk) + for row in cursor.fetchall(): + c_s_id, cmd, exit_code = row + commands_by_session[c_s_id].append((cmd, exit_code)) + + # 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, []) if not cmd_rows: continue @@ -402,12 +424,7 @@ def detect_late_night_chaotic_sessions(db=None) -> List[Dict]: failed_count = len(failed_cmds) total_count = len(commands) - # Chaos score heuristics: - # 1. Total command count >= 10 (working intensely) - # 2. Failed command count >= 3 (struggling) - # 3. Running git commit --amend or similar desperate commands has_desperate_command = any("amend" in cmd or "revert" in cmd or "force" in cmd or "reset" in cmd for cmd in commands) - is_chaotic = (total_count >= 10 or failed_count >= 3 or has_desperate_command) if is_chaotic: @@ -415,28 +432,66 @@ def detect_late_night_chaotic_sessions(db=None) -> List[Dict]: if p_name == "General / No Project" or not p_name: p_name = "Other" - # Fetch commits in session - commits = [] - if p_id is not None: - cursor.execute(""" - SELECT message - FROM commits - WHERE project_id = ? AND timestamp >= ? AND timestamp <= ? - ORDER BY timestamp ASC - """, (p_id, start - 300, end + 600 if end is not None else start + 3600)) - commits = [r[0] for r in cursor.fetchall()] - - chaotic_sessions.append({ + chaotic_candidates.append({ "session_id": s_id, "start_time": start, "end_time": end, "duration_seconds": duration, "project_name": p_name, + "project_id": p_id, "commands": commands, "failed_commands": failed_cmds, - "commits": commits, "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) return chaotic_sessions finally: diff --git a/tests/test_insights.py b/tests/test_insights.py index ca1d6a9..7b89afb 100644 --- a/tests/test_insights.py +++ b/tests/test_insights.py @@ -353,5 +353,78 @@ def test_detect_late_night_chaotic_sessions_invalid_timestamp(tmp_path): assert len(sessions) == 0 +def test_detect_late_night_chaotic_sessions_query_count(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_perf.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')") + + # Insert 5 late-night chaotic sessions + for s_id in range(1, 6): + start_ts = int(datetime(2026, 6, 16 + s_id, 2, 0, 0).timestamp()) + cursor.execute("INSERT INTO sessions (id, start_time, end_time, duration_seconds, project_id) VALUES (?, ?, ?, ?, ?)", + (s_id, start_ts, start_ts + 100, 100, 1)) + 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 if cmd_id < 3 else 0, s_id, 1)) + + # Insert a commit + commit_ts = int(datetime(2026, 6, 17, 2, 0, 0).timestamp()) + cursor.execute("INSERT INTO commits (hash, timestamp, message, cleaned_message, project_id) VALUES (?, ?, ?, ?, ?)", + ("hash1", commit_ts, "commit msg", "commit msg", 1)) + + conn.commit() + conn.close() + + # Track execute calls + query_count = 0 + original_get_connection = db.get_connection + + class CountingCursor: + def __init__(self, cursor): + self.cursor = cursor + + def execute(self, sql, params=None): + nonlocal query_count + query_count += 1 + if params is None: + return self.cursor.execute(sql) + return self.cursor.execute(sql, params) + + def fetchall(self): + return self.cursor.fetchall() + + def __getattr__(self, name): + return getattr(self.cursor, name) + + class CountingConnection: + def __init__(self, conn): + self.conn = conn + + def cursor(self): + return CountingCursor(self.conn.cursor()) + + def __getattr__(self, name): + return getattr(self.conn, name) + + def counting_get_connection(): + return CountingConnection(original_get_connection()) + + db.get_connection = counting_get_connection + + sessions = detect_late_night_chaotic_sessions(db) + + assert len(sessions) == 5 + assert query_count <= 5, f"Expected small constant query count, got {query_count} queries" + + + From c9702874626ec23bc616c891c052685fc9de3666 Mon Sep 17 00:00:00 2001 From: Deepam Mhatre Date: Sun, 19 Jul 2026 14:02:44 +0530 Subject: [PATCH 11/11] refactor: address onboarding review feedback --- termstory/tui.py | 51 ++++++++++++++++++++++++----------------------- tests/test_tui.py | 21 ++++++++++++++++--- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/termstory/tui.py b/termstory/tui.py index 157cf09..1e7c52a 100644 --- a/termstory/tui.py +++ b/termstory/tui.py @@ -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 = [ @@ -409,12 +430,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: def action_dismiss_none(self) -> None: self.dismiss_later() - def dismiss_later(self, result=None) -> None: - """Dismiss this modal screen on the next tick with a non-zero delay.""" - self.set_timer(0.001, lambda: (self.dismiss(result), None)[1]) - -class OnboardingScreen(ModalScreen[dict]): +class OnboardingScreen(_DeferredDismissMixin, ModalScreen[dict]): """Modal screen displaying trust warning and AI configuration options.""" BINDINGS = [ @@ -426,10 +443,6 @@ class OnboardingScreen(ModalScreen[dict]): ("escape", "dismiss_none", "Close"), ] - def dismiss_later(self, result=None) -> None: - """Dismiss this modal screen on the next tick with a non-zero delay.""" - self.set_timer(0.001, lambda: (self.dismiss(result), None)[1]) - def __init__(self, current_config: dict): super().__init__() self.config = json.loads(json.dumps(current_config)) @@ -1611,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 = [ @@ -1620,10 +1633,6 @@ class ResetConfirmScreen(ModalScreen): Binding("escape", "cancel_reset", "Cancel"), ] - def dismiss_later(self, result=None) -> None: - """Dismiss this modal screen on the next tick with a non-zero delay.""" - self.set_timer(0.001, lambda: (self.dismiss(result), None)[1]) - def compose(self) -> ComposeResult: yield Static( "\n\n" @@ -1645,17 +1654,13 @@ def action_cancel_reset(self) -> None: 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 dismiss_later(self, result=None) -> None: - """Dismiss this modal screen on the next tick with a non-zero delay.""" - self.set_timer(0.001, lambda: (self.dismiss(result), None)[1]) - def action_close_matrix(self) -> None: self.dismiss_later() @@ -1740,17 +1745,13 @@ 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 dismiss_later(self, result=None) -> None: - """Dismiss this modal screen on the next tick with a non-zero delay.""" - self.set_timer(0.001, lambda: (self.dismiss(result), None)[1]) - def action_close_typing(self) -> None: self.dismiss_later() diff --git a/tests/test_tui.py b/tests/test_tui.py index 329af95..b725055 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1041,7 +1041,7 @@ async def test_tui_deep_search_scope_escape(): assert "Timeline" in str(new_timeline_root.label) @pytest.mark.asyncio -async def test_tui_api_key_validation(): +async def test_tui_api_key_validation(monkeypatch): """End-to-end regression test for Issue #288: verify the real Save & Enable button path works under Textual 8.x without freezing. @@ -1052,6 +1052,12 @@ async def test_tui_api_key_validation(): from termstory.database import Database from termstory.models import Project, Session, Command + saved_configs = [] + def mock_save_config(config): + saved_configs.append(config) + + monkeypatch.setattr("termstory.tui.save_config", mock_save_config) + with tempfile.TemporaryDirectory() as tmp_dir: db_path = os.path.join(tmp_dir, "test.db") db = Database(db_path) @@ -1090,9 +1096,13 @@ async def test_tui_api_key_validation(): save_btn = onboarding.query_one("#btn-save") save_btn.press() - # Wait for the dismissal timer to fire and the screen to pop. + # Wait for both the dismissal timer to fire AND the push_screen + # callback (handle_onboarding_result) to update app.config. for _ in range(50): - if not isinstance(app.screen, OnboardingScreen): + if ( + not isinstance(app.screen, OnboardingScreen) + and app.config.get("has_seen_onboarding") + ): break await pilot.pause() @@ -1103,6 +1113,11 @@ async def test_tui_api_key_validation(): assert app.config["active_provider"] == "groq" assert app.config["providers"]["groq"]["api_key"] == "gsk_test_key" + # Ensure save_config was called with the new config but never + # wrote to the developer's real ~/.termstory/config.json. + assert len(saved_configs) == 1 + assert saved_configs[0]["has_seen_onboarding"] is True + @pytest.mark.asyncio async def test_tui_worker_cancellation(monkeypatch): import tempfile