aether-wisp 0.4.21

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use crate::components::common::{INPUT_BOX_INDENT, input_box_frame, input_box_width};
use crate::workspace_status::home_relative_path;
use acp_utils::notifications::{WorkspaceEntry, WorkspaceMoveTarget};
use std::path::{Path, PathBuf};
use tui::{
    BorderedTextField, Combobox, Component, Event, Frame, KeyCode, Line, MouseEventKind, PickerMessage, Searchable,
    Style, ViewContext, truncate_text,
};

pub struct WorkspacePicker {
    rows: Vec<WorkspaceRow>,
    parent_dir: Option<PathBuf>,
    mode: Mode,
}

pub enum WorkspacePickerMessage {
    Close,
    Move { target: WorkspaceMoveTarget },
}

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);
        let mode = Mode::List(Box::new(list_mode(&rows)));
        Self { rows, parent_dir, mode }
    }
}

impl Component for WorkspacePicker {
    type Message = WorkspacePickerMessage;

    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
        match &mut self.mode {
            Mode::List(combobox) => {
                if let Event::Mouse(mouse) = event {
                    match mouse.kind {
                        MouseEventKind::ScrollUp => combobox.move_up(),
                        MouseEventKind::ScrollDown => combobox.move_down(),
                        MouseEventKind::Down(_) => {
                            if let Some(index) = mouse.row.checked_sub(4).map(usize::from) {
                                combobox.set_selected_index(index);
                                if let Some(row) = combobox.selected().cloned() {
                                    return Some(confirm_workspace_row(row));
                                }
                            }
                        }
                        _ => {}
                    }
                    return Some(vec![]);
                }

                let msgs = combobox.handle_picker_event(event)?;
                let mut out = Vec::new();
                let mut start_naming = false;
                for msg in msgs {
                    match msg {
                        PickerMessage::Close | PickerMessage::CloseAndPopChar => {
                            out.push(WorkspacePickerMessage::Close);
                        }
                        PickerMessage::Confirm(WorkspaceRow::Existing(entry)) => {
                            out.push(WorkspacePickerMessage::Move {
                                target: WorkspaceMoveTarget::Existing { path: entry.path },
                            });
                        }
                        PickerMessage::Confirm(WorkspaceRow::CreateNew) => start_naming = true,
                        _ => {}
                    }
                }
                if start_naming {
                    self.mode = Mode::NamingNew {
                        field: BorderedTextField::new("New workspace name", String::new())
                            .placeholder("directory name for the new workspace"),
                    };
                }
                Some(out)
            }
            Mode::NamingNew { field } => {
                let Event::Key(key) = event else {
                    return Some(vec![]);
                };
                match key.code {
                    KeyCode::Esc => {
                        self.mode = Mode::List(Box::new(list_mode(&self.rows)));
                        Some(vec![])
                    }
                    KeyCode::Enter => {
                        let trimmed = field.value().trim();
                        if trimmed.is_empty() {
                            Some(vec![])
                        } else {
                            Some(vec![WorkspacePickerMessage::Move {
                                target: WorkspaceMoveTarget::New { name: trimmed.to_string() },
                            }])
                        }
                    }
                    _ => {
                        field.on_event(event).await;
                        Some(vec![])
                    }
                }
            }
        }
    }

    fn render(&mut self, context: &ViewContext) -> Frame {
        match &mut self.mode {
            Mode::List(combobox) => render_list(combobox, context),
            Mode::NamingNew { field } => render_name_input(field, self.parent_dir.as_deref(), context),
        }
    }
}

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

enum Mode {
    List(Box<Combobox<WorkspaceRow>>),
    NamingNew { field: BorderedTextField },
}

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

impl Searchable for WorkspaceRow {
    fn search_text(&self) -> String {
        match self {
            WorkspaceRow::Existing(entry) => home_relative_path(&entry.path),
            WorkspaceRow::CreateNew => CREATE_NEW_LABEL.to_string(),
        }
    }
}

fn confirm_workspace_row(row: WorkspaceRow) -> Vec<WorkspacePickerMessage> {
    match row {
        WorkspaceRow::Existing(entry) => {
            vec![WorkspacePickerMessage::Move { target: WorkspaceMoveTarget::Existing { path: entry.path } }]
        }
        WorkspaceRow::CreateNew => Vec::new(),
    }
}

fn list_mode(rows: &[WorkspaceRow]) -> Combobox<WorkspaceRow> {
    Combobox::new(rows.to_vec()).close_on_whitespace(false)
}

fn render_list(combobox: &mut Combobox<WorkspaceRow>, context: &ViewContext) -> Frame {
    let search = input_box_frame("Move to workspace", combobox.query(), "type to filter workspaces", context);

    let mut lines = vec![Line::new(String::new())];
    if combobox.is_empty() {
        lines.push(Line::new("  (no matching workspaces)"));
    } else {
        lines.extend(combobox.render_items(context, |row, is_selected, ctx| {
            let text = match row {
                WorkspaceRow::Existing(entry) => format!("  {}", home_relative_path(&entry.path)),
                WorkspaceRow::CreateNew => format!("  {CREATE_NEW_LABEL}"),
            };
            let truncated = truncate_text(&text, ctx.size.width as usize);
            if is_selected {
                ctx.theme.selected_row_line(truncated)
            } else if matches!(row, WorkspaceRow::CreateNew) {
                Line::with_style(truncated, Style::fg(ctx.theme.info()))
            } else {
                Line::new(truncated)
            }
        }));
    }

    Frame::vstack([search, Frame::new(lines)])
}

fn render_name_input(field: &mut BorderedTextField, parent_dir: Option<&Path>, context: &ViewContext) -> Frame {
    let width = input_box_width(context);
    field.set_width(width);
    let input = Frame::new(field.render_field(context, true)).indent(INPUT_BOX_INDENT);
    let mut hint_lines = Vec::new();
    if let Some(parent) = parent_dir {
        let mut hint = Line::new("  ");
        hint.push_with_style(
            format!("will be created in {}/", home_relative_path(parent)),
            Style::fg(context.theme.muted()),
        );
        hint_lines.push(hint);
    }
    Frame::vstack([input, Frame::new(hint_lines)])
}