[connectors] Support connectors part 2 - slash command and tui (#9728)

- [x] Support `/apps` slash command to browse the apps in tui.
- [x] Support inserting apps to prompt using `$`.
- [x] Lots of simplification/renaming from connectors to apps.
This commit is contained in:
Matthew Zeng
2026-01-28 19:51:58 -08:00
committed by GitHub
parent ecc66f4f52
commit b9cd089d1f
36 changed files with 2028 additions and 365 deletions

View File

@@ -0,0 +1,163 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use textwrap::wrap;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::style::user_message_style;
use crate::wrapping::word_wrap_lines;
pub(crate) struct AppLinkView {
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
complete: bool,
}
impl AppLinkView {
pub(crate) fn new(
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
) -> Self {
Self {
title,
description,
instructions,
url,
is_installed,
complete: false,
}
}
fn content_lines(&self, width: u16) -> Vec<Line<'static>> {
let usable_width = width.max(1) as usize;
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(self.title.clone().bold()));
if let Some(description) = self
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
{
for line in wrap(description, usable_width) {
lines.push(Line::from(line.into_owned().dim()));
}
}
lines.push(Line::from(""));
if self.is_installed {
for line in wrap("Use $ to insert this app into the prompt.", usable_width) {
lines.push(Line::from(line.into_owned()));
}
lines.push(Line::from(""));
}
let instructions = self.instructions.trim();
if !instructions.is_empty() {
for line in wrap(instructions, usable_width) {
lines.push(Line::from(line.into_owned()));
}
for line in wrap(
"Newly installed apps can take a few minutes to appear in /apps.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
if !self.is_installed {
for line in wrap(
"After installed, use $ to insert this app into the prompt.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
}
lines.push(Line::from(""));
}
lines.push(Line::from(vec!["Open:".dim()]));
let url_line = Line::from(vec![self.url.clone().cyan().underlined()]);
lines.extend(word_wrap_lines(vec![url_line], usable_width));
lines
}
}
impl BottomPaneView for AppLinkView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if let KeyEvent {
code: KeyCode::Esc, ..
} = key_event
{
self.on_ctrl_c();
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.complete
}
}
impl crate::render::renderable::Renderable for AppLinkView {
fn desired_height(&self, width: u16) -> u16 {
let content_width = width.saturating_sub(4).max(1);
let content_lines = self.content_lines(content_width);
content_lines.len() as u16 + 3
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
Block::default()
.style(user_message_style())
.render(area, buf);
let [content_area, hint_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
let inner = content_area.inset(Insets::vh(1, 2));
let content_width = inner.width.max(1);
let lines = self.content_lines(content_width);
Paragraph::new(lines).render(inner, buf);
if hint_area.height > 0 {
let hint_area = Rect {
x: hint_area.x.saturating_add(2),
y: hint_area.y,
width: hint_area.width.saturating_sub(2),
height: hint_area.height,
};
hint_line().dim().render(hint_area, buf);
}
}
}
fn hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
])
}