2025-08-11 12:31:34 -07:00
|
|
|
|
use crate::diff_render::create_diff_summary;
|
2025-10-02 11:36:03 -07:00
|
|
|
|
use crate::diff_render::display_path_for;
|
2025-09-26 07:13:44 -07:00
|
|
|
|
use crate::exec_cell::CommandOutput;
|
|
|
|
|
|
use crate::exec_cell::OutputLinesParams;
|
|
|
|
|
|
use crate::exec_cell::TOOL_CALL_MAX_LINES;
|
|
|
|
|
|
use crate::exec_cell::output_lines;
|
|
|
|
|
|
use crate::exec_cell::spinner;
|
2025-08-06 14:36:48 -07:00
|
|
|
|
use crate::exec_command::relativize_to_home;
|
2025-10-09 10:37:13 -07:00
|
|
|
|
use crate::exec_command::strip_bash_lc_and_escape;
|
2025-08-20 17:09:46 -07:00
|
|
|
|
use crate::markdown::append_markdown;
|
2025-09-05 07:10:32 -07:00
|
|
|
|
use crate::render::line_utils::line_to_static;
|
2025-09-04 16:54:53 -07:00
|
|
|
|
use crate::render::line_utils::prefix_lines;
|
2025-10-09 10:37:13 -07:00
|
|
|
|
use crate::render::line_utils::push_owned_lines;
|
2025-09-26 16:35:56 -07:00
|
|
|
|
use crate::style::user_message_style;
|
2025-06-03 14:29:26 -07:00
|
|
|
|
use crate::text_formatting::format_and_truncate_tool_result;
|
2025-10-09 10:37:13 -07:00
|
|
|
|
use crate::text_formatting::truncate_text;
|
2025-09-14 16:51:08 -04:00
|
|
|
|
use crate::ui_consts::LIVE_PREFIX_COLS;
|
2025-10-20 14:40:14 -07:00
|
|
|
|
use crate::updates::UpdateAction;
|
|
|
|
|
|
use crate::version::CODEX_CLI_VERSION;
|
2025-09-05 07:10:32 -07:00
|
|
|
|
use crate::wrapping::RtOptions;
|
|
|
|
|
|
use crate::wrapping::word_wrap_line;
|
|
|
|
|
|
use crate::wrapping::word_wrap_lines;
|
fix: introduce ResponseInputItem::McpToolCallOutput variant (#1151)
The output of an MCP server tool call can be one of several types, but
to date, we treated all outputs as text by showing the serialized JSON
as the "tool output" in Codex:
https://github.com/openai/codex/blob/25a9949c49194d5a64de54a11bcc5b4724ac9bd5/codex-rs/mcp-types/src/lib.rs#L96-L101
This PR adds support for the `ImageContent` variant so we can now
display an image output from an MCP tool call.
In making this change, we introduce a new
`ResponseInputItem::McpToolCallOutput` variant so that we can work with
the `mcp_types::CallToolResult` directly when the function call is made
to an MCP server.
Though arguably the more significant change is the introduction of
`HistoryCell::CompletedMcpToolCallWithImageOutput`, which is a cell that
uses `ratatui_image` to render an image into the terminal. To support
this, we introduce `ImageRenderCache`, cache a
`ratatui_image::picker::Picker`, and `ensure_image_cache()` to cache the
appropriate scaled image data and dimensions based on the current
terminal size.
To test, I created a minimal `package.json`:
```json
{
"name": "kitty-mcp",
"version": "1.0.0",
"type": "module",
"description": "MCP that returns image of kitty",
"main": "index.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0"
}
}
```
with the following `index.js` to define the MCP server:
```js
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const IMAGE_URI = "image://Ada.png";
const server = new McpServer({
name: "Demo",
version: "1.0.0",
});
server.tool(
"get-cat-image",
"If you need a cat image, this tool will provide one.",
async () => ({
content: [
{ type: "image", data: await getAdaPngBase64(), mimeType: "image/png" },
],
})
);
server.resource("Ada the Cat", IMAGE_URI, async (uri) => {
const base64Image = await getAdaPngBase64();
return {
contents: [
{
uri: uri.href,
mimeType: "image/png",
blob: base64Image,
},
],
};
});
async function getAdaPngBase64() {
const __dirname = new URL(".", import.meta.url).pathname;
// From https://github.com/benjajaja/ratatui-image/blob/9705ce2c59ec669abbce2924cbfd1f5ae22c9860/assets/Ada.png
const filePath = join(__dirname, "Ada.png");
const imageData = await readFile(filePath);
const base64Image = imageData.toString("base64");
return base64Image;
}
const transport = new StdioServerTransport();
await server.connect(transport);
```
With the local changes from this PR, I added the following to my
`config.toml`:
```toml
[mcp_servers.kitty]
command = "node"
args = ["/Users/mbolin/code/kitty-mcp/index.js"]
```
Running the TUI from source:
```
cargo run --bin codex -- --model o3 'I need a picture of a cat'
```
I get:
<img width="732" alt="image"
src="https://github.com/user-attachments/assets/bf80b721-9ca0-4d81-aec7-77d6899e2869"
/>
Now, that said, I have only tested in iTerm and there is definitely some
funny business with getting an accurate character-to-pixel ratio
(sometimes the `CompletedMcpToolCallWithImageOutput` thinks it needs 10
rows to render instead of 4), so there is still work to be done here.
2025-05-28 19:03:17 -07:00
|
|
|
|
use base64::Engine;
|
2025-10-16 21:24:43 -07:00
|
|
|
|
use codex_common::format_env_display::format_env_display;
|
2025-04-27 21:47:50 -07:00
|
|
|
|
use codex_core::config::Config;
|
2025-09-26 18:24:01 -07:00
|
|
|
|
use codex_core::config_types::McpServerTransportConfig;
|
2025-09-04 11:00:01 -07:00
|
|
|
|
use codex_core::config_types::ReasoningSummaryFormat;
|
feat: initial import of Rust implementation of Codex CLI in codex-rs/ (#629)
As stated in `codex-rs/README.md`:
Today, Codex CLI is written in TypeScript and requires Node.js 22+ to
run it. For a number of users, this runtime requirement inhibits
adoption: they would be better served by a standalone executable. As
maintainers, we want Codex to run efficiently in a wide range of
environments with minimal overhead. We also want to take advantage of
operating system-specific APIs to provide better sandboxing, where
possible.
To that end, we are moving forward with a Rust implementation of Codex
CLI contained in this folder, which has the following benefits:
- The CLI compiles to small, standalone, platform-specific binaries.
- Can make direct, native calls to
[seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) and
[landlock](https://man7.org/linux/man-pages/man7/landlock.7.html) in
order to support sandboxing on Linux.
- No runtime garbage collection, resulting in lower memory consumption
and better, more predictable performance.
Currently, the Rust implementation is materially behind the TypeScript
implementation in functionality, so continue to use the TypeScript
implmentation for the time being. We will publish native executables via
GitHub Releases as soon as we feel the Rust version is usable.
2025-04-24 13:31:40 -07:00
|
|
|
|
use codex_core::protocol::FileChange;
|
2025-10-08 14:37:57 -07:00
|
|
|
|
use codex_core::protocol::McpAuthStatus;
|
2025-07-30 10:05:40 -07:00
|
|
|
|
use codex_core::protocol::McpInvocation;
|
2025-05-13 19:22:16 -07:00
|
|
|
|
use codex_core::protocol::SessionConfiguredEvent;
|
2025-09-14 20:59:22 -07:00
|
|
|
|
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
chore: refactor tool handling (#4510)
# Tool System Refactor
- Centralizes tool definitions and execution in `core/src/tools/*`:
specs (`spec.rs`), handlers (`handlers/*`), router (`router.rs`),
registry/dispatch (`registry.rs`), and shared context (`context.rs`).
One registry now builds the model-visible tool list and binds handlers.
- Router converts model responses to tool calls; Registry dispatches
with consistent telemetry via `codex-rs/otel` and unified error
handling. Function, Local Shell, MCP, and experimental `unified_exec`
all flow through this path; legacy shell aliases still work.
- Rationale: reduce per‑tool boilerplate, keep spec/handler in sync, and
make adding tools predictable and testable.
Example: `read_file`
- Spec: `core/src/tools/spec.rs` (see `create_read_file_tool`,
registered by `build_specs`).
- Handler: `core/src/tools/handlers/read_file.rs` (absolute `file_path`,
1‑indexed `offset`, `limit`, `L#: ` prefixes, safe truncation).
- E2E test: `core/tests/suite/read_file.rs` validates the tool returns
the requested lines.
## Next steps:
- Decompose `handle_container_exec_with_params`
- Add parallel tool calls
2025-10-03 13:21:06 +01:00
|
|
|
|
use codex_protocol::plan_tool::PlanItemArg;
|
|
|
|
|
|
use codex_protocol::plan_tool::StepStatus;
|
|
|
|
|
|
use codex_protocol::plan_tool::UpdatePlanArgs;
|
fix: introduce ResponseInputItem::McpToolCallOutput variant (#1151)
The output of an MCP server tool call can be one of several types, but
to date, we treated all outputs as text by showing the serialized JSON
as the "tool output" in Codex:
https://github.com/openai/codex/blob/25a9949c49194d5a64de54a11bcc5b4724ac9bd5/codex-rs/mcp-types/src/lib.rs#L96-L101
This PR adds support for the `ImageContent` variant so we can now
display an image output from an MCP tool call.
In making this change, we introduce a new
`ResponseInputItem::McpToolCallOutput` variant so that we can work with
the `mcp_types::CallToolResult` directly when the function call is made
to an MCP server.
Though arguably the more significant change is the introduction of
`HistoryCell::CompletedMcpToolCallWithImageOutput`, which is a cell that
uses `ratatui_image` to render an image into the terminal. To support
this, we introduce `ImageRenderCache`, cache a
`ratatui_image::picker::Picker`, and `ensure_image_cache()` to cache the
appropriate scaled image data and dimensions based on the current
terminal size.
To test, I created a minimal `package.json`:
```json
{
"name": "kitty-mcp",
"version": "1.0.0",
"type": "module",
"description": "MCP that returns image of kitty",
"main": "index.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0"
}
}
```
with the following `index.js` to define the MCP server:
```js
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const IMAGE_URI = "image://Ada.png";
const server = new McpServer({
name: "Demo",
version: "1.0.0",
});
server.tool(
"get-cat-image",
"If you need a cat image, this tool will provide one.",
async () => ({
content: [
{ type: "image", data: await getAdaPngBase64(), mimeType: "image/png" },
],
})
);
server.resource("Ada the Cat", IMAGE_URI, async (uri) => {
const base64Image = await getAdaPngBase64();
return {
contents: [
{
uri: uri.href,
mimeType: "image/png",
blob: base64Image,
},
],
};
});
async function getAdaPngBase64() {
const __dirname = new URL(".", import.meta.url).pathname;
// From https://github.com/benjajaja/ratatui-image/blob/9705ce2c59ec669abbce2924cbfd1f5ae22c9860/assets/Ada.png
const filePath = join(__dirname, "Ada.png");
const imageData = await readFile(filePath);
const base64Image = imageData.toString("base64");
return base64Image;
}
const transport = new StdioServerTransport();
await server.connect(transport);
```
With the local changes from this PR, I added the following to my
`config.toml`:
```toml
[mcp_servers.kitty]
command = "node"
args = ["/Users/mbolin/code/kitty-mcp/index.js"]
```
Running the TUI from source:
```
cargo run --bin codex -- --model o3 'I need a picture of a cat'
```
I get:
<img width="732" alt="image"
src="https://github.com/user-attachments/assets/bf80b721-9ca0-4d81-aec7-77d6899e2869"
/>
Now, that said, I have only tested in iTerm and there is definitely some
funny business with getting an accurate character-to-pixel ratio
(sometimes the `CompletedMcpToolCallWithImageOutput` thinks it needs 10
rows to render instead of 4), so there is still work to be done here.
2025-05-28 19:03:17 -07:00
|
|
|
|
use image::DynamicImage;
|
|
|
|
|
|
use image::ImageReader;
|
2025-06-03 14:29:26 -07:00
|
|
|
|
use mcp_types::EmbeddedResourceResource;
|
2025-10-16 22:05:15 -07:00
|
|
|
|
use mcp_types::Resource;
|
2025-07-19 00:09:34 -04:00
|
|
|
|
use mcp_types::ResourceLink;
|
2025-10-16 22:05:15 -07:00
|
|
|
|
use mcp_types::ResourceTemplate;
|
feat: initial import of Rust implementation of Codex CLI in codex-rs/ (#629)
As stated in `codex-rs/README.md`:
Today, Codex CLI is written in TypeScript and requires Node.js 22+ to
run it. For a number of users, this runtime requirement inhibits
adoption: they would be better served by a standalone executable. As
maintainers, we want Codex to run efficiently in a wide range of
environments with minimal overhead. We also want to take advantage of
operating system-specific APIs to provide better sandboxing, where
possible.
To that end, we are moving forward with a Rust implementation of Codex
CLI contained in this folder, which has the following benefits:
- The CLI compiles to small, standalone, platform-specific binaries.
- Can make direct, native calls to
[seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) and
[landlock](https://man7.org/linux/man-pages/man7/landlock.7.html) in
order to support sandboxing on Linux.
- No runtime garbage collection, resulting in lower memory consumption
and better, more predictable performance.
Currently, the Rust implementation is materially behind the TypeScript
implementation in functionality, so continue to use the TypeScript
implmentation for the time being. We will publish native executables via
GitHub Releases as soon as we feel the Rust version is usable.
2025-04-24 13:31:40 -07:00
|
|
|
|
use ratatui::prelude::*;
|
|
|
|
|
|
use ratatui::style::Modifier;
|
|
|
|
|
|
use ratatui::style::Style;
|
2025-09-02 10:29:58 -07:00
|
|
|
|
use ratatui::style::Styled;
|
2025-08-22 16:32:31 -07:00
|
|
|
|
use ratatui::style::Stylize;
|
2025-08-06 12:03:45 -07:00
|
|
|
|
use ratatui::widgets::Paragraph;
|
|
|
|
|
|
use ratatui::widgets::WidgetRef;
|
2025-08-07 18:38:39 -07:00
|
|
|
|
use ratatui::widgets::Wrap;
|
2025-09-18 13:55:53 -07:00
|
|
|
|
use std::any::Any;
|
feat: initial import of Rust implementation of Codex CLI in codex-rs/ (#629)
As stated in `codex-rs/README.md`:
Today, Codex CLI is written in TypeScript and requires Node.js 22+ to
run it. For a number of users, this runtime requirement inhibits
adoption: they would be better served by a standalone executable. As
maintainers, we want Codex to run efficiently in a wide range of
environments with minimal overhead. We also want to take advantage of
operating system-specific APIs to provide better sandboxing, where
possible.
To that end, we are moving forward with a Rust implementation of Codex
CLI contained in this folder, which has the following benefits:
- The CLI compiles to small, standalone, platform-specific binaries.
- Can make direct, native calls to
[seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) and
[landlock](https://man7.org/linux/man-pages/man7/landlock.7.html) in
order to support sandboxing on Linux.
- No runtime garbage collection, resulting in lower memory consumption
and better, more predictable performance.
Currently, the Rust implementation is materially behind the TypeScript
implementation in functionality, so continue to use the TypeScript
implmentation for the time being. We will publish native executables via
GitHub Releases as soon as we feel the Rust version is usable.
2025-04-24 13:31:40 -07:00
|
|
|
|
use std::collections::HashMap;
|
fix: introduce ResponseInputItem::McpToolCallOutput variant (#1151)
The output of an MCP server tool call can be one of several types, but
to date, we treated all outputs as text by showing the serialized JSON
as the "tool output" in Codex:
https://github.com/openai/codex/blob/25a9949c49194d5a64de54a11bcc5b4724ac9bd5/codex-rs/mcp-types/src/lib.rs#L96-L101
This PR adds support for the `ImageContent` variant so we can now
display an image output from an MCP tool call.
In making this change, we introduce a new
`ResponseInputItem::McpToolCallOutput` variant so that we can work with
the `mcp_types::CallToolResult` directly when the function call is made
to an MCP server.
Though arguably the more significant change is the introduction of
`HistoryCell::CompletedMcpToolCallWithImageOutput`, which is a cell that
uses `ratatui_image` to render an image into the terminal. To support
this, we introduce `ImageRenderCache`, cache a
`ratatui_image::picker::Picker`, and `ensure_image_cache()` to cache the
appropriate scaled image data and dimensions based on the current
terminal size.
To test, I created a minimal `package.json`:
```json
{
"name": "kitty-mcp",
"version": "1.0.0",
"type": "module",
"description": "MCP that returns image of kitty",
"main": "index.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0"
}
}
```
with the following `index.js` to define the MCP server:
```js
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const IMAGE_URI = "image://Ada.png";
const server = new McpServer({
name: "Demo",
version: "1.0.0",
});
server.tool(
"get-cat-image",
"If you need a cat image, this tool will provide one.",
async () => ({
content: [
{ type: "image", data: await getAdaPngBase64(), mimeType: "image/png" },
],
})
);
server.resource("Ada the Cat", IMAGE_URI, async (uri) => {
const base64Image = await getAdaPngBase64();
return {
contents: [
{
uri: uri.href,
mimeType: "image/png",
blob: base64Image,
},
],
};
});
async function getAdaPngBase64() {
const __dirname = new URL(".", import.meta.url).pathname;
// From https://github.com/benjajaja/ratatui-image/blob/9705ce2c59ec669abbce2924cbfd1f5ae22c9860/assets/Ada.png
const filePath = join(__dirname, "Ada.png");
const imageData = await readFile(filePath);
const base64Image = imageData.toString("base64");
return base64Image;
}
const transport = new StdioServerTransport();
await server.connect(transport);
```
With the local changes from this PR, I added the following to my
`config.toml`:
```toml
[mcp_servers.kitty]
command = "node"
args = ["/Users/mbolin/code/kitty-mcp/index.js"]
```
Running the TUI from source:
```
cargo run --bin codex -- --model o3 'I need a picture of a cat'
```
I get:
<img width="732" alt="image"
src="https://github.com/user-attachments/assets/bf80b721-9ca0-4d81-aec7-77d6899e2869"
/>
Now, that said, I have only tested in iTerm and there is definitely some
funny business with getting an accurate character-to-pixel ratio
(sometimes the `CompletedMcpToolCallWithImageOutput` thinks it needs 10
rows to render instead of 4), so there is still work to be done here.
2025-05-28 19:03:17 -07:00
|
|
|
|
use std::io::Cursor;
|
2025-09-02 10:29:58 -07:00
|
|
|
|
use std::path::Path;
|
feat: initial import of Rust implementation of Codex CLI in codex-rs/ (#629)
As stated in `codex-rs/README.md`:
Today, Codex CLI is written in TypeScript and requires Node.js 22+ to
run it. For a number of users, this runtime requirement inhibits
adoption: they would be better served by a standalone executable. As
maintainers, we want Codex to run efficiently in a wide range of
environments with minimal overhead. We also want to take advantage of
operating system-specific APIs to provide better sandboxing, where
possible.
To that end, we are moving forward with a Rust implementation of Codex
CLI contained in this folder, which has the following benefits:
- The CLI compiles to small, standalone, platform-specific binaries.
- Can make direct, native calls to
[seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) and
[landlock](https://man7.org/linux/man-pages/man7/landlock.7.html) in
order to support sandboxing on Linux.
- No runtime garbage collection, resulting in lower memory consumption
and better, more predictable performance.
Currently, the Rust implementation is materially behind the TypeScript
implementation in functionality, so continue to use the TypeScript
implmentation for the time being. We will publish native executables via
GitHub Releases as soon as we feel the Rust version is usable.
2025-04-24 13:31:40 -07:00
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
use std::time::Duration;
|
2025-08-14 19:32:45 -04:00
|
|
|
|
use std::time::Instant;
|
fix: introduce ResponseInputItem::McpToolCallOutput variant (#1151)
The output of an MCP server tool call can be one of several types, but
to date, we treated all outputs as text by showing the serialized JSON
as the "tool output" in Codex:
https://github.com/openai/codex/blob/25a9949c49194d5a64de54a11bcc5b4724ac9bd5/codex-rs/mcp-types/src/lib.rs#L96-L101
This PR adds support for the `ImageContent` variant so we can now
display an image output from an MCP tool call.
In making this change, we introduce a new
`ResponseInputItem::McpToolCallOutput` variant so that we can work with
the `mcp_types::CallToolResult` directly when the function call is made
to an MCP server.
Though arguably the more significant change is the introduction of
`HistoryCell::CompletedMcpToolCallWithImageOutput`, which is a cell that
uses `ratatui_image` to render an image into the terminal. To support
this, we introduce `ImageRenderCache`, cache a
`ratatui_image::picker::Picker`, and `ensure_image_cache()` to cache the
appropriate scaled image data and dimensions based on the current
terminal size.
To test, I created a minimal `package.json`:
```json
{
"name": "kitty-mcp",
"version": "1.0.0",
"type": "module",
"description": "MCP that returns image of kitty",
"main": "index.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0"
}
}
```
with the following `index.js` to define the MCP server:
```js
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const IMAGE_URI = "image://Ada.png";
const server = new McpServer({
name: "Demo",
version: "1.0.0",
});
server.tool(
"get-cat-image",
"If you need a cat image, this tool will provide one.",
async () => ({
content: [
{ type: "image", data: await getAdaPngBase64(), mimeType: "image/png" },
],
})
);
server.resource("Ada the Cat", IMAGE_URI, async (uri) => {
const base64Image = await getAdaPngBase64();
return {
contents: [
{
uri: uri.href,
mimeType: "image/png",
blob: base64Image,
},
],
};
});
async function getAdaPngBase64() {
const __dirname = new URL(".", import.meta.url).pathname;
// From https://github.com/benjajaja/ratatui-image/blob/9705ce2c59ec669abbce2924cbfd1f5ae22c9860/assets/Ada.png
const filePath = join(__dirname, "Ada.png");
const imageData = await readFile(filePath);
const base64Image = imageData.toString("base64");
return base64Image;
}
const transport = new StdioServerTransport();
await server.connect(transport);
```
With the local changes from this PR, I added the following to my
`config.toml`:
```toml
[mcp_servers.kitty]
command = "node"
args = ["/Users/mbolin/code/kitty-mcp/index.js"]
```
Running the TUI from source:
```
cargo run --bin codex -- --model o3 'I need a picture of a cat'
```
I get:
<img width="732" alt="image"
src="https://github.com/user-attachments/assets/bf80b721-9ca0-4d81-aec7-77d6899e2869"
/>
Now, that said, I have only tested in iTerm and there is definitely some
funny business with getting an accurate character-to-pixel ratio
(sometimes the `CompletedMcpToolCallWithImageOutput` thinks it needs 10
rows to render instead of 4), so there is still work to be done here.
2025-05-28 19:03:17 -07:00
|
|
|
|
use tracing::error;
|
2025-09-02 10:29:58 -07:00
|
|
|
|
use unicode_width::UnicodeWidthStr;
|
feat: initial import of Rust implementation of Codex CLI in codex-rs/ (#629)
As stated in `codex-rs/README.md`:
Today, Codex CLI is written in TypeScript and requires Node.js 22+ to
run it. For a number of users, this runtime requirement inhibits
adoption: they would be better served by a standalone executable. As
maintainers, we want Codex to run efficiently in a wide range of
environments with minimal overhead. We also want to take advantage of
operating system-specific APIs to provide better sandboxing, where
possible.
To that end, we are moving forward with a Rust implementation of Codex
CLI contained in this folder, which has the following benefits:
- The CLI compiles to small, standalone, platform-specific binaries.
- Can make direct, native calls to
[seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) and
[landlock](https://man7.org/linux/man-pages/man7/landlock.7.html) in
order to support sandboxing on Linux.
- No runtime garbage collection, resulting in lower memory consumption
and better, more predictable performance.
Currently, the Rust implementation is materially behind the TypeScript
implementation in functionality, so continue to use the TypeScript
implmentation for the time being. We will publish native executables via
GitHub Releases as soon as we feel the Rust version is usable.
2025-04-24 13:31:40 -07:00
|
|
|
|
|
2025-08-14 14:10:05 -04:00
|
|
|
|
/// Represents an event to display in the conversation history. Returns its
|
|
|
|
|
|
/// `Vec<Line<'static>>` representation to make it easier to display in a
|
|
|
|
|
|
/// scrollable list.
|
2025-09-18 13:55:53 -07:00
|
|
|
|
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
2025-09-02 10:29:58 -07:00
|
|
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>>;
|
2025-08-14 14:10:05 -04:00
|
|
|
|
|
|
|
|
|
|
fn desired_height(&self, width: u16) -> u16 {
|
2025-09-02 10:29:58 -07:00
|
|
|
|
Paragraph::new(Text::from(self.display_lines(width)))
|
2025-08-14 14:10:05 -04:00
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
|
.line_count(width)
|
|
|
|
|
|
.try_into()
|
|
|
|
|
|
.unwrap_or(0)
|
2025-07-30 10:05:40 -07:00
|
|
|
|
}
|
2025-09-02 10:29:58 -07:00
|
|
|
|
|
2025-10-07 16:18:48 -07:00
|
|
|
|
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
|
|
|
|
self.display_lines(width)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn desired_transcript_height(&self, width: u16) -> u16 {
|
|
|
|
|
|
let lines = self.transcript_lines(width);
|
|
|
|
|
|
// Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines.
|
|
|
|
|
|
if let [line] = &lines[..]
|
|
|
|
|
|
&& line
|
|
|
|
|
|
.spans
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.all(|s| s.content.chars().all(char::is_whitespace))
|
|
|
|
|
|
{
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Paragraph::new(Text::from(lines))
|
|
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
|
.line_count(width)
|
|
|
|
|
|
.try_into()
|
|
|
|
|
|
.unwrap_or(0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-02 10:29:58 -07:00
|
|
|
|
fn is_stream_continuation(&self) -> bool {
|
|
|
|
|
|
false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-18 13:55:53 -07:00
|
|
|
|
impl dyn HistoryCell {
|
|
|
|
|
|
pub(crate) fn as_any(&self) -> &dyn Any {
|
|
|
|
|
|
self
|
|
|
|
|
|
}
|
2025-09-24 13:36:01 -07:00
|
|
|
|
|
|
|
|
|
|
pub(crate) fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
|
|
|
|
self
|
|
|
|
|
|
}
|
2025-09-18 13:55:53 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-02 10:29:58 -07:00
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub(crate) struct UserHistoryCell {
|
2025-09-18 13:55:53 -07:00
|
|
|
|
pub message: String,
|
2025-09-02 10:29:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl HistoryCell for UserHistoryCell {
|
|
|
|
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
|
|
|
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
|
|
|
|
|
|
2025-10-14 11:02:11 -07:00
|
|
|
|
let wrap_width = width
|
|
|
|
|
|
.saturating_sub(
|
|
|
|
|
|
LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */
|
|
|
|
|
|
)
|
|
|
|
|
|
.max(1);
|
2025-09-26 16:35:56 -07:00
|
|
|
|
|
2025-10-07 16:18:48 -07:00
|
|
|
|
let style = user_message_style();
|
2025-09-26 16:35:56 -07:00
|
|
|
|
|
|
|
|
|
|
let wrapped = word_wrap_lines(
|
|
|
|
|
|
&self
|
|
|
|
|
|
.message
|
|
|
|
|
|
.lines()
|
|
|
|
|
|
.map(|l| Line::from(l).style(style))
|
|
|
|
|
|
.collect::<Vec<_>>(),
|
2025-10-07 11:32:13 -07:00
|
|
|
|
// Wrap algorithm matches textarea.rs.
|
2025-10-14 11:02:11 -07:00
|
|
|
|
RtOptions::new(usize::from(wrap_width))
|
|
|
|
|
|
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
|
2025-09-02 10:29:58 -07:00
|
|
|
|
);
|
|
|
|
|
|
|
2025-09-26 16:35:56 -07:00
|
|
|
|
lines.push(Line::from("").style(style));
|
|
|
|
|
|
lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into()));
|
|
|
|
|
|
lines.push(Line::from("").style(style));
|
2025-09-02 10:29:58 -07:00
|
|
|
|
lines
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-16 16:42:43 -07:00
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub(crate) struct ReasoningSummaryCell {
|
2025-09-30 16:13:55 -07:00
|
|
|
|
_header: String,
|
|
|
|
|
|
content: String,
|
2025-10-07 16:18:48 -07:00
|
|
|
|
transcript_only: bool,
|
2025-09-16 16:42:43 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ReasoningSummaryCell {
|
2025-10-20 14:08:19 -07:00
|
|
|
|
pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self {
|
2025-09-16 16:42:43 -07:00
|
|
|
|
Self {
|
|
|
|
|
|
_header: header,
|
|
|
|
|
|
content,
|
2025-10-07 16:18:48 -07:00
|
|
|
|
transcript_only,
|
2025-09-16 16:42:43 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-07 16:18:48 -07:00
|
|
|
|
fn lines(&self, width: u16) -> Vec<Line<'static>> {
|
2025-09-30 16:13:55 -07:00
|
|
|
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
|
|
|
|
|
append_markdown(
|
|
|
|
|
|
&self.content,
|
|
|
|
|
|
Some((width as usize).saturating_sub(2)),
|
|
|
|
|
|
&mut lines,
|
|
|
|
|
|
);
|
2025-10-07 16:18:48 -07:00
|
|
|
|
let summary_style = Style::default().dim().italic();
|
2025-09-30 16:13:55 -07:00
|
|
|
|
let summary_lines = lines
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|mut line| {
|
|
|
|
|
|
line.spans = line
|
|
|
|
|
|
.spans
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|span| span.patch_style(summary_style))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
line
|
2025-09-26 16:35:56 -07:00
|
|
|
|
})
|
2025-09-16 16:42:43 -07:00
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
|
|
|
|
|
|
word_wrap_lines(
|
|
|
|
|
|
&summary_lines,
|
|
|
|
|
|
RtOptions::new(width as usize)
|
2025-09-29 15:46:47 -07:00
|
|
|
|
.initial_indent("• ".dim().into())
|
2025-09-16 16:42:43 -07:00
|
|
|
|
.subsequent_indent(" ".into()),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2025-10-07 16:18:48 -07:00
|
|
|
|
}
|
2025-09-16 16:42:43 -07:00
|
|
|
|
|
2025-10-07 16:18:48 -07:00
|
|
|
|
impl HistoryCell for ReasoningSummaryCell {
|
|
|
|
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
|
|
|
|
if self.transcript_only {
|
|
|
|
|
|
Vec::new()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.lines(width)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
|
|
|
|
if self.transcript_only {
|
|
|
|
|
|
0
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.lines(width).len() as u16
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
|
|
|
|
self.lines(width)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn desired_transcript_height(&self, width: u16) -> u16 {
|
|
|
|
|
|
self.lines(width).len() as u16
|
2025-09-16 16:42:43 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-02 10:29:58 -07:00
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub(crate) struct AgentMessageCell {
|
|
|
|
|
|
lines: Vec<Line<'static>>,
|
|
|
|
|
|
is_first_line: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl AgentMessageCell {
|
|
|
|
|
|
pub(crate) fn new(lines: Vec<Line<'static>>, is_first_line: bool) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
lines,
|
|
|
|
|
|
is_first_line,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl HistoryCell for AgentMessageCell {
|
|
|
|
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
2025-09-05 07:10:32 -07:00
|
|
|
|
word_wrap_lines(
|
|
|
|
|
|
&self.lines,
|
|
|
|
|
|
RtOptions::new(width as usize)
|
|
|
|
|
|
.initial_indent(if self.is_first_line {
|
2025-09-29 15:46:47 -07:00
|
|
|
|
"• ".dim().into()
|
2025-09-02 10:29:58 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
" ".into()
|
2025-09-05 07:10:32 -07:00
|
|
|
|
})
|
|
|
|
|
|
.subsequent_indent(" ".into()),
|
|
|
|
|
|
)
|
2025-09-02 10:29:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn is_stream_continuation(&self) -> bool {
|
|
|
|
|
|
!self.is_first_line
|
|
|
|
|
|
}
|
2025-07-30 10:05:40 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-20 17:09:46 -07:00
|
|
|
|
#[derive(Debug)]
|
2025-08-14 14:10:05 -04:00
|
|
|
|
pub(crate) struct PlainHistoryCell {
|
|
|
|
|
|
lines: Vec<Line<'static>>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-25 08:38:50 -07:00
|
|
|
|
impl PlainHistoryCell {
|
|
|
|
|
|
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
|
|
|
|
|
|
Self { lines }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 14:10:05 -04:00
|
|
|
|
impl HistoryCell for PlainHistoryCell {
|
2025-09-02 10:29:58 -07:00
|
|
|
|
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
2025-08-14 14:10:05 -04:00
|
|
|
|
self.lines.clone()
|
2025-07-30 10:05:40 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-20 14:40:14 -07:00
|
|
|
|
#[cfg_attr(debug_assertions, allow(dead_code))]
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub(crate) struct UpdateAvailableHistoryCell {
|
|
|
|
|
|
latest_version: String,
|
|
|
|
|
|
update_action: Option<UpdateAction>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg_attr(debug_assertions, allow(dead_code))]
|
|
|
|
|
|
impl UpdateAvailableHistoryCell {
|
|
|
|
|
|
pub(crate) fn new(latest_version: String, update_action: Option<UpdateAction>) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
latest_version,
|
|
|
|
|
|
update_action,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl HistoryCell for UpdateAvailableHistoryCell {
|
|
|
|
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
|
|
|
|
use ratatui_macros::line;
|
|
|
|
|
|
use ratatui_macros::text;
|
|
|
|
|
|
let update_instruction = if let Some(update_action) = self.update_action {
|
|
|
|
|
|
line!["Run ", update_action.command_str().cyan(), " to update."]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
line![
|
|
|
|
|
|
"See ",
|
|
|
|
|
|
"https://github.com/openai/codex".cyan().underlined(),
|
|
|
|
|
|
" for installation options."
|
|
|
|
|
|
]
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let content = text![
|
|
|
|
|
|
line![
|
|
|
|
|
|
padded_emoji("✨").bold().cyan(),
|
|
|
|
|
|
"Update available!".bold().cyan(),
|
|
|
|
|
|
" ",
|
|
|
|
|
|
format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(),
|
|
|
|
|
|
],
|
|
|
|
|
|
update_instruction,
|
|
|
|
|
|
"",
|
|
|
|
|
|
"See full release notes:",
|
|
|
|
|
|
"https://github.com/openai/codex/releases/latest"
|
|
|
|
|
|
.cyan()
|
|
|
|
|
|
.underlined(),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
let inner_width = content
|
|
|
|
|
|
.width()
|
|
|
|
|
|
.min(usize::from(width.saturating_sub(4)))
|
|
|
|
|
|
.max(1);
|
|
|
|
|
|
with_border_with_inner_width(content.lines, inner_width)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-09 10:37:13 -07:00
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub(crate) struct PrefixedWrappedHistoryCell {
|
|
|
|
|
|
text: Text<'static>,
|
|
|
|
|
|
initial_prefix: Line<'static>,
|
|
|
|
|
|
subsequent_prefix: Line<'static>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl PrefixedWrappedHistoryCell {
|
|
|
|
|
|
pub(crate) fn new(
|
|
|
|
|
|
text: impl Into<Text<'static>>,
|
|
|
|
|
|
initial_prefix: impl Into<Line<'static>>,
|
|
|
|
|
|
subsequent_prefix: impl Into<Line<'static>>,
|
|
|
|
|
|
) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
text: text.into(),
|
|
|
|
|
|
initial_prefix: initial_prefix.into(),
|
|
|
|
|
|
subsequent_prefix: subsequent_prefix.into(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl HistoryCell for PrefixedWrappedHistoryCell {
|
|
|
|
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
|
|
|
|
if width == 0 {
|
|
|
|
|
|
return Vec::new();
|
|
|
|
|
|
}
|
|
|
|
|
|
let opts = RtOptions::new(width.max(1) as usize)
|
|
|
|
|
|
.initial_indent(self.initial_prefix.clone())
|
|
|
|
|
|
.subsequent_indent(self.subsequent_prefix.clone());
|
|
|
|
|
|
let wrapped = word_wrap_lines(&self.text, opts);
|
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
|
push_owned_lines(&wrapped, &mut out);
|
|
|
|
|
|
out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
|
|
|
|
self.display_lines(width).len() as u16
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn truncate_exec_snippet(full_cmd: &str) -> String {
|
|
|
|
|
|
let mut snippet = match full_cmd.split_once('\n') {
|
|
|
|
|
|
Some((first, _)) => format!("{first} ..."),
|
|
|
|
|
|
None => full_cmd.to_string(),
|
|
|
|
|
|
};
|
|
|
|
|
|
snippet = truncate_text(&snippet, 80);
|
|
|
|
|
|
snippet
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn exec_snippet(command: &[String]) -> String {
|
|
|
|
|
|
let full_cmd = strip_bash_lc_and_escape(command);
|
|
|
|
|
|
truncate_exec_snippet(&full_cmd)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn new_approval_decision_cell(
|
|
|
|
|
|
command: Vec<String>,
|
|
|
|
|
|
decision: codex_core::protocol::ReviewDecision,
|
|
|
|
|
|
) -> Box<dyn HistoryCell> {
|
|
|
|
|
|
use codex_core::protocol::ReviewDecision::*;
|
|
|
|
|
|
|
|
|
|
|
|
let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
|
|
|
|
|
|
Approved => {
|
|
|
|
|
|
let snippet = Span::from(exec_snippet(&command)).dim();
|
|
|
|
|
|
(
|
|
|
|
|
|
"✔ ".green(),
|
|
|
|
|
|
vec![
|
|
|
|
|
|
"You ".into(),
|
|
|
|
|
|
"approved".bold(),
|
|
|
|
|
|
" codex to run ".into(),
|
|
|
|
|
|
snippet,
|
|
|
|
|
|
" this time".bold(),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
ApprovedForSession => {
|
|
|
|
|
|
let snippet = Span::from(exec_snippet(&command)).dim();
|
|
|
|
|
|
(
|
|
|
|
|
|
"✔ ".green(),
|
|
|
|
|
|
vec![
|
|
|
|
|
|
"You ".into(),
|
|
|
|
|
|
"approved".bold(),
|
|
|
|
|
|
" codex to run ".into(),
|
|
|
|
|
|
snippet,
|
|
|
|
|
|
" every time this session".bold(),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
Denied => {
|
|
|
|
|
|
let snippet = Span::from(exec_snippet(&command)).dim();
|
|
|
|
|
|
(
|
|
|
|
|
|
"✗ ".red(),
|
|
|
|
|
|
vec![
|
|
|
|
|
|
"You ".into(),
|
|
|
|
|
|
"did not approve".bold(),
|
|
|
|
|
|
" codex to run ".into(),
|
|
|
|
|
|
snippet,
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
Abort => {
|
|
|
|
|
|
let snippet = Span::from(exec_snippet(&command)).dim();
|
|
|
|
|
|
(
|
|
|
|
|
|
"✗ ".red(),
|
|
|
|
|
|
vec![
|
|
|
|
|
|
"You ".into(),
|
|
|
|
|
|
"canceled".bold(),
|
|
|
|
|
|
" the request to run ".into(),
|
|
|
|
|
|
snippet,
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Box::new(PrefixedWrappedHistoryCell::new(
|
|
|
|
|
|
Line::from(summary),
|
|
|
|
|
|
symbol,
|
|
|
|
|
|
" ",
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-18 14:14:16 -07:00
|
|
|
|
/// Cyan history cell line showing the current review status.
|
|
|
|
|
|
pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
|
|
|
|
|
|
PlainHistoryCell {
|
|
|
|
|
|
lines: vec![Line::from(message.cyan())],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-20 17:09:46 -07:00
|
|
|
|
#[derive(Debug)]
|
2025-09-02 10:29:58 -07:00
|
|
|
|
pub(crate) struct PatchHistoryCell {
|
|
|
|
|
|
changes: HashMap<PathBuf, FileChange>,
|
|
|
|
|
|
cwd: PathBuf,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl HistoryCell for PatchHistoryCell {
|
|
|
|
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
2025-10-01 14:29:05 -07:00
|
|
|
|
create_diff_summary(&self.changes, &self.cwd, width as usize)
|
2025-09-02 10:29:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-20 17:09:46 -07:00
|
|
|
|
#[derive(Debug)]
|
2025-08-14 14:10:05 -04:00
|
|
|
|
struct CompletedMcpToolCallWithImageOutput {
|
|
|
|
|
|
_image: DynamicImage,
|
|
|
|
|
|
}
|
|
|
|
|
|
impl HistoryCell for CompletedMcpToolCallWithImageOutput {
|
2025-09-02 10:29:58 -07:00
|
|
|
|
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
2025-09-02 16:19:54 -07:00
|
|
|
|
vec!["tool result (image output omitted)".into()]
|
2025-08-14 14:10:05 -04:00
|
|
|
|
}
|
feat: initial import of Rust implementation of Codex CLI in codex-rs/ (#629)
As stated in `codex-rs/README.md`:
Today, Codex CLI is written in TypeScript and requires Node.js 22+ to
run it. For a number of users, this runtime requirement inhibits
adoption: they would be better served by a standalone executable. As
maintainers, we want Codex to run efficiently in a wide range of
environments with minimal overhead. We also want to take advantage of
operating system-specific APIs to provide better sandboxing, where
possible.
To that end, we are moving forward with a Rust implementation of Codex
CLI contained in this folder, which has the following benefits:
- The CLI compiles to small, standalone, platform-specific binaries.
- Can make direct, native calls to
[seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) and
[landlock](https://man7.org/linux/man-pages/man7/landlock.7.html) in
order to support sandboxing on Linux.
- No runtime garbage collection, resulting in lower memory consumption
and better, more predictable performance.
Currently, the Rust implementation is materially behind the TypeScript
implementation in functionality, so continue to use the TypeScript
implmentation for the time being. We will publish native executables via
GitHub Releases as soon as we feel the Rust version is usable.
2025-04-24 13:31:40 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-25 08:38:50 -07:00
|
|
|
|
pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value
|
2025-05-06 16:12:15 -07:00
|
|
|
|
|
2025-09-25 08:38:50 -07:00
|
|
|
|
pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
|
2025-09-23 10:37:14 -07:00
|
|
|
|
if width < 4 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let inner_width = std::cmp::min(width.saturating_sub(4) as usize, max_inner_width);
|
|
|
|
|
|
Some(inner_width)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-25 08:38:50 -07:00
|
|
|
|
/// Render `lines` inside a border sized to the widest span in the content.
|
|
|
|
|
|
pub(crate) fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
|
|
|
|
|
with_border_internal(lines, None)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Render `lines` inside a border whose inner width is at least `inner_width`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This is useful when callers have already clamped their content to a
|
|
|
|
|
|
/// specific width and want the border math centralized here instead of
|
|
|
|
|
|
/// duplicating padding logic in the TUI widgets themselves.
|
|
|
|
|
|
pub(crate) fn with_border_with_inner_width(
|
|
|
|
|
|
lines: Vec<Line<'static>>,
|
|
|
|
|
|
inner_width: usize,
|
|
|
|
|
|
) -> Vec<Line<'static>> {
|
|
|
|
|
|
with_border_internal(lines, Some(inner_width))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn with_border_internal(
|
|
|
|
|
|
lines: Vec<Line<'static>>,
|
|
|
|
|
|
forced_inner_width: Option<usize>,
|
|
|
|
|
|
) -> Vec<Line<'static>> {
|
|
|
|
|
|
let max_line_width = lines
|
2025-09-23 10:37:14 -07:00
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|line| {
|
|
|
|
|
|
line.iter()
|
|
|
|
|
|
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
|
|
|
|
|
.sum::<usize>()
|
|
|
|
|
|
})
|
|
|
|
|
|
.max()
|
|
|
|
|
|
.unwrap_or(0);
|
2025-09-25 08:38:50 -07:00
|
|
|
|
let content_width = forced_inner_width
|
|
|
|
|
|
.unwrap_or(max_line_width)
|
|
|
|
|
|
.max(max_line_width);
|
2025-09-23 10:37:14 -07:00
|
|
|
|
|
|
|
|
|
|
let mut out = Vec::with_capacity(lines.len() + 2);
|
|
|
|
|
|
let border_inner_width = content_width + 2;
|
|
|
|
|
|
out.push(vec![format!("╭{}╮", "─".repeat(border_inner_width)).dim()].into());
|
|
|
|
|
|
|
|
|
|
|
|
for line in lines.into_iter() {
|
|
|
|
|
|
let used_width: usize = line
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
|
|
|
|
|
.sum();
|
|
|
|
|
|
let span_count = line.spans.len();
|
|
|
|
|
|
let mut spans: Vec<Span<'static>> = Vec::with_capacity(span_count + 4);
|
|
|
|
|
|
spans.push(Span::from("│ ").dim());
|
|
|
|
|
|
spans.extend(line.into_iter());
|
|
|
|
|
|
if used_width < content_width {
|
|
|
|
|
|
spans.push(Span::from(" ".repeat(content_width - used_width)).dim());
|
|
|
|
|
|
}
|
|
|
|
|
|
spans.push(Span::from(" │").dim());
|
|
|
|
|
|
out.push(Line::from(spans));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out.push(vec![format!("╰{}╯", "─".repeat(border_inner_width)).dim()].into());
|
|
|
|
|
|
|
|
|
|
|
|
out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 17:34:24 -07:00
|
|
|
|
/// Return the emoji followed by a hair space (U+200A).
|
|
|
|
|
|
/// Using only the hair space avoids excessive padding after the emoji while
|
|
|
|
|
|
/// still providing a small visual gap across terminals.
|
2025-09-25 08:38:50 -07:00
|
|
|
|
pub(crate) fn padded_emoji(emoji: &str) -> String {
|
2025-08-26 17:34:24 -07:00
|
|
|
|
format!("{emoji}\u{200A}")
|
2025-08-25 22:49:19 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 14:10:05 -04:00
|
|
|
|
pub(crate) fn new_session_info(
|
|
|
|
|
|
config: &Config,
|
|
|
|
|
|
event: SessionConfiguredEvent,
|
|
|
|
|
|
is_first_event: bool,
|
2025-09-14 20:53:50 -04:00
|
|
|
|
) -> CompositeHistoryCell {
|
2025-08-14 14:10:05 -04:00
|
|
|
|
let SessionConfiguredEvent {
|
|
|
|
|
|
model,
|
2025-09-14 20:59:22 -07:00
|
|
|
|
reasoning_effort,
|
2025-08-14 14:10:05 -04:00
|
|
|
|
session_id: _,
|
|
|
|
|
|
history_log_id: _,
|
|
|
|
|
|
history_entry_count: _,
|
2025-09-03 21:47:00 -07:00
|
|
|
|
initial_messages: _,
|
2025-09-09 00:11:48 -07:00
|
|
|
|
rollout_path: _,
|
2025-08-14 14:10:05 -04:00
|
|
|
|
} = event;
|
|
|
|
|
|
if is_first_event {
|
2025-09-14 20:53:50 -04:00
|
|
|
|
// Header box rendered as history (so it appears at the very top)
|
|
|
|
|
|
let header = SessionHeaderHistoryCell::new(
|
|
|
|
|
|
model,
|
2025-09-14 20:59:22 -07:00
|
|
|
|
reasoning_effort,
|
2025-09-14 20:53:50 -04:00
|
|
|
|
config.cwd.clone(),
|
|
|
|
|
|
crate::version::CODEX_CLI_VERSION,
|
|
|
|
|
|
);
|
2025-08-12 17:37:28 -07:00
|
|
|
|
|
2025-09-14 20:53:50 -04:00
|
|
|
|
// Help lines below the header (new copy and list)
|
|
|
|
|
|
let help_lines: Vec<Line<'static>> = vec![
|
2025-09-15 06:44:40 -07:00
|
|
|
|
" To get started, describe a task or try one of these commands:"
|
2025-09-14 20:53:50 -04:00
|
|
|
|
.dim()
|
|
|
|
|
|
.into(),
|
2025-09-15 06:44:40 -07:00
|
|
|
|
Line::from(""),
|
2025-09-14 20:53:50 -04:00
|
|
|
|
Line::from(vec![
|
2025-09-15 06:44:40 -07:00
|
|
|
|
" ".into(),
|
|
|
|
|
|
"/init".into(),
|
|
|
|
|
|
" - create an AGENTS.md file with instructions for Codex".dim(),
|
2025-09-14 20:53:50 -04:00
|
|
|
|
]),
|
|
|
|
|
|
Line::from(vec![
|
2025-09-15 06:44:40 -07:00
|
|
|
|
" ".into(),
|
|
|
|
|
|
"/status".into(),
|
|
|
|
|
|
" - show current session configuration".dim(),
|
2025-09-14 20:53:50 -04:00
|
|
|
|
]),
|
|
|
|
|
|
Line::from(vec![
|
2025-09-15 06:44:40 -07:00
|
|
|
|
" ".into(),
|
|
|
|
|
|
"/approvals".into(),
|
|
|
|
|
|
" - choose what Codex can do without approval".dim(),
|
|
|
|
|
|
]),
|
|
|
|
|
|
Line::from(vec![
|
|
|
|
|
|
" ".into(),
|
|
|
|
|
|
"/model".into(),
|
|
|
|
|
|
" - choose what model and reasoning effort to use".dim(),
|
2025-09-14 20:53:50 -04:00
|
|
|
|
]),
|
2025-09-28 16:59:39 -07:00
|
|
|
|
Line::from(vec![
|
|
|
|
|
|
" ".into(),
|
|
|
|
|
|
"/review".into(),
|
|
|
|
|
|
" - review any changes and find issues".dim(),
|
|
|
|
|
|
]),
|
2025-09-14 20:53:50 -04:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
CompositeHistoryCell {
|
|
|
|
|
|
parts: vec![
|
|
|
|
|
|
Box::new(header),
|
|
|
|
|
|
Box::new(PlainHistoryCell { lines: help_lines }),
|
|
|
|
|
|
],
|
2025-09-02 12:04:32 -07:00
|
|
|
|
}
|
2025-08-14 14:10:05 -04:00
|
|
|
|
} else if config.model == model {
|
2025-09-14 20:53:50 -04:00
|
|
|
|
CompositeHistoryCell { parts: vec![] }
|
2025-08-14 14:10:05 -04:00
|
|
|
|
} else {
|
|
|
|
|
|
let lines = vec![
|
2025-09-02 16:19:54 -07:00
|
|
|
|
"model changed:".magenta().bold().into(),
|
|
|
|
|
|
format!("requested: {}", config.model).into(),
|
|
|
|
|
|
format!("used: {model}").into(),
|
2025-08-14 14:10:05 -04:00
|
|
|
|
];
|
2025-09-14 20:53:50 -04:00
|
|
|
|
CompositeHistoryCell {
|
|
|
|
|
|
parts: vec![Box::new(PlainHistoryCell { lines })],
|
|
|
|
|
|
}
|
2025-08-06 12:03:45 -07:00
|
|
|
|
}
|
2025-08-14 14:10:05 -04:00
|
|
|
|
}
|
2025-08-06 12:03:45 -07:00
|
|
|
|
|
2025-09-02 10:29:58 -07:00
|
|
|
|
pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell {
|
|
|
|
|
|
UserHistoryCell { message }
|
|
|
|
|
|
}
|
2025-05-30 23:24:36 -07:00
|
|
|
|
|
2025-09-14 20:53:50 -04:00
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
struct SessionHeaderHistoryCell {
|
|
|
|
|
|
version: &'static str,
|
|
|
|
|
|
model: String,
|
2025-09-14 20:59:22 -07:00
|
|
|
|
reasoning_effort: Option<ReasoningEffortConfig>,
|
2025-09-14 20:53:50 -04:00
|
|
|
|
directory: PathBuf,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl SessionHeaderHistoryCell {
|
2025-09-14 20:59:22 -07:00
|
|
|
|
fn new(
|
|
|
|
|
|
model: String,
|
|
|
|
|
|
reasoning_effort: Option<ReasoningEffortConfig>,
|
|
|
|
|
|
directory: PathBuf,
|
|
|
|
|
|
version: &'static str,
|
|
|
|
|
|
) -> Self {
|
2025-09-14 20:53:50 -04:00
|
|
|
|
Self {
|
|
|
|
|
|
version,
|
|
|
|
|
|
model,
|
2025-09-14 20:59:22 -07:00
|
|
|
|
reasoning_effort,
|
2025-09-14 20:53:50 -04:00
|
|
|
|
directory,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-15 06:44:40 -07:00
|
|
|
|
fn format_directory(&self, max_width: Option<usize>) -> String {
|
|
|
|
|
|
Self::format_directory_inner(&self.directory, max_width)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn format_directory_inner(directory: &Path, max_width: Option<usize>) -> String {
|
|
|
|
|
|
let formatted = if let Some(rel) = relativize_to_home(directory) {
|
2025-09-14 20:53:50 -04:00
|
|
|
|
if rel.as_os_str().is_empty() {
|
|
|
|
|
|
"~".to_string()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display())
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2025-09-15 06:44:40 -07:00
|
|
|
|
directory.display().to_string()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if let Some(max_width) = max_width {
|
|
|
|
|
|
if max_width == 0 {
|
|
|
|
|
|
return String::new();
|
|
|
|
|
|
}
|
|
|
|
|
|
if UnicodeWidthStr::width(formatted.as_str()) > max_width {
|
|
|
|
|
|
return crate::text_formatting::center_truncate_path(&formatted, max_width);
|
|
|
|
|
|
}
|
2025-09-14 20:53:50 -04:00
|
|
|
|
}
|
2025-09-15 06:44:40 -07:00
|
|
|
|
|
|
|
|
|
|
formatted
|
2025-09-14 20:53:50 -04:00
|
|
|
|
}
|
2025-09-14 20:59:22 -07:00
|
|
|
|
|
|
|
|
|
|
fn reasoning_label(&self) -> Option<&'static str> {
|
|
|
|
|
|
self.reasoning_effort.map(|effort| match effort {
|
|
|
|
|
|
ReasoningEffortConfig::Minimal => "minimal",
|
|
|
|
|
|
ReasoningEffortConfig::Low => "low",
|
|
|
|
|
|
ReasoningEffortConfig::Medium => "medium",
|
|
|
|
|
|
ReasoningEffortConfig::High => "high",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2025-09-14 20:53:50 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl HistoryCell for SessionHeaderHistoryCell {
|
|
|
|
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
2025-09-23 10:37:14 -07:00
|
|
|
|
let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else {
|
|
|
|
|
|
return Vec::new();
|
|
|
|
|
|
};
|
2025-09-14 20:53:50 -04:00
|
|
|
|
|
2025-09-23 10:37:14 -07:00
|
|
|
|
let make_row = |spans: Vec<Span<'static>>| Line::from(spans);
|
|
|
|
|
|
|
|
|
|
|
|
// Title line rendered inside the box: ">_ OpenAI Codex (vX)"
|
|
|
|
|
|
let title_spans: Vec<Span<'static>> = vec![
|
2025-09-15 06:44:40 -07:00
|
|
|
|
Span::from(">_ ").dim(),
|
|
|
|
|
|
Span::from("OpenAI Codex").bold(),
|
|
|
|
|
|
Span::from(" ").dim(),
|
|
|
|
|
|
Span::from(format!("(v{})", self.version)).dim(),
|
2025-09-14 20:53:50 -04:00
|
|
|
|
];
|
|
|
|
|
|
|
2025-09-15 06:44:40 -07:00
|
|
|
|
const CHANGE_MODEL_HINT_COMMAND: &str = "/model";
|
|
|
|
|
|
const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change";
|
|
|
|
|
|
const DIR_LABEL: &str = "directory:";
|
|
|
|
|
|
let label_width = DIR_LABEL.len();
|
|
|
|
|
|
let model_label = format!(
|
|
|
|
|
|
"{model_label:<label_width$}",
|
|
|
|
|
|
model_label = "model:",
|
|
|
|
|
|
label_width = label_width
|
|
|
|
|
|
);
|
2025-09-14 20:59:22 -07:00
|
|
|
|
let reasoning_label = self.reasoning_label();
|
2025-09-23 10:37:14 -07:00
|
|
|
|
let mut model_spans: Vec<Span<'static>> = vec![
|
|
|
|
|
|
Span::from(format!("{model_label} ")).dim(),
|
2025-09-15 06:44:40 -07:00
|
|
|
|
Span::from(self.model.clone()),
|
2025-09-14 20:53:50 -04:00
|
|
|
|
];
|
2025-09-14 20:59:22 -07:00
|
|
|
|
if let Some(reasoning) = reasoning_label {
|
2025-09-23 10:37:14 -07:00
|
|
|
|
model_spans.push(Span::from(" "));
|
|
|
|
|
|
model_spans.push(Span::from(reasoning));
|
2025-09-14 20:53:50 -04:00
|
|
|
|
}
|
2025-09-23 10:37:14 -07:00
|
|
|
|
model_spans.push(" ".dim());
|
|
|
|
|
|
model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan());
|
|
|
|
|
|
model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim());
|
2025-09-14 20:53:50 -04:00
|
|
|
|
|
2025-09-15 06:44:40 -07:00
|
|
|
|
let dir_label = format!("{DIR_LABEL:<label_width$}");
|
2025-09-23 10:37:14 -07:00
|
|
|
|
let dir_prefix = format!("{dir_label} ");
|
|
|
|
|
|
let dir_prefix_width = UnicodeWidthStr::width(dir_prefix.as_str());
|
|
|
|
|
|
let dir_max_width = inner_width.saturating_sub(dir_prefix_width);
|
2025-09-15 06:44:40 -07:00
|
|
|
|
let dir = self.format_directory(Some(dir_max_width));
|
2025-09-23 10:37:14 -07:00
|
|
|
|
let dir_spans = vec![Span::from(dir_prefix).dim(), Span::from(dir)];
|
2025-09-14 20:53:50 -04:00
|
|
|
|
|
2025-09-23 10:37:14 -07:00
|
|
|
|
let lines = vec![
|
|
|
|
|
|
make_row(title_spans),
|
|
|
|
|
|
make_row(Vec::new()),
|
|
|
|
|
|
make_row(model_spans),
|
|
|
|
|
|
make_row(dir_spans),
|
|
|
|
|
|
];
|
2025-09-14 20:53:50 -04:00
|
|
|
|
|
2025-09-23 10:37:14 -07:00
|
|
|
|
with_border(lines)
|
2025-09-14 20:53:50 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub(crate) struct CompositeHistoryCell {
|
|
|
|
|
|
parts: Vec<Box<dyn HistoryCell>>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-25 08:38:50 -07:00
|
|
|
|
impl CompositeHistoryCell {
|
|
|
|
|
|
pub(crate) fn new(parts: Vec<Box<dyn HistoryCell>>) -> Self {
|
|
|
|
|
|
Self { parts }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-14 20:53:50 -04:00
|
|
|
|
impl HistoryCell for CompositeHistoryCell {
|
|
|
|
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
|
|
|
|
let mut out: Vec<Line<'static>> = Vec::new();
|
|
|
|
|
|
let mut first = true;
|
|
|
|
|
|
for part in &self.parts {
|
|
|
|
|
|
let mut lines = part.display_lines(width);
|
|
|
|
|
|
if !lines.is_empty() {
|
|
|
|
|
|
if !first {
|
|
|
|
|
|
out.push(Line::from(""));
|
|
|
|
|
|
}
|
|
|
|
|
|
out.append(&mut lines);
|
|
|
|
|
|
first = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
out
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-24 13:36:01 -07:00
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub(crate) struct McpToolCallCell {
|
|
|
|
|
|
call_id: String,
|
|
|
|
|
|
invocation: McpInvocation,
|
|
|
|
|
|
start_time: Instant,
|
|
|
|
|
|
duration: Option<Duration>,
|
|
|
|
|
|
result: Option<Result<mcp_types::CallToolResult, String>>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl McpToolCallCell {
|
|
|
|
|
|
pub(crate) fn new(call_id: String, invocation: McpInvocation) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
call_id,
|
|
|
|
|
|
invocation,
|
|
|
|
|
|
start_time: Instant::now(),
|
|
|
|
|
|
duration: None,
|
|
|
|
|
|
result: None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn call_id(&self) -> &str {
|
|
|
|
|
|
&self.call_id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn complete(
|
|
|
|
|
|
&mut self,
|
|
|
|
|
|
duration: Duration,
|
|
|
|
|
|
result: Result<mcp_types::CallToolResult, String>,
|
|
|
|
|
|
) -> Option<Box<dyn HistoryCell>> {
|
|
|
|
|
|
let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result)
|
|
|
|
|
|
.map(|cell| Box::new(cell) as Box<dyn HistoryCell>);
|
|
|
|
|
|
self.duration = Some(duration);
|
|
|
|
|
|
self.result = Some(result);
|
|
|
|
|
|
image_cell
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn success(&self) -> Option<bool> {
|
|
|
|
|
|
match self.result.as_ref() {
|
|
|
|
|
|
Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)),
|
|
|
|
|
|
Some(Err(_)) => Some(false),
|
|
|
|
|
|
None => None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn mark_failed(&mut self) {
|
|
|
|
|
|
let elapsed = self.start_time.elapsed();
|
|
|
|
|
|
self.duration = Some(elapsed);
|
|
|
|
|
|
self.result = Some(Err("interrupted".to_string()));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn render_content_block(block: &mcp_types::ContentBlock, width: usize) -> String {
|
|
|
|
|
|
match block {
|
|
|
|
|
|
mcp_types::ContentBlock::TextContent(text) => {
|
|
|
|
|
|
format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width)
|
|
|
|
|
|
}
|
|
|
|
|
|
mcp_types::ContentBlock::ImageContent(_) => "<image content>".to_string(),
|
|
|
|
|
|
mcp_types::ContentBlock::AudioContent(_) => "<audio content>".to_string(),
|
|
|
|
|
|
mcp_types::ContentBlock::EmbeddedResource(resource) => {
|
|
|
|
|
|
let uri = match &resource.resource {
|
|
|
|
|
|
EmbeddedResourceResource::TextResourceContents(text) => text.uri.clone(),
|
|
|
|
|
|
EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.clone(),
|
|
|
|
|
|
};
|
|
|
|
|
|
format!("embedded resource: {uri}")
|
|
|
|
|
|
}
|
|
|
|
|
|
mcp_types::ContentBlock::ResourceLink(ResourceLink { uri, .. }) => {
|
|
|
|
|
|
format!("link: {uri}")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl HistoryCell for McpToolCallCell {
|
|
|
|
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
|
|
|
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
|
|
|
|
|
let status = self.success();
|
|
|
|
|
|
let bullet = match status {
|
|
|
|
|
|
Some(true) => "•".green().bold(),
|
|
|
|
|
|
Some(false) => "•".red().bold(),
|
|
|
|
|
|
None => spinner(Some(self.start_time)),
|
|
|
|
|
|
};
|
|
|
|
|
|
let header_text = if status.is_some() {
|
|
|
|
|
|
"Called"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"Calling"
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let invocation_line = line_to_static(&format_mcp_invocation(self.invocation.clone()));
|
|
|
|
|
|
let mut compact_spans = vec![bullet.clone(), " ".into(), header_text.bold(), " ".into()];
|
|
|
|
|
|
let mut compact_header = Line::from(compact_spans.clone());
|
|
|
|
|
|
let reserved = compact_header.width();
|
|
|
|
|
|
|
|
|
|
|
|
let inline_invocation =
|
|
|
|
|
|
invocation_line.width() <= (width as usize).saturating_sub(reserved);
|
|
|
|
|
|
|
|
|
|
|
|
if inline_invocation {
|
|
|
|
|
|
compact_header.extend(invocation_line.spans.clone());
|
|
|
|
|
|
lines.push(compact_header);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
compact_spans.pop(); // drop trailing space for standalone header
|
|
|
|
|
|
lines.push(Line::from(compact_spans));
|
|
|
|
|
|
|
|
|
|
|
|
let opts = RtOptions::new((width as usize).saturating_sub(4))
|
|
|
|
|
|
.initial_indent("".into())
|
|
|
|
|
|
.subsequent_indent(" ".into());
|
|
|
|
|
|
let wrapped = word_wrap_line(&invocation_line, opts);
|
|
|
|
|
|
let body_lines: Vec<Line<'static>> = wrapped.iter().map(line_to_static).collect();
|
|
|
|
|
|
lines.extend(prefix_lines(body_lines, " └ ".dim(), " ".into()));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let mut detail_lines: Vec<Line<'static>> = Vec::new();
|
|
|
|
|
|
|
|
|
|
|
|
if let Some(result) = &self.result {
|
|
|
|
|
|
match result {
|
|
|
|
|
|
Ok(mcp_types::CallToolResult { content, .. }) => {
|
|
|
|
|
|
if !content.is_empty() {
|
|
|
|
|
|
for block in content {
|
|
|
|
|
|
let text = Self::render_content_block(block, width as usize);
|
|
|
|
|
|
for segment in text.split('\n') {
|
|
|
|
|
|
let line = Line::from(segment.to_string().dim());
|
|
|
|
|
|
let wrapped = word_wrap_line(
|
|
|
|
|
|
&line,
|
|
|
|
|
|
RtOptions::new((width as usize).saturating_sub(4))
|
|
|
|
|
|
.initial_indent("".into())
|
|
|
|
|
|
.subsequent_indent(" ".into()),
|
|
|
|
|
|
);
|
|
|
|
|
|
detail_lines.extend(wrapped.iter().map(line_to_static));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(err) => {
|
2025-10-17 14:47:50 -07:00
|
|
|
|
let err_text = format_and_truncate_tool_result(
|
|
|
|
|
|
&format!("Error: {err}"),
|
|
|
|
|
|
TOOL_CALL_MAX_LINES,
|
|
|
|
|
|
width as usize,
|
|
|
|
|
|
);
|
|
|
|
|
|
let err_line = Line::from(err_text.dim());
|
2025-09-24 13:36:01 -07:00
|
|
|
|
let wrapped = word_wrap_line(
|
|
|
|
|
|
&err_line,
|
|
|
|
|
|
RtOptions::new((width as usize).saturating_sub(4))
|
|
|
|
|
|
.initial_indent("".into())
|
|
|
|
|
|
.subsequent_indent(" ".into()),
|
|
|
|
|
|
);
|
|
|
|
|
|
detail_lines.extend(wrapped.iter().map(line_to_static));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !detail_lines.is_empty() {
|
|
|
|
|
|
let initial_prefix: Span<'static> = if inline_invocation {
|
|
|
|
|
|
" └ ".dim()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
" ".into()
|
|
|
|
|
|
};
|
|
|
|
|
|
lines.extend(prefix_lines(detail_lines, initial_prefix, " ".into()));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lines
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl WidgetRef for &McpToolCallCell {
|
|
|
|
|
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
|
|
|
|
|
if area.height == 0 {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let lines = self.display_lines(area.width);
|
|
|
|
|
|
let max_rows = area.height as usize;
|
|
|
|
|
|
let rendered = if lines.len() > max_rows {
|
|
|
|
|
|
lines[lines.len() - max_rows..].to_vec()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
lines
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Text::from(rendered).render(area, buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn new_active_mcp_tool_call(
|
|
|
|
|
|
call_id: String,
|
|
|
|
|
|
invocation: McpInvocation,
|
|
|
|
|
|
) -> McpToolCallCell {
|
|
|
|
|
|
McpToolCallCell::new(call_id, invocation)
|
2025-08-14 14:10:05 -04:00
|
|
|
|
}
|
feat: initial import of Rust implementation of Codex CLI in codex-rs/ (#629)
As stated in `codex-rs/README.md`:
Today, Codex CLI is written in TypeScript and requires Node.js 22+ to
run it. For a number of users, this runtime requirement inhibits
adoption: they would be better served by a standalone executable. As
maintainers, we want Codex to run efficiently in a wide range of
environments with minimal overhead. We also want to take advantage of
operating system-specific APIs to provide better sandboxing, where
possible.
To that end, we are moving forward with a Rust implementation of Codex
CLI contained in this folder, which has the following benefits:
- The CLI compiles to small, standalone, platform-specific binaries.
- Can make direct, native calls to
[seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) and
[landlock](https://man7.org/linux/man-pages/man7/landlock.7.html) in
order to support sandboxing on Linux.
- No runtime garbage collection, resulting in lower memory consumption
and better, more predictable performance.
Currently, the Rust implementation is materially behind the TypeScript
implementation in functionality, so continue to use the TypeScript
implmentation for the time being. We will publish native executables via
GitHub Releases as soon as we feel the Rust version is usable.
2025-04-24 13:31:40 -07:00
|
|
|
|
|
2025-08-23 22:58:56 -07:00
|
|
|
|
pub(crate) fn new_web_search_call(query: String) -> PlainHistoryCell {
|
2025-09-02 10:29:58 -07:00
|
|
|
|
let lines: Vec<Line<'static>> = vec![Line::from(vec![padded_emoji("🌐").into(), query.into()])];
|
2025-08-23 22:58:56 -07:00
|
|
|
|
PlainHistoryCell { lines }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 14:10:05 -04:00
|
|
|
|
/// If the first content is an image, return a new cell with the image.
|
|
|
|
|
|
/// TODO(rgwood-dd): Handle images properly even if they're not the first result.
|
|
|
|
|
|
fn try_new_completed_mcp_tool_call_with_image_output(
|
|
|
|
|
|
result: &Result<mcp_types::CallToolResult, String>,
|
|
|
|
|
|
) -> Option<CompletedMcpToolCallWithImageOutput> {
|
|
|
|
|
|
match result {
|
|
|
|
|
|
Ok(mcp_types::CallToolResult { content, .. }) => {
|
|
|
|
|
|
if let Some(mcp_types::ContentBlock::ImageContent(image)) = content.first() {
|
|
|
|
|
|
let raw_data = match base64::engine::general_purpose::STANDARD.decode(&image.data) {
|
|
|
|
|
|
Ok(data) => data,
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
error!("Failed to decode image data: {e}");
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
let reader = match ImageReader::new(Cursor::new(raw_data)).with_guessed_format() {
|
|
|
|
|
|
Ok(reader) => reader,
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
error!("Failed to guess image format: {e}");
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
feat: initial import of Rust implementation of Codex CLI in codex-rs/ (#629)
As stated in `codex-rs/README.md`:
Today, Codex CLI is written in TypeScript and requires Node.js 22+ to
run it. For a number of users, this runtime requirement inhibits
adoption: they would be better served by a standalone executable. As
maintainers, we want Codex to run efficiently in a wide range of
environments with minimal overhead. We also want to take advantage of
operating system-specific APIs to provide better sandboxing, where
possible.
To that end, we are moving forward with a Rust implementation of Codex
CLI contained in this folder, which has the following benefits:
- The CLI compiles to small, standalone, platform-specific binaries.
- Can make direct, native calls to
[seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) and
[landlock](https://man7.org/linux/man-pages/man7/landlock.7.html) in
order to support sandboxing on Linux.
- No runtime garbage collection, resulting in lower memory consumption
and better, more predictable performance.
Currently, the Rust implementation is materially behind the TypeScript
implementation in functionality, so continue to use the TypeScript
implmentation for the time being. We will publish native executables via
GitHub Releases as soon as we feel the Rust version is usable.
2025-04-24 13:31:40 -07:00
|
|
|
|
|
2025-08-14 14:10:05 -04:00
|
|
|
|
let image = match reader.decode() {
|
|
|
|
|
|
Ok(image) => image,
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
error!("Image decoding failed: {e}");
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-05-06 16:12:15 -07:00
|
|
|
|
|
2025-08-14 14:10:05 -04:00
|
|
|
|
Some(CompletedMcpToolCallWithImageOutput { _image: image })
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
2025-05-06 16:12:15 -07:00
|
|
|
|
}
|
2025-08-14 14:10:05 -04:00
|
|
|
|
_ => None,
|
2025-05-06 16:12:15 -07:00
|
|
|
|
}
|
2025-08-14 14:10:05 -04:00
|
|
|
|
}
|
2025-05-06 16:12:15 -07:00
|
|
|
|
|
2025-09-21 10:20:49 -07:00
|
|
|
|
#[allow(clippy::disallowed_methods)]
|
|
|
|
|
|
pub(crate) fn new_warning_event(message: String) -> PlainHistoryCell {
|
|
|
|
|
|
PlainHistoryCell {
|
|
|
|
|
|
lines: vec![vec![format!("⚠ {message}").yellow()].into()],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-19 09:00:31 -07:00
|
|
|
|
/// Render a summary of configured MCP servers from the current `Config`.
|
|
|
|
|
|
pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
|
|
|
|
|
|
let lines: Vec<Line<'static>> = vec![
|
2025-09-02 16:19:54 -07:00
|
|
|
|
"/mcp".magenta().into(),
|
|
|
|
|
|
"".into(),
|
|
|
|
|
|
vec!["🔌 ".into(), "MCP Tools".bold()].into(),
|
|
|
|
|
|
"".into(),
|
|
|
|
|
|
" • No MCP servers configured.".italic().into(),
|
2025-08-20 14:58:04 -07:00
|
|
|
|
Line::from(vec![
|
|
|
|
|
|
" See the ".into(),
|
2025-09-03 00:33:50 -06:00
|
|
|
|
"\u{1b}]8;;https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(),
|
2025-08-20 14:58:04 -07:00
|
|
|
|
" to configure them.".into(),
|
|
|
|
|
|
])
|
|
|
|
|
|
.style(Style::default().add_modifier(Modifier::DIM)),
|
2025-08-19 09:00:31 -07:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
PlainHistoryCell { lines }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Render MCP tools grouped by connection using the fully-qualified tool names.
|
|
|
|
|
|
pub(crate) fn new_mcp_tools_output(
|
|
|
|
|
|
config: &Config,
|
2025-10-08 14:37:57 -07:00
|
|
|
|
tools: HashMap<String, mcp_types::Tool>,
|
2025-10-16 22:05:15 -07:00
|
|
|
|
resources: HashMap<String, Vec<Resource>>,
|
|
|
|
|
|
resource_templates: HashMap<String, Vec<ResourceTemplate>>,
|
2025-10-08 14:37:57 -07:00
|
|
|
|
auth_statuses: &HashMap<String, McpAuthStatus>,
|
2025-08-19 09:00:31 -07:00
|
|
|
|
) -> PlainHistoryCell {
|
|
|
|
|
|
let mut lines: Vec<Line<'static>> = vec![
|
2025-09-02 16:19:54 -07:00
|
|
|
|
"/mcp".magenta().into(),
|
|
|
|
|
|
"".into(),
|
|
|
|
|
|
vec!["🔌 ".into(), "MCP Tools".bold()].into(),
|
|
|
|
|
|
"".into(),
|
2025-08-19 09:00:31 -07:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
if tools.is_empty() {
|
2025-09-02 16:19:54 -07:00
|
|
|
|
lines.push(" • No MCP tools available.".italic().into());
|
|
|
|
|
|
lines.push("".into());
|
2025-08-19 09:00:31 -07:00
|
|
|
|
return PlainHistoryCell { lines };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (server, cfg) in config.mcp_servers.iter() {
|
2025-10-19 17:41:55 -07:00
|
|
|
|
let prefix = format!("mcp__{server}__");
|
2025-08-19 09:00:31 -07:00
|
|
|
|
let mut names: Vec<String> = tools
|
|
|
|
|
|
.keys()
|
|
|
|
|
|
.filter(|k| k.starts_with(&prefix))
|
|
|
|
|
|
.map(|k| k[prefix.len()..].to_string())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
names.sort();
|
|
|
|
|
|
|
2025-10-20 15:35:36 -07:00
|
|
|
|
let auth_status = auth_statuses
|
2025-10-08 14:37:57 -07:00
|
|
|
|
.get(server.as_str())
|
|
|
|
|
|
.copied()
|
|
|
|
|
|
.unwrap_or(McpAuthStatus::Unsupported);
|
2025-10-20 15:35:36 -07:00
|
|
|
|
let mut header: Vec<Span<'static>> = vec![" • ".into(), server.clone().into()];
|
|
|
|
|
|
if !cfg.enabled {
|
|
|
|
|
|
header.push(" ".into());
|
|
|
|
|
|
header.push("(disabled)".red());
|
|
|
|
|
|
lines.push(header.into());
|
|
|
|
|
|
lines.push(Line::from(""));
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
lines.push(header.into());
|
|
|
|
|
|
lines.push(vec![" • Status: ".into(), "enabled".green()].into());
|
|
|
|
|
|
lines.push(vec![" • Auth: ".into(), auth_status.to_string().into()].into());
|
2025-08-19 09:00:31 -07:00
|
|
|
|
|
2025-09-26 18:24:01 -07:00
|
|
|
|
match &cfg.transport {
|
2025-10-16 21:24:43 -07:00
|
|
|
|
McpServerTransportConfig::Stdio {
|
|
|
|
|
|
command,
|
|
|
|
|
|
args,
|
|
|
|
|
|
env,
|
|
|
|
|
|
env_vars,
|
|
|
|
|
|
cwd,
|
|
|
|
|
|
} => {
|
2025-09-26 18:24:01 -07:00
|
|
|
|
let args_suffix = if args.is_empty() {
|
|
|
|
|
|
String::new()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
format!(" {}", args.join(" "))
|
|
|
|
|
|
};
|
|
|
|
|
|
let cmd_display = format!("{command}{args_suffix}");
|
|
|
|
|
|
lines.push(vec![" • Command: ".into(), cmd_display.into()].into());
|
2025-09-30 03:10:33 -07:00
|
|
|
|
|
2025-10-16 21:24:43 -07:00
|
|
|
|
if let Some(cwd) = cwd.as_ref() {
|
|
|
|
|
|
lines.push(vec![" • Cwd: ".into(), cwd.display().to_string().into()].into());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let env_display = format_env_display(env.as_ref(), env_vars);
|
|
|
|
|
|
if env_display != "-" {
|
|
|
|
|
|
lines.push(vec![" • Env: ".into(), env_display.into()].into());
|
2025-09-30 03:10:33 -07:00
|
|
|
|
}
|
2025-09-26 18:24:01 -07:00
|
|
|
|
}
|
2025-10-16 20:15:47 -07:00
|
|
|
|
McpServerTransportConfig::StreamableHttp {
|
|
|
|
|
|
url,
|
|
|
|
|
|
http_headers,
|
|
|
|
|
|
env_http_headers,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
2025-09-26 18:24:01 -07:00
|
|
|
|
lines.push(vec![" • URL: ".into(), url.clone().into()].into());
|
2025-10-16 20:15:47 -07:00
|
|
|
|
if let Some(headers) = http_headers.as_ref()
|
|
|
|
|
|
&& !headers.is_empty()
|
|
|
|
|
|
{
|
|
|
|
|
|
let mut pairs: Vec<_> = headers.iter().collect();
|
|
|
|
|
|
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
|
|
|
|
let display = pairs
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|(name, value)| format!("{name}={value}"))
|
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
|
.join(", ");
|
|
|
|
|
|
lines.push(vec![" • HTTP headers: ".into(), display.into()].into());
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(headers) = env_http_headers.as_ref()
|
|
|
|
|
|
&& !headers.is_empty()
|
|
|
|
|
|
{
|
|
|
|
|
|
let mut pairs: Vec<_> = headers.iter().collect();
|
|
|
|
|
|
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
|
|
|
|
let display = pairs
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|(name, env_var)| format!("{name}={env_var}"))
|
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
|
.join(", ");
|
|
|
|
|
|
lines.push(vec![" • Env HTTP headers: ".into(), display.into()].into());
|
|
|
|
|
|
}
|
2025-09-26 18:24:01 -07:00
|
|
|
|
}
|
2025-08-19 09:00:31 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-16 22:05:15 -07:00
|
|
|
|
if names.is_empty() {
|
2025-09-02 16:19:54 -07:00
|
|
|
|
lines.push(" • Tools: (none)".into());
|
2025-08-19 09:00:31 -07:00
|
|
|
|
} else {
|
2025-09-02 16:19:54 -07:00
|
|
|
|
lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into());
|
2025-08-19 09:00:31 -07:00
|
|
|
|
}
|
2025-10-16 22:05:15 -07:00
|
|
|
|
|
|
|
|
|
|
let server_resources: Vec<Resource> =
|
|
|
|
|
|
resources.get(server.as_str()).cloned().unwrap_or_default();
|
|
|
|
|
|
if server_resources.is_empty() {
|
|
|
|
|
|
lines.push(" • Resources: (none)".into());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let mut spans: Vec<Span<'static>> = vec![" • Resources: ".into()];
|
|
|
|
|
|
|
|
|
|
|
|
for (idx, resource) in server_resources.iter().enumerate() {
|
|
|
|
|
|
if idx > 0 {
|
|
|
|
|
|
spans.push(", ".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let label = resource.title.as_ref().unwrap_or(&resource.name);
|
|
|
|
|
|
spans.push(label.clone().into());
|
|
|
|
|
|
spans.push(" ".into());
|
|
|
|
|
|
spans.push(format!("({})", resource.uri).dim());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lines.push(spans.into());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let server_templates: Vec<ResourceTemplate> = resource_templates
|
|
|
|
|
|
.get(server.as_str())
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
if server_templates.is_empty() {
|
|
|
|
|
|
lines.push(" • Resource templates: (none)".into());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let mut spans: Vec<Span<'static>> = vec![" • Resource templates: ".into()];
|
|
|
|
|
|
|
|
|
|
|
|
for (idx, template) in server_templates.iter().enumerate() {
|
|
|
|
|
|
if idx > 0 {
|
|
|
|
|
|
spans.push(", ".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let label = template.title.as_ref().unwrap_or(&template.name);
|
|
|
|
|
|
spans.push(label.clone().into());
|
|
|
|
|
|
spans.push(" ".into());
|
|
|
|
|
|
spans.push(format!("({})", template.uri_template).dim());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lines.push(spans.into());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-19 09:00:31 -07:00
|
|
|
|
lines.push(Line::from(""));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
PlainHistoryCell { lines }
|
|
|
|
|
|
}
|
2025-09-12 14:09:31 -07:00
|
|
|
|
pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHistoryCell {
|
2025-09-29 15:46:47 -07:00
|
|
|
|
let mut line = vec!["• ".dim(), message.into()];
|
2025-09-12 14:09:31 -07:00
|
|
|
|
if let Some(hint) = hint {
|
|
|
|
|
|
line.push(" ".into());
|
|
|
|
|
|
line.push(hint.dark_gray());
|
|
|
|
|
|
}
|
|
|
|
|
|
let lines: Vec<Line<'static>> = vec![line.into()];
|
2025-09-11 15:04:29 -07:00
|
|
|
|
PlainHistoryCell { lines }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 14:10:05 -04:00
|
|
|
|
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
|
2025-08-25 22:49:19 -07:00
|
|
|
|
// Use a hair space (U+200A) to create a subtle, near-invisible separation
|
|
|
|
|
|
// before the text. VS16 is intentionally omitted to keep spacing tighter
|
|
|
|
|
|
// in terminals like Ghostty.
|
2025-09-12 16:17:02 -04:00
|
|
|
|
let lines: Vec<Line<'static>> = vec![vec![format!("■ {message}").red()].into()];
|
2025-08-14 14:10:05 -04:00
|
|
|
|
PlainHistoryCell { lines }
|
|
|
|
|
|
}
|
2025-08-07 00:01:38 -07:00
|
|
|
|
|
2025-08-14 14:10:05 -04:00
|
|
|
|
/// Render a user‑friendly plan update styled like a checkbox todo list.
|
2025-09-02 10:29:58 -07:00
|
|
|
|
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
|
2025-08-14 14:10:05 -04:00
|
|
|
|
let UpdatePlanArgs { explanation, plan } = update;
|
2025-09-02 10:29:58 -07:00
|
|
|
|
PlanUpdateCell { explanation, plan }
|
|
|
|
|
|
}
|
2025-08-14 14:10:05 -04:00
|
|
|
|
|
2025-09-02 10:29:58 -07:00
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub(crate) struct PlanUpdateCell {
|
|
|
|
|
|
explanation: Option<String>,
|
|
|
|
|
|
plan: Vec<PlanItemArg>,
|
|
|
|
|
|
}
|
2025-07-31 13:45:52 -07:00
|
|
|
|
|
2025-09-02 10:29:58 -07:00
|
|
|
|
impl HistoryCell for PlanUpdateCell {
|
|
|
|
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
|
|
|
|
let render_note = |text: &str| -> Vec<Line<'static>> {
|
|
|
|
|
|
let wrap_width = width.saturating_sub(4).max(1) as usize;
|
|
|
|
|
|
textwrap::wrap(text, wrap_width)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|s| s.to_string().dim().italic().into())
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let render_step = |status: &StepStatus, text: &str| -> Vec<Line<'static>> {
|
|
|
|
|
|
let (box_str, step_style) = match status {
|
|
|
|
|
|
StepStatus::Completed => ("✔ ", Style::default().crossed_out().dim()),
|
|
|
|
|
|
StepStatus::InProgress => ("□ ", Style::default().cyan().bold()),
|
|
|
|
|
|
StepStatus::Pending => ("□ ", Style::default().dim()),
|
2025-08-14 14:10:05 -04:00
|
|
|
|
};
|
2025-09-02 10:29:58 -07:00
|
|
|
|
let wrap_width = (width as usize)
|
|
|
|
|
|
.saturating_sub(4)
|
|
|
|
|
|
.saturating_sub(box_str.width())
|
|
|
|
|
|
.max(1);
|
|
|
|
|
|
let parts = textwrap::wrap(text, wrap_width);
|
|
|
|
|
|
let step_text = parts
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|s| s.to_string().set_style(step_style).into())
|
|
|
|
|
|
.collect();
|
2025-09-04 16:54:53 -07:00
|
|
|
|
prefix_lines(step_text, box_str.into(), " ".into())
|
2025-09-02 10:29:58 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let mut lines: Vec<Line<'static>> = vec![];
|
2025-09-29 15:46:47 -07:00
|
|
|
|
lines.push(vec!["• ".dim(), "Updated Plan".bold()].into());
|
2025-09-02 10:29:58 -07:00
|
|
|
|
|
|
|
|
|
|
let mut indented_lines = vec![];
|
|
|
|
|
|
let note = self
|
|
|
|
|
|
.explanation
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.map(|s| s.trim())
|
|
|
|
|
|
.filter(|t| !t.is_empty());
|
|
|
|
|
|
if let Some(expl) = note {
|
|
|
|
|
|
indented_lines.extend(render_note(expl));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if self.plan.is_empty() {
|
|
|
|
|
|
indented_lines.push(Line::from("(no steps provided)".dim().italic()));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
for PlanItemArg { step, status } in self.plan.iter() {
|
|
|
|
|
|
indented_lines.extend(render_step(status, step));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-02 18:17:34 -07:00
|
|
|
|
lines.extend(prefix_lines(indented_lines, " └ ".dim(), " ".into()));
|
2025-09-02 10:29:58 -07:00
|
|
|
|
|
|
|
|
|
|
lines
|
|
|
|
|
|
}
|
2025-08-14 14:10:05 -04:00
|
|
|
|
}
|
feat: initial import of Rust implementation of Codex CLI in codex-rs/ (#629)
As stated in `codex-rs/README.md`:
Today, Codex CLI is written in TypeScript and requires Node.js 22+ to
run it. For a number of users, this runtime requirement inhibits
adoption: they would be better served by a standalone executable. As
maintainers, we want Codex to run efficiently in a wide range of
environments with minimal overhead. We also want to take advantage of
operating system-specific APIs to provide better sandboxing, where
possible.
To that end, we are moving forward with a Rust implementation of Codex
CLI contained in this folder, which has the following benefits:
- The CLI compiles to small, standalone, platform-specific binaries.
- Can make direct, native calls to
[seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) and
[landlock](https://man7.org/linux/man-pages/man7/landlock.7.html) in
order to support sandboxing on Linux.
- No runtime garbage collection, resulting in lower memory consumption
and better, more predictable performance.
Currently, the Rust implementation is materially behind the TypeScript
implementation in functionality, so continue to use the TypeScript
implmentation for the time being. We will publish native executables via
GitHub Releases as soon as we feel the Rust version is usable.
2025-04-24 13:31:40 -07:00
|
|
|
|
|
2025-08-14 14:10:05 -04:00
|
|
|
|
/// Create a new `PendingPatch` cell that lists the file‑level summary of
|
|
|
|
|
|
/// a proposed patch. The summary lines should already be formatted (e.g.
|
|
|
|
|
|
/// "A path/to/file.rs").
|
|
|
|
|
|
pub(crate) fn new_patch_event(
|
|
|
|
|
|
changes: HashMap<PathBuf, FileChange>,
|
2025-09-02 10:29:58 -07:00
|
|
|
|
cwd: &Path,
|
|
|
|
|
|
) -> PatchHistoryCell {
|
|
|
|
|
|
PatchHistoryCell {
|
|
|
|
|
|
changes,
|
|
|
|
|
|
cwd: cwd.to_path_buf(),
|
|
|
|
|
|
}
|
2025-08-14 14:10:05 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
|
|
|
|
|
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
|
|
|
|
|
|
|
|
|
|
|
// Failure title
|
|
|
|
|
|
lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));
|
|
|
|
|
|
|
|
|
|
|
|
if !stderr.trim().is_empty() {
|
2025-10-14 18:15:47 +01:00
|
|
|
|
let output = output_lines(
|
2025-08-14 14:10:05 -04:00
|
|
|
|
Some(&CommandOutput {
|
|
|
|
|
|
exit_code: 1,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr,
|
2025-08-22 16:32:31 -07:00
|
|
|
|
formatted_output: String::new(),
|
2025-08-14 14:10:05 -04:00
|
|
|
|
}),
|
2025-09-14 13:07:38 -07:00
|
|
|
|
OutputLinesParams {
|
|
|
|
|
|
only_err: true,
|
|
|
|
|
|
include_angle_pipe: true,
|
|
|
|
|
|
include_prefix: true,
|
|
|
|
|
|
},
|
2025-10-14 18:15:47 +01:00
|
|
|
|
);
|
|
|
|
|
|
lines.extend(output.lines);
|
2025-08-05 22:44:27 -07:00
|
|
|
|
}
|
2025-08-12 17:37:28 -07:00
|
|
|
|
|
2025-08-14 14:10:05 -04:00
|
|
|
|
PlainHistoryCell { lines }
|
2025-08-06 12:03:45 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-02 11:36:03 -07:00
|
|
|
|
pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistoryCell {
|
|
|
|
|
|
let display_path = display_path_for(&path, cwd);
|
|
|
|
|
|
|
|
|
|
|
|
let lines: Vec<Line<'static>> = vec![
|
|
|
|
|
|
vec!["• ".dim(), "Viewed Image".bold()].into(),
|
|
|
|
|
|
vec![" └ ".dim(), display_path.dim()].into(),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
PlainHistoryCell { lines }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-02 18:47:14 -07:00
|
|
|
|
pub(crate) fn new_reasoning_summary_block(
|
|
|
|
|
|
full_reasoning_buffer: String,
|
|
|
|
|
|
config: &Config,
|
2025-09-16 16:42:43 -07:00
|
|
|
|
) -> Box<dyn HistoryCell> {
|
2025-09-04 11:00:01 -07:00
|
|
|
|
if config.model_family.reasoning_summary_format == ReasoningSummaryFormat::Experimental {
|
2025-09-02 18:47:14 -07:00
|
|
|
|
// Experimental format is following:
|
|
|
|
|
|
// ** header **
|
|
|
|
|
|
//
|
|
|
|
|
|
// reasoning summary
|
|
|
|
|
|
//
|
|
|
|
|
|
// So we need to strip header from reasoning summary
|
2025-09-04 09:45:14 -07:00
|
|
|
|
let full_reasoning_buffer = full_reasoning_buffer.trim();
|
2025-09-02 18:47:14 -07:00
|
|
|
|
if let Some(open) = full_reasoning_buffer.find("**") {
|
|
|
|
|
|
let after_open = &full_reasoning_buffer[(open + 2)..];
|
|
|
|
|
|
if let Some(close) = after_open.find("**") {
|
|
|
|
|
|
let after_close_idx = open + 2 + close + 2;
|
2025-09-04 09:45:14 -07:00
|
|
|
|
// if we don't have anything beyond `after_close_idx`
|
|
|
|
|
|
// then we don't have a summary to inject into history
|
|
|
|
|
|
if after_close_idx < full_reasoning_buffer.len() {
|
|
|
|
|
|
let header_buffer = full_reasoning_buffer[..after_close_idx].to_string();
|
2025-09-16 16:42:43 -07:00
|
|
|
|
let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string();
|
2025-09-30 16:13:55 -07:00
|
|
|
|
return Box::new(ReasoningSummaryCell::new(
|
|
|
|
|
|
header_buffer,
|
|
|
|
|
|
summary_buffer,
|
2025-10-07 16:18:48 -07:00
|
|
|
|
false,
|
2025-09-30 16:13:55 -07:00
|
|
|
|
));
|
2025-09-04 09:45:14 -07:00
|
|
|
|
}
|
2025-09-02 18:47:14 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-07 16:18:48 -07:00
|
|
|
|
Box::new(ReasoningSummaryCell::new(
|
|
|
|
|
|
"".to_string(),
|
|
|
|
|
|
full_reasoning_buffer,
|
|
|
|
|
|
true,
|
|
|
|
|
|
))
|
2025-09-02 18:47:14 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-26 22:49:59 -07:00
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub struct FinalMessageSeparator {
|
|
|
|
|
|
elapsed_seconds: Option<u64>,
|
|
|
|
|
|
}
|
|
|
|
|
|
impl FinalMessageSeparator {
|
|
|
|
|
|
pub(crate) fn new(elapsed_seconds: Option<u64>) -> Self {
|
|
|
|
|
|
Self { elapsed_seconds }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
impl HistoryCell for FinalMessageSeparator {
|
|
|
|
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
|
|
|
|
let elapsed_seconds = self
|
|
|
|
|
|
.elapsed_seconds
|
|
|
|
|
|
.map(super::status_indicator_widget::fmt_elapsed_compact);
|
|
|
|
|
|
if let Some(elapsed_seconds) = elapsed_seconds {
|
|
|
|
|
|
let worked_for = format!("─ Worked for {elapsed_seconds} ─");
|
|
|
|
|
|
let worked_for_width = worked_for.width();
|
|
|
|
|
|
vec![
|
|
|
|
|
|
Line::from_iter([
|
|
|
|
|
|
worked_for,
|
|
|
|
|
|
"─".repeat((width as usize).saturating_sub(worked_for_width)),
|
|
|
|
|
|
])
|
|
|
|
|
|
.dim(),
|
|
|
|
|
|
]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
vec![Line::from_iter(["─".repeat(width as usize).dim()])]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-30 10:05:40 -07:00
|
|
|
|
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
|
|
|
|
|
|
let args_str = invocation
|
|
|
|
|
|
.arguments
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.map(|v| {
|
|
|
|
|
|
// Use compact form to keep things short but readable.
|
|
|
|
|
|
serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
|
|
let invocation_spans = vec![
|
2025-09-02 16:19:54 -07:00
|
|
|
|
invocation.server.clone().cyan(),
|
|
|
|
|
|
".".into(),
|
2025-09-11 11:59:37 -07:00
|
|
|
|
invocation.tool.cyan(),
|
2025-09-02 16:19:54 -07:00
|
|
|
|
"(".into(),
|
|
|
|
|
|
args_str.dim(),
|
|
|
|
|
|
")".into(),
|
2025-07-30 10:05:40 -07:00
|
|
|
|
];
|
2025-09-02 16:19:54 -07:00
|
|
|
|
invocation_spans.into()
|
fix: introduce ResponseInputItem::McpToolCallOutput variant (#1151)
The output of an MCP server tool call can be one of several types, but
to date, we treated all outputs as text by showing the serialized JSON
as the "tool output" in Codex:
https://github.com/openai/codex/blob/25a9949c49194d5a64de54a11bcc5b4724ac9bd5/codex-rs/mcp-types/src/lib.rs#L96-L101
This PR adds support for the `ImageContent` variant so we can now
display an image output from an MCP tool call.
In making this change, we introduce a new
`ResponseInputItem::McpToolCallOutput` variant so that we can work with
the `mcp_types::CallToolResult` directly when the function call is made
to an MCP server.
Though arguably the more significant change is the introduction of
`HistoryCell::CompletedMcpToolCallWithImageOutput`, which is a cell that
uses `ratatui_image` to render an image into the terminal. To support
this, we introduce `ImageRenderCache`, cache a
`ratatui_image::picker::Picker`, and `ensure_image_cache()` to cache the
appropriate scaled image data and dimensions based on the current
terminal size.
To test, I created a minimal `package.json`:
```json
{
"name": "kitty-mcp",
"version": "1.0.0",
"type": "module",
"description": "MCP that returns image of kitty",
"main": "index.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0"
}
}
```
with the following `index.js` to define the MCP server:
```js
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const IMAGE_URI = "image://Ada.png";
const server = new McpServer({
name: "Demo",
version: "1.0.0",
});
server.tool(
"get-cat-image",
"If you need a cat image, this tool will provide one.",
async () => ({
content: [
{ type: "image", data: await getAdaPngBase64(), mimeType: "image/png" },
],
})
);
server.resource("Ada the Cat", IMAGE_URI, async (uri) => {
const base64Image = await getAdaPngBase64();
return {
contents: [
{
uri: uri.href,
mimeType: "image/png",
blob: base64Image,
},
],
};
});
async function getAdaPngBase64() {
const __dirname = new URL(".", import.meta.url).pathname;
// From https://github.com/benjajaja/ratatui-image/blob/9705ce2c59ec669abbce2924cbfd1f5ae22c9860/assets/Ada.png
const filePath = join(__dirname, "Ada.png");
const imageData = await readFile(filePath);
const base64Image = imageData.toString("base64");
return base64Image;
}
const transport = new StdioServerTransport();
await server.connect(transport);
```
With the local changes from this PR, I added the following to my
`config.toml`:
```toml
[mcp_servers.kitty]
command = "node"
args = ["/Users/mbolin/code/kitty-mcp/index.js"]
```
Running the TUI from source:
```
cargo run --bin codex -- --model o3 'I need a picture of a cat'
```
I get:
<img width="732" alt="image"
src="https://github.com/user-attachments/assets/bf80b721-9ca0-4d81-aec7-77d6899e2869"
/>
Now, that said, I have only tested in iTerm and there is definitely some
funny business with getting an accurate character-to-pixel ratio
(sometimes the `CompletedMcpToolCallWithImageOutput` thinks it needs 10
rows to render instead of 4), so there is still work to be done here.
2025-05-28 19:03:17 -07:00
|
|
|
|
}
|
2025-08-11 11:26:15 -07:00
|
|
|
|
|
2025-08-11 16:11:46 -07:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
2025-09-26 07:13:44 -07:00
|
|
|
|
use crate::exec_cell::CommandOutput;
|
|
|
|
|
|
use crate::exec_cell::ExecCall;
|
|
|
|
|
|
use crate::exec_cell::ExecCell;
|
2025-09-04 09:45:14 -07:00
|
|
|
|
use codex_core::config::Config;
|
|
|
|
|
|
use codex_core::config::ConfigOverrides;
|
|
|
|
|
|
use codex_core::config::ConfigToml;
|
2025-09-26 07:13:44 -07:00
|
|
|
|
use codex_protocol::parse_command::ParsedCommand;
|
2025-09-15 06:44:40 -07:00
|
|
|
|
use dirs::home_dir;
|
2025-09-16 16:42:43 -07:00
|
|
|
|
use pretty_assertions::assert_eq;
|
2025-09-24 13:36:01 -07:00
|
|
|
|
use serde_json::json;
|
|
|
|
|
|
|
|
|
|
|
|
use mcp_types::CallToolResult;
|
|
|
|
|
|
use mcp_types::ContentBlock;
|
|
|
|
|
|
use mcp_types::TextContent;
|
2025-09-04 09:45:14 -07:00
|
|
|
|
|
|
|
|
|
|
fn test_config() -> Config {
|
|
|
|
|
|
Config::load_from_base_config_with_overrides(
|
|
|
|
|
|
ConfigToml::default(),
|
|
|
|
|
|
ConfigOverrides::default(),
|
|
|
|
|
|
std::env::temp_dir(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("config")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
|
|
|
|
|
|
lines
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|line| {
|
|
|
|
|
|
line.spans
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|span| span.content.as_ref())
|
|
|
|
|
|
.collect::<String>()
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn render_transcript(cell: &dyn HistoryCell) -> Vec<String> {
|
2025-10-07 16:18:48 -07:00
|
|
|
|
render_lines(&cell.transcript_lines(u16::MAX))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn empty_agent_message_cell_transcript() {
|
|
|
|
|
|
let cell = AgentMessageCell::new(vec![Line::default()], false);
|
|
|
|
|
|
assert_eq!(cell.transcript_lines(80), vec![Line::from(" ")]);
|
|
|
|
|
|
assert_eq!(cell.desired_transcript_height(80), 1);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
}
|
2025-08-11 16:11:46 -07:00
|
|
|
|
|
2025-10-09 10:37:13 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn prefixed_wrapped_history_cell_indents_wrapped_lines() {
|
|
|
|
|
|
let summary = Line::from(vec![
|
|
|
|
|
|
"You ".into(),
|
|
|
|
|
|
"approved".bold(),
|
|
|
|
|
|
" codex to run ".into(),
|
|
|
|
|
|
"echo something really long to ensure wrapping happens".dim(),
|
|
|
|
|
|
" this time".bold(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
let cell = PrefixedWrappedHistoryCell::new(summary, "✔ ".green(), " ");
|
|
|
|
|
|
let rendered = render_lines(&cell.display_lines(24));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
rendered,
|
|
|
|
|
|
vec![
|
|
|
|
|
|
"✔ You approved codex".to_string(),
|
|
|
|
|
|
" to run echo something".to_string(),
|
|
|
|
|
|
" really long to ensure".to_string(),
|
|
|
|
|
|
" wrapping happens this".to_string(),
|
|
|
|
|
|
" time".to_string(),
|
|
|
|
|
|
]
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-24 13:36:01 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn active_mcp_tool_call_snapshot() {
|
|
|
|
|
|
let invocation = McpInvocation {
|
|
|
|
|
|
server: "search".into(),
|
|
|
|
|
|
tool: "find_docs".into(),
|
|
|
|
|
|
arguments: Some(json!({
|
|
|
|
|
|
"query": "ratatui styling",
|
|
|
|
|
|
"limit": 3,
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let cell = new_active_mcp_tool_call("call-1".into(), invocation);
|
|
|
|
|
|
let rendered = render_lines(&cell.display_lines(80)).join("\n");
|
|
|
|
|
|
|
|
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn completed_mcp_tool_call_success_snapshot() {
|
|
|
|
|
|
let invocation = McpInvocation {
|
|
|
|
|
|
server: "search".into(),
|
|
|
|
|
|
tool: "find_docs".into(),
|
|
|
|
|
|
arguments: Some(json!({
|
|
|
|
|
|
"query": "ratatui styling",
|
|
|
|
|
|
"limit": 3,
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let result = CallToolResult {
|
|
|
|
|
|
content: vec![ContentBlock::TextContent(TextContent {
|
|
|
|
|
|
annotations: None,
|
|
|
|
|
|
text: "Found styling guidance in styles.md".into(),
|
|
|
|
|
|
r#type: "text".into(),
|
|
|
|
|
|
})],
|
|
|
|
|
|
is_error: None,
|
|
|
|
|
|
structured_content: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let mut cell = new_active_mcp_tool_call("call-2".into(), invocation);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
cell.complete(Duration::from_millis(1420), Ok(result))
|
|
|
|
|
|
.is_none()
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let rendered = render_lines(&cell.display_lines(80)).join("\n");
|
|
|
|
|
|
|
|
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn completed_mcp_tool_call_error_snapshot() {
|
|
|
|
|
|
let invocation = McpInvocation {
|
|
|
|
|
|
server: "search".into(),
|
|
|
|
|
|
tool: "find_docs".into(),
|
|
|
|
|
|
arguments: Some(json!({
|
|
|
|
|
|
"query": "ratatui styling",
|
|
|
|
|
|
"limit": 3,
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let mut cell = new_active_mcp_tool_call("call-3".into(), invocation);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
cell.complete(Duration::from_secs(2), Err("network timeout".into()))
|
|
|
|
|
|
.is_none()
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let rendered = render_lines(&cell.display_lines(80)).join("\n");
|
|
|
|
|
|
|
|
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn completed_mcp_tool_call_multiple_outputs_snapshot() {
|
|
|
|
|
|
let invocation = McpInvocation {
|
|
|
|
|
|
server: "search".into(),
|
|
|
|
|
|
tool: "find_docs".into(),
|
|
|
|
|
|
arguments: Some(json!({
|
|
|
|
|
|
"query": "ratatui styling",
|
|
|
|
|
|
"limit": 3,
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let result = CallToolResult {
|
|
|
|
|
|
content: vec![
|
|
|
|
|
|
ContentBlock::TextContent(TextContent {
|
|
|
|
|
|
annotations: None,
|
|
|
|
|
|
text: "Found styling guidance in styles.md and additional notes in CONTRIBUTING.md.".into(),
|
|
|
|
|
|
r#type: "text".into(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
ContentBlock::ResourceLink(ResourceLink {
|
|
|
|
|
|
annotations: None,
|
|
|
|
|
|
description: Some("Link to styles documentation".into()),
|
|
|
|
|
|
mime_type: None,
|
|
|
|
|
|
name: "styles.md".into(),
|
|
|
|
|
|
size: None,
|
|
|
|
|
|
title: Some("Styles".into()),
|
|
|
|
|
|
r#type: "resource_link".into(),
|
|
|
|
|
|
uri: "file:///docs/styles.md".into(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
],
|
|
|
|
|
|
is_error: None,
|
|
|
|
|
|
structured_content: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let mut cell = new_active_mcp_tool_call("call-4".into(), invocation);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
cell.complete(Duration::from_millis(640), Ok(result))
|
|
|
|
|
|
.is_none()
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let rendered = render_lines(&cell.display_lines(48)).join("\n");
|
|
|
|
|
|
|
|
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn completed_mcp_tool_call_wrapped_outputs_snapshot() {
|
|
|
|
|
|
let invocation = McpInvocation {
|
|
|
|
|
|
server: "metrics".into(),
|
|
|
|
|
|
tool: "get_nearby_metric".into(),
|
|
|
|
|
|
arguments: Some(json!({
|
|
|
|
|
|
"query": "very_long_query_that_needs_wrapping_to_display_properly_in_the_history",
|
|
|
|
|
|
"limit": 1,
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let result = CallToolResult {
|
|
|
|
|
|
content: vec![ContentBlock::TextContent(TextContent {
|
|
|
|
|
|
annotations: None,
|
|
|
|
|
|
text: "Line one of the response, which is quite long and needs wrapping.\nLine two continues the response with more detail.".into(),
|
|
|
|
|
|
r#type: "text".into(),
|
|
|
|
|
|
})],
|
|
|
|
|
|
is_error: None,
|
|
|
|
|
|
structured_content: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let mut cell = new_active_mcp_tool_call("call-5".into(), invocation);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
cell.complete(Duration::from_millis(1280), Ok(result))
|
|
|
|
|
|
.is_none()
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let rendered = render_lines(&cell.display_lines(40)).join("\n");
|
|
|
|
|
|
|
|
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn completed_mcp_tool_call_multiple_outputs_inline_snapshot() {
|
|
|
|
|
|
let invocation = McpInvocation {
|
|
|
|
|
|
server: "metrics".into(),
|
|
|
|
|
|
tool: "summary".into(),
|
|
|
|
|
|
arguments: Some(json!({
|
|
|
|
|
|
"metric": "trace.latency",
|
|
|
|
|
|
"window": "15m",
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let result = CallToolResult {
|
|
|
|
|
|
content: vec![
|
|
|
|
|
|
ContentBlock::TextContent(TextContent {
|
|
|
|
|
|
annotations: None,
|
|
|
|
|
|
text: "Latency summary: p50=120ms, p95=480ms.".into(),
|
|
|
|
|
|
r#type: "text".into(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
ContentBlock::TextContent(TextContent {
|
|
|
|
|
|
annotations: None,
|
|
|
|
|
|
text: "No anomalies detected.".into(),
|
|
|
|
|
|
r#type: "text".into(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
],
|
|
|
|
|
|
is_error: None,
|
|
|
|
|
|
structured_content: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let mut cell = new_active_mcp_tool_call("call-6".into(), invocation);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
cell.complete(Duration::from_millis(320), Ok(result))
|
|
|
|
|
|
.is_none()
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let rendered = render_lines(&cell.display_lines(120)).join("\n");
|
|
|
|
|
|
|
|
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-14 20:59:22 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn session_header_includes_reasoning_level_when_present() {
|
|
|
|
|
|
let cell = SessionHeaderHistoryCell::new(
|
|
|
|
|
|
"gpt-4o".to_string(),
|
|
|
|
|
|
Some(ReasoningEffortConfig::High),
|
|
|
|
|
|
std::env::temp_dir(),
|
|
|
|
|
|
"test",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let lines = render_lines(&cell.display_lines(80));
|
|
|
|
|
|
let model_line = lines
|
|
|
|
|
|
.into_iter()
|
2025-09-15 06:44:40 -07:00
|
|
|
|
.find(|line| line.contains("model:"))
|
2025-09-14 20:59:22 -07:00
|
|
|
|
.expect("model line");
|
|
|
|
|
|
|
2025-09-15 06:44:40 -07:00
|
|
|
|
assert!(model_line.contains("gpt-4o high"));
|
|
|
|
|
|
assert!(model_line.contains("/model to change"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn session_header_directory_center_truncates() {
|
|
|
|
|
|
let mut dir = home_dir().expect("home directory");
|
|
|
|
|
|
for part in ["hello", "the", "fox", "is", "very", "fast"] {
|
|
|
|
|
|
dir.push(part);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let formatted = SessionHeaderHistoryCell::format_directory_inner(&dir, Some(24));
|
|
|
|
|
|
let sep = std::path::MAIN_SEPARATOR;
|
|
|
|
|
|
let expected = format!("~{sep}hello{sep}the{sep}…{sep}very{sep}fast");
|
|
|
|
|
|
assert_eq!(formatted, expected);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn session_header_directory_front_truncates_long_segment() {
|
|
|
|
|
|
let mut dir = home_dir().expect("home directory");
|
|
|
|
|
|
dir.push("supercalifragilisticexpialidocious");
|
|
|
|
|
|
|
|
|
|
|
|
let formatted = SessionHeaderHistoryCell::format_directory_inner(&dir, Some(18));
|
|
|
|
|
|
let sep = std::path::MAIN_SEPARATOR;
|
|
|
|
|
|
let expected = format!("~{sep}…cexpialidocious");
|
|
|
|
|
|
assert_eq!(formatted, expected);
|
2025-09-14 20:59:22 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-11 16:11:46 -07:00
|
|
|
|
#[test]
|
2025-09-02 10:29:58 -07:00
|
|
|
|
fn coalesces_sequential_reads_within_one_call() {
|
|
|
|
|
|
// Build one exec cell with a Search followed by two Reads
|
|
|
|
|
|
let call_id = "c1".to_string();
|
|
|
|
|
|
let mut cell = ExecCell::new(ExecCall {
|
|
|
|
|
|
call_id: call_id.clone(),
|
|
|
|
|
|
command: vec!["bash".into(), "-lc".into(), "echo".into()],
|
|
|
|
|
|
parsed: vec![
|
|
|
|
|
|
ParsedCommand::Search {
|
|
|
|
|
|
query: Some("shimmer_spans".into()),
|
|
|
|
|
|
path: None,
|
|
|
|
|
|
cmd: "rg shimmer_spans".into(),
|
|
|
|
|
|
},
|
|
|
|
|
|
ParsedCommand::Read {
|
|
|
|
|
|
name: "shimmer.rs".into(),
|
|
|
|
|
|
cmd: "cat shimmer.rs".into(),
|
2025-10-16 23:19:54 -07:00
|
|
|
|
path: "shimmer.rs".into(),
|
2025-09-02 10:29:58 -07:00
|
|
|
|
},
|
|
|
|
|
|
ParsedCommand::Read {
|
|
|
|
|
|
name: "status_indicator_widget.rs".into(),
|
|
|
|
|
|
cmd: "cat status_indicator_widget.rs".into(),
|
2025-10-16 23:19:54 -07:00
|
|
|
|
path: "status_indicator_widget.rs".into(),
|
2025-09-02 10:29:58 -07:00
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
output: None,
|
|
|
|
|
|
start_time: Some(Instant::now()),
|
|
|
|
|
|
duration: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
// Mark call complete so markers are ✓
|
|
|
|
|
|
cell.complete_call(
|
|
|
|
|
|
&call_id,
|
|
|
|
|
|
CommandOutput {
|
|
|
|
|
|
exit_code: 0,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr: String::new(),
|
|
|
|
|
|
formatted_output: String::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
Duration::from_millis(1),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let lines = cell.display_lines(80);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
let rendered = render_lines(&lines).join("\n");
|
2025-09-02 10:29:58 -07:00
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn coalesces_reads_across_multiple_calls() {
|
|
|
|
|
|
let mut cell = ExecCell::new(ExecCall {
|
|
|
|
|
|
call_id: "c1".to_string(),
|
|
|
|
|
|
command: vec!["bash".into(), "-lc".into(), "echo".into()],
|
|
|
|
|
|
parsed: vec![ParsedCommand::Search {
|
|
|
|
|
|
query: Some("shimmer_spans".into()),
|
|
|
|
|
|
path: None,
|
|
|
|
|
|
cmd: "rg shimmer_spans".into(),
|
|
|
|
|
|
}],
|
|
|
|
|
|
output: None,
|
|
|
|
|
|
start_time: Some(Instant::now()),
|
|
|
|
|
|
duration: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
// Call 1: Search only
|
|
|
|
|
|
cell.complete_call(
|
|
|
|
|
|
"c1",
|
|
|
|
|
|
CommandOutput {
|
|
|
|
|
|
exit_code: 0,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr: String::new(),
|
|
|
|
|
|
formatted_output: String::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
Duration::from_millis(1),
|
|
|
|
|
|
);
|
|
|
|
|
|
// Call 2: Read A
|
|
|
|
|
|
cell = cell
|
|
|
|
|
|
.with_added_call(
|
|
|
|
|
|
"c2".into(),
|
|
|
|
|
|
vec!["bash".into(), "-lc".into(), "echo".into()],
|
|
|
|
|
|
vec![ParsedCommand::Read {
|
|
|
|
|
|
name: "shimmer.rs".into(),
|
|
|
|
|
|
cmd: "cat shimmer.rs".into(),
|
2025-10-16 23:19:54 -07:00
|
|
|
|
path: "shimmer.rs".into(),
|
2025-09-02 10:29:58 -07:00
|
|
|
|
}],
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
cell.complete_call(
|
|
|
|
|
|
"c2",
|
|
|
|
|
|
CommandOutput {
|
|
|
|
|
|
exit_code: 0,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr: String::new(),
|
|
|
|
|
|
formatted_output: String::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
Duration::from_millis(1),
|
|
|
|
|
|
);
|
|
|
|
|
|
// Call 3: Read B
|
|
|
|
|
|
cell = cell
|
|
|
|
|
|
.with_added_call(
|
|
|
|
|
|
"c3".into(),
|
|
|
|
|
|
vec!["bash".into(), "-lc".into(), "echo".into()],
|
|
|
|
|
|
vec![ParsedCommand::Read {
|
|
|
|
|
|
name: "status_indicator_widget.rs".into(),
|
|
|
|
|
|
cmd: "cat status_indicator_widget.rs".into(),
|
2025-10-16 23:19:54 -07:00
|
|
|
|
path: "status_indicator_widget.rs".into(),
|
2025-09-02 10:29:58 -07:00
|
|
|
|
}],
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
cell.complete_call(
|
|
|
|
|
|
"c3",
|
|
|
|
|
|
CommandOutput {
|
|
|
|
|
|
exit_code: 0,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr: String::new(),
|
|
|
|
|
|
formatted_output: String::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
Duration::from_millis(1),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let lines = cell.display_lines(80);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
let rendered = render_lines(&lines).join("\n");
|
2025-09-02 10:29:58 -07:00
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn coalesced_reads_dedupe_names() {
|
|
|
|
|
|
let mut cell = ExecCell::new(ExecCall {
|
|
|
|
|
|
call_id: "c1".to_string(),
|
|
|
|
|
|
command: vec!["bash".into(), "-lc".into(), "echo".into()],
|
|
|
|
|
|
parsed: vec![
|
|
|
|
|
|
ParsedCommand::Read {
|
|
|
|
|
|
name: "auth.rs".into(),
|
|
|
|
|
|
cmd: "cat auth.rs".into(),
|
2025-10-16 23:19:54 -07:00
|
|
|
|
path: "auth.rs".into(),
|
2025-09-02 10:29:58 -07:00
|
|
|
|
},
|
|
|
|
|
|
ParsedCommand::Read {
|
|
|
|
|
|
name: "auth.rs".into(),
|
|
|
|
|
|
cmd: "cat auth.rs".into(),
|
2025-10-16 23:19:54 -07:00
|
|
|
|
path: "auth.rs".into(),
|
2025-09-02 10:29:58 -07:00
|
|
|
|
},
|
|
|
|
|
|
ParsedCommand::Read {
|
|
|
|
|
|
name: "shimmer.rs".into(),
|
|
|
|
|
|
cmd: "cat shimmer.rs".into(),
|
2025-10-16 23:19:54 -07:00
|
|
|
|
path: "shimmer.rs".into(),
|
2025-09-02 10:29:58 -07:00
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
output: None,
|
|
|
|
|
|
start_time: Some(Instant::now()),
|
|
|
|
|
|
duration: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
cell.complete_call(
|
|
|
|
|
|
"c1",
|
|
|
|
|
|
CommandOutput {
|
|
|
|
|
|
exit_code: 0,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr: String::new(),
|
|
|
|
|
|
formatted_output: String::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
Duration::from_millis(1),
|
|
|
|
|
|
);
|
|
|
|
|
|
let lines = cell.display_lines(80);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
let rendered = render_lines(&lines).join("\n");
|
2025-09-02 10:29:58 -07:00
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multiline_command_wraps_with_extra_indent_on_subsequent_lines() {
|
|
|
|
|
|
// Create a completed exec cell with a multiline command
|
|
|
|
|
|
let cmd = "set -o pipefail\ncargo test --all-features --quiet".to_string();
|
|
|
|
|
|
let call_id = "c1".to_string();
|
|
|
|
|
|
let mut cell = ExecCell::new(ExecCall {
|
|
|
|
|
|
call_id: call_id.clone(),
|
|
|
|
|
|
command: vec!["bash".into(), "-lc".into(), cmd],
|
|
|
|
|
|
parsed: Vec::new(),
|
|
|
|
|
|
output: None,
|
|
|
|
|
|
start_time: Some(Instant::now()),
|
|
|
|
|
|
duration: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
// Mark call complete so it renders as "Ran"
|
|
|
|
|
|
cell.complete_call(
|
|
|
|
|
|
&call_id,
|
|
|
|
|
|
CommandOutput {
|
|
|
|
|
|
exit_code: 0,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr: String::new(),
|
|
|
|
|
|
formatted_output: String::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
Duration::from_millis(1),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Small width to force wrapping on both lines
|
|
|
|
|
|
let width: u16 = 28;
|
|
|
|
|
|
let lines = cell.display_lines(width);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
let rendered = render_lines(&lines).join("\n");
|
2025-09-02 10:29:58 -07:00
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn single_line_command_compact_when_fits() {
|
|
|
|
|
|
let call_id = "c1".to_string();
|
|
|
|
|
|
let mut cell = ExecCell::new(ExecCall {
|
|
|
|
|
|
call_id: call_id.clone(),
|
|
|
|
|
|
command: vec!["echo".into(), "ok".into()],
|
|
|
|
|
|
parsed: Vec::new(),
|
|
|
|
|
|
output: None,
|
|
|
|
|
|
start_time: Some(Instant::now()),
|
|
|
|
|
|
duration: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
cell.complete_call(
|
|
|
|
|
|
&call_id,
|
|
|
|
|
|
CommandOutput {
|
|
|
|
|
|
exit_code: 0,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr: String::new(),
|
|
|
|
|
|
formatted_output: String::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
Duration::from_millis(1),
|
|
|
|
|
|
);
|
|
|
|
|
|
// Wide enough that it fits inline
|
|
|
|
|
|
let lines = cell.display_lines(80);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
let rendered = render_lines(&lines).join("\n");
|
2025-09-02 10:29:58 -07:00
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn single_line_command_wraps_with_four_space_continuation() {
|
|
|
|
|
|
let call_id = "c1".to_string();
|
|
|
|
|
|
let long = "a_very_long_token_without_spaces_to_force_wrapping".to_string();
|
|
|
|
|
|
let mut cell = ExecCell::new(ExecCall {
|
|
|
|
|
|
call_id: call_id.clone(),
|
|
|
|
|
|
command: vec!["bash".into(), "-lc".into(), long],
|
|
|
|
|
|
parsed: Vec::new(),
|
|
|
|
|
|
output: None,
|
|
|
|
|
|
start_time: Some(Instant::now()),
|
|
|
|
|
|
duration: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
cell.complete_call(
|
|
|
|
|
|
&call_id,
|
|
|
|
|
|
CommandOutput {
|
|
|
|
|
|
exit_code: 0,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr: String::new(),
|
|
|
|
|
|
formatted_output: String::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
Duration::from_millis(1),
|
|
|
|
|
|
);
|
|
|
|
|
|
let lines = cell.display_lines(24);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
let rendered = render_lines(&lines).join("\n");
|
2025-09-02 10:29:58 -07:00
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multiline_command_without_wrap_uses_branch_then_eight_spaces() {
|
|
|
|
|
|
let call_id = "c1".to_string();
|
|
|
|
|
|
let cmd = "echo one\necho two".to_string();
|
|
|
|
|
|
let mut cell = ExecCell::new(ExecCall {
|
|
|
|
|
|
call_id: call_id.clone(),
|
|
|
|
|
|
command: vec!["bash".into(), "-lc".into(), cmd],
|
|
|
|
|
|
parsed: Vec::new(),
|
|
|
|
|
|
output: None,
|
|
|
|
|
|
start_time: Some(Instant::now()),
|
|
|
|
|
|
duration: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
cell.complete_call(
|
|
|
|
|
|
&call_id,
|
|
|
|
|
|
CommandOutput {
|
|
|
|
|
|
exit_code: 0,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr: String::new(),
|
|
|
|
|
|
formatted_output: String::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
Duration::from_millis(1),
|
|
|
|
|
|
);
|
|
|
|
|
|
let lines = cell.display_lines(80);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
let rendered = render_lines(&lines).join("\n");
|
2025-09-02 10:29:58 -07:00
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multiline_command_both_lines_wrap_with_correct_prefixes() {
|
|
|
|
|
|
let call_id = "c1".to_string();
|
|
|
|
|
|
let cmd = "first_token_is_long_enough_to_wrap\nsecond_token_is_also_long_enough_to_wrap"
|
|
|
|
|
|
.to_string();
|
|
|
|
|
|
let mut cell = ExecCell::new(ExecCall {
|
|
|
|
|
|
call_id: call_id.clone(),
|
|
|
|
|
|
command: vec!["bash".into(), "-lc".into(), cmd],
|
|
|
|
|
|
parsed: Vec::new(),
|
|
|
|
|
|
output: None,
|
|
|
|
|
|
start_time: Some(Instant::now()),
|
|
|
|
|
|
duration: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
cell.complete_call(
|
|
|
|
|
|
&call_id,
|
|
|
|
|
|
CommandOutput {
|
|
|
|
|
|
exit_code: 0,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr: String::new(),
|
|
|
|
|
|
formatted_output: String::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
Duration::from_millis(1),
|
|
|
|
|
|
);
|
|
|
|
|
|
let lines = cell.display_lines(28);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
let rendered = render_lines(&lines).join("\n");
|
2025-09-02 10:29:58 -07:00
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn stderr_tail_more_than_five_lines_snapshot() {
|
|
|
|
|
|
// Build an exec cell with a non-zero exit and 10 lines on stderr to exercise
|
|
|
|
|
|
// the head/tail rendering and gutter prefixes.
|
|
|
|
|
|
let call_id = "c_err".to_string();
|
|
|
|
|
|
let mut cell = ExecCell::new(ExecCall {
|
|
|
|
|
|
call_id: call_id.clone(),
|
|
|
|
|
|
command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()],
|
|
|
|
|
|
parsed: Vec::new(),
|
|
|
|
|
|
output: None,
|
|
|
|
|
|
start_time: Some(Instant::now()),
|
|
|
|
|
|
duration: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
let stderr: String = (1..=10)
|
|
|
|
|
|
.map(|n| n.to_string())
|
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
cell.complete_call(
|
|
|
|
|
|
&call_id,
|
|
|
|
|
|
CommandOutput {
|
|
|
|
|
|
exit_code: 1,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr,
|
|
|
|
|
|
formatted_output: String::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
Duration::from_millis(1),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let rendered = cell
|
|
|
|
|
|
.display_lines(80)
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|l| {
|
|
|
|
|
|
l.spans
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|s| s.content.as_ref())
|
|
|
|
|
|
.collect::<String>()
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn ran_cell_multiline_with_stderr_snapshot() {
|
|
|
|
|
|
// Build an exec cell that completes (so it renders as "Ran") with a
|
|
|
|
|
|
// command long enough that it must render on its own line under the
|
|
|
|
|
|
// header, and include a couple of stderr lines to verify the output
|
|
|
|
|
|
// block prefixes and wrapping.
|
|
|
|
|
|
let call_id = "c_wrap_err".to_string();
|
|
|
|
|
|
let long_cmd =
|
|
|
|
|
|
"echo this_is_a_very_long_single_token_that_will_wrap_across_the_available_width";
|
|
|
|
|
|
let mut cell = ExecCell::new(ExecCall {
|
|
|
|
|
|
call_id: call_id.clone(),
|
|
|
|
|
|
command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()],
|
|
|
|
|
|
parsed: Vec::new(),
|
|
|
|
|
|
output: None,
|
|
|
|
|
|
start_time: Some(Instant::now()),
|
|
|
|
|
|
duration: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let stderr = "error: first line on stderr\nerror: second line on stderr".to_string();
|
|
|
|
|
|
cell.complete_call(
|
|
|
|
|
|
&call_id,
|
|
|
|
|
|
CommandOutput {
|
|
|
|
|
|
exit_code: 1,
|
|
|
|
|
|
stdout: String::new(),
|
|
|
|
|
|
stderr,
|
|
|
|
|
|
formatted_output: String::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
Duration::from_millis(5),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Narrow width to force the command to render under the header line.
|
|
|
|
|
|
let width: u16 = 28;
|
|
|
|
|
|
let rendered = cell
|
|
|
|
|
|
.display_lines(width)
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|l| {
|
|
|
|
|
|
l.spans
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|s| s.content.as_ref())
|
|
|
|
|
|
.collect::<String>()
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn user_history_cell_wraps_and_prefixes_each_line_snapshot() {
|
|
|
|
|
|
let msg = "one two three four five six seven";
|
|
|
|
|
|
let cell = UserHistoryCell {
|
|
|
|
|
|
message: msg.to_string(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-14 16:51:08 -04:00
|
|
|
|
// Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space.
|
2025-09-02 10:29:58 -07:00
|
|
|
|
let width: u16 = 12;
|
|
|
|
|
|
let lines = cell.display_lines(width);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
let rendered = render_lines(&lines).join("\n");
|
2025-09-02 10:29:58 -07:00
|
|
|
|
|
|
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn plan_update_with_note_and_wrapping_snapshot() {
|
|
|
|
|
|
// Long explanation forces wrapping; include long step text to verify step wrapping and alignment.
|
|
|
|
|
|
let update = UpdatePlanArgs {
|
|
|
|
|
|
explanation: Some(
|
|
|
|
|
|
"I’ll update Grafana call error handling by adding retries and clearer messages when the backend is unreachable."
|
|
|
|
|
|
.to_string(),
|
|
|
|
|
|
),
|
|
|
|
|
|
plan: vec![
|
|
|
|
|
|
PlanItemArg {
|
|
|
|
|
|
step: "Investigate existing error paths and logging around HTTP timeouts".into(),
|
|
|
|
|
|
status: StepStatus::Completed,
|
|
|
|
|
|
},
|
|
|
|
|
|
PlanItemArg {
|
|
|
|
|
|
step: "Harden Grafana client error handling with retry/backoff and user‑friendly messages".into(),
|
|
|
|
|
|
status: StepStatus::InProgress,
|
|
|
|
|
|
},
|
|
|
|
|
|
PlanItemArg {
|
|
|
|
|
|
step: "Add tests for transient failure scenarios and surfacing to the UI".into(),
|
|
|
|
|
|
status: StepStatus::Pending,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let cell = new_plan_update(update);
|
|
|
|
|
|
// Narrow width to force wrapping for both the note and steps
|
|
|
|
|
|
let lines = cell.display_lines(32);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
let rendered = render_lines(&lines).join("\n");
|
2025-09-02 10:29:58 -07:00
|
|
|
|
insta::assert_snapshot!(rendered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn plan_update_without_note_snapshot() {
|
|
|
|
|
|
let update = UpdatePlanArgs {
|
|
|
|
|
|
explanation: None,
|
|
|
|
|
|
plan: vec![
|
|
|
|
|
|
PlanItemArg {
|
|
|
|
|
|
step: "Define error taxonomy".into(),
|
|
|
|
|
|
status: StepStatus::InProgress,
|
|
|
|
|
|
},
|
|
|
|
|
|
PlanItemArg {
|
|
|
|
|
|
step: "Implement mapping to user messages".into(),
|
|
|
|
|
|
status: StepStatus::Pending,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let cell = new_plan_update(update);
|
|
|
|
|
|
let lines = cell.display_lines(40);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
let rendered = render_lines(&lines).join("\n");
|
2025-09-02 10:29:58 -07:00
|
|
|
|
insta::assert_snapshot!(rendered);
|
2025-08-11 16:11:46 -07:00
|
|
|
|
}
|
2025-09-16 16:42:43 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn reasoning_summary_block() {
|
|
|
|
|
|
let mut config = test_config();
|
|
|
|
|
|
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
|
|
|
|
|
|
|
|
|
|
|
let cell = new_reasoning_summary_block(
|
|
|
|
|
|
"**High level reasoning**\n\nDetailed reasoning goes here.".to_string(),
|
|
|
|
|
|
&config,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let rendered_display = render_lines(&cell.display_lines(80));
|
|
|
|
|
|
assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]);
|
|
|
|
|
|
|
|
|
|
|
|
let rendered_transcript = render_transcript(cell.as_ref());
|
2025-10-07 16:18:48 -07:00
|
|
|
|
assert_eq!(rendered_transcript, vec!["• Detailed reasoning goes here."]);
|
2025-09-16 16:42:43 -07:00
|
|
|
|
}
|
2025-09-04 09:45:14 -07:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() {
|
|
|
|
|
|
let mut config = test_config();
|
2025-09-04 11:00:01 -07:00
|
|
|
|
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
2025-09-04 09:45:14 -07:00
|
|
|
|
|
2025-09-16 16:42:43 -07:00
|
|
|
|
let cell =
|
2025-09-04 09:45:14 -07:00
|
|
|
|
new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &config);
|
|
|
|
|
|
|
2025-09-16 16:42:43 -07:00
|
|
|
|
let rendered = render_transcript(cell.as_ref());
|
2025-10-07 16:18:48 -07:00
|
|
|
|
assert_eq!(rendered, vec!["• Detailed reasoning goes here."]);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn reasoning_summary_block_falls_back_when_header_is_missing() {
|
|
|
|
|
|
let mut config = test_config();
|
2025-09-04 11:00:01 -07:00
|
|
|
|
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
2025-09-04 09:45:14 -07:00
|
|
|
|
|
2025-09-16 16:42:43 -07:00
|
|
|
|
let cell = new_reasoning_summary_block(
|
2025-09-04 09:45:14 -07:00
|
|
|
|
"**High level reasoning without closing".to_string(),
|
|
|
|
|
|
&config,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-09-16 16:42:43 -07:00
|
|
|
|
let rendered = render_transcript(cell.as_ref());
|
2025-10-07 16:18:48 -07:00
|
|
|
|
assert_eq!(rendered, vec!["• **High level reasoning without closing"]);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn reasoning_summary_block_falls_back_when_summary_is_missing() {
|
|
|
|
|
|
let mut config = test_config();
|
2025-09-04 11:00:01 -07:00
|
|
|
|
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
2025-09-04 09:45:14 -07:00
|
|
|
|
|
2025-09-16 16:42:43 -07:00
|
|
|
|
let cell = new_reasoning_summary_block(
|
2025-09-04 09:45:14 -07:00
|
|
|
|
"**High level reasoning without closing**".to_string(),
|
|
|
|
|
|
&config,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-09-16 16:42:43 -07:00
|
|
|
|
let rendered = render_transcript(cell.as_ref());
|
2025-10-07 16:18:48 -07:00
|
|
|
|
assert_eq!(rendered, vec!["• High level reasoning without closing"]);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
|
2025-09-16 16:42:43 -07:00
|
|
|
|
let cell = new_reasoning_summary_block(
|
2025-09-04 09:45:14 -07:00
|
|
|
|
"**High level reasoning without closing**\n\n ".to_string(),
|
|
|
|
|
|
&config,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-09-16 16:42:43 -07:00
|
|
|
|
let rendered = render_transcript(cell.as_ref());
|
2025-10-07 16:18:48 -07:00
|
|
|
|
assert_eq!(rendered, vec!["• High level reasoning without closing"]);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn reasoning_summary_block_splits_header_and_summary_when_present() {
|
|
|
|
|
|
let mut config = test_config();
|
2025-09-04 11:00:01 -07:00
|
|
|
|
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
2025-09-04 09:45:14 -07:00
|
|
|
|
|
2025-09-16 16:42:43 -07:00
|
|
|
|
let cell = new_reasoning_summary_block(
|
2025-09-04 09:45:14 -07:00
|
|
|
|
"**High level plan**\n\nWe should fix the bug next.".to_string(),
|
|
|
|
|
|
&config,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-09-16 16:42:43 -07:00
|
|
|
|
let rendered_display = render_lines(&cell.display_lines(80));
|
|
|
|
|
|
assert_eq!(rendered_display, vec!["• We should fix the bug next."]);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
|
2025-09-16 16:42:43 -07:00
|
|
|
|
let rendered_transcript = render_transcript(cell.as_ref());
|
2025-10-07 16:18:48 -07:00
|
|
|
|
assert_eq!(rendered_transcript, vec!["• We should fix the bug next."]);
|
2025-09-04 09:45:14 -07:00
|
|
|
|
}
|
2025-08-11 16:11:46 -07:00
|
|
|
|
}
|