From 0559f7549a9cef4ee6eeac126ba50c9f1d7f2660 Mon Sep 17 00:00:00 2001 From: "Alexander R." <118708187+Dewm-Bot@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:30:25 +0000 Subject: [PATCH] Syntax highlighting fixes, autocomplete overhaul, tui changes --- Makefile | 2 +- config.json.local | 16 + frontend/src/pages/Workspace.tsx | 14 +- frontend/src/utils/monacoCompletions.ts | 711 ++++++++++++++++++------ src/core/config.py | 15 +- src/core/generator.py | 11 +- src/core/handbook.py | 13 +- src/core/mentor.py | 11 +- src/tui/app.py | 179 +++++- src/tui/gen_app.py | 13 +- src/tui/widgets.py | 306 +++++++++- 11 files changed, 1046 insertions(+), 245 deletions(-) create mode 100644 config.json.local diff --git a/Makefile b/Makefile index c15eada..a1b9a2c 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: tui gentui tui-debug tui-test web install stop install: - uv pip install fastapi uvicorn textual httpx tree-sitter-markdown tree-sitter-python tree-sitter-java tree-sitter-cpp tree-sitter-rust tree-sitter-go tree-sitter-javascript tree-sitter-typescript tree-sitter-c-sharp tree-sitter-html + uv pip install fastapi uvicorn textual httpx tree-sitter tree-sitter-markdown tree-sitter-python tree-sitter-java tree-sitter-cpp tree-sitter-rust tree-sitter-go tree-sitter-javascript tree-sitter-typescript tree-sitter-c-sharp tree-sitter-html tree-sitter-lua devbox run -- bash -c "cd frontend && npm install" stop: diff --git a/config.json.local b/config.json.local new file mode 100644 index 0000000..ae206e2 --- /dev/null +++ b/config.json.local @@ -0,0 +1,16 @@ +{ + "llm": { + "base_url": "http://127.0.0.1:1010", + "model": "local-model", + "api_key": "not-needed", + "temperature": 0.9, + "max_tokens": 8120, + "timeout_seconds": 120.0 + }, + "web": { + "host": "127.0.0.1", + "port": 8000, + "public": true + } +} + diff --git a/frontend/src/pages/Workspace.tsx b/frontend/src/pages/Workspace.tsx index d56156c..a614e85 100644 --- a/frontend/src/pages/Workspace.tsx +++ b/frontend/src/pages/Workspace.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { Editor } from '@monaco-editor/react'; -import { registerMonacoCompletions } from '../utils/monacoCompletions'; +import { registerMonacoCompletions, getMonacoLanguage, getLanguageFileName } from '../utils/monacoCompletions'; import { getChallenges, runCode, @@ -184,7 +184,7 @@ const Workspace: React.FC = ({ challengeId, onBack }) => { const inputToSend = overrideStdin !== undefined ? overrideStdin : stdinText; try { - const res = await runCode(currentChallenge.language.toLowerCase() || 'python', code, inputToSend); + const res = await runCode(getMonacoLanguage(currentChallenge.language), code, inputToSend); if (res.exit_code === 0) { setOutput({ status: 'success', @@ -210,7 +210,7 @@ const Workspace: React.FC = ({ challengeId, onBack }) => { setIsLinting(true); setOutput({ status: 'idle', text: 'Running syntax & style lint...' }); try { - const res = await lintCode(currentChallenge.language.toLowerCase() || 'python', code); + const res = await lintCode(getMonacoLanguage(currentChallenge.language), code); if (res.exit_code === 0) { setOutput({ status: 'success', text: '✓ Syntax & Style clean! No linting errors detected.' }); } else { @@ -237,7 +237,7 @@ const Workspace: React.FC = ({ challengeId, onBack }) => { try { const res = await getGuidance( cid, - currentChallenge.language.toLowerCase() || 'python', + getMonacoLanguage(currentChallenge.language), code, q ); @@ -519,7 +519,7 @@ const Workspace: React.FC = ({ challengeId, onBack }) => { {/* Middle Column: Monaco Code Editor & Resizable Output Console */}
- main.py + {getLanguageFileName(currentChallenge?.language)} TactiTerm IDE
@@ -527,8 +527,8 @@ const Workspace: React.FC = ({ challengeId, onBack }) => {
= { + 'c++': 'cpp', + 'cpp': 'cpp', + 'cxx': 'cpp', + 'c#': 'csharp', + 'cs': 'csharp', + 'csharp': 'csharp', + '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 map[norm] || norm; +} + +export function getLanguageFileName(language?: string): string { + const lang = getMonacoLanguage(language); + const extMap: Record = { + python: 'main.py', + csharp: 'Program.cs', + cpp: 'main.cpp', + java: 'Main.java', + javascript: 'main.js', + typescript: 'main.ts', + rust: 'main.rs', + lua: 'main.lua', + html: 'index.html', + go: 'main.go', + }; + return extMap[lang] || 'main.txt'; +} + +function extractDocumentSymbols(code: string): string[] { + const words = code.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || []; + const unique = new Set(); + const reserved = new Set([ + '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 (const w of words) { + if (w.length > 2 && !reserved.has(w)) { + unique.add(w); + } + } + return Array.from(unique); +} + let registered = false; export function registerMonacoCompletions(monaco: Monaco) { if (registered) return; registered = true; - // Completion items for Python + // ── JAVA COMPLETER ────────────────────────────────────────── + monaco.languages.registerCompletionItemProvider('java', { + triggerCharacters: ['.'], + provideCompletionItems: (model, position) => { + const lineUntilPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + const code = model.getValue(); + const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*)\.\s*$/); + + if (dotMatch) { + const objName = dotMatch[1]; + const lowerName = objName.toLowerCase(); + let targetType = 'unknown'; + + if (new RegExp(`Map<|HashMap<|TreeMap<`, 'i').test(code) && (lowerName.includes('map') || lowerName.includes('dict'))) { + targetType = 'map'; + } else if (new RegExp(`List<|ArrayList<|LinkedList<`, 'i').test(code) && (lowerName.includes('list') || lowerName.includes('arr') || lowerName.includes('items'))) { + targetType = 'list'; + } else if (new RegExp(`Set<|HashSet<`, 'i').test(code) && lowerName.includes('set')) { + targetType = 'set'; + } else if (lowerName === 'out' || objName === 'System') { + targetType = 'system'; + } else if (lowerName.includes('str') || lowerName.includes('text') || lowerName.includes('name') || lowerName.includes('msg')) { + targetType = 'string'; + } else { + if (lowerName.includes('map') || lowerName.includes('dict')) targetType = 'map'; + else if (lowerName.includes('list') || lowerName.includes('arr')) targetType = 'list'; + else if (lowerName.includes('set')) targetType = 'set'; + else targetType = 'general'; + } + + const mapMethods = [ + { label: 'put', insertText: 'put(${1:key}, ${2:value})', doc: 'Associates specified value with key in map.' }, + { label: 'get', insertText: 'get(${1:key})', doc: 'Returns value mapped to specified key.' }, + { label: 'containsKey', insertText: 'containsKey(${1:key})', doc: 'Returns true if map contains key.' }, + { label: 'containsValue', insertText: 'containsValue(${1:value})', doc: 'Returns true if map contains value.' }, + { label: 'size', insertText: 'size()', doc: 'Returns number of key-value mappings.' }, + { label: 'isEmpty', insertText: 'isEmpty()', doc: 'Returns true if map contains no mappings.' }, + { label: 'keySet', insertText: 'keySet()', doc: 'Returns Set view of keys in map.' }, + { label: 'values', insertText: 'values()', doc: 'Returns Collection view of values in map.' }, + { label: 'entrySet', insertText: 'entrySet()', doc: 'Returns Set view of mappings in map.' }, + { label: 'remove', insertText: 'remove(${1:key})', doc: 'Removes mapping for key.' }, + { label: 'clear', insertText: 'clear()', doc: 'Removes all mappings from map.' }, + { label: 'getOrDefault', insertText: 'getOrDefault(${1:key}, ${2:defaultValue})', doc: 'Returns mapped value or default.' }, + ]; + + const listMethods = [ + { label: 'add', insertText: 'add(${1:element})', doc: 'Appends element to end of list.' }, + { label: 'get', insertText: 'get(${1:index})', doc: 'Returns element at index.' }, + { label: 'size', insertText: 'size()', doc: 'Returns number of elements.' }, + { label: 'remove', insertText: 'remove(${1:index})', doc: 'Removes element at index.' }, + { label: 'contains', insertText: 'contains(${1:element})', doc: 'Returns true if list contains element.' }, + { label: 'indexOf', insertText: 'indexOf(${1:element})', doc: 'Returns index of element.' }, + { label: 'isEmpty', insertText: 'isEmpty()', doc: 'Returns true if list contains no elements.' }, + { label: 'clear', insertText: 'clear()', doc: 'Removes all elements.' }, + ]; + + const stringMethods = [ + { label: 'length', insertText: 'length()', doc: 'Returns length of string.' }, + { label: 'substring', insertText: 'substring(${1:beginIndex})', doc: 'Returns substring starting at beginIndex.' }, + { label: 'charAt', insertText: 'charAt(${1:index})', doc: 'Returns char value at index.' }, + { label: 'toLowerCase', insertText: 'toLowerCase()', doc: 'Converts to lowercase.' }, + { label: 'toUpperCase', insertText: 'toUpperCase()', doc: 'Converts to uppercase.' }, + { label: 'trim', insertText: 'trim()', doc: 'Removes leading/trailing whitespace.' }, + { label: 'split', insertText: 'split("${1:regex}")', doc: 'Splits string around matches.' }, + { label: 'contains', insertText: 'contains("${1:str}")', doc: 'Returns true if contains string.' }, + { label: 'startsWith', insertText: 'startsWith("${1:prefix}")', doc: 'Checks if starts with prefix.' }, + { label: 'equals', insertText: 'equals(${1:anObject})', doc: 'Compares to specified object.' }, + ]; + + const systemMethods = [ + { label: 'println', insertText: 'println(${1:value});', doc: 'Prints value and terminates line.' }, + { label: 'printf', insertText: 'printf("${1:%s}\\n", ${2:args});', doc: 'Prints formatted string.' }, + { label: 'print', insertText: 'print(${1:value});', doc: 'Prints value.' }, + ]; + + let selected = mapMethods; + if (targetType === 'list') selected = listMethods; + else if (targetType === 'string') selected = stringMethods; + else if (targetType === 'system') selected = systemMethods; + else if (targetType === 'general') selected = [...mapMethods, ...listMethods, ...stringMethods]; + + return { + suggestions: selected.map(m => ({ + label: m.label, + kind: monaco.languages.CompletionItemKind.Method, + insertText: m.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: m.doc, + range, + })), + }; + } + + const symbols = extractDocumentSymbols(code); + const suggestions = [ + { label: 'System.out.println', kind: monaco.languages.CompletionItemKind.Snippet, insertText: 'System.out.println(${1:value});', range }, + { label: 'HashMap', kind: monaco.languages.CompletionItemKind.Class, insertText: 'Map<${1:String}, ${2:Integer}> ${3:map} = new HashMap<>();', range }, + { label: 'ArrayList', kind: monaco.languages.CompletionItemKind.Class, insertText: 'List<${1:String}> ${2:list} = new ArrayList<>();', range }, + ...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })), + ]; + + return { suggestions }; + }, + }); + + // ── PYTHON COMPLETER ──────────────────────────────────────── monaco.languages.registerCompletionItemProvider('python', { triggerCharacters: ['.'], provideCompletionItems: (model, position) => { @@ -25,70 +204,194 @@ export function registerMonacoCompletions(monaco: Monaco) { endColumn: word.endColumn, }; + const code = model.getValue(); + const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*)\.\s*$/); + + if (dotMatch) { + const objName = dotMatch[1].toLowerCase(); + let methods = [ + { label: 'get', insertText: 'get(${1:key})', doc: 'Returns value for key.' }, + { label: 'keys', insertText: 'keys()', doc: 'Returns dictionary keys.' }, + { label: 'values', insertText: 'values()', doc: 'Returns dictionary values.' }, + { label: 'items', insertText: 'items()', doc: 'Returns key-value pairs.' }, + { label: 'append', insertText: 'append(${1:item})', doc: 'Appends item to list.' }, + { label: 'pop', insertText: 'pop(${1:index})', doc: 'Removes and returns item.' }, + { label: 'split', insertText: 'split("${1:sep}")', doc: 'Splits string.' }, + { label: 'join', insertText: 'join(${1:iterable})', doc: 'Joins elements.' }, + ]; + if (objName.includes('map') || objName.includes('dict')) { + methods = [ + { label: 'get', insertText: 'get(${1:key})', doc: 'Returns value for key.' }, + { label: 'keys', insertText: 'keys()', doc: 'Returns dictionary keys.' }, + { label: 'values', insertText: 'values()', doc: 'Returns dictionary values.' }, + { label: 'items', insertText: 'items()', doc: 'Returns key-value pairs.' }, + { label: 'update', insertText: 'update(${1:dict})', doc: 'Updates dictionary.' }, + { label: 'pop', insertText: 'pop(${1:key})', doc: 'Removes key.' }, + ]; + } else if (objName.includes('list') || objName.includes('arr')) { + methods = [ + { label: 'append', insertText: 'append(${1:item})', doc: 'Appends item.' }, + { label: 'extend', insertText: 'extend(${1:iterable})', doc: 'Extends list.' }, + { label: 'insert', insertText: 'insert(${1:index}, ${2:item})', doc: 'Inserts item.' }, + { label: 'remove', insertText: 'remove(${1:item})', doc: 'Removes item.' }, + { label: 'sort', insertText: 'sort()', doc: 'Sorts list.' }, + ]; + } + + return { + suggestions: methods.map(m => ({ + label: m.label, + kind: monaco.languages.CompletionItemKind.Method, + insertText: m.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: m.doc, + range, + })), + }; + } + + const symbols = extractDocumentSymbols(code); const suggestions = [ - { - label: 'print', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'print(${1:value})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Prints specified objects to standard output.', - range, - }, - { - label: 'len', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'len(${1:obj})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Returns the number of items in a container.', - range, - }, - { - label: 'range', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'range(${1:stop})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Generates a sequence of numbers.', - range, - }, - { - label: 'enumerate', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'enumerate(${1:iterable})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Yields index and value tuples from an iterable.', - range, - }, - { - label: 'append', - kind: monaco.languages.CompletionItemKind.Method, - insertText: 'append(${1:item})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Appends a new item to the end of the list.', - range, - }, - { - label: 'split', - kind: monaco.languages.CompletionItemKind.Method, - insertText: 'split(${1:sep})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Splits string into a list of substrings.', - range, - }, - { - label: 'def', - kind: monaco.languages.CompletionItemKind.Keyword, - insertText: 'def ${1:function_name}(${2:params}):\n ${3:pass}', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Defines a Python function.', - range, - }, + { label: 'print', kind: monaco.languages.CompletionItemKind.Function, insertText: 'print(${1:value})', range }, + { label: 'len', kind: monaco.languages.CompletionItemKind.Function, insertText: 'len(${1:obj})', range }, + { label: 'range', kind: monaco.languages.CompletionItemKind.Function, insertText: 'range(${1:stop})', range }, + { label: 'enumerate', kind: monaco.languages.CompletionItemKind.Function, insertText: 'enumerate(${1:iterable})', range }, + ...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })), ]; return { suggestions }; }, }); - // Completion items for JavaScript / TypeScript + // ── C++ COMPLETER ─────────────────────────────────────────── + monaco.languages.registerCompletionItemProvider('cpp', { + triggerCharacters: ['.', ':', '>'], + provideCompletionItems: (model, position) => { + const lineUntilPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + const code = model.getValue(); + const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*|\bstd\b)(\.|\:\:|->)\s*$/); + + if (dotMatch) { + const objName = dotMatch[1].toLowerCase(); + let methods = [ + { label: 'push_back', insertText: 'push_back(${1:val})', doc: 'Appends element.' }, + { label: 'pop_back', insertText: 'pop_back()', doc: 'Removes last element.' }, + { label: 'size', insertText: 'size()', doc: 'Returns number of elements.' }, + { label: 'empty', insertText: 'empty()', doc: 'Checks if container is empty.' }, + { label: 'find', insertText: 'find(${1:key})', doc: 'Finds element.' }, + { label: 'insert', insertText: 'insert(${1:val})', doc: 'Inserts element.' }, + { label: 'clear', insertText: 'clear()', doc: 'Clears all elements.' }, + ]; + if (objName === 'std') { + methods = [ + { label: 'cout', insertText: 'cout << ${1:value} << std::endl;', doc: 'Standard output stream.' }, + { label: 'cin', insertText: 'cin >> ${1:var};', doc: 'Standard input stream.' }, + { label: 'vector', insertText: 'vector<${1:int}> ${2:vec};', doc: 'Dynamic array container.' }, + { label: 'map', insertText: 'map<${1:string}, ${2:int}> ${3:map};', doc: 'Sorted associative container.' }, + { label: 'sort', insertText: 'sort(${1:begin}, ${2:end});', doc: 'Sorts elements.' }, + ]; + } + + return { + suggestions: methods.map(m => ({ + label: m.label, + kind: monaco.languages.CompletionItemKind.Method, + insertText: m.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: m.doc, + range, + })), + }; + } + + const symbols = extractDocumentSymbols(code); + return { + suggestions: [ + { label: 'std::cout', kind: monaco.languages.CompletionItemKind.Snippet, insertText: 'std::cout << ${1:value} << std::endl;', range }, + { label: 'std::vector', kind: monaco.languages.CompletionItemKind.Class, insertText: 'std::vector<${1:int}> ${2:vec};', range }, + ...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })), + ], + }; + }, + }); + + // ── C# COMPLETER ──────────────────────────────────────────── + monaco.languages.registerCompletionItemProvider('csharp', { + triggerCharacters: ['.'], + provideCompletionItems: (model, position) => { + const lineUntilPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + const code = model.getValue(); + const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*)\.\s*$/); + + if (dotMatch) { + const objName = dotMatch[1].toLowerCase(); + let methods = [ + { label: 'Add', insertText: 'Add(${1:val});', doc: 'Adds value.' }, + { label: 'Remove', insertText: 'Remove(${1:val});', doc: 'Removes value.' }, + { label: 'ContainsKey', insertText: 'ContainsKey(${1:key})', doc: 'Checks if dictionary contains key.' }, + { label: 'Count', insertText: 'Count', doc: 'Gets number of elements.' }, + { label: 'Clear', insertText: 'Clear()', doc: 'Removes all elements.' }, + ]; + if (objName === 'console') { + methods = [ + { label: 'WriteLine', insertText: 'WriteLine(${1:value});', doc: 'Writes value to standard output.' }, + { label: 'ReadLine', insertText: 'ReadLine()', doc: 'Reads next line.' }, + { label: 'Write', insertText: 'Write(${1:value});', doc: 'Writes value.' }, + ]; + } + + return { + suggestions: methods.map(m => ({ + label: m.label, + kind: monaco.languages.CompletionItemKind.Method, + insertText: m.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: m.doc, + range, + })), + }; + } + + const symbols = extractDocumentSymbols(code); + return { + suggestions: [ + { label: 'Console.WriteLine', kind: monaco.languages.CompletionItemKind.Method, insertText: 'Console.WriteLine(${1:value});', range }, + { label: 'Dictionary', kind: monaco.languages.CompletionItemKind.Class, insertText: 'Dictionary<${1:string}, ${2:int}> ${3:dict} = new Dictionary<${1:string}, ${2:int}>();', range }, + ...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })), + ], + }; + }, + }); + + // ── JS & TS COMPLETER ─────────────────────────────────────── ['javascript', 'typescript'].forEach((lang) => { monaco.languages.registerCompletionItemProvider(lang, { triggerCharacters: ['.'], @@ -108,73 +411,62 @@ export function registerMonacoCompletions(monaco: Monaco) { endColumn: word.endColumn, }; - if (lineUntilPosition.endsWith('console.')) { + const code = model.getValue(); + const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*)\.\s*$/); + + if (dotMatch) { + const objName = dotMatch[1].toLowerCase(); + let methods = [ + { label: 'map', insertText: 'map((${1:x}) => ${2:x})', doc: 'Creates a new array with mapped elements.' }, + { label: 'filter', insertText: 'filter((${1:x}) => ${2:true})', doc: 'Filters array.' }, + { label: 'push', insertText: 'push(${1:item})', doc: 'Appends element.' }, + { label: 'slice', insertText: 'slice(${1:start}, ${2:end})', doc: 'Returns section of array.' }, + { label: 'length', insertText: 'length', doc: 'Gets length.' }, + { label: 'get', insertText: 'get(${1:key})', doc: 'Gets Map element.' }, + { label: 'set', insertText: 'set(${1:key}, ${2:val})', doc: 'Sets Map element.' }, + ]; + if (objName === 'console') { + methods = [ + { label: 'log', insertText: 'log(${1:msg});', doc: 'Outputs message.' }, + { label: 'error', insertText: 'error(${1:err});', doc: 'Outputs error.' }, + { label: 'warn', insertText: 'warn(${1:msg});', doc: 'Outputs warning.' }, + ]; + } + return { - suggestions: [ - { - label: 'log', - kind: monaco.languages.CompletionItemKind.Method, - insertText: 'log(${1:message});', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Outputs a message to the debugging console.', - range, - }, - { - label: 'error', - kind: monaco.languages.CompletionItemKind.Method, - insertText: 'error(${1:err});', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Outputs an error message to the console.', - range, - }, - { - label: 'warn', - kind: monaco.languages.CompletionItemKind.Method, - insertText: 'warn(${1:msg});', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Outputs a warning message to the console.', - range, - }, - ], + suggestions: methods.map(m => ({ + label: m.label, + kind: monaco.languages.CompletionItemKind.Method, + insertText: m.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: m.doc, + range, + })), }; } - const suggestions = [ - { - label: 'console.log', - kind: monaco.languages.CompletionItemKind.Snippet, - insertText: 'console.log(${1:val});', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Print debug message to console.', - range, - }, - { - label: 'map', - kind: monaco.languages.CompletionItemKind.Method, - insertText: 'map((${1:item}) => ${2:item})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Array map transformation function.', - range, - }, - { - label: 'filter', - kind: monaco.languages.CompletionItemKind.Method, - insertText: 'filter((${1:item}) => ${2:true})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Array filter function.', - range, - }, - ]; - - return { suggestions }; + const symbols = extractDocumentSymbols(code); + return { + suggestions: [ + { label: 'console.log', kind: monaco.languages.CompletionItemKind.Snippet, insertText: 'console.log(${1:val});', range }, + ...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })), + ], + }; }, }); }); - // Completion items for Rust + // ── RUST COMPLETER ────────────────────────────────────────── monaco.languages.registerCompletionItemProvider('rust', { triggerCharacters: ['.', ':'], provideCompletionItems: (model, position) => { + const lineUntilPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + const word = model.getWordUntilPosition(position); const range = { startLineNumber: position.lineNumber, @@ -183,38 +475,43 @@ export function registerMonacoCompletions(monaco: Monaco) { endColumn: word.endColumn, }; - const suggestions = [ - { - label: 'println!', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'println!("${1:{}}", ${2:val});', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Prints formatted text to stdout.', - range, - }, - { - label: 'format!', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'format!("${1:{}}", ${2:val})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Constructs a formatted String.', - range, - }, - { - label: 'Vec::push', - kind: monaco.languages.CompletionItemKind.Method, - insertText: 'push(${1:val});', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Appends element to back of vector.', - range, - }, - ]; + const code = model.getValue(); + const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*|\bVec|\bHashMap)(\.|\:\:)\s*$/); - return { suggestions }; + if (dotMatch) { + let methods = [ + { label: 'push', insertText: 'push(${1:val});', doc: 'Appends element.' }, + { label: 'insert', insertText: 'insert(${1:key}, ${2:val});', doc: 'Inserts key-value pair.' }, + { label: 'get', insertText: 'get(&${1:key})', doc: 'Returns reference to value.' }, + { label: 'len', insertText: 'len()', doc: 'Returns length.' }, + { label: 'is_empty', insertText: 'is_empty()', doc: 'Checks if empty.' }, + { label: 'iter', insertText: 'iter()', doc: 'Returns iterator.' }, + ]; + + return { + suggestions: methods.map(m => ({ + label: m.label, + kind: monaco.languages.CompletionItemKind.Method, + insertText: m.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: m.doc, + range, + })), + }; + } + + const symbols = extractDocumentSymbols(code); + return { + suggestions: [ + { label: 'println!', kind: monaco.languages.CompletionItemKind.Snippet, insertText: 'println!("${1:{}}", ${2:val});', range }, + { label: 'Vec::new', kind: monaco.languages.CompletionItemKind.Function, insertText: 'Vec::new()', range }, + ...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })), + ], + }; }, }); - // Completion items for Go + // ── GO COMPLETER ──────────────────────────────────────────── monaco.languages.registerCompletionItemProvider('go', { triggerCharacters: ['.'], provideCompletionItems: (model, position) => { @@ -233,49 +530,113 @@ export function registerMonacoCompletions(monaco: Monaco) { endColumn: word.endColumn, }; - if (lineUntilPosition.endsWith('fmt.')) { + const code = model.getValue(); + const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*)\.\s*$/); + + if (dotMatch) { + let methods = [ + { label: 'Println', insertText: 'Println(${1:v})', doc: 'Writes formatted line.' }, + { label: 'Printf', insertText: 'Printf("${1:%v}\\n", ${2:v})', doc: 'Writes formatted string.' }, + { label: 'Split', insertText: 'Split(${1:s}, "${2:sep}")', doc: 'Splits string.' }, + ]; + return { - suggestions: [ - { - label: 'Println', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'Println(${1:v})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Formats using default formats and writes to standard output.', - range, - }, - { - label: 'Printf', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'Printf("${1:%v}\\n", ${2:v})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Formats according to specifier and writes to standard output.', - range, - }, - ], + suggestions: methods.map(m => ({ + label: m.label, + kind: monaco.languages.CompletionItemKind.Method, + insertText: m.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: m.doc, + range, + })), }; } + const symbols = extractDocumentSymbols(code); return { suggestions: [ - { - label: 'fmt.Println', - kind: monaco.languages.CompletionItemKind.Snippet, - insertText: 'fmt.Println(${1:v})', + { label: 'fmt.Println', kind: monaco.languages.CompletionItemKind.Snippet, insertText: 'fmt.Println(${1:v})', range }, + { label: 'make', kind: monaco.languages.CompletionItemKind.Function, insertText: 'make(${1:type}, ${2:len})', range }, + ...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })), + ], + }; + }, + }); + + // ── LUA COMPLETER ─────────────────────────────────────────── + monaco.languages.registerCompletionItemProvider('lua', { + triggerCharacters: ['.'], + provideCompletionItems: (model, position) => { + const lineUntilPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + const code = model.getValue(); + const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*)\.\s*$/); + + if (dotMatch) { + let methods = [ + { label: 'insert', insertText: 'insert(${1:t}, ${2:val})', doc: 'Inserts element.' }, + { label: 'remove', insertText: 'remove(${1:t}, ${2:pos})', doc: 'Removes element.' }, + { label: 'sub', insertText: 'sub(${1:s}, ${2:i}, ${3:j})', doc: 'Returns substring.' }, + ]; + + return { + suggestions: methods.map(m => ({ + label: m.label, + kind: monaco.languages.CompletionItemKind.Method, + insertText: m.insertText, insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Print line to stdout.', + documentation: m.doc, range, - }, - { - label: 'make', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'make(${1:type}, ${2:len})', - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: 'Allocates slice, map, or channel.', - range, - }, + })), + }; + } + + const symbols = extractDocumentSymbols(code); + return { + suggestions: [ + { label: 'print', kind: monaco.languages.CompletionItemKind.Function, insertText: 'print(${1:value})', range }, + ...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })), + ], + }; + }, + }); + + // ── HTML COMPLETER ────────────────────────────────────────── + monaco.languages.registerCompletionItemProvider('html', { + triggerCharacters: ['<'], + provideCompletionItems: (model, position) => { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + return { + suggestions: [ + { label: 'div', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '
${1}
', range }, + { label: 'span', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '${1}', range }, + { label: 'p', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '

${1}

', range }, + { label: 'h1', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '

${1}

', range }, + { label: 'button', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '', range }, ], }; }, }); } + + diff --git a/src/core/config.py b/src/core/config.py index 60eded0..b4709f7 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -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 diff --git a/src/core/generator.py b/src/core/generator.py index c9e19fd..5101dc5 100644 --- a/src/core/generator.py +++ b/src/core/generator.py @@ -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"): diff --git a/src/core/handbook.py b/src/core/handbook.py index 603bcd5..f9ba34a 100644 --- a/src/core/handbook.py +++ b/src/core/handbook.py @@ -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", diff --git a/src/core/mentor.py b/src/core/mentor.py index 0edbf4a..c16a6f9 100644 --- a/src/core/mentor.py +++ b/src/core/mentor.py @@ -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 { diff --git a/src/tui/app.py b/src/tui/app.py index adff775..d522a2a 100644 --- a/src/tui/app.py +++ b/src/tui/app.py @@ -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) diff --git a/src/tui/gen_app.py b/src/tui/gen_app.py index b8a6a96..ba500d7 100644 --- a/src/tui/gen_app.py +++ b/src/tui/gen_app.py @@ -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") diff --git a/src/tui/widgets.py b/src/tui/widgets.py index a303568..64dde51 100644 --- a/src/tui/widgets.py +++ b/src/tui/widgets.py @@ -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", "map", "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)