mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
- Copy latest tui sources into tui2
- Restore notifications, tests, and styles
- Keep codex-tui interop conversions and snapshots
The expected changes that are necessary to make this work are still in
place:
diff -ru codex-rs/tui codex-rs/tui2 --exclude='*.snap'
--exclude='*.snap.new'
```diff
diff -ru --ex codex-rs/tui/Cargo.toml codex-rs/tui2/Cargo.toml
--- codex-rs/tui/Cargo.toml 2025-12-12 16:39:12
+++ codex-rs/tui2/Cargo.toml 2025-12-12 17:31:01
@@ -1,15 +1,15 @@
[package]
-name = "codex-tui"
+name = "codex-tui2"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
-name = "codex-tui"
+name = "codex-tui2"
path = "src/main.rs"
[lib]
-name = "codex_tui"
+name = "codex_tui2"
path = "src/lib.rs"
[features]
@@ -42,6 +42,7 @@
codex-login = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
+codex-tui = { workspace = true }
color-eyre = { workspace = true }
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
derive_more = { workspace = true, features = ["is_variant"] }
diff -ru --ex codex-rs/tui/src/app.rs codex-rs/tui2/src/app.rs
--- codex-rs/tui/src/app.rs 2025-12-12 16:39:05
+++ codex-rs/tui2/src/app.rs 2025-12-12 17:30:36
@@ -69,6 +69,16 @@
pub update_action: Option<UpdateAction>,
}
+impl From<AppExitInfo> for codex_tui::AppExitInfo {
+ fn from(info: AppExitInfo) -> Self {
+ codex_tui::AppExitInfo {
+ token_usage: info.token_usage,
+ conversation_id: info.conversation_id,
+ update_action: info.update_action.map(Into::into),
+ }
+ }
+}
+
fn session_summary(
token_usage: TokenUsage,
conversation_id: Option<ConversationId>,
Only in codex-rs/tui/src/bin: md-events.rs
Only in codex-rs/tui2/src/bin: md-events2.rs
diff -ru --ex codex-rs/tui/src/cli.rs codex-rs/tui2/src/cli.rs
--- codex-rs/tui/src/cli.rs 2025-11-19 13:40:42
+++ codex-rs/tui2/src/cli.rs 2025-12-12 17:30:43
@@ -88,3 +88,28 @@
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}
+
+impl From<codex_tui::Cli> for Cli {
+ fn from(cli: codex_tui::Cli) -> Self {
+ Self {
+ prompt: cli.prompt,
+ images: cli.images,
+ resume_picker: cli.resume_picker,
+ resume_last: cli.resume_last,
+ resume_session_id: cli.resume_session_id,
+ resume_show_all: cli.resume_show_all,
+ model: cli.model,
+ oss: cli.oss,
+ oss_provider: cli.oss_provider,
+ config_profile: cli.config_profile,
+ sandbox_mode: cli.sandbox_mode,
+ approval_policy: cli.approval_policy,
+ full_auto: cli.full_auto,
+ dangerously_bypass_approvals_and_sandbox: cli.dangerously_bypass_approvals_and_sandbox,
+ cwd: cli.cwd,
+ web_search: cli.web_search,
+ add_dir: cli.add_dir,
+ config_overrides: cli.config_overrides,
+ }
+ }
+}
diff -ru --ex codex-rs/tui/src/main.rs codex-rs/tui2/src/main.rs
--- codex-rs/tui/src/main.rs 2025-12-12 16:39:05
+++ codex-rs/tui2/src/main.rs 2025-12-12 16:39:06
@@ -1,8 +1,8 @@
use clap::Parser;
use codex_arg0::arg0_dispatch_or_else;
use codex_common::CliConfigOverrides;
-use codex_tui::Cli;
-use codex_tui::run_main;
+use codex_tui2::Cli;
+use codex_tui2::run_main;
#[derive(Parser, Debug)]
struct TopCli {
diff -ru --ex codex-rs/tui/src/update_action.rs codex-rs/tui2/src/update_action.rs
--- codex-rs/tui/src/update_action.rs 2025-11-19 11:11:47
+++ codex-rs/tui2/src/update_action.rs 2025-12-12 17:30:48
@@ -9,6 +9,20 @@
BrewUpgrade,
}
+impl From<UpdateAction> for codex_tui::update_action::UpdateAction {
+ fn from(action: UpdateAction) -> Self {
+ match action {
+ UpdateAction::NpmGlobalLatest => {
+ codex_tui::update_action::UpdateAction::NpmGlobalLatest
+ }
+ UpdateAction::BunGlobalLatest => {
+ codex_tui::update_action::UpdateAction::BunGlobalLatest
+ }
+ UpdateAction::BrewUpgrade => codex_tui::update_action::UpdateAction::BrewUpgrade,
+ }
+ }
+}
+
impl UpdateAction {
/// Returns the list of command-line arguments for invoking the update.
pub fn command_args(self) -> (&'static str, &'static [&'static str]) {
```
396 lines
15 KiB
Rust
396 lines
15 KiB
Rust
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::widgets::WidgetRef;
|
|
|
|
use super::popup_consts::MAX_POPUP_ROWS;
|
|
use super::scroll_state::ScrollState;
|
|
use super::selection_popup_common::GenericDisplayRow;
|
|
use super::selection_popup_common::render_rows;
|
|
use crate::render::Insets;
|
|
use crate::render::RectExt;
|
|
use crate::slash_command::SlashCommand;
|
|
use crate::slash_command::built_in_slash_commands;
|
|
use codex_common::fuzzy_match::fuzzy_match;
|
|
use codex_protocol::custom_prompts::CustomPrompt;
|
|
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
|
use std::collections::HashSet;
|
|
|
|
/// A selectable item in the popup: either a built-in command or a user prompt.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum CommandItem {
|
|
Builtin(SlashCommand),
|
|
// Index into `prompts`
|
|
UserPrompt(usize),
|
|
}
|
|
|
|
pub(crate) struct CommandPopup {
|
|
command_filter: String,
|
|
builtins: Vec<(&'static str, SlashCommand)>,
|
|
prompts: Vec<CustomPrompt>,
|
|
state: ScrollState,
|
|
}
|
|
|
|
impl CommandPopup {
|
|
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, skills_enabled: bool) -> Self {
|
|
let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands()
|
|
.into_iter()
|
|
.filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills)
|
|
.collect();
|
|
// Exclude prompts that collide with builtin command names and sort by name.
|
|
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
|
|
prompts.retain(|p| !exclude.contains(&p.name));
|
|
prompts.sort_by(|a, b| a.name.cmp(&b.name));
|
|
Self {
|
|
command_filter: String::new(),
|
|
builtins,
|
|
prompts,
|
|
state: ScrollState::new(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
|
|
let exclude: HashSet<String> = self
|
|
.builtins
|
|
.iter()
|
|
.map(|(n, _)| (*n).to_string())
|
|
.collect();
|
|
prompts.retain(|p| !exclude.contains(&p.name));
|
|
prompts.sort_by(|a, b| a.name.cmp(&b.name));
|
|
self.prompts = prompts;
|
|
}
|
|
|
|
pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> {
|
|
self.prompts.get(idx)
|
|
}
|
|
|
|
/// Update the filter string based on the current composer text. The text
|
|
/// passed in is expected to start with a leading '/'. Everything after the
|
|
/// *first* '/" on the *first* line becomes the active filter that is used
|
|
/// to narrow down the list of available commands.
|
|
pub(crate) fn on_composer_text_change(&mut self, text: String) {
|
|
let first_line = text.lines().next().unwrap_or("");
|
|
|
|
if let Some(stripped) = first_line.strip_prefix('/') {
|
|
// Extract the *first* token (sequence of non-whitespace
|
|
// characters) after the slash so that `/clear something` still
|
|
// shows the help for `/clear`.
|
|
let token = stripped.trim_start();
|
|
let cmd_token = token.split_whitespace().next().unwrap_or("");
|
|
|
|
// Update the filter keeping the original case (commands are all
|
|
// lower-case for now but this may change in the future).
|
|
self.command_filter = cmd_token.to_string();
|
|
} else {
|
|
// The composer no longer starts with '/'. Reset the filter so the
|
|
// popup shows the *full* command list if it is still displayed
|
|
// for some reason.
|
|
self.command_filter.clear();
|
|
}
|
|
|
|
// Reset or clamp selected index based on new filtered list.
|
|
let matches_len = self.filtered_items().len();
|
|
self.state.clamp_selection(matches_len);
|
|
self.state
|
|
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
|
}
|
|
|
|
/// Determine the preferred height of the popup for a given width.
|
|
/// Accounts for wrapped descriptions so that long tooltips don't overflow.
|
|
pub(crate) fn calculate_required_height(&self, width: u16) -> u16 {
|
|
use super::selection_popup_common::measure_rows_height;
|
|
let rows = self.rows_from_matches(self.filtered());
|
|
|
|
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width)
|
|
}
|
|
|
|
/// Compute fuzzy-filtered matches over built-in commands and user prompts,
|
|
/// paired with optional highlight indices and score. Sorted by ascending
|
|
/// score, then by name for stability.
|
|
fn filtered(&self) -> Vec<(CommandItem, Option<Vec<usize>>, i32)> {
|
|
let filter = self.command_filter.trim();
|
|
let mut out: Vec<(CommandItem, Option<Vec<usize>>, i32)> = Vec::new();
|
|
if filter.is_empty() {
|
|
// Built-ins first, in presentation order.
|
|
for (_, cmd) in self.builtins.iter() {
|
|
out.push((CommandItem::Builtin(*cmd), None, 0));
|
|
}
|
|
// Then prompts, already sorted by name.
|
|
for idx in 0..self.prompts.len() {
|
|
out.push((CommandItem::UserPrompt(idx), None, 0));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
for (_, cmd) in self.builtins.iter() {
|
|
if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
|
|
out.push((CommandItem::Builtin(*cmd), Some(indices), score));
|
|
}
|
|
}
|
|
// Support both search styles:
|
|
// - Typing "name" should surface "/prompts:name" results.
|
|
// - Typing "prompts:name" should also work.
|
|
for (idx, p) in self.prompts.iter().enumerate() {
|
|
let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name);
|
|
if let Some((indices, score)) = fuzzy_match(&display, filter) {
|
|
out.push((CommandItem::UserPrompt(idx), Some(indices), score));
|
|
}
|
|
}
|
|
// When filtering, sort by ascending score and then by name for stability.
|
|
out.sort_by(|a, b| {
|
|
a.2.cmp(&b.2).then_with(|| {
|
|
let an = match a.0 {
|
|
CommandItem::Builtin(c) => c.command(),
|
|
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
|
};
|
|
let bn = match b.0 {
|
|
CommandItem::Builtin(c) => c.command(),
|
|
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
|
};
|
|
an.cmp(bn)
|
|
})
|
|
});
|
|
out
|
|
}
|
|
|
|
fn filtered_items(&self) -> Vec<CommandItem> {
|
|
self.filtered().into_iter().map(|(c, _, _)| c).collect()
|
|
}
|
|
|
|
fn rows_from_matches(
|
|
&self,
|
|
matches: Vec<(CommandItem, Option<Vec<usize>>, i32)>,
|
|
) -> Vec<GenericDisplayRow> {
|
|
matches
|
|
.into_iter()
|
|
.map(|(item, indices, _)| {
|
|
let (name, description) = match item {
|
|
CommandItem::Builtin(cmd) => {
|
|
(format!("/{}", cmd.command()), cmd.description().to_string())
|
|
}
|
|
CommandItem::UserPrompt(i) => {
|
|
let prompt = &self.prompts[i];
|
|
let description = prompt
|
|
.description
|
|
.clone()
|
|
.unwrap_or_else(|| "send saved prompt".to_string());
|
|
(
|
|
format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name),
|
|
description,
|
|
)
|
|
}
|
|
};
|
|
GenericDisplayRow {
|
|
name,
|
|
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
|
display_shortcut: None,
|
|
description: Some(description),
|
|
wrap_indent: None,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Move the selection cursor one step up.
|
|
pub(crate) fn move_up(&mut self) {
|
|
let len = self.filtered_items().len();
|
|
self.state.move_up_wrap(len);
|
|
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
|
|
}
|
|
|
|
/// Move the selection cursor one step down.
|
|
pub(crate) fn move_down(&mut self) {
|
|
let matches_len = self.filtered_items().len();
|
|
self.state.move_down_wrap(matches_len);
|
|
self.state
|
|
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
|
}
|
|
|
|
/// Return currently selected command, if any.
|
|
pub(crate) fn selected_item(&self) -> Option<CommandItem> {
|
|
let matches = self.filtered_items();
|
|
self.state
|
|
.selected_idx
|
|
.and_then(|idx| matches.get(idx).copied())
|
|
}
|
|
}
|
|
|
|
impl WidgetRef for CommandPopup {
|
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
|
let rows = self.rows_from_matches(self.filtered());
|
|
render_rows(
|
|
area.inset(Insets::tlbr(0, 2, 0, 0)),
|
|
buf,
|
|
&rows,
|
|
&self.state,
|
|
MAX_POPUP_ROWS,
|
|
"no matches",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[test]
|
|
fn filter_includes_init_when_typing_prefix() {
|
|
let mut popup = CommandPopup::new(Vec::new(), false);
|
|
// Simulate the composer line starting with '/in' so the popup filters
|
|
// matching commands by prefix.
|
|
popup.on_composer_text_change("/in".to_string());
|
|
|
|
// Access the filtered list via the selected command and ensure that
|
|
// one of the matches is the new "init" command.
|
|
let matches = popup.filtered_items();
|
|
let has_init = matches.iter().any(|item| match item {
|
|
CommandItem::Builtin(cmd) => cmd.command() == "init",
|
|
CommandItem::UserPrompt(_) => false,
|
|
});
|
|
assert!(
|
|
has_init,
|
|
"expected '/init' to appear among filtered commands"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn selecting_init_by_exact_match() {
|
|
let mut popup = CommandPopup::new(Vec::new(), false);
|
|
popup.on_composer_text_change("/init".to_string());
|
|
|
|
// When an exact match exists, the selected command should be that
|
|
// command by default.
|
|
let selected = popup.selected_item();
|
|
match selected {
|
|
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"),
|
|
Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"),
|
|
None => panic!("expected a selected command for exact match"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn model_is_first_suggestion_for_mo() {
|
|
let mut popup = CommandPopup::new(Vec::new(), false);
|
|
popup.on_composer_text_change("/mo".to_string());
|
|
let matches = popup.filtered_items();
|
|
match matches.first() {
|
|
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
|
|
Some(CommandItem::UserPrompt(_)) => {
|
|
panic!("unexpected prompt ranked before '/model' for '/mo'")
|
|
}
|
|
None => panic!("expected at least one match for '/mo'"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn prompt_discovery_lists_custom_prompts() {
|
|
let prompts = vec![
|
|
CustomPrompt {
|
|
name: "foo".to_string(),
|
|
path: "/tmp/foo.md".to_string().into(),
|
|
content: "hello from foo".to_string(),
|
|
description: None,
|
|
argument_hint: None,
|
|
},
|
|
CustomPrompt {
|
|
name: "bar".to_string(),
|
|
path: "/tmp/bar.md".to_string().into(),
|
|
content: "hello from bar".to_string(),
|
|
description: None,
|
|
argument_hint: None,
|
|
},
|
|
];
|
|
let popup = CommandPopup::new(prompts, false);
|
|
let items = popup.filtered_items();
|
|
let mut prompt_names: Vec<String> = items
|
|
.into_iter()
|
|
.filter_map(|it| match it {
|
|
CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
prompt_names.sort();
|
|
assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn prompt_name_collision_with_builtin_is_ignored() {
|
|
// Create a prompt named like a builtin (e.g. "init").
|
|
let popup = CommandPopup::new(
|
|
vec![CustomPrompt {
|
|
name: "init".to_string(),
|
|
path: "/tmp/init.md".to_string().into(),
|
|
content: "should be ignored".to_string(),
|
|
description: None,
|
|
argument_hint: None,
|
|
}],
|
|
false,
|
|
);
|
|
let items = popup.filtered_items();
|
|
let has_collision_prompt = items.into_iter().any(|it| match it {
|
|
CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"),
|
|
_ => false,
|
|
});
|
|
assert!(
|
|
!has_collision_prompt,
|
|
"prompt with builtin name should be ignored"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prompt_description_uses_frontmatter_metadata() {
|
|
let popup = CommandPopup::new(
|
|
vec![CustomPrompt {
|
|
name: "draftpr".to_string(),
|
|
path: "/tmp/draftpr.md".to_string().into(),
|
|
content: "body".to_string(),
|
|
description: Some("Create feature branch, commit and open draft PR.".to_string()),
|
|
argument_hint: None,
|
|
}],
|
|
false,
|
|
);
|
|
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]);
|
|
let description = rows.first().and_then(|row| row.description.as_deref());
|
|
assert_eq!(
|
|
description,
|
|
Some("Create feature branch, commit and open draft PR.")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prompt_description_falls_back_when_missing() {
|
|
let popup = CommandPopup::new(
|
|
vec![CustomPrompt {
|
|
name: "foo".to_string(),
|
|
path: "/tmp/foo.md".to_string().into(),
|
|
content: "body".to_string(),
|
|
description: None,
|
|
argument_hint: None,
|
|
}],
|
|
false,
|
|
);
|
|
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]);
|
|
let description = rows.first().and_then(|row| row.description.as_deref());
|
|
assert_eq!(description, Some("send saved prompt"));
|
|
}
|
|
|
|
#[test]
|
|
fn fuzzy_filter_matches_subsequence_for_ac() {
|
|
let mut popup = CommandPopup::new(Vec::new(), false);
|
|
popup.on_composer_text_change("/ac".to_string());
|
|
|
|
let cmds: Vec<&str> = popup
|
|
.filtered_items()
|
|
.into_iter()
|
|
.filter_map(|item| match item {
|
|
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
|
CommandItem::UserPrompt(_) => None,
|
|
})
|
|
.collect();
|
|
assert!(
|
|
cmds.contains(&"compact") && cmds.contains(&"feedback"),
|
|
"expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}"
|
|
);
|
|
}
|
|
}
|