mirror of
https://github.com/block/goose.git
synced 2026-06-02 06:19:33 +02:00
feat: rust tui
This commit is contained in:
Generated
+177
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(¬ification, ¬ification_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 ¬ification.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(())
|
||||
}
|
||||
@@ -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(¬ice.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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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(()); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user