feat: add plan names and terminal title sync

This commit is contained in:
James Peter
2025-09-17 15:47:52 +10:00
parent d7ce134537
commit 29eda799d4
9 changed files with 101 additions and 25 deletions
+28 -6
View File
@@ -38,15 +38,17 @@ pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
let mut properties = BTreeMap::new();
properties.insert(
"explanation".to_string(),
JsonSchema::String { description: None },
"name".to_string(),
JsonSchema::String {
description: Some("2-5 word title describing the plan".to_string()),
},
);
properties.insert("plan".to_string(), plan_items_schema);
OpenAiTool::Function(ResponsesApiTool {
name: "update_plan".to_string(),
description: r#"Updates the task plan.
Provide an optional explanation and a list of plan items, each with a step and status.
Provide an optional 2-5 word name and a list of plan items, each with a step and status.
At most one step can be in_progress at a time.
"#
.to_string(),
@@ -62,9 +64,14 @@ At most one step can be in_progress at a time.
/// This function doesn't do anything useful. However, it gives the model a structured way to record its plan that clients can read and render.
/// So it's the _inputs_ to this function that are useful to clients, not the outputs and neither are actually useful for the model other
/// than forcing it to come up and document a plan (TBD how that affects performance).
pub(crate) async fn handle_update_plan(session: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
pub(crate) async fn handle_update_plan(
session: &Session,
ctx: &ToolCallCtx,
arguments: String,
) -> ResponseInputItem {
match parse_update_plan_arguments(arguments, &ctx.call_id) {
Ok(args) => {
Ok(mut args) => {
args.name = normalize_plan_name(args.name.take());
let output = ResponseInputItem::FunctionCallOutput {
call_id: ctx.call_id.clone(),
output: FunctionCallOutputPayload {
@@ -72,7 +79,9 @@ pub(crate) async fn handle_update_plan(session: &Session, ctx: &ToolCallCtx, arg
success: Some(true),
},
};
session.send_ordered_from_ctx(ctx, EventMsg::PlanUpdate(args)).await;
session
.send_ordered_from_ctx(ctx, EventMsg::PlanUpdate(args))
.await;
output
}
Err(output) => *output,
@@ -97,3 +106,16 @@ fn parse_update_plan_arguments(
}
}
}
fn normalize_plan_name(name: Option<String>) -> Option<String> {
let Some(name) = name else {
return None;
};
let words: Vec<&str> = name.split_whitespace().collect();
if words.is_empty() {
return None;
}
Some(words.join(" "))
}
@@ -655,8 +655,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
println!();
}
EventMsg::PlanUpdate(plan_update_event) => {
let UpdatePlanArgs { explanation, plan } = plan_update_event;
ts_println!(self, "explanation: {explanation:?}");
let UpdatePlanArgs { name, plan } = plan_update_event;
ts_println!(self, "name: {name:?}");
ts_println!(self, "plan: {plan:?}");
}
EventMsg::GetHistoryEntryResponse(_) => {
+1 -1
View File
@@ -22,6 +22,6 @@ pub struct PlanItemArg {
#[serde(deny_unknown_fields)]
pub struct UpdatePlanArgs {
#[serde(default)]
pub explanation: Option<String>,
pub name: Option<String>,
pub plan: Vec<PlanItemArg>,
}
+22 -2
View File
@@ -133,6 +133,8 @@ pub(crate) struct App<'a> {
alt_screen_active: bool,
terminal_runs: HashMap<u64, TerminalRunState>,
terminal_title_override: Option<String>,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -149,6 +151,8 @@ pub(crate) struct ChatWidgetArgs {
}
impl App<'_> {
const DEFAULT_TERMINAL_TITLE: &'static str = "Code";
pub(crate) fn new(
config: Config,
initial_prompt: Option<String>,
@@ -317,9 +321,21 @@ impl App<'_> {
timing: TimingStats::default(),
alt_screen_active: start_in_alt,
terminal_runs: HashMap::new(),
terminal_title_override: None,
}
}
fn apply_terminal_title(&self) {
let title = self
.terminal_title_override
.as_deref()
.unwrap_or(Self::DEFAULT_TERMINAL_TITLE);
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::SetTitle(title.to_string())
);
}
/// Schedule a redraw immediately and open a short debounce window to coalesce
/// subsequent requests. Crucially, even if a timer is already armed (e.g., an
@@ -1236,6 +1252,10 @@ impl App<'_> {
widget.set_github_watcher(enabled);
}
}
AppEvent::SetTerminalTitle { title } => {
self.terminal_title_override = title;
self.apply_terminal_title();
}
AppEvent::UpdateMcpServer { name, enable } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.toggle_mcp_server(&name, enable);
@@ -1314,9 +1334,9 @@ impl App<'_> {
)),
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::terminal::SetTitle("Code"),
crossterm::terminal::EnableLineWrap
);
self.apply_terminal_title();
// Update config and save to file
if let AppState::Chat { widget } = &mut self.app_state {
@@ -1355,9 +1375,9 @@ impl App<'_> {
)),
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::terminal::SetTitle("Code"),
crossterm::terminal::EnableLineWrap
);
self.apply_terminal_title();
// Retint pre-rendered history cells so the preview reflects immediately
if let AppState::Chat { widget } = &mut self.app_state {
+3
View File
@@ -49,6 +49,9 @@ pub(crate) enum AppEvent {
/// Actually draw the next frame.
Redraw,
/// Update the terminal title override. `None` restores the default title.
SetTerminalTitle { title: Option<String> },
/// Schedule a one-shot animation frame roughly after the given duration.
/// Multiple requests are coalesced by the central frame scheduler.
ScheduleFrameIn(Duration),
+1
View File
@@ -40,6 +40,7 @@ impl AppEventSender {
| AppEvent::RequestRedraw
| AppEvent::Redraw
| AppEvent::ExitRequest
| AppEvent::SetTerminalTitle { .. }
);
let tx = if is_high { &self.high_tx } else { &self.bulk_tx };
+35
View File
@@ -114,6 +114,7 @@ use codex_core::config::set_github_check_on_push;
use codex_file_search::FileMatch;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::plan_tool::StepStatus;
use ratatui::style::Stylize;
use ratatui::symbols::scrollbar as scrollbar_symbols;
use ratatui::text::Text as RtText;
@@ -214,6 +215,7 @@ pub(crate) struct ChatWidget<'a> {
agent_context: Option<String>,
agent_task: Option<String>,
overall_task_status: String,
active_plan_title: Option<String>,
// Sparkline data for showing agent activity (using RefCell for interior mutability)
// Each tuple is (value, is_completed) where is_completed indicates if any agent was complete at that time
sparkline_data: std::cell::RefCell<Vec<(u64, bool)>>,
@@ -811,6 +813,15 @@ impl ChatWidget<'_> {
}
}
}
fn apply_plan_terminal_title(&mut self, title: Option<String>) {
if self.active_plan_title == title {
return;
}
self.active_plan_title = title.clone();
self.app_event_tx
.send(AppEvent::SetTerminalTitle { title });
}
// Allocate a new synthetic key for internal (non-LLM) messages at the bottom of the
// current (active) request: (req = last_seen, out = +∞, seq = monotonic).
fn next_internal_key(&mut self) -> OrderKey {
@@ -1827,6 +1838,7 @@ impl ChatWidget<'_> {
agent_context: None,
agent_task: None,
overall_task_status: "preparing".to_string(),
active_plan_title: None,
sparkline_data: std::cell::RefCell::new(Vec::new()),
last_sparkline_update: std::cell::RefCell::new(std::time::Instant::now()),
stream: crate::streaming::controller::StreamController::new(config.clone()),
@@ -2008,6 +2020,7 @@ impl ChatWidget<'_> {
agent_context: None,
agent_task: None,
overall_task_status: "preparing".to_string(),
active_plan_title: None,
sparkline_data: std::cell::RefCell::new(Vec::new()),
last_sparkline_update: std::cell::RefCell::new(std::time::Instant::now()),
stream: crate::streaming::controller::StreamController::new(config.clone()),
@@ -3775,6 +3788,22 @@ impl ChatWidget<'_> {
self.on_error(message);
}
EventMsg::PlanUpdate(update) => {
let (plan_title, plan_active) = {
let title = update
.name
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let total = update.plan.len();
let completed = update
.plan
.iter()
.filter(|p| matches!(p.status, StepStatus::Completed))
.count();
let active = total > 0 && completed < total;
(title, active)
};
// Insert plan updates at the time they occur. If the provider
// supplied OrderMeta, honor it. Otherwise, derive a key within
// the current (last-seen) request — do NOT advance to the next
@@ -3787,6 +3816,12 @@ impl ChatWidget<'_> {
);
// If we inserted during streaming, keep the reasoning ellipsis visible.
self.restore_reasoning_in_progress_if_streaming();
let desired_title = if plan_active {
Some(plan_title.unwrap_or_else(|| "Plan".to_string()))
} else {
None
};
self.apply_plan_terminal_title(desired_title);
}
EventMsg::ExecApprovalRequest(ev) => {
let id2 = id.clone();
+2 -2
View File
@@ -962,7 +962,7 @@ fn apply_patch_request_shows_diff_summary() {
fn plan_update_renders_history_cell() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
let update = UpdatePlanArgs {
explanation: Some("Adapting plan".to_string()),
name: Some("Feature rollout plan".to_string()),
plan: vec![
PlanItemArg {
step: "Explore codebase".into(),
@@ -986,7 +986,7 @@ fn plan_update_renders_history_cell() {
assert!(!cells.is_empty(), "expected plan update cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
blob.contains("Update plan"),
blob.contains("Feature rollout plan"),
"missing plan header: {blob:?}"
);
assert!(blob.contains("Explore codebase"));
+7 -12
View File
@@ -7789,7 +7789,7 @@ fn plan_progress_icon(total: usize, completed: usize) -> &'static str {
}
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
let UpdatePlanArgs { explanation, plan } = update;
let UpdatePlanArgs { name, plan } = update;
let mut lines: Vec<Line<'static>> = Vec::new();
let total = plan.len();
@@ -7815,8 +7815,13 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
// Build header without leading icon; icon will render in the gutter
let mut header: Vec<Span> = Vec::new();
let title = name
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.unwrap_or("Plan");
header.push(Span::styled(
"Plan",
title.to_string(),
Style::default()
.fg(header_color)
.add_modifier(Modifier::BOLD),
@@ -7838,16 +7843,6 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
header.push(Span::raw(format!("{completed}/{total}")));
lines.push(Line::from(header));
// Optional explanation/note from the model
if let Some(expl) = explanation.and_then(|s| {
let t = s.trim().to_string();
if t.is_empty() { None } else { Some(t) }
}) {
for l in expl.lines() {
lines.push(Line::from(l.to_string()).dim());
}
}
// Steps styled as checkbox items
if plan.is_empty() {
lines.push(Line::from("(no steps provided)".dim().italic()));