Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@

1. Read `ARCHITECTURE.md` and corresponding docs before structural changes
2. Plans are treated as first-class artifacts. Ephemeral lightweight plans are used for small changes
3. Plans are created in `active` folder in `docs/execution-plans` and moved to `completed` folder after completion
3. Plans are created in `active` folder in `docs/execution-plans` and should be moved to `completed` folder after completion
4. Use an ExecPlan (as described in `docs/PLANS.md`) from design to implementation, when writing complex features
or significant refactors
5. Known technical debt is tracked in `docs/execution-plans/tech-debt-tracker.md`
6. Keep line length <= 120 in git-tracked Markdown files
7. Never use emoji
8. Commit message should strictly follow conventional commits format. Line length: title (first line) 50, body 72.
8. Always check and format before commit
9. Commit message should strictly follow conventional commits format. Line length: title (first line) 50, body 72.
In title, only use one word for scope. (e.g. plan instead of execute-plan). In body, use list (if more than one change)
to explain what are changed and why (each in natural human sentence).
9. Never skip git signature and commit message for tag, merge and so on.
to explain what are changed and why (each in natural human sentence). Body should be as one block.
10. Never skip git signature and commit message for tag, merge and so on.
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [

[workspace.package]
version = "0.1.0"
edition = "2021"
edition = "2024"
license = "GPL-3.0-or-later"
repository = "https://github.com/chrisyqpro/petit_trad"

Expand All @@ -17,6 +17,7 @@ thiserror = "1.0"
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
encoding_rs = "0.8"

# Inference
llama-cpp-2 = "0.1.132"
Expand Down
1 change: 1 addition & 0 deletions crates/petit-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ thiserror.workspace = true
anyhow.workspace = true
serde.workspace = true
toml.workspace = true
encoding_rs.workspace = true

llama-cpp-2.workspace = true
llama-cpp-sys-2.workspace = true
Expand Down
4 changes: 1 addition & 3 deletions crates/petit-core/src/gemma.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@ impl GemmaTranslator {
fn build_prompt(&self, text: &str, source_lang: &str, target_lang: &str) -> String {
let src = normalize_lang(source_lang);
let tgt = normalize_lang(target_lang);
format!(
"<start_of_turn>user\n[{src}->{tgt}] {text}<end_of_turn>\n<start_of_turn>model\n"
)
format!("<start_of_turn>user\n[{src}->{tgt}] {text}<end_of_turn>\n<start_of_turn>model\n")
}

/// Clean the model output by stripping whitespace and any echo artifacts
Expand Down
12 changes: 7 additions & 5 deletions crates/petit-core/src/model_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ use crate::{Config, Error, Result};
use llama_cpp_2::context::params::LlamaContextParams;
use llama_cpp_2::llama_backend::LlamaBackend;
use llama_cpp_2::llama_batch::LlamaBatch;
use llama_cpp_2::model::params::LlamaModelParams;
use llama_cpp_2::model::LlamaModel;
use llama_cpp_2::model::params::LlamaModelParams;
use llama_cpp_2::token::data_array::LlamaTokenDataArray;
use std::ffi::CStr;
use std::fs::{self, OpenOptions};
Expand Down Expand Up @@ -120,10 +120,11 @@ impl ModelManager {

// Convert tokens to string
let mut output = String::new();
let mut decoder = encoding_rs::UTF_8.new_decoder();
for token in output_tokens {
let piece = self
.model
.token_to_str(token, llama_cpp_2::model::Special::Tokenize)
.token_to_piece(token, &mut decoder, true, None)
.map_err(|e| Error::Inference(format!("Detokenization: {e}")))?;
output.push_str(&piece);
}
Expand Down Expand Up @@ -157,8 +158,7 @@ fn configure_logging(config: &Config, backend: &mut LlamaBackend) -> Result<()>

fn init_log_file(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| Error::ModelLoad(format!("Log dir create: {e}")))?;
fs::create_dir_all(parent).map_err(|e| Error::ModelLoad(format!("Log dir create: {e}")))?;
}

let file = OpenOptions::new()
Expand Down Expand Up @@ -187,6 +187,8 @@ unsafe extern "C" fn log_callback(
Ok(file) => file,
Err(_) => return,
};
let message = CStr::from_ptr(text).to_bytes();
// SAFETY: `text` is provided by llama.cpp for the duration of this callback and
// was checked for null above.
let message = unsafe { CStr::from_ptr(text) }.to_bytes();
let _ = file.write_all(message);
}
6 changes: 5 additions & 1 deletion crates/petit-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ impl Default for App {
}

impl App {
pub fn with_languages(source_lang: String, target_lang: String, compact_lang_display: bool) -> Self {
pub fn with_languages(
source_lang: String,
target_lang: String,
compact_lang_display: bool,
) -> Self {
Self {
source_lang,
target_lang,
Expand Down
14 changes: 10 additions & 4 deletions crates/petit-tui/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

//! CLI argument parsing for petit-tui.

use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use std::path::PathBuf;

#[derive(Debug, Default)]
Expand All @@ -28,10 +28,16 @@ impl CliArgs {
while let Some(arg) = args.next() {
match arg.as_str() {
"--model" => cli.model = Some(parse_path(&mut args, "--model")?),
"--source-lang" => cli.source_lang = Some(parse_string(&mut args, "--source-lang")?),
"--target-lang" => cli.target_lang = Some(parse_string(&mut args, "--target-lang")?),
"--source-lang" => {
cli.source_lang = Some(parse_string(&mut args, "--source-lang")?)
}
"--target-lang" => {
cli.target_lang = Some(parse_string(&mut args, "--target-lang")?)
}
"--gpu-layers" => cli.gpu_layers = Some(parse_u32(&mut args, "--gpu-layers")?),
"--context-size" => cli.context_size = Some(parse_u32(&mut args, "--context-size")?),
"--context-size" => {
cli.context_size = Some(parse_u32(&mut args, "--context-size")?)
}
"--threads" => cli.threads = Some(parse_u32(&mut args, "--threads")?),
"--config" => cli.config = Some(parse_path(&mut args, "--config")?),
"--no-config" => cli.no_config = true,
Expand Down
4 changes: 2 additions & 2 deletions crates/petit-tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

//! Config loading and precedence handling for petit-tui.

use anyhow::{anyhow, Result};
use petit_core::language::{normalize_lang, validate_pair};
use anyhow::{Result, anyhow};
use petit_core::Config;
use petit_core::language::{normalize_lang, validate_pair};
use serde::Deserialize;
use std::env;
use std::fs;
Expand Down
61 changes: 47 additions & 14 deletions crates/petit-tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,22 @@
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use petit_core::{Config, GemmaTranslator, Translator};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use std::io::{self, Read, Stdout, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use crate::app::{App, Focus, LangTarget, TranslationRequest};
use crate::cli::CliArgs;
use crate::config::{load_config, AppConfig};
use crate::config::{AppConfig, load_config};

mod app;
mod cli;
Expand Down Expand Up @@ -137,11 +139,7 @@ fn handle_key_event(app: &mut App, key: KeyEvent, tx: &Sender<TranslationRequest
return;
}

// TODO: Confirm Ctrl+Enter behavior across terminals; keep fallbacks until verified.
if (key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::CONTROL))
|| (key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL))
|| key.code == KeyCode::F(5)
{
if is_translate_shortcut(&key) {
request_translation(app, tx);
return;
}
Expand Down Expand Up @@ -172,10 +170,6 @@ fn handle_key_event(app: &mut App, key: KeyEvent, tx: &Sender<TranslationRequest
}

match key.code {
KeyCode::Enter => match app.focus {
Focus::Input => app.insert_char('\n'),
Focus::Output => request_translation(app, tx),
},
KeyCode::Backspace => app.backspace(),
KeyCode::Delete => app.delete(),
KeyCode::Left => app.move_left(),
Expand Down Expand Up @@ -238,6 +232,10 @@ fn is_text_input(key: &KeyEvent) -> bool {
!key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT)
}

fn is_translate_shortcut(key: &KeyEvent) -> bool {
key.code == KeyCode::Enter
}

fn request_translation(app: &mut App, tx: &Sender<TranslationRequest>) {
let request = match app.begin_translation() {
Some(request) => request,
Expand All @@ -249,7 +247,9 @@ fn request_translation(app: &mut App, tx: &Sender<TranslationRequest>) {
}
}

fn start_translation_worker(config: Config) -> (
fn start_translation_worker(
config: Config,
) -> (
Sender<TranslationRequest>,
Receiver<TranslationResponse>,
thread::JoinHandle<()>,
Expand Down Expand Up @@ -339,3 +339,36 @@ fn cleanup_terminal(cleaned: &AtomicBool) {
let _ = execute!(stdout, LeaveAlternateScreen);
let _ = stdout.flush();
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn translate_shortcuts_match_expected_keys() {
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let shift_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
let ctrl_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);

assert!(is_translate_shortcut(&enter));
assert!(is_translate_shortcut(&shift_enter));
assert!(is_translate_shortcut(&ctrl_enter));
}

#[test]
fn non_shortcuts_do_not_trigger_translation() {
let char_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
let char_m = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
let ctrl_m = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL);
let f5 = KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE);
let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);

assert!(!is_translate_shortcut(&char_a));
assert!(!is_translate_shortcut(&char_m));
assert!(!is_translate_shortcut(&ctrl_m));
assert!(!is_translate_shortcut(&f5));
assert!(!is_translate_shortcut(&ctrl_r));
assert!(!is_translate_shortcut(&tab));
}
}
21 changes: 14 additions & 7 deletions crates/petit-tui/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

use std::time::{SystemTime, UNIX_EPOCH};

use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;

use crate::app::{App, Focus};

Expand All @@ -31,7 +31,9 @@ pub fn render(app: &App, frame: &mut Frame) {
let input_area = layout[1];
let output_area = layout[2];

let focus_style = Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD);
let focus_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let border_style = Style::default().fg(Color::Gray);

let input_block = Block::default()
Expand Down Expand Up @@ -84,7 +86,9 @@ pub fn render(app: &App, frame: &mut Frame) {

fn header_widget(app: &App) -> Paragraph<'static> {
let label_style = Style::default().fg(Color::Gray);
let value_style = Style::default().fg(Color::White).add_modifier(Modifier::BOLD);
let value_style = Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD);
let focus_label = match app.focus {
Focus::Input => "Input",
Focus::Output => "Output",
Expand Down Expand Up @@ -130,7 +134,10 @@ fn status_widget(app: &App) -> Paragraph<'static> {
"Ready".to_string()
};

let line = Line::from(vec![Span::styled(status_text, Style::default().fg(Color::White))]);
let line = Line::from(vec![Span::styled(
status_text,
Style::default().fg(Color::White),
)]);

Paragraph::new(line)
.alignment(Alignment::Left)
Expand All @@ -140,11 +147,11 @@ fn status_widget(app: &App) -> Paragraph<'static> {
fn hints_widget(area: Rect) -> Paragraph<'static> {
let width = area.width as usize;
let hints = if width < 70 {
"Ctrl+Q Quit | Ctrl+Enter Translate | Tab Focus"
"Ctrl+Q Quit | Enter Translate | Tab Focus"
} else if width < 100 {
"Ctrl+Q Quit | Ctrl+Enter Translate | Tab Focus | Ctrl+R Swap | Ctrl+L Clear"
"Ctrl+Q Quit | Enter Translate | Tab Focus | Ctrl+R Swap | Ctrl+L Clear"
} else {
"Ctrl+Q Quit | Ctrl+Enter Translate | Tab Focus | Ctrl+R Swap | Ctrl+L Clear | Ctrl+S Source | Ctrl+T Target"
"Ctrl+Q Quit | Enter Translate | Tab Focus | Ctrl+R Swap | Ctrl+L Clear | Ctrl+S Source | Ctrl+T Target"
};
let line = Line::from(vec![Span::styled(hints, Style::default().fg(Color::Gray))]);

Expand Down
Loading