2025-05-14 10:13:29 -07:00
|
|
|
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
|
|
|
|
|
|
|
|
|
|
use bottom_pane_view::BottomPaneView;
|
|
|
|
|
use bottom_pane_view::ConditionalUpdate;
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
use ratatui::buffer::Buffer;
|
|
|
|
|
use ratatui::layout::Rect;
|
|
|
|
|
use ratatui::widgets::WidgetRef;
|
|
|
|
|
|
|
|
|
|
use crate::app_event::AppEvent;
|
2025-05-15 14:50:30 -07:00
|
|
|
use crate::app_event_sender::AppEventSender;
|
2025-05-14 10:13:29 -07:00
|
|
|
use crate::user_approval_widget::ApprovalRequest;
|
|
|
|
|
|
|
|
|
|
mod approval_modal_view;
|
|
|
|
|
mod bottom_pane_view;
|
|
|
|
|
mod chat_composer;
|
feat: record messages from user in ~/.codex/history.jsonl (#939)
This is a large change to support a "history" feature like you would
expect in a shell like Bash.
History events are recorded in `$CODEX_HOME/history.jsonl`. Because it
is a JSONL file, it is straightforward to append new entries (as opposed
to the TypeScript file that uses `$CODEX_HOME/history.json`, so to be
valid JSON, each new entry entails rewriting the entire file). Because
it is possible for there to be multiple instances of Codex CLI writing
to `history.jsonl` at once, we use advisory file locking when working
with `history.jsonl` in `codex-rs/core/src/message_history.rs`.
Because we believe history is a sufficiently useful feature, we enable
it by default. Though to provide some safety, we set the file
permissions of `history.jsonl` to be `o600` so that other users on the
system cannot read the user's history. We do not yet support a default
list of `SENSITIVE_PATTERNS` as the TypeScript CLI does:
https://github.com/openai/codex/blob/3fdf9df1335ac9501e3fb0e61715359145711e8b/codex-cli/src/utils/storage/command-history.ts#L10-L17
We are going to take a more conservative approach to this list in the
Rust CLI. For example, while `/\b[A-Za-z0-9-_]{20,}\b/` might exclude
sensitive information like API tokens, it would also exclude valuable
information such as references to Git commits.
As noted in the updated documentation, users can opt-out of history by
adding the following to `config.toml`:
```toml
[history]
persistence = "none"
```
Because `history.jsonl` could, in theory, be quite large, we take a[n
arguably overly pedantic] approach in reading history entries into
memory. Specifically, we start by telling the client the current number
of entries in the history file (`history_entry_count`) as well as the
inode (`history_log_id`) of `history.jsonl` (see the new fields on
`SessionConfiguredEvent`).
The client is responsible for keeping new entries in memory to create a
"local history," but if the user hits up enough times to go "past" the
end of local history, then the client should use the new
`GetHistoryEntryRequest` in the protocol to fetch older entries.
Specifically, it should pass the `history_log_id` it was given
originally and work backwards from `history_entry_count`. (It should
really fetch history in batches rather than one-at-a-time, but that is
something we can improve upon in subsequent PRs.)
The motivation behind this crazy scheme is that it is designed to defend
against:
* The `history.jsonl` being truncated during the session such that the
index into the history is no longer consistent with what had been read
up to that point. We do not yet have logic to enforce a `max_bytes` for
`history.jsonl`, but once we do, we will aspire to implement it in a way
that should result in a new inode for the file on most systems.
* New items from concurrent Codex CLI sessions amending to the history.
Because, in absence of truncation, `history.jsonl` is an append-only
log, so long as the client reads backwards from `history_entry_count`,
it should always get a consistent view of history. (That said, it will
not be able to read _new_ commands from concurrent sessions, but perhaps
we will introduce a `/` command to reload latest history or something
down the road.)
Admittedly, my testing of this feature thus far has been fairly light. I
expect we will find bugs and introduce enhancements/fixes going forward.
2025-05-15 16:26:23 -07:00
|
|
|
mod chat_composer_history;
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
mod command_popup;
|
2025-05-14 10:13:29 -07:00
|
|
|
mod status_indicator_view;
|
|
|
|
|
|
|
|
|
|
pub(crate) use chat_composer::ChatComposer;
|
|
|
|
|
pub(crate) use chat_composer::InputResult;
|
|
|
|
|
|
|
|
|
|
use approval_modal_view::ApprovalModalView;
|
|
|
|
|
use status_indicator_view::StatusIndicatorView;
|
|
|
|
|
|
|
|
|
|
/// Pane displayed in the lower half of the chat UI.
|
|
|
|
|
pub(crate) struct BottomPane<'a> {
|
|
|
|
|
/// Composer is retained even when a BottomPaneView is displayed so the
|
|
|
|
|
/// input state is retained when the view is closed.
|
|
|
|
|
composer: ChatComposer<'a>,
|
|
|
|
|
|
|
|
|
|
/// If present, this is displayed instead of the `composer`.
|
|
|
|
|
active_view: Option<Box<dyn BottomPaneView<'a> + 'a>>,
|
|
|
|
|
|
2025-05-15 14:50:30 -07:00
|
|
|
app_event_tx: AppEventSender,
|
2025-05-14 10:13:29 -07:00
|
|
|
has_input_focus: bool,
|
|
|
|
|
is_task_running: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) struct BottomPaneParams {
|
2025-05-15 14:50:30 -07:00
|
|
|
pub(crate) app_event_tx: AppEventSender,
|
2025-05-14 10:13:29 -07:00
|
|
|
pub(crate) has_input_focus: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl BottomPane<'_> {
|
|
|
|
|
pub fn new(params: BottomPaneParams) -> Self {
|
|
|
|
|
Self {
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
composer: ChatComposer::new(params.has_input_focus, params.app_event_tx.clone()),
|
2025-05-14 10:13:29 -07:00
|
|
|
active_view: None,
|
|
|
|
|
app_event_tx: params.app_event_tx,
|
|
|
|
|
has_input_focus: params.has_input_focus,
|
|
|
|
|
is_task_running: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Forward a key event to the active view or the composer.
|
2025-05-15 14:50:30 -07:00
|
|
|
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
|
2025-05-14 10:13:29 -07:00
|
|
|
if let Some(mut view) = self.active_view.take() {
|
2025-05-15 14:50:30 -07:00
|
|
|
view.handle_key_event(self, key_event);
|
2025-05-14 10:13:29 -07:00
|
|
|
if !view.is_complete() {
|
|
|
|
|
self.active_view = Some(view);
|
|
|
|
|
} else if self.is_task_running {
|
|
|
|
|
let height = self.composer.calculate_required_height(&Rect::default());
|
|
|
|
|
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
|
|
|
|
self.app_event_tx.clone(),
|
|
|
|
|
height,
|
|
|
|
|
)));
|
|
|
|
|
}
|
2025-05-15 14:50:30 -07:00
|
|
|
self.request_redraw();
|
|
|
|
|
InputResult::None
|
2025-05-14 10:13:29 -07:00
|
|
|
} else {
|
|
|
|
|
let (input_result, needs_redraw) = self.composer.handle_key_event(key_event);
|
|
|
|
|
if needs_redraw {
|
2025-05-15 14:50:30 -07:00
|
|
|
self.request_redraw();
|
2025-05-14 10:13:29 -07:00
|
|
|
}
|
2025-05-15 14:50:30 -07:00
|
|
|
input_result
|
2025-05-14 10:13:29 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Update the status indicator text (only when the `StatusIndicatorView` is
|
|
|
|
|
/// active).
|
2025-05-15 14:50:30 -07:00
|
|
|
pub(crate) fn update_status_text(&mut self, text: String) {
|
2025-05-14 10:13:29 -07:00
|
|
|
if let Some(view) = &mut self.active_view {
|
|
|
|
|
match view.update_status_text(text) {
|
|
|
|
|
ConditionalUpdate::NeedsRedraw => {
|
2025-05-15 14:50:30 -07:00
|
|
|
self.request_redraw();
|
2025-05-14 10:13:29 -07:00
|
|
|
}
|
|
|
|
|
ConditionalUpdate::NoRedraw => {
|
|
|
|
|
// No redraw needed.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Update the UI to reflect whether this `BottomPane` has input focus.
|
|
|
|
|
pub(crate) fn set_input_focus(&mut self, has_focus: bool) {
|
|
|
|
|
self.has_input_focus = has_focus;
|
|
|
|
|
self.composer.set_input_focus(has_focus);
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-15 14:50:30 -07:00
|
|
|
pub fn set_task_running(&mut self, running: bool) {
|
2025-05-14 10:13:29 -07:00
|
|
|
self.is_task_running = running;
|
|
|
|
|
|
|
|
|
|
match (running, self.active_view.is_some()) {
|
|
|
|
|
(true, false) => {
|
|
|
|
|
// Show status indicator overlay.
|
|
|
|
|
let height = self.composer.calculate_required_height(&Rect::default());
|
|
|
|
|
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
|
|
|
|
self.app_event_tx.clone(),
|
|
|
|
|
height,
|
|
|
|
|
)));
|
2025-05-15 14:50:30 -07:00
|
|
|
self.request_redraw();
|
2025-05-14 10:13:29 -07:00
|
|
|
}
|
|
|
|
|
(false, true) => {
|
|
|
|
|
if let Some(mut view) = self.active_view.take() {
|
|
|
|
|
if view.should_hide_when_task_is_done() {
|
|
|
|
|
// Leave self.active_view as None.
|
2025-05-15 14:50:30 -07:00
|
|
|
self.request_redraw();
|
2025-05-14 10:13:29 -07:00
|
|
|
} else {
|
|
|
|
|
// Preserve the view.
|
|
|
|
|
self.active_view = Some(view);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
// No change.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Called when the agent requests user approval.
|
2025-05-15 14:50:30 -07:00
|
|
|
pub fn push_approval_request(&mut self, request: ApprovalRequest) {
|
2025-05-14 10:13:29 -07:00
|
|
|
let request = if let Some(view) = self.active_view.as_mut() {
|
|
|
|
|
match view.try_consume_approval_request(request) {
|
|
|
|
|
Some(request) => request,
|
|
|
|
|
None => {
|
2025-05-15 14:50:30 -07:00
|
|
|
self.request_redraw();
|
|
|
|
|
return;
|
2025-05-14 10:13:29 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
request
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Otherwise create a new approval modal overlay.
|
|
|
|
|
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
|
|
|
|
|
self.active_view = Some(Box::new(modal));
|
|
|
|
|
self.request_redraw()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Height (terminal rows) required by the current bottom pane.
|
|
|
|
|
pub fn calculate_required_height(&self, area: &Rect) -> u16 {
|
|
|
|
|
if let Some(view) = &self.active_view {
|
|
|
|
|
view.calculate_required_height(area)
|
|
|
|
|
} else {
|
|
|
|
|
self.composer.calculate_required_height(area)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-15 14:50:30 -07:00
|
|
|
pub(crate) fn request_redraw(&self) {
|
2025-05-14 10:13:29 -07:00
|
|
|
self.app_event_tx.send(AppEvent::Redraw)
|
|
|
|
|
}
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
|
|
|
|
|
/// Returns true when the slash-command popup inside the composer is visible.
|
|
|
|
|
pub(crate) fn is_command_popup_visible(&self) -> bool {
|
|
|
|
|
self.active_view.is_none() && self.composer.is_command_popup_visible()
|
|
|
|
|
}
|
feat: record messages from user in ~/.codex/history.jsonl (#939)
This is a large change to support a "history" feature like you would
expect in a shell like Bash.
History events are recorded in `$CODEX_HOME/history.jsonl`. Because it
is a JSONL file, it is straightforward to append new entries (as opposed
to the TypeScript file that uses `$CODEX_HOME/history.json`, so to be
valid JSON, each new entry entails rewriting the entire file). Because
it is possible for there to be multiple instances of Codex CLI writing
to `history.jsonl` at once, we use advisory file locking when working
with `history.jsonl` in `codex-rs/core/src/message_history.rs`.
Because we believe history is a sufficiently useful feature, we enable
it by default. Though to provide some safety, we set the file
permissions of `history.jsonl` to be `o600` so that other users on the
system cannot read the user's history. We do not yet support a default
list of `SENSITIVE_PATTERNS` as the TypeScript CLI does:
https://github.com/openai/codex/blob/3fdf9df1335ac9501e3fb0e61715359145711e8b/codex-cli/src/utils/storage/command-history.ts#L10-L17
We are going to take a more conservative approach to this list in the
Rust CLI. For example, while `/\b[A-Za-z0-9-_]{20,}\b/` might exclude
sensitive information like API tokens, it would also exclude valuable
information such as references to Git commits.
As noted in the updated documentation, users can opt-out of history by
adding the following to `config.toml`:
```toml
[history]
persistence = "none"
```
Because `history.jsonl` could, in theory, be quite large, we take a[n
arguably overly pedantic] approach in reading history entries into
memory. Specifically, we start by telling the client the current number
of entries in the history file (`history_entry_count`) as well as the
inode (`history_log_id`) of `history.jsonl` (see the new fields on
`SessionConfiguredEvent`).
The client is responsible for keeping new entries in memory to create a
"local history," but if the user hits up enough times to go "past" the
end of local history, then the client should use the new
`GetHistoryEntryRequest` in the protocol to fetch older entries.
Specifically, it should pass the `history_log_id` it was given
originally and work backwards from `history_entry_count`. (It should
really fetch history in batches rather than one-at-a-time, but that is
something we can improve upon in subsequent PRs.)
The motivation behind this crazy scheme is that it is designed to defend
against:
* The `history.jsonl` being truncated during the session such that the
index into the history is no longer consistent with what had been read
up to that point. We do not yet have logic to enforce a `max_bytes` for
`history.jsonl`, but once we do, we will aspire to implement it in a way
that should result in a new inode for the file on most systems.
* New items from concurrent Codex CLI sessions amending to the history.
Because, in absence of truncation, `history.jsonl` is an append-only
log, so long as the client reads backwards from `history_entry_count`,
it should always get a consistent view of history. (That said, it will
not be able to read _new_ commands from concurrent sessions, but perhaps
we will introduce a `/` command to reload latest history or something
down the road.)
Admittedly, my testing of this feature thus far has been fairly light. I
expect we will find bugs and introduce enhancements/fixes going forward.
2025-05-15 16:26:23 -07:00
|
|
|
|
|
|
|
|
// --- History helpers ---
|
|
|
|
|
|
|
|
|
|
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
|
|
|
|
|
self.composer.set_history_metadata(log_id, entry_count);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn on_history_entry_response(
|
|
|
|
|
&mut self,
|
|
|
|
|
log_id: u64,
|
|
|
|
|
offset: usize,
|
|
|
|
|
entry: Option<String>,
|
|
|
|
|
) {
|
|
|
|
|
let updated = self
|
|
|
|
|
.composer
|
|
|
|
|
.on_history_entry_response(log_id, offset, entry);
|
|
|
|
|
|
|
|
|
|
if updated {
|
|
|
|
|
self.request_redraw();
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-05-14 10:13:29 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl WidgetRef for &BottomPane<'_> {
|
|
|
|
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
|
|
|
|
// Show BottomPaneView if present.
|
|
|
|
|
if let Some(ov) = &self.active_view {
|
|
|
|
|
ov.render(area, buf);
|
|
|
|
|
} else {
|
|
|
|
|
(&self.composer).render_ref(area, buf);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|