Syntax highlighting fixes, autocomplete overhaul, tui changes

This commit is contained in:
Alexander R.
2026-07-28 22:30:25 +00:00
parent bcb054a3fa
commit 0559f7549a
11 changed files with 1046 additions and 245 deletions
+13 -2
View File
@@ -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
+5 -6
View File
@@ -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"):
+6 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)