use std::cell::Cell;
use std::io::{self, Write as _};
use std::path::PathBuf;
use std::time::Duration;
use crossterm::event::{
self, DisableBracketedPaste, EnableBracketedPaste, KeyCode, KeyEvent, KeyModifiers,
};
use newt_core::tty::raw_mode::RawModeGuard;
use newt_core::tty::str_width;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use ratatui::Frame;
use tui_textarea::{CursorMove, TextArea};
use crate::chat::BackgroundJob;
use crate::palette::{palette_lines, PaletteState};
use crate::{footer_continues, InputSurface, ReadOutcome};
mod command;
use command::{
bang_view, cancel_hidden_bang_selection, command_background, command_line,
command_line_with_focus, is_bang_escape, CommandKind, COMMAND_BG,
};
mod geometry;
use geometry::{draw_overhang, overhang_rows, wrap_segments};
mod gutter;
use gutter::{resolve_gutter, GUTTER_W};
mod mounted;
pub(crate) use mounted::{EditorOutcome, MountedEditor};
const MAX_INPUT_ROWS: u16 = 8;
type Term = crate::inline_viewport::InlineTerm;
#[derive(Clone, PartialEq, Debug)]
pub(crate) enum Step {
Tab(crate::tabs::TabAction),
Continue,
Submit,
SubmitQuit,
Eof,
}
use crate::vi::{Confirm, Mode, Vi};
pub(crate) fn char_search(ta: &mut TextArea, kind: char, target: char) {
let (row, col) = ta.cursor();
let chars: Vec<char> = ta.lines()[row].chars().collect();
let dest = match kind {
'f' => (col + 1..chars.len()).find(|&i| chars[i] == target),
't' => (col + 1..chars.len())
.find(|&i| chars[i] == target)
.map(|i| i - 1),
'F' => (0..col).rev().find(|&i| chars[i] == target),
'T' => (0..col).rev().find(|&i| chars[i] == target).map(|i| i + 1),
_ => None,
};
if let Some(i) = dest {
ta.move_cursor(CursorMove::Jump(row as u16, i as u16));
}
}
pub(crate) fn reverse_find(kind: char) -> char {
match kind {
'f' => 'F',
'F' => 'f',
't' => 'T',
'T' => 't',
other => other,
}
}
pub(crate) fn help_text(edit: Edit) -> String {
match edit {
Edit::Vi => [
"vi Esc=NORMAL · i I a A o O=insert · :w=send · :wq=send+end · :q=quit · :jumps :help",
" move: h j k l · w b e · 0 ^ $ · gg G · f F t T ; , · Ctrl-O/Ctrl-I jumps",
" edit: x X · dd dw D C s S r · yy p · J join · u Ctrl-R · d/c/y+motion",
]
.join("\n"),
Edit::Nano => {
"nano Enter=submit · Ctrl-O or Shift-Enter=newline · ^X=exit · ^G=help".to_string()
}
Edit::Emacs => {
"emacs Enter=submit · Ctrl-O or Shift-Enter=newline · C-x C-c=exit · Ctrl-h=help"
.to_string()
}
}
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum Edit {
Emacs,
Nano,
Vi,
}
impl Edit {
fn is_modeless(self) -> bool {
matches!(self, Self::Emacs | Self::Nano)
}
}
fn current_edit() -> Edit {
match crate::resolve_edit_mode() {
newt_core::EditMode::Vi => Edit::Vi,
newt_core::EditMode::Nano => Edit::Nano,
newt_core::EditMode::Emacs => Edit::Emacs,
}
}
struct Editor {
edit: Edit,
vi: Vi,
cx_pending: bool,
}
impl Editor {
fn new(edit: Edit) -> Self {
Self {
edit,
vi: Vi::new(),
cx_pending: false,
}
}
fn reset_for_new_line(&mut self) {
self.cx_pending = false;
self.vi.reset_for_new_line();
}
#[cfg(unix)]
fn claims(&self, c: &mut precedence_ladder::ClaimSet) {
if self.edit == Edit::Vi {
self.vi.claims(c);
}
}
fn input(&mut self, key: KeyEvent, ta: &mut TextArea) -> Step {
if self.edit == Edit::Vi && self.vi.confirm.is_some() {
return self.vi.input(key, ta);
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if self.cx_pending {
self.cx_pending = false;
if ctrl && key.code == KeyCode::Char('c') {
return Step::Eof;
}
}
if ctrl && key.code == KeyCode::Char('x') {
match self.edit {
Edit::Emacs => {
self.cx_pending = true;
return Step::Continue;
}
Edit::Nano => return Step::Eof,
Edit::Vi => {}
}
}
if ctrl {
match key.code {
KeyCode::Char('c') => {
*ta = new_textarea(self.edit);
self.reset_for_new_line();
self.vi.msg = Some("Ctrl-C to interrupt · Ctrl-D to exit".to_string());
return Step::Continue;
}
KeyCode::Char('d') => {
return if buffer_is_empty(ta) {
Step::Eof
} else {
Step::Submit
};
}
KeyCode::Char('o') if self.edit.is_modeless() => {
ta.insert_newline();
return Step::Continue;
}
KeyCode::Char('g') if self.edit == Edit::Nano => {
self.vi.msg = Some(help_text(self.edit));
return Step::Continue;
}
KeyCode::Char('h') if self.edit == Edit::Emacs => {
self.vi.msg = Some(help_text(self.edit));
return Step::Continue;
}
_ => {}
}
}
if key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::SHIFT) {
ta.insert_newline();
return Step::Continue;
}
if key.code == KeyCode::Enter && self.ex().is_none() {
let body = ta.lines().join("\n");
if footer_continues(&body) {
ta.insert_newline();
return Step::Continue;
}
return Step::Submit;
}
if self.edit.is_modeless() {
ta.input(key);
return Step::Continue;
}
self.vi.input(key, ta)
}
#[cfg(test)]
fn label(&self) -> &'static str {
match self.edit {
Edit::Emacs => "emacs",
Edit::Nano => "nano",
Edit::Vi => self.vi.mode_label(),
}
}
fn ex(&self) -> Option<&str> {
if self.edit == Edit::Vi {
self.vi.ex.as_deref()
} else {
None
}
}
fn mode_hint(&self, turn_running: bool) -> String {
let head = match self.edit {
Edit::Vi => match self.vi.mode {
Mode::Insert => "vi INSERT — Esc: NORMAL · :help · /nano /emacs",
Mode::Normal => "vi NORMAL — i: insert · :cmd · /nano /emacs",
},
Edit::Emacs => "emacs — Enter sends · Ctrl-h help · /vi /nano",
Edit::Nano => "nano — Enter sends · ^G help · /vi /emacs",
};
let keys = if turn_running {
"^C interrupt"
} else {
"^C clear · ^D exit"
};
format!("{head} · {keys}")
}
fn header_mode(&self) -> &'static str {
match self.edit {
Edit::Emacs => "emacs",
Edit::Nano => "nano",
Edit::Vi => match self.vi.mode {
Mode::Insert => "vi --INSERT--",
Mode::Normal => "vi --NORMAL--",
},
}
}
fn confirm_prompt(&self) -> Option<&'static str> {
if self.edit != Edit::Vi {
return None;
}
match self.vi.confirm {
Some(Confirm::SubmitQuit) => Some("send prompt then quit? [y/N] "),
None => None,
}
}
fn take_msg(&mut self) -> Option<String> {
self.vi.msg.take()
}
}
fn buffer_is_empty(ta: &TextArea) -> bool {
ta.lines().iter().all(|l| l.is_empty())
}
fn make_terminal(height: u16) -> io::Result<Term> {
let lease =
crate::inline_viewport::lease_bottom_rows(height, newt_core::tty::OnCollision::Refuse)?;
crate::inline_viewport::inline_terminal(lease)
}
fn new_textarea(edit: Edit) -> TextArea<'static> {
let mut ta = TextArea::default();
let hint = match edit {
Edit::Vi => "type… (Esc=NORMAL · o/O open line · Enter submit · :help · :q quit)",
Edit::Nano => "type… (Enter submit · Ctrl-O/Shift-Enter newline · ^G help · ^X exit)",
Edit::Emacs => {
"type… (Enter submit · Ctrl-O/Shift-Enter newline · Ctrl-h help · C-x C-c exit)"
}
};
ta.set_placeholder_text(hint);
ta.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
ta.set_cursor_line_style(Style::default());
ta
}
fn textarea_with(edit: Edit, content: &str) -> TextArea<'static> {
let mut ta = new_textarea(edit);
ta.insert_str(content);
ta
}
fn history_step(pos: usize, len: usize, up: bool) -> Option<usize> {
if up {
(pos > 0).then(|| pos - 1)
} else {
(pos < len).then(|| pos + 1)
}
}
fn load_history(path: Option<&PathBuf>) -> Vec<String> {
let Some(p) = path else {
return Vec::new();
};
std::fs::read_to_string(p)
.map(|s| {
s.lines()
.filter(|l| !l.trim().is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn status_options(model: &str) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
for var in ["NEWT_LOADOUT", "NEWT_DGX_MODEL"] {
if let Ok(v) = std::env::var(var) {
if !(v.is_empty() || var == "NEWT_DGX_MODEL" && v == model) {
parts.push(v);
}
}
}
(!parts.is_empty()).then(|| parts.join(" "))
}
fn header_line(session: &str, headline: &str) -> Line<'static> {
let mut spans: Vec<Span> = Vec::new();
if !session.is_empty() {
spans.push(Span::styled(
format!("[{session}]"),
crate::theme::style(crate::theme::Role::Muted),
));
}
if !headline.is_empty() {
let gap = if spans.is_empty() { "" } else { " " };
spans.push(Span::styled(
format!("{gap}{headline}"),
crate::theme::style(crate::theme::Role::Text),
));
}
Line::from(spans)
}
fn header_height(session: &str, headline: &str, cols: u16) -> u16 {
let width = usize::from(cols.max(1));
let text: String = header_line(session, headline)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
u16::try_from(newt_core::tty::wrap_line(&text, width).len())
.unwrap_or(1)
.max(1)
}
fn modal_height(modal: Option<&[Line<'static>]>, cols: u16) -> u16 {
let width = usize::from(cols.max(1));
modal.map_or(0, |lines| {
let rows: usize = lines
.iter()
.map(|line| {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
newt_core::tty::wrap_line(&text, width).len().max(1)
})
.sum();
u16::try_from(rows).unwrap_or(u16::MAX)
})
}
fn footer_line(
editor: &Editor,
model: &str,
endpoint: &str,
gauge: Option<(u32, Option<u32>)>,
active: bool,
) -> Line<'static> {
let accent = crate::theme::style(crate::theme::Role::Accent);
let dim = crate::theme::style(crate::theme::Role::Dim);
let stamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut spans: Vec<Span> = vec![Span::styled(format!("[{stamp}]"), dim)];
spans.push(Span::styled(
format!(" {}", editor.header_mode()),
if active { accent } else { dim },
));
if !model.is_empty() {
let loc = if endpoint.is_empty() {
model.to_string()
} else {
format!("{model} @ {endpoint}")
};
spans.push(Span::styled(format!(" {loc}"), dim));
}
if let Some(opts) = status_options(model) {
spans.push(Span::styled(format!(" [{opts}]"), dim));
}
if let Some((used, budget)) = gauge {
if budget != Some(0) {
let g = newt_core::agentic::fmt_token_gauge(used, budget);
let c = match budget.map(|b| newt_core::agentic::gauge_level(used, b)) {
Some(newt_core::agentic::GaugeLevel::Ok) | None => {
crate::theme::style(crate::theme::Role::GaugeOk)
}
Some(newt_core::agentic::GaugeLevel::Warn) => {
crate::theme::style(crate::theme::Role::GaugeWarn)
}
Some(newt_core::agentic::GaugeLevel::Critical) => {
crate::theme::style(crate::theme::Role::GaugeCritical)
}
};
spans.push(Span::styled(format!(" {g}"), c));
}
}
Line::from(spans)
}
fn prompt_line(editor: &Editor, ex_inline: bool) -> Line<'static> {
prompt_line_with_focus(editor, ex_inline, true)
}
fn prompt_line_with_focus(editor: &Editor, ex_inline: bool, focused: bool) -> Line<'static> {
if let Some(question) = editor.confirm_prompt() {
return Line::from(Span::styled(
question.to_string(),
Style::default()
.fg(if focused {
Color::LightYellow
} else {
Color::DarkGray
})
.add_modifier(Modifier::BOLD),
));
}
let accent = if focused {
Color::from(newt_core::tty::ACTIVE_INPUT_CT)
} else {
Color::DarkGray
};
if ex_inline {
if let Some(ex) = editor.ex() {
return command_line_with_focus(CommandKind::Ex, ex, focused);
}
}
Line::from(Span::styled("❯ ", Style::default().fg(accent)))
}
fn ex_bottom_line(editor: &Editor, textarea: &TextArea) -> Option<String> {
editor
.ex()
.filter(|_| textarea.lines().len() > 1)
.map(str::to_string)
}
fn background_line(jobs: &[BackgroundJob], frame: usize) -> Option<Line<'static>> {
let active = jobs
.iter()
.filter(|job| job.is_running())
.map(BackgroundJob::label)
.collect::<Vec<_>>();
if active.is_empty() {
return None;
}
let count = if active.len() == 1 {
String::new()
} else {
format!(" ({})", active.len())
};
let labels = active.join(", ");
let spinner = newt_core::tty::SPINNER_FRAMES[frame % newt_core::tty::SPINNER_FRAMES.len()];
Some(Line::from(vec![
Span::styled(
format!("{spinner} background{count}"),
crate::theme::style(crate::theme::Role::Accent),
),
Span::styled(
format!(" · {labels}"),
crate::theme::style(crate::theme::Role::Dim),
),
]))
}
fn background_frame() -> usize {
chrono::Local::now().timestamp_subsec_millis() as usize / 100
}
#[derive(Default)]
struct RichStatus<'a> {
model: &'a str,
endpoint: &'a str,
gauge: Option<(u32, Option<u32>)>,
session: &'a str,
modal: Option<&'a [Line<'static>]>,
headline: &'a str,
background_jobs: &'a [BackgroundJob],
palette: Option<&'a PaletteState>,
tabs: &'a [crate::tab_bar::TabCell],
chat_inactive: bool,
turn_running: bool,
}
fn draw(
f: &mut Frame,
textarea: &TextArea,
editor: &Editor,
gutter: Option<u16>,
status: RichStatus<'_>,
) {
let area = f.area();
let input_focused = !status.chat_inactive;
let inactive_textarea = status.chat_inactive.then(|| {
let mut view = textarea.clone();
view.set_cursor_style(Style::default());
view
});
let textarea = inactive_textarea.as_ref().unwrap_or(textarea);
let empty = buffer_is_empty(textarea);
let activity = background_line(status.background_jobs, background_frame());
let tab_rows = crate::tab_bar::bar_rows(status.tabs);
let header_rows = header_height(status.session, status.headline, area.width);
let modal_rows = modal_height(status.modal, area.width);
let rows = [
Constraint::Length(u16::from(activity.is_some())),
Constraint::Length(header_rows),
Constraint::Min(1),
Constraint::Length(modal_rows),
Constraint::Length(1),
Constraint::Length(tab_rows),
];
let [activity_area, header_area, body_area, modal_area, footer_area, tab_area] =
Layout::vertical(rows).areas(area);
if modal_rows > 0 {
if let Some(lines) = status.modal {
f.render_widget(
Paragraph::new(lines.to_vec()).wrap(ratatui::widgets::Wrap { trim: false }),
modal_area,
);
}
}
if tab_rows > 0 {
if let Some(line) = crate::tab_bar::layout_tab_cells(status.tabs, tab_area.width) {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
line,
crate::theme::style(crate::theme::Role::Dim),
))),
tab_area,
);
}
}
if let Some(line) = activity {
f.render_widget(Paragraph::new(line), activity_area);
}
f.render_widget(
Paragraph::new(header_line(status.session, status.headline))
.wrap(ratatui::widgets::Wrap { trim: false }),
header_area,
);
f.render_widget(
Paragraph::new(footer_line(
editor,
status.model,
status.endpoint,
status.gauge,
!empty && input_focused,
)),
footer_area,
);
let body_area = match status.palette.filter(|p| p.is_open()) {
Some(p) if p.viewport() > 0 => {
let rows = (p.viewport() as u16).min(body_area.height.saturating_sub(1));
let [palette_area, rest] =
Layout::vertical([Constraint::Length(rows), Constraint::Min(1)]).areas(body_area);
f.render_widget(Paragraph::new(palette_lines(p)), palette_area);
rest
}
_ => body_area,
};
let input_area = body_area;
let g = resolve_gutter(gutter, input_area.width);
if let Some(ex) = ex_bottom_line(editor, textarea) {
let [msg_area, ex_area] =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(input_area);
let prompt = prompt_line_with_focus(editor, false, input_focused);
if g >= GUTTER_W {
let [gutter_area, input] =
Layout::horizontal([Constraint::Length(g), Constraint::Min(1)]).areas(msg_area);
f.render_widget(Paragraph::new(prompt), gutter_area);
f.render_widget(textarea, input);
} else {
draw_overhang(f, msg_area, &prompt, textarea, g, None, input_focused);
}
f.render_widget(
Block::default().style(Style::default().bg(command_background(input_focused))),
ex_area,
);
f.render_widget(
Paragraph::new(command_line_with_focus(CommandKind::Ex, &ex, input_focused)),
ex_area,
);
let tail_width = u16::try_from(str_width(&ex)).unwrap_or(u16::MAX);
let cx = ex_area.x.saturating_add(1).saturating_add(tail_width);
if input_focused && cx <= ex_area.right().saturating_sub(1) {
f.set_cursor_position((cx, ex_area.y));
}
return;
}
if let Some(ex) = editor.ex() {
f.render_widget(
Block::default().style(Style::default().bg(command_background(input_focused))),
input_area,
);
f.render_widget(
Paragraph::new(command_line_with_focus(CommandKind::Ex, ex, input_focused)),
input_area,
);
let tail_width = u16::try_from(str_width(ex)).unwrap_or(u16::MAX);
let cx = input_area.x.saturating_add(1).saturating_add(tail_width);
if input_focused && cx <= input_area.right().saturating_sub(1) {
f.set_cursor_position((cx, input_area.y));
}
return;
}
let bang = bang_view(textarea);
let shown = bang.as_ref().map_or(textarea, |view| &view.textarea);
if let Some(bang) = bang.as_ref() {
f.render_widget(
Block::default().style(Style::default().bg(command_background(input_focused))),
input_area,
);
let prompt = command_line_with_focus(CommandKind::Bang, "", input_focused);
draw_overhang(
f,
input_area,
&prompt,
shown,
1,
None,
input_focused && !bang.cursor_on_marker,
);
if input_focused && bang.cursor_on_marker && input_area.width > 0 && input_area.height > 0 {
f.set_cursor_position((input_area.x, input_area.y));
}
return;
}
let prompt = prompt_line_with_focus(editor, true, input_focused);
let hint = empty.then(|| editor.mode_hint(status.turn_running));
if g >= GUTTER_W {
let [gutter_area, input] =
Layout::horizontal([Constraint::Length(g), Constraint::Min(1)]).areas(input_area);
f.render_widget(Paragraph::new(prompt), gutter_area);
f.render_widget(shown, input);
} else {
draw_overhang(
f,
input_area,
&prompt,
shown,
g,
hint.as_deref(),
input_focused,
);
}
}
const ECHO_CHEVRON: &str = "› ";
#[derive(Debug, PartialEq, Eq)]
struct EchoRow {
lead: bool,
text: String,
}
fn echo_body_rows(body: &str, hang: usize, width: usize) -> Vec<EchoRow> {
let chevron = str_width(ECHO_CHEVRON);
let cont_w = width.saturating_sub(hang).max(1);
let mut rows = Vec::new();
for (li, logical) in body.lines().enumerate() {
let first_indent = if li == 0 { chevron } else { hang };
let first_w = width.saturating_sub(first_indent).max(1);
for (si, (_, seg)) in wrap_segments(logical, first_w, cont_w)
.into_iter()
.enumerate()
{
rows.push(EchoRow {
lead: li == 0 && si == 0,
text: seg,
});
}
}
if rows.is_empty() {
rows.push(EchoRow {
lead: true,
text: String::new(),
});
}
rows
}
fn command_body_rows(command: &str, kind: CommandKind, width: usize) -> Vec<EchoRow> {
let shown = command.trim_start();
let tail = shown.strip_prefix(kind.marker()).unwrap_or(shown);
let first_w = width.saturating_sub(1).max(1);
let cont_w = width.saturating_sub(2).max(1);
let mut rows = Vec::new();
for (li, logical) in tail.lines().enumerate() {
let line_first_w = if li == 0 { first_w } else { cont_w };
for (si, (_, seg)) in wrap_segments(logical, line_first_w, cont_w)
.into_iter()
.enumerate()
{
rows.push(EchoRow {
lead: li == 0 && si == 0,
text: seg,
});
}
}
if rows.is_empty() {
rows.push(EchoRow {
lead: true,
text: String::new(),
});
}
rows
}
fn command_echo_lines(command: &str, kind: CommandKind, width: usize) -> Vec<Line<'static>> {
let width = width.max(1);
let dim = Style::default().fg(Color::DarkGray);
let stamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut lines = vec![Line::from(Span::styled(format!("[{stamp}]"), dim))];
for row in command_body_rows(command, kind, width) {
let prefix = if row.lead {
Span::styled(
kind.marker().to_string(),
kind.marker_style().bg(COMMAND_BG),
)
} else {
Span::styled(" ", Style::default().bg(COMMAND_BG))
};
let used = if row.lead { 1 } else { 2 } + str_width(&row.text);
lines.push(Line::from(vec![
prefix,
Span::styled(row.text, Style::default().fg(Color::White).bg(COMMAND_BG)),
Span::styled(
" ".repeat(width.saturating_sub(used)),
Style::default().bg(COMMAND_BG),
),
]));
}
lines
}
fn echo_command(sink: &mut dyn ScrollbackSink, command: &str, kind: CommandKind) -> io::Result<()> {
let width = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80) as usize;
sink.insert(command_echo_lines(command, kind, width))
}
fn note_rows(note: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut rows: Vec<String> = Vec::new();
for line in note.lines() {
for (_, seg) in wrap_segments(line, width, width) {
rows.push(seg);
}
}
if rows.is_empty() {
rows.push(String::new());
}
rows
}
fn echo_submitted(
sink: &mut dyn ScrollbackSink,
body: &str,
gutter: Option<u16>,
) -> io::Result<()> {
if is_bang_escape(body) {
return echo_command(sink, body, CommandKind::Bang);
}
let stamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let width = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80);
let hang_cols = resolve_gutter(gutter, width) as usize;
let hang = " ".repeat(hang_cols);
let dim = crate::theme::style(crate::theme::Role::Dim);
let mut lines: Vec<Line> = vec![Line::from(Span::styled(format!("[{stamp}]"), dim))];
for row in echo_body_rows(body, hang_cols, width as usize) {
let prefix = if row.lead {
Span::styled(ECHO_CHEVRON, dim)
} else {
Span::raw(hang.clone())
};
lines.push(Line::from(vec![
prefix,
Span::styled(row.text, crate::theme::style(crate::theme::Role::HumanText)),
]));
}
sink.insert(lines)
}
fn echo_note(sink: &mut dyn ScrollbackSink, note: &str) -> io::Result<()> {
let width = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80) as usize;
let gray = Style::default().fg(Color::Gray);
let lines: Vec<Line> = note_rows(note, width)
.into_iter()
.map(|seg| Line::from(Span::styled(seg, gray)))
.collect();
sink.insert(lines)
}
pub(crate) struct RichSurface {
edit: Edit,
history_path: Option<PathBuf>,
unsaved: Vec<String>,
gutter: Option<u16>,
pending_end_quit: Cell<bool>,
model: String,
endpoint: String,
gauge: Option<(u32, Option<u32>)>,
background_jobs: Vec<BackgroundJob>,
tabs: Vec<crate::tab_bar::TabCell>,
session: String,
vi: Cell<Option<Vi>>,
}
pub(crate) struct RawPasteGuard {
_raw: RawModeGuard,
}
impl RawPasteGuard {
pub(crate) fn enter() -> io::Result<Self> {
let raw = RawModeGuard::enter()?;
let _ = crossterm::execute!(io::stdout(), EnableBracketedPaste);
Ok(Self { _raw: raw })
}
}
impl Drop for RawPasteGuard {
fn drop(&mut self) {
let _ = crossterm::execute!(io::stdout(), DisableBracketedPaste);
}
}
impl RichSurface {
pub(crate) fn new(history_path: Option<PathBuf>) -> anyhow::Result<Self> {
for complaint in crate::theme::complaints() {
eprintln!("⚠ theme: {complaint}");
}
Ok(Self {
edit: current_edit(),
history_path,
unsaved: Vec::new(),
gutter: crate::resolve_gutter_setting(),
pending_end_quit: Cell::new(false),
model: String::new(),
endpoint: String::new(),
gauge: None,
background_jobs: Vec::new(),
tabs: Vec::new(),
session: String::new(),
vi: Cell::new(None),
})
}
fn read_turn(&self) -> io::Result<ReadOutcome> {
let _guard = RawPasteGuard::enter()?;
self.event_loop()
}
pub(crate) fn chrome(&self) -> Chrome<'_> {
Chrome {
model: &self.model,
endpoint: &self.endpoint,
gauge: self.gauge,
session: &self.session,
headline: "",
modal: None,
background_jobs: &self.background_jobs,
tabs: &self.tabs,
}
}
pub(crate) fn history(&self) -> Vec<String> {
let mut history = load_history(self.history_path.as_ref());
history.extend(self.unsaved.iter().cloned());
history
}
#[cfg(unix)]
pub(crate) fn edit(&self) -> Edit {
self.edit
}
#[cfg(unix)]
pub(crate) fn gutter(&self) -> Option<u16> {
self.gutter
}
pub(crate) fn arm_end_quit(&self) {
self.pending_end_quit.set(true);
}
#[cfg(unix)]
pub(crate) fn take_end_quit(&self) -> bool {
self.pending_end_quit.replace(false)
}
fn event_loop(&self) -> io::Result<ReadOutcome> {
let mut cur_h = 1u16;
let mut terminal = make_terminal(cur_h)?;
terminal.clear()?;
let typed_ahead = crate::type_ahead::take();
let mut me = MountedEditor::new(
self.edit,
self.gutter,
self.history(),
typed_ahead.trim_end_matches('\n'),
);
if let Some(vi) = self.vi.take() {
me.adopt_vi(vi);
}
let outcome = loop {
let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 24));
let want = me.wanted_rows(term_w, term_h, &self.chrome());
if want != cur_h {
terminal.clear()?;
drop(terminal);
terminal = make_terminal(want)?;
terminal.clear()?;
cur_h = want;
}
terminal.draw(|f| me.draw(f, self.chrome(), false))?;
if !event::poll(Duration::from_millis(250))? {
continue;
}
let evt = event::read()?;
match me.on_event(evt, &mut terminal)? {
None => {}
Some(EditorOutcome::Line(body)) => break ReadOutcome::Line(body),
Some(EditorOutcome::LineThenQuit(body)) => {
self.arm_end_quit();
break ReadOutcome::Line(body);
}
Some(EditorOutcome::EndAndQuit) => break ReadOutcome::EndAndQuit,
Some(EditorOutcome::Tab(action)) => break ReadOutcome::Tab(action),
Some(EditorOutcome::Eof) => break ReadOutcome::Eof,
}
};
self.vi.set(Some(me.take_vi()));
Ok(outcome)
}
}
#[derive(Clone, Copy)]
pub(crate) struct Chrome<'a> {
pub(crate) model: &'a str,
pub(crate) endpoint: &'a str,
pub(crate) gauge: Option<(u32, Option<u32>)>,
pub(crate) session: &'a str,
pub(crate) headline: &'a str,
pub(crate) modal: Option<&'a [Line<'static>]>,
pub(crate) background_jobs: &'a [BackgroundJob],
pub(crate) tabs: &'a [crate::tab_bar::TabCell],
}
pub(crate) trait ScrollbackSink {
fn insert(&mut self, lines: Vec<Line<'static>>) -> io::Result<()>;
}
impl ScrollbackSink for Term {
fn insert(&mut self, lines: Vec<Line<'static>>) -> io::Result<()> {
let height = lines.len() as u16;
self.insert_before(height, move |buf| {
Paragraph::new(lines).render(buf.area, buf);
})
}
}
impl InputSurface for RichSurface {
fn present_interaction(
&mut self,
interaction: &newt_core::interaction_surface::SurfaceInteraction,
) -> newt_core::HumanQuestionOutcome {
let window = newt_core::tty::Terminal::suspend_for_prompt(
newt_core::tty::TerminalTaker::RichSurfaceModal,
);
let (outcome, canonical) = match crate::interaction_view::present(interaction) {
Ok(pair) => pair,
Err(_) => return crate::permissions::present_on_terminal(&window, interaction),
};
let _ = window.notice(&canonical);
let (outcome, notice) = crate::permissions::apply_chat_prompt_policy(outcome);
if let Some(notice) = notice {
let _ = window.notice(notice);
}
outcome
}
fn read_line(&mut self, _prompt: &str) -> anyhow::Result<ReadOutcome> {
if self.pending_end_quit.replace(false) {
return Ok(ReadOutcome::EndAndQuit);
}
Ok(self.read_turn()?)
}
fn present_clarification(
&mut self,
batch: &str,
hint: &str,
_prompt: &str,
_color: bool,
_verbose: bool,
) -> anyhow::Result<ReadOutcome> {
if self.pending_end_quit.replace(false) {
return Ok(ReadOutcome::EndAndQuit);
}
let _guard = RawPasteGuard::enter()?;
Ok(crate::clarification_modal::present(batch, hint)?)
}
fn add_history(&mut self, entry: &str) {
self.unsaved.push(entry.to_string());
}
fn save_history(&mut self) {
let Some(hp) = self.history_path.as_ref() else {
return;
};
if self.unsaved.is_empty() {
return;
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(hp)
{
for e in &self.unsaved {
let _ = writeln!(f, "{}", e.replace('\n', " "));
}
}
self.unsaved.clear();
}
fn reload(&mut self) -> anyhow::Result<()> {
self.edit = current_edit();
self.gutter = crate::resolve_gutter_setting();
Ok(())
}
fn set_runtime_context(
&mut self,
model: &str,
endpoint: &str,
gauge: Option<(u32, Option<u32>)>,
session: &str,
) {
self.model = model.to_string();
self.endpoint = endpoint.to_string();
self.gauge = gauge;
self.session = session.to_string();
}
fn set_background_jobs(&mut self, jobs: Vec<BackgroundJob>) {
self.background_jobs = jobs;
}
fn set_tabs(&mut self, tabs: Vec<crate::tab_bar::TabCell>) {
self.tabs = tabs;
}
}
#[cfg(test)]
#[path = "rich_input_tests/support.rs"]
mod test_support;
#[cfg(test)]
#[path = "rich_input_tests/terminal_guard.rs"]
mod terminal_guard_tests;
#[cfg(test)]
#[path = "rich_input_tests/command_rendering.rs"]
mod command_rendering_tests;
#[cfg(test)]
#[path = "rich_input_tests/geometry.rs"]
mod geometry_tests;
#[cfg(all(test, unix))]
#[path = "rich_input_tests/esc_ladder.rs"]
mod esc_ladder_tests;
#[cfg(test)]
#[path = "rich_input_tests/layout.rs"]
mod layout_tests;
#[cfg(test)]
#[path = "rich_input_tests/input_actions.rs"]
mod input_actions_tests;
#[cfg(test)]
#[path = "rich_input_tests/vi_motions.rs"]
mod vi_motions_tests;
#[cfg(test)]
#[path = "rich_input_tests/history.rs"]
mod history_tests;
#[cfg(test)]
#[path = "rich_input_tests/mounted_state.rs"]
mod mounted_state_tests;