Compare commits

...
2 Commits
113 changed files with 4035 additions and 366 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
.PHONY: tui gentui tui-debug tui-test web install stop .PHONY: tui gentui tui-debug tui-test web install stop
install: 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" devbox run -- bash -c "cd frontend && npm install"
stop: stop:
+1
View File
@@ -1,4 +1,5 @@
{ {
"enable_boilerplate": false,
"llm": { "llm": {
"base_url": "http://100.82.205.18:1010/", "base_url": "http://100.82.205.18:1010/",
"model": "local-model", "model": "local-model",
+17
View File
@@ -0,0 +1,17 @@
{
"enable_boilerplate": false,
"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
}
}
+29 -6
View File
@@ -43,19 +43,38 @@ export interface HandbookCatalog {
subjects: HandbookTopic[]; subjects: HandbookTopic[];
} }
export const getConfig = async (): Promise<{ enable_boilerplate: boolean }> => {
try {
const response = await apiClient.get('/config');
return response.data;
} catch (e) {
return { enable_boilerplate: false };
}
};
export const getChallenges = async (): Promise<Challenge[]> => { export const getChallenges = async (): Promise<Challenge[]> => {
const response = await apiClient.get('/challenges'); const response = await apiClient.get('/challenges');
return response.data.challenges; return response.data.challenges;
}; };
export const runCode = async (language: string, code: string, stdin: string = '') => { export const runCode = async (
const response = await apiClient.post('/run', { language, code, stdin }); language: string,
code: string,
stdin: string = '',
files?: Record<string, string>,
activeFile?: string
) => {
const response = await apiClient.post('/run', { language, code, stdin, files, active_file: activeFile });
return response.data; return response.data;
}; };
export const lintCode = async (
export const lintCode = async (language: string, code: string) => { language: string,
const response = await apiClient.post('/lint', { language, code }); code: string,
files?: Record<string, string>,
activeFile?: string
) => {
const response = await apiClient.post('/lint', { language, code, files, active_file: activeFile });
return response.data; return response.data;
}; };
@@ -63,13 +82,17 @@ export const getGuidance = async (
challengeId: string, challengeId: string,
language: string, language: string,
code: string, code: string,
question: string = '' question: string = '',
files?: Record<string, string>,
activeFile?: string
) => { ) => {
const response = await apiClient.post('/guide', { const response = await apiClient.post('/guide', {
challenge_id: challengeId, challenge_id: challengeId,
language, language,
code, code,
question, question,
files,
active_file: activeFile,
}); });
return response.data; return response.data;
}; };
+536 -34
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Editor } from '@monaco-editor/react'; import { Editor } from '@monaco-editor/react';
import { registerMonacoCompletions } from '../utils/monacoCompletions'; import { registerMonacoCompletions, getMonacoLanguage, getLanguageFileName } from '../utils/monacoCompletions';
import { import {
getChallenges, getChallenges,
runCode, runCode,
@@ -8,6 +8,7 @@ import {
getGuidance, getGuidance,
getHandbookCatalog, getHandbookCatalog,
getHandbookExample, getHandbookExample,
getConfig,
Challenge, Challenge,
HandbookTopic, HandbookTopic,
HandbookCatalog, HandbookCatalog,
@@ -32,18 +33,128 @@ import {
Terminal, Terminal,
GripVertical, GripVertical,
GripHorizontal, GripHorizontal,
Folder,
FolderPlus,
Plus,
Edit2,
Trash2,
FileCode,
} from 'lucide-react'; } from 'lucide-react';
interface WorkspaceProps { interface WorkspaceProps {
challengeId: string; challengeId?: string;
onBack: () => void; onBack: () => void;
} }
function getLanguageFromFilename(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';
const map: Record<string, string> = {
cpp: 'cpp',
hpp: 'cpp',
h: 'cpp',
cxx: 'cpp',
py: 'python',
java: 'java',
cs: 'csharp',
rs: 'rust',
go: 'go',
js: 'javascript',
ts: 'typescript',
html: 'html',
lua: 'lua',
json: 'json',
md: 'markdown',
};
return map[ext] || 'python';
}
function getDefaultFilesForLanguage(language?: string, enableBoilerplate: boolean = false): Record<string, string> {
const norm = (language || 'python').toLowerCase().trim();
if (!enableBoilerplate) {
if (norm === 'cpp' || norm === 'c++' || norm === 'cxx') {
return { 'main.cpp': '' };
} else if (norm === 'java') {
return { 'Main.java': '' };
} else if (norm === 'csharp' || norm === 'c#') {
return { 'Program.cs': '' };
} else if (norm === 'rust') {
return { 'main.rs': '' };
} else if (norm === 'go') {
return { 'main.go': '' };
} else if (norm === 'javascript' || norm === 'js') {
return { 'main.js': '' };
} else if (norm === 'typescript' || norm === 'ts') {
return { 'main.ts': '' };
} else if (norm === 'html') {
return { 'index.html': '' };
} else if (norm === 'lua') {
return { 'main.lua': '' };
}
return { 'main.py': '' };
}
if (norm === 'cpp' || norm === 'c++' || norm === 'cxx') {
return {
'main.cpp': '// Main implementation\n#include "main.h"\n#include <iostream>\n\nint main() {\n std::cout << "Hello from C++!" << std::endl;\n return 0;\n}\n',
'main.h': '// Main header file\n#ifndef MAIN_H\n#define MAIN_H\n\n// Declarations\n\n#endif\n',
};
} else if (norm === 'java') {
return {
'Main.java': 'public class Main {\n public static void main(String[] args) {\n System.out.println("Hello from Java!");\n }\n}\n',
};
} else if (norm === 'csharp' || norm === 'c#') {
return {
'Program.cs': 'using System;\n\nclass Program {\n static void Main() {\n Console.WriteLine("Hello from C#!");\n }\n}\n',
};
} else if (norm === 'rust') {
return {
'main.rs': 'fn main() {\n println!("Hello from Rust!");\n}\n',
};
} else if (norm === 'go') {
return {
'main.go': 'package main\nimport "fmt"\n\nfunc main() {\n fmt.Println("Hello from Go!")\n}\n',
};
} else if (norm === 'javascript' || norm === 'js') {
return {
'main.js': 'console.log("Hello from JavaScript!");\n',
};
} else if (norm === 'typescript' || norm === 'ts') {
return {
'main.ts': 'console.log("Hello from TypeScript!");\n',
};
} else if (norm === 'html') {
return {
'index.html': '<!DOCTYPE html>\n<html>\n<head>\n <title>App</title>\n</head>\n<body>\n <h1>Hello World</h1>\n</body>\n</html>\n',
};
} else if (norm === 'lua') {
return {
'main.lua': 'print("Hello from Lua!");\n',
};
}
return {
'main.py': '# Main script\nprint("Hello from Python!")\n',
};
}
const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => { const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
const [allChallenges, setAllChallenges] = useState<Challenge[]>([]); const [allChallenges, setAllChallenges] = useState<Challenge[]>([]);
const [currentChallenge, setCurrentChallenge] = useState<Challenge | null>(null); const [currentChallenge, setCurrentChallenge] = useState<Challenge | null>(null);
const [enableBoilerplate, setEnableBoilerplate] = useState(false);
// Multi-file & Folder state
const [files, setFiles] = useState<Record<string, string>>({ 'main.py': '' });
const [folders, setFolders] = useState<string[]>([]);
const [activeFile, setActiveFile] = useState<string>('main.py');
const [openTabs, setOpenTabs] = useState<string[]>(['main.py']);
// Modals for File & Folder Operations
const [isNewFileModalOpen, setIsNewFileModalOpen] = useState(false);
const [newFileNameInput, setNewFileNameInput] = useState('');
const [isNewFolderModalOpen, setIsNewFolderModalOpen] = useState(false);
const [newFolderNameInput, setNewFolderNameInput] = useState('');
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
const [renameFileNameInput, setRenameFileNameInput] = useState('');
const [code, setCode] = useState('');
const [stdinText, setStdinText] = useState(''); const [stdinText, setStdinText] = useState('');
// Resizable Layout Dimensions // Resizable Layout Dimensions
@@ -59,8 +170,8 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
text: 'Execution and lint output will appear here...', text: 'Execution and lint output will appear here...',
}); });
// Right sidebar state: 'mentor' | 'handbook' | 'closed' // Right sidebar state: 'mentor' | 'handbook' | 'files' | 'closed'
const [rightSidebarMode, setRightSidebarMode] = useState<'mentor' | 'handbook' | 'closed'>('mentor'); const [rightSidebarMode, setRightSidebarMode] = useState<'mentor' | 'handbook' | 'files' | 'closed'>('mentor');
// Mentor state // Mentor state
const [guidance, setGuidance] = useState<string>('Ask the mentor a question or click Check Answer below...'); const [guidance, setGuidance] = useState<string>('Ask the mentor a question or click Check Answer below...');
@@ -80,6 +191,15 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [isLinting, setIsLinting] = useState(false); const [isLinting, setIsLinting] = useState(false);
// Load backend config on mount
useEffect(() => {
getConfig()
.then((cfg) => {
setEnableBoilerplate(cfg.enable_boilerplate);
})
.catch(() => {});
}, []);
// Load all challenges // Load all challenges
useEffect(() => { useEffect(() => {
getChallenges() getChallenges()
@@ -95,7 +215,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
.catch((err) => console.error('Failed to load challenges:', err)); .catch((err) => console.error('Failed to load challenges:', err));
}, [challengeId]); }, [challengeId]);
// Fetch handbook catalog when current challenge language changes // Load handbook catalog when current challenge language changes
useEffect(() => { useEffect(() => {
if (currentChallenge) { if (currentChallenge) {
getHandbookCatalog(currentChallenge.language) getHandbookCatalog(currentChallenge.language)
@@ -105,8 +225,45 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
setHandbookExample(''); setHandbookExample('');
}) })
.catch((err) => console.error('Failed to load handbook catalog:', err)); .catch((err) => console.error('Failed to load handbook catalog:', err));
const defaultFiles = getDefaultFilesForLanguage(currentChallenge.language, enableBoilerplate);
setFiles(defaultFiles);
const keys = Object.keys(defaultFiles);
const first = keys[0] || 'main.py';
setActiveFile(first);
setOpenTabs(keys);
} }
}, [currentChallenge]); }, [currentChallenge, enableBoilerplate]);
const handleCreateFolder = (folderName: string) => {
const name = folderName.trim();
if (!name) return;
if (!folders.includes(name)) {
setFolders((prev) => [...prev, name]);
}
setIsNewFolderModalOpen(false);
setNewFolderNameInput('');
};
// Global keyboard shortcuts (Ctrl+1..9 for tabs, Ctrl+T for new file)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 't') {
e.preventDefault();
setNewFileNameInput('');
setIsNewFileModalOpen(true);
}
if ((e.ctrlKey || e.metaKey) && e.key >= '1' && e.key <= '9') {
const idx = parseInt(e.key, 10) - 1;
if (idx < openTabs.length) {
e.preventDefault();
setActiveFile(openTabs[idx]);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [openTabs]);
// Global mousemove and mouseup listeners for drag-to-resize splitters // Global mousemove and mouseup listeners for drag-to-resize splitters
useEffect(() => { useEffect(() => {
@@ -177,14 +334,76 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
} }
}; };
// Multi-file tab & file operation handlers
const handleCreateFile = (filename: string) => {
const name = filename.trim();
if (!name) return;
if (files[name] !== undefined) {
if (!openTabs.includes(name)) {
setOpenTabs((prev) => [...prev, name]);
}
setActiveFile(name);
} else {
setFiles((prev) => ({ ...prev, [name]: '' }));
setOpenTabs((prev) => [...prev, name]);
setActiveFile(name);
}
setIsNewFileModalOpen(false);
setNewFileNameInput('');
};
const handleCloseTab = (tabName: string, e?: React.MouseEvent) => {
if (e) {
e.stopPropagation();
}
if (openTabs.length <= 1) return;
const nextTabs = openTabs.filter((t) => t !== tabName);
setOpenTabs(nextTabs);
if (activeFile === tabName) {
setActiveFile(nextTabs[nextTabs.length - 1]);
}
};
const handleRenameActiveFile = (newName: string) => {
const name = newName.trim();
if (!name || name === activeFile) {
setIsRenameModalOpen(false);
return;
}
const content = files[activeFile] || '';
const newFiles = { ...files };
delete newFiles[activeFile];
newFiles[name] = content;
setFiles(newFiles);
setOpenTabs((prev) => prev.map((t) => (t === activeFile ? name : t)));
setActiveFile(name);
setIsRenameModalOpen(false);
setRenameFileNameInput('');
};
const handleDeleteFile = (fileName: string) => {
if (Object.keys(files).length <= 1) return;
const newFiles = { ...files };
delete newFiles[fileName];
setFiles(newFiles);
const nextTabs = openTabs.filter((t) => t !== fileName);
setOpenTabs(nextTabs.length > 0 ? nextTabs : [Object.keys(newFiles)[0]]);
if (activeFile === fileName) {
setActiveFile(Object.keys(newFiles)[0]);
}
};
const handleRunCode = async (overrideStdin?: string) => { const handleRunCode = async (overrideStdin?: string) => {
if (!code.trim() || !currentChallenge) return; const currentCode = files[activeFile] || '';
if (!currentCode.trim() || !currentChallenge) return;
setIsRunning(true); setIsRunning(true);
setOutput({ status: 'idle', text: 'Executing code...' }); setOutput({ status: 'idle', text: 'Executing code...' });
const inputToSend = overrideStdin !== undefined ? overrideStdin : stdinText; const inputToSend = overrideStdin !== undefined ? overrideStdin : stdinText;
try { try {
const res = await runCode(currentChallenge.language.toLowerCase() || 'python', code, inputToSend); const lang = getLanguageFromFilename(activeFile);
const res = await runCode(lang, currentCode, inputToSend, files, activeFile);
if (res.exit_code === 0) { if (res.exit_code === 0) {
setOutput({ setOutput({
status: 'success', status: 'success',
@@ -206,11 +425,13 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
}; };
const handleLintCode = async () => { const handleLintCode = async () => {
if (!code.trim() || !currentChallenge) return; const currentCode = files[activeFile] || '';
if (!currentCode.trim() || !currentChallenge) return;
setIsLinting(true); setIsLinting(true);
setOutput({ status: 'idle', text: 'Running syntax & style lint...' }); setOutput({ status: 'idle', text: 'Running syntax & style lint...' });
try { try {
const res = await lintCode(currentChallenge.language.toLowerCase() || 'python', code); const lang = getLanguageFromFilename(activeFile);
const res = await lintCode(lang, currentCode, files, activeFile);
if (res.exit_code === 0) { if (res.exit_code === 0) {
setOutput({ status: 'success', text: '✓ Syntax & Style clean! No linting errors detected.' }); setOutput({ status: 'success', text: '✓ Syntax & Style clean! No linting errors detected.' });
} else { } else {
@@ -235,11 +456,14 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
setUserQuestion(''); setUserQuestion('');
try { try {
const lang = getLanguageFromFilename(activeFile);
const res = await getGuidance( const res = await getGuidance(
cid, cid,
currentChallenge.language.toLowerCase() || 'python', lang,
code, files[activeFile] || '',
q q,
files,
activeFile
); );
const qHeader = q ? `### Question / Evaluation:\n> ${q}\n\n---\n\n` : ''; const qHeader = q ? `### Question / Evaluation:\n> ${q}\n\n---\n\n` : '';
setGuidance(`${qHeader}${res.mentor_response || 'No guidance received.'}`); setGuidance(`${qHeader}${res.mentor_response || 'No guidance received.'}`);
@@ -343,7 +567,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => handleRunCode()} onClick={() => handleRunCode()}
disabled={isRunning || !code.trim()} disabled={isRunning || !(files[activeFile] || '').trim()}
className="px-3.5 py-1.5 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm" className="px-3.5 py-1.5 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm"
> >
{isRunning ? ( {isRunning ? (
@@ -356,7 +580,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
<button <button
onClick={handleLintCode} onClick={handleLintCode}
disabled={isLinting || !code.trim()} disabled={isLinting || !(files[activeFile] || '').trim()}
className="px-3.5 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm" className="px-3.5 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm"
> >
{isLinting ? ( {isLinting ? (
@@ -369,14 +593,27 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
<button <button
onClick={handleCheckAnswer} onClick={handleCheckAnswer}
disabled={isLoadingGuidance || !code.trim()} disabled={isLoadingGuidance || !(files[activeFile] || '').trim()}
className="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm" className="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm"
> >
<Sparkles size={14} /> <Sparkles size={14} />
<span>Check Answer</span> <span>Check Answer</span>
</button> </button>
{/* Dual Sidebar Toggles */} {/* Sidebar Toggles: File Browser (Folder icon), Mentor, Syntax Handbook */}
<button
onClick={() => setRightSidebarMode(rightSidebarMode === 'files' ? 'closed' : 'files')}
className={`px-3.5 py-1.5 font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm border ${
rightSidebarMode === 'files'
? 'bg-blue-500/20 text-blue-300 border-blue-500/40'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700 border-gray-700'
}`}
title="Toggle File Tree Sidebar"
>
<Folder size={14} />
<span>Files</span>
</button>
<button <button
onClick={() => setRightSidebarMode(rightSidebarMode === 'mentor' ? 'closed' : 'mentor')} onClick={() => setRightSidebarMode(rightSidebarMode === 'mentor' ? 'closed' : 'mentor')}
className={`px-3.5 py-1.5 font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm border ${ className={`px-3.5 py-1.5 font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm border ${
@@ -405,7 +642,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
{/* Main Container */} {/* Main Container */}
<div className="flex flex-1 overflow-hidden relative"> <div className="flex flex-1 overflow-hidden relative">
{/* Full-Screen Drag Overlay to bypass Monaco Editor event capture */} {/* Full-Screen Drag Overlay */}
{draggingType && ( {draggingType && (
<div <div
onMouseMove={(e) => { onMouseMove={(e) => {
@@ -434,7 +671,8 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
}`} }`}
/> />
)} )}
{/* Left Column: Challenge Specifications (Scalable Width) */}
{/* Left Column: Challenge Specifications */}
<div <div
style={{ width: `${leftWidth}px` }} style={{ width: `${leftWidth}px` }}
className="border-r border-transparent bg-darkBg flex flex-col overflow-hidden shrink-0" className="border-r border-transparent bg-darkBg flex flex-col overflow-hidden shrink-0"
@@ -507,7 +745,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
</div> </div>
</div> </div>
{/* Resizable Splitter Handle: Left Column */} {/* Splitter Handle: Left */}
<div <div
onMouseDown={startDraggingLeft} onMouseDown={startDraggingLeft}
className="w-2.5 -mx-1 z-10 bg-transparent hover:bg-blue-500/50 active:bg-blue-600 cursor-col-resize flex items-center justify-center shrink-0 group transition-colors select-none" className="w-2.5 -mx-1 z-10 bg-transparent hover:bg-blue-500/50 active:bg-blue-600 cursor-col-resize flex items-center justify-center shrink-0 group transition-colors select-none"
@@ -516,27 +754,76 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
<GripVertical size={12} className="text-gray-600/40 group-hover:text-white" /> <GripVertical size={12} className="text-gray-600/40 group-hover:text-white" />
</div> </div>
{/* Middle Column: Monaco Code Editor & Resizable Output Console */} {/* Middle Column: Multi-File Tabs, 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="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"> {/* Multi-File Tab Bar */}
<span className="font-mono text-blue-400">main.py</span> <div className="bg-darkBg text-gray-400 px-2 py-1 text-xs flex items-center border-b border-darkBorder shrink-0 overflow-x-auto gap-1 select-none">
<span className="text-[11px] text-gray-500">TactiTerm IDE</span> {openTabs.map((tab, idx) => (
<div
key={tab}
onClick={() => setActiveFile(tab)}
onAuxClick={(e) => {
if (e.button === 1) handleCloseTab(tab, e);
}}
onDoubleClick={() => {
if (tab === activeFile) {
setRenameFileNameInput(tab);
setIsRenameModalOpen(true);
}
}}
className={`px-3 py-1 rounded-t-md text-xs font-mono flex items-center gap-2 cursor-pointer border-t border-x transition-colors ${
tab === activeFile
? 'bg-[#1e1e1e] text-blue-400 border-darkBorder font-semibold'
: 'bg-darkBg text-gray-400 border-transparent hover:bg-darkSurface hover:text-gray-200'
}`}
title="Double-click to rename | Middle-click to close"
>
<FileCode size={13} className={tab === activeFile ? 'text-blue-400' : 'text-gray-500'} />
<span>{tab}</span>
<span className="text-[10px] text-gray-500 opacity-60 font-sans">^{idx + 1}</span>
{openTabs.length > 1 && (
<button
onClick={(e) => handleCloseTab(tab, e)}
className="hover:text-red-400 p-0.5 rounded transition-colors ml-1"
>
<X size={12} />
</button>
)}
</div>
))}
<button
onClick={() => {
setNewFileNameInput('');
setIsNewFileModalOpen(true);
}}
className="p-1 hover:bg-gray-800 text-gray-400 hover:text-white rounded transition-colors ml-1"
title="New File Tab (Ctrl+T)"
>
<Plus size={14} />
</button>
<div className="ml-auto text-[11px] text-gray-500 font-mono pr-2">
{getLanguageFromFilename(activeFile)}
</div>
</div> </div>
{/* Code Editor */} {/* Code Editor */}
<div className="flex-1 min-h-0 relative"> <div className="flex-1 min-h-0 relative">
<Editor <Editor
height="100%" height="100%"
defaultLanguage={currentChallenge?.language.toLowerCase() || 'python'} defaultLanguage={getLanguageFromFilename(activeFile)}
language={currentChallenge?.language.toLowerCase() || 'python'} language={getLanguageFromFilename(activeFile)}
defaultValue="" defaultValue=""
value={code} value={files[activeFile] || ''}
theme="vs-dark" theme="vs-dark"
onMount={(editor, monaco) => { onMount={(editor, monaco) => {
editorRef.current = editor; editorRef.current = editor;
registerMonacoCompletions(monaco); registerMonacoCompletions(monaco);
}} }}
onChange={(val) => setCode(val || '')} onChange={(val) =>
setFiles((prev) => ({ ...prev, [activeFile]: val || '' }))
}
options={{ options={{
minimap: { enabled: false }, minimap: { enabled: false },
fontSize: 13, fontSize: 13,
@@ -611,7 +898,111 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
</div> </div>
)} )}
{/* Right Column Mode 1: Mentor Sidebar (Scalable Width) */} {/* Right Column Mode 1: File Browser Sidebar */}
{rightSidebarMode === 'files' && (
<div
style={{ width: `${rightWidth}px` }}
className="border-l border-transparent bg-darkSurface flex flex-col overflow-hidden shrink-0"
>
<div className="p-3 border-b border-darkBorder bg-darkBg flex justify-between items-center">
<h2 className="flex items-center gap-2 font-bold text-blue-400 text-xs uppercase tracking-wider">
<Folder size={16} /> File Browser
</h2>
<div className="flex items-center gap-1">
<button
onClick={() => {
setNewFolderNameInput('');
setIsNewFolderModalOpen(true);
}}
className="p-1 hover:bg-gray-800 text-gray-300 hover:text-white rounded"
title="Create Folder"
>
<FolderPlus size={16} />
</button>
<button
onClick={() => {
setNewFileNameInput('');
setIsNewFileModalOpen(true);
}}
className="p-1 hover:bg-gray-800 text-gray-300 hover:text-white rounded"
title="Create File"
>
<Plus size={16} />
</button>
<button
onClick={() => setRightSidebarMode('closed')}
className="text-gray-400 hover:text-white text-xs p-1 rounded hover:bg-gray-800"
>
<X size={16} />
</button>
</div>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-1 bg-darkBg">
{folders.map((folderName) => (
<div
key={`folder-${folderName}`}
className="flex items-center justify-between p-2 rounded-lg text-xs font-mono bg-darkSurface border border-darkBorder/50 text-amber-300 select-none"
>
<div className="flex items-center gap-2 overflow-hidden">
<Folder size={14} className="text-amber-400" />
<span className="truncate">{folderName}/</span>
</div>
</div>
))}
{Object.keys(files).map((fileName) => (
<div
key={fileName}
onClick={() => {
if (!openTabs.includes(fileName)) {
setOpenTabs((prev) => [...prev, fileName]);
}
setActiveFile(fileName);
}}
className={`flex items-center justify-between p-2 rounded-lg text-xs font-mono transition-all cursor-pointer border ${
fileName === activeFile
? 'bg-blue-600/20 border-blue-500/50 text-blue-300 font-bold'
: 'bg-darkSurface border-darkBorder/50 text-gray-300 hover:bg-gray-800'
}`}
>
<div className="flex items-center gap-2 overflow-hidden">
<FileCode size={14} className={fileName === activeFile ? 'text-blue-400' : 'text-gray-500'} />
<span className="truncate">{fileName}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
setRenameFileNameInput(fileName);
setActiveFile(fileName);
setIsRenameModalOpen(true);
}}
className="p-1 text-gray-400 hover:text-white"
title="Rename File"
>
<Edit2 size={12} />
</button>
{Object.keys(files).length > 1 && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFile(fileName);
}}
className="p-1 text-gray-400 hover:text-red-400"
title="Delete File"
>
<Trash2 size={12} />
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Right Column Mode 2: Mentor Sidebar (Scalable Width) */}
{rightSidebarMode === 'mentor' && ( {rightSidebarMode === 'mentor' && (
<div <div
style={{ width: `${rightWidth}px` }} style={{ width: `${rightWidth}px` }}
@@ -673,7 +1064,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
<button <button
onClick={handleCheckAnswer} onClick={handleCheckAnswer}
disabled={isLoadingGuidance || !code.trim()} disabled={isLoadingGuidance || !(files[activeFile] || '').trim()}
className="py-2 px-3 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center justify-center gap-1" className="py-2 px-3 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center justify-center gap-1"
title="Check Answer" title="Check Answer"
> >
@@ -689,7 +1080,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
</div> </div>
)} )}
{/* Right Column Mode 2: Handbook Sidebar (Scalable Width) */} {/* Right Column Mode 3: Handbook Sidebar (Scalable Width) */}
{rightSidebarMode === 'handbook' && ( {rightSidebarMode === 'handbook' && (
<div <div
style={{ width: `${rightWidth}px` }} style={{ width: `${rightWidth}px` }}
@@ -746,7 +1137,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
</div> </div>
</div> </div>
{/* Topic Catalog List (Fills full height when no topic selected) */} {/* Topic Catalog List */}
<div className={`p-3 bg-darkBg flex flex-col gap-1.5 overflow-y-auto ${ <div className={`p-3 bg-darkBg flex flex-col gap-1.5 overflow-y-auto ${
selectedTopic ? 'max-h-52 border-b border-darkBorder' : 'flex-1' selectedTopic ? 'max-h-52 border-b border-darkBorder' : 'flex-1'
}`}> }`}>
@@ -788,7 +1179,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
)} )}
</div> </div>
{/* Generated LLM Reference Card View (ONLY renders after selecting a topic) */} {/* Generated LLM Reference Card View */}
{selectedTopic && ( {selectedTopic && (
<div className="flex-1 p-4 overflow-y-auto bg-darkSurface border-t border-darkBorder"> <div className="flex-1 p-4 overflow-y-auto bg-darkSurface border-t border-darkBorder">
<div className="p-4 bg-darkBg border border-darkBorder rounded-xl shadow-inner"> <div className="p-4 bg-darkBg border border-darkBorder rounded-xl shadow-inner">
@@ -808,6 +1199,117 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
</div> </div>
)} )}
</div> </div>
{/* New File Modal */}
{isNewFileModalOpen && (
<div className="fixed inset-0 bg-black/60 z-[999999] flex items-center justify-center p-4">
<div className="bg-darkSurface border border-darkBorder rounded-xl p-5 w-full max-w-sm shadow-xl flex flex-col gap-4">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Plus size={16} className="text-blue-400" /> Create New File
</h3>
<input
type="text"
autoFocus
value={newFileNameInput}
onChange={(e) => setNewFileNameInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFile(newFileNameInput);
if (e.key === 'Escape') setIsNewFileModalOpen(false);
}}
placeholder="e.g. helper.cpp, utils.h, data.json"
className="w-full bg-darkBg border border-darkBorder rounded-lg px-3 py-2 text-xs text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 font-mono"
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setIsNewFileModalOpen(false)}
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white rounded-lg hover:bg-gray-800"
>
Cancel
</button>
<button
onClick={() => handleCreateFile(newFileNameInput)}
className="px-4 py-1.5 text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white rounded-lg"
>
Create File
</button>
</div>
</div>
</div>
)}
{/* New Folder Modal */}
{isNewFolderModalOpen && (
<div className="fixed inset-0 bg-black/60 z-[999999] flex items-center justify-center p-4">
<div className="bg-darkSurface border border-darkBorder rounded-xl p-5 w-full max-w-sm shadow-xl flex flex-col gap-4">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<FolderPlus size={16} className="text-amber-400" /> Create New Folder
</h3>
<input
type="text"
autoFocus
value={newFolderNameInput}
onChange={(e) => setNewFolderNameInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder(newFolderNameInput);
if (e.key === 'Escape') setIsNewFolderModalOpen(false);
}}
placeholder="e.g. src, include, components"
className="w-full bg-darkBg border border-darkBorder rounded-lg px-3 py-2 text-xs text-white placeholder-gray-500 focus:outline-none focus:border-amber-500 font-mono"
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setIsNewFolderModalOpen(false)}
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white rounded-lg hover:bg-gray-800"
>
Cancel
</button>
<button
onClick={() => handleCreateFolder(newFolderNameInput)}
className="px-4 py-1.5 text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white rounded-lg"
>
Create Folder
</button>
</div>
</div>
</div>
)}
{/* Rename File Modal */}
{isRenameModalOpen && (
<div className="fixed inset-0 bg-black/60 z-[999999] flex items-center justify-center p-4">
<div className="bg-darkSurface border border-darkBorder rounded-xl p-5 w-full max-w-sm shadow-xl flex flex-col gap-4">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Edit2 size={16} className="text-amber-400" /> Rename File
</h3>
<input
type="text"
autoFocus
value={renameFileNameInput}
onChange={(e) => setRenameFileNameInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameActiveFile(renameFileNameInput);
if (e.key === 'Escape') setIsRenameModalOpen(false);
}}
placeholder="Enter new filename..."
className="w-full bg-darkBg border border-darkBorder rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500 font-mono"
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setIsRenameModalOpen(false)}
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white rounded-lg hover:bg-gray-800"
>
Cancel
</button>
<button
onClick={() => handleRenameActiveFile(renameFileNameInput)}
className="px-4 py-1.5 text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white rounded-lg"
>
Rename
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };
+536 -175
View File
@@ -1,12 +1,191 @@
import type { Monaco } from '@monaco-editor/react'; 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; let registered = false;
export function registerMonacoCompletions(monaco: Monaco) { export function registerMonacoCompletions(monaco: Monaco) {
if (registered) return; if (registered) return;
registered = true; 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', { monaco.languages.registerCompletionItemProvider('python', {
triggerCharacters: ['.'], triggerCharacters: ['.'],
provideCompletionItems: (model, position) => { provideCompletionItems: (model, position) => {
@@ -25,70 +204,194 @@ export function registerMonacoCompletions(monaco: Monaco) {
endColumn: word.endColumn, 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 = [ const suggestions = [
{ { label: 'print', kind: monaco.languages.CompletionItemKind.Function, insertText: 'print(${1:value})', range },
label: 'print', { label: 'len', kind: monaco.languages.CompletionItemKind.Function, insertText: 'len(${1:obj})', range },
kind: monaco.languages.CompletionItemKind.Function, { label: 'range', kind: monaco.languages.CompletionItemKind.Function, insertText: 'range(${1:stop})', range },
insertText: 'print(${1:value})', { label: 'enumerate', kind: monaco.languages.CompletionItemKind.Function, insertText: 'enumerate(${1:iterable})', range },
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, ...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })),
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,
},
]; ];
return { suggestions }; 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) => { ['javascript', 'typescript'].forEach((lang) => {
monaco.languages.registerCompletionItemProvider(lang, { monaco.languages.registerCompletionItemProvider(lang, {
triggerCharacters: ['.'], triggerCharacters: ['.'],
@@ -108,73 +411,62 @@ export function registerMonacoCompletions(monaco: Monaco) {
endColumn: word.endColumn, 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 { return {
suggestions: [ suggestions: methods.map(m => ({
{ label: m.label,
label: 'log', kind: monaco.languages.CompletionItemKind.Method,
kind: monaco.languages.CompletionItemKind.Method, insertText: m.insertText,
insertText: 'log(${1:message});', insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, documentation: m.doc,
documentation: 'Outputs a message to the debugging console.', range,
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 = [ const symbols = extractDocumentSymbols(code);
{ return {
label: 'console.log', suggestions: [
kind: monaco.languages.CompletionItemKind.Snippet, { label: 'console.log', kind: monaco.languages.CompletionItemKind.Snippet, insertText: 'console.log(${1:val});', range },
insertText: 'console.log(${1:val});', ...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })),
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 };
}, },
}); });
}); });
// Completion items for Rust // ── RUST COMPLETER ──────────────────────────────────────────
monaco.languages.registerCompletionItemProvider('rust', { monaco.languages.registerCompletionItemProvider('rust', {
triggerCharacters: ['.', ':'], triggerCharacters: ['.', ':'],
provideCompletionItems: (model, position) => { provideCompletionItems: (model, position) => {
const lineUntilPosition = model.getValueInRange({
startLineNumber: position.lineNumber,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});
const word = model.getWordUntilPosition(position); const word = model.getWordUntilPosition(position);
const range = { const range = {
startLineNumber: position.lineNumber, startLineNumber: position.lineNumber,
@@ -183,38 +475,43 @@ export function registerMonacoCompletions(monaco: Monaco) {
endColumn: word.endColumn, endColumn: word.endColumn,
}; };
const suggestions = [ const code = model.getValue();
{ const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*|\bVec|\bHashMap)(\.|\:\:)\s*$/);
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,
},
];
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', { monaco.languages.registerCompletionItemProvider('go', {
triggerCharacters: ['.'], triggerCharacters: ['.'],
provideCompletionItems: (model, position) => { provideCompletionItems: (model, position) => {
@@ -233,49 +530,113 @@ export function registerMonacoCompletions(monaco: Monaco) {
endColumn: word.endColumn, 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 { return {
suggestions: [ suggestions: methods.map(m => ({
{ label: m.label,
label: 'Println', kind: monaco.languages.CompletionItemKind.Method,
kind: monaco.languages.CompletionItemKind.Function, insertText: m.insertText,
insertText: 'Println(${1:v})', insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, documentation: m.doc,
documentation: 'Formats using default formats and writes to standard output.', range,
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 { return {
suggestions: [ suggestions: [
{ { label: 'fmt.Println', kind: monaco.languages.CompletionItemKind.Snippet, insertText: 'fmt.Println(${1:v})', range },
label: 'fmt.Println', { label: 'make', kind: monaco.languages.CompletionItemKind.Function, insertText: 'make(${1:type}, ${2:len})', range },
kind: monaco.languages.CompletionItemKind.Snippet, ...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })),
insertText: 'fmt.Println(${1:v})', ],
};
},
});
// ── 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, insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: 'Print line to stdout.', documentation: m.doc,
range, range,
}, })),
{ };
label: 'make', }
kind: monaco.languages.CompletionItemKind.Function,
insertText: 'make(${1:type}, ${2:len})', const symbols = extractDocumentSymbols(code);
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, return {
documentation: 'Allocates slice, map, or channel.', suggestions: [
range, { 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 },
], ],
}; };
}, },
}); });
} }
+61
View File
@@ -0,0 +1,61 @@
"""Convert themes from convert-themes/registry.json into src/tui/themes/*.json"""
import json
import re
from pathlib import Path
REGISTRY_PATH = Path("convert-themes/registry.json")
OUTPUT_DIR = Path("src/tui/themes")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
if not REGISTRY_PATH.exists():
print(f"Error: {REGISTRY_PATH} not found.")
exit(1)
with open(REGISTRY_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
themes_list = data.get("themes", [])
converted_count = 0
for item in themes_list:
name = item.get("name")
if not name:
continue
dark = item.get("dark") or item.get("light") or {}
if not dark:
continue
theme_id = re.sub(r'[^a-z0-9\-]', '', name.lower().replace(' ', '-'))
if not theme_id:
continue
background = dark.get("mSurfaceVariant") or dark.get("mSurface") or "#1e1e1e"
surface = dark.get("mSurface") or "#252526"
primary = dark.get("mPrimary") or "#3b82f6"
primary_text = dark.get("mOnPrimary") or "#ffffff"
secondary = dark.get("mSecondary") or "#64748b"
accent = dark.get("mTertiary") or dark.get("mHover") or primary
folder_header = accent
folder_text = dark.get("mOnTertiary") or dark.get("mOnHover") or "#ffffff"
theme_dict = {
"id": theme_id,
"name": name,
"background": background,
"surface": surface,
"primary": primary,
"primary_text": primary_text,
"secondary": secondary,
"accent": accent,
"folder_header": folder_header,
"folder_text": folder_text
}
out_file = OUTPUT_DIR / f"{theme_id}.json"
with open(out_file, "w", encoding="utf-8") as f_out:
json.dump(theme_dict, f_out, indent=2)
converted_count += 1
print(f"Successfully converted {converted_count} themes to {OUTPUT_DIR}/!")
+27
View File
@@ -0,0 +1,27 @@
import asyncio
from src.tui.app import TactiTermTUI
async def test_command_palette_integration():
app = TactiTermTUI()
async with app.run_test() as pilot:
print("Available themes count registered with Textual App:", len(app.available_themes))
sample_themes = list(app.available_themes.keys())[:15]
print("Sample registered themes in App:", sample_themes)
# Test system commands
commands = list(app.get_system_commands(app.screen))
cmd_titles = [c.title for c in commands]
print("System commands in Command Palette:", cmd_titles)
assert "Keys" in cmd_titles, "Keys option missing from Command Palette"
assert "Themes" in cmd_titles, "Themes option missing from Command Palette"
# Test applying a theme from registered themes
app.apply_theme("dracula")
await pilot.pause(0.1)
print("Main box classes after applying Dracula:", app.query_one("#main-container").classes)
assert app.query_one("#main-container").has_class("theme-dracula")
print("Command Palette Theme Integration test PASSED!")
if __name__ == "__main__":
asyncio.run(test_command_palette_integration())
+45
View File
@@ -0,0 +1,45 @@
"""Integration test for multi-file code execution, linting, and prompt context formatting."""
import unittest
from src.core.executor import CodeExecutor
from src.core.linter import CodeLinter
from src.core.prompts import format_multifile_context, compress_file_content
class TestMultiFile(unittest.TestCase):
def setUp(self):
self.executor = CodeExecutor()
self.linter = CodeLinter()
def test_cpp_multifile_execution(self):
files = {
"main.cpp": '#include "main.h"\n#include <iostream>\nint main() { printMessage(); return 0; }\n',
"main.h": '#ifndef MAIN_H\n#define MAIN_H\n#include <iostream>\ninline void printMessage() { std::cout << "C++ Multi-File Success!"; }\n#endif\n'
}
res = self.executor.run("cpp", code="", stdin="", files=files)
self.assertEqual(res.get("exit_code"), 0)
self.assertIn("C++ Multi-File Success!", res.get("stdout"))
def test_python_multifile_execution(self):
files = {
"main.py": 'import helper\nprint(helper.get_msg())\n',
"helper.py": 'def get_msg(): return "Python Multi-File Success!"\n'
}
res = self.executor.run("python", code="", stdin="", files=files)
self.assertEqual(res.get("exit_code"), 0)
self.assertIn("Python Multi-File Success!", res.get("stdout"))
def test_multifile_context_formatting(self):
files = {
"main.cpp": '#include "main.h"\nint main() { return 0; }',
"main.h": '#define MSG "Hello"'
}
ctx = format_multifile_context(files, active_file="main.cpp")
self.assertIn("=== File: main.cpp (Active) ===", ctx)
self.assertIn("=== File: main.h ===", ctx)
def test_compression(self):
large_code = "def foo():\n pass\n" * 100
compressed = compress_file_content("test.py", large_code)
self.assertIn("def foo()", compressed)
if __name__ == "__main__":
unittest.main()
+17
View File
@@ -0,0 +1,17 @@
from textual.theme import Theme
from src.tui.theme_manager import theme_manager
print("Loaded theme manager count:", len(theme_manager.themes))
t_sample = list(theme_manager.themes.values())[0]
print("Sample theme data:", t_sample)
t_obj = Theme(
name=t_sample["id"],
primary=t_sample["primary"],
secondary=t_sample.get("secondary"),
accent=t_sample.get("accent"),
background=t_sample["background"],
surface=t_sample["surface"],
dark=True
)
print("Created Textual Theme object successfully:", t_obj.name)
+20
View File
@@ -0,0 +1,20 @@
import asyncio
from src.tui.app import TactiTermTUI
from textual.widgets import OptionList
async def test_theme_popup():
app = TactiTermTUI()
async with app.run_test() as pilot:
print("Call action_cycle_theme()...")
app.action_cycle_theme()
await pilot.pause(0.2)
main_box = app.query_one("#main-container")
print("Is show-theme-menu in classes:", main_box.has_class("show-theme-menu"))
theme_list = app.query_one("#theme_list_popup", OptionList)
print("Theme list option count:", theme_list.option_count)
print("Focused widget ID:", getattr(app.focused, "id", None))
if __name__ == "__main__":
asyncio.run(test_theme_popup())
+29 -9
View File
@@ -6,6 +6,7 @@ from src.core.loader import ChallengeLoader
from src.core.executor import executor from src.core.executor import executor
from src.core.linter import linter from src.core.linter import linter
from src.core.prompts import get_socratic_prompt from src.core.prompts import get_socratic_prompt
from src.core.config import config
app = FastAPI(title="Socratic Tutor API") app = FastAPI(title="Socratic Tutor API")
@@ -20,25 +21,40 @@ app.add_middleware(
loader = ChallengeLoader() loader = ChallengeLoader()
@app.get("/config")
def get_app_config():
return {
"enable_boilerplate": config.enable_boilerplate,
"llm_base_url": config.llm_base_url,
"llm_model": config.llm_model,
}
from typing import Optional, Dict
class LintRequest(BaseModel): class LintRequest(BaseModel):
language: str language: str
code: str code: Optional[str] = ""
files: Optional[Dict[str, str]] = None
active_file: Optional[str] = None
from typing import Optional
class GuideRequest(BaseModel): class GuideRequest(BaseModel):
challenge_id: str challenge_id: str
language: str language: str
code: str code: Optional[str] = ""
question: Optional[str] = "" question: Optional[str] = ""
files: Optional[Dict[str, str]] = None
active_file: Optional[str] = None
class RunRequest(BaseModel): class RunRequest(BaseModel):
language: str language: str
code: str code: Optional[str] = ""
stdin: Optional[str] = "" stdin: Optional[str] = ""
files: Optional[Dict[str, str]] = None
active_file: Optional[str] = None
@app.get("/challenges") @app.get("/challenges")
@@ -60,7 +76,7 @@ def get_challenge(challenge_id: str):
@app.post("/lint") @app.post("/lint")
def lint_code(req: LintRequest): def lint_code(req: LintRequest):
"""Runs linting on user code using Core Linter.""" """Runs linting on user code using Core Linter."""
res = linter.lint(req.language, req.code) res = linter.lint(req.language, req.code or "", files=req.files, active_file=req.active_file)
if res.get("stderr") == f"Language '{req.language}' is not supported.": if res.get("stderr") == f"Language '{req.language}' is not supported.":
raise HTTPException(status_code=404, detail=res["stderr"]) raise HTTPException(status_code=404, detail=res["stderr"])
return res return res
@@ -69,7 +85,7 @@ def lint_code(req: LintRequest):
@app.post("/run") @app.post("/run")
def run_code(req: RunRequest): def run_code(req: RunRequest):
"""Executes user code using Core Executor.""" """Executes user code using Core Executor."""
res = executor.run(req.language, req.code, stdin=req.stdin or "") res = executor.run(req.language, req.code or "", stdin=req.stdin or "", files=req.files)
if res.get("stderr") == f"Language '{req.language}' is not supported.": if res.get("stderr") == f"Language '{req.language}' is not supported.":
raise HTTPException(status_code=404, detail=res["stderr"]) raise HTTPException(status_code=404, detail=res["stderr"])
return res return res
@@ -100,7 +116,11 @@ async def guide_code(req: GuideRequest):
challenge = challenges[req.challenge_id] challenge = challenges[req.challenge_id]
guidance = await mentor.get_guidance_async( guidance = await mentor.get_guidance_async(
challenge, req.code, user_question=req.question or "" challenge,
user_code=req.code or "",
user_question=req.question or "",
files=req.files,
active_file=req.active_file,
) )
return { return {
+24 -3
View File
@@ -8,13 +8,14 @@ class Config:
"""Manages application configuration settings.""" """Manages application configuration settings."""
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"enable_boilerplate": False,
"llm": { "llm": {
"base_url": "http://localhost:8080/v1", "base_url": "http://localhost:8080/v1",
"model": "local-model", "model": "local-model",
"api_key": "not-needed", "api_key": "not-needed",
"temperature": 0.7, "temperature": 0.7,
"max_tokens": 512, "max_tokens": 4096,
"timeout_seconds": 5.0, "timeout_seconds": 60.0,
}, },
"web": { "web": {
"host": "127.0.0.1", "host": "127.0.0.1",
@@ -34,6 +35,8 @@ class Config:
try: try:
with open(self.config_path, "r", encoding="utf-8") as f: with open(self.config_path, "r", encoding="utf-8") as f:
user_config = json.load(f) user_config = json.load(f)
if "enable_boilerplate" in user_config:
config["enable_boilerplate"] = bool(user_config["enable_boilerplate"])
if "llm" in user_config and isinstance(user_config["llm"], dict): if "llm" in user_config and isinstance(user_config["llm"], dict):
config["llm"].update(user_config["llm"]) config["llm"].update(user_config["llm"])
if "web" in user_config and isinstance(user_config["web"], dict): if "web" in user_config and isinstance(user_config["web"], dict):
@@ -50,8 +53,11 @@ class Config:
except Exception as e: except Exception as e:
print(f"Warning: Failed to create default {self.config_path}: {e}") print(f"Warning: Failed to create default {self.config_path}: {e}")
# Allow environment variable overrides # Allow environment variable overrides
env_boilerplate = os.getenv("TACTTERM_ENABLE_BOILERPLATE")
if env_boilerplate is not None:
config["enable_boilerplate"] = env_boilerplate.lower() in ("true", "1", "yes")
env_base_url = os.getenv("TACTTERM_LLM_BASE_URL") env_base_url = os.getenv("TACTTERM_LLM_BASE_URL")
if env_base_url: if env_base_url:
config["llm"]["base_url"] = env_base_url config["llm"]["base_url"] = env_base_url
@@ -60,6 +66,17 @@ class Config:
if env_model: if env_model:
config["llm"]["model"] = 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") env_web_host = os.getenv("TACTTERM_WEB_HOST")
if env_web_host: if env_web_host:
config["web"]["host"] = env_web_host config["web"]["host"] = env_web_host
@@ -77,6 +94,10 @@ class Config:
return config return config
@property
def enable_boilerplate(self) -> bool:
return bool(self._data.get("enable_boilerplate", False))
@property @property
def llm_base_url(self) -> str: def llm_base_url(self) -> str:
return self._data["llm"]["base_url"].rstrip("/") return self._data["llm"]["base_url"].rstrip("/")
+67 -18
View File
@@ -8,9 +8,15 @@ from src.core.registry import registry
class CodeExecutor: class CodeExecutor:
"""Executes code for all 10 supported programming languages with stdin support.""" """Executes code for all 10 supported programming languages with stdin support and multi-file projects."""
def run(self, language: str, code: str, stdin: str = "") -> Dict[str, Any]: def run(
self,
language: str,
code: str = "",
stdin: str = "",
files: Dict[str, str] = None,
) -> Dict[str, Any]:
config = registry.get_config(language) config = registry.get_config(language)
if not config: if not config:
return { return {
@@ -24,20 +30,42 @@ class CodeExecutor:
ext = config.get("ext", ".txt") ext = config.get("ext", ".txt")
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
if lang_key == "java": # 1. Write multi-file project contents or fallback single file
temp_file_name = "Main.java" if files and isinstance(files, dict) and len(files) > 0:
elif lang_key == "cpp": for rel_path, content in files.items():
temp_file_name = "main.cpp" target_path = os.path.join(tmpdir, rel_path)
elif lang_key == "rust": os.makedirs(os.path.dirname(target_path), exist_ok=True)
temp_file_name = "main.rs" with open(target_path, "w", encoding="utf-8") as f:
elif lang_key == "csharp": f.write(content)
temp_file_name = "Program.cs" # Determine main entry file
main_file_candidates = [
"main.cpp", "Main.java", "main.rs", "Program.cs", "main.py",
"main.go", "main.js", "main.ts", "main.lua", "index.html"
]
temp_file_name = None
for candidate in main_file_candidates:
if candidate in files:
temp_file_name = candidate
break
if not temp_file_name:
temp_file_name = list(files.keys())[0]
else: else:
temp_file_name = f"main{ext}" if lang_key == "java":
temp_file_name = "Main.java"
elif lang_key == "cpp":
temp_file_name = "main.cpp"
elif lang_key == "rust":
temp_file_name = "main.rs"
elif lang_key == "csharp":
temp_file_name = "Program.cs"
else:
temp_file_name = f"main{ext}"
temp_file_path = os.path.join(tmpdir, temp_file_name)
with open(temp_file_path, "w", encoding="utf-8") as f:
f.write(code)
temp_file_path = os.path.join(tmpdir, temp_file_name) temp_file_path = os.path.join(tmpdir, temp_file_name)
with open(temp_file_path, "w", encoding="utf-8") as f:
f.write(code)
try: try:
if lang_key == "python": if lang_key == "python":
@@ -51,10 +79,13 @@ class CodeExecutor:
timeout=15, timeout=15,
) )
elif lang_key == "html": elif lang_key == "html":
primary_code = code
if files and temp_file_name in files:
primary_code = files[temp_file_name]
return { return {
"language": language, "language": language,
"exit_code": 0, "exit_code": 0,
"stdout": code, "stdout": primary_code,
"stderr": "", "stderr": "",
} }
elif lang_key == "javascript": elif lang_key == "javascript":
@@ -77,7 +108,7 @@ class CodeExecutor:
) )
elif lang_key == "go": elif lang_key == "go":
result = subprocess.run( result = subprocess.run(
["go", "run", temp_file_path], ["go", "run", "."],
input=stdin, input=stdin,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -94,8 +125,17 @@ class CodeExecutor:
timeout=15, timeout=15,
) )
elif lang_key == "cpp": elif lang_key == "cpp":
# Collect all .cpp files recursively in tmpdir
cpp_files = []
for root, _, fnames in os.walk(tmpdir):
for fn in fnames:
if fn.endswith(".cpp"):
cpp_files.append(os.path.relpath(os.path.join(root, fn), tmpdir))
if not cpp_files:
cpp_files = [temp_file_name]
compile_res = subprocess.run( compile_res = subprocess.run(
["g++", "-O2", temp_file_path, "-o", "main"], ["g++", "-O2"] + cpp_files + ["-o", "main"],
capture_output=True, capture_output=True,
text=True, text=True,
cwd=tmpdir, cwd=tmpdir,
@@ -140,8 +180,16 @@ class CodeExecutor:
timeout=15, timeout=15,
) )
elif lang_key == "java": elif lang_key == "java":
java_files = []
for root, _, fnames in os.walk(tmpdir):
for fn in fnames:
if fn.endswith(".java"):
java_files.append(os.path.relpath(os.path.join(root, fn), tmpdir))
if not java_files:
java_files = [temp_file_name]
compile_res = subprocess.run( compile_res = subprocess.run(
["javac", temp_file_path], ["javac"] + java_files,
capture_output=True, capture_output=True,
text=True, text=True,
cwd=tmpdir, cwd=tmpdir,
@@ -154,8 +202,9 @@ class CodeExecutor:
"stdout": compile_res.stdout, "stdout": compile_res.stdout,
"stderr": f"Javac Compilation Error:\n{compile_res.stderr}", "stderr": f"Javac Compilation Error:\n{compile_res.stderr}",
} }
main_class = os.path.splitext(temp_file_name)[0]
result = subprocess.run( result = subprocess.run(
["java", "Main"], ["java", main_class],
input=stdin, input=stdin,
capture_output=True, capture_output=True,
text=True, text=True,
+5 -6
View File
@@ -78,12 +78,11 @@ class ChallengeGenerator:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
raw_md = ( msg_obj = data.get("choices", [{}])[0].get("message", {})
data.get("choices", [{}])[0] raw_md = msg_obj.get("content") or ""
.get("message", {}) if not raw_md.strip() and msg_obj.get("reasoning_content"):
.get("content", "") raw_md = msg_obj.get("reasoning_content", "")
.strip() raw_md = raw_md.strip()
)
# Clean up outer markdown wrapper if present # Clean up outer markdown wrapper if present
if raw_md.startswith("```markdown"): if raw_md.startswith("```markdown"):
+6 -7
View File
@@ -353,7 +353,7 @@ class HandbookService:
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},
], ],
"temperature": 0.2, "temperature": 0.2,
"max_tokens": 900, "max_tokens": max(config.llm_max_tokens, 4096),
} }
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
@@ -366,12 +366,11 @@ class HandbookService:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
example_md = ( msg_obj = data.get("choices", [{}])[0].get("message", {})
data.get("choices", [{}])[0] example_md = msg_obj.get("content") or ""
.get("message", {}) if not example_md.strip() and msg_obj.get("reasoning_content"):
.get("content", "") example_md = msg_obj.get("reasoning_content", "")
.strip() example_md = example_md.strip()
)
return { return {
"status": "success", "status": "success",
+28 -12
View File
@@ -10,7 +10,13 @@ from src.core.registry import registry
class CodeLinter: class CodeLinter:
"""Lints user code for syntax and style issues for 10 languages.""" """Lints user code for syntax and style issues for 10 languages."""
def lint(self, language: str, code: str) -> Dict[str, Any]: def lint(
self,
language: str,
code: str = "",
files: Dict[str, str] = None,
active_file: str = None,
) -> Dict[str, Any]:
config = registry.get_config(language) config = registry.get_config(language)
if not config: if not config:
return { return {
@@ -24,20 +30,30 @@ class CodeLinter:
ext = config.get("ext", ".txt") ext = config.get("ext", ".txt")
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
if lang_key == "java": if files and isinstance(files, dict) and len(files) > 0:
temp_file_name = "Main.java" for rel_path, content in files.items():
elif lang_key == "cpp": target_path = os.path.join(tmpdir, rel_path)
temp_file_name = "main.cpp" os.makedirs(os.path.dirname(target_path), exist_ok=True)
elif lang_key == "rust": with open(target_path, "w", encoding="utf-8") as f:
temp_file_name = "main.rs" f.write(content)
elif lang_key == "csharp": temp_file_name = active_file if active_file and active_file in files else list(files.keys())[0]
temp_file_name = "Program.cs"
else: else:
temp_file_name = f"main{ext}" if lang_key == "java":
temp_file_name = "Main.java"
elif lang_key == "cpp":
temp_file_name = "main.cpp"
elif lang_key == "rust":
temp_file_name = "main.rs"
elif lang_key == "csharp":
temp_file_name = "Program.cs"
else:
temp_file_name = f"main{ext}"
temp_path = os.path.join(tmpdir, temp_file_name)
with open(temp_path, "w", encoding="utf-8") as f:
f.write(code)
temp_path = os.path.join(tmpdir, temp_file_name) temp_path = os.path.join(tmpdir, temp_file_name)
with open(temp_path, "w", encoding="utf-8") as f:
f.write(code)
try: try:
if lang_key == "python": if lang_key == "python":
+29 -12
View File
@@ -21,10 +21,17 @@ class MentorClient:
self.config = config self.config = config
async def get_guidance_async( async def get_guidance_async(
self, challenge: object, user_code: str, user_question: str = "" self,
challenge: object,
user_code: str = "",
user_question: str = "",
files: Optional[Dict[str, str]] = None,
active_file: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Fetch mentor guidance asynchronously from the configured LLM endpoint.""" """Fetch mentor guidance asynchronously from the configured LLM endpoint."""
prompt = get_mentor_prompt(challenge, user_code, user_question=user_question) prompt = get_mentor_prompt(
challenge, user_code, user_question=user_question, files=files, active_file=active_file
)
endpoint = get_chat_completions_url(self.config.llm_base_url) endpoint = get_chat_completions_url(self.config.llm_base_url)
user_content = ( user_content = (
@@ -53,12 +60,11 @@ class MentorClient:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
content = ( msg_obj = data.get("choices", [{}])[0].get("message", {})
data.get("choices", [{}])[0] content = msg_obj.get("content") or ""
.get("message", {}) if not content.strip() and msg_obj.get("reasoning_content"):
.get("content", "") content = msg_obj.get("reasoning_content", "")
.strip() content = content.strip()
)
if content: if content:
return { return {
@@ -93,7 +99,12 @@ class MentorClient:
} }
def get_guidance( def get_guidance(
self, challenge: object, user_code: str, user_question: str = "" self,
challenge: object,
user_code: str = "",
user_question: str = "",
files: Optional[Dict[str, str]] = None,
active_file: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Synchronous wrapper for fetching mentor guidance.""" """Synchronous wrapper for fetching mentor guidance."""
import asyncio import asyncio
@@ -104,15 +115,21 @@ class MentorClient:
import nest_asyncio import nest_asyncio
nest_asyncio.apply() nest_asyncio.apply()
return loop.run_until_complete( return loop.run_until_complete(
self.get_guidance_async(challenge, user_code, user_question=user_question) self.get_guidance_async(
challenge, user_code, user_question=user_question, files=files, active_file=active_file
)
) )
else: else:
return asyncio.run( return asyncio.run(
self.get_guidance_async(challenge, user_code, user_question=user_question) self.get_guidance_async(
challenge, user_code, user_question=user_question, files=files, active_file=active_file
)
) )
except Exception: except Exception:
return asyncio.run( return asyncio.run(
self.get_guidance_async(challenge, user_code, user_question=user_question) self.get_guidance_async(
challenge, user_code, user_question=user_question, files=files, active_file=active_file
)
) )
+80 -2
View File
@@ -41,7 +41,81 @@ Your ultimate mission is to build the user's confidence in designing and constru
SYSTEM_PROMPT_SOCRATIC = SYSTEM_PROMPT_MENTOR SYSTEM_PROMPT_SOCRATIC = SYSTEM_PROMPT_MENTOR
def get_mentor_prompt(challenge: object, user_code: str, user_question: str = "") -> str: from typing import Dict, Optional
def compress_file_content(filename: str, content: str, max_lines: int = 30) -> str:
"""Compresses file content by extracting signatures and truncating bodies if context limit is reached."""
lines = content.splitlines()
if len(lines) <= max_lines:
return content
headers = []
for line in lines:
stripped = line.strip()
if (
stripped.startswith(("#include", "import ", "from ", "using ", "package "))
or stripped.startswith(("def ", "class ", "struct ", "fn ", "interface ", "enum ", "public ", "private "))
or stripped.endswith(("{", ":"))
):
headers.append(line)
if len(headers) >= 5:
return "\n".join(headers[:max_lines]) + f"\n... [{len(lines) - len(headers)} lines omitted]"
else:
return "\n".join(lines[:max_lines]) + f"\n... [{len(lines) - max_lines} lines omitted]"
def format_multifile_context(
user_code: str | Dict[str, str] = "",
files: Optional[Dict[str, str]] = None,
active_file: Optional[str] = None,
max_chars: int = 12000,
) -> str:
"""Formats single or multi-file project code for the LLM mentor, applying context compression if needed."""
if isinstance(user_code, dict) and files is None:
files = user_code
user_code = ""
if not files or not isinstance(files, dict):
code_str = str(user_code or "")
return code_str if code_str.strip() else "# No code written yet"
active_name = active_file if active_file and active_file in files else list(files.keys())[0]
total_len = sum(len(c) for c in files.values())
should_compress = total_len > max_chars
parts = []
file_list_summary = "Project Structure:\n" + "\n".join(
f"- {fname}{' (Active)' if fname == active_name else ''}" for fname in files.keys()
)
parts.append(file_list_summary)
# 1. Active file first (kept in full)
active_content = files[active_name]
parts.append(f"=== File: {active_name} (Active) ===\n{active_content}")
# 2. Inactive files (compressed if overall context is large)
for fname, content in files.items():
if fname == active_name:
continue
if should_compress and not fname.endswith((".h", ".hpp", ".d.ts")):
compressed = compress_file_content(fname, content)
parts.append(f"=== File: {fname} (Compressed) ===\n{compressed}")
else:
parts.append(f"=== File: {fname} ===\n{content}")
return "\n\n".join(parts)
def get_mentor_prompt(
challenge: object,
user_code: str,
user_question: str = "",
files: Optional[Dict[str, str]] = None,
active_file: Optional[str] = None,
) -> str:
"""Returns the formatted system prompt for the LLM mentor.""" """Returns the formatted system prompt for the LLM mentor."""
reqs = "\n".join(f"- {r}" for r in getattr(challenge, "requirements", [])) reqs = "\n".join(f"- {r}" for r in getattr(challenge, "requirements", []))
hints = "\n".join(f"- {h}" for h in getattr(challenge, "hints", [])) hints = "\n".join(f"- {h}" for h in getattr(challenge, "hints", []))
@@ -52,6 +126,10 @@ def get_mentor_prompt(challenge: object, user_code: str, user_question: str = ""
else "How should I structure my program and overcome my current blocker for this challenge?" else "How should I structure my program and overcome my current blocker for this challenge?"
) )
formatted_code = format_multifile_context(
user_code=user_code, files=files, active_file=active_file
)
return SYSTEM_PROMPT_MENTOR.format( return SYSTEM_PROMPT_MENTOR.format(
challenge_name=getattr(challenge, "name", "Coding Challenge"), challenge_name=getattr(challenge, "name", "Coding Challenge"),
language=getattr(challenge, "language", "Python"), language=getattr(challenge, "language", "Python"),
@@ -59,7 +137,7 @@ def get_mentor_prompt(challenge: object, user_code: str, user_question: str = ""
challenge_description=getattr(challenge, "description", ""), challenge_description=getattr(challenge, "description", ""),
requirements=reqs if reqs else "- Follow standard problem specifications", requirements=reqs if reqs else "- Follow standard problem specifications",
hints=hints if hints else "- Think through edge cases", hints=hints if hints else "- Think through edge cases",
user_code=user_code if user_code.strip() else "# No code written yet", user_code=formatted_code,
user_question=q_text, user_question=q_text,
) )
+906 -60
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -141,7 +141,7 @@ class GenHelpModal(ModalScreen):
}, },
{"role": "user", "content": question}, {"role": "user", "content": question},
], ],
"max_tokens": 1000, "max_tokens": config.llm_max_tokens,
} }
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
@@ -234,7 +234,7 @@ class GenTUIApp(App):
border: solid $secondary; border: solid $secondary;
padding: 1; padding: 1;
background: $surface; background: $surface;
overflow-y: auto; overflow: auto;
} }
#btn-help-modal { #btn-help-modal {
@@ -262,6 +262,7 @@ class GenTUIApp(App):
Binding("ctrl+h", "open_help_modal", "AI Helper"), Binding("ctrl+h", "open_help_modal", "AI Helper"),
Binding("ctrl+g", "generate_ai", "Generate AI"), Binding("ctrl+g", "generate_ai", "Generate AI"),
Binding("ctrl+s", "save_challenge", "Save File"), Binding("ctrl+s", "save_challenge", "Save File"),
Binding("ctrl+w", "toggle_word_wrap", "Word Wrap (Ctrl+W)"),
Binding("ctrl+q", "quit", "Quit"), Binding("ctrl+q", "quit", "Quit"),
] ]
@@ -404,6 +405,14 @@ class GenTUIApp(App):
else: else:
self.query_one("#status-bar", Static).update(f"✗ Save Error: {res.get('message')}") 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__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="GenTUI — TactiTerm AI Challenge Generator") parser = argparse.ArgumentParser(description="GenTUI — TactiTerm AI Challenge Generator")
+154
View File
@@ -0,0 +1,154 @@
"""Dynamic Theme Loader and Manager for TactiTerm TUI.
Discovers and loads JSON theme palette files from src/tui/themes/ and user custom directories.
"""
import json
from pathlib import Path
from typing import Dict, List, Any
THEME_DIR = Path(__file__).parent / "themes"
USER_THEME_DIR = Path.home() / ".config" / "tactiterm" / "themes"
class ThemeManager:
"""Manages discovery, loading, and dynamic CSS generation for TactiTerm theme palettes."""
def __init__(self) -> None:
self.themes: Dict[str, Dict[str, Any]] = {}
self.load_all_themes()
def load_all_themes(self) -> None:
"""Loads all JSON themes from default theme directory and user custom directory."""
self.themes.clear()
# 1. Load built-in themes
if THEME_DIR.exists():
for filepath in THEME_DIR.glob("*.json"):
self._load_theme_file(filepath)
# 2. Load custom user themes from ~/.config/tactiterm/themes/
if USER_THEME_DIR.exists():
for filepath in USER_THEME_DIR.glob("*.json"):
self._load_theme_file(filepath)
def _load_theme_file(self, filepath: Path) -> None:
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
theme_id = data.get("id") or filepath.stem.lower()
theme_name = data.get("name", theme_id.capitalize())
self.themes[theme_id] = {
"id": theme_id,
"name": theme_name,
"background": data.get("background", "#1e1e1e"),
"surface": data.get("surface", "#252526"),
"primary": data.get("primary", "#3b82f6"),
"primary_text": data.get("primary_text", "#ffffff"),
"secondary": data.get("secondary", "#64748b"),
"accent": data.get("accent", "#38bdf8"),
"folder_header": data.get("folder_header", data.get("accent", "#38bdf8")),
"folder_text": data.get("folder_text", "#ffffff"),
}
except Exception as e:
print(f"Warning: Failed to load theme file {filepath}: {e}")
def get_theme_ids(self) -> List[str]:
"""Returns list of all loaded theme IDs."""
return list(self.themes.keys())
def get_all_themes(self) -> List[Dict[str, Any]]:
"""Returns sorted list of all loaded theme dictionaries."""
return sorted(list(self.themes.values()), key=lambda x: x["name"])
def get_theme(self, theme_id: str) -> Dict[str, Any]:
"""Returns theme dict for theme_id or default."""
return self.themes.get(theme_id, self.themes.get("default", {}))
def register_all(self, app: Any) -> None:
"""Registers all loaded JSON themes into Textual app.register_theme."""
from textual.theme import Theme
for t_info in self.themes.values():
try:
theme_obj = Theme(
name=t_info["name"],
primary=t_info["primary"],
secondary=t_info.get("secondary"),
accent=t_info.get("accent"),
background=t_info["background"],
surface=t_info["surface"],
dark=True,
)
app.register_theme(theme_obj)
except Exception:
pass
def generate_css(self) -> str:
"""Generates Textual CSS dynamically for all loaded themes."""
css_blocks = []
for theme_id, theme in self.themes.items():
bg = theme["background"]
surf = theme["surface"]
pri = theme["primary"]
pri_txt = theme["primary_text"]
sec = theme["secondary"]
acc = theme["accent"]
f_hdr = theme["folder_header"]
f_txt = theme["folder_text"]
block = f"""
/* Theme Palette: {theme["name"]} ({theme_id}) */
#main-container.theme-{theme_id} {{
background: {bg};
}}
#main-container.theme-{theme_id} #challenge-header,
#main-container.theme-{theme_id} #mentor-header,
#main-container.theme-{theme_id} #handbook-header,
#main-container.theme-{theme_id} #help-title,
#main-container.theme-{theme_id} #theme-title {{
background: {pri};
color: {pri_txt};
}}
#main-container.theme-{theme_id} #folder-header {{
background: {f_hdr};
color: {f_txt};
}}
#main-container.theme-{theme_id} CodeEditor {{
background: {surf};
color: {pri_txt};
border: solid {acc};
}}
#main-container.theme-{theme_id} CodeEditor:focus {{
border: heavy {pri};
}}
#main-container.theme-{theme_id} #challenge-details-container,
#main-container.theme-{theme_id} #output-scroll-container,
#main-container.theme-{theme_id} #mentor-response-scroll,
#main-container.theme-{theme_id} #handbook-example-scroll {{
background: {surf};
border: solid {sec};
}}
#main-container.theme-{theme_id} OptionList {{
background: {surf};
border: solid {sec};
}}
#main-container.theme-{theme_id} OptionList > .option-list--option-highlighted {{
background: {pri};
color: {pri_txt};
text-style: bold;
}}
#main-container.theme-{theme_id} #status-bar {{
background: {pri};
color: {pri_txt};
}}
#main-container.theme-{theme_id} #theme-modal-popup,
#main-container.theme-{theme_id} #help-modal-popup {{
background: {bg};
border: heavy {pri};
}}
"""
css_blocks.append(block)
return "\n".join(css_blocks)
theme_manager = ThemeManager()
+12
View File
@@ -0,0 +1,12 @@
{
"id": "adw",
"name": "ADW",
"background": "#1e1e1e",
"surface": "#242424",
"primary": "#3584e4",
"primary_text": "#ffffff",
"secondary": "#1b467c",
"accent": "#ffffff",
"folder_header": "#ffffff",
"folder_text": "#2e3436"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "atuel",
"name": "Atuel",
"background": "#0F1B40",
"surface": "#0A1126",
"primary": "#99B6F2",
"primary_text": "#0A1126",
"secondary": "#5581D9",
"accent": "#4E6BA6",
"folder_header": "#4E6BA6",
"folder_text": "#d9e2f7"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "ayu-blue",
"name": "Ayu Blue",
"background": "#1E222A",
"surface": "#0B0E14",
"primary": "#39BAE6",
"primary_text": "#0B0E14",
"secondary": "#AAD94C",
"accent": "#E6B450",
"folder_header": "#E6B450",
"folder_text": "#0B0E14"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "ayu-green",
"name": "Ayu Green",
"background": "#1E222A",
"surface": "#0B0E14",
"primary": "#AAD94C",
"primary_text": "#0B0E14",
"secondary": "#E6B450",
"accent": "#39BAE6",
"folder_header": "#39BAE6",
"folder_text": "#0B0E14"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "ayu-red",
"name": "Ayu Red",
"background": "#1E222A",
"surface": "#0B0E14",
"primary": "#D95757",
"primary_text": "#0B0E14",
"secondary": "#E6B450",
"accent": "#39BAE6",
"folder_header": "#39BAE6",
"folder_text": "#0B0E14"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "breeze",
"name": "Breeze",
"background": "#292d32",
"surface": "#202224",
"primary": "#3daee9",
"primary_text": "#141618",
"secondary": "#1d99f3",
"accent": "#9b59b6",
"folder_header": "#9b59b6",
"folder_text": "#141618"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-frappe-blue",
"name": "Catppuccin Frappe Blue",
"background": "#414559",
"surface": "#303446",
"primary": "#8caaee",
"primary_text": "#303446",
"secondary": "#babbf1",
"accent": "#ca9ee6",
"folder_header": "#ca9ee6",
"folder_text": "#303446"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-frappe-lavender",
"name": "Catppuccin Frappe Lavender",
"background": "#414559",
"surface": "#303446",
"primary": "#babbf1",
"primary_text": "#303446",
"secondary": "#8caaee",
"accent": "#ca9ee6",
"folder_header": "#ca9ee6",
"folder_text": "#303446"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-frappe-mauve",
"name": "Catppuccin Frappe Mauve",
"background": "#414559",
"surface": "#303446",
"primary": "#ca9ee6",
"primary_text": "#303446",
"secondary": "#babbf1",
"accent": "#99d1db",
"folder_header": "#99d1db",
"folder_text": "#303446"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-frappe-pink",
"name": "Catppuccin Frappe Pink",
"background": "#414559",
"surface": "#303446",
"primary": "#f4b8e4",
"primary_text": "#303446",
"secondary": "#ea999c",
"accent": "#99d1db",
"folder_header": "#99d1db",
"folder_text": "#303446"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-frappe-rosewater",
"name": "Catppuccin Frappe Rosewater",
"background": "#414559",
"surface": "#303446",
"primary": "#f2d5cf",
"primary_text": "#303446",
"secondary": "#eebebe",
"accent": "#e5c890",
"folder_header": "#e5c890",
"folder_text": "#303446"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-frappe-sapphirejson",
"name": "Catppuccin Frappe Sapphire.json",
"background": "#414559",
"surface": "#303446",
"primary": "#85c1dc",
"primary_text": "#303446",
"secondary": "#8caaee",
"accent": "#99d1db",
"folder_header": "#99d1db",
"folder_text": "#303446"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "catppuccin-lavender",
"name": "Catppuccin Lavender",
"background": "#313244",
"surface": "#1e1e2e",
"primary": "#b4befe",
"primary_text": "#11111b",
"secondary": "#f5bde6",
"accent": "#c6a0f6",
"folder_header": "#c6a0f6",
"folder_text": "#11111b"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-macchiato-lavender",
"name": "Catppuccin Macchiato Lavender",
"background": "#363a4f",
"surface": "#24273a",
"primary": "#b7bdf8",
"primary_text": "#181926",
"secondary": "#8aadf4",
"accent": "#c6a0f6",
"folder_header": "#c6a0f6",
"folder_text": "#181926"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-macchiato-mauve",
"name": "Catppuccin Macchiato Mauve",
"background": "#363a4f",
"surface": "#24273a",
"primary": "#c6a0f6",
"primary_text": "#181926",
"secondary": "#f5a97f",
"accent": "#8bd5ca",
"folder_header": "#8bd5ca",
"folder_text": "#181926"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-macchiato-pink",
"name": "Catppuccin Macchiato Pink",
"background": "#363a4f",
"surface": "#24273a",
"primary": "#f5bde6",
"primary_text": "#181926",
"secondary": "#f5a97f",
"accent": "#8bd5ca",
"folder_header": "#8bd5ca",
"folder_text": "#181926"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-macchiato-rosewater",
"name": "Catppuccin Macchiato Rosewater",
"background": "#363a4f",
"surface": "#24273a",
"primary": "#f4dbd6",
"primary_text": "#181926",
"secondary": "#f0c6c6",
"accent": "#e5c890",
"folder_header": "#e5c890",
"folder_text": "#181926"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-macchiato-sapphire",
"name": "Catppuccin Macchiato Sapphire",
"background": "#363a4f",
"surface": "#24273a",
"primary": "#7dc4e4",
"primary_text": "#181926",
"secondary": "#8aadf4",
"accent": "#91d7e3",
"folder_header": "#91d7e3",
"folder_text": "#181926"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "catppuccin-mocha-pink",
"name": "Catppuccin Mocha Pink",
"background": "#313244",
"surface": "#1e1e2e",
"primary": "#f5c2e7",
"primary_text": "#11111b",
"secondary": "#fab387",
"accent": "#94e2d5",
"folder_header": "#94e2d5",
"folder_text": "#11111b"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-mocha-rosewater",
"name": "Catppuccin Mocha Rosewater",
"background": "#313244",
"surface": "#1e1e2e",
"primary": "#f5e0dc",
"primary_text": "#11111b",
"secondary": "#f2cdcd",
"accent": "#f9e2af",
"folder_header": "#f9e2af",
"folder_text": "#11111b"
}
@@ -0,0 +1,12 @@
{
"id": "catppuccin-mocha-sapphire",
"name": "Catppuccin Mocha Sapphire",
"background": "#313244",
"surface": "#1e1e2e",
"primary": "#74c7ec",
"primary_text": "#11111b",
"secondary": "#89b4fa",
"accent": "#89dceb",
"folder_header": "#89dceb",
"folder_text": "#11111b"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "catppuccin",
"name": "Catppuccin Mocha",
"background": "#1e1e2e",
"surface": "#313244",
"primary": "#cba6f7",
"primary_text": "#11111b",
"secondary": "#89b4fa",
"accent": "#f5c2e7",
"folder_header": "#f5c2e7",
"folder_text": "#11111b"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "cherry-blossom",
"name": "Cherry Blossom",
"background": "#4D3745",
"surface": "#2A1922",
"primary": "#F2C1D4",
"primary_text": "#2A1B21",
"secondary": "#FFD6E2",
"accent": "#D4A3BD",
"folder_header": "#D4A3BD",
"folder_text": "#2A1B21"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "cream-autumn",
"name": "Cream Autumn",
"background": "#231e1a",
"surface": "#161311",
"primary": "#e5c799",
"primary_text": "#161311",
"secondary": "#cbaba0",
"accent": "#9aa887",
"folder_header": "#9aa887",
"folder_text": "#161311"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "cream",
"name": "Cream",
"background": "#26211e",
"surface": "#161311",
"primary": "#a2b574",
"primary_text": "#161311",
"secondary": "#dfb26c",
"accent": "#e09260",
"folder_header": "#e09260",
"folder_text": "#161311"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "creamy-forest",
"name": "Creamy Forest",
"background": "#252722",
"surface": "#1c1e1a",
"primary": "#a2b08d",
"primary_text": "#131411",
"secondary": "#948e74",
"accent": "#6e857b",
"folder_header": "#6e857b",
"folder_text": "#f4f6eb"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "cyberpunk",
"name": "Cyberpunk",
"background": "#11151D",
"surface": "#0C1017",
"primary": "#C4A82E",
"primary_text": "#0E1015",
"secondary": "#D14358",
"accent": "#00A66C",
"folder_header": "#00A66C",
"folder_text": "#0E1015"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "default",
"name": "TactiTerm Dark (Default)",
"background": "#1e1e1e",
"surface": "#252526",
"primary": "#3b82f6",
"primary_text": "#ffffff",
"secondary": "#64748b",
"accent": "#38bdf8",
"folder_header": "#38bdf8",
"folder_text": "#ffffff"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "doomed",
"name": "Doomed",
"background": "#282c34",
"surface": "#1c1e1e",
"primary": "#51afef",
"primary_text": "#1c1e1e",
"secondary": "#f2c481",
"accent": "#98be65",
"folder_header": "#98be65",
"folder_text": "#1c1e1e"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "dracula",
"name": "Dracula",
"background": "#282a36",
"surface": "#44475a",
"primary": "#bd93f9",
"primary_text": "#282a36",
"secondary": "#6272a4",
"accent": "#8be9fd",
"folder_header": "#ff79c6",
"folder_text": "#282a36"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "espresso-cream",
"name": "Espresso Cream",
"background": "#2c1e13",
"surface": "#1a120b",
"primary": "#e29578",
"primary_text": "#1a120b",
"secondary": "#ddb892",
"accent": "#a3b19b",
"folder_header": "#a3b19b",
"folder_text": "#1a120b"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "everdeer",
"name": "Everdeer",
"background": "#291d1a",
"surface": "#1c110e",
"primary": "#ffb4a3",
"primary_text": "#621000",
"secondary": "#ffb4a3",
"accent": "#e9c258",
"folder_header": "#e9c258",
"folder_text": "#3d2e00"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "everforest-alt",
"name": "Everforest Alt",
"background": "#3d484d",
"surface": "#2d353b",
"primary": "#a7c080",
"primary_text": "#2d353b",
"secondary": "#7fbbb3",
"accent": "#e69875",
"folder_header": "#e69875",
"folder_text": "#2d353b"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "everforest-material",
"name": "Everforest Material",
"background": "#202020",
"surface": "#161616",
"primary": "#A7C080",
"primary_text": "#161616",
"secondary": "#DBBC7F",
"accent": "#7FBBB3",
"folder_header": "#7FBBB3",
"folder_text": "#161616"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "everforest",
"name": "Everforest",
"background": "#2D353B",
"surface": "#232A2E",
"primary": "#A7C080",
"primary_text": "#232A2E",
"secondary": "#D3C6AA",
"accent": "#9DA9A0",
"folder_header": "#9DA9A0",
"folder_text": "#232A2E"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "flexoki",
"name": "Flexoki",
"background": "#1c1b1a",
"surface": "#100f0f",
"primary": "#879a39",
"primary_text": "#1a1e0c",
"secondary": "#4385be",
"accent": "#8b7ec8",
"folder_header": "#8b7ec8",
"folder_text": "#1a1623"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "garnet",
"name": "Garnet",
"background": "#181616",
"surface": "#121212",
"primary": "#990000",
"primary_text": "#f5eeee",
"secondary": "#c8962a",
"accent": "#7a0000",
"folder_header": "#7a0000",
"folder_text": "#f0e8e8"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "github-dark",
"name": "GitHub Dark",
"background": "#161b22",
"surface": "#010409",
"primary": "#58a6ff",
"primary_text": "#010409",
"secondary": "#bc8cff",
"accent": "#bc8cff",
"folder_header": "#bc8cff",
"folder_text": "#010409"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "gruber-darker",
"name": "Gruber Darker",
"background": "#282828",
"surface": "#181818",
"primary": "#ffdd33",
"primary_text": "#181818",
"secondary": "#96a6c8",
"accent": "#9e95c7",
"folder_header": "#9e95c7",
"folder_text": "#101010"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "gruvbox-material",
"name": "Gruvbox Material",
"background": "#3c3836",
"surface": "#32302f",
"primary": "#a9b665",
"primary_text": "#32302f",
"secondary": "#e78a4e",
"accent": "#89b482",
"folder_header": "#89b482",
"folder_text": "#32302f"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "gruvbox",
"name": "Gruvbox Dark",
"background": "#282828",
"surface": "#3c3836",
"primary": "#fabd2f",
"primary_text": "#282828",
"secondary": "#b8bb26",
"accent": "#fe8019",
"folder_header": "#fe8019",
"folder_text": "#282828"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "gruvboxalt",
"name": "GruvboxAlt",
"background": "#3c3836",
"surface": "#282828",
"primary": "#ebdbb2",
"primary_text": "#282828",
"secondary": "#8ec07c",
"accent": "#83a598",
"folder_header": "#83a598",
"folder_text": "#282828"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "hexa34c",
"name": "Hexa34C",
"background": "#101510",
"surface": "#101510",
"primary": "#9ad4a1",
"primary_text": "#003916",
"secondary": "#b7ccb6",
"accent": "#a1ced8",
"folder_header": "#a1ced8",
"folder_text": "#00363e"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "horizon",
"name": "Horizon",
"background": "#232530",
"surface": "#1A1C23",
"primary": "#25B2BC",
"primary_text": "#16161C",
"secondary": "#B877DB",
"accent": "#FAB795",
"folder_header": "#FAB795",
"folder_text": "#16161C"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "jiva-lotus",
"name": "Jiva Lotus",
"background": "#2c2e33",
"surface": "#212226",
"primary": "#debfbf",
"primary_text": "#262626",
"secondary": "#debfbf",
"accent": "#ebcece",
"folder_header": "#ebcece",
"folder_text": "#262626"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "jiva-paper",
"name": "Jiva Paper",
"background": "#2c2e33",
"surface": "#212226",
"primary": "#bfc0de",
"primary_text": "#262626",
"secondary": "#bfc0de",
"accent": "#cecfeb",
"folder_header": "#cecfeb",
"folder_text": "#262626"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "jiva",
"name": "Jiva",
"background": "#3D3B43",
"surface": "#2D2B33",
"primary": "#EDDD8E",
"primary_text": "#303030",
"secondary": "#EDDD8E",
"accent": "#f7e99e",
"folder_header": "#f7e99e",
"folder_text": "#303030"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "kanagawa-dragon",
"name": "Kanagawa Dragon",
"background": "#282727",
"surface": "#181616",
"primary": "#8a9a7b",
"primary_text": "#181616",
"secondary": "#8ea4a2",
"accent": "#c4746e",
"folder_header": "#c4746e",
"folder_text": "#181616"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "kanagawa-kasumi",
"name": "Kanagawa Kasumi",
"background": "#20272E",
"surface": "#1A2026",
"primary": "#D9A78B",
"primary_text": "#1A2026",
"secondary": "#7794A6",
"accent": "#8BA37A",
"folder_header": "#8BA37A",
"folder_text": "#1A2026"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "kanagawa-paper",
"name": "Kanagawa Paper",
"background": "#2A2A37",
"surface": "#1F1F28",
"primary": "#c4b28a",
"primary_text": "#1F1F28",
"secondary": "#8ea49e",
"accent": "#938AA9",
"folder_header": "#938AA9",
"folder_text": "#1F1F28"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "kemuri-koke",
"name": "Kemuri Koke",
"background": "#36312f",
"surface": "#242120",
"primary": "#8ba37e",
"primary_text": "#242120",
"secondary": "#a69680",
"accent": "#cb9168",
"folder_header": "#cb9168",
"folder_text": "#242120"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "kemuri-susu",
"name": "Kemuri Susu",
"background": "#282624",
"surface": "#1e1d1b",
"primary": "#cabaaa",
"primary_text": "#1e1d1b",
"secondary": "#73685F",
"accent": "#594F46",
"folder_header": "#594F46",
"folder_text": "#f0ede6"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "kemuri",
"name": "Kemuri",
"background": "#36312f",
"surface": "#242120",
"primary": "#cb9168",
"primary_text": "#242120",
"secondary": "#a69680",
"accent": "#8da388",
"folder_header": "#8da388",
"folder_text": "#242120"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "lilac-amoled",
"name": "Lilac AMOLED",
"background": "#110d1a",
"surface": "#000000",
"primary": "#b58fff",
"primary_text": "#000000",
"secondary": "#c79aff",
"accent": "#d8b4ff",
"folder_header": "#d8b4ff",
"folder_text": "#000000"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "macaron",
"name": "Macaron",
"background": "#242426",
"surface": "#18181A",
"primary": "#E5B4E2",
"primary_text": "#18181A",
"secondary": "#B4E5E5",
"accent": "#E5D0B4",
"folder_header": "#E5D0B4",
"folder_text": "#18181A"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "matecito",
"name": "Matecito",
"background": "#272e2a",
"surface": "#1e2320",
"primary": "#8da383",
"primary_text": "#1e2320",
"secondary": "#cb9b7c",
"accent": "#7f9193",
"folder_header": "#7f9193",
"folder_text": "#1e2320"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "miasma",
"name": "Miasma",
"background": "#2a2a2a",
"surface": "#222222",
"primary": "#c9a554",
"primary_text": "#222222",
"secondary": "#b36d43",
"accent": "#bb7744",
"folder_header": "#bb7744",
"folder_text": "#222222"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "mine",
"name": "Mine",
"background": "#262427",
"surface": "#1d1b1d",
"primary": "#e1e2d5",
"primary_text": "#1d1b1d",
"secondary": "#2d2a2e",
"accent": "#2d2a2e",
"folder_header": "#2d2a2e",
"folder_text": "#e1e2d5"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "mizuki-akiyama",
"name": "Mizuki-Akiyama",
"background": "#1d1a2a",
"surface": "#10111b",
"primary": "#e6a6c8",
"primary_text": "#2b1422",
"secondary": "#afa2d8",
"accent": "#7fb6d6",
"folder_header": "#7fb6d6",
"folder_text": "#071f2d"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "monochrome",
"name": "Monochrome",
"background": "#191919",
"surface": "#111111",
"primary": "#aaaaaa",
"primary_text": "#111111",
"secondary": "#a7a7a7",
"accent": "#cccccc",
"folder_header": "#cccccc",
"folder_text": "#111111"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "murasaki",
"name": "Murasaki",
"background": "#50066f",
"surface": "#300443",
"primary": "#e8bcfb",
"primary_text": "#8e0cc6",
"secondary": "#8e0cc6",
"accent": "#066f50",
"folder_header": "#066f50",
"folder_text": "#90f9d9"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "murata",
"name": "Murata",
"background": "#252627",
"surface": "#1a1b1c",
"primary": "#db6d6d",
"primary_text": "#1a1b1c",
"secondary": "#d1b394",
"accent": "#8faeb1",
"folder_header": "#8faeb1",
"folder_text": "#1a1b1c"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "naysayer",
"name": "NaySayer",
"background": "#0b3335",
"surface": "#062329",
"primary": "#2ec09c",
"primary_text": "#062329",
"secondary": "#8cde94",
"accent": "#7ad0c6",
"folder_header": "#7ad0c6",
"folder_text": "#d1b897"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "neon-surf",
"name": "Neon Surf",
"background": "#06101A",
"surface": "#000000",
"primary": "#00a8f4",
"primary_text": "#000000",
"secondary": "#22d3ee",
"accent": "#0ea5e9",
"folder_header": "#0ea5e9",
"folder_text": "#000000"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "noctalia-legacy",
"name": "Noctalia legacy",
"background": "#262130",
"surface": "#1c1822",
"primary": "#c7a1d8",
"primary_text": "#1a151f",
"secondary": "#a984c4",
"accent": "#e0b7c9",
"folder_header": "#e0b7c9",
"folder_text": "#20161f"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "nord-aurora",
"name": "Nord Aurora",
"background": "#3b4252",
"surface": "#2e3440",
"primary": "#b48ead",
"primary_text": "#2e3440",
"secondary": "#a3be8c",
"accent": "#ebcb8b",
"folder_header": "#ebcb8b",
"folder_text": "#2e3440"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "nord",
"name": "Nord Arctic",
"background": "#2e3440",
"surface": "#3b4252",
"primary": "#88c0d0",
"primary_text": "#2e3440",
"secondary": "#81a1c1",
"accent": "#ebcb8b",
"folder_header": "#ebcb8b",
"folder_text": "#2e3440"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "oasis-abyss",
"name": "Oasis Abyss",
"background": "#080808",
"surface": "#1A1A1A",
"primary": "#D06666",
"primary_text": "#1A1A1A",
"secondary": "#FFA247",
"accent": "#F0E68C",
"folder_header": "#F0E68C",
"folder_text": "#1A1A1A"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "occult-umbral",
"name": "Occult Umbral",
"background": "#14141E",
"surface": "#0A0A12",
"primary": "#8B2E2E",
"primary_text": "#1C1C28",
"secondary": "#8BAA82",
"accent": "#9A7398",
"folder_header": "#9A7398",
"folder_text": "#0A0A12"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "one-dark-two",
"name": "One Dark Two",
"background": "#282C34",
"surface": "#21252B",
"primary": "#62BAC6",
"primary_text": "#21252B",
"secondary": "#EAC786",
"accent": "#98C379",
"folder_header": "#98C379",
"folder_text": "#21252B"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "one",
"name": "One",
"background": "#282c34",
"surface": "#1e2127",
"primary": "#61afef",
"primary_text": "#1e2127",
"secondary": "#c678dd",
"accent": "#98c379",
"folder_header": "#98c379",
"folder_text": "#1e2127"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "osaka-jade",
"name": "Osaka jade",
"background": "#0F251F",
"surface": "#081512",
"primary": "#1E9177",
"primary_text": "#B8C8C4",
"secondary": "#167A63",
"accent": "#26A589",
"folder_header": "#26A589",
"folder_text": "#B8C8C4"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "oxide",
"name": "Oxide",
"background": "#2E2623",
"surface": "#231D1B",
"primary": "#B85A30",
"primary_text": "#1D1816",
"secondary": "#8B9A5A",
"accent": "#D8A657",
"folder_header": "#D8A657",
"folder_text": "#1D1816"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "oxocarbon",
"name": "Oxocarbon",
"background": "#262626",
"surface": "#161616",
"primary": "#33b1ff",
"primary_text": "#161616",
"secondary": "#42be65",
"accent": "#be95ff",
"folder_header": "#be95ff",
"folder_text": "#161616"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "paradise",
"name": "Paradise",
"background": "#222222",
"surface": "#151515",
"primary": "#8da3b9",
"primary_text": "#151515",
"secondary": "#8aa6a2",
"accent": "#a988b0",
"folder_header": "#a988b0",
"folder_text": "#151515"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "peche",
"name": "Peche",
"background": "#192024",
"surface": "#0e1214",
"primary": "#9DD2C0",
"primary_text": "#0e1214",
"secondary": "#FADDD2",
"accent": "#E8B8A8",
"folder_header": "#E8B8A8",
"folder_text": "#0e1214"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "rose-pine-alt",
"name": "Rose Pine Alt",
"background": "#26233a",
"surface": "#191724",
"primary": "#c4a7e7",
"primary_text": "#191724",
"secondary": "#f6c177",
"accent": "#9ccfd8",
"folder_header": "#9ccfd8",
"folder_text": "#191724"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "rose-pine-moon-alt",
"name": "Rose Pine Moon Alt",
"background": "#393552",
"surface": "#232136",
"primary": "#c4a7e7",
"primary_text": "#232136",
"secondary": "#f6c177",
"accent": "#9ccfd8",
"folder_header": "#9ccfd8",
"folder_text": "#232136"
}

Some files were not shown because too many files have changed in this diff Show More