use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use scrin::command_palette::{Command, CommandPalette};
use scrin::core::buffer::{Buffer, Cell};
use scrin::core::color::Color;
use scrin::core::rect::Rect;
use scrin::effects::{EffectPlayer, LoaderPlayer};
use scrin::flow_manager::{
FlowChoice, FlowManager, FlowRenderer, FlowState, FlowStep, FlowStepType,
};
use scrin::layout::{Constraint, Direction, Layout};
use scrin::overlays::toast::{self, ToastKind};
use scrin::overlays::{Modal, Overlay};
use scrin::panes::{Pane, PaneManager, ResizeBehavior};
use scrin::sanitize;
use scrin::scroll_state::{ScrollState, StickyScroll};
use scrin::status_bar::{StatusBar, StatusBarPosition};
use scrin::text_expander::{ExpandTrigger, Snippet, SuggestionPopup, TextExpander};
use scrin::widgets::block::{Block, BorderStyle};
use scrin::widgets::markdown_output::{MarkdownOutput, OutputTheme};
use scrin::widgets::Widget;
use std::io::{self, Write};
use std::time::{Duration, Instant};
const BG: Color = Color::rgb(8, 10, 16);
const PANEL_BG: Color = Color::rgb(13, 17, 23);
const ACCENT: Color = Color::rgb(88, 166, 255);
const GREEN: Color = Color::rgb(63, 185, 80);
const GOLD: Color = Color::rgb(255, 178, 72);
const DIM: Color = Color::rgb(110, 118, 129);
const TEXT: Color = Color::rgb(201, 209, 217);
const PINK: Color = Color::rgb(255, 0, 128);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Role {
System,
User,
Assistant,
}
#[derive(Debug, Clone)]
struct Message {
role: Role,
content: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Prompt,
Palette,
Leader,
Flow,
}
struct App {
panes: PaneManager,
palette: CommandPalette,
status: StatusBar,
expander: TextExpander,
suggestions: SuggestionPopup,
flow: FlowManager,
flow_renderer: FlowRenderer,
help_modal: Modal,
toasts: Vec<toast::Toast>,
messages: Vec<Message>,
prompt: String,
cursor: usize,
mode: Mode,
streaming: bool,
streaming_response: String,
streaming_chars: usize,
loader: LoaderPlayer,
matrix: EffectPlayer,
decrypt: EffectPlayer,
loader_tick: usize,
effect_tick: usize,
progress: f32,
progress_dir: f32,
fps: f64,
fps_timer: Instant,
fps_counter: u64,
frame: u64,
last_copy: String,
}
impl App {
fn new() -> Self {
let mut panes = PaneManager::new().with_direction(Direction::Horizontal);
panes.add_named_pane(
Pane::new(0, "workspace")
.with_constraint(Constraint::Length(24))
.with_min_size(18, 10)
.with_resize_behavior(ResizeBehavior::AutoCollapse)
.with_border_color(Color::rgb(56, 139, 253))
.with_toggle_key('f'),
"files",
);
panes.add_named_pane(
Pane::new(1, "session")
.with_constraint(Constraint::Min(40))
.with_min_size(35, 12)
.with_resize_behavior(ResizeBehavior::Fixed)
.with_border_color(ACCENT),
"session",
);
panes.add_named_pane(
Pane::new(2, "tools")
.with_constraint(Constraint::Length(30))
.with_min_size(24, 12)
.with_resize_behavior(ResizeBehavior::AutoCollapse)
.with_border_color(PINK)
.with_toggle_key('t'),
"tools",
);
let palette = CommandPalette::new().with_commands(vec![
command(
"Submit Prompt",
"Enter",
"send prompt to simulated AI",
"submit",
),
command(
"Toggle Files",
"Space f",
"open or collapse workspace pane",
"toggle_files",
),
command(
"Toggle Tools",
"Space t",
"open or collapse tools pane",
"toggle_tools",
),
command(
"Copy Last Response",
"Space c",
"copy assistant response using OSC52",
"copy",
),
command(
"Clear Transcript",
"Space x",
"clear all chat messages",
"clear",
),
command("Start Flow", "Ctrl+O", "show guided TUI workflow", "flow"),
command("Show Help", "?", "open keyboard help overlay", "help"),
command("Quit", "Ctrl+C", "exit demo", "quit"),
]);
let mut expander = TextExpander::new()
.with_trigger(ExpandTrigger::Hash)
.with_trigger(ExpandTrigger::Colon)
.with_max_suggestions(7);
expander.add_snippet(
Snippet::new(
"fix",
"inspect the failing behavior, identify the root cause, make the smallest correct patch, and verify with tests",
)
.with_trigger(ExpandTrigger::Slash)
.with_description("repair workflow"),
);
expander.add_snippet(
Snippet::new(
"explain",
"explain the architecture and call out edge cases, risks, and verification steps",
)
.with_trigger(ExpandTrigger::Slash)
.with_description("architecture explanation"),
);
expander.add_snippet(
Snippet::new(
"tests",
"add practical tests for state machines, timers, layout calculations, and effect composition",
)
.with_trigger(ExpandTrigger::Slash)
.with_description("test generation"),
);
expander.add_snippet(
Snippet::new(
"workspace",
"the current scrin workspace, including panes, overlays, command palette, status bar, text expansion, and aisling effects",
)
.with_trigger(ExpandTrigger::At)
.with_description("workspace context"),
);
expander.add_snippet(
Snippet::new(
"file",
"the focused Rust source file and its nearby module API",
)
.with_trigger(ExpandTrigger::At)
.with_description("focused file context"),
);
expander.add_snippet(
Snippet::new("ship", "produce production-ready Rust, no stubs, no TODOs, then run cargo fmt, cargo check, and cargo test")
.with_trigger(ExpandTrigger::Exclamation)
.with_description("release criteria"),
);
let mut flow = FlowManager::new("agent flow")
.with_highlight_color(GREEN)
.with_border_color(PINK);
flow.add_step(
FlowStep::new(
"intent",
"Capture Intent",
"The prompt layer expands slash and mention tokens, sanitizes text, then routes the request into the session pane.",
)
.with_type(FlowStepType::Choice)
.with_choice(FlowChoice::new("Patch code", "patch"))
.with_choice(FlowChoice::new("Explain code", "explain"))
.with_choice(FlowChoice::new("Run verification", "verify")),
);
flow.add_step(
FlowStep::new(
"compose",
"Compose Response",
"Aisling powers the thinking loaders and transition effects while the transcript streams sanitized assistant output.",
)
.with_type(FlowStepType::Single),
);
flow.add_step(
FlowStep::new(
"ship",
"Ship",
"The final pass keeps panes responsive, overlays transient, cursor feedback visible, and verification explicit.",
)
.with_type(FlowStepType::Single),
);
let mut help_modal = Modal::new(
"scrin Agent Shell",
"Enter sends, Ctrl+K opens commands, empty Space opens leader, Tab completes snippets, Ctrl+O opens flow, Ctrl+C quits.",
);
help_modal.show();
Self {
panes,
palette,
status: StatusBar::new().with_position(StatusBarPosition::Bottom),
expander,
suggestions: SuggestionPopup::new(),
flow,
flow_renderer: FlowRenderer::new().with_highlight_color(GREEN).with_border_color(PINK),
help_modal,
toasts: vec![toast::Toast::new(
"scrin shell: type /fix @workspace !ship",
ToastKind::Info,
)],
messages: vec![Message {
role: Role::System,
content: "Session initialized. The UI demonstrates pane toggles, command palette filtering, prompt expansion, streaming responses, loaders, transitions, overlays, and status feedback.".into(),
}],
prompt: String::from("/fix @workspace !ship"),
cursor: 21,
mode: Mode::Prompt,
streaming: false,
streaming_response: String::new(),
streaming_chars: 0,
loader: LoaderPlayer::new(LoaderPlayer::all_kinds()[0])
.with_label("thinking".into())
.with_size(24, 4)
.with_gradient_colors(vec![ACCENT, PINK, GREEN], 45.0),
matrix: EffectPlayer::new(
aisling::effects::EffectKind::Matrix,
"scrin panes overlays command palette prompt expansion",
)
.with_accent(GREEN)
.with_duration(120),
decrypt: EffectPlayer::new(
aisling::effects::EffectKind::Decrypt,
"streaming assistant response rendered through aisling",
)
.with_accent(ACCENT)
.with_duration(100),
loader_tick: 0,
effect_tick: 0,
progress: 0.0,
progress_dir: 0.02,
fps: 0.0,
fps_timer: Instant::now(),
fps_counter: 0,
frame: 0,
last_copy: String::new(),
}
}
fn update(&mut self, delta: Duration) {
self.frame = self.frame.wrapping_add(1);
self.fps_counter += 1;
if self.fps_timer.elapsed() >= Duration::from_secs(1) {
self.fps = self.fps_counter as f64 / self.fps_timer.elapsed().as_secs_f64();
self.fps_counter = 0;
self.fps_timer = Instant::now();
}
self.loader_tick = self.loader_tick.wrapping_add(1);
self.effect_tick = self.effect_tick.wrapping_add(1);
self.matrix.set_frame(self.effect_tick);
self.decrypt.set_frame(self.effect_tick);
self.progress += self.progress_dir;
if self.progress >= 1.0 {
self.progress = 1.0;
self.progress_dir = -0.015;
} else if self.progress <= 0.0 {
self.progress = 0.0;
self.progress_dir = 0.02;
}
if self.streaming {
let total = self.streaming_response.chars().count();
self.streaming_chars = (self.streaming_chars + 3).min(total);
if self.streaming_chars >= total {
self.streaming = false;
let content = self.streaming_response.clone();
self.last_copy = content.clone();
self.messages.push(Message {
role: Role::Assistant,
content,
});
self.streaming_response.clear();
self.streaming_chars = 0;
self.toasts.push(toast::Toast::new(
"Assistant response complete",
ToastKind::Success,
));
}
}
for toast in &mut self.toasts {
toast.update(delta);
}
self.toasts.retain(|toast| !toast.is_expired());
self.help_modal.update(delta);
self.suggestions
.update(self.expander.parse_input(&self.prompt));
}
fn submit_prompt(&mut self) {
if self.streaming {
self.toasts.push(toast::Toast::new(
"Assistant is still streaming",
ToastKind::Warning,
));
return;
}
let raw = self.prompt.trim();
let prompt = if raw.is_empty() {
"Review the TUI shell and propose the smallest production patch".to_string()
} else {
expand_prompt(raw, &self.expander)
};
self.messages.push(Message {
role: Role::User,
content: prompt.clone(),
});
self.streaming_response = build_response(&prompt);
self.streaming_chars = 0;
self.streaming = true;
self.prompt.clear();
self.cursor = 0;
self.toasts
.push(toast::Toast::new("Prompt submitted", ToastKind::Info));
}
fn insert_char(&mut self, ch: char) {
let idx = byte_index_for_char(&self.prompt, self.cursor);
self.prompt.insert(idx, ch);
self.cursor += 1;
}
fn paste_text(&mut self, text: &str) {
let sanitized = sanitize::sanitize_str(text, 1200).replace(['\n', '\r'], " ");
for ch in sanitized.chars() {
self.insert_char(ch);
}
}
fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let start = byte_index_for_char(&self.prompt, self.cursor - 1);
let end = byte_index_for_char(&self.prompt, self.cursor);
self.prompt.replace_range(start..end, "");
self.cursor -= 1;
}
fn move_left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
fn move_right(&mut self) {
let len = self.prompt.chars().count();
self.cursor = (self.cursor + 1).min(len);
}
fn complete_suggestion(&mut self) {
let result = self.expander.parse_input(&self.prompt);
if let Some(completed) = self.expander.complete(&result, self.suggestions.selected) {
self.prompt = completed;
self.cursor = self.prompt.chars().count();
}
}
fn start_flow(&mut self) {
self.flow.start();
self.mode = Mode::Flow;
self.toasts.push(toast::Toast::new(
"Flow started in tools pane",
ToastKind::Info,
));
}
fn clear_transcript(&mut self) {
self.messages.clear();
self.messages.push(Message {
role: Role::System,
content: "Transcript cleared. Prompt, panes, command palette, and effect state are still live.".into(),
});
self.streaming = false;
self.streaming_response.clear();
self.streaming_chars = 0;
self.toasts
.push(toast::Toast::new("Transcript cleared", ToastKind::Success));
}
}
fn command(name: &str, shortcut: &str, description: &str, action_id: &str) -> Command {
Command {
name: name.into(),
shortcut: Some(shortcut.into()),
description: description.into(),
action_id: action_id.into(),
}
}
fn main() -> io::Result<()> {
let mut stdout = io::stdout();
terminal::enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
let result = run(&mut stdout);
execute!(stdout, cursor::Show, LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
result
}
fn run(stdout: &mut io::Stdout) -> io::Result<()> {
let mut app = App::new();
let mut last_tick = Instant::now();
loop {
let now = Instant::now();
let delta = now.duration_since(last_tick);
last_tick = now;
app.update(delta);
let (cols, rows) = terminal::size()?;
app.panes.handle_resize(cols, rows.saturating_sub(4));
let mut buffer = Buffer::with_background(cols as usize, rows as usize, Some(BG));
render_app(&mut app, &mut buffer, cols, rows);
write!(stdout, "\x1b[H{}", buffer.to_ansi_string())?;
stdout.flush()?;
if event::poll(Duration::from_millis(28))? {
let ev = event::read()?;
if handle_event(&mut app, stdout, ev)? {
return Ok(());
}
}
}
}
fn handle_event(app: &mut App, stdout: &mut io::Stdout, ev: Event) -> io::Result<bool> {
match ev {
Event::Key(key) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') | KeyCode::Char('q') => return Ok(true),
KeyCode::Char('k') | KeyCode::Char('p') => {
app.palette.open();
app.mode = Mode::Palette;
return Ok(false);
}
KeyCode::Char('o') => {
app.start_flow();
return Ok(false);
}
_ => {}
}
}
if app.help_modal.is_visible() {
match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => app.help_modal.hide(),
_ => {}
}
return Ok(false);
}
match app.mode {
Mode::Palette => handle_palette_key(app, stdout, key.code),
Mode::Leader => handle_leader_key(app, stdout, key.code),
Mode::Flow => handle_flow_key(app, key.code),
Mode::Prompt => handle_prompt_key(app, key.code),
}
}
Event::Paste(text) => {
app.paste_text(&text);
Ok(false)
}
Event::Resize(_, _) => Ok(false),
_ => Ok(false),
}
}
fn handle_palette_key(app: &mut App, stdout: &mut io::Stdout, code: KeyCode) -> io::Result<bool> {
match code {
KeyCode::Esc => {
app.palette.close();
app.mode = Mode::Prompt;
}
KeyCode::Enter => {
let action = app.palette.execute_selected().map(str::to_string);
app.palette.close();
app.mode = Mode::Prompt;
if let Some(action) = action {
return execute_action(app, stdout, &action);
}
}
KeyCode::Backspace => app.palette.backspace(),
KeyCode::Up => app.palette.select_prev(),
KeyCode::Down => app.palette.select_next(),
KeyCode::Char(ch) => app.palette.input_char(ch),
_ => {}
}
Ok(false)
}
fn handle_leader_key(app: &mut App, stdout: &mut io::Stdout, code: KeyCode) -> io::Result<bool> {
app.mode = Mode::Prompt;
match code {
KeyCode::Esc => Ok(false),
KeyCode::Char('f') => execute_action(app, stdout, "toggle_files"),
KeyCode::Char('t') => execute_action(app, stdout, "toggle_tools"),
KeyCode::Char('p') => {
app.palette.open();
app.mode = Mode::Palette;
Ok(false)
}
KeyCode::Char('c') => execute_action(app, stdout, "copy"),
KeyCode::Char('x') => execute_action(app, stdout, "clear"),
KeyCode::Char('o') => execute_action(app, stdout, "flow"),
KeyCode::Char('h') | KeyCode::Char('?') => execute_action(app, stdout, "help"),
KeyCode::Char('q') => Ok(true),
_ => Ok(false),
}
}
fn handle_flow_key(app: &mut App, code: KeyCode) -> io::Result<bool> {
match code {
KeyCode::Esc => {
app.mode = Mode::Prompt;
}
KeyCode::Up | KeyCode::Char('k') => app.flow.select_choice_up(),
KeyCode::Down | KeyCode::Char('j') => app.flow.select_choice_down(),
KeyCode::Left | KeyCode::Char('p') => {
app.flow.go_back();
}
KeyCode::Right | KeyCode::Char('n') | KeyCode::Enter => {
app.flow.advance();
if matches!(app.flow.state, FlowState::Completed) {
app.mode = Mode::Prompt;
app.toasts
.push(toast::Toast::new("Flow completed", ToastKind::Success));
}
}
KeyCode::Char('s') => {
app.flow.skip();
}
KeyCode::Char('q') => {
app.flow.cancel();
app.mode = Mode::Prompt;
}
_ => {}
}
Ok(false)
}
fn handle_prompt_key(app: &mut App, code: KeyCode) -> io::Result<bool> {
match code {
KeyCode::Esc => {
if !app.prompt.is_empty() {
app.prompt.clear();
app.cursor = 0;
}
}
KeyCode::Enter => app.submit_prompt(),
KeyCode::Backspace => app.backspace(),
KeyCode::Left => app.move_left(),
KeyCode::Right => app.move_right(),
KeyCode::Home => app.cursor = 0,
KeyCode::End => app.cursor = app.prompt.chars().count(),
KeyCode::Up => app.suggestions.move_up(),
KeyCode::Down => app.suggestions.move_down(),
KeyCode::Tab => app.complete_suggestion(),
KeyCode::Char('?') => app.help_modal.show(),
KeyCode::Char(':') => {
app.palette.open();
app.mode = Mode::Palette;
}
KeyCode::Char(' ') if app.prompt.is_empty() => app.mode = Mode::Leader,
KeyCode::Char(ch) => app.insert_char(ch),
_ => {}
}
Ok(false)
}
fn execute_action(app: &mut App, stdout: &mut io::Stdout, action: &str) -> io::Result<bool> {
match action {
"submit" => app.submit_prompt(),
"toggle_files" => {
app.panes.toggle_by_name("files");
app.toasts
.push(toast::Toast::new("Workspace pane toggled", ToastKind::Info));
}
"toggle_tools" => {
app.panes.toggle_by_name("tools");
app.toasts
.push(toast::Toast::new("Tools pane toggled", ToastKind::Info));
}
"copy" => {
let text = if app.last_copy.is_empty() {
app.messages
.iter()
.rev()
.find(|msg| msg.role == Role::Assistant)
.map(|msg| msg.content.clone())
.unwrap_or_else(|| "No assistant response yet".into())
} else {
app.last_copy.clone()
};
write_osc52(stdout, &text)?;
app.toasts.push(toast::Toast::new(
"Copied last assistant response",
ToastKind::Copy,
));
}
"clear" => app.clear_transcript(),
"flow" => app.start_flow(),
"help" => app.help_modal.show(),
"quit" => return Ok(true),
_ => app
.toasts
.push(toast::Toast::new("Unknown command", ToastKind::Warning)),
}
Ok(false)
}
fn render_app(app: &mut App, buffer: &mut Buffer, cols: u16, rows: u16) {
if cols < 40 || rows < 12 {
render_tiny(buffer, cols, rows);
return;
}
let root = Rect::new(0, 0, cols, rows);
render_background(buffer, root, app.frame);
let layout = Layout::vertical(vec![
Constraint::Length(2),
Constraint::Min(0),
Constraint::Length(4),
Constraint::Length(1),
]);
let areas = layout.split(root);
render_header(app, buffer, areas[0]);
render_panes(app, buffer, areas[1]);
render_prompt(app, buffer, areas[2]);
app.status.clear();
app.status
.set_left(&format!("knott/open-shell mode:{:?}", app.mode), ACCENT);
app.status.set_center(
&format!(
"fps:{:.0} frames:{} panes:{}",
app.fps,
app.frame,
app.panes.visible_pane_ids().len()
),
DIM,
);
app.status.set_right(
if app.streaming { "streaming" } else { "ready" },
if app.streaming { GOLD } else { GREEN },
);
app.status.render(buffer, areas[3]);
let popup_y = areas[2].y.saturating_sub(7);
app.suggestions.render(
buffer,
Rect::new(areas[2].x + 2, popup_y, areas[2].width.saturating_sub(4), 7),
);
if app.mode == Mode::Leader {
render_leader(buffer, root);
}
app.palette.render(buffer, root);
app.help_modal.render(buffer, root);
for toast in &app.toasts {
toast.render(buffer, root);
}
}
fn render_background(buffer: &mut Buffer, area: Rect, frame: u64) {
buffer.fill(area, ' ', TEXT, Some(BG));
for y in area.y..area.bottom() {
if (y as u64 + frame / 5) % 4 == 0 {
for x in area.x..area.right() {
if (x + y) % 17 == 0 {
buffer.set(
x as usize,
y as usize,
Cell::new('·', Color::rgb(24, 32, 46), Some(BG)),
);
}
}
}
}
}
fn render_header(app: &App, buffer: &mut Buffer, area: Rect) {
let title = " scrin // agent shell ";
let hint =
" Ctrl+K palette | empty Space leader | Tab snippets | Ctrl+O flow | Ctrl+C quit ";
buffer.set_str_bold(
area.x as usize + 2,
area.y as usize,
title,
ACCENT,
Some(BG),
);
let shimmer = if app.frame % 30 < 15 { PINK } else { GREEN };
let hint = sanitize::truncate_str(hint, area.width.saturating_sub(4) as usize);
buffer.set_str(
area.x as usize + 2,
area.y as usize + 1,
&hint,
shimmer,
Some(BG),
);
}
fn render_panes(app: &mut App, buffer: &mut Buffer, area: Rect) {
let ids = app.panes.visible_pane_ids();
app.panes
.render_with_content(buffer, area, |idx, inner, buf| {
let Some(id) = ids.get(idx).copied() else {
return;
};
match id.0 {
0 => render_workspace(buf, inner),
1 => render_transcript(app, buf, inner),
2 => render_tools(app, buf, inner),
_ => {}
}
});
}
fn render_workspace(buffer: &mut Buffer, area: Rect) {
let files = [
("src/lib.rs", "pub api"),
("src/panes/mod.rs", "dynamic panes"),
("src/text_expander.rs", "prompt snippets"),
("src/flow_manager.rs", "agent flows"),
("src/effects/", "aisling bridge"),
("examples/demo_scrin_shell.rs", "this shell"),
];
let mut y = area.y as usize;
write_line(
buffer,
area.x as usize,
y,
"workspace",
GREEN,
Some(PANEL_BG),
area.width,
);
y += 2;
for (name, note) in files {
if y >= area.bottom() as usize {
break;
}
let line = format!("▸ {:<23} {}", name, note);
write_line(
buffer,
area.x as usize,
y,
&line,
TEXT,
Some(PANEL_BG),
area.width,
);
y += 1;
}
y += 1;
let hints = ["/fix", "/explain", "/tests", "@workspace", "@file", "!ship"];
for hint in hints {
if y >= area.bottom() as usize {
break;
}
let line = format!(" {}", hint);
write_line(
buffer,
area.x as usize,
y,
&line,
ACCENT,
Some(PANEL_BG),
area.width,
);
y += 1;
}
}
fn render_transcript(app: &App, buffer: &mut Buffer, area: Rect) {
let mut content = String::new();
for msg in &app.messages {
match msg.role {
Role::System => {
content.push_str("> ");
content.push_str(&msg.content.replace('\n', "\n> "));
content.push_str("\n\n");
}
Role::User => {
content.push_str("### You\n");
content.push_str(&msg.content);
content.push_str("\n\n");
}
Role::Assistant => {
content.push_str("### Assistant\n");
content.push_str(&msg.content);
content.push_str("\n\n");
}
};
}
if app.streaming {
content.push_str("### Assistant\n");
let visible = visible_prefix(&app.streaming_response, app.streaming_chars);
content.push_str(&visible);
content.push_str("\n█");
}
let mut theme = OutputTheme::SCRIN;
theme.bg = Some(PANEL_BG);
let output = MarkdownOutput::new(&content)
.with_theme(theme)
.with_code_line_numbers(true)
.with_scroll(ScrollState::new().with_sticky(StickyScroll::Bottom));
output.render(buffer, area);
}
fn render_tools(app: &App, buffer: &mut Buffer, area: Rect) {
if area.width < 8 || area.height < 4 {
return;
}
if matches!(app.flow.state, FlowState::Running | FlowState::Completed) {
app.flow_renderer.render_flow(&app.flow, buffer, area);
return;
}
let top = Rect::new(area.x, area.y, area.width, area.height / 2);
let bottom = Rect::new(
area.x,
area.y + top.height,
area.width,
area.height.saturating_sub(top.height),
);
let top_title = if app.streaming {
"thinking"
} else {
"ambient matrix"
};
let top_block = Block::new(top_title)
.with_borders(BorderStyle::Rounded)
.with_border_color(if app.streaming { GOLD } else { GREEN })
.with_bg(PANEL_BG);
top_block.render(buffer, top);
let top_inner = top_block.inner(top);
if app.streaming {
app.loader.render(
app.loader_tick,
LoaderPlayer::progress_from_fraction(app.progress),
buffer,
top_inner,
);
} else {
app.matrix.render_to_buffer(buffer, top_inner);
}
let bottom_block = Block::new("response effect")
.with_borders(BorderStyle::Rounded)
.with_border_color(ACCENT)
.with_bg(PANEL_BG);
bottom_block.render(buffer, bottom);
let bottom_inner = bottom_block.inner(bottom);
app.decrypt.render_to_buffer(buffer, bottom_inner);
}
fn render_prompt(app: &App, buffer: &mut Buffer, area: Rect) {
let title = if app.streaming {
"prompt // assistant streaming"
} else {
"prompt // /fix @workspace !ship"
};
let block = Block::new(title)
.with_borders(BorderStyle::Rounded)
.with_border_color(if app.streaming { GOLD } else { ACCENT })
.with_bg(PANEL_BG);
block.render(buffer, area);
let inner = block.inner(area);
if inner.width == 0 || inner.height == 0 {
return;
}
let prompt = sanitize::truncate_str(&app.prompt, inner.width.saturating_sub(4) as usize);
buffer.set_str(
inner.x as usize,
inner.y as usize,
"❯",
if app.streaming { GOLD } else { GREEN },
Some(PANEL_BG),
);
buffer.set_str(
inner.x as usize + 2,
inner.y as usize,
&prompt,
TEXT,
Some(PANEL_BG),
);
let cursor_x = inner.x as usize + 2 + app.cursor.min(prompt.chars().count());
if (app.frame / 12) % 2 == 0 && cursor_x < inner.right() as usize {
buffer.set(
cursor_x,
inner.y as usize,
Cell::new('█', ACCENT, Some(PANEL_BG)),
);
}
if inner.height > 1 {
let help = "Tab completes suggestions, Enter submits, : opens command palette, empty Space opens leader";
let help = sanitize::truncate_str(help, inner.width as usize);
buffer.set_str(
inner.x as usize,
inner.y as usize + 1,
&help,
DIM,
Some(PANEL_BG),
);
}
}
fn render_leader(buffer: &mut Buffer, root: Rect) {
let w = 56.min(root.width);
let h = 10.min(root.height);
let x = root.x + (root.width.saturating_sub(w)) / 2;
let y = root.y + (root.height.saturating_sub(h)) / 2;
let rect = Rect::new(x, y, w, h);
let block = Block::new("which-key")
.with_borders(BorderStyle::Double)
.with_border_color(PINK)
.with_bg(Color::rgb(16, 20, 31));
block.render(buffer, rect);
let inner = block.inner(rect);
let rows = [
"f toggle files pane t toggle tools pane",
"p command palette c copy last response",
"x clear transcript o open guided flow",
"h help overlay q quit",
];
for (i, row) in rows.iter().enumerate() {
write_line(
buffer,
inner.x as usize,
inner.y as usize + i,
row,
TEXT,
Some(Color::rgb(16, 20, 31)),
inner.width,
);
}
}
fn render_tiny(buffer: &mut Buffer, cols: u16, rows: u16) {
buffer.fill(Rect::new(0, 0, cols, rows), ' ', TEXT, Some(BG));
if rows > 0 {
write_line(
buffer,
0,
0,
"scrin demo needs at least 40x12",
GOLD,
Some(BG),
cols,
);
}
}
fn write_line(
buffer: &mut Buffer,
x: usize,
y: usize,
text: &str,
fg: Color,
bg: Option<Color>,
width: u16,
) {
if width == 0 || y >= buffer.height {
return;
}
let text = sanitize::truncate_str(text, width as usize);
buffer.set_str(x, y, &text, fg, bg);
}
fn expand_prompt(input: &str, expander: &TextExpander) -> String {
input
.split_whitespace()
.map(|token| expander.expand(token).unwrap_or_else(|| token.to_string()))
.collect::<Vec<_>>()
.join(" ")
}
fn build_response(prompt: &str) -> String {
format!(
"I expanded and sanitized your prompt as:\n\n> {}\n\n## Plan\n- Inspect the relevant modules before editing.\n- Keep the patch minimal and preserve the sleek terminal aesthetic.\n- Use dynamic panes for workspace/session/tools, keep overlays transient, and route commands through the palette.\n- Drive thinking/streaming visuals through aisling loaders and text effects.\n- Verify with cargo fmt, cargo check, cargo test, and example compilation.\n\n```rust\nlet loader = LoaderPlayer::new(LoaderPlayer::all_kinds()[0])\n .with_label(\"thinking\".into())\n .with_gradient_colors(vec![ACCENT, PINK, GREEN], 45.0);\n\nlet output = MarkdownOutput::new(&assistant_text)\n .with_code_line_numbers(true)\n .with_scroll(ScrollState::new().with_sticky(StickyScroll::Bottom));\n```\n\n```diff\n+ code blocks render with a subdued backdrop\n+ assistant output stays scrollable and sanitized\n+ aisling effects and loaders stay reusable through the TUI layer\n```\n\nResult: this shell demonstrates prompt editing, snippet expansion, command routing, flow panels, streaming assistant output, status feedback, copy toast, animated borders, code backdrops, and aisling-powered effects in one cohesive interface.",
prompt
)
}
fn visible_prefix(text: &str, chars: usize) -> String {
text.chars().take(chars).collect()
}
fn byte_index_for_char(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(idx, _)| idx)
.unwrap_or_else(|| s.len())
}
fn write_osc52(stdout: &mut io::Stdout, text: &str) -> io::Result<()> {
let encoded = base64_encode(text.as_bytes());
write!(stdout, "\x1b]52;c;{}\x07", encoded)?;
stdout.flush()
}
fn base64_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
let mut i = 0;
while i < bytes.len() {
let b0 = bytes[i];
let b1 = if i + 1 < bytes.len() { bytes[i + 1] } else { 0 };
let b2 = if i + 2 < bytes.len() { bytes[i + 2] } else { 0 };
let n = ((b0 as u32) << 16) | ((b1 as u32) << 8) | b2 as u32;
out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
if i + 1 < bytes.len() {
out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
} else {
out.push('=');
}
if i + 2 < bytes.len() {
out.push(TABLE[(n & 0x3f) as usize] as char);
} else {
out.push('=');
}
i += 3;
}
out
}