Skip to content
Open
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
1 change: 1 addition & 0 deletions nori-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions nori-rs/acp/src/connection/sacp_connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,9 @@ impl SacpConnection {
connection: ConnectionTo<Agent>| {
// Translate ACP permission request to Codex approval event.
let event = if let Some(patch_event) =
translator::permission_request_to_patch_approval_event(&request)
{
translator::permission_request_to_patch_approval_event(
&request, &cwd,
) {
ApprovalEventType::Patch(patch_event)
} else {
let exec_event = translator::permission_request_to_approval_event(
Expand Down
103 changes: 93 additions & 10 deletions nori-rs/acp/src/translator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ pub fn is_patch_operation(
pub fn tool_call_to_file_change(
kind: Option<&acp::ToolKind>,
raw_input: Option<&serde_json::Value>,
cwd: &std::path::Path,
) -> Option<(PathBuf, FileChange)> {
let input = raw_input?;
let file_path = extract_file_path(Some(input))?;
Expand Down Expand Up @@ -737,8 +738,9 @@ pub fn tool_call_to_file_change(
let old_string = input.get("old_string").and_then(|v| v.as_str())?;
let new_string = input.get("new_string").and_then(|v| v.as_str())?;

// Generate unified diff using diffy
let unified_diff = diffy::create_patch(old_string, new_string).to_string();
// Generate unified diff using diffy with file context if available
let unified_diff =
codex_core::util::create_patch_with_context(&path, cwd, old_string, new_string);

Some((
path,
Expand All @@ -755,6 +757,7 @@ pub fn tool_call_to_file_change(
/// patch approval UI in the TUI instead of the generic exec approval.
pub fn permission_request_to_patch_approval_event(
request: &acp::RequestPermissionRequest,
cwd: &std::path::Path,
) -> Option<ApplyPatchApprovalRequestEvent> {
let kind = request.tool_call.fields.kind.as_ref();
let raw_input = request.tool_call.fields.raw_input.as_ref();
Expand All @@ -764,7 +767,7 @@ pub fn permission_request_to_patch_approval_event(
return None;
}

let (path, change) = tool_call_to_file_change(kind, raw_input)?;
let (path, change) = tool_call_to_file_change(kind, raw_input, cwd)?;

let mut changes = HashMap::new();
changes.insert(path, change);
Expand Down Expand Up @@ -1237,7 +1240,11 @@ mod tests {
"new_string": "fn new() {\n println!(\"hello\");\n}"
});

let result = tool_call_to_file_change(Some(&acp::ToolKind::Edit), Some(&input));
let result = tool_call_to_file_change(
Some(&acp::ToolKind::Edit),
Some(&input),
std::path::Path::new("."),
);
assert!(result.is_some());

let (path, change) = result.unwrap();
Expand All @@ -1258,14 +1265,82 @@ mod tests {
}
}

#[test]
fn test_tool_call_to_file_change_edit_with_context() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let content = "line 1\nline 2\nline 3\nline 4\nline 5\n";
std::fs::write(&file_path, content).unwrap();

let input = serde_json::json!({
"path": file_path.to_str().unwrap(),
"old_string": "line 3\n",
"new_string": "line 3 modified\n"
});

// Use the temp dir as cwd
let result =
tool_call_to_file_change(Some(&acp::ToolKind::Edit), Some(&input), temp_dir.path());
assert!(result.is_some());

let (_, change) = result.unwrap();
if let FileChange::Update { unified_diff, .. } = change {
// The diff should show line 3, not line 1
assert!(
unified_diff.contains("@@ -1,5 +1,5 @@")
|| unified_diff.contains("@@ -1,4 +1,4 @@")
|| unified_diff.contains("-line 3")
|| unified_diff.contains("line 2")
);

// Wait, if it uses the whole file, it should have the correct line numbers.
// For a 5 line file, it might still show @@ -1,5 +1,5 @@ if the whole file is context.
// Let's use a larger file to be sure.

let large_content = (1..=100).map(|i| format!("line {i}\n")).collect::<String>();
std::fs::write(&file_path, &large_content).unwrap();

let input2 = serde_json::json!({
"path": file_path.to_str().unwrap(),
"old_string": "line 50\n",
"new_string": "line 50 modified\n"
});

let result2 = tool_call_to_file_change(
Some(&acp::ToolKind::Edit),
Some(&input2),
temp_dir.path(),
);
let (_, change2) = result2.unwrap();
if let FileChange::Update { unified_diff, .. } = change2 {
// The diff should contain line 50
assert!(unified_diff.contains("-line 50"));
assert!(unified_diff.contains("+line 50 modified"));
// And the hunk header should NOT be @ -1,x +1,x
assert!(!unified_diff.contains("@@ -1,"));
// It should be adjusted to line 50
assert!(
unified_diff.contains("@@ -50 +50 @@")
|| unified_diff.contains("@@ -50,1 +50,1 @@")
);
}
} else {
panic!("Expected FileChange::Update");
}
}

#[test]
fn test_tool_call_to_file_change_write() {
let input = serde_json::json!({
"file_path": "/src/new_file.rs",
"content": "// New file\nfn main() {}\n"
});

let result = tool_call_to_file_change(Some(&acp::ToolKind::Edit), Some(&input));
let result = tool_call_to_file_change(
Some(&acp::ToolKind::Edit),
Some(&input),
std::path::Path::new("."),
);
assert!(result.is_some());

let (path, change) = result.unwrap();
Expand All @@ -1286,7 +1361,11 @@ mod tests {
"content": "// File to delete\n"
});

let result = tool_call_to_file_change(Some(&acp::ToolKind::Delete), Some(&input));
let result = tool_call_to_file_change(
Some(&acp::ToolKind::Delete),
Some(&input),
std::path::Path::new("."),
);
assert!(result.is_some());

let (path, change) = result.unwrap();
Expand All @@ -1306,7 +1385,11 @@ mod tests {
"content": "some content"
});

let result = tool_call_to_file_change(Some(&acp::ToolKind::Edit), Some(&input));
let result = tool_call_to_file_change(
Some(&acp::ToolKind::Edit),
Some(&input),
std::path::Path::new("."),
);
assert!(result.is_none());
}

Expand All @@ -1330,7 +1413,7 @@ mod tests {
vec![],
);

let event = permission_request_to_patch_approval_event(&request);
let event = permission_request_to_patch_approval_event(&request, std::path::Path::new("."));
assert!(event.is_some());

let event = event.unwrap();
Expand Down Expand Up @@ -1365,7 +1448,7 @@ mod tests {
vec![],
);

let event = permission_request_to_patch_approval_event(&request);
let event = permission_request_to_patch_approval_event(&request, std::path::Path::new("."));
assert!(event.is_none());
}

Expand Down Expand Up @@ -1503,7 +1586,7 @@ mod tests {
vec![],
);

let event = permission_request_to_patch_approval_event(&request);
let event = permission_request_to_patch_approval_event(&request, std::path::Path::new("."));
assert!(event.is_some());

let event = event.unwrap();
Expand Down
1 change: 1 addition & 0 deletions nori-rs/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ codex-utils-pty = { workspace = true }
codex-utils-readiness = { workspace = true }
codex-utils-string = { workspace = true }
codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
diffy = { workspace = true }
dirs = { workspace = true }
dunce = { workspace = true }
env-flags = { workspace = true }
Expand Down
55 changes: 55 additions & 0 deletions nori-rs/core/src/util.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,60 @@
use tracing::debug;

pub fn create_patch_with_context(
path: &std::path::Path,
cwd: &std::path::Path,
old_text: &str,
new_text: &str,
) -> String {
let full_path = if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
};

let line_offset = if let Ok(file_content) = std::fs::read_to_string(&full_path) {
file_content
.find(old_text)
.map(|offset| file_content[..offset].lines().count() + 1)
} else {
None
};

let patch = diffy::create_patch(old_text, new_text).to_string();
if let Some(offset) = line_offset
&& offset > 1
{
return adjust_patch_line_numbers(&patch, offset);
}
patch
}

fn adjust_patch_line_numbers(patch: &str, line_offset: usize) -> String {
let Ok(re) = regex::Regex::new(r"^@@ -(\d+)(,?\d*) \+(\d+)(,?\d*) @@") else {
return patch.to_string();
};
let mut result = String::new();
for line in patch.lines() {
if let Some(caps) = re.captures(line) {
let old_start: usize = caps[1].parse().unwrap_or(1);
let new_start: usize = caps[3].parse().unwrap_or(1);
let old_rest = &caps[2];
let new_rest = &caps[4];

let adjusted_old_start = old_start + line_offset - 1;
let adjusted_new_start = new_start + line_offset - 1;

result.push_str(&format!(
"@@ -{adjusted_old_start}{old_rest} +{adjusted_new_start}{new_rest} @@\n",
));
} else {
result.push_str(line);
result.push('\n');
}
}
result
}

pub(crate) fn try_parse_error_message(text: &str) -> String {
debug!("Parsing server error response: {}", text);
let json = serde_json::from_str::<serde_json::Value>(text).unwrap_or_default();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ expression: normalize_for_input_snapshot(contents)

› [DEFAULT_PROMPT]

⎇ master · Approvals: Agent · ? for shortcuts
⎇ master · ! · Approvals: Agent · ? for shortcuts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ expression: normalize_for_input_snapshot(contents)

› [DEFAULT_PROMPT]

⎇ master · Approvals: Agent · ? for shortcuts
⎇ master · ! · Approvals: Agent · ? for shortcuts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ expression: normalize_for_input_snapshot(contents)

› [DEFAULT_PROMPT]

⎇ master · Approvals: Agent · ? for shortcuts
⎇ master · ! · Approvals: Agent · ? for shortcuts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ expression: normalize_for_input_snapshot(contents)

› [DEFAULT_PROMPT]

master · Approvals: Agent · ? for shortcuts
master · ! · Approvals: Agent · ? for shortcuts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ expression: normalize_for_input_snapshot(contents)

› [DEFAULT_PROMPT]

⎇ master · Approvals: Agent · ? for shortcuts
⎇ master · ! · Approvals: Agent · ? for shortcuts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ expression: normalize_for_input_snapshot(contents)

› [DEFAULT_PROMPT]

⎇ master · Approvals: Agent · ? for shortcuts
⎇ master · ! · Approvals: Agent · ? for shortcuts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ expression: normalize_for_input_snapshot(session.screen_contents())

› [DEFAULT_PROMPT]

⎇ master · Approvals: Agent · ? for shortcuts
⎇ master · ! · Approvals: Agent · ? for shortcuts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ expression: normalize_for_input_snapshot(contents)

› [DEFAULT_PROMPT]

⎇ master · Approvals: Agent · ? for shortcuts
⎇ master · ! · Approvals: Agent · ? for shortcuts
17 changes: 7 additions & 10 deletions nori-rs/tui/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,23 +203,20 @@ The `SystemInfo` struct collects environment data in a background thread to avoi
|-------|--------|
| `git_branch` | Git repository branch name |
| `active_skillsets` | Active skillsets from `nori-skillsets list-active` (one name per line; returns all skillsets active for the current directory). Empty vec if the command is unavailable or fails. |
| `git_lines_added` / `git_lines_removed` | Git diff statistics relative to the merge-base with the default branch (PR-like stats) |
| `git_lines_added` / `git_lines_removed` | Git working tree statistics relative to `HEAD` for tracked files |
| `git_has_untracked` | Whether untracked, non-ignored files are present |
| `is_worktree` | Whether CWD is a git worktree |
| `worktree_name` | Last path component of CWD when parent directory is `.worktrees`; used to display the immutable worktree directory identifier in the footer |
| `transcript_location` | Discovered transcript path and token usage when running within an agent environment |
| `worktree_cleanup_warning` | Warning when git worktrees exist and disk space is below 10% free (unix only) |

The `transcript_location` field includes both `token_usage` (total tokens) and `token_breakdown` (detailed input/output/cached breakdown) which are displayed in the TUI footer when Nori runs as a nested agent inside Claude Code, Codex, or Gemini.

**Git Diff Base Resolution** (`system_info.rs: resolve_diff_base()`):

The git diff stats are computed against the merge-base with the default branch, so they reflect what a PR would show rather than only uncommitted changes. The resolution order is:
1. `origin/HEAD` via `git symbolic-ref` -- detects the remote's default branch name
2. Falls back to checking if local `main` or `master` branches exist
3. Computes `git merge-base HEAD <branch>` to find the common ancestor
4. Falls back to `HEAD` if no default branch can be resolved (shows only uncommitted changes)

Untracked files (via `git ls-files --others --exclude-standard`) are also counted: their line counts are added to the insertion total. Binary files (non-UTF-8) are silently skipped. This means the statusline stats include new files that haven't been `git add`ed yet.
The footer git stats are intentionally scoped to uncommitted tracked-file
changes so the statusline stays compact in long-lived branches or repositories
with large histories. Untracked, non-ignored files render as a compact red `!`
alert instead of contributing line counts. The `/diff` command still produces a
PR-like diff when users ask for the full change context.

Two collection methods are provided:
- `collect_for_directory()` - Basic collection without first-message matching (test-only)
Expand Down
12 changes: 8 additions & 4 deletions nori-rs/tui/src/app/event_handling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -528,11 +528,15 @@ impl App {
| nori_protocol::ToolKind::Delete
| nori_protocol::ToolKind::Move
) {
let mut changes =
client_tool_cell::diff_changes_from_artifacts(&snapshot.artifacts);
let mut changes = client_tool_cell::diff_changes_from_artifacts(
&snapshot.artifacts,
&cwd,
);
if changes.is_empty() {
changes =
client_tool_cell::changes_from_invocation(&snapshot.invocation);
changes = client_tool_cell::changes_from_invocation(
&snapshot.invocation,
&cwd,
);
}
if changes.is_empty() {
None
Expand Down
12 changes: 8 additions & 4 deletions nori-rs/tui/src/bottom_pane/approval_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,11 +473,15 @@ impl From<ApprovalRequest> for ApprovalRequestState {

// For edit-like tools, try to render a DiffSummary from the snapshot
if is_edit_like {
let mut changes =
crate::client_tool_cell::diff_changes_from_artifacts(&snapshot.artifacts);
let mut changes = crate::client_tool_cell::diff_changes_from_artifacts(
&snapshot.artifacts,
&cwd,
);
if changes.is_empty() {
changes =
crate::client_tool_cell::changes_from_invocation(&snapshot.invocation);
changes = crate::client_tool_cell::changes_from_invocation(
&snapshot.invocation,
&cwd,
);
}
if !changes.is_empty() {
let header: Vec<Box<dyn Renderable>> =
Expand Down
Loading