use std::cell::RefCell;
use std::collections::HashMap;
use qframe::i18n;
use qframe::keymap::KeyChord;
use qframe::prelude::*;
use qframe::text;
use qframe::widgets::{CodeView, Language, LineMark, Markdown, ScrollView, Spinner, StatusLine, TextInput, ToastKind};
use serde_json::Value;
use super::{Live, Msg, Notice, Phase, QCli};
use crate::actions::{Action, ActionKind, Change, Preview};
use crate::conversation;
use crate::instructions::AGENTS_FILE;
use crate::provider::KeyError;
pub const ALLOW: &str = "allow";
pub const DENY: &str = "deny";
pub const INPUT: &str = "input";
const RESULT_LINES: usize = 3;
const TAIL_LINES: usize = 5;
pub(super) fn view(app: &QCli, ui: &mut View<'_, Msg>) {
AppShell::new()
.header(|ui| header(app, ui))
.body(|ui| {
ui.column(|ui| {
ui.add_with(ScrollView::new().follow_end(true), |ui| {
ui.column(|ui| {
transcript(app, ui);
app.answers.settle();
live(app, ui);
status(app, ui);
if let Phase::Asking { action, preview, ready, .. } = &app.phase {
card(ui, action, preview, *ready);
if !ready {
let shown = app.card;
ui.on_idle(super::QUIET, move |quiet| Msg::Quiet(shown, quiet));
}
}
})
.gap(1)
.padding(Padding::symmetric(1, 2));
})
.id(format!("conversation-{}", app.turn))
.fill();
input(app, ui);
})
.fill();
})
.footer(|ui| {
ui.add(
KeyHints::new()
.hint("enter", t!("keys.send"))
.action(Scope::App, "stop")
.action(Scope::App, "new")
.action(Scope::App, "provider")
.action_right(Scope::Global, "quit"),
);
})
.show(ui);
if let Some(form) = &app.form {
form.view(ui);
}
}
fn header(app: &QCli, ui: &mut View<'_, Msg>) {
let folder = app
.workspace
.root()
.file_name()
.map_or_else(|| app.workspace.root().display().to_string(), |name| name.to_string_lossy().into_owned());
let name = t!("app.name");
let model = if app.provider.model.is_empty() { t!("header.no-model") } else { app.provider.model.clone() };
let room = ui.size().width.saturating_sub(2 * BAR_MARGIN);
let (name_w, folder_w, model_w) = (text::width(&name), text::width(&folder), text::width(&model));
let with_model = name_w + BAR_GAP + folder_w + 2 * BAR_GAP + model_w;
let left_for_folder = room.saturating_sub(name_w + BAR_GAP);
let (show_name, folder, show_model) = if with_model <= room {
(true, folder, true)
} else if left_for_folder >= FOLDER_AT_LEAST.min(folder_w) {
(true, text::truncate_middle(&folder, left_for_folder).into_owned(), false)
} else if room >= FOLDER_AT_LEAST.min(folder_w) {
(false, text::truncate_middle(&folder, room).into_owned(), false)
} else {
(false, String::new(), false)
};
ui.row(|ui| {
if show_name {
ui.add(Text::new(name).bold().color("accent").no_wrap());
}
if !folder.is_empty() {
ui.add(Text::new(folder).no_wrap());
}
if show_model {
ui.spacer();
ui.add(Text::new(model).role("secondary").no_wrap());
}
})
.gap(BAR_GAP)
.padding(Padding::symmetric(0, BAR_MARGIN));
}
const BAR_MARGIN: u16 = 2;
const BAR_GAP: u16 = 2;
const FOLDER_AT_LEAST: u16 = 8;
fn transcript(app: &QCli, ui: &mut View<'_, Msg>) {
let messages = app.conversation.messages();
if messages.is_empty() {
let mut spans = vec![Span::new(t!("chat.empty"))];
if app.agents {
spans.push(Span::new(" "));
spans.push(Span::new(t!("chat.agents", file = AGENTS_FILE)));
}
ui.add(Text::rich(spans).role("secondary"));
return;
}
let results: HashMap<&str, (&str, bool)> = messages
.iter()
.flat_map(conversation::blocks)
.filter(|block| block["type"] == "tool_result")
.map(|block| {
let text = block["content"].as_str().unwrap_or_default();
(block["tool_use_id"].as_str().unwrap_or_default(), (text, block["is_error"] == true))
})
.collect();
for message in messages {
let from_person = message["role"] == "user";
for block in conversation::blocks(message) {
match block["type"].as_str().unwrap_or_default() {
"text" if from_person => said(ui, block["text"].as_str().unwrap_or_default()),
"text" => app.answers.draw(ui, block["text"].as_str().unwrap_or_default()),
"thinking" => thinking(ui, block["thinking"].as_str().unwrap_or_default()),
"tool_use" => {
let id = block["id"].as_str().unwrap_or_default();
call(app, ui, block, results.get(id).copied());
}
_ => {}
}
}
}
}
fn said(ui: &mut View<'_, Msg>, text: &str) {
ui.add_with(Panel::new(), |ui| {
ui.add(Text::new(text)).selectable(true);
});
}
fn answer(ui: &mut View<'_, Msg>, text: &str) {
if !text.trim().is_empty() {
ui.add(Markdown::new(text));
}
}
#[derive(Default)]
pub(super) struct Answers(RefCell<HashMap<String, (Markdown, bool)>>);
impl Answers {
fn draw(&self, ui: &mut View<'_, Msg>, text: &str) {
if text.trim().is_empty() {
return;
}
let mut answers = self.0.borrow_mut();
let markdown = if let Some((markdown, drawn)) = answers.get_mut(text) {
*drawn = true;
markdown.clone()
} else {
let markdown = Markdown::new(text);
answers.insert(text.to_owned(), (markdown.clone(), true));
markdown
};
drop(answers);
ui.add(markdown);
}
#[cfg(test)]
pub(super) fn held(&self) -> usize {
self.0.borrow().len()
}
fn settle(&self) {
self.0.borrow_mut().retain(|_, (_, drawn)| std::mem::replace(drawn, false));
}
}
fn thinking(ui: &mut View<'_, Msg>, text: &str) {
let text = text.trim();
if !text.is_empty() {
ui.add(Text::new(text).role("faint"));
}
}
fn call(app: &QCli, ui: &mut View<'_, Msg>, block: &Value, result: Option<(&str, bool)>) {
let name = block["name"].as_str().unwrap_or_default();
let id = block["id"].as_str().unwrap_or_default();
ui.column(|ui| {
match Action::from_call(name, &block["input"]) {
Ok(action) => ui.add(Text::rich([
Span::new(verb(action.kind())).color("accent").bold(),
Span::new(" "),
Span::new(action.subject().to_owned()),
])),
Err(_) => ui.add(Text::new(name.to_owned()).color("accent").bold()),
};
if let Some(Preview::Diff { path, lines }) = app.previews.get(id) {
diff(ui, path, lines);
}
if let Some((text, is_error)) = result {
outcome(ui, text, is_error);
}
});
}
fn verb(kind: ActionKind) -> String {
match kind {
ActionKind::Read => t!("action.read"),
ActionKind::List => t!("action.list"),
ActionKind::Search => t!("action.search"),
ActionKind::Edit => t!("action.edit"),
ActionKind::Run => t!("action.run"),
}
}
fn diff(ui: &mut View<'_, Msg>, path: &str, lines: &[crate::actions::DiffLine]) {
let code: Vec<&str> = lines.iter().map(|line| line.text.as_str()).collect();
let marks = lines.iter().map(|line| match line.change {
Change::Same => LineMark::Unchanged,
Change::Added => LineMark::Added,
Change::Removed => LineMark::Removed,
});
ui.add(CodeView::new(code.join("\n"), Language::from_file_name(path)).line_marks(marks).line_numbers(false));
}
fn outcome(ui: &mut View<'_, Msg>, text: &str, is_error: bool) {
let lines: Vec<&str> = text.lines().collect();
let mut shown = lines.iter().take(RESULT_LINES).copied().collect::<Vec<_>>().join("\n");
if lines.len() > RESULT_LINES {
shown.push('\n');
shown.push_str(&t!("action.more", n = lines.len() - RESULT_LINES));
}
if shown.trim().is_empty() {
shown = t!("action.empty");
}
if is_error {
marked(ui, &shown, ToastKind::Danger);
} else {
ui.add(Text::new(shown).role("faint")).selectable(true);
}
}
fn live(app: &QCli, ui: &mut View<'_, Msg>) {
for piece in &app.live {
match piece {
Live::Thinking(text) => thinking(ui, text),
Live::Text(text) => answer(ui, text),
Live::Call(name) => {
let shown = ActionKind::of_tool(name).map_or_else(|| name.clone(), verb);
ui.add(Text::new(shown).color("accent").bold());
}
}
}
}
fn status(app: &QCli, ui: &mut View<'_, Msg>) {
match &app.phase {
Phase::Replying if app.live.is_empty() => {
ui.add(Spinner::new().label(t!("status.waiting")).delayed(true));
}
Phase::Acting { action, .. } => {
ui.add(Spinner::new().label(t!("status.acting", what = action.subject().to_owned())).delayed(true));
let lines: Vec<&str> = app.tail.lines().collect();
let last = lines[lines.len().saturating_sub(TAIL_LINES)..].join("\n");
if !last.trim().is_empty() {
ui.add(Text::new(last).role("faint"));
}
}
_ => {}
}
if let Some(notice) = &app.notice {
let (text, tone) = match notice {
Notice::Provider(said) => (t!("notice.provider", said = said.clone()), ToastKind::Danger),
Notice::Unset => (t!("notice.unset"), ToastKind::Warning),
Notice::Key(KeyError::Unreadable { path, reason }) => (
t!("provider.key-unreadable", path = path.display().to_string(), reason = reason.clone()),
ToastKind::Danger,
),
Notice::Key(KeyError::NotAKey { path }) => {
(t!("provider.not-a-key", path = path.display().to_string()), ToastKind::Danger)
}
Notice::Stopped => (t!("notice.stopped"), ToastKind::Warning),
Notice::Cut => (t!("notice.cut"), ToastKind::Warning),
Notice::Rounds => (t!("notice.rounds", n = super::MAX_ROUNDS), ToastKind::Warning),
Notice::Unsaved(reason) => (t!("notice.unsaved", reason = reason.clone()), ToastKind::Danger),
Notice::Profile => (t!("notice.profile"), ToastKind::Info),
};
marked(ui, &text, tone);
}
if matches!(app.phase, Phase::Idle) && app.tokens.any() {
let said = t!(
"status.tokens",
read = i18n::number(app.tokens.read as f64, 0),
written = i18n::number(app.tokens.written as f64, 0),
);
ui.add(Text::new(said).role("faint"));
}
}
fn marked(ui: &mut View<'_, Msg>, text: &str, tone: ToastKind) {
ui.add(StatusLine::new(text).tone(tone)).selectable(true);
}
fn input(app: &QCli, ui: &mut View<'_, Msg>) {
if matches!(app.phase, Phase::Asking { .. }) {
return;
}
ui.add(
TextInput::new(app.draft.clone())
.placeholder(t!("chat.placeholder"))
.on_change(Msg::Draft)
.on_submit(|_| Msg::Send),
)
.id(INPUT)
.fill_width()
.padding(Padding::symmetric(0, 2));
}
fn card(ui: &mut View<'_, Msg>, action: &Action, preview: &Preview, ready: bool) {
let title = match action.kind() {
ActionKind::Edit => t!("ask.edit", path = action.subject().to_owned()),
_ => t!("ask.run"),
};
ui.add_with(Panel::new().title(title).selected(true), |ui| {
match preview {
Preview::Diff { path, lines } => diff(ui, path, lines),
Preview::Command { command } => {
ui.add(CodeView::new(command.clone(), Language::Shell).line_numbers(false));
}
Preview::Plain => {
ui.add(Text::new(action.summary()));
}
}
buttons(ui, ready);
});
}
fn buttons(ui: &mut View<'_, Msg>, ready: bool) {
let keymap = ui.env().keymap();
let allow = keymap.chords_for(Scope::App, "allow").first().map(KeyChord::label);
let deny = keymap.chords_for(Scope::App, "deny").first().map(KeyChord::label);
let mut allow_button = Button::new(t!("ask.allow")).variant("primary").on_press(Msg::Allow).disabled(!ready);
if let Some(key) = allow.clone() {
allow_button = allow_button.shortcut(key);
}
let mut deny_button = Button::new(t!("ask.deny")).on_press(Msg::Deny).disabled(!ready);
if let Some(key) = deny.clone() {
deny_button = deny_button.shortcut(key);
}
let wide = |label: &str, key: &Option<String>| {
u32::from(text::width(label)) + key.as_ref().map_or(0, |key| u32::from(text::width(key)) + 3) + 6
};
let row = wide(&t!("ask.allow"), &allow) + wide(&t!("ask.deny"), &deny) + CARD_GAP + CARD_AROUND;
let build = |ui: &mut View<'_, Msg>| {
ui.add(allow_button).id(ALLOW);
ui.add(deny_button).id(DENY);
};
if row <= u32::from(ui.size().width) {
ui.row(build).gap(2);
} else {
ui.column(build);
}
}
const CARD_GAP: u32 = 2;
const CARD_AROUND: u32 = 9;