mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
merge code-branch-20250929-011511 via /merge
This commit is contained in:
@@ -3,6 +3,18 @@
|
||||
> [!TIP]
|
||||
> We're constantly improving Code! This page documents the core changes. You can also check our [releases page](https://github.com/just-every/code/releases) for additional information.
|
||||
|
||||
## [0.2.173] - 2025-09-29
|
||||
|
||||
- TUI/Browser: auto hand off /browser startup failures to Code so sessions self-heal. (95e27cd0)
|
||||
- TUI/Browser: sanitize and surface error details when handoff triggers for faster diagnosis. (95e27cd0)
|
||||
|
||||
## [0.2.172] - 2025-09-28
|
||||
|
||||
- CLI: introduce a responses API proxy command so shared hosts can forward Responses calls securely. (c5494815)
|
||||
- MCP: add streamable HTTP client support and tighten per-call timeout handling. (3a1be084, bd6aad0b, d3ecf4de, 9bb84892)
|
||||
- Auto-drive: stream coordinator reasoning, keep plan context, and smooth heading presentation. (fc8da8cf, 0d5bc1f1, 45a6fb94, aaa91865)
|
||||
- TUI/History: route diff and explore cells through domain events for consistent playback. (0ae94dca, 0d5d57ee)
|
||||
|
||||
## [0.2.170] - 2025-09-27
|
||||
|
||||
- TUI/History: drive exec, assistant, explore, and rate-limit cells from domain events for consistent streaming. (38abccc, 5409493, e6e72d3, f851369, d7e9ee7)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@just-every/code",
|
||||
"version": "0.2.171",
|
||||
"version": "0.2.173",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Lightweight coding agent that runs in your terminal - fork of OpenAI Codex",
|
||||
"bin": {
|
||||
@@ -35,10 +35,10 @@
|
||||
"prettier": "^3.3.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@just-every/code-darwin-arm64": "0.2.171",
|
||||
"@just-every/code-darwin-x64": "0.2.171",
|
||||
"@just-every/code-linux-x64-musl": "0.2.171",
|
||||
"@just-every/code-linux-arm64-musl": "0.2.171",
|
||||
"@just-every/code-win32-x64": "0.2.171"
|
||||
"@just-every/code-darwin-arm64": "0.2.173",
|
||||
"@just-every/code-darwin-x64": "0.2.173",
|
||||
"@just-every/code-linux-x64-musl": "0.2.173",
|
||||
"@just-every/code-linux-arm64-musl": "0.2.173",
|
||||
"@just-every/code-win32-x64": "0.2.173"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1803,7 +1803,7 @@ impl App<'_> {
|
||||
}
|
||||
SlashCommand::Update => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.handle_update_command();
|
||||
widget.handle_update_command(command_args.trim());
|
||||
}
|
||||
}
|
||||
SlashCommand::Notifications => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::colors;
|
||||
use crate::auto_drive_strings;
|
||||
use crate::colors;
|
||||
use crate::header_wave::{HeaderBorderWeaveEffect, HeaderWaveEffect};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
@@ -10,7 +11,7 @@ use ratatui::widgets::{Block, Borders, Paragraph, Widget, Wrap};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use std::borrow::Cow;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DRIVE_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
const DRIVE_SPINNER_INTERVAL_MS: u64 = 120;
|
||||
@@ -43,6 +44,7 @@ pub(crate) struct AutoCoordinatorViewModel {
|
||||
pub button: Option<AutoCoordinatorButton>,
|
||||
pub manual_hint: Option<String>,
|
||||
pub ctrl_switch_hint: String,
|
||||
pub cli_running: bool,
|
||||
}
|
||||
|
||||
struct VariantContext {
|
||||
@@ -54,11 +56,29 @@ struct VariantContext {
|
||||
pub(crate) struct AutoCoordinatorView {
|
||||
model: AutoCoordinatorViewModel,
|
||||
app_event_tx: AppEventSender,
|
||||
header_wave: HeaderWaveEffect,
|
||||
header_border: HeaderBorderWeaveEffect,
|
||||
}
|
||||
|
||||
impl AutoCoordinatorView {
|
||||
pub fn new(model: AutoCoordinatorViewModel, app_event_tx: AppEventSender) -> Self {
|
||||
Self { model, app_event_tx }
|
||||
let now = Instant::now();
|
||||
let header_wave = {
|
||||
let effect = HeaderWaveEffect::new();
|
||||
effect.set_enabled(false, now);
|
||||
effect
|
||||
};
|
||||
let header_border = {
|
||||
let effect = HeaderBorderWeaveEffect::new();
|
||||
effect.set_enabled(false, now);
|
||||
effect
|
||||
};
|
||||
Self {
|
||||
model,
|
||||
app_event_tx,
|
||||
header_wave,
|
||||
header_border,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_context(&self) -> VariantContext {
|
||||
@@ -74,7 +94,7 @@ impl AutoCoordinatorView {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_frame(&self, area: Rect, buf: &mut Buffer, title: &str) -> Option<Rect> {
|
||||
fn render_frame(&self, area: Rect, buf: &mut Buffer, title: &str, now: Instant) -> Option<Rect> {
|
||||
if area.width < 3 || area.height < 3 {
|
||||
return None;
|
||||
}
|
||||
@@ -89,6 +109,28 @@ impl AutoCoordinatorView {
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(colors::border()))
|
||||
.render(area, buf);
|
||||
if self.header_wave.is_enabled() {
|
||||
self.header_wave.render(area, buf, now);
|
||||
}
|
||||
if self.header_border.is_enabled() {
|
||||
self.header_border.render(area, buf, now);
|
||||
}
|
||||
// Reapply static title styling so animation never recolors it
|
||||
let title_style = Style::default()
|
||||
.fg(colors::text())
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let title_y = area.y;
|
||||
let title_start = area.x + 1;
|
||||
for (offset, ch) in title.chars().enumerate() {
|
||||
let x = title_start + offset as u16;
|
||||
if x >= area.x && x < area.x.saturating_add(area.width) {
|
||||
let mut ch_buf = [0u8; 4];
|
||||
let symbol = ch.encode_utf8(&mut ch_buf);
|
||||
let cell = &mut buf[(x, title_y)];
|
||||
cell.set_symbol(symbol);
|
||||
cell.set_style(title_style);
|
||||
}
|
||||
}
|
||||
Some(Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
@@ -290,7 +332,25 @@ impl AutoCoordinatorView {
|
||||
}
|
||||
|
||||
fn render_classic(&self, area: Rect, buf: &mut Buffer, ctx: &VariantContext) {
|
||||
let Some(inner) = self.render_frame(area, buf, " Auto Drive ") else {
|
||||
let now = Instant::now();
|
||||
let waiting = self.model.waiting_for_response;
|
||||
let mut frame_needed = false;
|
||||
|
||||
let should_enable_border = waiting && !self.model.cli_running;
|
||||
let mut border_enabled = self.header_border.is_enabled();
|
||||
if should_enable_border && !border_enabled {
|
||||
self.header_border.set_enabled(true, now);
|
||||
border_enabled = true;
|
||||
} else if !should_enable_border && border_enabled {
|
||||
self.header_border.set_enabled(false, now);
|
||||
border_enabled = false;
|
||||
}
|
||||
|
||||
if border_enabled && self.header_border.schedule_if_needed(now) {
|
||||
frame_needed = true;
|
||||
}
|
||||
|
||||
let Some(inner) = self.render_frame(area, buf, " Auto Drive ", now) else {
|
||||
return;
|
||||
};
|
||||
let inner = self.apply_left_padding(inner, buf);
|
||||
@@ -349,8 +409,12 @@ impl AutoCoordinatorView {
|
||||
.wrap(Wrap { trim: true })
|
||||
.render(inner, buf);
|
||||
|
||||
let mut next_interval = Duration::from_millis(DRIVE_SPINNER_INTERVAL_MS);
|
||||
if frame_needed {
|
||||
next_interval = next_interval.min(HeaderBorderWeaveEffect::FRAME_INTERVAL);
|
||||
}
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(DRIVE_SPINNER_INTERVAL_MS)));
|
||||
.send(AppEvent::ScheduleFrameIn(next_interval));
|
||||
}
|
||||
|
||||
fn apply_left_padding(&self, area: Rect, buf: &mut Buffer) -> Rect {
|
||||
|
||||
@@ -100,7 +100,7 @@ impl UpdateSettingsView {
|
||||
.unwrap_or_else(|| command.join(" "));
|
||||
|
||||
self.app_event_tx.send_background_event(format!(
|
||||
"⬆️ Update available: {} → {}. Running `{}`...",
|
||||
"⬆️ Update available: {} → {}. Opening guided upgrade with `{}`…",
|
||||
self.current_version, latest, display
|
||||
));
|
||||
self.app_event_tx.send(AppEvent::RunUpdateCommand {
|
||||
@@ -109,7 +109,7 @@ impl UpdateSettingsView {
|
||||
latest_version: Some(latest.clone()),
|
||||
});
|
||||
self.app_event_tx.send_background_event(format!(
|
||||
"↻ Restart Code after `{}` completes to finish upgrading to {}.",
|
||||
"↻ Complete the guided terminal steps for `{}` then restart Code to finish upgrading to {}.",
|
||||
display, latest
|
||||
));
|
||||
self.is_complete = true;
|
||||
|
||||
+264
-27
@@ -16,7 +16,7 @@ use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use crate::header_wave::HeaderWaveEffect;
|
||||
use crate::header_wave::{HeaderBorderWeaveEffect, HeaderWaveEffect};
|
||||
use crate::auto_drive_strings;
|
||||
|
||||
use codex_common::elapsed::format_duration;
|
||||
@@ -70,6 +70,7 @@ use self::agent_install::{
|
||||
start_agent_install_session,
|
||||
start_direct_terminal_session,
|
||||
start_prompt_terminal_session,
|
||||
start_upgrade_terminal_session,
|
||||
wrap_command,
|
||||
};
|
||||
use self::auto_coordinator::{start_auto_coordinator, AutoCoordinatorCommand};
|
||||
@@ -117,6 +118,7 @@ use crate::bottom_pane::{
|
||||
AutoCoordinatorViewModel,
|
||||
CountdownState,
|
||||
};
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use codex_git_tooling::{
|
||||
create_ghost_commit,
|
||||
restore_ghost_commit,
|
||||
@@ -166,6 +168,7 @@ const STATUS_LABEL_GAP: usize = 2;
|
||||
const STATUS_CONTENT_PREFIX: &str = " ";
|
||||
const STATUS_TOKENS_PREFIX: &str = " ";
|
||||
const AUTO_COUNTDOWN_SECONDS: u8 = 10;
|
||||
const ENABLE_WARP_STRIPES: bool = false;
|
||||
|
||||
fn status_field_prefix(label: &str) -> String {
|
||||
let padding = STATUS_LABEL_GAP
|
||||
@@ -522,12 +525,14 @@ pub(crate) struct ChatWidget<'a> {
|
||||
tools_state: ToolState,
|
||||
live_builder: RowBuilder,
|
||||
header_wave: HeaderWaveEffect,
|
||||
header_border: HeaderBorderWeaveEffect,
|
||||
// Store pending image paths keyed by their placeholder text
|
||||
pending_images: HashMap<String, PathBuf>,
|
||||
// (removed) pending non-image files are no longer tracked; non-image paths remain as plain text
|
||||
welcome_shown: bool,
|
||||
// Path to the latest browser screenshot and URL for display
|
||||
latest_browser_screenshot: Arc<Mutex<Option<(PathBuf, String)>>>,
|
||||
browser_autofix_requested: Arc<AtomicBool>,
|
||||
// Cached image protocol to avoid recreating every frame (path, area, protocol)
|
||||
cached_image_protocol:
|
||||
std::cell::RefCell<Option<(PathBuf, Rect, ratatui_image::protocol::Protocol)>>,
|
||||
@@ -2766,12 +2771,22 @@ impl ChatWidget<'_> {
|
||||
live_builder: RowBuilder::new(usize::MAX),
|
||||
header_wave: {
|
||||
let effect = HeaderWaveEffect::new();
|
||||
if ENABLE_WARP_STRIPES {
|
||||
effect.set_enabled(true, Instant::now());
|
||||
} else {
|
||||
effect.set_enabled(false, Instant::now());
|
||||
}
|
||||
effect
|
||||
},
|
||||
header_border: {
|
||||
let effect = HeaderBorderWeaveEffect::new();
|
||||
effect.set_enabled(false, Instant::now());
|
||||
effect
|
||||
},
|
||||
pending_images: HashMap::new(),
|
||||
welcome_shown: false,
|
||||
latest_browser_screenshot: Arc::new(Mutex::new(None)),
|
||||
browser_autofix_requested: Arc::new(AtomicBool::new(false)),
|
||||
cached_image_protocol: RefCell::new(None),
|
||||
cached_picker: RefCell::new(terminal_info.picker.clone()),
|
||||
cached_cell_size: std::cell::OnceCell::new(),
|
||||
@@ -3010,12 +3025,22 @@ impl ChatWidget<'_> {
|
||||
live_builder: RowBuilder::new(usize::MAX),
|
||||
header_wave: {
|
||||
let effect = HeaderWaveEffect::new();
|
||||
if ENABLE_WARP_STRIPES {
|
||||
effect.set_enabled(true, Instant::now());
|
||||
} else {
|
||||
effect.set_enabled(false, Instant::now());
|
||||
}
|
||||
effect
|
||||
},
|
||||
header_border: {
|
||||
let effect = HeaderBorderWeaveEffect::new();
|
||||
effect.set_enabled(false, Instant::now());
|
||||
effect
|
||||
},
|
||||
pending_images: HashMap::new(),
|
||||
welcome_shown: false,
|
||||
latest_browser_screenshot: Arc::new(Mutex::new(None)),
|
||||
browser_autofix_requested: Arc::new(AtomicBool::new(false)),
|
||||
cached_image_protocol: RefCell::new(None),
|
||||
cached_picker: RefCell::new(terminal_info.picker.clone()),
|
||||
cached_cell_size: std::cell::OnceCell::new(),
|
||||
@@ -8608,16 +8633,48 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_update_command(&mut self) {
|
||||
if crate::updates::upgrade_ui_enabled() {
|
||||
pub(crate) fn handle_update_command(&mut self, command_args: &str) {
|
||||
if !crate::updates::upgrade_ui_enabled() {
|
||||
self.app_event_tx.send_background_event(
|
||||
"`/update` — updates are disabled in debug builds. Set SHOW_UPGRADE=1 to preview.".
|
||||
to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let trimmed = command_args.trim();
|
||||
if trimmed.eq_ignore_ascii_case("settings")
|
||||
|| trimmed.eq_ignore_ascii_case("ui")
|
||||
|| trimmed.eq_ignore_ascii_case("config")
|
||||
{
|
||||
self.show_update_settings_ui();
|
||||
return;
|
||||
}
|
||||
|
||||
self.app_event_tx.send_background_event(
|
||||
"`/update` — updates are disabled in debug builds. Set SHOW_UPGRADE=1 to preview.".
|
||||
to_string(),
|
||||
);
|
||||
match crate::updates::resolve_upgrade_resolution() {
|
||||
crate::updates::UpgradeResolution::Command { command, display } => {
|
||||
if command.is_empty() {
|
||||
self.history_push(history_cell::new_error_event(
|
||||
"`/upgrade` — no upgrade command available for this install.".to_string(),
|
||||
));
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
let latest = self.latest_upgrade_version.clone();
|
||||
self.push_background_tail(
|
||||
"Opening a guided upgrade terminal to finish installing updates.".to_string(),
|
||||
);
|
||||
if let Some(launch) = self.launch_update_command(command, display, latest) {
|
||||
self.app_event_tx.send(AppEvent::OpenTerminal(launch));
|
||||
}
|
||||
}
|
||||
crate::updates::UpgradeResolution::Manual { instructions } => {
|
||||
self.show_update_settings_ui();
|
||||
self.history_push(history_cell::new_background_event(instructions));
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_notifications_command(&mut self, args: String) {
|
||||
@@ -9564,6 +9621,44 @@ impl ChatWidget<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
fn is_cli_running(&self) -> bool {
|
||||
if !self.exec.running_commands.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if !self.tools_state.running_custom_tools.is_empty()
|
||||
|| !self.tools_state.running_web_search.is_empty()
|
||||
|| !self.tools_state.running_wait_tools.is_empty()
|
||||
|| !self.tools_state.running_kill_tools.is_empty()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if self.stream.is_write_cycle_active() {
|
||||
return true;
|
||||
}
|
||||
if !self.active_task_ids.is_empty() {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn update_header_border_activation(&self) {
|
||||
let now = Instant::now();
|
||||
let should_enable_header = self.auto_state.active && self.is_cli_running();
|
||||
let currently_enabled = self.header_border.is_enabled();
|
||||
if should_enable_header && !currently_enabled {
|
||||
self.header_border.set_enabled(true, now);
|
||||
} else if !should_enable_header && currently_enabled {
|
||||
self.header_border.set_enabled(false, now);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_auto_drive_visuals(&mut self) {
|
||||
self.update_header_border_activation();
|
||||
if self.auto_state.active {
|
||||
self.auto_rebuild_live_ring();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_auto_command(&mut self, goal: Option<String>) {
|
||||
let provided = goal.unwrap_or_default();
|
||||
let trimmed = provided.trim();
|
||||
@@ -9592,10 +9687,10 @@ impl ChatWidget<'_> {
|
||||
self.bottom_pane.set_task_running(true);
|
||||
self.bottom_pane
|
||||
.update_status_text("Auto Drive Goal".to_string());
|
||||
self.header_wave.set_enabled(false, Instant::now());
|
||||
self.push_background_tail(
|
||||
"Please enter the goal you would like to work autonomously towards.".to_string(),
|
||||
);
|
||||
self.update_header_border_activation();
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
@@ -9629,7 +9724,7 @@ impl ChatWidget<'_> {
|
||||
self.auto_state.last_broadcast_thought = None;
|
||||
self.auto_state.seconds_remaining = AUTO_COUNTDOWN_SECONDS;
|
||||
self.auto_state.waiting_for_response = true;
|
||||
self.header_wave.set_enabled(true, Instant::now());
|
||||
self.update_header_border_activation();
|
||||
self.auto_rebuild_live_ring();
|
||||
self.push_background_tail(format!("Auto Drive started: {goal_text}"));
|
||||
self.request_redraw();
|
||||
@@ -9663,6 +9758,7 @@ impl ChatWidget<'_> {
|
||||
self.auto_state.placeholder_phrase =
|
||||
Some(auto_drive_strings::next_auto_drive_phrase().to_string());
|
||||
self.auto_state.thinking_prefix_stripped = false;
|
||||
self.update_header_border_activation();
|
||||
self.auto_rebuild_live_ring();
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -9684,6 +9780,7 @@ impl ChatWidget<'_> {
|
||||
self.auto_state.resume_after_manual_submit = false;
|
||||
self.auto_state.awaiting_submission = false;
|
||||
self.auto_state.waiting_for_response = false;
|
||||
self.update_header_border_activation();
|
||||
|
||||
match status {
|
||||
AutoCoordinatorStatus::Continue => {
|
||||
@@ -9876,6 +9973,7 @@ impl ChatWidget<'_> {
|
||||
self.bottom_pane.set_task_running(true);
|
||||
self.bottom_pane
|
||||
.update_status_text("Auto Drive".to_string());
|
||||
self.update_header_border_activation();
|
||||
self.auto_rebuild_live_ring();
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -9906,7 +10004,10 @@ impl ChatWidget<'_> {
|
||||
self.bottom_pane.update_status_text(String::new());
|
||||
}
|
||||
self.bottom_pane.ensure_input_focus();
|
||||
self.header_wave.set_enabled(false, Instant::now());
|
||||
if ENABLE_WARP_STRIPES {
|
||||
self.header_wave.set_enabled(false, Instant::now());
|
||||
}
|
||||
self.update_header_border_activation();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -9925,6 +10026,7 @@ impl ChatWidget<'_> {
|
||||
self.auto_state.placeholder_phrase = None;
|
||||
self.auto_state.thinking_prefix_stripped = false;
|
||||
self.auto_state.last_decision_display = None;
|
||||
self.update_header_border_activation();
|
||||
self.auto_rebuild_live_ring();
|
||||
self.request_redraw();
|
||||
self.auto_send_conversation();
|
||||
@@ -9982,6 +10084,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
status_lines.push(telemetry_line);
|
||||
}
|
||||
let cli_running = self.is_cli_running();
|
||||
|
||||
let prompt = self
|
||||
.auto_state
|
||||
@@ -10036,6 +10139,7 @@ impl ChatWidget<'_> {
|
||||
button,
|
||||
manual_hint,
|
||||
ctrl_switch_hint,
|
||||
cli_running,
|
||||
};
|
||||
|
||||
self.bottom_pane
|
||||
@@ -10220,19 +10324,52 @@ impl ChatWidget<'_> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = self.terminal.alloc_id();
|
||||
if let Some(version) = latest_version {
|
||||
self.pending_upgrade_notice = Some((id, version));
|
||||
let command_text = if display.trim().is_empty() {
|
||||
strip_bash_lc_and_escape(&command)
|
||||
} else {
|
||||
display.clone()
|
||||
};
|
||||
|
||||
if command_text.trim().is_empty() {
|
||||
self.history_push(history_cell::new_error_event(
|
||||
"`/update` — unable to resolve upgrade command text.".to_string(),
|
||||
));
|
||||
self.request_redraw();
|
||||
return None;
|
||||
}
|
||||
Some(TerminalLaunch {
|
||||
|
||||
let id = self.terminal.alloc_id();
|
||||
if let Some(version) = &latest_version {
|
||||
self.pending_upgrade_notice = Some((id, version.clone()));
|
||||
}
|
||||
|
||||
let (controller_tx, controller_rx) = mpsc::channel();
|
||||
let controller = TerminalRunController { tx: controller_tx };
|
||||
let display_label = Self::truncate_with_ellipsis(&format!("Guided: {command_text}"), 128);
|
||||
|
||||
let launch = TerminalLaunch {
|
||||
id,
|
||||
title: "Upgrade Code".to_string(),
|
||||
command,
|
||||
command_display: display,
|
||||
controller: None,
|
||||
command: Vec::new(),
|
||||
command_display: display_label,
|
||||
controller: Some(controller.clone()),
|
||||
auto_close_on_success: false,
|
||||
start_running: true,
|
||||
})
|
||||
};
|
||||
|
||||
let cwd = self.config.cwd.to_string_lossy().to_string();
|
||||
start_upgrade_terminal_session(
|
||||
self.app_event_tx.clone(),
|
||||
id,
|
||||
command_text,
|
||||
latest_version,
|
||||
Some(cwd),
|
||||
controller,
|
||||
controller_rx,
|
||||
self.config.debug,
|
||||
);
|
||||
|
||||
Some(launch)
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_open(&mut self, launch: &TerminalLaunch) {
|
||||
@@ -11811,10 +11948,17 @@ impl ChatWidget<'_> {
|
||||
let tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
match crate::updates::auto_upgrade_if_enabled(&cfg).await {
|
||||
Ok(Some(version)) => {
|
||||
tx.send(AppEvent::AutoUpgradeCompleted { version });
|
||||
Ok(outcome) => {
|
||||
if let Some(version) = outcome.installed_version {
|
||||
tx.send(AppEvent::AutoUpgradeCompleted { version });
|
||||
}
|
||||
if let Some(message) = outcome.user_notice {
|
||||
tx.send(AppEvent::InsertBackgroundEvent {
|
||||
message,
|
||||
placement: BackgroundPlacement::Tail,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!("auto-upgrade: background task failed: {err:?}");
|
||||
}
|
||||
@@ -14000,6 +14144,56 @@ impl ChatWidget<'_> {
|
||||
));
|
||||
}
|
||||
|
||||
fn schedule_browser_autofix(
|
||||
app_event_tx: AppEventSender,
|
||||
autofix_state: Arc<AtomicBool>,
|
||||
failure_context: &str,
|
||||
raw_error: String,
|
||||
) {
|
||||
if autofix_state
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
tracing::info!(
|
||||
"[/browser] auto-handoff already requested; skipping duplicate dispatch"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let sanitized = raw_error.replace('\n', " ").replace('\r', " ");
|
||||
let trimmed = sanitized.trim();
|
||||
let truncated = if trimmed.len() > 220 {
|
||||
let mut shortened = trimmed.chars().take(220).collect::<String>();
|
||||
shortened.push('…');
|
||||
shortened
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"[/browser] scheduling Code autofix for context='{}', error='{}'",
|
||||
failure_context,
|
||||
truncated
|
||||
);
|
||||
|
||||
let visible_message = format!(
|
||||
"🤖 Handing /browser failure ({}) to Code. Error: {}",
|
||||
failure_context,
|
||||
truncated
|
||||
);
|
||||
app_event_tx.send_background_event(visible_message);
|
||||
|
||||
let command_text = format!(
|
||||
"/code The /browser command failed to {context}. Recent error: {error}. Please diagnose and fix the environment (for example, install or configure Chrome) so /browser works in this workspace.",
|
||||
context = failure_context,
|
||||
error = truncated
|
||||
);
|
||||
app_event_tx.send(AppEvent::DispatchCommand(
|
||||
SlashCommand::Code,
|
||||
command_text,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn handle_browser_command(&mut self, command_text: String) {
|
||||
// Parse the browser subcommand
|
||||
let trimmed = command_text.trim();
|
||||
@@ -14015,6 +14209,7 @@ impl ChatWidget<'_> {
|
||||
|
||||
// Toggle asynchronously: if internal browser is active, disable it; otherwise enable and open about:blank
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
let browser_autofix_flag = self.browser_autofix_requested.clone();
|
||||
tokio::spawn(async move {
|
||||
let browser_manager = ChatWidget::get_browser_manager().await;
|
||||
// Determine if internal browser is currently active
|
||||
@@ -14047,14 +14242,26 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
if let Err(e) = browser_manager.start().await {
|
||||
tracing::error!("[/browser] failed to start internal browser: {}", e);
|
||||
let error_text = e.to_string();
|
||||
tracing::error!(
|
||||
"[/browser] failed to start internal browser: {}",
|
||||
error_text
|
||||
);
|
||||
app_event_tx.send_background_event(format!(
|
||||
"❌ Failed to start internal browser: {}",
|
||||
e
|
||||
error_text
|
||||
));
|
||||
ChatWidget::schedule_browser_autofix(
|
||||
app_event_tx.clone(),
|
||||
browser_autofix_flag.clone(),
|
||||
"start the internal browser",
|
||||
error_text,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
browser_autofix_flag.store(false, Ordering::SeqCst);
|
||||
|
||||
// Set as global manager so core/session share the same instance
|
||||
codex_browser::global::set_global_browser_manager(browser_manager.clone())
|
||||
.await;
|
||||
@@ -14096,6 +14303,7 @@ impl ChatWidget<'_> {
|
||||
// Navigate to URL and wait for it to load
|
||||
let latest_screenshot = self.latest_browser_screenshot.clone();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
let browser_autofix_flag = self.browser_autofix_requested.clone();
|
||||
let url_for_goto = full_url.clone();
|
||||
|
||||
// Add status message
|
||||
@@ -14124,10 +14332,26 @@ impl ChatWidget<'_> {
|
||||
|
||||
// IMPORTANT: Start the browser manager first before navigating
|
||||
if let Err(e) = browser_manager.start().await {
|
||||
tracing::error!("Failed to start TUI browser manager: {}", e);
|
||||
let error_text = e.to_string();
|
||||
tracing::error!(
|
||||
"Failed to start TUI browser manager: {}",
|
||||
error_text
|
||||
);
|
||||
app_event_tx.send_background_event(format!(
|
||||
"❌ Failed to start internal browser: {}",
|
||||
error_text
|
||||
));
|
||||
ChatWidget::schedule_browser_autofix(
|
||||
app_event_tx.clone(),
|
||||
browser_autofix_flag.clone(),
|
||||
"launch the internal browser",
|
||||
error_text,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
browser_autofix_flag.store(false, Ordering::SeqCst);
|
||||
|
||||
// Set up navigation callback to auto-capture screenshots
|
||||
{
|
||||
let latest_screenshot_callback = latest_screenshot.clone();
|
||||
@@ -15856,20 +16080,33 @@ impl ChatWidget<'_> {
|
||||
let status_line = Line::from(status_spans);
|
||||
|
||||
let now = Instant::now();
|
||||
if self.header_wave.schedule_if_needed(now) {
|
||||
self.update_header_border_activation();
|
||||
let mut frame_needed = false;
|
||||
if ENABLE_WARP_STRIPES && self.header_wave.schedule_if_needed(now) {
|
||||
frame_needed = true;
|
||||
}
|
||||
if self.header_border.schedule_if_needed(now) {
|
||||
frame_needed = true;
|
||||
}
|
||||
if frame_needed {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ScheduleFrameIn(HeaderWaveEffect::FRAME_INTERVAL));
|
||||
.send(AppEvent::ScheduleFrameIn(HeaderBorderWeaveEffect::FRAME_INTERVAL));
|
||||
}
|
||||
|
||||
// Render the block first
|
||||
status_block.render(padded_area, buf);
|
||||
let wave_enabled = self.header_wave.is_enabled();
|
||||
let border_enabled = self.header_border.is_enabled();
|
||||
if wave_enabled {
|
||||
self.header_wave.render(padded_area, buf, now);
|
||||
}
|
||||
if border_enabled {
|
||||
self.header_border.render(padded_area, buf, now);
|
||||
}
|
||||
|
||||
// Then render the text inside with padding, centered
|
||||
let status_style = if wave_enabled {
|
||||
let effect_enabled = wave_enabled || border_enabled;
|
||||
let status_style = if effect_enabled {
|
||||
Style::default().fg(crate::colors::text())
|
||||
} else {
|
||||
Style::default()
|
||||
|
||||
@@ -36,6 +36,10 @@ enum GuidedTerminalMode {
|
||||
},
|
||||
Prompt { user_prompt: String },
|
||||
DirectCommand { command: String },
|
||||
Upgrade {
|
||||
initial_command: String,
|
||||
latest_version: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -112,6 +116,45 @@ pub(super) fn start_direct_terminal_session(
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn start_upgrade_terminal_session(
|
||||
app_event_tx: AppEventSender,
|
||||
terminal_id: u64,
|
||||
initial_command: String,
|
||||
latest_version: Option<String>,
|
||||
cwd: Option<String>,
|
||||
controller: TerminalRunController,
|
||||
controller_rx: Receiver<TerminalRunEvent>,
|
||||
debug_enabled: bool,
|
||||
) {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let _ = (
|
||||
app_event_tx,
|
||||
terminal_id,
|
||||
initial_command,
|
||||
latest_version,
|
||||
cwd,
|
||||
controller,
|
||||
controller_rx,
|
||||
debug_enabled,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
start_guided_terminal_session(
|
||||
app_event_tx,
|
||||
terminal_id,
|
||||
GuidedTerminalMode::Upgrade {
|
||||
initial_command,
|
||||
latest_version,
|
||||
},
|
||||
cwd,
|
||||
controller,
|
||||
controller_rx,
|
||||
debug_enabled,
|
||||
);
|
||||
}
|
||||
|
||||
fn start_guided_terminal_session(
|
||||
app_event_tx: AppEventSender,
|
||||
terminal_id: u64,
|
||||
@@ -130,9 +173,9 @@ fn start_guided_terminal_session(
|
||||
Err(err) => {
|
||||
let helper = match &mode {
|
||||
GuidedTerminalMode::AgentInstall { .. } => "Install helper",
|
||||
GuidedTerminalMode::Prompt { .. } | GuidedTerminalMode::DirectCommand { .. } => {
|
||||
"Terminal helper"
|
||||
}
|
||||
GuidedTerminalMode::Prompt { .. }
|
||||
| GuidedTerminalMode::DirectCommand { .. } => "Terminal helper",
|
||||
GuidedTerminalMode::Upgrade { .. } => "Upgrade helper",
|
||||
};
|
||||
let msg = format!("Failed to start {helper} runtime: {err}");
|
||||
app_event_tx.send(AppEvent::TerminalChunk {
|
||||
@@ -161,9 +204,9 @@ fn start_guided_terminal_session(
|
||||
) {
|
||||
let helper = match &mode {
|
||||
GuidedTerminalMode::AgentInstall { .. } => "Install helper",
|
||||
GuidedTerminalMode::Prompt { .. } | GuidedTerminalMode::DirectCommand { .. } => {
|
||||
"Terminal helper"
|
||||
}
|
||||
GuidedTerminalMode::Prompt { .. }
|
||||
| GuidedTerminalMode::DirectCommand { .. } => "Terminal helper",
|
||||
GuidedTerminalMode::Upgrade { .. } => "Upgrade helper",
|
||||
};
|
||||
let msg = if debug_enabled {
|
||||
format!("{helper} error: {err:#}")
|
||||
@@ -262,6 +305,25 @@ fn run_guided_loop(
|
||||
),
|
||||
"direct_terminal_flow",
|
||||
),
|
||||
GuidedTerminalMode::Upgrade {
|
||||
initial_command,
|
||||
latest_version,
|
||||
} => {
|
||||
let latest = latest_version
|
||||
.as_ref()
|
||||
.map(String::as_str)
|
||||
.unwrap_or("unknown");
|
||||
(
|
||||
"Upgrade helper",
|
||||
format!(
|
||||
"You are helping the user upgrade Code to the latest available version. The preferred upgrade command is `{initial_command}`. Prioritize resolving permission prompts (including sudo password requests) and confirm the upgrade succeeds."
|
||||
),
|
||||
format!(
|
||||
"Upgrade target: Code (latest version: {latest}).\nPrimary command: {initial_command}.\nPlatform: {platform}.\nSandbox: {sandbox}.\nWorking directory: {cwd_text}.\nRun the command, diagnose any failures (especially permissions), and guide the user until the upgrade completes."
|
||||
),
|
||||
"upgrade_terminal_flow",
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if debug_enabled {
|
||||
@@ -301,6 +363,20 @@ fn run_guided_loop(
|
||||
cwd_text,
|
||||
);
|
||||
}
|
||||
GuidedTerminalMode::Upgrade {
|
||||
initial_command,
|
||||
latest_version,
|
||||
} => {
|
||||
debug!(
|
||||
"[{}] Starting upgrade session: command={} latest_version={:?} platform={} sandbox={} cwd={}",
|
||||
helper_label,
|
||||
initial_command,
|
||||
latest_version,
|
||||
platform,
|
||||
sandbox,
|
||||
cwd_text,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +419,7 @@ fn run_guided_loop(
|
||||
conversation.push(make_message("user", initial_user));
|
||||
|
||||
let mut steps = match mode {
|
||||
GuidedTerminalMode::DirectCommand { .. } => 1,
|
||||
GuidedTerminalMode::DirectCommand { .. } | GuidedTerminalMode::Upgrade { .. } => 1,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
@@ -391,6 +467,50 @@ fn run_guided_loop(
|
||||
id: terminal_id,
|
||||
message: "Analyzing output…".to_string(),
|
||||
});
|
||||
} else if let GuidedTerminalMode::Upgrade { initial_command, .. } = mode {
|
||||
let wrapped = wrap_command(initial_command);
|
||||
if wrapped.is_empty() {
|
||||
app_event_tx.send(AppEvent::TerminalChunk {
|
||||
id: terminal_id,
|
||||
chunk: b"Unable to build upgrade command for execution.\n".to_vec(),
|
||||
_is_stderr: true,
|
||||
});
|
||||
app_event_tx.send(AppEvent::TerminalUpdateMessage {
|
||||
id: terminal_id,
|
||||
message: "Upgrade command could not be constructed.".to_string(),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
app_event_tx.send(AppEvent::TerminalRunCommand {
|
||||
id: terminal_id,
|
||||
command: wrapped,
|
||||
command_display: initial_command.clone(),
|
||||
controller: Some(controller.clone()),
|
||||
});
|
||||
|
||||
let Some((output, exit_code)) = collect_command_output(controller_rx)
|
||||
.context("collecting upgrade command output")?
|
||||
else {
|
||||
if debug_enabled {
|
||||
debug!("[Upgrade helper] Initial command cancelled by user");
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let truncated = tail_chars(&output, MAX_OUTPUT_CHARS);
|
||||
let summary = format!(
|
||||
"Command: {initial_command}\nExit code: {}\nOutput (last {} chars):\n{}",
|
||||
exit_code
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
truncated.chars().count(),
|
||||
truncated
|
||||
);
|
||||
conversation.push(make_message("user", summary));
|
||||
app_event_tx.send(AppEvent::TerminalSetAssistantMessage {
|
||||
id: terminal_id,
|
||||
message: "Analyzing output…".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
loop {
|
||||
@@ -452,7 +572,9 @@ fn run_guided_loop(
|
||||
let require_confirmation = match mode {
|
||||
GuidedTerminalMode::AgentInstall { .. } => steps > 1,
|
||||
GuidedTerminalMode::Prompt { .. } => steps > 1,
|
||||
GuidedTerminalMode::DirectCommand { .. } => true,
|
||||
GuidedTerminalMode::DirectCommand { .. } | GuidedTerminalMode::Upgrade { .. } => {
|
||||
true
|
||||
}
|
||||
};
|
||||
let final_command = if require_confirmation {
|
||||
let (gate_tx, gate_rx) = channel();
|
||||
|
||||
@@ -244,6 +244,7 @@ pub(super) fn finalize_all_running_as_interrupted(chat: &mut ChatWidget<'_>) {
|
||||
chat.bottom_pane.set_task_running(false);
|
||||
}
|
||||
chat.maybe_hide_spinner();
|
||||
chat.refresh_auto_drive_visuals();
|
||||
}
|
||||
|
||||
pub(super) fn finalize_all_running_due_to_answer(chat: &mut ChatWidget<'_>) {
|
||||
@@ -364,6 +365,8 @@ pub(super) fn finalize_all_running_due_to_answer(chat: &mut ChatWidget<'_>) {
|
||||
chat.tools_state.running_web_search.remove(&call_id);
|
||||
}
|
||||
}
|
||||
|
||||
chat.refresh_auto_drive_visuals();
|
||||
}
|
||||
|
||||
pub(super) fn try_merge_completed_exec_at(chat: &mut ChatWidget<'_>, idx: usize) {
|
||||
@@ -579,6 +582,7 @@ pub(super) fn handle_exec_begin_now(
|
||||
_ => "exploring…",
|
||||
};
|
||||
chat.bottom_pane.update_status_text(status_text.to_string());
|
||||
chat.refresh_auto_drive_visuals();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -633,6 +637,7 @@ pub(super) fn handle_exec_begin_now(
|
||||
chat.bottom_pane
|
||||
.update_status_text(format!("running command: {}", preview_short));
|
||||
}
|
||||
chat.refresh_auto_drive_visuals();
|
||||
}
|
||||
|
||||
pub(super) fn handle_exec_end_now(
|
||||
@@ -645,6 +650,7 @@ pub(super) fn handle_exec_end_now(
|
||||
chat.exec.unsuppress_exec_end(&call_id);
|
||||
chat.ended_call_ids.insert(call_id);
|
||||
chat.maybe_hide_spinner();
|
||||
chat.refresh_auto_drive_visuals();
|
||||
return;
|
||||
}
|
||||
chat.ended_call_ids.insert(super::ExecCallId(ev.call_id.clone()));
|
||||
@@ -655,6 +661,7 @@ pub(super) fn handle_exec_end_now(
|
||||
.remove(&super::ExecCallId(ev.call_id.clone()))
|
||||
{
|
||||
chat.maybe_hide_spinner();
|
||||
chat.refresh_auto_drive_visuals();
|
||||
return;
|
||||
}
|
||||
let ExecCommandEndEvent {
|
||||
@@ -738,6 +745,7 @@ pub(super) fn handle_exec_end_now(
|
||||
};
|
||||
chat.bottom_pane.update_status_text(status_text);
|
||||
chat.maybe_hide_spinner();
|
||||
chat.refresh_auto_drive_visuals();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -829,6 +837,7 @@ pub(super) fn handle_exec_end_now(
|
||||
.update_status_text(format!("command failed (exit {})", exit_code));
|
||||
}
|
||||
chat.maybe_hide_spinner();
|
||||
chat.refresh_auto_drive_visuals();
|
||||
}
|
||||
|
||||
// Stable ordering now inserts at the correct position; these helpers are removed.
|
||||
|
||||
@@ -694,6 +694,32 @@ pub(crate) fn make_chatwidget_manual_with_sender() -> (
|
||||
(widget, app_event_tx, rx, op_rx)
|
||||
}
|
||||
|
||||
struct EnvGuard {
|
||||
key: String,
|
||||
prev: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set(key: &str, value: &str) -> Self {
|
||||
let prev = std::env::var(key).ok();
|
||||
std::env::set_var(key, value);
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
prev,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(prev) = self.prev.take() {
|
||||
std::env::set_var(&self.key, prev);
|
||||
} else {
|
||||
std::env::remove_var(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_insert_history(
|
||||
rx: &std::sync::mpsc::Receiver<AppEvent>,
|
||||
) -> Vec<Vec<ratatui::text::Line<'static>>> {
|
||||
@@ -975,6 +1001,9 @@ fn pump_app_events(chat: &mut ChatWidget<'_>, rx: &std::sync::mpsc::Receiver<App
|
||||
AppEvent::DispatchCommand(SlashCommand::Undo, _command_text) => {
|
||||
chat.handle_undo_command();
|
||||
}
|
||||
AppEvent::DispatchCommand(SlashCommand::Update, command_text) => {
|
||||
chat.handle_update_command(&command_text);
|
||||
}
|
||||
AppEvent::ShowUndoOptions { index } => {
|
||||
chat.show_undo_restore_options(index);
|
||||
}
|
||||
@@ -1061,6 +1090,41 @@ fn slash_agents_opens_overview() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_upgrade_opens_guided_terminal() {
|
||||
let _guard = EnvGuard::set("CODEX_MANAGED_BY_NPM", "1");
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
chat.latest_upgrade_version = Some("9.9.9".to_string());
|
||||
|
||||
let script = [
|
||||
ScriptStep::key_char('/'),
|
||||
ScriptStep::key_char('u'),
|
||||
ScriptStep::key_char('p'),
|
||||
ScriptStep::key_char('g'),
|
||||
ScriptStep::key_char('r'),
|
||||
ScriptStep::key_char('a'),
|
||||
ScriptStep::key_char('d'),
|
||||
ScriptStep::key_char('e'),
|
||||
ScriptStep::enter(),
|
||||
];
|
||||
run_script(&mut chat, &script, &rx);
|
||||
|
||||
let mut saw_open_terminal = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::OpenTerminal(launch) = event {
|
||||
saw_open_terminal = true;
|
||||
assert_eq!(launch.title, "Upgrade Code");
|
||||
assert!(
|
||||
launch.command_display.contains("Guided"),
|
||||
"expected guided terminal display, got {}",
|
||||
launch.command_display
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_open_terminal, "expected guided upgrade terminal to open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_undo_shows_no_snapshot_state() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
@@ -6,6 +6,71 @@ use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Margin, Rect};
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub struct HeaderBorderWeaveEffect {
|
||||
enabled: Cell<bool>,
|
||||
started_at: Cell<Option<Instant>>,
|
||||
next_frame: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl HeaderBorderWeaveEffect {
|
||||
pub const FRAME_INTERVAL: Duration = HeaderWaveEffect::FRAME_INTERVAL;
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
enabled: Cell::new(false),
|
||||
started_at: Cell::new(None),
|
||||
next_frame: Cell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, enabled: bool, now: Instant) {
|
||||
self.enabled.set(enabled);
|
||||
if enabled {
|
||||
self.started_at.set(Some(now));
|
||||
self.next_frame.set(None);
|
||||
} else {
|
||||
self.started_at.set(None);
|
||||
self.next_frame.set(None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled.get()
|
||||
}
|
||||
|
||||
pub fn schedule_if_needed(&self, now: Instant) -> bool {
|
||||
if !self.enabled.get() {
|
||||
return false;
|
||||
}
|
||||
match self.next_frame.get() {
|
||||
Some(due) if due > now => false,
|
||||
_ => {
|
||||
self.next_frame
|
||||
.set(Some(now + Self::FRAME_INTERVAL));
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, area: Rect, buf: &mut Buffer, now: Instant) {
|
||||
if !self.enabled.get() {
|
||||
return;
|
||||
}
|
||||
if area.width < 2 || area.height < 2 {
|
||||
return;
|
||||
}
|
||||
let start = self.started_at.get().unwrap_or_else(|| {
|
||||
self.started_at.set(Some(now));
|
||||
now
|
||||
});
|
||||
let elapsed = now.saturating_duration_since(start).as_secs_f32();
|
||||
let tick_interval = Self::FRAME_INTERVAL.as_secs_f32().max(0.001);
|
||||
let tick = (elapsed / tick_interval).floor() as u32;
|
||||
let phase = wrap_unit(elapsed * 0.25);
|
||||
render_border_weave(area, buf, phase, tick);
|
||||
}
|
||||
}
|
||||
|
||||
/// Declarative background animation used by the TUI status header.
|
||||
pub struct HeaderWaveEffect {
|
||||
enabled: Cell<bool>,
|
||||
@@ -115,3 +180,69 @@ fn spectral_color(t: f32) -> Color {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_border_weave(area: Rect, buf: &mut Buffer, phase: f32, tick: u32) {
|
||||
let positions = border_positions(area);
|
||||
if positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let base_color = crate::colors::border();
|
||||
let highlight = crate::colors::mix_toward(base_color, prism_color(phase), 0.75);
|
||||
for (idx, &(x, y)) in positions.iter().enumerate() {
|
||||
let tick_step = (tick as usize) % 12;
|
||||
let phase_idx = (idx + (12 - tick_step)) % 12;
|
||||
let color = if phase_idx < 4 {
|
||||
highlight
|
||||
} else if phase_idx < 8 {
|
||||
crate::colors::mix_toward(highlight, base_color, 0.5)
|
||||
} else {
|
||||
crate::colors::mix_toward(base_color, crate::colors::text_dim(), 0.35)
|
||||
};
|
||||
let cell = &mut buf[(x, y)];
|
||||
cell.set_fg(color);
|
||||
cell.set_bg(crate::colors::background());
|
||||
}
|
||||
}
|
||||
|
||||
fn border_positions(area: Rect) -> Vec<(u16, u16)> {
|
||||
if area.width < 2 || area.height < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut positions = Vec::new();
|
||||
let x0 = area.x;
|
||||
let x1 = area.x + area.width.saturating_sub(1);
|
||||
let y0 = area.y;
|
||||
let y1 = area.y + area.height.saturating_sub(1);
|
||||
|
||||
for x in x0..=x1 {
|
||||
positions.push((x, y0));
|
||||
}
|
||||
if y1 > y0 {
|
||||
for y in y0 + 1..=y1 {
|
||||
positions.push((x1, y));
|
||||
}
|
||||
}
|
||||
if x1 > x0 {
|
||||
for x in (x0..x1).rev() {
|
||||
positions.push((x, y1));
|
||||
}
|
||||
}
|
||||
if y1 > y0 + 1 {
|
||||
for y in (y0 + 1..y1).rev() {
|
||||
positions.push((x0, y));
|
||||
}
|
||||
}
|
||||
|
||||
positions
|
||||
}
|
||||
|
||||
fn prism_color(t: f32) -> Color {
|
||||
let angle = wrap_unit(t) * TAU;
|
||||
let r = (angle.sin() * 0.5 + 0.5).powf(0.55);
|
||||
let g = ((angle + TAU / 3.0).sin() * 0.5 + 0.5).powf(0.55);
|
||||
let b = ((angle + 2.0 * TAU / 3.0).sin() * 0.5 + 0.5).powf(0.55);
|
||||
Color::Rgb(
|
||||
(r * 255.0).clamp(0.0, 255.0) as u8,
|
||||
(g * 255.0).clamp(0.0, 255.0) as u8,
|
||||
(b * 255.0).clamp(0.0, 255.0) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
+188
-24
@@ -147,9 +147,15 @@ pub fn resolve_upgrade_resolution() -> UpgradeResolution {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn auto_upgrade_if_enabled(config: &Config) -> anyhow::Result<Option<String>> {
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AutoUpgradeOutcome {
|
||||
pub installed_version: Option<String>,
|
||||
pub user_notice: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn auto_upgrade_if_enabled(config: &Config) -> anyhow::Result<AutoUpgradeOutcome> {
|
||||
if !config.auto_upgrade_enabled {
|
||||
return Ok(None);
|
||||
return Ok(AutoUpgradeOutcome::default());
|
||||
}
|
||||
|
||||
let resolution = resolve_upgrade_resolution();
|
||||
@@ -160,7 +166,7 @@ pub async fn auto_upgrade_if_enabled(config: &Config) -> anyhow::Result<Option<S
|
||||
} if !command.is_empty() => (command, command_display),
|
||||
_ => {
|
||||
info!("auto-upgrade enabled but no managed installer detected; skipping");
|
||||
return Ok(None);
|
||||
return Ok(AutoUpgradeOutcome::default());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,24 +174,24 @@ pub async fn auto_upgrade_if_enabled(config: &Config) -> anyhow::Result<Option<S
|
||||
Ok(info) => info,
|
||||
Err(err) => {
|
||||
warn!("auto-upgrade: failed to check for updates: {err}");
|
||||
return Ok(None);
|
||||
return Ok(AutoUpgradeOutcome::default());
|
||||
}
|
||||
};
|
||||
|
||||
let Some(latest_version) = info.latest_version.clone() else {
|
||||
// Already up to date
|
||||
return Ok(None);
|
||||
return Ok(AutoUpgradeOutcome::default());
|
||||
};
|
||||
|
||||
let lock = match AutoUpgradeLock::acquire(&config.codex_home) {
|
||||
Ok(Some(lock)) => lock,
|
||||
Ok(None) => {
|
||||
info!("auto-upgrade already in progress by another instance; skipping");
|
||||
return Ok(None);
|
||||
return Ok(AutoUpgradeOutcome::default());
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("auto-upgrade: unable to acquire lock: {err}");
|
||||
return Ok(None);
|
||||
return Ok(AutoUpgradeOutcome::default());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,17 +200,75 @@ pub async fn auto_upgrade_if_enabled(config: &Config) -> anyhow::Result<Option<S
|
||||
latest_version = latest_version.as_str(),
|
||||
"auto-upgrade: running managed installer"
|
||||
);
|
||||
let result = run_upgrade_command(command).await;
|
||||
let result = execute_upgrade_command(&command).await;
|
||||
drop(lock);
|
||||
|
||||
let mut outcome = AutoUpgradeOutcome {
|
||||
installed_version: None,
|
||||
user_notice: None,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
info!("auto-upgrade: successfully installed {latest_version}");
|
||||
Ok(Some(latest_version))
|
||||
Ok(primary) => {
|
||||
if primary.success {
|
||||
info!("auto-upgrade: successfully installed {latest_version}");
|
||||
outcome.installed_version = Some(latest_version);
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
if !starts_with_sudo(&command) {
|
||||
info!("auto-upgrade: retrying with sudo -n");
|
||||
let sudo_command = wrap_with_sudo(&command);
|
||||
match execute_upgrade_command(&sudo_command).await {
|
||||
Ok(fallback) if fallback.success => {
|
||||
info!("auto-upgrade: sudo retry succeeded; installed {latest_version}");
|
||||
outcome.installed_version = Some(latest_version);
|
||||
return Ok(outcome);
|
||||
}
|
||||
Ok(fallback) => {
|
||||
if sudo_requires_manual_intervention(&fallback.stderr, fallback.status)
|
||||
{
|
||||
outcome.user_notice = Some(format!(
|
||||
"Automatic upgrade needs your attention. Run `/upgrade` to finish with `{}`.",
|
||||
command_display
|
||||
));
|
||||
}
|
||||
warn!(
|
||||
"auto-upgrade: sudo retry failed: status={:?} stderr={}",
|
||||
fallback.status,
|
||||
truncate_for_log(&fallback.stderr)
|
||||
);
|
||||
return Ok(outcome);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("auto-upgrade: sudo retry error: {err}");
|
||||
outcome.user_notice = Some(format!(
|
||||
"Automatic upgrade could not escalate permissions. Run `/upgrade` to finish with `{}`.",
|
||||
command_display
|
||||
));
|
||||
return Ok(outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
let _ = primary; // suppress unused warning on non-Unix targets
|
||||
}
|
||||
|
||||
warn!(
|
||||
"auto-upgrade: upgrade command failed: status={:?} stderr={}",
|
||||
primary.status,
|
||||
truncate_for_log(&primary.stderr)
|
||||
);
|
||||
Ok(outcome)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("auto-upgrade: upgrade command failed: {err}");
|
||||
Ok(None)
|
||||
warn!("auto-upgrade: failed to launch upgrade command: {err}");
|
||||
Ok(outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,7 +352,14 @@ impl Drop for AutoUpgradeLock {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_upgrade_command(command: Vec<String>) -> anyhow::Result<()> {
|
||||
#[derive(Debug, Clone)]
|
||||
struct CommandCapture {
|
||||
success: bool,
|
||||
status: Option<i32>,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
async fn execute_upgrade_command(command: &[String]) -> anyhow::Result<CommandCapture> {
|
||||
if command.is_empty() {
|
||||
anyhow::bail!("upgrade command is empty");
|
||||
}
|
||||
@@ -297,18 +368,111 @@ async fn run_upgrade_command(command: Vec<String>) -> anyhow::Result<()> {
|
||||
if command.len() > 1 {
|
||||
cmd.args(&command[1..]);
|
||||
}
|
||||
cmd.stdin(Stdio::null());
|
||||
cmd.stdout(Stdio::null());
|
||||
cmd.stderr(Stdio::null());
|
||||
|
||||
let status = cmd.status().await?;
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"upgrade command exited with status {}",
|
||||
status.code().map_or_else(|| "signal".to_string(), |c| c.to_string())
|
||||
);
|
||||
cmd.stdin(Stdio::null());
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let output = cmd.output().await?;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
|
||||
Ok(CommandCapture {
|
||||
success: output.status.success(),
|
||||
status: output.status.code(),
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn wrap_with_sudo(command: &[String]) -> Vec<String> {
|
||||
let mut out = Vec::with_capacity(command.len() + 3);
|
||||
out.push("sudo".to_string());
|
||||
out.push("-n".to_string());
|
||||
out.push("--".to_string());
|
||||
out.extend(command.iter().cloned());
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn starts_with_sudo(command: &[String]) -> bool {
|
||||
command
|
||||
.first()
|
||||
.map(|c| c.eq_ignore_ascii_case("sudo"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn sudo_requires_manual_intervention(stderr: &str, status: Option<i32>) -> bool {
|
||||
let lowered = stderr.to_ascii_lowercase();
|
||||
let needs_password = lowered.contains("password is required")
|
||||
|| lowered.contains("a password is required")
|
||||
|| lowered.contains("no tty present and no askpass program specified")
|
||||
|| lowered.contains("must be run from a terminal")
|
||||
|| lowered.contains("may not run sudo")
|
||||
|| lowered.contains("permission denied");
|
||||
needs_password && status == Some(1)
|
||||
}
|
||||
|
||||
fn truncate_for_log(text: &str) -> String {
|
||||
const LIMIT: usize = 256;
|
||||
if text.len() <= LIMIT {
|
||||
text.replace('\n', " ")
|
||||
} else {
|
||||
let mut truncated = text[..LIMIT].to_string();
|
||||
truncated.push_str("…");
|
||||
truncated.replace('\n', " ")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truncate_for_log_short_text_preserved() {
|
||||
let text = "no issues";
|
||||
assert_eq!(truncate_for_log(text), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_log_truncates_long_messages() {
|
||||
let long = "a".repeat(300);
|
||||
let truncated = truncate_for_log(&long);
|
||||
assert!(truncated.ends_with('…'));
|
||||
assert!(truncated.len() <= 257);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn wrap_with_sudo_injects_dash_dash() {
|
||||
let original = vec!["npm".to_string(), "install".to_string()];
|
||||
let wrapped = wrap_with_sudo(&original);
|
||||
assert_eq!(wrapped[0], "sudo");
|
||||
assert_eq!(wrapped[1], "-n");
|
||||
assert_eq!(wrapped[2], "--");
|
||||
assert_eq!(&wrapped[3..], original);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn starts_with_sudo_detects_prefix_case_insensitive() {
|
||||
assert!(starts_with_sudo(&["sudo".into(), "true".into()]));
|
||||
assert!(starts_with_sudo(&["SUDO".into(), "true".into()]));
|
||||
assert!(!starts_with_sudo(&["npm".into(), "install".into()]));
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn sudo_requires_manual_intervention_matches_common_errors() {
|
||||
let stderr = "sudo: a password is required";
|
||||
assert!(sudo_requires_manual_intervention(stderr, Some(1)));
|
||||
|
||||
let stderr_case = "sudo: Permission denied";
|
||||
assert!(sudo_requires_manual_intervention(stderr_case, Some(1)));
|
||||
|
||||
let stderr_other = "sudo: authentication succeeded";
|
||||
assert!(!sudo_requires_manual_intervention(stderr_other, Some(0)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
|
||||
|
||||
@@ -47,8 +47,8 @@ Notes
|
||||
- `/prompts`: show example prompts.
|
||||
- `/status`: show current session configuration and token usage.
|
||||
- `/limits`: visualize current hourly and weekly rate-limit usage.
|
||||
- `/update`: check the installed version, detect available upgrades, and run the
|
||||
appropriate installer when possible.
|
||||
- `/update`: check the installed version, detect available upgrades, and open a
|
||||
guided upgrade terminal that runs the installer interactively when possible.
|
||||
- `/notifications`: inspect and toggle TUI notifications.
|
||||
- `/mcp`: manage MCP servers (status/on/off/add).
|
||||
- `/validation [status|on|off|<tool> (on|off)]`: inspect or toggle validation
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
## @just-every/code v0.2.170
|
||||
## @just-every/code v0.2.173
|
||||
|
||||
This update modernizes the TUI event pipeline and shores up notifications, quotas, and account workflows.
|
||||
This release tightens the TUI browser loop so failures hand off to Code with clearer diagnostics.
|
||||
|
||||
### Changes
|
||||
|
||||
- TUI/History: drive exec, assistant, explore, and rate-limit cells from domain events for consistent streaming.
|
||||
- TUI/Notifications: add an OSC toggle command and harden slash routing, persistence, and filters so alerts stay accurate.
|
||||
- Usage/Rate-limits: compact persisted stats, relog after resets, and persist reset state to keep quotas current.
|
||||
- TUI/Accounts: prioritize ChatGPT accounts in login flows and restore the label prefix for clarity.
|
||||
- UX: show a session resume hint on exit, surface Zed model selection, and restore Option+Enter newline plus Cmd+Z undo.
|
||||
- TUI/Browser: auto hand off /browser startup failures to Code so sessions self-heal.
|
||||
- TUI/Browser: sanitize and surface error details when handoff triggers for faster diagnosis.
|
||||
|
||||
### Install
|
||||
|
||||
@@ -17,4 +14,4 @@ npm install -g @just-every/code@latest
|
||||
code
|
||||
```
|
||||
|
||||
Compare: https://github.com/just-every/code/compare/v0.2.169...v0.2.170
|
||||
Compare: https://github.com/just-every/code/compare/v0.2.172...v0.2.173
|
||||
|
||||
Reference in New Issue
Block a user