Syntax highlighting fixes, autocomplete overhaul, tui changes
This commit is contained in:
+13
-2
@@ -13,8 +13,8 @@ class Config:
|
||||
"model": "local-model",
|
||||
"api_key": "not-needed",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 512,
|
||||
"timeout_seconds": 5.0,
|
||||
"max_tokens": 4096,
|
||||
"timeout_seconds": 60.0,
|
||||
},
|
||||
"web": {
|
||||
"host": "127.0.0.1",
|
||||
@@ -60,6 +60,17 @@ class Config:
|
||||
if env_model:
|
||||
config["llm"]["model"] = env_model
|
||||
|
||||
env_api_key = os.getenv("TACTTERM_LLM_API_KEY")
|
||||
if env_api_key:
|
||||
config["llm"]["api_key"] = env_api_key
|
||||
|
||||
env_timeout = os.getenv("TACTTERM_LLM_TIMEOUT")
|
||||
if env_timeout:
|
||||
try:
|
||||
config["llm"]["timeout_seconds"] = float(env_timeout)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
env_web_host = os.getenv("TACTTERM_WEB_HOST")
|
||||
if env_web_host:
|
||||
config["web"]["host"] = env_web_host
|
||||
|
||||
@@ -78,12 +78,11 @@ class ChallengeGenerator:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
raw_md = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
.strip()
|
||||
)
|
||||
msg_obj = data.get("choices", [{}])[0].get("message", {})
|
||||
raw_md = msg_obj.get("content") or ""
|
||||
if not raw_md.strip() and msg_obj.get("reasoning_content"):
|
||||
raw_md = msg_obj.get("reasoning_content", "")
|
||||
raw_md = raw_md.strip()
|
||||
|
||||
# Clean up outer markdown wrapper if present
|
||||
if raw_md.startswith("```markdown"):
|
||||
|
||||
@@ -353,7 +353,7 @@ class HandbookService:
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 900,
|
||||
"max_tokens": max(config.llm_max_tokens, 4096),
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
@@ -366,12 +366,11 @@ class HandbookService:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
example_md = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
.strip()
|
||||
)
|
||||
msg_obj = data.get("choices", [{}])[0].get("message", {})
|
||||
example_md = msg_obj.get("content") or ""
|
||||
if not example_md.strip() and msg_obj.get("reasoning_content"):
|
||||
example_md = msg_obj.get("reasoning_content", "")
|
||||
example_md = example_md.strip()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
|
||||
+5
-6
@@ -53,12 +53,11 @@ class MentorClient:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
content = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
.strip()
|
||||
)
|
||||
msg_obj = data.get("choices", [{}])[0].get("message", {})
|
||||
content = msg_obj.get("content") or ""
|
||||
if not content.strip() and msg_obj.get("reasoning_content"):
|
||||
content = msg_obj.get("reasoning_content", "")
|
||||
content = content.strip()
|
||||
|
||||
if content:
|
||||
return {
|
||||
|
||||
+159
-20
@@ -15,7 +15,7 @@ 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
|
||||
from src.tui.widgets import CodeEditor, MentorInputTextArea, StdinInput, TUI_COMPLETIONS, get_tui_language
|
||||
|
||||
|
||||
class TactiTermTUI(App):
|
||||
@@ -48,6 +48,8 @@ class TactiTermTUI(App):
|
||||
width: 68%;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
layers: default overlay;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Right columns: Mentor & Handbook sidebars (hidden by default) */
|
||||
@@ -114,7 +116,7 @@ class TactiTermTUI(App):
|
||||
border: solid $primary-darken-2;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Sleek compact action bar with reduced height */
|
||||
@@ -145,11 +147,14 @@ class TactiTermTUI(App):
|
||||
|
||||
#completion-popup {
|
||||
display: none;
|
||||
height: 8;
|
||||
background: $surface-darken-1;
|
||||
border: heavy $warning;
|
||||
margin-bottom: 1;
|
||||
padding: 0 1;
|
||||
layer: overlay;
|
||||
position: absolute;
|
||||
width: 32;
|
||||
height: auto;
|
||||
max-height: 6;
|
||||
background: #252526;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#main-container.show-completion #completion-popup {
|
||||
@@ -157,14 +162,14 @@ class TactiTermTUI(App):
|
||||
}
|
||||
|
||||
#completion-title {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $warning;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#completion_list_popup {
|
||||
height: 5;
|
||||
border: solid $warning-darken-2;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Output Console Header & Expandable Terminal */
|
||||
@@ -192,7 +197,7 @@ class TactiTermTUI(App):
|
||||
background: $surface;
|
||||
border: solid $accent;
|
||||
padding: 1;
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
@@ -227,7 +232,7 @@ class TactiTermTUI(App):
|
||||
border: solid $warning-darken-1;
|
||||
padding: 1;
|
||||
margin-bottom: 1;
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#mentor-loading-indicator {
|
||||
@@ -266,6 +271,7 @@ class TactiTermTUI(App):
|
||||
BINDINGS = [
|
||||
Binding("ctrl+r", "run_code", "Run"),
|
||||
Binding("ctrl+l", "lint_code", "Lint"),
|
||||
Binding("ctrl+w", "toggle_word_wrap", "Word Wrap (Ctrl+W)"),
|
||||
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),
|
||||
@@ -273,6 +279,8 @@ class TactiTermTUI(App):
|
||||
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("alt+left,alt+a", "scroll_left", "Scroll Left", show=False),
|
||||
Binding("alt+right,alt+d", "scroll_right", "Scroll Right", 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),
|
||||
@@ -312,6 +320,7 @@ class TactiTermTUI(App):
|
||||
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("🌐 Wrap (Ctrl+W)", id="btn-wrap", variant="default")
|
||||
yield Button("🔄 Refresh", id="btn-refresh", variant="default")
|
||||
|
||||
yield CodeEditor(id="editor")
|
||||
@@ -448,6 +457,8 @@ class TactiTermTUI(App):
|
||||
self.action_toggle_handbook_sidebar()
|
||||
elif button_id == "btn-handbook-mode":
|
||||
self.toggle_handbook_mode()
|
||||
elif button_id == "btn-wrap":
|
||||
self.action_toggle_word_wrap()
|
||||
elif button_id == "btn-expand-output":
|
||||
self.toggle_expand_output()
|
||||
elif button_id == "btn-send-stdin":
|
||||
@@ -646,7 +657,7 @@ class TactiTermTUI(App):
|
||||
self.query_one("#challenge-details", Markdown).update(markdown_content)
|
||||
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
lang_key = challenge.language.lower()
|
||||
lang_key = get_tui_language(challenge.language)
|
||||
try:
|
||||
if lang_key in editor.available_languages:
|
||||
editor.language = lang_key
|
||||
@@ -690,9 +701,7 @@ class TactiTermTUI(App):
|
||||
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.hide_completion_popup()
|
||||
self.query_one("#status-bar", Static).update(f"✓ Inserted '{chosen_text}'")
|
||||
elif event.option_list.id == "handbook_topic_list":
|
||||
selected_option = event.option
|
||||
@@ -708,7 +717,63 @@ class TactiTermTUI(App):
|
||||
)
|
||||
self.run_worker(self._handbook_worker(lang, item["id"], item["title"]))
|
||||
|
||||
def show_completion_popup(self, prefix: str, matches: List[str]) -> None:
|
||||
def is_completion_open(self) -> bool:
|
||||
"""Returns True if completion popup overlay is visible."""
|
||||
main_box = self.query_one("#main-container")
|
||||
return main_box.has_class("show-completion")
|
||||
|
||||
def hide_completion_popup(self) -> None:
|
||||
"""Dismiss completion popup menu."""
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("show-completion"):
|
||||
main_box.remove_class("show-completion")
|
||||
|
||||
def update_as_you_type_completion(self, editor: CodeEditor) -> None:
|
||||
"""Real-time buffer listener to update popup matches as user types or backspaces."""
|
||||
prefix, matches, is_dot = editor.get_completions_at_cursor()
|
||||
if matches:
|
||||
self.show_completion_popup(prefix, matches, is_dot)
|
||||
elif self.is_completion_open():
|
||||
self.hide_completion_popup()
|
||||
|
||||
def navigate_completion(self, direction: int) -> None:
|
||||
"""Navigate highlighted item in floating completion list."""
|
||||
if not self.is_completion_open():
|
||||
return
|
||||
popup_list = self.query_one("#completion_list_popup", OptionList)
|
||||
if len(popup_list.options) > 0:
|
||||
current = popup_list.highlighted if popup_list.highlighted is not None else 0
|
||||
popup_list.highlighted = (current + direction) % len(popup_list.options)
|
||||
popup_list.scroll_to_highlight()
|
||||
|
||||
def insert_selected_completion(self) -> None:
|
||||
"""Insert currently highlighted completion option into CodeEditor, cleanly replacing typed token."""
|
||||
if not self.is_completion_open():
|
||||
return
|
||||
popup_list = self.query_one("#completion_list_popup", OptionList)
|
||||
if popup_list.highlighted is not None and popup_list.highlighted < len(popup_list.options):
|
||||
option = popup_list.get_option_at_index(popup_list.highlighted)
|
||||
chosen_text = str(option.prompt)
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
cursor_row, cursor_col = editor.cursor_location
|
||||
lines = editor.text.split("\n")
|
||||
line_until_cursor = lines[cursor_row][:cursor_col] if cursor_row < len(lines) else ""
|
||||
|
||||
import re
|
||||
m_dot = re.search(r"([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z0-9_]*)$", line_until_cursor)
|
||||
if m_dot:
|
||||
typed_len = len(m_dot.group(2))
|
||||
else:
|
||||
m_word = re.search(r"([a-zA-Z_][a-zA-Z0-9_]*)$", line_until_cursor)
|
||||
typed_len = len(m_word.group(1)) if m_word else len(self.active_completion_prefix)
|
||||
|
||||
start_col = max(0, cursor_col - typed_len)
|
||||
editor.delete((cursor_row, start_col), (cursor_row, cursor_col))
|
||||
editor.insert(chosen_text)
|
||||
self.hide_completion_popup()
|
||||
self.query_one("#status-bar", Static).update(f"✓ Inserted '{chosen_text}'")
|
||||
|
||||
def show_completion_popup(self, prefix: str, matches: List[str], is_dot_access: bool = False) -> None:
|
||||
self.active_completion_prefix = prefix
|
||||
popup_list = self.query_one("#completion_list_popup", OptionList)
|
||||
popup_list.clear_options()
|
||||
@@ -716,9 +781,48 @@ class TactiTermTUI(App):
|
||||
for match in matches:
|
||||
popup_list.add_option(Option(match, id=match))
|
||||
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
popup = self.query_one("#completion-popup", Container)
|
||||
panel = popup.parent or editor.parent
|
||||
|
||||
cursor_row, cursor_col = editor.cursor_location
|
||||
gutter = getattr(editor, "gutter_width", 3)
|
||||
vis_row = cursor_row - int(getattr(editor, "scroll_y", 0))
|
||||
vis_col = cursor_col - int(getattr(editor, "scroll_x", 0))
|
||||
|
||||
popup_height = max(1, min(len(matches), 5))
|
||||
popup.styles.height = popup_height
|
||||
|
||||
try:
|
||||
editor_top_rel = editor.region.y - panel.region.y
|
||||
editor_left_rel = editor.region.x - panel.region.x
|
||||
editor_height = editor.region.height
|
||||
panel_width = panel.region.width
|
||||
except Exception:
|
||||
editor_top_rel = 2
|
||||
editor_left_rel = 0
|
||||
editor_height = 15
|
||||
panel_width = 80
|
||||
|
||||
# Line Y position relative to panel (1 cell for top border of editor)
|
||||
line_y = editor_top_rel + 1 + vis_row
|
||||
|
||||
# Smart placement: position BELOW cursor line if space permits, else ABOVE cursor line
|
||||
if line_y + 1 + popup_height <= editor_top_rel + editor_height:
|
||||
top = line_y + 1
|
||||
else:
|
||||
top = max(editor_top_rel + 1, line_y - popup_height)
|
||||
|
||||
# X position: editor left rel + 1 for border + gutter + vis_col
|
||||
left_pos = editor_left_rel + 1 + gutter + vis_col
|
||||
popup_width = 32
|
||||
max_left = max(0, panel_width - popup_width)
|
||||
left = min(max(0, left_pos), max_left)
|
||||
|
||||
popup.styles.offset = (int(left), int(top))
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
main_box.add_class("show-completion")
|
||||
popup_list.focus()
|
||||
if len(matches) > 0:
|
||||
popup_list.highlighted = 0
|
||||
|
||||
@@ -744,6 +848,41 @@ class TactiTermTUI(App):
|
||||
def action_scroll_output_down(self) -> None:
|
||||
self.query_one("#output-scroll-container", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
def action_toggle_word_wrap(self) -> None:
|
||||
"""Toggle soft word wrapping on CodeEditor."""
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
editor.soft_wrap = not editor.soft_wrap
|
||||
status = "ON" if editor.soft_wrap else "OFF"
|
||||
try:
|
||||
btn = self.query_one("#btn-wrap", Button)
|
||||
if editor.soft_wrap:
|
||||
btn.label = "🌐 Wrap: ON"
|
||||
btn.variant = "warning"
|
||||
else:
|
||||
btn.label = "🌐 Wrap: OFF"
|
||||
btn.variant = "default"
|
||||
except Exception:
|
||||
pass
|
||||
self.notify(f"Code Editor Word Wrap turned {status}", title="Word Wrap Toggled")
|
||||
|
||||
def action_scroll_left(self) -> None:
|
||||
"""Scroll active container or detail/output panels left horizontally."""
|
||||
focused = self.focused
|
||||
if focused and hasattr(focused, "scroll_left"):
|
||||
focused.scroll_left(animate=False)
|
||||
else:
|
||||
self.query_one("#challenge-details-container", VerticalScroll).scroll_left(animate=False)
|
||||
self.query_one("#output-scroll-container", VerticalScroll).scroll_left(animate=False)
|
||||
|
||||
def action_scroll_right(self) -> None:
|
||||
"""Scroll active container or detail/output panels right horizontally."""
|
||||
focused = self.focused
|
||||
if focused and hasattr(focused, "scroll_right"):
|
||||
focused.scroll_right(animate=False)
|
||||
else:
|
||||
self.query_one("#challenge-details-container", VerticalScroll).scroll_right(animate=False)
|
||||
self.query_one("#output-scroll-container", VerticalScroll).scroll_right(animate=False)
|
||||
|
||||
def action_scroll_details_up(self) -> None:
|
||||
self.query_one("#challenge-details-container", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
|
||||
+11
-2
@@ -141,7 +141,7 @@ class GenHelpModal(ModalScreen):
|
||||
},
|
||||
{"role": "user", "content": question},
|
||||
],
|
||||
"max_tokens": 1000,
|
||||
"max_tokens": config.llm_max_tokens,
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
@@ -234,7 +234,7 @@ class GenTUIApp(App):
|
||||
border: solid $secondary;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#btn-help-modal {
|
||||
@@ -262,6 +262,7 @@ class GenTUIApp(App):
|
||||
Binding("ctrl+h", "open_help_modal", "AI Helper"),
|
||||
Binding("ctrl+g", "generate_ai", "Generate AI"),
|
||||
Binding("ctrl+s", "save_challenge", "Save File"),
|
||||
Binding("ctrl+w", "toggle_word_wrap", "Word Wrap (Ctrl+W)"),
|
||||
Binding("ctrl+q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
@@ -404,6 +405,14 @@ class GenTUIApp(App):
|
||||
else:
|
||||
self.query_one("#status-bar", Static).update(f"✗ Save Error: {res.get('message')}")
|
||||
|
||||
def action_toggle_word_wrap(self) -> None:
|
||||
"""Toggle soft word wrap in the raw markdown editor."""
|
||||
editor = self.query_one("#markdown-editor", TextArea)
|
||||
editor.soft_wrap = not editor.soft_wrap
|
||||
status = "ON" if editor.soft_wrap else "OFF"
|
||||
self.notify(f"Markdown Editor Word Wrap turned {status}", title="Word Wrap Toggled")
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="GenTUI — TactiTerm AI Challenge Generator")
|
||||
|
||||
+287
-19
@@ -79,6 +79,217 @@ TUI_COMPLETIONS: Dict[str, List[str]] = {
|
||||
}
|
||||
|
||||
|
||||
# Duplicate csharp completion list for c_sharp key
|
||||
TUI_COMPLETIONS["c_sharp"] = TUI_COMPLETIONS["csharp"]
|
||||
|
||||
|
||||
def get_tui_language(language: str) -> str:
|
||||
"""Map display/challenge language names to Textual's expected TextArea language keys."""
|
||||
if not language:
|
||||
return "python"
|
||||
norm = language.lower().strip()
|
||||
mapping = {
|
||||
"c++": "cpp",
|
||||
"cpp": "cpp",
|
||||
"cxx": "cpp",
|
||||
"c#": "c_sharp",
|
||||
"cs": "c_sharp",
|
||||
"csharp": "c_sharp",
|
||||
"c_sharp": "c_sharp",
|
||||
"js": "javascript",
|
||||
"javascript": "javascript",
|
||||
"ts": "typescript",
|
||||
"typescript": "typescript",
|
||||
"py": "python",
|
||||
"python": "python",
|
||||
"rs": "rust",
|
||||
"rust": "rust",
|
||||
"golang": "go",
|
||||
"go": "go",
|
||||
"html": "html",
|
||||
"java": "java",
|
||||
"lua": "lua",
|
||||
}
|
||||
return mapping.get(norm, norm)
|
||||
|
||||
|
||||
def register_custom_tree_sitter_languages(editor: TextArea) -> None:
|
||||
"""Register tree-sitter language grammars with rich query definitions for cpp, c_sharp, typescript, lua if available."""
|
||||
langs = {
|
||||
"cpp": (
|
||||
"tree_sitter_cpp",
|
||||
"language",
|
||||
[
|
||||
'["if" "else" "for" "while" "return" "class" "struct" "namespace" "using" "public" "private" "protected" "virtual" "const" "inline" "static" "template" "typename" "new" "delete" "catch" "try" "throw"] @keyword',
|
||||
'(primitive_type) @type',
|
||||
'(type_identifier) @type',
|
||||
'(comment) @comment',
|
||||
'(string_literal) @string',
|
||||
'(system_lib_string) @string',
|
||||
'(number_literal) @number',
|
||||
'(field_identifier) @property',
|
||||
'(function_declarator declarator: (identifier) @function)',
|
||||
'(call_expression function: (identifier) @function.call)',
|
||||
'(preproc_include) @include',
|
||||
'(preproc_def) @keyword',
|
||||
'(true) @boolean',
|
||||
'(false) @boolean',
|
||||
'(null) @constant.builtin',
|
||||
],
|
||||
),
|
||||
"c_sharp": (
|
||||
"tree_sitter_c_sharp",
|
||||
"language",
|
||||
[
|
||||
'["if" "else" "for" "foreach" "while" "return" "class" "struct" "interface" "public" "private" "protected" "internal" "static" "async" "await" "using" "namespace" "new" "get" "set" "try" "catch" "throw"] @keyword',
|
||||
'(predefined_type) @type',
|
||||
'(comment) @comment',
|
||||
'(string_literal) @string',
|
||||
'(verbatim_string_literal) @string',
|
||||
'(integer_literal) @number',
|
||||
'(real_literal) @number',
|
||||
'(identifier) @variable',
|
||||
'(method_declaration name: (identifier) @function)',
|
||||
'(invocation_expression function: (identifier) @function.call)',
|
||||
'(boolean_literal) @boolean',
|
||||
'(null_literal) @constant.builtin',
|
||||
],
|
||||
),
|
||||
"typescript": (
|
||||
"tree_sitter_typescript",
|
||||
"language_typescript",
|
||||
[
|
||||
'["if" "else" "for" "while" "return" "function" "class" "interface" "type" "const" "let" "var" "import" "from" "export" "async" "await" "new" "try" "catch" "throw" "switch" "case"] @keyword',
|
||||
'(predefined_type) @type',
|
||||
'(type_identifier) @type',
|
||||
'(comment) @comment',
|
||||
'(string) @string',
|
||||
'(template_string) @string',
|
||||
'(number) @number',
|
||||
'(property_identifier) @property',
|
||||
'(function_declaration name: (identifier) @function)',
|
||||
'(call_expression function: (identifier) @function.call)',
|
||||
'(true) @boolean',
|
||||
'(false) @boolean',
|
||||
'(null) @constant.builtin',
|
||||
'(undefined) @constant.builtin',
|
||||
],
|
||||
),
|
||||
"lua": (
|
||||
"tree_sitter_lua",
|
||||
"language",
|
||||
[
|
||||
'["if" "then" "else" "elseif" "end" "function" "return" "while" "for" "do" "local" "repeat" "until" "and" "or" "not" "in"] @keyword',
|
||||
'(break_statement) @keyword',
|
||||
'(comment) @comment',
|
||||
'(string) @string',
|
||||
'(number) @number',
|
||||
'(function_declaration name: (identifier) @function)',
|
||||
'(function_call name: (identifier) @function.call)',
|
||||
'(dot_index_expression field: (identifier) @property)',
|
||||
'(true) @boolean',
|
||||
'(false) @boolean',
|
||||
'(nil) @constant.builtin',
|
||||
],
|
||||
),
|
||||
}
|
||||
for key, (mod_name, func_name, sample_queries) in langs.items():
|
||||
if key not in editor.available_languages:
|
||||
try:
|
||||
import importlib
|
||||
import textual._tree_sitter as ts
|
||||
import textual.widgets._text_area as ta
|
||||
|
||||
mod = importlib.import_module(mod_name)
|
||||
func = getattr(mod, func_name)
|
||||
lang_obj = ts.Language(func())
|
||||
|
||||
query_str = ""
|
||||
for q in sample_queries:
|
||||
try:
|
||||
document = ta.SyntaxAwareDocument("", lang_obj)
|
||||
document.prepare_query(q)
|
||||
query_str += ("\n" if query_str else "") + q
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
editor.register_language(key, lang_obj, query_str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
import re
|
||||
|
||||
class CodebaseSymbolExtractor:
|
||||
"""Extracts symbols, variables, functions, and classes from the active code buffer."""
|
||||
|
||||
@staticmethod
|
||||
def extract_symbols(code: str) -> List[str]:
|
||||
if not code:
|
||||
return []
|
||||
words = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", code)
|
||||
unique = set()
|
||||
reserved = {
|
||||
"if", "else", "for", "while", "return", "import", "from", "def", "class",
|
||||
"public", "private", "protected", "static", "void", "int", "double", "float",
|
||||
"bool", "boolean", "char", "const", "let", "var", "func", "package", "struct",
|
||||
}
|
||||
for w in words:
|
||||
if len(w) > 2 and w not in reserved:
|
||||
unique.add(w)
|
||||
return sorted(list(unique))
|
||||
|
||||
|
||||
METHOD_CATALOGS: Dict[str, Dict[str, List[str]]] = {
|
||||
"java": {
|
||||
"map": ["put(key, value)", "get(key)", "containsKey(key)", "containsValue(val)", "size()", "isEmpty()", "keySet()", "values()", "entrySet()", "remove(key)", "clear()", "getOrDefault(key, default)"],
|
||||
"list": ["add(element)", "get(index)", "size()", "remove(index)", "contains(element)", "indexOf(element)", "isEmpty()", "clear()", "set(index, element)"],
|
||||
"set": ["add(element)", "remove(element)", "contains(element)", "size()", "isEmpty()", "clear()"],
|
||||
"string": ["length()", "substring(beginIndex)", "charAt(index)", "toLowerCase()", "toUpperCase()", "trim()", "split(regex)", "contains(str)", "startsWith(prefix)", "equals(obj)"],
|
||||
"system": ["println(value)", "printf(format, args)", "print(value)"],
|
||||
},
|
||||
"python": {
|
||||
"map": ["get(key)", "keys()", "values()", "items()", "update(dict)", "pop(key)", "clear()"],
|
||||
"list": ["append(item)", "extend(iterable)", "insert(index, item)", "remove(item)", "pop()", "sort()", "reverse()", "clear()", "count(item)", "index(item)"],
|
||||
"string": ["split(sep)", "join(iterable)", "lower()", "upper()", "strip()", "replace(old, new)", "startswith(prefix)", "endswith(suffix)", "find(sub)"],
|
||||
},
|
||||
"cpp": {
|
||||
"map": ["insert({key, val})", "find(key)", "count(key)", "size()", "empty()", "clear()", "at(key)"],
|
||||
"list": ["push_back(val)", "pop_back()", "size()", "empty()", "clear()", "begin()", "end()", "at(idx)"],
|
||||
"string": ["length()", "size()", "substr(pos, len)", "append(str)", "find(str)", "c_str()", "empty()"],
|
||||
"std": ["cout << value << std::endl;", "cin >> var;", "vector<int>", "map<string, int>", "sort(begin, end)"],
|
||||
},
|
||||
"c_sharp": {
|
||||
"map": ["Add(key, val)", "ContainsKey(key)", "TryGetValue(key, out val)", "Remove(key)", "Count", "Clear()"],
|
||||
"list": ["Add(item)", "Remove(item)", "RemoveAt(index)", "Contains(item)", "Count", "Clear()"],
|
||||
"string": ["Length", "Substring(startIndex)", "ToLower()", "ToUpper()", "Trim()", "Split(sep)", "Replace(old, new)"],
|
||||
"console": ["WriteLine(value)", "ReadLine()", "Write(value)"],
|
||||
},
|
||||
"javascript": {
|
||||
"map": ["set(key, val)", "get(key)", "has(key)", "delete(key)", "clear()", "size"],
|
||||
"list": ["map(x => x)", "filter(x => true)", "push(item)", "pop()", "slice(start, end)", "includes(item)", "length"],
|
||||
"console": ["log(msg)", "error(err)", "warn(msg)"],
|
||||
},
|
||||
"typescript": {
|
||||
"map": ["set(key, val)", "get(key)", "has(key)", "delete(key)", "clear()", "size"],
|
||||
"list": ["map(x => x)", "filter(x => true)", "push(item)", "pop()", "slice(start, end)", "includes(item)", "length"],
|
||||
"console": ["log(msg)", "error(err)", "warn(msg)"],
|
||||
},
|
||||
"rust": {
|
||||
"map": ["insert(key, val)", "get(&key)", "contains_key(&key)", "remove(&key)", "len()", "is_empty()"],
|
||||
"list": ["push(val)", "pop()", "len()", "is_empty()", "contains(&val)", "iter()", "collect()"],
|
||||
},
|
||||
"go": {
|
||||
"fmt": ["Println(v)", "Printf(format, v)", "Sprintf(format, v)"],
|
||||
"strings": ["Split(s, sep)", "Join(a, sep)", "ToLower(s)", "ToUpper(s)"],
|
||||
},
|
||||
"lua": {
|
||||
"table": ["insert(t, val)", "remove(t, pos)", "concat(t, sep)", "sort(t)"],
|
||||
"string": ["sub(s, i, j)", "lower(s)", "upper(s)", "len(s)"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CodeEditor(TextArea):
|
||||
"""A code editor with auto-completion dropdown trigger."""
|
||||
|
||||
@@ -101,13 +312,15 @@ class CodeEditor(TextArea):
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
language=language,
|
||||
language=None,
|
||||
theme=theme,
|
||||
soft_wrap=False,
|
||||
show_line_numbers=True,
|
||||
id=id,
|
||||
classes=classes,
|
||||
)
|
||||
register_custom_tree_sitter_languages(self)
|
||||
self.language = get_tui_language(language)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.tab_behavior = "indent"
|
||||
@@ -116,8 +329,60 @@ class CodeEditor(TextArea):
|
||||
def get_code(self) -> str:
|
||||
return self.text
|
||||
|
||||
def get_completions_at_cursor(self) -> tuple[str, List[str], bool]:
|
||||
"""Returns (prefix_to_replace, matching_candidates, is_dot_access)."""
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
lines = self.text.split("\n")
|
||||
if cursor_row >= len(lines):
|
||||
return ("", [], False)
|
||||
|
||||
current_line = lines[cursor_row][:cursor_col]
|
||||
lang = get_tui_language(self.language or "python")
|
||||
|
||||
# 1. Check dot member access (e.g. stockMap. or stockMap.p or System.out.)
|
||||
dot_match = re.search(r"([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z0-9_]*)$", current_line)
|
||||
if dot_match:
|
||||
obj_name = dot_match.group(1)
|
||||
member_prefix = dot_match.group(2)
|
||||
obj_lower = obj_name.lower()
|
||||
|
||||
lang_methods = METHOD_CATALOGS.get(lang, METHOD_CATALOGS.get(lang.replace("_", ""), METHOD_CATALOGS["java"]))
|
||||
target_type = "map"
|
||||
if "map" in obj_lower or "dict" in obj_lower:
|
||||
target_type = "map"
|
||||
elif "list" in obj_lower or "arr" in obj_lower or "items" in obj_lower:
|
||||
target_type = "list"
|
||||
elif "set" in obj_lower:
|
||||
target_type = "set"
|
||||
elif "str" in obj_lower or "text" in obj_lower or "name" in obj_lower or "msg" in obj_lower:
|
||||
target_type = "string"
|
||||
elif obj_lower in lang_methods:
|
||||
target_type = obj_lower
|
||||
|
||||
candidates = lang_methods.get(target_type, lang_methods.get("map", []))
|
||||
matches = [c for c in candidates if c.lower().startswith(member_prefix.lower())]
|
||||
return (member_prefix, matches, True)
|
||||
|
||||
# 2. Standalone prefix word completion
|
||||
word_match = re.search(r"([a-zA-Z_][a-zA-Z0-9_]*)$", current_line)
|
||||
if word_match:
|
||||
prefix = word_match.group(1)
|
||||
if len(prefix) >= 1:
|
||||
static_candidates = TUI_COMPLETIONS.get(lang, TUI_COMPLETIONS.get(lang.replace("_", ""), TUI_COMPLETIONS["python"]))
|
||||
extracted_symbols = CodebaseSymbolExtractor.extract_symbols(self.text)
|
||||
all_candidates = list(dict.fromkeys(static_candidates + extracted_symbols))
|
||||
matches = [c for c in all_candidates if c.lower().startswith(prefix.lower()) and c.lower() != prefix.lower()]
|
||||
return (prefix, matches, False)
|
||||
|
||||
return ("", [], False)
|
||||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
"""Trigger instant as-you-type completion update on any text mutation."""
|
||||
if hasattr(self.app, "update_as_you_type_completion"):
|
||||
self.app.update_as_you_type_completion(self)
|
||||
|
||||
def _on_key(self, event: events.Key) -> None:
|
||||
"""Handle Tab completion and Ctrl+E toggle."""
|
||||
"""Handle completion key navigation, selection, escape, and Ctrl+E toggle."""
|
||||
if event.key == "ctrl+e":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
@@ -125,24 +390,27 @@ class CodeEditor(TextArea):
|
||||
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()]
|
||||
is_open = getattr(self.app, "is_completion_open", lambda: False)()
|
||||
|
||||
if matches:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "show_completion_popup"):
|
||||
self.app.show_completion_popup(words[-1], matches)
|
||||
return
|
||||
if is_open:
|
||||
if event.key in ("down", "up"):
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "navigate_completion"):
|
||||
self.app.navigate_completion(1 if event.key == "down" else -1)
|
||||
return
|
||||
elif event.key in ("enter", "tab"):
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "insert_selected_completion"):
|
||||
self.app.insert_selected_completion()
|
||||
return
|
||||
elif event.key == "escape":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "hide_completion_popup"):
|
||||
self.app.hide_completion_popup()
|
||||
return
|
||||
|
||||
super()._on_key(event)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user