Compare commits
11
Commits
c4f6f012b7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0b6f3e460 | ||
|
|
0559f7549a | ||
|
|
bcb054a3fa | ||
|
|
b669d2e799 | ||
|
|
c4be76bcaa | ||
|
|
ea7501976c | ||
|
|
0b7259a011 | ||
|
|
edfcc872d9 | ||
|
|
e494bcc2cc | ||
|
|
e9c32f9413 | ||
|
|
9edbfd3f77 |
@@ -174,3 +174,7 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
|
||||
# Custom
|
||||
config.json
|
||||
node_modules/
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.PHONY: tui gentui tui-debug tui-test web install stop
|
||||
|
||||
install:
|
||||
uv pip install fastapi uvicorn textual httpx tree-sitter tree-sitter-markdown tree-sitter-python tree-sitter-java tree-sitter-cpp tree-sitter-rust tree-sitter-go tree-sitter-javascript tree-sitter-typescript tree-sitter-c-sharp tree-sitter-html tree-sitter-lua
|
||||
devbox run -- bash -c "cd frontend && npm install"
|
||||
|
||||
stop:
|
||||
@lsof -t -i:8000 | xargs -r kill -9 > /dev/null 2>&1 || true
|
||||
|
||||
tui:
|
||||
@echo "Starting TactiTerm TUI..."
|
||||
@export PYTHONPATH=. && uv run python3 src/tui/app.py
|
||||
|
||||
gentui:
|
||||
@echo "Starting GenTUI Challenge Generator..."
|
||||
@export PYTHONPATH=. && uv run python3 src/tui/gen_app.py
|
||||
|
||||
tui-debug:
|
||||
@echo "Starting TUI in Debug Mode..."
|
||||
@export PYTHONPATH=. && uv run python3 src/tui/app.py --debug
|
||||
|
||||
tui-test:
|
||||
@export PYTHONPATH=. && uv run python3 src/tui/app.py --test
|
||||
|
||||
web:
|
||||
@echo "Stopping any existing backend..."
|
||||
@make stop
|
||||
@echo "Starting Web..."
|
||||
@export PYTHONPATH=. && uv run python3 src/api/main.py &
|
||||
@sleep 3
|
||||
@devbox run -- bash -c "cd frontend && npm run dev"
|
||||
|
||||
@@ -1,3 +1,137 @@
|
||||
# TactiTerm
|
||||
|
||||
Tactile Terminal: A WebUI / TUI built to help teach and and study programming concepts in an interactive way.
|
||||
**Tactile Terminal:** A WebUI / TUI built to help teach and study programming concepts in an interactive, hands-on way.
|
||||
|
||||
> I found it frustrating that there wasn't a simple, easy to use program for studying program in an interactive or kinesthetic way. So, while I worked on other projects, I decided to slop together a program and leverage local LLMs to build **TactiTerm**.
|
||||
>
|
||||
> A self-sufficient, offline capable IDE, with an LLM powered tutor to help guide you through mental roadblocks, and build up confidence when it comes to programming in various languages.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**TactiTerm** is an interactive software engineering tutor designed with a "guidance over answers" philosophy. Rather than providing copy-paste solutions, TactiTerm utilizes an **LLM Harness** to ask probing questions, provide hints, and guide users toward mastering concepts across various languages.
|
||||
|
||||
TactiTerm features both a rich **Terminal User Interface (TUI)** for command-line use, and a modern **Web UI** featuring the Monaco Editor.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **AI Mentor:** Provides step-by-step guidance, code review, and conceptual hints without spoiling direct answers.
|
||||
- **Dual Interfaces:**
|
||||
- **TUI:** Built with Python and Textual for lightweight, keyboard-driven terminal workflows.
|
||||
- **Web UI:** Built with React, Vite, TypeScript, Tailwind CSS, and Monaco Editor.
|
||||
- **10 Supported Languages:** Python, C#, C++, Java, JavaScript, TypeScript, Rust, Lua, HTML, and Go.
|
||||
- **Real-time Linting & Execution:** Tree-sitter powered syntax validation and safe local code execution.
|
||||
- **Interactive Syntax Handbook:** Comprehensive reference catalog with on-demand AI code examples for functions and core language topics.
|
||||
- **AI Challenge Generator (GenTUI):** Create and save custom Markdown programming challenges tailored to specific subjects and difficulties.
|
||||
- **Configurable & Network Ready:** Easily configure LLM endpoints, ports, and enable `"public": true` to host the Web UI across your local network (`0.0.0.0`).
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Devbox](https://www.jetify.com/devbox)
|
||||
|
||||
> Currently we only officially support running TactiTerm through devbox. This is to simplify the deployment process across devices. If you dare, and you have the prequisites installed; you should be able to run TactiTerm without it.
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/your-username/TactiTerm.git
|
||||
cd TactiTerm
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
devbox run install
|
||||
```
|
||||
*(or `make install` inside `devbox shell`)*
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
TactiTerm is executed via the use of **Devbox** run commands.
|
||||
|
||||
### Terminal Interface (TUI)
|
||||
|
||||
Launch the interactive terminal application:
|
||||
```bash
|
||||
devbox run tui
|
||||
```
|
||||
*(or `make tui` inside `devbox shell`)*
|
||||
|
||||
Additional TUI modes:
|
||||
- `devbox run gentui` - Launch the standalone GenTUI AI Challenge Generator.
|
||||
- `devbox run -- make tui-debug` - Launch TUI in debug logging mode.
|
||||
|
||||
### Web Interface (Web UI)
|
||||
|
||||
Launch the FastAPI backend server and Vite frontend dev server:
|
||||
```bash
|
||||
devbox run web
|
||||
```
|
||||
*(or `make web` inside `devbox shell`)*
|
||||
|
||||
Then open your browser to `http://localhost:5173`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
When initialized, TactiTerm automatically generates a `config.json` file in the root directory if one does not exist.
|
||||
|
||||
### `config.json` Example
|
||||
|
||||
```json
|
||||
{
|
||||
"llm": {
|
||||
"base_url": "http://localhost:8080/v1",
|
||||
"model": "local-model",
|
||||
"api_key": "not-needed",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 512,
|
||||
"timeout_seconds": 60.0
|
||||
},
|
||||
"web": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8000,
|
||||
"public": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Settings
|
||||
|
||||
- **LLM Settings:** Point `base_url` to any OpenAI-compatible server (LocalAI, vLLM, Ollama, LM Studio, or OpenAI API).
|
||||
- **Web UI Public Access:** Set `"public": true` under `"web"` (or set environment variable `TACTTERM_WEB_PUBLIC=true`) to bind the server to `0.0.0.0`, allowing other devices on your local network to connect.
|
||||
|
||||
### Environment Variable Overrides
|
||||
|
||||
- `TACTTERM_LLM_BASE_URL` - Override LLM API URL.
|
||||
- `TACTTERM_LLM_MODEL` - Override LLM model name.
|
||||
- `TACTTERM_WEB_HOST` - Override web server bind address.
|
||||
- `TACTTERM_WEB_PORT` - Override web server port (default: 8000).
|
||||
- `TACTTERM_WEB_PUBLIC` - Set to `true` to enable network binding (`0.0.0.0`).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Backend (`src/api/main.py`):** FastAPI application serving REST endpoints for challenges, code linting, code execution, mentoring guidance, and handbook references.
|
||||
- **Core Engine (`src/core/`):**
|
||||
- `config.py`: Application config manager with auto-generation and env overrides.
|
||||
- `executor.py`: Subprocess execution engine for multi-language code snippets.
|
||||
- `linter.py`: Tree-sitter syntax checker.
|
||||
- `mentor.py`: Async Socratic mentor prompt & LLM service.
|
||||
- `handbook.py`: Reference catalog and example generator.
|
||||
- `generator.py`: AI challenge generation service.
|
||||
- **TUI Client (`src/tui/`):** Textual terminal application.
|
||||
- **Web Client (`frontend/`):** React + TypeScript SPA powered by Vite, Tailwind CSS, and Monaco Editor.
|
||||
|
||||
---
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"llm": {
|
||||
"base_url": "http://100.82.205.18:1010/",
|
||||
"model": "local-model",
|
||||
"api_key": "not-needed",
|
||||
"temperature": 0.9,
|
||||
"max_tokens": 64000,
|
||||
"timeout_seconds": 60.0
|
||||
},
|
||||
"web": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8000,
|
||||
"public": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"llm": {
|
||||
"base_url": "http://127.0.0.1:1010",
|
||||
"model": "local-model",
|
||||
"api_key": "not-needed",
|
||||
"temperature": 0.9,
|
||||
"max_tokens": 8120,
|
||||
"timeout_seconds": 120.0
|
||||
},
|
||||
"web": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8000,
|
||||
"public": true
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "tactiterm",
|
||||
"description": "A coding tutor for software engineering.",
|
||||
"packages": [
|
||||
"python@3.13",
|
||||
"nodejs@latest",
|
||||
"git",
|
||||
"curl",
|
||||
"uv",
|
||||
"gnumake"
|
||||
],
|
||||
"shell": {
|
||||
"scripts": {
|
||||
"install": "make install",
|
||||
"tui": "make tui",
|
||||
"gentui": "make gentui",
|
||||
"web": "make web"
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"lockfile_version": "1",
|
||||
"packages": {
|
||||
"curl": {
|
||||
"resolved": "github:NixOS/nixpkgs/3e41b24abd260e8f71dbe2f5737d24122f972158?narHash=sha256-rxO%2Buc%2FKFbSJp%2BpgyXRuAX6QlG9hJdnt0BXpEQRXY%2BU%3D#curl",
|
||||
"source": "nixpkg",
|
||||
"systems": {
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "bin",
|
||||
"path": "/nix/store/0aha64svrrch155x78xhzn6kmgrrsl9a-curl-8.20.0-bin",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/zghy1pwbzdn7awhk7id00jlb83hk8y8v-curl-8.20.0-man",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"git": {
|
||||
"resolved": "github:NixOS/nixpkgs/3e41b24abd260e8f71dbe2f5737d24122f972158?narHash=sha256-rxO%2Buc%2FKFbSJp%2BpgyXRuAX6QlG9hJdnt0BXpEQRXY%2BU%3D#git",
|
||||
"source": "nixpkg",
|
||||
"systems": {
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"path": "/nix/store/bcnisk3ydfgv26v2gw3zlky24g00yww2-git-2.54.0",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"github:NixOS/nixpkgs/nixpkgs-unstable": {
|
||||
"last_modified": "2026-07-15T11:37:32Z",
|
||||
"resolved": "github:NixOS/nixpkgs/35d3407a3816f3b341d8cf1d60abaf2b7b8166ac?lastModified=1784115452&narHash=sha256-BoYPdqk6jlKXy%2BDyUzyGV%2FCtRGfAhk2MmIgBhsemTGI%3D"
|
||||
},
|
||||
"gnumake": {
|
||||
"resolved": "github:NixOS/nixpkgs/3e41b24abd260e8f71dbe2f5737d24122f972158?narHash=sha256-rxO%2Buc%2FKFbSJp%2BpgyXRuAX6QlG9hJdnt0BXpEQRXY%2BU%3D#gnumake",
|
||||
"source": "nixpkg",
|
||||
"systems": {
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/bmvqa5ym318mymhxlwwmxq8wxal958p4-gnumake-4.4.1-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"path": "/nix/store/d3bwqm6bymhy3pdgbvf7vxjqfp31m3j1-gnumake-4.4.1",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodejs@latest": {
|
||||
"last_modified": "2026-06-27T07:37:20Z",
|
||||
"plugin_version": "0.0.4",
|
||||
"resolved": "github:NixOS/nixpkgs/3d46470bb3030020f7e1361f33514854f5bfa86d#nodejs_26",
|
||||
"source": "devbox-search",
|
||||
"version": "26.4.0",
|
||||
"systems": {
|
||||
"aarch64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/dz3nxng3029j1gc570pkwj8ahfdl3sa4-nodejs-26.4.0",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/dz3nxng3029j1gc570pkwj8ahfdl3sa4-nodejs-26.4.0"
|
||||
},
|
||||
"aarch64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/4bv9k994r33svxczpy2wk3lhsrh3kspl-nodejs-26.4.0",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/4bv9k994r33svxczpy2wk3lhsrh3kspl-nodejs-26.4.0"
|
||||
},
|
||||
"x86_64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/wyxj7d54jggi26lnap66c858mb2l2m2q-nodejs-26.4.0",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/wyxj7d54jggi26lnap66c858mb2l2m2q-nodejs-26.4.0"
|
||||
},
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/i2jf2l5lqhy3d7zy3lzx5wjydyzw2hwm-nodejs-26.4.0",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/i2jf2l5lqhy3d7zy3lzx5wjydyzw2hwm-nodejs-26.4.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"python@3.13": {
|
||||
"last_modified": "2025-05-16T20:19:48Z",
|
||||
"plugin_version": "0.0.5",
|
||||
"resolved": "github:NixOS/nixpkgs/12a55407652e04dcf2309436eb06fef0d3713ef3#python313",
|
||||
"source": "devbox-search",
|
||||
"version": "3.13.3",
|
||||
"systems": {
|
||||
"aarch64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/1a8xg8l3m67hxinxzzcsak9736qm9vsf-python3-3.13.3",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/1a8xg8l3m67hxinxzzcsak9736qm9vsf-python3-3.13.3"
|
||||
},
|
||||
"aarch64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/yy0xvc2rydhrs0h1v8d7r3sql347xzz5-python3-3.13.3",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "debug",
|
||||
"path": "/nix/store/42bxfqfrh8cwspl7szr0cw8739xv8qlq-python3-3.13.3-debug"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/yy0xvc2rydhrs0h1v8d7r3sql347xzz5-python3-3.13.3"
|
||||
},
|
||||
"x86_64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/gbrigjhghz9v2p0zf9b2fnvs0g0yx7q4-python3-3.13.3",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/gbrigjhghz9v2p0zf9b2fnvs0g0yx7q4-python3-3.13.3"
|
||||
},
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/2mab9iiwhcqwk75qwvp3zv0bvbiaq6cs-python3-3.13.3",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "debug",
|
||||
"path": "/nix/store/9z6k8ijl2md0y2n95yprbjj4vxbfsi15-python3-3.13.3-debug"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/2mab9iiwhcqwk75qwvp3zv0bvbiaq6cs-python3-3.13.3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uv": {
|
||||
"resolved": "github:NixOS/nixpkgs/3e41b24abd260e8f71dbe2f5737d24122f972158?narHash=sha256-rxO%2Buc%2FKFbSJp%2BpgyXRuAX6QlG9hJdnt0BXpEQRXY%2BU%3D#uv",
|
||||
"source": "nixpkg",
|
||||
"systems": {
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"path": "/nix/store/2xqsvim09lc968bc67w0jvxpp2j4lfm5-uv-0.11.19",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Socratic Tutor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2496
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "programming-tutor-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"axios": "^1.6.0",
|
||||
"lucide-react": "^0.300.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^8.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React, { useState } from 'react';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Workspace from './pages/Workspace';
|
||||
|
||||
function App() {
|
||||
const [selectedChallengeId, setSelectedChallengeId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-darkBg text-gray-100 font-sans">
|
||||
{selectedChallengeId ? (
|
||||
<Workspace
|
||||
challengeId={selectedChallengeId}
|
||||
onBack={() => setSelectedChallengeId(null)}
|
||||
/>
|
||||
) : (
|
||||
<Dashboard
|
||||
onSelectChallenge={(id) => setSelectedChallengeId(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,121 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const getApiBaseUrl = () => {
|
||||
if (import.meta.env.VITE_API_BASE_URL) {
|
||||
return import.meta.env.VITE_API_BASE_URL;
|
||||
}
|
||||
// Use relative path in browser so requests are routed via Vite proxy or reverse proxy
|
||||
if (typeof window !== 'undefined') {
|
||||
return '';
|
||||
}
|
||||
return 'http://127.0.0.1:8000';
|
||||
};
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
export interface Challenge {
|
||||
id: string;
|
||||
challenge_id: string;
|
||||
name: string;
|
||||
difficulty: string;
|
||||
language: string;
|
||||
subject?: string;
|
||||
description: string;
|
||||
requirements: string[];
|
||||
hints: string[];
|
||||
}
|
||||
|
||||
export interface HandbookTopic {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface HandbookCatalog {
|
||||
functions: HandbookTopic[];
|
||||
subjects: HandbookTopic[];
|
||||
}
|
||||
|
||||
export const getChallenges = async (): Promise<Challenge[]> => {
|
||||
const response = await apiClient.get('/challenges');
|
||||
return response.data.challenges;
|
||||
};
|
||||
|
||||
export const runCode = async (language: string, code: string, stdin: string = '') => {
|
||||
const response = await apiClient.post('/run', { language, code, stdin });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const lintCode = async (language: string, code: string) => {
|
||||
const response = await apiClient.post('/lint', { language, code });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getGuidance = async (
|
||||
challengeId: string,
|
||||
language: string,
|
||||
code: string,
|
||||
question: string = ''
|
||||
) => {
|
||||
const response = await apiClient.post('/guide', {
|
||||
challenge_id: challengeId,
|
||||
language,
|
||||
code,
|
||||
question,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getHandbookCatalog = async (language: string): Promise<HandbookCatalog> => {
|
||||
const response = await apiClient.get(`/handbook/catalog/${encodeURIComponent(language)}`);
|
||||
return response.data.catalog;
|
||||
};
|
||||
|
||||
export const getHandbookTopics = async (language: string): Promise<HandbookTopic[]> => {
|
||||
const response = await apiClient.get(`/handbook/topics/${encodeURIComponent(language)}`);
|
||||
return response.data.topics;
|
||||
};
|
||||
|
||||
export const getHandbookExample = async (
|
||||
language: string,
|
||||
topicId: string,
|
||||
topicTitle: string
|
||||
) => {
|
||||
const response = await apiClient.post('/handbook/example', {
|
||||
language,
|
||||
topic_id: topicId,
|
||||
topic_title: topicTitle,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const generateChallenge = async (
|
||||
prompt: string,
|
||||
language: string,
|
||||
difficulty: string,
|
||||
subject: string
|
||||
) => {
|
||||
const response = await apiClient.post('/challenges/generate', {
|
||||
prompt,
|
||||
language,
|
||||
difficulty,
|
||||
subject,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const saveChallenge = async (filename: string, markdown: string) => {
|
||||
const response = await apiClient.post('/challenges/save', {
|
||||
filename,
|
||||
markdown,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
|
||||
interface MarkdownViewProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const MarkdownView: React.FC<MarkdownViewProps> = ({ content }) => {
|
||||
if (!content) return null;
|
||||
|
||||
// Simple, robust custom Markdown renderer for Mentor responses & challenge specs
|
||||
const lines = content.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeBuffer: string[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (line.trim().startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
elements.push(
|
||||
<pre
|
||||
key={`code-${index}`}
|
||||
className="my-3 p-3 bg-black/70 border border-gray-800 rounded-lg text-xs font-mono text-emerald-400 overflow-x-auto"
|
||||
>
|
||||
<code>{codeBuffer.join('\n')}</code>
|
||||
</pre>
|
||||
);
|
||||
codeBuffer = [];
|
||||
inCodeBlock = false;
|
||||
} else {
|
||||
inCodeBlock = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeBuffer.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('# ')) {
|
||||
elements.push(
|
||||
<h1 key={index} className="text-xl font-bold text-white mt-4 mb-2">
|
||||
{line.replace('# ', '')}
|
||||
</h1>
|
||||
);
|
||||
} else if (line.startsWith('## ')) {
|
||||
elements.push(
|
||||
<h2 key={index} className="text-base font-bold text-blue-400 mt-4 mb-2 border-b border-gray-800 pb-1">
|
||||
{line.replace('## ', '')}
|
||||
</h2>
|
||||
);
|
||||
} else if (line.startsWith('### ')) {
|
||||
elements.push(
|
||||
<h3 key={index} className="text-sm font-bold text-amber-400 mt-3 mb-1">
|
||||
{line.replace('### ', '')}
|
||||
</h3>
|
||||
);
|
||||
} else if (line.startsWith('> ')) {
|
||||
elements.push(
|
||||
<blockquote key={index} className="my-2 p-2 bg-blue-950/40 border-l-4 border-blue-500 rounded-r text-xs text-blue-200 italic">
|
||||
{line.replace('> ', '')}
|
||||
</blockquote>
|
||||
);
|
||||
} else if (line.trim().startsWith('- ') || line.trim().startsWith('* ')) {
|
||||
elements.push(
|
||||
<li key={index} className="ml-4 list-disc text-xs text-gray-300 my-1">
|
||||
{formatInline(line.trim().replace(/^[-*]\s+/, ''))}
|
||||
</li>
|
||||
);
|
||||
} else if (line.trim() === '---') {
|
||||
elements.push(<hr key={index} className="my-3 border-gray-800" />);
|
||||
} else if (line.trim().length > 0) {
|
||||
elements.push(
|
||||
<p key={index} className="text-xs text-gray-300 leading-relaxed my-1.5">
|
||||
{formatInline(line)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return <div className="space-y-1">{elements}</div>;
|
||||
};
|
||||
|
||||
function formatInline(text: string): React.ReactNode {
|
||||
// Simple bold and inline code formatter
|
||||
const parts = text.split(/(\*\*.*?\*\*|`.*?`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={i} className="font-semibold text-white">{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
return <code key={i} className="px-1.5 py-0.5 bg-gray-800 text-amber-300 font-mono text-[11px] rounded">{part.slice(1, -1)}</code>;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,357 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getChallenges, generateChallenge, saveChallenge, Challenge } from '../api/client';
|
||||
import { MarkdownView } from '../components/MarkdownView';
|
||||
import { BookOpen, ArrowRight, Code, ShieldCheck, Sparkles, X, Save, Layers } from 'lucide-react';
|
||||
|
||||
interface DashboardProps {
|
||||
onSelectChallenge: (id: string) => void;
|
||||
}
|
||||
|
||||
const Dashboard: React.FC<DashboardProps> = ({ onSelectChallenge }) => {
|
||||
const [challenges, setChallenges] = useState<Challenge[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Generator Modal state
|
||||
const [isGenModalOpen, setIsGenModalOpen] = useState(false);
|
||||
const [promptText, setPromptText] = useState('');
|
||||
const [genLanguage, setGenLanguage] = useState('Python');
|
||||
const [genDifficulty, setGenDifficulty] = useState('Medium');
|
||||
const [genSubject, setGenSubject] = useState('General Concepts');
|
||||
const [genMarkdown, setGenMarkdown] = useState('');
|
||||
const [genFilename, setGenFilename] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [genStatusMsg, setGenStatusMsg] = useState('');
|
||||
|
||||
const fetchChallengesList = () => {
|
||||
setLoading(true);
|
||||
getChallenges()
|
||||
.then(setChallenges)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchChallengesList();
|
||||
}, []);
|
||||
|
||||
const handleGenerateAI = async () => {
|
||||
if (!promptText.trim()) {
|
||||
setGenStatusMsg('Please enter a prompt for the AI generator.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setGenStatusMsg('⏳ Generating challenge via LLM backend...');
|
||||
|
||||
try {
|
||||
const res = await generateChallenge(promptText, genLanguage, genDifficulty, genSubject);
|
||||
if (res.status === 'success') {
|
||||
setGenMarkdown(res.markdown);
|
||||
setGenFilename(res.filename);
|
||||
setGenStatusMsg(`✓ Challenge generated: '${res.title}'`);
|
||||
} else {
|
||||
setGenStatusMsg(`✗ Failed: ${res.message}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setGenStatusMsg(`✗ Error generating challenge: ${err.message}`);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveChallenge = async () => {
|
||||
if (!genFilename.trim() || !genMarkdown.trim()) {
|
||||
setGenStatusMsg('Please provide a filename and markdown content.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setGenStatusMsg('Saving challenge to disk...');
|
||||
|
||||
try {
|
||||
const res = await saveChallenge(genFilename, genMarkdown);
|
||||
if (res.status === 'success') {
|
||||
setGenStatusMsg(`✓ Saved challenge to ${res.filename}`);
|
||||
fetchChallengesList(); // Refresh dashboard list
|
||||
} else {
|
||||
setGenStatusMsg(`✗ Save failed: ${res.message}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setGenStatusMsg(`✗ Error saving challenge: ${err.message}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-darkBg text-gray-300">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="font-medium text-sm">Loading TactiTerm Challenges...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-darkBg text-red-400">
|
||||
<div className="p-6 bg-darkSurface border border-red-900/50 rounded-xl text-center max-w-md">
|
||||
<p className="font-bold mb-2">Failed to load challenges</p>
|
||||
<p className="text-xs text-gray-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-xs font-semibold rounded-lg hover:bg-blue-500"
|
||||
>
|
||||
Retry Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-darkBg text-gray-100 p-8 flex flex-col justify-between">
|
||||
<div>
|
||||
<header className="max-w-6xl mx-auto mb-10 flex justify-between items-center border-b border-darkBorder pb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-white tracking-tight flex items-center gap-3">
|
||||
<Code className="text-blue-500" size={32} />
|
||||
TactiTerm Workspace
|
||||
</h1>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Build programming confidence from scratch with interactive AI guidance
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-darkSurface border border-darkBorder rounded-full text-xs text-gray-300">
|
||||
<ShieldCheck size={14} className="text-green-400" />
|
||||
<span>Local Engine Active (10 Languages)</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-6xl mx-auto">
|
||||
<h2 className="text-xl font-bold text-gray-200 mb-6">Select a Challenge</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{challenges.map((c) => {
|
||||
const cid = c.id || c.challenge_id;
|
||||
return (
|
||||
<div
|
||||
key={cid}
|
||||
onClick={() => onSelectChallenge(cid)}
|
||||
className="group p-6 bg-darkSurface border border-darkBorder rounded-2xl hover:border-blue-500/80 hover:shadow-xl hover:shadow-blue-500/10 cursor-pointer transition-all duration-200 flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-2.5 bg-blue-500/10 border border-blue-500/20 rounded-xl text-blue-400">
|
||||
<BookOpen size={20} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-0.5 bg-gray-800 text-gray-300 text-[10px] uppercase font-bold rounded-md tracking-wider border border-gray-700">
|
||||
{c.language}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2.5 py-0.5 text-[10px] uppercase font-bold rounded-md tracking-wider border ${
|
||||
c.difficulty.toLowerCase() === 'easy'
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20'
|
||||
: c.difficulty.toLowerCase() === 'medium'
|
||||
? 'bg-amber-500/10 text-amber-400 border-amber-500/20'
|
||||
: 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
{c.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-white group-hover:text-blue-400 transition-colors mb-2">
|
||||
{c.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5 text-xs text-amber-400 font-semibold mb-2">
|
||||
<Layers size={13} />
|
||||
<span>{c.subject || 'General Concepts'}</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-xs line-clamp-3 leading-relaxed">
|
||||
{c.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-darkBorder/50 flex justify-between items-center text-xs font-semibold text-blue-400 group-hover:translate-x-1 transition-transform">
|
||||
<span>Start Challenge</span>
|
||||
<ArrowRight size={14} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Footer Generator Action Section */}
|
||||
<footer className="max-w-6xl mx-auto w-full mt-12 pt-6 border-t border-darkBorder flex justify-between items-center">
|
||||
<p className="text-xs text-gray-500">TactiTerm Software Engineering Tutor</p>
|
||||
<button
|
||||
onClick={() => setIsGenModalOpen(true)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-white font-bold text-xs rounded-xl shadow-lg shadow-amber-500/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
<span>Generate New Challenge with AI</span>
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
{/* AI Challenge Generator Modal */}
|
||||
{isGenModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-6 z-50">
|
||||
<div className="bg-darkSurface border border-darkBorder rounded-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden shadow-2xl">
|
||||
{/* Modal Header */}
|
||||
<div className="p-4 border-b border-darkBorder bg-darkBg flex justify-between items-center">
|
||||
<h2 className="text-base font-bold text-amber-400 flex items-center gap-2">
|
||||
<Sparkles size={18} /> AI Challenge Generator (GenTUI Web)
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsGenModalOpen(false)}
|
||||
className="p-1 hover:bg-gray-800 text-gray-400 hover:text-white rounded-lg"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Controls Column */}
|
||||
<div className="w-1/3 p-5 border-r border-darkBorder bg-darkBg flex flex-col gap-4 overflow-y-auto">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-300 mb-1">AI Prompt:</label>
|
||||
<textarea
|
||||
value={promptText}
|
||||
onChange={(e) => setPromptText(e.target.value)}
|
||||
placeholder="e.g. Medium difficulty Rust challenge on Borrowing & References"
|
||||
className="w-full h-24 bg-darkSurface border border-darkBorder rounded-xl p-3 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-amber-500 resize-none font-sans"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-300 mb-1">Language:</label>
|
||||
<select
|
||||
value={genLanguage}
|
||||
onChange={(e) => setGenLanguage(e.target.value)}
|
||||
className="w-full bg-darkSurface border border-darkBorder text-gray-200 text-xs rounded-lg p-2 focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
{['Python', 'C#', 'C++', 'Java', 'JavaScript', 'TypeScript', 'Rust', 'Lua', 'HTML', 'Go'].map(
|
||||
(lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{lang}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-300 mb-1">Difficulty:</label>
|
||||
<select
|
||||
value={genDifficulty}
|
||||
onChange={(e) => setGenDifficulty(e.target.value)}
|
||||
className="w-full bg-darkSurface border border-darkBorder text-gray-200 text-xs rounded-lg p-2 focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
{['Easy', 'Medium', 'Hard', 'Advanced'].map((diff) => (
|
||||
<option key={diff} value={diff}>
|
||||
{diff}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-300 mb-1">Subject / Topic:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={genSubject}
|
||||
onChange={(e) => setGenSubject(e.target.value)}
|
||||
placeholder="e.g. Memory Management, Data Structures"
|
||||
className="w-full bg-darkSurface border border-darkBorder rounded-lg p-2 text-xs text-gray-200 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerateAI}
|
||||
disabled={isGenerating}
|
||||
className="w-full py-2.5 bg-amber-600 hover:bg-amber-500 disabled:opacity-50 text-white font-bold text-xs rounded-xl shadow-md transition-all flex items-center justify-center gap-2 mt-2"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Sparkles size={16} />
|
||||
)}
|
||||
<span>Generate with AI</span>
|
||||
</button>
|
||||
|
||||
<hr className="border-darkBorder my-1" />
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-300 mb-1">Filename (.md):</label>
|
||||
<input
|
||||
type="text"
|
||||
value={genFilename}
|
||||
onChange={(e) => setGenFilename(e.target.value)}
|
||||
placeholder="007-new-challenge.md"
|
||||
className="w-full bg-darkSurface border border-darkBorder rounded-lg p-2 text-xs text-gray-200 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveChallenge}
|
||||
disabled={isSaving || !genMarkdown.trim() || !genFilename.trim()}
|
||||
className="w-full py-2.5 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white font-bold text-xs rounded-xl shadow-md transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)}
|
||||
<span>Save Challenge to Disk</span>
|
||||
</button>
|
||||
|
||||
{genStatusMsg && (
|
||||
<p className="text-xs text-amber-300 font-mono bg-amber-950/30 p-2.5 rounded-lg border border-amber-800/40">
|
||||
{genStatusMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Raw Editor & Preview Column */}
|
||||
<div className="w-2/3 flex flex-col bg-[#1e1e1e] overflow-hidden">
|
||||
<div className="grid grid-cols-2 flex-1 overflow-hidden">
|
||||
{/* Markdown Editor */}
|
||||
<div className="flex flex-col border-r border-darkBorder p-4 overflow-hidden">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-2">Raw Markdown (.md) Editor</h3>
|
||||
<textarea
|
||||
value={genMarkdown}
|
||||
onChange={(e) => setGenMarkdown(e.target.value)}
|
||||
placeholder="# Challenge: Title..."
|
||||
className="w-full flex-1 bg-black/50 border border-darkBorder rounded-xl p-3 text-xs text-emerald-300 font-mono focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rendered Preview */}
|
||||
<div className="flex flex-col p-4 bg-darkBg overflow-y-auto">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-blue-400 mb-2">Live Rendered Preview</h3>
|
||||
<div className="flex-1 p-3 bg-darkSurface border border-darkBorder rounded-xl">
|
||||
<MarkdownView content={genMarkdown || '*Generated preview will appear here...*'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,815 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Editor } from '@monaco-editor/react';
|
||||
import { registerMonacoCompletions, getMonacoLanguage, getLanguageFileName } from '../utils/monacoCompletions';
|
||||
import {
|
||||
getChallenges,
|
||||
runCode,
|
||||
lintCode,
|
||||
getGuidance,
|
||||
getHandbookCatalog,
|
||||
getHandbookExample,
|
||||
Challenge,
|
||||
HandbookTopic,
|
||||
HandbookCatalog,
|
||||
} from '../api/client';
|
||||
import { MarkdownView } from '../components/MarkdownView';
|
||||
import {
|
||||
MessageSquare,
|
||||
ChevronLeft,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
Sparkles,
|
||||
Code,
|
||||
Send,
|
||||
HelpCircle,
|
||||
BookMarked,
|
||||
X,
|
||||
Search,
|
||||
Zap,
|
||||
BookOpen,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Terminal,
|
||||
GripVertical,
|
||||
GripHorizontal,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WorkspaceProps {
|
||||
challengeId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
const [allChallenges, setAllChallenges] = useState<Challenge[]>([]);
|
||||
const [currentChallenge, setCurrentChallenge] = useState<Challenge | null>(null);
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const [stdinText, setStdinText] = useState('');
|
||||
|
||||
// Resizable Layout Dimensions
|
||||
const [leftWidth, setLeftWidth] = useState(320); // Left Challenge Panel width in px
|
||||
const [rightWidth, setRightWidth] = useState(350); // Right Sidebar width in px
|
||||
const [outputHeight, setOutputHeight] = useState(180); // Output Console height in px
|
||||
|
||||
const editorRef = useRef<any>(null);
|
||||
const [draggingType, setDraggingType] = useState<'left' | 'right' | 'output' | null>(null);
|
||||
|
||||
const [output, setOutput] = useState<{ status: 'idle' | 'success' | 'error'; text: string }>({
|
||||
status: 'idle',
|
||||
text: 'Execution and lint output will appear here...',
|
||||
});
|
||||
|
||||
// Right sidebar state: 'mentor' | 'handbook' | 'closed'
|
||||
const [rightSidebarMode, setRightSidebarMode] = useState<'mentor' | 'handbook' | 'closed'>('mentor');
|
||||
|
||||
// Mentor state
|
||||
const [guidance, setGuidance] = useState<string>('Ask the mentor a question or click Check Answer below...');
|
||||
const [userQuestion, setUserQuestion] = useState('');
|
||||
const [isLoadingGuidance, setIsLoadingGuidance] = useState(false);
|
||||
|
||||
// Handbook state
|
||||
const [handbookCatalog, setHandbookCatalog] = useState<HandbookCatalog>({ functions: [], subjects: [] });
|
||||
const [handbookTab, setHandbookTab] = useState<'functions' | 'subjects'>('functions');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTopic, setSelectedTopic] = useState<HandbookTopic | null>(null);
|
||||
const [handbookExample, setHandbookExample] = useState<string>(
|
||||
'Select a built-in function or topic above to view reference card...'
|
||||
);
|
||||
const [isLoadingExample, setIsLoadingExample] = useState(false);
|
||||
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isLinting, setIsLinting] = useState(false);
|
||||
|
||||
// Load all challenges
|
||||
useEffect(() => {
|
||||
getChallenges()
|
||||
.then((challenges) => {
|
||||
setAllChallenges(challenges);
|
||||
const match = challenges.find((c) => (c.id || c.challenge_id) === challengeId);
|
||||
if (match) {
|
||||
setCurrentChallenge(match);
|
||||
} else if (challenges.length > 0) {
|
||||
setCurrentChallenge(challenges[0]);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('Failed to load challenges:', err));
|
||||
}, [challengeId]);
|
||||
|
||||
// Fetch handbook catalog when current challenge language changes
|
||||
useEffect(() => {
|
||||
if (currentChallenge) {
|
||||
getHandbookCatalog(currentChallenge.language)
|
||||
.then((catalog) => {
|
||||
setHandbookCatalog(catalog);
|
||||
setSelectedTopic(null);
|
||||
setHandbookExample('');
|
||||
})
|
||||
.catch((err) => console.error('Failed to load handbook catalog:', err));
|
||||
}
|
||||
}, [currentChallenge]);
|
||||
|
||||
// Global mousemove and mouseup listeners for drag-to-resize splitters
|
||||
useEffect(() => {
|
||||
if (!draggingType) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (draggingType === 'left') {
|
||||
const newWidth = Math.max(150, Math.min(Math.floor(window.innerWidth * 0.6), e.clientX));
|
||||
setLeftWidth(newWidth);
|
||||
} else if (draggingType === 'right') {
|
||||
const newWidth = Math.max(180, Math.min(Math.floor(window.innerWidth * 0.6), window.innerWidth - e.clientX));
|
||||
setRightWidth(newWidth);
|
||||
} else if (draggingType === 'output') {
|
||||
const maxHeight = Math.max(150, window.innerHeight - 150);
|
||||
const newHeight = Math.max(60, Math.min(maxHeight, window.innerHeight - e.clientY));
|
||||
setOutputHeight(newHeight);
|
||||
if (editorRef.current) {
|
||||
try { editorRef.current.layout(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDraggingType(null);
|
||||
document.body.style.cursor = 'default';
|
||||
document.body.style.userSelect = 'auto';
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [draggingType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
try { editorRef.current.layout(); } catch (_) {}
|
||||
}
|
||||
}, [outputHeight, leftWidth, rightWidth]);
|
||||
|
||||
const startDraggingLeft = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setDraggingType('left');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const startDraggingRight = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setDraggingType('right');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const startDraggingOutput = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setDraggingType('output');
|
||||
document.body.style.cursor = 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const handleSelectChallenge = (cid: string) => {
|
||||
const match = allChallenges.find((c) => (c.id || c.challenge_id) === cid);
|
||||
if (match) {
|
||||
setCurrentChallenge(match);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunCode = async (overrideStdin?: string) => {
|
||||
if (!code.trim() || !currentChallenge) return;
|
||||
setIsRunning(true);
|
||||
setOutput({ status: 'idle', text: 'Executing code...' });
|
||||
const inputToSend = overrideStdin !== undefined ? overrideStdin : stdinText;
|
||||
|
||||
try {
|
||||
const res = await runCode(getMonacoLanguage(currentChallenge.language), code, inputToSend);
|
||||
if (res.exit_code === 0) {
|
||||
setOutput({
|
||||
status: 'success',
|
||||
text: res.stdout?.trim() ? `Output:\n${res.stdout}` : '✓ Code executed successfully (exit code 0, no stdout).',
|
||||
});
|
||||
} else {
|
||||
const err = res.stderr?.trim() || res.stdout?.trim() || 'Unknown runtime error';
|
||||
setOutput({ status: 'error', text: `✗ Runtime Error (exit code ${res.exit_code}):\n${err}` });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setOutput({ status: 'error', text: `✗ Error running code: ${err.message}` });
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendStdin = () => {
|
||||
handleRunCode(stdinText);
|
||||
};
|
||||
|
||||
const handleLintCode = async () => {
|
||||
if (!code.trim() || !currentChallenge) return;
|
||||
setIsLinting(true);
|
||||
setOutput({ status: 'idle', text: 'Running syntax & style lint...' });
|
||||
try {
|
||||
const res = await lintCode(getMonacoLanguage(currentChallenge.language), code);
|
||||
if (res.exit_code === 0) {
|
||||
setOutput({ status: 'success', text: '✓ Syntax & Style clean! No linting errors detected.' });
|
||||
} else {
|
||||
const raw = (res.stdout || '') + '\n' + (res.stderr || '');
|
||||
setOutput({ status: 'error', text: `✗ Lint Error(s):\n${raw.trim()}` });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setOutput({ status: 'error', text: `✗ Error linting code: ${err.message}` });
|
||||
} finally {
|
||||
setIsLinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAskMentor = async (questionToAsk?: string) => {
|
||||
if (!currentChallenge) return;
|
||||
const q = questionToAsk !== undefined ? questionToAsk : userQuestion.trim();
|
||||
const cid = currentChallenge.id || currentChallenge.challenge_id;
|
||||
|
||||
setIsLoadingGuidance(true);
|
||||
setRightSidebarMode('mentor');
|
||||
setGuidance('');
|
||||
setUserQuestion('');
|
||||
|
||||
try {
|
||||
const res = await getGuidance(
|
||||
cid,
|
||||
getMonacoLanguage(currentChallenge.language),
|
||||
code,
|
||||
q
|
||||
);
|
||||
const qHeader = q ? `### Question / Evaluation:\n> ${q}\n\n---\n\n` : '';
|
||||
setGuidance(`${qHeader}${res.mentor_response || 'No guidance received.'}`);
|
||||
} catch (err: any) {
|
||||
setGuidance(`⚠️ Error consulting Mentor: ${err.message}`);
|
||||
} finally {
|
||||
setIsLoadingGuidance(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHandbookTopic = async (topic: HandbookTopic) => {
|
||||
if (!currentChallenge) return;
|
||||
setSelectedTopic(topic);
|
||||
setIsLoadingExample(true);
|
||||
setHandbookExample(`⏳ **Generating reference card for '${topic.title}' in ${currentChallenge.language}...**\n\n*Consulting LLM backend...*`);
|
||||
|
||||
try {
|
||||
const res = await getHandbookExample(currentChallenge.language, topic.id, topic.title);
|
||||
if (res.status === 'success') {
|
||||
setHandbookExample(res.example_markdown);
|
||||
} else {
|
||||
setHandbookExample(`⚠️ ${res.message}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setHandbookExample(`⚠️ Error generating reference card: ${err.message}`);
|
||||
} finally {
|
||||
setIsLoadingExample(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckAnswer = () => {
|
||||
const checkPrompt =
|
||||
'Please evaluate my code implementation against all the requirements of this challenge. Check if my solution is complete and correct, point out any missing requirements or edge cases, and give me feedback on my answer.';
|
||||
handleAskMentor(checkPrompt);
|
||||
};
|
||||
|
||||
const handleKeyDownQuestion = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleAskMentor();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDownStdin = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSendStdin();
|
||||
}
|
||||
};
|
||||
|
||||
const activeCId = currentChallenge?.id || currentChallenge?.challenge_id || '';
|
||||
|
||||
// Filter handbook catalog based on active tab and search query
|
||||
const rawList = handbookTab === 'functions' ? handbookCatalog.functions : handbookCatalog.subjects;
|
||||
const filteredList = rawList.filter((item) =>
|
||||
searchQuery.trim() === ''
|
||||
? true
|
||||
: item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.desc.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-darkBg text-gray-100 font-sans overflow-hidden">
|
||||
{/* Header Bar */}
|
||||
<header className="h-14 border-b border-darkBorder bg-darkSurface px-4 flex justify-between items-center select-none shadow-md z-10 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1.5 hover:bg-gray-800 text-gray-400 hover:text-white rounded-lg transition-colors flex items-center gap-1 text-xs"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
<span>Dashboard</span>
|
||||
</button>
|
||||
<div className="h-4 w-px bg-darkBorder" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="text-blue-500" size={20} />
|
||||
<span className="font-bold text-sm text-white">TactiTerm</span>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-darkBorder" />
|
||||
|
||||
{/* Challenge Selector */}
|
||||
<select
|
||||
value={activeCId}
|
||||
onChange={(e) => handleSelectChallenge(e.target.value)}
|
||||
className="bg-darkBg border border-darkBorder text-gray-200 text-xs rounded-lg px-3 py-1.5 focus:outline-none focus:border-blue-500 font-medium"
|
||||
>
|
||||
{allChallenges.map((c) => {
|
||||
const cid = c.id || c.challenge_id;
|
||||
return (
|
||||
<option key={cid} value={cid}>
|
||||
{c.name} ({c.difficulty})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleRunCode()}
|
||||
disabled={isRunning || !code.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"
|
||||
>
|
||||
{isRunning ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Play size={14} fill="currentColor" />
|
||||
)}
|
||||
<span>Run</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLintCode}
|
||||
disabled={isLinting || !code.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"
|
||||
>
|
||||
{isLinting ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 size={14} />
|
||||
)}
|
||||
<span>Lint</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCheckAnswer}
|
||||
disabled={isLoadingGuidance || !code.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"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Check Answer</span>
|
||||
</button>
|
||||
|
||||
{/* Dual Sidebar Toggles */}
|
||||
<button
|
||||
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 ${
|
||||
rightSidebarMode === 'mentor'
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500/40'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700 border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span>Mentor</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setRightSidebarMode(rightSidebarMode === 'handbook' ? 'closed' : 'handbook')}
|
||||
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 === 'handbook'
|
||||
? 'bg-indigo-500/20 text-indigo-300 border-indigo-500/40'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700 border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<BookMarked size={14} />
|
||||
<span>Syntax Handbook</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Container */}
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
{/* Full-Screen Drag Overlay to bypass Monaco Editor event capture */}
|
||||
{draggingType && (
|
||||
<div
|
||||
onMouseMove={(e) => {
|
||||
if (draggingType === 'left') {
|
||||
const newWidth = Math.max(150, Math.min(Math.floor(window.innerWidth * 0.6), e.clientX));
|
||||
setLeftWidth(newWidth);
|
||||
} else if (draggingType === 'right') {
|
||||
const newWidth = Math.max(180, Math.min(Math.floor(window.innerWidth * 0.6), window.innerWidth - e.clientX));
|
||||
setRightWidth(newWidth);
|
||||
} else if (draggingType === 'output') {
|
||||
const maxHeight = Math.max(150, window.innerHeight - 150);
|
||||
const newHeight = Math.max(60, Math.min(maxHeight, window.innerHeight - e.clientY));
|
||||
setOutputHeight(newHeight);
|
||||
if (editorRef.current) {
|
||||
try { editorRef.current.layout(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
setDraggingType(null);
|
||||
document.body.style.cursor = 'default';
|
||||
document.body.style.userSelect = 'auto';
|
||||
}}
|
||||
className={`fixed inset-0 z-[99999] select-none ${
|
||||
draggingType === 'output' ? 'cursor-row-resize' : 'cursor-col-resize'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{/* Left Column: Challenge Specifications (Scalable Width) */}
|
||||
<div
|
||||
style={{ width: `${leftWidth}px` }}
|
||||
className="border-r border-transparent bg-darkBg flex flex-col overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="p-3 bg-darkSurface border-b border-darkBorder flex justify-between items-center">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-gray-400">Challenge Details</h2>
|
||||
{currentChallenge && (
|
||||
<span className="px-2 py-0.5 bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[10px] uppercase font-bold rounded">
|
||||
{currentChallenge.language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-5 overflow-y-auto space-y-6">
|
||||
{currentChallenge ? (
|
||||
<>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-2">{currentChallenge.name}</h1>
|
||||
<span
|
||||
className={`inline-block px-2.5 py-0.5 text-[10px] uppercase font-bold rounded border ${
|
||||
currentChallenge.difficulty.toLowerCase() === 'easy'
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20'
|
||||
: currentChallenge.difficulty.toLowerCase() === 'medium'
|
||||
? 'bg-amber-500/10 text-amber-400 border-amber-500/20'
|
||||
: 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
{currentChallenge.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-blue-400 mb-2">Description</h3>
|
||||
<p className="text-xs text-gray-300 leading-relaxed bg-darkSurface p-3 rounded-xl border border-darkBorder/50">
|
||||
{currentChallenge.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{currentChallenge.requirements && currentChallenge.requirements.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-blue-400 mb-2">Requirements</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{currentChallenge.requirements.map((req, idx) => (
|
||||
<li key={idx} className="text-xs text-gray-300 flex items-start gap-2">
|
||||
<span className="text-blue-500 font-bold">•</span>
|
||||
<span>{req}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentChallenge.hints && currentChallenge.hints.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-amber-400 mb-2">Hints</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{currentChallenge.hints.map((hint, idx) => (
|
||||
<li key={idx} className="text-xs text-gray-400 flex items-start gap-2 bg-amber-500/5 p-2 rounded-lg border border-amber-500/10">
|
||||
<HelpCircle size={14} className="text-amber-400 shrink-0 mt-0.5" />
|
||||
<span>{hint}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">Loading details...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizable Splitter Handle: Left Column */}
|
||||
<div
|
||||
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"
|
||||
title="Drag to resize Challenge Panel"
|
||||
>
|
||||
<GripVertical size={12} className="text-gray-600/40 group-hover:text-white" />
|
||||
</div>
|
||||
|
||||
{/* Middle Column: Monaco Code Editor & Resizable Output Console */}
|
||||
<div className="flex-1 flex flex-col bg-[#1e1e1e] overflow-hidden min-w-0 min-h-0">
|
||||
<div className="bg-darkBg text-gray-400 px-4 py-2 text-xs flex justify-between items-center border-b border-darkBorder shrink-0">
|
||||
<span className="font-mono text-blue-400">{getLanguageFileName(currentChallenge?.language)}</span>
|
||||
<span className="text-[11px] text-gray-500">TactiTerm IDE</span>
|
||||
</div>
|
||||
|
||||
{/* Code Editor */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage={getMonacoLanguage(currentChallenge?.language)}
|
||||
language={getMonacoLanguage(currentChallenge?.language)}
|
||||
defaultValue=""
|
||||
value={code}
|
||||
theme="vs-dark"
|
||||
onMount={(editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
registerMonacoCompletions(monaco);
|
||||
}}
|
||||
onChange={(val) => setCode(val || '')}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
padding: { top: 12 },
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resizable Splitter Handle: Output Console */}
|
||||
<div
|
||||
onMouseDown={startDraggingOutput}
|
||||
className="h-2.5 -my-1 z-10 bg-transparent hover:bg-blue-500/50 active:bg-blue-600 cursor-row-resize flex items-center justify-center shrink-0 group transition-colors select-none"
|
||||
title="Drag up/down to resize Output Console"
|
||||
>
|
||||
<GripHorizontal size={12} className="text-gray-600/40 group-hover:text-white" />
|
||||
</div>
|
||||
|
||||
{/* Scalable Output & Stdin Input Terminal Area */}
|
||||
<div
|
||||
style={{ height: `${outputHeight}px` }}
|
||||
className="border-t border-transparent bg-black/90 p-4 font-mono text-xs flex flex-col overflow-hidden shrink-0 min-h-0"
|
||||
>
|
||||
<div className="flex justify-between items-center text-gray-400 pb-2 mb-2 border-b border-gray-800 text-[11px] uppercase font-bold shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={14} className="text-blue-400" />
|
||||
<span>Output Console</span>
|
||||
{output.status === 'success' && <span className="text-green-400 lowercase font-normal">(clean)</span>}
|
||||
{output.status === 'error' && <span className="text-red-400 lowercase font-normal">(error)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre className={`flex-1 overflow-y-auto whitespace-pre-wrap leading-relaxed ${
|
||||
output.status === 'error' ? 'text-red-400' : 'text-emerald-400'
|
||||
}`}>
|
||||
{output.text}
|
||||
</pre>
|
||||
|
||||
{/* Interactive Stdin Input Bar */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-800 flex gap-2 font-sans shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={stdinText}
|
||||
onChange={(e) => setStdinText(e.target.value)}
|
||||
onKeyDown={handleKeyDownStdin}
|
||||
placeholder="Type program input (stdin) and hit Enter to run with input..."
|
||||
className="flex-1 bg-darkSurface border border-darkBorder rounded-lg px-3 py-1.5 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendStdin}
|
||||
disabled={isRunning}
|
||||
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"
|
||||
>
|
||||
<Send size={13} />
|
||||
<span>Send Input</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizable Splitter Handle: Right Column */}
|
||||
{rightSidebarMode !== 'closed' && (
|
||||
<div
|
||||
onMouseDown={startDraggingRight}
|
||||
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"
|
||||
title="Drag to resize Right Sidebar"
|
||||
>
|
||||
<GripVertical size={12} className="text-gray-600/40 group-hover:text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Column Mode 1: Mentor Sidebar (Scalable Width) */}
|
||||
{rightSidebarMode === 'mentor' && (
|
||||
<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-amber-400 text-xs uppercase tracking-wider">
|
||||
<MessageSquare size={16} /> Mentor
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setRightSidebarMode('closed')}
|
||||
className="text-gray-400 hover:text-white text-xs px-2 py-0.5 rounded hover:bg-gray-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mentor Guidance View */}
|
||||
<div className="flex-1 p-4 overflow-y-auto space-y-4">
|
||||
{isLoadingGuidance ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="w-10 h-10 border-4 border-amber-500/20 border-t-amber-400 rounded-full animate-spin" />
|
||||
<Sparkles size={16} className="absolute text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-darkBg border border-darkBorder rounded-xl shadow-inner">
|
||||
<MarkdownView content={guidance} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Question Input Area */}
|
||||
<div className="p-4 border-t border-darkBorder bg-darkBg flex flex-col gap-2">
|
||||
<label className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">
|
||||
Ask Question (Enter to Send):
|
||||
</label>
|
||||
<textarea
|
||||
value={userQuestion}
|
||||
onChange={(e) => setUserQuestion(e.target.value)}
|
||||
onKeyDown={handleKeyDownQuestion}
|
||||
placeholder="e.g. How should I structure my loop? Why is my variable returning None?"
|
||||
className="w-full h-20 bg-darkSurface border border-darkBorder rounded-lg p-2.5 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-amber-500 resize-none font-sans"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAskMentor()}
|
||||
disabled={isLoadingGuidance}
|
||||
className="flex-1 py-2 bg-amber-600 hover:bg-amber-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center justify-center gap-1.5 shadow-sm"
|
||||
>
|
||||
{isLoadingGuidance ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Send size={14} />
|
||||
)}
|
||||
<span>Ask Mentor</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCheckAnswer}
|
||||
disabled={isLoadingGuidance || !code.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"
|
||||
title="Check Answer"
|
||||
>
|
||||
{isLoadingGuidance ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Sparkles size={14} />
|
||||
)}
|
||||
<span>Check</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Column Mode 2: Handbook Sidebar (Scalable Width) */}
|
||||
{rightSidebarMode === 'handbook' && (
|
||||
<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-indigo-400 text-xs uppercase tracking-wider">
|
||||
<BookMarked size={16} /> Syntax Reference ({currentChallenge?.language || 'Python'})
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setRightSidebarMode('closed')}
|
||||
className="text-gray-400 hover:text-white text-xs px-2 py-0.5 rounded hover:bg-gray-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs & Search Bar */}
|
||||
<div className="p-3 border-b border-darkBorder bg-darkBg flex flex-col gap-2.5">
|
||||
<div className="flex bg-darkSurface p-1 rounded-xl border border-darkBorder">
|
||||
<button
|
||||
onClick={() => setHandbookTab('functions')}
|
||||
className={`flex-1 py-1.5 text-[11px] font-bold rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||
handbookTab === 'functions'
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Zap size={13} />
|
||||
<span>Built-ins</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setHandbookTab('subjects')}
|
||||
className={`flex-1 py-1.5 text-[11px] font-bold rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||
handbookTab === 'subjects'
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<BookOpen size={13} />
|
||||
<span>Subjects</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-2.5 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Filter functions or topics..."
|
||||
className="w-full bg-darkSurface border border-darkBorder rounded-lg pl-8 pr-3 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-indigo-500 font-sans"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Topic Catalog List (Fills full height when no topic selected) */}
|
||||
<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 && (
|
||||
<div className="flex justify-between items-center pb-1 text-[11px] text-gray-400 font-sans border-b border-darkBorder/40 mb-1">
|
||||
<span>Selected: <strong className="text-indigo-400">{selectedTopic.title}</strong></span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedTopic(null);
|
||||
setHandbookExample('');
|
||||
}}
|
||||
className="text-indigo-400 hover:text-indigo-300 underline text-[10px] font-semibold"
|
||||
>
|
||||
Clear selection (Show all)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredList.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 italic p-2">No matching functions or topics found.</p>
|
||||
) : (
|
||||
filteredList.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelectHandbookTopic(item)}
|
||||
className={`text-left p-2.5 rounded-lg text-xs transition-all border ${
|
||||
selectedTopic?.id === item.id
|
||||
? 'bg-indigo-600/20 border-indigo-500/50 text-indigo-300 font-bold'
|
||||
: 'bg-darkSurface border-darkBorder/50 text-gray-300 hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold flex items-center gap-1.5">
|
||||
{handbookTab === 'functions' ? <Zap size={12} className="text-amber-400" /> : <BookOpen size={12} className="text-blue-400" />}
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 font-normal line-clamp-1 mt-0.5">{item.desc}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generated LLM Reference Card View (ONLY renders after selecting a topic) */}
|
||||
{selectedTopic && (
|
||||
<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">
|
||||
{isLoadingExample ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="w-10 h-10 border-4 border-indigo-500/20 border-t-indigo-400 rounded-full animate-spin" />
|
||||
<BookMarked size={16} className="absolute text-indigo-400 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownView content={handbookExample} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Workspace;
|
||||
@@ -0,0 +1,642 @@
|
||||
import type { Monaco } from '@monaco-editor/react';
|
||||
|
||||
export function getMonacoLanguage(language?: string): string {
|
||||
if (!language) return 'python';
|
||||
const norm = language.toLowerCase().trim();
|
||||
const map: Record<string, string> = {
|
||||
'c++': 'cpp',
|
||||
'cpp': 'cpp',
|
||||
'cxx': 'cpp',
|
||||
'c#': 'csharp',
|
||||
'cs': 'csharp',
|
||||
'csharp': 'csharp',
|
||||
'js': 'javascript',
|
||||
'javascript': 'javascript',
|
||||
'ts': 'typescript',
|
||||
'typescript': 'typescript',
|
||||
'py': 'python',
|
||||
'python': 'python',
|
||||
'rs': 'rust',
|
||||
'rust': 'rust',
|
||||
'golang': 'go',
|
||||
'go': 'go',
|
||||
'html': 'html',
|
||||
'java': 'java',
|
||||
'lua': 'lua',
|
||||
};
|
||||
return map[norm] || norm;
|
||||
}
|
||||
|
||||
export function getLanguageFileName(language?: string): string {
|
||||
const lang = getMonacoLanguage(language);
|
||||
const extMap: Record<string, string> = {
|
||||
python: 'main.py',
|
||||
csharp: 'Program.cs',
|
||||
cpp: 'main.cpp',
|
||||
java: 'Main.java',
|
||||
javascript: 'main.js',
|
||||
typescript: 'main.ts',
|
||||
rust: 'main.rs',
|
||||
lua: 'main.lua',
|
||||
html: 'index.html',
|
||||
go: 'main.go',
|
||||
};
|
||||
return extMap[lang] || 'main.txt';
|
||||
}
|
||||
|
||||
function extractDocumentSymbols(code: string): string[] {
|
||||
const words = code.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
|
||||
const unique = new Set<string>();
|
||||
const reserved = new Set([
|
||||
'if', 'else', 'for', 'while', 'return', 'import', 'from', 'def', 'class',
|
||||
'public', 'private', 'protected', 'static', 'void', 'int', 'double', 'float',
|
||||
'bool', 'boolean', 'char', 'const', 'let', 'var', 'func', 'package', 'struct',
|
||||
]);
|
||||
for (const w of words) {
|
||||
if (w.length > 2 && !reserved.has(w)) {
|
||||
unique.add(w);
|
||||
}
|
||||
}
|
||||
return Array.from(unique);
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
export function registerMonacoCompletions(monaco: Monaco) {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
// ── JAVA COMPLETER ──────────────────────────────────────────
|
||||
monaco.languages.registerCompletionItemProvider('java', {
|
||||
triggerCharacters: ['.'],
|
||||
provideCompletionItems: (model, position) => {
|
||||
const lineUntilPosition = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
});
|
||||
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
const code = model.getValue();
|
||||
const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*)\.\s*$/);
|
||||
|
||||
if (dotMatch) {
|
||||
const objName = dotMatch[1];
|
||||
const lowerName = objName.toLowerCase();
|
||||
let targetType = 'unknown';
|
||||
|
||||
if (new RegExp(`Map<|HashMap<|TreeMap<`, 'i').test(code) && (lowerName.includes('map') || lowerName.includes('dict'))) {
|
||||
targetType = 'map';
|
||||
} else if (new RegExp(`List<|ArrayList<|LinkedList<`, 'i').test(code) && (lowerName.includes('list') || lowerName.includes('arr') || lowerName.includes('items'))) {
|
||||
targetType = 'list';
|
||||
} else if (new RegExp(`Set<|HashSet<`, 'i').test(code) && lowerName.includes('set')) {
|
||||
targetType = 'set';
|
||||
} else if (lowerName === 'out' || objName === 'System') {
|
||||
targetType = 'system';
|
||||
} else if (lowerName.includes('str') || lowerName.includes('text') || lowerName.includes('name') || lowerName.includes('msg')) {
|
||||
targetType = 'string';
|
||||
} else {
|
||||
if (lowerName.includes('map') || lowerName.includes('dict')) targetType = 'map';
|
||||
else if (lowerName.includes('list') || lowerName.includes('arr')) targetType = 'list';
|
||||
else if (lowerName.includes('set')) targetType = 'set';
|
||||
else targetType = 'general';
|
||||
}
|
||||
|
||||
const mapMethods = [
|
||||
{ label: 'put', insertText: 'put(${1:key}, ${2:value})', doc: 'Associates specified value with key in map.' },
|
||||
{ label: 'get', insertText: 'get(${1:key})', doc: 'Returns value mapped to specified key.' },
|
||||
{ label: 'containsKey', insertText: 'containsKey(${1:key})', doc: 'Returns true if map contains key.' },
|
||||
{ label: 'containsValue', insertText: 'containsValue(${1:value})', doc: 'Returns true if map contains value.' },
|
||||
{ label: 'size', insertText: 'size()', doc: 'Returns number of key-value mappings.' },
|
||||
{ label: 'isEmpty', insertText: 'isEmpty()', doc: 'Returns true if map contains no mappings.' },
|
||||
{ label: 'keySet', insertText: 'keySet()', doc: 'Returns Set view of keys in map.' },
|
||||
{ label: 'values', insertText: 'values()', doc: 'Returns Collection view of values in map.' },
|
||||
{ label: 'entrySet', insertText: 'entrySet()', doc: 'Returns Set view of mappings in map.' },
|
||||
{ label: 'remove', insertText: 'remove(${1:key})', doc: 'Removes mapping for key.' },
|
||||
{ label: 'clear', insertText: 'clear()', doc: 'Removes all mappings from map.' },
|
||||
{ label: 'getOrDefault', insertText: 'getOrDefault(${1:key}, ${2:defaultValue})', doc: 'Returns mapped value or default.' },
|
||||
];
|
||||
|
||||
const listMethods = [
|
||||
{ label: 'add', insertText: 'add(${1:element})', doc: 'Appends element to end of list.' },
|
||||
{ label: 'get', insertText: 'get(${1:index})', doc: 'Returns element at index.' },
|
||||
{ label: 'size', insertText: 'size()', doc: 'Returns number of elements.' },
|
||||
{ label: 'remove', insertText: 'remove(${1:index})', doc: 'Removes element at index.' },
|
||||
{ label: 'contains', insertText: 'contains(${1:element})', doc: 'Returns true if list contains element.' },
|
||||
{ label: 'indexOf', insertText: 'indexOf(${1:element})', doc: 'Returns index of element.' },
|
||||
{ label: 'isEmpty', insertText: 'isEmpty()', doc: 'Returns true if list contains no elements.' },
|
||||
{ label: 'clear', insertText: 'clear()', doc: 'Removes all elements.' },
|
||||
];
|
||||
|
||||
const stringMethods = [
|
||||
{ label: 'length', insertText: 'length()', doc: 'Returns length of string.' },
|
||||
{ label: 'substring', insertText: 'substring(${1:beginIndex})', doc: 'Returns substring starting at beginIndex.' },
|
||||
{ label: 'charAt', insertText: 'charAt(${1:index})', doc: 'Returns char value at index.' },
|
||||
{ label: 'toLowerCase', insertText: 'toLowerCase()', doc: 'Converts to lowercase.' },
|
||||
{ label: 'toUpperCase', insertText: 'toUpperCase()', doc: 'Converts to uppercase.' },
|
||||
{ label: 'trim', insertText: 'trim()', doc: 'Removes leading/trailing whitespace.' },
|
||||
{ label: 'split', insertText: 'split("${1:regex}")', doc: 'Splits string around matches.' },
|
||||
{ label: 'contains', insertText: 'contains("${1:str}")', doc: 'Returns true if contains string.' },
|
||||
{ label: 'startsWith', insertText: 'startsWith("${1:prefix}")', doc: 'Checks if starts with prefix.' },
|
||||
{ label: 'equals', insertText: 'equals(${1:anObject})', doc: 'Compares to specified object.' },
|
||||
];
|
||||
|
||||
const systemMethods = [
|
||||
{ label: 'println', insertText: 'println(${1:value});', doc: 'Prints value and terminates line.' },
|
||||
{ label: 'printf', insertText: 'printf("${1:%s}\\n", ${2:args});', doc: 'Prints formatted string.' },
|
||||
{ label: 'print', insertText: 'print(${1:value});', doc: 'Prints value.' },
|
||||
];
|
||||
|
||||
let selected = mapMethods;
|
||||
if (targetType === 'list') selected = listMethods;
|
||||
else if (targetType === 'string') selected = stringMethods;
|
||||
else if (targetType === 'system') selected = systemMethods;
|
||||
else if (targetType === 'general') selected = [...mapMethods, ...listMethods, ...stringMethods];
|
||||
|
||||
return {
|
||||
suggestions: selected.map(m => ({
|
||||
label: m.label,
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: m.insertText,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: m.doc,
|
||||
range,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const symbols = extractDocumentSymbols(code);
|
||||
const suggestions = [
|
||||
{ label: 'System.out.println', kind: monaco.languages.CompletionItemKind.Snippet, insertText: 'System.out.println(${1:value});', range },
|
||||
{ label: 'HashMap', kind: monaco.languages.CompletionItemKind.Class, insertText: 'Map<${1:String}, ${2:Integer}> ${3:map} = new HashMap<>();', range },
|
||||
{ label: 'ArrayList', kind: monaco.languages.CompletionItemKind.Class, insertText: 'List<${1:String}> ${2:list} = new ArrayList<>();', range },
|
||||
...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })),
|
||||
];
|
||||
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
|
||||
// ── PYTHON COMPLETER ────────────────────────────────────────
|
||||
monaco.languages.registerCompletionItemProvider('python', {
|
||||
triggerCharacters: ['.'],
|
||||
provideCompletionItems: (model, position) => {
|
||||
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: 'get', insertText: 'get(${1:key})', doc: 'Returns value for key.' },
|
||||
{ label: 'keys', insertText: 'keys()', doc: 'Returns dictionary keys.' },
|
||||
{ label: 'values', insertText: 'values()', doc: 'Returns dictionary values.' },
|
||||
{ label: 'items', insertText: 'items()', doc: 'Returns key-value pairs.' },
|
||||
{ label: 'append', insertText: 'append(${1:item})', doc: 'Appends item to list.' },
|
||||
{ label: 'pop', insertText: 'pop(${1:index})', doc: 'Removes and returns item.' },
|
||||
{ label: 'split', insertText: 'split("${1:sep}")', doc: 'Splits string.' },
|
||||
{ label: 'join', insertText: 'join(${1:iterable})', doc: 'Joins elements.' },
|
||||
];
|
||||
if (objName.includes('map') || objName.includes('dict')) {
|
||||
methods = [
|
||||
{ label: 'get', insertText: 'get(${1:key})', doc: 'Returns value for key.' },
|
||||
{ label: 'keys', insertText: 'keys()', doc: 'Returns dictionary keys.' },
|
||||
{ label: 'values', insertText: 'values()', doc: 'Returns dictionary values.' },
|
||||
{ label: 'items', insertText: 'items()', doc: 'Returns key-value pairs.' },
|
||||
{ label: 'update', insertText: 'update(${1:dict})', doc: 'Updates dictionary.' },
|
||||
{ label: 'pop', insertText: 'pop(${1:key})', doc: 'Removes key.' },
|
||||
];
|
||||
} else if (objName.includes('list') || objName.includes('arr')) {
|
||||
methods = [
|
||||
{ label: 'append', insertText: 'append(${1:item})', doc: 'Appends item.' },
|
||||
{ label: 'extend', insertText: 'extend(${1:iterable})', doc: 'Extends list.' },
|
||||
{ label: 'insert', insertText: 'insert(${1:index}, ${2:item})', doc: 'Inserts item.' },
|
||||
{ label: 'remove', insertText: 'remove(${1:item})', doc: 'Removes item.' },
|
||||
{ label: 'sort', insertText: 'sort()', doc: 'Sorts list.' },
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: methods.map(m => ({
|
||||
label: m.label,
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: m.insertText,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: m.doc,
|
||||
range,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const symbols = extractDocumentSymbols(code);
|
||||
const suggestions = [
|
||||
{ label: 'print', kind: monaco.languages.CompletionItemKind.Function, insertText: 'print(${1:value})', range },
|
||||
{ label: 'len', kind: monaco.languages.CompletionItemKind.Function, insertText: 'len(${1:obj})', range },
|
||||
{ label: 'range', kind: monaco.languages.CompletionItemKind.Function, insertText: 'range(${1:stop})', range },
|
||||
{ label: 'enumerate', kind: monaco.languages.CompletionItemKind.Function, insertText: 'enumerate(${1:iterable})', range },
|
||||
...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })),
|
||||
];
|
||||
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
|
||||
// ── C++ COMPLETER ───────────────────────────────────────────
|
||||
monaco.languages.registerCompletionItemProvider('cpp', {
|
||||
triggerCharacters: ['.', ':', '>'],
|
||||
provideCompletionItems: (model, position) => {
|
||||
const lineUntilPosition = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
});
|
||||
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
const code = model.getValue();
|
||||
const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*|\bstd\b)(\.|\:\:|->)\s*$/);
|
||||
|
||||
if (dotMatch) {
|
||||
const objName = dotMatch[1].toLowerCase();
|
||||
let methods = [
|
||||
{ label: 'push_back', insertText: 'push_back(${1:val})', doc: 'Appends element.' },
|
||||
{ label: 'pop_back', insertText: 'pop_back()', doc: 'Removes last element.' },
|
||||
{ label: 'size', insertText: 'size()', doc: 'Returns number of elements.' },
|
||||
{ label: 'empty', insertText: 'empty()', doc: 'Checks if container is empty.' },
|
||||
{ label: 'find', insertText: 'find(${1:key})', doc: 'Finds element.' },
|
||||
{ label: 'insert', insertText: 'insert(${1:val})', doc: 'Inserts element.' },
|
||||
{ label: 'clear', insertText: 'clear()', doc: 'Clears all elements.' },
|
||||
];
|
||||
if (objName === 'std') {
|
||||
methods = [
|
||||
{ label: 'cout', insertText: 'cout << ${1:value} << std::endl;', doc: 'Standard output stream.' },
|
||||
{ label: 'cin', insertText: 'cin >> ${1:var};', doc: 'Standard input stream.' },
|
||||
{ label: 'vector', insertText: 'vector<${1:int}> ${2:vec};', doc: 'Dynamic array container.' },
|
||||
{ label: 'map', insertText: 'map<${1:string}, ${2:int}> ${3:map};', doc: 'Sorted associative container.' },
|
||||
{ label: 'sort', insertText: 'sort(${1:begin}, ${2:end});', doc: 'Sorts elements.' },
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: methods.map(m => ({
|
||||
label: m.label,
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: m.insertText,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: m.doc,
|
||||
range,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const symbols = extractDocumentSymbols(code);
|
||||
return {
|
||||
suggestions: [
|
||||
{ label: 'std::cout', kind: monaco.languages.CompletionItemKind.Snippet, insertText: 'std::cout << ${1:value} << std::endl;', range },
|
||||
{ label: 'std::vector', kind: monaco.languages.CompletionItemKind.Class, insertText: 'std::vector<${1:int}> ${2:vec};', range },
|
||||
...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })),
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── C# COMPLETER ────────────────────────────────────────────
|
||||
monaco.languages.registerCompletionItemProvider('csharp', {
|
||||
triggerCharacters: ['.'],
|
||||
provideCompletionItems: (model, position) => {
|
||||
const lineUntilPosition = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
});
|
||||
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
const code = model.getValue();
|
||||
const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*)\.\s*$/);
|
||||
|
||||
if (dotMatch) {
|
||||
const objName = dotMatch[1].toLowerCase();
|
||||
let methods = [
|
||||
{ label: 'Add', insertText: 'Add(${1:val});', doc: 'Adds value.' },
|
||||
{ label: 'Remove', insertText: 'Remove(${1:val});', doc: 'Removes value.' },
|
||||
{ label: 'ContainsKey', insertText: 'ContainsKey(${1:key})', doc: 'Checks if dictionary contains key.' },
|
||||
{ label: 'Count', insertText: 'Count', doc: 'Gets number of elements.' },
|
||||
{ label: 'Clear', insertText: 'Clear()', doc: 'Removes all elements.' },
|
||||
];
|
||||
if (objName === 'console') {
|
||||
methods = [
|
||||
{ label: 'WriteLine', insertText: 'WriteLine(${1:value});', doc: 'Writes value to standard output.' },
|
||||
{ label: 'ReadLine', insertText: 'ReadLine()', doc: 'Reads next line.' },
|
||||
{ label: 'Write', insertText: 'Write(${1:value});', doc: 'Writes value.' },
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: methods.map(m => ({
|
||||
label: m.label,
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: m.insertText,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: m.doc,
|
||||
range,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const symbols = extractDocumentSymbols(code);
|
||||
return {
|
||||
suggestions: [
|
||||
{ label: 'Console.WriteLine', kind: monaco.languages.CompletionItemKind.Method, insertText: 'Console.WriteLine(${1:value});', range },
|
||||
{ label: 'Dictionary', kind: monaco.languages.CompletionItemKind.Class, insertText: 'Dictionary<${1:string}, ${2:int}> ${3:dict} = new Dictionary<${1:string}, ${2:int}>();', range },
|
||||
...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })),
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── JS & TS COMPLETER ───────────────────────────────────────
|
||||
['javascript', 'typescript'].forEach((lang) => {
|
||||
monaco.languages.registerCompletionItemProvider(lang, {
|
||||
triggerCharacters: ['.'],
|
||||
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: 'map', insertText: 'map((${1:x}) => ${2:x})', doc: 'Creates a new array with mapped elements.' },
|
||||
{ label: 'filter', insertText: 'filter((${1:x}) => ${2:true})', doc: 'Filters array.' },
|
||||
{ label: 'push', insertText: 'push(${1:item})', doc: 'Appends element.' },
|
||||
{ label: 'slice', insertText: 'slice(${1:start}, ${2:end})', doc: 'Returns section of array.' },
|
||||
{ label: 'length', insertText: 'length', doc: 'Gets length.' },
|
||||
{ label: 'get', insertText: 'get(${1:key})', doc: 'Gets Map element.' },
|
||||
{ label: 'set', insertText: 'set(${1:key}, ${2:val})', doc: 'Sets Map element.' },
|
||||
];
|
||||
if (objName === 'console') {
|
||||
methods = [
|
||||
{ label: 'log', insertText: 'log(${1:msg});', doc: 'Outputs message.' },
|
||||
{ label: 'error', insertText: 'error(${1:err});', doc: 'Outputs error.' },
|
||||
{ label: 'warn', insertText: 'warn(${1:msg});', doc: 'Outputs warning.' },
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: 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.log', kind: monaco.languages.CompletionItemKind.Snippet, insertText: 'console.log(${1:val});', range },
|
||||
...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })),
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── RUST COMPLETER ──────────────────────────────────────────
|
||||
monaco.languages.registerCompletionItemProvider('rust', {
|
||||
triggerCharacters: ['.', ':'],
|
||||
provideCompletionItems: (model, position) => {
|
||||
const lineUntilPosition = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
});
|
||||
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
const code = model.getValue();
|
||||
const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*|\bVec|\bHashMap)(\.|\:\:)\s*$/);
|
||||
|
||||
if (dotMatch) {
|
||||
let methods = [
|
||||
{ label: 'push', insertText: 'push(${1:val});', doc: 'Appends element.' },
|
||||
{ label: 'insert', insertText: 'insert(${1:key}, ${2:val});', doc: 'Inserts key-value pair.' },
|
||||
{ label: 'get', insertText: 'get(&${1:key})', doc: 'Returns reference to value.' },
|
||||
{ label: 'len', insertText: 'len()', doc: 'Returns length.' },
|
||||
{ label: 'is_empty', insertText: 'is_empty()', doc: 'Checks if empty.' },
|
||||
{ label: 'iter', insertText: 'iter()', doc: 'Returns iterator.' },
|
||||
];
|
||||
|
||||
return {
|
||||
suggestions: 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 })),
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── GO COMPLETER ────────────────────────────────────────────
|
||||
monaco.languages.registerCompletionItemProvider('go', {
|
||||
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: 'Println', insertText: 'Println(${1:v})', doc: 'Writes formatted line.' },
|
||||
{ label: 'Printf', insertText: 'Printf("${1:%v}\\n", ${2:v})', doc: 'Writes formatted string.' },
|
||||
{ label: 'Split', insertText: 'Split(${1:s}, "${2:sep}")', doc: 'Splits string.' },
|
||||
];
|
||||
|
||||
return {
|
||||
suggestions: methods.map(m => ({
|
||||
label: m.label,
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: m.insertText,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: m.doc,
|
||||
range,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const symbols = extractDocumentSymbols(code);
|
||||
return {
|
||||
suggestions: [
|
||||
{ label: 'fmt.Println', kind: monaco.languages.CompletionItemKind.Snippet, insertText: 'fmt.Println(${1:v})', range },
|
||||
{ label: 'make', kind: monaco.languages.CompletionItemKind.Function, insertText: 'make(${1:type}, ${2:len})', range },
|
||||
...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })),
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── LUA COMPLETER ───────────────────────────────────────────
|
||||
monaco.languages.registerCompletionItemProvider('lua', {
|
||||
triggerCharacters: ['.'],
|
||||
provideCompletionItems: (model, position) => {
|
||||
const lineUntilPosition = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
});
|
||||
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
const code = model.getValue();
|
||||
const dotMatch = lineUntilPosition.match(/([a-zA-Z_][a-zA-Z0-9_]*)\.\s*$/);
|
||||
|
||||
if (dotMatch) {
|
||||
let methods = [
|
||||
{ label: 'insert', insertText: 'insert(${1:t}, ${2:val})', doc: 'Inserts element.' },
|
||||
{ label: 'remove', insertText: 'remove(${1:t}, ${2:pos})', doc: 'Removes element.' },
|
||||
{ label: 'sub', insertText: 'sub(${1:s}, ${2:i}, ${3:j})', doc: 'Returns substring.' },
|
||||
];
|
||||
|
||||
return {
|
||||
suggestions: methods.map(m => ({
|
||||
label: m.label,
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: m.insertText,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: m.doc,
|
||||
range,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const symbols = extractDocumentSymbols(code);
|
||||
return {
|
||||
suggestions: [
|
||||
{ label: 'print', kind: monaco.languages.CompletionItemKind.Function, insertText: 'print(${1:value})', range },
|
||||
...symbols.map(s => ({ label: s, kind: monaco.languages.CompletionItemKind.Variable, insertText: s, range })),
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── HTML COMPLETER ──────────────────────────────────────────
|
||||
monaco.languages.registerCompletionItemProvider('html', {
|
||||
triggerCharacters: ['<'],
|
||||
provideCompletionItems: (model, position) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
return {
|
||||
suggestions: [
|
||||
{ label: 'div', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '<div>${1}</div>', range },
|
||||
{ label: 'span', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '<span>${1}</span>', range },
|
||||
{ label: 'p', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '<p>${1}</p>', range },
|
||||
{ label: 'h1', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '<h1>${1}</h1>', range },
|
||||
{ label: 'button', kind: monaco.languages.CompletionItemKind.Snippet, insertText: '<button>${1:Click}</button>', range },
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
primary: '#3b82f6',
|
||||
secondary: '#1e293b',
|
||||
accent: '#60a5fa',
|
||||
},
|
||||
darkBg: '#0f172a',
|
||||
darkSurface: '#1e293b',
|
||||
darkBorder: '#334155',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 10000,
|
||||
host: true,
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
'/challenges': 'http://127.0.0.1:8000',
|
||||
'/run': 'http://127.0.0.1:8000',
|
||||
'/lint': 'http://127.0.0.1:8000',
|
||||
'/guide': 'http://127.0.0.1:8000',
|
||||
'/handbook': 'http://127.0.0.1:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from src.core.loader import ChallengeLoader
|
||||
from src.core.executor import executor
|
||||
from src.core.linter import linter
|
||||
from src.core.prompts import get_socratic_prompt
|
||||
|
||||
app = FastAPI(title="Socratic Tutor API")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
loader = ChallengeLoader()
|
||||
|
||||
|
||||
class LintRequest(BaseModel):
|
||||
language: str
|
||||
code: str
|
||||
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class GuideRequest(BaseModel):
|
||||
challenge_id: str
|
||||
language: str
|
||||
code: str
|
||||
question: Optional[str] = ""
|
||||
|
||||
|
||||
class RunRequest(BaseModel):
|
||||
language: str
|
||||
code: str
|
||||
stdin: Optional[str] = ""
|
||||
|
||||
|
||||
@app.get("/challenges")
|
||||
def list_challenges():
|
||||
"""Returns a list of all challenge details."""
|
||||
challenges = loader.load_all()
|
||||
return {"challenges": [c.to_dict(challenge_id=k) for k, c in sorted(challenges.items())]}
|
||||
|
||||
|
||||
@app.get("/challenges/{challenge_id}")
|
||||
def get_challenge(challenge_id: str):
|
||||
"""Returns details of a single challenge."""
|
||||
challenges = loader.load_all()
|
||||
if challenge_id not in challenges:
|
||||
raise HTTPException(status_code=404, detail=f"Challenge '{challenge_id}' not found.")
|
||||
return challenges[challenge_id].to_dict(challenge_id=challenge_id)
|
||||
|
||||
|
||||
@app.post("/lint")
|
||||
def lint_code(req: LintRequest):
|
||||
"""Runs linting on user code using Core Linter."""
|
||||
res = linter.lint(req.language, req.code)
|
||||
if res.get("stderr") == f"Language '{req.language}' is not supported.":
|
||||
raise HTTPException(status_code=404, detail=res["stderr"])
|
||||
return res
|
||||
|
||||
|
||||
@app.post("/run")
|
||||
def run_code(req: RunRequest):
|
||||
"""Executes user code using Core Executor."""
|
||||
res = executor.run(req.language, req.code, stdin=req.stdin or "")
|
||||
if res.get("stderr") == f"Language '{req.language}' is not supported.":
|
||||
raise HTTPException(status_code=404, detail=res["stderr"])
|
||||
return res
|
||||
|
||||
|
||||
from src.core.mentor import mentor
|
||||
from src.core.generator import generator
|
||||
|
||||
|
||||
class GenerateChallengeRequest(BaseModel):
|
||||
prompt: str
|
||||
difficulty: Optional[str] = "Medium"
|
||||
language: Optional[str] = "Python"
|
||||
subject: Optional[str] = "General"
|
||||
|
||||
|
||||
class SaveChallengeRequest(BaseModel):
|
||||
filename: str
|
||||
markdown: str
|
||||
|
||||
|
||||
@app.post("/guide")
|
||||
async def guide_code(req: GuideRequest):
|
||||
"""Provides mentor guidance based on user's code and question for a challenge."""
|
||||
challenges = loader.load_all()
|
||||
if req.challenge_id not in challenges:
|
||||
raise HTTPException(status_code=404, detail=f"Challenge '{req.challenge_id}' not found.")
|
||||
|
||||
challenge = challenges[req.challenge_id]
|
||||
guidance = await mentor.get_guidance_async(
|
||||
challenge, req.code, user_question=req.question or ""
|
||||
)
|
||||
|
||||
return {
|
||||
"challenge_id": req.challenge_id,
|
||||
"status": guidance.get("status"),
|
||||
"mentor_response": guidance.get("mentor_response"),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/challenges/generate")
|
||||
async def generate_challenge_endpoint(req: GenerateChallengeRequest):
|
||||
"""Generates a new challenge markdown document via LLM."""
|
||||
res = await generator.generate_challenge_async(
|
||||
prompt_text=req.prompt,
|
||||
difficulty=req.difficulty or "Medium",
|
||||
language=req.language or "Python",
|
||||
subject=req.subject or "General",
|
||||
)
|
||||
if res.get("status") == "error":
|
||||
raise HTTPException(status_code=500, detail=res.get("message"))
|
||||
return res
|
||||
|
||||
|
||||
@app.post("/challenges/save")
|
||||
def save_challenge_endpoint(req: SaveChallengeRequest):
|
||||
"""Saves generated markdown challenge content to disk."""
|
||||
res = generator.save_challenge(req.filename, req.markdown)
|
||||
if res.get("status") == "error":
|
||||
raise HTTPException(status_code=500, detail=res.get("message"))
|
||||
return res
|
||||
|
||||
|
||||
from src.core.handbook import handbook_service
|
||||
|
||||
|
||||
class HandbookExampleRequest(BaseModel):
|
||||
language: str
|
||||
topic_id: str
|
||||
topic_title: str
|
||||
|
||||
|
||||
@app.get("/handbook/catalog/{language}")
|
||||
def get_handbook_catalog_endpoint(language: str):
|
||||
"""Returns syntax handbook catalog (functions & subjects) for specified language."""
|
||||
catalog = handbook_service.get_catalog(language)
|
||||
return {"language": language, "catalog": catalog}
|
||||
|
||||
|
||||
@app.get("/handbook/topics/{language}")
|
||||
def get_handbook_topics_endpoint(language: str):
|
||||
"""Returns syntax handbook catalog for backwards compatibility."""
|
||||
catalog = handbook_service.get_catalog(language)
|
||||
return {"language": language, "topics": catalog.get("functions", []) + catalog.get("subjects", [])}
|
||||
|
||||
|
||||
|
||||
@app.post("/handbook/example")
|
||||
async def get_handbook_example_endpoint(req: HandbookExampleRequest):
|
||||
"""Generates an LLM code example for a handbook reference topic."""
|
||||
res = await handbook_service.generate_example_async(
|
||||
language=req.language,
|
||||
topic_id=req.topic_id,
|
||||
topic_title=req.topic_title,
|
||||
)
|
||||
if res.get("status") == "error":
|
||||
raise HTTPException(status_code=500, detail=res.get("message"))
|
||||
return res
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
from src.core.config import config
|
||||
uvicorn.run("src.api.main:app", host=config.web_host, port=config.web_port, reload=True)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Challenge: Hello World
|
||||
**Difficulty:** Easy
|
||||
**Language:** Python
|
||||
**Subject:** Basic I/O
|
||||
|
||||
## Description
|
||||
Write a Python script that prints the phrase "Hello, World!" to the console.
|
||||
|
||||
## Requirements
|
||||
- The script must output exactly "Hello, World!".
|
||||
- The script should be named `hello.py`.
|
||||
|
||||
## Hints
|
||||
- Use the `print()` function in Python.
|
||||
- Ensure there are no extra spaces or characters in the string.
|
||||
|
||||
## Validation
|
||||
- **Check:** `python3 -c "import hello; print(hello.main())"`
|
||||
- **Expected Output:** Hello, World!
|
||||
@@ -0,0 +1,20 @@
|
||||
# Challenge: Variables and Assignment
|
||||
**Difficulty:** Easy
|
||||
**Language:** Python
|
||||
**Subject:** Variables & Types
|
||||
|
||||
## Description
|
||||
Create a variable named `score` and assign it the value 100. Then, print the message "The score is 100" to the console.
|
||||
|
||||
## Requirements
|
||||
- Create a variable named `score`.
|
||||
- Assign the integer value 100 to it.
|
||||
- Print "The score is 100".
|
||||
|
||||
## Hints
|
||||
- Use `score = 100` to create the variable.
|
||||
- Use `print("The score is 100")` to output the text.
|
||||
|
||||
## Validation
|
||||
- **Check:** `python3 -c "import main; print(main.score)"`
|
||||
- **Expected Output:** 100
|
||||
@@ -0,0 +1,24 @@
|
||||
# Challenge: For Loops
|
||||
**Difficulty:** Easy
|
||||
**Language:** Python
|
||||
**Subject:** Control Flow (Loops)
|
||||
|
||||
## Description
|
||||
Write a loop that prints the numbers from 1 to 5 (inclusive).
|
||||
|
||||
## Requirements
|
||||
- Use a `for` loop.
|
||||
- Print each number on a new line.
|
||||
|
||||
## Hints
|
||||
- The `range(1, 6)` function generates numbers from 1 to 5.
|
||||
- Use `print()` inside the loop body.
|
||||
|
||||
## Validation
|
||||
- **Check:** `python3 -c "import main; [print(i) for i in range(1, 6)]"`
|
||||
- **Expected Output:**
|
||||
1
|
||||
2
|
||||
3
|
||||
4
|
||||
5
|
||||
@@ -0,0 +1,20 @@
|
||||
# Challenge: Conditionals
|
||||
**Difficulty:** Easy
|
||||
**Language:** Python
|
||||
**Subject:** Control Flow (Conditionals)
|
||||
|
||||
## Description
|
||||
Write a program that checks if a number is positive, negative, or zero.
|
||||
|
||||
## Requirements
|
||||
- Create a variable `num` and assign it a value (e.g., -5).
|
||||
- Use `if`, `elif`, and `else` to check the value.
|
||||
- Print "Positive", "Negative", or "Zero" based on the value.
|
||||
|
||||
## Hints
|
||||
- Use `num > 0`, `num < 0`, and `num == 0` for the conditions.
|
||||
- Remember to use indentation for the code inside the blocks.
|
||||
|
||||
## Validation
|
||||
- **Check:** `python3 -c "import main; print(main.check_num(-5))"`
|
||||
- **Expected Output:** Negative
|
||||
@@ -0,0 +1,20 @@
|
||||
# Challenge: Functions
|
||||
**Difficulty:** Easy
|
||||
**Language:** Python
|
||||
**Subject:** Modular Programming (Functions)
|
||||
|
||||
## Description
|
||||
Write a function named `add_numbers` that takes two parameters, `a` and `b`, and returns their sum.
|
||||
|
||||
## Requirements
|
||||
- Define the function `add_numbers(a, b)`.
|
||||
- Use the `return` keyword.
|
||||
- Call the function and print the result of adding 5 and 10.
|
||||
|
||||
## Hints
|
||||
- `def add_numbers(a, b):` is the standard way to define a function.
|
||||
- Use `print(add_numbers(5, 10))` to see the result.
|
||||
|
||||
## Validation
|
||||
- **Check:** `python3 -c "import main; print(main.add_numbers(5, 10))"`
|
||||
- **Expected Output:** 15
|
||||
@@ -0,0 +1,20 @@
|
||||
# Challenge: List Basics
|
||||
**Difficulty:** Easy
|
||||
**Language:** Python
|
||||
**Subject:** Data Structures (Lists)
|
||||
|
||||
## Description
|
||||
Create a list of 3 fruits. Add a 4th fruit to the list and then print the final list.
|
||||
|
||||
## Requirements
|
||||
- Create a list named `fruits`.
|
||||
- Use `.append()` to add another item.
|
||||
- Print the final list.
|
||||
|
||||
## Hints
|
||||
- `fruits = ["apple", "banana", "cherry"]`
|
||||
- `fruits.append("date")`
|
||||
|
||||
## Validation
|
||||
- **Check:** `python3 -c "import main; print(main.fruits)"`
|
||||
- **Expected Output:** ['apple', 'banana', 'cherry', 'date']
|
||||
@@ -0,0 +1,26 @@
|
||||
# Challenge: Inventory Stock Counter
|
||||
**Difficulty:** Easy
|
||||
**Language:** Java
|
||||
**Subject:** HashMap Fundamentals
|
||||
|
||||
## Description
|
||||
In software engineering, HashMaps are one of the most frequently used data structures for creating efficient lookups. One of the most common "real-world" applications for a HashMap is counting occurrences—such as counting how many times a specific word appears in a book, how many users performed an action, or, in this case, how many units of a product are in a warehouse.
|
||||
|
||||
Your task is to build a simple Inventory Management tool. You will be given an array of strings where each string represents a product being scanned into a warehouse. If a product is scanned multiple times, it means multiple units of that same product are being added to the stock. You need to process this list and produce a summary showing the total count for each unique product.
|
||||
|
||||
## Requirements
|
||||
- Create a method `Map<String, Integer> countStock(String[] products)` that accepts an array of product names.
|
||||
- Use a `HashMap<String, Integer>` to store the results, where the **Key** is the product name (String) and the **Value** is the total count (Integer).
|
||||
- The method must iterate through the array and update the counts correctly:
|
||||
- If a product is not in the map yet, add it with a count of 1.
|
||||
- If a product is already in the map, increment its existing count by 1.
|
||||
- The final Map should be returned by the method.
|
||||
|
||||
## Hints
|
||||
- Use the `map.containsKey(key)` method to check if a product has already been encountered.
|
||||
- Alternatively, look into the `map.getOrDefault(key, 0)` method, which is a very "clean" way to handle values that might not exist yet.
|
||||
- Ensure you import `java.util.HashMap` and `java.util.Map`.
|
||||
|
||||
## Validation
|
||||
- **Check:** Call `countStock(new String[]{"apple", "banana", "apple", "orange", "banana", "apple"})`
|
||||
- **Expected Output:** A Map containing: `{apple=3, banana=2, orange=1}`
|
||||
@@ -0,0 +1,44 @@
|
||||
# Challenge: LRU Cache Implementation
|
||||
**Difficulty:** Medium
|
||||
**Language:** Java
|
||||
**Subject:** Data Structures
|
||||
|
||||
## Description
|
||||
In high-performance software engineering, caching is a critical technique used to reduce data retrieval time by storing frequently accessed information in fast-access memory. One of the most common eviction policies is **LRU (Least Recently Used)**. This policy discards the least recently accessed items first when the cache reaches its capacity.
|
||||
|
||||
Your task is to design and implement a data structure for an LRU Cache. The cache must support two primary operations: `get` and `put`.
|
||||
- `get(key)`: Retrieve the value associated with the key. If the key exists, it should be marked as "recently used." If it doesn't exist, return -1.
|
||||
- `put(key, value)`: Insert or update the value for a given key. If the key already exists, update its value and mark it as "recently used." If the key is new and the cache is at full capacity, you must remove the least recently used item before inserting the new one.
|
||||
|
||||
The primary constraint is that both `get` and `put` operations must run in **O(1)** average time complexity.
|
||||
|
||||
## Requirements
|
||||
- Implement a class `LRUCache` that takes an integer `capacity` as a constructor argument.
|
||||
- Implement the `get(int key)` method:
|
||||
- Return the value if the key exists; otherwise, return -1.
|
||||
- Moving a key to the "most recently used" position must happen automatically upon access.
|
||||
- Implement the `put(int key, int value)` method:
|
||||
- If the key exists, update the value and move it to the "most recently used" position.
|
||||
- If the key is new, add it to the cache.
|
||||
- If the cache exceeds `capacity`, remove the entry that was accessed least recently.
|
||||
- **Performance Constraint:** You must achieve $O(1)$ time complexity for both operations. (Hint: Using only a HashMap or only a Linked List will not satisfy the time complexity requirements for both operations simultaneously).
|
||||
|
||||
## Hints
|
||||
- To achieve $O(1)$ lookup, a `HashMap` is essential. However, a standard `HashMap` does not maintain the order of access.
|
||||
- To achieve $O(1)$ removal and insertion at specific positions, a **Doubly Linked List** is the ideal companion to the HashMap.
|
||||
- The HashMap should store the key as the map key and the corresponding Node object (from your Doubly Linked List) as the map value. This allows you to jump directly to the node in the list to re-link it in constant time.
|
||||
|
||||
## Validation
|
||||
- **Check:** Initialize `LRUCache cache = new LRUCache(2)`.
|
||||
- **Sequence:**
|
||||
1. `cache.put(1, 1)`
|
||||
2. `cache.put(2, 2)`
|
||||
3. `cache.get(1)` (Should return 1, and 1 becomes most recent)
|
||||
4. `cache.put(3, 3)` (Capacity is 2, so the least recently used key '2' should be evicted)
|
||||
5. `cache.get(2)` (Should return -1)
|
||||
6. `cache.get(3)` (Should return 3)
|
||||
7. `cache.put(4, 4)` (Capacity is 2, so the least recently used key '1' should be evicted)
|
||||
8. `cache.get(1)` (Should return -1)
|
||||
9. `cache.get(3)` (Should return 3)
|
||||
10. `cache.get(4)` (Should return 4)
|
||||
- **Expected Output:** `[1, -1, 3, -1, 3, 4]`
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Configuration loader for TactTerm."""
|
||||
import os
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class Config:
|
||||
"""Manages application configuration settings."""
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"llm": {
|
||||
"base_url": "http://localhost:8080/v1",
|
||||
"model": "local-model",
|
||||
"api_key": "not-needed",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
"timeout_seconds": 60.0,
|
||||
},
|
||||
"web": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8000,
|
||||
"public": False,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, config_path: str = "config.json") -> None:
|
||||
self.config_path = config_path
|
||||
self._data = self.load_config()
|
||||
|
||||
def load_config(self) -> Dict[str, Any]:
|
||||
# Deep copy default configuration structure
|
||||
config = json.loads(json.dumps(self.DEFAULT_CONFIG))
|
||||
if os.path.exists(self.config_path):
|
||||
try:
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
user_config = json.load(f)
|
||||
if "llm" in user_config and isinstance(user_config["llm"], dict):
|
||||
config["llm"].update(user_config["llm"])
|
||||
if "web" in user_config and isinstance(user_config["web"], dict):
|
||||
config["web"].update(user_config["web"])
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load {self.config_path}: {e}")
|
||||
else:
|
||||
try:
|
||||
dirname = os.path.dirname(self.config_path)
|
||||
if dirname:
|
||||
os.makedirs(dirname, exist_ok=True)
|
||||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to create default {self.config_path}: {e}")
|
||||
|
||||
|
||||
# Allow environment variable overrides
|
||||
env_base_url = os.getenv("TACTTERM_LLM_BASE_URL")
|
||||
if env_base_url:
|
||||
config["llm"]["base_url"] = env_base_url
|
||||
|
||||
env_model = os.getenv("TACTTERM_LLM_MODEL")
|
||||
if env_model:
|
||||
config["llm"]["model"] = env_model
|
||||
|
||||
env_api_key = os.getenv("TACTTERM_LLM_API_KEY")
|
||||
if env_api_key:
|
||||
config["llm"]["api_key"] = env_api_key
|
||||
|
||||
env_timeout = os.getenv("TACTTERM_LLM_TIMEOUT")
|
||||
if env_timeout:
|
||||
try:
|
||||
config["llm"]["timeout_seconds"] = float(env_timeout)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
env_web_host = os.getenv("TACTTERM_WEB_HOST")
|
||||
if env_web_host:
|
||||
config["web"]["host"] = env_web_host
|
||||
|
||||
env_web_port = os.getenv("TACTTERM_WEB_PORT")
|
||||
if env_web_port:
|
||||
try:
|
||||
config["web"]["port"] = int(env_web_port)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
env_web_public = os.getenv("TACTTERM_WEB_PUBLIC")
|
||||
if env_web_public is not None:
|
||||
config["web"]["public"] = env_web_public.lower() in ("true", "1", "yes")
|
||||
|
||||
return config
|
||||
|
||||
@property
|
||||
def llm_base_url(self) -> str:
|
||||
return self._data["llm"]["base_url"].rstrip("/")
|
||||
|
||||
@property
|
||||
def llm_model(self) -> str:
|
||||
return self._data["llm"]["model"]
|
||||
|
||||
@property
|
||||
def llm_api_key(self) -> str:
|
||||
return self._data["llm"]["api_key"]
|
||||
|
||||
@property
|
||||
def llm_temperature(self) -> float:
|
||||
return float(self._data["llm"]["temperature"])
|
||||
|
||||
@property
|
||||
def llm_max_tokens(self) -> int:
|
||||
return int(self._data["llm"]["max_tokens"])
|
||||
|
||||
@property
|
||||
def llm_timeout(self) -> float:
|
||||
return float(self._data["llm"]["timeout_seconds"])
|
||||
|
||||
@property
|
||||
def web_public(self) -> bool:
|
||||
return bool(self._data.get("web", {}).get("public", False))
|
||||
|
||||
@property
|
||||
def web_host(self) -> str:
|
||||
if self.web_public:
|
||||
return "0.0.0.0"
|
||||
return self._data.get("web", {}).get("host", "127.0.0.1")
|
||||
|
||||
@property
|
||||
def web_port(self) -> int:
|
||||
return int(self._data.get("web", {}).get("port", 8000))
|
||||
|
||||
|
||||
config = Config()
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Service for executing user code safely across 10 programming languages with stdin support."""
|
||||
import sys
|
||||
import os
|
||||
import tempfile
|
||||
import subprocess
|
||||
from typing import Dict, Any
|
||||
from src.core.registry import registry
|
||||
|
||||
|
||||
class CodeExecutor:
|
||||
"""Executes code for all 10 supported programming languages with stdin support."""
|
||||
|
||||
def run(self, language: str, code: str, stdin: str = "") -> Dict[str, Any]:
|
||||
config = registry.get_config(language)
|
||||
if not config:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": f"Language '{language}' is not supported.",
|
||||
}
|
||||
|
||||
lang_key = registry.canonical_name(language)
|
||||
ext = config.get("ext", ".txt")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
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)
|
||||
|
||||
try:
|
||||
if lang_key == "python":
|
||||
cmd = [sys.executable, temp_file_path]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "html":
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 0,
|
||||
"stdout": code,
|
||||
"stderr": "",
|
||||
}
|
||||
elif lang_key == "javascript":
|
||||
result = subprocess.run(
|
||||
["node", temp_file_path],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "typescript":
|
||||
result = subprocess.run(
|
||||
["npx", "ts-node", temp_file_path],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "go":
|
||||
result = subprocess.run(
|
||||
["go", "run", temp_file_path],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "lua":
|
||||
result = subprocess.run(
|
||||
["lua", temp_file_path],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "cpp":
|
||||
compile_res = subprocess.run(
|
||||
["g++", "-O2", temp_file_path, "-o", "main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
if compile_res.returncode != 0:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": compile_res.returncode,
|
||||
"stdout": compile_res.stdout,
|
||||
"stderr": f"Compilation Error:\n{compile_res.stderr}",
|
||||
}
|
||||
result = subprocess.run(
|
||||
["./main"],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "rust":
|
||||
compile_res = subprocess.run(
|
||||
["rustc", temp_file_path, "-o", "main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
if compile_res.returncode != 0:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": compile_res.returncode,
|
||||
"stdout": compile_res.stdout,
|
||||
"stderr": f"Rustc Compilation Error:\n{compile_res.stderr}",
|
||||
}
|
||||
result = subprocess.run(
|
||||
["./main"],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "java":
|
||||
compile_res = subprocess.run(
|
||||
["javac", temp_file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
if compile_res.returncode != 0:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": compile_res.returncode,
|
||||
"stdout": compile_res.stdout,
|
||||
"stderr": f"Javac Compilation Error:\n{compile_res.stderr}",
|
||||
}
|
||||
result = subprocess.run(
|
||||
["java", "Main"],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "csharp":
|
||||
result = subprocess.run(
|
||||
["dotnet", "run"],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
else:
|
||||
exec_cmd = config["exec"]
|
||||
result = subprocess.run(
|
||||
f"{exec_cmd} {temp_file_path}",
|
||||
shell=True,
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
except FileNotFoundError as fnf:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 127,
|
||||
"stdout": "",
|
||||
"stderr": f"Compiler/Interpreter binary not found for {language}: {fnf}",
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 124,
|
||||
"stdout": "",
|
||||
"stderr": "Execution timed out (exceeded 15 seconds).",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": str(e),
|
||||
}
|
||||
|
||||
|
||||
executor = CodeExecutor()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Service for generating and saving new programming challenges via LLM backend."""
|
||||
import os
|
||||
import re
|
||||
import httpx
|
||||
from typing import Dict, Any
|
||||
from src.core.config import config
|
||||
from src.core.mentor import get_chat_completions_url
|
||||
|
||||
SYSTEM_PROMPT_GENERATOR = """You are a Master Software Engineering Challenge Creator for TactiTerm.
|
||||
Your task is to generate a complete, high-quality coding challenge formatted strictly as a Markdown document according to the specification below.
|
||||
|
||||
### STRICT MARKDOWN FORMAT SPECIFICATION:
|
||||
```markdown
|
||||
# Challenge: <Short Clear Title>
|
||||
**Difficulty:** <Easy|Medium|Hard|Advanced>
|
||||
**Language:** <Python|C#|C++|Java|JavaScript|TypeScript|Rust|Lua|HTML|Go>
|
||||
**Subject:** <Specific Subject/Topic>
|
||||
|
||||
## Description
|
||||
<Clear multi-paragraph explanation of the problem, background context, and what the student needs to build.>
|
||||
|
||||
## Requirements
|
||||
- <Clear, objective requirement 1>
|
||||
- <Clear, objective requirement 2>
|
||||
- <Clear, objective requirement 3>
|
||||
|
||||
## Hints
|
||||
- <Helpful conceptual hint 1>
|
||||
- <Helpful conceptual hint 2>
|
||||
|
||||
## Validation
|
||||
- **Check:** <Validation command or test description>
|
||||
- **Expected Output:** <Expected output or return value>
|
||||
```
|
||||
|
||||
### RULES:
|
||||
1. ONLY return the valid Markdown content starting with `# Challenge: `.
|
||||
2. Do NOT enclose the entire Markdown output in outer markdown triple backticks.
|
||||
3. Ensure the Language is one of: Python, C#, C++, Java, JavaScript, TypeScript, Rust, Lua, HTML, Go.
|
||||
4. Ensure Difficulty is one of: Easy, Medium, Hard, Advanced.
|
||||
5. Create realistic, engaging, and pedagogically sound requirements and hints.
|
||||
"""
|
||||
|
||||
|
||||
class ChallengeGenerator:
|
||||
"""Generates new challenge markdown files using the configured LLM backend."""
|
||||
|
||||
def __init__(self, challenges_dir: str = "src/challenges/"):
|
||||
self.challenges_dir = challenges_dir
|
||||
|
||||
async def generate_challenge_async(
|
||||
self, prompt_text: str, difficulty: str = "Medium", language: str = "Python", subject: str = "General"
|
||||
) -> Dict[str, Any]:
|
||||
endpoint = get_chat_completions_url(config.llm_base_url)
|
||||
|
||||
user_content = (
|
||||
f"Generate a {difficulty} difficulty challenge in {language} focusing on the subject '{subject}'.\n"
|
||||
f"User Prompt Details: {prompt_text}"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": config.llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_GENERATOR},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": config.llm_max_tokens,
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if config.llm_api_key and config.llm_api_key != "not-needed":
|
||||
headers["Authorization"] = f"Bearer {config.llm_api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.llm_timeout, follow_redirects=True) as client:
|
||||
response = await client.post(endpoint, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
msg_obj = data.get("choices", [{}])[0].get("message", {})
|
||||
raw_md = msg_obj.get("content") or ""
|
||||
if not raw_md.strip() and msg_obj.get("reasoning_content"):
|
||||
raw_md = msg_obj.get("reasoning_content", "")
|
||||
raw_md = raw_md.strip()
|
||||
|
||||
# Clean up outer markdown wrapper if present
|
||||
if raw_md.startswith("```markdown"):
|
||||
raw_md = raw_md.replace("```markdown", "", 1).rstrip("` \n")
|
||||
elif raw_md.startswith("```"):
|
||||
raw_md = raw_md.replace("```", "", 1).rstrip("` \n")
|
||||
|
||||
if raw_md:
|
||||
# Generate filename slug from title
|
||||
title_match = re.search(r"# Challenge: (.*)", raw_md)
|
||||
title = title_match.group(1).strip() if title_match else "new-challenge"
|
||||
slug = re.sub(r"[^\w\s-]", "", title.lower()).strip().replace(" ", "-")
|
||||
filename = f"{slug}.md"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": filename,
|
||||
"markdown": raw_md,
|
||||
"title": title,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Received empty response from LLM server.",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to generate challenge via LLM: {e}",
|
||||
}
|
||||
|
||||
def save_challenge(self, filename: str, markdown_content: str) -> Dict[str, Any]:
|
||||
"""Save a generated markdown challenge file into src/challenges/."""
|
||||
if not filename.endswith(".md"):
|
||||
filename = f"{filename}.md"
|
||||
|
||||
# Sanitize filename
|
||||
safe_filename = os.path.basename(filename)
|
||||
save_path = os.path.join(self.challenges_dir, safe_filename)
|
||||
|
||||
os.makedirs(self.challenges_dir, exist_ok=True)
|
||||
try:
|
||||
with open(save_path, "w", encoding="utf-8") as f:
|
||||
f.write(markdown_content)
|
||||
return {
|
||||
"status": "success",
|
||||
"path": save_path,
|
||||
"filename": safe_filename,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to save challenge to {save_path}: {e}",
|
||||
}
|
||||
|
||||
|
||||
generator = ChallengeGenerator()
|
||||
@@ -0,0 +1,388 @@
|
||||
"""Language Reference Handbook service for built-in functions, methods, primitive types, and topics."""
|
||||
import httpx
|
||||
from typing import Dict, List, Any
|
||||
from src.core.config import config
|
||||
from src.core.mentor import get_chat_completions_url
|
||||
from src.core.registry import registry
|
||||
|
||||
HANDBOOK_CATALOG: Dict[str, Dict[str, List[Dict[str, str]]]] = {
|
||||
"python": {
|
||||
"functions": [
|
||||
{"id": "type_int", "title": "int", "desc": "Integer numerical data type (e.g. 42, -7)."},
|
||||
{"id": "type_float", "title": "float", "desc": "Floating-point real numerical data type (e.g. 3.14159)."},
|
||||
{"id": "type_bool", "title": "bool", "desc": "Boolean truth value type (True or False)."},
|
||||
{"id": "type_str", "title": "str", "desc": "Immutable text string data type."},
|
||||
{"id": "type_list", "title": "list", "desc": "Mutable ordered sequence collection type."},
|
||||
{"id": "type_dict", "title": "dict", "desc": "Key-value dictionary mapping collection type."},
|
||||
{"id": "type_set", "title": "set", "desc": "Unordered collection of unique items."},
|
||||
{"id": "type_tuple", "title": "tuple", "desc": "Immutable ordered sequence collection."},
|
||||
{"id": "print", "title": "print()", "desc": "Prints specified objects to standard output."},
|
||||
{"id": "input", "title": "input()", "desc": "Reads line from standard input as string."},
|
||||
{"id": "len", "title": "len()", "desc": "Returns the number of items in a container."},
|
||||
{"id": "range", "title": "range()", "desc": "Generates a sequence of numbers from start to stop."},
|
||||
{"id": "enumerate", "title": "enumerate()", "desc": "Yields index and value tuples from an iterable."},
|
||||
{"id": "zip", "title": "zip()", "desc": "Iterates over multiple iterables in parallel."},
|
||||
{"id": "map", "title": "map()", "desc": "Applies function to all items in an iterable."},
|
||||
{"id": "filter", "title": "filter()", "desc": "Filters elements of iterable where predicate is True."},
|
||||
{"id": "sorted", "title": "sorted()", "desc": "Returns new sorted list from items in iterable."},
|
||||
{"id": "isinstance", "title": "isinstance()", "desc": "Checks if object is an instance of a class."},
|
||||
{"id": "type_func", "title": "type()", "desc": "Returns the type of an object."},
|
||||
{"id": "open", "title": "open()", "desc": "Opens a file and returns file object."},
|
||||
{"id": "str_split", "title": "str.split()", "desc": "Splits string into list using delimiter."},
|
||||
{"id": "str_join", "title": "str.join()", "desc": "Concatenates string elements with separator."},
|
||||
{"id": "str_replace", "title": "str.replace()", "desc": "Replaces occurrences of substring."},
|
||||
{"id": "dict_get", "title": "dict.get()", "desc": "Returns value for key or default if missing."},
|
||||
{"id": "list_append", "title": "list.append()", "desc": "Appends item to end of list."},
|
||||
{"id": "sum", "title": "sum()", "desc": "Sums items of an iterable."},
|
||||
{"id": "min_max", "title": "min() / max()", "desc": "Returns smallest or largest item."},
|
||||
{"id": "abs", "title": "abs()", "desc": "Returns absolute value of number."},
|
||||
{"id": "round", "title": "round()", "desc": "Rounds float to given precision."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Primitive Types", "desc": "int, float, bool, str, dynamic typing, mutability."},
|
||||
{"id": "control", "title": "Control Flow & Loops", "desc": "if, elif, else, for, while, break, continue, pass."},
|
||||
{"id": "funcs", "title": "Functions & Type Hints", "desc": "def, return, lambda, *args, **kwargs, docstrings."},
|
||||
{"id": "collections", "title": "Lists, Dicts, Sets & Tuples", "desc": "Comprehensions, operations, set math, tuple unpacking."},
|
||||
{"id": "errors", "title": "Exception Handling", "desc": "try, except, else, finally, raise, custom exceptions."},
|
||||
{"id": "oop", "title": "Classes & OOP Concepts", "desc": "class, __init__, self, inheritance, super(), dunder methods."},
|
||||
{"id": "modules", "title": "Modules & Standard Library", "desc": "import, math, sys, os, json, random, datetime."},
|
||||
{"id": "decorators", "title": "Decorators & Generators", "desc": "@decorator syntax, yield, generator expressions."},
|
||||
],
|
||||
},
|
||||
"java": {
|
||||
"functions": [
|
||||
{"id": "type_int", "title": "int / long", "desc": "32-bit and 64-bit primitive signed integer data types."},
|
||||
{"id": "type_boolean", "title": "boolean", "desc": "Primitive boolean data type (true or false)."},
|
||||
{"id": "type_double", "title": "float / double", "desc": "Single and double precision floating point types."},
|
||||
{"id": "type_char", "title": "char", "desc": "Single 16-bit Unicode character primitive."},
|
||||
{"id": "type_string", "title": "String", "desc": "Immutable sequence of characters in Java."},
|
||||
{"id": "sys_out", "title": "System.out.println()", "desc": "Prints text to standard output with newline."},
|
||||
{"id": "sys_print", "title": "System.out.print()", "desc": "Prints text to standard output without newline."},
|
||||
{"id": "scanner_next", "title": "Scanner.nextLine()", "desc": "Reads line of user input from System.in."},
|
||||
{"id": "str_length", "title": "String.length()", "desc": "Returns total character count of string."},
|
||||
{"id": "str_char_at", "title": "String.charAt()", "desc": "Returns character at specified 0-based index."},
|
||||
{"id": "str_substring", "title": "String.substring()", "desc": "Extracts substring between start and end index."},
|
||||
{"id": "str_equals", "title": "String.equals()", "desc": "Compares two strings for content equality."},
|
||||
{"id": "str_split", "title": "String.split()", "desc": "Splits string into array using regex delimiter."},
|
||||
{"id": "str_replace", "title": "String.replace()", "desc": "Replaces all target character sequences."},
|
||||
{"id": "math_abs", "title": "Math.abs()", "desc": "Returns absolute positive value of number."},
|
||||
{"id": "math_max", "title": "Math.max() / Math.min()", "desc": "Returns greater or lesser of two numerical values."},
|
||||
{"id": "math_pow", "title": "Math.pow()", "desc": "Raises base to exponent power (e.g. Math.pow(2, 3) = 8)."},
|
||||
{"id": "math_sqrt", "title": "Math.sqrt()", "desc": "Returns square root of a double value."},
|
||||
{"id": "math_random", "title": "Math.random()", "desc": "Returns pseudo-random double between 0.0 and 1.0."},
|
||||
{"id": "arrays_sort", "title": "Arrays.sort()", "desc": "Sorts primitive or object array in ascending order."},
|
||||
{"id": "list_add", "title": "List.add()", "desc": "Appends element to Java List collection."},
|
||||
{"id": "list_get", "title": "List.get()", "desc": "Returns element at specified index in List."},
|
||||
{"id": "list_size", "title": "List.size()", "desc": "Returns number of elements in collection."},
|
||||
{"id": "map_put", "title": "Map.put() / Map.get()", "desc": "Stores key-value pair or retrieves value by key in HashMap."},
|
||||
{"id": "set_add", "title": "Set.add()", "desc": "Adds element to HashSet if not already present."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Primitive Types", "desc": "int, double, boolean, String, char, float, final, scope."},
|
||||
{"id": "control", "title": "Control Flow & Loops", "desc": "if, else if, else, switch, for, for-each, while, do-while."},
|
||||
{"id": "arrays", "title": "Arrays & Collections", "desc": "1D/2D arrays, ArrayList, HashMap, HashSet, Iterators."},
|
||||
{"id": "methods", "title": "Methods & Overloading", "desc": "Signatures, return values, parameters, method overloading, static."},
|
||||
{"id": "classes", "title": "Classes & OOP Concepts", "desc": "Classes, objects, constructors, encapsulation, getters/setters."},
|
||||
{"id": "inheritance", "title": "Inheritance & Polymorphism", "desc": "extends, super, method overriding, @Override, dynamic dispatch."},
|
||||
{"id": "interfaces", "title": "Interfaces & Abstraction", "desc": "abstract class, abstract methods, interface, implements, default methods."},
|
||||
{"id": "exceptions", "title": "Exception Handling", "desc": "try, catch, finally, throw, throws, custom Exception classes."},
|
||||
{"id": "generics", "title": "Generics & Type Safety", "desc": "Generic classes, <T>, List<T>, bounded wildcards (? extends T)."},
|
||||
{"id": "packages", "title": "Packages & Access Modifiers", "desc": "package, import, public, private, protected, package-private."},
|
||||
],
|
||||
},
|
||||
"csharp": {
|
||||
"functions": [
|
||||
{"id": "type_int", "title": "int / long", "desc": "Signed 32-bit and 64-bit integer primitive types."},
|
||||
{"id": "type_bool", "title": "bool", "desc": "Boolean primitive type (true or false)."},
|
||||
{"id": "type_float", "title": "float / double / decimal", "desc": "Single, double, and high-precision financial decimal types."},
|
||||
{"id": "type_string", "title": "string", "desc": "UTF-16 text string type in C#."},
|
||||
{"id": "console_write", "title": "Console.WriteLine()", "desc": "Writes line terminator to standard output."},
|
||||
{"id": "console_read", "title": "Console.ReadLine()", "desc": "Reads next line of characters from stdin."},
|
||||
{"id": "str_length", "title": "string.Length", "desc": "Gets number of characters in string."},
|
||||
{"id": "str_substr", "title": "string.Substring()", "desc": "Retrieves substring starting at index."},
|
||||
{"id": "str_split", "title": "string.Split()", "desc": "Splits string into array based on separator."},
|
||||
{"id": "str_replace", "title": "string.Replace()", "desc": "Replaces all occurrences of string/char."},
|
||||
{"id": "str_contains", "title": "string.Contains()", "desc": "Checks if string contains specified substring."},
|
||||
{"id": "math_abs", "title": "Math.Abs()", "desc": "Returns absolute value of number."},
|
||||
{"id": "math_max", "title": "Math.Max() / Math.Min()", "desc": "Returns larger or smaller of two values."},
|
||||
{"id": "math_pow", "title": "Math.Pow()", "desc": "Raises number to specified power."},
|
||||
{"id": "math_sqrt", "title": "Math.Sqrt()", "desc": "Returns square root of a number."},
|
||||
{"id": "rand_next", "title": "Random.Next()", "desc": "Generates random non-negative integer."},
|
||||
{"id": "list_add", "title": "List<T>.Add()", "desc": "Adds object to end of List<T>."},
|
||||
{"id": "list_remove", "title": "List<T>.Remove()", "desc": "Removes first occurrence of specific object."},
|
||||
{"id": "dict_add", "title": "Dictionary<K,V>[key]", "desc": "Key-value pair map accessor in C#."},
|
||||
{"id": "linq_where", "title": "LINQ (.Where(), .Select())", "desc": "Language Integrated Query operations on collections."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Primitive Types", "desc": "var, int, bool, string, double, decimal, const, readonly."},
|
||||
{"id": "control", "title": "Control Flow & Switches", "desc": "if, else, switch expressions, for, foreach, while, do while."},
|
||||
{"id": "methods", "title": "Methods & Parameters", "desc": "Methods, ref, out, in, optional parameters, extension methods."},
|
||||
{"id": "classes", "title": "Classes, Structs & Records", "desc": "class, struct, record, properties (get; set;), constructors."},
|
||||
{"id": "inheritance", "title": "Inheritance & Polymorphism", "desc": "virtual, override, sealed, base, abstract classes."},
|
||||
{"id": "interfaces", "title": "Interfaces & Abstraction", "desc": "interface, explicit implementation, default interface methods."},
|
||||
{"id": "generics", "title": "Generics & Collections", "desc": "List<T>, Dictionary<K,V>, HashSet<T>, generic constraints."},
|
||||
{"id": "linq", "title": "LINQ & Lambda Expressions", "desc": "Query syntax, method syntax, delegates, Func<T>, Action<T>."},
|
||||
{"id": "exceptions", "title": "Exception Handling", "desc": "try, catch, finally, throw, exception filters (when)."},
|
||||
],
|
||||
},
|
||||
"cpp": {
|
||||
"functions": [
|
||||
{"id": "type_int", "title": "int / long long", "desc": "Signed 32-bit and 64-bit integer primitive types."},
|
||||
{"id": "type_bool", "title": "bool", "desc": "Boolean primitive type (true or false)."},
|
||||
{"id": "type_double", "title": "float / double", "desc": "Single or double precision floating point."},
|
||||
{"id": "type_string", "title": "std::string", "desc": "Standard library C++ string class."},
|
||||
{"id": "type_vector", "title": "std::vector<T>", "desc": "Dynamic array sequence container."},
|
||||
{"id": "std_cout", "title": "std::cout / std::cin", "desc": "Standard stream output and input operations."},
|
||||
{"id": "std_getline", "title": "std::getline()", "desc": "Reads line from input stream into string."},
|
||||
{"id": "vec_push_back", "title": "std::vector::push_back()", "desc": "Adds element to end of vector."},
|
||||
{"id": "std_sort", "title": "std::sort()", "desc": "Sorts range [first, last) in ascending order."},
|
||||
{"id": "make_unique", "title": "std::make_unique()", "desc": "Constructs std::unique_ptr object."},
|
||||
{"id": "make_shared", "title": "std::make_shared()", "desc": "Constructs std::shared_ptr object."},
|
||||
{"id": "math_abs", "title": "std::abs()", "desc": "Returns absolute value of number."},
|
||||
{"id": "math_max", "title": "std::max() / std::min()", "desc": "Returns maximum or minimum of two values."},
|
||||
{"id": "math_pow", "title": "std::pow() / std::sqrt()", "desc": "Calculates power or square root of double."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Primitive Data Types", "desc": "int, float, double, bool, char, void, const, constexpr, auto."},
|
||||
{"id": "control", "title": "Control Flow & Loops", "desc": "if, else, for, range-based for (auto& x : vec), while, switch."},
|
||||
{"id": "pointers", "title": "Pointers & References", "desc": "Raw pointers (*), references (&), nullptr, address-of (&)."},
|
||||
{"id": "memory", "title": "Smart Pointers & Memory", "desc": "unique_ptr, shared_ptr, weak_ptr, RAII pattern."},
|
||||
{"id": "classes", "title": "Classes & OOP", "desc": "class, struct, public, private, protected, constructors, destructors."},
|
||||
{"id": "inheritance", "title": "Inheritance & Virtual Functions", "desc": "Inheritance, virtual, override, pure virtual (= 0), abstract classes."},
|
||||
{"id": "templates", "title": "Templates & Generics", "desc": "Function templates, class templates, generic programming."},
|
||||
{"id": "stl", "title": "STL Containers & Iterators", "desc": "vector, map, set, unordered_map, pair, iterators (begin, end)."},
|
||||
{"id": "exceptions", "title": "Exception Handling", "desc": "try, catch, throw, std::exception, noexcept."},
|
||||
],
|
||||
},
|
||||
"javascript": {
|
||||
"functions": [
|
||||
{"id": "type_boolean", "title": "boolean", "desc": "Boolean primitive type (true or false)."},
|
||||
{"id": "type_number", "title": "number", "desc": "IEEE 754 floating-point numerical type."},
|
||||
{"id": "type_string", "title": "string", "desc": "Textual string primitive data type."},
|
||||
{"id": "console_log", "title": "console.log()", "desc": "Outputs message to debugging console."},
|
||||
{"id": "fetch", "title": "fetch()", "desc": "Asynchronously fetches network resource via HTTP."},
|
||||
{"id": "arr_map", "title": "Array.prototype.map()", "desc": "Creates new array with mapped elements."},
|
||||
{"id": "arr_filter", "title": "Array.prototype.filter()", "desc": "Filters elements satisfying predicate function."},
|
||||
{"id": "arr_reduce", "title": "Array.prototype.reduce()", "desc": "Executes reducer function across elements."},
|
||||
{"id": "arr_includes", "title": "Array.prototype.includes()", "desc": "Determines whether array contains value."},
|
||||
{"id": "arr_push", "title": "Array.prototype.push() / pop()", "desc": "Appends or removes items from array end."},
|
||||
{"id": "obj_keys", "title": "Object.keys() / values()", "desc": "Returns array of object key names or values."},
|
||||
{"id": "json_parse", "title": "JSON.parse() / stringify()", "desc": "Parses JSON text or serializes object."},
|
||||
{"id": "math_max", "title": "Math.max() / Math.min()", "desc": "Returns largest or smallest of numbers."},
|
||||
{"id": "math_random", "title": "Math.random()", "desc": "Returns pseudo-random number between 0 and 1."},
|
||||
{"id": "str_includes", "title": "String.prototype.includes()", "desc": "Checks if string contains substring."},
|
||||
{"id": "str_split", "title": "String.prototype.split()", "desc": "Splits string into array using separator."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Primitives", "desc": "const, let, var, number, string, boolean, null, undefined."},
|
||||
{"id": "control", "title": "Control Flow & Logic", "desc": "if, else, ternary (? :), switch, for, for...of, for...in, while."},
|
||||
{"id": "funcs", "title": "Arrow Functions & Scope", "desc": "function, => arrow syntax, closures, lexical this."},
|
||||
{"id": "objects", "title": "Objects & Prototypes", "desc": "Object literals, prototype inheritance, ES6 class syntax."},
|
||||
{"id": "async", "title": "Asynchronous JS & Promises", "desc": "Callbacks, Promise, async, await, event loop."},
|
||||
{"id": "modules", "title": "ES Modules (Import/Export)", "desc": "import, export default, named exports, CommonJS require."},
|
||||
{"id": "destructuring", "title": "Destructuring & Rest/Spread", "desc": "Array/Object destructuring, ...rest, ...spread operator."},
|
||||
],
|
||||
},
|
||||
"typescript": {
|
||||
"functions": [
|
||||
{"id": "type_boolean", "title": "boolean", "desc": "Boolean type annotation (true or false)."},
|
||||
{"id": "type_number", "title": "number", "desc": "Numerical primitive type annotation."},
|
||||
{"id": "type_string", "title": "string", "desc": "Textual string type annotation."},
|
||||
{"id": "type_any", "title": "any / unknown", "desc": "Escape hatch any or type-safe unknown."},
|
||||
{"id": "type_never", "title": "never / void", "desc": "Unreachable type or function returning nothing."},
|
||||
{"id": "partial", "title": "Partial<T>", "desc": "Constructs type with all properties optional."},
|
||||
{"id": "required", "title": "Required<T>", "desc": "Constructs type with all properties required."},
|
||||
{"id": "readonly", "title": "Readonly<T>", "desc": "Constructs type with all properties read-only."},
|
||||
{"id": "record", "title": "Record<K, V>", "desc": "Constructs object type with keys K and value V."},
|
||||
{"id": "pick", "title": "Pick<T, K> / Omit<T, K>", "desc": "Selects or removes subset of properties K from T."},
|
||||
{"id": "returntype", "title": "ReturnType<T>", "desc": "Extracts return type of function type T."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "types", "title": "Type Annotations & Primitives", "desc": "number, string, boolean, explicit typing, type inference."},
|
||||
{"id": "interfaces", "title": "Interfaces & Type Aliases", "desc": "interface vs type, extending interfaces, index signatures."},
|
||||
{"id": "unions", "title": "Unions & Type Narrowing", "desc": "type A | B, typeof, instanceof, custom type predicates (is)."},
|
||||
{"id": "generics", "title": "Generics & Constraints", "desc": "Generic functions (<T>), generic interfaces, extends constraints."},
|
||||
{"id": "enums", "title": "Enums & Literal Types", "desc": "enum, const enum, string/number literal union types."},
|
||||
{"id": "classes", "title": "Classes & Access Modifiers", "desc": "public, private, protected, readonly, abstract classes."},
|
||||
{"id": "decorators", "title": "Decorators & Utility Types", "desc": "Class/method decorators, built-in TS utility type library."},
|
||||
],
|
||||
},
|
||||
"rust": {
|
||||
"functions": [
|
||||
{"id": "type_bool", "title": "bool", "desc": "Boolean primitive type (true or false)."},
|
||||
{"id": "type_i32", "title": "i32 / i64", "desc": "Signed integer primitive types (32-bit or 64-bit)."},
|
||||
{"id": "type_u32", "title": "u32 / u64", "desc": "Unsigned integer primitive types (32-bit or 64-bit)."},
|
||||
{"id": "type_f64", "title": "f32 / f64", "desc": "Single and double precision floating-point types."},
|
||||
{"id": "type_str", "title": "str / String", "desc": "String slice (&str) and owned String buffer."},
|
||||
{"id": "type_vec", "title": "Vec<T>", "desc": "Growable heap-allocated vector collection."},
|
||||
{"id": "type_option", "title": "Option<T>", "desc": "Type representing optional value (Some or None)."},
|
||||
{"id": "type_result", "title": "Result<T, E>", "desc": "Type representing outcome (Ok or Err)."},
|
||||
{"id": "println", "title": "println!()", "desc": "Prints formatted text to stdout with newline."},
|
||||
{"id": "format", "title": "format!()", "desc": "Constructs formatted String using macro interpolation."},
|
||||
{"id": "vec_macro", "title": "vec![]", "desc": "Creates vector containing given elements."},
|
||||
{"id": "vec_push", "title": "Vec::push()", "desc": "Appends element to back of vector."},
|
||||
{"id": "string_from", "title": "String::from()", "desc": "Creates owned String from string literal."},
|
||||
{"id": "option_unwrap", "title": "Option::unwrap()", "desc": "Returns contained Some value or panics."},
|
||||
{"id": "result_expect", "title": "Result::expect()", "desc": "Returns contained Ok value or panics with message."},
|
||||
{"id": "iter_collect", "title": "Iterator::collect()", "desc": "Transforms iterator into collection."},
|
||||
{"id": "box_new", "title": "Box::new()", "desc": "Allocates memory on heap."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Primitives", "desc": "let, let mut, immutability, bool, i32, f64, char, str."},
|
||||
{"id": "ownership", "title": "Ownership, Borrowing & Lifetimes", "desc": "Move semantics, references (&), mutable (&mut), lifetime ('a)."},
|
||||
{"id": "control", "title": "Control Flow & Pattern Matching", "desc": "if, loop, while, for, match, if let, while let."},
|
||||
{"id": "structs", "title": "Structs & Implementations", "desc": "struct (field, tuple, unit), impl blocks, associated functions."},
|
||||
{"id": "enums", "title": "Enums & Option/Result", "desc": "enum, Option<T>, Result<T, E>, pattern matching."},
|
||||
{"id": "traits", "title": "Traits & Generics", "desc": "trait definition, impl Trait, generic functions, trait bounds."},
|
||||
{"id": "errors", "title": "Error Handling & Panic", "desc": "panic!(), recoverable errors with Result, ? operator."},
|
||||
{"id": "modules", "title": "Modules & Cargo Crates", "desc": "mod, use, pub, module hierarchy, Cargo dependencies."},
|
||||
],
|
||||
},
|
||||
"go": {
|
||||
"functions": [
|
||||
{"id": "type_int", "title": "int / int64", "desc": "Signed integer numerical primitive types."},
|
||||
{"id": "type_bool", "title": "bool", "desc": "Boolean primitive type (true or false)."},
|
||||
{"id": "type_float", "title": "float64", "desc": "64-bit floating point real number type."},
|
||||
{"id": "type_string", "title": "string", "desc": "Immutable sequence of bytes / UTF-8 text."},
|
||||
{"id": "type_error", "title": "error", "desc": "Built-in interface type for error handling."},
|
||||
{"id": "fmt_println", "title": "fmt.Println() / Printf()", "desc": "Formatted write to standard output."},
|
||||
{"id": "make", "title": "make()", "desc": "Allocates and initializes slice, map, or channel."},
|
||||
{"id": "new", "title": "new()", "desc": "Allocates memory and returns pointer to zeroed value."},
|
||||
{"id": "append", "title": "append()", "desc": "Appends elements to end of slice."},
|
||||
{"id": "len", "title": "len() / cap()", "desc": "Returns length or capacity of collection."},
|
||||
{"id": "delete", "title": "delete()", "desc": "Deletes key entry from map."},
|
||||
{"id": "str_contains", "title": "strings.Contains()", "desc": "Reports whether substring is within string."},
|
||||
{"id": "str_atoi", "title": "strconv.Atoi()", "desc": "Converts string representation of integer to int."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Primitives", "desc": "var, short declaration (:=), zero values, const, type cast."},
|
||||
{"id": "control", "title": "Control Flow & Switch", "desc": "if with init, for loops (single loop construct), switch."},
|
||||
{"id": "funcs", "title": "Functions & Multiple Returns", "desc": "func, multiple returns (val, err), variadic ...T, defer."},
|
||||
{"id": "pointers", "title": "Pointers & Addresses", "desc": "Pointer types (*T), address-of (&), dereferencing (*)."},
|
||||
{"id": "structs", "title": "Structs & Methods", "desc": "type Struct struct, value vs pointer receivers (s *Struct)."},
|
||||
{"id": "interfaces", "title": "Interfaces & Duck Typing", "desc": "type Interface interface, implicit satisfaction, interface{}/any."},
|
||||
{"id": "slices", "title": "Slices & Maps", "desc": "Slicing arrays arr[1:4], make(), map access (val, ok), range."},
|
||||
{"id": "concurrency", "title": "Goroutines & Channels", "desc": "go func(), buffered/unbuffered chan, select statement."},
|
||||
{"id": "errors", "title": "Error Handling", "desc": "Explicit error return (val, err), nil checks, custom error types."},
|
||||
],
|
||||
},
|
||||
"lua": {
|
||||
"functions": [
|
||||
{"id": "type_number", "title": "number", "desc": "Numerical data type in Lua (float/int)."},
|
||||
{"id": "type_boolean", "title": "boolean", "desc": "Boolean truth value (true or false)."},
|
||||
{"id": "type_string", "title": "string", "desc": "Byte sequence string data type."},
|
||||
{"id": "type_table", "title": "table", "desc": "Universal associative array data structure."},
|
||||
{"id": "print", "title": "print()", "desc": "Prints values to standard output."},
|
||||
{"id": "type_func", "title": "type()", "desc": "Returns string name of value data type."},
|
||||
{"id": "tostring", "title": "tostring() / tonumber()", "desc": "Converts value to string or number."},
|
||||
{"id": "table_insert", "title": "table.insert() / remove()", "desc": "Inserts or removes element from array table."},
|
||||
{"id": "table_sort", "title": "table.sort()", "desc": "Sorts elements of array table in-place."},
|
||||
{"id": "pairs", "title": "pairs() / ipairs()", "desc": "Iterators for key-value or index-value table loops."},
|
||||
{"id": "str_len", "title": "string.len() / sub()", "desc": "Returns string length or substring slice."},
|
||||
{"id": "math_abs", "title": "math.abs() / random()", "desc": "Returns absolute value or pseudo-random float."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Scope", "desc": "local vs global, dynamic typing, nil, numbers, booleans, strings."},
|
||||
{"id": "control", "title": "Control Flow & Loops", "desc": "if, elseif, else, while, repeat...until, for (numeric/generic)."},
|
||||
{"id": "funcs", "title": "Functions & First-Class Functions", "desc": "function declaration, anonymous functions, multiple returns, ..."},
|
||||
{"id": "tables", "title": "Tables as Arrays & Dictionaries", "desc": "1-indexed arrays, key-value maps, nested tables."},
|
||||
{"id": "metatables", "title": "Metatables & OOP", "desc": "setmetatable(), __index, __newindex, OOP patterns in Lua."},
|
||||
{"id": "modules", "title": "Modules & Packages", "desc": "require(), returning module tables."},
|
||||
],
|
||||
},
|
||||
"html": {
|
||||
"functions": [
|
||||
{"id": "elem_doctype", "title": "<!DOCTYPE html>", "desc": "Declares document type as modern HTML5."},
|
||||
{"id": "elem_html", "title": "<html>", "desc": "Root element enclosing entire HTML document."},
|
||||
{"id": "elem_head", "title": "<head> / <title>", "desc": "Document metadata container and window title."},
|
||||
{"id": "elem_body", "title": "<body>", "desc": "Container for all visible web page content."},
|
||||
{"id": "elem_div", "title": "<div> / <span>", "desc": "Generic block-level and inline layout container elements."},
|
||||
{"id": "elem_headings", "title": "<h1> to <h6>", "desc": "Section heading tags ordered by hierarchy."},
|
||||
{"id": "elem_p", "title": "<p> / <br>", "desc": "Paragraph text block and line break elements."},
|
||||
{"id": "elem_a", "title": "<a>", "desc": "Anchor link element with href target attribute."},
|
||||
{"id": "elem_img", "title": "<img>", "desc": "Embedded image element with src and alt attributes."},
|
||||
{"id": "elem_lists", "title": "<ul> / <ol> / <li>", "desc": "Unordered bulleted or ordered numbered list items."},
|
||||
{"id": "elem_table", "title": "<table> / <tr> / <td>", "desc": "Tabular data container, table rows, and data cells."},
|
||||
{"id": "elem_form", "title": "<form>", "desc": "Interactive container for submitting user input controls."},
|
||||
{"id": "elem_input", "title": "<input>", "desc": "User input field (text, password, checkbox, radio, button)."},
|
||||
{"id": "elem_button", "title": "<button>", "desc": "Clickable action button element."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "struct", "title": "Document Structure & Setup", "desc": "DOCTYPE, html, head, body, meta tags, UTF-8 charset."},
|
||||
{"id": "semantic", "title": "Semantic HTML5 Elements", "desc": "main, nav, header, footer, section, article, layout semantics."},
|
||||
{"id": "forms", "title": "Forms & User Input Controls", "desc": "form, input types, label, select, textarea, button, validation."},
|
||||
{"id": "links", "title": "Links & Media Embeds", "desc": "Anchor tags (href, target), img, video, audio, iframe, SVG."},
|
||||
{"id": "tables", "title": "Tables & Data Display", "desc": "table, thead, tbody, tr, th, td, colspan, rowspan."},
|
||||
{"id": "attributes", "title": "Attributes & Accessibility", "desc": "id, class, style, title, data-* attributes, ARIA roles."},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class HandbookService:
|
||||
"""Language Reference Handbook service."""
|
||||
|
||||
def get_catalog(self, language: str) -> Dict[str, List[Dict[str, str]]]:
|
||||
canonical = registry.canonical_name(language)
|
||||
return HANDBOOK_CATALOG.get(canonical, HANDBOOK_CATALOG["python"])
|
||||
|
||||
async def generate_example_async(self, language: str, topic_id: str, topic_title: str) -> Dict[str, Any]:
|
||||
endpoint = get_chat_completions_url(config.llm_base_url)
|
||||
canonical = registry.canonical_name(language)
|
||||
config_info = registry.get_config(canonical)
|
||||
lang_name = config_info.get("name", language) if config_info else language
|
||||
|
||||
prompt = (
|
||||
f"Generate a Reference Card for '{topic_title}' in {lang_name}.\n"
|
||||
f"Structure your response strictly into these four Markdown sections:\n"
|
||||
f"1. **Syntax / Signature:** Clear representation of how to invoke or write it.\n"
|
||||
f"2. **Description & Parameters:** Brief summary of parameter types and return value.\n"
|
||||
f"3. **Try It Yourself (Executable Code):** A clean, self-contained, working code snippet in ```{canonical} code blocks.\n"
|
||||
f"4. **Key Notes & Best Practices:** 2 bullet points on common pitfalls or best practices."
|
||||
)
|
||||
|
||||
sys_prompt = (
|
||||
"You are an expert Documentation Assistant for TactiTerm.\n"
|
||||
"Provide clean, educational reference cards. Ensure the code example is realistic, complete, and copy-pasteable."
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": config.llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": sys_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"max_tokens": max(config.llm_max_tokens, 4096),
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if config.llm_api_key and config.llm_api_key != "not-needed":
|
||||
headers["Authorization"] = f"Bearer {config.llm_api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.llm_timeout, follow_redirects=True) as client:
|
||||
response = await client.post(endpoint, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
msg_obj = data.get("choices", [{}])[0].get("message", {})
|
||||
example_md = msg_obj.get("content") or ""
|
||||
if not example_md.strip() and msg_obj.get("reasoning_content"):
|
||||
example_md = msg_obj.get("reasoning_content", "")
|
||||
example_md = example_md.strip()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"language": lang_name,
|
||||
"topic_title": topic_title,
|
||||
"example_markdown": example_md,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to generate reference card via LLM: {e}",
|
||||
}
|
||||
|
||||
|
||||
handbook_service = HandbookService()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Service for linting user code across 10 programming languages."""
|
||||
import sys
|
||||
import os
|
||||
import tempfile
|
||||
import subprocess
|
||||
from typing import Dict, Any
|
||||
from src.core.registry import registry
|
||||
|
||||
|
||||
class CodeLinter:
|
||||
"""Lints user code for syntax and style issues for 10 languages."""
|
||||
|
||||
def lint(self, language: str, code: str) -> Dict[str, Any]:
|
||||
config = registry.get_config(language)
|
||||
if not config:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": f"Language '{language}' is not supported.",
|
||||
}
|
||||
|
||||
lang_key = registry.canonical_name(language)
|
||||
ext = config.get("ext", ".txt")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
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)
|
||||
|
||||
try:
|
||||
if lang_key == "python":
|
||||
cmd = [sys.executable, "-m", "flake8", temp_path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "javascript":
|
||||
result = subprocess.run(["node", "--check", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "typescript":
|
||||
result = subprocess.run(["npx", "tsc", "--noEmit", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "lua":
|
||||
result = subprocess.run(["luac", "-p", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "go":
|
||||
result = subprocess.run(["go", "vet", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "cpp":
|
||||
result = subprocess.run(["g++", "-fsyntax-only", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "rust":
|
||||
result = subprocess.run(["rustc", "--emit=metadata", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "java":
|
||||
result = subprocess.run(["javac", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "html":
|
||||
result = subprocess.run(["python3", "-c", f"import html.parser; html.parser.HTMLParser().feed(open('{temp_path}').read())"], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
else:
|
||||
lint_cmd = config["lint"]
|
||||
result = subprocess.run(f"{lint_cmd} {temp_path}", shell=True, capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
except FileNotFoundError as fnf:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 0,
|
||||
"stdout": f"✓ Syntax check skipped (linter tool not installed for {language}).",
|
||||
"stderr": "",
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 124,
|
||||
"stdout": "",
|
||||
"stderr": "Linting timed out.",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": str(e),
|
||||
}
|
||||
|
||||
|
||||
linter = CodeLinter()
|
||||
@@ -0,0 +1,107 @@
|
||||
import re
|
||||
import os
|
||||
from typing import List, Dict, Optional
|
||||
from src.core.models import Challenge
|
||||
|
||||
|
||||
class ChallengeLoader:
|
||||
def __init__(self, challenges_dir: str = "src/challenges/"):
|
||||
self.challenges_dir = challenges_dir
|
||||
|
||||
def load_all(self) -> Dict[str, Challenge]:
|
||||
challenges = {}
|
||||
if not os.path.exists(self.challenges_dir):
|
||||
return challenges
|
||||
|
||||
for filename in os.listdir(self.challenges_dir):
|
||||
if filename.endswith(".md"):
|
||||
path = os.path.join(self.challenges_dir, filename)
|
||||
challenge = self.parse_file(path)
|
||||
if challenge:
|
||||
key = filename.replace(".md", "")
|
||||
challenges[key] = challenge
|
||||
return challenges
|
||||
|
||||
def parse_file(self, path: str) -> Optional[Challenge]:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse YAML frontmatter if present
|
||||
fm_data = {}
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm_raw = parts[1]
|
||||
content = parts[2]
|
||||
for line in fm_raw.splitlines():
|
||||
if ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
fm_data[k.strip().lower()] = v.strip()
|
||||
|
||||
name = fm_data.get("name")
|
||||
difficulty = fm_data.get("difficulty")
|
||||
language = fm_data.get("language")
|
||||
subject = fm_data.get("subject") or fm_data.get("topic")
|
||||
|
||||
# Regex extraction fallback for markdown headers
|
||||
if not name:
|
||||
name_match = re.search(r"# (?:Challenge: )?(.*)", content)
|
||||
name = name_match.group(1).strip() if name_match else None
|
||||
|
||||
if not difficulty:
|
||||
diff_match = re.search(r"\*\*Difficulty:\*\* (.*)", content)
|
||||
difficulty = diff_match.group(1).strip() if diff_match else "Medium"
|
||||
|
||||
if not language:
|
||||
lang_match = re.search(r"\*\*Language:\*\* (.*)", content)
|
||||
language = lang_match.group(1).strip() if lang_match else "Python"
|
||||
|
||||
if not subject:
|
||||
subj_match = re.search(r"\*\*(?:Subject|Topic):\*\* (.*)", content)
|
||||
subject = subj_match.group(1).strip() if subj_match else "General Concepts"
|
||||
|
||||
desc_match = re.search(r"## Description\n(.*?)(?=\n##|$)", content, re.DOTALL)
|
||||
req_match = re.search(r"## Requirements\n(.*?)(?=\n##|$)", content, re.DOTALL)
|
||||
hint_match = re.search(r"## Hints\n(.*?)(?=\n##|$)", content, re.DOTALL)
|
||||
valid_match = re.search(r"## Validation\n-\s*\*\*Check:\*\* (.*)", content)
|
||||
exp_match = re.search(r"\*\*Expected Output:\*\* (.*)", content)
|
||||
|
||||
if not name:
|
||||
return None
|
||||
|
||||
description = desc_match.group(1).strip() if desc_match else ""
|
||||
|
||||
requirements = (
|
||||
[
|
||||
r.strip("- ").strip()
|
||||
for r in req_match.group(1).strip().split("\n")
|
||||
if r.strip("- ")
|
||||
]
|
||||
if req_match
|
||||
else []
|
||||
)
|
||||
|
||||
hints = (
|
||||
[
|
||||
h.strip("- ").strip()
|
||||
for h in hint_match.group(1).strip().split("\n")
|
||||
if h.strip("- ")
|
||||
]
|
||||
if hint_match
|
||||
else []
|
||||
)
|
||||
|
||||
validation_check = valid_match.group(1).strip() if valid_match else ""
|
||||
expected_output = exp_match.group(1).strip() if exp_match else ""
|
||||
|
||||
return Challenge(
|
||||
name=name,
|
||||
difficulty=difficulty,
|
||||
language=language,
|
||||
subject=subject,
|
||||
description=description,
|
||||
requirements=requirements,
|
||||
hints=hints,
|
||||
validation_check=validation_check,
|
||||
expected_output=expected_output,
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Mentor LLM client communicating with llama.cpp server (OpenAI API compatible)."""
|
||||
import httpx
|
||||
from typing import Dict, Any, Optional
|
||||
from src.core.config import config
|
||||
from src.core.prompts import get_mentor_prompt, get_socratic_prompt
|
||||
|
||||
|
||||
def get_chat_completions_url(base_url: str) -> str:
|
||||
url = base_url.strip().rstrip("/")
|
||||
if url.endswith("/chat/completions"):
|
||||
return url
|
||||
if url.endswith("/v1"):
|
||||
return f"{url}/chat/completions"
|
||||
return f"{url}/v1/chat/completions"
|
||||
|
||||
|
||||
class MentorClient:
|
||||
"""LLM client for software engineering tutoring via OpenAI-compatible endpoints."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.config = config
|
||||
|
||||
async def get_guidance_async(
|
||||
self, challenge: object, user_code: str, user_question: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch mentor guidance asynchronously from the configured LLM endpoint."""
|
||||
prompt = get_mentor_prompt(challenge, user_code, user_question=user_question)
|
||||
endpoint = get_chat_completions_url(self.config.llm_base_url)
|
||||
|
||||
user_content = (
|
||||
f"Question for Mentor: {user_question}\n\nPlease evaluate my code or answer my question for this challenge."
|
||||
if user_question.strip()
|
||||
else "Please review my code structure and provide guidance for my current step."
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": self.config.llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": self.config.llm_temperature,
|
||||
"max_tokens": self.config.llm_max_tokens,
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.config.llm_api_key and self.config.llm_api_key != "not-needed":
|
||||
headers["Authorization"] = f"Bearer {self.config.llm_api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.config.llm_timeout, follow_redirects=True) as client:
|
||||
response = await client.post(endpoint, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
msg_obj = data.get("choices", [{}])[0].get("message", {})
|
||||
content = msg_obj.get("content") or ""
|
||||
if not content.strip() and msg_obj.get("reasoning_content"):
|
||||
content = msg_obj.get("reasoning_content", "")
|
||||
content = content.strip()
|
||||
|
||||
if content:
|
||||
return {
|
||||
"status": "success",
|
||||
"mentor_response": content,
|
||||
"offline": False,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"mentor_response": "Received empty response from LLM server.",
|
||||
"offline": False,
|
||||
}
|
||||
except (httpx.ConnectError, httpx.TimeoutException, httpx.TransportError):
|
||||
return {
|
||||
"status": "offline",
|
||||
"mentor_response": (
|
||||
f"⚠️ LLM Server offline or unreachable at {self.config.llm_base_url}.\n"
|
||||
"Start your llama.cpp server with:\n"
|
||||
" ./llama-server -m <your-model.gguf> --port 8080\n\n"
|
||||
"💡 Offline Hint:\n"
|
||||
"How is your code structuring the initial state before entering your main logic loop?"
|
||||
),
|
||||
"offline": True,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"mentor_response": f"⚠️ Error consulting LLM mentor: {e}",
|
||||
"offline": False,
|
||||
}
|
||||
|
||||
def get_guidance(
|
||||
self, challenge: object, user_code: str, user_question: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""Synchronous wrapper for fetching mentor guidance."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
return loop.run_until_complete(
|
||||
self.get_guidance_async(challenge, user_code, user_question=user_question)
|
||||
)
|
||||
else:
|
||||
return asyncio.run(
|
||||
self.get_guidance_async(challenge, user_code, user_question=user_question)
|
||||
)
|
||||
except Exception:
|
||||
return asyncio.run(
|
||||
self.get_guidance_async(challenge, user_code, user_question=user_question)
|
||||
)
|
||||
|
||||
|
||||
SocraticMentor = MentorClient
|
||||
mentor = MentorClient()
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
class Challenge:
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
difficulty: str,
|
||||
language: str,
|
||||
subject: str,
|
||||
description: str,
|
||||
requirements: List[str],
|
||||
hints: List[str],
|
||||
validation_check: str,
|
||||
expected_output: str,
|
||||
):
|
||||
self.name = name
|
||||
self.difficulty = difficulty
|
||||
self.language = language
|
||||
self.subject = subject if subject else "General Software Engineering"
|
||||
self.description = description
|
||||
self.requirements = requirements
|
||||
self.hints = hints
|
||||
self.validation_check = validation_check
|
||||
self.expected_output = expected_output
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Challenge: {self.name} [{self.subject}] ({self.language})>"
|
||||
|
||||
def to_dict(self, challenge_id: str = "") -> Dict[str, Any]:
|
||||
return {
|
||||
"id": challenge_id,
|
||||
"challenge_id": challenge_id,
|
||||
"name": self.name,
|
||||
"difficulty": self.difficulty,
|
||||
"language": self.language,
|
||||
"subject": self.subject,
|
||||
"description": self.description,
|
||||
"requirements": self.requirements,
|
||||
"hints": self.hints,
|
||||
"validation_check": self.validation_check,
|
||||
"expected_output": self.expected_output,
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Mentor system prompt and prompt generation utilities."""
|
||||
|
||||
SYSTEM_PROMPT_MENTOR = """You are a Software Engineering Mentor.
|
||||
Your ultimate mission is to build the user's confidence in designing and constructing complex software programs from scratch.
|
||||
|
||||
### YOUR CORE RESPONSIBILITIES:
|
||||
1. **Upfront Program Structure:** Guide the user on how to structure their software cleanly from the start — decomposing complex problems into small modular functions, defining clear data flow, and choosing appropriate data structures.
|
||||
2. **Roadblock Breaking & Solution Evaluation:** When the user asks a question, encounters bugs, or asks to check their answer, evaluate their code against all challenge requirements, illuminate root causes, and provide clear constructive feedback.
|
||||
3. **Illustrative Code Examples:** You MAY provide abstract code templates, conceptual code snippets, and syntax examples (e.g. showing how to define a function `def function_name(param):`, how to use `try/except`, or how to structure a loop) to help the user learn programming constructs.
|
||||
|
||||
### STRICT GUARDRAILS (NEVER BREAK THESE):
|
||||
- **NEVER** write or provide the specific solution code, completed logic, or full answer for the active challenge.
|
||||
- **NEVER** solve the challenge's core problem for the user.
|
||||
- Abstract syntax snippets and structural templates ARE allowed as long as they demonstrate general programming concepts rather than giving away the specific challenge solution.
|
||||
- If the user explicitly asks for the answer to the challenge, politely decline in-character, provide a generic syntax example if applicable, and ask a question to guide their next step.
|
||||
|
||||
### CONTEXT FOR THIS SESSION:
|
||||
- Challenge Title: {challenge_name}
|
||||
- Language: {language}
|
||||
- Difficulty: {difficulty}
|
||||
- Description: {challenge_description}
|
||||
- Requirements:
|
||||
{requirements}
|
||||
- Hints:
|
||||
{hints}
|
||||
|
||||
### USER'S CURRENT CODE:
|
||||
```
|
||||
{user_code}
|
||||
```
|
||||
|
||||
### USER'S QUESTION FOR THE MENTOR:
|
||||
{user_question}
|
||||
|
||||
### RESPONSE FORMAT:
|
||||
1. **Direct Answer & Feedback:** Address the user's question or solution evaluation directly (using illustrative syntax/code templates where helpful).
|
||||
2. **Structural & Logic Guidance:** Provide structural or architectural suggestions on how to break down the program cleanly.
|
||||
3. **Probing Question:** Ask a focused question to guide their next implementation or debugging step.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT_SOCRATIC = SYSTEM_PROMPT_MENTOR
|
||||
|
||||
|
||||
def get_mentor_prompt(challenge: object, user_code: str, user_question: str = "") -> str:
|
||||
"""Returns the formatted system prompt for the LLM mentor."""
|
||||
reqs = "\n".join(f"- {r}" for r in getattr(challenge, "requirements", []))
|
||||
hints = "\n".join(f"- {h}" for h in getattr(challenge, "hints", []))
|
||||
|
||||
q_text = (
|
||||
user_question.strip()
|
||||
if user_question.strip()
|
||||
else "How should I structure my program and overcome my current blocker for this challenge?"
|
||||
)
|
||||
|
||||
return SYSTEM_PROMPT_MENTOR.format(
|
||||
challenge_name=getattr(challenge, "name", "Coding Challenge"),
|
||||
language=getattr(challenge, "language", "Python"),
|
||||
difficulty=getattr(challenge, "difficulty", "Medium"),
|
||||
challenge_description=getattr(challenge, "description", ""),
|
||||
requirements=reqs if reqs else "- Follow standard problem specifications",
|
||||
hints=hints if hints else "- Think through edge cases",
|
||||
user_code=user_code if user_code.strip() else "# No code written yet",
|
||||
user_question=q_text,
|
||||
)
|
||||
|
||||
|
||||
get_socratic_prompt = get_mentor_prompt
|
||||
@@ -0,0 +1,97 @@
|
||||
from typing import Dict, Optional, List
|
||||
|
||||
|
||||
class LanguageRegistry:
|
||||
"""Registry mapping programming languages to execution, linting, and extension settings."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._languages: Dict[str, Dict[str, str]] = {
|
||||
"python": {
|
||||
"name": "Python",
|
||||
"ext": ".py",
|
||||
"exec": "python3",
|
||||
"lint": "flake8",
|
||||
},
|
||||
"csharp": {
|
||||
"name": "C#",
|
||||
"ext": ".cs",
|
||||
"exec": "dotnet run",
|
||||
"lint": "dotnet build",
|
||||
},
|
||||
"cpp": {
|
||||
"name": "C++",
|
||||
"ext": ".cpp",
|
||||
"exec": "g++ -O2 -o main main.cpp && ./main",
|
||||
"lint": "g++ -fsyntax-only main.cpp",
|
||||
},
|
||||
"java": {
|
||||
"name": "Java",
|
||||
"ext": ".java",
|
||||
"exec": "javac Main.java && java Main",
|
||||
"lint": "javac Main.java",
|
||||
},
|
||||
"javascript": {
|
||||
"name": "JavaScript",
|
||||
"ext": ".js",
|
||||
"exec": "node",
|
||||
"lint": "node --check",
|
||||
},
|
||||
"typescript": {
|
||||
"name": "TypeScript",
|
||||
"ext": ".ts",
|
||||
"exec": "npx ts-node",
|
||||
"lint": "npx tsc --noEmit",
|
||||
},
|
||||
"rust": {
|
||||
"name": "Rust",
|
||||
"ext": ".rs",
|
||||
"exec": "rustc main.rs -o main && ./main",
|
||||
"lint": "rustc --emit=metadata main.rs",
|
||||
},
|
||||
"lua": {
|
||||
"name": "Lua",
|
||||
"ext": ".lua",
|
||||
"exec": "lua",
|
||||
"lint": "luac -p",
|
||||
},
|
||||
"html": {
|
||||
"name": "HTML",
|
||||
"ext": ".html",
|
||||
"exec": "cat",
|
||||
"lint": "tidy -e -q",
|
||||
},
|
||||
"go": {
|
||||
"name": "Go",
|
||||
"ext": ".go",
|
||||
"exec": "go run",
|
||||
"lint": "go vet",
|
||||
},
|
||||
}
|
||||
|
||||
# Language aliases mapping
|
||||
self._aliases: Dict[str, str] = {
|
||||
"py": "python",
|
||||
"c#": "csharp",
|
||||
"cs": "csharp",
|
||||
"c++": "cpp",
|
||||
"cxx": "cpp",
|
||||
"js": "javascript",
|
||||
"node": "javascript",
|
||||
"ts": "typescript",
|
||||
"rs": "rust",
|
||||
"golang": "go",
|
||||
}
|
||||
|
||||
def canonical_name(self, language: str) -> str:
|
||||
lang_lower = language.lower().strip()
|
||||
return self._aliases.get(lang_lower, lang_lower)
|
||||
|
||||
def get_config(self, language: str) -> Optional[Dict[str, str]]:
|
||||
key = self.canonical_name(language)
|
||||
return self._languages.get(key)
|
||||
|
||||
def list_languages(self) -> List[str]:
|
||||
return list(self._languages.keys())
|
||||
|
||||
|
||||
registry = LanguageRegistry()
|
||||
@@ -0,0 +1,18 @@
|
||||
# Challenge: [Name]
|
||||
**Difficulty:** [Easy/Med/Hard]
|
||||
**Language:** [Language]
|
||||
|
||||
## Description
|
||||
[Description of the problem]
|
||||
|
||||
## Requirements
|
||||
- [Requirement 1]
|
||||
- [Requirement 2]
|
||||
|
||||
## Hints
|
||||
- [Hint 1]
|
||||
- [Hint 2]
|
||||
|
||||
## Validation
|
||||
- **Check:** [Command to run, e.g., pytest or eslint]
|
||||
- **Expected Output:** [Description]
|
||||
+994
@@ -0,0 +1,994 @@
|
||||
"""TactiTerm TUI — Coding Tutor Terminal User Interface with Handbook & Expandable Output Terminal."""
|
||||
import argparse
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
from textual.widgets import Header, Footer, Static, OptionList, Button, Markdown, TextArea, Input, LoadingIndicator
|
||||
from textual.widgets.option_list import Option
|
||||
from textual.binding import Binding
|
||||
|
||||
from src.core.loader import ChallengeLoader
|
||||
from src.core.executor import executor
|
||||
from src.core.linter import linter
|
||||
from src.core.mentor import mentor
|
||||
from src.core.handbook import handbook_service
|
||||
from src.core.prompts import get_mentor_prompt
|
||||
from src.tui.widgets import CodeEditor, MentorInputTextArea, StdinInput, TUI_COMPLETIONS, get_tui_language
|
||||
|
||||
|
||||
class TactiTermTUI(App):
|
||||
"""A coding tutor TUI with direct core service integration, Mentor, Handbook, and Expandable Stdin Output Terminal."""
|
||||
|
||||
TITLE = "TactiTerm"
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
layout: horizontal;
|
||||
height: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Left column: challenge list & details */
|
||||
#challenge-panel {
|
||||
width: 32%;
|
||||
min-width: 25;
|
||||
height: 100%;
|
||||
border-right: heavy $primary;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
/* Middle column: code editor & execution output */
|
||||
#editor-panel {
|
||||
width: 68%;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
layers: default overlay;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Right columns: Mentor & Handbook sidebars (hidden by default) */
|
||||
#mentor-sidebar, #handbook-sidebar {
|
||||
width: 32%;
|
||||
min-width: 26;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Adjusted column widths when a sidebar is toggled open */
|
||||
#main-container.sidebar-open #challenge-panel {
|
||||
width: 26%;
|
||||
}
|
||||
|
||||
#main-container.sidebar-open #editor-panel {
|
||||
width: 44%;
|
||||
border-right: heavy $primary;
|
||||
}
|
||||
|
||||
#main-container.sidebar-mentor-open #mentor-sidebar {
|
||||
display: block;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
#main-container.sidebar-handbook-open #handbook-sidebar {
|
||||
display: block;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
#challenge-header, #mentor-header, #handbook-header {
|
||||
height: 3;
|
||||
background: $primary;
|
||||
color: white;
|
||||
content-align: center middle;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#challenge_list {
|
||||
height: 8;
|
||||
border: solid $secondary;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#handbook-search-input {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#btn-handbook-mode {
|
||||
width: 100%;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#handbook_topic_list {
|
||||
height: 8;
|
||||
border: solid $secondary;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#challenge-details-container {
|
||||
height: 1fr;
|
||||
border: solid $primary-darken-2;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Sleek compact action bar with reduced height */
|
||||
#action-bar {
|
||||
height: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#action-bar Button {
|
||||
height: 1;
|
||||
min-width: 7;
|
||||
padding: 0 1;
|
||||
margin-right: 1;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Editor & Auto-Complete Popup Overlay styling */
|
||||
CodeEditor {
|
||||
height: 1fr;
|
||||
min-height: 8;
|
||||
border: solid $accent;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
CodeEditor:focus {
|
||||
border: heavy $primary;
|
||||
}
|
||||
|
||||
#completion-popup {
|
||||
display: none;
|
||||
layer: overlay;
|
||||
position: absolute;
|
||||
width: 32;
|
||||
height: auto;
|
||||
max-height: 6;
|
||||
background: #252526;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#main-container.show-completion #completion-popup {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#completion-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#completion_list_popup {
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Output Console Header & Expandable Terminal */
|
||||
#output-header-bar {
|
||||
height: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#output-title {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $accent;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
#btn-expand-output {
|
||||
height: 1;
|
||||
min-width: 16;
|
||||
padding: 0 1;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#output-scroll-container {
|
||||
height: 7;
|
||||
background: $surface;
|
||||
border: solid $accent;
|
||||
padding: 1;
|
||||
overflow: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#editor-panel.output-expanded #output-scroll-container {
|
||||
height: 15;
|
||||
}
|
||||
|
||||
#expanded-input-bar {
|
||||
display: none;
|
||||
height: 3;
|
||||
layout: horizontal;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#editor-panel.output-expanded #expanded-input-bar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#program-stdin-input {
|
||||
width: 1fr;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
#btn-send-stdin {
|
||||
width: 18;
|
||||
}
|
||||
|
||||
/* Mentor & Handbook Sidebar Elements */
|
||||
#mentor-response-scroll, #handbook-example-scroll {
|
||||
height: 1fr;
|
||||
background: $surface;
|
||||
border: solid $warning-darken-1;
|
||||
padding: 1;
|
||||
margin-bottom: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#mentor-loading-indicator {
|
||||
display: none;
|
||||
height: 1fr;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#mentor-input-title {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
#mentor-question-input {
|
||||
height: 4;
|
||||
border: solid $warning;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#btn-send-mentor {
|
||||
width: 100%;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
#status-bar {
|
||||
height: 1;
|
||||
background: $primary;
|
||||
color: white;
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+r", "run_code", "Run"),
|
||||
Binding("ctrl+l", "lint_code", "Lint"),
|
||||
Binding("ctrl+w", "toggle_word_wrap", "Word Wrap (Ctrl+W)"),
|
||||
Binding("ctrl+e,f4", "toggle_expand_output", "Expand Output (Ctrl+E / F4)"),
|
||||
Binding("f2", "check_answer", "Check", show=False),
|
||||
Binding("f1", "toggle_mentor_sidebar", "Mentor", show=False),
|
||||
Binding("f3", "toggle_handbook_sidebar", "Handbook", show=False),
|
||||
Binding("ctrl+f", "refresh_challenges", "Refresh"),
|
||||
Binding("alt+w", "scroll_output_up", "Output Up", show=False),
|
||||
Binding("alt+s", "scroll_output_down", "Output Down", show=False),
|
||||
Binding("alt+left,alt+a", "scroll_left", "Scroll Left", show=False),
|
||||
Binding("alt+right,alt+d", "scroll_right", "Scroll Right", show=False),
|
||||
Binding("ctrl+up", "scroll_mentor_up", "Sidebar Up", show=False),
|
||||
Binding("ctrl+down", "scroll_mentor_down", "Sidebar Down", show=False),
|
||||
Binding("alt+up", "scroll_details_up", "Details Up", show=False),
|
||||
Binding("alt+down", "scroll_details_down", "Details Down", show=False),
|
||||
Binding("escape", "unfocus_editor", "Exit Focus"),
|
||||
Binding("f6", "toggle_panel_focus", "Switch Focus"),
|
||||
Binding("ctrl+q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
def __init__(self, debug: bool = False) -> None:
|
||||
super().__init__()
|
||||
self.loader = ChallengeLoader()
|
||||
self.active_challenge_id = None
|
||||
self.challenges_dict = {}
|
||||
self.catalog_functions: List[Dict[str, str]] = []
|
||||
self.catalog_subjects: List[Dict[str, str]] = []
|
||||
self.active_handbook_mode = "all"
|
||||
self.active_completion_prefix = ""
|
||||
self.debug_mode = debug
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
|
||||
with Horizontal(id="main-container"):
|
||||
# Left column: Challenges & Details
|
||||
with Container(id="challenge-panel"):
|
||||
yield Static("TactiTerm Workspace", id="challenge-header")
|
||||
yield OptionList(id="challenge_list")
|
||||
with VerticalScroll(id="challenge-details-container"):
|
||||
yield Markdown("Select a challenge from the list above to view details...", id="challenge-details")
|
||||
|
||||
# Middle column: Code Editor, Completion Dropdown Popup & Expandable Output Terminal
|
||||
with Container(id="editor-panel"):
|
||||
with Horizontal(id="action-bar"):
|
||||
yield Button("▶ Run (Ctrl+R)", id="btn-run", variant="success")
|
||||
yield Button("🔍 Lint (Ctrl+L)", id="btn-lint", variant="primary")
|
||||
yield Button("✅ Check (F2)", id="btn-check", variant="success")
|
||||
yield Button("💡 Mentor (F1)", id="btn-guide", variant="warning")
|
||||
yield Button("📖 Handbook (F3)", id="btn-handbook", variant="primary")
|
||||
yield Button("🌐 Wrap (Ctrl+W)", id="btn-wrap", variant="default")
|
||||
yield Button("🔄 Refresh", id="btn-refresh", variant="default")
|
||||
|
||||
yield CodeEditor(id="editor")
|
||||
|
||||
# Auto-Completion Dropdown Popup Overlay
|
||||
with Container(id="completion-popup"):
|
||||
yield Static("💡 Auto-Complete Suggestions (↑/↓ to navigate, Enter/Tab to insert):", id="completion-title")
|
||||
yield OptionList(id="completion_list_popup")
|
||||
|
||||
with Horizontal(id="output-header-bar"):
|
||||
yield Static("Output Console (Alt+W/S: Scroll)", id="output-title")
|
||||
yield Button("⤢ Expand / Input (Ctrl+E)", id="btn-expand-output", variant="primary")
|
||||
|
||||
with VerticalScroll(id="output-scroll-container"):
|
||||
yield Static("Execution and lint output will appear here...", id="output-container")
|
||||
|
||||
with Horizontal(id="expanded-input-bar"):
|
||||
yield StdinInput(placeholder="Type stdin input here and press Enter to send to program...", id="program-stdin-input")
|
||||
yield Button("Send Input", id="btn-send-stdin", variant="success")
|
||||
|
||||
# Right column 1: Mentor Sidebar
|
||||
with Container(id="mentor-sidebar"):
|
||||
yield Static("💡 Mentor", id="mentor-header")
|
||||
with VerticalScroll(id="mentor-response-scroll"):
|
||||
yield Markdown("Ask the mentor a question or click Check Answer...", id="mentor-response-display")
|
||||
yield LoadingIndicator(id="mentor-loading-indicator")
|
||||
|
||||
yield Static("Ask Question (Enter: Send | Shift+Enter: Newline):", id="mentor-input-title")
|
||||
yield MentorInputTextArea(id="mentor-question-input")
|
||||
yield Button("💡 Ask Mentor (Enter)", id="btn-send-mentor", variant="warning")
|
||||
|
||||
# Right column 2: Handbook Sidebar
|
||||
with Container(id="handbook-sidebar"):
|
||||
yield Static("📖 Handbook", id="handbook-header")
|
||||
yield Input(placeholder="🔍 Search built-ins or topics...", id="handbook-search-input")
|
||||
yield Button("Filter: All Items (Click to Switch)", id="btn-handbook-mode", variant="default")
|
||||
yield OptionList(id="handbook_topic_list")
|
||||
with VerticalScroll(id="handbook-example-scroll"):
|
||||
yield Markdown("Select a function or topic above to view reference card...", id="handbook-example-display")
|
||||
|
||||
yield Static("Ready", id="status-bar")
|
||||
yield Footer()
|
||||
|
||||
# ── Events & Lifecycle ────────────────────────────────────
|
||||
|
||||
def action_toggle_expand_output(self) -> None:
|
||||
"""Keybinding handler for Ctrl+E and F4."""
|
||||
self.toggle_expand_output()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.call_after_refresh(self.load_challenges)
|
||||
|
||||
def load_challenges(self) -> None:
|
||||
self.challenges_dict = self.loader.load_all()
|
||||
|
||||
try:
|
||||
option_list = self.query_one("#challenge_list", OptionList)
|
||||
option_list.clear_options()
|
||||
|
||||
for cid, challenge in sorted(self.challenges_dict.items()):
|
||||
option_list.add_option(
|
||||
Option(f"{challenge.name} ({challenge.difficulty})", id=cid)
|
||||
)
|
||||
|
||||
count = len(self.challenges_dict)
|
||||
if count > 0:
|
||||
option_list.highlighted = 0
|
||||
first_opt = option_list.get_option_at_index(0)
|
||||
self.on_option_list_option_highlighted(
|
||||
OptionList.OptionHighlighted(option_list, first_opt, 0)
|
||||
)
|
||||
self.query_one("#status-bar", Static).update(
|
||||
f"Loaded {count} challenges — Hovering '{first_opt.prompt}' (Hit Enter to edit)"
|
||||
)
|
||||
else:
|
||||
self.query_one("#status-bar", Static).update("No challenges found in src/challenges/.")
|
||||
except Exception as e:
|
||||
if self.debug_mode:
|
||||
print(f"DEBUG: Exception in load_challenges: {e}")
|
||||
|
||||
def load_handbook_catalog(self, language: str) -> None:
|
||||
catalog = handbook_service.get_catalog(language)
|
||||
self.catalog_functions = catalog.get("functions", [])
|
||||
self.catalog_subjects = catalog.get("subjects", [])
|
||||
self.filter_handbook_list()
|
||||
|
||||
def filter_handbook_list(self) -> None:
|
||||
search_term = ""
|
||||
try:
|
||||
search_input = self.query_one("#handbook-search-input", Input)
|
||||
search_term = search_input.value.strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
items: List[Dict[str, str]] = []
|
||||
if self.active_handbook_mode == "functions":
|
||||
items = [dict(t, icon="⚡") for t in self.catalog_functions]
|
||||
elif self.active_handbook_mode == "subjects":
|
||||
items = [dict(t, icon="📚") for t in self.catalog_subjects]
|
||||
else:
|
||||
items = [dict(t, icon="⚡") for t in self.catalog_functions] + [dict(t, icon="📚") for t in self.catalog_subjects]
|
||||
|
||||
if search_term:
|
||||
items = [
|
||||
i for i in items
|
||||
if search_term in i["title"].lower() or search_term in i["desc"].lower()
|
||||
]
|
||||
|
||||
topic_list = self.query_one("#handbook_topic_list", OptionList)
|
||||
topic_list.clear_options()
|
||||
|
||||
for item in items:
|
||||
topic_list.add_option(Option(f"{item['icon']} {item['title']}", id=item["id"]))
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
if event.input.id == "handbook-search-input":
|
||||
self.filter_handbook_list()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id == "program-stdin-input":
|
||||
self.send_program_stdin()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
button_id = event.button.id
|
||||
if button_id == "btn-run":
|
||||
self.action_run_code()
|
||||
elif button_id == "btn-lint":
|
||||
self.action_lint_code()
|
||||
elif button_id == "btn-check":
|
||||
self.action_check_answer()
|
||||
elif button_id == "btn-guide":
|
||||
self.action_toggle_mentor_sidebar()
|
||||
elif button_id == "btn-handbook":
|
||||
self.action_toggle_handbook_sidebar()
|
||||
elif button_id == "btn-handbook-mode":
|
||||
self.toggle_handbook_mode()
|
||||
elif button_id == "btn-wrap":
|
||||
self.action_toggle_word_wrap()
|
||||
elif button_id == "btn-expand-output":
|
||||
self.toggle_expand_output()
|
||||
elif button_id == "btn-send-stdin":
|
||||
self.send_program_stdin()
|
||||
elif button_id == "btn-send-mentor":
|
||||
self.submit_mentor_question()
|
||||
elif button_id == "btn-refresh":
|
||||
self.action_refresh_challenges()
|
||||
|
||||
def toggle_expand_output(self) -> None:
|
||||
"""Toggle output terminal expansion and stdin input bar."""
|
||||
panel = self.query_one("#editor-panel")
|
||||
btn = self.query_one("#btn-expand-output", Button)
|
||||
if panel.has_class("output-expanded"):
|
||||
panel.remove_class("output-expanded")
|
||||
btn.label = "⤢ Expand / Input (Ctrl+E)"
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
self.query_one("#status-bar", Static).update("Output console collapsed.")
|
||||
else:
|
||||
panel.add_class("output-expanded")
|
||||
btn.label = "⤡ Collapse (Ctrl+E)"
|
||||
self.query_one("#program-stdin-input", StdinInput).focus()
|
||||
self.query_one("#status-bar", Static).update(
|
||||
"Output console expanded. Type stdin input below and hit Enter to run program with input."
|
||||
)
|
||||
|
||||
def send_program_stdin(self) -> None:
|
||||
"""Send input text from #program-stdin-input to running code."""
|
||||
stdin_input = self.query_one("#program-stdin-input", StdinInput)
|
||||
user_stdin = stdin_input.value
|
||||
stdin_input.value = ""
|
||||
self.action_run_code(stdin=user_stdin)
|
||||
|
||||
def toggle_handbook_mode(self) -> None:
|
||||
if self.active_handbook_mode == "all":
|
||||
self.active_handbook_mode = "functions"
|
||||
label = "Filter: ⚡ Built-in Functions"
|
||||
elif self.active_handbook_mode == "functions":
|
||||
self.active_handbook_mode = "subjects"
|
||||
label = "Filter: 📚 Broad Subjects"
|
||||
else:
|
||||
self.active_handbook_mode = "all"
|
||||
label = "Filter: All Items"
|
||||
|
||||
self.query_one("#btn-handbook-mode", Button).label = label
|
||||
self.filter_handbook_list()
|
||||
|
||||
# ── Sidebar & Focus Management ───────────────────────────
|
||||
|
||||
def action_toggle_mentor_sidebar(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
q_input = self.query_one("#mentor-question-input", MentorInputTextArea)
|
||||
|
||||
if main_box.has_class("sidebar-mentor-open"):
|
||||
main_box.remove_class("sidebar-mentor-open")
|
||||
main_box.remove_class("sidebar-open")
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
self.query_one("#status-bar", Static).update("Mentor sidebar closed.")
|
||||
else:
|
||||
main_box.remove_class("sidebar-handbook-open")
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-mentor-open")
|
||||
q_input.focus()
|
||||
self.query_one("#status-bar", Static).update("Mentor sidebar open.")
|
||||
|
||||
def action_toggle_handbook_sidebar(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
main_box.remove_class("sidebar-handbook-open")
|
||||
main_box.remove_class("sidebar-open")
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
self.query_one("#status-bar", Static).update("Handbook sidebar closed.")
|
||||
else:
|
||||
main_box.remove_class("sidebar-mentor-open")
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-handbook-open")
|
||||
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id) if self.active_challenge_id else None
|
||||
lang = challenge.language if challenge else "Python"
|
||||
self.load_handbook_catalog(lang)
|
||||
|
||||
self.query_one("#handbook_topic_list", OptionList).focus()
|
||||
self.query_one("#status-bar", Static).update(f"Handbook open for {lang}.")
|
||||
|
||||
def on_mentor_input_text_area_submit_question(
|
||||
self, event: MentorInputTextArea.SubmitQuestion
|
||||
) -> None:
|
||||
self.submit_mentor_question(event.question)
|
||||
|
||||
def submit_mentor_question(self, question: str | None = None) -> None:
|
||||
if not self.active_challenge_id:
|
||||
self.query_one("#status-bar", Static).update("Please select a challenge first.")
|
||||
return
|
||||
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id)
|
||||
if not challenge:
|
||||
return
|
||||
|
||||
q_input = self.query_one("#mentor-question-input", MentorInputTextArea)
|
||||
user_question = q_input.text.strip() if question is None else question.strip()
|
||||
code = self.query_one("#editor", CodeEditor).text
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
main_box.remove_class("sidebar-handbook-open")
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-mentor-open")
|
||||
|
||||
q_input.text = ""
|
||||
self.query_one("#mentor-response-display", Markdown).display = False
|
||||
self.query_one("#mentor-loading-indicator", LoadingIndicator).display = True
|
||||
self.query_one("#status-bar", Static).update(
|
||||
f"⏳ Consulting Mentor at {mentor.config.llm_base_url}..."
|
||||
)
|
||||
self.run_worker(self._mentor_worker(challenge, code, user_question))
|
||||
|
||||
def action_check_answer(self) -> None:
|
||||
if not self.active_challenge_id:
|
||||
self.query_one("#status-bar", Static).update("Please select a challenge first.")
|
||||
return
|
||||
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id)
|
||||
if not challenge:
|
||||
return
|
||||
|
||||
code = self.query_one("#editor", CodeEditor).text
|
||||
if not code.strip():
|
||||
self.query_one("#status-bar", Static).update("Editor is empty — write code before checking answer.")
|
||||
return
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
main_box.remove_class("sidebar-handbook-open")
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-mentor-open")
|
||||
|
||||
check_question = (
|
||||
"Please evaluate my code implementation against all the requirements of this challenge. "
|
||||
"Check if my solution is complete and correct, point out any missing requirements or edge cases, "
|
||||
"and give me feedback on my answer."
|
||||
)
|
||||
|
||||
self.query_one("#mentor-response-display", Markdown).display = False
|
||||
self.query_one("#mentor-loading-indicator", LoadingIndicator).display = True
|
||||
self.query_one("#status-bar", Static).update("⏳ Consulting Mentor to check answer...")
|
||||
self.run_worker(self._mentor_worker(challenge, code, check_question))
|
||||
|
||||
def action_unfocus_editor(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("show-completion"):
|
||||
main_box.remove_class("show-completion")
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
return
|
||||
|
||||
self.query_one("#challenge_list", OptionList).focus()
|
||||
self.query_one("#status-bar", Static).update("Focus moved to Challenge List.")
|
||||
|
||||
def action_toggle_panel_focus(self) -> None:
|
||||
focused = self.focused
|
||||
if focused and getattr(focused, "id", None) == "editor":
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
self.query_one("#handbook_topic_list", OptionList).focus()
|
||||
else:
|
||||
if not main_box.has_class("sidebar-mentor-open"):
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-mentor-open")
|
||||
self.query_one("#mentor-question-input", MentorInputTextArea).focus()
|
||||
elif focused and getattr(focused, "id", None) in ("mentor-question-input", "handbook_topic_list"):
|
||||
self.query_one("#challenge_list", OptionList).focus()
|
||||
else:
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
|
||||
# ── Challenge selection & Handbook selection ─────────────
|
||||
|
||||
def _display_challenge(self, cid: str) -> None:
|
||||
self.active_challenge_id = cid
|
||||
challenge = self.challenges_dict.get(cid)
|
||||
if not challenge:
|
||||
return
|
||||
|
||||
req_str = "\n".join(f"- {r}" for r in challenge.requirements) if challenge.requirements else "*None*"
|
||||
hint_str = "\n".join(f"- {h}" for h in challenge.hints) if challenge.hints else "*None*"
|
||||
|
||||
markdown_content = f"""# {challenge.name}
|
||||
**Difficulty:** {challenge.difficulty} | **Language:** {challenge.language} | **Subject:** {challenge.subject}
|
||||
|
||||
## Description
|
||||
{challenge.description}
|
||||
|
||||
## Requirements
|
||||
{req_str}
|
||||
|
||||
## Hints
|
||||
{hint_str}
|
||||
"""
|
||||
self.query_one("#challenge-details", Markdown).update(markdown_content)
|
||||
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
lang_key = get_tui_language(challenge.language)
|
||||
try:
|
||||
if lang_key in editor.available_languages:
|
||||
editor.language = lang_key
|
||||
else:
|
||||
editor.language = None
|
||||
except Exception:
|
||||
editor.language = None
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
self.load_handbook_catalog(challenge.language)
|
||||
|
||||
def on_option_list_option_highlighted(
|
||||
self, event: OptionList.OptionHighlighted
|
||||
) -> None:
|
||||
if event.option_list.id == "challenge_list":
|
||||
selected_option = event.option
|
||||
if selected_option and selected_option.id:
|
||||
self._display_challenge(selected_option.id)
|
||||
self.query_one("#status-bar", Static).update(
|
||||
f"Hovering Challenge: {selected_option.prompt} — Hit Enter to jump to Editor"
|
||||
)
|
||||
|
||||
def on_option_list_option_selected(
|
||||
self, event: OptionList.OptionSelected
|
||||
) -> None:
|
||||
if event.option_list.id == "challenge_list":
|
||||
selected_option = event.option
|
||||
if selected_option and selected_option.id:
|
||||
self._display_challenge(selected_option.id)
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
self.query_one("#status-bar", Static).update("Focused: Code Editor")
|
||||
elif event.option_list.id == "completion_list_popup":
|
||||
selected_option = event.option
|
||||
if selected_option and selected_option.prompt:
|
||||
chosen_text = str(selected_option.prompt)
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
cursor_row, cursor_col = editor.cursor_location
|
||||
suffix_len = len(self.active_completion_prefix)
|
||||
start_col = max(0, cursor_col - suffix_len)
|
||||
editor.delete((cursor_row, start_col), (cursor_row, cursor_col))
|
||||
editor.insert(chosen_text)
|
||||
|
||||
self.hide_completion_popup()
|
||||
self.query_one("#status-bar", Static).update(f"✓ Inserted '{chosen_text}'")
|
||||
elif event.option_list.id == "handbook_topic_list":
|
||||
selected_option = event.option
|
||||
if selected_option and selected_option.id:
|
||||
all_items = self.catalog_functions + self.catalog_subjects
|
||||
item = next((i for i in all_items if i["id"] == selected_option.id), None)
|
||||
if item:
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id) if self.active_challenge_id else None
|
||||
lang = challenge.language if challenge else "Python"
|
||||
|
||||
self.query_one("#handbook-example-display", Markdown).update(
|
||||
f"⏳ **Generating reference card for '{item['title']}' in {lang}...**"
|
||||
)
|
||||
self.run_worker(self._handbook_worker(lang, item["id"], item["title"]))
|
||||
|
||||
def is_completion_open(self) -> bool:
|
||||
"""Returns True if completion popup overlay is visible."""
|
||||
main_box = self.query_one("#main-container")
|
||||
return main_box.has_class("show-completion")
|
||||
|
||||
def hide_completion_popup(self) -> None:
|
||||
"""Dismiss completion popup menu."""
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("show-completion"):
|
||||
main_box.remove_class("show-completion")
|
||||
|
||||
def update_as_you_type_completion(self, editor: CodeEditor) -> None:
|
||||
"""Real-time buffer listener to update popup matches as user types or backspaces."""
|
||||
prefix, matches, is_dot = editor.get_completions_at_cursor()
|
||||
if matches:
|
||||
self.show_completion_popup(prefix, matches, is_dot)
|
||||
elif self.is_completion_open():
|
||||
self.hide_completion_popup()
|
||||
|
||||
def navigate_completion(self, direction: int) -> None:
|
||||
"""Navigate highlighted item in floating completion list."""
|
||||
if not self.is_completion_open():
|
||||
return
|
||||
popup_list = self.query_one("#completion_list_popup", OptionList)
|
||||
if len(popup_list.options) > 0:
|
||||
current = popup_list.highlighted if popup_list.highlighted is not None else 0
|
||||
popup_list.highlighted = (current + direction) % len(popup_list.options)
|
||||
popup_list.scroll_to_highlight()
|
||||
|
||||
def insert_selected_completion(self) -> None:
|
||||
"""Insert currently highlighted completion option into CodeEditor, cleanly replacing typed token."""
|
||||
if not self.is_completion_open():
|
||||
return
|
||||
popup_list = self.query_one("#completion_list_popup", OptionList)
|
||||
if popup_list.highlighted is not None and popup_list.highlighted < len(popup_list.options):
|
||||
option = popup_list.get_option_at_index(popup_list.highlighted)
|
||||
chosen_text = str(option.prompt)
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
cursor_row, cursor_col = editor.cursor_location
|
||||
lines = editor.text.split("\n")
|
||||
line_until_cursor = lines[cursor_row][:cursor_col] if cursor_row < len(lines) else ""
|
||||
|
||||
import re
|
||||
m_dot = re.search(r"([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z0-9_]*)$", line_until_cursor)
|
||||
if m_dot:
|
||||
typed_len = len(m_dot.group(2))
|
||||
else:
|
||||
m_word = re.search(r"([a-zA-Z_][a-zA-Z0-9_]*)$", line_until_cursor)
|
||||
typed_len = len(m_word.group(1)) if m_word else len(self.active_completion_prefix)
|
||||
|
||||
start_col = max(0, cursor_col - typed_len)
|
||||
editor.delete((cursor_row, start_col), (cursor_row, cursor_col))
|
||||
editor.insert(chosen_text)
|
||||
self.hide_completion_popup()
|
||||
self.query_one("#status-bar", Static).update(f"✓ Inserted '{chosen_text}'")
|
||||
|
||||
def show_completion_popup(self, prefix: str, matches: List[str], is_dot_access: bool = False) -> None:
|
||||
self.active_completion_prefix = prefix
|
||||
popup_list = self.query_one("#completion_list_popup", OptionList)
|
||||
popup_list.clear_options()
|
||||
|
||||
for match in matches:
|
||||
popup_list.add_option(Option(match, id=match))
|
||||
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
popup = self.query_one("#completion-popup", Container)
|
||||
panel = popup.parent or editor.parent
|
||||
|
||||
cursor_row, cursor_col = editor.cursor_location
|
||||
gutter = getattr(editor, "gutter_width", 3)
|
||||
vis_row = cursor_row - int(getattr(editor, "scroll_y", 0))
|
||||
vis_col = cursor_col - int(getattr(editor, "scroll_x", 0))
|
||||
|
||||
popup_height = max(1, min(len(matches), 5))
|
||||
popup.styles.height = popup_height
|
||||
|
||||
try:
|
||||
editor_top_rel = editor.region.y - panel.region.y
|
||||
editor_left_rel = editor.region.x - panel.region.x
|
||||
editor_height = editor.region.height
|
||||
panel_width = panel.region.width
|
||||
except Exception:
|
||||
editor_top_rel = 2
|
||||
editor_left_rel = 0
|
||||
editor_height = 15
|
||||
panel_width = 80
|
||||
|
||||
# Line Y position relative to panel (1 cell for top border of editor)
|
||||
line_y = editor_top_rel + 1 + vis_row
|
||||
|
||||
# Smart placement: position BELOW cursor line if space permits, else ABOVE cursor line
|
||||
if line_y + 1 + popup_height <= editor_top_rel + editor_height:
|
||||
top = line_y + 1
|
||||
else:
|
||||
top = max(editor_top_rel + 1, line_y - popup_height)
|
||||
|
||||
# X position: editor left rel + 1 for border + gutter + vis_col
|
||||
left_pos = editor_left_rel + 1 + gutter + vis_col
|
||||
popup_width = 32
|
||||
max_left = max(0, panel_width - popup_width)
|
||||
left = min(max(0, left_pos), max_left)
|
||||
|
||||
popup.styles.offset = (int(left), int(top))
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
main_box.add_class("show-completion")
|
||||
if len(matches) > 0:
|
||||
popup_list.highlighted = 0
|
||||
|
||||
# ── Scrolling Actions ────────────────────────────────────
|
||||
|
||||
def action_scroll_mentor_up(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
self.query_one("#handbook-example-scroll", VerticalScroll).scroll_up(animate=False)
|
||||
else:
|
||||
self.query_one("#mentor-response-scroll", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
def action_scroll_mentor_down(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
self.query_one("#handbook-example-scroll", VerticalScroll).scroll_down(animate=False)
|
||||
else:
|
||||
self.query_one("#mentor-response-scroll", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
def action_scroll_output_up(self) -> None:
|
||||
self.query_one("#output-scroll-container", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
def action_scroll_output_down(self) -> None:
|
||||
self.query_one("#output-scroll-container", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
def action_toggle_word_wrap(self) -> None:
|
||||
"""Toggle soft word wrapping on CodeEditor."""
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
editor.soft_wrap = not editor.soft_wrap
|
||||
status = "ON" if editor.soft_wrap else "OFF"
|
||||
try:
|
||||
btn = self.query_one("#btn-wrap", Button)
|
||||
if editor.soft_wrap:
|
||||
btn.label = "🌐 Wrap: ON"
|
||||
btn.variant = "warning"
|
||||
else:
|
||||
btn.label = "🌐 Wrap: OFF"
|
||||
btn.variant = "default"
|
||||
except Exception:
|
||||
pass
|
||||
self.notify(f"Code Editor Word Wrap turned {status}", title="Word Wrap Toggled")
|
||||
|
||||
def action_scroll_left(self) -> None:
|
||||
"""Scroll active container or detail/output panels left horizontally."""
|
||||
focused = self.focused
|
||||
if focused and hasattr(focused, "scroll_left"):
|
||||
focused.scroll_left(animate=False)
|
||||
else:
|
||||
self.query_one("#challenge-details-container", VerticalScroll).scroll_left(animate=False)
|
||||
self.query_one("#output-scroll-container", VerticalScroll).scroll_left(animate=False)
|
||||
|
||||
def action_scroll_right(self) -> None:
|
||||
"""Scroll active container or detail/output panels right horizontally."""
|
||||
focused = self.focused
|
||||
if focused and hasattr(focused, "scroll_right"):
|
||||
focused.scroll_right(animate=False)
|
||||
else:
|
||||
self.query_one("#challenge-details-container", VerticalScroll).scroll_right(animate=False)
|
||||
self.query_one("#output-scroll-container", VerticalScroll).scroll_right(animate=False)
|
||||
|
||||
def action_scroll_details_up(self) -> None:
|
||||
self.query_one("#challenge-details-container", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
def action_scroll_details_down(self) -> None:
|
||||
self.query_one("#challenge-details-container", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
# ── Core Workers ─────────────────────────────────────────
|
||||
|
||||
def action_refresh_challenges(self) -> None:
|
||||
self.query_one("#status-bar", Static).update("Refreshing challenges...")
|
||||
self.load_challenges()
|
||||
|
||||
async def _mentor_worker(self, challenge: object, code: str, user_question: str) -> None:
|
||||
try:
|
||||
res = await mentor.get_guidance_async(challenge, code, user_question=user_question)
|
||||
response_text = res.get("mentor_response", "No response received.")
|
||||
|
||||
q_header = f"### Question / Evaluation:\n> {user_question}\n\n---\n\n" if user_question else ""
|
||||
full_md = f"{q_header}{response_text}"
|
||||
except Exception as e:
|
||||
full_md = f"⚠️ Error consulting Mentor: {e}"
|
||||
|
||||
self.query_one("#mentor-loading-indicator", LoadingIndicator).display = False
|
||||
self.query_one("#mentor-response-display", Markdown).display = True
|
||||
self.query_one("#mentor-response-display", Markdown).update(full_md)
|
||||
self.query_one("#status-bar", Static).update("Mentor response received — Press F1 to ask or F2 to check answer")
|
||||
|
||||
async def _handbook_worker(self, language: str, topic_id: str, topic_title: str) -> None:
|
||||
res = await handbook_service.generate_example_async(language, topic_id, topic_title)
|
||||
if res.get("status") == "success":
|
||||
md_text = res.get("example_markdown", "No reference card generated.")
|
||||
self.query_one("#handbook-example-display", Markdown).update(md_text)
|
||||
self.query_one("#status-bar", Static).update(f"✓ Reference card loaded for '{topic_title}'")
|
||||
else:
|
||||
err_msg = res.get("message", "Error generating reference card.")
|
||||
self.query_one("#handbook-example-display", Markdown).update(err_msg)
|
||||
self.query_one("#status-bar", Static).update("✗ Failed to generate reference card")
|
||||
|
||||
def action_lint_code(self) -> None:
|
||||
code = self.query_one("#editor", CodeEditor).text
|
||||
if not code.strip():
|
||||
self.query_one("#status-bar", Static).update("Editor is empty — nothing to lint.")
|
||||
return
|
||||
|
||||
self.query_one("#status-bar", Static).update("Running syntax & style lint...")
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id)
|
||||
lang = challenge.language.lower() if challenge else "python"
|
||||
|
||||
res = linter.lint(lang, code)
|
||||
output_widget = self.query_one("#output-container", Static)
|
||||
|
||||
if res.get("exit_code") == 0:
|
||||
output_widget.update("✓ Syntax & Style clean! No linting errors detected.")
|
||||
else:
|
||||
raw = (res.get("stdout") or "") + "\n" + (res.get("stderr") or "")
|
||||
lines = [line.strip() for line in raw.strip().splitlines() if line.strip()]
|
||||
cleaned = []
|
||||
for line in lines:
|
||||
if ":" in line:
|
||||
parts = line.split(":", 3)
|
||||
if len(parts) >= 3 and parts[1].isdigit():
|
||||
cleaned.append(f"Line {parts[1]}, Col {parts[2]}:{parts[3]}")
|
||||
else:
|
||||
cleaned.append(line)
|
||||
else:
|
||||
cleaned.append(line)
|
||||
msg = "\n".join(cleaned) if cleaned else "Syntax error detected."
|
||||
output_widget.update(f"✗ Lint Error(s):\n{msg}")
|
||||
|
||||
self.query_one("#status-bar", Static).update("Lint complete")
|
||||
|
||||
def action_run_code(self, stdin: str = "") -> None:
|
||||
code = self.query_one("#editor", CodeEditor).text
|
||||
if not code.strip():
|
||||
self.query_one("#status-bar", Static).update("Editor is empty — write code before running.")
|
||||
return
|
||||
|
||||
self.query_one("#status-bar", Static).update("Executing code...")
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id)
|
||||
lang = challenge.language.lower() if challenge else "python"
|
||||
|
||||
res = executor.run(lang, code, stdin=stdin)
|
||||
output_widget = self.query_one("#output-container", Static)
|
||||
|
||||
if res.get("exit_code") == 0:
|
||||
stdout = res.get("stdout", "").strip()
|
||||
if stdout:
|
||||
output_widget.update(f"Output:\n{stdout}")
|
||||
else:
|
||||
output_widget.update("✓ Code executed successfully (exit code 0, no stdout).")
|
||||
else:
|
||||
stderr = res.get("stderr", "").strip()
|
||||
stdout = res.get("stdout", "").strip()
|
||||
err_msg = stderr or stdout or "Unknown runtime error"
|
||||
output_widget.update(f"✗ Runtime Error (exit code {res.get('exit_code')}):\n{err_msg}")
|
||||
|
||||
self.query_one("#status-bar", Static).update("Run complete")
|
||||
|
||||
|
||||
TactTermTUI = TactiTermTUI
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="TactiTerm TUI")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging to tactiterm_tui.log")
|
||||
args = parser.parse_args()
|
||||
|
||||
app = TactiTermTUI(debug=args.debug)
|
||||
app.run()
|
||||
@@ -0,0 +1,423 @@
|
||||
"""GenTUI — Standalone TUI tool for generating and editing TactiTerm challenges with AI."""
|
||||
import os
|
||||
import argparse
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.screen import ModalScreen
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
from textual.widgets import Header, Footer, Static, Button, Markdown, TextArea, Input, Select
|
||||
from textual.binding import Binding
|
||||
|
||||
from src.core.generator import generator
|
||||
from src.core.config import config
|
||||
from src.core.mentor import get_chat_completions_url
|
||||
|
||||
|
||||
class GenHelpModal(ModalScreen):
|
||||
"""Interactive AI Assistant Modal for helping user design challenges."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GenHelpModal {
|
||||
align: center middle;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
#help-dialog {
|
||||
width: 75%;
|
||||
height: 80%;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1 2;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#help-header {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
background: $primary;
|
||||
color: white;
|
||||
height: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#help-response-scroll {
|
||||
height: 1fr;
|
||||
border: solid $secondary;
|
||||
padding: 1;
|
||||
background: $background;
|
||||
margin-bottom: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#help-input-label {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
#help-input {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#help-button-container {
|
||||
height: 3;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
#btn-ask-help {
|
||||
width: 1fr;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
#btn-close-help {
|
||||
width: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+up", "scroll_up", "Scroll Up (Ctrl+↑)"),
|
||||
Binding("ctrl+down", "scroll_down", "Scroll Down (Ctrl+↓)"),
|
||||
]
|
||||
|
||||
def action_scroll_up(self) -> None:
|
||||
self.query_one("#help-response-scroll", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
def action_scroll_down(self) -> None:
|
||||
self.query_one("#help-response-scroll", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
|
||||
with Container(id="help-dialog"):
|
||||
yield Static("💡 TactiTerm AI Challenge Design Assistant", id="help-header")
|
||||
with VerticalScroll(id="help-response-scroll"):
|
||||
yield Markdown(
|
||||
"### How can I help you design your challenge?\n"
|
||||
"Ask me anything if you're unsure about what language features, topics, requirements, or expected outputs to use!",
|
||||
id="help-response-markdown",
|
||||
)
|
||||
yield Static("Ask Assistant a Question:", id="help-input-label")
|
||||
yield Input(
|
||||
placeholder="e.g. What are good topics for a Medium Rust challenge? Or what requirements should a Java OOP challenge have?",
|
||||
id="help-input",
|
||||
)
|
||||
with Horizontal(id="help-button-container"):
|
||||
yield Button("✨ Ask Assistant (Enter)", id="btn-ask-help", variant="warning")
|
||||
yield Button("Close Window", id="btn-close-help", variant="error")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#help-input", Input).focus()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-ask-help":
|
||||
self.action_ask_assistant()
|
||||
elif event.button.id == "btn-close-help":
|
||||
self.dismiss()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id == "help-input":
|
||||
self.action_ask_assistant()
|
||||
|
||||
def action_ask_assistant(self) -> None:
|
||||
question = self.query_one("#help-input", Input).value.strip()
|
||||
if not question:
|
||||
return
|
||||
|
||||
self.query_one("#help-response-markdown", Markdown).update("⏳ **Assistant is thinking...**\n\n*Consulting LLM backend for advice...*")
|
||||
self.run_worker(self._ask_worker(question))
|
||||
|
||||
async def _ask_worker(self, question: str) -> None:
|
||||
endpoint = get_chat_completions_url(config.llm_base_url)
|
||||
payload = {
|
||||
"model": config.llm_model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are an expert Software Engineering Curriculum Designer assisting a teacher in creating coding challenges. "
|
||||
"Give clear, concise, actionable advice for challenge topics, difficulties, programming languages, and requirements."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": question},
|
||||
],
|
||||
"max_tokens": config.llm_max_tokens,
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if config.llm_api_key and config.llm_api_key != "not-needed":
|
||||
headers["Authorization"] = f"Bearer {config.llm_api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.llm_timeout, follow_redirects=True) as client:
|
||||
response = await client.post(endpoint, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
text = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "No response received.")
|
||||
)
|
||||
self.query_one("#help-response-markdown", Markdown).update(text)
|
||||
except Exception as e:
|
||||
self.query_one("#help-response-markdown", Markdown).update(f"⚠️ Error asking Assistant: {e}")
|
||||
|
||||
|
||||
class GenTUIApp(App):
|
||||
"""Standalone TUI Challenge Generator Application."""
|
||||
|
||||
TITLE = "TactiTerm GenTUI"
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
layout: horizontal;
|
||||
height: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Left Panel: Generator Controls */
|
||||
#control-panel {
|
||||
width: 32%;
|
||||
min-width: 28;
|
||||
height: 100%;
|
||||
border-right: heavy $primary;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
/* Middle Panel: Raw Markdown Editor */
|
||||
#editor-panel {
|
||||
width: 36%;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
border-right: heavy $primary;
|
||||
}
|
||||
|
||||
/* Right Panel: Live Markdown Preview */
|
||||
#preview-panel {
|
||||
width: 32%;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
height: 3;
|
||||
background: $primary;
|
||||
color: white;
|
||||
content-align: center middle;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $accent;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
Input, Select {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
TextArea {
|
||||
height: 1fr;
|
||||
border: solid $accent;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#preview-scroll {
|
||||
height: 1fr;
|
||||
border: solid $secondary;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#btn-help-modal {
|
||||
width: 100%;
|
||||
margin-top: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#btn-generate, #btn-save {
|
||||
width: 100%;
|
||||
margin-top: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
height: 1;
|
||||
background: $primary;
|
||||
color: white;
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+h", "open_help_modal", "AI Helper"),
|
||||
Binding("ctrl+g", "generate_ai", "Generate AI"),
|
||||
Binding("ctrl+s", "save_challenge", "Save File"),
|
||||
Binding("ctrl+w", "toggle_word_wrap", "Word Wrap (Ctrl+W)"),
|
||||
Binding("ctrl+q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
LANGUAGES = ["Python", "C#", "C++", "Java", "JavaScript", "TypeScript", "Rust", "Lua", "HTML", "Go"]
|
||||
DIFFICULTIES = ["Easy", "Medium", "Hard", "Advanced"]
|
||||
|
||||
def __init__(self, debug: bool = False) -> None:
|
||||
super().__init__()
|
||||
self.debug_mode = debug
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
|
||||
with Horizontal(id="main-container"):
|
||||
# Left Panel: Prompt Controls
|
||||
with Container(id="control-panel"):
|
||||
yield Static("1. Prompt AI Generator", classes="section-header")
|
||||
|
||||
yield Button("💡 Help Me Decide (AI Assist) [Ctrl+H]", id="btn-help-modal", variant="default")
|
||||
|
||||
yield Static("AI Challenge Prompt:", classes="input-label")
|
||||
yield Input(
|
||||
placeholder="e.g. Medium Rust challenge on Ownership & References",
|
||||
id="input-prompt",
|
||||
)
|
||||
|
||||
yield Static("Language (Dropdown):", classes="input-label")
|
||||
yield Select.from_values(
|
||||
self.LANGUAGES,
|
||||
allow_blank=False,
|
||||
value="Python",
|
||||
id="select-language",
|
||||
)
|
||||
|
||||
yield Static("Difficulty (Dropdown):", classes="input-label")
|
||||
yield Select.from_values(
|
||||
self.DIFFICULTIES,
|
||||
allow_blank=False,
|
||||
value="Medium",
|
||||
id="select-difficulty",
|
||||
)
|
||||
|
||||
yield Static("Subject / Topic:", classes="input-label")
|
||||
yield Input(placeholder="e.g. Memory Management, Data Structures", id="input-subject", value="General Concepts")
|
||||
|
||||
yield Button("✨ Generate with AI (Ctrl+G)", id="btn-generate", variant="warning")
|
||||
|
||||
yield Static("Filename (.md):", classes="input-label")
|
||||
yield Input(placeholder="new-challenge.md", id="input-filename")
|
||||
|
||||
yield Button("💾 Save Challenge to Disk (Ctrl+S)", id="btn-save", variant="success")
|
||||
|
||||
# Middle Panel: Markdown Code Editor
|
||||
with Container(id="editor-panel"):
|
||||
yield Static("2. Raw Markdown (.md) Editor", classes="section-header")
|
||||
yield TextArea(id="markdown-editor")
|
||||
|
||||
# Right Panel: Live Preview
|
||||
with Container(id="preview-panel"):
|
||||
yield Static("3. Live Rendered Preview", classes="section-header")
|
||||
with VerticalScroll(id="preview-scroll"):
|
||||
yield Markdown("AI generated preview will appear here...", id="markdown-preview")
|
||||
|
||||
yield Static("Ready — Type your prompt and click Generate with AI", id="status-bar")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
editor = self.query_one("#markdown-editor", TextArea)
|
||||
if "markdown" in editor.available_languages:
|
||||
editor.language = "markdown"
|
||||
self.query_one("#input-prompt", Input).focus()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-help-modal":
|
||||
self.action_open_help_modal()
|
||||
elif event.button.id == "btn-generate":
|
||||
self.action_generate_ai()
|
||||
elif event.button.id == "btn-save":
|
||||
self.action_save_challenge()
|
||||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
"""Update live preview whenever markdown editor content changes."""
|
||||
if event.text_area.id == "markdown-editor":
|
||||
self.query_one("#markdown-preview", Markdown).update(event.text_area.text)
|
||||
|
||||
def action_open_help_modal(self) -> None:
|
||||
"""Pop up the AI assistant help window."""
|
||||
self.push_screen(GenHelpModal())
|
||||
|
||||
def action_generate_ai(self) -> None:
|
||||
"""Generate challenge markdown using LLM service worker."""
|
||||
prompt_text = self.query_one("#input-prompt", Input).value.strip()
|
||||
if not prompt_text:
|
||||
self.query_one("#status-bar", Static).update("Please enter an AI prompt description.")
|
||||
return
|
||||
|
||||
lang_select = self.query_one("#select-language", Select)
|
||||
diff_select = self.query_one("#select-difficulty", Select)
|
||||
|
||||
lang = str(lang_select.value) if lang_select.value != Select.BLANK else "Python"
|
||||
diff = str(diff_select.value) if diff_select.value != Select.BLANK else "Medium"
|
||||
subj = self.query_one("#input-subject", Input).value.strip() or "General"
|
||||
|
||||
self.query_one("#status-bar", Static).update("⏳ Generating challenge via LLM backend...")
|
||||
self.run_worker(self._generate_worker(prompt_text, diff, lang, subj))
|
||||
|
||||
async def _generate_worker(self, prompt: str, difficulty: str, language: str, subject: str) -> None:
|
||||
res = await generator.generate_challenge_async(prompt, difficulty=difficulty, language=language, subject=subject)
|
||||
|
||||
if res.get("status") == "success":
|
||||
md_text = res.get("markdown", "")
|
||||
filename = res.get("filename", "new-challenge.md")
|
||||
|
||||
editor = self.query_one("#markdown-editor", TextArea)
|
||||
editor.text = md_text
|
||||
self.query_one("#input-filename", Input).value = filename
|
||||
self.query_one("#markdown-preview", Markdown).update(md_text)
|
||||
|
||||
self.query_one("#status-bar", Static).update(f"✓ Challenge generated: '{res.get('title')}' — Edit or click Save to disk")
|
||||
else:
|
||||
err_msg = res.get("message", "Unknown error")
|
||||
self.query_one("#status-bar", Static).update(f"✗ Challenge Generation Failed: {err_msg}")
|
||||
|
||||
def action_save_challenge(self) -> None:
|
||||
"""Save the markdown content to disk."""
|
||||
filename = self.query_one("#input-filename", Input).value.strip()
|
||||
md_text = self.query_one("#markdown-editor", TextArea).text.strip()
|
||||
|
||||
if not filename:
|
||||
self.query_one("#status-bar", Static).update("Please specify a filename (.md) before saving.")
|
||||
return
|
||||
|
||||
if not md_text:
|
||||
self.query_one("#status-bar", Static).update("Markdown editor is empty — write or generate content first.")
|
||||
return
|
||||
|
||||
res = generator.save_challenge(filename, md_text)
|
||||
if res.get("status") == "success":
|
||||
self.query_one("#status-bar", Static).update(f"✓ Saved challenge file to {res.get('path')}")
|
||||
else:
|
||||
self.query_one("#status-bar", Static).update(f"✗ Save Error: {res.get('message')}")
|
||||
|
||||
def action_toggle_word_wrap(self) -> None:
|
||||
"""Toggle soft word wrap in the raw markdown editor."""
|
||||
editor = self.query_one("#markdown-editor", TextArea)
|
||||
editor.soft_wrap = not editor.soft_wrap
|
||||
status = "ON" if editor.soft_wrap else "OFF"
|
||||
self.notify(f"Markdown Editor Word Wrap turned {status}", title="Word Wrap Toggled")
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="GenTUI — TactiTerm AI Challenge Generator")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||
args = parser.parse_args()
|
||||
|
||||
app = GenTUIApp(debug=args.debug)
|
||||
app.run()
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Custom widgets for the TactiTerm TUI with auto-completion engine and StdinInput."""
|
||||
import asyncio
|
||||
from typing import List, Dict
|
||||
from textual import events
|
||||
from textual.message import Message
|
||||
from textual.widgets import TextArea, Input
|
||||
|
||||
# Comprehensive auto-completion syntax catalog per language for TUI
|
||||
TUI_COMPLETIONS: Dict[str, List[str]] = {
|
||||
"python": [
|
||||
"int", "float", "bool", "str", "list", "dict", "set", "tuple", "bytes", "None",
|
||||
"True", "False", "print()", "len()", "range()", "enumerate()", "zip()", "map()",
|
||||
"filter()", "sorted()", "isinstance()", "type()", "open()", "input()", ".append()",
|
||||
".split()", ".join()", ".get()", "def ", "class ", "return ", "import ", "from ",
|
||||
"if ", "elif ", "else:", "try:", "except ", "finally:", "raise ", "with ", "as ",
|
||||
"yield ", "async ", "await ", "lambda ", "pass", "break", "continue", "assert "
|
||||
],
|
||||
"rust": [
|
||||
"bool", "i32", "i64", "u32", "u64", "usize", "f32", "f64", "char", "str", "String",
|
||||
"Vec", "Option", "Result", "Some", "None", "Ok", "Err", "println!()", "print!()",
|
||||
"format!()", "vec![]", "Vec::push()", "String::from()", "Option::unwrap()",
|
||||
"Result::expect()", ".iter()", ".collect()", "Box::new()", "fn ", "let ", "let mut ",
|
||||
"pub ", "struct ", "enum ", "impl ", "trait ", "match ", "if ", "else ", "loop ",
|
||||
"while ", "for ", "in ", "return ", "use std::", "async ", "await "
|
||||
],
|
||||
"cpp": [
|
||||
"int", "float", "double", "bool", "char", "void", "size_t", "std::string",
|
||||
"std::vector<", "std::map<", "std::set<", "std::unique_ptr<", "std::shared_ptr<",
|
||||
"std::cout << ", "std::cin >> ", "std::endl", ".push_back()", "std::sort()",
|
||||
"std::make_unique<", "std::make_shared<", "class ", "struct ", "public:", "private:",
|
||||
"protected:", "virtual ", "override", "const ", "constexpr ", "auto ", "template<typename T>",
|
||||
"namespace ", "using namespace std;", "return ", "if ", "else ", "for ", "while "
|
||||
],
|
||||
"go": [
|
||||
"int", "int64", "float64", "bool", "string", "byte", "rune", "error", "nil", "true",
|
||||
"false", "fmt.Println()", "fmt.Printf()", "fmt.Sprintf()", "make()", "append()",
|
||||
"len()", "cap()", "delete()", "strings.Split()", "os.Open()", "func ", "var ", "type ",
|
||||
"struct ", "interface ", "package main", "import ", "return ", "if ", "else ", "for ",
|
||||
"range ", "switch ", "select ", "chan ", "go func()"
|
||||
],
|
||||
"javascript": [
|
||||
"number", "string", "boolean", "bool", "any", "void", "null", "undefined", "Array",
|
||||
"Object", "Promise", "console.log()", "console.error()", "console.warn()", ".map()",
|
||||
".filter()", ".reduce()", ".find()", ".push()", ".includes()", "Object.keys()",
|
||||
"JSON.parse()", "fetch()", "const ", "let ", "var ", "function ", "class ", "return ",
|
||||
"if ", "else ", "for ", "while ", "switch ", "try ", "catch ", "async ", "await ", "new "
|
||||
],
|
||||
"typescript": [
|
||||
"number", "string", "boolean", "bool", "any", "void", "unknown", "never", "null",
|
||||
"undefined", "Array<", "Record<", "Partial<", "Readonly<", "Pick<", "Omit<",
|
||||
"Promise<", "console.log()", ".map()", ".filter()", "interface ", "type ", "const ",
|
||||
"let ", "function ", "class ", "export ", "import ", "async ", "await "
|
||||
],
|
||||
"java": [
|
||||
"int", "long", "double", "float", "boolean", "bool", "char", "byte", "short", "void",
|
||||
"String", "List<", "ArrayList<", "Map<", "HashMap<", "Set<", "HashSet<",
|
||||
"System.out.println()", ".add()", ".get()", ".size()", "public ", "private ",
|
||||
"protected ", "static ", "final ", "class ", "interface ", "extends ", "implements ",
|
||||
"new ", "return ", "if ", "else ", "for ", "while ", "try ", "catch "
|
||||
],
|
||||
"csharp": [
|
||||
"int", "long", "double", "float", "bool", "char", "string", "object", "void", "var",
|
||||
"List<", "Dictionary<", "HashSet<", "Task<", "Console.WriteLine()", "Console.ReadLine()",
|
||||
".Add()", "Enumerable.Where()", "public ", "private ", "protected ", "internal ",
|
||||
"static ", "class ", "struct ", "interface ", "record ", "namespace ", "using System;",
|
||||
"new ", "return ", "if ", "else ", "foreach ", "while ", "async ", "await "
|
||||
],
|
||||
"lua": [
|
||||
"number", "int", "string", "boolean", "bool", "table", "nil", "function", "print()",
|
||||
"type()", "tostring()", "tonumber()", "table.insert()", "string.sub()", "local ",
|
||||
"if ", "then", "else", "elseif ", "end", "for ", "while ", "do", "return ", "require()"
|
||||
],
|
||||
"html": [
|
||||
"<!DOCTYPE html>", "<html>", "<head>", "<title>", "<body>", "<h1>", "<h2>", "<h3>",
|
||||
"<p>", "<span>", "<div>", "<a href=\"\">", "<img src=\"\">", "<form>", "<input type=\"text\">",
|
||||
"<button>", "<select>", "<option>", "<ul>", "<ol>", "<li>", "<table>", "<tr>", "<td>", "<th>",
|
||||
"<script>", "<link rel=\"stylesheet\">"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# Duplicate csharp completion list for c_sharp key
|
||||
TUI_COMPLETIONS["c_sharp"] = TUI_COMPLETIONS["csharp"]
|
||||
|
||||
|
||||
def get_tui_language(language: str) -> str:
|
||||
"""Map display/challenge language names to Textual's expected TextArea language keys."""
|
||||
if not language:
|
||||
return "python"
|
||||
norm = language.lower().strip()
|
||||
mapping = {
|
||||
"c++": "cpp",
|
||||
"cpp": "cpp",
|
||||
"cxx": "cpp",
|
||||
"c#": "c_sharp",
|
||||
"cs": "c_sharp",
|
||||
"csharp": "c_sharp",
|
||||
"c_sharp": "c_sharp",
|
||||
"js": "javascript",
|
||||
"javascript": "javascript",
|
||||
"ts": "typescript",
|
||||
"typescript": "typescript",
|
||||
"py": "python",
|
||||
"python": "python",
|
||||
"rs": "rust",
|
||||
"rust": "rust",
|
||||
"golang": "go",
|
||||
"go": "go",
|
||||
"html": "html",
|
||||
"java": "java",
|
||||
"lua": "lua",
|
||||
}
|
||||
return mapping.get(norm, norm)
|
||||
|
||||
|
||||
def register_custom_tree_sitter_languages(editor: TextArea) -> None:
|
||||
"""Register tree-sitter language grammars with rich query definitions for cpp, c_sharp, typescript, lua if available."""
|
||||
langs = {
|
||||
"cpp": (
|
||||
"tree_sitter_cpp",
|
||||
"language",
|
||||
[
|
||||
'["if" "else" "for" "while" "return" "class" "struct" "namespace" "using" "public" "private" "protected" "virtual" "const" "inline" "static" "template" "typename" "new" "delete" "catch" "try" "throw"] @keyword',
|
||||
'(primitive_type) @type',
|
||||
'(type_identifier) @type',
|
||||
'(comment) @comment',
|
||||
'(string_literal) @string',
|
||||
'(system_lib_string) @string',
|
||||
'(number_literal) @number',
|
||||
'(field_identifier) @property',
|
||||
'(function_declarator declarator: (identifier) @function)',
|
||||
'(call_expression function: (identifier) @function.call)',
|
||||
'(preproc_include) @include',
|
||||
'(preproc_def) @keyword',
|
||||
'(true) @boolean',
|
||||
'(false) @boolean',
|
||||
'(null) @constant.builtin',
|
||||
],
|
||||
),
|
||||
"c_sharp": (
|
||||
"tree_sitter_c_sharp",
|
||||
"language",
|
||||
[
|
||||
'["if" "else" "for" "foreach" "while" "return" "class" "struct" "interface" "public" "private" "protected" "internal" "static" "async" "await" "using" "namespace" "new" "get" "set" "try" "catch" "throw"] @keyword',
|
||||
'(predefined_type) @type',
|
||||
'(comment) @comment',
|
||||
'(string_literal) @string',
|
||||
'(verbatim_string_literal) @string',
|
||||
'(integer_literal) @number',
|
||||
'(real_literal) @number',
|
||||
'(identifier) @variable',
|
||||
'(method_declaration name: (identifier) @function)',
|
||||
'(invocation_expression function: (identifier) @function.call)',
|
||||
'(boolean_literal) @boolean',
|
||||
'(null_literal) @constant.builtin',
|
||||
],
|
||||
),
|
||||
"typescript": (
|
||||
"tree_sitter_typescript",
|
||||
"language_typescript",
|
||||
[
|
||||
'["if" "else" "for" "while" "return" "function" "class" "interface" "type" "const" "let" "var" "import" "from" "export" "async" "await" "new" "try" "catch" "throw" "switch" "case"] @keyword',
|
||||
'(predefined_type) @type',
|
||||
'(type_identifier) @type',
|
||||
'(comment) @comment',
|
||||
'(string) @string',
|
||||
'(template_string) @string',
|
||||
'(number) @number',
|
||||
'(property_identifier) @property',
|
||||
'(function_declaration name: (identifier) @function)',
|
||||
'(call_expression function: (identifier) @function.call)',
|
||||
'(true) @boolean',
|
||||
'(false) @boolean',
|
||||
'(null) @constant.builtin',
|
||||
'(undefined) @constant.builtin',
|
||||
],
|
||||
),
|
||||
"lua": (
|
||||
"tree_sitter_lua",
|
||||
"language",
|
||||
[
|
||||
'["if" "then" "else" "elseif" "end" "function" "return" "while" "for" "do" "local" "repeat" "until" "and" "or" "not" "in"] @keyword',
|
||||
'(break_statement) @keyword',
|
||||
'(comment) @comment',
|
||||
'(string) @string',
|
||||
'(number) @number',
|
||||
'(function_declaration name: (identifier) @function)',
|
||||
'(function_call name: (identifier) @function.call)',
|
||||
'(dot_index_expression field: (identifier) @property)',
|
||||
'(true) @boolean',
|
||||
'(false) @boolean',
|
||||
'(nil) @constant.builtin',
|
||||
],
|
||||
),
|
||||
}
|
||||
for key, (mod_name, func_name, sample_queries) in langs.items():
|
||||
if key not in editor.available_languages:
|
||||
try:
|
||||
import importlib
|
||||
import textual._tree_sitter as ts
|
||||
import textual.widgets._text_area as ta
|
||||
|
||||
mod = importlib.import_module(mod_name)
|
||||
func = getattr(mod, func_name)
|
||||
lang_obj = ts.Language(func())
|
||||
|
||||
query_str = ""
|
||||
for q in sample_queries:
|
||||
try:
|
||||
document = ta.SyntaxAwareDocument("", lang_obj)
|
||||
document.prepare_query(q)
|
||||
query_str += ("\n" if query_str else "") + q
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
editor.register_language(key, lang_obj, query_str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
import re
|
||||
|
||||
class CodebaseSymbolExtractor:
|
||||
"""Extracts symbols, variables, functions, and classes from the active code buffer."""
|
||||
|
||||
@staticmethod
|
||||
def extract_symbols(code: str) -> List[str]:
|
||||
if not code:
|
||||
return []
|
||||
words = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", code)
|
||||
unique = set()
|
||||
reserved = {
|
||||
"if", "else", "for", "while", "return", "import", "from", "def", "class",
|
||||
"public", "private", "protected", "static", "void", "int", "double", "float",
|
||||
"bool", "boolean", "char", "const", "let", "var", "func", "package", "struct",
|
||||
}
|
||||
for w in words:
|
||||
if len(w) > 2 and w not in reserved:
|
||||
unique.add(w)
|
||||
return sorted(list(unique))
|
||||
|
||||
|
||||
METHOD_CATALOGS: Dict[str, Dict[str, List[str]]] = {
|
||||
"java": {
|
||||
"map": ["put(key, value)", "get(key)", "containsKey(key)", "containsValue(val)", "size()", "isEmpty()", "keySet()", "values()", "entrySet()", "remove(key)", "clear()", "getOrDefault(key, default)"],
|
||||
"list": ["add(element)", "get(index)", "size()", "remove(index)", "contains(element)", "indexOf(element)", "isEmpty()", "clear()", "set(index, element)"],
|
||||
"set": ["add(element)", "remove(element)", "contains(element)", "size()", "isEmpty()", "clear()"],
|
||||
"string": ["length()", "substring(beginIndex)", "charAt(index)", "toLowerCase()", "toUpperCase()", "trim()", "split(regex)", "contains(str)", "startsWith(prefix)", "equals(obj)"],
|
||||
"system": ["println(value)", "printf(format, args)", "print(value)"],
|
||||
},
|
||||
"python": {
|
||||
"map": ["get(key)", "keys()", "values()", "items()", "update(dict)", "pop(key)", "clear()"],
|
||||
"list": ["append(item)", "extend(iterable)", "insert(index, item)", "remove(item)", "pop()", "sort()", "reverse()", "clear()", "count(item)", "index(item)"],
|
||||
"string": ["split(sep)", "join(iterable)", "lower()", "upper()", "strip()", "replace(old, new)", "startswith(prefix)", "endswith(suffix)", "find(sub)"],
|
||||
},
|
||||
"cpp": {
|
||||
"map": ["insert({key, val})", "find(key)", "count(key)", "size()", "empty()", "clear()", "at(key)"],
|
||||
"list": ["push_back(val)", "pop_back()", "size()", "empty()", "clear()", "begin()", "end()", "at(idx)"],
|
||||
"string": ["length()", "size()", "substr(pos, len)", "append(str)", "find(str)", "c_str()", "empty()"],
|
||||
"std": ["cout << value << std::endl;", "cin >> var;", "vector<int>", "map<string, int>", "sort(begin, end)"],
|
||||
},
|
||||
"c_sharp": {
|
||||
"map": ["Add(key, val)", "ContainsKey(key)", "TryGetValue(key, out val)", "Remove(key)", "Count", "Clear()"],
|
||||
"list": ["Add(item)", "Remove(item)", "RemoveAt(index)", "Contains(item)", "Count", "Clear()"],
|
||||
"string": ["Length", "Substring(startIndex)", "ToLower()", "ToUpper()", "Trim()", "Split(sep)", "Replace(old, new)"],
|
||||
"console": ["WriteLine(value)", "ReadLine()", "Write(value)"],
|
||||
},
|
||||
"javascript": {
|
||||
"map": ["set(key, val)", "get(key)", "has(key)", "delete(key)", "clear()", "size"],
|
||||
"list": ["map(x => x)", "filter(x => true)", "push(item)", "pop()", "slice(start, end)", "includes(item)", "length"],
|
||||
"console": ["log(msg)", "error(err)", "warn(msg)"],
|
||||
},
|
||||
"typescript": {
|
||||
"map": ["set(key, val)", "get(key)", "has(key)", "delete(key)", "clear()", "size"],
|
||||
"list": ["map(x => x)", "filter(x => true)", "push(item)", "pop()", "slice(start, end)", "includes(item)", "length"],
|
||||
"console": ["log(msg)", "error(err)", "warn(msg)"],
|
||||
},
|
||||
"rust": {
|
||||
"map": ["insert(key, val)", "get(&key)", "contains_key(&key)", "remove(&key)", "len()", "is_empty()"],
|
||||
"list": ["push(val)", "pop()", "len()", "is_empty()", "contains(&val)", "iter()", "collect()"],
|
||||
},
|
||||
"go": {
|
||||
"fmt": ["Println(v)", "Printf(format, v)", "Sprintf(format, v)"],
|
||||
"strings": ["Split(s, sep)", "Join(a, sep)", "ToLower(s)", "ToUpper(s)"],
|
||||
},
|
||||
"lua": {
|
||||
"table": ["insert(t, val)", "remove(t, pos)", "concat(t, sep)", "sort(t)"],
|
||||
"string": ["sub(s, i, j)", "lower(s)", "upper(s)", "len(s)"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CodeEditor(TextArea):
|
||||
"""A code editor with auto-completion dropdown trigger."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
CodeEditor {
|
||||
height: 1fr;
|
||||
min-height: 8;
|
||||
border: solid $accent;
|
||||
}
|
||||
CodeEditor:focus {
|
||||
border: heavy $primary;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
language: str = "python",
|
||||
theme: str = "vscode_dark",
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
language=None,
|
||||
theme=theme,
|
||||
soft_wrap=False,
|
||||
show_line_numbers=True,
|
||||
id=id,
|
||||
classes=classes,
|
||||
)
|
||||
register_custom_tree_sitter_languages(self)
|
||||
self.language = get_tui_language(language)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.tab_behavior = "indent"
|
||||
self.cursor_style = "bold blue"
|
||||
|
||||
def get_code(self) -> str:
|
||||
return self.text
|
||||
|
||||
def get_completions_at_cursor(self) -> tuple[str, List[str], bool]:
|
||||
"""Returns (prefix_to_replace, matching_candidates, is_dot_access)."""
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
lines = self.text.split("\n")
|
||||
if cursor_row >= len(lines):
|
||||
return ("", [], False)
|
||||
|
||||
current_line = lines[cursor_row][:cursor_col]
|
||||
lang = get_tui_language(self.language or "python")
|
||||
|
||||
# 1. Check dot member access (e.g. stockMap. or stockMap.p or System.out.)
|
||||
dot_match = re.search(r"([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z0-9_]*)$", current_line)
|
||||
if dot_match:
|
||||
obj_name = dot_match.group(1)
|
||||
member_prefix = dot_match.group(2)
|
||||
obj_lower = obj_name.lower()
|
||||
|
||||
lang_methods = METHOD_CATALOGS.get(lang, METHOD_CATALOGS.get(lang.replace("_", ""), METHOD_CATALOGS["java"]))
|
||||
target_type = "map"
|
||||
if "map" in obj_lower or "dict" in obj_lower:
|
||||
target_type = "map"
|
||||
elif "list" in obj_lower or "arr" in obj_lower or "items" in obj_lower:
|
||||
target_type = "list"
|
||||
elif "set" in obj_lower:
|
||||
target_type = "set"
|
||||
elif "str" in obj_lower or "text" in obj_lower or "name" in obj_lower or "msg" in obj_lower:
|
||||
target_type = "string"
|
||||
elif obj_lower in lang_methods:
|
||||
target_type = obj_lower
|
||||
|
||||
candidates = lang_methods.get(target_type, lang_methods.get("map", []))
|
||||
matches = [c for c in candidates if c.lower().startswith(member_prefix.lower())]
|
||||
return (member_prefix, matches, True)
|
||||
|
||||
# 2. Standalone prefix word completion
|
||||
word_match = re.search(r"([a-zA-Z_][a-zA-Z0-9_]*)$", current_line)
|
||||
if word_match:
|
||||
prefix = word_match.group(1)
|
||||
if len(prefix) >= 1:
|
||||
static_candidates = TUI_COMPLETIONS.get(lang, TUI_COMPLETIONS.get(lang.replace("_", ""), TUI_COMPLETIONS["python"]))
|
||||
extracted_symbols = CodebaseSymbolExtractor.extract_symbols(self.text)
|
||||
all_candidates = list(dict.fromkeys(static_candidates + extracted_symbols))
|
||||
matches = [c for c in all_candidates if c.lower().startswith(prefix.lower()) and c.lower() != prefix.lower()]
|
||||
return (prefix, matches, False)
|
||||
|
||||
return ("", [], False)
|
||||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
"""Trigger instant as-you-type completion update on any text mutation."""
|
||||
if hasattr(self.app, "update_as_you_type_completion"):
|
||||
self.app.update_as_you_type_completion(self)
|
||||
|
||||
def _on_key(self, event: events.Key) -> None:
|
||||
"""Handle completion key navigation, selection, escape, and Ctrl+E toggle."""
|
||||
if event.key == "ctrl+e":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "action_toggle_expand_output"):
|
||||
self.app.action_toggle_expand_output()
|
||||
return
|
||||
|
||||
is_open = getattr(self.app, "is_completion_open", lambda: False)()
|
||||
|
||||
if is_open:
|
||||
if event.key in ("down", "up"):
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "navigate_completion"):
|
||||
self.app.navigate_completion(1 if event.key == "down" else -1)
|
||||
return
|
||||
elif event.key in ("enter", "tab"):
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "insert_selected_completion"):
|
||||
self.app.insert_selected_completion()
|
||||
return
|
||||
elif event.key == "escape":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "hide_completion_popup"):
|
||||
self.app.hide_completion_popup()
|
||||
return
|
||||
|
||||
super()._on_key(event)
|
||||
|
||||
|
||||
class StdinInput(Input):
|
||||
"""Input control for stdin that forwards Ctrl+E to screen output toggler."""
|
||||
|
||||
def _on_key(self, event: events.Key) -> None:
|
||||
if event.key == "ctrl+e":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "action_toggle_expand_output"):
|
||||
self.app.action_toggle_expand_output()
|
||||
return
|
||||
res = super()._on_key(event)
|
||||
if asyncio.iscoroutine(res):
|
||||
asyncio.create_task(res)
|
||||
|
||||
|
||||
class MentorInputTextArea(TextArea):
|
||||
"""Multi-line question input for Socratic Mentor. Submits on Enter, inserts newline on Shift+Enter."""
|
||||
|
||||
class SubmitQuestion(Message):
|
||||
"""Event posted when Enter is pressed."""
|
||||
|
||||
def __init__(self, question: str) -> None:
|
||||
self.question = question
|
||||
super().__init__()
|
||||
|
||||
def _on_key(self, event: events.Key) -> None:
|
||||
if event.key == "ctrl+e":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "action_toggle_expand_output"):
|
||||
self.app.action_toggle_expand_output()
|
||||
return
|
||||
elif event.key == "enter":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
self.post_message(self.SubmitQuestion(self.text))
|
||||
elif event.key in ["shift+enter", "ctrl+j"]:
|
||||
self.insert("\n")
|
||||
event.prevent_default()
|
||||
else:
|
||||
super()._on_key(event)
|
||||
Reference in New Issue
Block a user