feat: rust tui

This commit is contained in:
Alex Hancock
2026-05-27 21:16:14 -04:00
parent 25ff547487
commit 0de2e4a41d
15 changed files with 3528 additions and 106 deletions
Generated
+177
View File
@@ -2454,6 +2454,34 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 2.11.1",
"crossterm_winapi",
"derive_more",
"document-features",
"futures-core",
"mio",
"parking_lot",
"rustix 1.1.4",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
@@ -4590,6 +4618,8 @@ dependencies = [
name = "goose-cli"
version = "1.36.0"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",
"anstream",
"anyhow",
"async-trait",
@@ -4605,15 +4635,19 @@ dependencies = [
"cliclack",
"comfy-table",
"console",
"crossterm",
"dotenvy",
"env-lock",
"etcetera 0.11.0",
"futures",
"goose",
"goose-mcp",
"goose-sdk",
"indicatif",
"open",
"pulldown-cmark",
"rand 0.8.6",
"ratatui",
"regex",
"reqwest 0.13.4",
"rmcp",
@@ -5494,6 +5528,19 @@ dependencies = [
"tempfile",
]
[[package]]
name = "instability"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling 0.23.0",
"indoc",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "instant"
version = "0.1.13"
@@ -5770,6 +5817,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "kasuari"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
dependencies = [
"hashbrown 0.16.1",
"portable-atomic",
"thiserror 2.0.18",
]
[[package]]
name = "keyring"
version = "3.6.3"
@@ -5902,6 +5960,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "line-clipping"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8"
dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "linktime-proc-macro"
version = "0.1.0"
@@ -6018,6 +6085,9 @@ name = "lru"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "lru-slab"
@@ -6170,6 +6240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@@ -6591,6 +6662,15 @@ dependencies = [
"libc",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "oauth2"
version = "5.0.0"
@@ -7989,6 +8069,69 @@ version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
[[package]]
name = "ratatui"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc"
dependencies = [
"instability",
"ratatui-core",
"ratatui-crossterm",
"ratatui-widgets",
]
[[package]]
name = "ratatui-core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
dependencies = [
"bitflags 2.11.1",
"compact_str 0.9.0",
"hashbrown 0.16.1",
"indoc",
"itertools 0.14.0",
"kasuari",
"lru",
"strum 0.27.2",
"thiserror 2.0.18",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.2",
]
[[package]]
name = "ratatui-crossterm"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3"
dependencies = [
"cfg-if",
"crossterm",
"instability",
"ratatui-core",
]
[[package]]
name = "ratatui-widgets"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db"
dependencies = [
"bitflags 2.11.1",
"hashbrown 0.16.1",
"indoc",
"instability",
"itertools 0.14.0",
"line-clipping",
"ratatui-core",
"strum 0.27.2",
"time",
"unicode-segmentation",
"unicode-width 0.2.2",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
@@ -9052,6 +9195,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -10678,7 +10842,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
@@ -11568,6 +11734,17 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-truncate"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools 0.14.0",
"unicode-segmentation",
"unicode-width 0.2.2",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
-7
View File
@@ -82,7 +82,6 @@ test-case = { version = "3", default-features = false }
url = { version = "2.5.4", default-features = false, features = ["std"] }
opentelemetry = { version = "0.32", default-features = false }
opentelemetry_sdk = { version = "0.32", default-features = false, features = ["metrics"] }
opentelemetry-http = { version = "0.32", default-features = false, features = ["internal-logs", "reqwest"] }
opentelemetry-otlp = { version = "0.32", default-features = false, features = ["http-proto", "internal-logs", "logs", "metrics", "reqwest-client", "trace"] }
opentelemetry-appender-tracing = { version = "0.32", default-features = false, features = ["experimental_span_attributes"] }
opentelemetry-stdout = { version = "0.32", default-features = false, features = ["trace", "metrics", "logs"] }
@@ -104,12 +103,6 @@ tree-sitter-typescript = { version = "0.23", default-features = false }
llama-cpp-2 = { version = "=0.1.146", default-features = false, features = ["sampler", "mtmd"] }
llama-cpp-sys-2 = { version = "=0.1.146", default-features = false }
# These are needed because temporal_rs 0.1 (a transitive dep via PCTX) enables unstable features on icu_calendar without pinning the dependency version
# A fix is available in temporal_rs 0.2 but PCTX has not updated
# They are just here to pin the version, and can be removed if PCTX updates temporal_rs
icu_calendar = { version = "=2.1.1", default-features = false }
icu_locale = { version = "=2.1.1", default-features = false }
[patch.crates-io]
v8 = { path = "vendor/v8" }
cudaforge = { git = "https://github.com/jbg/cudaforge", rev = "e7c1967340e40673db98dc9e17da0f04834a456f" }
+6
View File
@@ -64,6 +64,12 @@ sha2 = { workspace = true }
sigstore-verify = { version = "0.8", default-features = false, optional = true }
axum.workspace = true
clap_complete_nushell = { version = "4", default-features = false }
ratatui = { version = "0.30", default-features = false, features = ["crossterm"] }
crossterm = { version = "0.29.0", default-features = false, features = ["event-stream"] }
goose-sdk = { version = "1.36.0", path = "../goose-sdk" }
agent-client-protocol = { workspace = true, features = ["unstable"] }
agent-client-protocol-schema = { workspace = true, features = ["unstable"] }
pulldown-cmark = { version = "0.13", default-features = false }
[target.'cfg(target_os = "windows")'.dependencies]
anstream = { version = "1", default-features = false, features = ["wincon"] }
+1 -1
View File
@@ -2157,7 +2157,7 @@ pub async fn cli() -> anyhow::Result<()> {
Some(Command::Plugin { command }) => handle_plugin_subcommand(command),
Some(Command::Term { command }) => handle_term_subcommand(command).await,
#[cfg(feature = "tui")]
Some(Command::Tui { args }) => crate::commands::tui::handle_tui(args),
Some(Command::Tui { args }) => crate::commands::tui::handle_tui(args).await,
#[cfg(feature = "local-inference")]
Some(Command::LocalModels { command }) => handle_local_models_command(command).await,
Some(Command::Review {
+3 -97
View File
@@ -1,99 +1,5 @@
use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::Result;
const TUI_NPM_SPEC_ENV: &str = "GOOSE_TUI_NPM_SPEC";
const TUI_REL_PATH: &str = "ui/text/dist/tui.js";
const DEFAULT_NPM_SPEC: &str = "@aaif/goose@latest";
const NPM_BIN_NAME: &str = "goose-tui";
enum TuiSource {
LocalScript(PathBuf),
Npx(String),
}
fn find_local_script() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent().unwrap_or_else(|| Path::new("."));
let mut dir = Some(exe_dir.to_path_buf());
for _ in 0..6 {
if let Some(d) = dir.clone() {
let candidate = d.join(TUI_REL_PATH);
if candidate.is_file() {
return Some(candidate);
}
dir = d.parent().map(Path::to_path_buf);
}
}
if let Ok(cwd) = std::env::current_dir() {
let candidate = cwd.join(TUI_REL_PATH);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn resolve_source() -> TuiSource {
if let Some(script) = find_local_script() {
return TuiSource::LocalScript(script);
}
let spec = std::env::var(TUI_NPM_SPEC_ENV).unwrap_or_else(|_| DEFAULT_NPM_SPEC.to_string());
TuiSource::Npx(spec)
}
fn build_command(source: &TuiSource, args: &[String]) -> Result<Command> {
match source {
TuiSource::LocalScript(script) => {
let mut cmd = Command::new("node");
cmd.arg(script).args(args);
Ok(cmd)
}
TuiSource::Npx(spec) => {
let mut cmd = Command::new("npx");
cmd.arg("--yes")
.arg("--package")
.arg(spec)
.arg("--")
.arg(NPM_BIN_NAME)
.args(args);
Ok(cmd)
}
}
}
pub fn handle_tui(args: Vec<String>) -> Result<()> {
let source = resolve_source();
let goose_binary = std::env::current_exe()
.context("could not determine current goose executable to expose as GOOSE_BINARY")?;
let mut cmd = build_command(&source, &args)?;
cmd.env("GOOSE_BINARY", &goose_binary);
let descriptor = match &source {
TuiSource::LocalScript(p) => format!("node {}", p.display()),
TuiSource::Npx(spec) => format!("npx --package {} -- {}", spec, NPM_BIN_NAME),
};
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = cmd.exec();
Err(anyhow!("failed to exec TUI ({descriptor}): {err}"))
}
#[cfg(not(unix))]
{
let status = cmd
.status()
.with_context(|| format!("failed to run `{descriptor}`"))?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
pub async fn handle_tui(_args: Vec<String>) -> Result<()> {
crate::tui::run_tui().await
}
+1
View File
@@ -12,6 +12,7 @@ pub mod recipes;
pub mod scenario_tests;
pub mod session;
pub mod signal;
pub mod tui;
// Re-export commonly used types
pub use cli::Cli;
+516
View File
@@ -0,0 +1,516 @@
use agent_client_protocol::schema::{
ToolCallContent as AcpToolCallContent, ToolCallStatus, ToolKind,
};
use anyhow::Result;
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream, MouseEvent,
MouseEventKind,
};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::ExecutableCommand;
use futures::StreamExt;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::io::stdout;
use std::time::Duration;
use tokio::sync::mpsc;
mod acp;
mod chat;
mod keyboard;
mod markdown;
mod slash;
mod style;
mod views;
use acp::{
spawn_acp_client, AgentMessage, ClientCommand, ExtensionInfo, ProviderInfo, SessionInfo,
};
use views::render;
#[derive(Clone, PartialEq)]
enum View {
Splash,
Providers,
Models,
Chat,
Sessions,
Extensions,
}
#[derive(Clone)]
enum TimelineItem {
Message { role: Role, content: String },
ToolCall(ToolCall),
Notice(Notice),
}
#[derive(Clone)]
enum Role {
User,
Assistant,
System,
}
#[derive(Clone)]
struct ToolCall {
title: String,
id: String,
kind: ToolKind,
status: ToolCallStatus,
raw_input: Option<serde_json::Value>,
raw_output: Option<serde_json::Value>,
content: Vec<AcpToolCallContent>,
}
#[derive(Clone)]
struct Notice {
title: String,
body: String,
kind: NoticeKind,
}
#[derive(Clone)]
enum NoticeKind {
Info,
Error,
}
struct App {
view: View,
tick: usize,
timeline: Vec<TimelineItem>,
input: String,
cursor: usize,
streaming: String,
loading: bool,
status: String,
providers: Vec<ProviderInfo>,
provider_search: String,
providers_selected: usize,
models: Vec<String>,
model_search: String,
models_selected: usize,
pending_provider: Option<String>,
sessions: Vec<SessionInfo>,
sessions_selected: usize,
extensions: Vec<ExtensionInfo>,
extensions_selected: usize,
selected_tool_call: Option<usize>,
expanded_tool_call: bool,
scrollback: usize,
expanded_scroll: usize,
show_help_menu: bool,
slash_selected: usize,
cmd_tx: mpsc::UnboundedSender<ClientCommand>,
msg_rx: mpsc::UnboundedReceiver<AgentMessage>,
should_quit: bool,
has_session: bool,
}
impl App {
fn new(
cmd_tx: mpsc::UnboundedSender<ClientCommand>,
msg_rx: mpsc::UnboundedReceiver<AgentMessage>,
) -> Self {
Self {
view: View::Splash,
tick: 0,
timeline: Vec::new(),
input: String::new(),
cursor: 0,
streaming: String::new(),
loading: true,
status: "starting".into(),
providers: Vec::new(),
provider_search: String::new(),
providers_selected: 0,
models: Vec::new(),
model_search: String::new(),
models_selected: 0,
pending_provider: None,
sessions: Vec::new(),
sessions_selected: 0,
extensions: Vec::new(),
extensions_selected: 0,
selected_tool_call: None,
expanded_tool_call: false,
scrollback: 0,
expanded_scroll: 0,
show_help_menu: false,
slash_selected: 0,
cmd_tx,
msg_rx,
should_quit: false,
has_session: false,
}
}
fn handle_agent_message(&mut self, msg: AgentMessage) {
match msg {
AgentMessage::Initialized => {
self.status = "loading providers".into();
let _ = self.cmd_tx.send(ClientCommand::ListProviders);
}
AgentMessage::ProvidersList(providers) => {
let has_configured = providers.iter().any(|p| p.configured);
self.providers = providers;
self.models.clear();
self.providers_selected = 0;
if has_configured && self.view == View::Splash {
self.start_session();
} else {
self.status = "choose provider".into();
self.loading = false;
self.view = View::Providers;
}
}
AgentMessage::SessionCreated => {
self.has_session = true;
self.loading = false;
self.status = "ready".into();
if self.view != View::Splash {
self.view = View::Chat;
if self.timeline.is_empty() {
self.push_message(Role::System, "What would you like to work on?".into());
}
}
}
AgentMessage::DefaultsSaved { provider, model } => {
self.loading = false;
self.status = "ready".into();
self.push_notice(
NoticeKind::Info,
"Provider defaults updated".into(),
format!("New sessions will use {provider} with {model}"),
);
self.view = View::Chat;
if !self.has_session {
self.start_session();
}
}
AgentMessage::ProviderChanged { provider, model } => {
self.loading = false;
self.status = "ready".into();
self.push_notice(
NoticeKind::Info,
"Provider changed".into(),
format!("Now using {provider} with {model}"),
);
}
AgentMessage::ProviderModelsList { provider, models } => {
self.loading = false;
self.status = "choose model".into();
self.pending_provider = Some(provider.clone());
self.models = models;
self.model_search.clear();
self.models_selected = 0;
if self.models.is_empty() {
self.push_notice(
NoticeKind::Error,
"No models found".into(),
format!("{provider} did not return any supported models."),
);
self.view = View::Chat;
} else {
self.view = View::Models;
}
}
AgentMessage::TextChunk(text) => {
self.loading = true;
self.status = "thinking".into();
self.streaming.push_str(&text);
}
AgentMessage::ToolCallStarted {
title,
id,
kind,
status,
raw_input,
raw_output,
content,
} => {
self.flush_streaming();
self.loading = true;
self.status = "using tools".into();
self.timeline.push(TimelineItem::ToolCall(ToolCall {
title,
id,
kind,
status,
raw_input,
raw_output,
content,
}));
self.scrollback = 0;
if self.selected_tool_call.is_none() {
self.selected_tool_call = self.tool_call_count().checked_sub(1);
}
}
AgentMessage::ToolCallUpdate {
id,
title,
kind,
status,
raw_input,
raw_output,
content,
} => {
if let Some(tool) = self.timeline.iter_mut().find_map(|item| match item {
TimelineItem::ToolCall(tool) if tool.id == id => Some(tool),
_ => None,
}) {
if let Some(title) = title {
tool.title = title;
}
if let Some(kind) = kind {
tool.kind = kind;
}
if let Some(status) = status {
tool.status = status;
}
if raw_input.is_some() {
tool.raw_input = raw_input;
}
if raw_output.is_some() {
tool.raw_output = raw_output;
}
if let Some(content) = content {
tool.content = content;
}
}
}
AgentMessage::ResponseComplete => {
self.flush_streaming();
self.loading = false;
self.status = "ready".into();
}
AgentMessage::SessionsList(sessions) => {
self.sessions = sessions;
self.sessions_selected = self
.sessions_selected
.min(self.sessions.len().saturating_sub(1));
self.view = View::Sessions;
self.status = "sessions".into();
}
AgentMessage::ExtensionsList(extensions) => {
self.extensions = extensions;
self.extensions_selected = self
.extensions_selected
.min(self.extensions.len().saturating_sub(1));
self.view = View::Extensions;
self.status = "extensions".into();
}
AgentMessage::Error(error) => {
self.loading = false;
self.status = "error".into();
self.push_notice(NoticeKind::Error, "Provider error".into(), error);
self.view = View::Chat;
}
}
}
fn start_session(&mut self) {
self.loading = true;
self.status = "starting session".into();
let _ = self.cmd_tx.send(ClientCommand::CreateSession);
}
fn clear_chat(&mut self) {
self.reset_session_state();
self.push_message(Role::System, "Chat cleared.".into());
}
fn reset_session_state(&mut self) {
self.timeline.clear();
self.streaming.clear();
self.selected_tool_call = None;
self.expanded_tool_call = false;
self.scrollback = 0;
self.expanded_scroll = 0;
}
fn start_new_session(&mut self) {
self.reset_session_state();
self.has_session = false;
self.start_session();
}
fn push_message(&mut self, role: Role, content: String) {
self.timeline.push(TimelineItem::Message { role, content });
}
fn push_notice(&mut self, kind: NoticeKind, title: String, body: String) {
self.flush_streaming();
self.timeline
.push(TimelineItem::Notice(Notice { title, body, kind }));
self.scrollback = 0;
}
fn flush_streaming(&mut self) {
if !self.streaming.is_empty() {
let content = std::mem::take(&mut self.streaming);
self.push_message(Role::Assistant, content);
}
}
fn turn_count(&self) -> usize {
self.timeline
.iter()
.filter(|item| matches!(item, TimelineItem::Message { .. } | TimelineItem::Notice(_)))
.count()
}
fn tool_call_count(&self) -> usize {
self.timeline
.iter()
.filter(|item| matches!(item, TimelineItem::ToolCall(_)))
.count()
}
fn selected_tool(&self) -> Option<&ToolCall> {
let selected = self.selected_tool_call?;
self.timeline
.iter()
.filter_map(|item| match item {
TimelineItem::ToolCall(tool) => Some(tool),
_ => None,
})
.nth(selected)
}
fn move_tool_selection(&mut self, direction: isize) {
let count = self.tool_call_count();
if count == 0 {
self.selected_tool_call = None;
return;
}
let current = self
.selected_tool_call
.unwrap_or(if direction < 0 { count } else { 0 });
let next = if direction < 0 {
current.saturating_sub(1)
} else {
(current + 1).min(count - 1)
};
self.selected_tool_call = Some(next);
}
fn filtered_providers(&self) -> Vec<&ProviderInfo> {
let query = self.provider_search.to_lowercase();
self.providers
.iter()
.filter(|p| {
query.is_empty()
|| p.name.to_lowercase().contains(&query)
|| p.id.to_lowercase().contains(&query)
})
.collect()
}
fn ensure_models(&mut self) {
if self.models.is_empty() {
let mut models: Vec<String> = self
.providers
.iter()
.flat_map(|provider| provider.models.iter().cloned())
.collect();
models.sort();
models.dedup();
self.models = models;
}
}
fn filtered_models(&self) -> Vec<&String> {
let query = self.model_search.to_lowercase();
self.models
.iter()
.filter(|model| query.is_empty() || model.to_lowercase().contains(&query))
.collect()
}
fn handle_mouse(&mut self, event: MouseEvent) {
if self.view != View::Chat || !self.mouse_in_main_content(event.column, event.row) {
return;
}
let scroll = if self.expanded_tool_call {
&mut self.expanded_scroll
} else {
&mut self.scrollback
};
match event.kind {
MouseEventKind::ScrollUp => *scroll = scroll.saturating_add(3),
MouseEventKind::ScrollDown => *scroll = scroll.saturating_sub(3),
_ => {}
}
}
fn mouse_in_main_content(&self, column: u16, row: u16) -> bool {
let Ok((width, height)) = crossterm::terminal::size() else {
return false;
};
let horizontal_padding = width.min(2);
let vertical_padding = height.min(1);
let content_x = horizontal_padding;
let content_y = vertical_padding.saturating_add(2);
let content_width = width.saturating_sub(horizontal_padding * 2);
let content_height = height
.saturating_sub(vertical_padding * 2)
.saturating_sub(5);
column >= content_x
&& column < content_x.saturating_add(content_width)
&& row >= content_y
&& row < content_y.saturating_add(content_height)
}
}
pub async fn run_tui() -> Result<()> {
let (cmd_tx, msg_rx) = spawn_acp_client(std::env::current_exe()?);
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
stdout().execute(EnableMouseCapture)?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
terminal.clear()?;
let mut app = App::new(cmd_tx.clone(), msg_rx);
let mut events = EventStream::new();
let mut tick = tokio::time::interval(Duration::from_millis(120));
let _ = cmd_tx.send(ClientCommand::Initialize);
loop {
terminal.draw(|frame| render(frame, &app))?;
if app.should_quit {
break;
}
tokio::select! {
_ = tick.tick() => app.tick = app.tick.wrapping_add(1),
event = events.next() => {
match event {
Some(Ok(CrosstermEvent::Key(key))) => app.handle_key(key.code, key.modifiers),
Some(Ok(CrosstermEvent::Mouse(mouse))) => app.handle_mouse(mouse),
_ => {}
}
}
msg = app.msg_rx.recv() => {
if let Some(msg) = msg {
app.handle_agent_message(msg);
} else {
break;
}
}
}
}
let _ = cmd_tx.send(ClientCommand::Shutdown);
disable_raw_mode()?;
stdout().execute(DisableMouseCapture)?;
stdout().execute(LeaveAlternateScreen)?;
Ok(())
}
+441
View File
@@ -0,0 +1,441 @@
use agent_client_protocol::schema::{
ContentBlock, InitializeRequest, ListSessionsRequest, ProtocolVersion,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SelectedPermissionOutcome, SessionConfigId, SessionConfigValueId, SessionNotification,
SessionUpdate, SetSessionConfigOptionRequest, ToolCallContent as AcpToolCallContent,
ToolCallStatus, ToolKind,
};
use agent_client_protocol::{ActiveSession, Agent, Client, ConnectionTo};
use anyhow::Result;
use goose_sdk::custom_requests::*;
use std::path::PathBuf;
use tokio::sync::mpsc;
const PROVIDER_CONFIG_ID: &str = "provider";
const PROVIDER_MODEL_META_KEY: &str = "model";
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
#[derive(Debug, Clone)]
pub(super) enum AgentMessage {
TextChunk(String),
ToolCallStarted {
title: String,
id: String,
kind: ToolKind,
status: ToolCallStatus,
raw_input: Option<serde_json::Value>,
raw_output: Option<serde_json::Value>,
content: Vec<AcpToolCallContent>,
},
ToolCallUpdate {
id: String,
title: Option<String>,
kind: Option<ToolKind>,
status: Option<ToolCallStatus>,
raw_input: Option<serde_json::Value>,
raw_output: Option<serde_json::Value>,
content: Option<Vec<AcpToolCallContent>>,
},
ResponseComplete,
Error(String),
SessionCreated,
DefaultsSaved {
provider: String,
model: String,
},
ProviderChanged {
provider: String,
model: String,
},
SessionsList(Vec<SessionInfo>),
ProvidersList(Vec<ProviderInfo>),
ProviderModelsList {
provider: String,
models: Vec<String>,
},
ExtensionsList(Vec<ExtensionInfo>),
Initialized,
}
#[derive(Debug, Clone)]
pub(super) struct SessionInfo {
pub(super) title: String,
pub(super) updated_at: String,
}
#[derive(Debug, Clone)]
pub(super) struct ProviderInfo {
pub(super) id: String,
pub(super) name: String,
pub(super) configured: bool,
pub(super) description: String,
pub(super) models: Vec<String>,
}
#[derive(Debug, Clone)]
pub(super) struct ExtensionInfo {
pub(super) name: String,
pub(super) enabled: bool,
pub(super) ext_type: String,
}
#[derive(Debug, Clone)]
pub(super) enum ClientCommand {
Initialize,
CreateSession,
SendPrompt(String),
ListSessions,
ListProviders,
ListExtensions,
ListProviderModels { provider: String },
SaveDefaults { provider: String, model: String },
ToggleExtension { key: String, enabled: bool },
Shutdown,
}
pub(super) fn spawn_acp_client(
goose_bin: PathBuf,
) -> (
mpsc::UnboundedSender<ClientCommand>,
mpsc::UnboundedReceiver<AgentMessage>,
) {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let (msg_tx, msg_rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
if let Err(error) = run_client(goose_bin, cmd_rx, msg_tx.clone()).await {
let _ = msg_tx.send(AgentMessage::Error(error.to_string()));
}
});
(cmd_tx, msg_rx)
}
async fn run_client(
goose_bin: PathBuf,
mut cmd_rx: mpsc::UnboundedReceiver<ClientCommand>,
msg_tx: mpsc::UnboundedSender<AgentMessage>,
) -> Result<()> {
let mut child = tokio::process::Command::new(&goose_bin)
.arg("acp")
.arg("--with-builtin")
.arg("developer")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()?;
let child_stdin = child.stdin.take().expect("stdin piped");
let child_stdout = child.stdout.take().expect("stdout piped");
let transport =
agent_client_protocol::ByteStreams::new(child_stdin.compat_write(), child_stdout.compat());
let notification_tx = msg_tx.clone();
let permission_tx = msg_tx.clone();
Client
.builder()
.name("goose-tui")
.on_receive_notification(
async move |notification: SessionNotification, _cx| {
handle_notification(&notification, &notification_tx);
Ok(())
},
agent_client_protocol::on_receive_notification!(),
)
.on_receive_request(
async move |request: RequestPermissionRequest, responder, _cx| {
let _ =
permission_tx.send(AgentMessage::TextChunk("\nAuto-approving tool\n".into()));
let response = request
.options
.first()
.map(|option| {
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
option.option_id.clone(),
))
})
.unwrap_or(RequestPermissionOutcome::Cancelled);
responder.respond(RequestPermissionResponse::new(response))
},
agent_client_protocol::on_receive_request!(),
)
.connect_with(transport, async move |cx| {
run_command_loop(cx, &mut cmd_rx, &msg_tx).await
})
.await?;
let _ = child.kill().await;
Ok(())
}
fn handle_notification(
notification: &SessionNotification,
msg_tx: &mpsc::UnboundedSender<AgentMessage>,
) {
match &notification.update {
SessionUpdate::AgentMessageChunk(chunk) => {
if let ContentBlock::Text(text) = &chunk.content {
let _ = msg_tx.send(AgentMessage::TextChunk(text.text.clone()));
}
}
SessionUpdate::ToolCall(tool_call) => {
let _ = msg_tx.send(AgentMessage::ToolCallStarted {
title: tool_call.title.clone(),
id: tool_call.tool_call_id.0.to_string(),
kind: tool_call.kind,
status: tool_call.status,
raw_input: tool_call.raw_input.clone(),
raw_output: tool_call.raw_output.clone(),
content: tool_call.content.clone(),
});
}
SessionUpdate::ToolCallUpdate(update) => {
let _ = msg_tx.send(AgentMessage::ToolCallUpdate {
id: update.tool_call_id.0.to_string(),
title: update.fields.title.clone(),
kind: update.fields.kind,
status: update.fields.status,
raw_input: update.fields.raw_input.clone(),
raw_output: update.fields.raw_output.clone(),
content: update.fields.content.clone(),
});
}
_ => {}
}
}
async fn run_command_loop(
cx: ConnectionTo<agent_client_protocol::Agent>,
cmd_rx: &mut mpsc::UnboundedReceiver<ClientCommand>,
msg_tx: &mpsc::UnboundedSender<AgentMessage>,
) -> Result<(), agent_client_protocol::Error> {
while let Some(cmd) = cmd_rx.recv().await {
if matches!(cmd, ClientCommand::Initialize) {
break;
}
}
cx.send_request(InitializeRequest::new(ProtocolVersion::LATEST))
.block_task()
.await?;
let _ = msg_tx.send(AgentMessage::Initialized);
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
ClientCommand::CreateSession => {
let _ = msg_tx.send(AgentMessage::SessionCreated);
cx.build_session_cwd()
.map_err(|_| agent_client_protocol::Error::internal_error())?
.block_task()
.run_until(async |mut session| {
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
ClientCommand::SendPrompt(prompt) => {
if let Err(error) = session.send_prompt(&prompt) {
send_error(msg_tx, "Failed to send prompt", error);
continue;
}
if let Err(error) = session.read_to_string().await {
send_error(msg_tx, "Provider request failed", error);
continue;
}
let _ = msg_tx.send(AgentMessage::ResponseComplete);
}
ClientCommand::SaveDefaults { provider, model } => {
handle_session_defaults_change(
&mut session,
&provider,
&model,
msg_tx,
)
.await;
}
ClientCommand::CreateSession | ClientCommand::Shutdown => {
return Ok(());
}
other => handle_non_session_cmd(&cx, other, msg_tx).await?,
}
}
Ok(())
})
.await?;
}
ClientCommand::Shutdown => break,
other => handle_non_session_cmd(&cx, other, msg_tx).await?,
}
}
Ok(())
}
fn send_error(
msg_tx: &mpsc::UnboundedSender<AgentMessage>,
context: &str,
error: agent_client_protocol::Error,
) {
let detail = provider_error_message(context, &error);
let _ = msg_tx.send(AgentMessage::Error(detail));
}
fn provider_error_message(context: &str, error: &agent_client_protocol::Error) -> String {
let data = error
.data
.as_ref()
.map(|data| {
data.as_str().map(ToString::to_string).unwrap_or_else(|| {
serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string())
})
})
.filter(|data| !data.trim().is_empty());
match data {
Some(data) if error.message.is_empty() => format!("{context}: {data}"),
Some(data) => format!("{context}: {}\n{data}", error.message),
None => format!("{context}: {}", error.message),
}
}
async fn handle_session_defaults_change(
session: &mut ActiveSession<'_, Agent>,
provider: &str,
model: &str,
msg_tx: &mpsc::UnboundedSender<AgentMessage>,
) {
let connection = session.connection();
let mut meta = serde_json::Map::new();
meta.insert(
PROVIDER_MODEL_META_KEY.to_string(),
serde_json::Value::String(model.to_string()),
);
match connection
.send_request(
SetSessionConfigOptionRequest::new(
session.session_id().clone(),
SessionConfigId::new(PROVIDER_CONFIG_ID),
SessionConfigValueId::new(provider.to_string()),
)
.meta(meta),
)
.block_task()
.await
{
Ok(_) => {
let _ = msg_tx.send(AgentMessage::ProviderChanged {
provider: provider.to_string(),
model: model.to_string(),
});
}
Err(error) => send_error(msg_tx, "Failed to change provider", error),
}
}
async fn handle_non_session_cmd(
cx: &ConnectionTo<agent_client_protocol::Agent>,
cmd: ClientCommand,
msg_tx: &mpsc::UnboundedSender<AgentMessage>,
) -> Result<(), agent_client_protocol::Error> {
match cmd {
ClientCommand::ListSessions => {
let resp = cx
.send_request(ListSessionsRequest::default())
.block_task()
.await?;
let sessions = resp
.sessions
.into_iter()
.map(|session| SessionInfo {
title: session.title.unwrap_or_default(),
updated_at: session.updated_at.unwrap_or_default(),
})
.collect();
let _ = msg_tx.send(AgentMessage::SessionsList(sessions));
}
ClientCommand::ListProviders => {
let resp = cx
.send_request(ListProvidersRequest::default())
.block_task()
.await?;
let providers = resp
.entries
.into_iter()
.map(|entry| ProviderInfo {
id: entry.provider_id,
name: entry.provider_name,
configured: entry.configured,
description: entry.description,
models: entry.models.into_iter().map(|model| model.id).collect(),
})
.collect();
let _ = msg_tx.send(AgentMessage::ProvidersList(providers));
}
ClientCommand::ListExtensions => {
let resp = cx
.send_request(GetExtensionsRequest {})
.block_task()
.await?;
let extensions = resp
.extensions
.into_iter()
.filter_map(|value| {
let obj = value.as_object()?;
Some(ExtensionInfo {
name: obj.get("name")?.as_str()?.to_string(),
enabled: obj.get("enabled")?.as_bool()?,
ext_type: obj
.get("type")
.and_then(|value| value.as_str())
.unwrap_or("unknown")
.to_string(),
})
})
.collect();
let _ = msg_tx.send(AgentMessage::ExtensionsList(extensions));
}
ClientCommand::ListProviderModels { provider } => {
match cx
.send_request(ProviderSupportedModelsListRequest {
provider_id: provider.clone(),
})
.block_task()
.await
{
Ok(response) => {
let mut models = response.models;
models.sort();
models.dedup();
let _ = msg_tx.send(AgentMessage::ProviderModelsList { provider, models });
}
Err(error) => send_error(msg_tx, "Failed to fetch provider models", error),
}
}
ClientCommand::SaveDefaults { provider, model } => {
match cx
.send_request(DefaultsSaveRequest {
provider_id: provider.clone(),
model_id: Some(model.clone()),
})
.block_task()
.await
{
Ok(response) => {
let _ = msg_tx.send(AgentMessage::DefaultsSaved {
provider: response.provider_id.unwrap_or(provider),
model: response.model_id.unwrap_or(model),
});
}
Err(error) => send_error(msg_tx, "Failed to save provider defaults", error),
}
}
ClientCommand::ToggleExtension { key, enabled } => {
let _ = cx
.send_request(ToggleConfigExtensionRequest {
config_key: key,
enabled,
})
.block_task()
.await;
}
ClientCommand::Initialize
| ClientCommand::CreateSession
| ClientCommand::SendPrompt(_)
| ClientCommand::Shutdown => {}
}
Ok(())
}
+402
View File
@@ -0,0 +1,402 @@
use agent_client_protocol::schema::{ToolCallStatus, ToolKind};
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{BorderType, Paragraph, Wrap};
use ratatui::Frame;
use super::markdown::push_markdown;
use super::style::*;
use super::views::{expanded_tool_lines, render_help_menu, render_slash_popover};
use super::{App, Notice, NoticeKind, Role, TimelineItem, ToolCall, View};
pub(super) fn render_chat(frame: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::vertical([
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(3),
])
.split(area);
render_header(
frame,
chunks[0],
&app.status,
app.loading,
app.tick,
app.turn_count(),
);
match (app.expanded_tool_call, app.selected_tool()) {
(true, Some(tool)) => render_tool_expanded(frame, chunks[1], tool, app.expanded_scroll),
_ => render_messages(frame, chunks[1], app),
}
render_input(frame, chunks[2], app);
render_slash_popover(frame, area, app);
render_help_menu(frame, area, app);
}
fn render_header(
frame: &mut Frame,
area: Rect,
status: &str,
loading: bool,
tick: usize,
turns: usize,
) {
let width = area.width as usize;
let left_width = width.saturating_mul(7) / 10;
let right_width = width.saturating_sub(left_width);
let row = Layout::horizontal([
Constraint::Length(left_width as u16),
Constraint::Length(right_width as u16),
])
.split(area);
let status_color = match status {
"ready" => TEAL,
"error" => CRANBERRY,
_ => TEXT_DIM,
};
let mut left = vec![
Span::styled(
"goose",
Style::default()
.fg(TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
),
Span::styled(" · ", fg(RULE_COLOR)),
Span::styled(
truncate(status, left_width.saturating_sub(10)),
fg(status_color),
),
];
if loading {
left.push(Span::raw(" "));
left.push(Span::styled(SPINNER[tick % SPINNER.len()], fg(TEAL)));
}
frame.render_widget(Paragraph::new(Line::from(left)), row[0]);
let right = if turns > 1 {
format!("{turns} turns /help commands")
} else {
"/help commands".to_string()
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
truncate(&right, right_width),
fg(TEXT_DIM),
)))
.alignment(Alignment::Right),
row[1],
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled("".repeat(width), fg(RULE_COLOR)))),
Rect {
y: area.y + 1,
height: 1,
..area
},
);
}
fn render_messages(frame: &mut Frame, area: Rect, app: &App) {
let width = area.width as usize;
let content_width = width.saturating_sub(4).max(10);
let mut lines = Vec::new();
let mut tool_index = 0;
for (index, item) in app.timeline.iter().enumerate() {
let previous_is_tool = index
.checked_sub(1)
.and_then(|previous| app.timeline.get(previous))
.is_some_and(|item| matches!(item, TimelineItem::ToolCall(_)));
let next_is_tool = app
.timeline
.get(index + 1)
.is_some_and(|item| matches!(item, TimelineItem::ToolCall(_)));
if !lines.is_empty() && !(previous_is_tool && matches!(item, TimelineItem::ToolCall(_))) {
lines.push(Line::from(""));
}
match item {
TimelineItem::Message { role, content } => match role {
Role::User => push_user_message(&mut lines, content, content_width),
Role::Assistant => push_markdown(&mut lines, content, content_width),
Role::System => lines.push(Line::from(Span::styled(
truncate_flat(content, width),
italic(TEXT_DIM),
))),
},
TimelineItem::ToolCall(tool) => {
push_tool_call(
&mut lines,
tool,
width,
app.selected_tool_call == Some(tool_index),
previous_is_tool,
next_is_tool,
);
tool_index += 1;
}
TimelineItem::Notice(notice) => push_notice(&mut lines, notice, width),
}
}
if !app.streaming.is_empty() {
if !lines.is_empty() {
lines.push(Line::from(""));
}
push_markdown(&mut lines, &app.streaming, content_width);
}
let max_scroll = lines.len().saturating_sub(area.height as usize);
let scroll = max_scroll.saturating_sub(app.scrollback.min(max_scroll)) as u16;
frame.render_widget(
Paragraph::new(lines)
.scroll((scroll, 0))
.wrap(Wrap { trim: false }),
area,
);
}
fn push_user_message(lines: &mut Vec<Line<'static>>, content: &str, width: usize) {
lines.push(Line::from(vec![
Span::styled(" ", bold(CRANBERRY)),
Span::styled(truncate_flat(content, width), fg(TEXT_PRIMARY)),
]));
}
fn push_notice(lines: &mut Vec<Line<'static>>, notice: &Notice, width: usize) {
let safe_width = width.max(10);
let inner_width = safe_width.saturating_sub(4).max(6);
let border_color = match notice.kind {
NoticeKind::Info => TEAL,
NoticeKind::Error => CRANBERRY,
};
let label = match notice.kind {
NoticeKind::Info => "notice",
NoticeKind::Error => "error",
};
let h_rule = "".repeat(safe_width.saturating_sub(2));
lines.push(Line::from(Span::styled(
format!("{h_rule}"),
fg(border_color),
)));
lines.push(Line::from(vec![
Span::styled("", fg(border_color)),
Span::styled(label, bold(border_color)),
Span::styled(" · ", fg(RULE_COLOR)),
Span::styled(
truncate_flat(&notice.title, inner_width.saturating_sub(label.len() + 3)),
bold(TEXT_PRIMARY),
),
]));
for wrapped in notice
.body
.lines()
.flat_map(|line| wrap_words(line, inner_width.saturating_sub(2)))
{
lines.push(Line::from(vec![
Span::styled("", fg(border_color)),
Span::styled(truncate_flat(&wrapped, inner_width), fg(TEXT_SECONDARY)),
]));
}
lines.push(Line::from(Span::styled(
format!("{h_rule}"),
fg(border_color),
)));
}
fn push_tool_call(
lines: &mut Vec<Line<'static>>,
tool: &ToolCall,
width: usize,
selected: bool,
previous_is_tool: bool,
next_is_tool: bool,
) {
let safe_width = width.max(10);
let inner_width = safe_width.saturating_sub(4).max(6);
let border_color = if selected {
GOLD
} else if matches!(tool.status, ToolCallStatus::Failed) {
CRANBERRY
} else {
CEDAR
};
let connector_color = if matches!(tool.status, ToolCallStatus::Failed) {
CRANBERRY
} else {
CEDAR
};
let h_rule = "".repeat(safe_width.saturating_sub(2));
if selected {
lines.push(Line::from(Span::styled(
format!("{h_rule}"),
fg(border_color),
)));
}
let connector = match (previous_is_tool, next_is_tool) {
(true, true) => "",
(true, false) => "╰─",
(false, true) => "╭─",
(false, false) => " ",
};
let (status, status_color) = tool_status(tool.status);
let kind = tool_kind_label(tool.kind);
let hint_text = if selected { "space to expand" } else { "" };
let fixed_len = display_width(connector)
+ kind.chars().count()
+ status.chars().count()
+ hint_text.chars().count()
+ 6;
let title = truncate_flat(&tool.title, inner_width.saturating_sub(fixed_len).max(4));
let used = display_width(&format!("{connector}{kind} {title} {status}{hint_text}"));
let spacer = " ".repeat(inner_width.saturating_sub(used));
lines.push(Line::from(vec![
Span::styled(connector, fg(connector_color)),
Span::styled(kind, fg(TEXT_DIM)),
Span::raw(" "),
Span::styled(
title,
Style::default()
.fg(TEXT_SECONDARY)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(status, fg(status_color)),
Span::raw(spacer),
Span::styled(hint_text, italic(GOLD)),
Span::raw(" "),
]));
if selected {
lines.push(Line::from(Span::styled(
format!("{h_rule}"),
fg(border_color),
)));
}
}
fn tool_status(status: ToolCallStatus) -> (&'static str, Color) {
match status {
ToolCallStatus::InProgress => ("running", GOLD),
ToolCallStatus::Completed => ("done", TEAL),
ToolCallStatus::Failed => ("failed", CRANBERRY),
_ => ("pending", TEXT_DIM),
}
}
fn tool_kind_label(kind: ToolKind) -> &'static str {
match kind {
ToolKind::Read => "read",
ToolKind::Edit => "edit",
ToolKind::Delete => "delete",
ToolKind::Move => "move",
ToolKind::Search => "search",
ToolKind::Execute => "run",
ToolKind::Think => "think",
ToolKind::Fetch => "fetch",
ToolKind::SwitchMode => "mode",
ToolKind::Other => "tool",
_ => "tool",
}
}
fn render_tool_expanded(frame: &mut Frame, area: Rect, tool: &ToolCall, scroll_offset: usize) {
let width = area.width as usize;
let height = area.height as usize;
let content_width = width.saturating_sub(4).max(10);
let body_height = height.saturating_sub(4).max(1);
let mut body = expanded_tool_lines(tool, content_width);
if body.is_empty() {
body.push(Line::from(Span::styled(
"(no details yet)",
italic(TEXT_DIM),
)));
}
let total = body.len();
let content_height = if total > body_height {
body_height.saturating_sub(2).max(1)
} else {
body_height
};
let end = total
.saturating_sub(scroll_offset)
.max(content_height)
.min(total);
let start = end.saturating_sub(content_height);
let mut lines = vec![Line::from(vec![
Span::styled("", fg(tool_status(tool.status).1)),
Span::styled(format!(" {:?}", tool.status), fg(TEXT_DIM)),
Span::raw(" "),
Span::styled(
truncate_flat(&tool.title, content_width.saturating_sub(18)),
Style::default()
.fg(TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
),
])];
lines.push(Line::from(Span::styled(
"".repeat(content_width),
fg(RULE_COLOR),
)));
if total > body_height {
let above = start;
lines.push(Line::from(Span::styled(
if above > 0 {
format!("{above} more (↑)")
} else {
String::new()
},
fg(TEXT_DIM),
)));
}
lines.extend(body[start..end].iter().cloned());
for _ in 0..content_height.saturating_sub(end - start) {
lines.push(Line::from(""));
}
if total > body_height {
let below = total.saturating_sub(end);
lines.push(Line::from(Span::styled(
if below > 0 {
format!("{below} more (↓)")
} else {
String::new()
},
fg(TEXT_DIM),
)));
}
let block = ui_block(GOLD, BorderType::Rounded, 1);
frame.render_widget(Paragraph::new(lines).block(block), area);
}
fn render_input(frame: &mut Frame, area: Rect, app: &App) {
let block = ui_block(RULE_COLOR, BorderType::Rounded, 2);
let input_area = block.inner(area);
frame.render_widget(block, area);
let text = if app.input.is_empty() {
vec![
Span::styled(" ", bold(CRANBERRY)),
Span::styled("Type a message or /help for commands…", fg(TEXT_DIM)),
]
} else {
vec![
Span::styled(" ", bold(CRANBERRY)),
Span::styled(
truncate(&app.input, input_area.width.saturating_sub(2) as usize),
fg(TEXT_PRIMARY),
),
]
};
frame.render_widget(Paragraph::new(Line::from(text)), input_area);
if app.view == View::Chat && !app.loading {
let x = input_area.x + 2 + (app.cursor as u16).min(input_area.width.saturating_sub(3));
frame.set_cursor_position((x, input_area.y));
}
}
+318
View File
@@ -0,0 +1,318 @@
use crossterm::event::{KeyCode, KeyModifiers};
use super::acp::ClientCommand;
use super::style::{provider_columns, terminal_width};
use super::{App, Role, View};
impl App {
pub(super) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) {
if code == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL) {
self.should_quit = true;
return;
}
match self.view {
View::Splash => self.handle_splash_key(code, modifiers),
View::Chat => self.handle_chat_key(code, modifiers),
View::Providers => self.handle_provider_key(code),
View::Models => self.handle_model_key(code),
View::Sessions => self.handle_sessions_key(code),
View::Extensions => self.handle_extensions_key(code),
}
}
fn handle_splash_key(&mut self, code: KeyCode, modifiers: KeyModifiers) {
match (code, modifiers) {
(KeyCode::Enter, KeyModifiers::NONE) if !self.loading => {
let input = self.take_input();
if input.is_empty() {
return;
}
self.view = View::Chat;
self.push_message(Role::User, input.clone());
self.scrollback = 0;
self.loading = true;
self.status = "queued".into();
let _ = self.cmd_tx.send(ClientCommand::SendPrompt(input));
}
(KeyCode::Backspace, _) => self.input_backspace(),
(KeyCode::Delete, _) => self.input_delete(),
(KeyCode::Left, _) => self.input_left(),
(KeyCode::Right, _) => self.input_right(),
(KeyCode::Home, _) => self.cursor = 0,
(KeyCode::End, _) => self.cursor = self.input.len(),
(KeyCode::Char(c), m)
if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
{
self.input.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
_ => {}
}
}
fn handle_chat_key(&mut self, code: KeyCode, modifiers: KeyModifiers) {
if self.handle_slash_command_key(code, modifiers) {
return;
}
if self.expanded_tool_call {
match code {
KeyCode::Esc | KeyCode::Char(' ') => {
self.expanded_tool_call = false;
self.expanded_scroll = 0;
self.selected_tool_call = None;
}
KeyCode::Up => self.expanded_scroll = self.expanded_scroll.saturating_add(3),
KeyCode::Down => self.expanded_scroll = self.expanded_scroll.saturating_sub(3),
_ => {}
}
return;
}
match (code, modifiers) {
(KeyCode::Char(' '), KeyModifiers::NONE) if self.selected_tool_call.is_some() => {
self.expanded_tool_call = true;
self.expanded_scroll = 0;
}
(KeyCode::Up, KeyModifiers::SHIFT) => self.move_tool_selection(-1),
(KeyCode::Down, KeyModifiers::SHIFT) => self.move_tool_selection(1),
(KeyCode::Esc, _) if self.show_help_menu => self.show_help_menu = false,
(KeyCode::Esc, _) if self.selected_tool_call.is_some() => {
self.selected_tool_call = None;
self.expanded_scroll = 0;
}
(KeyCode::Tab, KeyModifiers::NONE) => self.autocomplete_slash(),
(KeyCode::Enter, KeyModifiers::NONE) => {
let input = self.take_input();
if input.is_empty() {
return;
}
self.show_help_menu = false;
if input.starts_with('/') {
self.handle_slash(&input);
} else {
self.push_message(Role::User, input.clone());
self.scrollback = 0;
self.loading = true;
self.status = "queued".into();
let _ = self.cmd_tx.send(ClientCommand::SendPrompt(input));
}
}
(KeyCode::Backspace, _) => {
self.input_backspace();
self.reset_slash_selection();
}
(KeyCode::Delete, _) => {
self.input_delete();
self.reset_slash_selection();
}
(KeyCode::Left, _) => self.input_left(),
(KeyCode::Right, _) => self.input_right(),
(KeyCode::Home, _) => self.cursor = 0,
(KeyCode::End, _) => self.cursor = self.input.len(),
(KeyCode::Char(c), m)
if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
{
self.input.insert(self.cursor, c);
self.cursor += c.len_utf8();
self.reset_slash_selection();
}
_ => {}
}
}
fn handle_provider_key(&mut self, code: KeyCode) {
let count = self.filtered_providers().len();
match code {
KeyCode::Esc if !self.provider_search.is_empty() => {
self.provider_search.clear();
self.providers_selected = 0;
}
KeyCode::Esc => self.view = View::Chat,
KeyCode::Left => self.providers_selected = self.providers_selected.saturating_sub(1),
KeyCode::Right => {
if count > 0 {
self.providers_selected = (self.providers_selected + 1).min(count - 1);
}
}
KeyCode::Up => {
self.providers_selected = self
.providers_selected
.saturating_sub(provider_columns(terminal_width()))
}
KeyCode::Down => {
if count > 0 {
self.providers_selected = (self.providers_selected
+ provider_columns(terminal_width()))
.min(count - 1);
}
}
KeyCode::Enter => {
let selected = self
.filtered_providers()
.get(self.providers_selected)
.copied()
.cloned();
if let Some(provider) = selected {
self.pending_provider = Some(provider.id.clone());
self.models.clear();
self.model_search.clear();
self.models_selected = 0;
self.provider_search.clear();
self.loading = true;
self.status = "loading models".into();
let _ = self.cmd_tx.send(ClientCommand::ListProviderModels {
provider: provider.id,
});
}
}
KeyCode::Backspace | KeyCode::Delete => {
self.provider_search.pop();
self.providers_selected = 0;
}
KeyCode::Char(c) => {
self.provider_search.push(c);
self.providers_selected = 0;
}
_ => {}
}
}
fn handle_model_key(&mut self, code: KeyCode) {
let count = self.filtered_models().len();
match code {
KeyCode::Esc if !self.model_search.is_empty() => {
self.model_search.clear();
self.models_selected = 0;
}
KeyCode::Esc => {
self.pending_provider = None;
self.view = View::Chat;
}
KeyCode::Up => self.models_selected = self.models_selected.saturating_sub(1),
KeyCode::Down => {
if count > 0 {
self.models_selected = (self.models_selected + 1).min(count - 1);
}
}
KeyCode::Enter => {
let model = self
.filtered_models()
.get(self.models_selected)
.cloned()
.cloned();
if let Some(model) = model {
let provider = self.pending_provider.take().or_else(|| {
self.providers
.iter()
.find(|p| p.models.contains(&model))
.map(|p| p.id.clone())
});
if let Some(provider) = provider {
let _ = self
.cmd_tx
.send(ClientCommand::SaveDefaults { provider, model });
self.model_search.clear();
self.loading = true;
self.status = "changing model".into();
self.view = View::Chat;
}
}
}
KeyCode::Backspace | KeyCode::Delete => {
self.model_search.pop();
self.models_selected = 0;
}
KeyCode::Char(c) => {
self.model_search.push(c);
self.models_selected = 0;
}
_ => {}
}
}
fn handle_sessions_key(&mut self, code: KeyCode) {
match code {
KeyCode::Esc => self.view = View::Chat,
KeyCode::Char('n') | KeyCode::Enter => self.start_new_session(),
KeyCode::Up => self.sessions_selected = self.sessions_selected.saturating_sub(1),
KeyCode::Down => {
if !self.sessions.is_empty() {
self.sessions_selected =
(self.sessions_selected + 1).min(self.sessions.len() - 1);
}
}
_ => {}
}
}
fn handle_extensions_key(&mut self, code: KeyCode) {
match code {
KeyCode::Esc => self.view = View::Chat,
KeyCode::Up => self.extensions_selected = self.extensions_selected.saturating_sub(1),
KeyCode::Down => {
if !self.extensions.is_empty() {
self.extensions_selected =
(self.extensions_selected + 1).min(self.extensions.len() - 1);
}
}
KeyCode::Char(' ') | KeyCode::Enter => {
if let Some(ext) = self.extensions.get_mut(self.extensions_selected) {
ext.enabled = !ext.enabled;
let _ = self.cmd_tx.send(ClientCommand::ToggleExtension {
key: ext.name.clone(),
enabled: ext.enabled,
});
}
}
_ => {}
}
}
fn take_input(&mut self) -> String {
self.cursor = 0;
std::mem::take(&mut self.input).trim().to_string()
}
#[allow(clippy::string_slice)]
fn input_backspace(&mut self) {
if self.cursor == 0 {
return;
}
let prev = self.input[..self.cursor]
.chars()
.last()
.map(char::len_utf8)
.unwrap_or(0);
self.cursor -= prev;
self.input.remove(self.cursor);
}
fn input_delete(&mut self) {
if self.cursor < self.input.len() {
self.input.remove(self.cursor);
}
}
#[allow(clippy::string_slice)]
fn input_left(&mut self) {
if self.cursor > 0 {
self.cursor -= self.input[..self.cursor]
.chars()
.last()
.map(char::len_utf8)
.unwrap_or(0);
}
}
#[allow(clippy::string_slice)]
fn input_right(&mut self) {
if self.cursor < self.input.len() {
self.cursor += self.input[self.cursor..]
.chars()
.next()
.map(char::len_utf8)
.unwrap_or(0);
}
}
}
+682
View File
@@ -0,0 +1,682 @@
use pulldown_cmark::{
Alignment as MarkdownAlignment, CodeBlockKind, Event as MarkdownEvent,
HeadingLevel as MarkdownHeadingLevel, Options, Parser, Tag, TagEnd,
};
use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
use super::style::{
bold, display_width, fg, italic, truncate, truncate_flat, wrap_words, GOLD, RULE_COLOR, TEAL,
TEXT_DIM, TEXT_PRIMARY, TEXT_SECONDARY,
};
pub(super) fn push_markdown(lines: &mut Vec<Line<'static>>, text: &str, width: usize) {
let mut renderer = MarkdownRenderer::new(width);
renderer.render(text);
lines.extend(renderer.lines);
}
struct MarkdownRenderer {
lines: Vec<Line<'static>>,
width: usize,
spans: Vec<Span<'static>>,
styles: Vec<Style>,
list_stack: Vec<ListState>,
line_prefix: Option<String>,
continuation_prefix: String,
quote_depth: usize,
block: MarkdownBlock,
link_urls: Vec<String>,
code_lang: Option<String>,
code_text: String,
table: Option<TableState>,
}
struct ListState {
next: Option<u64>,
}
#[derive(Default)]
enum MarkdownBlock {
#[default]
None,
Paragraph,
Heading(MarkdownHeadingLevel),
Item,
TableCell,
}
#[derive(Default)]
struct TableState {
alignments: Vec<MarkdownAlignment>,
rows: Vec<Vec<Vec<Span<'static>>>>,
current_row: Vec<Vec<Span<'static>>>,
current_cell: Vec<Span<'static>>,
}
impl MarkdownRenderer {
fn new(width: usize) -> Self {
Self {
lines: Vec::new(),
width: width.max(10),
spans: Vec::new(),
styles: vec![fg(TEXT_PRIMARY)],
list_stack: Vec::new(),
line_prefix: None,
continuation_prefix: String::new(),
quote_depth: 0,
block: MarkdownBlock::None,
link_urls: Vec::new(),
code_lang: None,
code_text: String::new(),
table: None,
}
}
fn render(&mut self, text: &str) {
let options = Options::ENABLE_TABLES
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_SMART_PUNCTUATION
| Options::ENABLE_HEADING_ATTRIBUTES
| Options::ENABLE_DEFINITION_LIST
| Options::ENABLE_GFM;
for event in Parser::new_ext(text, options) {
self.handle_event(event);
}
self.finish_inline_block();
self.finish_code_block();
self.finish_table();
}
fn handle_event(&mut self, event: MarkdownEvent<'_>) {
match event {
MarkdownEvent::Start(tag) => self.start_tag(tag),
MarkdownEvent::End(tag) => self.end_tag(tag),
MarkdownEvent::Text(text) => self.push_text(text.as_ref()),
MarkdownEvent::Code(code) => self.push_styled(code.as_ref(), bold(GOLD)),
MarkdownEvent::InlineMath(math) => self.push_styled(&format!("${math}$"), italic(GOLD)),
MarkdownEvent::DisplayMath(math) => {
self.finish_inline_block();
self.lines
.push(Line::from(Span::styled(format!(" {math}"), italic(GOLD))));
}
MarkdownEvent::Html(html) | MarkdownEvent::InlineHtml(html) => {
self.push_styled(html.as_ref(), fg(TEXT_DIM));
}
MarkdownEvent::FootnoteReference(reference) => {
self.push_styled(&format!("[{reference}]"), bold(TEAL));
}
MarkdownEvent::SoftBreak => self.push_text(" "),
MarkdownEvent::HardBreak => self.push_text("\n"),
MarkdownEvent::Rule => {
self.finish_inline_block();
self.lines.push(Line::from(Span::styled(
"".repeat(self.width),
fg(RULE_COLOR),
)));
}
MarkdownEvent::TaskListMarker(checked) => {
self.push_styled(if checked { "" } else { "" }, fg(TEAL));
}
}
}
fn start_tag(&mut self, tag: Tag<'_>) {
match tag {
Tag::Paragraph => self.block = MarkdownBlock::Paragraph,
Tag::Heading { level, .. } => self.block = MarkdownBlock::Heading(level),
Tag::BlockQuote(_) => self.quote_depth += 1,
Tag::CodeBlock(kind) => {
self.finish_inline_block();
self.block = MarkdownBlock::None;
self.code_lang = match kind {
CodeBlockKind::Fenced(lang) => {
let lang = lang.to_string();
Some(if lang.is_empty() { "code".into() } else { lang })
}
CodeBlockKind::Indented => Some("code".into()),
};
self.code_text.clear();
}
Tag::List(start) => self.list_stack.push(ListState { next: start }),
Tag::Item => {
self.block = MarkdownBlock::Item;
self.finish_inline_block();
let marker = self.next_list_prefix();
let indent = " ".repeat(self.list_stack.len().saturating_sub(1));
self.line_prefix = Some(format!("{indent}{marker}"));
self.continuation_prefix = format!(
"{}{}",
quote_prefix(self.quote_depth),
" ".repeat(indent.chars().count() + marker.chars().count())
);
}
Tag::Emphasis => self.push_style(
Style::default()
.fg(TEXT_SECONDARY)
.add_modifier(Modifier::ITALIC),
),
Tag::Strong => self.push_style(
Style::default()
.fg(TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
),
Tag::Strikethrough => self.push_style(
Style::default()
.fg(TEXT_DIM)
.add_modifier(Modifier::CROSSED_OUT),
),
Tag::Superscript => self.push_style(fg(GOLD)),
Tag::Subscript => self.push_style(fg(TEXT_DIM)),
Tag::Link { dest_url, .. } => {
self.link_urls.push(dest_url.to_string());
self.push_style(fg(TEAL).add_modifier(Modifier::UNDERLINED));
}
Tag::Image { dest_url, .. } => {
self.link_urls.push(dest_url.to_string());
self.push_styled("[image: ", fg(TEXT_DIM));
self.push_style(fg(TEAL).add_modifier(Modifier::UNDERLINED));
}
Tag::Table(alignments) => {
self.finish_inline_block();
self.table = Some(TableState {
alignments,
..TableState::default()
});
}
Tag::TableHead | Tag::TableRow => {
if let Some(table) = &mut self.table {
table.current_row.clear();
}
}
Tag::TableCell => {
self.block = MarkdownBlock::TableCell;
self.spans.clear();
}
Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
| Tag::DefinitionList
| Tag::DefinitionListTitle
| Tag::DefinitionListDefinition
| Tag::MetadataBlock(_) => {}
}
}
fn end_tag(&mut self, tag: TagEnd) {
match tag {
TagEnd::Paragraph => self.finish_inline_block(),
TagEnd::Item => {
self.finish_inline_block();
self.continuation_prefix.clear();
}
TagEnd::Heading(_) => self.finish_heading(),
TagEnd::BlockQuote(_) => self.quote_depth = self.quote_depth.saturating_sub(1),
TagEnd::CodeBlock => self.finish_code_block(),
TagEnd::List(_) => {
self.finish_inline_block();
self.list_stack.pop();
}
TagEnd::Emphasis
| TagEnd::Strong
| TagEnd::Strikethrough
| TagEnd::Superscript
| TagEnd::Subscript => self.pop_style(),
TagEnd::Link => self.finish_link(" (", ")"),
TagEnd::Image => self.finish_link("](", ")"),
TagEnd::TableCell => {
if let Some(table) = &mut self.table {
table.current_cell = std::mem::take(&mut self.spans);
table
.current_row
.push(std::mem::take(&mut table.current_cell));
}
self.block = MarkdownBlock::None;
}
TagEnd::TableHead | TagEnd::TableRow => {
if let Some(table) = &mut self.table {
table.rows.push(std::mem::take(&mut table.current_row));
}
}
TagEnd::Table => self.finish_table(),
TagEnd::HtmlBlock
| TagEnd::FootnoteDefinition
| TagEnd::DefinitionList
| TagEnd::DefinitionListTitle
| TagEnd::DefinitionListDefinition
| TagEnd::MetadataBlock(_) => {}
}
}
fn push_text(&mut self, text: &str) {
if self.code_lang.is_some() {
self.code_text.push_str(text);
} else {
self.push_styled(text, *self.styles.last().unwrap_or(&fg(TEXT_PRIMARY)));
}
}
fn push_style(&mut self, style: Style) {
self.styles.push(style);
}
fn pop_style(&mut self) {
self.styles.pop();
}
fn finish_link(&mut self, before: &str, after: &str) {
self.pop_style();
if let Some(url) = self.link_urls.pop() {
self.push_styled(&format!("{before}{url}{after}"), fg(TEXT_DIM));
}
}
fn push_styled(&mut self, text: &str, style: Style) {
self.spans.push(Span::styled(text.to_string(), style));
}
fn next_list_prefix(&mut self) -> String {
match self
.list_stack
.last_mut()
.and_then(|list| list.next.as_mut())
{
Some(next) => {
let prefix = format!("{next}. ");
*next += 1;
prefix
}
None => "".to_string(),
}
}
fn finish_inline_block(&mut self) {
if self.spans.is_empty() {
self.block = MarkdownBlock::None;
return;
}
let quote_prefix = quote_prefix(self.quote_depth);
let active_list_item = !self.continuation_prefix.is_empty();
let first_prefix = self
.line_prefix
.take()
.map(|prefix| format!("{quote_prefix}{prefix}"))
.unwrap_or_else(|| {
if active_list_item {
self.continuation_prefix.clone()
} else {
quote_prefix.clone()
}
});
let continuation_prefix = if active_list_item {
self.continuation_prefix.clone()
} else {
quote_prefix
};
let prefix_width = display_width(&first_prefix).max(display_width(&continuation_prefix));
let available = self.width.saturating_sub(prefix_width).max(1);
let wrapped = wrap_spans(std::mem::take(&mut self.spans), available);
for (index, line) in wrapped.into_iter().enumerate() {
let prefix = if index == 0 {
&first_prefix
} else {
&continuation_prefix
};
let mut spans = Vec::new();
if !prefix.is_empty() {
spans.push(Span::styled(prefix.clone(), fg(RULE_COLOR)));
}
spans.extend(line);
self.lines.push(Line::from(spans));
}
self.block = MarkdownBlock::None;
}
fn finish_heading(&mut self) {
let level = match self.block {
MarkdownBlock::Heading(level) => level,
_ => MarkdownHeadingLevel::H3,
};
let text = spans_plain_text(&self.spans);
self.spans.clear();
if text.is_empty() {
self.block = MarkdownBlock::None;
return;
}
let style = match level {
MarkdownHeadingLevel::H1
| MarkdownHeadingLevel::H2
| MarkdownHeadingLevel::H3
| MarkdownHeadingLevel::H4 => bold(TEXT_PRIMARY),
MarkdownHeadingLevel::H5 => bold(TEXT_SECONDARY),
MarkdownHeadingLevel::H6 => bold(TEXT_DIM),
};
self.push_heading_margin(1);
for line in heading_lines(&text, level, self.width) {
self.lines.push(Line::from(Span::styled(line, style)));
}
if let Some(rule) = heading_rule(level) {
self.lines
.push(Line::from(Span::styled(rule, fg(RULE_COLOR))));
}
self.block = MarkdownBlock::None;
}
fn push_heading_margin(&mut self, count: usize) {
if self.lines.is_empty() {
return;
}
for _ in 0..count {
self.lines.push(Line::from(""));
}
}
fn finish_code_block(&mut self) {
if self.code_text.is_empty() && self.code_lang.is_none() {
return;
}
let label = self.code_lang.take().unwrap_or_else(|| "code".into());
self.lines.push(Line::from(vec![
Span::styled("╭─ ", fg(RULE_COLOR)),
Span::styled(label, italic(TEXT_DIM)),
]));
for raw in self.code_text.trim_end_matches('\n').lines() {
self.lines.push(Line::from(vec![
Span::styled("", fg(RULE_COLOR)),
Span::styled(
truncate(raw, self.width.saturating_sub(2)),
fg(TEXT_SECONDARY),
),
]));
}
self.lines
.push(Line::from(Span::styled("", fg(RULE_COLOR))));
self.code_text.clear();
self.block = MarkdownBlock::None;
}
fn finish_table(&mut self) {
let Some(table) = self.table.take() else {
return;
};
if table.rows.is_empty() {
return;
}
self.lines.extend(render_table(table, self.width));
}
}
fn heading_lines(text: &str, level: MarkdownHeadingLevel, width: usize) -> Vec<String> {
match level {
MarkdownHeadingLevel::H1 => glyph_heading_lines(text, width, 2),
MarkdownHeadingLevel::H2 => glyph_heading_lines(text, width, 1),
MarkdownHeadingLevel::H3 => wrap_words(&letterspaced_heading(&text.to_uppercase()), width),
MarkdownHeadingLevel::H4 | MarkdownHeadingLevel::H5 | MarkdownHeadingLevel::H6 => {
wrap_words(text, width)
}
}
}
fn heading_rule(level: MarkdownHeadingLevel) -> Option<String> {
Some(match level {
MarkdownHeadingLevel::H1 => "".repeat(32),
MarkdownHeadingLevel::H2 => "".repeat(24),
_ => return None,
})
}
fn glyph_heading_lines(text: &str, width: usize, scale: usize) -> Vec<String> {
let glyph_width = 4 * scale;
let chars_per_line = (width / glyph_width).max(1);
let mut lines = Vec::new();
for wrapped in wrap_words(&text.to_uppercase(), chars_per_line) {
let glyphs = wrapped.chars().map(heading_glyph).collect::<Vec<_>>();
for row in 0..3 {
let line = glyphs
.iter()
.map(|glyph| scale_glyph_row(glyph[row], scale))
.collect::<Vec<_>>()
.join(" ")
.trim_end()
.to_string();
if !line.is_empty() {
lines.push(line);
}
}
}
lines
}
fn scale_glyph_row(row: &str, scale: usize) -> String {
if scale <= 1 {
return row.to_string();
}
row.chars()
.flat_map(|character| std::iter::repeat_n(character, scale))
.collect()
}
fn heading_glyph(character: char) -> [&'static str; 3] {
match character {
'A' => ["▄▀▄", "█▀█", "▀ ▀"],
'B' => ["█▀▄", "█▀▄", "▀▀ "],
'C' => ["█▀▀", "", "▀▀▀"],
'D' => ["█▀▄", "█ █", "▀▀ "],
'E' => ["█▀▀", "█▀ ", "▀▀▀"],
'F' => ["█▀▀", "█▀ ", ""],
'G' => ["█▀▀", "█ ▄", "▀▀▀"],
'H' => ["█ █", "█▀█", "▀ ▀"],
'I' => ["▀█▀", "", "▀▀▀"],
'J' => [" ▀█", "", "▀▀ "],
'K' => ["█ █", "█▀▄", "▀ ▀"],
'L' => ["", "", "▀▀▀"],
'M' => ["█▄█", "█ █", "▀ ▀"],
'N' => ["█▄█", "█▀█", "▀ ▀"],
'O' => ["█▀█", "█ █", "▀▀▀"],
'P' => ["█▀█", "█▀▀", ""],
'Q' => ["█▀█", "█▄█", "▀▀█"],
'R' => ["█▀█", "█▀▄", "▀ ▀"],
'S' => ["█▀▀", "▀▀█", "▀▀▀"],
'T' => ["▀█▀", "", ""],
'U' => ["█ █", "█ █", "▀▀▀"],
'V' => ["█ █", "█ █", ""],
'W' => ["█ █", "█ █", "▀▄▀"],
'X' => ["█ █", "▄▀▄", "▀ ▀"],
'Y' => ["█ █", "▀█▀", ""],
'Z' => ["▀▀█", "▄▀ ", "▀▀▀"],
'0' => ["█▀█", "█ █", "▀▀▀"],
'1' => ["▄█ ", "", "▀▀▀"],
'2' => ["▀▀█", "█▀▀", "▀▀▀"],
'3' => ["▀▀█", " ▀█", "▀▀▀"],
'4' => ["█ █", "▀▀█", ""],
'5' => ["█▀▀", "▀▀█", "▀▀▀"],
'6' => ["█▀▀", "█▀█", "▀▀▀"],
'7' => ["▀▀█", "", ""],
'8' => ["█▀█", "█▀█", "▀▀▀"],
'9' => ["█▀█", "▀▀█", "▀▀▀"],
'-' | '' | '—' => [" ", "▀▀▀", " "],
'_' => [" ", " ", "▀▀▀"],
'/' => ["", "", ""],
'.' => [" ", " ", ""],
':' => ["", " ", ""],
'&' => ["█▄ ", "█▄█", "▀▄█"],
' ' => [" ", " ", " "],
_ => ["▀▀█", " ▄▀", ""],
}
}
fn letterspaced_heading(text: &str) -> String {
text.chars()
.flat_map(|character| [character, ' '])
.collect::<String>()
.trim_end()
.to_string()
}
fn wrap_spans(spans: Vec<Span<'static>>, width: usize) -> Vec<Vec<Span<'static>>> {
let width = width.max(1);
let mut lines = vec![Vec::new()];
let mut current_width = 0;
for span in spans {
let style = span.style;
for segment in span.content.split_inclusive('\n') {
let hard_break = segment.ends_with('\n');
let segment = segment.trim_end_matches('\n');
for word in segment.split_whitespace() {
let word_width = display_width(word);
if current_width == 0 {
push_wrapped_word(&mut lines, word, style, width, &mut current_width);
} else if current_width + 1 + word_width <= width {
lines
.last_mut()
.expect("line exists")
.push(Span::styled(" ", style));
current_width += 1;
push_wrapped_word(&mut lines, word, style, width, &mut current_width);
} else {
lines.push(Vec::new());
current_width = 0;
push_wrapped_word(&mut lines, word, style, width, &mut current_width);
}
}
if hard_break {
lines.push(Vec::new());
current_width = 0;
}
}
}
trim_trailing_empty_line(lines)
}
fn push_wrapped_word(
lines: &mut Vec<Vec<Span<'static>>>,
word: &str,
style: Style,
width: usize,
current_width: &mut usize,
) {
let mut remainder = word;
while display_width(remainder) > width {
let chunk: String = remainder.chars().take(width).collect();
let chunk_len = chunk.len();
lines
.last_mut()
.expect("line exists")
.push(Span::styled(chunk, style));
lines.push(Vec::new());
remainder = &remainder[chunk_len..];
*current_width = 0;
}
if !remainder.is_empty() {
lines
.last_mut()
.expect("line exists")
.push(Span::styled(remainder.to_string(), style));
*current_width += display_width(remainder);
}
}
fn trim_trailing_empty_line(mut lines: Vec<Vec<Span<'static>>>) -> Vec<Vec<Span<'static>>> {
if lines.len() > 1 && lines.last().is_some_and(Vec::is_empty) {
lines.pop();
}
lines
}
fn spans_plain_text(spans: &[Span<'_>]) -> String {
spans.iter().map(|span| span.content.as_ref()).collect()
}
fn quote_prefix(depth: usize) -> String {
"".repeat(depth)
}
fn render_table(table: TableState, width: usize) -> Vec<Line<'static>> {
let columns = table.rows.iter().map(Vec::len).max().unwrap_or(0);
if columns == 0 {
return Vec::new();
}
let mut widths = vec![3usize; columns];
for row in &table.rows {
for (index, cell) in row.iter().enumerate() {
widths[index] = widths[index].max(spans_plain_text(cell).chars().count().min(32));
}
}
let chrome = columns.saturating_add(1);
let separators = columns.saturating_sub(1) * 3;
let available = width.saturating_sub(chrome + separators).max(columns);
let total: usize = widths.iter().sum();
if total > available {
for column_width in &mut widths {
*column_width = ((*column_width * available) / total).max(3);
}
}
let mut lines = Vec::new();
lines.push(table_border('┌', '┬', '┐', &widths));
for (row_index, row) in table.rows.iter().enumerate() {
lines.push(table_row(row, &widths, &table.alignments));
if row_index == 0 {
lines.push(table_border('├', '┼', '┤', &widths));
}
}
lines.push(table_border('└', '┴', '┘', &widths));
lines
}
fn table_border(left: char, separator: char, right: char, widths: &[usize]) -> Line<'static> {
let mut text = String::new();
text.push(left);
for (index, width) in widths.iter().enumerate() {
if index > 0 {
text.push(separator);
}
text.push_str(&"".repeat(*width + 2));
}
text.push(right);
Line::from(Span::styled(text, fg(RULE_COLOR)))
}
fn table_row(
row: &[Vec<Span<'static>>],
widths: &[usize],
alignments: &[MarkdownAlignment],
) -> Line<'static> {
let mut spans = vec![Span::styled("", fg(RULE_COLOR))];
for (index, width) in widths.iter().enumerate() {
let text = row
.get(index)
.map(|cell| spans_plain_text(cell))
.unwrap_or_default();
let text = truncate_flat(&text, *width);
let text_width = display_width(&text);
let remaining = width.saturating_sub(text_width);
let (left_pad, right_pad) = match alignments.get(index).unwrap_or(&MarkdownAlignment::None)
{
MarkdownAlignment::Right => (remaining, 0),
MarkdownAlignment::Center => (remaining / 2, remaining - remaining / 2),
MarkdownAlignment::Left | MarkdownAlignment::None => (0, remaining),
};
spans.push(Span::raw(format!(
" {}{}{} ",
" ".repeat(left_pad),
text,
" ".repeat(right_pad)
)));
spans.push(Span::styled("", fg(RULE_COLOR)));
}
Line::from(spans)
}
+144
View File
@@ -0,0 +1,144 @@
use crossterm::event::{KeyCode, KeyModifiers};
use super::acp::ClientCommand;
use super::{App, Role, View};
#[derive(Clone, Copy)]
pub(super) struct SlashCommand {
pub(super) name: &'static str,
pub(super) description: &'static str,
}
pub(super) const SLASH_COMMANDS: &[SlashCommand] = &[
SlashCommand {
name: "/help",
description: "show this command menu",
},
SlashCommand {
name: "/extensions",
description: "manage configured extensions",
},
SlashCommand {
name: "/provider",
description: "choose the active provider",
},
SlashCommand {
name: "/model",
description: "choose the active model",
},
SlashCommand {
name: "/sessions",
description: "view recent sessions",
},
SlashCommand {
name: "/clear",
description: "clear the current chat history",
},
SlashCommand {
name: "/new",
description: "start a new session",
},
SlashCommand {
name: "/quit",
description: "exit goose",
},
];
pub(super) fn matching_slash_commands(input: &str) -> Vec<SlashCommand> {
let query = input.split_whitespace().next().unwrap_or(input);
SLASH_COMMANDS
.iter()
.copied()
.filter(|command| command.name.starts_with(query))
.collect()
}
impl App {
pub(super) fn handle_slash_command_key(
&mut self,
code: KeyCode,
modifiers: KeyModifiers,
) -> bool {
if modifiers != KeyModifiers::NONE {
return false;
}
let commands = self.slash_commands();
if commands.is_empty() {
return false;
}
match code {
KeyCode::Up => {
self.slash_selected = self.slash_selected.saturating_sub(1);
true
}
KeyCode::Down => {
self.slash_selected = (self.slash_selected + 1).min(commands.len() - 1);
true
}
KeyCode::Enter => {
let command = commands[self.slash_selected.min(commands.len() - 1)];
self.input.clear();
self.cursor = 0;
self.slash_selected = 0;
self.show_help_menu = false;
self.handle_slash(command.name);
true
}
_ => false,
}
}
pub(super) fn slash_commands(&self) -> Vec<SlashCommand> {
if !self.input.starts_with('/') {
return Vec::new();
}
if self.input.trim() == "/help" {
SLASH_COMMANDS.to_vec()
} else {
matching_slash_commands(&self.input)
}
}
pub(super) fn reset_slash_selection(&mut self) {
self.slash_selected = 0;
}
pub(super) fn handle_slash(&mut self, input: &str) {
match input.split_whitespace().next().unwrap_or_default() {
"/help" => {
self.show_help_menu = true;
}
"/sessions" => {
let _ = self.cmd_tx.send(ClientCommand::ListSessions);
}
"/extensions" => {
let _ = self.cmd_tx.send(ClientCommand::ListExtensions);
}
"/provider" => {
let _ = self.cmd_tx.send(ClientCommand::ListProviders);
self.view = View::Providers;
}
"/model" => {
self.ensure_models();
self.model_search.clear();
self.models_selected = 0;
self.view = View::Models;
}
"/clear" => self.clear_chat(),
"/new" => self.start_new_session(),
"/quit" => self.should_quit = true,
cmd => self.push_message(Role::System, format!("Unknown command: {cmd}. Type /help")),
}
}
pub(super) fn autocomplete_slash(&mut self) {
let matches = self.slash_commands();
if matches.len() == 1 {
self.input = format!("{} ", matches[0].name);
self.cursor = self.input.len();
self.reset_slash_selection();
}
}
}
+99
View File
@@ -0,0 +1,99 @@
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, BorderType, Borders, Padding};
pub(super) const BACKGROUND: Color = Color::Rgb(0, 0, 0);
pub(super) const CRANBERRY: Color = Color::Rgb(238, 238, 238);
pub(super) const TEAL: Color = Color::Rgb(245, 245, 245);
pub(super) const GOLD: Color = Color::Rgb(210, 210, 210);
pub(super) const TEXT_PRIMARY: Color = Color::White;
pub(super) const TEXT_SECONDARY: Color = Color::Rgb(188, 188, 188);
pub(super) const TEXT_DIM: Color = Color::Rgb(112, 112, 112);
pub(super) const RULE_COLOR: Color = Color::Rgb(38, 38, 38);
pub(super) const CEDAR: Color = Color::Rgb(72, 72, 72);
pub(super) const SPINNER: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
pub(super) fn fg(color: Color) -> Style {
Style::default().fg(color)
}
pub(super) fn bold(color: Color) -> Style {
fg(color).add_modifier(Modifier::BOLD)
}
pub(super) fn italic(color: Color) -> Style {
fg(color).add_modifier(Modifier::ITALIC)
}
pub(super) fn ui_block(border: Color, border_type: BorderType, padding: u16) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(border_type)
.border_style(fg(border))
.padding(Padding::horizontal(padding))
}
pub(super) fn wrap_words(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
if line.is_empty() {
line.push_str(word);
} else if line.len() + word.len() < width.max(1) {
line.push(' ');
line.push_str(word);
} else {
lines.push(std::mem::take(&mut line));
line.push_str(word);
}
}
if !line.is_empty() {
lines.push(line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
pub(super) fn display_width(text: &str) -> usize {
text.chars().count()
}
pub(super) fn truncate(text: &str, max: usize) -> String {
match text.chars().count() {
count if count <= max => text.to_string(),
_ if max > 1 => format!("{}", text.chars().take(max - 1).collect::<String>()),
_ => "".into(),
}
}
pub(super) fn truncate_flat(text: &str, max: usize) -> String {
truncate(&text.split_whitespace().collect::<Vec<_>>().join(" "), max)
}
pub(super) fn provider_columns(width: u16) -> usize {
((width.saturating_sub(4) / 38).max(1)) as usize
}
pub(super) fn terminal_width() -> u16 {
crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80)
}
pub(super) fn padded(area: Rect) -> Rect {
let horizontal = area.width.min(2);
let vertical = area.height.min(1);
Rect {
x: area.x + horizontal,
y: area.y + vertical,
width: area.width.saturating_sub(horizontal * 2),
height: area.height.saturating_sub(vertical * 2),
}
}
pub(super) fn centered(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x + (area.width - width) / 2,
y: area.y + (area.height - height) / 2,
width,
height,
}
}
+733
View File
@@ -0,0 +1,733 @@
use agent_client_protocol::schema::{
ContentBlock, EmbeddedResourceResource, ToolCallContent as AcpToolCallContent,
};
use ratatui::{
layout::{Alignment, Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
Frame,
};
use super::acp::ProviderInfo;
use super::chat::render_chat;
use super::slash::{SlashCommand, SLASH_COMMANDS};
use super::style::*;
use super::{App, ToolCall, View};
pub(super) fn render(frame: &mut Frame, app: &App) {
let full = frame.area();
frame.render_widget(
Block::default().style(Style::default().bg(BACKGROUND).fg(TEXT_PRIMARY)),
full,
);
let area = padded(full);
match app.view {
View::Splash => render_splash(frame, full, app),
View::Chat => render_chat(frame, area, app),
View::Providers => render_providers(frame, area, app),
View::Models => render_picker(
frame,
area,
"Models",
"Choose a model for your provider",
"search…",
"type to filter · ↑↓ navigate · enter select · esc back",
&app.model_search,
app.models_selected,
app.filtered_models()
.iter()
.map(|m| (m.as_str(), "", false))
.collect(),
"No matches",
),
View::Sessions => render_picker(
frame,
area,
"Sessions",
"recent sessions",
"",
"↑↓ navigate · enter resume · n new · esc back",
"",
app.sessions_selected,
app.sessions
.iter()
.map(|s| (s.title.as_str(), s.updated_at.as_str(), false))
.collect(),
"Nothing here yet",
),
View::Extensions => render_picker(
frame,
area,
"Extensions",
"session extensions",
"",
"↑↓ navigate · space toggle · esc back",
"",
app.extensions_selected,
app.extensions
.iter()
.map(|e| (e.name.as_str(), e.ext_type.as_str(), e.enabled))
.collect(),
"Nothing here yet",
),
}
}
fn heading(title: &str, subtitle: &str) -> Paragraph<'static> {
Paragraph::new(vec![
Line::from(Span::styled(title.to_string(), bold(TEXT_PRIMARY))),
Line::from(Span::styled(subtitle.to_string(), fg(TEXT_DIM))),
])
.alignment(Alignment::Center)
}
fn selected_style(selected: bool) -> Style {
fg(if selected {
TEXT_PRIMARY
} else {
TEXT_SECONDARY
})
.add_modifier(if selected {
Modifier::BOLD
} else {
Modifier::empty()
})
}
fn render_search(frame: &mut Frame, area: Rect, search: &str, placeholder: &str) {
let area = centered(area, area.width.saturating_sub(4).min(60), 3);
let block = ui_block(RULE_COLOR, BorderType::Rounded, 2);
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", bold(CRANBERRY)),
Span::styled(
if search.is_empty() {
placeholder
} else {
search
},
fg(if search.is_empty() {
TEXT_DIM
} else {
TEXT_PRIMARY
}),
),
])),
inner,
);
}
fn render_picker(
frame: &mut Frame,
area: Rect,
title: &str,
subtitle: &str,
placeholder: &str,
help: &str,
search: &str,
selected: usize,
items: Vec<(&str, &str, bool)>,
empty: &str,
) {
let constraints = if placeholder.is_empty() {
vec![
Constraint::Length(4),
Constraint::Min(1),
Constraint::Length(2),
]
} else {
vec![
Constraint::Length(4),
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(2),
]
};
let chunks = Layout::vertical(constraints).split(area);
frame.render_widget(heading(title, subtitle), chunks[0]);
let (list_chunk, help_chunk) = if placeholder.is_empty() {
(chunks[1], chunks[2])
} else {
render_search(frame, chunks[1], search, placeholder);
(chunks[2], chunks[3])
};
let list_width = area.width.saturating_sub(4).min(86);
let list = centered(list_chunk, list_width, list_chunk.height);
let visible = list.height.saturating_sub(2) as usize;
let scroll = selected.saturating_sub(visible.saturating_sub(1));
let lines = if items.is_empty() {
vec![Line::from(Span::styled(empty.to_string(), fg(TEXT_DIM)))]
} else {
items
.iter()
.enumerate()
.skip(scroll)
.take(visible)
.map(|(idx, (name, meta, enabled))| {
let name = if name.is_empty() {
"Untitled Session"
} else {
name
};
Line::from(vec![
Span::styled(if idx == selected { " " } else { " " }, bold(CRANBERRY)),
Span::styled(if *enabled { "" } else { "" }, fg(TEAL)),
Span::styled(
truncate_flat(
name,
if placeholder.is_empty() {
list_width as usize / 2
} else {
list_width.saturating_sub(8) as usize
},
),
selected_style(idx == selected),
),
Span::styled(format!(" {meta}"), fg(TEXT_DIM)),
])
})
.collect()
};
frame.render_widget(
Paragraph::new(lines).block(ui_block(RULE_COLOR, BorderType::Rounded, 1)),
list,
);
frame.render_widget(
Paragraph::new(help.to_string())
.style(fg(TEXT_DIM))
.alignment(Alignment::Center),
help_chunk,
);
}
pub(super) fn render_providers(frame: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::vertical([
Constraint::Length(4),
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(2),
])
.split(area);
frame.render_widget(
heading("goose", "Connect an AI model provider to get started"),
chunks[0],
);
render_search(frame, chunks[1], &app.provider_search, "search providers…");
render_provider_grid(frame, chunks[2], app);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"↑↓←→ navigate · enter select · type to search · esc clear/back",
fg(TEXT_DIM),
)))
.alignment(Alignment::Center),
chunks[3],
);
}
fn render_provider_grid(frame: &mut Frame, area: Rect, app: &App) {
let providers = app.filtered_providers();
if providers.is_empty() {
frame.render_widget(
Paragraph::new("No matching providers found")
.style(fg(TEXT_DIM))
.alignment(Alignment::Center),
area,
);
return;
}
let (card_width, card_height) = (36u16, 8u16);
let columns = provider_columns(area.width);
let rows_visible = ((area.height as usize + 1) / (card_height as usize + 1)).max(1);
let total_rows = providers.len().div_ceil(columns);
let scroll_row =
(app.providers_selected / columns).saturating_sub(rows_visible.saturating_sub(1));
let visible_rows = rows_visible.min(total_rows.saturating_sub(scroll_row));
let grid = centered(
area,
((columns as u16 * card_width) + (columns.saturating_sub(1) as u16 * 2)).min(area.width),
(visible_rows as u16 * (card_height + 1))
.saturating_sub(1)
.min(area.height),
);
for row in 0..visible_rows {
for col in 0..columns {
let idx = (scroll_row + row) * columns + col;
let Some(provider) = providers.get(idx) else {
continue;
};
let rect = Rect {
x: grid.x + col as u16 * (card_width + 2),
y: grid.y + row as u16 * (card_height + 1),
width: card_width,
height: card_height,
};
if rect.x + card_width <= area.x + area.width
&& rect.y + card_height <= area.y + area.height
{
render_provider_card(frame, rect, provider, idx == app.providers_selected);
}
}
}
if scroll_row > 0 {
scroll_hint(
frame,
area,
area.y,
format!("{} more above", scroll_row * columns),
);
}
if scroll_row + visible_rows < total_rows {
scroll_hint(
frame,
area,
area.y + area.height.saturating_sub(1),
format!(
"▼ {} more below",
providers
.len()
.saturating_sub((scroll_row + visible_rows) * columns)
),
);
}
}
fn scroll_hint(frame: &mut Frame, area: Rect, y: u16, text: String) {
frame.render_widget(
Paragraph::new(text)
.style(fg(TEXT_DIM))
.alignment(Alignment::Center),
Rect {
y,
height: 1,
..area
},
);
}
fn render_provider_card(frame: &mut Frame, area: Rect, provider: &ProviderInfo, selected: bool) {
let block = ui_block(
if selected { GOLD } else { RULE_COLOR },
BorderType::Plain,
1,
);
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
truncate(&provider.name, inner.width.saturating_sub(2) as usize),
selected_style(selected),
),
Span::styled(if provider.configured { "" } else { "" }, fg(TEAL)),
])),
Rect { height: 1, ..inner },
);
frame.render_widget(
Paragraph::new(truncate(&provider.id, inner.width as usize)).style(fg(TEXT_DIM)),
Rect {
y: inner.y + 2,
height: 1,
..inner
},
);
frame.render_widget(
Paragraph::new(truncate_flat(
&provider.description,
inner.width as usize * 3,
))
.style(fg(TEXT_DIM))
.wrap(Wrap { trim: true }),
Rect {
y: inner.y + 4,
height: 3,
..inner
},
);
}
pub(super) fn render_help_menu(frame: &mut Frame, area: Rect, app: &App) {
if app.view == View::Chat && app.show_help_menu {
render_command_menu(
frame,
centered(area, 56.min(area.width), 12.min(area.height)),
SLASH_COMMANDS,
None,
);
}
}
pub(super) fn render_slash_popover(frame: &mut Frame, area: Rect, app: &App) {
if app.view != View::Chat {
return;
}
let commands = app.slash_commands();
if commands.is_empty() {
return;
}
let height = (commands.len() as u16 + 2).min(10);
let visible = height.saturating_sub(2) as usize;
let selected = app.slash_selected.min(commands.len() - 1);
let start = selected.saturating_sub(visible.saturating_sub(1));
render_command_menu(
frame,
Rect {
x: area.x + 1,
y: area.y + area.height.saturating_sub(height + 3),
width: 52.min(area.width.saturating_sub(2)).max(24),
height,
},
&commands[start..(start + visible).min(commands.len())],
Some(selected - start),
);
}
fn render_command_menu(
frame: &mut Frame,
area: Rect,
commands: &[SlashCommand],
selected: Option<usize>,
) {
let inner_width = area.width.saturating_sub(4) as usize;
let lines = commands
.iter()
.enumerate()
.map(|(index, command)| {
let is_selected = selected == Some(index);
let style = Style::default()
.fg(if is_selected { BACKGROUND } else { GOLD })
.bg(if is_selected { GOLD } else { BACKGROUND })
.add_modifier(Modifier::BOLD);
let description_style = Style::default()
.fg(if is_selected { BACKGROUND } else { TEXT_DIM })
.bg(if is_selected { GOLD } else { BACKGROUND });
Line::from(vec![
Span::styled(if is_selected { " " } else { " " }, style),
Span::styled(command.name, style),
Span::styled(" ", description_style),
Span::styled(
truncate(
command.description,
inner_width.saturating_sub(command.name.len() + 4),
),
description_style,
),
])
})
.collect::<Vec<_>>();
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(fg(GOLD))
.title(Span::styled(" commands ", fg(TEXT_SECONDARY))),
),
area,
);
}
const GOOSE_FRAMES: &[&[&str]] = &[
&[
r#" __"#,
r#" __/o )_"#,
r#" .-.___/ ___/ \"#,
r#" / _ / \"#,
r#" /__/ \___/ _/\_ \"#,
r#" `---' / \__)"#,
r#" `-._,"#,
],
&[
r#" __"#,
r#" __/o )_"#,
r#" _.-_/ ___/ \"#,
r#" / _ / _\"#,
r#"/__/ \___/ _/ `"#,
r#" `---' /"#,
r#" `-._,"#,
],
&[
r#" __"#,
r#" __/o )_"#,
r#" __..__/ ___/ \"#,
r#" __/ _ / \"#,
r#" _/ \___/ _/\_ \"#,
r#" / `---' / \_)"#,
r#" _.-'"#,
],
&[
r#" __"#,
r#" __/o )_"#,
r#" .-.___/ ___/ \"#,
r#"/ _ / \"#,
r#" / \___/ _/\_ \"#,
r#" / `---' / \__)"#,
r#" `-._,"#,
],
];
pub(super) fn render_splash(frame: &mut Frame, area: Rect, app: &App) {
let tick = app.tick;
let prompt_height = 3.min(area.height);
let status_height = 3.min(area.height.saturating_sub(prompt_height));
let bottom_margin = area
.height
.saturating_sub(prompt_height + status_height + 8)
.min(3);
let lower_content_height = prompt_height + status_height + bottom_margin;
let flight_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(lower_content_height),
};
render_flying_goose(frame, flight_area, tick);
render_splash_status(
frame,
area,
status_height,
prompt_height + bottom_margin,
tick,
);
render_splash_prompt(
frame,
area,
prompt_height,
bottom_margin,
&app.input,
app.cursor,
);
}
fn render_flying_goose(frame: &mut Frame, area: Rect, tick: usize) {
if area.is_empty() {
return;
}
let goose = GOOSE_FRAMES[(tick / 2) % GOOSE_FRAMES.len()];
let goose_width = goose
.iter()
.map(|line| line.chars().count())
.max()
.unwrap_or(0);
let goose_height = goose.len() as u16;
let position =
((tick * 2) % (area.width as usize + goose_width + 8)) as isize - goose_width as isize;
let y = area
.y
.saturating_add(area.height.saturating_sub(goose_height) / 2)
.saturating_sub(usize::from(matches!(tick % 12, 3..=5 | 9..=11)) as u16);
if y >= area.y.saturating_add(area.height) {
return;
}
let x = position.max(0) as u16;
if x >= area.width {
return;
}
let visible_height = area
.y
.saturating_add(area.height)
.saturating_sub(y)
.min(goose_height);
let visible_width = area.width.saturating_sub(x) as usize;
let lines = goose
.iter()
.take(visible_height as usize)
.map(|line| {
Line::from(Span::styled(
line.chars()
.skip(if position < 0 {
position.saturating_abs() as usize
} else {
0
})
.take(visible_width)
.collect::<String>(),
fg(TEXT_PRIMARY).bg(BACKGROUND),
))
})
.collect::<Vec<_>>();
frame.render_widget(
Paragraph::new(lines),
Rect {
x: area.x + x,
y,
width: visible_width as u16,
height: visible_height,
},
);
}
fn render_splash_status(
frame: &mut Frame,
area: Rect,
height: u16,
bottom_offset: u16,
tick: usize,
) {
if height == 0 {
return;
}
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
"·".repeat((area.width.saturating_sub(8) as usize).min(48)),
fg(RULE_COLOR).bg(BACKGROUND),
)),
Line::from(vec![
Span::styled("goose", bold(TEXT_PRIMARY).bg(BACKGROUND)),
Span::styled(" / ", fg(RULE_COLOR).bg(BACKGROUND)),
Span::styled("initializing", fg(TEXT_DIM).bg(BACKGROUND)),
Span::raw(" "),
Span::styled(
SPINNER[tick % SPINNER.len()],
fg(TEXT_SECONDARY).bg(BACKGROUND),
),
]),
])
.alignment(Alignment::Center),
Rect {
x: area.x,
y: area.y + area.height.saturating_sub(height + bottom_offset),
width: area.width,
height,
},
);
}
fn render_splash_prompt(
frame: &mut Frame,
area: Rect,
height: u16,
bottom_margin: u16,
input: &str,
cursor: usize,
) {
if height == 0 {
return;
}
let prompt = centered(
Rect {
height,
y: area
.y
.saturating_add(area.height.saturating_sub(height + bottom_margin)),
..area
},
area.width.saturating_sub(12).max(32),
height,
);
let block = ui_block(RULE_COLOR, BorderType::Rounded, 2);
let input_area = block.inner(prompt);
frame.render_widget(block, prompt);
let text = if input.is_empty() {
vec![
Span::styled(" ", bold(CRANBERRY)),
Span::styled("Start typing while goose wakes up…", fg(TEXT_DIM)),
]
} else {
vec![
Span::styled(" ", bold(CRANBERRY)),
Span::styled(
truncate(input, input_area.width.saturating_sub(2) as usize),
fg(TEXT_PRIMARY),
),
]
};
frame.render_widget(Paragraph::new(Line::from(text)), input_area);
let x = input_area.x + 2 + (cursor as u16).min(input_area.width.saturating_sub(3));
frame.set_cursor_position((x, input_area.y));
}
pub(super) fn expanded_tool_lines(tool: &ToolCall, width: usize) -> Vec<Line<'static>> {
let mut lines = Vec::new();
push_expanded_section(
&mut lines,
"arguments",
format_json(tool.raw_input.as_ref()),
width,
);
push_expanded_section(&mut lines, "result", tool_result_text(tool), width);
lines
}
fn push_expanded_section(lines: &mut Vec<Line<'static>>, label: &str, text: String, width: usize) {
if !lines.is_empty() {
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
label.to_string(),
bold(TEXT_SECONDARY),
)));
if text.is_empty() {
lines.push(Line::from(Span::styled("(empty)", italic(TEXT_DIM))));
}
for raw in text.lines() {
for wrapped in wrap_words(raw, width) {
lines.push(Line::from(Span::styled(wrapped, fg(TEXT_PRIMARY))));
}
}
}
fn format_json(value: Option<&serde_json::Value>) -> String {
value
.and_then(|value| serde_json::to_string_pretty(value).ok())
.unwrap_or_default()
}
fn tool_result_text(tool: &ToolCall) -> String {
let raw = format_json(tool.raw_output.as_ref());
if !raw.is_empty() {
return raw;
}
tool.content
.iter()
.filter_map(|item| match item {
AcpToolCallContent::Content(content) => match &content.content {
ContentBlock::Text(text) => Some(text.text.clone()),
ContentBlock::ResourceLink(link) => Some(format!("link {}", link.uri)),
ContentBlock::Image(image) => Some(format!(
"image ({}){}",
image.mime_type,
image
.uri
.as_deref()
.map(|uri| format!(" {uri}"))
.unwrap_or_default()
)),
ContentBlock::Audio(audio) => Some(format!("audio ({})", audio.mime_type)),
ContentBlock::Resource(resource) => match &resource.resource {
EmbeddedResourceResource::TextResourceContents(text) => Some(text.text.clone()),
EmbeddedResourceResource::BlobResourceContents(blob) => {
Some(format!("blob {}", blob.uri))
}
_ => None,
},
_ => None,
},
AcpToolCallContent::Diff(diff) => {
let mut lines = vec![format!("diff {}", diff.path.display())];
if let Some(old) = &diff.old_text {
lines.extend(old.lines().map(|line| format!("- {line}")));
}
lines.extend(diff.new_text.lines().map(|line| format!("+ {line}")));
Some(lines.join("\n"))
}
AcpToolCallContent::Terminal(terminal) => {
Some(format!("▶ terminal: {}", terminal.terminal_id.0))
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n")
}
+5 -1
View File
@@ -100,13 +100,17 @@ impl HandleDispatchFrom<Client> for GooseAcpHandler {
.ok_or_else(|| agent_client_protocol::Error::invalid_params().data("Expected a value ID"))?
.clone();
let session_id = req.session_id.clone();
let model_name = req.meta.as_ref()
.and_then(|meta| meta.get("model"))
.and_then(|value| value.as_str())
.map(ToOwned::to_owned);
let sid = sid_short(session_id.0.as_ref());
let config_id = req.config_id.0.to_string();
let t_handler = std::time::Instant::now();
match config_id.as_ref() {
"provider" => {
Config::global().invalidate_secrets_cache();
match agent.update_provider(&session_id.0, &value_id.0, None, None, None).await {
match agent.update_provider(&session_id.0, &value_id.0, model_name.as_deref(), None, None).await {
Ok(_) => {}
Err(e) => { responder.respond_with_error(e)?; return Ok(()); }
}