Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b5b38fa
backend: add clone workspace command
ratulsarna Jan 18, 2026
8c3723a
frontend: add clone IPC + copiesFolder field
ratulsarna Jan 18, 2026
a1e399e
settings: add per-project copies folder
ratulsarna Jan 18, 2026
fd7cb28
ui: add clone prompt and sidebar action
ratulsarna Jan 18, 2026
8aedc9e
ui: prevent Enter submit while cloning
ratulsarna Jan 18, 2026
d52665b
ui: prevent closing create modals while busy
ratulsarna Jan 18, 2026
0394a71
backend: cleanup clone dir on failure
ratulsarna Jan 18, 2026
5380d7b
backend: avoid blocking cleanup in async path
ratulsarna Jan 18, 2026
978f186
ui: improve visibility of suggested copies path
ratulsarna Jan 18, 2026
36beacc
ui: make suggested path horizontally scrollable
ratulsarna Jan 18, 2026
b9ed9f2
ui: hide clone modal path scrollbars
ratulsarna Jan 18, 2026
402f160
threads: speed up thread list matching
ratulsarna Jan 18, 2026
4500b63
Merge remote-tracking branch 'upstream/main' into feature/project-clo…
ratulsarna Jan 19, 2026
f5e4345
clones: ensure per-project Codex home
ratulsarna Jan 19, 2026
9e7031b
ui: include repo name in clone default
ratulsarna Jan 19, 2026
7903be1
ui: make clone default name repo-prefixed
ratulsarna Jan 19, 2026
5f52283
Merge branch 'main' into feature/project-clone-working-copies
Dimillian Jan 19, 2026
42928c7
Merge remote-tracking branch 'upstream/main' into feature/project-clo…
ratulsarna Jan 19, 2026
6140443
Merge remote-tracking branch 'origin/feature/project-clone-working-co…
ratulsarna Jan 19, 2026
80b54b1
clones: avoid dirty working tree
ratulsarna Jan 19, 2026
a9f3445
ui: prevent connect pill clipping in sidebar
ratulsarna Jan 19, 2026
6513680
fix: require modifier for shortcut capture
ratulsarna Jan 19, 2026
2713881
Merge branch 'main' into feature/project-clone-working-copies
ratulsarna Jan 19, 2026
c6596c1
Merge branch 'main' into feature/project-clone-working-copies
ratulsarna Jan 19, 2026
31009dd
Merge branch 'main' into feature/project-clone-working-copies
ratulsarna Jan 19, 2026
e34238a
fix: allow paging older threads after capped scan
ratulsarna Jan 19, 2026
349fb34
fix: avoid double-counting local usage totals
ratulsarna Jan 19, 2026
84477b9
fix: surface git action errors and refresh
ratulsarna Jan 19, 2026
79db41b
fix: avoid /dev/null in worktree apply on Windows
ratulsarna Jan 19, 2026
282fa0c
fix: remove duplicate file-link menu actions
ratulsarna Jan 19, 2026
4849c81
fix: guard clipboard write in thread menu
ratulsarna Jan 19, 2026
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
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ pub fn run() {
codex::codex_doctor,
workspaces::list_workspaces,
workspaces::add_workspace,
workspaces::add_clone,
workspaces::add_worktree,
workspaces::remove_workspace,
workspaces::remove_worktree,
Expand Down
80 changes: 80 additions & 0 deletions src-tauri/src/local_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,14 @@ fn scan_file(
output: (output - prev.output).max(0),
};
previous_totals = Some(UsageTotals { input, cached, output });
} else {
// Some streams emit `last_token_usage` deltas between `total_token_usage` snapshots.
// Treat those as already-counted to avoid double-counting when the next total arrives.
let mut next = previous_totals.unwrap_or_default();
next.input += delta.input;
next.cached += delta.cached;
next.output += delta.output;
previous_totals = Some(next);
}

if delta.input == 0 && delta.cached == 0 && delta.output == 0 {
Expand Down Expand Up @@ -344,3 +352,75 @@ fn day_dir_for_key(root: &Path, day_key: &str) -> PathBuf {
let day = parts.next().unwrap_or("01");
root.join(year).join(month).join(day)
}

#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use uuid::Uuid;

fn write_temp_jsonl(lines: &[&str]) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"codexmonitor-local-usage-test-{}.jsonl",
Uuid::new_v4()
));
let mut file = File::create(&path).expect("create temp jsonl");
for line in lines {
writeln!(file, "{line}").expect("write jsonl line");
}
path
}

#[test]
fn scan_file_does_not_double_count_last_and_total_usage() {
let day_key = "2026-01-19";
let path = write_temp_jsonl(&[
r#"{"payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}"#,
r#"{"payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}"#,
]);

let mut daily: HashMap<String, DailyTotals> = HashMap::new();
let mut model_totals: HashMap<String, i64> = HashMap::new();
scan_file(&path, day_key, &mut daily, &mut model_totals).expect("scan file");

let totals = daily.get(day_key).copied().unwrap_or_default();
assert_eq!(totals.input, 10);
assert_eq!(totals.output, 5);
}

#[test]
fn scan_file_counts_last_deltas_before_total_snapshot_once() {
let day_key = "2026-01-19";
let path = write_temp_jsonl(&[
r#"{"payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}"#,
r#"{"payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":20,"cached_input_tokens":0,"output_tokens":10}}}}"#,
]);

let mut daily: HashMap<String, DailyTotals> = HashMap::new();
let mut model_totals: HashMap<String, i64> = HashMap::new();
scan_file(&path, day_key, &mut daily, &mut model_totals).expect("scan file");

let totals = daily.get(day_key).copied().unwrap_or_default();
assert_eq!(totals.input, 20);
assert_eq!(totals.output, 10);
}

#[test]
fn scan_file_does_not_double_count_last_between_total_snapshots() {
let day_key = "2026-01-19";
let path = write_temp_jsonl(&[
r#"{"payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}"#,
r#"{"payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":2,"cached_input_tokens":0,"output_tokens":1}}}}"#,
r#"{"payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":12,"cached_input_tokens":0,"output_tokens":6}}}}"#,
]);

let mut daily: HashMap<String, DailyTotals> = HashMap::new();
let mut model_totals: HashMap<String, i64> = HashMap::new();
scan_file(&path, day_key, &mut daily, &mut model_totals).expect("scan file");

let totals = daily.get(day_key).copied().unwrap_or_default();
assert_eq!(totals.input, 12);
assert_eq!(totals.output, 6);
}
}
33 changes: 32 additions & 1 deletion src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ pub(crate) struct WorkspaceGroup {
pub(crate) name: String,
#[serde(default, rename = "sortOrder")]
pub(crate) sort_order: Option<u32>,
#[serde(default, rename = "copiesFolder")]
pub(crate) copies_folder: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone, Default)]
Expand Down Expand Up @@ -396,7 +398,9 @@ impl Default for AppSettings {

#[cfg(test)]
mod tests {
use super::{AppSettings, BackendMode, WorkspaceEntry, WorkspaceKind, WorkspaceSettings};
use super::{
AppSettings, BackendMode, WorkspaceEntry, WorkspaceGroup, WorkspaceKind, WorkspaceSettings,
};

#[test]
fn app_settings_defaults_from_empty_json() {
Expand Down Expand Up @@ -430,6 +434,33 @@ mod tests {
assert!(settings.workspace_groups.is_empty());
}

#[test]
fn workspace_group_defaults_from_minimal_json() {
let group: WorkspaceGroup =
serde_json::from_str(r#"{"id":"g1","name":"Group"}"#).expect("group deserialize");
assert!(group.sort_order.is_none());
assert!(group.copies_folder.is_none());
}

#[test]
fn app_settings_round_trip_preserves_workspace_group_copies_folder() {
let mut settings = AppSettings::default();
settings.workspace_groups = vec![WorkspaceGroup {
id: "g1".to_string(),
name: "Group".to_string(),
sort_order: Some(2),
copies_folder: Some("/tmp/group-copies".to_string()),
}];

let json = serde_json::to_string(&settings).expect("serialize settings");
let decoded: AppSettings = serde_json::from_str(&json).expect("deserialize settings");
assert_eq!(decoded.workspace_groups.len(), 1);
assert_eq!(
decoded.workspace_groups[0].copies_folder.as_deref(),
Some("/tmp/group-copies")
);
}

#[test]
fn workspace_entry_defaults_from_minimal_json() {
let entry: WorkspaceEntry = serde_json::from_str(
Expand Down
Loading