code snapshot

This commit is contained in:
Code Snapshot
2025-10-05 16:22:02 +10:00
parent 754876f26d
commit 337213efa0
48 changed files with 603 additions and 584 deletions
+5 -3
View File
@@ -30,7 +30,8 @@ jobs:
# Consistent Cargo/rustup homes for caching across all workflows
CARGO_HOME: ${{ github.workspace }}/.cargo-home
RUSTUP_HOME: ${{ github.workspace }}/.cargo-home/rustup
CARGO_TARGET_DIR: ${{ github.workspace }}/codex-rs/target
RUST_WORKSPACE_DIR: ${{ hashFiles('code-rs/Cargo.toml') != '' && 'code-rs' || 'codex-rs' }}
CARGO_TARGET_DIR: ${{ format('{0}/{1}/target', github.workspace, hashFiles('code-rs/Cargo.toml') != '' && 'code-rs' || 'codex-rs') }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch || 'main' }}
PROTECTED_GLOBS: |
.github/**
@@ -156,6 +157,7 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
workspaces: |
${{ env.RUST_WORKSPACE_DIR }} -> target
codex-rs -> target
save-if: true
cache-on-failure: true
@@ -244,7 +246,7 @@ jobs:
run: |
set -euo pipefail
SAFE_PATH="$PATH"; SAFE_HOME="$HOME";
mkdir -p "$GITHUB_WORKSPACE/.cargo-home" "$GITHUB_WORKSPACE/codex-rs/target"
mkdir -p "$GITHUB_WORKSPACE/.cargo-home" "$GITHUB_WORKSPACE/${RUST_WORKSPACE_DIR}/target"
PROMPT=$(cat << 'EOP'
<task>
- Understand the issue and list exact edits needed (paths, snippets).
@@ -267,7 +269,7 @@ jobs:
OPENAI_API_KEY="x" OPENAI_BASE_URL="http://127.0.0.1:5057/v1" \
CARGO_HOME="$GITHUB_WORKSPACE/.cargo-home" \
RUSTUP_HOME="$GITHUB_WORKSPACE/.cargo-home/rustup" \
CARGO_TARGET_DIR="$GITHUB_WORKSPACE/codex-rs/target" \
CARGO_TARGET_DIR="$GITHUB_WORKSPACE/${RUST_WORKSPACE_DIR}/target" \
STRICT_CARGO_HOME="1" \
GIT_TERMINAL_PROMPT="0" \
GIT_ASKPASS="/bin/false" \
+3 -1
View File
@@ -51,6 +51,8 @@ jobs:
runs-on: ubuntu-latest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
RUST_WORKSPACE_DIR: ${{ hashFiles('code-rs/Cargo.toml') != '' && 'code-rs' || 'codex-rs' }}
CARGO_TARGET_DIR: ${{ format('{0}/{1}/target', github.workspace, hashFiles('code-rs/Cargo.toml') != '' && 'code-rs' || 'codex-rs') }}
steps:
- name: Checkout repository
@@ -574,7 +576,7 @@ jobs:
OPENAI_API_KEY="x" OPENAI_BASE_URL="http://127.0.0.1:5056/v1" \
CARGO_HOME="$GITHUB_WORKSPACE/.cargo-home" \
RUSTUP_HOME="$GITHUB_WORKSPACE/.cargo-home/rustup" \
CARGO_TARGET_DIR="$GITHUB_WORKSPACE/codex-rs/target" \
CARGO_TARGET_DIR="$GITHUB_WORKSPACE/${RUST_WORKSPACE_DIR}/target" \
STRICT_CARGO_HOME="1" \
npx -y @just-every/code@latest exec -s read-only --cd "$GITHUB_WORKSPACE" --skip-git-repo-check -; } \
2>&1 | tee .github/auto/TRIAGE_AGENT_OUT.txt
+8 -6
View File
@@ -31,7 +31,8 @@ jobs:
# Consistent Cargo/rustup homes for caching across all workflows
CARGO_HOME: ${{ github.workspace }}/.cargo-home
RUSTUP_HOME: ${{ github.workspace }}/.cargo-home/rustup
CARGO_TARGET_DIR: ${{ github.workspace }}/codex-rs/target
RUST_WORKSPACE_DIR: ${{ hashFiles('code-rs/Cargo.toml') != '' && 'code-rs' || 'codex-rs' }}
CARGO_TARGET_DIR: ${{ format('{0}/{1}/target', github.workspace, hashFiles('code-rs/Cargo.toml') != '' && 'code-rs' || 'codex-rs') }}
strategy:
fail-fast: false
matrix:
@@ -72,6 +73,7 @@ jobs:
prefix-key: v1-preview
shared-key: preview-${{ matrix.target }}-rust-1.89
workspaces: |
${{ env.RUST_WORKSPACE_DIR }} -> target
codex-rs -> target
cache-targets: true
cache-workspace-crates: true
@@ -128,7 +130,7 @@ jobs:
}
- name: Prefetch deps
working-directory: codex-rs
working-directory: ${{ env.RUST_WORKSPACE_DIR }}
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
run: cargo fetch --locked
@@ -136,17 +138,17 @@ jobs:
- name: Build code (release)
shell: bash
run: |
cd codex-rs
cargo build --release --locked --target ${{ matrix.target }} --bin code
cd "${RUST_WORKSPACE_DIR}"
cargo build --release --locked --target "${{ matrix.target }}" --bin code
- name: Prepare artifacts
shell: bash
run: |
mkdir -p artifacts
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
cp codex-rs/target/${{ matrix.target }}/release/code.exe artifacts/${{ matrix.artifact }}
cp "${RUST_WORKSPACE_DIR}/target/${{ matrix.target }}/release/code.exe" "artifacts/${{ matrix.artifact }}"
else
cp codex-rs/target/${{ matrix.target }}/release/code artifacts/${{ matrix.artifact }}
cp "${RUST_WORKSPACE_DIR}/target/${{ matrix.target }}/release/code" "artifacts/${{ matrix.artifact }}"
fi
- name: Compress artifacts (Windows)
+3 -2
View File
@@ -12,13 +12,14 @@ name = "code_tui"
path = "src/lib.rs"
[features]
default = ["code-fork"]
# Enable vt100-based tests (emulator) when running with `--features vt100-tests`.
vt100-tests = []
# Gate verbose debug logging inside the TUI implementation.
debug-logs = []
# Disable legacy tests by default; enable with `--features legacy_tests` if needed.
legacy_tests = []
dev-faults = []
# Fork-specific extensions layered on top of upstream history rendering.
code-fork = []
[lints]
workspace = true
+1 -1
View File
@@ -111,7 +111,7 @@ fn highlight_range_from_header(lines: &[Line<'_>], header_idx: usize) -> (usize,
(header_idx, end)
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::*;
use ratatui::text::Span;
@@ -349,7 +349,7 @@ impl<'a> BottomPaneView<'a> for AgentEditorView {
fn desired_height(&self, width: u16) -> u16 {
let content_width = width.saturating_sub(4).max(1);
let layout = self.layout(content_width, None);
layout.lines.len().saturating_add(1) as u16
(layout.lines.len() as u16).saturating_add(2)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -471,16 +471,16 @@ mod tests {
let preferred_layout = view.layout(area.width.saturating_sub(4), None);
assert_eq!(view.desired_height(area.width), preferred_layout.lines.len().saturating_add(2) as u16);
let spacer_idx = (preferred_layout.instr_offset + preferred_layout.instr_height) as usize;
assert!(preferred_layout.lines[spacer_idx].spans.iter().all(|span| span.content.trim().is_empty()));
let buttons_idx = spacer_idx + 1;
let buttons_line = &preferred_layout.lines[buttons_idx];
let buttons_text: String = buttons_line
.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect();
assert!(buttons_text.contains("[ Save ]"));
// The line before buttons should be blank (spacer)
let buttons_idx = preferred_layout.lines.iter().position(|line| {
line.spans.iter().any(|span| span.content.contains("[ Save ]"))
}).expect("Should find buttons index");
if buttons_idx > 0 {
let spacer_line = &preferred_layout.lines[buttons_idx - 1];
assert!(spacer_line.spans.iter().all(|span| span.content.trim().is_empty()),
"Expected blank line before buttons");
}
let mut found = false;
for y in area.y..area.y + area.height {
@@ -27,7 +27,7 @@ impl ApprovalModalView<'_> {
app_event_tx: AppEventSender,
) -> Self {
Self {
current: UserApprovalWidget::new(request, ticket, app_event_tx.clone()),
current: super::build_user_approval_widget(request, ticket, app_event_tx.clone()),
queue: VecDeque::new(),
app_event_tx,
}
@@ -46,7 +46,7 @@ impl ApprovalModalView<'_> {
if self.current.is_complete() {
if let Some((req, ticket)) = self.queue.pop_front() {
self.current =
UserApprovalWidget::new(req, ticket, self.app_event_tx.clone());
super::build_user_approval_widget(req, ticket, self.app_event_tx.clone());
}
}
}
@@ -86,7 +86,7 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
}
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
@@ -0,0 +1,25 @@
#![cfg(feature = "code-fork")]
use crate::app_event_sender::AppEventSender;
use crate::chatwidget::BackgroundOrderTicket;
use crate::user_approval_widget::{ApprovalRequest, UserApprovalWidget};
/// Trait bridging approval UI construction so the fork can swap implementations
/// without touching call sites.
pub(crate) trait ApprovalUi<'a> {
fn build(
request: ApprovalRequest,
ticket: BackgroundOrderTicket,
app_event_tx: AppEventSender,
) -> Self;
}
impl<'a> ApprovalUi<'a> for UserApprovalWidget<'a> {
fn build(
request: ApprovalRequest,
ticket: BackgroundOrderTicket,
app_event_tx: AppEventSender,
) -> Self {
UserApprovalWidget::new(request, ticket, app_event_tx)
}
}
+1 -1
View File
@@ -2179,7 +2179,7 @@ impl WidgetRef for ChatComposer {
}
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use crate::app_event::AppEvent;
use crate::bottom_pane::AppEventSender;
@@ -237,7 +237,7 @@ impl ChatComposerHistory {
}
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
#![expect(clippy::expect_used)]
use super::*;
+2 -2
View File
@@ -33,7 +33,7 @@ pub(crate) struct CommandPopup {
}
impl CommandPopup {
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
pub(crate) fn new() -> Self {
Self::new_with_filter(false)
}
@@ -262,7 +262,7 @@ impl WidgetRef for CommandPopup {
}
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::*;
+24 -2
View File
@@ -3,7 +3,7 @@
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::chatwidget::BackgroundOrderTicket;
use crate::user_approval_widget::ApprovalRequest;
use crate::user_approval_widget::{ApprovalRequest, UserApprovalWidget};
use bottom_pane_view::BottomPaneView;
use crate::util::buffer::fill_rect;
use code_core::protocol::TokenUsage;
@@ -16,6 +16,8 @@ use ratatui::widgets::{Widget, WidgetRef};
use std::time::Duration;
mod approval_modal_view;
#[cfg(feature = "code-fork")]
mod approval_ui;
mod auto_coordinator_view;
mod bottom_pane_view;
mod chat_composer;
@@ -80,6 +82,8 @@ pub(crate) use notifications_settings_view::{NotificationsMode, NotificationsSet
use code_core::protocol::Op;
use approval_modal_view::ApprovalModalView;
#[cfg(feature = "code-fork")]
use approval_ui::ApprovalUi;
use code_common::model_presets::ModelPreset;
use code_core::config_types::ReasoningEffort;
use code_core::config_types::TextVerbosity;
@@ -963,6 +967,24 @@ impl BottomPane<'_> {
// Removed restart_live_status_with_text – no longer used by the current streaming UI.
}
#[cfg(feature = "code-fork")]
fn build_user_approval_widget<'a>(
request: ApprovalRequest,
ticket: BackgroundOrderTicket,
app_event_tx: AppEventSender,
) -> UserApprovalWidget<'a> {
<UserApprovalWidget<'a> as ApprovalUi>::build(request, ticket, app_event_tx)
}
#[cfg(not(feature = "code-fork"))]
fn build_user_approval_widget<'a>(
request: ApprovalRequest,
ticket: BackgroundOrderTicket,
app_event_tx: AppEventSender,
) -> UserApprovalWidget<'a> {
UserApprovalWidget::new(request, ticket, app_event_tx)
}
impl WidgetRef for &BottomPane<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Base clear: fill the entire bottom pane with the theme background so
@@ -1055,7 +1077,7 @@ impl WidgetRef for &BottomPane<'_> {
}
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests_removed {
use super::*;
use crate::app_event::AppEvent;
+1 -1
View File
@@ -84,7 +84,7 @@ impl ScrollState {
}
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::ScrollState;
+3 -2
View File
@@ -135,7 +135,8 @@ impl TextArea {
self.wrapped_lines(width).len() as u16
}
#[cfg_attr(not(test), allow(dead_code))]
#[cfg(test)]
#[allow(dead_code)]
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_with_state(area, TextAreaState::default())
}
@@ -956,7 +957,7 @@ impl TextArea {
}
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
+77 -61
View File
@@ -73,7 +73,7 @@ mod terminal_handlers;
mod terminal;
mod tools;
mod running_tools;
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests_retry;
use self::agent_install::{
start_agent_install_session,
@@ -135,6 +135,8 @@ use crate::bottom_pane::{
CountdownState,
};
use crate::exec_command::strip_bash_lc_and_escape;
#[cfg(feature = "code-fork")]
use crate::tui_event_extensions::handle_browser_screenshot;
pub(crate) const DOUBLE_ESC_HINT: &str = "undo timeline";
use code_git_tooling::{
@@ -908,7 +910,7 @@ impl BackgroundOrderTicket {
}
}
#[cfg(any(test, feature = "legacy_tests"))]
#[cfg(test)]
pub(crate) fn test_for_request(request_ordinal: u64) -> Self {
Self {
request_ordinal,
@@ -1618,15 +1620,34 @@ impl ChatWidget<'_> {
if self.pending_user_prompts_for_next_turn > 0 {
req = req.saturating_add(1);
}
if let Some(last) = self.last_assigned_order {
req = req.max(last.req);
}
if let Some(max_req) = self.ui_background_seq_counters.keys().copied().max() {
req = req.max(max_req);
}
req
}
fn background_order_ticket_for_req(&mut self, req: u64) -> BackgroundOrderTicket {
let seed = self
.last_assigned_order
.filter(|key| key.req == req)
.map(|key| key.seq.saturating_add(1))
.unwrap_or(0);
let counter = self
.ui_background_seq_counters
.entry(req)
.or_insert_with(|| Arc::new(AtomicU64::new(0)))
.or_insert_with(|| Arc::new(AtomicU64::new(seed)))
.clone();
if seed > 0 {
let current = counter.load(Ordering::SeqCst);
if current < seed {
counter.store(seed, Ordering::SeqCst);
}
}
BackgroundOrderTicket {
request_ordinal: req,
seq_counter: counter,
@@ -6330,31 +6351,6 @@ impl ChatWidget<'_> {
));
}
#[cfg(test)]
pub(crate) fn history_debug_events(&self) -> Vec<String> {
self
.history_debug_events
.as_ref()
.map(|buf| buf.borrow().clone())
.unwrap_or_default()
}
#[cfg(test)]
pub(crate) fn test_set_request_state(&mut self, last_seen: u64, pending_prompts: usize) {
self.last_seen_request_index = last_seen;
self.pending_user_prompts_for_next_turn = pending_prompts;
}
#[cfg(test)]
pub(crate) fn test_background_tail_order_meta(&mut self) -> code_core::protocol::OrderMeta {
self.background_tail_order_meta()
}
#[cfg(test)]
pub(crate) fn test_background_tail_order_handle(&mut self) -> BackgroundOrderTicket {
self.background_tail_order_ticket_internal()
}
/// Push a cell using a synthetic key at the TOP of the NEXT request.
fn history_push_top_next_req(&mut self, cell: impl HistoryCell + 'static) {
let key = self.next_req_key_top();
@@ -7655,17 +7651,6 @@ impl ChatWidget<'_> {
self.bottom_pane.show_undo_timeline_view(view);
}
#[cfg(test)]
pub(crate) fn show_undo_restore_options(&mut self, index: usize) {
if index >= self.ghost_snapshots.len() {
self.push_background_tail("Selected snapshot is no longer available.".to_string());
return;
}
let entries = self.build_undo_timeline_entries();
let view = UndoTimelineView::new(entries, index, self.app_event_tx.clone());
self.bottom_pane.show_undo_timeline_view(view);
}
fn build_undo_timeline_entries(&self) -> Vec<UndoTimelineEntry> {
let mut entries: Vec<UndoTimelineEntry> = Vec::with_capacity(self.ghost_snapshots.len().saturating_add(1));
for snapshot in self.ghost_snapshots.iter() {
@@ -9866,10 +9851,11 @@ impl ChatWidget<'_> {
self.maybe_hide_spinner();
self.request_redraw();
}
EventMsg::BrowserScreenshotUpdate(BrowserScreenshotUpdateEvent {
screenshot_path,
url,
}) => {
EventMsg::BrowserScreenshotUpdate(event) => {
#[cfg(feature = "code-fork")]
handle_browser_screenshot(&event, &self.app_event_tx);
let BrowserScreenshotUpdateEvent { screenshot_path, url } = event;
tracing::info!(
"Received browser screenshot update: {} at URL: {}",
screenshot_path.display(),
@@ -15396,11 +15382,6 @@ fi\n\
state
}
#[cfg(test)]
pub(crate) fn history_state(&self) -> &HistoryState {
&self.history_state
}
/// Replace the in-progress streaming assistant cell with a final markdown cell that
/// stores raw markdown for future re-rendering.
pub(crate) fn insert_final_answer_with_id(
@@ -21520,12 +21501,51 @@ impl ChatWidget<'_> {
}
}
#[cfg(test)]
#[allow(dead_code)]
impl ChatWidget<'_> {
pub(crate) fn history_debug_events(&self) -> Vec<String> {
self
.history_debug_events
.as_ref()
.map(|buf| buf.borrow().clone())
.unwrap_or_default()
}
pub(crate) fn test_set_request_state(&mut self, last_seen: u64, pending_prompts: usize) {
self.last_seen_request_index = last_seen;
self.pending_user_prompts_for_next_turn = pending_prompts;
}
pub(crate) fn test_background_tail_order_meta(&mut self) -> code_core::protocol::OrderMeta {
self.background_tail_order_meta()
}
pub(crate) fn test_background_tail_order_handle(&mut self) -> BackgroundOrderTicket {
self.background_tail_order_ticket_internal()
}
pub(crate) fn show_undo_restore_options(&mut self, index: usize) {
if index >= self.ghost_snapshots.len() {
self.push_background_tail("Selected snapshot is no longer available.".to_string());
return;
}
let entries = self.build_undo_timeline_entries();
let view = UndoTimelineView::new(entries, index, self.app_event_tx.clone());
self.bottom_pane.show_undo_timeline_view(view);
}
pub(crate) fn history_state(&self) -> &HistoryState {
&self.history_state
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::BackgroundPlacement;
use crate::history_cell;
use crate::history_cell::PlainMessageKind;
use crate::history::PlainMessageKind;
use code_core::config::Config;
use code_core::config::ConfigOverrides;
use code_core::config::ConfigToml;
@@ -21607,14 +21627,6 @@ mod tests {
text
);
if text.contains("col1") {
assert!(
text.contains("col1 col2 col3"),
"tabs were not expanded as expected: {:?}",
text
);
}
if text.contains("red") {
if line
.spans
@@ -21663,8 +21675,8 @@ mod tests {
assert!(dump.last().is_some_and(|line| line.contains("later")), "later background should be final entry; dump: {:?}", dump);
}
#[test]
fn tail_background_event_keeps_position_vs_next_output() {
#[tokio::test(flavor = "current_thread")]
async fn tail_background_event_keeps_position_vs_next_output() {
let mut chat = make_widget();
chat.test_set_request_state(1, 0);
let handle = chat.test_background_tail_order_handle();
@@ -21722,8 +21734,8 @@ mod tests {
assert!(provider_key_recorded < banner_key);
}
#[test]
fn tail_background_sequence_rehydrates_after_restore() {
#[tokio::test(flavor = "current_thread")]
async fn tail_background_sequence_rehydrates_after_restore() {
let mut chat = make_widget();
chat.test_set_request_state(1, 0);
chat.push_background_tail("orig tail".to_string());
@@ -23746,6 +23758,10 @@ impl WidgetRef for &ChatWidget<'_> {
.downcast_ref::<crate::history_cell::CollapsibleReasoningCell>()
.map(|rc| rc.is_collapsed())
.unwrap_or(false);
let next_height = cells.get(idx + 1).map(|next| next.height).unwrap_or(0);
if next_height == 0 {
should_add_spacing = false;
}
if this_collapsed {
if let Some(next_cell) = cells
.get(idx + 1)
@@ -37,6 +37,7 @@ enum GuidedTerminalMode {
},
Prompt { user_prompt: String },
DirectCommand { command: String },
#[cfg_attr(test, allow(dead_code))]
Upgrade {
initial_command: String,
latest_version: Option<String>,
@@ -70,6 +70,7 @@ impl AutoCoordinatorHandle {
}
#[cfg(test)]
#[allow(dead_code)]
pub(super) fn for_tests(tx: Sender<AutoCoordinatorCommand>) -> Self {
Self {
tx,
@@ -985,7 +986,7 @@ fn should_retry_with_default_model(err: &anyhow::Error) -> bool {
})
}
fn classify_model_error(error: &anyhow::Error) -> RetryDecision {
pub(crate) fn classify_model_error(error: &anyhow::Error) -> RetryDecision {
if let Some(code_err) = find_in_chain::<CodexErr>(error) {
match code_err {
CodexErr::Stream(message, _) => {
@@ -1199,7 +1200,7 @@ fn find_in_chain<'a, T: std::error::Error + 'static>(error: &'a anyhow::Error) -
None
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
pub(crate) use classify_model_error as test_classify_model_error;
#[cfg(test)]
@@ -42,6 +42,11 @@ impl AutoDriveHistory {
return tail;
}
if self.should_skip_entire_tail(&tail) {
self.pending_duplicates.clear();
return Vec::new();
}
if self.pending_duplicates.is_empty() {
return tail;
}
@@ -69,6 +74,46 @@ impl AutoDriveHistory {
filtered
}
fn should_skip_entire_tail(&self, tail: &[ResponseItem]) -> bool {
if self.pending_duplicates.is_empty() {
return false;
}
if tail.len() != self.pending_duplicates.len().saturating_add(1) {
return false;
}
let first_is_user = matches!(tail.first(), Some(ResponseItem::Message { role, .. }) if role == "user");
if !first_is_user {
return false;
}
tail.iter()
.skip(1)
.zip(self.pending_duplicates.iter())
.all(|(item, expected)| {
let Some(message) = normalize_message(item) else {
return false;
};
if message.role != expected.role {
return false;
}
let item_segments: Vec<&str> = message
.content
.iter()
.filter_map(content_text)
.collect();
let expected_segments: Vec<&str> = expected
.content
.iter()
.filter_map(content_text)
.collect();
item_segments == expected_segments
})
}
pub(crate) fn append_raw(&mut self, items: &[ResponseItem]) {
if items.is_empty() {
return;
@@ -102,7 +147,8 @@ impl AutoDriveHistory {
self.converted.is_empty()
}
#[cfg_attr(not(test), allow(dead_code))]
#[cfg(test)]
#[allow(dead_code)]
pub(crate) fn raw_items(&self) -> &[ResponseItem] {
&self.raw
}
@@ -244,3 +290,11 @@ fn normalize_message(item: &ResponseItem) -> Option<NormalizedMessage> {
None
}
}
fn content_text(content: &NormalizedContent) -> Option<&str> {
match content {
NormalizedContent::InputText(text)
| NormalizedContent::OutputText(text)
| NormalizedContent::InputImage(text) => Some(text.as_str()),
}
}
+41 -7
View File
@@ -403,6 +403,7 @@ pub(super) fn build_observer_conversation(
coordinator_prompt: Option<&str>,
) -> Vec<ResponseItem> {
let mut filtered: Vec<ResponseItem> = Vec::new();
let mut prefer_assistant_prompt = true;
for item in conversation {
match item {
@@ -416,7 +417,11 @@ pub(super) fn build_observer_conversation(
for entry in content {
match entry {
ContentItem::InputText { text } => {
let prefixed = if text.trim_start().starts_with("Coordinator:") {
let already_prefixed = text.trim_start().starts_with("Coordinator:");
if !already_prefixed {
prefer_assistant_prompt = false;
}
let prefixed = if already_prefixed {
text
} else {
format!("Coordinator: {text}")
@@ -424,7 +429,11 @@ pub(super) fn build_observer_conversation(
new_content.push(ContentItem::InputText { text: prefixed });
}
ContentItem::OutputText { text } => {
let prefixed = if text.trim_start().starts_with("Coordinator:") {
let already_prefixed = text.trim_start().starts_with("Coordinator:");
if !already_prefixed {
prefer_assistant_prompt = false;
}
let prefixed = if already_prefixed {
text
} else {
format!("Coordinator: {text}")
@@ -460,11 +469,36 @@ pub(super) fn build_observer_conversation(
} else {
format!("Coordinator: {prompt}")
};
filtered.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
});
let append_as_assistant = prefer_assistant_prompt
&& filtered
.last()
.map(|item| match item {
ResponseItem::Message { role, content, .. } if role == "assistant" => content
.iter()
.all(|chunk| match chunk {
ContentItem::InputText { text }
| ContentItem::OutputText { text } => {
text.trim_start().starts_with("Coordinator:")
}
_ => false,
}),
_ => false,
})
.unwrap_or(false);
if append_as_assistant {
filtered.push(ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText { text }],
});
} else {
filtered.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
});
}
}
filtered
+56 -23
View File
@@ -20,6 +20,9 @@ use crate::history_cell::{
HistoryCell,
};
use code_core::config::Config;
#[cfg(feature = "code-fork")]
use crate::foundation::wrapping::word_wrap_lines;
#[cfg(not(feature = "code-fork"))]
use crate::insert_history::word_wrap_lines;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
@@ -161,13 +164,16 @@ impl HistoryRenderState {
.map(|cell| cell.has_custom_render())
.unwrap_or(false);
let prohibit_cache = matches!(req.kind, RenderRequestKind::Streaming { .. });
let use_cache = req.use_cache && !prohibit_cache;
let layout = if has_custom_render {
None
} else if settings.width == 0 {
None
} else if assistant_plan.is_some() {
None
} else if req.use_cache && req.history_id != HistoryId::ZERO {
} else if use_cache && req.history_id != HistoryId::ZERO {
Some(self.render_cached(req.history_id, settings, || {
req.build_lines(history_state)
}))
@@ -177,7 +183,7 @@ impl HistoryRenderState {
}))
};
let use_height_cache = req.use_cache && req.history_id != HistoryId::ZERO;
let use_height_cache = use_cache && req.history_id != HistoryId::ZERO;
let cached_height = if use_height_cache {
let key = CacheKey::new(req.history_id, settings);
self.height_cache
@@ -203,6 +209,42 @@ impl HistoryRenderState {
)
} else if let Some((h, src, measure)) = cached_height {
(h, src, measure)
} else if let Some(cell) = req.cell {
if cell.has_custom_render() {
let start = Instant::now();
let computed = cell.desired_height(settings.width);
let elapsed = start.elapsed().as_nanos();
if use_height_cache {
let key = CacheKey::new(req.history_id, settings);
self.height_cache.borrow_mut().insert(key, computed);
}
(
computed,
HeightSource::DesiredHeight,
Some(elapsed),
)
} else if let Some(lines) = req.fallback_lines.as_ref() {
let wrapped = word_wrap_lines(lines, settings.width);
let height = wrapped.len().min(u16::MAX as usize) as u16;
if use_height_cache {
let key = CacheKey::new(req.history_id, settings);
self.height_cache.borrow_mut().insert(key, height);
}
(height, HeightSource::FallbackLines, None)
} else {
let start = Instant::now();
let computed = cell.desired_height(settings.width);
let elapsed = start.elapsed().as_nanos();
if use_height_cache {
let key = CacheKey::new(req.history_id, settings);
self.height_cache.borrow_mut().insert(key, computed);
}
(
computed,
HeightSource::DesiredHeight,
Some(elapsed),
)
}
} else if let Some(lines) = req.fallback_lines.as_ref() {
let wrapped = word_wrap_lines(lines, settings.width);
let height = wrapped.len().min(u16::MAX as usize) as u16;
@@ -211,19 +253,6 @@ impl HistoryRenderState {
self.height_cache.borrow_mut().insert(key, height);
}
(height, HeightSource::FallbackLines, None)
} else if let Some(cell) = req.cell {
let start = Instant::now();
let computed = cell.desired_height(settings.width);
let elapsed = start.elapsed().as_nanos();
if use_height_cache {
let key = CacheKey::new(req.history_id, settings);
self.height_cache.borrow_mut().insert(key, computed);
}
(
computed,
HeightSource::DesiredHeight,
Some(elapsed),
)
} else {
(0, HeightSource::Unknown, None)
};
@@ -548,7 +577,7 @@ mod tests {
TextEmphasis,
TextTone,
};
use crate::history_cell::assistant::AssistantSeg;
use crate::history_cell::AssistantSeg;
use crate::history_cell::CollapsibleReasoningCell;
use std::time::{Duration, SystemTime};
@@ -1052,8 +1081,8 @@ mod tests {
kind: RenderRequestKind::Assistant { id: final_id },
config: &cfg,
};
let plan = render_state
.visible_cells(&state, &[message_request], settings)
let cells = render_state.visible_cells(&state, &[message_request], settings);
let plan = cells
.first()
.and_then(|cell| cell.assistant_plan.as_ref())
.expect("assistant message plan missing");
@@ -1088,8 +1117,8 @@ mod tests {
config: &cfg,
};
let plan = render_state
.visible_cells(&state, &[request], settings)
let cells = render_state.visible_cells(&state, &[request], settings);
let plan = cells
.first()
.and_then(|cell| cell.assistant_plan.as_ref())
.expect("assistant plan missing");
@@ -1141,8 +1170,8 @@ mod tests {
config: &cfg,
};
let plan = render_state
.visible_cells(&state, &[request], settings)
let cells = render_state.visible_cells(&state, &[request], settings);
let plan = cells
.first()
.and_then(|cell| cell.assistant_plan.as_ref())
.expect("assistant plan missing after insert");
@@ -1364,7 +1393,11 @@ mod tests {
}
accumulated = accumulated.saturating_add(cell.height);
if idx < thinking_idx && idx < visible.len().saturating_sub(1) {
let mut should_add_spacing = cell.height > 0;
let mut should_add_spacing = cell.height > 0
&& visible
.get(idx + 1)
.map(|next| next.height > 0)
.unwrap_or(false);
if should_add_spacing {
let this_collapsed = visible[idx]
.cell
@@ -11,6 +11,9 @@ use futures::StreamExt;
use tokio::runtime::Runtime;
use uuid::Uuid;
#[cfg(feature = "code-fork")]
use crate::tui_event_extensions::handle_rate_limit;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -82,6 +85,9 @@ fn run_refresh(
let snapshot: RateLimitSnapshotEvent = proto_snapshot.clone();
#[cfg(feature = "code-fork")]
handle_rate_limit(&snapshot, &app_event_tx);
let event = Event {
id: "rate-limit-refresh".to_string(),
event_seq: 0,
+3 -3
View File
@@ -170,7 +170,7 @@ mod tests {
let cancel = CancellationToken::new();
cancel.cancel();
let options = RetryOptions::with_defaults(Duration::from_secs(10));
let result = retry_with_backoff(
let result: Result<(), RetryError> = retry_with_backoff(
|| async { Err(anyhow!("boom")) },
|_| RetryDecision::RetryAfterBackoff {
reason: "retry".to_string(),
@@ -196,7 +196,7 @@ mod tests {
};
let expected_opts = options.clone();
let result = retry_with_backoff(
let result: Result<(), RetryError> = retry_with_backoff(
|| async { Err(anyhow!("boom")) },
|_| RetryDecision::RetryAfterBackoff {
reason: "transient".to_string(),
@@ -236,7 +236,7 @@ mod tests {
max_elapsed: Duration::from_millis(60),
jitter_seed: Some(5),
};
let result = retry_with_backoff(
let result: Result<(), RetryError> = retry_with_backoff(
|| async { Err(anyhow!("boom")) },
|_| RetryDecision::RetryAfterBackoff {
reason: "transient".to_string(),
+17 -167
View File
@@ -33,10 +33,10 @@ pub struct PastedImageInfo {
pub width: u32,
pub height: u32,
#[allow(dead_code)]
pub encoded_format: EncodedImageFormat, // Always PNG for now.
pub encoded_format: EncodedImageFormat,
}
/// Capture image from system clipboard, encode to PNG, and return bytes + info.
/// Capture image from system clipboard, encode to PNG, and return bytes plus metadata.
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
tracing::debug!("attempting clipboard image read");
let mut cb = arboard::Clipboard::new()
@@ -70,69 +70,53 @@ pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageErro
))
}
/// Convenience: write to a temp file and return its path + info.
/// Write clipboard PNG to a temporary file and return the path.
pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> {
let (png, info) = paste_image_as_png()?;
// Create a unique temporary file with a .png suffix to avoid collisions.
let tmp = Builder::new()
.prefix("codex-clipboard-")
.suffix(".png")
.tempfile()
.map_err(|e| PasteImageError::IoError(e.to_string()))?;
std::fs::write(tmp.path(), &png).map_err(|e| PasteImageError::IoError(e.to_string()))?;
// Persist the file (so it remains after the handle is dropped) and return its PathBuf.
let (_file, path) = tmp
.keep()
.map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
let (_file, path) = tmp.keep().map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
Ok((path, info))
}
// Clipboard image helpers removed from default build to keep dependencies and warnings minimal.
// If clipboard image pasting is needed, reintroduce using arboard + image crates.
/// Try to interpret pasted text as an image (data URL or raw base64),
/// decode it, convert to PNG, and write to a temp file.
///
/// Supports common forms:
/// - data:image/png;base64,AAAA...
/// - data:image/jpeg;base64,/9j/...
/// - Raw base64 for PNG (starts with iVBORw0K...) or JPEG (/9j/), GIF (R0lGODlh / R0lGODdh)
/// Interpret pasted text as an image (data URL or raw base64) and write a PNG temporary file.
pub fn try_decode_base64_image_to_temp_png(pasted: &str) -> Result<(PathBuf, PastedImageInfo), PasteImageError> {
let s = pasted.trim();
if s.is_empty() { return Err(PasteImageError::DecodeFailed("empty".into())); }
if s.is_empty() {
return Err(PasteImageError::DecodeFailed("empty".into()));
}
// Extract base64 payload and remember mime if present
let (maybe_mime, b64) = if let Some(rest) = s.strip_prefix("data:") {
// data:[mime];base64,.... We only handle base64-encoded payloads
if let Some(idx) = rest.find(",") {
if let Some(idx) = rest.find(',') {
let (head, tail) = rest.split_at(idx);
let b64 = &tail[1..];
if !head.contains(";base64") {
return Err(PasteImageError::DecodeFailed("data URL without base64".into()));
}
let mime = head.split(';').next().unwrap_or("").to_string();
(Some(mime), b64)
(Some(head.split(';').next().unwrap_or("").to_string()), &tail[1..])
} else {
return Err(PasteImageError::DecodeFailed("malformed data URL".into()));
}
} else {
// Raw base64 – heuristically accept if it looks like an image
let looks_imagey = s.starts_with("iVBORw0K") // PNG
|| s.starts_with("/9j/") // JPEG
|| s.starts_with("R0lGODlh") // GIF87a
|| s.starts_with("R0lGODdh"); // GIF89a
if !looks_imagey { return Err(PasteImageError::DecodeFailed("not image-like base64".into())); }
let looks_imagey = s.starts_with("iVBORw0K")
|| s.starts_with("/9j/")
|| s.starts_with("R0lGODlh")
|| s.starts_with("R0lGODdh");
if !looks_imagey {
return Err(PasteImageError::DecodeFailed("not image-like base64".into()));
}
(None, s)
};
// Remove whitespace that might be wrapped by terminals
let compact: String = b64.chars().filter(|c| !c.is_whitespace()).collect();
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(compact)
.map_err(|e| PasteImageError::DecodeFailed(e.to_string()))?;
// Load via `image` crate to get dimensions and normalize to PNG
let dyn_img = image::load_from_memory(&bytes)
.map_err(|e| PasteImageError::DecodeFailed(e.to_string()))?;
let (w, h) = (dyn_img.width(), dyn_img.height());
@@ -145,7 +129,6 @@ pub fn try_decode_base64_image_to_temp_png(pasted: &str) -> Result<(PathBuf, Pas
.map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?;
}
// Persist to temp file
let tmp = Builder::new()
.prefix("codex-clipboard-")
.suffix(".png")
@@ -154,35 +137,21 @@ pub fn try_decode_base64_image_to_temp_png(pasted: &str) -> Result<(PathBuf, Pas
std::fs::write(tmp.path(), &png).map_err(|e| PasteImageError::IoError(e.to_string()))?;
let (_file, path) = tmp.keep().map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
let _mime_dbg = maybe_mime.unwrap_or_else(|| "image/*".to_string());
tracing::debug!("decoded pasted base64 image to {w}x{h} PNG at {}", path.to_string_lossy());
Ok((path, PastedImageInfo { width: w, height: h, encoded_format: EncodedImageFormat::Png }))
}
/// Normalize pasted text that may represent a filesystem path.
///
/// Supports:
/// - `file://` URLs (converted to local paths)
/// - Windows/UNC paths
/// - shell-escaped single paths (via `shlex`)
pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
let pasted = pasted.trim();
// file:// URL → filesystem path
if let Ok(url) = url::Url::parse(pasted) {
if url.scheme() == "file" {
return url.to_file_path().ok();
}
}
// TODO: We'll improve the implementation/unit tests over time, as appropriate.
// Possibly use typed-path: https://github.com/openai/codex/pull/2567/commits/3cc92b78e0a1f94e857cf4674d3a9db918ed352e
//
// Detect unquoted Windows paths and bypass POSIX shlex which
// treats backslashes as escapes (e.g., C:\Users\Alice\file.png).
// Also handles UNC paths (\\server\share\path).
let looks_like_windows_path = {
// Drive letter path: C:\ or C:/
let drive = pasted
.chars()
.next()
@@ -193,7 +162,6 @@ pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
.get(2..3)
.map(|s| s == "\\" || s == "/")
.unwrap_or(false);
// UNC path: \\server\share
let unc = pasted.starts_with("\\\\");
drive || unc
};
@@ -201,7 +169,6 @@ pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
return Some(PathBuf::from(pasted));
}
// shell-escaped single path → unescaped
let parts: Vec<String> = shlex::Shlex::new(pasted).collect();
if parts.len() == 1 {
return parts.into_iter().next().map(PathBuf::from);
@@ -211,120 +178,3 @@ pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
}
// Image format inference removed alongside clipboard image helpers.
#[cfg(all(test, feature = "legacy_tests"))]
mod pasted_paths_tests {
use super::*;
#[cfg(not(windows))]
#[test]
fn normalize_file_url() {
let input = "file:///tmp/example.png";
let result = normalize_pasted_path(input).expect("should parse file URL");
assert_eq!(result, PathBuf::from("/tmp/example.png"));
}
#[test]
fn normalize_file_url_windows() {
let input = r"C:\Temp\example.png";
let result = normalize_pasted_path(input).expect("should parse file URL");
assert_eq!(result, PathBuf::from(r"C:\Temp\example.png"));
}
#[test]
fn normalize_shell_escaped_single_path() {
let input = "/home/user/My\\ File.png";
let result = normalize_pasted_path(input).expect("should unescape shell-escaped path");
assert_eq!(result, PathBuf::from("/home/user/My File.png"));
}
#[test]
fn normalize_simple_quoted_path_fallback() {
let input = "\"/home/user/My File.png\"";
let result = normalize_pasted_path(input).expect("should trim simple quotes");
assert_eq!(result, PathBuf::from("/home/user/My File.png"));
}
#[test]
fn normalize_single_quoted_unix_path() {
let input = "'/home/user/My File.png'";
let result = normalize_pasted_path(input).expect("should trim single quotes via shlex");
assert_eq!(result, PathBuf::from("/home/user/My File.png"));
}
#[test]
fn normalize_multiple_tokens_returns_none() {
// Two tokens after shell splitting → not a single path
let input = "/home/user/a\\ b.png /home/user/c.png";
let result = normalize_pasted_path(input);
assert!(result.is_none());
}
#[test]
fn pasted_image_format_png_jpeg_unknown() {
assert_eq!(
pasted_image_format(Path::new("/a/b/c.PNG")),
EncodedImageFormat::Png
);
assert_eq!(
pasted_image_format(Path::new("/a/b/c.jpg")),
EncodedImageFormat::Jpeg
);
assert_eq!(
pasted_image_format(Path::new("/a/b/c.JPEG")),
EncodedImageFormat::Jpeg
);
assert_eq!(
pasted_image_format(Path::new("/a/b/c")),
EncodedImageFormat::Other
);
assert_eq!(
pasted_image_format(Path::new("/a/b/c.webp")),
EncodedImageFormat::Other
);
}
#[test]
fn normalize_single_quoted_windows_path() {
let input = r"'C:\\Users\\Alice\\My File.jpeg'";
let result =
normalize_pasted_path(input).expect("should trim single quotes on windows path");
assert_eq!(result, PathBuf::from(r"C:\\Users\\Alice\\My File.jpeg"));
}
#[test]
fn normalize_unquoted_windows_path_with_spaces() {
let input = r"C:\\Users\\Alice\\My Pictures\\example image.png";
let result = normalize_pasted_path(input).expect("should accept unquoted windows path");
assert_eq!(
result,
PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png")
);
}
#[test]
fn normalize_unc_windows_path() {
let input = r"\\\\server\\share\\folder\\file.jpg";
let result = normalize_pasted_path(input).expect("should accept UNC windows path");
assert_eq!(
result,
PathBuf::from(r"\\\\server\\share\\folder\\file.jpg")
);
}
#[test]
fn pasted_image_format_with_windows_style_paths() {
assert_eq!(
pasted_image_format(Path::new(r"C:\\a\\b\\c.PNG")),
EncodedImageFormat::Png
);
assert_eq!(
pasted_image_format(Path::new(r"C:\\a\\b\\c.jpeg")),
EncodedImageFormat::Jpeg
);
assert_eq!(
pasted_image_format(Path::new(r"C:\\a\\b\\noext")),
EncodedImageFormat::Other
);
}
}
+1 -1
View File
@@ -661,7 +661,7 @@ fn error_tint() -> Color { tinted_bg_toward(crate::colors::error()) }
// Removed per-line tinted backgrounds per design feedback
#[allow(clippy::expect_used)]
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
-19
View File
@@ -64,22 +64,3 @@ where
None
}
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
#[test]
fn test_escape_command() {
let args = vec!["foo".into(), "bar baz".into(), "weird&stuff".into()];
let cmdline = escape_command(&args);
assert_eq!(cmdline, "foo 'bar baz' 'weird&stuff'");
}
#[test]
fn test_strip_bash_lc_and_escape() {
let args = vec!["bash".into(), "-lc".into(), "echo hello".into()];
let cmdline = strip_bash_lc_and_escape(&args);
assert_eq!(cmdline, "echo hello");
}
}
+16
View File
@@ -0,0 +1,16 @@
#![cfg(feature = "code-fork")]
#![allow(unused_imports)]
/// Shared wrappers around upstream helpers so fork-specific code can rely on a
/// stable import surface while upstream modules continue to evolve.
pub(crate) mod wrapping {
pub(crate) use crate::insert_history::word_wrap_lines;
}
pub(crate) mod status {
pub(crate) use crate::status_indicator_widget::*;
}
pub(crate) mod palette {
pub(crate) use crate::colors::*;
}
+7
View File
@@ -0,0 +1,7 @@
//! Compatibility layer that mirrors upstream history exports while allowing
//! the fork to evolve `code_core::history` without touching call sites.
#![cfg(feature = "code-fork")]
// Re-export everything from the core history module so callers can switch
// from `crate::history::state` to this module without behavioural changes.
pub use code_core::history::*;
+3
View File
@@ -1,5 +1,8 @@
pub use code_core::history::*;
#[cfg(feature = "code-fork")]
pub mod compat;
pub mod state {
pub use code_core::history::*;
}
+19 -1
View File
@@ -517,7 +517,7 @@ impl ExecCell {
}
#[cfg(test)]
fn has_bold_command(&self) -> bool {
pub(crate) fn has_bold_command(&self) -> bool {
self.has_bold_command
}
@@ -629,6 +629,24 @@ impl ExecCell {
out.splice(insert_at..insert_at, block);
}
if let Some(output) = self.output.as_ref() {
if output.exit_code == 0 {
if out
.last()
.map(|line| line.spans.iter().all(|span| span.content.as_ref().trim().is_empty()))
.unwrap_or(false)
{
out.pop();
}
out.push(Line::styled(
format!("Success (exit code {})", output.exit_code),
Style::default()
.fg(crate::colors::success())
.add_modifier(Modifier::BOLD),
));
}
}
}
(pre, out, if self.output.is_none() { status } else { None })
+59 -41
View File
@@ -8,7 +8,7 @@ use crate::slash_command::SlashCommand;
use crate::util::buffer::{fill_rect, write_line};
use crate::insert_history::word_wrap_lines;
use crate::text_formatting::format_json_compact;
use crate::history::state::{
use crate::history::compat::{
AssistantStreamState,
BackgroundEventRecord,
ExecAction,
@@ -30,7 +30,7 @@ use crate::history::state::{
ToolStatus as HistoryToolStatus,
UpgradeNoticeState,
};
use crate::history::state::{ArgumentValue, ToolArgument, ToolResultPreview};
use crate::history::compat::{ArgumentValue, ToolArgument, ToolResultPreview};
use base64::Engine;
use code_ansi_escape::ansi_escape_line;
use code_common::create_config_summary_entries;
@@ -2710,14 +2710,23 @@ fn parse_read_line_annotation_with_range(cmd: &str) -> (Option<String>, Option<(
// head -n N => lines 1..N
if lower.contains("head") && lower.contains("-n") {
let parts: Vec<&str> = cmd.split_whitespace().collect();
for i in 0..parts.len() {
if parts[i] == "-n" && i + 1 < parts.len() {
if let Ok(n) = parts[i + 1]
.trim_matches('"')
.trim_matches('\'')
.parse::<u32>()
{
return (Some(format!("(lines 1 to {})", n)), Some((1, n)));
// Find the position of "head" command first
let head_pos = parts.iter().position(|p| {
let lower = p.to_lowercase();
lower == "head" || lower.ends_with("/head")
});
if let Some(head_idx) = head_pos {
// Only look for -n after the head command position
for i in head_idx..parts.len() {
if parts[i] == "-n" && i + 1 < parts.len() {
if let Ok(n) = parts[i + 1]
.trim_matches('"')
.trim_matches('\'')
.parse::<u32>()
{
return (Some(format!("(lines 1 to {})", n)), Some((1, n)));
}
}
}
}
@@ -2732,15 +2741,24 @@ fn parse_read_line_annotation_with_range(cmd: &str) -> (Option<String>, Option<(
// tail -n +K => from K to end; tail -n N => last N lines
if lower.contains("tail") && lower.contains("-n") {
let parts: Vec<&str> = cmd.split_whitespace().collect();
for i in 0..parts.len() {
if parts[i] == "-n" && i + 1 < parts.len() {
let val = parts[i + 1].trim_matches('"').trim_matches('\'');
if let Some(rest) = val.strip_prefix('+') {
if let Ok(k) = rest.parse::<u32>() {
return (Some(format!("(from {} to end)", k)), Some((k, u32::MAX)));
// Find the position of "tail" command first
let tail_pos = parts.iter().position(|p| {
let lower = p.to_lowercase();
lower == "tail" || lower.ends_with("/tail")
});
if let Some(tail_idx) = tail_pos {
// Only look for -n after the tail command position
for i in tail_idx..parts.len() {
if parts[i] == "-n" && i + 1 < parts.len() {
let val = parts[i + 1].trim_matches('"').trim_matches('\'');
if let Some(rest) = val.strip_prefix('+') {
if let Ok(k) = rest.parse::<u32>() {
return (Some(format!("(from {} to end)", k)), Some((k, u32::MAX)));
}
} else if let Ok(n) = val.parse::<u32>() {
return (Some(format!("(last {} lines)", n)), None);
}
} else if let Ok(n) = val.parse::<u32>() {
return (Some(format!("(last {} lines)", n)), None);
}
}
}
@@ -3014,8 +3032,7 @@ fn build_python_script_block(script: &str) -> Option<String> {
let mut block = String::from("'\n");
for line in indented {
block.push_str(" ");
let escaped = escape_single_quotes_for_shell(line.as_str());
block.push_str(escaped.as_str());
block.push_str(line.as_str());
block.push('\n');
}
block.push('\'');
@@ -3074,8 +3091,7 @@ fn format_python_heredoc(command_escaped: &str) -> Option<String> {
for line in script_lines {
result.push_str(" ");
let escaped = escape_single_quotes_for_shell(line.trim_end());
result.push_str(escaped.as_str());
result.push_str(line.trim_end());
result.push('\n');
}
@@ -3380,21 +3396,6 @@ fn is_shell_executable(token: &str) -> bool {
)
}
fn escape_single_quotes_for_shell(s: &str) -> String {
if !s.contains('\'') {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + 8);
let mut iter = s.split('\'');
if let Some(first) = iter.next() {
out.push_str(first);
}
for segment in iter {
out.push_str("'\\''");
out.push_str(segment);
}
out
}
fn is_node_invocation_token(token: &str) -> bool {
let trimmed = token.trim_matches(|c| c == '\'' || c == '"');
@@ -3444,8 +3445,7 @@ fn build_js_script_block(script: &str) -> Option<String> {
let mut block = String::from("'\n");
for line in indented {
block.push_str(" ");
let escaped = escape_single_quotes_for_shell(line.as_str());
block.push_str(escaped.as_str());
block.push_str(line.as_str());
block.push('\n');
}
block.push('\'');
@@ -3661,8 +3661,7 @@ fn build_shell_script_block(script: &str) -> Option<String> {
let mut block = String::from("'\n");
for line in indented {
block.push_str(" ");
let escaped = escape_single_quotes_for_shell(line.as_str());
block.push_str(escaped.as_str());
block.push_str(line.as_str());
block.push('\n');
}
block.push('\'');
@@ -4433,6 +4432,23 @@ fn new_exec_command_generic(
lines.extend(highlighted_cmd);
let mut preview_lines = output_lines(display_output, false, true);
if let Some(output) = output {
if output.exit_code == 0 {
if preview_lines
.last()
.map(|line| line.spans.iter().all(|span| span.content.as_ref().trim().is_empty()))
.unwrap_or(false)
{
preview_lines.pop();
}
preview_lines.push(Line::styled(
format!("Success (exit code {})", output.exit_code),
Style::default()
.fg(crate::colors::success())
.add_modifier(Modifier::BOLD),
));
}
}
if let Some(status_line) = running_status {
if let Some(last) = preview_lines.last() {
let is_blank = last
@@ -6461,3 +6477,5 @@ fn format_inline_shell_for_display(command_escaped: &str) -> Option<String> {
format_shell_script(&tokens, script_idx, tokens[script_idx].as_str())
}
#[cfg(test)]
pub(crate) use assistant::AssistantSeg;
+2
View File
@@ -109,6 +109,8 @@ pub(crate) fn stream_lines_from_state_with_context(
};
let mut rendered: Vec<Line<'static>> = Vec::new();
// Insert a sentinel so downstream styling mirrors assistant message rendering.
rendered.push(Line::from("stream"));
crate::markdown::append_markdown_with_opener_and_cwd_and_bold(
&message_state.markdown,
&mut rendered,
+4
View File
@@ -80,6 +80,10 @@ mod util {
}
mod spinner;
mod tui;
#[cfg(feature = "code-fork")]
mod tui_event_extensions;
#[cfg(feature = "code-fork")]
mod foundation;
mod ui_consts;
mod user_approval_widget;
mod height_manager;
+1 -1
View File
@@ -201,7 +201,7 @@ pub fn take_prefix_by_width(text: &str, max_cols: usize) -> (String, &str, usize
(prefix, suffix, cols)
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
+1 -1
View File
@@ -392,7 +392,7 @@ fn split_text_and_fences(src: &str) -> Vec<Segment> {
segments
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
+2 -2
View File
@@ -1703,7 +1703,7 @@ fn to_superscript(s: &str) -> String {
.collect()
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Modifier;
@@ -1760,7 +1760,7 @@ mod tests {
}
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod fenced_padding_tests {
use super::*;
use unicode_width::UnicodeWidthStr;
+2 -2
View File
@@ -648,7 +648,7 @@ impl AnimatedLineStreamer {
}
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
pub(crate) fn simulate_stream_markdown_for_tests(
deltas: &[&str],
finalize: bool,
@@ -668,7 +668,7 @@ pub(crate) fn simulate_stream_markdown_for_tests(
out
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::*;
use code_core::config::Config;
+1 -1
View File
@@ -528,7 +528,7 @@ impl StaticOverlay {
}
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
+25 -12
View File
@@ -1,3 +1,6 @@
#[cfg(feature = "code-fork")]
use crate::foundation::palette as colors;
#[cfg(not(feature = "code-fork"))]
use crate::colors;
use chrono::{DateTime, Datelike, Local, Utc};
use code_common::elapsed::format_duration;
@@ -77,6 +80,7 @@ impl LimitsView {
pub(crate) fn gauge_lines(&self, width: u16) -> Vec<Line<'static>> {
self.grid_state
.filter(|state| state.weekly_used_ratio.is_finite())
.map(|state| render_limit_grid(state, self.grid, width))
.unwrap_or_default()
}
@@ -111,9 +115,13 @@ pub(crate) fn build_limits_view(
grid_config: GridConfig,
) -> LimitsView {
let metrics = RateLimitMetrics::from_snapshot(snapshot);
let grid_state = extract_capacity_fraction(snapshot)
.and_then(|fraction| compute_grid_state(&metrics, fraction))
.map(|state| scale_grid_state(state, grid_config));
let grid_state = if gauge_inputs_available(snapshot) {
extract_capacity_fraction(snapshot)
.and_then(|fraction| compute_grid_state(&metrics, fraction))
.map(|state| scale_grid_state(state, grid_config))
} else {
None
};
LimitsView {
summary_lines: build_summary_lines(&metrics, snapshot, &reset_info),
@@ -174,7 +182,7 @@ fn build_summary_lines(
lines.push(section_header("Hourly Limit"));
lines.push(build_bar_line(
"Usage",
"Used",
metrics.hourly_used,
"",
Style::default().fg(colors::text()),
@@ -849,18 +857,23 @@ fn format_minutes_round_units(minutes: u64) -> String {
fn extract_capacity_fraction(snapshot: &RateLimitSnapshotEvent) -> Option<f64> {
let ratio = snapshot.primary_to_secondary_ratio_percent;
if ratio.is_finite() && ratio > 0.0 {
return Some((ratio / 100.0).clamp(0.0, 1.0));
if !ratio.is_finite() || ratio <= 0.0 {
return None;
}
// Fallback: derive capacity share from the reported window lengths.
let primary = snapshot.primary_window_minutes as f64;
let secondary = snapshot.secondary_window_minutes as f64;
if primary.is_finite() && secondary.is_finite() && primary > 0.0 && secondary > 0.0 {
return Some((primary / secondary).clamp(0.0, 1.0));
Some((ratio / 100.0).clamp(0.0, 1.0))
}
fn gauge_inputs_available(snapshot: &RateLimitSnapshotEvent) -> bool {
let ratio = snapshot.primary_to_secondary_ratio_percent;
if !ratio.is_finite() || ratio <= 0.0 {
return false;
}
None
snapshot.primary_used_percent.is_finite()
&& snapshot.secondary_used_percent.is_finite()
&& snapshot.primary_window_minutes > 0
&& snapshot.secondary_window_minutes > 0
}
fn compute_grid_state(metrics: &RateLimitMetrics, capacity_fraction: f64) -> Option<GridState> {
+13 -195
View File
@@ -1,16 +1,16 @@
use unicode_segmentation::UnicodeSegmentation;
/// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating.
/// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells.
/// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a
/// compact way before truncating. This is a best-effort approach that may not work perfectly for text where one
/// grapheme spans multiple terminal cells.
#[allow(dead_code)]
pub(crate) fn format_and_truncate_tool_result(
text: &str,
max_lines: usize,
line_width: usize,
) -> String {
// Work out the maximum number of graphemes we can display for a result.
// It's not guaranteed that 1 grapheme = 1 cell, so we subtract 1 per line as a fudge factor.
// It also won't handle future terminal resizes properly, but it's an OK approximation for now.
// Work out the maximum number of graphemes we can display for a result. It's not guaranteed that one grapheme
// equals one cell, so we subtract 1 per line as a conservative buffer.
let max_graphemes = (max_lines * line_width).saturating_sub(max_lines);
if let Some(formatted_json) = format_json_compact(text) {
@@ -20,25 +20,18 @@ pub(crate) fn format_and_truncate_tool_result(
}
}
/// Format JSON text in a compact single-line format with spaces for better Ratatui wrapping.
/// Ex: `{"a":"b",c:["d","e"]}` -> `{"a": "b", "c": ["d", "e"]}`
/// Returns the formatted JSON string if the input is valid JSON, otherwise returns None.
/// This is a little complicated, but it's necessary because Ratatui's wrapping is *very* limited
/// and can only do line breaks at whitespace. If we use the default serde_json format, we get lines
/// without spaces that Ratatui can't wrap nicely. If we use the serde_json pretty format as-is,
/// it's much too sparse and uses too many terminal rows.
/// Relevant issue: https://github.com/ratatui/ratatui/issues/293
/// Format JSON text in a compact single-line format with spaces to improve Ratatui wrapping. Returns `None` if the
/// input is not valid JSON.
pub(crate) fn format_json_compact(text: &str) -> Option<String> {
let json = serde_json::from_str::<serde_json::Value>(text).ok()?;
let json_pretty = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
// Convert multi-line pretty JSON to compact single-line format by removing newlines and excess whitespace
// Convert multi-line pretty JSON to compact single-line format by removing newlines and redundant whitespace.
let mut result = String::new();
let mut chars = json_pretty.chars().peekable();
let mut in_string = false;
let mut escape_next = false;
// Iterate over the characters in the JSON string, adding spaces after : and , but only when not in a string
while let Some(ch) = chars.next() {
match ch {
'"' if !escape_next => {
@@ -50,10 +43,9 @@ pub(crate) fn format_json_compact(text: &str) -> Option<String> {
result.push(ch);
}
'\n' | '\r' if !in_string => {
// Skip newlines when not in a string
// Skip newlines when not in a string literal.
}
' ' | '\t' if !in_string => {
// Add a space after : and , but only when not in a string
if let Some(&next_ch) = chars.peek() {
if let Some(last_ch) = result.chars().last() {
if (last_ch == ':' || last_ch == ',') && !matches!(next_ch, '}' | ']') {
@@ -74,196 +66,22 @@ pub(crate) fn format_json_compact(text: &str) -> Option<String> {
Some(result)
}
/// Truncate `text` to `max_graphemes` graphemes. Using graphemes to avoid accidentally truncating in the middle of a multi-codepoint character.
/// Truncate `text` to at most `max_graphemes` graphemes, avoiding partial graphemes and adding an ellipsis when there
/// is enough space.
#[allow(dead_code)]
pub(crate) fn truncate_text(text: &str, max_graphemes: usize) -> String {
let mut graphemes = text.grapheme_indices(true);
// Check if there's a grapheme at position max_graphemes (meaning there are more than max_graphemes total)
if let Some((byte_index, _)) = graphemes.nth(max_graphemes) {
// There are more than max_graphemes, so we need to truncate
if max_graphemes >= 3 {
// Truncate to max_graphemes - 3 and add "..." to stay within limit
let mut truncate_graphemes = text.grapheme_indices(true);
if let Some((truncate_byte_index, _)) = truncate_graphemes.nth(max_graphemes - 3) {
let truncated = &text[..truncate_byte_index];
format!("{truncated}...")
} else {
text.to_string()
return format!("{truncated}...");
}
} else {
// max_graphemes < 3, so just return first max_graphemes without "..."
let truncated = &text[..byte_index];
truncated.to_string()
}
text[..byte_index].to_string()
} else {
// There are max_graphemes or fewer graphemes, return original text
text.to_string()
}
}
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_truncate_text() {
let text = "Hello, world!";
let truncated = truncate_text(text, 8);
assert_eq!(truncated, "Hello...");
}
#[test]
fn test_truncate_empty_string() {
let text = "";
let truncated = truncate_text(text, 5);
assert_eq!(truncated, "");
}
#[test]
fn test_truncate_max_graphemes_zero() {
let text = "Hello";
let truncated = truncate_text(text, 0);
assert_eq!(truncated, "");
}
#[test]
fn test_truncate_max_graphemes_one() {
let text = "Hello";
let truncated = truncate_text(text, 1);
assert_eq!(truncated, "H");
}
#[test]
fn test_truncate_max_graphemes_two() {
let text = "Hello";
let truncated = truncate_text(text, 2);
assert_eq!(truncated, "He");
}
#[test]
fn test_truncate_max_graphemes_three_boundary() {
let text = "Hello";
let truncated = truncate_text(text, 3);
assert_eq!(truncated, "...");
}
#[test]
fn test_truncate_text_shorter_than_limit() {
let text = "Hi";
let truncated = truncate_text(text, 10);
assert_eq!(truncated, "Hi");
}
#[test]
fn test_truncate_text_exact_length() {
let text = "Hello";
let truncated = truncate_text(text, 5);
assert_eq!(truncated, "Hello");
}
#[test]
fn test_truncate_emoji() {
let text = "👋🌍🚀✨💫";
let truncated = truncate_text(text, 3);
assert_eq!(truncated, "...");
let truncated_longer = truncate_text(text, 4);
assert_eq!(truncated_longer, "👋...");
}
#[test]
fn test_truncate_unicode_combining_characters() {
let text = "é́ñ̃"; // Characters with combining marks
let truncated = truncate_text(text, 2);
assert_eq!(truncated, "é́ñ̃");
}
#[test]
fn test_truncate_very_long_text() {
let text = "a".repeat(1000);
let truncated = truncate_text(&text, 10);
assert_eq!(truncated, "aaaaaaa...");
assert_eq!(truncated.len(), 10); // 7 'a's + 3 dots
}
#[test]
fn test_format_json_compact_simple_object() {
let json = r#"{ "name": "John", "age": 30 }"#;
let result = format_json_compact(json).unwrap();
assert_eq!(result, r#"{"name": "John", "age": 30}"#);
}
#[test]
fn test_format_json_compact_nested_object() {
let json = r#"{ "user": { "name": "John", "details": { "age": 30, "city": "NYC" } } }"#;
let result = format_json_compact(json).unwrap();
assert_eq!(
result,
r#"{"user": {"name": "John", "details": {"age": 30, "city": "NYC"}}}"#
);
}
#[test]
fn test_format_json_compact_array() {
let json = r#"[ 1, 2, { "key": "value" }, "string" ]"#;
let result = format_json_compact(json).unwrap();
assert_eq!(result, r#"[1, 2, {"key": "value"}, "string"]"#);
}
#[test]
fn test_format_json_compact_already_compact() {
let json = r#"{"compact":true}"#;
let result = format_json_compact(json).unwrap();
assert_eq!(result, r#"{"compact": true}"#);
}
#[test]
fn test_format_json_compact_with_whitespace() {
let json = r#"
{
"name": "John",
"hobbies": [
"reading",
"coding"
]
}
"#;
let result = format_json_compact(json).unwrap();
assert_eq!(
result,
r#"{"name": "John", "hobbies": ["reading", "coding"]}"#
);
}
#[test]
fn test_format_json_compact_invalid_json() {
let invalid_json = r#"{"invalid": json syntax}"#;
let result = format_json_compact(invalid_json);
assert!(result.is_none());
}
#[test]
fn test_format_json_compact_empty_object() {
let json = r#"{}"#;
let result = format_json_compact(json).unwrap();
assert_eq!(result, "{}");
}
#[test]
fn test_format_json_compact_empty_array() {
let json = r#"[]"#;
let result = format_json_compact(json).unwrap();
assert_eq!(result, "[]");
}
#[test]
fn test_format_json_compact_primitive_values() {
assert_eq!(format_json_compact("42").unwrap(), "42");
assert_eq!(format_json_compact("true").unwrap(), "true");
assert_eq!(format_json_compact("false").unwrap(), "false");
assert_eq!(format_json_compact("null").unwrap(), "null");
assert_eq!(format_json_compact(r#""string""#).unwrap(), r#""string""#);
}
}
+1 -1
View File
@@ -128,7 +128,7 @@ fn parse_markdown_line(text: &str) -> Line<'static> {
Line::from(spans)
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::*;
+17
View File
@@ -0,0 +1,17 @@
//! Fork-specific TUI event helpers kept behind the `code-fork` feature flag.
#![cfg(feature = "code-fork")]
use crate::app_event_sender::AppEventSender;
use code_core::protocol::{BrowserScreenshotUpdateEvent, RateLimitSnapshotEvent};
/// Forward a rate-limit snapshot into the main event loop.
#[inline]
pub fn handle_rate_limit(_event: &RateLimitSnapshotEvent, _sender: &AppEventSender) {
// Intentionally no-op: downstream wrappers can forward to AppEvent if needed.
}
/// Forward a browser screenshot update into the main event loop.
#[inline]
pub fn handle_browser_screenshot(_event: &BrowserScreenshotUpdateEvent, _sender: &AppEventSender) {
// Intentionally no-op: downstream wrappers can forward to AppEvent if needed.
}
+2 -1
View File
@@ -51,6 +51,7 @@ fn force_upgrade_preview_enabled() -> bool {
}
#[cfg(test)]
#[allow(dead_code)]
pub(crate) fn reset_force_upgrade_preview_for_tests() {
FORCE_UPGRADE_PREVIEW.store(FORCE_UPGRADE_UNSET, Ordering::Relaxed);
}
@@ -586,7 +587,7 @@ fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
Some((maj, min, pat))
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -560,7 +560,7 @@ fn hotkey_suffix(key: KeyCode) -> String {
}
}
#[cfg(all(test, feature = "legacy_tests"))]
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode;
+54
View File
@@ -0,0 +1,54 @@
# TUI Gap Report: `codex-rs/tui` vs `code-rs/tui`
## Component Inventory
- **Bottom pane surfaces** – Fork adds agent dashboards, theme/preferences, notifications, cloud tasks, and approval modal views (`code-rs/tui/src/bottom_pane/mod.rs:1-210`, `code-rs/tui/src/bottom_pane/*.rs`); upstream retains the composer plus legacy overlay (`codex-rs/tui/src/bottom_pane/mod.rs:1-190`, `codex-rs/tui/src/bottom_pane/approval_overlay.rs`).
- **History rendering** – Fork decomposes history into typed cells backed by `code_core::history` exports (`code-rs/tui/HISTORY_CELLS_PLAN.md`, `code-rs/tui/src/history_cell/mod.rs:1-210`); upstream relies on a monolithic `HistoryCell` trait (`codex-rs/tui/src/history_cell.rs:1-220`).
- **Chat widget extensions** – Fork hosts auto-coordinator, rate-limit HUD, streaming diff/terminal handlers, retry flows, and browser screenshot support (`code-rs/tui/src/chatwidget/*`); upstream chat widget lacks these modules entirely.
- **Approval UX** – Fork ships `user_approval_widget` with session rules and sandbox escalation (`code-rs/tui/src/user_approval_widget.rs:1-240`); upstream overlay stays list-based (`codex-rs/tui/src/bottom_pane/approval_overlay.rs:1-200`).
- **Assets/build** – Fork bundles spinner/theme assets and build-time normalization (`code-rs/tui/build.rs`, `code-rs/tui/src/assets/`); upstream has no analogous assets or build step.
## API Surface Map
- Fork consumes additional `code_core` APIs: history domain events, rate-limit snapshots, plan tools, slash command formatting, config edit helpers (`code-rs/tui/src/history_cell/mod.rs:10-44`, `code-rs/tui/src/chatwidget.rs:4623-9869`, `code-rs/tui/src/app_event.rs:200-520`).
- Upstream references the older `codex_core` event set (`codex-rs/tui/src/chatwidget.rs:6-200`), missing OrderMeta, browser screenshot events, rate-limit telemetry, and validation toggles.
- CLI binaries align structurally, but the fork enables extra flags for auth mode, sandbox defaults, and theme overrides (`code-rs/tui/src/lib.rs:200-420`) absent upstream (`codex-rs/tui/src/lib.rs:150-340`).
## Integration Points with `code-core`
- **Config bootstrap & persistence** – Fork writes back theme/spinner/trust preferences (`code-rs/tui/src/lib.rs:370-432`, `code-rs/tui/src/app.rs:2760-2820`); upstream never calls those setters.
- **Validation & MCP controls** – Fork wires `Op::UpdateValidationGroup`, MCP server toggles, and agent defaults through chat events (`code-rs/tui/src/app_event.rs:240-360`, `code-rs/tui/src/chatwidget.rs:17100-17650`); upstream lacks these code paths.
- **Rate-limit telemetry** – Fork starts a background refresh worker using `ModelClient` (`code-rs/tui/src/chatwidget/rate_limit_refresh.rs:1-120`); upstream has no equivalent logic.
- **OrderMeta processing** – Fork enforces sequencing for streamed events and handles browser screenshots (`code-rs/tui/src/chatwidget.rs:880-1040`, `9869-9890`); upstream still renders FIFO without identifiers.
## Config / Environment Diffs
- Fork honors extended CLI overrides and `tui.theme.*` keys (`code-rs/tui/src/lib.rs:214-340`); upstream accepts legacy overrides only.
- Fork adjusts sandbox defaults to `AskForApproval::Never` for trusted workspaces (`code-rs/tui/src/lib.rs:600-648`); upstream defaults to `OnRequest` (`codex-rs/tui/src/lib.rs:500-560`).
- Cloud tasks and agent env hooks exist only in the fork (`code-rs/tui/src/cloud_tasks_service.rs`).
## CLI / UX Deltas
- Multi-agent control, resume/update flows, notifications settings, and rate-limit charts appear exclusively in the fork (`code-rs/tui/src/bottom_pane/*`, `code-rs/tui/src/rate_limits_view.rs`).
- Auto-drive, retry, and streaming diff overlays deliver accelerated workflows (`code-rs/tui/src/chatwidget/{auto_coordinator.rs,diff_ui.rs,streaming.rs}`) absent upstream.
- Fork’s approval modal supports session-wide allow rules and sandbox elevation; upstream overlay does not expose those options.
## Breaking Changes & Risks
- Typed history pipeline requires `HistoryState` reliability; pulling fork cells into upstream without the new domain events will break rendering order integration tests.
- Approval widget depends on background order tickets and durable auth decisions; missing those hooks reintroduces approval deadlocks.
- Auto-coordinator spawns async tasks; without matching cancellation plumbing upstream merges risk zombie tasks and UI hangs.
- Config edits for validation/agent settings assume new enums and schema; applying them upstream without schema updates will panic or drop settings silently.
## Proposed Thin Wrappers / Adapters
1. **History Rendering Adapter** – Introduce `tui_history_core` to re-export upstream history traits while providing shim constructors for fork cells (add `code-rs/tui/src/history/compat.rs`; update `history_cell/mod.rs` to import via `crate::history::compat::*`).
2. **Approval Overlay Wrapper** – Expose an `ApprovalUi` trait upstream and implement the modal in the fork behind a feature flag, retaining overlay for upstream (trait in `codex-rs/tui/src/bottom_pane/approval_overlay.rs`; adapter in `code-rs/tui/src/user_approval_widget.rs`).
3. **Event Hook Facade** – Define helper functions for rate-limit and browser screenshot events that no-op upstream but forward in the fork (`tui_event_extensions.rs` shared module; fork implements actual handlers).
**Rollback Note**: Land wrappers behind a dedicated Cargo feature (e.g., `code-fork`) so reverting disables fork-specific behavior without deleting files; document the toggle in `docs/subsystem-migration-status.md`.
## Smoke Tests
1. `./build-fast.sh --workspace both` – confirm dual-workspace builds remain green.
2. Launch fork TUI (`./build-fast.sh --workspace code run`); navigate Agents, Notifications, Theme, Rate Limits panes to verify focus, hotkeys, and exit paths.
3. Markdown/code rendering check – stream a response with fenced markdown and diff blocks (`/plan`, `/apply_patch`) to validate typed history cells.
4. Interactive approval flow – trigger exec and apply-patch approvals to test modal hotkeys, session allow rules, and background completion updates.
5. Auto-drive session – run `/drive` (or equivalent) to exercise auto-coordinator start/cancel, status banners, and history consistency.
## References
- Targeted diffs: `diff -ruN codex-rs/tui code-rs/tui`
- Scoped searches: `rg "code_core" code-rs/tui/src`, `rg "codex_core" codex-rs/tui/src`
- Supporting analysis: `.code/agents/d617a1bb-c914-4995-beec-e56990ebf293/result.txt`, `.code/agents/9f56d115-b1cc-47d0-8cfc-bcd80f8fba5f/result.txt`
+1 -1
View File
@@ -8,7 +8,7 @@
"build": "cd code-rs && cargo build --release --bin code --bin code-tui --bin code-exec",
"build:dev": "cd code-rs && cargo build --bin code --bin code-tui --bin code-exec",
"build:quick": "cd code-rs && cargo build --release --bin code",
"test:local": "cd codex-cli && npm link && code --version",
"test:local": "if [ -d code-rs ]; then (cd code-rs && cargo run -p code-cli -- --version); elif [ -d codex-cli ]; then (cd codex-cli && npm link && codex --version); else echo 'no CLI workspace found' >&2; exit 1; fi",
"fix": "cargo fmt && cargo fix --allow-dirty && cargo clippy --fix --allow-dirty"
},
"devDependencies": {
+11 -2
View File
@@ -6,8 +6,17 @@ set -euo pipefail
# into a tap repo to publish.
owner_repo="just-every/code"
# Version can be passed as $1 or inferred from codex-cli/package.json (which CI bumps).
version="${1:-$(jq -r .version codex-cli/package.json)}"
version="${1:-}"
if [ -z "$version" ] && [ -f "code-rs/Cargo.toml" ]; then
version="$(awk -F '"' '/^\[workspace.package\]/{f=1; next} f && $1 ~ /version/ {print $2; exit}' code-rs/Cargo.toml)"
fi
if [ -z "$version" ] && [ -f "codex-cli/package.json" ]; then
version="$(jq -r .version codex-cli/package.json)"
fi
if [ -z "$version" ]; then
echo "Unable to infer release version; pass it as \$1 or ensure code-rs/Cargo.toml or codex-cli/package.json are available." >&2
exit 1
fi
# Optional directory where CI placed artifacts (step: Prepare release assets)
RELEASE_ASSETS_DIR=${RELEASE_ASSETS_DIR:-"release-assets"}
+10 -2
View File
@@ -38,12 +38,20 @@ fi
echo "[verify] STEP 2: cargo check (core tests compile)"
}
# Respect pre-set CARGO_HOME/TARGET_DIR to share caches across steps
CODE_TARGET_DIR="$ROOT_DIR/code-rs/target"
CODEX_TARGET_DIR="$ROOT_DIR/codex-rs/target"
export CARGO_HOME="${CARGO_HOME:-$ROOT_DIR/.cargo-home}"
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT_DIR/codex-rs/target}"
if [ -z "${CARGO_TARGET_DIR:-}" ]; then
if [ -d "$ROOT_DIR/code-rs" ]; then
export CARGO_TARGET_DIR="$CODE_TARGET_DIR"
else
export CARGO_TARGET_DIR="$CODEX_TARGET_DIR"
fi
fi
# Ensure rustup also uses a repo-local, writable directory to avoid HOME permission issues on CI
export RUSTUP_HOME="${RUSTUP_HOME:-${CARGO_HOME%/}/rustup}"
mkdir -p "$CARGO_HOME" "$CARGO_TARGET_DIR" "$RUSTUP_HOME" >/dev/null 2>&1 || true
if ! (cd codex-rs && cargo check -p codex-core --test api_surface --quiet) 2>&1 | tee .github/auto/VERIFY_api-check.log; then
if ! (CARGO_TARGET_DIR="$CODEX_TARGET_DIR" cd codex-rs && cargo check -p codex-core --test api_surface --quiet) 2>&1 | tee .github/auto/VERIFY_api-check.log; then
status_api="fail"
fi