Add session header to chat widget (#3592)
<img width="570" height="332" alt="image" src="https://github.com/user-attachments/assets/ca6dfcb0-f3a1-4b3e-978d-4f844ba77527" />
This commit is contained in:
@@ -295,7 +295,7 @@ impl App {
|
|||||||
self.on_update_reasoning_effort(effort);
|
self.on_update_reasoning_effort(effort);
|
||||||
}
|
}
|
||||||
AppEvent::UpdateModel(model) => {
|
AppEvent::UpdateModel(model) => {
|
||||||
self.chat_widget.set_model(model.clone());
|
self.chat_widget.set_model(&model);
|
||||||
self.config.model = model.clone();
|
self.config.model = model.clone();
|
||||||
if let Some(family) = find_family_for_model(&model) {
|
if let Some(family) = find_family_for_model(&model) {
|
||||||
self.config.model_family = family;
|
self.config.model_family = family;
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ use self::interrupts::InterruptManager;
|
|||||||
mod agent;
|
mod agent;
|
||||||
use self::agent::spawn_agent;
|
use self::agent::spawn_agent;
|
||||||
use self::agent::spawn_agent_from_existing;
|
use self::agent::spawn_agent_from_existing;
|
||||||
|
mod session_header;
|
||||||
|
use self::session_header::SessionHeader;
|
||||||
use crate::streaming::controller::AppEventHistorySink;
|
use crate::streaming::controller::AppEventHistorySink;
|
||||||
use crate::streaming::controller::StreamController;
|
use crate::streaming::controller::StreamController;
|
||||||
use codex_common::approval_presets::ApprovalPreset;
|
use codex_common::approval_presets::ApprovalPreset;
|
||||||
@@ -109,6 +111,7 @@ pub(crate) struct ChatWidget {
|
|||||||
bottom_pane: BottomPane,
|
bottom_pane: BottomPane,
|
||||||
active_exec_cell: Option<ExecCell>,
|
active_exec_cell: Option<ExecCell>,
|
||||||
config: Config,
|
config: Config,
|
||||||
|
session_header: SessionHeader,
|
||||||
initial_user_message: Option<UserMessage>,
|
initial_user_message: Option<UserMessage>,
|
||||||
token_info: Option<TokenUsageInfo>,
|
token_info: Option<TokenUsageInfo>,
|
||||||
// Stream lifecycle controller
|
// Stream lifecycle controller
|
||||||
@@ -165,9 +168,11 @@ impl ChatWidget {
|
|||||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||||
self.conversation_id = Some(event.session_id);
|
self.conversation_id = Some(event.session_id);
|
||||||
let initial_messages = event.initial_messages.clone();
|
let initial_messages = event.initial_messages.clone();
|
||||||
|
let model_for_header = event.model.clone();
|
||||||
if let Some(messages) = initial_messages {
|
if let Some(messages) = initial_messages {
|
||||||
self.replay_initial_messages(messages);
|
self.replay_initial_messages(messages);
|
||||||
}
|
}
|
||||||
|
self.session_header.set_model(&model_for_header);
|
||||||
self.add_to_history(history_cell::new_session_info(
|
self.add_to_history(history_cell::new_session_info(
|
||||||
&self.config,
|
&self.config,
|
||||||
event,
|
event,
|
||||||
@@ -609,14 +614,23 @@ impl ChatWidget {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn layout_areas(&self, area: Rect) -> [Rect; 2] {
|
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
||||||
|
let bottom_min = self.bottom_pane.desired_height(area.width).min(area.height);
|
||||||
|
let remaining = area.height.saturating_sub(bottom_min);
|
||||||
|
|
||||||
|
let active_desired = self
|
||||||
|
.active_exec_cell
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |c| c.desired_height(area.width) + 1);
|
||||||
|
let active_height = active_desired.min(remaining);
|
||||||
|
// Note: no header area; remaining is not used beyond computing active height.
|
||||||
|
|
||||||
|
let header_height = 0u16;
|
||||||
|
|
||||||
Layout::vertical([
|
Layout::vertical([
|
||||||
Constraint::Max(
|
Constraint::Length(header_height),
|
||||||
self.active_exec_cell
|
Constraint::Length(active_height),
|
||||||
.as_ref()
|
Constraint::Min(bottom_min),
|
||||||
.map_or(0, |c| c.desired_height(area.width) + 1),
|
|
||||||
),
|
|
||||||
Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
|
||||||
])
|
])
|
||||||
.areas(area)
|
.areas(area)
|
||||||
}
|
}
|
||||||
@@ -651,6 +665,7 @@ impl ChatWidget {
|
|||||||
}),
|
}),
|
||||||
active_exec_cell: None,
|
active_exec_cell: None,
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
|
session_header: SessionHeader::new(config.model.clone()),
|
||||||
initial_user_message: create_initial_user_message(
|
initial_user_message: create_initial_user_message(
|
||||||
initial_prompt.unwrap_or_default(),
|
initial_prompt.unwrap_or_default(),
|
||||||
initial_images,
|
initial_images,
|
||||||
@@ -703,6 +718,7 @@ impl ChatWidget {
|
|||||||
}),
|
}),
|
||||||
active_exec_cell: None,
|
active_exec_cell: None,
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
|
session_header: SessionHeader::new(config.model.clone()),
|
||||||
initial_user_message: create_initial_user_message(
|
initial_user_message: create_initial_user_message(
|
||||||
initial_prompt.unwrap_or_default(),
|
initial_prompt.unwrap_or_default(),
|
||||||
initial_images,
|
initial_images,
|
||||||
@@ -1282,8 +1298,9 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the model in the widget's config copy.
|
/// Set the model in the widget's config copy.
|
||||||
pub(crate) fn set_model(&mut self, model: String) {
|
pub(crate) fn set_model(&mut self, model: &str) {
|
||||||
self.config.model = model;
|
self.session_header.set_model(model);
|
||||||
|
self.config.model = model.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn add_info_message(&mut self, message: String, hint: Option<String>) {
|
pub(crate) fn add_info_message(&mut self, message: String, hint: Option<String>) {
|
||||||
@@ -1404,14 +1421,14 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||||
let [_, bottom_pane_area] = self.layout_areas(area);
|
let [_, _, bottom_pane_area] = self.layout_areas(area);
|
||||||
self.bottom_pane.cursor_pos(bottom_pane_area)
|
self.bottom_pane.cursor_pos(bottom_pane_area)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WidgetRef for &ChatWidget {
|
impl WidgetRef for &ChatWidget {
|
||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
|
let [_, active_cell_area, bottom_pane_area] = self.layout_areas(area);
|
||||||
(&self.bottom_pane).render(bottom_pane_area, buf);
|
(&self.bottom_pane).render(bottom_pane_area, buf);
|
||||||
if !active_cell_area.is_empty()
|
if !active_cell_area.is_empty()
|
||||||
&& let Some(cell) = &self.active_exec_cell
|
&& let Some(cell) = &self.active_exec_cell
|
||||||
|
|||||||
16
codex-rs/tui/src/chatwidget/session_header.rs
Normal file
16
codex-rs/tui/src/chatwidget/session_header.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
pub(crate) struct SessionHeader {
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionHeader {
|
||||||
|
pub(crate) fn new(model: String) -> Self {
|
||||||
|
Self { model }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the header's model text.
|
||||||
|
pub(crate) fn set_model(&mut self, model: &str) {
|
||||||
|
if self.model != model {
|
||||||
|
self.model = model.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
assertion_line: 728
|
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
assertion_line: 794
|
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
assertion_line: 921
|
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ fn final_answer_without_newline_is_flushed_immediately() {
|
|||||||
|
|
||||||
// Set up a VT100 test terminal to capture ANSI visual output
|
// Set up a VT100 test terminal to capture ANSI visual output
|
||||||
let width: u16 = 80;
|
let width: u16 = 80;
|
||||||
let height: u16 = 2000;
|
// Increased height to keep the initial banner/help lines in view even if
|
||||||
|
// the session renders an extra header line or minor layout changes occur.
|
||||||
|
let height: u16 = 2500;
|
||||||
let viewport = Rect::new(0, height - 1, width, 1);
|
let viewport = Rect::new(0, height - 1, width, 1);
|
||||||
let backend = ratatui::backend::TestBackend::new(width, height);
|
let backend = ratatui::backend::TestBackend::new(width, height);
|
||||||
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
|
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
|
||||||
@@ -230,6 +232,7 @@ fn make_chatwidget_manual() -> (
|
|||||||
bottom_pane: bottom,
|
bottom_pane: bottom,
|
||||||
active_exec_cell: None,
|
active_exec_cell: None,
|
||||||
config: cfg.clone(),
|
config: cfg.clone(),
|
||||||
|
session_header: SessionHeader::new(cfg.model.clone()),
|
||||||
initial_user_message: None,
|
initial_user_message: None,
|
||||||
token_info: None,
|
token_info: None,
|
||||||
stream: StreamController::new(cfg),
|
stream: StreamController::new(cfg),
|
||||||
@@ -773,10 +776,11 @@ async fn binary_size_transcript_snapshot() {
|
|||||||
// Consider content only after the last session banner marker. Skip the transient
|
// Consider content only after the last session banner marker. Skip the transient
|
||||||
// 'thinking' header if present, and start from the first non-empty content line
|
// 'thinking' header if present, and start from the first non-empty content line
|
||||||
// that follows. This keeps the snapshot stable across sessions.
|
// that follows. This keeps the snapshot stable across sessions.
|
||||||
const MARKER_PREFIX: &str = ">_ You are using OpenAI Codex in ";
|
const MARKER_PREFIX: &str =
|
||||||
|
"Describe a task to get started or try one of the following commands:";
|
||||||
let last_marker_line_idx = lines
|
let last_marker_line_idx = lines
|
||||||
.iter()
|
.iter()
|
||||||
.rposition(|l| l.starts_with(MARKER_PREFIX))
|
.rposition(|l| l.trim_start().starts_with(MARKER_PREFIX))
|
||||||
.expect("marker not found in visible output");
|
.expect("marker not found in visible output");
|
||||||
// Prefer the first assistant content line (blockquote '>' prefix) after the marker;
|
// Prefer the first assistant content line (blockquote '>' prefix) after the marker;
|
||||||
// fallback to the first non-empty, non-'thinking' line.
|
// fallback to the first non-empty, non-'thinking' line.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use crate::markdown::append_markdown;
|
|||||||
use crate::render::line_utils::line_to_static;
|
use crate::render::line_utils::line_to_static;
|
||||||
use crate::render::line_utils::prefix_lines;
|
use crate::render::line_utils::prefix_lines;
|
||||||
use crate::render::line_utils::push_owned_lines;
|
use crate::render::line_utils::push_owned_lines;
|
||||||
use crate::slash_command::SlashCommand;
|
|
||||||
use crate::text_formatting::format_and_truncate_tool_result;
|
use crate::text_formatting::format_and_truncate_tool_result;
|
||||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||||
use crate::wrapping::RtOptions;
|
use crate::wrapping::RtOptions;
|
||||||
@@ -581,6 +580,7 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TOOL_CALL_MAX_LINES: usize = 5;
|
const TOOL_CALL_MAX_LINES: usize = 5;
|
||||||
|
const SESSION_HEADER_MAX_INNER_WIDTH: usize = 70;
|
||||||
|
|
||||||
fn title_case(s: &str) -> String {
|
fn title_case(s: &str) -> String {
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
@@ -613,7 +613,7 @@ pub(crate) fn new_session_info(
|
|||||||
config: &Config,
|
config: &Config,
|
||||||
event: SessionConfiguredEvent,
|
event: SessionConfiguredEvent,
|
||||||
is_first_event: bool,
|
is_first_event: bool,
|
||||||
) -> PlainHistoryCell {
|
) -> CompositeHistoryCell {
|
||||||
let SessionConfiguredEvent {
|
let SessionConfiguredEvent {
|
||||||
model,
|
model,
|
||||||
reasoning_effort: _,
|
reasoning_effort: _,
|
||||||
@@ -624,58 +624,53 @@ pub(crate) fn new_session_info(
|
|||||||
rollout_path: _,
|
rollout_path: _,
|
||||||
} = event;
|
} = event;
|
||||||
if is_first_event {
|
if is_first_event {
|
||||||
let cwd_str = match relativize_to_home(&config.cwd) {
|
// Header box rendered as history (so it appears at the very top)
|
||||||
Some(rel) if !rel.as_os_str().is_empty() => {
|
let header = SessionHeaderHistoryCell::new(
|
||||||
let sep = std::path::MAIN_SEPARATOR;
|
model,
|
||||||
format!("~{sep}{}", rel.display())
|
config.cwd.clone(),
|
||||||
}
|
crate::version::CODEX_CLI_VERSION,
|
||||||
Some(_) => "~".to_string(),
|
);
|
||||||
None => config.cwd.display().to_string(),
|
|
||||||
};
|
|
||||||
// Discover AGENTS.md files to decide whether to suggest `/init`.
|
|
||||||
let has_agents_md = discover_project_doc_paths(config)
|
|
||||||
.map(|v| !v.is_empty())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
// Help lines below the header (new copy and list)
|
||||||
lines.push(Line::from(vec![
|
let help_lines: Vec<Line<'static>> = vec![
|
||||||
">_ ".dim(),
|
"Describe a task to get started or try one of the following commands:"
|
||||||
"You are using OpenAI Codex in".bold(),
|
.dim()
|
||||||
format!(" {cwd_str}").dim(),
|
.into(),
|
||||||
]));
|
Line::from("".dim()),
|
||||||
lines.push(Line::from("".dim()));
|
Line::from(vec![
|
||||||
lines.push(Line::from(
|
"1. ".into(),
|
||||||
" To get started, describe a task or try one of these commands:".dim(),
|
"/status".bold(),
|
||||||
));
|
" - show current session configuration and token usage".dim(),
|
||||||
lines.push(Line::from("".dim()));
|
]),
|
||||||
if !has_agents_md {
|
Line::from(vec![
|
||||||
lines.push(Line::from(vec![
|
"2. ".into(),
|
||||||
" /init".bold(),
|
"/compact".bold(),
|
||||||
format!(" - {}", SlashCommand::Init.description()).dim(),
|
" - compact the chat history to avoid context limits".dim(),
|
||||||
]));
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
"3. ".into(),
|
||||||
|
"/prompts".bold(),
|
||||||
|
" - explore starter prompts to get to know Codex".dim(),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
CompositeHistoryCell {
|
||||||
|
parts: vec![
|
||||||
|
Box::new(header),
|
||||||
|
Box::new(PlainHistoryCell { lines: help_lines }),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
lines.push(Line::from(vec![
|
|
||||||
" /status".bold(),
|
|
||||||
format!(" - {}", SlashCommand::Status.description()).dim(),
|
|
||||||
]));
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
" /approvals".bold(),
|
|
||||||
format!(" - {}", SlashCommand::Approvals.description()).dim(),
|
|
||||||
]));
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
" /model".bold(),
|
|
||||||
format!(" - {}", SlashCommand::Model.description()).dim(),
|
|
||||||
]));
|
|
||||||
PlainHistoryCell { lines }
|
|
||||||
} else if config.model == model {
|
} else if config.model == model {
|
||||||
PlainHistoryCell { lines: Vec::new() }
|
CompositeHistoryCell { parts: vec![] }
|
||||||
} else {
|
} else {
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
"model changed:".magenta().bold().into(),
|
"model changed:".magenta().bold().into(),
|
||||||
format!("requested: {}", config.model).into(),
|
format!("requested: {}", config.model).into(),
|
||||||
format!("used: {model}").into(),
|
format!("used: {model}").into(),
|
||||||
];
|
];
|
||||||
PlainHistoryCell { lines }
|
CompositeHistoryCell {
|
||||||
|
parts: vec![Box::new(PlainHistoryCell { lines })],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,6 +697,141 @@ pub(crate) fn new_active_exec_command(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SessionHeaderHistoryCell {
|
||||||
|
version: &'static str,
|
||||||
|
model: String,
|
||||||
|
directory: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionHeaderHistoryCell {
|
||||||
|
fn new(model: String, directory: PathBuf, version: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
version,
|
||||||
|
model,
|
||||||
|
directory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_directory(&self) -> String {
|
||||||
|
if let Some(rel) = relativize_to_home(&self.directory) {
|
||||||
|
if rel.as_os_str().is_empty() {
|
||||||
|
"~".to_string()
|
||||||
|
} else {
|
||||||
|
format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.directory.display().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryCell for SessionHeaderHistoryCell {
|
||||||
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||||
|
let mut out: Vec<Line<'static>> = Vec::new();
|
||||||
|
if width < 4 {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner_width = std::cmp::min(
|
||||||
|
width.saturating_sub(2) as usize,
|
||||||
|
SESSION_HEADER_MAX_INNER_WIDTH,
|
||||||
|
);
|
||||||
|
// Top border without a title on the border
|
||||||
|
let mut top = String::with_capacity(inner_width + 2);
|
||||||
|
top.push('╭');
|
||||||
|
top.push_str(&"─".repeat(inner_width));
|
||||||
|
top.push('╮');
|
||||||
|
out.push(Line::from(top));
|
||||||
|
|
||||||
|
// Title line rendered inside the box: " >_ OpenAI Codex (vX)"
|
||||||
|
let title_text = format!(" >_ OpenAI Codex (v{})", self.version);
|
||||||
|
let title_w = UnicodeWidthStr::width(title_text.as_str());
|
||||||
|
let pad_w = inner_width.saturating_sub(title_w);
|
||||||
|
let mut title_spans: Vec<Span<'static>> = vec![
|
||||||
|
"│".into(),
|
||||||
|
" ".into(),
|
||||||
|
">_ ".into(),
|
||||||
|
"OpenAI Codex".bold(),
|
||||||
|
" ".into(),
|
||||||
|
format!("(v{})", self.version).dim(),
|
||||||
|
];
|
||||||
|
if pad_w > 0 {
|
||||||
|
title_spans.push(" ".repeat(pad_w).into());
|
||||||
|
}
|
||||||
|
title_spans.push("│".into());
|
||||||
|
out.push(Line::from(title_spans));
|
||||||
|
|
||||||
|
// Spacer row between title and details
|
||||||
|
out.push(Line::from(vec![
|
||||||
|
"│".into(),
|
||||||
|
" ".repeat(inner_width).into(),
|
||||||
|
"│".into(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Model line: " Model: <model> (change with /model)"
|
||||||
|
const CHANGE_MODEL_HINT: &str = "(change with /model)";
|
||||||
|
let model_text = format!(" Model: {} {}", self.model, CHANGE_MODEL_HINT);
|
||||||
|
let model_w = UnicodeWidthStr::width(model_text.as_str());
|
||||||
|
let pad_w = inner_width.saturating_sub(model_w);
|
||||||
|
let mut spans: Vec<Span<'static>> = vec![
|
||||||
|
"│".into(),
|
||||||
|
" ".into(),
|
||||||
|
"Model: ".bold(),
|
||||||
|
self.model.clone().into(),
|
||||||
|
" ".into(),
|
||||||
|
CHANGE_MODEL_HINT.dim(),
|
||||||
|
];
|
||||||
|
if pad_w > 0 {
|
||||||
|
spans.push(" ".repeat(pad_w).into());
|
||||||
|
}
|
||||||
|
spans.push("│".into());
|
||||||
|
out.push(Line::from(spans));
|
||||||
|
|
||||||
|
// Directory line: " Directory: <cwd>"
|
||||||
|
let dir = self.format_directory();
|
||||||
|
let dir_text = format!(" Directory: {dir}");
|
||||||
|
let dir_w = UnicodeWidthStr::width(dir_text.as_str());
|
||||||
|
let pad_w = inner_width.saturating_sub(dir_w);
|
||||||
|
let mut spans: Vec<Span<'static>> =
|
||||||
|
vec!["│".into(), " ".into(), "Directory: ".bold(), dir.into()];
|
||||||
|
if pad_w > 0 {
|
||||||
|
spans.push(" ".repeat(pad_w).into());
|
||||||
|
}
|
||||||
|
spans.push("│".into());
|
||||||
|
out.push(Line::from(spans));
|
||||||
|
|
||||||
|
// Bottom border
|
||||||
|
let bottom = format!("╰{}╯", "─".repeat(inner_width));
|
||||||
|
out.push(Line::from(bottom));
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct CompositeHistoryCell {
|
||||||
|
parts: Vec<Box<dyn HistoryCell>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn spinner(start_time: Option<Instant>) -> Span<'static> {
|
fn spinner(start_time: Option<Instant>) -> Span<'static> {
|
||||||
const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
let idx = start_time
|
let idx = start_time
|
||||||
|
|||||||
Reference in New Issue
Block a user