mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat: add plan names and terminal title sync
This commit is contained in:
@@ -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(_) => {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user