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
+1 -1
View File
@@ -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:
+16
View File
@@ -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
}
}
+7 -7
View File
@@ -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<WorkspaceProps> = ({ 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<WorkspaceProps> = ({ 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<WorkspaceProps> = ({ 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<WorkspaceProps> = ({ challengeId, onBack }) => {
{/* Middle Column: Monaco Code Editor & Resizable Output Console */}
<div className="flex-1 flex flex-col bg-[#1e1e1e] overflow-hidden min-w-0 min-h-0">
<div className="bg-darkBg text-gray-400 px-4 py-2 text-xs flex justify-between items-center border-b border-darkBorder shrink-0">
<span className="font-mono text-blue-400">main.py</span>
<span className="font-mono text-blue-400">{getLanguageFileName(currentChallenge?.language)}</span>
<span className="text-[11px] text-gray-500">TactiTerm IDE</span>
</div>
@@ -527,8 +527,8 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
<div className="flex-1 min-h-0 relative">
<Editor
height="100%"
defaultLanguage={currentChallenge?.language.toLowerCase() || 'python'}
language={currentChallenge?.language.toLowerCase() || 'python'}
defaultLanguage={getMonacoLanguage(currentChallenge?.language)}
language={getMonacoLanguage(currentChallenge?.language)}
defaultValue=""
value={code}
theme="vs-dark"
+528 -167
View File
@@ -1,12 +1,191 @@
import type { Monaco } from '@monaco-editor/react';
export function getMonacoLanguage(language?: string): string {
if (!language) return 'python';
const norm = language.toLowerCase().trim();
const map: Record<string, string> = {
'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<string, string> = {
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<string>();
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',
suggestions: methods.map(m => ({
label: m.label,
kind: monaco.languages.CompletionItemKind.Method,
insertText: 'log(${1:message});',
insertText: m.insertText,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: 'Outputs a message to the debugging console.',
documentation: m.doc,
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,
},
],
})),
};
}
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*$/);
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 };
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})',
suggestions: methods.map(m => ({
label: m.label,
kind: monaco.languages.CompletionItemKind.Method,
insertText: m.insertText,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: 'Formats using default formats and writes to standard output.',
documentation: m.doc,
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,
},
],
})),
};
}
const symbols = extractDocumentSymbols(code);
return {
suggestions: [
{
label: 'fmt.Println',
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: 'fmt.Println(${1:v})',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: 'Print line to stdout.',
range,
{ 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 })),
],
};
},
{
label: 'make',
kind: monaco.languages.CompletionItemKind.Function,
insertText: 'make(${1:type}, ${2:len})',
});
// ── 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: 'Allocates slice, map, or channel.',
documentation: m.doc,
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: '<div>${1}</div>', range },
{ label: 'span', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '<span>${1}</span>', range },
{ label: 'p', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '<p>${1}</p>', range },
{ label: 'h1', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '<h1>${1}</h1>', range },
{ label: 'button', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '<button>${1:Click}</button>', range },
],
};
},
});
}
+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")
+284 -16
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,23 +390,26 @@ 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:
if is_open:
if event.key in ("down", "up"):
event.prevent_default()
event.stop()
if hasattr(self.app, "show_completion_popup"):
self.app.show_completion_popup(words[-1], matches)
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)