practicode 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +13 -7
- package/assets/i18n/en.json +54 -0
- package/assets/i18n/es.json +54 -0
- package/assets/i18n/ja.json +54 -0
- package/assets/i18n/ko.json +54 -0
- package/assets/i18n/zh.json +54 -0
- package/docs/CONTRIBUTING.md +68 -23
- package/docs/MAINTAINING.md +67 -0
- package/package.json +1 -1
- package/src/ai.rs +95 -6
- package/src/core.rs +61 -216
- package/src/i18n.rs +45 -0
- package/src/lib.rs +2 -0
- package/src/tui.rs +429 -69
- package/src/update.rs +45 -0
package/src/ai.rs
CHANGED
|
@@ -3,13 +3,14 @@ use crate::{
|
|
|
3
3
|
AppState, PROBLEM_NOTES_PATH, Problem, Settings, ensure_submission, normalize_ai_provider,
|
|
4
4
|
render_problem,
|
|
5
5
|
},
|
|
6
|
-
process::{run_capture, sh_quote, shell_process, unique_temp_path},
|
|
6
|
+
process::{run_capture, sh_quote, shell_process, unique_temp_path, which},
|
|
7
7
|
};
|
|
8
8
|
use anyhow::Result;
|
|
9
9
|
use std::{
|
|
10
|
+
env,
|
|
10
11
|
fs::{self, OpenOptions},
|
|
11
12
|
io::Write,
|
|
12
|
-
path::Path,
|
|
13
|
+
path::{Path, PathBuf},
|
|
13
14
|
process::Command,
|
|
14
15
|
time::Duration,
|
|
15
16
|
};
|
|
@@ -39,7 +40,7 @@ pub fn run_ai_prompt(root: &Path, problem: &Problem, settings: &Settings, prompt
|
|
|
39
40
|
|
|
40
41
|
pub fn run_ai_next(root: &Path, state: &AppState, force: bool, request: &str) -> String {
|
|
41
42
|
if state.settings.next_source != "ai" && !force {
|
|
42
|
-
return "AI next is disabled; using local
|
|
43
|
+
return "AI next is disabled; using local problems.".to_string();
|
|
43
44
|
}
|
|
44
45
|
let provider = normalize_ai_provider(&state.settings.ai_provider);
|
|
45
46
|
let command = if state.settings.next_ai_command().trim().is_empty() {
|
|
@@ -78,9 +79,39 @@ pub fn default_ai_next_command(root: &Path, settings: &Settings, request: &str)
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
pub fn provider_status(provider: &str) -> String {
|
|
83
|
+
match normalize_ai_provider(provider).as_str() {
|
|
84
|
+
"claude" => {
|
|
85
|
+
if which("claude").is_some() {
|
|
86
|
+
"Claude CLI found.".to_string()
|
|
87
|
+
} else {
|
|
88
|
+
"Claude CLI not found. Install Claude Code or choose /provider codex.".to_string()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
_ => {
|
|
92
|
+
if which("codex").is_none() {
|
|
93
|
+
return "Codex CLI not found. Install Codex CLI or choose /provider claude."
|
|
94
|
+
.to_string();
|
|
95
|
+
}
|
|
96
|
+
if codex_daemon_path().is_some_and(|path| path.exists()) {
|
|
97
|
+
"Codex CLI found. App-server daemon is available.".to_string()
|
|
98
|
+
} else {
|
|
99
|
+
"Codex CLI found. App-server daemon is not available; practicode will use codex exec directly.".to_string()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
pub fn available_models(provider: &str) -> Vec<String> {
|
|
106
|
+
match normalize_ai_provider(provider).as_str() {
|
|
107
|
+
"codex" => codex_models(),
|
|
108
|
+
_ => Vec::new(),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
81
112
|
pub fn default_ai_next_prompt(request: &str) -> String {
|
|
82
113
|
format!(
|
|
83
|
-
"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.
|
|
114
|
+
"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.",
|
|
84
115
|
if request.is_empty() {
|
|
85
116
|
"(none)"
|
|
86
117
|
} else {
|
|
@@ -108,6 +139,44 @@ pub fn read_problem_notes(root: &Path) -> Result<String> {
|
|
|
108
139
|
}
|
|
109
140
|
}
|
|
110
141
|
|
|
142
|
+
fn codex_models() -> Vec<String> {
|
|
143
|
+
if which("codex").is_none() {
|
|
144
|
+
return Vec::new();
|
|
145
|
+
}
|
|
146
|
+
let mut command = Command::new("codex");
|
|
147
|
+
command.args(["app-server", "proxy"]);
|
|
148
|
+
let input = r#"{"id":1,"method":"model/list","params":{"limit":25}}"#;
|
|
149
|
+
let Ok(run) = run_capture(&mut command, &format!("{input}\n"), Duration::from_secs(2)) else {
|
|
150
|
+
return Vec::new();
|
|
151
|
+
};
|
|
152
|
+
if run.code != Some(0) {
|
|
153
|
+
return Vec::new();
|
|
154
|
+
}
|
|
155
|
+
parse_model_list(&run.stdout)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fn parse_model_list(output: &str) -> Vec<String> {
|
|
159
|
+
output
|
|
160
|
+
.lines()
|
|
161
|
+
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
|
162
|
+
.flat_map(|value| {
|
|
163
|
+
value
|
|
164
|
+
.pointer("/result/data")
|
|
165
|
+
.or_else(|| value.get("data"))
|
|
166
|
+
.and_then(|data| data.as_array())
|
|
167
|
+
.cloned()
|
|
168
|
+
.unwrap_or_default()
|
|
169
|
+
})
|
|
170
|
+
.filter_map(|model| {
|
|
171
|
+
model
|
|
172
|
+
.get("model")
|
|
173
|
+
.or_else(|| model.get("id"))
|
|
174
|
+
.and_then(|value| value.as_str())
|
|
175
|
+
.map(str::to_string)
|
|
176
|
+
})
|
|
177
|
+
.collect()
|
|
178
|
+
}
|
|
179
|
+
|
|
111
180
|
fn run_codex_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
|
|
112
181
|
let output_path = unique_temp_path("practicode-last-message", "txt");
|
|
113
182
|
let mut command = Command::new("codex");
|
|
@@ -177,9 +246,9 @@ fn run_claude_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
|
|
|
177
246
|
}
|
|
178
247
|
|
|
179
248
|
fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -> String {
|
|
180
|
-
let start = "codex app-server daemon start >/dev/null 2>&1 || true";
|
|
249
|
+
let start = "if [ -x \"$HOME/.codex/packages/standalone/current/codex\" ]; then codex app-server daemon start >/dev/null 2>&1 || true; fi";
|
|
181
250
|
let mut exec = format!(
|
|
182
|
-
"codex exec --cd {} --sandbox workspace-write",
|
|
251
|
+
"codex exec --ephemeral --cd {} --sandbox workspace-write",
|
|
183
252
|
sh_quote(&root.display().to_string())
|
|
184
253
|
);
|
|
185
254
|
if let Some(model) = settings.model_arg() {
|
|
@@ -190,6 +259,14 @@ fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -
|
|
|
190
259
|
format!("{start}; {exec}")
|
|
191
260
|
}
|
|
192
261
|
|
|
262
|
+
fn codex_daemon_path() -> Option<PathBuf> {
|
|
263
|
+
env::var_os("HOME").map(|home| {
|
|
264
|
+
PathBuf::from(home)
|
|
265
|
+
.join(".codex/packages/standalone/current")
|
|
266
|
+
.join(if cfg!(windows) { "codex.exe" } else { "codex" })
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
193
270
|
fn default_claude_next_command(root: &Path, settings: &Settings, request: &str) -> String {
|
|
194
271
|
let mut claude = "claude --permission-mode acceptEdits".to_string();
|
|
195
272
|
if let Some(model) = settings.model_arg() {
|
|
@@ -211,3 +288,15 @@ fn output_text(stdout: &str, stderr: &str) -> String {
|
|
|
211
288
|
.collect::<Vec<_>>()
|
|
212
289
|
.join("\n")
|
|
213
290
|
}
|
|
291
|
+
|
|
292
|
+
#[cfg(test)]
|
|
293
|
+
mod tests {
|
|
294
|
+
use super::parse_model_list;
|
|
295
|
+
|
|
296
|
+
#[test]
|
|
297
|
+
fn parses_codex_model_list_response() {
|
|
298
|
+
let output =
|
|
299
|
+
r#"{"id":1,"result":{"data":[{"model":"gpt-test","displayName":"GPT Test"}]}}"#;
|
|
300
|
+
assert_eq!(parse_model_list(output), vec!["gpt-test"]);
|
|
301
|
+
}
|
|
302
|
+
}
|
package/src/core.rs
CHANGED
|
@@ -10,7 +10,7 @@ use std::{
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
pub const LANGUAGES: &[&str] = &["python", "ts", "java", "rust"];
|
|
13
|
-
pub
|
|
13
|
+
pub use crate::i18n::{UI_LANGUAGES, normalize_ui_language, ui_text};
|
|
14
14
|
pub const THEMES: &[&str] = &["dark", "light"];
|
|
15
15
|
pub const AI_PROVIDERS: &[&str] = &["codex", "claude"];
|
|
16
16
|
pub const BANK_PATH: &str = ".practicode/problem_bank.json";
|
|
@@ -383,20 +383,6 @@ pub fn normalize_language(language: &str) -> String {
|
|
|
383
383
|
}
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
-
pub fn normalize_ui_language(language: &str) -> String {
|
|
387
|
-
let lower = language.trim().to_lowercase();
|
|
388
|
-
let short = lower
|
|
389
|
-
.split(['-', '_'])
|
|
390
|
-
.next()
|
|
391
|
-
.filter(|value| !value.is_empty())
|
|
392
|
-
.unwrap_or("en");
|
|
393
|
-
if UI_LANGUAGES.contains(&short) {
|
|
394
|
-
short.to_string()
|
|
395
|
-
} else {
|
|
396
|
-
default_ui_language()
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
386
|
pub fn normalize_next_source(source: &str) -> String {
|
|
401
387
|
if source == "ai" {
|
|
402
388
|
"ai".to_string()
|
|
@@ -423,207 +409,6 @@ pub fn localized(map: &HashMap<String, String>, lang: &str) -> String {
|
|
|
423
409
|
.unwrap_or_default()
|
|
424
410
|
}
|
|
425
411
|
|
|
426
|
-
pub fn ui_text(lang: &str, key: &str) -> &'static str {
|
|
427
|
-
let lang = normalize_ui_language(lang);
|
|
428
|
-
match (lang.as_str(), key) {
|
|
429
|
-
("ko", "problem") => "문제",
|
|
430
|
-
("ko", "output") => "출력",
|
|
431
|
-
("ko", "command") => "명령",
|
|
432
|
-
("ko", "commands") => "명령",
|
|
433
|
-
("ko", "difficulty") => "난이도",
|
|
434
|
-
("ko", "topics") => "주제",
|
|
435
|
-
("ko", "input") => "입력",
|
|
436
|
-
("ko", "examples") => "예시",
|
|
437
|
-
("ko", "example") => "예시",
|
|
438
|
-
("ko", "command_placeholder") => "/ 입력 후 ↑/↓로 선택, Enter 실행",
|
|
439
|
-
("ko", "palette_hint") => "↑/↓ 선택 | Enter 실행 | Esc 취소",
|
|
440
|
-
("ko", "hint_command") => "Enter 실행 | Esc 취소",
|
|
441
|
-
("ko", "hint_list") => "↑/↓ 이동 | Enter 열기 | Esc 닫기",
|
|
442
|
-
("ko", "hint_output") => "Esc 코드 | / 명령 | ? 도움말",
|
|
443
|
-
("ko", "hint_code") => "Esc 후 / 명령",
|
|
444
|
-
("ko", "hint_idle") => "/ 명령 | ? 도움말",
|
|
445
|
-
("ko", "help_title") => "도움말",
|
|
446
|
-
("ko", "daily_loop") => "기본 흐름",
|
|
447
|
-
("ko", "keys") => "키",
|
|
448
|
-
("ko", "debug_prints") => "디버그 출력",
|
|
449
|
-
("ko", "cmd_run") => "현재 제출을 채점",
|
|
450
|
-
("ko", "cmd_edit") => "코드 편집기로 돌아가기",
|
|
451
|
-
("ko", "cmd_next") => "다음 문제 열기",
|
|
452
|
-
("ko", "cmd_prev") => "이전 문제 열기",
|
|
453
|
-
("ko", "cmd_list") => "문제 목록 열기",
|
|
454
|
-
("ko", "cmd_open") => "번호, id, slug로 문제 열기",
|
|
455
|
-
("ko", "cmd_giveup") => "정답 보기",
|
|
456
|
-
("ko", "cmd_ai") => "현재 문제와 코드에 대해 AI에게 질문",
|
|
457
|
-
("ko", "cmd_provider") => "AI provider 설정",
|
|
458
|
-
("ko", "cmd_model") => "AI model 설정",
|
|
459
|
-
("ko", "cmd_note") => "다음 문제 생성 메모 추가",
|
|
460
|
-
("ko", "cmd_notes") => "저장된 메모 보기",
|
|
461
|
-
("ko", "cmd_lang") => "코드 언어 설정",
|
|
462
|
-
("ko", "cmd_ui") => "UI 언어 설정",
|
|
463
|
-
("ko", "cmd_theme") => "테마 설정",
|
|
464
|
-
("ko", "cmd_source") => "다음 문제 출처 설정",
|
|
465
|
-
("ko", "cmd_exit") => "종료",
|
|
466
|
-
("ko", "cmd_help") => "도움말 열기",
|
|
467
|
-
|
|
468
|
-
("ja", "problem") => "問題",
|
|
469
|
-
("ja", "output") => "出力",
|
|
470
|
-
("ja", "command") => "コマンド",
|
|
471
|
-
("ja", "commands") => "コマンド",
|
|
472
|
-
("ja", "difficulty") => "難易度",
|
|
473
|
-
("ja", "topics") => "トピック",
|
|
474
|
-
("ja", "input") => "入力",
|
|
475
|
-
("ja", "examples") => "例",
|
|
476
|
-
("ja", "example") => "例",
|
|
477
|
-
("ja", "command_placeholder") => "/ を入力し ↑/↓ で選択、Enter で実行",
|
|
478
|
-
("ja", "palette_hint") => "↑/↓ 選択 | Enter 実行 | Esc キャンセル",
|
|
479
|
-
("ja", "hint_command") => "Enter 実行 | Esc キャンセル",
|
|
480
|
-
("ja", "hint_list") => "↑/↓ 移動 | Enter 開く | Esc 閉じる",
|
|
481
|
-
("ja", "hint_output") => "Esc コード | / コマンド | ? ヘルプ",
|
|
482
|
-
("ja", "hint_code") => "Esc の後 / コマンド",
|
|
483
|
-
("ja", "hint_idle") => "/ コマンド | ? ヘルプ",
|
|
484
|
-
("ja", "help_title") => "ヘルプ",
|
|
485
|
-
("ja", "daily_loop") => "基本フロー",
|
|
486
|
-
("ja", "keys") => "キー",
|
|
487
|
-
("ja", "debug_prints") => "デバッグ出力",
|
|
488
|
-
("ja", "cmd_run") => "現在の提出を判定",
|
|
489
|
-
("ja", "cmd_edit") => "コードエディタに戻る",
|
|
490
|
-
("ja", "cmd_next") => "次の問題を開く",
|
|
491
|
-
("ja", "cmd_prev") => "前の問題を開く",
|
|
492
|
-
("ja", "cmd_list") => "問題一覧を開く",
|
|
493
|
-
("ja", "cmd_open") => "番号、id、slug で問題を開く",
|
|
494
|
-
("ja", "cmd_giveup") => "解答を見る",
|
|
495
|
-
("ja", "cmd_ai") => "現在の問題とコードについて AI に質問",
|
|
496
|
-
("ja", "cmd_provider") => "AI provider を設定",
|
|
497
|
-
("ja", "cmd_model") => "AI model を設定",
|
|
498
|
-
("ja", "cmd_note") => "次の問題生成メモを追加",
|
|
499
|
-
("ja", "cmd_notes") => "保存済みメモを見る",
|
|
500
|
-
("ja", "cmd_lang") => "コード言語を設定",
|
|
501
|
-
("ja", "cmd_ui") => "UI 言語を設定",
|
|
502
|
-
("ja", "cmd_theme") => "テーマを設定",
|
|
503
|
-
("ja", "cmd_source") => "次の問題の取得元を設定",
|
|
504
|
-
("ja", "cmd_exit") => "終了",
|
|
505
|
-
("ja", "cmd_help") => "ヘルプを開く",
|
|
506
|
-
|
|
507
|
-
("zh", "problem") => "题目",
|
|
508
|
-
("zh", "output") => "输出",
|
|
509
|
-
("zh", "command") => "命令",
|
|
510
|
-
("zh", "commands") => "命令",
|
|
511
|
-
("zh", "difficulty") => "难度",
|
|
512
|
-
("zh", "topics") => "主题",
|
|
513
|
-
("zh", "input") => "输入",
|
|
514
|
-
("zh", "examples") => "示例",
|
|
515
|
-
("zh", "example") => "示例",
|
|
516
|
-
("zh", "command_placeholder") => "输入 / 后用 ↑/↓ 选择,Enter 执行",
|
|
517
|
-
("zh", "palette_hint") => "↑/↓ 选择 | Enter 执行 | Esc 取消",
|
|
518
|
-
("zh", "hint_command") => "Enter 执行 | Esc 取消",
|
|
519
|
-
("zh", "hint_list") => "↑/↓ 移动 | Enter 打开 | Esc 关闭",
|
|
520
|
-
("zh", "hint_output") => "Esc 代码 | / 命令 | ? 帮助",
|
|
521
|
-
("zh", "hint_code") => "Esc 后输入 / 命令",
|
|
522
|
-
("zh", "hint_idle") => "/ 命令 | ? 帮助",
|
|
523
|
-
("zh", "help_title") => "帮助",
|
|
524
|
-
("zh", "daily_loop") => "日常流程",
|
|
525
|
-
("zh", "keys") => "按键",
|
|
526
|
-
("zh", "debug_prints") => "调试输出",
|
|
527
|
-
("zh", "cmd_run") => "评测当前提交",
|
|
528
|
-
("zh", "cmd_edit") => "回到代码编辑器",
|
|
529
|
-
("zh", "cmd_next") => "打开下一题",
|
|
530
|
-
("zh", "cmd_prev") => "打开上一题",
|
|
531
|
-
("zh", "cmd_list") => "打开题目列表",
|
|
532
|
-
("zh", "cmd_open") => "按编号、id 或 slug 打开题目",
|
|
533
|
-
("zh", "cmd_giveup") => "显示参考答案",
|
|
534
|
-
("zh", "cmd_ai") => "向 AI 询问当前题目和代码",
|
|
535
|
-
("zh", "cmd_provider") => "设置 AI provider",
|
|
536
|
-
("zh", "cmd_model") => "设置 AI model",
|
|
537
|
-
("zh", "cmd_note") => "添加下次出题备注",
|
|
538
|
-
("zh", "cmd_notes") => "查看保存的备注",
|
|
539
|
-
("zh", "cmd_lang") => "设置代码语言",
|
|
540
|
-
("zh", "cmd_ui") => "设置 UI 语言",
|
|
541
|
-
("zh", "cmd_theme") => "设置主题",
|
|
542
|
-
("zh", "cmd_source") => "设置下一题来源",
|
|
543
|
-
("zh", "cmd_exit") => "退出",
|
|
544
|
-
("zh", "cmd_help") => "打开帮助",
|
|
545
|
-
|
|
546
|
-
("es", "problem") => "Problema",
|
|
547
|
-
("es", "output") => "Salida",
|
|
548
|
-
("es", "command") => "Comando",
|
|
549
|
-
("es", "commands") => "Comandos",
|
|
550
|
-
("es", "difficulty") => "Dificultad",
|
|
551
|
-
("es", "topics") => "Temas",
|
|
552
|
-
("es", "input") => "Entrada",
|
|
553
|
-
("es", "examples") => "Ejemplos",
|
|
554
|
-
("es", "example") => "Ejemplo",
|
|
555
|
-
("es", "command_placeholder") => "Escribe /, elige con ↑/↓, Enter ejecuta",
|
|
556
|
-
("es", "palette_hint") => "↑/↓ elegir | Enter ejecutar | Esc cancelar",
|
|
557
|
-
("es", "hint_command") => "Enter ejecutar | Esc cancelar",
|
|
558
|
-
("es", "hint_list") => "↑/↓ mover | Enter abrir | Esc cerrar",
|
|
559
|
-
("es", "hint_output") => "Esc codigo | / comando | ? ayuda",
|
|
560
|
-
("es", "hint_code") => "Esc y luego / comando",
|
|
561
|
-
("es", "hint_idle") => "/ comando | ? ayuda",
|
|
562
|
-
("es", "help_title") => "Ayuda",
|
|
563
|
-
("es", "daily_loop") => "Flujo diario",
|
|
564
|
-
("es", "keys") => "Teclas",
|
|
565
|
-
("es", "debug_prints") => "Salida de depuracion",
|
|
566
|
-
("es", "cmd_run") => "Evalua la solucion actual",
|
|
567
|
-
("es", "cmd_edit") => "Volver al editor de codigo",
|
|
568
|
-
("es", "cmd_next") => "Abrir el siguiente problema",
|
|
569
|
-
("es", "cmd_prev") => "Abrir el problema anterior",
|
|
570
|
-
("es", "cmd_list") => "Abrir la lista de problemas",
|
|
571
|
-
("es", "cmd_open") => "Abrir por numero, id o slug",
|
|
572
|
-
("es", "cmd_giveup") => "Mostrar la respuesta de referencia",
|
|
573
|
-
("es", "cmd_ai") => "Preguntar a AI sobre el problema y codigo actuales",
|
|
574
|
-
("es", "cmd_provider") => "Configurar AI provider",
|
|
575
|
-
("es", "cmd_model") => "Configurar AI model",
|
|
576
|
-
("es", "cmd_note") => "Agregar nota para generar problemas",
|
|
577
|
-
("es", "cmd_notes") => "Ver notas guardadas",
|
|
578
|
-
("es", "cmd_lang") => "Configurar lenguaje de codigo",
|
|
579
|
-
("es", "cmd_ui") => "Configurar idioma de UI",
|
|
580
|
-
("es", "cmd_theme") => "Configurar tema",
|
|
581
|
-
("es", "cmd_source") => "Configurar fuente del siguiente problema",
|
|
582
|
-
("es", "cmd_exit") => "Salir",
|
|
583
|
-
("es", "cmd_help") => "Abrir ayuda",
|
|
584
|
-
|
|
585
|
-
(_, "problem") => "Problem",
|
|
586
|
-
(_, "output") => "Output",
|
|
587
|
-
(_, "command") => "Command",
|
|
588
|
-
(_, "commands") => "Commands",
|
|
589
|
-
(_, "difficulty") => "Difficulty",
|
|
590
|
-
(_, "topics") => "Topics",
|
|
591
|
-
(_, "input") => "Input",
|
|
592
|
-
(_, "examples") => "Examples",
|
|
593
|
-
(_, "example") => "Example",
|
|
594
|
-
(_, "command_placeholder") => "Type /, move with ↑/↓, Enter runs",
|
|
595
|
-
(_, "palette_hint") => "↑/↓ select | Enter run | Esc cancel",
|
|
596
|
-
(_, "hint_command") => "Enter submit | Esc cancel",
|
|
597
|
-
(_, "hint_list") => "up/down move | Enter open | Esc close",
|
|
598
|
-
(_, "hint_output") => "Esc code | / command | ? help",
|
|
599
|
-
(_, "hint_code") => "Esc then / command",
|
|
600
|
-
(_, "hint_idle") => "/ command | ? help",
|
|
601
|
-
(_, "help_title") => "Help",
|
|
602
|
-
(_, "daily_loop") => "Daily loop",
|
|
603
|
-
(_, "keys") => "Keys",
|
|
604
|
-
(_, "debug_prints") => "Debug prints",
|
|
605
|
-
(_, "cmd_run") => "Judge the current submission",
|
|
606
|
-
(_, "cmd_edit") => "Return to the code editor",
|
|
607
|
-
(_, "cmd_next") => "Open the next problem",
|
|
608
|
-
(_, "cmd_prev") => "Open the previous problem",
|
|
609
|
-
(_, "cmd_list") => "Browse problems",
|
|
610
|
-
(_, "cmd_open") => "Open by number, id, or slug",
|
|
611
|
-
(_, "cmd_giveup") => "Show the reference answer",
|
|
612
|
-
(_, "cmd_ai") => "Ask AI about the current problem and code",
|
|
613
|
-
(_, "cmd_provider") => "Set AI provider",
|
|
614
|
-
(_, "cmd_model") => "Set AI model",
|
|
615
|
-
(_, "cmd_note") => "Add a next-problem note",
|
|
616
|
-
(_, "cmd_notes") => "Show saved notes",
|
|
617
|
-
(_, "cmd_lang") => "Set code language",
|
|
618
|
-
(_, "cmd_ui") => "Set UI language",
|
|
619
|
-
(_, "cmd_theme") => "Set theme",
|
|
620
|
-
(_, "cmd_source") => "Set next-problem source",
|
|
621
|
-
(_, "cmd_exit") => "Quit",
|
|
622
|
-
(_, "cmd_help") => "Open help",
|
|
623
|
-
_ => "",
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
412
|
pub fn template_for(language: &str) -> String {
|
|
628
413
|
match normalize_language(language).as_str() {
|
|
629
414
|
"python" => "# Read from stdin and print to stdout.\nimport sys\n\n\n".to_string(),
|
|
@@ -690,6 +475,66 @@ pub fn render_problem(problem: &Problem, ui_language: &str) -> String {
|
|
|
690
475
|
)
|
|
691
476
|
}
|
|
692
477
|
|
|
478
|
+
pub fn render_problem_tui(problem: &Problem, ui_language: &str) -> String {
|
|
479
|
+
let lang = normalize_ui_language(ui_language);
|
|
480
|
+
let number = problem
|
|
481
|
+
.id
|
|
482
|
+
.split_once('-')
|
|
483
|
+
.map(|(number, _)| number)
|
|
484
|
+
.unwrap_or(&problem.id);
|
|
485
|
+
let mut lines = vec![
|
|
486
|
+
format!("{number}. {}", localized(&problem.title, &lang)),
|
|
487
|
+
format!(
|
|
488
|
+
"{}: {} {}: {}",
|
|
489
|
+
ui_text(&lang, "difficulty"),
|
|
490
|
+
problem.difficulty,
|
|
491
|
+
ui_text(&lang, "topics"),
|
|
492
|
+
problem.topics.join(", ")
|
|
493
|
+
),
|
|
494
|
+
String::new(),
|
|
495
|
+
localized(&problem.statement, &lang),
|
|
496
|
+
];
|
|
497
|
+
push_tui_section(
|
|
498
|
+
&mut lines,
|
|
499
|
+
ui_text(&lang, "input"),
|
|
500
|
+
&localized(&problem.input, &lang),
|
|
501
|
+
);
|
|
502
|
+
push_tui_section(
|
|
503
|
+
&mut lines,
|
|
504
|
+
ui_text(&lang, "output"),
|
|
505
|
+
&localized(&problem.output, &lang),
|
|
506
|
+
);
|
|
507
|
+
lines.push(String::new());
|
|
508
|
+
lines.push(ui_text(&lang, "examples").to_string());
|
|
509
|
+
for (index, case) in problem.examples.iter().enumerate() {
|
|
510
|
+
lines.push(format!(" {} {}", ui_text(&lang, "example"), index + 1));
|
|
511
|
+
lines.push(format!(" {}:", ui_text(&lang, "input")));
|
|
512
|
+
push_case_text(&mut lines, &case.input);
|
|
513
|
+
lines.push(format!(" {}:", ui_text(&lang, "output")));
|
|
514
|
+
push_case_text(&mut lines, &case.output);
|
|
515
|
+
}
|
|
516
|
+
lines.join("\n").trim_end().to_string()
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
fn push_tui_section(lines: &mut Vec<String>, title: &str, body: &str) {
|
|
520
|
+
lines.push(String::new());
|
|
521
|
+
lines.push(title.to_string());
|
|
522
|
+
for line in body.trim_end().lines() {
|
|
523
|
+
lines.push(format!(" {line}"));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
fn push_case_text(lines: &mut Vec<String>, body: &str) {
|
|
528
|
+
let body = body.trim_end();
|
|
529
|
+
if body.is_empty() {
|
|
530
|
+
lines.push(" <empty>".to_string());
|
|
531
|
+
} else {
|
|
532
|
+
for line in body.lines() {
|
|
533
|
+
lines.push(format!(" {line}"));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
693
538
|
pub fn fenced_text(value: &str) -> String {
|
|
694
539
|
let mut body = value.to_string();
|
|
695
540
|
if !body.ends_with('\n') {
|
package/src/i18n.rs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
use std::{collections::HashMap, sync::OnceLock};
|
|
2
|
+
|
|
3
|
+
pub const UI_LANGUAGES: &[&str] = &["en", "ko", "ja", "zh", "es"];
|
|
4
|
+
|
|
5
|
+
static EN: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
6
|
+
static KO: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
7
|
+
static JA: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
8
|
+
static ZH: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
9
|
+
static ES: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
10
|
+
|
|
11
|
+
pub fn normalize_ui_language(language: &str) -> String {
|
|
12
|
+
let lower = language.trim().to_lowercase();
|
|
13
|
+
let short = lower
|
|
14
|
+
.split(['-', '_'])
|
|
15
|
+
.next()
|
|
16
|
+
.filter(|value| !value.is_empty())
|
|
17
|
+
.unwrap_or("en");
|
|
18
|
+
if UI_LANGUAGES.contains(&short) {
|
|
19
|
+
short.to_string()
|
|
20
|
+
} else {
|
|
21
|
+
"en".to_string()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
pub fn ui_text(lang: &str, key: &str) -> &'static str {
|
|
26
|
+
catalog(&normalize_ui_language(lang))
|
|
27
|
+
.get(key)
|
|
28
|
+
.or_else(|| catalog("en").get(key))
|
|
29
|
+
.map(String::as_str)
|
|
30
|
+
.unwrap_or("")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fn catalog(lang: &str) -> &'static HashMap<String, String> {
|
|
34
|
+
match lang {
|
|
35
|
+
"ko" => KO.get_or_init(|| load(include_str!("../assets/i18n/ko.json"))),
|
|
36
|
+
"ja" => JA.get_or_init(|| load(include_str!("../assets/i18n/ja.json"))),
|
|
37
|
+
"zh" => ZH.get_or_init(|| load(include_str!("../assets/i18n/zh.json"))),
|
|
38
|
+
"es" => ES.get_or_init(|| load(include_str!("../assets/i18n/es.json"))),
|
|
39
|
+
_ => EN.get_or_init(|| load(include_str!("../assets/i18n/en.json"))),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn load(text: &str) -> HashMap<String, String> {
|
|
44
|
+
serde_json::from_str(text).expect("valid i18n catalog")
|
|
45
|
+
}
|
package/src/lib.rs
CHANGED