Version 1.0
This commit is contained in:
+845
@@ -0,0 +1,845 @@
|
||||
"""TactiTerm TUI — Coding Tutor Terminal User Interface with W3Schools-style Handbook & Expandable Output Terminal."""
|
||||
import argparse
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
from textual.widgets import Header, Footer, Static, OptionList, Button, Markdown, TextArea, Input
|
||||
from textual.widgets.option_list import Option
|
||||
from textual.binding import Binding
|
||||
|
||||
from src.core.loader import ChallengeLoader
|
||||
from src.core.executor import executor
|
||||
from src.core.linter import linter
|
||||
from src.core.mentor import mentor
|
||||
from src.core.handbook import handbook_service
|
||||
from src.core.prompts import get_mentor_prompt
|
||||
from src.tui.widgets import CodeEditor, MentorInputTextArea, StdinInput, TUI_COMPLETIONS
|
||||
|
||||
|
||||
class TactiTermTUI(App):
|
||||
"""A coding tutor TUI with direct core service integration, Mentor, Handbook, and Expandable Stdin Output Terminal."""
|
||||
|
||||
TITLE = "TactiTerm"
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
layout: horizontal;
|
||||
height: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Left column: challenge list & details */
|
||||
#challenge-panel {
|
||||
width: 32%;
|
||||
min-width: 25;
|
||||
height: 100%;
|
||||
border-right: heavy $primary;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
/* Middle column: code editor & execution output */
|
||||
#editor-panel {
|
||||
width: 68%;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
/* Right columns: Mentor & Handbook sidebars (hidden by default) */
|
||||
#mentor-sidebar, #handbook-sidebar {
|
||||
width: 32%;
|
||||
min-width: 26;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Adjusted column widths when a sidebar is toggled open */
|
||||
#main-container.sidebar-open #challenge-panel {
|
||||
width: 26%;
|
||||
}
|
||||
|
||||
#main-container.sidebar-open #editor-panel {
|
||||
width: 44%;
|
||||
border-right: heavy $primary;
|
||||
}
|
||||
|
||||
#main-container.sidebar-mentor-open #mentor-sidebar {
|
||||
display: block;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
#main-container.sidebar-handbook-open #handbook-sidebar {
|
||||
display: block;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
#challenge-header, #mentor-header, #handbook-header {
|
||||
height: 3;
|
||||
background: $primary;
|
||||
color: white;
|
||||
content-align: center middle;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#challenge_list {
|
||||
height: 8;
|
||||
border: solid $secondary;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#handbook-search-input {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#btn-handbook-mode {
|
||||
width: 100%;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#handbook_topic_list {
|
||||
height: 8;
|
||||
border: solid $secondary;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#challenge-details-container {
|
||||
height: 1fr;
|
||||
border: solid $primary-darken-2;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Sleek compact action bar with reduced height */
|
||||
#action-bar {
|
||||
height: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#action-bar Button {
|
||||
height: 1;
|
||||
min-width: 7;
|
||||
padding: 0 1;
|
||||
margin-right: 1;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Editor & Auto-Complete Popup Overlay styling */
|
||||
CodeEditor {
|
||||
height: 1fr;
|
||||
min-height: 8;
|
||||
border: solid $accent;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
CodeEditor:focus {
|
||||
border: heavy $primary;
|
||||
}
|
||||
|
||||
#completion-popup {
|
||||
display: none;
|
||||
height: 8;
|
||||
background: $surface-darken-1;
|
||||
border: heavy $warning;
|
||||
margin-bottom: 1;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#main-container.show-completion #completion-popup {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#completion-title {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
#completion_list_popup {
|
||||
height: 5;
|
||||
border: solid $warning-darken-2;
|
||||
}
|
||||
|
||||
/* Output Console Header & Expandable Terminal */
|
||||
#output-header-bar {
|
||||
height: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#output-title {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $accent;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
#btn-expand-output {
|
||||
height: 1;
|
||||
min-width: 16;
|
||||
padding: 0 1;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#output-scroll-container {
|
||||
height: 7;
|
||||
background: $surface;
|
||||
border: solid $accent;
|
||||
padding: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#editor-panel.output-expanded #output-scroll-container {
|
||||
height: 15;
|
||||
}
|
||||
|
||||
#expanded-input-bar {
|
||||
display: none;
|
||||
height: 3;
|
||||
layout: horizontal;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#editor-panel.output-expanded #expanded-input-bar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#program-stdin-input {
|
||||
width: 1fr;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
#btn-send-stdin {
|
||||
width: 18;
|
||||
}
|
||||
|
||||
/* Mentor & Handbook Sidebar Elements */
|
||||
#mentor-response-scroll, #handbook-example-scroll {
|
||||
height: 1fr;
|
||||
background: $surface;
|
||||
border: solid $warning-darken-1;
|
||||
padding: 1;
|
||||
margin-bottom: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#mentor-input-title {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
#mentor-question-input {
|
||||
height: 4;
|
||||
border: solid $warning;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#btn-send-mentor {
|
||||
width: 100%;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
#status-bar {
|
||||
height: 1;
|
||||
background: $primary;
|
||||
color: white;
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+r", "run_code", "Run"),
|
||||
Binding("ctrl+l", "lint_code", "Lint"),
|
||||
Binding("ctrl+e,f4", "toggle_expand_output", "Expand Output (Ctrl+E / F4)"),
|
||||
Binding("f2", "check_answer", "Check", show=False),
|
||||
Binding("f1", "toggle_mentor_sidebar", "Mentor", show=False),
|
||||
Binding("f3", "toggle_handbook_sidebar", "Handbook", show=False),
|
||||
Binding("ctrl+f", "refresh_challenges", "Refresh"),
|
||||
Binding("alt+w", "scroll_output_up", "Output Up", show=False),
|
||||
Binding("alt+s", "scroll_output_down", "Output Down", show=False),
|
||||
Binding("ctrl+up", "scroll_mentor_up", "Sidebar Up", show=False),
|
||||
Binding("ctrl+down", "scroll_mentor_down", "Sidebar Down", show=False),
|
||||
Binding("alt+up", "scroll_details_up", "Details Up", show=False),
|
||||
Binding("alt+down", "scroll_details_down", "Details Down", show=False),
|
||||
Binding("escape", "unfocus_editor", "Exit Focus"),
|
||||
Binding("f6", "toggle_panel_focus", "Switch Focus"),
|
||||
Binding("ctrl+q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
def __init__(self, debug: bool = False) -> None:
|
||||
super().__init__()
|
||||
self.loader = ChallengeLoader()
|
||||
self.active_challenge_id = None
|
||||
self.challenges_dict = {}
|
||||
self.catalog_functions: List[Dict[str, str]] = []
|
||||
self.catalog_subjects: List[Dict[str, str]] = []
|
||||
self.active_handbook_mode = "all"
|
||||
self.active_completion_prefix = ""
|
||||
self.debug_mode = debug
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
|
||||
with Horizontal(id="main-container"):
|
||||
# Left column: Challenges & Details
|
||||
with Container(id="challenge-panel"):
|
||||
yield Static("TactiTerm Workspace", id="challenge-header")
|
||||
yield OptionList(id="challenge_list")
|
||||
with VerticalScroll(id="challenge-details-container"):
|
||||
yield Markdown("Select a challenge from the list above to view details...", id="challenge-details")
|
||||
|
||||
# Middle column: Code Editor, Completion Dropdown Popup & Expandable Output Terminal
|
||||
with Container(id="editor-panel"):
|
||||
with Horizontal(id="action-bar"):
|
||||
yield Button("▶ Run (Ctrl+R)", id="btn-run", variant="success")
|
||||
yield Button("🔍 Lint (Ctrl+L)", id="btn-lint", variant="primary")
|
||||
yield Button("✅ Check (F2)", id="btn-check", variant="success")
|
||||
yield Button("💡 Mentor (F1)", id="btn-guide", variant="warning")
|
||||
yield Button("📖 Handbook (F3)", id="btn-handbook", variant="primary")
|
||||
yield Button("🔄 Refresh", id="btn-refresh", variant="default")
|
||||
|
||||
yield CodeEditor(id="editor")
|
||||
|
||||
# Auto-Completion Dropdown Popup Overlay
|
||||
with Container(id="completion-popup"):
|
||||
yield Static("💡 Auto-Complete Suggestions (↑/↓ to navigate, Enter/Tab to insert):", id="completion-title")
|
||||
yield OptionList(id="completion_list_popup")
|
||||
|
||||
with Horizontal(id="output-header-bar"):
|
||||
yield Static("Output Console (Alt+W/S: Scroll)", id="output-title")
|
||||
yield Button("⤢ Expand / Input (Ctrl+E)", id="btn-expand-output", variant="primary")
|
||||
|
||||
with VerticalScroll(id="output-scroll-container"):
|
||||
yield Static("Execution and lint output will appear here...", id="output-container")
|
||||
|
||||
with Horizontal(id="expanded-input-bar"):
|
||||
yield StdinInput(placeholder="Type stdin input here and press Enter to send to program...", id="program-stdin-input")
|
||||
yield Button("Send Input", id="btn-send-stdin", variant="success")
|
||||
|
||||
# Right column 1: Mentor Sidebar
|
||||
with Container(id="mentor-sidebar"):
|
||||
yield Static("💡 Mentor", id="mentor-header")
|
||||
with VerticalScroll(id="mentor-response-scroll"):
|
||||
yield Markdown("Ask the mentor a question or click Check Answer...", id="mentor-response-display")
|
||||
|
||||
yield Static("Ask Question (Enter: Send | Shift+Enter: Newline):", id="mentor-input-title")
|
||||
yield MentorInputTextArea(id="mentor-question-input")
|
||||
yield Button("💡 Ask Mentor (Enter)", id="btn-send-mentor", variant="warning")
|
||||
|
||||
# Right column 2: W3Schools-Style Handbook Sidebar
|
||||
with Container(id="handbook-sidebar"):
|
||||
yield Static("📖 W3Schools Handbook", id="handbook-header")
|
||||
yield Input(placeholder="🔍 Search built-ins or topics...", id="handbook-search-input")
|
||||
yield Button("Filter: All Items (Click to Switch)", id="btn-handbook-mode", variant="default")
|
||||
yield OptionList(id="handbook_topic_list")
|
||||
with VerticalScroll(id="handbook-example-scroll"):
|
||||
yield Markdown("Select a function or topic above to view W3Schools reference card...", id="handbook-example-display")
|
||||
|
||||
yield Static("Ready", id="status-bar")
|
||||
yield Footer()
|
||||
|
||||
# ── Events & Lifecycle ────────────────────────────────────
|
||||
|
||||
def action_toggle_expand_output(self) -> None:
|
||||
"""Keybinding handler for Ctrl+E and F4."""
|
||||
self.toggle_expand_output()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.call_after_refresh(self.load_challenges)
|
||||
|
||||
def load_challenges(self) -> None:
|
||||
self.challenges_dict = self.loader.load_all()
|
||||
|
||||
try:
|
||||
option_list = self.query_one("#challenge_list", OptionList)
|
||||
option_list.clear_options()
|
||||
|
||||
for cid, challenge in sorted(self.challenges_dict.items()):
|
||||
option_list.add_option(
|
||||
Option(f"{challenge.name} ({challenge.difficulty})", id=cid)
|
||||
)
|
||||
|
||||
count = len(self.challenges_dict)
|
||||
if count > 0:
|
||||
option_list.highlighted = 0
|
||||
first_opt = option_list.get_option_at_index(0)
|
||||
self.on_option_list_option_highlighted(
|
||||
OptionList.OptionHighlighted(option_list, first_opt, 0)
|
||||
)
|
||||
self.query_one("#status-bar", Static).update(
|
||||
f"Loaded {count} challenges — Hovering '{first_opt.prompt}' (Hit Enter to edit)"
|
||||
)
|
||||
else:
|
||||
self.query_one("#status-bar", Static).update("No challenges found in src/challenges/.")
|
||||
except Exception as e:
|
||||
if self.debug_mode:
|
||||
print(f"DEBUG: Exception in load_challenges: {e}")
|
||||
|
||||
def load_handbook_catalog(self, language: str) -> None:
|
||||
catalog = handbook_service.get_catalog(language)
|
||||
self.catalog_functions = catalog.get("functions", [])
|
||||
self.catalog_subjects = catalog.get("subjects", [])
|
||||
self.filter_handbook_list()
|
||||
|
||||
def filter_handbook_list(self) -> None:
|
||||
search_term = ""
|
||||
try:
|
||||
search_input = self.query_one("#handbook-search-input", Input)
|
||||
search_term = search_input.value.strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
items: List[Dict[str, str]] = []
|
||||
if self.active_handbook_mode == "functions":
|
||||
items = [dict(t, icon="⚡") for t in self.catalog_functions]
|
||||
elif self.active_handbook_mode == "subjects":
|
||||
items = [dict(t, icon="📚") for t in self.catalog_subjects]
|
||||
else:
|
||||
items = [dict(t, icon="⚡") for t in self.catalog_functions] + [dict(t, icon="📚") for t in self.catalog_subjects]
|
||||
|
||||
if search_term:
|
||||
items = [
|
||||
i for i in items
|
||||
if search_term in i["title"].lower() or search_term in i["desc"].lower()
|
||||
]
|
||||
|
||||
topic_list = self.query_one("#handbook_topic_list", OptionList)
|
||||
topic_list.clear_options()
|
||||
|
||||
for item in items:
|
||||
topic_list.add_option(Option(f"{item['icon']} {item['title']}", id=item["id"]))
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
if event.input.id == "handbook-search-input":
|
||||
self.filter_handbook_list()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id == "program-stdin-input":
|
||||
self.send_program_stdin()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
button_id = event.button.id
|
||||
if button_id == "btn-run":
|
||||
self.action_run_code()
|
||||
elif button_id == "btn-lint":
|
||||
self.action_lint_code()
|
||||
elif button_id == "btn-check":
|
||||
self.action_check_answer()
|
||||
elif button_id == "btn-guide":
|
||||
self.action_toggle_mentor_sidebar()
|
||||
elif button_id == "btn-handbook":
|
||||
self.action_toggle_handbook_sidebar()
|
||||
elif button_id == "btn-handbook-mode":
|
||||
self.toggle_handbook_mode()
|
||||
elif button_id == "btn-expand-output":
|
||||
self.toggle_expand_output()
|
||||
elif button_id == "btn-send-stdin":
|
||||
self.send_program_stdin()
|
||||
elif button_id == "btn-send-mentor":
|
||||
self.submit_mentor_question()
|
||||
elif button_id == "btn-refresh":
|
||||
self.action_refresh_challenges()
|
||||
|
||||
def toggle_expand_output(self) -> None:
|
||||
"""Toggle output terminal expansion and stdin input bar."""
|
||||
panel = self.query_one("#editor-panel")
|
||||
btn = self.query_one("#btn-expand-output", Button)
|
||||
if panel.has_class("output-expanded"):
|
||||
panel.remove_class("output-expanded")
|
||||
btn.label = "⤢ Expand / Input (Ctrl+E)"
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
self.query_one("#status-bar", Static).update("Output console collapsed.")
|
||||
else:
|
||||
panel.add_class("output-expanded")
|
||||
btn.label = "⤡ Collapse (Ctrl+E)"
|
||||
self.query_one("#program-stdin-input", StdinInput).focus()
|
||||
self.query_one("#status-bar", Static).update(
|
||||
"Output console expanded. Type stdin input below and hit Enter to run program with input."
|
||||
)
|
||||
|
||||
def send_program_stdin(self) -> None:
|
||||
"""Send input text from #program-stdin-input to running code."""
|
||||
stdin_input = self.query_one("#program-stdin-input", StdinInput)
|
||||
user_stdin = stdin_input.value
|
||||
stdin_input.value = ""
|
||||
self.action_run_code(stdin=user_stdin)
|
||||
|
||||
def toggle_handbook_mode(self) -> None:
|
||||
if self.active_handbook_mode == "all":
|
||||
self.active_handbook_mode = "functions"
|
||||
label = "Filter: ⚡ Built-in Functions"
|
||||
elif self.active_handbook_mode == "functions":
|
||||
self.active_handbook_mode = "subjects"
|
||||
label = "Filter: 📚 Broad Subjects"
|
||||
else:
|
||||
self.active_handbook_mode = "all"
|
||||
label = "Filter: All Items"
|
||||
|
||||
self.query_one("#btn-handbook-mode", Button).label = label
|
||||
self.filter_handbook_list()
|
||||
|
||||
# ── Sidebar & Focus Management ───────────────────────────
|
||||
|
||||
def action_toggle_mentor_sidebar(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
q_input = self.query_one("#mentor-question-input", MentorInputTextArea)
|
||||
|
||||
if main_box.has_class("sidebar-mentor-open"):
|
||||
main_box.remove_class("sidebar-mentor-open")
|
||||
main_box.remove_class("sidebar-open")
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
self.query_one("#status-bar", Static).update("Mentor sidebar closed.")
|
||||
else:
|
||||
main_box.remove_class("sidebar-handbook-open")
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-mentor-open")
|
||||
q_input.focus()
|
||||
self.query_one("#status-bar", Static).update("Mentor sidebar open.")
|
||||
|
||||
def action_toggle_handbook_sidebar(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
main_box.remove_class("sidebar-handbook-open")
|
||||
main_box.remove_class("sidebar-open")
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
self.query_one("#status-bar", Static).update("Handbook sidebar closed.")
|
||||
else:
|
||||
main_box.remove_class("sidebar-mentor-open")
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-handbook-open")
|
||||
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id) if self.active_challenge_id else None
|
||||
lang = challenge.language if challenge else "Python"
|
||||
self.load_handbook_catalog(lang)
|
||||
|
||||
self.query_one("#handbook_topic_list", OptionList).focus()
|
||||
self.query_one("#status-bar", Static).update(f"W3Schools Handbook open for {lang}.")
|
||||
|
||||
def on_mentor_input_text_area_submit_question(
|
||||
self, event: MentorInputTextArea.SubmitQuestion
|
||||
) -> None:
|
||||
self.submit_mentor_question(event.question)
|
||||
|
||||
def submit_mentor_question(self, question: str | None = None) -> None:
|
||||
if not self.active_challenge_id:
|
||||
self.query_one("#status-bar", Static).update("Please select a challenge first.")
|
||||
return
|
||||
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id)
|
||||
if not challenge:
|
||||
return
|
||||
|
||||
q_input = self.query_one("#mentor-question-input", MentorInputTextArea)
|
||||
user_question = q_input.text.strip() if question is None else question.strip()
|
||||
code = self.query_one("#editor", CodeEditor).text
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
main_box.remove_class("sidebar-handbook-open")
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-mentor-open")
|
||||
|
||||
q_input.text = ""
|
||||
self.query_one("#mentor-response-display", Markdown).update(
|
||||
"⏳ **Mentor is thinking and generating guidance...**\n\n*Analyzing your code, task requirements, and question...*"
|
||||
)
|
||||
self.query_one("#status-bar", Static).update(
|
||||
f"⏳ Consulting Mentor at {mentor.config.llm_base_url}..."
|
||||
)
|
||||
self.run_worker(self._mentor_worker(challenge, code, user_question))
|
||||
|
||||
def action_check_answer(self) -> None:
|
||||
if not self.active_challenge_id:
|
||||
self.query_one("#status-bar", Static).update("Please select a challenge first.")
|
||||
return
|
||||
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id)
|
||||
if not challenge:
|
||||
return
|
||||
|
||||
code = self.query_one("#editor", CodeEditor).text
|
||||
if not code.strip():
|
||||
self.query_one("#status-bar", Static).update("Editor is empty — write code before checking answer.")
|
||||
return
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
main_box.remove_class("sidebar-handbook-open")
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-mentor-open")
|
||||
|
||||
check_question = (
|
||||
"Please evaluate my code implementation against all the requirements of this challenge. "
|
||||
"Check if my solution is complete and correct, point out any missing requirements or edge cases, "
|
||||
"and give me feedback on my answer."
|
||||
)
|
||||
|
||||
self.query_one("#mentor-response-display", Markdown).update(
|
||||
"⏳ **Mentor is evaluating your solution against challenge requirements...**\n\n*Checking logic, requirements, and edge cases...*"
|
||||
)
|
||||
self.query_one("#status-bar", Static).update("⏳ Consulting Mentor to check answer...")
|
||||
self.run_worker(self._mentor_worker(challenge, code, check_question))
|
||||
|
||||
def action_unfocus_editor(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("show-completion"):
|
||||
main_box.remove_class("show-completion")
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
return
|
||||
|
||||
self.query_one("#challenge_list", OptionList).focus()
|
||||
self.query_one("#status-bar", Static).update("Focus moved to Challenge List.")
|
||||
|
||||
def action_toggle_panel_focus(self) -> None:
|
||||
focused = self.focused
|
||||
if focused and getattr(focused, "id", None) == "editor":
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
self.query_one("#handbook_topic_list", OptionList).focus()
|
||||
else:
|
||||
if not main_box.has_class("sidebar-mentor-open"):
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-mentor-open")
|
||||
self.query_one("#mentor-question-input", MentorInputTextArea).focus()
|
||||
elif focused and getattr(focused, "id", None) in ("mentor-question-input", "handbook_topic_list"):
|
||||
self.query_one("#challenge_list", OptionList).focus()
|
||||
else:
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
|
||||
# ── Challenge selection & Handbook selection ─────────────
|
||||
|
||||
def _display_challenge(self, cid: str) -> None:
|
||||
self.active_challenge_id = cid
|
||||
challenge = self.challenges_dict.get(cid)
|
||||
if not challenge:
|
||||
return
|
||||
|
||||
req_str = "\n".join(f"- {r}" for r in challenge.requirements) if challenge.requirements else "*None*"
|
||||
hint_str = "\n".join(f"- {h}" for h in challenge.hints) if challenge.hints else "*None*"
|
||||
|
||||
markdown_content = f"""# {challenge.name}
|
||||
**Difficulty:** {challenge.difficulty} | **Language:** {challenge.language} | **Subject:** {challenge.subject}
|
||||
|
||||
## Description
|
||||
{challenge.description}
|
||||
|
||||
## Requirements
|
||||
{req_str}
|
||||
|
||||
## Hints
|
||||
{hint_str}
|
||||
"""
|
||||
self.query_one("#challenge-details", Markdown).update(markdown_content)
|
||||
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
lang_key = challenge.language.lower()
|
||||
try:
|
||||
if lang_key in editor.available_languages:
|
||||
editor.language = lang_key
|
||||
else:
|
||||
editor.language = None
|
||||
except Exception:
|
||||
editor.language = None
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
self.load_handbook_catalog(challenge.language)
|
||||
|
||||
def on_option_list_option_highlighted(
|
||||
self, event: OptionList.OptionHighlighted
|
||||
) -> None:
|
||||
if event.option_list.id == "challenge_list":
|
||||
selected_option = event.option
|
||||
if selected_option and selected_option.id:
|
||||
self._display_challenge(selected_option.id)
|
||||
self.query_one("#status-bar", Static).update(
|
||||
f"Hovering Challenge: {selected_option.prompt} — Hit Enter to jump to Editor"
|
||||
)
|
||||
|
||||
def on_option_list_option_selected(
|
||||
self, event: OptionList.OptionSelected
|
||||
) -> None:
|
||||
if event.option_list.id == "challenge_list":
|
||||
selected_option = event.option
|
||||
if selected_option and selected_option.id:
|
||||
self._display_challenge(selected_option.id)
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
self.query_one("#status-bar", Static).update("Focused: Code Editor")
|
||||
elif event.option_list.id == "completion_list_popup":
|
||||
selected_option = event.option
|
||||
if selected_option and selected_option.prompt:
|
||||
chosen_text = str(selected_option.prompt)
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
cursor_row, cursor_col = editor.cursor_location
|
||||
suffix_len = len(self.active_completion_prefix)
|
||||
start_col = max(0, cursor_col - suffix_len)
|
||||
editor.delete((cursor_row, start_col), (cursor_row, cursor_col))
|
||||
editor.insert(chosen_text)
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
main_box.remove_class("show-completion")
|
||||
editor.focus()
|
||||
self.query_one("#status-bar", Static).update(f"✓ Inserted '{chosen_text}'")
|
||||
elif event.option_list.id == "handbook_topic_list":
|
||||
selected_option = event.option
|
||||
if selected_option and selected_option.id:
|
||||
all_items = self.catalog_functions + self.catalog_subjects
|
||||
item = next((i for i in all_items if i["id"] == selected_option.id), None)
|
||||
if item:
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id) if self.active_challenge_id else None
|
||||
lang = challenge.language if challenge else "Python"
|
||||
|
||||
self.query_one("#handbook-example-display", Markdown).update(
|
||||
f"⏳ **Generating W3Schools reference card for '{item['title']}' in {lang}...**"
|
||||
)
|
||||
self.run_worker(self._handbook_worker(lang, item["id"], item["title"]))
|
||||
|
||||
def show_completion_popup(self, prefix: str, matches: List[str]) -> None:
|
||||
self.active_completion_prefix = prefix
|
||||
popup_list = self.query_one("#completion_list_popup", OptionList)
|
||||
popup_list.clear_options()
|
||||
|
||||
for match in matches:
|
||||
popup_list.add_option(Option(match, id=match))
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
main_box.add_class("show-completion")
|
||||
popup_list.focus()
|
||||
if len(matches) > 0:
|
||||
popup_list.highlighted = 0
|
||||
|
||||
# ── Scrolling Actions ────────────────────────────────────
|
||||
|
||||
def action_scroll_mentor_up(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
self.query_one("#handbook-example-scroll", VerticalScroll).scroll_up(animate=False)
|
||||
else:
|
||||
self.query_one("#mentor-response-scroll", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
def action_scroll_mentor_down(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
self.query_one("#handbook-example-scroll", VerticalScroll).scroll_down(animate=False)
|
||||
else:
|
||||
self.query_one("#mentor-response-scroll", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
def action_scroll_output_up(self) -> None:
|
||||
self.query_one("#output-scroll-container", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
def action_scroll_output_down(self) -> None:
|
||||
self.query_one("#output-scroll-container", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
def action_scroll_details_up(self) -> None:
|
||||
self.query_one("#challenge-details-container", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
def action_scroll_details_down(self) -> None:
|
||||
self.query_one("#challenge-details-container", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
# ── Core Workers ─────────────────────────────────────────
|
||||
|
||||
def action_refresh_challenges(self) -> None:
|
||||
self.query_one("#status-bar", Static).update("Refreshing challenges...")
|
||||
self.load_challenges()
|
||||
|
||||
async def _mentor_worker(self, challenge: object, code: str, user_question: str) -> None:
|
||||
res = await mentor.get_guidance_async(challenge, code, user_question=user_question)
|
||||
response_text = res.get("mentor_response", "No response received.")
|
||||
|
||||
q_header = f"### Question / Evaluation:\n> {user_question}\n\n---\n\n" if user_question else ""
|
||||
full_md = f"{q_header}{response_text}"
|
||||
|
||||
self.query_one("#mentor-response-display", Markdown).update(full_md)
|
||||
self.query_one("#status-bar", Static).update("Mentor response received — Press F1 to ask or F2 to check answer")
|
||||
|
||||
async def _handbook_worker(self, language: str, topic_id: str, topic_title: str) -> None:
|
||||
res = await handbook_service.generate_example_async(language, topic_id, topic_title)
|
||||
if res.get("status") == "success":
|
||||
md_text = res.get("example_markdown", "No reference card generated.")
|
||||
self.query_one("#handbook-example-display", Markdown).update(md_text)
|
||||
self.query_one("#status-bar", Static).update(f"✓ Reference card loaded for '{topic_title}'")
|
||||
else:
|
||||
err_msg = res.get("message", "Error generating reference card.")
|
||||
self.query_one("#handbook-example-display", Markdown).update(err_msg)
|
||||
self.query_one("#status-bar", Static).update("✗ Failed to generate reference card")
|
||||
|
||||
def action_lint_code(self) -> None:
|
||||
code = self.query_one("#editor", CodeEditor).text
|
||||
if not code.strip():
|
||||
self.query_one("#status-bar", Static).update("Editor is empty — nothing to lint.")
|
||||
return
|
||||
|
||||
self.query_one("#status-bar", Static).update("Running syntax & style lint...")
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id)
|
||||
lang = challenge.language.lower() if challenge else "python"
|
||||
|
||||
res = linter.lint(lang, code)
|
||||
output_widget = self.query_one("#output-container", Static)
|
||||
|
||||
if res.get("exit_code") == 0:
|
||||
output_widget.update("✓ Syntax & Style clean! No linting errors detected.")
|
||||
else:
|
||||
raw = (res.get("stdout") or "") + "\n" + (res.get("stderr") or "")
|
||||
lines = [line.strip() for line in raw.strip().splitlines() if line.strip()]
|
||||
cleaned = []
|
||||
for line in lines:
|
||||
if ":" in line:
|
||||
parts = line.split(":", 3)
|
||||
if len(parts) >= 3 and parts[1].isdigit():
|
||||
cleaned.append(f"Line {parts[1]}, Col {parts[2]}:{parts[3]}")
|
||||
else:
|
||||
cleaned.append(line)
|
||||
else:
|
||||
cleaned.append(line)
|
||||
msg = "\n".join(cleaned) if cleaned else "Syntax error detected."
|
||||
output_widget.update(f"✗ Lint Error(s):\n{msg}")
|
||||
|
||||
self.query_one("#status-bar", Static).update("Lint complete")
|
||||
|
||||
def action_run_code(self, stdin: str = "") -> None:
|
||||
code = self.query_one("#editor", CodeEditor).text
|
||||
if not code.strip():
|
||||
self.query_one("#status-bar", Static).update("Editor is empty — write code before running.")
|
||||
return
|
||||
|
||||
self.query_one("#status-bar", Static).update("Executing code...")
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id)
|
||||
lang = challenge.language.lower() if challenge else "python"
|
||||
|
||||
res = executor.run(lang, code, stdin=stdin)
|
||||
output_widget = self.query_one("#output-container", Static)
|
||||
|
||||
if res.get("exit_code") == 0:
|
||||
stdout = res.get("stdout", "").strip()
|
||||
if stdout:
|
||||
output_widget.update(f"Output:\n{stdout}")
|
||||
else:
|
||||
output_widget.update("✓ Code executed successfully (exit code 0, no stdout).")
|
||||
else:
|
||||
stderr = res.get("stderr", "").strip()
|
||||
stdout = res.get("stdout", "").strip()
|
||||
err_msg = stderr or stdout or "Unknown runtime error"
|
||||
output_widget.update(f"✗ Runtime Error (exit code {res.get('exit_code')}):\n{err_msg}")
|
||||
|
||||
self.query_one("#status-bar", Static).update("Run complete")
|
||||
|
||||
|
||||
TactTermTUI = TactiTermTUI
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="TactiTerm TUI")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging to tactiterm_tui.log")
|
||||
args = parser.parse_args()
|
||||
|
||||
app = TactiTermTUI(debug=args.debug)
|
||||
app.run()
|
||||
@@ -0,0 +1,414 @@
|
||||
"""GenTUI — Standalone TUI tool for generating and editing TactiTerm challenges with AI."""
|
||||
import os
|
||||
import argparse
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.screen import ModalScreen
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
from textual.widgets import Header, Footer, Static, Button, Markdown, TextArea, Input, Select
|
||||
from textual.binding import Binding
|
||||
|
||||
from src.core.generator import generator
|
||||
from src.core.config import config
|
||||
from src.core.mentor import get_chat_completions_url
|
||||
|
||||
|
||||
class GenHelpModal(ModalScreen):
|
||||
"""Interactive AI Assistant Modal for helping user design challenges."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GenHelpModal {
|
||||
align: center middle;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
#help-dialog {
|
||||
width: 75%;
|
||||
height: 80%;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1 2;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#help-header {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
background: $primary;
|
||||
color: white;
|
||||
height: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#help-response-scroll {
|
||||
height: 1fr;
|
||||
border: solid $secondary;
|
||||
padding: 1;
|
||||
background: $background;
|
||||
margin-bottom: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#help-input-label {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
#help-input {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#help-button-container {
|
||||
height: 3;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
#btn-ask-help {
|
||||
width: 1fr;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
#btn-close-help {
|
||||
width: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+up", "scroll_up", "Scroll Up (Ctrl+↑)"),
|
||||
Binding("ctrl+down", "scroll_down", "Scroll Down (Ctrl+↓)"),
|
||||
]
|
||||
|
||||
def action_scroll_up(self) -> None:
|
||||
self.query_one("#help-response-scroll", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
def action_scroll_down(self) -> None:
|
||||
self.query_one("#help-response-scroll", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
|
||||
with Container(id="help-dialog"):
|
||||
yield Static("💡 TactiTerm AI Challenge Design Assistant", id="help-header")
|
||||
with VerticalScroll(id="help-response-scroll"):
|
||||
yield Markdown(
|
||||
"### How can I help you design your challenge?\n"
|
||||
"Ask me anything if you're unsure about what language features, topics, requirements, or expected outputs to use!",
|
||||
id="help-response-markdown",
|
||||
)
|
||||
yield Static("Ask Assistant a Question:", id="help-input-label")
|
||||
yield Input(
|
||||
placeholder="e.g. What are good topics for a Medium Rust challenge? Or what requirements should a Java OOP challenge have?",
|
||||
id="help-input",
|
||||
)
|
||||
with Horizontal(id="help-button-container"):
|
||||
yield Button("✨ Ask Assistant (Enter)", id="btn-ask-help", variant="warning")
|
||||
yield Button("Close Window", id="btn-close-help", variant="error")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#help-input", Input).focus()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-ask-help":
|
||||
self.action_ask_assistant()
|
||||
elif event.button.id == "btn-close-help":
|
||||
self.dismiss()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id == "help-input":
|
||||
self.action_ask_assistant()
|
||||
|
||||
def action_ask_assistant(self) -> None:
|
||||
question = self.query_one("#help-input", Input).value.strip()
|
||||
if not question:
|
||||
return
|
||||
|
||||
self.query_one("#help-response-markdown", Markdown).update("⏳ **Assistant is thinking...**\n\n*Consulting LLM backend for advice...*")
|
||||
self.run_worker(self._ask_worker(question))
|
||||
|
||||
async def _ask_worker(self, question: str) -> None:
|
||||
endpoint = get_chat_completions_url(config.llm_base_url)
|
||||
payload = {
|
||||
"model": config.llm_model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are an expert Software Engineering Curriculum Designer assisting a teacher in creating coding challenges. "
|
||||
"Give clear, concise, actionable advice for challenge topics, difficulties, programming languages, and requirements."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": question},
|
||||
],
|
||||
"max_tokens": 1000,
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if config.llm_api_key and config.llm_api_key != "not-needed":
|
||||
headers["Authorization"] = f"Bearer {config.llm_api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.llm_timeout, follow_redirects=True) as client:
|
||||
response = await client.post(endpoint, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
text = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "No response received.")
|
||||
)
|
||||
self.query_one("#help-response-markdown", Markdown).update(text)
|
||||
except Exception as e:
|
||||
self.query_one("#help-response-markdown", Markdown).update(f"⚠️ Error asking Assistant: {e}")
|
||||
|
||||
|
||||
class GenTUIApp(App):
|
||||
"""Standalone TUI Challenge Generator Application."""
|
||||
|
||||
TITLE = "TactiTerm GenTUI"
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
layout: horizontal;
|
||||
height: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Left Panel: Generator Controls */
|
||||
#control-panel {
|
||||
width: 32%;
|
||||
min-width: 28;
|
||||
height: 100%;
|
||||
border-right: heavy $primary;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
/* Middle Panel: Raw Markdown Editor */
|
||||
#editor-panel {
|
||||
width: 36%;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
border-right: heavy $primary;
|
||||
}
|
||||
|
||||
/* Right Panel: Live Markdown Preview */
|
||||
#preview-panel {
|
||||
width: 32%;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
height: 3;
|
||||
background: $primary;
|
||||
color: white;
|
||||
content-align: center middle;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $accent;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
Input, Select {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
TextArea {
|
||||
height: 1fr;
|
||||
border: solid $accent;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#preview-scroll {
|
||||
height: 1fr;
|
||||
border: solid $secondary;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#btn-help-modal {
|
||||
width: 100%;
|
||||
margin-top: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#btn-generate, #btn-save {
|
||||
width: 100%;
|
||||
margin-top: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
height: 1;
|
||||
background: $primary;
|
||||
color: white;
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+h", "open_help_modal", "AI Helper"),
|
||||
Binding("ctrl+g", "generate_ai", "Generate AI"),
|
||||
Binding("ctrl+s", "save_challenge", "Save File"),
|
||||
Binding("ctrl+q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
LANGUAGES = ["Python", "C#", "C++", "Java", "JavaScript", "TypeScript", "Rust", "Lua", "HTML", "Go"]
|
||||
DIFFICULTIES = ["Easy", "Medium", "Hard", "Advanced"]
|
||||
|
||||
def __init__(self, debug: bool = False) -> None:
|
||||
super().__init__()
|
||||
self.debug_mode = debug
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
|
||||
with Horizontal(id="main-container"):
|
||||
# Left Panel: Prompt Controls
|
||||
with Container(id="control-panel"):
|
||||
yield Static("1. Prompt AI Generator", classes="section-header")
|
||||
|
||||
yield Button("💡 Help Me Decide (AI Assist) [Ctrl+H]", id="btn-help-modal", variant="default")
|
||||
|
||||
yield Static("AI Challenge Prompt:", classes="input-label")
|
||||
yield Input(
|
||||
placeholder="e.g. Medium Rust challenge on Ownership & References",
|
||||
id="input-prompt",
|
||||
)
|
||||
|
||||
yield Static("Language (Dropdown):", classes="input-label")
|
||||
yield Select.from_values(
|
||||
self.LANGUAGES,
|
||||
allow_blank=False,
|
||||
value="Python",
|
||||
id="select-language",
|
||||
)
|
||||
|
||||
yield Static("Difficulty (Dropdown):", classes="input-label")
|
||||
yield Select.from_values(
|
||||
self.DIFFICULTIES,
|
||||
allow_blank=False,
|
||||
value="Medium",
|
||||
id="select-difficulty",
|
||||
)
|
||||
|
||||
yield Static("Subject / Topic:", classes="input-label")
|
||||
yield Input(placeholder="e.g. Memory Management, Data Structures", id="input-subject", value="General Concepts")
|
||||
|
||||
yield Button("✨ Generate with AI (Ctrl+G)", id="btn-generate", variant="warning")
|
||||
|
||||
yield Static("Filename (.md):", classes="input-label")
|
||||
yield Input(placeholder="new-challenge.md", id="input-filename")
|
||||
|
||||
yield Button("💾 Save Challenge to Disk (Ctrl+S)", id="btn-save", variant="success")
|
||||
|
||||
# Middle Panel: Markdown Code Editor
|
||||
with Container(id="editor-panel"):
|
||||
yield Static("2. Raw Markdown (.md) Editor", classes="section-header")
|
||||
yield TextArea(id="markdown-editor")
|
||||
|
||||
# Right Panel: Live Preview
|
||||
with Container(id="preview-panel"):
|
||||
yield Static("3. Live Rendered Preview", classes="section-header")
|
||||
with VerticalScroll(id="preview-scroll"):
|
||||
yield Markdown("AI generated preview will appear here...", id="markdown-preview")
|
||||
|
||||
yield Static("Ready — Type your prompt and click Generate with AI", id="status-bar")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
editor = self.query_one("#markdown-editor", TextArea)
|
||||
if "markdown" in editor.available_languages:
|
||||
editor.language = "markdown"
|
||||
self.query_one("#input-prompt", Input).focus()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-help-modal":
|
||||
self.action_open_help_modal()
|
||||
elif event.button.id == "btn-generate":
|
||||
self.action_generate_ai()
|
||||
elif event.button.id == "btn-save":
|
||||
self.action_save_challenge()
|
||||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
"""Update live preview whenever markdown editor content changes."""
|
||||
if event.text_area.id == "markdown-editor":
|
||||
self.query_one("#markdown-preview", Markdown).update(event.text_area.text)
|
||||
|
||||
def action_open_help_modal(self) -> None:
|
||||
"""Pop up the AI assistant help window."""
|
||||
self.push_screen(GenHelpModal())
|
||||
|
||||
def action_generate_ai(self) -> None:
|
||||
"""Generate challenge markdown using LLM service worker."""
|
||||
prompt_text = self.query_one("#input-prompt", Input).value.strip()
|
||||
if not prompt_text:
|
||||
self.query_one("#status-bar", Static).update("Please enter an AI prompt description.")
|
||||
return
|
||||
|
||||
lang_select = self.query_one("#select-language", Select)
|
||||
diff_select = self.query_one("#select-difficulty", Select)
|
||||
|
||||
lang = str(lang_select.value) if lang_select.value != Select.BLANK else "Python"
|
||||
diff = str(diff_select.value) if diff_select.value != Select.BLANK else "Medium"
|
||||
subj = self.query_one("#input-subject", Input).value.strip() or "General"
|
||||
|
||||
self.query_one("#status-bar", Static).update("⏳ Generating challenge via LLM backend...")
|
||||
self.run_worker(self._generate_worker(prompt_text, diff, lang, subj))
|
||||
|
||||
async def _generate_worker(self, prompt: str, difficulty: str, language: str, subject: str) -> None:
|
||||
res = await generator.generate_challenge_async(prompt, difficulty=difficulty, language=language, subject=subject)
|
||||
|
||||
if res.get("status") == "success":
|
||||
md_text = res.get("markdown", "")
|
||||
filename = res.get("filename", "new-challenge.md")
|
||||
|
||||
editor = self.query_one("#markdown-editor", TextArea)
|
||||
editor.text = md_text
|
||||
self.query_one("#input-filename", Input).value = filename
|
||||
self.query_one("#markdown-preview", Markdown).update(md_text)
|
||||
|
||||
self.query_one("#status-bar", Static).update(f"✓ Challenge generated: '{res.get('title')}' — Edit or click Save to disk")
|
||||
else:
|
||||
err_msg = res.get("message", "Unknown error")
|
||||
self.query_one("#status-bar", Static).update(f"✗ Challenge Generation Failed: {err_msg}")
|
||||
|
||||
def action_save_challenge(self) -> None:
|
||||
"""Save the markdown content to disk."""
|
||||
filename = self.query_one("#input-filename", Input).value.strip()
|
||||
md_text = self.query_one("#markdown-editor", TextArea).text.strip()
|
||||
|
||||
if not filename:
|
||||
self.query_one("#status-bar", Static).update("Please specify a filename (.md) before saving.")
|
||||
return
|
||||
|
||||
if not md_text:
|
||||
self.query_one("#status-bar", Static).update("Markdown editor is empty — write or generate content first.")
|
||||
return
|
||||
|
||||
res = generator.save_challenge(filename, md_text)
|
||||
if res.get("status") == "success":
|
||||
self.query_one("#status-bar", Static).update(f"✓ Saved challenge file to {res.get('path')}")
|
||||
else:
|
||||
self.query_one("#status-bar", Static).update(f"✗ Save Error: {res.get('message')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="GenTUI — TactiTerm AI Challenge Generator")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||
args = parser.parse_args()
|
||||
|
||||
app = GenTUIApp(debug=args.debug)
|
||||
app.run()
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Custom widgets for the TactiTerm TUI with auto-completion engine and StdinInput."""
|
||||
import asyncio
|
||||
from typing import List, Dict
|
||||
from textual import events
|
||||
from textual.message import Message
|
||||
from textual.widgets import TextArea, Input
|
||||
|
||||
# Comprehensive auto-completion syntax catalog per language for TUI
|
||||
TUI_COMPLETIONS: Dict[str, List[str]] = {
|
||||
"python": [
|
||||
"int", "float", "bool", "str", "list", "dict", "set", "tuple", "bytes", "None",
|
||||
"True", "False", "print()", "len()", "range()", "enumerate()", "zip()", "map()",
|
||||
"filter()", "sorted()", "isinstance()", "type()", "open()", "input()", ".append()",
|
||||
".split()", ".join()", ".get()", "def ", "class ", "return ", "import ", "from ",
|
||||
"if ", "elif ", "else:", "try:", "except ", "finally:", "raise ", "with ", "as ",
|
||||
"yield ", "async ", "await ", "lambda ", "pass", "break", "continue", "assert "
|
||||
],
|
||||
"rust": [
|
||||
"bool", "i32", "i64", "u32", "u64", "usize", "f32", "f64", "char", "str", "String",
|
||||
"Vec", "Option", "Result", "Some", "None", "Ok", "Err", "println!()", "print!()",
|
||||
"format!()", "vec![]", "Vec::push()", "String::from()", "Option::unwrap()",
|
||||
"Result::expect()", ".iter()", ".collect()", "Box::new()", "fn ", "let ", "let mut ",
|
||||
"pub ", "struct ", "enum ", "impl ", "trait ", "match ", "if ", "else ", "loop ",
|
||||
"while ", "for ", "in ", "return ", "use std::", "async ", "await "
|
||||
],
|
||||
"cpp": [
|
||||
"int", "float", "double", "bool", "char", "void", "size_t", "std::string",
|
||||
"std::vector<", "std::map<", "std::set<", "std::unique_ptr<", "std::shared_ptr<",
|
||||
"std::cout << ", "std::cin >> ", "std::endl", ".push_back()", "std::sort()",
|
||||
"std::make_unique<", "std::make_shared<", "class ", "struct ", "public:", "private:",
|
||||
"protected:", "virtual ", "override", "const ", "constexpr ", "auto ", "template<typename T>",
|
||||
"namespace ", "using namespace std;", "return ", "if ", "else ", "for ", "while "
|
||||
],
|
||||
"go": [
|
||||
"int", "int64", "float64", "bool", "string", "byte", "rune", "error", "nil", "true",
|
||||
"false", "fmt.Println()", "fmt.Printf()", "fmt.Sprintf()", "make()", "append()",
|
||||
"len()", "cap()", "delete()", "strings.Split()", "os.Open()", "func ", "var ", "type ",
|
||||
"struct ", "interface ", "package main", "import ", "return ", "if ", "else ", "for ",
|
||||
"range ", "switch ", "select ", "chan ", "go func()"
|
||||
],
|
||||
"javascript": [
|
||||
"number", "string", "boolean", "bool", "any", "void", "null", "undefined", "Array",
|
||||
"Object", "Promise", "console.log()", "console.error()", "console.warn()", ".map()",
|
||||
".filter()", ".reduce()", ".find()", ".push()", ".includes()", "Object.keys()",
|
||||
"JSON.parse()", "fetch()", "const ", "let ", "var ", "function ", "class ", "return ",
|
||||
"if ", "else ", "for ", "while ", "switch ", "try ", "catch ", "async ", "await ", "new "
|
||||
],
|
||||
"typescript": [
|
||||
"number", "string", "boolean", "bool", "any", "void", "unknown", "never", "null",
|
||||
"undefined", "Array<", "Record<", "Partial<", "Readonly<", "Pick<", "Omit<",
|
||||
"Promise<", "console.log()", ".map()", ".filter()", "interface ", "type ", "const ",
|
||||
"let ", "function ", "class ", "export ", "import ", "async ", "await "
|
||||
],
|
||||
"java": [
|
||||
"int", "long", "double", "float", "boolean", "bool", "char", "byte", "short", "void",
|
||||
"String", "List<", "ArrayList<", "Map<", "HashMap<", "Set<", "HashSet<",
|
||||
"System.out.println()", ".add()", ".get()", ".size()", "public ", "private ",
|
||||
"protected ", "static ", "final ", "class ", "interface ", "extends ", "implements ",
|
||||
"new ", "return ", "if ", "else ", "for ", "while ", "try ", "catch "
|
||||
],
|
||||
"csharp": [
|
||||
"int", "long", "double", "float", "bool", "char", "string", "object", "void", "var",
|
||||
"List<", "Dictionary<", "HashSet<", "Task<", "Console.WriteLine()", "Console.ReadLine()",
|
||||
".Add()", "Enumerable.Where()", "public ", "private ", "protected ", "internal ",
|
||||
"static ", "class ", "struct ", "interface ", "record ", "namespace ", "using System;",
|
||||
"new ", "return ", "if ", "else ", "foreach ", "while ", "async ", "await "
|
||||
],
|
||||
"lua": [
|
||||
"number", "int", "string", "boolean", "bool", "table", "nil", "function", "print()",
|
||||
"type()", "tostring()", "tonumber()", "table.insert()", "string.sub()", "local ",
|
||||
"if ", "then", "else", "elseif ", "end", "for ", "while ", "do", "return ", "require()"
|
||||
],
|
||||
"html": [
|
||||
"<!DOCTYPE html>", "<html>", "<head>", "<title>", "<body>", "<h1>", "<h2>", "<h3>",
|
||||
"<p>", "<span>", "<div>", "<a href=\"\">", "<img src=\"\">", "<form>", "<input type=\"text\">",
|
||||
"<button>", "<select>", "<option>", "<ul>", "<ol>", "<li>", "<table>", "<tr>", "<td>", "<th>",
|
||||
"<script>", "<link rel=\"stylesheet\">"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class CodeEditor(TextArea):
|
||||
"""A code editor with auto-completion dropdown trigger."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
CodeEditor {
|
||||
height: 1fr;
|
||||
min-height: 8;
|
||||
border: solid $accent;
|
||||
}
|
||||
CodeEditor:focus {
|
||||
border: heavy $primary;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
language: str = "python",
|
||||
theme: str = "vscode_dark",
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
language=language,
|
||||
theme=theme,
|
||||
soft_wrap=False,
|
||||
show_line_numbers=True,
|
||||
id=id,
|
||||
classes=classes,
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.tab_behavior = "indent"
|
||||
self.cursor_style = "bold blue"
|
||||
|
||||
def get_code(self) -> str:
|
||||
return self.text
|
||||
|
||||
def _on_key(self, event: events.Key) -> None:
|
||||
"""Handle Tab completion and Ctrl+E toggle."""
|
||||
if event.key == "ctrl+e":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "action_toggle_expand_output"):
|
||||
self.app.action_toggle_expand_output()
|
||||
return
|
||||
|
||||
if event.key == "tab":
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
lines = self.text.split("\n")
|
||||
if cursor_row < len(lines):
|
||||
current_line = lines[cursor_row][:cursor_col]
|
||||
words = current_line.replace("(", " ").replace(")", " ").split()
|
||||
if words:
|
||||
prefix = words[-1].lower()
|
||||
lang = (self.language or "python").lower()
|
||||
candidates = TUI_COMPLETIONS.get(lang, TUI_COMPLETIONS["python"])
|
||||
matches = [c for c in candidates if c.lower().startswith(prefix) or prefix in c.lower()]
|
||||
|
||||
if matches:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "show_completion_popup"):
|
||||
self.app.show_completion_popup(words[-1], matches)
|
||||
return
|
||||
|
||||
super()._on_key(event)
|
||||
|
||||
|
||||
class StdinInput(Input):
|
||||
"""Input control for stdin that forwards Ctrl+E to screen output toggler."""
|
||||
|
||||
def _on_key(self, event: events.Key) -> None:
|
||||
if event.key == "ctrl+e":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "action_toggle_expand_output"):
|
||||
self.app.action_toggle_expand_output()
|
||||
return
|
||||
res = super()._on_key(event)
|
||||
if asyncio.iscoroutine(res):
|
||||
asyncio.create_task(res)
|
||||
|
||||
|
||||
class MentorInputTextArea(TextArea):
|
||||
"""Multi-line question input for Socratic Mentor. Submits on Enter, inserts newline on Shift+Enter."""
|
||||
|
||||
class SubmitQuestion(Message):
|
||||
"""Event posted when Enter is pressed."""
|
||||
|
||||
def __init__(self, question: str) -> None:
|
||||
self.question = question
|
||||
super().__init__()
|
||||
|
||||
def _on_key(self, event: events.Key) -> None:
|
||||
if event.key == "ctrl+e":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "action_toggle_expand_output"):
|
||||
self.app.action_toggle_expand_output()
|
||||
return
|
||||
elif event.key == "enter":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
self.post_message(self.SubmitQuestion(self.text))
|
||||
elif event.key in ["shift+enter", "ctrl+j"]:
|
||||
self.insert("\n")
|
||||
event.prevent_default()
|
||||
else:
|
||||
super()._on_key(event)
|
||||
Reference in New Issue
Block a user