feat: support textwrap (#171)

* feat: support textwrap

* improve cmd_render_stream

* done cmd_render_stream

* add `-w` alias to `--wrap`

* done repl_render_stream

* add `config.wrap_code`

* remove cached config.wrap_width

* fix unxpected duplicate lines on kitty

* refactor markdown render

* improve render

* fix test
pull/175/head
sigoden 7 months ago committed by GitHub
parent 632a6fcc0b
commit b276dfedd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

25
Cargo.lock generated

@ -58,8 +58,8 @@ dependencies = [
"serde_json",
"serde_yaml",
"syntect",
"textwrap",
"tokio",
"unicode-width",
]
[[package]]
@ -1608,6 +1608,12 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "socket2"
version = "0.4.10"
@ -1736,6 +1742,17 @@ dependencies = [
"libc",
]
[[package]]
name = "textwrap"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width",
]
[[package]]
name = "thiserror"
version = "1.0.50"
@ -1878,6 +1895,12 @@ version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-normalization"
version = "0.1.22"

@ -27,7 +27,6 @@ tokio = { version = "1.26.0", features = ["rt", "time", "macros", "signal", "io-
crossbeam = "0.8.2"
crossterm = "0.26.1"
chrono = "0.4.23"
unicode-width = "0.1.10"
bincode = "1.3.3"
ctrlc = "3.2.5"
parking_lot = "0.12.1"
@ -39,6 +38,7 @@ bstr = "1.3.0"
nu-ansi-term = "0.47.0"
arboard = { version = "3.2.0", default-features = false }
async-trait = "0.1.74"
textwrap = "0.16.0"
[dependencies.reqwest]
version = "0.11.14"

@ -61,6 +61,8 @@ temperature: 1.0 # See https://platform.openai.com/docs/api-refe
save: true # If set true, aichat will save non-session chat messages to messages.md
highlight: true # Set false to turn highlight
light_theme: false # If set true, use light theme
wrap: no # Specify the text-wrapping mode (no*, auto, <max-width>)
wrap_code: false # Whether wrap code block
auto_copy: false # Automatically copy the last output to the clipboard
keybindings: emacs # REPL keybindings, possible values: emacs (default), vi

@ -7,7 +7,7 @@ pub struct Cli {
/// List all models
#[clap(long)]
pub list_models: bool,
/// Choose a llm model
/// Choose a LLM model
#[clap(short, long)]
pub model: Option<String>,
/// List all roles
@ -25,6 +25,9 @@ pub struct Cli {
/// Initiate or continue a session
#[clap(short = 's', long)]
pub session: Option<Option<String>>,
/// Specify the text-wrapping mode (no*, auto, <max-width>)
#[clap(short = 'w', long)]
pub wrap: Option<String>,
/// Print information
#[clap(long)]
pub info: bool,

@ -9,6 +9,7 @@ use self::session::{Session, TEMP_SESSION_NAME};
use crate::client::openai::{OpenAIClient, OpenAIConfig};
use crate::client::{all_clients, create_client_config, list_models, ClientConfig, ModelInfo};
use crate::config::message::num_tokens_from_messages;
use crate::render::RenderOptions;
use crate::utils::{get_env_name, now};
use anyhow::{anyhow, bail, Context, Result};
@ -56,6 +57,10 @@ pub struct Config {
pub dry_run: bool,
/// If set true, use light theme
pub light_theme: bool,
/// Specify the text-wrapping mode (no*, auto, <max-width>)
pub wrap: Option<String>,
/// Whethter wrap code block
pub wrap_code: bool,
/// Automatically copy the last output to the clipboard
pub auto_copy: bool,
/// REPL keybindings, possible values: emacs (default), vi
@ -86,6 +91,8 @@ impl Default for Config {
highlight: true,
dry_run: false,
light_theme: false,
wrap: None,
wrap_code: false,
auto_copy: false,
keybindings: Default::default(),
clients: vec![ClientConfig::OpenAI(OpenAIConfig::default())],
@ -125,6 +132,9 @@ impl Config {
if let Some(name) = config.model.clone() {
config.set_model(&name)?;
}
if let Some(wrap) = config.wrap.clone() {
config.set_wrap(&wrap)?;
}
config.merge_env_vars();
config.load_roles()?;
@ -163,6 +173,10 @@ impl Config {
pub fn save_message(&mut self, input: &str, output: &str) -> Result<()> {
self.last_message = Some((input.to_string(), output.to_string()));
if self.dry_run {
return Ok(());
}
if let Some(session) = self.session.as_mut() {
session.add_message(input, output)?;
return Ok(());
@ -295,6 +309,20 @@ impl Config {
Ok(messages)
}
pub fn set_wrap(&mut self, value: &str) -> Result<()> {
if value == "no" {
self.wrap = None;
} else if value == "auto" {
self.wrap = Some(value.into());
} else {
value
.parse::<u16>()
.map_err(|_| anyhow!("Invalid wrap value"))?;
self.wrap = Some(value.into())
}
Ok(())
}
pub fn set_model(&mut self, value: &str) -> Result<()> {
let models = list_models(self);
let mut model_info = None;
@ -333,6 +361,10 @@ impl Config {
let temperature = self
.temperature
.map_or_else(|| String::from("-"), |v| v.to_string());
let wrap = self
.wrap
.clone()
.map_or_else(|| String::from("no"), |v| v.to_string());
let items = vec![
("config_file", path_info(&Self::config_file()?)),
("roles_file", path_info(&Self::roles_file()?)),
@ -343,6 +375,8 @@ impl Config {
("save", self.save.to_string()),
("highlight", self.highlight.to_string()),
("light_theme", self.light_theme.to_string()),
("wrap", wrap),
("wrap_code", self.wrap_code.to_string()),
("dry_run", self.dry_run.to_string()),
("keybindings", self.keybindings.stringify().into()),
];
@ -500,8 +534,13 @@ impl Config {
}
}
pub const fn get_render_options(&self) -> (bool, bool) {
(self.highlight, self.light_theme)
pub fn get_render_options(&self) -> RenderOptions {
RenderOptions::new(
self.highlight,
self.light_theme,
self.wrap.clone(),
self.wrap_code,
)
}
pub fn maybe_print_send_tokens(&self, input: &str) {

@ -47,6 +47,9 @@ fn main() -> Result<()> {
println!("{sessions}");
exit(0);
}
if let Some(wrap) = &cli.wrap {
config.write().set_wrap(wrap)?;
}
if cli.light_theme {
config.write().light_theme = true;
}
@ -115,14 +118,10 @@ fn start_directive(
}
config.read().maybe_print_send_tokens(input);
let output = if no_stream {
let (highlight, light_theme) = config.read().get_render_options();
let render_options = config.read().get_render_options();
let output = client.send_message(input)?;
if highlight {
let mut markdown_render = MarkdownRender::new(light_theme);
println!("{}", markdown_render.render_block(&output).trim());
} else {
println!("{}", output.trim());
}
let mut markdown_render = MarkdownRender::init(render_options)?;
println!("{}", markdown_render.render(&output).trim());
output
} else {
let wg = WaitGroup::new();

@ -5,15 +5,16 @@ use crate::repl::{ReplyStreamEvent, SharedAbortSignal};
use anyhow::Result;
use crossbeam::channel::Receiver;
use textwrap::core::display_width;
#[allow(clippy::unnecessary_wraps, clippy::module_name_repetitions)]
pub fn cmd_render_stream(
rx: &Receiver<ReplyStreamEvent>,
light_theme: bool,
render: &mut MarkdownRender,
abort: &SharedAbortSignal,
) -> Result<()> {
let mut buffer = String::new();
let mut markdown_render = MarkdownRender::new(light_theme);
let mut col = 0;
loop {
if abort.aborted() {
return Ok(());
@ -23,28 +24,39 @@ pub fn cmd_render_stream(
ReplyStreamEvent::Text(text) => {
if text.contains('\n') {
let text = format!("{buffer}{text}");
let mut lines: Vec<&str> = text.split('\n').collect();
buffer = lines.pop().unwrap_or_default().to_string();
let output = lines.join("\n");
print_now!("{}\n", markdown_render.render_block(&output));
let (head, tail) = split_line_tail(&text);
buffer = tail.to_string();
let input = format!("{}{head}", spaces(col));
let output = render.render(&input);
print_now!("{}\n", &output[col..]);
col = 0;
} else {
buffer = format!("{buffer}{text}");
if !(markdown_render.is_code_block()
|| buffer.len() < 60
if !(render.is_code()
|| buffer.len() < 40
|| buffer.starts_with('#')
|| buffer.starts_with('>')
|| buffer.starts_with('|'))
{
if let Some((output, remain)) = split_line(&buffer) {
print_now!("{}", markdown_render.render_line(&output));
if let Some((head, remain)) = split_line_sematic(&buffer) {
buffer = remain;
let input = format!("{}{head}", spaces(col));
let output = render.render(&input);
let output = &output[col..];
let (_, tail) = split_line_tail(output);
if output.contains('\n') {
col = display_width(tail);
} else {
col += display_width(output);
}
print_now!("{}", output);
}
}
}
}
ReplyStreamEvent::Done => {
let output = markdown_render.render_block(&buffer);
print_now!("{}\n", output.trim_end());
let input = format!("{}{buffer}", spaces(col));
print_now!("{}\n", render.render(&input));
break;
}
}
@ -53,9 +65,9 @@ pub fn cmd_render_stream(
Ok(())
}
fn split_line(line: &str) -> Option<(String, String)> {
fn split_line_sematic(text: &str) -> Option<(String, String)> {
let mut balance: Vec<Kind> = Vec::new();
let chars: Vec<char> = line.chars().collect();
let chars: Vec<char> = text.chars().collect();
let mut index = 0;
let len = chars.len();
while index < len - 1 {
@ -82,6 +94,18 @@ fn split_line(line: &str) -> Option<(String, String)> {
None
}
pub(crate) fn split_line_tail(text: &str) -> (&str, &str) {
if let Some((head, tail)) = text.rsplit_once('\n') {
(head, tail)
} else {
("", text)
}
}
fn spaces(n: usize) -> String {
" ".repeat(n)
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum Kind {
ParentheseStart,
@ -176,12 +200,12 @@ mod tests {
macro_rules! assert_split_line {
($a:literal, $b:literal, true) => {
assert_eq!(
split_line(&format!("{}{}", $a, $b)),
split_line_sematic(&format!("{}{}", $a, $b)),
Some(($a.into(), $b.into()))
);
};
($a:literal, $b:literal, false) => {
assert_eq!(split_line(&format!("{}{}", $a, $b)), None);
assert_eq!(split_line_sematic(&format!("{}{}", $a, $b)), None);
};
}

@ -1,4 +1,6 @@
use anyhow::{anyhow, Context, Result};
use crossterm::style::{Color, Stylize};
use crossterm::terminal;
use lazy_static::lazy_static;
use std::collections::HashMap;
use syntect::highlighting::{Color as SyntectColor, FontStyle, Style, Theme};
@ -23,115 +25,175 @@ lazy_static! {
#[allow(clippy::module_name_repetitions)]
pub struct MarkdownRender {
options: RenderOptions,
syntax_set: SyntaxSet,
md_theme: Theme,
code_color: Color,
md_theme: Option<Theme>,
code_color: Option<Color>,
md_syntax: SyntaxReference,
code_syntax: Option<SyntaxReference>,
prev_line_type: LineType,
wrap_width: Option<u16>,
}
impl MarkdownRender {
pub fn new(light_theme: bool) -> Self {
let syntax_set: SyntaxSet =
bincode::deserialize_from(SYNTAXES).expect("invalid syntaxes binary");
let md_theme: Theme = if light_theme {
bincode::deserialize_from(MD_THEME_LIGHT).expect("invalid theme binary")
} else {
bincode::deserialize_from(MD_THEME).expect("invalid theme binary")
pub fn init(options: RenderOptions) -> Result<Self> {
let syntax_set: SyntaxSet = bincode::deserialize_from(SYNTAXES)
.with_context(|| "MarkdownRender: invalid syntaxes binary")?;
let md_theme: Option<Theme> = match (options.highlight, options.light_theme) {
(false, _) => None,
(true, false) => Some(
bincode::deserialize_from(MD_THEME)
.with_context(|| "MarkdownRender: invalid theme binary")?,
),
(true, true) => Some(
bincode::deserialize_from(MD_THEME_LIGHT)
.expect("MarkdownRender: invalid theme binary"),
),
};
let code_color = get_code_color(&md_theme);
let code_color = md_theme.as_ref().map(get_code_color);
let md_syntax = syntax_set.find_syntax_by_extension("md").unwrap().clone();
let line_type = LineType::Normal;
Self {
let wrap_width = match options.wrap.as_deref() {
None => None,
Some(value) => match terminal::size() {
Ok((columns, _)) => {
if value == "auto" {
Some(columns)
} else {
let value = value
.parse::<u16>()
.map_err(|_| anyhow!("Invalid wrap value"))?;
Some(columns.min(value))
}
}
Err(_) => None,
},
};
Ok(Self {
syntax_set,
md_theme,
code_color,
md_syntax,
code_syntax: None,
prev_line_type: line_type,
}
wrap_width,
options,
})
}
pub(crate) const fn is_code(&self) -> bool {
matches!(
self.prev_line_type,
LineType::CodeBegin | LineType::CodeInner
)
}
pub fn render_block(&mut self, src: &str) -> String {
src.split('\n')
.map(|line| {
self.render_line_impl(line)
.unwrap_or_else(|| line.to_string())
})
pub fn render(&mut self, text: &str) -> String {
text.split('\n')
.map(|line| self.render_line_mut(line))
.collect::<Vec<String>>()
.join("\n")
}
pub fn render_line(&self, line: &str) -> String {
let output = if self.is_code_block() && detect_code_block(line).is_none() {
self.render_code_line(line)
let (_, code_syntax, is_code) = self.check_line(line);
if is_code {
self.highlint_code_line(line, &code_syntax)
} else {
self.render_line_inner(line, &self.md_syntax)
};
output.unwrap_or_else(|| line.to_string())
self.highligh_line(line, &self.md_syntax, false)
}
}
pub const fn is_code_block(&self) -> bool {
matches!(
self.prev_line_type,
LineType::CodeBegin | LineType::CodeInner
)
fn render_line_mut(&mut self, line: &str) -> String {
let (line_type, code_syntax, is_code) = self.check_line(line);
let output = if is_code {
self.highlint_code_line(line, &code_syntax)
} else {
self.highligh_line(line, &self.md_syntax, false)
};
self.prev_line_type = line_type;
self.code_syntax = code_syntax;
output
}
fn render_line_impl(&mut self, line: &str) -> Option<String> {
fn check_line(&self, line: &str) -> (LineType, Option<SyntaxReference>, bool) {
let mut line_type = self.prev_line_type;
let mut code_syntax = self.code_syntax.clone();
let mut is_code = false;
if let Some(lang) = detect_code_block(line) {
match self.prev_line_type {
match line_type {
LineType::Normal | LineType::CodeEnd => {
self.prev_line_type = LineType::CodeBegin;
self.code_syntax = if lang.is_empty() {
line_type = LineType::CodeBegin;
code_syntax = if lang.is_empty() {
None
} else {
self.find_syntax(&lang).cloned()
};
}
LineType::CodeBegin | LineType::CodeInner => {
self.prev_line_type = LineType::CodeEnd;
self.code_syntax = None;
line_type = LineType::CodeEnd;
code_syntax = None;
}
}
self.render_line_inner(line, &self.md_syntax)
} else {
match self.prev_line_type {
LineType::Normal => self.render_line_inner(line, &self.md_syntax),
match line_type {
LineType::Normal => {}
LineType::CodeEnd => {
self.prev_line_type = LineType::Normal;
self.render_line_inner(line, &self.md_syntax)
line_type = LineType::Normal;
}
LineType::CodeBegin => {
if self.code_syntax.is_none() {
if code_syntax.is_none() {
if let Some(syntax) = self.syntax_set.find_syntax_by_first_line(line) {
self.code_syntax = Some(syntax.clone());
code_syntax = Some(syntax.clone());
}
}
self.prev_line_type = LineType::CodeInner;
self.render_code_line(line)
line_type = LineType::CodeInner;
is_code = true;
}
LineType::CodeInner => {
is_code = true;
}
LineType::CodeInner => self.render_code_line(line),
}
}
(line_type, code_syntax, is_code)
}
fn render_line_inner(&self, line: &str, syntax: &SyntaxReference) -> Option<String> {
fn highligh_line(&self, line: &str, syntax: &SyntaxReference, is_code: bool) -> String {
let ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
let trimed_line = &line[ws.len()..];
let mut highlighter = HighlightLines::new(syntax, &self.md_theme);
let ranges = highlighter
.highlight_line(trimed_line, &self.syntax_set)
.ok()?;
Some(format!("{ws}{}", as_terminal_escaped(&ranges)))
}
fn render_code_line(&self, line: &str) -> Option<String> {
self.code_syntax.as_ref().map_or_else(
|| Some(format!("{}", line.with(self.code_color))),
|syntax| self.render_line_inner(line, syntax),
)
let trimed_line: &str = &line[ws.len()..];
let mut line_highlighted = None;
if let Some(theme) = &self.md_theme {
let mut highlighter = HighlightLines::new(syntax, theme);
if let Ok(ranges) = highlighter.highlight_line(trimed_line, &self.syntax_set) {
line_highlighted = Some(format!("{ws}{}", as_terminal_escaped(&ranges)))
}
}
let line = line_highlighted.unwrap_or_else(|| line.into());
self.wrap_line(line, is_code)
}
fn highlint_code_line(&self, line: &str, code_syntax: &Option<SyntaxReference>) -> String {
if let Some(syntax) = code_syntax {
self.highligh_line(line, syntax, true)
} else {
let line = match self.code_color {
Some(color) => line.with(color).to_string(),
None => line.to_string(),
};
self.wrap_line(line, true)
}
}
fn wrap_line(&self, line: String, is_code: bool) -> String {
if let Some(width) = self.wrap_width {
if is_code && !self.options.wrap_code {
return line;
}
textwrap::wrap(&line, width as usize).join("\n")
} else {
line
}
}
fn find_syntax(&self, lang: &str) -> Option<&SyntaxReference> {
@ -146,6 +208,30 @@ impl MarkdownRender {
}
}
#[derive(Debug, Clone, Default)]
pub struct RenderOptions {
pub highlight: bool,
pub light_theme: bool,
pub wrap: Option<String>,
pub wrap_code: bool,
}
impl RenderOptions {
pub(crate) fn new(
highlight: bool,
light_theme: bool,
wrap: Option<String>,
wrap_code: bool,
) -> Self {
Self {
highlight,
light_theme,
wrap,
wrap_code,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineType {
Normal,
@ -222,6 +308,44 @@ fn get_code_color(theme: &Theme) -> Color {
mod tests {
use super::*;
const TEXT: &str = r#"
To unzip a file in Rust, you can use the `zip` crate. Here's an example code that shows how to unzip a file:
```rust
use std::fs::File;
fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
todo!()
}
```
"#;
const TEXT_NO_WRAP_CODE: &str = r#"
To unzip a file in Rust, you can use the `zip` crate. Here's an example code
that shows how to unzip a file:
```rust
use std::fs::File;
fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
todo!()
}
```
"#;
const TEXT_WRAP_ALL: &str = r#"
To unzip a file in Rust, you can use the `zip` crate. Here's an example code
that shows how to unzip a file:
```rust
use std::fs::File;
fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn
std::error::Error>> {
todo!()
}
```
"#;
#[test]
fn test_assets() {
let syntax_set: SyntaxSet =
@ -233,7 +357,37 @@ mod tests {
#[test]
fn test_render() {
let render = MarkdownRender::new(true);
let options = RenderOptions::default();
let render = MarkdownRender::init(options).unwrap();
assert!(render.find_syntax("csharp").is_some());
}
#[test]
fn no_theme() {
let options = RenderOptions::default();
let mut render = MarkdownRender::init(options).unwrap();
let output = render.render(TEXT);
assert_eq!(TEXT, output);
}
#[test]
fn no_wrap_code() {
let options = RenderOptions::default();
let mut render = MarkdownRender::init(options).unwrap();
render.wrap_width = Some(80);
let output = render.render(TEXT);
assert_eq!(TEXT_NO_WRAP_CODE, output);
}
#[test]
fn wrap_all() {
let options = RenderOptions {
wrap_code: true,
..Default::default()
};
let mut render = MarkdownRender::init(options).unwrap();
render.wrap_width = Some(80);
let output = render.render(TEXT);
assert_eq!(TEXT_WRAP_ALL, output);
}
}

@ -4,7 +4,7 @@ mod repl;
use self::cmd::cmd_render_stream;
#[allow(clippy::module_name_repetitions)]
pub use self::markdown::MarkdownRender;
pub use self::markdown::{MarkdownRender, RenderOptions};
use self::repl::repl_render_stream;
use crate::client::Client;
@ -26,26 +26,27 @@ pub fn render_stream(
abort: SharedAbortSignal,
wg: WaitGroup,
) -> Result<String> {
let (highlight, light_theme) = config.read().get_render_options();
let mut stream_handler = if highlight {
let render_options = config.read().get_render_options();
let mut stream_handler = {
let (tx, rx) = unbounded();
let abort_clone = abort.clone();
spawn(move || {
let err = if repl {
repl_render_stream(&rx, light_theme, &abort)
} else {
cmd_render_stream(&rx, light_theme, &abort)
let run = move || {
if repl {
let mut render = MarkdownRender::init(render_options)?;
repl_render_stream(&rx, &mut render, &abort)
} else {
let mut render = MarkdownRender::init(render_options)?;
cmd_render_stream(&rx, &mut render, &abort)
}
};
if let Err(err) = err {
if let Err(err) = run() {
let err = format!("{err:?}");
print_now!("{}\n\n", err.trim());
}
drop(wg);
});
ReplyStreamHandler::new(Some(tx), repl, abort_clone)
} else {
drop(wg);
ReplyStreamHandler::new(None, repl, abort)
ReplyStreamHandler::new(tx, abort_clone)
};
client.send_message_streaming(input, &mut stream_handler)?;
let buffer = stream_handler.get_buffer();

@ -1,4 +1,4 @@
use super::MarkdownRender;
use super::{cmd::split_line_tail, MarkdownRender};
use crate::repl::{ReplyStreamEvent, SharedAbortSignal};
@ -14,18 +14,18 @@ use std::{
io::{self, Stdout, Write},
time::{Duration, Instant},
};
use unicode_width::UnicodeWidthStr;
use textwrap::core::display_width;
#[allow(clippy::module_name_repetitions)]
pub fn repl_render_stream(
rx: &Receiver<ReplyStreamEvent>,
light_theme: bool,
render: &mut MarkdownRender,
abort: &SharedAbortSignal,
) -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
let ret = repl_render_stream_inner(rx, light_theme, abort, &mut stdout);
let ret = repl_render_stream_inner(rx, render, abort, &mut stdout);
disable_raw_mode()?;
@ -34,15 +34,16 @@ pub fn repl_render_stream(
fn repl_render_stream_inner(
rx: &Receiver<ReplyStreamEvent>,
light_theme: bool,
render: &mut MarkdownRender,
abort: &SharedAbortSignal,
writer: &mut Stdout,
) -> Result<()> {
let mut last_tick = Instant::now();
let tick_rate = Duration::from_millis(50);
let mut buffer = String::new();
let mut markdown_render = MarkdownRender::new(light_theme);
let columns = terminal::size()?.0;
let mut clear_rows = 0;
loop {
if abort.aborted() {
return Ok(());
@ -51,44 +52,45 @@ fn repl_render_stream_inner(
if let Ok(evt) = rx.try_recv() {
match evt {
ReplyStreamEvent::Text(text) => {
if !buffer.is_empty() {
let buffer_width = buffer.width() as u16;
let need_rows = (buffer_width + columns - 1) / columns;
let (col, row) = cursor::position()?;
if row + 1 >= need_rows {
if col == 0 {
queue!(writer, cursor::MoveTo(0, row - need_rows))?;
} else {
queue!(writer, cursor::MoveTo(0, row + 1 - need_rows))?;
}
} else {
queue!(
writer,
terminal::ScrollUp(need_rows - 1 - row),
cursor::MoveTo(0, 0)
)?;
}
let (col, mut row) = cursor::position()?;
// fix unxpected duplicate lines on kitty, see https://github.com/sigoden/aichat/issues/105
if col == 0 && row > 0 && display_width(&buffer) == columns as usize {
row -= 1;
}
if row + 1 >= clear_rows {
queue!(writer, cursor::MoveTo(0, row - clear_rows))?;
} else {
let scroll_rows = clear_rows - row - 1;
queue!(
writer,
terminal::ScrollUp(scroll_rows),
cursor::MoveTo(0, 0),
)?;
}
if text.contains('\n') {
let text = format!("{buffer}{text}");
let mut lines: Vec<&str> = text.split('\n').collect();
buffer = lines.pop().unwrap_or_default().to_string();
let output = markdown_render.render_block(&lines.join("\n"));
for line in output.split('\n') {
queue!(
writer,
style::Print(line),
style::Print("\n"),
cursor::MoveLeft(columns),
)?;
}
let (head, tail) = split_line_tail(&text);
buffer = tail.to_string();
let output = render.render(head);
print_block(writer, &output, columns)?;
queue!(writer, style::Print(&buffer),)?;
clear_rows = 0;
} else {
buffer = format!("{buffer}{text}");
let output = markdown_render.render_line(&buffer);
queue!(writer, style::Print(&output))?;
let output = render.render_line(&buffer);
if output.contains('\n') {
let (head, tail) = split_line_tail(&output);
clear_rows = print_block(writer, head, columns)?;
queue!(writer, style::Print(&tail),)?;
} else {
queue!(writer, style::Print(&output))?;
let buffer_width = display_width(&output) as u16;
let need_rows = (buffer_width + columns - 1) / columns;
clear_rows = need_rows.saturating_sub(1);
}
}
writer.flush()?;
@ -128,3 +130,17 @@ fn repl_render_stream_inner(
}
Ok(())
}
fn print_block(writer: &mut Stdout, text: &str, columns: u16) -> Result<u16> {
let mut num = 0;
for line in text.split('\n') {
queue!(
writer,
style::Print(line),
style::Print("\n"),
cursor::MoveLeft(columns),
)?;
num += 1;
}
Ok(num)
}

@ -115,9 +115,9 @@ impl ReplCmdHandler {
}
ReplCmd::ReadFile(file) => {
let mut contents = String::new();
let mut file = fs::File::open(file).expect("Unable to open file");
let mut file = fs::File::open(file).with_context(|| "Unable to open file")?;
file.read_to_string(&mut contents)
.expect("Unable to read file");
.with_context(|| "Unable to read file")?;
self.handle(ReplCmd::Submit(contents))?;
}
}
@ -137,23 +137,17 @@ impl ReplCmdHandler {
#[allow(clippy::module_name_repetitions)]
pub struct ReplyStreamHandler {
sender: Option<Sender<ReplyStreamEvent>>,
sender: Sender<ReplyStreamEvent>,
buffer: String,
abort: SharedAbortSignal,
repl: bool,
}
impl ReplyStreamHandler {
pub fn new(
sender: Option<Sender<ReplyStreamEvent>>,
repl: bool,
abort: SharedAbortSignal,
) -> Self {
pub fn new(sender: Sender<ReplyStreamEvent>, abort: SharedAbortSignal) -> Self {
Self {
sender,
abort,
buffer: String::new(),
repl,
}
}
@ -162,37 +156,20 @@ impl ReplyStreamHandler {
return Ok(());
}
self.buffer.push_str(text);
match self.sender.as_ref() {
Some(tx) => {
let ret = tx
.send(ReplyStreamEvent::Text(text.to_string()))
.with_context(|| "Failed to send StreamEvent:Text");
self.safe_ret(ret)?;
}
None => {
print_now!("{}", text);
}
}
let ret = self
.sender
.send(ReplyStreamEvent::Text(text.to_string()))
.with_context(|| "Failed to send StreamEvent:Text");
self.safe_ret(ret)?;
Ok(())
}
pub fn done(&mut self) -> Result<()> {
if let Some(tx) = self.sender.as_ref() {
let ret = tx
.send(ReplyStreamEvent::Done)
.with_context(|| "Failed to send StreamEvent:Done");
self.safe_ret(ret)?;
} else {
if !self.buffer.ends_with('\n') {
print_now!("\n");
}
if self.repl {
print_now!("\n");
if cfg!(macos) {
print_now!("\n");
}
}
}
let ret = self
.sender
.send(ReplyStreamEvent::Done)
.with_context(|| "Failed to send StreamEvent:Done");
self.safe_ret(ret)?;
Ok(())
}

@ -41,15 +41,15 @@ impl ReplPrompt {
}
pub fn get_colors(config: &SharedConfig) -> (Color, nu_ansi_term::Color, Color, Color) {
let (highlight, light_theme) = config.read().get_render_options();
if highlight {
let render_options = config.read().get_render_options();
if render_options.highlight {
(
PROMPT_COLOR,
PROMPT_MULTILINE_COLOR,
INDICATOR_COLOR,
PROMPT_RIGHT_COLOR,
)
} else if light_theme {
} else if render_options.light_theme {
(
Color::Black,
nu_ansi_term::Color::Black,

Loading…
Cancel
Save