Add cloud tasks (#3197)
Adds a TUI for managing, applying, and creating cloud tasks
This commit is contained in:
@@ -7,6 +7,7 @@ use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Margin;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
@@ -93,6 +94,7 @@ pub(crate) struct ChatComposer {
|
||||
disable_paste_burst: bool,
|
||||
custom_prompts: Vec<CustomPrompt>,
|
||||
footer_mode: FooterMode,
|
||||
footer_hint_override: Option<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
@@ -134,6 +136,7 @@ impl ChatComposer {
|
||||
disable_paste_burst: false,
|
||||
custom_prompts: Vec::new(),
|
||||
footer_mode: FooterMode::ShortcutPrompt,
|
||||
footer_hint_override: None,
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -142,7 +145,9 @@ impl ChatComposer {
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
let footer_props = self.footer_props();
|
||||
let footer_hint_height = footer_height(footer_props);
|
||||
let footer_hint_height = self
|
||||
.custom_footer_height()
|
||||
.unwrap_or_else(|| footer_height(footer_props));
|
||||
let footer_spacing = Self::footer_spacing(footer_hint_height);
|
||||
let footer_total_height = footer_hint_height + footer_spacing;
|
||||
self.textarea
|
||||
@@ -157,7 +162,9 @@ impl ChatComposer {
|
||||
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
||||
let footer_props = self.footer_props();
|
||||
let footer_hint_height = footer_height(footer_props);
|
||||
let footer_hint_height = self
|
||||
.custom_footer_height()
|
||||
.unwrap_or_else(|| footer_height(footer_props));
|
||||
let footer_spacing = Self::footer_spacing(footer_hint_height);
|
||||
let footer_total_height = footer_hint_height + footer_spacing;
|
||||
let popup_constraint = match &self.active_popup {
|
||||
@@ -273,6 +280,12 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the footer hint items displayed beneath the composer. Passing
|
||||
/// `None` restores the default shortcut footer.
|
||||
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
|
||||
self.footer_hint_override = items;
|
||||
}
|
||||
|
||||
/// Replace the entire composer content with `text` and reset cursor.
|
||||
pub(crate) fn set_text_content(&mut self, text: String) {
|
||||
// Clear any existing content, placeholders, and attachments first.
|
||||
@@ -1304,6 +1317,12 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_footer_height(&self) -> Option<u16> {
|
||||
self.footer_hint_override
|
||||
.as_ref()
|
||||
.map(|items| if items.is_empty() { 0 } else { 1 })
|
||||
}
|
||||
|
||||
/// Synchronize `self.command_popup` with the current text in the
|
||||
/// textarea. This must be called after every modification that can change
|
||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||
@@ -1436,7 +1455,9 @@ impl WidgetRef for ChatComposer {
|
||||
}
|
||||
ActivePopup::None => {
|
||||
let footer_props = self.footer_props();
|
||||
let footer_hint_height = footer_height(footer_props);
|
||||
let custom_height = self.custom_footer_height();
|
||||
let footer_hint_height =
|
||||
custom_height.unwrap_or_else(|| footer_height(footer_props));
|
||||
let footer_spacing = Self::footer_spacing(footer_hint_height);
|
||||
let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
|
||||
let [_, hint_rect] = Layout::vertical([
|
||||
@@ -1448,7 +1469,27 @@ impl WidgetRef for ChatComposer {
|
||||
} else {
|
||||
popup_rect
|
||||
};
|
||||
render_footer(hint_rect, buf, footer_props);
|
||||
if let Some(items) = self.footer_hint_override.as_ref() {
|
||||
if !items.is_empty() {
|
||||
let mut spans = Vec::with_capacity(items.len() * 4);
|
||||
for (idx, (key, label)) in items.iter().enumerate() {
|
||||
spans.push(" ".into());
|
||||
spans.push(Span::styled(key.clone(), Style::default().bold()));
|
||||
spans.push(format!(" {label}").into());
|
||||
if idx + 1 != items.len() {
|
||||
spans.push(" ".into());
|
||||
}
|
||||
}
|
||||
let mut custom_rect = hint_rect;
|
||||
if custom_rect.width > 2 {
|
||||
custom_rect.x += 2;
|
||||
custom_rect.width = custom_rect.width.saturating_sub(2);
|
||||
}
|
||||
Line::from(spans).render_ref(custom_rect, buf);
|
||||
}
|
||||
} else {
|
||||
render_footer(hint_rect, buf, footer_props);
|
||||
}
|
||||
}
|
||||
}
|
||||
let style = user_message_style(terminal_palette::default_bg());
|
||||
|
||||
@@ -875,7 +875,7 @@ pub(crate) fn new_mcp_tools_output(
|
||||
lines.push(vec![" • Server: ".into(), server.clone().into()].into());
|
||||
|
||||
match &cfg.transport {
|
||||
McpServerTransportConfig::Stdio { command, args, .. } => {
|
||||
McpServerTransportConfig::Stdio { command, args, env } => {
|
||||
let args_suffix = if args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -883,6 +883,15 @@ pub(crate) fn new_mcp_tools_output(
|
||||
};
|
||||
let cmd_display = format!("{command}{args_suffix}");
|
||||
lines.push(vec![" • Command: ".into(), cmd_display.into()].into());
|
||||
|
||||
if let Some(env) = env.as_ref()
|
||||
&& !env.is_empty()
|
||||
{
|
||||
let mut env_pairs: Vec<String> =
|
||||
env.iter().map(|(k, v)| format!("{k}={v}")).collect();
|
||||
env_pairs.sort();
|
||||
lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into());
|
||||
}
|
||||
}
|
||||
McpServerTransportConfig::StreamableHttp { url, .. } => {
|
||||
lines.push(vec![" • URL: ".into(), url.clone().into()].into());
|
||||
|
||||
@@ -55,6 +55,7 @@ mod markdown_render;
|
||||
mod markdown_stream;
|
||||
pub mod onboarding;
|
||||
mod pager_overlay;
|
||||
pub mod public_widgets;
|
||||
mod render;
|
||||
mod resume_picker;
|
||||
mod session_log;
|
||||
@@ -82,6 +83,9 @@ use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
|
||||
use crate::onboarding::onboarding_screen::run_onboarding_app;
|
||||
use crate::tui::Tui;
|
||||
pub use cli::Cli;
|
||||
pub use markdown_render::render_markdown_text;
|
||||
pub use public_widgets::composer_input::ComposerAction;
|
||||
pub use public_widgets::composer_input::ComposerInput;
|
||||
|
||||
// (tests access modules directly within the crate)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ impl IndentContext {
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn render_markdown_text(input: &str) -> Text<'static> {
|
||||
pub fn render_markdown_text(input: &str) -> Text<'static> {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
let parser = Parser::new_ext(input, options);
|
||||
|
||||
128
codex-rs/tui/src/public_widgets/composer_input.rs
Normal file
128
codex-rs/tui/src/public_widgets/composer_input.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! Public wrapper around the internal ChatComposer for simple, reusable text input.
|
||||
//!
|
||||
//! This exposes a minimal interface suitable for other crates (e.g.,
|
||||
//! codex-cloud-tasks) to reuse the mature composer behavior: multi-line input,
|
||||
//! paste heuristics, Enter-to-submit, and Shift+Enter for newline.
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ChatComposer;
|
||||
use crate::bottom_pane::InputResult;
|
||||
|
||||
/// Action returned from feeding a key event into the ComposerInput.
|
||||
pub enum ComposerAction {
|
||||
/// The user submitted the current text (typically via Enter). Contains the submitted text.
|
||||
Submitted(String),
|
||||
/// No submission occurred; UI may need to redraw if `needs_redraw()` returned true.
|
||||
None,
|
||||
}
|
||||
|
||||
/// A minimal, public wrapper for the internal `ChatComposer` that behaves as a
|
||||
/// reusable text input field with submit semantics.
|
||||
pub struct ComposerInput {
|
||||
inner: ChatComposer,
|
||||
_tx: tokio::sync::mpsc::UnboundedSender<AppEvent>,
|
||||
rx: tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
}
|
||||
|
||||
impl ComposerInput {
|
||||
/// Create a new composer input with a neutral placeholder.
|
||||
pub fn new() -> Self {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let sender = AppEventSender::new(tx.clone());
|
||||
// `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior.
|
||||
let inner = ChatComposer::new(true, sender, true, "Compose new task".to_string(), false);
|
||||
Self { inner, _tx: tx, rx }
|
||||
}
|
||||
|
||||
/// Returns true if the input is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.inner.is_empty()
|
||||
}
|
||||
|
||||
/// Clear the input text.
|
||||
pub fn clear(&mut self) {
|
||||
self.inner.set_text_content(String::new());
|
||||
}
|
||||
|
||||
/// Feed a key event into the composer and return a high-level action.
|
||||
pub fn input(&mut self, key: KeyEvent) -> ComposerAction {
|
||||
let action = match self.inner.handle_key_event(key).0 {
|
||||
InputResult::Submitted(text) => ComposerAction::Submitted(text),
|
||||
_ => ComposerAction::None,
|
||||
};
|
||||
self.drain_app_events();
|
||||
action
|
||||
}
|
||||
|
||||
pub fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
let handled = self.inner.handle_paste(pasted);
|
||||
self.drain_app_events();
|
||||
handled
|
||||
}
|
||||
|
||||
/// Override the footer hint items displayed under the composer.
|
||||
/// Each tuple is rendered as "<key> <label>", with keys styled.
|
||||
pub fn set_hint_items(&mut self, items: Vec<(impl Into<String>, impl Into<String>)>) {
|
||||
let mapped: Vec<(String, String)> = items
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.into(), v.into()))
|
||||
.collect();
|
||||
self.inner.set_footer_hint_override(Some(mapped));
|
||||
}
|
||||
|
||||
/// Clear any previously set custom hint items and restore the default hints.
|
||||
pub fn clear_hint_items(&mut self) {
|
||||
self.inner.set_footer_hint_override(None);
|
||||
}
|
||||
|
||||
/// Desired height (in rows) for a given width.
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
self.inner.desired_height(width)
|
||||
}
|
||||
|
||||
/// Compute the on-screen cursor position for the given area.
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.inner.cursor_pos(area)
|
||||
}
|
||||
|
||||
/// Render the input into the provided buffer at `area`.
|
||||
pub fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
WidgetRef::render_ref(&self.inner, area, buf);
|
||||
}
|
||||
|
||||
/// Return true if a paste-burst detection is currently active.
|
||||
pub fn is_in_paste_burst(&self) -> bool {
|
||||
self.inner.is_in_paste_burst()
|
||||
}
|
||||
|
||||
/// Flush a pending paste-burst if the inter-key timeout has elapsed.
|
||||
/// Returns true if text changed and a redraw is warranted.
|
||||
pub fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||
let flushed = self.inner.flush_paste_burst_if_due();
|
||||
self.drain_app_events();
|
||||
flushed
|
||||
}
|
||||
|
||||
/// Recommended delay to schedule the next micro-flush frame while a
|
||||
/// paste-burst is active.
|
||||
pub fn recommended_flush_delay() -> Duration {
|
||||
crate::bottom_pane::ChatComposer::recommended_paste_flush_delay()
|
||||
}
|
||||
|
||||
fn drain_app_events(&mut self) {
|
||||
while self.rx.try_recv().is_ok() {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ComposerInput {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
1
codex-rs/tui/src/public_widgets/mod.rs
Normal file
1
codex-rs/tui/src/public_widgets/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod composer_input;
|
||||
Reference in New Issue
Block a user