🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

practicode

Package Overview
Dependencies
Maintainers
1
Versions
21
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

practicode - npm Package Compare versions

Comparing version
0.1.0
to
0.1.4
+54
assets/i18n/en.json
{
"problem": "Problem",
"output": "Output",
"command": "Command",
"commands": "Commands",
"difficulty": "Difficulty",
"topics": "Topics",
"input": "Input",
"examples": "Examples",
"example": "Example",
"answer": "Answer",
"source": "source",
"update": "update",
"command_placeholder": "Type /, move with up/down, Enter runs",
"palette_hint": "up/down select | Enter run | Esc cancel",
"hint_command": "Enter submit | Esc cancel",
"hint_list": "up/down move | Enter open | Esc close",
"hint_output": "Esc code | / command | ? help",
"hint_code": "Esc then / command",
"hint_idle": "/ command | ? help",
"help_title": "Help",
"daily_loop": "Daily loop",
"keys": "Keys",
"debug_prints": "Debug prints",
"cmd_run": "Judge the current submission",
"cmd_edit": "Return to the code editor",
"cmd_next": "Open the next problem",
"cmd_prev": "Open the previous problem",
"cmd_list": "Browse problems",
"cmd_open": "Open by number, id, or slug",
"cmd_giveup": "Show the reference answer",
"cmd_hint": "Ask for a hint about the current problem",
"cmd_ai": "Ask AI about the current problem and code",
"cmd_provider": "Set AI provider",
"cmd_model": "Set AI model",
"cmd_model_auto": "Use provider default model",
"cmd_model_available": "Use an available provider model",
"cmd_model_custom": "Type a model name",
"cmd_note": "Add a next-problem note",
"cmd_notes": "Show saved notes",
"cmd_lang": "Set code language",
"cmd_ui": "Set UI language",
"cmd_theme": "Set theme",
"cmd_source": "Set next-problem source",
"cmd_update": "Show update instructions",
"cmd_help": "Open help",
"cmd_exit": "Quit",
"update_available": "Update available",
"update_none": "practicode is up to date.",
"update_check_disabled": "Update check is disabled.",
"update_check_failed": "Could not check for updates.",
"generating_next": "Generating next problem",
"already_busy": "Already busy."
}
{
"problem": "Problema",
"output": "Salida",
"command": "Comando",
"commands": "Comandos",
"difficulty": "Dificultad",
"topics": "Temas",
"input": "Entrada",
"examples": "Ejemplos",
"example": "Ejemplo",
"answer": "Respuesta",
"source": "fuente",
"update": "actualizacion",
"command_placeholder": "Escribe /, elige con arriba/abajo, Enter ejecuta",
"palette_hint": "arriba/abajo elegir | Enter ejecutar | Esc cancelar",
"hint_command": "Enter ejecutar | Esc cancelar",
"hint_list": "arriba/abajo mover | Enter abrir | Esc cerrar",
"hint_output": "Esc codigo | / comando | ? ayuda",
"hint_code": "Esc y luego / comando",
"hint_idle": "/ comando | ? ayuda",
"help_title": "Ayuda",
"daily_loop": "Flujo diario",
"keys": "Teclas",
"debug_prints": "Salida de depuracion",
"cmd_run": "Evalua la solucion actual",
"cmd_edit": "Volver al editor de codigo",
"cmd_next": "Abrir el siguiente problema",
"cmd_prev": "Abrir el problema anterior",
"cmd_list": "Abrir la lista de problemas",
"cmd_open": "Abrir por numero, id o slug",
"cmd_giveup": "Mostrar la respuesta de referencia",
"cmd_hint": "Pedir una pista para el problema actual",
"cmd_ai": "Preguntar a AI sobre el problema y codigo actuales",
"cmd_provider": "Configurar AI provider",
"cmd_model": "Configurar AI model",
"cmd_model_auto": "Usar el modelo predeterminado del provider",
"cmd_model_available": "Usar un modelo disponible del provider",
"cmd_model_custom": "Escribir un nombre de modelo",
"cmd_note": "Agregar nota para generar problemas",
"cmd_notes": "Ver notas guardadas",
"cmd_lang": "Configurar lenguaje de codigo",
"cmd_ui": "Configurar idioma de UI",
"cmd_theme": "Configurar tema",
"cmd_source": "Configurar fuente del siguiente problema",
"cmd_update": "Mostrar instrucciones de actualizacion",
"cmd_help": "Abrir ayuda",
"cmd_exit": "Salir",
"update_available": "Hay una nueva version",
"update_none": "practicode esta actualizado.",
"update_check_disabled": "La comprobacion de actualizaciones esta desactivada.",
"update_check_failed": "No se pudo comprobar actualizaciones.",
"generating_next": "Generando el siguiente problema",
"already_busy": "Ya hay una tarea en curso."
}
{
"problem": "問題",
"output": "出力",
"command": "コマンド",
"commands": "コマンド",
"difficulty": "難易度",
"topics": "トピック",
"input": "入力",
"examples": "例",
"example": "例",
"answer": "解答",
"source": "source",
"update": "update",
"command_placeholder": "/ を入力し上下で選択、Enter で実行",
"palette_hint": "上下 選択 | Enter 実行 | Esc キャンセル",
"hint_command": "Enter 実行 | Esc キャンセル",
"hint_list": "上下 移動 | Enter 開く | Esc 閉じる",
"hint_output": "Esc コード | / コマンド | ? ヘルプ",
"hint_code": "Esc の後 / コマンド",
"hint_idle": "/ コマンド | ? ヘルプ",
"help_title": "ヘルプ",
"daily_loop": "基本フロー",
"keys": "キー",
"debug_prints": "デバッグ出力",
"cmd_run": "現在の提出を判定",
"cmd_edit": "コードエディタに戻る",
"cmd_next": "次の問題を開く",
"cmd_prev": "前の問題を開く",
"cmd_list": "問題一覧を開く",
"cmd_open": "番号、id、slug で問題を開く",
"cmd_giveup": "解答を見る",
"cmd_hint": "現在の問題のヒントを依頼",
"cmd_ai": "現在の問題とコードについて AI に質問",
"cmd_provider": "AI provider を設定",
"cmd_model": "AI model を設定",
"cmd_model_auto": "provider の既定モデルを使用",
"cmd_model_available": "利用可能な provider モデルを使用",
"cmd_model_custom": "モデル名を直接入力",
"cmd_note": "次の問題生成メモを追加",
"cmd_notes": "保存済みメモを見る",
"cmd_lang": "コード言語を設定",
"cmd_ui": "UI 言語を設定",
"cmd_theme": "テーマを設定",
"cmd_source": "次の問題の取得元を設定",
"cmd_update": "更新手順を表示",
"cmd_help": "ヘルプを開く",
"cmd_exit": "終了",
"update_available": "新しいバージョンがあります",
"update_none": "practicode は最新です。",
"update_check_disabled": "更新確認は無効です。",
"update_check_failed": "更新を確認できませんでした。",
"generating_next": "次の問題を生成中",
"already_busy": "すでに処理中です。"
}
{
"problem": "문제",
"output": "출력",
"command": "명령",
"commands": "명령",
"difficulty": "난이도",
"topics": "주제",
"input": "입력",
"examples": "예시",
"example": "예시",
"answer": "정답",
"source": "출처",
"update": "업데이트",
"command_placeholder": "/ 입력 후 위/아래로 선택, Enter 실행",
"palette_hint": "위/아래 선택 | Enter 실행 | Esc 취소",
"hint_command": "Enter 실행 | Esc 취소",
"hint_list": "위/아래 이동 | Enter 열기 | Esc 닫기",
"hint_output": "Esc 코드 | / 명령 | ? 도움말",
"hint_code": "Esc 후 / 명령",
"hint_idle": "/ 명령 | ? 도움말",
"help_title": "도움말",
"daily_loop": "기본 흐름",
"keys": "키",
"debug_prints": "디버그 출력",
"cmd_run": "현재 제출을 채점",
"cmd_edit": "코드 편집기로 돌아가기",
"cmd_next": "다음 문제 열기",
"cmd_prev": "이전 문제 열기",
"cmd_list": "문제 목록 열기",
"cmd_open": "번호, id, slug로 문제 열기",
"cmd_giveup": "정답 보기",
"cmd_hint": "현재 문제 힌트 요청",
"cmd_ai": "현재 문제와 코드에 대해 AI에게 질문",
"cmd_provider": "AI provider 설정",
"cmd_model": "AI model 설정",
"cmd_model_auto": "provider 기본 모델 사용",
"cmd_model_available": "사용 가능한 provider 모델 선택",
"cmd_model_custom": "모델 이름 직접 입력",
"cmd_note": "다음 문제 생성 메모 추가",
"cmd_notes": "저장된 메모 보기",
"cmd_lang": "코드 언어 설정",
"cmd_ui": "UI 언어 설정",
"cmd_theme": "테마 설정",
"cmd_source": "다음 문제 출처 설정",
"cmd_update": "업데이트 안내 보기",
"cmd_help": "도움말 열기",
"cmd_exit": "종료",
"update_available": "새 버전 사용 가능",
"update_none": "practicode가 최신 버전입니다.",
"update_check_disabled": "업데이트 확인이 꺼져 있습니다.",
"update_check_failed": "업데이트를 확인할 수 없습니다.",
"generating_next": "다음 문제 생성 중",
"already_busy": "이미 작업 중입니다."
}
{
"problem": "题目",
"output": "输出",
"command": "命令",
"commands": "命令",
"difficulty": "难度",
"topics": "主题",
"input": "输入",
"examples": "示例",
"example": "示例",
"answer": "答案",
"source": "来源",
"update": "更新",
"command_placeholder": "输入 / 后用上下键选择,Enter 执行",
"palette_hint": "上下 选择 | Enter 执行 | Esc 取消",
"hint_command": "Enter 执行 | Esc 取消",
"hint_list": "上下 移动 | Enter 打开 | Esc 关闭",
"hint_output": "Esc 代码 | / 命令 | ? 帮助",
"hint_code": "Esc 后输入 / 命令",
"hint_idle": "/ 命令 | ? 帮助",
"help_title": "帮助",
"daily_loop": "日常流程",
"keys": "按键",
"debug_prints": "调试输出",
"cmd_run": "评测当前提交",
"cmd_edit": "回到代码编辑器",
"cmd_next": "打开下一题",
"cmd_prev": "打开上一题",
"cmd_list": "打开题目列表",
"cmd_open": "按编号、id 或 slug 打开题目",
"cmd_giveup": "显示参考答案",
"cmd_hint": "请求当前题目的提示",
"cmd_ai": "向 AI 询问当前题目和代码",
"cmd_provider": "设置 AI provider",
"cmd_model": "设置 AI model",
"cmd_model_auto": "使用 provider 默认模型",
"cmd_model_available": "使用可用的 provider 模型",
"cmd_model_custom": "输入模型名称",
"cmd_note": "添加下次出题备注",
"cmd_notes": "查看保存的备注",
"cmd_lang": "设置代码语言",
"cmd_ui": "设置 UI 语言",
"cmd_theme": "设置主题",
"cmd_source": "设置下一题来源",
"cmd_update": "显示更新说明",
"cmd_help": "打开帮助",
"cmd_exit": "退出",
"update_available": "有新版本可用",
"update_none": "practicode 已是最新版本。",
"update_check_disabled": "更新检查已禁用。",
"update_check_failed": "无法检查更新。",
"generating_next": "正在生成下一题",
"already_busy": "已有任务正在运行。"
}
# Contributing
Thanks for helping improve practicode. This guide is for contributors opening issues or pull requests.
Maintainer-only review and release steps live in [MAINTAINING.md](MAINTAINING.md).
## Before You Start
- Search existing issues and pull requests first.
- Small bug fixes, docs fixes, tests, and localization updates can go straight to a pull request.
- For larger UI, AI-generation, storage, or packaging changes, open an issue first so the scope is clear.
- Do not commit local practice data from `.practicode/`, `problems/`, or `submissions/`.
- Do not include secrets, tokens, private prompts, or generated answer keys in docs or examples.
## Fork And Pull Request Flow
1. Fork `baba9811/practicode` on GitHub.
2. Clone your fork and add the original repo as `upstream`.
```bash
git clone https://github.com/<your-user>/practicode.git
cd practicode
git remote add upstream https://github.com/baba9811/practicode.git
```
3. Create a focused branch from the latest `main`.
```bash
git fetch upstream
git checkout -b fix-short-name upstream/main
```
4. Make one focused change.
5. Run the smallest checks that cover your change.
6. Push your branch and open a pull request into `baba9811/practicode:main`.
```bash
git push origin fix-short-name
```
When opening the pull request, include what changed, how you checked it, and screenshots for visible TUI changes.
## Local Setup
Prerequisites:
- Rust stable with Cargo, rustfmt, and clippy.
- Node.js 18+ for the npm wrapper and package checks.
- Optional local runtimes for judging: Python, Node.js, JDK, and Rust.
Common commands:
```bash
cargo run --
cargo run -- --smoke
cargo test
npm run smoke
```
Full local check:
```bash
make test
```
## Project Map
| Path | Role |
| --- | --- |
| `src/core.rs` | Problem storage, state, rendering, judging |
| `src/i18n.rs` | Loads UI strings from `assets/i18n/*.json` |
| `src/tui.rs` | Ratatui app, editor, command parser |
| `src/ai.rs` | Codex/Claude command integration and notes |
| `src/text.rs` | UTF-8 cursor math and Hangul composition |
| `src/process.rs` | Process execution helpers |
| `tests/` | Integration tests split by module |
## Change Guidelines
- Keep pull requests small and reviewable.
- Reuse existing helpers and patterns before adding new code.
- Prefer the Rust standard library. Add crates only when they remove real complexity.
- Put UI strings in [assets/i18n](../assets/i18n), not inline in Rust code.
- Keep English localization complete first; other locales can be partial because the runtime falls back per key to English.
- Keep the root [README](../README.md) focused on users.
- Use relative links for repo-local docs and assets.
## Problem Authoring
AI generation reads [problem-authoring-notes.md](problem-authoring-notes.md) every time it creates a problem.
Local generated data stays ignored by git:
| Path | Purpose |
| --- | --- |
| `.practicode/problem_bank.json` | Local/custom/generated problems |
| `.practicode/problem_notes.md` | Personal problem-generation notes |
| `.practicode/problem-state.json` | Current problem, history, settings |
| `problems/` | Generated problem markdown/index files |
| `submissions/` | Local answer files |
## Pull Request Checklist
- The change is focused and explained.
- Relevant checks were run, or the PR says why they were not.
- Visible TUI changes include a screenshot or short terminal description.
- User-facing text is in `assets/i18n/*.json`.
- Local generated data, secrets, tokens, and answer keys are not committed.
## References
- GitHub contributing guide: https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-open-source
- GitHub contributing guidelines: https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/setting-guidelines-for-repository-contributors
- GitHub pull requests: https://docs.github.com/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests
- Open Source Guides community notes: https://opensource.guide/building-community/
- WAI-ARIA combobox keyboard interaction: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
- Command Line Interface Guidelines: https://clig.dev/
- Ratatui terminal UI library: https://ratatui.rs/
- Crossterm terminal backend/events: https://github.com/crossterm-rs/crossterm
- Kattis problem package format: https://www.kattis.com/problem-package-format/
- ICPC judging guidelines: https://icpc.global/regionals/regional-contest-cookbook-judging-guidelines
# Maintaining
This guide is for maintainers with commit, tag, or publishing responsibility.
Contributor-facing workflow lives in [CONTRIBUTING.md](CONTRIBUTING.md).
## Triage
- Keep issues actionable: expected behavior, actual behavior, reproduction steps, OS, terminal, and install method.
- Use small scopes. Ask broad proposals to become one issue or pull request per behavior change.
- Label approachable work as `good first issue` or `help wanted` when the fix is clear.
- Close duplicates with a link to the canonical issue.
## Pull Request Review
- Review the diff file by file.
- Check correctness, local-first behavior, terminal UX, docs, and tests before style.
- Prefer focused pull requests. Ask contributors to split unrelated changes.
- Require checks or a clear reason checks were not run.
- For visible TUI changes, ask for a screenshot or a short terminal description.
- Merge only after CI passes and the branch is up to date enough to avoid obvious conflicts.
## Release
`main` runs CI only. Releases are tag-based and publish to crates.io and npm through GitHub Actions.
Preflight:
```bash
git checkout main
git pull --ff-only origin main
make test
```
Release:
```bash
make release VERSION=0.1.3
```
The release script checks versions, runs tests, creates the version commit and tag, and pushes `main` plus the tag.
Verify publication:
```bash
gh run list --limit 5
npm view practicode version
cargo search practicode --limit 1
```
Do not print or commit tokens. Local `.env` and `.npmrc` are ignored; GitHub Actions uses `NPM_TOKEN` and `CRATES_TOKEN` repository secrets.
## Documentation Ownership
| File | Audience |
| --- | --- |
| [../README.md](../README.md) | Users installing and running practicode |
| [CONTRIBUTING.md](CONTRIBUTING.md) | External contributors opening issues or pull requests |
| [MAINTAINING.md](MAINTAINING.md) | Maintainers reviewing, triaging, and releasing |
| [problem-authoring-notes.md](problem-authoring-notes.md) | AI/local problem generation rules |
## Maintainer References
- GitHub reviewing pull requests: https://docs.github.com/articles/reviewing-proposed-changes-in-a-pull-request
- GitHub helping reviewers: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/getting-started/helping-others-review-your-changes
- GitHub healthy contributions: https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions
- Git contributing through forks: https://git-scm.com/book/en/v2/GitHub-Contributing-to-a-Project
#!/usr/bin/env bash
set -euo pipefail
current=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
npm_current=$(node -p "require('./package.json').version")
if [[ "$current" != "$npm_current" ]]; then
echo "Cargo.toml version ($current) does not match package.json ($npm_current)" >&2
exit 1
fi
if [[ -n "$(git status --porcelain)" ]]; then
echo "working tree is dirty; commit or stash changes first" >&2
exit 1
fi
branch=$(git branch --show-current)
if [[ "$branch" != "main" ]]; then
echo "release from main, not $branch" >&2
exit 1
fi
git fetch origin main --tags
if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
echo "local main is not synced with origin/main" >&2
exit 1
fi
echo "Current version: $current"
version="${1:-}"
if [[ -z "$version" ]]; then
read -r -p "Next version: " version
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "version must look like 0.1.1" >&2
exit 2
fi
node -e '
const [current, next] = process.argv.slice(1).map(v => v.split(".").map(Number));
const ok = next[0] > current[0] ||
(next[0] === current[0] && (next[1] > current[1] ||
(next[1] === current[1] && next[2] > current[2])));
process.exit(ok ? 0 : 1);
' "$current" "$version" || {
echo "next version must be greater than $current" >&2
exit 2
}
if git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then
echo "tag v$version already exists locally" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "v$version" >/dev/null 2>&1; then
echo "tag v$version already exists on origin" >&2
exit 1
fi
echo "Releasing v$version"
VERSION="$version" perl -0pi -e 's/^version = ".*"/version = "$ENV{VERSION}"/m' Cargo.toml
node -e "const fs=require('fs'); const p=require('./package.json'); p.version=process.env.VERSION; fs.writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n')"
cargo test
npm pack --dry-run >/dev/null
git add Cargo.toml Cargo.lock package.json
git commit -m "Release v$version"
git tag "v$version"
git push origin main "v$version"
use std::{collections::HashMap, sync::OnceLock};
pub const UI_LANGUAGES: &[&str] = &["en", "ko", "ja", "zh", "es"];
static EN: OnceLock<HashMap<String, String>> = OnceLock::new();
static KO: OnceLock<HashMap<String, String>> = OnceLock::new();
static JA: OnceLock<HashMap<String, String>> = OnceLock::new();
static ZH: OnceLock<HashMap<String, String>> = OnceLock::new();
static ES: OnceLock<HashMap<String, String>> = OnceLock::new();
pub fn normalize_ui_language(language: &str) -> String {
let lower = language.trim().to_lowercase();
let short = lower
.split(['-', '_'])
.next()
.filter(|value| !value.is_empty())
.unwrap_or("en");
if UI_LANGUAGES.contains(&short) {
short.to_string()
} else {
"en".to_string()
}
}
pub fn ui_text(lang: &str, key: &str) -> &'static str {
catalog(&normalize_ui_language(lang))
.get(key)
.or_else(|| catalog("en").get(key))
.map(String::as_str)
.unwrap_or("")
}
fn catalog(lang: &str) -> &'static HashMap<String, String> {
match lang {
"ko" => KO.get_or_init(|| load(include_str!("../assets/i18n/ko.json"))),
"ja" => JA.get_or_init(|| load(include_str!("../assets/i18n/ja.json"))),
"zh" => ZH.get_or_init(|| load(include_str!("../assets/i18n/zh.json"))),
"es" => ES.get_or_init(|| load(include_str!("../assets/i18n/es.json"))),
_ => EN.get_or_init(|| load(include_str!("../assets/i18n/en.json"))),
}
}
fn load(text: &str) -> HashMap<String, String> {
serde_json::from_str(text).expect("valid i18n catalog")
}
use crate::process::{run_capture, which};
use std::{env, process::Command, time::Duration};
pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum UpdateCheck {
Disabled,
Current,
Available(String),
Failed,
}
pub fn check_latest_version() -> UpdateCheck {
if env::var("PRACTICODE_NO_UPDATE_CHECK").ok().as_deref() == Some("1") {
return UpdateCheck::Disabled;
}
let Some(npm) = which("npm") else {
return UpdateCheck::Failed;
};
let mut command = Command::new(npm);
command.args(["view", "practicode", "version", "--silent"]);
match run_capture(&mut command, "", Duration::from_secs(5)) {
Ok(output) if output.code == Some(0) => {
let latest = output.stdout.trim();
if is_newer(latest, CURRENT_VERSION) {
UpdateCheck::Available(latest.to_string())
} else {
UpdateCheck::Current
}
}
_ => UpdateCheck::Failed,
}
}
pub fn is_newer(latest: &str, current: &str) -> bool {
version_parts(latest) > version_parts(current)
}
fn version_parts(version: &str) -> Vec<u64> {
version
.split('.')
.map(|part| part.parse::<u64>().unwrap_or(0))
.collect()
}
# Third-Party Licenses
This file summarizes third-party dependency license metadata for practicode
0.1.0, checked from Cargo.lock and Cargo metadata.
This is not legal advice.
## Project License
practicode is licensed under MIT. The current dependency set is compatible with
that choice: all Rust dependencies use permissive licenses, and the npm package
has no npm dependencies.
## npm Dependencies
None.
## Direct Rust Dependencies
| Crate | Version | License |
| --- | --- | --- |
| anyhow | 1.0.103 | MIT OR Apache-2.0 |
| crossterm | 0.29.0 | MIT |
| ratatui | 0.29.0 | MIT |
| serde | 1.0.228 | MIT OR Apache-2.0 |
| serde_json | 1.0.150 | MIT OR Apache-2.0 |
| unicode-width | 0.2.0 | MIT OR Apache-2.0 |
| wait-timeout | 0.2.1 | MIT/Apache-2.0 |
## Transitive Rust Dependency License Groups
The locked Rust dependency graph contains 87 third-party packages.
| License expression | Packages |
| --- | ---: |
| MIT OR Apache-2.0 | 50 |
| MIT | 20 |
| MIT/Apache-2.0 | 5 |
| Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | 5 |
| Apache-2.0 OR MIT | 1 |
| Apache-2.0/MIT | 1 |
| MIT / Apache-2.0 | 1 |
| Unlicense OR MIT | 1 |
| Zlib | 1 |
| Apache-2.0 OR BSL-1.0 | 1 |
| (MIT OR Apache-2.0) AND Unicode-3.0 | 1 |
No GPL, LGPL, AGPL, or MPL dependencies were detected in the locked Rust
dependency graph.
## Release Notes
The crates.io and npm packages distribute practicode source, not vendored
third-party source or prebuilt third-party binaries. The npm installer builds
the Rust binary locally with Cargo.
If practicode later ships prebuilt binaries, include full third-party notices
and license texts with those binary artifacts.
+1
-1

@@ -360,3 +360,3 @@ # This file is automatically @generated by Cargo.

name = "practicode"
version = "0.1.0"
version = "0.1.4"
dependencies = [

@@ -363,0 +363,0 @@ "anyhow",

[package]
name = "practicode"
version = "0.1.0"
version = "0.1.4"
edition = "2024"

@@ -5,0 +5,0 @@ description = "Local-first coding-test practice in a Rust terminal UI with optional AI help."

{
"name": "practicode",
"version": "0.1.0",
"version": "0.1.4",
"description": "Local-first coding-test practice in a Rust terminal UI with optional AI help.",

@@ -15,2 +15,3 @@ "license": "MIT",

"scripts": {
"start": "node bin/practicode.js",
"build": "cargo build --release --locked",

@@ -29,3 +30,4 @@ "postinstall": "node scripts/npm-postinstall.js",

"LICENSE",
"README.md"
"README.md",
"THIRD_PARTY_LICENSES.md"
],

@@ -32,0 +34,0 @@ "keywords": [

+45
-100

@@ -14,50 +14,34 @@ # practicode

`practicode` is a small Rust TUI for stdin/stdout practice: problem on the left, code on the right, judge loop in the same terminal.
No browser tab shuffle, no paste dance, just solve and run.
## Why It Exists
## Start
- Fast local judging for Python, TypeScript, Java, and Rust
- Gradual problem flow with local history
- AI-powered `/next <request>` when you want a custom problem
- Personal problem-generation notes
- Small stack: Rust, Ratatui, Crossterm, and plain process execution
### Prerequisites
## Quick Start
- Node.js 18+ for npm installation.
- Rust and Cargo. The npm package builds the Rust binary during install, and also on first run if needed.
- A runtime for the language you practice in: Python, Node.js for TypeScript, JDK for Java, or Rust.
```bash
git clone https://github.com/baba9811/practicode.git
cd practicode
cargo run --
```
### npm
Want a local binary?
```bash
cargo install practicode
npm install -g practicode
practicode
```
Prefer npm from GitHub?
### Cargo
```bash
npm install -g github:baba9811/practicode
cargo install practicode
practicode
```
Working from a local checkout?
### Local checkout
```bash
cargo install --path .
practicode
git clone https://github.com/baba9811/practicode.git
cd practicode
npm install
npm start
```
Or with npm:
```bash
npm install -g .
practicode
```
The npm wrapper builds the Rust binary with Cargo, so Rust/Cargo is still required.
## Daily Loop

@@ -69,6 +53,9 @@

write code
Esc, then /run
Esc, then /next easy string problem
Esc, then /
choose /run
choose /next when it passes
```
Typing `/` outside the editor opens the command palette. Use `up/down` to move, `Enter` to run or complete the selected command, and `Esc` to cancel.
Submissions are saved as you type under `submissions/<problem-id>/solution.<ext>`.

@@ -78,4 +65,2 @@

Press `Esc`, then `/`, to focus the command bar.
| Command | Action |

@@ -90,19 +75,20 @@ | --- | --- |

| `/giveup` | Show the reference answer |
| `/ai hint` | Ask the selected AI about the current problem and submission |
| `/provider codex` | Set AI provider: `codex` or `claude` |
| `/model sonnet` | Set the model for `/ai` and AI-backed `/next`; use `auto` for the CLI default |
| `/hint` | Ask the selected AI for a concise hint |
| `/hint explain my bug` | Ask the selected AI about the current problem and submission |
| `/provider codex` | Set AI provider and show local CLI/daemon status |
| `/model auto` | Use the provider default model for `/hint` and AI-backed `/next` |
| `/note prefer hashmap practice` | Append a standing note for future problem generation |
| `/notes` | Show your local next-problem notes |
| `/lang python` | Set language: `python`, `ts`, `java`, `rust` |
| `/ui ko` | Set UI language: `ko`, `en` |
| `/theme` | Toggle dark/light theme |
| `/lang python` | Set code language: `python`, `ts`, `java`, `rust` |
| `/ui en` | Set UI language: `en`, `ko`, `ja`, `zh`, `es` |
| `/theme dark` | Set theme: `dark` or `light` |
| `/source ai` | Prefer AI for next-problem generation |
| `/update` | Show update instructions when a newer version is available |
| `/exit` | Quit |
The editor owns normal typing keys.
Press `Esc`, then `/`, when you want the command bar.
The default UI language is English. Switch it any time with `/ui ko`, `/ui ja`, `/ui zh`, or `/ui es`.
## Custom Problem Generation
## AI Problems
`/next <request>` passes your request into the selected AI problem generator. Examples:
`/next <request>` passes your request into the selected AI problem generator.

@@ -115,17 +101,5 @@ ```text

AI generation reads [docs/problem-authoring-notes.md](docs/problem-authoring-notes.md) every time it creates a problem. Add personal preferences from inside the TUI:
Codex is the default provider:
```text
/note Prefer concise statements.
/note I want more string and hashmap practice.
/note Avoid DP until I ask for it.
```
Those notes are stored in `.practicode/problem_notes.md`, so they stay local.
## AI Providers
Codex is the default:
```text
/provider codex

@@ -143,10 +117,7 @@ /model auto

`/ai <prompt>` uses the current provider for coaching. AI-backed `/next` uses the same provider and model.
If you want a custom daemon or wrapper script, set `/ai-next-command <shell command>`; practicode passes `PRACTICODE_NEXT_REQUEST`, `PRACTICODE_AI_PROVIDER`, and `PRACTICODE_AI_MODEL`.
Generated problems and submissions stay local:
Generated problem banks stay local:
| Path | Purpose |
| --- | --- |
| `.practicode/problem_bank.json` | Local/custom/generated problem bank |
| `.practicode/problem_bank.json` | Local/custom/generated problems |
| `.practicode/problem_notes.md` | Optional personal problem-generation notes |

@@ -159,49 +130,23 @@ | `.practicode/problem-state.json` | Current problem, history, settings |

## Safety
## Update
`/run` executes your local submission as a normal process. practicode runs it from `.practicode/build/<problem-id>/run`, but this is not an OS sandbox. Only run code you trust.
The app checks for newer npm releases in the background and shows `/update` in the status line when one is available. Disable that check with `PRACTICODE_NO_UPDATE_CHECK=1`.
## Debug Prints
`/run` shows raw stdout when a case fails. If you want debug output without changing the judged answer, print to stderr:
```python
import sys
print("debug", value, file=sys.stderr)
```bash
npm update -g practicode
cargo install --force practicode
```
## Development
## Safety
```bash
cargo test
cargo run -- --smoke
cargo run --
```
`/run` executes your local submission as a normal process. practicode runs it from `.practicode/build/<problem-id>/run`, but this is not an OS sandbox. Only run code you trust.
The source is split by boring responsibility:
## Contributing
| Path | Role |
| --- | --- |
| `src/core.rs` | Problem bank, state, rendering, judging |
| `src/tui.rs` | Ratatui app, editor, command parser |
| `src/ai.rs` | Codex/Claude command integration and notes |
| `src/text.rs` | UTF-8 cursor math and Hangul composition |
| `src/process.rs` | Process execution helpers |
| `tests/` | Integration tests split by module |
External contributions use the fork and pull request flow in [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md).
## Discovery Notes
Maintainer-only review and release notes live in [docs/MAINTAINING.md](docs/MAINTAINING.md).
Recommended GitHub topics for this repo:
`coding-practice`, `competitive-programming`, `algorithms`, `ratatui`, `tui`, `rust`, `codex`, `claude-code`, `local-first`.
## License
## References
- Ratatui terminal UI library: https://ratatui.rs/
- Crossterm terminal backend/events: https://github.com/crossterm-rs/crossterm
- Codex CLI open-source repo: https://github.com/openai/codex
- Claude Code CLI reference: https://docs.anthropic.com/en/docs/claude-code/cli-reference
- Kattis problem package format: https://www.kattis.com/problem-package-format/
- ICPC judging guidelines: https://icpc.global/regionals/regional-contest-cookbook-judging-guidelines
- GitHub README image guidance: https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-readmes
- GitHub repository topics: https://docs.github.com/articles/classifying-your-repository-with-topics
practicode is MIT licensed. Third-party dependency license notes are in [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md).
+144
-6

@@ -6,9 +6,10 @@ use crate::{

},
process::{run_capture, sh_quote, shell_process, unique_temp_path},
process::{run_capture, sh_quote, shell_process, unique_temp_path, which},
};
use anyhow::Result;
use std::{
env,
fs::{self, OpenOptions},
io::Write,
path::Path,
path::{Path, PathBuf},
process::Command,

@@ -18,2 +19,8 @@ time::Duration,

#[derive(Clone, Debug, Default)]
pub struct ModelCatalog {
pub models: Vec<String>,
pub message: Option<String>,
}
pub fn run_ai_prompt(root: &Path, problem: &Problem, settings: &Settings, prompt: &str) -> String {

@@ -44,3 +51,3 @@ let solution = match ensure_submission(root, problem, settings) {

if state.settings.next_source != "ai" && !force {
return "AI next is disabled; using local problem bank.".to_string();
return "AI next is disabled; using local problems.".to_string();
}

@@ -84,5 +91,42 @@ let provider = normalize_ai_provider(&state.settings.ai_provider);

pub fn provider_status(provider: &str) -> String {
match normalize_ai_provider(provider).as_str() {
"claude" => {
if which("claude").is_some() {
"Claude CLI found.".to_string()
} else {
"Claude CLI not found. Install Claude Code or choose /provider codex.".to_string()
}
}
_ => {
if which("codex").is_none() {
return "Codex CLI not found. Install Codex CLI or choose /provider claude."
.to_string();
}
if codex_daemon_path().is_some_and(|path| path.exists()) {
"Codex CLI found. App-server daemon is available.".to_string()
} else {
"Codex CLI found. App-server daemon is not available; practicode will use codex exec directly.".to_string()
}
}
}
}
pub fn available_models(provider: &str) -> ModelCatalog {
match normalize_ai_provider(provider).as_str() {
"codex" => codex_models(),
"claude" => ModelCatalog {
models: Vec::new(),
message: Some(
"Claude CLI does not expose a model list; use /model <name> for a known model."
.to_string(),
),
},
_ => ModelCatalog::default(),
}
}
pub fn default_ai_next_prompt(request: &str) -> String {
format!(
"Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json. The app has a built-in starter problem 001-hello-world, so do not duplicate it. Create exactly one new non-duplicate coding practice problem. User request for this problem: {}. Update .practicode/problem_bank.json, the local problem files, the index, and state files. Do not include the answer in the problem statement.",
"Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json. Create exactly one new non-duplicate coding practice problem. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. Make the smallest valid edits: update .practicode/problem_bank.json, one problem directory, problems/INDEX.md, and .practicode/problem-state.json. Do not include the answer in the problem statement.",
if request.is_empty() {

@@ -115,2 +159,76 @@ "(none)"

fn codex_models() -> ModelCatalog {
if which("codex").is_none() {
return ModelCatalog {
models: Vec::new(),
message: Some(
"Codex CLI not found; choose /provider claude or install Codex CLI.".to_string(),
),
};
}
if codex_daemon_path().is_none_or(|path| !path.exists()) {
return ModelCatalog {
models: Vec::new(),
message: Some("Codex app-server daemon is unavailable; install the standalone Codex app to list models, or use /model <name>.".to_string()),
};
}
let mut start = Command::new("codex");
start.args(["app-server", "daemon", "start"]);
let _ = run_capture(&mut start, "", Duration::from_secs(5));
let mut command = Command::new("codex");
command.args(["app-server", "proxy"]);
let input = r#"{"id":1,"method":"model/list","params":{"limit":25}}"#;
let Ok(run) = run_capture(&mut command, &format!("{input}\n"), Duration::from_secs(2)) else {
return ModelCatalog {
models: Vec::new(),
message: Some("Could not query Codex model list.".to_string()),
};
};
if run.code != Some(0) {
let detail = output_text(&run.stdout, &run.stderr);
return ModelCatalog {
models: Vec::new(),
message: Some(if detail.is_empty() {
"Could not query Codex model list.".to_string()
} else {
format!("Could not query Codex model list: {detail}")
}),
};
}
let models = parse_model_list(&run.stdout);
if models.is_empty() {
ModelCatalog {
models,
message: Some("Codex app-server returned no models.".to_string()),
}
} else {
ModelCatalog {
models,
message: None,
}
}
}
fn parse_model_list(output: &str) -> Vec<String> {
output
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.flat_map(|value| {
value
.pointer("/result/data")
.or_else(|| value.get("data"))
.and_then(|data| data.as_array())
.cloned()
.unwrap_or_default()
})
.filter_map(|model| {
model
.get("model")
.or_else(|| model.get("id"))
.and_then(|value| value.as_str())
.map(str::to_string)
})
.collect()
}
fn run_codex_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {

@@ -185,5 +303,5 @@ let output_path = unique_temp_path("practicode-last-message", "txt");

fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -> String {
let start = "codex app-server daemon start >/dev/null 2>&1 || true";
let start = "if [ -x \"$HOME/.codex/packages/standalone/current/codex\" ]; then codex app-server daemon start >/dev/null 2>&1 || true; fi";
let mut exec = format!(
"codex exec --cd {} --sandbox workspace-write",
"codex exec --ephemeral --cd {} --sandbox workspace-write",
sh_quote(&root.display().to_string())

@@ -199,2 +317,10 @@ );

fn codex_daemon_path() -> Option<PathBuf> {
env::var_os("HOME").map(|home| {
PathBuf::from(home)
.join(".codex/packages/standalone/current")
.join(if cfg!(windows) { "codex.exe" } else { "codex" })
})
}
fn default_claude_next_command(root: &Path, settings: &Settings, request: &str) -> String {

@@ -221,1 +347,13 @@ let mut claude = "claude --permission-mode acceptEdits".to_string();

}
#[cfg(test)]
mod tests {
use super::parse_model_list;
#[test]
fn parses_codex_model_list_response() {
let output =
r#"{"id":1,"result":{"data":[{"model":"gpt-test","displayName":"GPT Test"}]}}"#;
assert_eq!(parse_model_list(output), vec!["gpt-test"]);
}
}

@@ -13,3 +13,3 @@ use crate::process::{CommandSpec, run_capture, which};

pub const LANGUAGES: &[&str] = &["python", "ts", "java", "rust"];
pub const UI_LANGUAGES: &[&str] = &["ko", "en"];
pub use crate::i18n::{UI_LANGUAGES, normalize_ui_language, ui_text};
pub const THEMES: &[&str] = &["dark", "light"];

@@ -124,3 +124,3 @@ pub const AI_PROVIDERS: &[&str] = &["codex", "claude"];

pub fn default_ui_language() -> String {
"ko".to_string()
"en".to_string()
}

@@ -168,16 +168,30 @@

topics: vec!["io".to_string()],
title: map2("ko", "Hello World", "en", "Hello World"),
statement: map2(
"ko",
"표준 출력으로 정확히 `Hello, World!`를 출력하세요.",
"en",
"Print exactly `Hello, World!` to stdout.",
),
input: map2("ko", "입력은 없습니다.", "en", "No input."),
output: map2(
"ko",
"`Hello, World!` 한 줄",
"en",
"One line: `Hello, World!`",
),
title: localized_map(&[
("en", "Hello World"),
("ko", "Hello World"),
("ja", "Hello World"),
("zh", "Hello World"),
("es", "Hello World"),
]),
statement: localized_map(&[
("en", "Print exactly `Hello, World!` to stdout."),
("ko", "표준 출력으로 정확히 `Hello, World!`를 출력하세요."),
("ja", "標準出力に正確に `Hello, World!` を出力してください。"),
("zh", "向标准输出准确打印 `Hello, World!`。"),
("es", "Imprime exactamente `Hello, World!` en stdout."),
]),
input: localized_map(&[
("en", "No input."),
("ko", "입력은 없습니다."),
("ja", "入力はありません。"),
("zh", "没有输入。"),
("es", "No hay entrada."),
]),
output: localized_map(&[
("en", "One line: `Hello, World!`"),
("ko", "`Hello, World!` 한 줄"),
("ja", "1行: `Hello, World!`"),
("zh", "一行: `Hello, World!`"),
("es", "Una linea: `Hello, World!`"),
]),
examples: vec![IoCase {

@@ -216,2 +230,9 @@ input: String::new(),

pub fn localized_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
entries
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect()
}
pub fn load_bank(root: &Path) -> Result<Vec<Problem>> {

@@ -346,5 +367,3 @@ let path = root.join(BANK_PATH);

settings.language = normalize_language(&settings.language);
if !UI_LANGUAGES.contains(&settings.ui_language.as_str()) {
settings.ui_language = "ko".to_string();
}
settings.ui_language = normalize_ui_language(&settings.ui_language);
if !THEMES.contains(&settings.theme.as_str()) {

@@ -389,5 +408,6 @@ settings.theme = "dark".to_string();

pub fn localized(map: &HashMap<String, String>, lang: &str) -> String {
map.get(lang)
let lang = normalize_ui_language(lang);
map.get(lang.as_str())
.or_else(|| map.get("en"))
.or_else(|| map.get("ko"))
.or_else(|| map.get("en"))
.or_else(|| map.values().next())

@@ -424,7 +444,3 @@ .cloned()

pub fn render_problem(problem: &Problem, ui_language: &str) -> String {
let lang = if UI_LANGUAGES.contains(&ui_language) {
ui_language
} else {
"ko"
};
let lang = normalize_ui_language(ui_language);
let examples = problem

@@ -436,5 +452,8 @@ .examples

format!(
"### Example {}\n\nInput\n\n{}\n\nOutput\n\n{}",
"### {} {}\n\n{}\n\n{}\n\n{}\n\n{}",
ui_text(&lang, "example"),
index + 1,
ui_text(&lang, "input"),
fenced_text(&case.input),
ui_text(&lang, "output"),
fenced_text(&case.output)

@@ -451,9 +470,14 @@ )

format!(
"# {number}. {}\n\nDifficulty: {}\nTopics: {}\n\n{}\n\n## Input\n\n{}\n\n## Output\n\n{}\n\n## Examples\n\n{}",
localized(&problem.title, lang),
"# {number}. {}\n\n{}: {}\n{}: {}\n\n{}\n\n## {}\n\n{}\n\n## {}\n\n{}\n\n## {}\n\n{}",
localized(&problem.title, &lang),
ui_text(&lang, "difficulty"),
problem.difficulty,
ui_text(&lang, "topics"),
problem.topics.join(", "),
localized(&problem.statement, lang),
localized(&problem.input, lang),
localized(&problem.output, lang),
localized(&problem.statement, &lang),
ui_text(&lang, "input"),
localized(&problem.input, &lang),
ui_text(&lang, "output"),
localized(&problem.output, &lang),
ui_text(&lang, "examples"),
examples

@@ -463,2 +487,62 @@ )

pub fn render_problem_tui(problem: &Problem, ui_language: &str) -> String {
let lang = normalize_ui_language(ui_language);
let number = problem
.id
.split_once('-')
.map(|(number, _)| number)
.unwrap_or(&problem.id);
let mut lines = vec![
format!("{number}. {}", localized(&problem.title, &lang)),
format!(
"{}: {} {}: {}",
ui_text(&lang, "difficulty"),
problem.difficulty,
ui_text(&lang, "topics"),
problem.topics.join(", ")
),
String::new(),
localized(&problem.statement, &lang),
];
push_tui_section(
&mut lines,
ui_text(&lang, "input"),
&localized(&problem.input, &lang),
);
push_tui_section(
&mut lines,
ui_text(&lang, "output"),
&localized(&problem.output, &lang),
);
lines.push(String::new());
lines.push(ui_text(&lang, "examples").to_string());
for (index, case) in problem.examples.iter().enumerate() {
lines.push(format!(" {} {}", ui_text(&lang, "example"), index + 1));
lines.push(format!(" {}:", ui_text(&lang, "input")));
push_case_text(&mut lines, &case.input);
lines.push(format!(" {}:", ui_text(&lang, "output")));
push_case_text(&mut lines, &case.output);
}
lines.join("\n").trim_end().to_string()
}
fn push_tui_section(lines: &mut Vec<String>, title: &str, body: &str) {
lines.push(String::new());
lines.push(title.to_string());
for line in body.trim_end().lines() {
lines.push(format!(" {line}"));
}
}
fn push_case_text(lines: &mut Vec<String>, body: &str) {
let body = body.trim_end();
if body.is_empty() {
lines.push(" <empty>".to_string());
} else {
for line in body.lines() {
lines.push(format!(" {line}"));
}
}
}
pub fn fenced_text(value: &str) -> String {

@@ -465,0 +549,0 @@ let mut body = value.to_string();

@@ -7,5 +7,7 @@ use anyhow::{Context, Result};

pub mod core;
pub mod i18n;
pub mod process;
pub mod text;
pub mod tui;
pub mod update;

@@ -12,0 +14,0 @@ pub fn run_cli() -> Result<()> {

+810
-106
use crate::{
ai::{append_problem_note, read_problem_notes, run_ai_next, run_ai_prompt},
ai::{
ModelCatalog, append_problem_note, available_models, provider_status, read_problem_notes,
run_ai_next, run_ai_prompt,
},
core::{

@@ -7,4 +10,4 @@ AI_PROVIDERS, AppState, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, THEMES,

load_state, localized, next_problem, normalize_ai_provider, normalize_language,
normalize_next_source, previous_problem, problem_by_id, record_pass, render_problem,
save_state, template_for,
normalize_next_source, normalize_ui_language, previous_problem, problem_by_id, record_pass,
save_state, template_for, ui_text,
},

@@ -14,2 +17,3 @@ text::{

},
update::{CURRENT_VERSION, UpdateCheck, check_latest_version},
};

@@ -22,3 +26,4 @@ use anyhow::Result;

style::{Color, Modifier, Style},
widgets::{Block, Borders, Paragraph, Wrap},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};

@@ -34,43 +39,232 @@ use std::{

pub const HELP: &str = r#"# Help
#[derive(Clone, Copy)]
struct CommandHint {
insert: &'static str,
display: &'static str,
desc_key: &'static str,
keep_open: bool,
help: bool,
}
## Daily loop
#[derive(Clone)]
struct CommandChoice {
insert: String,
display: String,
desc_key: &'static str,
keep_open: bool,
}
1. Type code in the right pane.
2. Press `Esc`, then `/run`.
3. Use `/next` when it passes.
const COMMAND_HINTS: &[CommandHint] = &[
CommandHint {
insert: "run",
display: "/run",
desc_key: "cmd_run",
keep_open: false,
help: true,
},
CommandHint {
insert: "edit",
display: "/edit",
desc_key: "cmd_edit",
keep_open: false,
help: true,
},
CommandHint {
insert: "next",
display: "/next",
desc_key: "cmd_next",
keep_open: false,
help: true,
},
CommandHint {
insert: "prev",
display: "/prev",
desc_key: "cmd_prev",
keep_open: false,
help: true,
},
CommandHint {
insert: "list",
display: "/list",
desc_key: "cmd_list",
keep_open: false,
help: true,
},
CommandHint {
insert: "open ",
display: "/open <id>",
desc_key: "cmd_open",
keep_open: true,
help: true,
},
CommandHint {
insert: "giveup",
display: "/giveup",
desc_key: "cmd_giveup",
keep_open: false,
help: true,
},
CommandHint {
insert: "hint ",
display: "/hint <request>",
desc_key: "cmd_hint",
keep_open: true,
help: true,
},
CommandHint {
insert: "provider codex",
display: "/provider codex",
desc_key: "cmd_provider",
keep_open: false,
help: true,
},
CommandHint {
insert: "provider claude",
display: "/provider claude",
desc_key: "cmd_provider",
keep_open: false,
help: false,
},
CommandHint {
insert: "model auto",
display: "/model auto",
desc_key: "cmd_model_auto",
keep_open: false,
help: true,
},
CommandHint {
insert: "model ",
display: "/model <name>",
desc_key: "cmd_model_custom",
keep_open: true,
help: false,
},
CommandHint {
insert: "note ",
display: "/note <text>",
desc_key: "cmd_note",
keep_open: true,
help: true,
},
CommandHint {
insert: "notes",
display: "/notes",
desc_key: "cmd_notes",
keep_open: false,
help: true,
},
CommandHint {
insert: "lang python",
display: "/lang python",
desc_key: "cmd_lang",
keep_open: false,
help: true,
},
CommandHint {
insert: "lang ts",
display: "/lang ts",
desc_key: "cmd_lang",
keep_open: false,
help: false,
},
CommandHint {
insert: "lang java",
display: "/lang java",
desc_key: "cmd_lang",
keep_open: false,
help: false,
},
CommandHint {
insert: "lang rust",
display: "/lang rust",
desc_key: "cmd_lang",
keep_open: false,
help: false,
},
CommandHint {
insert: "ui en",
display: "/ui en",
desc_key: "cmd_ui",
keep_open: false,
help: true,
},
CommandHint {
insert: "ui ko",
display: "/ui ko",
desc_key: "cmd_ui",
keep_open: false,
help: false,
},
CommandHint {
insert: "ui ja",
display: "/ui ja",
desc_key: "cmd_ui",
keep_open: false,
help: false,
},
CommandHint {
insert: "ui zh",
display: "/ui zh",
desc_key: "cmd_ui",
keep_open: false,
help: false,
},
CommandHint {
insert: "ui es",
display: "/ui es",
desc_key: "cmd_ui",
keep_open: false,
help: false,
},
CommandHint {
insert: "theme dark",
display: "/theme dark",
desc_key: "cmd_theme",
keep_open: false,
help: true,
},
CommandHint {
insert: "theme light",
display: "/theme light",
desc_key: "cmd_theme",
keep_open: false,
help: false,
},
CommandHint {
insert: "source local",
display: "/source local",
desc_key: "cmd_source",
keep_open: false,
help: false,
},
CommandHint {
insert: "source ai",
display: "/source ai",
desc_key: "cmd_source",
keep_open: false,
help: false,
},
CommandHint {
insert: "update",
display: "/update",
desc_key: "cmd_update",
keep_open: false,
help: true,
},
CommandHint {
insert: "help",
display: "/help",
desc_key: "cmd_help",
keep_open: false,
help: true,
},
CommandHint {
insert: "exit",
display: "/exit",
desc_key: "cmd_exit",
keep_open: false,
help: true,
},
];
## Commands
- `/run` judge current submission
- `/edit` focus the code editor
- `/next [request]` next problem, optionally with a request
- `/prev` previous problem
- `/list` choose from problem list
- `/open 2` open by number, id, or slug
- `/giveup` show answer
- `/ai hint` ask the selected AI about current problem + code
- `/provider codex|claude`
- `/model auto|sonnet|opus|...`
- `/note prefer strings this week`
- `/notes` show next-problem notes
- `/lang python|ts|java|rust`
- `/ui ko|en`
- `/theme dark|light`
- `/source bank|ai`
- `/exit` quit
## Keys
- `Esc` leaves the editor or output pane
- `/` opens the command bar when the editor is not focused
- `?` opens this help when the editor is not focused
- `up/down` or `j/k` move in `/list`
## Debug prints
- stdout prints are shown when a case fails
- stderr prints are shown without affecting the expected stdout
"#;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]

@@ -92,4 +286,6 @@ enum Focus {

command_cursor: usize,
command_palette_cursor: usize,
output: String,
output_is_markdown: bool,
showing_model_status: bool,
show_output: bool,

@@ -102,2 +298,9 @@ focus: Focus,

task_rx: Option<Receiver<TaskResult>>,
update_rx: Option<Receiver<UpdateCheck>>,
model_rx: Option<Receiver<ModelCatalog>>,
available_models: Vec<String>,
available_models_provider: String,
model_message: Option<String>,
update_check: Option<UpdateCheck>,
update_notice: Option<String>,
should_quit: bool,

@@ -130,4 +333,6 @@ }

command_cursor: 0,
command_palette_cursor: 0,
output: String::new(),
output_is_markdown: false,
showing_model_status: false,
show_output: false,

@@ -140,2 +345,9 @@ focus: Focus::Code,

task_rx: None,
update_rx: None,
model_rx: None,
available_models: Vec::new(),
available_models_provider: String::new(),
model_message: None,
update_check: None,
update_notice: None,
should_quit: false,

@@ -148,5 +360,10 @@ };

pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
self.start_update_check();
self.start_model_check();
while !self.should_quit {
terminal.draw(|frame| self.draw(frame))?;
self.check_task();
self.check_update();
self.start_model_check();
self.check_models();
if event::poll(Duration::from_millis(100))?

@@ -159,3 +376,3 @@ && let Event::Key(key) = event::read()?

if !self.busy_label.is_empty() {
self.busy_frame = (self.busy_frame + 1) % 4;
self.busy_frame = (self.busy_frame + 1) % 16;
}

@@ -187,2 +404,6 @@ }

pub fn handle_key_for_test(&mut self, key: KeyEvent) -> Result<()> {
self.handle_key(key)
}
pub fn busy_label(&self) -> &str {

@@ -196,2 +417,33 @@ &self.busy_label

pub fn status_text_for_test(&self) -> String {
self.status_text()
}
pub fn output_for_test(&self) -> &str {
&self.output
}
pub fn command_suggestions_for_test(&self) -> Vec<String> {
self.command_suggestions()
.into_iter()
.map(|choice| choice.display)
.collect()
}
pub fn set_available_models_for_test(&mut self, models: Vec<&str>) {
self.available_models = models.into_iter().map(str::to_string).collect();
self.available_models_provider = self.state.settings.ai_provider.clone();
self.model_message = None;
}
pub fn set_model_message_for_test(&mut self, message: &str) {
self.available_models.clear();
self.available_models_provider = self.state.settings.ai_provider.clone();
self.model_message = Some(message.to_string());
}
pub fn pane_title_for_test(title: &str, active: bool) -> String {
Self::pane_title(title, active)
}
fn draw(&mut self, frame: &mut Frame) {

@@ -212,8 +464,9 @@ let size = frame.area();

let problem = Paragraph::new(render_markdown_plain(&render_problem(
&self.problem,
&self.state.settings.ui_language,
)))
.block(Self::block("Problem", self.state.settings.theme == "light"))
.wrap(Wrap { trim: false });
let problem = Paragraph::new(self.problem_text())
.block(Self::block(
ui_text(&self.state.settings.ui_language, "problem"),
self.state.settings.theme == "light",
false,
))
.wrap(Wrap { trim: false });
frame.render_widget(problem, body[0]);

@@ -223,3 +476,3 @@

let text = if !self.busy_label.is_empty() {
format!("{}{}", self.busy_body, ".".repeat(self.busy_frame))
format!("{}{}", self.busy_body, self.busy_dots())
} else if self.output_is_markdown {

@@ -231,3 +484,7 @@ render_markdown_plain(&self.output)

let output = Paragraph::new(text)
.block(Self::block("Output", self.state.settings.theme == "light"))
.block(Self::block(
ui_text(&self.state.settings.ui_language, "output"),
self.state.settings.theme == "light",
self.focus != Focus::Command,
))
.wrap(Wrap { trim: false });

@@ -240,4 +497,7 @@ frame.render_widget(output, body[1]);

let title = format!("solution.{}", ext_for(&self.state.settings.language));
let code = Paragraph::new(code)
.block(Self::block(&title, self.state.settings.theme == "light"));
let code = Paragraph::new(code).block(Self::block(
&title,
self.state.settings.theme == "light",
self.focus == Focus::Code,
));
frame.render_widget(code, body[1]);

@@ -263,22 +523,222 @@ }

} else {
"/run, /next easy string problem, /ai hint, /help".to_string()
ui_text(&self.state.settings.ui_language, "command_placeholder").to_string()
};
let command = Paragraph::new(command_text)
.block(Self::block("Command", self.state.settings.theme == "light"))
.block(Self::block(
ui_text(&self.state.settings.ui_language, "command"),
self.state.settings.theme == "light",
self.focus == Focus::Command,
))
.wrap(Wrap { trim: false });
frame.render_widget(command, vertical[2]);
self.draw_command_palette(frame, vertical[2]);
self.set_terminal_cursor(frame, body[1], vertical[2]);
}
fn block(title: &str, light: bool) -> Block<'_> {
fn problem_text(&self) -> Text<'static> {
let lang = normalize_ui_language(&self.state.settings.ui_language);
let light = self.state.settings.theme == "light";
let title_style = if light {
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
};
let section_style = if light {
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
};
let body_style = if light {
Style::default().fg(Color::Black)
} else {
Style::default().fg(Color::Rgb(229, 231, 235))
};
let meta_style = if light {
Style::default().fg(Color::Rgb(75, 85, 99))
} else {
Style::default().fg(Color::Rgb(156, 163, 175))
};
let code_style = if light {
Style::default()
.fg(Color::Black)
.bg(Color::Rgb(229, 231, 235))
} else {
Style::default()
.fg(Color::Rgb(243, 244, 246))
.bg(Color::Rgb(31, 41, 55))
};
let number = self
.problem
.id
.split_once('-')
.map(|(number, _)| number)
.unwrap_or(&self.problem.id);
let mut lines = vec![
Line::from(Span::styled(
format!("{number}. {}", localized(&self.problem.title, &lang)),
title_style,
)),
Line::from(Span::styled(
format!(
"{}: {} {}: {}",
ui_text(&lang, "difficulty"),
self.problem.difficulty,
ui_text(&lang, "topics"),
self.problem.topics.join(", ")
),
meta_style,
)),
];
lines.push(Line::default());
for line in localized(&self.problem.statement, &lang).trim_end().lines() {
lines.push(Line::from(Span::styled(line.to_string(), body_style)));
}
Self::push_problem_section(
&mut lines,
ui_text(&lang, "input"),
&localized(&self.problem.input, &lang),
section_style,
body_style,
);
Self::push_problem_section(
&mut lines,
ui_text(&lang, "output"),
&localized(&self.problem.output, &lang),
section_style,
body_style,
);
lines.push(Line::default());
lines.push(Line::from(Span::styled(
ui_text(&lang, "examples").to_string(),
section_style,
)));
for (index, case) in self.problem.examples.iter().enumerate() {
lines.push(Line::from(Span::styled(
format!(" {} {}", ui_text(&lang, "example"), index + 1),
meta_style.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" {}", ui_text(&lang, "input")),
meta_style,
)));
Self::push_code_lines(&mut lines, &case.input, code_style);
lines.push(Line::from(Span::styled(
format!(" {}", ui_text(&lang, "output")),
meta_style,
)));
Self::push_code_lines(&mut lines, &case.output, code_style);
}
Text::from(lines)
}
fn push_problem_section(
lines: &mut Vec<Line<'static>>,
title: &str,
body: &str,
section_style: Style,
body_style: Style,
) {
lines.push(Line::default());
lines.push(Line::from(Span::styled(title.to_string(), section_style)));
for line in body.trim_end().lines() {
lines.push(Line::from(Span::styled(format!(" {line}"), body_style)));
}
}
fn push_code_lines(lines: &mut Vec<Line<'static>>, body: &str, code_style: Style) {
let body = body.trim_end();
if body.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("<empty>".to_string(), code_style),
]));
return;
}
for line in body.lines() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(line.to_string(), code_style),
]));
}
}
fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) {
let suggestions = self.command_suggestions();
if suggestions.is_empty() || command_area.y < 3 {
return;
}
let height = ((suggestions.len() + 3) as u16).min(14).min(command_area.y);
let area = Rect::new(
command_area.x,
command_area.y - height,
command_area.width,
height,
);
let selected = self.command_palette_cursor.min(suggestions.len() - 1);
let visible = height.saturating_sub(2) as usize;
let start = selected.saturating_sub(visible.saturating_sub(1));
let mut lines = suggestions
.iter()
.enumerate()
.skip(start)
.take(visible)
.map(|(index, hint)| {
let marker = if index == selected { ">" } else { " " };
format!(
"{marker} {:<16} {}",
hint.display,
ui_text(&self.state.settings.ui_language, hint.desc_key)
)
})
.collect::<Vec<_>>();
lines.push(ui_text(&self.state.settings.ui_language, "palette_hint").to_string());
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(lines.join("\n")).block(Self::block(
ui_text(&self.state.settings.ui_language, "commands"),
self.state.settings.theme == "light",
true,
)),
area,
);
}
fn block(title: &str, light: bool, active: bool) -> Block<'static> {
let border = if active {
if light {
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
}
} else if light {
Style::default().fg(Color::Blue)
} else {
Style::default().fg(Color::Cyan)
};
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(if light {
Style::default().fg(Color::Blue)
} else {
Style::default().fg(Color::Cyan)
})
.title(Self::pane_title(title, active))
.border_style(border)
}
fn pane_title(title: &str, active: bool) -> String {
if active {
format!("> {title}")
} else {
title.to_string()
}
}
fn handle_key(&mut self, key: KeyEvent) -> Result<()> {

@@ -297,10 +757,14 @@ match self.focus {

self.command_cursor = 0;
self.command_palette_cursor = 0;
self.focus = Focus::None;
}
KeyCode::Enter => {
let value = self.command.trim().to_string();
self.command.clear();
self.command_cursor = 0;
self.focus = Focus::None;
self.submit_command(&value)?;
if !self.accept_command_palette()? {
let value = self.command.trim().to_string();
self.command.clear();
self.command_cursor = 0;
self.command_palette_cursor = 0;
self.focus = Focus::None;
self.submit_command(&value)?;
}
}

@@ -313,2 +777,4 @@ KeyCode::Backspace => self.delete_command_before_cursor(),

}
KeyCode::Up => self.move_command_palette(-1),
KeyCode::Down => self.move_command_palette(1),
KeyCode::Home => self.command_cursor = 0,

@@ -319,2 +785,3 @@ KeyCode::End => self.command_cursor = char_len(&self.command),

self.command_cursor = 0;
self.command_palette_cursor = 0;
self.focus = Focus::None;

@@ -412,2 +879,3 @@ self.handle_command("help")?;

}
self.command_palette_cursor = 0;
self.focus = Focus::Command;

@@ -428,3 +896,3 @@ }

self.list_cursor = None;
self.write_output(HELP);
self.write_output(&self.help_text());
return Ok(());

@@ -453,18 +921,12 @@ }

"ui" if arg.is_empty() => self.action_toggle_ui_language()?,
"ui" if UI_LANGUAGES.contains(&arg) => self.set_ui_language(arg)?,
"ui" => self.set_ui_language(&normalize_ui_language(arg))?,
"theme" if arg.is_empty() => self.action_toggle_theme()?,
"theme" if THEMES.contains(&arg) => self.set_theme(arg)?,
"source" | "next-source" if arg.is_empty() => {
self.write_text_output(&format!(
"Next source: {}",
self.state.settings.next_source
));
self.write_text_output(&format!("Next source: {}", self.next_source_label()));
}
"source" | "next-source" if matches!(arg, "bank" | "ai") => {
"source" | "next-source" if matches!(arg, "bank" | "local" | "ai") => {
self.state.settings.next_source = normalize_next_source(arg);
save_state(&self.root, &self.state)?;
self.write_text_output(&format!(
"Next source: {}",
self.state.settings.next_source
));
self.write_text_output(&format!("Next source: {}", self.next_source_label()));
}

@@ -479,4 +941,5 @@ "ai-next-command" if !arg.is_empty() => {

self.write_text_output(&format!(
"AI provider: {}",
self.state.settings.ai_provider
"AI provider: {}\n{}",
self.state.settings.ai_provider,
provider_status(&self.state.settings.ai_provider)
));

@@ -486,19 +949,36 @@ }

self.state.settings.ai_provider = normalize_ai_provider(arg);
self.model_rx = None;
self.available_models.clear();
self.available_models_provider.clear();
self.model_message = None;
save_state(&self.root, &self.state)?;
self.write_text_output(&format!(
"AI provider: {}",
self.state.settings.ai_provider
"AI provider: {}\n{}",
self.state.settings.ai_provider,
provider_status(&self.state.settings.ai_provider)
));
}
"model" if arg.is_empty() => {
self.write_text_output(&format!("AI model: {}", self.state.settings.ai_model));
self.start_model_check();
self.check_models();
self.write_model_status();
}
"model" => {
self.state.settings.ai_model = arg.to_string();
self.state.settings.ai_model = if arg == "auto" {
"auto".to_string()
} else {
arg.to_string()
};
save_state(&self.root, &self.state)?;
self.write_text_output(&format!("AI model: {arg}"));
self.start_model_check();
self.check_models();
self.write_model_status();
}
"ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
"hint" if arg.is_empty() => {
self.start_ai_prompt("Give one concise hint for the current problem.")?
}
"hint" | "ask" | "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
"note" if !arg.is_empty() => self.append_note(arg)?,
"note" | "notes" => self.show_notes()?,
"update" => self.show_update_notice(),
"exit" | "quit" | "q" => self.should_quit = true,

@@ -562,6 +1042,9 @@ _ => self.write_text_output(&format!("Unknown command: {value}\nTry /help.")),

if self.task_rx.is_some() {
self.write_text_output("Already busy.");
self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
return;
}
self.start_busy("next", "Generating next problem");
self.start_busy(
"next",
ui_text(&self.state.settings.ui_language, "generating_next"),
);
let root = self.root.clone();

@@ -669,5 +1152,5 @@ let state = self.state.clone();

fn set_ui_language(&mut self, language: &str) -> Result<()> {
self.state.settings.ui_language = language.to_string();
self.state.settings.ui_language = normalize_ui_language(language);
save_state(&self.root, &self.state)?;
self.write_text_output(&format!("UI language: {language}"));
self.write_text_output(&format!("UI language: {}", self.state.settings.ui_language));
Ok(())

@@ -685,3 +1168,3 @@ }

if self.task_rx.is_some() {
self.write_text_output("Already busy.");
self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
return Ok(());

@@ -725,2 +1208,85 @@ }

fn check_update(&mut self) {
let result = self.update_rx.as_ref().and_then(|rx| rx.try_recv().ok());
if let Some(result) = result {
self.update_rx = None;
self.update_check = Some(result.clone());
if let UpdateCheck::Available(version) = &result {
self.update_notice = Some(version.clone());
}
}
}
fn start_update_check(&mut self) {
if self.update_rx.is_some() {
return;
}
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let _ = tx.send(check_latest_version());
});
self.update_rx = Some(rx);
}
fn start_model_check(&mut self) {
let provider = self.state.settings.ai_provider.clone();
if self.model_rx.is_some() || self.available_models_provider == provider {
return;
}
let query_provider = provider.clone();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let _ = tx.send(available_models(&query_provider));
});
self.available_models_provider = provider;
self.model_rx = Some(rx);
}
fn check_models(&mut self) {
let models = self.model_rx.as_ref().and_then(|rx| rx.try_recv().ok());
if let Some(catalog) = models {
self.model_rx = None;
self.available_models = catalog.models;
self.model_message = catalog.message;
if self.showing_model_status {
self.output = self.model_status_text();
self.output_is_markdown = false;
self.show_output = true;
}
}
}
fn model_status_text(&self) -> String {
let mut lines = vec![
format!("AI provider: {}", self.state.settings.ai_provider),
format!(
"AI model: {}",
if self.state.settings.ai_model == "auto" {
"auto (provider default)"
} else {
self.state.settings.ai_model.as_str()
}
),
"Use /model auto to let the provider choose its default.".to_string(),
];
if self.model_rx.is_some() {
lines.push("Loading provider model list...".to_string());
} else if self.available_models.is_empty() {
lines.push(
self.model_message
.clone()
.unwrap_or_else(|| "Provider model list is unavailable.".to_string()),
);
lines.push("Use /model <name> for a known model.".to_string());
} else {
lines.push("Available models:".to_string());
lines.extend(
self.available_models
.iter()
.map(|model| format!("- /model {model}")),
);
}
lines.join("\n")
}
fn start_busy(&mut self, label: &str, body: &str) {

@@ -741,2 +1307,3 @@ self.busy_label = label.to_string();

fn write_output(&mut self, output: &str) {
self.showing_model_status = false;
self.output = output.to_string();

@@ -749,2 +1316,3 @@ self.output_is_markdown = true;

fn write_text_output(&mut self, output: &str) {
self.showing_model_status = false;
self.output = output.trim_end().to_string();

@@ -756,2 +1324,28 @@ self.output_is_markdown = false;

fn write_model_status(&mut self) {
self.output = self.model_status_text();
self.output_is_markdown = false;
self.showing_model_status = true;
self.show_output = true;
self.focus = Focus::Output;
}
fn show_update_notice(&mut self) {
let lang = self.state.settings.ui_language.clone();
if let Some(version) = &self.update_notice {
self.write_text_output(&format!(
"{}: practicode {version} (current {CURRENT_VERSION})\n\nnpm update -g practicode\ncargo install --force practicode",
ui_text(&lang, "update_available")
));
} else if self.update_rx.is_some() {
self.write_text_output("Checking for updates...");
} else if matches!(self.update_check, Some(UpdateCheck::Disabled)) {
self.write_text_output(ui_text(&lang, "update_check_disabled"));
} else if matches!(self.update_check, Some(UpdateCheck::Failed)) {
self.write_text_output(ui_text(&lang, "update_check_failed"));
} else {
self.write_text_output(ui_text(&lang, "update_none"));
}
}
fn append_note(&mut self, note: &str) -> Result<()> {

@@ -777,2 +1371,3 @@ append_problem_note(&self.root, note)?;

self.command_cursor += 1;
self.command_palette_cursor = 0;
self.normalize_command_input();

@@ -789,2 +1384,3 @@ }

self.command_cursor -= 1;
self.command_palette_cursor = 0;
self.normalize_command_input();

@@ -800,5 +1396,77 @@ }

self.command.replace_range(start..end, "");
self.command_palette_cursor = 0;
self.normalize_command_input();
}
fn command_suggestions(&self) -> Vec<CommandChoice> {
if self.focus != Focus::Command {
return Vec::new();
}
let Some(query) = self.command.trim_start().strip_prefix('/') else {
return Vec::new();
};
let query = query.to_lowercase();
self.command_choices()
.into_iter()
.filter(|hint| hint.insert.starts_with(query.trim_start()))
.collect()
}
fn command_choices(&self) -> Vec<CommandChoice> {
let mut choices = Vec::new();
for hint in COMMAND_HINTS {
if hint.insert == "model " {
for model in self
.available_models
.iter()
.filter(|model| *model != "auto")
{
choices.push(CommandChoice {
insert: format!("model {model}"),
display: format!("/model {model}"),
desc_key: "cmd_model_available",
keep_open: false,
});
}
}
choices.push(CommandChoice {
insert: hint.insert.to_string(),
display: hint.display.to_string(),
desc_key: hint.desc_key,
keep_open: hint.keep_open,
});
}
choices
}
fn move_command_palette(&mut self, delta: isize) {
let len = self.command_suggestions().len();
if len == 0 {
return;
}
let cursor = self.command_palette_cursor as isize;
self.command_palette_cursor = ((cursor + delta).rem_euclid(len as isize)) as usize;
}
fn accept_command_palette(&mut self) -> Result<bool> {
let suggestions = self.command_suggestions();
if suggestions.is_empty() {
return Ok(false);
}
let hint = &suggestions[self.command_palette_cursor.min(suggestions.len() - 1)];
if hint.keep_open {
self.command = format!("/{}", hint.insert);
self.command_cursor = char_len(&self.command);
self.command_palette_cursor = 0;
return Ok(true);
}
let value = hint.insert.clone();
self.command.clear();
self.command_cursor = 0;
self.command_palette_cursor = 0;
self.focus = Focus::None;
self.submit_command(&value)?;
Ok(true)
}
fn normalize_command_input(&mut self) {

@@ -998,34 +1666,70 @@ let normalized = compose_hangul_jamo(&self.command);

let code_status = self.submission_status(&self.problem).0;
let activity = if self.busy_label.is_empty() {
"idle".to_string()
} else {
format!("{}{}", self.busy_body, self.busy_dots())
};
let tail = self
.update_notice
.as_ref()
.map(|version| {
format!(
"{}:{version} /update",
ui_text(&self.state.settings.ui_language, "update")
)
})
.unwrap_or_else(|| self.mode_hint().to_string());
format!(
" PRACTICODE | {} | {} | {} | {} | code:{} | {} | next:{} | ai:{}/{} | {} ",
" PRACTICODE | {} | {} | {} | {} | code:{} | {} | {} ",
self.problem.id,
self.problem.difficulty,
self.busy_status(),
self.problem_status(&self.problem),
activity,
code_status,
self.state.settings.language,
self.state.settings.next_source,
self.state.settings.ai_provider,
self.state.settings.ai_model,
self.mode_hint(),
tail,
)
}
fn busy_status(&self) -> String {
if self.busy_label.is_empty() {
"idle".to_string()
fn next_source_label(&self) -> &'static str {
if self.state.settings.next_source == "ai" {
"ai"
} else {
format!("busy:{}{}", self.busy_label, ".".repeat(self.busy_frame))
"local"
}
}
fn busy_dots(&self) -> String {
".".repeat(self.busy_frame / 4)
}
fn mode_hint(&self) -> &'static str {
let lang = &self.state.settings.ui_language;
match (self.focus, self.list_cursor.is_some(), self.show_output) {
(Focus::Command, _, _) => "Enter submit | Esc cancel",
(_, true, _) => "up/down move | Enter open | Esc close",
(_, _, true) => "Esc code | / command | ? help",
(Focus::Code, _, _) => "Esc then / command",
_ => "/ command | ? help",
(Focus::Command, _, _) => ui_text(lang, "hint_command"),
(_, true, _) => ui_text(lang, "hint_list"),
(_, _, true) => ui_text(lang, "hint_output"),
(Focus::Code, _, _) => ui_text(lang, "hint_code"),
_ => ui_text(lang, "hint_idle"),
}
}
fn help_text(&self) -> String {
let lang = &self.state.settings.ui_language;
let commands = COMMAND_HINTS
.iter()
.filter(|hint| hint.help)
.map(|hint| format!("- `{}` {}", hint.display, ui_text(lang, hint.desc_key)))
.collect::<Vec<_>>()
.join("\n");
format!(
"# {}\n\n## {}\n\n1. Type code in the right pane.\n2. Press `Esc`, then choose `/run` from the command palette.\n3. Use `/next` when it passes.\n\n## {}\n\n{}\n\n## {}\n\n- `/` opens the command palette outside the editor.\n- `↑/↓` selects a command and `Enter` accepts it.\n- `Esc` cancels the command palette or leaves output.\n\n## {}\n\n- stdout is shown when a case fails.\n- stderr is shown without affecting the expected stdout.",
ui_text(lang, "help_title"),
ui_text(lang, "daily_loop"),
ui_text(lang, "commands"),
commands,
ui_text(lang, "keys"),
ui_text(lang, "debug_prints"),
)
}
}

@@ -1032,0 +1736,0 @@