mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui): add raw scrollback mode (#20819)
## Why Granular copy is particularly difficult with the current output. Part of it was solved with the introduction of the `/copy` command but when you only need to copy parts of a response, you still encounter some issues: - When you copy a paragraph, the result is a sequence of separate lines instead of one correctly joined paragraph. - When a word wraps, part of it stays on the original line and the rest appears at the start of the next line. - When you copy a long command, extra line breaks are often inserted, and command arguments can be split across multiple lines. https://github.com/user-attachments/assets/0ef85c84-9363-4aad-b43a-15fce062a443 ## Solution Now that we own the scrollback and we re-create it when we resize, we have the opportunity of toggling between the raw text and the rich text we see today. - Add TUI raw scrollback mode with `tui.raw_output_mode`, `/raw [on|off]`, and the configurable `tui.keymap.global.toggle_raw_output` action. - Render transcript cells through rich/raw-aware paths so raw mode preserves source text and lets the terminal soft-wrap selection-friendly output. - Bind raw-mode toggle to `alt-r` by default, with the keybinding path toggling silently while `/raw` continues to emit confirmation messages. ## Related Issues Likely addressed by raw mode: - #12200: clean copy for multiline and soft-wrapped output. Raw mode removes Codex-inserted wrapping/indentation and lets the terminal soft-wrap logical lines. - #9252: command suggestions gain unwanted leading spaces when copied. Raw mode renders transcript text without the rich-mode left padding/gutter. - #8258: prompt output is hard to copy because of leading indentation. Raw mode renders user/source-backed transcript text without that decorative indentation. Partially or conditionally addressed: - #2880: copy/export message as Markdown. Raw mode exposes raw Markdown for terminal selection, but this PR does not add a dedicated export/copy-message command. - #19820: mouse drag selection + copy in the TUI. Raw mode improves terminal-native selection of output/history text, but this PR does not implement in-TUI mouse selection, highlighting, auto-copy, or composer selection. - #18979: copied content is divided into two parts. This should improve cases caused by Codex-inserted wraps/padding in rendered output; if the report is about pasting into the composer/input path, that remains outside this PR. ## Validation - `just write-config-schema` - `just fmt` - `cargo test -p codex-config` - `cargo test -p codex-tui` - `just fix -p codex-tui` - `just argument-comment-lint` - `cargo test -p codex-tui raw_output_mode_can_change_without_inserting_notice -- --nocapture` - `cargo test -p codex-tui raw_slash_command_toggles_and_accepts_on_off_args -- --nocapture` - `cargo test -p codex-tui raw_output_toggle -- --nocapture` - `git diff --check` - `cargo insta pending-snapshots`
This commit is contained in:
committed by
GitHub
Unverified
parent
172303bbfa
commit
5e0a4adbe5
@@ -106,6 +106,8 @@ pub struct TuiGlobalKeymap {
|
||||
pub toggle_vim_mode: Option<KeybindingsSpec>,
|
||||
/// Toggle Fast mode.
|
||||
pub toggle_fast_mode: Option<KeybindingsSpec>,
|
||||
/// Toggle raw scrollback mode for copy-friendly transcript selection.
|
||||
pub toggle_raw_output: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Chat context keybindings.
|
||||
|
||||
@@ -617,6 +617,11 @@ pub struct Tui {
|
||||
#[serde(default)]
|
||||
pub vim_mode_default: bool,
|
||||
|
||||
/// Start the TUI in raw scrollback mode for copy-friendly transcript output.
|
||||
/// Defaults to `false`.
|
||||
#[serde(default)]
|
||||
pub raw_output_mode: bool,
|
||||
|
||||
/// Controls whether the TUI uses the terminal's alternate screen buffer.
|
||||
///
|
||||
/// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere.
|
||||
|
||||
@@ -2460,6 +2460,7 @@
|
||||
"queue": null,
|
||||
"submit": null,
|
||||
"toggle_fast_mode": null,
|
||||
"toggle_raw_output": null,
|
||||
"toggle_shortcuts": null,
|
||||
"toggle_vim_mode": null
|
||||
},
|
||||
@@ -2558,6 +2559,11 @@
|
||||
"default": true,
|
||||
"description": "Enable desktop notifications from the TUI. Defaults to `true`."
|
||||
},
|
||||
"raw_output_mode": {
|
||||
"default": false,
|
||||
"description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"show_tooltips": {
|
||||
"default": true,
|
||||
"description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.",
|
||||
@@ -2956,6 +2962,14 @@
|
||||
],
|
||||
"description": "Toggle Fast mode."
|
||||
},
|
||||
"toggle_raw_output": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Toggle raw scrollback mode for copy-friendly transcript selection."
|
||||
},
|
||||
"toggle_shortcuts": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -3062,6 +3076,7 @@
|
||||
"queue": null,
|
||||
"submit": null,
|
||||
"toggle_fast_mode": null,
|
||||
"toggle_raw_output": null,
|
||||
"toggle_shortcuts": null,
|
||||
"toggle_vim_mode": null
|
||||
}
|
||||
|
||||
@@ -550,6 +550,7 @@ fn config_toml_deserializes_model_availability_nux() {
|
||||
animations: true,
|
||||
show_tooltips: true,
|
||||
vim_mode_default: false,
|
||||
raw_output_mode: false,
|
||||
alternate_screen: AltScreenMode::default(),
|
||||
status_line: None,
|
||||
status_line_use_colors: true,
|
||||
@@ -660,6 +661,53 @@ fn test_tui_vim_mode_default_true() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tui_raw_output_mode_defaults_to_false() {
|
||||
let toml = r#"
|
||||
[tui]
|
||||
"#;
|
||||
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize empty [tui] table");
|
||||
assert!(
|
||||
!parsed
|
||||
.tui
|
||||
.expect("config should include tui section")
|
||||
.raw_output_mode
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tui_raw_output_mode_true() {
|
||||
let toml = r#"
|
||||
[tui]
|
||||
raw_output_mode = true
|
||||
"#;
|
||||
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true");
|
||||
assert!(
|
||||
parsed
|
||||
.tui
|
||||
.expect("config should include tui section")
|
||||
.raw_output_mode
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_config_uses_tui_raw_output_mode() {
|
||||
let toml = r#"
|
||||
[tui]
|
||||
raw_output_mode = true
|
||||
"#;
|
||||
let cfg_toml: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true");
|
||||
let cfg = Config::load_from_base_config_with_overrides(
|
||||
cfg_toml,
|
||||
ConfigOverrides::default(),
|
||||
tempdir().expect("tempdir").abs(),
|
||||
)
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
assert!(cfg.tui_raw_output_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_deserializes_permission_profiles() {
|
||||
let toml = r#"
|
||||
@@ -2125,6 +2173,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
|
||||
animations: true,
|
||||
show_tooltips: true,
|
||||
vim_mode_default: false,
|
||||
raw_output_mode: false,
|
||||
alternate_screen: AltScreenMode::Auto,
|
||||
status_line: None,
|
||||
status_line_use_colors: true,
|
||||
@@ -6450,6 +6499,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
animations: true,
|
||||
show_tooltips: true,
|
||||
tui_vim_mode_default: false,
|
||||
tui_raw_output_mode: false,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
model_availability_nux: ModelAvailabilityNuxConfig::default(),
|
||||
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
|
||||
@@ -6652,6 +6702,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
animations: true,
|
||||
show_tooltips: true,
|
||||
tui_vim_mode_default: false,
|
||||
tui_raw_output_mode: false,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
model_availability_nux: ModelAvailabilityNuxConfig::default(),
|
||||
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
|
||||
@@ -6808,6 +6859,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
animations: true,
|
||||
show_tooltips: true,
|
||||
tui_vim_mode_default: false,
|
||||
tui_raw_output_mode: false,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
model_availability_nux: ModelAvailabilityNuxConfig::default(),
|
||||
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
|
||||
@@ -6949,6 +7001,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
animations: true,
|
||||
show_tooltips: true,
|
||||
tui_vim_mode_default: false,
|
||||
tui_raw_output_mode: false,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
model_availability_nux: ModelAvailabilityNuxConfig::default(),
|
||||
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
|
||||
|
||||
@@ -517,6 +517,9 @@ pub struct Config {
|
||||
/// Start the composer in Vim mode (`Normal`) by default.
|
||||
pub tui_vim_mode_default: bool,
|
||||
|
||||
/// Start the TUI in raw scrollback mode for copy-friendly transcript output.
|
||||
pub tui_raw_output_mode: bool,
|
||||
|
||||
/// Start the TUI in the specified collaboration mode (plan/default).
|
||||
|
||||
/// Controls whether the TUI uses the terminal's alternate screen buffer.
|
||||
@@ -3147,6 +3150,11 @@ impl Config {
|
||||
.as_ref()
|
||||
.map(|t| t.vim_mode_default)
|
||||
.unwrap_or(false),
|
||||
tui_raw_output_mode: cfg
|
||||
.tui
|
||||
.as_ref()
|
||||
.map(|t| t.raw_output_mode)
|
||||
.unwrap_or(false),
|
||||
tui_alternate_screen: cfg
|
||||
.tui
|
||||
.as_ref()
|
||||
|
||||
@@ -197,6 +197,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
|
||||
tui_status_line_use_colors: true,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
tui_raw_output_mode: false,
|
||||
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
tui_vim_mode_default: false,
|
||||
|
||||
@@ -35,6 +35,9 @@ impl App {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
AppEvent::RawOutputModeChanged { enabled } => {
|
||||
self.apply_raw_output_mode(tui, enabled, /*notify*/ false);
|
||||
}
|
||||
AppEvent::ClearUiAndSubmitUserMessage { text } => {
|
||||
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
|
||||
self.reset_app_ui_state_after_clear();
|
||||
|
||||
@@ -69,6 +69,25 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
pub(super) fn apply_raw_output_mode(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
enabled: bool,
|
||||
notify: bool,
|
||||
) {
|
||||
if notify {
|
||||
self.chat_widget.set_raw_output_mode_and_notify(enabled);
|
||||
} else {
|
||||
self.chat_widget.set_raw_output_mode(enabled);
|
||||
}
|
||||
if let Err(err) = self.reflow_transcript_now(tui) {
|
||||
tracing::warn!(error = %err, "failed to reflow transcript after raw output mode toggle");
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to redraw transcript: {err}"));
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
pub(super) async fn handle_key_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
@@ -137,6 +156,13 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
if app_keymap_shortcuts_available && self.keymap.app.toggle_raw_output.is_pressed(key_event)
|
||||
{
|
||||
let enabled = !self.chat_widget.raw_output_mode();
|
||||
self.apply_raw_output_mode(tui, enabled, /*notify*/ false);
|
||||
return;
|
||||
}
|
||||
|
||||
if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) {
|
||||
// Enter alternate screen and set viewport to full size.
|
||||
let _ = tui.enter_alt_screen();
|
||||
|
||||
@@ -26,6 +26,7 @@ use super::App;
|
||||
use super::InitialHistoryReplayBuffer;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::insert_history::HistoryLineWrapPolicy;
|
||||
use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE;
|
||||
use crate::tui;
|
||||
|
||||
@@ -75,7 +76,8 @@ impl App {
|
||||
cell: &dyn HistoryCell,
|
||||
width: u16,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut display = cell.display_lines(width);
|
||||
let mut display =
|
||||
cell.display_lines_for_mode(width, self.chat_widget.history_render_mode());
|
||||
if !display.is_empty() && !cell.is_stream_continuation() {
|
||||
if self.has_emitted_history_lines {
|
||||
display.insert(0, Line::from(""));
|
||||
@@ -99,7 +101,7 @@ impl App {
|
||||
if self.overlay.is_some() {
|
||||
self.deferred_history_lines.extend(display);
|
||||
} else {
|
||||
tui.insert_history_lines(display);
|
||||
tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +160,7 @@ impl App {
|
||||
}
|
||||
|
||||
let retained_lines = buffer.retained_lines.into_iter().collect::<Vec<_>>();
|
||||
tui.insert_history_lines(retained_lines);
|
||||
tui.insert_history_lines_with_wrap_policy(retained_lines, self.history_line_wrap_policy());
|
||||
}
|
||||
|
||||
pub(super) fn insert_history_cell_lines_with_initial_replay_buffer(
|
||||
@@ -188,11 +190,19 @@ impl App {
|
||||
} else if self.overlay.is_some() {
|
||||
self.deferred_history_lines.extend(display);
|
||||
} else {
|
||||
tui.insert_history_lines(display);
|
||||
tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn history_line_wrap_policy(&self) -> HistoryLineWrapPolicy {
|
||||
if self.chat_widget.raw_output_mode() {
|
||||
HistoryLineWrapPolicy::Terminal
|
||||
} else {
|
||||
HistoryLineWrapPolicy::PreWrap
|
||||
}
|
||||
}
|
||||
|
||||
/// Retain only the newest rendered rows for initial resume replay.
|
||||
///
|
||||
/// The oldest rows are dropped first because terminal scrollback caps preserve the tail of the
|
||||
@@ -408,7 +418,7 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result<u16> {
|
||||
pub(super) fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result<u16> {
|
||||
let width = tui.terminal.size()?.width;
|
||||
if self.transcript_cells.is_empty() {
|
||||
// Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells.
|
||||
@@ -426,7 +436,10 @@ impl App {
|
||||
|
||||
self.deferred_history_lines.clear();
|
||||
if !reflowed_lines.is_empty() {
|
||||
tui.insert_history_lines(reflowed_lines);
|
||||
tui.insert_history_lines_with_wrap_policy(
|
||||
reflowed_lines,
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(width)
|
||||
@@ -448,7 +461,7 @@ impl App {
|
||||
while start > 0 {
|
||||
start -= 1;
|
||||
let cell = self.transcript_cells[start].clone();
|
||||
let lines = cell.display_lines(width);
|
||||
let lines = cell.display_lines_for_mode(width, self.chat_widget.history_render_mode());
|
||||
rendered_rows += lines.len();
|
||||
cell_displays.push_front(ReflowCellDisplay {
|
||||
lines,
|
||||
@@ -468,7 +481,7 @@ impl App {
|
||||
start -= 1;
|
||||
let cell = self.transcript_cells[start].clone();
|
||||
cell_displays.push_front(ReflowCellDisplay {
|
||||
lines: cell.display_lines(width),
|
||||
lines: cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()),
|
||||
is_stream_continuation: cell.is_stream_continuation(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ impl App {
|
||||
let was_backtrack = self.backtrack.overlay_preview_active;
|
||||
if !self.deferred_history_lines.is_empty() {
|
||||
let lines = std::mem::take(&mut self.deferred_history_lines);
|
||||
tui.insert_history_lines(lines);
|
||||
tui.insert_history_lines_with_wrap_policy(lines, self.history_line_wrap_policy());
|
||||
}
|
||||
self.overlay = None;
|
||||
self.backtrack.overlay_preview_active = false;
|
||||
@@ -261,7 +261,10 @@ impl App {
|
||||
if !self.transcript_cells.is_empty() {
|
||||
let width = tui.terminal.last_known_screen_size.width;
|
||||
for cell in &self.transcript_cells {
|
||||
tui.insert_history_lines(cell.display_lines(width));
|
||||
tui.insert_history_lines_with_wrap_policy(
|
||||
cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()),
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,11 @@ pub(crate) enum AppEvent {
|
||||
/// previous chat resumable.
|
||||
ClearUi,
|
||||
|
||||
/// Re-render the transcript using the selected scrollback rendering mode.
|
||||
RawOutputModeChanged {
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Clear the current context, start a fresh session, and submit an initial user message.
|
||||
///
|
||||
/// This is the Plan Mode handoff path: the previous thread remains resumable, but the model
|
||||
|
||||
@@ -167,6 +167,7 @@ mod tests {
|
||||
vec![
|
||||
SlashCommand::Ide,
|
||||
SlashCommand::Copy,
|
||||
SlashCommand::Raw,
|
||||
SlashCommand::Diff,
|
||||
SlashCommand::Mention,
|
||||
SlashCommand::Status,
|
||||
|
||||
@@ -117,6 +117,9 @@ pub(crate) enum StatusLineItem {
|
||||
/// Whether Fast mode is currently active.
|
||||
FastMode,
|
||||
|
||||
/// Whether raw scrollback mode is currently active.
|
||||
RawOutput,
|
||||
|
||||
/// Current thread title (if set by user).
|
||||
ThreadTitle,
|
||||
|
||||
@@ -163,6 +166,7 @@ impl StatusLineItem {
|
||||
"Current session identifier (omitted until session starts)"
|
||||
}
|
||||
StatusLineItem::FastMode => "Whether Fast mode is currently active",
|
||||
StatusLineItem::RawOutput => "Whether raw scrollback mode is active",
|
||||
StatusLineItem::ThreadTitle => "Current thread title (omitted when unavailable)",
|
||||
StatusLineItem::TaskProgress => {
|
||||
"Latest task progress from update_plan (omitted until available)"
|
||||
@@ -191,6 +195,7 @@ impl StatusLineItem {
|
||||
StatusLineItem::TotalOutputTokens => StatusSurfacePreviewItem::TotalOutputTokens,
|
||||
StatusLineItem::SessionId => StatusSurfacePreviewItem::SessionId,
|
||||
StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode,
|
||||
StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput,
|
||||
StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle,
|
||||
StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress,
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ impl StatusLineAccent {
|
||||
| StatusLineItem::TotalOutputTokens => Self::Usage,
|
||||
StatusLineItem::FiveHourLimit | StatusLineItem::WeeklyLimit => Self::Limit,
|
||||
StatusLineItem::CodexVersion | StatusLineItem::SessionId => Self::Metadata,
|
||||
StatusLineItem::FastMode => Self::Mode,
|
||||
StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode,
|
||||
StatusLineItem::ThreadTitle => Self::Thread,
|
||||
StatusLineItem::TaskProgress => Self::Progress,
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub(crate) enum StatusSurfacePreviewItem {
|
||||
TotalOutputTokens,
|
||||
SessionId,
|
||||
FastMode,
|
||||
RawOutput,
|
||||
Model,
|
||||
ModelWithReasoning,
|
||||
TaskProgress,
|
||||
@@ -55,6 +56,7 @@ impl StatusSurfacePreviewItem {
|
||||
StatusSurfacePreviewItem::TotalOutputTokens => "0 out",
|
||||
StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4",
|
||||
StatusSurfacePreviewItem::FastMode => "Fast on",
|
||||
StatusSurfacePreviewItem::RawOutput => "raw output",
|
||||
StatusSurfacePreviewItem::Model => "gpt-5.2-codex",
|
||||
StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium",
|
||||
StatusSurfacePreviewItem::TaskProgress => "Tasks 0/0",
|
||||
@@ -83,6 +85,7 @@ impl StatusSurfacePreviewItem {
|
||||
Self::TotalOutputTokens,
|
||||
Self::SessionId,
|
||||
Self::FastMode,
|
||||
Self::RawOutput,
|
||||
Self::Model,
|
||||
Self::ModelWithReasoning,
|
||||
Self::TaskProgress,
|
||||
|
||||
@@ -293,6 +293,7 @@ use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::HistoryRenderMode;
|
||||
use crate::history_cell::HookCell;
|
||||
use crate::history_cell::McpInvocation;
|
||||
use crate::history_cell::McpToolCallCell;
|
||||
@@ -756,6 +757,7 @@ pub(crate) struct ChatWidget {
|
||||
/// where the overlay may briefly treat new tail content as already cached.
|
||||
active_cell_revision: u64,
|
||||
config: Config,
|
||||
raw_output_mode: bool,
|
||||
/// Runtime value resolved by core. `config.service_tier` remains the explicit user choice.
|
||||
effective_service_tier: Option<ServiceTier>,
|
||||
/// The unmasked collaboration mode settings (always Default mode).
|
||||
@@ -2329,6 +2331,7 @@ impl ChatWidget {
|
||||
self.plan_stream_controller = Some(PlanStreamController::new(
|
||||
self.current_stream_width(/*reserved_cols*/ 4),
|
||||
&self.config.cwd,
|
||||
self.history_render_mode(),
|
||||
));
|
||||
}
|
||||
if let Some(controller) = self.plan_stream_controller.as_mut()
|
||||
@@ -4331,6 +4334,7 @@ impl ChatWidget {
|
||||
self.stream_controller = Some(StreamController::new(
|
||||
self.current_stream_width(/*reserved_cols*/ 2),
|
||||
&self.config.cwd,
|
||||
self.history_render_mode(),
|
||||
));
|
||||
}
|
||||
if let Some(controller) = self.stream_controller.as_mut()
|
||||
@@ -4890,6 +4894,7 @@ impl ChatWidget {
|
||||
}),
|
||||
active_cell,
|
||||
active_cell_revision: 0,
|
||||
raw_output_mode: config.tui_raw_output_mode,
|
||||
config,
|
||||
effective_service_tier,
|
||||
skills_all: Vec::new(),
|
||||
@@ -10276,6 +10281,53 @@ impl ChatWidget {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn raw_output_mode(&self) -> bool {
|
||||
self.raw_output_mode
|
||||
}
|
||||
|
||||
pub(crate) fn history_render_mode(&self) -> HistoryRenderMode {
|
||||
if self.raw_output_mode {
|
||||
HistoryRenderMode::Raw
|
||||
} else {
|
||||
HistoryRenderMode::Rich
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_raw_output_mode(&mut self, enabled: bool) {
|
||||
self.raw_output_mode = enabled;
|
||||
self.config.tui_raw_output_mode = enabled;
|
||||
let render_mode = self.history_render_mode();
|
||||
if let Some(controller) = self.stream_controller.as_mut() {
|
||||
controller.set_render_mode(render_mode);
|
||||
}
|
||||
if let Some(controller) = self.plan_stream_controller.as_mut() {
|
||||
controller.set_render_mode(render_mode);
|
||||
}
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
|
||||
pub(crate) fn raw_output_mode_notice(enabled: bool) -> &'static str {
|
||||
if enabled {
|
||||
"Raw output mode on: transcript text is shown for clean terminal selection."
|
||||
} else {
|
||||
"Raw output mode off: rich transcript rendering restored."
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_raw_output_mode_and_notify(&mut self, enabled: bool) {
|
||||
self.set_raw_output_mode(enabled);
|
||||
self.add_info_message(
|
||||
Self::raw_output_mode_notice(enabled).to_string(),
|
||||
/*hint*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_raw_output_mode_and_notify(&mut self) -> bool {
|
||||
let enabled = !self.raw_output_mode;
|
||||
self.set_raw_output_mode_and_notify(enabled);
|
||||
enabled
|
||||
}
|
||||
|
||||
/// Update resize-sensitive chat widget state after the terminal width changes.
|
||||
///
|
||||
/// The app calls this even when terminal resize reflow is disabled so live stream wrapping
|
||||
|
||||
@@ -32,6 +32,7 @@ const SIDE_REVIEW_UNAVAILABLE_MESSAGE: &str =
|
||||
const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Esc to return to the main thread first.";
|
||||
const GOAL_USAGE: &str = "Usage: /goal <objective>";
|
||||
const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage";
|
||||
const RAW_USAGE: &str = "Usage: /raw [on|off]";
|
||||
|
||||
impl ChatWidget {
|
||||
/// Dispatch a bare slash command and record its staged local-history entry.
|
||||
@@ -104,6 +105,11 @@ impl ChatWidget {
|
||||
self.request_side_conversation(parent_thread_id, /*user_message*/ None);
|
||||
}
|
||||
|
||||
fn emit_raw_output_mode_changed(&self, enabled: bool) {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::RawOutputModeChanged { enabled });
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) {
|
||||
if !self.ensure_slash_command_allowed_in_side_conversation(cmd) {
|
||||
return;
|
||||
@@ -315,6 +321,10 @@ impl ChatWidget {
|
||||
SlashCommand::Copy => {
|
||||
self.copy_last_agent_markdown();
|
||||
}
|
||||
SlashCommand::Raw => {
|
||||
let enabled = self.toggle_raw_output_mode_and_notify();
|
||||
self.emit_raw_output_mode_changed(enabled);
|
||||
}
|
||||
SlashCommand::Diff => {
|
||||
self.add_diff_in_progress();
|
||||
let tx = self.app_event_tx.clone();
|
||||
@@ -595,6 +605,17 @@ impl ChatWidget {
|
||||
}
|
||||
_ => self.add_error_message("Usage: /keymap [debug]".to_string()),
|
||||
},
|
||||
SlashCommand::Raw => match trimmed.to_ascii_lowercase().as_str() {
|
||||
"on" => {
|
||||
self.set_raw_output_mode_and_notify(/*enabled*/ true);
|
||||
self.emit_raw_output_mode_changed(/*enabled*/ true);
|
||||
}
|
||||
"off" => {
|
||||
self.set_raw_output_mode_and_notify(/*enabled*/ false);
|
||||
self.emit_raw_output_mode_changed(/*enabled*/ false);
|
||||
}
|
||||
_ => self.add_error_message(RAW_USAGE.to_string()),
|
||||
},
|
||||
SlashCommand::Rename if !trimmed.is_empty() => {
|
||||
if !self.ensure_thread_rename_allowed() {
|
||||
return;
|
||||
@@ -878,6 +899,7 @@ impl ChatWidget {
|
||||
| SlashCommand::Plugins
|
||||
| SlashCommand::Rollout
|
||||
| SlashCommand::Copy
|
||||
| SlashCommand::Raw
|
||||
| SlashCommand::Vim
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::Rename
|
||||
|
||||
@@ -647,6 +647,7 @@ impl ChatWidget {
|
||||
"Fast off".to_string()
|
||||
},
|
||||
),
|
||||
StatusLineItem::RawOutput => self.raw_output_mode().then(|| "raw output".to_string()),
|
||||
StatusLineItem::ThreadTitle => self.thread_name.as_ref().and_then(|name| {
|
||||
let trimmed = name.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
@@ -688,6 +689,7 @@ impl ChatWidget {
|
||||
StatusSurfacePreviewItem::TotalOutputTokens => StatusLineItem::TotalOutputTokens,
|
||||
StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId,
|
||||
StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode,
|
||||
StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput,
|
||||
StatusSurfacePreviewItem::Model => StatusLineItem::ModelName,
|
||||
StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning,
|
||||
};
|
||||
|
||||
@@ -189,6 +189,7 @@ pub(super) async fn make_chatwidget_manual(
|
||||
bottom_pane: bottom,
|
||||
active_cell: None,
|
||||
active_cell_revision: 0,
|
||||
raw_output_mode: cfg.tui_raw_output_mode,
|
||||
config: cfg,
|
||||
effective_service_tier,
|
||||
current_collaboration_mode,
|
||||
|
||||
@@ -1975,6 +1975,57 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_slash_command_toggles_and_accepts_on_off_args() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.dispatch_command(SlashCommand::Raw);
|
||||
assert!(chat.raw_output_mode());
|
||||
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::RawOutputModeChanged { enabled: true }))
|
||||
);
|
||||
|
||||
chat.dispatch_command_with_args(SlashCommand::Raw, "off".to_string(), Vec::new());
|
||||
assert!(!chat.raw_output_mode());
|
||||
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::RawOutputModeChanged { enabled: false }))
|
||||
);
|
||||
|
||||
chat.dispatch_command_with_args(SlashCommand::Raw, "on".to_string(), Vec::new());
|
||||
assert!(chat.raw_output_mode());
|
||||
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::RawOutputModeChanged { enabled: true }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_slash_command_reports_usage_for_invalid_arg() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.dispatch_command_with_args(SlashCommand::Raw, "status".to_string(), Vec::new());
|
||||
|
||||
assert!(!chat.raw_output_mode());
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let rendered = cells
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
rendered.contains("Usage: /raw [on|off]"),
|
||||
"expected raw usage error, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_queues_user_messages_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -156,6 +156,23 @@ async fn status_line_git_summary_items_render_values() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_output_status_line_value_only_shows_when_enabled() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
assert_eq!(
|
||||
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::RawOutput),
|
||||
None
|
||||
);
|
||||
|
||||
chat.set_raw_output_mode(/*enabled*/ true);
|
||||
|
||||
assert_eq!(
|
||||
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::RawOutput),
|
||||
Some("raw output".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_line_branch_changes_render_no_changes() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -196,6 +213,30 @@ async fn stale_status_line_git_summary_update_is_ignored() {
|
||||
assert!(chat.status_line_git_summary.is_none());
|
||||
assert!(!chat.status_line_git_summary_pending);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_output_mode_can_change_without_inserting_notice() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.set_raw_output_mode(/*enabled*/ true);
|
||||
|
||||
assert!(chat.raw_output_mode());
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
|
||||
chat.set_raw_output_mode_and_notify(/*enabled*/ false);
|
||||
|
||||
assert!(!chat.raw_output_mode());
|
||||
let history = drain_insert_history(&mut rx)
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
history.contains("Raw output mode off: rich transcript rendering restored."),
|
||||
"expected raw output notice, got {history:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn helpers_are_available_and_do_not_panic() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
@@ -5,6 +5,7 @@ use super::model::ExecCall;
|
||||
use super::model::ExecCell;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::plain_lines;
|
||||
use crate::motion::MotionMode;
|
||||
use crate::motion::ReducedMotionIndicator;
|
||||
use crate::motion::activity_indicator;
|
||||
@@ -243,6 +244,10 @@ impl HistoryCell for ExecCell {
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.transcript_lines(u16::MAX))
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecCell {
|
||||
|
||||
@@ -99,12 +99,51 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use url::Url;
|
||||
|
||||
const RAW_DIFF_SUMMARY_WIDTH: usize = 10_000;
|
||||
const RAW_TOOL_OUTPUT_WIDTH: usize = 10_000;
|
||||
|
||||
mod hook_cell;
|
||||
|
||||
pub(crate) use hook_cell::HookCell;
|
||||
pub(crate) use hook_cell::new_active_hook_cell;
|
||||
pub(crate) use hook_cell::new_completed_hook_cell;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum HistoryRenderMode {
|
||||
Rich,
|
||||
Raw,
|
||||
}
|
||||
|
||||
pub(crate) fn raw_lines_from_source(source: &str) -> Vec<Line<'static>> {
|
||||
if source.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut parts = source.split('\n').collect::<Vec<_>>();
|
||||
if source.ends_with('\n') {
|
||||
parts.pop();
|
||||
}
|
||||
|
||||
parts
|
||||
.into_iter()
|
||||
.map(|line| Line::from(line.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn plain_lines(lines: impl IntoIterator<Item = Line<'static>>) -> Vec<Line<'static>> {
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
let text = line
|
||||
.spans
|
||||
.into_iter()
|
||||
.map(|span| span.content.into_owned())
|
||||
.collect::<String>();
|
||||
Line::from(text)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A single renderable unit of conversation history.
|
||||
///
|
||||
/// Each cell produces logical `Line`s and reports how many viewport
|
||||
@@ -118,6 +157,16 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
/// Returns the logical lines for the main chat viewport.
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>>;
|
||||
|
||||
/// Returns copy-friendly plain logical lines for raw scrollback mode.
|
||||
fn raw_lines(&self) -> Vec<Line<'static>>;
|
||||
|
||||
fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec<Line<'static>> {
|
||||
match mode {
|
||||
HistoryRenderMode::Rich => self.display_lines(width),
|
||||
HistoryRenderMode::Raw => self.raw_lines(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of viewport rows needed to render this cell.
|
||||
///
|
||||
/// The default delegates to `Paragraph::line_count` with
|
||||
@@ -126,7 +175,11 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
/// for lines containing URL-like tokens that are wider than the
|
||||
/// terminal — the logical line count would undercount.
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
Paragraph::new(Text::from(self.display_lines(width)))
|
||||
self.desired_height_for_mode(width, HistoryRenderMode::Rich)
|
||||
}
|
||||
|
||||
fn desired_height_for_mode(&self, width: u16, mode: HistoryRenderMode) -> u16 {
|
||||
Paragraph::new(Text::from(self.display_lines_for_mode(width, mode)))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
@@ -391,6 +444,22 @@ impl HistoryCell for UserHistoryCell {
|
||||
lines.push(Line::from("").style(style));
|
||||
lines
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let mut lines = raw_lines_from_source(self.message.trim_end_matches(['\r', '\n']));
|
||||
if !self.remote_image_urls.is_empty() {
|
||||
if !lines.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines.extend(
|
||||
self.remote_image_urls
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, _url)| Line::from(local_image_label_text(idx.saturating_add(1)))),
|
||||
);
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -456,6 +525,14 @@ impl HistoryCell for ReasoningSummaryCell {
|
||||
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
self.lines(width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
if self.transcript_only {
|
||||
Vec::new()
|
||||
} else {
|
||||
raw_lines_from_source(self.content.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -487,6 +564,10 @@ impl HistoryCell for AgentMessageCell {
|
||||
)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.lines.clone())
|
||||
}
|
||||
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
!self.is_first_line
|
||||
}
|
||||
@@ -541,6 +622,10 @@ impl HistoryCell for AgentMarkdownCell {
|
||||
);
|
||||
prefix_lines(lines, "• ".dim(), " ".into())
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
raw_lines_from_source(&self.markdown_source)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -558,6 +643,10 @@ impl HistoryCell for PlainHistoryCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.lines.clone()
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.lines.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(debug_assertions, allow(dead_code))]
|
||||
@@ -612,6 +701,22 @@ impl HistoryCell for UpdateAvailableHistoryCell {
|
||||
.max(1);
|
||||
with_border_with_inner_width(content.lines, inner_width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let update_instruction = if let Some(update_action) = self.update_action {
|
||||
format!("Run {} to update.", update_action.command_str())
|
||||
} else {
|
||||
"See https://github.com/openai/codex for installation options.".to_string()
|
||||
};
|
||||
vec![
|
||||
Line::from("Update available!"),
|
||||
Line::from(format!("{CODEX_CLI_VERSION} -> {}", self.latest_version)),
|
||||
Line::from(update_instruction),
|
||||
Line::from(""),
|
||||
Line::from("See full release notes:"),
|
||||
Line::from("https://github.com/openai/codex/releases/latest"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -645,6 +750,10 @@ impl HistoryCell for PrefixedWrappedHistoryCell {
|
||||
.subsequent_indent(self.subsequent_prefix.clone());
|
||||
adaptive_wrap_lines(&self.text, opts)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.text.clone().lines)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -706,6 +815,38 @@ impl HistoryCell for UnifiedExecInteractionCell {
|
||||
out.extend(input_wrapped);
|
||||
out
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let mut out = Vec::new();
|
||||
if self.stdin.is_empty() {
|
||||
if let Some(command) = self
|
||||
.command_display
|
||||
.as_ref()
|
||||
.filter(|command| !command.is_empty())
|
||||
{
|
||||
out.push(Line::from(format!(
|
||||
"Waited for background terminal: {command}"
|
||||
)));
|
||||
} else {
|
||||
out.push(Line::from("Waited for background terminal"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if let Some(command) = self
|
||||
.command_display
|
||||
.as_ref()
|
||||
.filter(|command| !command.is_empty())
|
||||
{
|
||||
out.push(Line::from(format!(
|
||||
"Interacted with background terminal: {command}"
|
||||
)));
|
||||
} else {
|
||||
out.push(Line::from("Interacted with background terminal"));
|
||||
}
|
||||
out.extend(raw_lines_from_source(&self.stdin));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_unified_exec_interaction(
|
||||
@@ -837,6 +978,10 @@ impl HistoryCell for UnifiedExecProcessesCell {
|
||||
out
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.display_lines(u16::MAX))
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.display_lines(width).len() as u16
|
||||
}
|
||||
@@ -1108,6 +1253,14 @@ impl HistoryCell for PatchHistoryCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
create_diff_summary(&self.changes, &self.cwd, width as usize)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(create_diff_summary(
|
||||
&self.changes,
|
||||
&self.cwd,
|
||||
RAW_DIFF_SUMMARY_WIDTH,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1118,6 +1271,10 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
vec!["tool result (image output)".into()]
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
vec![Line::from("tool result (image output)")]
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value
|
||||
@@ -1228,6 +1385,10 @@ impl HistoryCell for TooltipHistoryCell {
|
||||
|
||||
prefix_lines(lines, indent.into(), indent.into())
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
vec![Line::from(format!("Tip: {}", self.tip))]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1245,6 +1406,10 @@ impl HistoryCell for SessionInfoCell {
|
||||
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
self.0.transcript_lines(width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
self.0.raw_lines()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_session_info(
|
||||
@@ -1539,6 +1704,27 @@ impl HistoryCell for SessionHeaderHistoryCell {
|
||||
|
||||
with_border(lines)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let mut lines = vec![
|
||||
Line::from(format!("OpenAI Codex (v{})", self.version)),
|
||||
Line::from(format!(
|
||||
"model: {}{}",
|
||||
self.model,
|
||||
self.reasoning_label()
|
||||
.map(|reasoning| format!(" {reasoning}"))
|
||||
.unwrap_or_default()
|
||||
)),
|
||||
Line::from(format!(
|
||||
"directory: {}",
|
||||
self.format_directory(/*max_width*/ None)
|
||||
)),
|
||||
];
|
||||
if self.yolo_mode {
|
||||
lines.push(Line::from("permissions: YOLO mode"));
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1568,6 +1754,22 @@ impl HistoryCell for CompositeHistoryCell {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let mut out: Vec<Line<'static>> = Vec::new();
|
||||
let mut first = true;
|
||||
for part in &self.parts {
|
||||
let mut lines = part.raw_lines();
|
||||
if !lines.is_empty() {
|
||||
if !first {
|
||||
out.push(Line::from(""));
|
||||
}
|
||||
out.append(&mut lines);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1759,6 +1961,32 @@ impl HistoryCell for McpToolCallCell {
|
||||
lines
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let header_text = if self.success().is_some() {
|
||||
"Called"
|
||||
} else {
|
||||
"Calling"
|
||||
};
|
||||
let mut lines = vec![Line::from(format!(
|
||||
"{header_text} {}",
|
||||
format_mcp_invocation(self.invocation.clone())
|
||||
))];
|
||||
|
||||
if let Some(result) = &self.result {
|
||||
match result {
|
||||
Ok(codex_protocol::mcp::CallToolResult { content, .. }) => {
|
||||
for block in content {
|
||||
let text = Self::render_content_block(block, RAW_TOOL_OUTPUT_WIDTH);
|
||||
lines.extend(raw_lines_from_source(&text));
|
||||
}
|
||||
}
|
||||
Err(err) => lines.push(Line::from(format!("Error: {err}"))),
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn transcript_animation_tick(&self) -> Option<u64> {
|
||||
if !self.animations_enabled || self.result.is_some() {
|
||||
return None;
|
||||
@@ -1881,6 +2109,16 @@ impl HistoryCell for WebSearchCell {
|
||||
};
|
||||
PrefixedWrappedHistoryCell::new(text, vec![bullet, " ".into()], " ").display_lines(width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let header = web_search_header(self.completed);
|
||||
let detail = web_search_detail(self.action.as_ref(), &self.query);
|
||||
if detail.is_empty() {
|
||||
vec![Line::from(header)]
|
||||
} else {
|
||||
vec![Line::from(format!("{header} {detail}"))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_active_web_search_call(
|
||||
@@ -2016,6 +2254,16 @@ impl HistoryCell for CyberPolicyNoticeCell {
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
vec![
|
||||
Line::from("This chat was flagged for possible cybersecurity risk"),
|
||||
Line::from(
|
||||
"If this seems wrong, try rephrasing your request. To get authorized for security work, join the Trusted Access for Cyber program.",
|
||||
),
|
||||
Line::from(TRUSTED_ACCESS_FOR_CYBER_URL),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -2046,6 +2294,14 @@ impl HistoryCell for DeprecationNoticeCell {
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let mut lines = vec![Line::from(self.summary.clone())];
|
||||
if let Some(details) = &self.details {
|
||||
lines.extend(raw_lines_from_source(details));
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a summary of configured MCP servers from the current `Config`.
|
||||
@@ -2494,6 +2750,10 @@ impl HistoryCell for McpInventoryLoadingCell {
|
||||
]
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
vec![Line::from("Loading MCP inventory...")]
|
||||
}
|
||||
|
||||
fn transcript_animation_tick(&self) -> Option<u64> {
|
||||
if !self.animations_enabled {
|
||||
return None;
|
||||
@@ -2612,6 +2872,48 @@ impl HistoryCell for RequestUserInputResultCell {
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let total = self.questions.len();
|
||||
let answered = self
|
||||
.questions
|
||||
.iter()
|
||||
.filter(|question| {
|
||||
self.answers
|
||||
.get(&question.id)
|
||||
.is_some_and(|answer| !answer.answers.is_empty())
|
||||
})
|
||||
.count();
|
||||
let mut lines = vec![Line::from(format!("Questions {answered}/{total} answered"))];
|
||||
if self.interrupted {
|
||||
lines.push(Line::from("(interrupted)"));
|
||||
}
|
||||
for question in &self.questions {
|
||||
lines.push(Line::from(question.question.clone()));
|
||||
if let Some(answer) = self
|
||||
.answers
|
||||
.get(&question.id)
|
||||
.filter(|answer| !answer.answers.is_empty())
|
||||
{
|
||||
if question.is_secret {
|
||||
lines.push(Line::from("answer: ******"));
|
||||
} else {
|
||||
let (options, note) = split_request_user_input_answer(answer);
|
||||
lines.extend(
|
||||
options
|
||||
.into_iter()
|
||||
.map(|option| Line::from(format!("answer: {option}"))),
|
||||
);
|
||||
if let Some(note) = note {
|
||||
lines.push(Line::from(format!("note: {note}")));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push(Line::from("(unanswered)"));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a plain string with textwrap and prefix each line, while applying a style to the content.
|
||||
@@ -2728,6 +3030,10 @@ impl HistoryCell for ProposedPlanCell {
|
||||
lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style)));
|
||||
lines
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
raw_lines_from_source(&self.plan_markdown)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for ProposedPlanStreamCell {
|
||||
@@ -2735,6 +3041,10 @@ impl HistoryCell for ProposedPlanStreamCell {
|
||||
self.lines.clone()
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.lines.clone())
|
||||
}
|
||||
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
self.is_stream_continuation
|
||||
}
|
||||
@@ -2798,6 +3108,26 @@ impl HistoryCell for PlanUpdateCell {
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let mut lines = vec![Line::from("Updated Plan")];
|
||||
if let Some(explanation) = self
|
||||
.explanation
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
lines.extend(raw_lines_from_source(explanation));
|
||||
}
|
||||
if self.plan.is_empty() {
|
||||
lines.push(Line::from("(no steps provided)"));
|
||||
} else {
|
||||
for PlanItemArg { step, status } in &self.plan {
|
||||
lines.push(Line::from(format!("{status:?}: {step}")));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new `PendingPatch` cell that lists the file‑level summary of
|
||||
@@ -2959,6 +3289,25 @@ impl HistoryCell for FinalMessageSeparator {
|
||||
.dim(),
|
||||
]
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let mut label_parts = Vec::new();
|
||||
if let Some(elapsed_seconds) = self
|
||||
.elapsed_seconds
|
||||
.filter(|seconds| *seconds > 60)
|
||||
.map(super::status_indicator_widget::fmt_elapsed_compact)
|
||||
{
|
||||
label_parts.push(format!("Worked for {elapsed_seconds}"));
|
||||
}
|
||||
if let Some(metrics_label) = self.runtime_metrics.and_then(runtime_metrics_label) {
|
||||
label_parts.push(metrics_label);
|
||||
}
|
||||
if label_parts.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![Line::from(label_parts.join(" • "))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_metrics_label(summary: RuntimeMetricsSummary) -> Option<String> {
|
||||
@@ -3225,6 +3574,15 @@ mod tests {
|
||||
render_lines(&cell.transcript_lines(u16::MAX))
|
||||
}
|
||||
|
||||
fn assert_unstyled_lines(lines: &[Line<'static>]) {
|
||||
for line in lines {
|
||||
assert_eq!(line.style, Style::default());
|
||||
for span in &line.spans {
|
||||
assert_eq!(span.style, Style::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn image_block(data: &str) -> serde_json::Value {
|
||||
serde_json::to_value(Content::image(data.to_string(), "image/png"))
|
||||
.expect("image content should serialize")
|
||||
@@ -3253,6 +3611,185 @@ mod tests {
|
||||
.expect("resource link content should serialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_lines_from_source_preserves_explicit_blank_lines() {
|
||||
let lines = raw_lines_from_source("alpha\n\nbeta\n");
|
||||
|
||||
assert_eq!(
|
||||
render_lines(&lines),
|
||||
vec!["alpha".to_string(), String::new(), "beta".to_string()]
|
||||
);
|
||||
assert_unstyled_lines(&lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_lines_from_source_preserves_trailing_blank_but_not_trailing_newline() {
|
||||
assert_eq!(
|
||||
render_lines(&raw_lines_from_source("alpha\n\n")),
|
||||
vec!["alpha".to_string(), String::new()]
|
||||
);
|
||||
assert_eq!(raw_lines_from_source(""), Vec::<Line<'static>>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_backed_cells_render_raw_source_without_prefix_or_style() {
|
||||
let user = new_user_prompt(
|
||||
"hello\n\nworld\n".to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
let assistant = AgentMarkdownCell::new(
|
||||
"- item\n\n| A | B |\n| - | - |\n| x | y |\n".to_string(),
|
||||
&test_cwd(),
|
||||
);
|
||||
let reasoning = ReasoningSummaryCell::new(
|
||||
"thinking".to_string(),
|
||||
"first thought\n\nsecond thought".to_string(),
|
||||
&test_cwd(),
|
||||
/*transcript_only*/ false,
|
||||
);
|
||||
let plan = new_proposed_plan(
|
||||
"1. Inspect\n\n```sh\ncargo test\n```".to_string(),
|
||||
&test_cwd(),
|
||||
);
|
||||
|
||||
let user_lines = user.raw_lines();
|
||||
assert_eq!(
|
||||
render_lines(&user_lines),
|
||||
vec!["hello".to_string(), String::new(), "world".to_string()]
|
||||
);
|
||||
assert_unstyled_lines(&user_lines);
|
||||
|
||||
let assistant_lines = assistant.raw_lines();
|
||||
assert_eq!(
|
||||
render_lines(&assistant_lines),
|
||||
vec![
|
||||
"- item".to_string(),
|
||||
String::new(),
|
||||
"| A | B |".to_string(),
|
||||
"| - | - |".to_string(),
|
||||
"| x | y |".to_string(),
|
||||
]
|
||||
);
|
||||
assert_unstyled_lines(&assistant_lines);
|
||||
|
||||
let reasoning_lines = reasoning.raw_lines();
|
||||
assert_eq!(
|
||||
render_lines(&reasoning_lines),
|
||||
vec![
|
||||
"first thought".to_string(),
|
||||
String::new(),
|
||||
"second thought".to_string(),
|
||||
]
|
||||
);
|
||||
assert_unstyled_lines(&reasoning_lines);
|
||||
|
||||
let plan_lines = plan.raw_lines();
|
||||
assert_eq!(
|
||||
render_lines(&plan_lines),
|
||||
vec![
|
||||
"1. Inspect".to_string(),
|
||||
String::new(),
|
||||
"```sh".to_string(),
|
||||
"cargo test".to_string(),
|
||||
"```".to_string(),
|
||||
]
|
||||
);
|
||||
assert_unstyled_lines(&plan_lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_tool_cell_renders_raw_plain_text_without_prefix_or_style() {
|
||||
let invocation = McpInvocation {
|
||||
server: "search".into(),
|
||||
tool: "find_docs".into(),
|
||||
arguments: Some(json!({"query": "raw mode"})),
|
||||
};
|
||||
let result = CallToolResult {
|
||||
content: vec![text_block("alpha\nbeta")],
|
||||
is_error: None,
|
||||
structured_content: None,
|
||||
meta: None,
|
||||
};
|
||||
let mut cell = new_active_mcp_tool_call(
|
||||
"call-raw".to_string(),
|
||||
invocation,
|
||||
/*animations_enabled*/ false,
|
||||
);
|
||||
assert!(
|
||||
cell.complete(Duration::from_millis(1), Ok(result))
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let lines = cell.raw_lines();
|
||||
let rendered = render_lines(&lines);
|
||||
assert!(rendered[0].starts_with("Called search.find_docs("));
|
||||
assert_eq!(rendered[1..], ["alpha".to_string(), "beta".to_string()]);
|
||||
assert_unstyled_lines(&lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_mode_toggle_transcript_snapshot() {
|
||||
let mut tool_cell = new_active_mcp_tool_call(
|
||||
"call-snapshot".to_string(),
|
||||
McpInvocation {
|
||||
server: "workspace".to_string(),
|
||||
tool: "inspect".to_string(),
|
||||
arguments: Some(json!({"path": "README.md"})),
|
||||
},
|
||||
/*animations_enabled*/ false,
|
||||
);
|
||||
assert!(
|
||||
tool_cell
|
||||
.complete(
|
||||
Duration::from_millis(5),
|
||||
Ok(CallToolResult {
|
||||
content: vec![text_block("structured output\nsecond line")],
|
||||
is_error: None,
|
||||
structured_content: None,
|
||||
meta: None,
|
||||
}),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let cells: Vec<Box<dyn HistoryCell>> = vec![
|
||||
Box::new(new_user_prompt(
|
||||
"Please format this\nfor copying".to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)),
|
||||
Box::new(AgentMarkdownCell::new(
|
||||
"- first item\n- second item\n\n| Col | Value |\n| --- | --- |\n| code | `x = 1` |\n\n```text\ncopy me\n```".to_string(),
|
||||
&test_cwd(),
|
||||
)),
|
||||
Box::new(tool_cell),
|
||||
];
|
||||
|
||||
let render = |mode| {
|
||||
cells
|
||||
.iter()
|
||||
.flat_map(|cell| cell.display_lines_for_mode(/*width*/ 40, mode))
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.into_iter()
|
||||
.map(|span| span.content.into_owned())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
let rendered = format!(
|
||||
"rich before:\n{}\n\nraw on:\n{}\n\nrich after:\n{}",
|
||||
render(HistoryRenderMode::Rich),
|
||||
render(HistoryRenderMode::Raw),
|
||||
render(HistoryRenderMode::Rich)
|
||||
);
|
||||
|
||||
insta::assert_snapshot!("raw_mode_toggle_transcript", rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_generation_call_renders_saved_path() {
|
||||
let saved_path = test_path_buf("/tmp/generated-image.png").abs();
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! first drawn.
|
||||
//! 4. Completed runs only persist when they have output or a non-success status.
|
||||
use super::HistoryCell;
|
||||
use super::plain_lines;
|
||||
use crate::motion::MotionMode;
|
||||
use crate::motion::ReducedMotionIndicator;
|
||||
use crate::motion::activity_indicator;
|
||||
@@ -340,6 +341,10 @@ impl HistoryCell for HookCell {
|
||||
self.display_lines(width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.display_lines(u16::MAX))
|
||||
}
|
||||
|
||||
/// Produces a coarse cache key for transcript overlays while hook animations are active.
|
||||
fn transcript_animation_tick(&self) -> Option<u64> {
|
||||
if !self.animations_enabled {
|
||||
|
||||
@@ -57,6 +57,12 @@ impl InsertHistoryMode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HistoryLineWrapPolicy {
|
||||
PreWrap,
|
||||
Terminal,
|
||||
}
|
||||
|
||||
/// Insert `lines` above the viewport using the terminal's backend writer
|
||||
/// (avoids direct stdout references).
|
||||
pub fn insert_history_lines<B>(
|
||||
@@ -83,6 +89,23 @@ pub fn insert_history_lines_with_mode<B>(
|
||||
lines: Vec<Line>,
|
||||
mode: InsertHistoryMode,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
{
|
||||
insert_history_lines_with_mode_and_wrap_policy(
|
||||
terminal,
|
||||
lines,
|
||||
mode,
|
||||
HistoryLineWrapPolicy::PreWrap,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn insert_history_lines_with_mode_and_wrap_policy<B>(
|
||||
terminal: &mut crate::custom_terminal::Terminal<B>,
|
||||
lines: Vec<Line>,
|
||||
mode: InsertHistoryMode,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
{
|
||||
@@ -109,12 +132,15 @@ where
|
||||
let mut wrapped_rows = 0usize;
|
||||
|
||||
for line in &lines {
|
||||
let line_wrapped =
|
||||
if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) {
|
||||
let line_wrapped = match wrap_policy {
|
||||
HistoryLineWrapPolicy::Terminal => vec![line.clone()],
|
||||
HistoryLineWrapPolicy::PreWrap
|
||||
if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) =>
|
||||
{
|
||||
vec![line.clone()]
|
||||
} else {
|
||||
adaptive_wrap_line(line, RtOptions::new(wrap_width))
|
||||
};
|
||||
}
|
||||
HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line(line, RtOptions::new(wrap_width)),
|
||||
};
|
||||
wrapped_rows += line_wrapped
|
||||
.iter()
|
||||
.map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width))
|
||||
@@ -738,6 +764,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_terminal_wrap_policy_does_not_pre_wrap_long_paragraph() {
|
||||
let width: u16 = 20;
|
||||
let height: u16 = 8;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
||||
let viewport = Rect::new(0, height - 1, width, 1);
|
||||
term.set_viewport_area(viewport);
|
||||
|
||||
let line = Line::from("alpha beta gamma delta epsilon zeta");
|
||||
|
||||
insert_history_lines_with_mode_and_wrap_policy(
|
||||
&mut term,
|
||||
vec![line],
|
||||
InsertHistoryMode::Standard,
|
||||
HistoryLineWrapPolicy::Terminal,
|
||||
)
|
||||
.expect("insert raw history");
|
||||
|
||||
let rows: Vec<String> = term.backend().vt100().screen().rows(0, width).collect();
|
||||
assert!(
|
||||
rows.iter()
|
||||
.any(|row| row.trim_end() == "alpha beta gamma del"),
|
||||
"expected terminal soft-wrap instead of Codex word pre-wrap, rows: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_unwrapped_url_like_clears_continuation_rows() {
|
||||
let width: u16 = 20;
|
||||
|
||||
@@ -65,6 +65,8 @@ pub(crate) struct AppKeymap {
|
||||
pub(crate) toggle_vim_mode: Vec<KeyBinding>,
|
||||
/// Toggle Fast mode.
|
||||
pub(crate) toggle_fast_mode: Vec<KeyBinding>,
|
||||
/// Toggle raw scrollback mode for copy-friendly transcript selection.
|
||||
pub(crate) toggle_raw_output: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
/// Chat-level keybindings evaluated at the app event layer.
|
||||
@@ -377,6 +379,11 @@ impl RuntimeKeymap {
|
||||
&defaults.app.toggle_fast_mode,
|
||||
"tui.keymap.global.toggle_fast_mode",
|
||||
)?,
|
||||
toggle_raw_output: resolve_bindings(
|
||||
keymap.global.toggle_raw_output.as_ref(),
|
||||
&defaults.app.toggle_raw_output,
|
||||
"tui.keymap.global.toggle_raw_output",
|
||||
)?,
|
||||
};
|
||||
|
||||
let chat = ChatKeymap {
|
||||
@@ -546,6 +553,7 @@ impl RuntimeKeymap {
|
||||
clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))],
|
||||
toggle_vim_mode: default_bindings![],
|
||||
toggle_fast_mode: default_bindings![],
|
||||
toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))],
|
||||
},
|
||||
chat: ChatKeymap {
|
||||
decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))],
|
||||
@@ -754,6 +762,7 @@ impl RuntimeKeymap {
|
||||
("clear_terminal", self.app.clear_terminal.as_slice()),
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
||||
(
|
||||
"chat.decrease_reasoning_effort",
|
||||
self.chat.decrease_reasoning_effort.as_slice(),
|
||||
@@ -795,6 +804,7 @@ impl RuntimeKeymap {
|
||||
("clear_terminal", self.app.clear_terminal.as_slice()),
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
||||
(
|
||||
"chat.decrease_reasoning_effort",
|
||||
self.chat.decrease_reasoning_effort.as_slice(),
|
||||
@@ -837,6 +847,7 @@ impl RuntimeKeymap {
|
||||
("clear_terminal", self.app.clear_terminal.as_slice()),
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
||||
],
|
||||
[
|
||||
("list.move_up", self.list.move_up.as_slice()),
|
||||
@@ -886,6 +897,7 @@ impl RuntimeKeymap {
|
||||
("composer.submit", self.composer.submit.as_slice()),
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
||||
(
|
||||
"composer.history_search_previous",
|
||||
self.composer.history_search_previous.as_slice(),
|
||||
@@ -1908,6 +1920,28 @@ mod tests {
|
||||
assert!(runtime.composer.toggle_shortcuts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_output_toggle_defaults_to_alt_r() {
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
assert_eq!(
|
||||
runtime.app.toggle_raw_output,
|
||||
vec![key_hint::alt(KeyCode::Char('r'))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_output_toggle_can_be_remapped() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.global.toggle_raw_output = Some(one("f12"));
|
||||
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
||||
|
||||
assert_eq!(
|
||||
runtime.app.toggle_raw_output,
|
||||
vec![key_hint::plain(KeyCode::F(12))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_editor_insert_newline_includes_current_aliases() {
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
|
||||
@@ -92,6 +92,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("global", "Global", "clear_terminal", "Clear the terminal UI."),
|
||||
action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."),
|
||||
gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode),
|
||||
action("global", "Global", "toggle_raw_output", "Toggle raw scrollback mode."),
|
||||
action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."),
|
||||
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
|
||||
action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."),
|
||||
@@ -213,6 +214,7 @@ pub(super) fn binding_slot<'a>(
|
||||
("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal),
|
||||
("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode),
|
||||
("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode),
|
||||
("global", "toggle_raw_output") => Some(&mut keymap.global.toggle_raw_output),
|
||||
("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort),
|
||||
("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort),
|
||||
("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message),
|
||||
@@ -316,6 +318,7 @@ pub(super) fn bindings_for_action<'a>(
|
||||
("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()),
|
||||
("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()),
|
||||
("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()),
|
||||
("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.as_slice()),
|
||||
("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()),
|
||||
("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()),
|
||||
("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()),
|
||||
|
||||
@@ -941,6 +941,10 @@ mod tests {
|
||||
self.lines.clone()
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
self.lines.clone()
|
||||
}
|
||||
|
||||
fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.lines.clone()
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ pub enum SlashCommand {
|
||||
Agent,
|
||||
Side,
|
||||
Copy,
|
||||
Raw,
|
||||
Diff,
|
||||
Mention,
|
||||
Status,
|
||||
@@ -88,6 +89,7 @@ impl SlashCommand {
|
||||
SlashCommand::Fork => "fork the current chat",
|
||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
||||
SlashCommand::Copy => "copy last response as markdown",
|
||||
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
|
||||
@@ -153,6 +155,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Ide
|
||||
| SlashCommand::Keymap
|
||||
| SlashCommand::Mcp
|
||||
| SlashCommand::Raw
|
||||
| SlashCommand::Side
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::SandboxReadRoot
|
||||
@@ -164,6 +167,7 @@ impl SlashCommand {
|
||||
matches!(
|
||||
self,
|
||||
SlashCommand::Copy
|
||||
| SlashCommand::Raw
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Status
|
||||
@@ -197,6 +201,7 @@ impl SlashCommand {
|
||||
| SlashCommand::MemoryUpdate => false,
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Copy
|
||||
| SlashCommand::Raw
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Skills
|
||||
@@ -268,6 +273,9 @@ mod tests {
|
||||
assert!(SlashCommand::Ide.available_during_task());
|
||||
assert!(SlashCommand::Title.available_during_task());
|
||||
assert!(SlashCommand::Statusline.available_during_task());
|
||||
assert!(SlashCommand::Raw.available_during_task());
|
||||
assert!(SlashCommand::Raw.available_in_side_conversation());
|
||||
assert!(SlashCommand::Raw.supports_inline_args());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
expression: rendered
|
||||
---
|
||||
rich before:
|
||||
|
||||
› Please format this
|
||||
for copying
|
||||
|
||||
• - first item
|
||||
- second item
|
||||
|
||||
| Col | Value |
|
||||
| --- | --- |
|
||||
| code | x = 1 |
|
||||
|
||||
copy me
|
||||
• Called
|
||||
└ workspace.inspect({"path":"README.md
|
||||
"})
|
||||
structured output
|
||||
second line
|
||||
|
||||
raw on:
|
||||
Please format this
|
||||
for copying
|
||||
- first item
|
||||
- second item
|
||||
|
||||
| Col | Value |
|
||||
| --- | --- |
|
||||
| code | `x = 1` |
|
||||
|
||||
```text
|
||||
copy me
|
||||
```
|
||||
Called workspace.inspect({"path":"README.md"})
|
||||
structured output
|
||||
second line
|
||||
|
||||
rich after:
|
||||
|
||||
› Please format this
|
||||
for copying
|
||||
|
||||
• - first item
|
||||
- second item
|
||||
|
||||
| Col | Value |
|
||||
| --- | --- |
|
||||
| code | x = 1 |
|
||||
|
||||
copy me
|
||||
• Called
|
||||
└ workspace.inspect({"path":"README.md
|
||||
"})
|
||||
structured output
|
||||
second line
|
||||
+1
-1
@@ -7,10 +7,10 @@ Open External Editor | ctrl-g | Global open_external_editor Open External Editor
|
||||
Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default
|
||||
Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default
|
||||
Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default
|
||||
Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default
|
||||
Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default
|
||||
Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default
|
||||
Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default
|
||||
Submit | enter | Composer submit Submit Submit the current composer draft. enter Default
|
||||
Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default
|
||||
Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default
|
||||
History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
86 actions, 1 customized, 2 unbound.
|
||||
87 actions, 1 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
@@ -15,8 +15,8 @@ expression: "render_picker(params, 120)"
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
Chat Edit Queued Message alt-up, shift-left
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
87 actions, 0 customized, 3 unbound.
|
||||
88 actions, 0 customized, 3 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
@@ -16,7 +16,7 @@ expression: "render_picker(params, 120)"
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global - Toggle Fast Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+3
-3
@@ -2,11 +2,11 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (86 selectable)
|
||||
tab: All (87 selectable)
|
||||
tab: Common (19 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (2) (2 selectable)
|
||||
tab: App (8 selectable)
|
||||
tab: App (9 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (17 selectable)
|
||||
tab: Vim (34 selectable)
|
||||
@@ -18,10 +18,10 @@ Open External Editor | ctrl-g | Global open_external_editor Open External Editor
|
||||
Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default
|
||||
Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default
|
||||
Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default
|
||||
Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default
|
||||
Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default
|
||||
Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default
|
||||
Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default
|
||||
Submit | enter | Composer submit Submit Submit the current composer draft. enter Default
|
||||
Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default
|
||||
Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default
|
||||
History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
86 actions, 0 customized, 2 unbound.
|
||||
87 actions, 0 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
|
||||
Navigation Approval Debug
|
||||
@@ -16,8 +16,8 @@ expression: "render_picker(params, 78)"
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
Chat Edit Queued Message alt-up, shift-left
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
86 actions, 0 customized, 2 unbound.
|
||||
87 actions, 0 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
@@ -15,8 +15,8 @@ expression: "render_picker(params, 120)"
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
Chat Edit Queued Message alt-up, shift-left
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::history_cell::CompositeHistoryCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::history_cell::plain_lines;
|
||||
use crate::history_cell::with_border_with_inner_width;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::token_usage::TokenUsage;
|
||||
@@ -788,6 +789,10 @@ impl HistoryCell for StatusHistoryCell {
|
||||
|
||||
with_border_with_inner_width(truncated_lines, inner_width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.display_lines(u16::MAX))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_model_provider(config: &Config, runtime_base_url: Option<&str>) -> Option<String> {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
//! scrollback from finalized cells.
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::HistoryRenderMode;
|
||||
use crate::history_cell::raw_lines_from_source;
|
||||
use crate::history_cell::{self};
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
@@ -39,10 +41,11 @@ struct StreamCore {
|
||||
enqueued_len: usize,
|
||||
emitted_len: usize,
|
||||
cwd: PathBuf,
|
||||
render_mode: HistoryRenderMode,
|
||||
}
|
||||
|
||||
impl StreamCore {
|
||||
fn new(width: Option<usize>, cwd: &Path) -> Self {
|
||||
fn new(width: Option<usize>, cwd: &Path, render_mode: HistoryRenderMode) -> Self {
|
||||
Self {
|
||||
state: StreamState::new(width, cwd),
|
||||
width,
|
||||
@@ -51,6 +54,7 @@ impl StreamCore {
|
||||
enqueued_len: 0,
|
||||
emitted_len: 0,
|
||||
cwd: cwd.to_path_buf(),
|
||||
render_mode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,13 +81,7 @@ impl StreamCore {
|
||||
self.raw_source.push_str(&remainder_source);
|
||||
}
|
||||
|
||||
let mut rendered = Vec::new();
|
||||
append_markdown(
|
||||
&self.raw_source,
|
||||
self.width,
|
||||
Some(self.cwd.as_path()),
|
||||
&mut rendered,
|
||||
);
|
||||
let rendered = self.render_source(&self.raw_source);
|
||||
if self.emitted_len >= rendered.len() {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -150,6 +148,27 @@ impl StreamCore {
|
||||
self.rebuild_queue_from_render();
|
||||
}
|
||||
|
||||
fn set_render_mode(&mut self, render_mode: HistoryRenderMode) {
|
||||
if self.render_mode == render_mode {
|
||||
return;
|
||||
}
|
||||
|
||||
let had_pending_queue = self.state.queued_len() > 0;
|
||||
self.render_mode = render_mode;
|
||||
if self.raw_source.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.recompute_render();
|
||||
self.emitted_len = self.emitted_len.min(self.rendered_lines.len());
|
||||
self.state.clear_queue();
|
||||
if self.emitted_len > 0 && !had_pending_queue {
|
||||
self.enqueued_len = self.rendered_lines.len();
|
||||
return;
|
||||
}
|
||||
self.rebuild_queue_from_render();
|
||||
}
|
||||
|
||||
fn clear_queue(&mut self) {
|
||||
self.state.clear_queue();
|
||||
self.enqueued_len = self.emitted_len;
|
||||
@@ -164,13 +183,18 @@ impl StreamCore {
|
||||
}
|
||||
|
||||
fn recompute_render(&mut self) {
|
||||
self.rendered_lines.clear();
|
||||
append_markdown(
|
||||
&self.raw_source,
|
||||
self.width,
|
||||
Some(self.cwd.as_path()),
|
||||
&mut self.rendered_lines,
|
||||
);
|
||||
self.rendered_lines = self.render_source(&self.raw_source);
|
||||
}
|
||||
|
||||
fn render_source(&self, source: &str) -> Vec<Line<'static>> {
|
||||
match self.render_mode {
|
||||
HistoryRenderMode::Rich => {
|
||||
let mut rendered = Vec::new();
|
||||
append_markdown(source, self.width, Some(self.cwd.as_path()), &mut rendered);
|
||||
rendered
|
||||
}
|
||||
HistoryRenderMode::Raw => raw_lines_from_source(source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append newly rendered lines to the live queue without replaying already queued rows.
|
||||
@@ -227,9 +251,9 @@ impl StreamController {
|
||||
/// `width` is the content width available to markdown rendering, not necessarily the full
|
||||
/// terminal width. Passing a stale width after resize will keep queued live output wrapped for
|
||||
/// the old viewport until app-level reflow repairs the finalized transcript.
|
||||
pub(crate) fn new(width: Option<usize>, cwd: &Path) -> Self {
|
||||
pub(crate) fn new(width: Option<usize>, cwd: &Path, render_mode: HistoryRenderMode) -> Self {
|
||||
Self {
|
||||
core: StreamCore::new(width, cwd),
|
||||
core: StreamCore::new(width, cwd, render_mode),
|
||||
header_emitted: false,
|
||||
}
|
||||
}
|
||||
@@ -289,6 +313,10 @@ impl StreamController {
|
||||
self.core.set_width(width);
|
||||
}
|
||||
|
||||
pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) {
|
||||
self.core.set_render_mode(render_mode);
|
||||
}
|
||||
|
||||
fn emit(&mut self, lines: Vec<Line<'static>>) -> Option<Box<dyn HistoryCell>> {
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
@@ -317,9 +345,9 @@ impl PlanStreamController {
|
||||
///
|
||||
/// The width has the same meaning as in `StreamController`: it is the markdown body width, and
|
||||
/// callers must update it when the terminal width changes.
|
||||
pub(crate) fn new(width: Option<usize>, cwd: &Path) -> Self {
|
||||
pub(crate) fn new(width: Option<usize>, cwd: &Path, render_mode: HistoryRenderMode) -> Self {
|
||||
Self {
|
||||
core: StreamCore::new(width, cwd),
|
||||
core: StreamCore::new(width, cwd, render_mode),
|
||||
header_emitted: false,
|
||||
top_padding_emitted: false,
|
||||
}
|
||||
@@ -385,6 +413,10 @@ impl PlanStreamController {
|
||||
self.core.set_width(width);
|
||||
}
|
||||
|
||||
pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) {
|
||||
self.core.set_render_mode(render_mode);
|
||||
}
|
||||
|
||||
fn emit(
|
||||
&mut self,
|
||||
lines: Vec<Line<'static>>,
|
||||
@@ -436,11 +468,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn stream_controller(width: Option<usize>) -> StreamController {
|
||||
StreamController::new(width, &test_cwd())
|
||||
StreamController::new(width, &test_cwd(), HistoryRenderMode::Rich)
|
||||
}
|
||||
|
||||
fn plan_stream_controller(width: Option<usize>) -> PlanStreamController {
|
||||
PlanStreamController::new(width, &test_cwd())
|
||||
PlanStreamController::new(width, &test_cwd(), HistoryRenderMode::Rich)
|
||||
}
|
||||
|
||||
fn lines_to_plain_strings(lines: &[Line<'_>]) -> Vec<String> {
|
||||
|
||||
+35
-8
@@ -40,6 +40,7 @@ use tokio_stream::Stream;
|
||||
pub use self::frame_requester::FrameRequester;
|
||||
use crate::custom_terminal;
|
||||
use crate::custom_terminal::Terminal as CustomTerminal;
|
||||
use crate::insert_history::HistoryLineWrapPolicy;
|
||||
use crate::notifications::DesktopNotificationBackend;
|
||||
use crate::notifications::detect_backend;
|
||||
use crate::tui::event_stream::EventBroker;
|
||||
@@ -369,7 +370,7 @@ pub struct Tui {
|
||||
draw_tx: broadcast::Sender<()>,
|
||||
event_broker: Arc<EventBroker>,
|
||||
pub(crate) terminal: Terminal,
|
||||
pending_history_lines: Vec<Line<'static>>,
|
||||
pending_history_lines: Vec<PendingHistoryLines>,
|
||||
alt_saved_viewport: Option<ratatui::layout::Rect>,
|
||||
#[cfg(unix)]
|
||||
suspend_context: SuspendContext,
|
||||
@@ -385,6 +386,11 @@ pub struct Tui {
|
||||
alt_screen_enabled: bool,
|
||||
}
|
||||
|
||||
struct PendingHistoryLines {
|
||||
lines: Vec<Line<'static>>,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
}
|
||||
|
||||
impl Tui {
|
||||
pub fn new(terminal: Terminal) -> Self {
|
||||
let (draw_tx, _) = broadcast::channel(1);
|
||||
@@ -582,7 +588,25 @@ impl Tui {
|
||||
}
|
||||
|
||||
pub fn insert_history_lines(&mut self, lines: Vec<Line<'static>>) {
|
||||
self.pending_history_lines.extend(lines);
|
||||
self.insert_history_lines_with_wrap_policy(lines, HistoryLineWrapPolicy::PreWrap);
|
||||
}
|
||||
|
||||
pub fn insert_history_lines_with_wrap_policy(
|
||||
&mut self,
|
||||
lines: Vec<Line<'static>>,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(last) = self.pending_history_lines.last_mut()
|
||||
&& last.wrap_policy == wrap_policy
|
||||
{
|
||||
last.lines.extend(lines);
|
||||
} else {
|
||||
self.pending_history_lines
|
||||
.push(PendingHistoryLines { lines, wrap_policy });
|
||||
}
|
||||
self.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
@@ -698,18 +722,21 @@ impl Tui {
|
||||
/// invalidate the diff buffer for a full repaint.
|
||||
fn flush_pending_history_lines(
|
||||
terminal: &mut Terminal,
|
||||
pending_history_lines: &mut Vec<Line<'static>>,
|
||||
pending_history_lines: &mut Vec<PendingHistoryLines>,
|
||||
is_zellij: bool,
|
||||
) -> Result<bool> {
|
||||
if pending_history_lines.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
crate::insert_history::insert_history_lines_with_mode(
|
||||
terminal,
|
||||
pending_history_lines.clone(),
|
||||
crate::insert_history::InsertHistoryMode::new(is_zellij),
|
||||
)?;
|
||||
for batch in pending_history_lines.iter() {
|
||||
crate::insert_history::insert_history_lines_with_mode_and_wrap_policy(
|
||||
terminal,
|
||||
batch.lines.clone(),
|
||||
crate::insert_history::InsertHistoryMode::new(is_zellij),
|
||||
batch.wrap_policy,
|
||||
)?;
|
||||
}
|
||||
pending_history_lines.clear();
|
||||
Ok(is_zellij)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user