studyctl 2.2.0__tar.gz → 2.3.0__tar.gz
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.
- {studyctl-2.2.0 → studyctl-2.3.0}/.gitignore +1 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/PKG-INFO +1 -1
- {studyctl-2.2.0 → studyctl-2.3.0}/pyproject.toml +1 -1
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_study.py +1 -1
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/session/cleanup.py +14 -3
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/session/orchestrator.py +70 -6
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/session_state.py +1 -1
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/settings.py +30 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/tui/sidebar.py +96 -33
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/app.py +5 -2
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/session.py +56 -3
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/terminal_proxy.py +5 -1
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/components.js +69 -10
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/index.html +214 -151
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/style.css +273 -28
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/sw.js +15 -1
- studyctl-2.3.0/src/studyctl/web/static/vendor/js/split.min.js +3 -0
- studyctl-2.3.0/tests/test_fixes_pomodoro_auth_terminal.py +556 -0
- studyctl-2.3.0/tests/test_split_pane_layout.py +987 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_terminal.py +119 -66
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_vendor.py +7 -2
- {studyctl-2.2.0 → studyctl-2.3.0}/README.md +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/agent_launcher.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/__main__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_backup.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_clean.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_config.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_content.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_doctor.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_lazy.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_review.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_session.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_setup.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_shared.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_sync.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_topics.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_upgrade.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/cli/_web.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/markdown_converter.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/models.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/notebooklm_client.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/splitter.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/storage.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/content/syllabus.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/data/tmux-studyctl.conf +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/db.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/agents.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/config.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/core.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/database.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/deps.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/models.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/doctor/updates.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/_connection.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/bridges.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/concepts.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/medication.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/progress.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/search.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/sessions.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/streaks.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/history/teachback.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/backlog_logic.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/break_logic.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/briefing_logic.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/clean_logic.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/streaks_logic.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/logic/topic_resolver.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/maintenance.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/mcp/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/mcp/server.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/mcp/tools.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/output.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/parking.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/pdf.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/review_db.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/review_loader.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/services/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/services/backlog.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/services/content.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/services/flashcard_writer.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/services/review.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/session/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/session/resume.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/shared.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/state.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/sync.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/tmux.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/topics.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/tui/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/tui/__main__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/auth.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/artefacts.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/cards.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/courses.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/routes/history.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/server.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/icon-192.svg +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/icon-512.svg +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/manifest.json +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/css/files/inter-latin-ext.woff2 +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/css/files/inter-latin.woff2 +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff2 +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/css/inter.css +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/css/opendyslexic-400.css +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/js/alpine-3.14.8.min.js +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/js/htmx-2.0.4.min.js +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/src/studyctl/web/static/vendor/js/htmx-ext-sse-2.2.2.js +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/_helpers.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/harness/__init__.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/harness/agents.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/harness/study.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/harness/terminal.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/harness/tmux.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_agent_launcher.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_backlog_logic.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_backup.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_break_logic.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_briefing_logic.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_clean.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_cli.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_cli_doctor.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_cli_session.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_cli_upgrade.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_content_cli.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_content_notebooklm.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_content_splitter.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_content_storage.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_content_syllabus.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_agents.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_config.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_core.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_database.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_deps.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_integration.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_models.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_doctor_updates.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_e2e_session_demo.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_flashcard_writer.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_harness_matrix.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_history.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_install_mentor_prompt.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_lan_auth.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_lazy_imports.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_mcp_tools.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_migrate_from_empty_db.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_orchestrator.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_parking.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_review_db.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_review_loader.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_services_content.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_services_review.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_session_db_integration.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_session_state.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_setup_wizard.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_shared.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_sidebar_pilot.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_streaks_logic.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_study.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_study_integration.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_study_lifecycle.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_terminal_proxy.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_tmux.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_topic_resolver.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_topics_cli.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_uat_terminal.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_app.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_artefacts.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_cli.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_session.py +0 -0
- {studyctl-2.2.0 → studyctl-2.3.0}/tests/test_web_sidebar.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: studyctl
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: AuDHD-aware study tool with AI Socratic mentoring, spaced repetition, and content pipeline
|
|
5
5
|
Project-URL: Homepage, https://github.com/NetDevAutomate/socratic-study-mentor
|
|
6
6
|
Project-URL: Repository, https://github.com/NetDevAutomate/socratic-study-mentor
|
|
@@ -616,7 +616,7 @@ def _handle_start(
|
|
|
616
616
|
start_web_background(session_name, lan=lan, password=lan_password)
|
|
617
617
|
|
|
618
618
|
# Start ttyd if installed (allows iPad/LAN terminal access)
|
|
619
|
-
start_ttyd_background(session_name, lan=lan)
|
|
619
|
+
start_ttyd_background(session_name, lan=lan, username=lan_username, password=lan_password)
|
|
620
620
|
|
|
621
621
|
# Persist LAN info to session state so it's visible after os.execvp
|
|
622
622
|
if lan:
|
|
@@ -140,11 +140,12 @@ def end_session_common(
|
|
|
140
140
|
oneline.unlink()
|
|
141
141
|
|
|
142
142
|
# Kill background processes (web dashboard, ttyd).
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
# we'd kill the wrong thing.
|
|
143
|
+
# Strategy: kill by PID first (fast), then by port as fallback
|
|
144
|
+
# to catch processes started by a different code path.
|
|
146
145
|
import subprocess as _sp
|
|
147
146
|
|
|
147
|
+
# PID-based kill — check command matches to guard against PID recycling.
|
|
148
|
+
# "studyctl" matches both the binary and "python -m studyctl.cli".
|
|
148
149
|
pid_checks = {"web_pid": "studyctl", "ttyd_pid": "ttyd"}
|
|
149
150
|
for pid_key, expected in pid_checks.items():
|
|
150
151
|
pid = state.get(pid_key)
|
|
@@ -162,6 +163,16 @@ def end_session_common(
|
|
|
162
163
|
except (OSError, _sp.TimeoutExpired):
|
|
163
164
|
pass
|
|
164
165
|
|
|
166
|
+
# Port-based kill — fallback for orphaned processes whose PIDs we lost.
|
|
167
|
+
from studyctl.session.orchestrator import _kill_port_occupant
|
|
168
|
+
|
|
169
|
+
ttyd_port = state.get("ttyd_port")
|
|
170
|
+
if ttyd_port:
|
|
171
|
+
_kill_port_occupant(int(ttyd_port), expected_cmd="ttyd")
|
|
172
|
+
web_port = state.get("web_port")
|
|
173
|
+
if web_port:
|
|
174
|
+
_kill_port_occupant(int(web_port), expected_cmd="studyctl")
|
|
175
|
+
|
|
165
176
|
# Kill all study tmux sessions
|
|
166
177
|
with contextlib.suppress(Exception):
|
|
167
178
|
kill_all_study_sessions(current_session=session_name)
|
|
@@ -237,6 +237,9 @@ def start_web_background(session_name: str, *, lan: bool = False, password: str
|
|
|
237
237
|
|
|
238
238
|
port = _get_web_port()
|
|
239
239
|
|
|
240
|
+
# Kill any stale web server left over from a previous session
|
|
241
|
+
_kill_port_occupant(port, expected_cmd="studyctl")
|
|
242
|
+
|
|
240
243
|
studyctl_bin = shutil.which("studyctl")
|
|
241
244
|
cmd = (
|
|
242
245
|
[studyctl_bin, "web", "--port", str(port)]
|
|
@@ -255,12 +258,53 @@ def start_web_background(session_name: str, *, lan: bool = False, password: str
|
|
|
255
258
|
)
|
|
256
259
|
from studyctl.session_state import write_session_state
|
|
257
260
|
|
|
258
|
-
write_session_state({"web_pid": proc.pid})
|
|
261
|
+
write_session_state({"web_pid": proc.pid, "web_port": port})
|
|
259
262
|
_open_browser(f"http://127.0.0.1:{port}/session")
|
|
260
263
|
except Exception:
|
|
261
264
|
console.print("[yellow]Could not start web dashboard.[/yellow]")
|
|
262
265
|
|
|
263
266
|
|
|
267
|
+
def _kill_port_occupant(port: int, expected_cmd: str = "") -> None:
|
|
268
|
+
"""Kill any process listening on *port*.
|
|
269
|
+
|
|
270
|
+
If *expected_cmd* is given, only kill when the process command contains
|
|
271
|
+
that string (safety guard against killing unrelated processes).
|
|
272
|
+
"""
|
|
273
|
+
import signal
|
|
274
|
+
import time as _time
|
|
275
|
+
|
|
276
|
+
killed = False
|
|
277
|
+
try:
|
|
278
|
+
result = subprocess.run(
|
|
279
|
+
["lsof", "-ti", f":{port}"],
|
|
280
|
+
capture_output=True,
|
|
281
|
+
text=True,
|
|
282
|
+
timeout=5,
|
|
283
|
+
)
|
|
284
|
+
if not result.stdout.strip():
|
|
285
|
+
return
|
|
286
|
+
for pid_str in result.stdout.strip().splitlines():
|
|
287
|
+
pid = int(pid_str)
|
|
288
|
+
if expected_cmd:
|
|
289
|
+
ps_result = subprocess.run(
|
|
290
|
+
["ps", "-p", str(pid), "-o", "command="],
|
|
291
|
+
capture_output=True,
|
|
292
|
+
text=True,
|
|
293
|
+
timeout=5,
|
|
294
|
+
)
|
|
295
|
+
if expected_cmd not in ps_result.stdout:
|
|
296
|
+
continue
|
|
297
|
+
with __import__("contextlib").suppress(OSError):
|
|
298
|
+
os.kill(pid, signal.SIGTERM)
|
|
299
|
+
killed = True
|
|
300
|
+
except (subprocess.TimeoutExpired, ValueError, OSError):
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
# Wait for the port to be released after killing
|
|
304
|
+
if killed:
|
|
305
|
+
_time.sleep(0.5)
|
|
306
|
+
|
|
307
|
+
|
|
264
308
|
def _get_web_port() -> int:
|
|
265
309
|
"""Read web port from config, default 8567."""
|
|
266
310
|
try:
|
|
@@ -339,10 +383,18 @@ def _get_ttyd_port() -> int:
|
|
|
339
383
|
return 7681
|
|
340
384
|
|
|
341
385
|
|
|
342
|
-
def start_ttyd_background(
|
|
386
|
+
def start_ttyd_background(
|
|
387
|
+
session_name: str,
|
|
388
|
+
*,
|
|
389
|
+
lan: bool = False,
|
|
390
|
+
username: str = "",
|
|
391
|
+
password: str = "",
|
|
392
|
+
) -> None:
|
|
343
393
|
"""Start ttyd to expose the tmux session over HTTP.
|
|
344
394
|
|
|
345
395
|
Attaches a writable ttyd client to the study tmux session.
|
|
396
|
+
When credentials are provided, passes ``-c username:password``
|
|
397
|
+
so ttyd enforces HTTP Basic Auth on direct connections.
|
|
346
398
|
Skips silently if ttyd is not installed.
|
|
347
399
|
"""
|
|
348
400
|
from studyctl.session_state import write_session_state
|
|
@@ -354,6 +406,9 @@ def start_ttyd_background(session_name: str, *, lan: bool = False) -> None:
|
|
|
354
406
|
host = "0.0.0.0" if lan else "127.0.0.1"
|
|
355
407
|
port = _get_ttyd_port()
|
|
356
408
|
|
|
409
|
+
# Kill any stale ttyd left over from a previous session
|
|
410
|
+
_kill_port_occupant(port, expected_cmd="ttyd")
|
|
411
|
+
|
|
357
412
|
cmd = [
|
|
358
413
|
ttyd_bin,
|
|
359
414
|
"-W", # writable (user interacts with the agent)
|
|
@@ -361,12 +416,21 @@ def start_ttyd_background(session_name: str, *, lan: bool = False) -> None:
|
|
|
361
416
|
host,
|
|
362
417
|
"-p",
|
|
363
418
|
str(port),
|
|
364
|
-
"tmux",
|
|
365
|
-
"attach",
|
|
366
|
-
"-t",
|
|
367
|
-
session_name,
|
|
368
419
|
]
|
|
369
420
|
|
|
421
|
+
# Enforce auth on ttyd when credentials are available
|
|
422
|
+
if username and password:
|
|
423
|
+
cmd.extend(["-c", f"{username}:{password}"])
|
|
424
|
+
|
|
425
|
+
cmd.extend(
|
|
426
|
+
[
|
|
427
|
+
"tmux",
|
|
428
|
+
"attach",
|
|
429
|
+
"-t",
|
|
430
|
+
session_name,
|
|
431
|
+
]
|
|
432
|
+
)
|
|
433
|
+
|
|
370
434
|
try:
|
|
371
435
|
proc = subprocess.Popen(
|
|
372
436
|
cmd,
|
|
@@ -13,7 +13,7 @@ import threading
|
|
|
13
13
|
from dataclasses import dataclass
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
-
SESSION_DIR = Path.home() / ".config" / "studyctl"
|
|
16
|
+
SESSION_DIR = Path(os.environ.get("STUDYCTL_SESSION_DIR", Path.home() / ".config" / "studyctl"))
|
|
17
17
|
STATE_FILE = SESSION_DIR / "session-state.json"
|
|
18
18
|
TOPICS_FILE = SESSION_DIR / "session-topics.md"
|
|
19
19
|
PARKING_FILE = SESSION_DIR / "session-parking.md"
|
|
@@ -103,6 +103,16 @@ class LocalLLMConfig:
|
|
|
103
103
|
base_url: str = ""
|
|
104
104
|
|
|
105
105
|
|
|
106
|
+
@dataclass
|
|
107
|
+
class PomodoroConfig:
|
|
108
|
+
"""Configuration for the Pomodoro timer (web UI + TUI sidebar)."""
|
|
109
|
+
|
|
110
|
+
focus: int = 25 # minutes
|
|
111
|
+
short_break: int = 5
|
|
112
|
+
long_break: int = 15
|
|
113
|
+
cycles: int = 4 # long break after this many focus blocks
|
|
114
|
+
|
|
115
|
+
|
|
106
116
|
@dataclass
|
|
107
117
|
class AgentsConfig:
|
|
108
118
|
"""Configuration for AI agent detection and priority."""
|
|
@@ -143,6 +153,7 @@ class Settings:
|
|
|
143
153
|
ttyd_port: int = 7681
|
|
144
154
|
web_port: int = 8567
|
|
145
155
|
browser: str = "" # empty = system default; or "chrome", "safari", "firefox", "brave"
|
|
156
|
+
pomodoro: PomodoroConfig = field(default_factory=PomodoroConfig)
|
|
146
157
|
lan_username: str = "study" # username for HTTP Basic Auth when using --lan
|
|
147
158
|
lan_password: str = "" # password for HTTP Basic Auth when using --lan (empty = auto-generate)
|
|
148
159
|
|
|
@@ -222,6 +233,16 @@ def load_settings() -> Settings:
|
|
|
222
233
|
),
|
|
223
234
|
)
|
|
224
235
|
|
|
236
|
+
# Pomodoro timer configuration
|
|
237
|
+
pomo = raw.get("pomodoro", {})
|
|
238
|
+
if pomo:
|
|
239
|
+
settings.pomodoro = PomodoroConfig(
|
|
240
|
+
focus=int(pomo.get("focus", 25)),
|
|
241
|
+
short_break=int(pomo.get("short_break", 5)),
|
|
242
|
+
long_break=int(pomo.get("long_break", 15)),
|
|
243
|
+
cycles=int(pomo.get("cycles", 4)),
|
|
244
|
+
)
|
|
245
|
+
|
|
225
246
|
# Content pipeline configuration
|
|
226
247
|
ct = raw.get("content", {})
|
|
227
248
|
if ct:
|
|
@@ -362,6 +383,15 @@ topics:
|
|
|
362
383
|
# - domain: cooking
|
|
363
384
|
# anchors: ["mise en place", "flavour balancing"]
|
|
364
385
|
|
|
386
|
+
# Pomodoro timer (web UI + TUI sidebar)
|
|
387
|
+
# Adjust focus/break durations and cycle length.
|
|
388
|
+
# These are defaults — can also be changed in the web UI per-session.
|
|
389
|
+
# pomodoro:
|
|
390
|
+
# focus: 25 # Focus duration in minutes
|
|
391
|
+
# short_break: 5 # Short break in minutes
|
|
392
|
+
# long_break: 15 # Long break in minutes (after 'cycles' focus blocks)
|
|
393
|
+
# cycles: 4 # Number of focus blocks before a long break
|
|
394
|
+
|
|
365
395
|
# LAN access credentials (for --lan mode)
|
|
366
396
|
# Set these to avoid auto-generated passwords each session.
|
|
367
397
|
# If lan_password is empty and --lan is used, a random password is generated.
|
|
@@ -96,17 +96,35 @@ def _timer_phase(elapsed_secs: int, energy: int) -> str:
|
|
|
96
96
|
return "red"
|
|
97
97
|
|
|
98
98
|
|
|
99
|
-
# Pomodoro
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
POMODORO_CYCLE_LENGTH = 4 # long break after this many focus blocks
|
|
99
|
+
# Pomodoro defaults (overridden by config via load_settings().pomodoro).
|
|
100
|
+
_POMO_FOCUS = 25 * 60
|
|
101
|
+
_POMO_SHORT_BREAK = 5 * 60
|
|
102
|
+
_POMO_LONG_BREAK = 15 * 60
|
|
103
|
+
_POMO_CYCLES = 4
|
|
105
104
|
|
|
106
105
|
|
|
107
|
-
def
|
|
106
|
+
def _load_pomodoro_config() -> tuple[int, int, int, int]:
|
|
107
|
+
"""Load pomodoro durations from settings. Returns (focus, short, long, cycles) in seconds."""
|
|
108
|
+
try:
|
|
109
|
+
from studyctl.settings import load_settings
|
|
110
|
+
|
|
111
|
+
pomo = load_settings().pomodoro
|
|
112
|
+
return (pomo.focus * 60, pomo.short_break * 60, pomo.long_break * 60, pomo.cycles)
|
|
113
|
+
except Exception:
|
|
114
|
+
return (_POMO_FOCUS, _POMO_SHORT_BREAK, _POMO_LONG_BREAK, _POMO_CYCLES)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _pomodoro_state(
|
|
118
|
+
elapsed_secs: int,
|
|
119
|
+
focus: int = 0,
|
|
120
|
+
short_break: int = 0,
|
|
121
|
+
long_break: int = 0,
|
|
122
|
+
cycles: int = 0,
|
|
123
|
+
) -> tuple[str, int, int, int]:
|
|
108
124
|
"""Compute pomodoro state from total elapsed seconds.
|
|
109
125
|
|
|
126
|
+
If durations are not passed (0), loads from config.
|
|
127
|
+
|
|
110
128
|
Returns:
|
|
111
129
|
(phase, remaining_secs, cycle_number, block_in_cycle)
|
|
112
130
|
phase: "focus" | "short_break" | "long_break"
|
|
@@ -114,36 +132,33 @@ def _pomodoro_state(elapsed_secs: int) -> tuple[str, int, int, int]:
|
|
|
114
132
|
cycle_number: which full cycle (1-based)
|
|
115
133
|
block_in_cycle: which focus block within the cycle (1-4)
|
|
116
134
|
"""
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
if not focus:
|
|
136
|
+
focus, short_break, long_break, cycles = _load_pomodoro_config()
|
|
137
|
+
|
|
138
|
+
single_block = focus + short_break
|
|
139
|
+
full_cycle = (single_block * cycles) - short_break + long_break
|
|
121
140
|
|
|
122
141
|
cycle_number = elapsed_secs // full_cycle + 1
|
|
123
142
|
pos_in_cycle = elapsed_secs % full_cycle
|
|
124
143
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if pos_in_cycle < POMODORO_FOCUS:
|
|
129
|
-
remaining = POMODORO_FOCUS - pos_in_cycle
|
|
144
|
+
for block_idx in range(cycles):
|
|
145
|
+
if pos_in_cycle < focus:
|
|
146
|
+
remaining = focus - pos_in_cycle
|
|
130
147
|
return ("focus", remaining, cycle_number, block_idx + 1)
|
|
131
|
-
pos_in_cycle -=
|
|
148
|
+
pos_in_cycle -= focus
|
|
132
149
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
remaining = POMODORO_SHORT_BREAK - pos_in_cycle
|
|
150
|
+
if block_idx < cycles - 1:
|
|
151
|
+
if pos_in_cycle < short_break:
|
|
152
|
+
remaining = short_break - pos_in_cycle
|
|
137
153
|
return ("short_break", remaining, cycle_number, block_idx + 1)
|
|
138
|
-
pos_in_cycle -=
|
|
154
|
+
pos_in_cycle -= short_break
|
|
139
155
|
else:
|
|
140
|
-
if pos_in_cycle <
|
|
141
|
-
remaining =
|
|
156
|
+
if pos_in_cycle < long_break:
|
|
157
|
+
remaining = long_break - pos_in_cycle
|
|
142
158
|
return ("long_break", remaining, cycle_number, block_idx + 1)
|
|
143
|
-
pos_in_cycle -=
|
|
159
|
+
pos_in_cycle -= long_break
|
|
144
160
|
|
|
145
|
-
|
|
146
|
-
return ("focus", POMODORO_FOCUS, cycle_number + 1, 1)
|
|
161
|
+
return ("focus", focus, cycle_number + 1, 1)
|
|
147
162
|
|
|
148
163
|
|
|
149
164
|
# ---------------------------------------------------------------------------
|
|
@@ -156,7 +171,7 @@ class TimerWidget(Static):
|
|
|
156
171
|
|
|
157
172
|
Two modes:
|
|
158
173
|
- **elapsed**: counts up, colour transitions at energy thresholds
|
|
159
|
-
- **pomodoro**:
|
|
174
|
+
- **pomodoro**: configurable focus/break cycles, counts down within each phase
|
|
160
175
|
"""
|
|
161
176
|
|
|
162
177
|
elapsed: reactive[int] = reactive(0)
|
|
@@ -164,6 +179,17 @@ class TimerWidget(Static):
|
|
|
164
179
|
energy: reactive[int] = reactive(5)
|
|
165
180
|
timer_mode: reactive[str] = reactive("elapsed")
|
|
166
181
|
|
|
182
|
+
# Pomodoro durations (seconds) — loaded from config on mount
|
|
183
|
+
pomo_focus: int = 25 * 60
|
|
184
|
+
pomo_short_break: int = 5 * 60
|
|
185
|
+
pomo_long_break: int = 15 * 60
|
|
186
|
+
pomo_cycles: int = 4
|
|
187
|
+
|
|
188
|
+
def on_mount(self) -> None:
|
|
189
|
+
self.pomo_focus, self.pomo_short_break, self.pomo_long_break, self.pomo_cycles = (
|
|
190
|
+
_load_pomodoro_config()
|
|
191
|
+
)
|
|
192
|
+
|
|
167
193
|
def render(self) -> str:
|
|
168
194
|
indicator = " [bold red]PAUSED[/]" if self.paused else ""
|
|
169
195
|
|
|
@@ -182,12 +208,18 @@ class TimerWidget(Static):
|
|
|
182
208
|
|
|
183
209
|
def _render_pomodoro(self) -> str:
|
|
184
210
|
"""Countdown timer with focus/break cycle display."""
|
|
185
|
-
phase, remaining, _cycle, block = _pomodoro_state(
|
|
211
|
+
phase, remaining, _cycle, block = _pomodoro_state(
|
|
212
|
+
self.elapsed,
|
|
213
|
+
focus=self.pomo_focus,
|
|
214
|
+
short_break=self.pomo_short_break,
|
|
215
|
+
long_break=self.pomo_long_break,
|
|
216
|
+
cycles=self.pomo_cycles,
|
|
217
|
+
)
|
|
186
218
|
mins, secs = divmod(remaining, 60)
|
|
187
219
|
|
|
188
220
|
if phase == "focus":
|
|
189
221
|
colour = "green"
|
|
190
|
-
label = f"FOCUS {block}/{
|
|
222
|
+
label = f"FOCUS {block}/{self.pomo_cycles}"
|
|
191
223
|
elif phase == "short_break":
|
|
192
224
|
colour = "cyan"
|
|
193
225
|
label = "BREAK"
|
|
@@ -195,7 +227,9 @@ class TimerWidget(Static):
|
|
|
195
227
|
colour = "magenta"
|
|
196
228
|
label = "LONG BREAK"
|
|
197
229
|
|
|
198
|
-
|
|
230
|
+
focus_min = self.pomo_focus // 60
|
|
231
|
+
time_part = f"[bold {colour}]{mins:02d}:{secs:02d}[/]"
|
|
232
|
+
return f"{time_part} [{colour}]{label}[/] [dim]({focus_min}m)[/]"
|
|
199
233
|
|
|
200
234
|
|
|
201
235
|
class ActivityFeed(Static):
|
|
@@ -312,7 +346,10 @@ class SidebarApp(App[None]):
|
|
|
312
346
|
|
|
313
347
|
BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
|
|
314
348
|
("p", "toggle_pause", "Pause/Resume"),
|
|
349
|
+
("s", "toggle_pomodoro", "Start/Stop Pomodoro"),
|
|
315
350
|
("r", "reset_timer", "Reset"),
|
|
351
|
+
("plus_sign", "pomo_focus_up", "+5min focus"),
|
|
352
|
+
("minus", "pomo_focus_down", "-5min focus"),
|
|
316
353
|
("Q", "end_session", "End Session"),
|
|
317
354
|
("q", "quit", "Quit sidebar"),
|
|
318
355
|
]
|
|
@@ -322,7 +359,7 @@ class SidebarApp(App[None]):
|
|
|
322
359
|
yield BreakBanner(id="break_banner")
|
|
323
360
|
yield ActivityFeed(id="activity")
|
|
324
361
|
yield CounterBar(id="counters")
|
|
325
|
-
yield Static("[dim]p:pause
|
|
362
|
+
yield Static("[dim]p:pause s:pomodoro +/-:adjust r:reset Q:end[/]", id="status")
|
|
326
363
|
|
|
327
364
|
def on_mount(self) -> None:
|
|
328
365
|
self._poll_ipc_files()
|
|
@@ -427,7 +464,14 @@ class SidebarApp(App[None]):
|
|
|
427
464
|
|
|
428
465
|
timer_mode = state.get("timer_mode", "elapsed")
|
|
429
466
|
if timer_mode == "pomodoro":
|
|
430
|
-
|
|
467
|
+
tw = self.query_one("#timer", TimerWidget)
|
|
468
|
+
phase, remaining, _cycle, block = _pomodoro_state(
|
|
469
|
+
elapsed,
|
|
470
|
+
focus=tw.pomo_focus,
|
|
471
|
+
short_break=tw.pomo_short_break,
|
|
472
|
+
long_break=tw.pomo_long_break,
|
|
473
|
+
cycles=tw.pomo_cycles,
|
|
474
|
+
)
|
|
431
475
|
r_mins, r_secs = divmod(remaining, 60)
|
|
432
476
|
if phase == "focus":
|
|
433
477
|
timer_str = f"{r_mins:02d}:{r_secs:02d} F{block}"
|
|
@@ -490,6 +534,25 @@ class SidebarApp(App[None]):
|
|
|
490
534
|
}
|
|
491
535
|
)
|
|
492
536
|
|
|
537
|
+
def action_toggle_pomodoro(self) -> None:
|
|
538
|
+
"""Toggle between elapsed and pomodoro timer modes."""
|
|
539
|
+
state = read_session_state()
|
|
540
|
+
current = state.get("timer_mode", "elapsed")
|
|
541
|
+
new_mode = "elapsed" if current == "pomodoro" else "pomodoro"
|
|
542
|
+
write_session_state({"timer_mode": new_mode})
|
|
543
|
+
|
|
544
|
+
def action_pomo_focus_up(self) -> None:
|
|
545
|
+
"""Increase pomodoro focus duration by 5 minutes."""
|
|
546
|
+
timer = self.query_one("#timer", TimerWidget)
|
|
547
|
+
timer.pomo_focus = min(timer.pomo_focus + 5 * 60, 120 * 60) # cap at 120min
|
|
548
|
+
timer.refresh()
|
|
549
|
+
|
|
550
|
+
def action_pomo_focus_down(self) -> None:
|
|
551
|
+
"""Decrease pomodoro focus duration by 5 minutes."""
|
|
552
|
+
timer = self.query_one("#timer", TimerWidget)
|
|
553
|
+
timer.pomo_focus = max(timer.pomo_focus - 5 * 60, 5 * 60) # min 5min
|
|
554
|
+
timer.refresh()
|
|
555
|
+
|
|
493
556
|
def action_end_session(self) -> None:
|
|
494
557
|
"""End the entire study session (agent + sidebar + tmux).
|
|
495
558
|
|
|
@@ -84,10 +84,13 @@ def create_app(
|
|
|
84
84
|
except ImportError:
|
|
85
85
|
pass # httpx/websockets not installed — proxy unavailable
|
|
86
86
|
|
|
87
|
-
# Serve index.html at root
|
|
87
|
+
# Serve index.html at root (no-cache to prevent stale SW/browser cache)
|
|
88
88
|
@app.get("/")
|
|
89
89
|
async def index() -> FileResponse:
|
|
90
|
-
return FileResponse(
|
|
90
|
+
return FileResponse(
|
|
91
|
+
STATIC_DIR / "index.html",
|
|
92
|
+
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
|
|
93
|
+
)
|
|
91
94
|
|
|
92
95
|
# Redirect /session to hash-routed study-session tab
|
|
93
96
|
@app.get("/session")
|
|
@@ -57,17 +57,40 @@ def _is_tmux_session_alive(session_name: str) -> bool:
|
|
|
57
57
|
return result.returncode == 0
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def _kill_stale_ttyd(state: dict) -> None:
|
|
61
|
+
"""Kill a stale ttyd process if the tmux session it attaches to is gone."""
|
|
62
|
+
import os
|
|
63
|
+
import subprocess as _sp
|
|
64
|
+
|
|
65
|
+
ttyd_pid = state.get("ttyd_pid")
|
|
66
|
+
if not ttyd_pid:
|
|
67
|
+
return
|
|
68
|
+
try:
|
|
69
|
+
result = _sp.run(
|
|
70
|
+
["ps", "-p", str(ttyd_pid), "-o", "command="],
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
timeout=5,
|
|
74
|
+
)
|
|
75
|
+
if "ttyd" in result.stdout:
|
|
76
|
+
os.kill(ttyd_pid, 15) # SIGTERM
|
|
77
|
+
except (OSError, _sp.TimeoutExpired):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
60
81
|
def _get_full_state() -> dict:
|
|
61
82
|
"""Read all IPC files into a single state dict.
|
|
62
83
|
|
|
63
84
|
If the state file claims a session is active but the tmux session
|
|
64
|
-
is gone (zombie),
|
|
85
|
+
is gone (zombie), kills stale ttyd, clears state, and returns empty.
|
|
65
86
|
"""
|
|
66
87
|
state = read_session_state()
|
|
67
88
|
|
|
68
89
|
# Zombie detection: state says active but tmux session is dead
|
|
69
90
|
tmux_session = state.get("tmux_session")
|
|
70
91
|
if tmux_session and state.get("mode") != "ended" and not _is_tmux_session_alive(tmux_session):
|
|
92
|
+
# Kill orphaned ttyd before clearing state
|
|
93
|
+
_kill_stale_ttyd(state)
|
|
71
94
|
# Clear stale IPC files
|
|
72
95
|
for f in (STATE_FILE, TOPICS_FILE, PARKING_FILE):
|
|
73
96
|
if f.exists():
|
|
@@ -284,6 +307,26 @@ def get_topics() -> list[dict]:
|
|
|
284
307
|
return []
|
|
285
308
|
|
|
286
309
|
|
|
310
|
+
@router.get("/settings/pomodoro")
|
|
311
|
+
def get_pomodoro_settings() -> dict:
|
|
312
|
+
"""Return pomodoro timer defaults from config.
|
|
313
|
+
|
|
314
|
+
The web UI uses these as defaults, overridden by localStorage.
|
|
315
|
+
"""
|
|
316
|
+
try:
|
|
317
|
+
from studyctl.settings import load_settings
|
|
318
|
+
|
|
319
|
+
pomo = load_settings().pomodoro
|
|
320
|
+
return {
|
|
321
|
+
"focus": pomo.focus,
|
|
322
|
+
"short_break": pomo.short_break,
|
|
323
|
+
"long_break": pomo.long_break,
|
|
324
|
+
"cycles": pomo.cycles,
|
|
325
|
+
}
|
|
326
|
+
except Exception:
|
|
327
|
+
return {"focus": 25, "short_break": 5, "long_break": 15, "cycles": 4}
|
|
328
|
+
|
|
329
|
+
|
|
287
330
|
# ---------------------------------------------------------------------------
|
|
288
331
|
# Start / End session from web UI
|
|
289
332
|
# ---------------------------------------------------------------------------
|
|
@@ -480,8 +523,18 @@ def start_session(body: StartSessionRequest) -> JSONResponse:
|
|
|
480
523
|
state_update["topic_config_name"] = topic_config.name
|
|
481
524
|
write_session_state(state_update)
|
|
482
525
|
|
|
483
|
-
# Start ttyd for terminal access
|
|
484
|
-
|
|
526
|
+
# Start ttyd for terminal access (with auth from config if available)
|
|
527
|
+
ttyd_username = ""
|
|
528
|
+
ttyd_password = ""
|
|
529
|
+
try:
|
|
530
|
+
from studyctl.settings import load_settings as _ls_ttyd
|
|
531
|
+
|
|
532
|
+
_ttyd_settings = _ls_ttyd()
|
|
533
|
+
ttyd_username = _ttyd_settings.lan_username or ""
|
|
534
|
+
ttyd_password = _ttyd_settings.lan_password or ""
|
|
535
|
+
except Exception:
|
|
536
|
+
pass
|
|
537
|
+
start_ttyd_background(session_name, username=ttyd_username, password=ttyd_password)
|
|
485
538
|
|
|
486
539
|
return JSONResponse(
|
|
487
540
|
{
|
|
@@ -122,10 +122,14 @@ async def proxy_terminal_ws(ws: WebSocket) -> None:
|
|
|
122
122
|
qs = "&".join(f"{k}={v}" for k, v in ws.query_params.items())
|
|
123
123
|
upstream_ws_url = f"{upstream_ws_url}?{qs}"
|
|
124
124
|
|
|
125
|
-
# Connect to upstream with the same subprotocol
|
|
125
|
+
# Connect to upstream with the same subprotocol.
|
|
126
|
+
# Forward the Authorization header so ttyd's -c auth is satisfied.
|
|
126
127
|
upstream_kwargs: dict = {}
|
|
127
128
|
if subprotocol:
|
|
128
129
|
upstream_kwargs["subprotocols"] = [subprotocol]
|
|
130
|
+
auth_header = ws.headers.get("authorization")
|
|
131
|
+
if auth_header:
|
|
132
|
+
upstream_kwargs["additional_headers"] = {"Authorization": auth_header}
|
|
129
133
|
|
|
130
134
|
try:
|
|
131
135
|
async with websockets.connect(upstream_ws_url, **upstream_kwargs) as upstream:
|