aether-wisp 0.7.18

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use crate::renderer::DrawContext;
use crate::surfaces::input::UiOutput;
use crate::view::input::{Nav, UiEvent};
use crate::theme::Theme;
use crate::view::edit_buffer::{EditBuffer, apply_edit_key};
use crate::view::filterable_list::FilterableList;
use crate::view::widgets::visible_window;
use crate::view::wrap::as_u16;
use acp_utils::notifications::{WorkspaceEntry, WorkspaceMoveTarget};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use std::path::{Path, PathBuf};

/// Picker for moving the session to another workspace, or naming a new one.
pub struct WorkspacePicker {
    rows: FilterableList<WorkspaceRow>,
    parent_dir: Option<PathBuf>,
    mode: Mode,
}

enum Mode {
    List,
    NamingNew { name: EditBuffer },
}

#[derive(Clone)]
enum WorkspaceRow {
    Existing(WorkspaceEntry),
    CreateNew,
}

const CREATE_NEW_LABEL: &str = "Create new workspace…";

impl WorkspacePicker {
    pub fn new(workspaces: Vec<WorkspaceEntry>) -> Self {
        let parent_dir = workspaces.iter().find(|w| w.is_current).and_then(|w| w.path.parent().map(Path::to_path_buf));
        let mut rows: Vec<WorkspaceRow> =
            workspaces.into_iter().filter(|w| !w.is_current).map(WorkspaceRow::Existing).collect();
        rows.push(WorkspaceRow::CreateNew);
        Self {
            rows: FilterableList::new(rows, move |row| match row {
                WorkspaceRow::Existing(entry) => entry.path.display().to_string(),
                WorkspaceRow::CreateNew => CREATE_NEW_LABEL.to_string(),
            }),
            parent_dir,
            mode: Mode::List,
        }
    }

    fn on_naming_key(&mut self, key: KeyEvent) -> Vec<UiOutput> {
        let Mode::NamingNew { name } = &mut self.mode else {
            return Vec::new();
        };
        match key.code {
            KeyCode::Esc => {
                self.mode = Mode::List;
                Vec::new()
            }
            KeyCode::Enter => {
                let trimmed = name.text().trim().to_string();
                if trimmed.is_empty() {
                    return Vec::new();
                }
                vec![UiOutput::MoveWorkspace(WorkspaceMoveTarget::New { name: trimmed })]
            }
            _ => {
                apply_edit_key(name, key);
                Vec::new()
            }
        }
    }

    fn render_list(&mut self, area: Rect, buf: &mut Buffer, theme: &Theme) {
        self.rows.render_pane("Workspaces", "  (no matching workspaces)", area, buf, theme, |row| match row {
            WorkspaceRow::Existing(entry) => {
                Line::styled(format!("  {}", entry.path.display()), theme.text_secondary)
            }
            WorkspaceRow::CreateNew => Line::styled(format!("  {CREATE_NEW_LABEL}"), theme.info),
        });
    }

    fn render_name_input(
        parent_dir: Option<&Path>,
        name: &EditBuffer,
        area: Rect,
        buf: &mut Buffer,
        theme: &Theme,
    ) -> Position {
        let block = Block::bordered().title(" New workspace ").style(theme.text_primary);
        let inner = block.inner(area);
        block.render(area, buf);

        if let Some(parent) = parent_dir {
            let hint = Line::from(Span::styled(
                format!("  will be created in {}/", parent.display()),
                theme.muted,
            ));
            Paragraph::new(vec![Line::raw(""), hint]).render(inner, buf);
        }

        let prefix = "  Name: ";
        let (visible, cursor) = visible_window(name, usize::from(inner.width).saturating_sub(prefix.len()));
        Paragraph::new(Line::styled(format!("{prefix}{visible}"), theme.surface_style()))
            .render(Rect { height: 1, ..inner }, buf);
        Position::new(inner.x + as_u16(prefix.len() + cursor), inner.y)
    }
}

impl WorkspacePicker {
    pub(crate) fn on_ui_event(&mut self, event: &UiEvent) -> Vec<UiOutput> {
        if matches!(self.mode, Mode::NamingNew { .. }) {
            return match event {
                UiEvent::Key(key) => self.on_naming_key(*key),
                UiEvent::Paste(text) => self.on_paste(text),
                UiEvent::Mouse(..) => Vec::new(),
            };
        }
        if let UiEvent::Key(key) = event
            && key.code == KeyCode::Tab
        {
            return self.activate();
        }
        match self.rows.on_nav_event(event) {
            Nav::Close => vec![UiOutput::Close],
            Nav::Activate | Nav::Clicked => self.activate(),
            Nav::Moved | Nav::Unhandled => Vec::new(),
        }
    }

    fn on_paste(&mut self, text: &str) -> Vec<UiOutput> {
        if let Mode::NamingNew { name } = &mut self.mode {
            name.insert_paste(text);
        }
        Vec::new()
    }

    /// Acts on the focused row: existing workspaces move immediately, while
    /// "create new" switches to the name prompt.
    fn activate(&mut self) -> Vec<UiOutput> {
        match self.rows.selected_entry().cloned() {
            Some(WorkspaceRow::Existing(entry)) => {
                vec![UiOutput::MoveWorkspace(WorkspaceMoveTarget::Existing { path: entry.path })]
            }
            Some(WorkspaceRow::CreateNew) => {
                self.mode = Mode::NamingNew { name: EditBuffer::default() };
                Vec::new()
            }
            None => Vec::new(),
        }
    }
}

impl WorkspacePicker {
    pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
        match &mut self.mode {
            Mode::NamingNew { name } => {
                Some(Self::render_name_input(self.parent_dir.as_deref(), name, area, buf, cx.theme))
            }
            Mode::List => {
                self.render_list(area, buf, cx.theme);
                None
            }
        }
    }
}