aether-wisp 0.1.7

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use std::time::Duration;

use agent_client_protocol::{PlanEntry, PlanEntryPriority, PlanEntryStatus};
use tui::{
    Component, CrosstermEvent, Event, Frame, Gallery, GalleryMessage, Line, MouseCapture, TerminalConfig,
    TerminalRuntime, Theme, ViewContext, terminal_size,
};
use wisp::components::command_picker::{CommandEntry, CommandPicker};
use wisp::components::file_picker::{FileMatch, FilePicker};
use wisp::components::plan_view::PlanView;
use wisp::components::progress_indicator::ProgressIndicator;
use wisp::components::status_line::StatusLine;
use wisp::components::text_input::TextInput;
use wisp::components::thought_message::ThoughtMessage;
use wisp::components::tool_call_status_view::{ToolCallStatus, ToolCallStatusView};
use wisp::keybindings::Keybindings;

enum WispStory {
    TextInput(TextInput),
    CommandPicker(CommandPicker),
    FilePicker(FilePicker),
    ProgressIndicator(ProgressIndicatorStory),
    ToolCallStatus(ToolCallStatusStory),
    ThoughtMessage(ThoughtMessageStory),
    StatusLine(StatusLineStory),
    PlanView(PlanViewStory),
}

impl Component for WispStory {
    type Message = ();

    async fn on_event(&mut self, event: &Event) -> Option<Vec<()>> {
        match self {
            Self::TextInput(c) => c.on_event(event).await.map(|_| vec![]),
            Self::CommandPicker(c) => c.on_event(event).await.map(|_| vec![]),
            Self::FilePicker(c) => c.on_event(event).await.map(|_| vec![]),
            Self::ProgressIndicator(c) => c.on_event(event).await,
            Self::ToolCallStatus(c) => c.on_event(event).await,
            Self::ThoughtMessage(c) => c.on_event(event).await,
            Self::StatusLine(c) => c.on_event(event).await,
            Self::PlanView(c) => c.on_event(event).await,
        }
    }

    fn render(&mut self, ctx: &ViewContext) -> Frame {
        match self {
            Self::TextInput(c) => c.render(ctx),
            Self::CommandPicker(c) => c.render(ctx),
            Self::FilePicker(c) => c.render(ctx),
            Self::ProgressIndicator(c) => c.render(ctx),
            Self::ToolCallStatus(c) => c.render(ctx),
            Self::ThoughtMessage(c) => c.render(ctx),
            Self::StatusLine(c) => c.render(ctx),
            Self::PlanView(c) => c.render(ctx),
        }
    }
}

struct ProgressIndicatorStory {
    indicator: ProgressIndicator,
}

impl Component for ProgressIndicatorStory {
    type Message = ();

    async fn on_event(&mut self, event: &Event) -> Option<Vec<()>> {
        if let Event::Tick = event {
            self.indicator.on_tick();
        }
        Some(vec![])
    }

    fn render(&mut self, ctx: &ViewContext) -> Frame {
        self.indicator.render(ctx)
    }
}

struct ToolCallStatusStory {
    tick: u16,
}

impl Component for ToolCallStatusStory {
    type Message = ();

    async fn on_event(&mut self, event: &Event) -> Option<Vec<()>> {
        if let Event::Tick = event {
            self.tick = self.tick.wrapping_add(1);
        }
        Some(vec![])
    }

    fn render(&mut self, ctx: &ViewContext) -> Frame {
        let running = ToolCallStatus::Running;
        let success = ToolCallStatus::Success;
        let error = ToolCallStatus::Error("exit code 1".into());

        let mut lines = Vec::new();

        lines.extend(
            ToolCallStatusView {
                name: "Read",
                arguments: "src/main.rs",
                display_value: None,
                diff_preview: None,
                status: &running,
                tick: self.tick,
            }
            .render(ctx)
            .into_lines(),
        );
        lines.push(Line::default());

        lines.extend(
            ToolCallStatusView {
                name: "Write",
                arguments: "src/lib.rs",
                display_value: Some("src/lib.rs"),
                diff_preview: None,
                status: &success,
                tick: self.tick,
            }
            .render(ctx)
            .into_lines(),
        );
        lines.push(Line::default());

        lines.extend(
            ToolCallStatusView {
                name: "Bash",
                arguments: "cargo test",
                display_value: None,
                diff_preview: None,
                status: &error,
                tick: self.tick,
            }
            .render(ctx)
            .into_lines(),
        );

        Frame::new(lines)
    }
}

struct ThoughtMessageStory {
    text: String,
}

impl Component for ThoughtMessageStory {
    type Message = ();

    async fn on_event(&mut self, _event: &Event) -> Option<Vec<()>> {
        Some(vec![])
    }

    fn render(&mut self, ctx: &ViewContext) -> Frame {
        let view = ThoughtMessage { text: &self.text };
        view.render(ctx)
    }
}

struct StatusLineStory;

impl Component for StatusLineStory {
    type Message = ();

    async fn on_event(&mut self, _event: &Event) -> Option<Vec<()>> {
        Some(vec![])
    }

    fn render(&mut self, ctx: &ViewContext) -> Frame {
        let status = StatusLine {
            agent_name: "aether",
            config_options: &[],
            context_pct_left: Some(72),
            waiting_for_response: false,
            unhealthy_server_count: 0,
            content_padding: wisp::settings::DEFAULT_CONTENT_PADDING,
            exit_confirmation_active: false,
        };
        status.render(ctx)
    }
}

struct PlanViewStory {
    entries: Vec<PlanEntry>,
}

impl Component for PlanViewStory {
    type Message = ();

    async fn on_event(&mut self, _event: &Event) -> Option<Vec<()>> {
        Some(vec![])
    }

    fn render(&mut self, ctx: &ViewContext) -> Frame {
        let view = PlanView { entries: &self.entries };
        view.render(ctx)
    }
}

fn sample_commands() -> Vec<CommandEntry> {
    vec![
        CommandEntry {
            name: "clear".into(),
            description: "Clear screen and start a new session".into(),
            has_input: false,
            hint: None,
            builtin: true,
        },
        CommandEntry {
            name: "settings".into(),
            description: "Open settings".into(),
            has_input: false,
            hint: None,
            builtin: true,
        },
        CommandEntry {
            name: "search".into(),
            description: "Search code in the project".into(),
            has_input: true,
            hint: Some("query".into()),
            builtin: false,
        },
        CommandEntry {
            name: "web".into(),
            description: "Browse the web".into(),
            has_input: true,
            hint: Some("url".into()),
            builtin: false,
        },
        CommandEntry {
            name: "resume".into(),
            description: "Resume a previous session".into(),
            has_input: false,
            hint: None,
            builtin: true,
        },
    ]
}

fn sample_plan_entries() -> Vec<PlanEntry> {
    vec![
        PlanEntry::new("Research AI agent patterns", PlanEntryPriority::Medium, PlanEntryStatus::Completed),
        PlanEntry::new("Implement task tracking", PlanEntryPriority::High, PlanEntryStatus::Completed),
        PlanEntry::new("Build component gallery", PlanEntryPriority::Medium, PlanEntryStatus::InProgress),
        PlanEntry::new("Write integration tests", PlanEntryPriority::Medium, PlanEntryStatus::Pending),
        PlanEntry::new("Update documentation", PlanEntryPriority::Low, PlanEntryStatus::Pending),
    ]
}

fn sample_files() -> Vec<FileMatch> {
    vec![
        FileMatch { path: "src/main.rs".into(), display_name: "src/main.rs".into() },
        FileMatch { path: "src/lib.rs".into(), display_name: "src/lib.rs".into() },
        FileMatch { path: "src/components/mod.rs".into(), display_name: "src/components/mod.rs".into() },
        FileMatch { path: "src/components/gallery.rs".into(), display_name: "src/components/gallery.rs".into() },
        FileMatch { path: "src/rendering/renderer.rs".into(), display_name: "src/rendering/renderer.rs".into() },
        FileMatch { path: "Cargo.toml".into(), display_name: "Cargo.toml".into() },
        FileMatch { path: "README.md".into(), display_name: "README.md".into() },
    ]
}

fn stories() -> Vec<(String, WispStory)> {
    vec![
        (
            "TextInput".into(),
            WispStory::TextInput(TextInput::new(Keybindings::default())),
        ),
        (
            "CommandPicker".into(),
            WispStory::CommandPicker(CommandPicker::new(sample_commands())),
        ),
        (
            "FilePicker".into(),
            WispStory::FilePicker(FilePicker::new_with_entries(sample_files())),
        ),
        (
            "ProgressIndicator".into(),
            WispStory::ProgressIndicator({
                let mut indicator = ProgressIndicator::default();
                indicator.update(1, 3, true);
                ProgressIndicatorStory { indicator }
            }),
        ),
        (
            "ToolCallStatus".into(),
            WispStory::ToolCallStatus(ToolCallStatusStory { tick: 0 }),
        ),
        (
            "ThoughtMessage".into(),
            WispStory::ThoughtMessage(ThoughtMessageStory {
                text: "Let me analyze the codebase structure.\nI see several modules that need refactoring.\nStarting with the authentication layer."
                    .into(),
            }),
        ),
        ("StatusLine".into(), WispStory::StatusLine(StatusLineStory)),
        (
            "PlanView".into(),
            WispStory::PlanView(PlanViewStory {
                entries: sample_plan_entries(),
            }),
        ),
    ]
}

#[tokio::main]
async fn main() {
    let mut gallery = Gallery::new(stories());
    let size = terminal_size().unwrap_or((80, 24));
    let mut terminal = TerminalRuntime::new(
        std::io::stdout(),
        Theme::default(),
        size,
        TerminalConfig { bracketed_paste: true, mouse_capture: MouseCapture::Disabled },
    )
    .unwrap();
    let mut tick = tokio::time::interval(Duration::from_millis(100));
    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);

    terminal.render_frame(|ctx| gallery.render(ctx)).unwrap();

    loop {
        tokio::select! {
            Some(raw) = terminal.next_event() => {
                if let CrosstermEvent::Resize(cols, rows) = &raw {
                    terminal.on_resize((*cols, *rows));
                }
                if let Ok(event) = Event::try_from(raw) {
                    if let Some(msgs) = gallery.on_event(&event).await
                        && msgs.iter().any(|m| matches!(m, GalleryMessage::Quit))
                    {
                        return;
                    }
                    terminal.render_frame(|ctx| gallery.render(ctx)).unwrap();
                }
            }
            _ = tick.tick() => {
                gallery.on_event(&Event::Tick).await;
                terminal.render_frame(|ctx| gallery.render(ctx)).unwrap();
            }
        }
    }
}