2025-09-06 08:19:23 -07:00
|
|
|
|
use codex_core::protocol::TokenUsageInfo;
|
2025-09-08 14:48:48 -07:00
|
|
|
|
use codex_protocol::num_format::format_si_suffix;
|
2025-08-03 11:31:35 -07:00
|
|
|
|
use crossterm::event::KeyCode;
|
2025-05-14 10:13:29 -07:00
|
|
|
|
use crossterm::event::KeyEvent;
|
2025-08-25 20:15:38 -07:00
|
|
|
|
use crossterm::event::KeyEventKind;
|
2025-08-03 11:31:35 -07:00
|
|
|
|
use crossterm::event::KeyModifiers;
|
2025-05-14 10:13:29 -07:00
|
|
|
|
use ratatui::buffer::Buffer;
|
2025-08-03 11:31:35 -07:00
|
|
|
|
use ratatui::layout::Constraint;
|
|
|
|
|
|
use ratatui::layout::Layout;
|
|
|
|
|
|
use ratatui::layout::Margin;
|
2025-05-14 10:13:29 -07:00
|
|
|
|
use ratatui::layout::Rect;
|
2025-07-31 00:43:21 -07:00
|
|
|
|
use ratatui::style::Color;
|
2025-08-07 00:45:47 -07:00
|
|
|
|
use ratatui::style::Modifier;
|
2025-05-14 10:13:29 -07:00
|
|
|
|
use ratatui::style::Style;
|
|
|
|
|
|
use ratatui::style::Stylize;
|
|
|
|
|
|
use ratatui::text::Line;
|
2025-07-31 00:43:21 -07:00
|
|
|
|
use ratatui::text::Span;
|
2025-08-03 11:31:35 -07:00
|
|
|
|
use ratatui::widgets::Block;
|
2025-05-14 10:13:29 -07:00
|
|
|
|
use ratatui::widgets::BorderType;
|
|
|
|
|
|
use ratatui::widgets::Borders;
|
2025-08-03 11:31:35 -07:00
|
|
|
|
use ratatui::widgets::StatefulWidgetRef;
|
2025-05-14 10:13:29 -07:00
|
|
|
|
use ratatui::widgets::WidgetRef;
|
|
|
|
|
|
|
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
|
|
|
|
use super::chat_composer_history::ChatComposerHistory;
|
2025-08-28 19:16:39 -07:00
|
|
|
|
use super::command_popup::CommandItem;
|
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
|
|
|
|
use super::command_popup::CommandPopup;
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
use super::file_search_popup::FileSearchPopup;
|
2025-08-28 12:54:12 -07:00
|
|
|
|
use super::paste_burst::CharDecision;
|
|
|
|
|
|
use super::paste_burst::PasteBurst;
|
2025-09-02 10:29:58 -07:00
|
|
|
|
use crate::bottom_pane::paste_burst::FlushResult;
|
2025-08-21 10:36:58 -07:00
|
|
|
|
use crate::slash_command::SlashCommand;
|
2025-08-28 19:16:39 -07:00
|
|
|
|
use codex_protocol::custom_prompts::CustomPrompt;
|
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
|
|
|
|
|
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
|
|
|
|
use crate::app_event::AppEvent;
|
2025-05-15 14:50:30 -07:00
|
|
|
|
use crate::app_event_sender::AppEventSender;
|
2025-08-03 11:31:35 -07:00
|
|
|
|
use crate::bottom_pane::textarea::TextArea;
|
|
|
|
|
|
use crate::bottom_pane::textarea::TextAreaState;
|
2025-08-25 16:39:42 -07:00
|
|
|
|
use crate::clipboard_paste::normalize_pasted_path;
|
|
|
|
|
|
use crate::clipboard_paste::pasted_image_format;
|
2025-09-04 10:55:50 -07:00
|
|
|
|
use crate::key_hint;
|
2025-06-28 15:04:23 -07:00
|
|
|
|
use codex_file_search::FileMatch;
|
2025-08-03 11:31:35 -07:00
|
|
|
|
use std::cell::RefCell;
|
2025-08-22 18:05:43 +01:00
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
use std::path::PathBuf;
|
2025-08-22 12:23:58 -07:00
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
use std::time::Instant;
|
|
|
|
|
|
|
2025-07-12 15:32:00 -07:00
|
|
|
|
/// If the pasted content exceeds this number of characters, replace it with a
|
|
|
|
|
|
/// placeholder in the UI.
|
|
|
|
|
|
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
feat: show number of tokens remaining in UI (#1388)
When using the OpenAI Responses API, we now record the `usage` field for
a `"response.completed"` event, which includes metrics about the number
of tokens consumed. We also introduce `openai_model_info.rs`, which
includes current data about the most common OpenAI models available via
the API (specifically `context_window` and `max_output_tokens`). If
Codex does not recognize the model, you can set `model_context_window`
and `model_max_output_tokens` explicitly in `config.toml`.
When then introduce a new event type to `protocol.rs`, `TokenCount`,
which includes the `TokenUsage` for the most recent turn.
Finally, we update the TUI to record the running sum of tokens used so
the percentage of available context window remaining can be reported via
the placeholder text for the composer:

We could certainly get much fancier with this (such as reporting the
estimated cost of the conversation), but for now, we are just trying to
achieve feature parity with the TypeScript CLI.
Though arguably this improves upon the TypeScript CLI, as the TypeScript
CLI uses heuristics to estimate the number of tokens used rather than
using the `usage` information directly:
https://github.com/openai/codex/blob/296996d74e345b1b05d8c3451a06ace21c5ada96/codex-cli/src/utils/approximate-tokens-used.ts#L3-L16
Fixes https://github.com/openai/codex/issues/1242
2025-06-25 23:31:11 -07:00
|
|
|
|
|
2025-05-14 10:13:29 -07:00
|
|
|
|
/// Result returned when the user interacts with the text area.
|
2025-08-28 19:16:39 -07:00
|
|
|
|
#[derive(Debug, PartialEq)]
|
2025-05-14 10:13:29 -07:00
|
|
|
|
pub enum InputResult {
|
|
|
|
|
|
Submitted(String),
|
2025-08-21 10:36:58 -07:00
|
|
|
|
Command(SlashCommand),
|
2025-05-14 10:13:29 -07:00
|
|
|
|
None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-22 18:05:43 +01:00
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
|
struct AttachedImage {
|
|
|
|
|
|
placeholder: String,
|
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
pub(crate) struct ChatComposer {
|
|
|
|
|
|
textarea: TextArea,
|
|
|
|
|
|
textarea_state: RefCell<TextAreaState>,
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
active_popup: ActivePopup,
|
2025-05-15 14:50:30 -07:00
|
|
|
|
app_event_tx: AppEventSender,
|
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: ChatComposerHistory,
|
2025-06-27 13:37:11 -04:00
|
|
|
|
ctrl_c_quit_hint: bool,
|
2025-08-23 23:23:15 -07:00
|
|
|
|
esc_backtrack_hint: bool,
|
2025-07-31 17:30:44 -07:00
|
|
|
|
use_shift_enter_hint: bool,
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
dismissed_file_popup_token: Option<String>,
|
|
|
|
|
|
current_file_query: Option<String>,
|
2025-07-12 15:32:00 -07:00
|
|
|
|
pending_pastes: Vec<(String, String)>,
|
2025-08-03 11:31:35 -07:00
|
|
|
|
token_usage_info: Option<TokenUsageInfo>,
|
|
|
|
|
|
has_focus: bool,
|
2025-08-22 18:05:43 +01:00
|
|
|
|
attached_images: Vec<AttachedImage>,
|
2025-08-15 22:37:10 -04:00
|
|
|
|
placeholder_text: String,
|
2025-09-07 20:21:53 -07:00
|
|
|
|
is_task_running: bool,
|
2025-08-28 12:54:12 -07:00
|
|
|
|
// Non-bracketed paste burst tracker.
|
|
|
|
|
|
paste_burst: PasteBurst,
|
|
|
|
|
|
// When true, disables paste-burst logic and inserts characters immediately.
|
|
|
|
|
|
disable_paste_burst: bool,
|
2025-08-28 19:16:39 -07:00
|
|
|
|
custom_prompts: Vec<CustomPrompt>,
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Popup state – at most one can be visible at any time.
|
|
|
|
|
|
enum ActivePopup {
|
|
|
|
|
|
None,
|
|
|
|
|
|
Command(CommandPopup),
|
|
|
|
|
|
File(FileSearchPopup),
|
2025-05-14 10:13:29 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-12 15:31:24 -04:00
|
|
|
|
const FOOTER_HINT_HEIGHT: u16 = 1;
|
|
|
|
|
|
const FOOTER_SPACING_HEIGHT: u16 = 1;
|
|
|
|
|
|
const FOOTER_HEIGHT_WITH_HINT: u16 = FOOTER_HINT_HEIGHT + FOOTER_SPACING_HEIGHT;
|
|
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
impl ChatComposer {
|
2025-07-31 17:30:44 -07:00
|
|
|
|
pub fn new(
|
|
|
|
|
|
has_input_focus: bool,
|
|
|
|
|
|
app_event_tx: AppEventSender,
|
|
|
|
|
|
enhanced_keys_supported: bool,
|
2025-08-15 22:37:10 -04:00
|
|
|
|
placeholder_text: String,
|
2025-08-28 12:54:12 -07:00
|
|
|
|
disable_paste_burst: bool,
|
2025-07-31 17:30:44 -07:00
|
|
|
|
) -> Self {
|
|
|
|
|
|
let use_shift_enter_hint = enhanced_keys_supported;
|
|
|
|
|
|
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut this = Self {
|
2025-08-03 11:31:35 -07:00
|
|
|
|
textarea: TextArea::new(),
|
|
|
|
|
|
textarea_state: RefCell::new(TextAreaState::default()),
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
active_popup: ActivePopup::None,
|
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
|
|
|
|
app_event_tx,
|
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: ChatComposerHistory::new(),
|
2025-06-27 13:37:11 -04:00
|
|
|
|
ctrl_c_quit_hint: false,
|
2025-08-23 23:23:15 -07:00
|
|
|
|
esc_backtrack_hint: false,
|
2025-07-31 17:30:44 -07:00
|
|
|
|
use_shift_enter_hint,
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
dismissed_file_popup_token: None,
|
|
|
|
|
|
current_file_query: None,
|
2025-07-12 15:32:00 -07:00
|
|
|
|
pending_pastes: Vec::new(),
|
2025-08-03 11:31:35 -07:00
|
|
|
|
token_usage_info: None,
|
|
|
|
|
|
has_focus: has_input_focus,
|
2025-08-22 18:05:43 +01:00
|
|
|
|
attached_images: Vec::new(),
|
2025-08-15 22:37:10 -04:00
|
|
|
|
placeholder_text,
|
2025-09-07 20:21:53 -07:00
|
|
|
|
is_task_running: false,
|
2025-08-28 12:54:12 -07:00
|
|
|
|
paste_burst: PasteBurst::default(),
|
|
|
|
|
|
disable_paste_burst: false,
|
2025-08-28 19:16:39 -07:00
|
|
|
|
custom_prompts: Vec::new(),
|
2025-08-28 12:54:12 -07:00
|
|
|
|
};
|
|
|
|
|
|
// Apply configuration via the setter to keep side-effects centralized.
|
|
|
|
|
|
this.set_disable_paste_burst(disable_paste_burst);
|
|
|
|
|
|
this
|
2025-05-14 10:13:29 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
pub fn desired_height(&self, width: u16) -> u16 {
|
|
|
|
|
|
self.textarea.desired_height(width - 1)
|
2025-07-30 17:06:55 -07:00
|
|
|
|
+ match &self.active_popup {
|
2025-09-12 15:31:24 -04:00
|
|
|
|
ActivePopup::None => FOOTER_HEIGHT_WITH_HINT,
|
2025-07-30 17:06:55 -07:00
|
|
|
|
ActivePopup::Command(c) => c.calculate_required_height(),
|
|
|
|
|
|
ActivePopup::File(c) => c.calculate_required_height(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
2025-09-12 15:31:24 -04:00
|
|
|
|
let popup_constraint = match &self.active_popup {
|
|
|
|
|
|
ActivePopup::Command(popup) => Constraint::Max(popup.calculate_required_height()),
|
|
|
|
|
|
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
|
|
|
|
|
|
ActivePopup::None => Constraint::Max(FOOTER_HEIGHT_WITH_HINT),
|
2025-08-03 11:31:35 -07:00
|
|
|
|
};
|
|
|
|
|
|
let [textarea_rect, _] =
|
2025-09-12 15:31:24 -04:00
|
|
|
|
Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area);
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let mut textarea_rect = textarea_rect;
|
|
|
|
|
|
textarea_rect.width = textarea_rect.width.saturating_sub(1);
|
|
|
|
|
|
textarea_rect.x += 1;
|
|
|
|
|
|
let state = self.textarea_state.borrow();
|
|
|
|
|
|
self.textarea.cursor_pos_with_state(textarea_rect, &state)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-16 08:59:26 -07:00
|
|
|
|
/// Returns true if the composer currently contains no user input.
|
|
|
|
|
|
pub(crate) fn is_empty(&self) -> bool {
|
|
|
|
|
|
self.textarea.is_empty()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: show number of tokens remaining in UI (#1388)
When using the OpenAI Responses API, we now record the `usage` field for
a `"response.completed"` event, which includes metrics about the number
of tokens consumed. We also introduce `openai_model_info.rs`, which
includes current data about the most common OpenAI models available via
the API (specifically `context_window` and `max_output_tokens`). If
Codex does not recognize the model, you can set `model_context_window`
and `model_max_output_tokens` explicitly in `config.toml`.
When then introduce a new event type to `protocol.rs`, `TokenCount`,
which includes the `TokenUsage` for the most recent turn.
Finally, we update the TUI to record the running sum of tokens used so
the percentage of available context window remaining can be reported via
the placeholder text for the composer:

We could certainly get much fancier with this (such as reporting the
estimated cost of the conversation), but for now, we are just trying to
achieve feature parity with the TypeScript CLI.
Though arguably this improves upon the TypeScript CLI, as the TypeScript
CLI uses heuristics to estimate the number of tokens used rather than
using the `usage` information directly:
https://github.com/openai/codex/blob/296996d74e345b1b05d8c3451a06ace21c5ada96/codex-cli/src/utils/approximate-tokens-used.ts#L3-L16
Fixes https://github.com/openai/codex/issues/1242
2025-06-25 23:31:11 -07:00
|
|
|
|
/// Update the cached *context-left* percentage and refresh the placeholder
|
|
|
|
|
|
/// text. The UI relies on the placeholder to convey the remaining
|
|
|
|
|
|
/// context when the composer is empty.
|
2025-09-06 08:19:23 -07:00
|
|
|
|
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
|
|
|
|
|
|
self.token_usage_info = token_info;
|
feat: show number of tokens remaining in UI (#1388)
When using the OpenAI Responses API, we now record the `usage` field for
a `"response.completed"` event, which includes metrics about the number
of tokens consumed. We also introduce `openai_model_info.rs`, which
includes current data about the most common OpenAI models available via
the API (specifically `context_window` and `max_output_tokens`). If
Codex does not recognize the model, you can set `model_context_window`
and `model_max_output_tokens` explicitly in `config.toml`.
When then introduce a new event type to `protocol.rs`, `TokenCount`,
which includes the `TokenUsage` for the most recent turn.
Finally, we update the TUI to record the running sum of tokens used so
the percentage of available context window remaining can be reported via
the placeholder text for the composer:

We could certainly get much fancier with this (such as reporting the
estimated cost of the conversation), but for now, we are just trying to
achieve feature parity with the TypeScript CLI.
Though arguably this improves upon the TypeScript CLI, as the TypeScript
CLI uses heuristics to estimate the number of tokens used rather than
using the `usage` information directly:
https://github.com/openai/codex/blob/296996d74e345b1b05d8c3451a06ace21c5ada96/codex-cli/src/utils/approximate-tokens-used.ts#L3-L16
Fixes https://github.com/openai/codex/issues/1242
2025-06-25 23:31:11 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/// Record the history metadata advertised by `SessionConfiguredEvent` so
|
|
|
|
|
|
/// that the composer can navigate cross-session history.
|
|
|
|
|
|
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
|
|
|
|
|
|
self.history.set_metadata(log_id, entry_count);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Integrate an asynchronous response to an on-demand history lookup. If
|
|
|
|
|
|
/// the entry is present and the offset matches the current cursor we
|
|
|
|
|
|
/// immediately populate the textarea.
|
|
|
|
|
|
pub(crate) fn on_history_entry_response(
|
|
|
|
|
|
&mut self,
|
|
|
|
|
|
log_id: u64,
|
|
|
|
|
|
offset: usize,
|
|
|
|
|
|
entry: Option<String>,
|
|
|
|
|
|
) -> bool {
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let Some(text) = self.history.on_entry_response(log_id, offset, entry) else {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
self.textarea.set_text(&text);
|
|
|
|
|
|
self.textarea.set_cursor(0);
|
|
|
|
|
|
true
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-12 15:32:00 -07:00
|
|
|
|
pub fn handle_paste(&mut self, pasted: String) -> bool {
|
|
|
|
|
|
let char_count = pasted.chars().count();
|
|
|
|
|
|
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
|
|
|
|
|
let placeholder = format!("[Pasted Content {char_count} chars]");
|
2025-08-14 16:58:51 -04:00
|
|
|
|
self.textarea.insert_element(&placeholder);
|
2025-07-12 15:32:00 -07:00
|
|
|
|
self.pending_pastes.push((placeholder, pasted));
|
2025-09-02 10:29:58 -07:00
|
|
|
|
} else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) {
|
2025-08-25 16:39:42 -07:00
|
|
|
|
self.textarea.insert_str(" ");
|
2025-07-12 15:32:00 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
self.textarea.insert_str(&pasted);
|
|
|
|
|
|
}
|
2025-08-22 12:23:58 -07:00
|
|
|
|
// Explicit paste events should not trigger Enter suppression.
|
2025-08-28 12:54:12 -07:00
|
|
|
|
self.paste_burst.clear_after_explicit_paste();
|
|
|
|
|
|
// Keep popup sync consistent with key handling: prefer slash popup; only
|
|
|
|
|
|
// sync file popup when slash popup is NOT active.
|
2025-07-12 15:32:00 -07:00
|
|
|
|
self.sync_command_popup();
|
2025-08-28 12:54:12 -07:00
|
|
|
|
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
|
|
|
|
|
self.dismissed_file_popup_token = None;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.sync_file_search_popup();
|
|
|
|
|
|
}
|
2025-07-12 15:32:00 -07:00
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-25 16:39:42 -07:00
|
|
|
|
pub fn handle_paste_image_path(&mut self, pasted: String) -> bool {
|
|
|
|
|
|
let Some(path_buf) = normalize_pasted_path(&pasted) else {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
match image::image_dimensions(&path_buf) {
|
|
|
|
|
|
Ok((w, h)) => {
|
|
|
|
|
|
tracing::info!("OK: {pasted}");
|
|
|
|
|
|
let format_label = pasted_image_format(&path_buf).label();
|
|
|
|
|
|
self.attach_image(path_buf, w, h, format_label);
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
tracing::info!("ERR: {err}");
|
|
|
|
|
|
false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-28 12:54:12 -07:00
|
|
|
|
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
|
|
|
|
|
|
let was_disabled = self.disable_paste_burst;
|
|
|
|
|
|
self.disable_paste_burst = disabled;
|
|
|
|
|
|
if disabled && !was_disabled {
|
|
|
|
|
|
self.paste_burst.clear_window_after_non_char();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-25 14:38:38 -07:00
|
|
|
|
/// Replace the entire composer content with `text` and reset cursor.
|
|
|
|
|
|
pub(crate) fn set_text_content(&mut self, text: String) {
|
2025-09-11 11:45:17 -07:00
|
|
|
|
// Clear any existing content, placeholders, and attachments first.
|
|
|
|
|
|
self.textarea.set_text("");
|
|
|
|
|
|
self.pending_pastes.clear();
|
|
|
|
|
|
self.attached_images.clear();
|
2025-08-25 14:38:38 -07:00
|
|
|
|
self.textarea.set_text(&text);
|
2025-08-27 10:31:49 +09:00
|
|
|
|
self.textarea.set_cursor(0);
|
2025-08-25 14:38:38 -07:00
|
|
|
|
self.sync_command_popup();
|
|
|
|
|
|
self.sync_file_search_popup();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get the current composer text.
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
pub(crate) fn current_text(&self) -> String {
|
|
|
|
|
|
self.textarea.text().to_string()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-28 12:54:12 -07:00
|
|
|
|
/// Attempt to start a burst by retro-capturing recent chars before the cursor.
|
2025-08-22 18:05:43 +01:00
|
|
|
|
pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) {
|
|
|
|
|
|
let placeholder = format!("[image {width}x{height} {format_label}]");
|
|
|
|
|
|
// Insert as an element to match large paste placeholder behavior:
|
|
|
|
|
|
// styled distinctly and treated atomically for cursor/mutations.
|
|
|
|
|
|
self.textarea.insert_element(&placeholder);
|
|
|
|
|
|
self.attached_images
|
|
|
|
|
|
.push(AttachedImage { placeholder, path });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
|
|
|
|
|
|
let images = std::mem::take(&mut self.attached_images);
|
|
|
|
|
|
images.into_iter().map(|img| img.path).collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-28 12:54:12 -07:00
|
|
|
|
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
2025-09-02 10:29:58 -07:00
|
|
|
|
self.handle_paste_burst_flush(Instant::now())
|
2025-08-28 12:54:12 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
|
|
|
|
|
self.paste_burst.is_active()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn recommended_paste_flush_delay() -> Duration {
|
|
|
|
|
|
PasteBurst::recommended_flush_delay()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
/// Integrate results from an asynchronous file search.
|
2025-06-28 15:04:23 -07:00
|
|
|
|
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
// Only apply if user is still editing a token starting with `query`.
|
|
|
|
|
|
let current_opt = Self::current_at_token(&self.textarea);
|
|
|
|
|
|
let Some(current_token) = current_opt else {
|
|
|
|
|
|
return;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if !current_token.starts_with(&query) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if let ActivePopup::File(popup) = &mut self.active_popup {
|
|
|
|
|
|
popup.set_matches(&query, matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-06-27 13:37:11 -04:00
|
|
|
|
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
|
|
|
|
|
|
self.ctrl_c_quit_hint = show;
|
2025-08-03 11:31:35 -07:00
|
|
|
|
self.set_has_focus(has_focus);
|
2025-06-27 13:37:11 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-11 14:15:41 -07:00
|
|
|
|
pub(crate) fn insert_str(&mut self, text: &str) {
|
|
|
|
|
|
self.textarea.insert_str(text);
|
|
|
|
|
|
self.sync_command_popup();
|
|
|
|
|
|
self.sync_file_search_popup();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/// Handle a key event coming from the main UI.
|
2025-05-14 10:13:29 -07:00
|
|
|
|
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
let result = match &mut self.active_popup {
|
|
|
|
|
|
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
|
|
|
|
|
|
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
|
|
|
|
|
|
ActivePopup::None => self.handle_key_event_without_popup(key_event),
|
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
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Update (or hide/show) popup after processing the key.
|
|
|
|
|
|
self.sync_command_popup();
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
|
|
|
|
|
self.dismissed_file_popup_token = None;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.sync_file_search_popup();
|
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
|
|
result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-25 15:38:46 -07:00
|
|
|
|
/// Return true if either the slash-command popup or the file-search popup is active.
|
|
|
|
|
|
pub(crate) fn popup_active(&self) -> bool {
|
|
|
|
|
|
!matches!(self.active_popup, ActivePopup::None)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/// Handle key event when the slash-command popup is visible.
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
|
|
|
|
|
let ActivePopup::Command(popup) = &mut self.active_popup else {
|
|
|
|
|
|
unreachable!();
|
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
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
match key_event {
|
|
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Up, ..
|
|
|
|
|
|
} => {
|
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
|
|
|
|
popup.move_up();
|
|
|
|
|
|
(InputResult::None, true)
|
|
|
|
|
|
}
|
2025-08-03 11:31:35 -07:00
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Down,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
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
|
|
|
|
popup.move_down();
|
|
|
|
|
|
(InputResult::None, true)
|
|
|
|
|
|
}
|
2025-08-25 15:38:46 -07:00
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Esc, ..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
// Dismiss the slash popup; keep the current input untouched.
|
|
|
|
|
|
self.active_popup = ActivePopup::None;
|
|
|
|
|
|
(InputResult::None, true)
|
|
|
|
|
|
}
|
2025-08-03 11:31:35 -07:00
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Tab, ..
|
|
|
|
|
|
} => {
|
2025-09-02 10:29:58 -07:00
|
|
|
|
// Ensure popup filtering/selection reflects the latest composer text
|
|
|
|
|
|
// before applying completion.
|
|
|
|
|
|
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
|
|
|
|
|
popup.on_composer_text_change(first_line.to_string());
|
2025-08-28 19:16:39 -07:00
|
|
|
|
if let Some(sel) = popup.selected_item() {
|
|
|
|
|
|
match sel {
|
|
|
|
|
|
CommandItem::Builtin(cmd) => {
|
|
|
|
|
|
let starts_with_cmd = first_line
|
|
|
|
|
|
.trim_start()
|
|
|
|
|
|
.starts_with(&format!("/{}", cmd.command()));
|
|
|
|
|
|
if !starts_with_cmd {
|
|
|
|
|
|
self.textarea.set_text(&format!("/{} ", cmd.command()));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
CommandItem::UserPrompt(idx) => {
|
|
|
|
|
|
if let Some(name) = popup.prompt_name(idx) {
|
|
|
|
|
|
let starts_with_cmd =
|
|
|
|
|
|
first_line.trim_start().starts_with(&format!("/{name}"));
|
|
|
|
|
|
if !starts_with_cmd {
|
|
|
|
|
|
self.textarea.set_text(&format!("/{name} "));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|
2025-08-20 09:57:55 -07:00
|
|
|
|
// After completing the command, move cursor to the end.
|
|
|
|
|
|
if !self.textarea.text().is_empty() {
|
|
|
|
|
|
let end = self.textarea.text().len();
|
|
|
|
|
|
self.textarea.set_cursor(end);
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|
|
|
|
|
|
(InputResult::None, true)
|
|
|
|
|
|
}
|
2025-08-03 11:31:35 -07:00
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Enter,
|
|
|
|
|
|
modifiers: KeyModifiers::NONE,
|
|
|
|
|
|
..
|
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
|
|
|
|
} => {
|
2025-08-28 19:16:39 -07:00
|
|
|
|
if let Some(sel) = popup.selected_item() {
|
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
|
|
|
|
// Clear textarea so no residual text remains.
|
2025-08-03 11:31:35 -07:00
|
|
|
|
self.textarea.set_text("");
|
2025-08-28 19:16:39 -07:00
|
|
|
|
// Capture any needed data from popup before clearing it.
|
|
|
|
|
|
let prompt_content = match sel {
|
|
|
|
|
|
CommandItem::UserPrompt(idx) => {
|
|
|
|
|
|
popup.prompt_content(idx).map(|s| s.to_string())
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
};
|
|
|
|
|
|
// Hide popup since an action has been dispatched.
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
self.active_popup = ActivePopup::None;
|
2025-08-21 10:36:58 -07:00
|
|
|
|
|
2025-08-28 19:16:39 -07:00
|
|
|
|
match sel {
|
|
|
|
|
|
CommandItem::Builtin(cmd) => {
|
|
|
|
|
|
return (InputResult::Command(cmd), true);
|
|
|
|
|
|
}
|
|
|
|
|
|
CommandItem::UserPrompt(_) => {
|
|
|
|
|
|
if let Some(contents) = prompt_content {
|
|
|
|
|
|
return (InputResult::Submitted(contents), true);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (InputResult::None, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|
|
|
|
|
|
// Fallback to default newline handling if no command selected.
|
|
|
|
|
|
self.handle_key_event_without_popup(key_event)
|
|
|
|
|
|
}
|
|
|
|
|
|
input => self.handle_input_basic(input),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-27 10:31:49 +09:00
|
|
|
|
#[inline]
|
|
|
|
|
|
fn clamp_to_char_boundary(text: &str, pos: usize) -> usize {
|
|
|
|
|
|
let mut p = pos.min(text.len());
|
|
|
|
|
|
if p < text.len() && !text.is_char_boundary(p) {
|
|
|
|
|
|
p = text
|
|
|
|
|
|
.char_indices()
|
|
|
|
|
|
.map(|(i, _)| i)
|
|
|
|
|
|
.take_while(|&i| i <= p)
|
|
|
|
|
|
.last()
|
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
p
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[inline]
|
|
|
|
|
|
fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
2025-08-28 12:54:12 -07:00
|
|
|
|
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
|
2025-08-27 10:31:49 +09:00
|
|
|
|
self.handle_paste(pasted);
|
|
|
|
|
|
}
|
|
|
|
|
|
self.textarea.input(input);
|
|
|
|
|
|
let text_after = self.textarea.text();
|
|
|
|
|
|
self.pending_pastes
|
|
|
|
|
|
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
|
|
|
|
|
(InputResult::None, true)
|
|
|
|
|
|
}
|
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
|
|
|
|
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
/// Handle key events when file search popup is visible.
|
|
|
|
|
|
fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
|
|
|
|
|
let ActivePopup::File(popup) = &mut self.active_popup else {
|
|
|
|
|
|
unreachable!();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
match key_event {
|
|
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Up, ..
|
|
|
|
|
|
} => {
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
popup.move_up();
|
|
|
|
|
|
(InputResult::None, true)
|
|
|
|
|
|
}
|
2025-08-03 11:31:35 -07:00
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Down,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
popup.move_down();
|
|
|
|
|
|
(InputResult::None, true)
|
|
|
|
|
|
}
|
2025-08-03 11:31:35 -07:00
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Esc, ..
|
|
|
|
|
|
} => {
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
// Hide popup without modifying text, remember token to avoid immediate reopen.
|
|
|
|
|
|
if let Some(tok) = Self::current_at_token(&self.textarea) {
|
2025-09-11 11:59:37 -07:00
|
|
|
|
self.dismissed_file_popup_token = Some(tok);
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
self.active_popup = ActivePopup::None;
|
|
|
|
|
|
(InputResult::None, true)
|
|
|
|
|
|
}
|
2025-08-03 11:31:35 -07:00
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Tab, ..
|
|
|
|
|
|
}
|
|
|
|
|
|
| KeyEvent {
|
|
|
|
|
|
code: KeyCode::Enter,
|
|
|
|
|
|
modifiers: KeyModifiers::NONE,
|
|
|
|
|
|
..
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
} => {
|
2025-08-22 18:05:43 +01:00
|
|
|
|
let Some(sel) = popup.selected_match() else {
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
self.active_popup = ActivePopup::None;
|
|
|
|
|
|
return (InputResult::None, true);
|
2025-08-22 18:05:43 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let sel_path = sel.to_string();
|
|
|
|
|
|
// If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
|
|
|
|
|
|
let is_image = Self::is_image_path(&sel_path);
|
|
|
|
|
|
if is_image {
|
|
|
|
|
|
// Determine dimensions; if that fails fall back to normal path insertion.
|
|
|
|
|
|
let path_buf = PathBuf::from(&sel_path);
|
|
|
|
|
|
if let Ok((w, h)) = image::image_dimensions(&path_buf) {
|
|
|
|
|
|
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
|
|
|
|
|
|
// using the flat text and byte-offset cursor API.
|
|
|
|
|
|
let cursor_offset = self.textarea.cursor();
|
|
|
|
|
|
let text = self.textarea.text();
|
2025-08-27 10:31:49 +09:00
|
|
|
|
// Clamp to a valid char boundary to avoid panics when slicing.
|
|
|
|
|
|
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
|
|
|
|
|
let before_cursor = &text[..safe_cursor];
|
|
|
|
|
|
let after_cursor = &text[safe_cursor..];
|
2025-08-22 18:05:43 +01:00
|
|
|
|
|
|
|
|
|
|
// Determine token boundaries in the full text.
|
|
|
|
|
|
let start_idx = before_cursor
|
|
|
|
|
|
.char_indices()
|
|
|
|
|
|
.rfind(|(_, c)| c.is_whitespace())
|
|
|
|
|
|
.map(|(idx, c)| idx + c.len_utf8())
|
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
|
let end_rel_idx = after_cursor
|
|
|
|
|
|
.char_indices()
|
|
|
|
|
|
.find(|(_, c)| c.is_whitespace())
|
|
|
|
|
|
.map(|(idx, _)| idx)
|
|
|
|
|
|
.unwrap_or(after_cursor.len());
|
2025-08-27 10:31:49 +09:00
|
|
|
|
let end_idx = safe_cursor + end_rel_idx;
|
2025-08-22 18:05:43 +01:00
|
|
|
|
|
|
|
|
|
|
self.textarea.replace_range(start_idx..end_idx, "");
|
|
|
|
|
|
self.textarea.set_cursor(start_idx);
|
|
|
|
|
|
|
|
|
|
|
|
let format_label = match Path::new(&sel_path)
|
|
|
|
|
|
.extension()
|
|
|
|
|
|
.and_then(|e| e.to_str())
|
|
|
|
|
|
.map(|s| s.to_ascii_lowercase())
|
|
|
|
|
|
{
|
|
|
|
|
|
Some(ext) if ext == "png" => "PNG",
|
|
|
|
|
|
Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG",
|
|
|
|
|
|
_ => "IMG",
|
|
|
|
|
|
};
|
2025-09-11 11:59:37 -07:00
|
|
|
|
self.attach_image(path_buf, w, h, format_label);
|
2025-08-22 18:05:43 +01:00
|
|
|
|
// Add a trailing space to keep typing fluid.
|
|
|
|
|
|
self.textarea.insert_str(" ");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Fallback to plain path insertion if metadata read fails.
|
|
|
|
|
|
self.insert_selected_path(&sel_path);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Non-image: inserting file path.
|
|
|
|
|
|
self.insert_selected_path(&sel_path);
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
}
|
2025-08-22 18:05:43 +01:00
|
|
|
|
// No selection: treat Enter as closing the popup/session.
|
|
|
|
|
|
self.active_popup = ActivePopup::None;
|
|
|
|
|
|
(InputResult::None, true)
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
input => self.handle_input_basic(input),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-22 18:05:43 +01:00
|
|
|
|
fn is_image_path(path: &str) -> bool {
|
|
|
|
|
|
let lower = path.to_ascii_lowercase();
|
|
|
|
|
|
lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
/// Extract the `@token` that the cursor is currently positioned on, if any.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The returned string **does not** include the leading `@`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Behavior:
|
|
|
|
|
|
/// - The cursor may be anywhere *inside* the token (including on the
|
|
|
|
|
|
/// leading `@`). It does **not** need to be at the end of the line.
|
|
|
|
|
|
/// - A token is delimited by ASCII whitespace (space, tab, newline).
|
2025-08-07 00:41:48 -07:00
|
|
|
|
/// - If the token under the cursor starts with `@`, that token is
|
|
|
|
|
|
/// returned without the leading `@`. This includes the case where the
|
|
|
|
|
|
/// token is just "@" (empty query), which is used to trigger a UI hint
|
2025-08-03 11:31:35 -07:00
|
|
|
|
fn current_at_token(textarea: &TextArea) -> Option<String> {
|
|
|
|
|
|
let cursor_offset = textarea.cursor();
|
|
|
|
|
|
let text = textarea.text();
|
|
|
|
|
|
|
|
|
|
|
|
// Adjust the provided byte offset to the nearest valid char boundary at or before it.
|
|
|
|
|
|
let mut safe_cursor = cursor_offset.min(text.len());
|
|
|
|
|
|
// If we're not on a char boundary, move back to the start of the current char.
|
|
|
|
|
|
if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) {
|
|
|
|
|
|
// Find the last valid boundary <= cursor_offset.
|
|
|
|
|
|
safe_cursor = text
|
|
|
|
|
|
.char_indices()
|
|
|
|
|
|
.map(|(i, _)| i)
|
|
|
|
|
|
.take_while(|&i| i <= cursor_offset)
|
|
|
|
|
|
.last()
|
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
|
}
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
// Split the line around the (now safe) cursor position.
|
|
|
|
|
|
let before_cursor = &text[..safe_cursor];
|
|
|
|
|
|
let after_cursor = &text[safe_cursor..];
|
|
|
|
|
|
|
|
|
|
|
|
// Detect whether we're on whitespace at the cursor boundary.
|
|
|
|
|
|
let at_whitespace = if safe_cursor < text.len() {
|
|
|
|
|
|
text[safe_cursor..]
|
|
|
|
|
|
.chars()
|
|
|
|
|
|
.next()
|
|
|
|
|
|
.map(|c| c.is_whitespace())
|
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
false
|
|
|
|
|
|
};
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
// Left candidate: token containing the cursor position.
|
|
|
|
|
|
let start_left = before_cursor
|
2025-07-08 05:43:31 +09:00
|
|
|
|
.char_indices()
|
|
|
|
|
|
.rfind(|(_, c)| c.is_whitespace())
|
|
|
|
|
|
.map(|(idx, c)| idx + c.len_utf8())
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
.unwrap_or(0);
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let end_left_rel = after_cursor
|
2025-07-08 05:43:31 +09:00
|
|
|
|
.char_indices()
|
|
|
|
|
|
.find(|(_, c)| c.is_whitespace())
|
|
|
|
|
|
.map(|(idx, _)| idx)
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
.unwrap_or(after_cursor.len());
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let end_left = safe_cursor + end_left_rel;
|
|
|
|
|
|
let token_left = if start_left < end_left {
|
|
|
|
|
|
Some(&text[start_left..end_left])
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
};
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
// Right candidate: token immediately after any whitespace from the cursor.
|
|
|
|
|
|
let ws_len_right: usize = after_cursor
|
|
|
|
|
|
.chars()
|
|
|
|
|
|
.take_while(|c| c.is_whitespace())
|
|
|
|
|
|
.map(|c| c.len_utf8())
|
|
|
|
|
|
.sum();
|
|
|
|
|
|
let start_right = safe_cursor + ws_len_right;
|
|
|
|
|
|
let end_right_rel = text[start_right..]
|
|
|
|
|
|
.char_indices()
|
|
|
|
|
|
.find(|(_, c)| c.is_whitespace())
|
|
|
|
|
|
.map(|(idx, _)| idx)
|
|
|
|
|
|
.unwrap_or(text.len() - start_right);
|
|
|
|
|
|
let end_right = start_right + end_right_rel;
|
|
|
|
|
|
let token_right = if start_right < end_right {
|
|
|
|
|
|
Some(&text[start_right..end_right])
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
None
|
2025-08-03 11:31:35 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let left_at = token_left
|
2025-08-07 00:41:48 -07:00
|
|
|
|
.filter(|t| t.starts_with('@'))
|
2025-08-03 11:31:35 -07:00
|
|
|
|
.map(|t| t[1..].to_string());
|
|
|
|
|
|
let right_at = token_right
|
2025-08-07 00:41:48 -07:00
|
|
|
|
.filter(|t| t.starts_with('@'))
|
2025-08-03 11:31:35 -07:00
|
|
|
|
.map(|t| t[1..].to_string());
|
|
|
|
|
|
|
|
|
|
|
|
if at_whitespace {
|
2025-08-07 00:41:48 -07:00
|
|
|
|
if right_at.is_some() {
|
|
|
|
|
|
return right_at;
|
|
|
|
|
|
}
|
|
|
|
|
|
if token_left.is_some_and(|t| t == "@") {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
return left_at;
|
2025-08-03 11:31:35 -07:00
|
|
|
|
}
|
|
|
|
|
|
if after_cursor.starts_with('@') {
|
|
|
|
|
|
return right_at.or(left_at);
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
}
|
2025-08-03 11:31:35 -07:00
|
|
|
|
left_at.or(right_at)
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Replace the active `@token` (the one under the cursor) with `path`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The algorithm mirrors `current_at_token` so replacement works no matter
|
|
|
|
|
|
/// where the cursor is within the token and regardless of how many
|
|
|
|
|
|
/// `@tokens` exist in the line.
|
|
|
|
|
|
fn insert_selected_path(&mut self, path: &str) {
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let cursor_offset = self.textarea.cursor();
|
|
|
|
|
|
let text = self.textarea.text();
|
2025-08-27 10:31:49 +09:00
|
|
|
|
// Clamp to a valid char boundary to avoid panics when slicing.
|
|
|
|
|
|
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
|
2025-08-27 10:31:49 +09:00
|
|
|
|
let before_cursor = &text[..safe_cursor];
|
|
|
|
|
|
let after_cursor = &text[safe_cursor..];
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
// Determine token boundaries.
|
|
|
|
|
|
let start_idx = before_cursor
|
|
|
|
|
|
.char_indices()
|
|
|
|
|
|
.rfind(|(_, c)| c.is_whitespace())
|
|
|
|
|
|
.map(|(idx, c)| idx + c.len_utf8())
|
|
|
|
|
|
.unwrap_or(0);
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let end_rel_idx = after_cursor
|
|
|
|
|
|
.char_indices()
|
|
|
|
|
|
.find(|(_, c)| c.is_whitespace())
|
|
|
|
|
|
.map(|(idx, _)| idx)
|
|
|
|
|
|
.unwrap_or(after_cursor.len());
|
2025-08-27 10:31:49 +09:00
|
|
|
|
let end_idx = safe_cursor + end_rel_idx;
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
|
|
|
|
|
|
let mut new_text =
|
|
|
|
|
|
String::with_capacity(text.len() - (end_idx - start_idx) + path.len() + 1);
|
|
|
|
|
|
new_text.push_str(&text[..start_idx]);
|
|
|
|
|
|
new_text.push_str(path);
|
|
|
|
|
|
new_text.push(' ');
|
|
|
|
|
|
new_text.push_str(&text[end_idx..]);
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
self.textarea.set_text(&new_text);
|
2025-08-07 00:58:06 +01:00
|
|
|
|
let new_cursor = start_idx.saturating_add(path.len()).saturating_add(1);
|
|
|
|
|
|
self.textarea.set_cursor(new_cursor);
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/// Handle key event when no popup is visible.
|
|
|
|
|
|
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
2025-08-03 11:31:35 -07:00
|
|
|
|
match key_event {
|
2025-08-25 20:15:38 -07:00
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Char('d'),
|
|
|
|
|
|
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
|
|
|
|
|
kind: KeyEventKind::Press,
|
|
|
|
|
|
..
|
|
|
|
|
|
} if self.is_empty() => {
|
|
|
|
|
|
self.app_event_tx.send(AppEvent::ExitRequest);
|
|
|
|
|
|
(InputResult::None, true)
|
|
|
|
|
|
}
|
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 navigation (Up / Down) – only when the composer is not
|
|
|
|
|
|
// empty or when the cursor is at the correct position, to avoid
|
|
|
|
|
|
// interfering with normal cursor movement.
|
|
|
|
|
|
// -------------------------------------------------------------
|
2025-08-03 11:31:35 -07:00
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Up | KeyCode::Down,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
if self
|
|
|
|
|
|
.history
|
|
|
|
|
|
.should_handle_navigation(self.textarea.text(), self.textarea.cursor())
|
|
|
|
|
|
{
|
|
|
|
|
|
let replace_text = match key_event.code {
|
|
|
|
|
|
KeyCode::Up => self.history.navigate_up(&self.app_event_tx),
|
|
|
|
|
|
KeyCode::Down => self.history.navigate_down(&self.app_event_tx),
|
|
|
|
|
|
_ => unreachable!(),
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Some(text) = replace_text {
|
|
|
|
|
|
self.textarea.set_text(&text);
|
|
|
|
|
|
self.textarea.set_cursor(0);
|
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
|
|
|
|
return (InputResult::None, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-03 11:31:35 -07:00
|
|
|
|
self.handle_input_basic(key_event)
|
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
|
|
|
|
}
|
2025-08-03 11:31:35 -07:00
|
|
|
|
KeyEvent {
|
|
|
|
|
|
code: KeyCode::Enter,
|
|
|
|
|
|
modifiers: KeyModifiers::NONE,
|
|
|
|
|
|
..
|
2025-05-14 10:13:29 -07:00
|
|
|
|
} => {
|
2025-08-22 12:23:58 -07:00
|
|
|
|
// If we're in a paste-like burst capture, treat Enter as part of the burst
|
|
|
|
|
|
// and accumulate it rather than submitting or inserting immediately.
|
|
|
|
|
|
// Do not treat Enter as paste inside a slash-command context.
|
|
|
|
|
|
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|
|
|
|
|
|
|| self
|
|
|
|
|
|
.textarea
|
|
|
|
|
|
.text()
|
|
|
|
|
|
.lines()
|
|
|
|
|
|
.next()
|
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
|
.starts_with('/');
|
2025-08-28 12:54:12 -07:00
|
|
|
|
if self.paste_burst.is_active() && !in_slash_context {
|
2025-08-22 12:23:58 -07:00
|
|
|
|
let now = Instant::now();
|
2025-08-28 12:54:12 -07:00
|
|
|
|
if self.paste_burst.append_newline_if_active(now) {
|
|
|
|
|
|
return (InputResult::None, true);
|
|
|
|
|
|
}
|
2025-08-22 12:23:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
// If we have pending placeholder pastes, submit immediately to expand them.
|
|
|
|
|
|
if !self.pending_pastes.is_empty() {
|
|
|
|
|
|
let mut text = self.textarea.text().to_string();
|
|
|
|
|
|
self.textarea.set_text("");
|
|
|
|
|
|
for (placeholder, actual) in &self.pending_pastes {
|
|
|
|
|
|
if text.contains(placeholder) {
|
|
|
|
|
|
text = text.replace(placeholder, actual);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self.pending_pastes.clear();
|
|
|
|
|
|
if text.is_empty() {
|
|
|
|
|
|
return (InputResult::None, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
self.history.record_local_submission(&text);
|
|
|
|
|
|
return (InputResult::Submitted(text), true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// During a paste-like burst, treat Enter as a newline instead of submit.
|
|
|
|
|
|
let now = Instant::now();
|
2025-08-28 12:54:12 -07:00
|
|
|
|
if self
|
|
|
|
|
|
.paste_burst
|
|
|
|
|
|
.newline_should_insert_instead_of_submit(now)
|
|
|
|
|
|
{
|
2025-08-22 12:23:58 -07:00
|
|
|
|
self.textarea.insert_str("\n");
|
2025-08-28 12:54:12 -07:00
|
|
|
|
self.paste_burst.extend_window(now);
|
2025-08-22 12:23:58 -07:00
|
|
|
|
return (InputResult::None, true);
|
|
|
|
|
|
}
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let mut text = self.textarea.text().to_string();
|
|
|
|
|
|
self.textarea.set_text("");
|
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
|
|
|
|
|
2025-07-12 15:32:00 -07:00
|
|
|
|
// Replace all pending pastes in the text
|
|
|
|
|
|
for (placeholder, actual) in &self.pending_pastes {
|
|
|
|
|
|
if text.contains(placeholder) {
|
|
|
|
|
|
text = text.replace(placeholder, actual);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self.pending_pastes.clear();
|
|
|
|
|
|
|
2025-09-03 02:05:45 +09:00
|
|
|
|
// If there is neither text nor attachments, suppress submission entirely.
|
|
|
|
|
|
let has_attachments = !self.attached_images.is_empty();
|
2025-08-22 18:05:43 +01:00
|
|
|
|
text = text.trim().to_string();
|
2025-09-03 02:05:45 +09:00
|
|
|
|
if text.is_empty() && !has_attachments {
|
|
|
|
|
|
return (InputResult::None, true);
|
|
|
|
|
|
}
|
2025-08-22 18:05:43 +01:00
|
|
|
|
if !text.is_empty() {
|
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
|
|
|
|
self.history.record_local_submission(&text);
|
|
|
|
|
|
}
|
2025-08-22 18:05:43 +01:00
|
|
|
|
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
|
|
|
|
|
|
(InputResult::Submitted(text), true)
|
2025-05-14 10:13:29 -07:00
|
|
|
|
}
|
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
|
|
|
|
input => self.handle_input_basic(input),
|
2025-05-14 10:13:29 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-02 10:29:58 -07:00
|
|
|
|
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
|
|
|
|
|
|
match self.paste_burst.flush_if_due(now) {
|
|
|
|
|
|
FlushResult::Paste(pasted) => {
|
|
|
|
|
|
self.handle_paste(pasted);
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
FlushResult::Typed(ch) => {
|
|
|
|
|
|
// Mirror insert_str() behavior so popups stay in sync when a
|
|
|
|
|
|
// pending fast char flushes as normal typed input.
|
|
|
|
|
|
self.textarea.insert_str(ch.to_string().as_str());
|
|
|
|
|
|
// Keep popup sync consistent with key handling: prefer slash popup; only
|
|
|
|
|
|
// sync file popup when slash popup is NOT active.
|
|
|
|
|
|
self.sync_command_popup();
|
|
|
|
|
|
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
|
|
|
|
|
self.dismissed_file_popup_token = None;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.sync_file_search_popup();
|
|
|
|
|
|
}
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
FlushResult::None => false,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/// Handle generic Input events that modify the textarea content.
|
2025-08-03 11:31:35 -07:00
|
|
|
|
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
2025-08-22 12:23:58 -07:00
|
|
|
|
// If we have a buffered non-bracketed paste burst and enough time has
|
|
|
|
|
|
// elapsed since the last char, flush it before handling a new input.
|
|
|
|
|
|
let now = Instant::now();
|
2025-09-02 10:29:58 -07:00
|
|
|
|
self.handle_paste_burst_flush(now);
|
2025-08-22 12:23:58 -07:00
|
|
|
|
|
|
|
|
|
|
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
|
|
|
|
|
|
if matches!(input.code, KeyCode::Enter)
|
2025-08-28 12:54:12 -07:00
|
|
|
|
&& self.paste_burst.is_active()
|
|
|
|
|
|
&& self.paste_burst.append_newline_if_active(now)
|
2025-08-22 12:23:58 -07:00
|
|
|
|
{
|
|
|
|
|
|
return (InputResult::None, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Intercept plain Char inputs to optionally accumulate into a burst buffer.
|
|
|
|
|
|
if let KeyEvent {
|
|
|
|
|
|
code: KeyCode::Char(ch),
|
|
|
|
|
|
modifiers,
|
|
|
|
|
|
..
|
|
|
|
|
|
} = input
|
|
|
|
|
|
{
|
|
|
|
|
|
let has_ctrl_or_alt =
|
|
|
|
|
|
modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT);
|
|
|
|
|
|
if !has_ctrl_or_alt {
|
2025-08-27 10:31:49 +09:00
|
|
|
|
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts and be
|
2025-08-28 12:54:12 -07:00
|
|
|
|
// misclassified by paste heuristics. Flush any active burst buffer and insert
|
|
|
|
|
|
// non-ASCII characters directly.
|
2025-08-27 10:31:49 +09:00
|
|
|
|
if !ch.is_ascii() {
|
|
|
|
|
|
return self.handle_non_ascii_char(input);
|
|
|
|
|
|
}
|
2025-08-28 12:54:12 -07:00
|
|
|
|
|
|
|
|
|
|
match self.paste_burst.on_plain_char(ch, now) {
|
|
|
|
|
|
CharDecision::BufferAppend => {
|
|
|
|
|
|
self.paste_burst.append_char_to_buffer(ch, now);
|
|
|
|
|
|
return (InputResult::None, true);
|
2025-08-22 12:23:58 -07:00
|
|
|
|
}
|
2025-08-28 12:54:12 -07:00
|
|
|
|
CharDecision::BeginBuffer { retro_chars } => {
|
|
|
|
|
|
let cur = self.textarea.cursor();
|
|
|
|
|
|
let txt = self.textarea.text();
|
|
|
|
|
|
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
|
|
|
|
|
|
let before = &txt[..safe_cur];
|
|
|
|
|
|
if let Some(grab) =
|
|
|
|
|
|
self.paste_burst
|
|
|
|
|
|
.decide_begin_buffer(now, before, retro_chars as usize)
|
|
|
|
|
|
{
|
|
|
|
|
|
if !grab.grabbed.is_empty() {
|
|
|
|
|
|
self.textarea.replace_range(grab.start_byte..safe_cur, "");
|
|
|
|
|
|
}
|
|
|
|
|
|
self.paste_burst.begin_with_retro_grabbed(grab.grabbed, now);
|
|
|
|
|
|
self.paste_burst.append_char_to_buffer(ch, now);
|
|
|
|
|
|
return (InputResult::None, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
// If decide_begin_buffer opted not to start buffering,
|
|
|
|
|
|
// fall through to normal insertion below.
|
2025-08-22 12:23:58 -07:00
|
|
|
|
}
|
2025-08-28 12:54:12 -07:00
|
|
|
|
CharDecision::BeginBufferFromPending => {
|
|
|
|
|
|
// First char was held; now append the current one.
|
|
|
|
|
|
self.paste_burst.append_char_to_buffer(ch, now);
|
|
|
|
|
|
return (InputResult::None, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
CharDecision::RetainFirstChar => {
|
|
|
|
|
|
// Keep the first fast char pending momentarily.
|
2025-08-22 12:23:58 -07:00
|
|
|
|
return (InputResult::None, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-28 12:54:12 -07:00
|
|
|
|
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
|
|
|
|
|
|
self.handle_paste(pasted);
|
|
|
|
|
|
}
|
2025-08-22 12:23:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For non-char inputs (or after flushing), handle normally.
|
2025-08-22 18:05:43 +01:00
|
|
|
|
// Special handling for backspace on placeholders
|
|
|
|
|
|
if let KeyEvent {
|
|
|
|
|
|
code: KeyCode::Backspace,
|
|
|
|
|
|
..
|
|
|
|
|
|
} = input
|
|
|
|
|
|
&& self.try_remove_any_placeholder_at_cursor()
|
|
|
|
|
|
{
|
|
|
|
|
|
return (InputResult::None, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-12 15:32:00 -07:00
|
|
|
|
// Normal input handling
|
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
|
|
|
|
self.textarea.input(input);
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let text_after = self.textarea.text();
|
2025-07-12 15:32:00 -07:00
|
|
|
|
|
2025-08-22 12:23:58 -07:00
|
|
|
|
// Update paste-burst heuristic for plain Char (no Ctrl/Alt) events.
|
|
|
|
|
|
let crossterm::event::KeyEvent {
|
|
|
|
|
|
code, modifiers, ..
|
|
|
|
|
|
} = input;
|
|
|
|
|
|
match code {
|
|
|
|
|
|
KeyCode::Char(_) => {
|
|
|
|
|
|
let has_ctrl_or_alt = modifiers.contains(KeyModifiers::CONTROL)
|
|
|
|
|
|
|| modifiers.contains(KeyModifiers::ALT);
|
|
|
|
|
|
if has_ctrl_or_alt {
|
2025-08-28 12:54:12 -07:00
|
|
|
|
self.paste_burst.clear_window_after_non_char();
|
2025-08-22 12:23:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
|
// Keep burst window alive (supports blank lines in paste).
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {
|
2025-08-28 12:54:12 -07:00
|
|
|
|
// Other keys: clear burst window (buffer should have been flushed above if needed).
|
|
|
|
|
|
self.paste_burst.clear_window_after_non_char();
|
2025-08-22 12:23:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-12 15:32:00 -07:00
|
|
|
|
// Check if any placeholders were removed and remove their corresponding pending pastes
|
|
|
|
|
|
self.pending_pastes
|
|
|
|
|
|
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
|
|
|
|
|
|
2025-08-22 18:05:43 +01:00
|
|
|
|
// Keep attached images in proportion to how many matching placeholders exist in the text.
|
|
|
|
|
|
// This handles duplicate placeholders that share the same visible label.
|
|
|
|
|
|
if !self.attached_images.is_empty() {
|
|
|
|
|
|
let mut needed: HashMap<String, usize> = HashMap::new();
|
|
|
|
|
|
for img in &self.attached_images {
|
|
|
|
|
|
needed
|
|
|
|
|
|
.entry(img.placeholder.clone())
|
|
|
|
|
|
.or_insert_with(|| text_after.matches(&img.placeholder).count());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let mut used: HashMap<String, usize> = HashMap::new();
|
|
|
|
|
|
let mut kept: Vec<AttachedImage> = Vec::with_capacity(self.attached_images.len());
|
|
|
|
|
|
for img in self.attached_images.drain(..) {
|
|
|
|
|
|
let total_needed = *needed.get(&img.placeholder).unwrap_or(&0);
|
|
|
|
|
|
let used_count = used.entry(img.placeholder.clone()).or_insert(0);
|
|
|
|
|
|
if *used_count < total_needed {
|
|
|
|
|
|
kept.push(img);
|
|
|
|
|
|
*used_count += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self.attached_images = kept;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
(InputResult::None, true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-22 18:05:43 +01:00
|
|
|
|
/// Attempts to remove an image or paste placeholder if the cursor is at the end of one.
|
|
|
|
|
|
/// Returns true if a placeholder was removed.
|
|
|
|
|
|
fn try_remove_any_placeholder_at_cursor(&mut self) -> bool {
|
2025-08-27 10:31:49 +09:00
|
|
|
|
// Clamp the cursor to a valid char boundary to avoid panics when slicing.
|
2025-08-22 18:05:43 +01:00
|
|
|
|
let text = self.textarea.text();
|
2025-08-27 10:31:49 +09:00
|
|
|
|
let p = Self::clamp_to_char_boundary(text, self.textarea.cursor());
|
2025-08-22 18:05:43 +01:00
|
|
|
|
|
|
|
|
|
|
// Try image placeholders first
|
|
|
|
|
|
let mut out: Option<(usize, String)> = None;
|
|
|
|
|
|
// Detect if the cursor is at the end of any image placeholder.
|
|
|
|
|
|
// If duplicates exist, remove the specific occurrence's mapping.
|
|
|
|
|
|
for (i, img) in self.attached_images.iter().enumerate() {
|
|
|
|
|
|
let ph = &img.placeholder;
|
|
|
|
|
|
if p < ph.len() {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let start = p - ph.len();
|
2025-08-27 10:31:49 +09:00
|
|
|
|
if text.get(start..p) != Some(ph.as_str()) {
|
2025-08-22 18:05:43 +01:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Count the number of occurrences of `ph` before `start`.
|
|
|
|
|
|
let mut occ_before = 0usize;
|
|
|
|
|
|
let mut search_pos = 0usize;
|
|
|
|
|
|
while search_pos < start {
|
2025-08-27 10:31:49 +09:00
|
|
|
|
let segment = match text.get(search_pos..start) {
|
|
|
|
|
|
Some(s) => s,
|
|
|
|
|
|
None => break,
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Some(found) = segment.find(ph) {
|
2025-08-22 18:05:43 +01:00
|
|
|
|
occ_before += 1;
|
|
|
|
|
|
search_pos += found + ph.len();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Remove the occ_before-th attached image that shares this placeholder label.
|
|
|
|
|
|
out = if let Some((remove_idx, _)) = self
|
|
|
|
|
|
.attached_images
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
|
|
|
|
|
.filter(|(_, img2)| img2.placeholder == *ph)
|
|
|
|
|
|
.nth(occ_before)
|
|
|
|
|
|
{
|
|
|
|
|
|
Some((remove_idx, ph.clone()))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Some((i, ph.clone()))
|
|
|
|
|
|
};
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some((idx, placeholder)) = out {
|
|
|
|
|
|
self.textarea.replace_range(p - placeholder.len()..p, "");
|
|
|
|
|
|
self.attached_images.remove(idx);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Also handle when the cursor is at the START of an image placeholder.
|
|
|
|
|
|
// let result = 'out: {
|
|
|
|
|
|
let out: Option<(usize, String)> = 'out: {
|
|
|
|
|
|
for (i, img) in self.attached_images.iter().enumerate() {
|
|
|
|
|
|
let ph = &img.placeholder;
|
|
|
|
|
|
if p + ph.len() > text.len() {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2025-08-27 10:31:49 +09:00
|
|
|
|
if text.get(p..p + ph.len()) != Some(ph.as_str()) {
|
2025-08-22 18:05:43 +01:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Count occurrences of `ph` before `p`.
|
|
|
|
|
|
let mut occ_before = 0usize;
|
|
|
|
|
|
let mut search_pos = 0usize;
|
|
|
|
|
|
while search_pos < p {
|
2025-08-27 10:31:49 +09:00
|
|
|
|
let segment = match text.get(search_pos..p) {
|
|
|
|
|
|
Some(s) => s,
|
|
|
|
|
|
None => break 'out None,
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Some(found) = segment.find(ph) {
|
2025-08-22 18:05:43 +01:00
|
|
|
|
occ_before += 1;
|
|
|
|
|
|
search_pos += found + ph.len();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
break 'out None;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if let Some((remove_idx, _)) = self
|
|
|
|
|
|
.attached_images
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
|
|
|
|
|
.filter(|(_, img2)| img2.placeholder == *ph)
|
|
|
|
|
|
.nth(occ_before)
|
|
|
|
|
|
{
|
|
|
|
|
|
break 'out Some((remove_idx, ph.clone()));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
break 'out Some((i, ph.clone()));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
None
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if let Some((idx, placeholder)) = out {
|
|
|
|
|
|
self.textarea.replace_range(p..p + placeholder.len(), "");
|
|
|
|
|
|
self.attached_images.remove(idx);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Then try pasted-content placeholders
|
|
|
|
|
|
if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
|
|
|
|
|
|
if p < ph.len() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let start = p - ph.len();
|
2025-08-27 10:31:49 +09:00
|
|
|
|
if text.get(start..p) == Some(ph.as_str()) {
|
2025-08-22 18:05:43 +01:00
|
|
|
|
Some(ph.clone())
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
}) {
|
|
|
|
|
|
self.textarea.replace_range(p - placeholder.len()..p, "");
|
|
|
|
|
|
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Also handle when the cursor is at the START of a pasted-content placeholder.
|
|
|
|
|
|
if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
|
|
|
|
|
|
if p + ph.len() > text.len() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
2025-08-27 10:31:49 +09:00
|
|
|
|
if text.get(p..p + ph.len()) == Some(ph.as_str()) {
|
2025-08-22 18:05:43 +01:00
|
|
|
|
Some(ph.clone())
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
}) {
|
|
|
|
|
|
self.textarea.replace_range(p..p + placeholder.len(), "");
|
|
|
|
|
|
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/// 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.
|
|
|
|
|
|
fn sync_command_popup(&mut self) {
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
let input_starts_with_slash = first_line.starts_with('/');
|
|
|
|
|
|
match &mut self.active_popup {
|
|
|
|
|
|
ActivePopup::Command(popup) => {
|
|
|
|
|
|
if input_starts_with_slash {
|
|
|
|
|
|
popup.on_composer_text_change(first_line.to_string());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.active_popup = ActivePopup::None;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {
|
|
|
|
|
|
if input_starts_with_slash {
|
2025-08-28 19:16:39 -07:00
|
|
|
|
let mut command_popup = CommandPopup::new(self.custom_prompts.clone());
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
command_popup.on_composer_text_change(first_line.to_string());
|
|
|
|
|
|
self.active_popup = ActivePopup::Command(command_popup);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-28 19:16:39 -07:00
|
|
|
|
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
|
|
|
|
|
|
self.custom_prompts = prompts.clone();
|
|
|
|
|
|
if let ActivePopup::Command(popup) = &mut self.active_popup {
|
|
|
|
|
|
popup.set_prompts(prompts);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
/// Synchronize `self.file_search_popup` with the current text in the textarea.
|
|
|
|
|
|
/// Note this is only called when self.active_popup is NOT Command.
|
|
|
|
|
|
fn sync_file_search_popup(&mut self) {
|
|
|
|
|
|
// Determine if there is an @token underneath the cursor.
|
|
|
|
|
|
let query = match Self::current_at_token(&self.textarea) {
|
|
|
|
|
|
Some(token) => token,
|
|
|
|
|
|
None => {
|
|
|
|
|
|
self.active_popup = ActivePopup::None;
|
|
|
|
|
|
self.dismissed_file_popup_token = None;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
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
|
|
|
|
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
// If user dismissed popup for this exact query, don't reopen until text changes.
|
|
|
|
|
|
if self.dismissed_file_popup_token.as_ref() == Some(&query) {
|
|
|
|
|
|
return;
|
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
|
|
|
|
}
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
|
2025-08-07 00:41:48 -07:00
|
|
|
|
if !query.is_empty() {
|
|
|
|
|
|
self.app_event_tx
|
|
|
|
|
|
.send(AppEvent::StartFileSearch(query.clone()));
|
|
|
|
|
|
}
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
|
|
|
|
|
|
match &mut self.active_popup {
|
|
|
|
|
|
ActivePopup::File(popup) => {
|
2025-08-07 00:41:48 -07:00
|
|
|
|
if query.is_empty() {
|
|
|
|
|
|
popup.set_empty_prompt();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
popup.set_query(&query);
|
|
|
|
|
|
}
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
_ => {
|
|
|
|
|
|
let mut popup = FileSearchPopup::new();
|
2025-08-07 00:41:48 -07:00
|
|
|
|
if query.is_empty() {
|
|
|
|
|
|
popup.set_empty_prompt();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
popup.set_query(&query);
|
|
|
|
|
|
}
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
self.active_popup = ActivePopup::File(popup);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
self.current_file_query = Some(query);
|
|
|
|
|
|
self.dismissed_file_popup_token = None;
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
fn set_has_focus(&mut self, has_focus: bool) {
|
|
|
|
|
|
self.has_focus = has_focus;
|
2025-05-14 10:13:29 -07:00
|
|
|
|
}
|
2025-08-23 23:23:15 -07:00
|
|
|
|
|
2025-09-07 20:21:53 -07:00
|
|
|
|
pub fn set_task_running(&mut self, running: bool) {
|
|
|
|
|
|
self.is_task_running = running;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-23 23:23:15 -07:00
|
|
|
|
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
|
|
|
|
|
|
self.esc_backtrack_hint = show;
|
|
|
|
|
|
}
|
2025-05-14 10:13:29 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-25 14:38:38 -07:00
|
|
|
|
impl WidgetRef for ChatComposer {
|
2025-05-14 10:13:29 -07:00
|
|
|
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
2025-09-12 15:31:24 -04:00
|
|
|
|
let (popup_constraint, hint_spacing) = match &self.active_popup {
|
|
|
|
|
|
ActivePopup::Command(popup) => (Constraint::Max(popup.calculate_required_height()), 0),
|
|
|
|
|
|
ActivePopup::File(popup) => (Constraint::Max(popup.calculate_required_height()), 0),
|
|
|
|
|
|
ActivePopup::None => (
|
|
|
|
|
|
Constraint::Length(FOOTER_HEIGHT_WITH_HINT),
|
|
|
|
|
|
FOOTER_SPACING_HEIGHT,
|
|
|
|
|
|
),
|
2025-08-03 11:31:35 -07:00
|
|
|
|
};
|
|
|
|
|
|
let [textarea_rect, popup_rect] =
|
2025-09-12 15:31:24 -04:00
|
|
|
|
Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area);
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
match &self.active_popup {
|
|
|
|
|
|
ActivePopup::Command(popup) => {
|
2025-08-03 11:31:35 -07:00
|
|
|
|
popup.render_ref(popup_rect, buf);
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
ActivePopup::File(popup) => {
|
2025-08-03 11:31:35 -07:00
|
|
|
|
popup.render_ref(popup_rect, buf);
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
ActivePopup::None => {
|
2025-09-12 15:31:24 -04:00
|
|
|
|
let hint_rect = if hint_spacing > 0 {
|
|
|
|
|
|
let [_, hint_rect] = Layout::vertical([
|
|
|
|
|
|
Constraint::Length(hint_spacing),
|
|
|
|
|
|
Constraint::Length(FOOTER_HINT_HEIGHT),
|
|
|
|
|
|
])
|
|
|
|
|
|
.areas(popup_rect);
|
|
|
|
|
|
hint_rect
|
|
|
|
|
|
} else {
|
|
|
|
|
|
popup_rect
|
|
|
|
|
|
};
|
2025-09-04 10:55:50 -07:00
|
|
|
|
let mut hint: Vec<Span<'static>> = if self.ctrl_c_quit_hint {
|
2025-09-07 20:21:53 -07:00
|
|
|
|
let ctrl_c_followup = if self.is_task_running {
|
|
|
|
|
|
" to interrupt"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
" to quit"
|
|
|
|
|
|
};
|
2025-07-31 00:43:21 -07:00
|
|
|
|
vec![
|
2025-09-02 16:19:54 -07:00
|
|
|
|
" ".into(),
|
2025-09-04 10:55:50 -07:00
|
|
|
|
key_hint::ctrl('C'),
|
|
|
|
|
|
" again".into(),
|
2025-09-07 20:21:53 -07:00
|
|
|
|
ctrl_c_followup.into(),
|
2025-07-31 00:43:21 -07:00
|
|
|
|
]
|
|
|
|
|
|
} else {
|
2025-07-31 17:30:44 -07:00
|
|
|
|
let newline_hint_key = if self.use_shift_enter_hint {
|
2025-09-04 10:55:50 -07:00
|
|
|
|
key_hint::shift('⏎')
|
2025-07-31 17:30:44 -07:00
|
|
|
|
} else {
|
2025-09-04 10:55:50 -07:00
|
|
|
|
key_hint::ctrl('J')
|
2025-07-31 17:30:44 -07:00
|
|
|
|
};
|
2025-07-31 00:43:21 -07:00
|
|
|
|
vec![
|
2025-09-02 16:19:54 -07:00
|
|
|
|
" ".into(),
|
2025-09-04 10:55:50 -07:00
|
|
|
|
key_hint::plain('⏎'),
|
2025-09-02 16:19:54 -07:00
|
|
|
|
" send ".into(),
|
2025-09-04 10:55:50 -07:00
|
|
|
|
newline_hint_key,
|
2025-09-02 16:19:54 -07:00
|
|
|
|
" newline ".into(),
|
2025-09-04 10:55:50 -07:00
|
|
|
|
key_hint::ctrl('T'),
|
2025-09-02 16:19:54 -07:00
|
|
|
|
" transcript ".into(),
|
2025-09-04 10:55:50 -07:00
|
|
|
|
key_hint::ctrl('C'),
|
2025-09-02 16:19:54 -07:00
|
|
|
|
" quit".into(),
|
2025-07-31 00:43:21 -07:00
|
|
|
|
]
|
|
|
|
|
|
};
|
2025-08-07 00:45:47 -07:00
|
|
|
|
|
2025-08-23 23:23:15 -07:00
|
|
|
|
if !self.ctrl_c_quit_hint && self.esc_backtrack_hint {
|
2025-09-02 16:19:54 -07:00
|
|
|
|
hint.push(" ".into());
|
2025-09-04 10:55:50 -07:00
|
|
|
|
hint.push(key_hint::plain("Esc"));
|
2025-09-02 16:19:54 -07:00
|
|
|
|
hint.push(" edit prev".into());
|
2025-08-23 23:23:15 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-07 00:45:47 -07:00
|
|
|
|
// Append token/context usage info to the footer hints when available.
|
|
|
|
|
|
if let Some(token_usage_info) = &self.token_usage_info {
|
2025-08-07 05:17:18 -07:00
|
|
|
|
let token_usage = &token_usage_info.total_token_usage;
|
2025-09-02 16:19:54 -07:00
|
|
|
|
hint.push(" ".into());
|
2025-08-07 00:45:47 -07:00
|
|
|
|
hint.push(
|
2025-09-08 14:48:48 -07:00
|
|
|
|
Span::from(format!(
|
|
|
|
|
|
"{} tokens used",
|
|
|
|
|
|
format_si_suffix(token_usage.blended_total())
|
|
|
|
|
|
))
|
|
|
|
|
|
.style(Style::default().add_modifier(Modifier::DIM)),
|
2025-08-07 00:45:47 -07:00
|
|
|
|
);
|
2025-08-07 05:17:18 -07:00
|
|
|
|
let last_token_usage = &token_usage_info.last_token_usage;
|
2025-08-07 00:45:47 -07:00
|
|
|
|
if let Some(context_window) = token_usage_info.model_context_window {
|
|
|
|
|
|
let percent_remaining: u8 = if context_window > 0 {
|
2025-09-04 16:34:14 -07:00
|
|
|
|
last_token_usage.percent_of_context_window_remaining(context_window)
|
2025-08-07 00:45:47 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
100
|
|
|
|
|
|
};
|
2025-09-06 08:19:23 -07:00
|
|
|
|
let context_style = if percent_remaining < 20 {
|
|
|
|
|
|
Style::default().fg(Color::Yellow)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Style::default().add_modifier(Modifier::DIM)
|
|
|
|
|
|
};
|
2025-09-02 16:19:54 -07:00
|
|
|
|
hint.push(" ".into());
|
2025-09-06 08:19:23 -07:00
|
|
|
|
hint.push(Span::styled(
|
|
|
|
|
|
format!("{percent_remaining}% context left"),
|
|
|
|
|
|
context_style,
|
|
|
|
|
|
));
|
2025-08-07 00:45:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-31 00:43:21 -07:00
|
|
|
|
Line::from(hint)
|
|
|
|
|
|
.style(Style::default().dim())
|
2025-09-12 15:31:24 -04:00
|
|
|
|
.render_ref(hint_rect, buf);
|
feat: add support for @ to do file search (#1401)
Introduces support for `@` to trigger a fuzzy-filename search in the
composer. Under the hood, this leverages
https://crates.io/crates/nucleo-matcher to do the fuzzy matching and
https://crates.io/crates/ignore to build up the list of file candidates
(so that it respects `.gitignore`).
For simplicity (at least for now), we do not do any caching between
searches like VS Code does for its file search:
https://github.com/microsoft/vscode/blob/1d89ed699b2e924d418c856318a3e12bca67ff3a/src/vs/workbench/services/search/node/rawSearchService.ts#L212-L218
Because we do not do any caching, I saw queries take up to three seconds
on large repositories with hundreds of thousands of files. To that end,
we do not perform searches synchronously on each keystroke, but instead
dispatch an event to do the search on a background thread that
asynchronously reports back to the UI when the results are available.
This is largely handled by the `FileSearchManager` introduced in this
PR, which also has logic for debouncing requests so there is at most one
search in flight at a time.
While we could potentially polish and tune this feature further, it may
already be overengineered for how it will be used, in practice, so we
can improve things going forward if it turns out that this is not "good
enough" in the wild.
Note this feature does not work like `@` in the TypeScript CLI, which
was more like directory-based tab completion. In the Rust CLI, `@`
triggers a full-repo fuzzy-filename search.
Fixes https://github.com/openai/codex/issues/1261.
2025-06-28 13:47:42 -07:00
|
|
|
|
}
|
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
|
|
|
|
}
|
2025-08-13 15:50:50 -07:00
|
|
|
|
let border_style = if self.has_focus {
|
|
|
|
|
|
Style::default().fg(Color::Cyan)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Style::default().add_modifier(Modifier::DIM)
|
|
|
|
|
|
};
|
2025-08-03 11:31:35 -07:00
|
|
|
|
Block::default()
|
|
|
|
|
|
.borders(Borders::LEFT)
|
|
|
|
|
|
.border_type(BorderType::QuadrantOutside)
|
2025-08-13 15:50:50 -07:00
|
|
|
|
.border_style(border_style)
|
2025-08-03 11:31:35 -07:00
|
|
|
|
.render_ref(
|
|
|
|
|
|
Rect::new(textarea_rect.x, textarea_rect.y, 1, textarea_rect.height),
|
|
|
|
|
|
buf,
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut textarea_rect = textarea_rect;
|
|
|
|
|
|
textarea_rect.width = textarea_rect.width.saturating_sub(1);
|
|
|
|
|
|
textarea_rect.x += 1;
|
2025-08-07 00:46:45 -07:00
|
|
|
|
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let mut state = self.textarea_state.borrow_mut();
|
|
|
|
|
|
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
|
|
|
|
|
if self.textarea.text().is_empty() {
|
2025-08-15 22:37:10 -04:00
|
|
|
|
Line::from(self.placeholder_text.as_str())
|
2025-08-03 11:31:35 -07:00
|
|
|
|
.style(Style::default().dim())
|
|
|
|
|
|
.render_ref(textarea_rect.inner(Margin::new(1, 0)), buf);
|
|
|
|
|
|
}
|
2025-05-14 10:13:29 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-07-08 05:43:31 +09:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
2025-08-22 18:05:43 +01:00
|
|
|
|
use super::*;
|
2025-08-25 16:39:42 -07:00
|
|
|
|
use image::ImageBuffer;
|
|
|
|
|
|
use image::Rgba;
|
2025-09-12 15:31:24 -04:00
|
|
|
|
use pretty_assertions::assert_eq;
|
2025-08-22 18:05:43 +01:00
|
|
|
|
use std::path::PathBuf;
|
2025-08-25 16:39:42 -07:00
|
|
|
|
use tempfile::tempdir;
|
2025-08-22 18:05:43 +01:00
|
|
|
|
|
2025-08-06 09:10:23 -07:00
|
|
|
|
use crate::app_event::AppEvent;
|
2025-07-12 15:32:00 -07:00
|
|
|
|
use crate::bottom_pane::AppEventSender;
|
2025-07-08 05:43:31 +09:00
|
|
|
|
use crate::bottom_pane::ChatComposer;
|
2025-07-12 15:32:00 -07:00
|
|
|
|
use crate::bottom_pane::InputResult;
|
2025-08-22 18:05:43 +01:00
|
|
|
|
use crate::bottom_pane::chat_composer::AttachedImage;
|
2025-07-12 15:32:00 -07:00
|
|
|
|
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
2025-08-03 11:31:35 -07:00
|
|
|
|
use crate::bottom_pane::textarea::TextArea;
|
2025-08-20 10:11:09 -07:00
|
|
|
|
use tokio::sync::mpsc::unbounded_channel;
|
2025-07-08 05:43:31 +09:00
|
|
|
|
|
2025-09-12 15:31:24 -04:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn footer_hint_row_is_separated_from_composer() {
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
|
|
|
|
|
let composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let area = Rect::new(0, 0, 40, 6);
|
|
|
|
|
|
let mut buf = Buffer::empty(area);
|
|
|
|
|
|
composer.render_ref(area, &mut buf);
|
|
|
|
|
|
|
|
|
|
|
|
let row_to_string = |y: u16| {
|
|
|
|
|
|
let mut row = String::new();
|
|
|
|
|
|
for x in 0..area.width {
|
|
|
|
|
|
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
|
|
|
|
|
}
|
|
|
|
|
|
row
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let mut hint_row: Option<(u16, String)> = None;
|
|
|
|
|
|
for y in 0..area.height {
|
|
|
|
|
|
let row = row_to_string(y);
|
|
|
|
|
|
if row.contains(" send") {
|
|
|
|
|
|
hint_row = Some((y, row));
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let (hint_row_idx, hint_row_contents) =
|
|
|
|
|
|
hint_row.expect("expected footer hint row to be rendered");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
hint_row_idx,
|
|
|
|
|
|
area.height - 1,
|
|
|
|
|
|
"hint row should occupy the bottom line: {hint_row_contents:?}",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
hint_row_idx > 0,
|
|
|
|
|
|
"expected a spacing row above the footer hints",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let spacing_row = row_to_string(hint_row_idx - 1);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
spacing_row.trim(),
|
|
|
|
|
|
"",
|
|
|
|
|
|
"expected blank spacing row above hints but saw: {spacing_row:?}",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-08 05:43:31 +09:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_current_at_token_basic_cases() {
|
|
|
|
|
|
let test_cases = vec![
|
|
|
|
|
|
// Valid @ tokens
|
|
|
|
|
|
("@hello", 3, Some("hello".to_string()), "Basic ASCII token"),
|
|
|
|
|
|
(
|
|
|
|
|
|
"@file.txt",
|
|
|
|
|
|
4,
|
|
|
|
|
|
Some("file.txt".to_string()),
|
|
|
|
|
|
"ASCII with extension",
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"hello @world test",
|
|
|
|
|
|
8,
|
|
|
|
|
|
Some("world".to_string()),
|
|
|
|
|
|
"ASCII token in middle",
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"@test123",
|
|
|
|
|
|
5,
|
|
|
|
|
|
Some("test123".to_string()),
|
|
|
|
|
|
"ASCII with numbers",
|
|
|
|
|
|
),
|
|
|
|
|
|
// Unicode examples
|
|
|
|
|
|
("@İstanbul", 3, Some("İstanbul".to_string()), "Turkish text"),
|
|
|
|
|
|
(
|
|
|
|
|
|
"@testЙЦУ.rs",
|
|
|
|
|
|
8,
|
|
|
|
|
|
Some("testЙЦУ.rs".to_string()),
|
|
|
|
|
|
"Mixed ASCII and Cyrillic",
|
|
|
|
|
|
),
|
|
|
|
|
|
("@诶", 2, Some("诶".to_string()), "Chinese character"),
|
|
|
|
|
|
("@👍", 2, Some("👍".to_string()), "Emoji token"),
|
|
|
|
|
|
// Invalid cases (should return None)
|
|
|
|
|
|
("hello", 2, None, "No @ symbol"),
|
2025-08-07 00:41:48 -07:00
|
|
|
|
(
|
|
|
|
|
|
"@",
|
|
|
|
|
|
1,
|
|
|
|
|
|
Some("".to_string()),
|
|
|
|
|
|
"Only @ symbol triggers empty query",
|
|
|
|
|
|
),
|
2025-07-08 05:43:31 +09:00
|
|
|
|
("@ hello", 2, None, "@ followed by space"),
|
|
|
|
|
|
("test @ world", 6, None, "@ with spaces around"),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (input, cursor_pos, expected, description) in test_cases {
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let mut textarea = TextArea::new();
|
2025-07-08 05:43:31 +09:00
|
|
|
|
textarea.insert_str(input);
|
2025-08-03 11:31:35 -07:00
|
|
|
|
textarea.set_cursor(cursor_pos);
|
2025-07-08 05:43:31 +09:00
|
|
|
|
|
|
|
|
|
|
let result = ChatComposer::current_at_token(&textarea);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
result, expected,
|
2025-07-10 20:08:16 +02:00
|
|
|
|
"Failed for case: {description} - input: '{input}', cursor: {cursor_pos}"
|
2025-07-08 05:43:31 +09:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_current_at_token_cursor_positions() {
|
|
|
|
|
|
let test_cases = vec![
|
|
|
|
|
|
// Different cursor positions within a token
|
|
|
|
|
|
("@test", 0, Some("test".to_string()), "Cursor at @"),
|
|
|
|
|
|
("@test", 1, Some("test".to_string()), "Cursor after @"),
|
|
|
|
|
|
("@test", 5, Some("test".to_string()), "Cursor at end"),
|
|
|
|
|
|
// Multiple tokens - cursor determines which token
|
|
|
|
|
|
("@file1 @file2", 0, Some("file1".to_string()), "First token"),
|
|
|
|
|
|
(
|
|
|
|
|
|
"@file1 @file2",
|
|
|
|
|
|
8,
|
|
|
|
|
|
Some("file2".to_string()),
|
|
|
|
|
|
"Second token",
|
|
|
|
|
|
),
|
|
|
|
|
|
// Edge cases
|
2025-08-07 00:41:48 -07:00
|
|
|
|
("@", 0, Some("".to_string()), "Only @ symbol"),
|
2025-07-08 05:43:31 +09:00
|
|
|
|
("@a", 2, Some("a".to_string()), "Single character after @"),
|
|
|
|
|
|
("", 0, None, "Empty input"),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (input, cursor_pos, expected, description) in test_cases {
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let mut textarea = TextArea::new();
|
2025-07-08 05:43:31 +09:00
|
|
|
|
textarea.insert_str(input);
|
2025-08-03 11:31:35 -07:00
|
|
|
|
textarea.set_cursor(cursor_pos);
|
2025-07-08 05:43:31 +09:00
|
|
|
|
|
|
|
|
|
|
let result = ChatComposer::current_at_token(&textarea);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
result, expected,
|
|
|
|
|
|
"Failed for cursor position case: {description} - input: '{input}', cursor: {cursor_pos}",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_current_at_token_whitespace_boundaries() {
|
|
|
|
|
|
let test_cases = vec![
|
|
|
|
|
|
// Space boundaries
|
|
|
|
|
|
(
|
|
|
|
|
|
"aaa@aaa",
|
|
|
|
|
|
4,
|
|
|
|
|
|
None,
|
|
|
|
|
|
"Connected @ token - no completion by design",
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"aaa @aaa",
|
|
|
|
|
|
5,
|
|
|
|
|
|
Some("aaa".to_string()),
|
|
|
|
|
|
"@ token after space",
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"test @file.txt",
|
|
|
|
|
|
7,
|
|
|
|
|
|
Some("file.txt".to_string()),
|
|
|
|
|
|
"@ token after space",
|
|
|
|
|
|
),
|
|
|
|
|
|
// Full-width space boundaries
|
|
|
|
|
|
(
|
|
|
|
|
|
"test @İstanbul",
|
2025-08-03 11:31:35 -07:00
|
|
|
|
8,
|
2025-07-08 05:43:31 +09:00
|
|
|
|
Some("İstanbul".to_string()),
|
|
|
|
|
|
"@ token after full-width space",
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"@ЙЦУ @诶",
|
2025-08-03 11:31:35 -07:00
|
|
|
|
10,
|
2025-07-08 05:43:31 +09:00
|
|
|
|
Some("诶".to_string()),
|
|
|
|
|
|
"Full-width space between Unicode tokens",
|
|
|
|
|
|
),
|
|
|
|
|
|
// Tab and newline boundaries
|
|
|
|
|
|
(
|
|
|
|
|
|
"test\t@file",
|
|
|
|
|
|
6,
|
|
|
|
|
|
Some("file".to_string()),
|
|
|
|
|
|
"@ token after tab",
|
|
|
|
|
|
),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (input, cursor_pos, expected, description) in test_cases {
|
2025-08-03 11:31:35 -07:00
|
|
|
|
let mut textarea = TextArea::new();
|
2025-07-08 05:43:31 +09:00
|
|
|
|
textarea.insert_str(input);
|
2025-08-03 11:31:35 -07:00
|
|
|
|
textarea.set_cursor(cursor_pos);
|
2025-07-08 05:43:31 +09:00
|
|
|
|
|
|
|
|
|
|
let result = ChatComposer::current_at_token(&textarea);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
result, expected,
|
|
|
|
|
|
"Failed for whitespace boundary case: {description} - input: '{input}', cursor: {cursor_pos}",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-07-12 15:32:00 -07:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn handle_paste_small_inserts_text() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
2025-08-20 10:11:09 -07:00
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
2025-07-12 15:32:00 -07:00
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-07-12 15:32:00 -07:00
|
|
|
|
|
|
|
|
|
|
let needs_redraw = composer.handle_paste("hello".to_string());
|
|
|
|
|
|
assert!(needs_redraw);
|
2025-08-03 11:31:35 -07:00
|
|
|
|
assert_eq!(composer.textarea.text(), "hello");
|
2025-07-12 15:32:00 -07:00
|
|
|
|
assert!(composer.pending_pastes.is_empty());
|
|
|
|
|
|
|
|
|
|
|
|
let (result, _) =
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
|
|
match result {
|
|
|
|
|
|
InputResult::Submitted(text) => assert_eq!(text, "hello"),
|
|
|
|
|
|
_ => panic!("expected Submitted"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-03 02:05:45 +09:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn empty_enter_returns_none() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
|
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure composer is empty and press Enter.
|
|
|
|
|
|
assert!(composer.textarea.text().is_empty());
|
|
|
|
|
|
let (result, _needs_redraw) =
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
|
|
|
|
|
|
|
|
match result {
|
|
|
|
|
|
InputResult::None => {}
|
|
|
|
|
|
other => panic!("expected None for empty enter, got: {other:?}"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-12 15:32:00 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn handle_paste_large_uses_placeholder_and_replaces_on_submit() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
2025-08-20 10:11:09 -07:00
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
2025-07-12 15:32:00 -07:00
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-07-12 15:32:00 -07:00
|
|
|
|
|
|
|
|
|
|
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
|
|
|
|
|
|
let needs_redraw = composer.handle_paste(large.clone());
|
|
|
|
|
|
assert!(needs_redraw);
|
|
|
|
|
|
let placeholder = format!("[Pasted Content {} chars]", large.chars().count());
|
2025-08-03 11:31:35 -07:00
|
|
|
|
assert_eq!(composer.textarea.text(), placeholder);
|
2025-07-12 15:32:00 -07:00
|
|
|
|
assert_eq!(composer.pending_pastes.len(), 1);
|
|
|
|
|
|
assert_eq!(composer.pending_pastes[0].0, placeholder);
|
|
|
|
|
|
assert_eq!(composer.pending_pastes[0].1, large);
|
|
|
|
|
|
|
|
|
|
|
|
let (result, _) =
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
|
|
match result {
|
|
|
|
|
|
InputResult::Submitted(text) => assert_eq!(text, large),
|
|
|
|
|
|
_ => panic!("expected Submitted"),
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(composer.pending_pastes.is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn edit_clears_pending_paste() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
|
|
|
|
|
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
|
2025-08-20 10:11:09 -07:00
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
2025-07-12 15:32:00 -07:00
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-07-12 15:32:00 -07:00
|
|
|
|
|
|
|
|
|
|
composer.handle_paste(large);
|
|
|
|
|
|
assert_eq!(composer.pending_pastes.len(), 1);
|
|
|
|
|
|
|
|
|
|
|
|
// Any edit that removes the placeholder should clear pending_paste
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
|
|
|
|
|
assert!(composer.pending_pastes.is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn ui_snapshots() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
use ratatui::Terminal;
|
|
|
|
|
|
use ratatui::backend::TestBackend;
|
|
|
|
|
|
|
2025-08-20 10:11:09 -07:00
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
2025-07-12 15:32:00 -07:00
|
|
|
|
let sender = AppEventSender::new(tx);
|
|
|
|
|
|
let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
|
|
|
|
|
|
Ok(t) => t,
|
|
|
|
|
|
Err(e) => panic!("Failed to create terminal: {e}"),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let test_cases = vec![
|
|
|
|
|
|
("empty", None),
|
|
|
|
|
|
("small", Some("short".to_string())),
|
|
|
|
|
|
("large", Some("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5))),
|
|
|
|
|
|
("multiple_pastes", None),
|
|
|
|
|
|
("backspace_after_pastes", None),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (name, input) in test_cases {
|
|
|
|
|
|
// Create a fresh composer for each test case
|
2025-08-15 22:37:10 -04:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender.clone(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
2025-08-28 12:54:12 -07:00
|
|
|
|
false,
|
2025-08-15 22:37:10 -04:00
|
|
|
|
);
|
2025-07-12 15:32:00 -07:00
|
|
|
|
|
|
|
|
|
|
if let Some(text) = input {
|
|
|
|
|
|
composer.handle_paste(text);
|
|
|
|
|
|
} else if name == "multiple_pastes" {
|
|
|
|
|
|
// First large paste
|
|
|
|
|
|
composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3));
|
|
|
|
|
|
// Second large paste
|
|
|
|
|
|
composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7));
|
|
|
|
|
|
// Small paste
|
|
|
|
|
|
composer.handle_paste(" another short paste".to_string());
|
|
|
|
|
|
} else if name == "backspace_after_pastes" {
|
|
|
|
|
|
// Three large pastes
|
|
|
|
|
|
composer.handle_paste("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2));
|
|
|
|
|
|
composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4));
|
|
|
|
|
|
composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6));
|
|
|
|
|
|
// Move cursor to end and press backspace
|
2025-08-03 11:31:35 -07:00
|
|
|
|
composer.textarea.set_cursor(composer.textarea.text().len());
|
2025-07-12 15:32:00 -07:00
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
terminal
|
2025-08-25 14:38:38 -07:00
|
|
|
|
.draw(|f| f.render_widget_ref(composer, f.area()))
|
2025-07-12 15:32:00 -07:00
|
|
|
|
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
|
|
|
|
|
|
|
2025-09-04 10:55:50 -07:00
|
|
|
|
insta::assert_snapshot!(name, terminal.backend());
|
2025-07-12 15:32:00 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-02 10:29:58 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn slash_popup_model_first_for_mo_ui() {
|
|
|
|
|
|
use ratatui::Terminal;
|
|
|
|
|
|
use ratatui::backend::TestBackend;
|
|
|
|
|
|
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
|
|
|
|
|
|
|
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Type "/mo" humanlike so paste-burst doesn’t interfere.
|
|
|
|
|
|
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
|
|
|
|
|
|
|
|
|
|
|
|
let mut terminal = match Terminal::new(TestBackend::new(60, 4)) {
|
|
|
|
|
|
Ok(t) => t,
|
|
|
|
|
|
Err(e) => panic!("Failed to create terminal: {e}"),
|
|
|
|
|
|
};
|
|
|
|
|
|
terminal
|
|
|
|
|
|
.draw(|f| f.render_widget_ref(composer, f.area()))
|
|
|
|
|
|
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
|
|
|
|
|
|
|
|
|
|
|
|
// Visual snapshot should show the slash popup with /model as the first entry.
|
2025-09-04 10:55:50 -07:00
|
|
|
|
insta::assert_snapshot!("slash_popup_mo", terminal.backend());
|
2025-09-02 10:29:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn slash_popup_model_first_for_mo_logic() {
|
|
|
|
|
|
use super::super::command_popup::CommandItem;
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
|
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
|
|
|
|
|
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
|
|
|
|
|
|
|
|
|
|
|
|
match &composer.active_popup {
|
|
|
|
|
|
ActivePopup::Command(popup) => match popup.selected_item() {
|
|
|
|
|
|
Some(CommandItem::Builtin(cmd)) => {
|
|
|
|
|
|
assert_eq!(cmd.command(), "model")
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(CommandItem::UserPrompt(_)) => {
|
|
|
|
|
|
panic!("unexpected prompt selected for '/mo'")
|
|
|
|
|
|
}
|
|
|
|
|
|
None => panic!("no selected command for '/mo'"),
|
|
|
|
|
|
},
|
|
|
|
|
|
_ => panic!("slash popup not active after typing '/mo'"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-28 12:54:12 -07:00
|
|
|
|
// Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
|
|
|
|
|
|
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
for &ch in chars {
|
|
|
|
|
|
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
|
|
|
|
|
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
|
|
|
|
|
|
let _ = composer.flush_paste_burst_if_due();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-06 09:10:23 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn slash_init_dispatches_command_and_does_not_submit_literal_text() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
2025-08-21 10:36:58 -07:00
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
2025-08-06 09:10:23 -07:00
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-08-06 09:10:23 -07:00
|
|
|
|
|
|
|
|
|
|
// Type the slash command.
|
2025-08-28 12:54:12 -07:00
|
|
|
|
type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']);
|
2025-08-06 09:10:23 -07:00
|
|
|
|
|
|
|
|
|
|
// Press Enter to dispatch the selected command.
|
|
|
|
|
|
let (result, _needs_redraw) =
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
|
|
|
2025-08-21 10:36:58 -07:00
|
|
|
|
// When a slash command is dispatched, the composer should return a
|
|
|
|
|
|
// Command result (not submit literal text) and clear its textarea.
|
2025-08-06 09:10:23 -07:00
|
|
|
|
match result {
|
2025-08-21 10:36:58 -07:00
|
|
|
|
InputResult::Command(cmd) => {
|
|
|
|
|
|
assert_eq!(cmd.command(), "init");
|
|
|
|
|
|
}
|
2025-08-06 09:10:23 -07:00
|
|
|
|
InputResult::Submitted(text) => {
|
|
|
|
|
|
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
|
|
|
|
|
}
|
2025-08-21 10:36:58 -07:00
|
|
|
|
InputResult::None => panic!("expected Command result for '/init'"),
|
2025-08-06 09:10:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-20 01:34:52 +09:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn slash_tab_completion_moves_cursor_to_end() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
2025-08-20 10:11:09 -07:00
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
2025-08-20 01:34:52 +09:00
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-08-20 01:34:52 +09:00
|
|
|
|
|
2025-08-28 12:54:12 -07:00
|
|
|
|
type_chars_humanlike(&mut composer, &['/', 'c']);
|
2025-08-20 01:34:52 +09:00
|
|
|
|
|
|
|
|
|
|
let (_result, _needs_redraw) =
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(composer.textarea.text(), "/compact ");
|
|
|
|
|
|
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-11 14:15:41 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn slash_mention_dispatches_command_and_inserts_at() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
2025-08-21 10:36:58 -07:00
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
2025-08-11 14:15:41 -07:00
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-08-11 14:15:41 -07:00
|
|
|
|
|
2025-08-28 12:54:12 -07:00
|
|
|
|
type_chars_humanlike(&mut composer, &['/', 'm', 'e', 'n', 't', 'i', 'o', 'n']);
|
2025-08-11 14:15:41 -07:00
|
|
|
|
|
|
|
|
|
|
let (result, _needs_redraw) =
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
|
|
|
|
|
|
|
|
match result {
|
2025-08-21 10:36:58 -07:00
|
|
|
|
InputResult::Command(cmd) => {
|
|
|
|
|
|
assert_eq!(cmd.command(), "mention");
|
|
|
|
|
|
}
|
2025-08-11 14:15:41 -07:00
|
|
|
|
InputResult::Submitted(text) => {
|
|
|
|
|
|
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
|
|
|
|
|
}
|
2025-08-21 10:36:58 -07:00
|
|
|
|
InputResult::None => panic!("expected Command result for '/mention'"),
|
2025-08-11 14:15:41 -07:00
|
|
|
|
}
|
|
|
|
|
|
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
2025-08-21 10:36:58 -07:00
|
|
|
|
composer.insert_str("@");
|
2025-08-11 14:15:41 -07:00
|
|
|
|
assert_eq!(composer.textarea.text(), "@");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-12 15:32:00 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_multiple_pastes_submission() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
2025-08-20 10:11:09 -07:00
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
2025-07-12 15:32:00 -07:00
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-07-12 15:32:00 -07:00
|
|
|
|
|
|
|
|
|
|
// Define test cases: (paste content, is_large)
|
|
|
|
|
|
let test_cases = [
|
|
|
|
|
|
("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true),
|
|
|
|
|
|
(" and ".to_string(), false),
|
|
|
|
|
|
("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// Expected states after each paste
|
|
|
|
|
|
let mut expected_text = String::new();
|
|
|
|
|
|
let mut expected_pending_count = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Apply all pastes and build expected state
|
|
|
|
|
|
let states: Vec<_> = test_cases
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|(content, is_large)| {
|
|
|
|
|
|
composer.handle_paste(content.clone());
|
|
|
|
|
|
if *is_large {
|
|
|
|
|
|
let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
|
|
|
|
|
|
expected_text.push_str(&placeholder);
|
|
|
|
|
|
expected_pending_count += 1;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
expected_text.push_str(content);
|
|
|
|
|
|
}
|
|
|
|
|
|
(expected_text.clone(), expected_pending_count)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
// Verify all intermediate states were correct
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
states,
|
|
|
|
|
|
vec![
|
|
|
|
|
|
(
|
|
|
|
|
|
format!("[Pasted Content {} chars]", test_cases[0].0.chars().count()),
|
|
|
|
|
|
1
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
format!(
|
|
|
|
|
|
"[Pasted Content {} chars] and ",
|
|
|
|
|
|
test_cases[0].0.chars().count()
|
|
|
|
|
|
),
|
|
|
|
|
|
1
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
format!(
|
|
|
|
|
|
"[Pasted Content {} chars] and [Pasted Content {} chars]",
|
|
|
|
|
|
test_cases[0].0.chars().count(),
|
|
|
|
|
|
test_cases[2].0.chars().count()
|
|
|
|
|
|
),
|
|
|
|
|
|
2
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Submit and verify final expansion
|
|
|
|
|
|
let (result, _) =
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
|
|
if let InputResult::Submitted(text) = result {
|
|
|
|
|
|
assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
panic!("expected Submitted");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_placeholder_deletion() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
2025-08-20 10:11:09 -07:00
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
2025-07-12 15:32:00 -07:00
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-07-12 15:32:00 -07:00
|
|
|
|
|
|
|
|
|
|
// Define test cases: (content, is_large)
|
|
|
|
|
|
let test_cases = [
|
|
|
|
|
|
("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true),
|
|
|
|
|
|
(" and ".to_string(), false),
|
|
|
|
|
|
("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// Apply all pastes
|
|
|
|
|
|
let mut current_pos = 0;
|
|
|
|
|
|
let states: Vec<_> = test_cases
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|(content, is_large)| {
|
|
|
|
|
|
composer.handle_paste(content.clone());
|
|
|
|
|
|
if *is_large {
|
|
|
|
|
|
let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
|
|
|
|
|
|
current_pos += placeholder.len();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
current_pos += content.len();
|
|
|
|
|
|
}
|
|
|
|
|
|
(
|
2025-08-03 11:31:35 -07:00
|
|
|
|
composer.textarea.text().to_string(),
|
2025-07-12 15:32:00 -07:00
|
|
|
|
composer.pending_pastes.len(),
|
|
|
|
|
|
current_pos,
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
// Delete placeholders one by one and collect states
|
|
|
|
|
|
let mut deletion_states = vec![];
|
|
|
|
|
|
|
|
|
|
|
|
// First deletion
|
2025-08-03 11:31:35 -07:00
|
|
|
|
composer.textarea.set_cursor(states[0].2);
|
2025-07-12 15:32:00 -07:00
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
|
|
|
|
|
deletion_states.push((
|
2025-08-03 11:31:35 -07:00
|
|
|
|
composer.textarea.text().to_string(),
|
2025-07-12 15:32:00 -07:00
|
|
|
|
composer.pending_pastes.len(),
|
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
// Second deletion
|
2025-08-03 11:31:35 -07:00
|
|
|
|
composer.textarea.set_cursor(composer.textarea.text().len());
|
2025-07-12 15:32:00 -07:00
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
|
|
|
|
|
deletion_states.push((
|
2025-08-03 11:31:35 -07:00
|
|
|
|
composer.textarea.text().to_string(),
|
2025-07-12 15:32:00 -07:00
|
|
|
|
composer.pending_pastes.len(),
|
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
// Verify all states
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
deletion_states,
|
|
|
|
|
|
vec![
|
|
|
|
|
|
(" and [Pasted Content 1006 chars]".to_string(), 1),
|
|
|
|
|
|
(" and ".to_string(), 0),
|
|
|
|
|
|
]
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_partial_placeholder_deletion() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
2025-08-20 10:11:09 -07:00
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
2025-07-12 15:32:00 -07:00
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-07-12 15:32:00 -07:00
|
|
|
|
|
|
|
|
|
|
// Define test cases: (cursor_position_from_end, expected_pending_count)
|
|
|
|
|
|
let test_cases = [
|
|
|
|
|
|
5, // Delete from middle - should clear tracking
|
|
|
|
|
|
0, // Delete from end - should clear tracking
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
|
|
|
|
|
|
let placeholder = format!("[Pasted Content {} chars]", paste.chars().count());
|
|
|
|
|
|
|
|
|
|
|
|
let states: Vec<_> = test_cases
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|pos_from_end| {
|
|
|
|
|
|
composer.handle_paste(paste.clone());
|
|
|
|
|
|
composer
|
|
|
|
|
|
.textarea
|
2025-09-02 18:36:19 -07:00
|
|
|
|
.set_cursor(placeholder.len() - pos_from_end);
|
2025-07-12 15:32:00 -07:00
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
|
|
|
|
|
let result = (
|
2025-08-03 11:31:35 -07:00
|
|
|
|
composer.textarea.text().contains(&placeholder),
|
2025-07-12 15:32:00 -07:00
|
|
|
|
composer.pending_pastes.len(),
|
|
|
|
|
|
);
|
2025-08-03 11:31:35 -07:00
|
|
|
|
composer.textarea.set_text("");
|
2025-07-12 15:32:00 -07:00
|
|
|
|
result
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
states,
|
|
|
|
|
|
vec![
|
|
|
|
|
|
(false, 0), // After deleting from middle
|
|
|
|
|
|
(false, 0), // After deleting from end
|
|
|
|
|
|
]
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-08-22 18:05:43 +01:00
|
|
|
|
|
|
|
|
|
|
// --- Image attachment tests ---
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn attach_image_and_submit_includes_image_paths() {
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-08-22 18:05:43 +01:00
|
|
|
|
let path = PathBuf::from("/tmp/image1.png");
|
|
|
|
|
|
composer.attach_image(path.clone(), 32, 16, "PNG");
|
|
|
|
|
|
composer.handle_paste(" hi".into());
|
|
|
|
|
|
let (result, _) =
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
|
|
match result {
|
2025-08-25 16:39:42 -07:00
|
|
|
|
InputResult::Submitted(text) => assert_eq!(text, "[image 32x16 PNG] hi"),
|
2025-08-22 18:05:43 +01:00
|
|
|
|
_ => panic!("expected Submitted"),
|
|
|
|
|
|
}
|
|
|
|
|
|
let imgs = composer.take_recent_submission_images();
|
|
|
|
|
|
assert_eq!(vec![path], imgs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn attach_image_without_text_submits_empty_text_and_images() {
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-08-22 18:05:43 +01:00
|
|
|
|
let path = PathBuf::from("/tmp/image2.png");
|
|
|
|
|
|
composer.attach_image(path.clone(), 10, 5, "PNG");
|
|
|
|
|
|
let (result, _) =
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
|
|
match result {
|
2025-08-25 16:39:42 -07:00
|
|
|
|
InputResult::Submitted(text) => assert_eq!(text, "[image 10x5 PNG]"),
|
2025-08-22 18:05:43 +01:00
|
|
|
|
_ => panic!("expected Submitted"),
|
|
|
|
|
|
}
|
|
|
|
|
|
let imgs = composer.take_recent_submission_images();
|
|
|
|
|
|
assert_eq!(imgs.len(), 1);
|
|
|
|
|
|
assert_eq!(imgs[0], path);
|
|
|
|
|
|
assert!(composer.attached_images.is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn image_placeholder_backspace_behaves_like_text_placeholder() {
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-08-22 18:05:43 +01:00
|
|
|
|
let path = PathBuf::from("/tmp/image3.png");
|
|
|
|
|
|
composer.attach_image(path.clone(), 20, 10, "PNG");
|
|
|
|
|
|
let placeholder = composer.attached_images[0].placeholder.clone();
|
|
|
|
|
|
|
|
|
|
|
|
// Case 1: backspace at end
|
|
|
|
|
|
composer.textarea.move_cursor_to_end_of_line(false);
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
|
|
|
|
|
assert!(!composer.textarea.text().contains(&placeholder));
|
|
|
|
|
|
assert!(composer.attached_images.is_empty());
|
|
|
|
|
|
|
|
|
|
|
|
// Re-add and test backspace in middle: should break the placeholder string
|
|
|
|
|
|
// and drop the image mapping (same as text placeholder behavior).
|
2025-09-11 11:59:37 -07:00
|
|
|
|
composer.attach_image(path, 20, 10, "PNG");
|
2025-08-22 18:05:43 +01:00
|
|
|
|
let placeholder2 = composer.attached_images[0].placeholder.clone();
|
|
|
|
|
|
// Move cursor to roughly middle of placeholder
|
|
|
|
|
|
if let Some(start_pos) = composer.textarea.text().find(&placeholder2) {
|
|
|
|
|
|
let mid_pos = start_pos + (placeholder2.len() / 2);
|
|
|
|
|
|
composer.textarea.set_cursor(mid_pos);
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
|
|
|
|
|
assert!(!composer.textarea.text().contains(&placeholder2));
|
|
|
|
|
|
assert!(composer.attached_images.is_empty());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
panic!("Placeholder not found in textarea");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-27 10:31:49 +09:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn backspace_with_multibyte_text_before_placeholder_does_not_panic() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-08-27 10:31:49 +09:00
|
|
|
|
|
|
|
|
|
|
// Insert an image placeholder at the start
|
|
|
|
|
|
let path = PathBuf::from("/tmp/image_multibyte.png");
|
|
|
|
|
|
composer.attach_image(path, 10, 5, "PNG");
|
|
|
|
|
|
// Add multibyte text after the placeholder
|
|
|
|
|
|
composer.textarea.insert_str("日本語");
|
|
|
|
|
|
|
|
|
|
|
|
// Cursor is at end; pressing backspace should delete the last character
|
|
|
|
|
|
// without panicking and leave the placeholder intact.
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(composer.attached_images.len(), 1);
|
|
|
|
|
|
assert!(composer.textarea.text().starts_with("[image 10x5 PNG]"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-22 18:05:43 +01:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() {
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-08-22 18:05:43 +01:00
|
|
|
|
|
|
|
|
|
|
let path1 = PathBuf::from("/tmp/image_dup1.png");
|
|
|
|
|
|
let path2 = PathBuf::from("/tmp/image_dup2.png");
|
|
|
|
|
|
|
2025-09-11 11:59:37 -07:00
|
|
|
|
composer.attach_image(path1, 10, 5, "PNG");
|
2025-08-22 18:05:43 +01:00
|
|
|
|
// separate placeholders with a space for clarity
|
|
|
|
|
|
composer.handle_paste(" ".into());
|
|
|
|
|
|
composer.attach_image(path2.clone(), 10, 5, "PNG");
|
|
|
|
|
|
|
|
|
|
|
|
let ph = composer.attached_images[0].placeholder.clone();
|
|
|
|
|
|
let text = composer.textarea.text().to_string();
|
|
|
|
|
|
let start1 = text.find(&ph).expect("first placeholder present");
|
|
|
|
|
|
let end1 = start1 + ph.len();
|
|
|
|
|
|
composer.textarea.set_cursor(end1);
|
|
|
|
|
|
|
|
|
|
|
|
// Backspace should delete the first placeholder and its mapping.
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
|
|
|
|
|
|
|
|
|
|
|
let new_text = composer.textarea.text().to_string();
|
|
|
|
|
|
assert_eq!(1, new_text.matches(&ph).count(), "one placeholder remains");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
vec![AttachedImage {
|
|
|
|
|
|
path: path2,
|
|
|
|
|
|
placeholder: "[image 10x5 PNG]".to_string()
|
|
|
|
|
|
}],
|
|
|
|
|
|
composer.attached_images,
|
|
|
|
|
|
"one image mapping remains"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-08-25 16:39:42 -07:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn pasting_filepath_attaches_image() {
|
|
|
|
|
|
let tmp = tempdir().expect("create TempDir");
|
|
|
|
|
|
let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png");
|
|
|
|
|
|
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
|
|
|
|
|
ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255]));
|
|
|
|
|
|
img.save(&tmp_path).expect("failed to write temp png");
|
|
|
|
|
|
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
2025-08-28 12:54:12 -07:00
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
2025-08-25 16:39:42 -07:00
|
|
|
|
|
|
|
|
|
|
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
|
|
|
|
|
|
assert!(needs_redraw);
|
|
|
|
|
|
assert!(composer.textarea.text().starts_with("[image 3x2 PNG] "));
|
|
|
|
|
|
|
|
|
|
|
|
let imgs = composer.take_recent_submission_images();
|
2025-09-11 11:59:37 -07:00
|
|
|
|
assert_eq!(imgs, vec![tmp_path]);
|
2025-08-25 16:39:42 -07:00
|
|
|
|
}
|
2025-08-28 12:54:12 -07:00
|
|
|
|
|
2025-08-28 19:16:39 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn selecting_custom_prompt_submits_file_contents() {
|
|
|
|
|
|
let prompt_text = "Hello from saved prompt";
|
|
|
|
|
|
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
|
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Inject prompts as if received via event.
|
|
|
|
|
|
composer.set_custom_prompts(vec![CustomPrompt {
|
|
|
|
|
|
name: "my-prompt".to_string(),
|
|
|
|
|
|
path: "/tmp/my-prompt.md".to_string().into(),
|
|
|
|
|
|
content: prompt_text.to_string(),
|
|
|
|
|
|
}]);
|
|
|
|
|
|
|
|
|
|
|
|
type_chars_humanlike(
|
|
|
|
|
|
&mut composer,
|
|
|
|
|
|
&['/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't'],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let (result, _needs_redraw) =
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(InputResult::Submitted(prompt_text.to_string()), result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-28 12:54:12 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn burst_paste_fast_small_buffers_and_flushes_on_stop() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
|
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let count = 32;
|
|
|
|
|
|
for _ in 0..count {
|
|
|
|
|
|
let _ =
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
composer.is_in_paste_burst(),
|
|
|
|
|
|
"expected active paste burst during fast typing"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
composer.textarea.text().is_empty(),
|
|
|
|
|
|
"text should not appear during burst"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
composer.textarea.text().is_empty(),
|
|
|
|
|
|
"text should remain empty until flush"
|
|
|
|
|
|
);
|
|
|
|
|
|
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
|
|
|
|
|
|
let flushed = composer.flush_paste_burst_if_due();
|
|
|
|
|
|
assert!(flushed, "expected buffered text to flush after stop");
|
|
|
|
|
|
assert_eq!(composer.textarea.text(), "a".repeat(count));
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
composer.pending_pastes.is_empty(),
|
|
|
|
|
|
"no placeholder for small burst"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn burst_paste_fast_large_inserts_placeholder_on_flush() {
|
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
|
use crossterm::event::KeyEvent;
|
|
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
|
|
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
|
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder
|
|
|
|
|
|
for _ in 0..count {
|
|
|
|
|
|
let _ =
|
|
|
|
|
|
composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Nothing should appear until we stop and flush
|
|
|
|
|
|
assert!(composer.textarea.text().is_empty());
|
|
|
|
|
|
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
|
|
|
|
|
|
let flushed = composer.flush_paste_burst_if_due();
|
|
|
|
|
|
assert!(flushed, "expected flush after stopping fast input");
|
|
|
|
|
|
|
|
|
|
|
|
let expected_placeholder = format!("[Pasted Content {count} chars]");
|
|
|
|
|
|
assert_eq!(composer.textarea.text(), expected_placeholder);
|
|
|
|
|
|
assert_eq!(composer.pending_pastes.len(), 1);
|
|
|
|
|
|
assert_eq!(composer.pending_pastes[0].0, expected_placeholder);
|
|
|
|
|
|
assert_eq!(composer.pending_pastes[0].1.len(), count);
|
|
|
|
|
|
assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn humanlike_typing_1000_chars_appears_live_no_placeholder() {
|
|
|
|
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
|
|
|
|
let sender = AppEventSender::new(tx);
|
|
|
|
|
|
let mut composer = ChatComposer::new(
|
|
|
|
|
|
true,
|
|
|
|
|
|
sender,
|
|
|
|
|
|
false,
|
|
|
|
|
|
"Ask Codex to do anything".to_string(),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config
|
|
|
|
|
|
let chars: Vec<char> = vec!['z'; count];
|
|
|
|
|
|
type_chars_humanlike(&mut composer, &chars);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(composer.textarea.text(), "z".repeat(count));
|
|
|
|
|
|
assert!(composer.pending_pastes.is_empty());
|
|
|
|
|
|
}
|
2025-07-08 05:43:31 +09:00
|
|
|
|
}
|