tui: fix justfile launcher, add two-pane agent sidebar layout

`just tui` (formerly `tui-cli`) called `syn-cli tui`, a subcommand that
never existed on syn-cli, so the TUI could not be launched at all.
Point it at the actual syn-tui binary instead.

Restructure tui-app into a two-pane layout: a thin left sidebar listing
every registered agent (grouped like the GUI's sidebar, with Ensemble
and Settings pinned below a divider) and a right pane showing the
active agent's screen. Tab switches keyboard focus between the two
panes, chosen because neither tmux nor zellij bind a bare Tab by
default. Only the Notes agent has a real screen so far (moved
unchanged into notes_screen.rs); every other agent shows a placeholder.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Greg Shuflin
2026-07-05 20:36:49 -07:00
parent 1a201e9271
commit 92b5259fe2
6 changed files with 1007 additions and 737 deletions
+3 -2
View File
@@ -91,8 +91,9 @@ daemon-cli *args:
# Launch the TUI
[group: "run"]
tui-cli *args:
just cli tui {{args}}
[env("RUST_LOG", "info")]
tui *args:
cargo run --manifest-path rust/Cargo.toml --bin syn-tui -- {{args}}
# Build all Rust binaries (native)
[group: "build"]
+170
View File
@@ -0,0 +1,170 @@
//! Top-level two-pane TUI app: a thin sidebar listing agents on the left, and
//! the active agent's screen on the right. `Tab` switches which pane has
//! keyboard focus — chosen because neither tmux nor zellij bind a bare `Tab`
//! in their default configs, so it won't fight either multiplexer's prefix.
use std::io;
use std::path::{Path, PathBuf};
use anyhow::Result;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout},
};
use syn_daemon::client;
use synchronicity::agents::AgentId;
use crate::notes_screen::{self, NotesScreen};
use crate::placeholder;
use crate::sidebar::{self, Sidebar};
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum Focus {
Sidebar,
Content,
}
pub struct App {
sidebar: Sidebar,
focus: Focus,
notes: NotesScreen,
should_quit: bool,
}
impl App {
fn new(store_path: PathBuf) -> Self {
let mut sidebar = Sidebar::new();
// Start on the Notes agent since it's the only one with a real screen.
if let Some(i) = sidebar.agents.iter().position(|a| a.id == AgentId::Notes) {
sidebar.selected = i;
}
Self {
sidebar,
focus: Focus::Content,
notes: NotesScreen::new(store_path),
should_quit: false,
}
}
}
pub fn run_tui(store_path: &Path, log_config: synchronicity::log::LogConfig) -> Result<()> {
client::ensure_daemon_running(store_path, log_config)?;
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new(store_path.to_path_buf());
if let Err(e) = app.notes.load_notebooks() {
app.notes.error = Some(format!("Failed to load notebooks: {e}"));
}
let result = run_loop(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<()> {
loop {
terminal.draw(|f| render(f, app))?;
if app.should_quit {
break;
}
if event::poll(std::time::Duration::from_millis(50))?
&& let Event::Key(key) = event::read()?
{
handle_key(terminal, app, key)?;
}
}
Ok(())
}
fn handle_key(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
key: event::KeyEvent,
) -> Result<()> {
if key.kind != KeyEventKind::Press {
return Ok(());
}
// An open error popup on the Notes screen swallows the next key (any key)
// to dismiss itself, same as before this screen had sidebar siblings.
if app.focus == Focus::Content
&& app.sidebar.selected_agent().id == AgentId::Notes
&& app.notes.error.take().is_some()
{
return Ok(());
}
if key.code == KeyCode::Tab {
app.focus = match app.focus {
Focus::Sidebar => Focus::Content,
Focus::Content => Focus::Sidebar,
};
return Ok(());
}
match app.focus {
Focus::Sidebar => handle_sidebar_key(app, key),
Focus::Content => handle_content_key(terminal, app, key)?,
}
Ok(())
}
fn handle_sidebar_key(app: &mut App, key: event::KeyEvent) {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('j') | KeyCode::Down => app.sidebar.move_down(),
KeyCode::Char('k') | KeyCode::Up => app.sidebar.move_up(),
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => app.focus = Focus::Content,
_ => {}
}
}
fn handle_content_key(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
key: event::KeyEvent,
) -> Result<()> {
match app.sidebar.selected_agent().id {
AgentId::Notes if notes_screen::handle_key(terminal, &mut app.notes, key)? => {
app.should_quit = true;
}
AgentId::Notes => {}
_ if key.code == KeyCode::Char('q') => app.should_quit = true,
_ => {}
}
Ok(())
}
fn render(f: &mut Frame, app: &mut App) {
let area = f.area();
let chunks = Layout::horizontal([Constraint::Length(24), Constraint::Min(0)]).split(area);
sidebar::render(f, &app.sidebar, chunks[0], app.focus == Focus::Sidebar);
render_content(f, app, chunks[1]);
}
fn render_content(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let focused = app.focus == Focus::Content;
let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area);
let meta = app.sidebar.selected_agent().clone();
match meta.id {
AgentId::Notes => notes_screen::render(f, &mut app.notes, chunks[0], chunks[1], focused),
_ => placeholder::render(f, &meta, chunks[0], chunks[1], focused),
}
}
+6 -735
View File
@@ -1,30 +1,12 @@
use std::collections::HashSet;
use std::io;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::path::PathBuf;
use anyhow::Result;
use bpaf::Bpaf;
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
};
use serde_json::Value;
use syn_daemon::client;
use syn_daemon::protocol::{CollectionCmd, DaemonCommand, NoteCmd, NotebookCmd};
use synchronicity::documents::DocumentID;
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
mod app;
mod notes_screen;
mod placeholder;
mod sidebar;
#[derive(Bpaf, Debug)]
#[bpaf(options, version, descr("Synchronicity TUI"))]
@@ -46,716 +28,5 @@ fn run() -> Result<()> {
synchronicity::log::init_logger(log_config);
let cli = cli_arguments().run();
let store_path = cli.store.unwrap_or_else(synchronicity::default_store_path);
run_tui(&store_path, log_config)
}
// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct FlatItem {
id: DocumentID,
title: String,
depth: usize,
kind: ItemKind,
}
#[derive(Clone, PartialEq)]
enum ItemKind {
Notebook,
Collection,
Note,
}
// ---------------------------------------------------------------------------
// App state
// ---------------------------------------------------------------------------
enum AppScreen {
NotebookList,
NotebookView { notebook_id: DocumentID, notebook_title: String },
}
#[derive(Clone)]
enum InputAction {
NewNotebook,
NewCollection { parent_id: DocumentID },
NewNote { parent_id: DocumentID },
Rename { id: DocumentID, kind: ItemKind },
}
enum Mode {
Normal,
Typing { prompt: String, buffer: String, action: InputAction },
Confirming { message: String, target_id: DocumentID, kind: ItemKind },
}
struct App {
store_path: PathBuf,
screen: AppScreen,
items: Vec<FlatItem>,
list_state: ListState,
expanded: HashSet<String>,
preview: String,
mode: Mode,
status: String,
error: Option<String>,
should_quit: bool,
}
impl App {
fn new(store_path: PathBuf) -> Self {
Self {
store_path,
screen: AppScreen::NotebookList,
items: Vec::new(),
list_state: ListState::default(),
expanded: HashSet::new(),
preview: String::new(),
mode: Mode::Normal,
status: String::new(),
error: None,
should_quit: false,
}
}
fn query(&self, cmd: &DaemonCommand) -> Result<Value> {
client::query_daemon(&self.store_path, cmd)
}
fn load_notebooks(&mut self) -> Result<()> {
let val = self.query(&DaemonCommand::Notebook(NotebookCmd::List))?;
let notebooks = val["notebooks"].as_array().cloned().unwrap_or_default();
self.items = notebooks
.into_iter()
.filter_map(|nb| {
let id = parse_id(nb["id"].as_str()?)?;
let title = nb["title"].as_str().unwrap_or("(untitled)").to_string();
Some(FlatItem { id, title, depth: 0, kind: ItemKind::Notebook })
})
.collect();
select_first(&mut self.list_state, &self.items);
self.preview.clear();
self.status = "j/k: navigate Enter: open n: new notebook d: delete r: rename q: quit"
.to_string();
Ok(())
}
fn enter_notebook(&mut self, id: DocumentID, title: String) -> Result<()> {
self.expanded.clear();
self.screen = AppScreen::NotebookView { notebook_id: id, notebook_title: title };
self.reload_tree()
}
fn reload_tree(&mut self) -> Result<()> {
let AppScreen::NotebookView { ref notebook_id, .. } = self.screen else {
return Ok(());
};
let nb_id = notebook_id.clone();
let val = self.query(&DaemonCommand::Notebook(NotebookCmd::Tree { id: nb_id }))?;
// Remember where we were
let prev_id =
self.list_state.selected().and_then(|i| self.items.get(i)).map(|fi| fi.id.to_string());
self.items.clear();
if let Some(children) = val["children"].as_array() {
flatten_json_nodes(children, 0, &self.expanded, &mut self.items);
}
// Restore selection to same item if still present
if let Some(ref pid) = prev_id {
if let Some(pos) = self.items.iter().position(|fi| fi.id.to_string() == *pid) {
self.list_state.select(Some(pos));
} else {
select_first(&mut self.list_state, &self.items);
}
} else {
select_first(&mut self.list_state, &self.items);
}
self.refresh_preview();
self.status = "j/k: navigate Enter: expand/collapse e: edit n: new note \
f: new collection d: delete r: rename b: back q: quit"
.to_string();
Ok(())
}
fn refresh_preview(&mut self) {
let Some(item) = self.list_state.selected().and_then(|i| self.items.get(i)) else {
self.preview.clear();
return;
};
if item.kind != ItemKind::Note {
self.preview.clear();
return;
}
let id = item.id.clone();
self.preview = match self.query(&DaemonCommand::Note(NoteCmd::Show { id })) {
Ok(val) => val["content"].as_str().unwrap_or("").to_string(),
Err(e) => format!("Error loading note: {e}"),
};
}
/// Returns the DocumentID of the container that a new child should be created in:
/// the selected collection if one is selected, otherwise the notebook root.
fn current_container_id(&self) -> Option<DocumentID> {
let AppScreen::NotebookView { ref notebook_id, .. } = self.screen else { return None };
let container = match self.list_state.selected().and_then(|i| self.items.get(i)) {
Some(fi) if fi.kind == ItemKind::Collection => fi.id.clone(),
_ => notebook_id.clone(),
};
Some(container)
}
fn selected_item_cloned(&self) -> Option<FlatItem> {
self.list_state.selected().and_then(|i| self.items.get(i)).cloned()
}
fn move_down(&mut self) {
let len = self.items.len();
if len == 0 {
return;
}
let next = match self.list_state.selected() {
Some(i) if i + 1 < len => i + 1,
_ => 0,
};
self.list_state.select(Some(next));
self.refresh_preview();
}
fn move_up(&mut self) {
let len = self.items.len();
if len == 0 {
return;
}
let prev = match self.list_state.selected() {
Some(0) | None => len.saturating_sub(1),
Some(i) => i - 1,
};
self.list_state.select(Some(prev));
self.refresh_preview();
}
}
fn flatten_json_nodes(
nodes: &[Value],
depth: usize,
expanded: &HashSet<String>,
out: &mut Vec<FlatItem>,
) {
for node in nodes {
match node["kind"].as_str() {
Some("collection") => {
let Some(id) = parse_id(node["id"].as_str().unwrap_or("")) else { continue };
let title = node["title"].as_str().unwrap_or("(unnamed)").to_string();
let id_str = id.to_string();
out.push(FlatItem { id, title, depth, kind: ItemKind::Collection });
if expanded.contains(&id_str)
&& let Some(children) = node["children"].as_array()
{
flatten_json_nodes(children, depth + 1, expanded, out);
}
}
Some("note") => {
let Some(id) = parse_id(node["id"].as_str().unwrap_or("")) else { continue };
let base = node["title"].as_str().unwrap_or("(untitled)");
let ext = match node["format"].as_str() {
Some("markdown") => "md",
Some("typst") => "typ",
_ => "txt",
};
out.push(FlatItem {
id,
title: format!("{base}.{ext}"),
depth,
kind: ItemKind::Note,
});
}
_ => {}
}
}
}
fn parse_id(s: &str) -> Option<DocumentID> {
DocumentID::from_str(s).ok()
}
fn select_first(state: &mut ListState, items: &[FlatItem]) {
state.select(if items.is_empty() { None } else { Some(0) });
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
fn run_tui(store_path: &Path, log_config: synchronicity::log::LogConfig) -> Result<()> {
client::ensure_daemon_running(store_path, log_config)?;
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new(store_path.to_path_buf());
if let Err(e) = app.load_notebooks() {
app.error = Some(format!("Failed to load notebooks: {e}"));
}
let result = run_loop(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<()> {
loop {
terminal.draw(|f| render(f, app))?;
if app.should_quit {
break;
}
if event::poll(std::time::Duration::from_millis(50))?
&& let Event::Key(key) = event::read()?
{
handle_key(terminal, app, key)?;
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Key handling
// ---------------------------------------------------------------------------
fn handle_key(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
key: event::KeyEvent,
) -> Result<()> {
// Dismiss error on any key
if app.error.take().is_some() {
return Ok(());
}
if key.kind != event::KeyEventKind::Press {
return Ok(());
}
match &app.mode {
Mode::Normal => handle_normal(terminal, app, key),
Mode::Typing { .. } => handle_typing(app, key),
Mode::Confirming { .. } => handle_confirming(app, key),
}
}
fn handle_normal(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
key: event::KeyEvent,
) -> Result<()> {
match key.code {
KeyCode::Char('q') => {
app.should_quit = true;
}
KeyCode::Char('j') | KeyCode::Down => app.move_down(),
KeyCode::Char('k') | KeyCode::Up => app.move_up(),
KeyCode::Enter | KeyCode::Right => {
let is_list = matches!(app.screen, AppScreen::NotebookList);
if is_list {
if let Some(fi) = app.selected_item_cloned()
&& let Err(e) = app.enter_notebook(fi.id, fi.title)
{
app.error = Some(format!("{e}"));
}
} else if let Some(fi) = app.selected_item_cloned() {
match fi.kind {
ItemKind::Collection => {
let id_str = fi.id.to_string();
if app.expanded.contains(&id_str) {
app.expanded.remove(&id_str);
} else {
app.expanded.insert(id_str);
}
if let Err(e) = app.reload_tree() {
app.error = Some(format!("{e}"));
}
}
ItemKind::Note => {
if let Err(e) = edit_note(terminal, app, fi.id) {
app.error = Some(format!("{e}"));
}
}
ItemKind::Notebook => {}
}
}
}
KeyCode::Char('b') | KeyCode::Left => {
if matches!(app.screen, AppScreen::NotebookView { .. }) {
app.screen = AppScreen::NotebookList;
if let Err(e) = app.load_notebooks() {
app.error = Some(format!("{e}"));
}
}
}
KeyCode::Char('e') => {
if matches!(app.screen, AppScreen::NotebookView { .. })
&& let Some(fi) = app.selected_item_cloned()
&& fi.kind == ItemKind::Note
&& let Err(e) = edit_note(terminal, app, fi.id)
{
app.error = Some(format!("{e}"));
}
}
KeyCode::Char('n') => {
if matches!(app.screen, AppScreen::NotebookList) {
app.mode = Mode::Typing {
prompt: "New notebook title: ".to_string(),
buffer: String::new(),
action: InputAction::NewNotebook,
};
} else if let Some(parent_id) = app.current_container_id() {
app.mode = Mode::Typing {
prompt: "New note title: ".to_string(),
buffer: String::new(),
action: InputAction::NewNote { parent_id },
};
}
}
KeyCode::Char('f') => {
if matches!(app.screen, AppScreen::NotebookView { .. })
&& let Some(parent_id) = app.current_container_id()
{
app.mode = Mode::Typing {
prompt: "New collection name: ".to_string(),
buffer: String::new(),
action: InputAction::NewCollection { parent_id },
};
}
}
KeyCode::Char('r') => {
if let Some(fi) = app.selected_item_cloned() {
app.mode = Mode::Typing {
prompt: format!("Rename '{}' to: ", fi.title),
buffer: fi.title.clone(),
action: InputAction::Rename { id: fi.id, kind: fi.kind },
};
}
}
KeyCode::Char('d') => {
if let Some(fi) = app.selected_item_cloned() {
let kind_label = match fi.kind {
ItemKind::Notebook => "notebook",
ItemKind::Collection => "collection (and all contents)",
ItemKind::Note => "note",
};
app.mode = Mode::Confirming {
message: format!("Delete {} '{}'? (y/n)", kind_label, fi.title),
target_id: fi.id,
kind: fi.kind,
};
}
}
_ => {}
}
Ok(())
}
fn handle_typing(app: &mut App, key: event::KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
app.mode = Mode::Normal;
}
KeyCode::Enter => {
let (title, action) = match &app.mode {
Mode::Typing { buffer, action, .. } => (buffer.trim().to_string(), action.clone()),
_ => return Ok(()),
};
app.mode = Mode::Normal;
if !title.is_empty()
&& let Err(e) = execute_input(app, &action, &title)
{
app.error = Some(format!("{e}"));
}
let is_list = matches!(app.screen, AppScreen::NotebookList);
if is_list {
if let Err(e) = app.load_notebooks() {
app.error = Some(format!("{e}"));
}
} else if let Err(e) = app.reload_tree() {
app.error = Some(format!("{e}"));
}
}
KeyCode::Backspace => {
if let Mode::Typing { ref mut buffer, .. } = app.mode {
buffer.pop();
}
}
KeyCode::Char(c) => {
if let Mode::Typing { ref mut buffer, .. } = app.mode {
buffer.push(c);
}
}
_ => {}
}
Ok(())
}
fn execute_input(app: &App, action: &InputAction, title: &str) -> Result<()> {
match action {
InputAction::NewNotebook => {
app.query(&DaemonCommand::Notebook(NotebookCmd::New { title: title.to_string() }))?;
}
InputAction::NewCollection { parent_id } => {
app.query(&DaemonCommand::Collection(CollectionCmd::New {
parent_id: parent_id.clone(),
title: title.to_string(),
}))?;
}
InputAction::NewNote { parent_id } => {
app.query(&DaemonCommand::Note(NoteCmd::New {
parent_id: parent_id.clone(),
title: title.to_string(),
}))?;
}
InputAction::Rename { id, kind } => {
let new_title = title.to_string();
let cmd = match kind {
ItemKind::Notebook => {
DaemonCommand::Notebook(NotebookCmd::Rename { id: id.clone(), new_title })
}
ItemKind::Collection => {
DaemonCommand::Collection(CollectionCmd::Rename { id: id.clone(), new_title })
}
ItemKind::Note => {
DaemonCommand::Note(NoteCmd::Rename { id: id.clone(), new_title })
}
};
app.query(&cmd)?;
}
}
Ok(())
}
fn handle_confirming(app: &mut App, key: event::KeyEvent) -> Result<()> {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
let (target_id, kind) = match &app.mode {
Mode::Confirming { target_id, kind, .. } => (target_id.clone(), kind.clone()),
_ => return Ok(()),
};
app.mode = Mode::Normal;
let cmd = match kind {
ItemKind::Notebook => {
DaemonCommand::Notebook(NotebookCmd::Delete { id: target_id })
}
ItemKind::Collection => {
DaemonCommand::Collection(CollectionCmd::Delete { id: target_id })
}
ItemKind::Note => DaemonCommand::Note(NoteCmd::Delete { id: target_id }),
};
if let Err(e) = app.query(&cmd) {
app.error = Some(format!("{e}"));
}
let is_list = matches!(app.screen, AppScreen::NotebookList);
if is_list {
if let Err(e) = app.load_notebooks() {
app.error = Some(format!("{e}"));
}
} else if let Err(e) = app.reload_tree() {
app.error = Some(format!("{e}"));
}
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.mode = Mode::Normal;
}
_ => {}
}
Ok(())
}
fn edit_note(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
note_id: DocumentID,
) -> Result<()> {
let val = app.query(&DaemonCommand::Note(NoteCmd::Show { id: note_id.clone() }))?;
let content = val["content"].as_str().unwrap_or("").to_string();
let mut tmp = tempfile::Builder::new().suffix(".md").tempfile()?;
use std::io::Write;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
let tmp_path = tmp.path().to_owned();
// Suspend TUI while editor runs
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let _ = std::process::Command::new(&editor).arg(&tmp_path).status();
enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.clear()?;
let new_content = std::fs::read_to_string(&tmp_path)?;
app.query(&DaemonCommand::Note(NoteCmd::Update { id: note_id, content: new_content }))?;
app.refresh_preview();
Ok(())
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
fn render(f: &mut Frame, app: &mut App) {
let area = f.area();
// Overlay error message if present
if let Some(ref msg) = app.error {
let block = Block::default().title(" Error ").borders(Borders::ALL);
let para = Paragraph::new(format!("{msg}\n\n(press any key to dismiss)"))
.block(block)
.wrap(Wrap { trim: false })
.style(Style::default().fg(Color::Red));
// Center a popup
let popup_area = centered_rect(60, 30, area);
f.render_widget(ratatui::widgets::Clear, popup_area);
f.render_widget(para, popup_area);
return;
}
let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area);
let content_area = chunks[0];
let status_area = chunks[1];
match &app.screen {
AppScreen::NotebookList => render_notebook_list(f, app, content_area),
AppScreen::NotebookView { notebook_title, .. } => {
let title = notebook_title.clone();
render_notebook_view(f, app, content_area, &title);
}
}
render_status(f, app, status_area);
}
fn render_notebook_list(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = if app.items.is_empty() {
vec![ListItem::new(" (no notebooks — press 'n' to create one)")]
} else {
app.items.iter().map(|fi| ListItem::new(fi.title.as_str())).collect()
};
let list = List::new(items)
.block(Block::default().title(" Notebooks ").borders(Borders::ALL))
.highlight_style(Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut app.list_state);
}
fn render_notebook_view(
f: &mut Frame,
app: &mut App,
area: ratatui::layout::Rect,
notebook_title: &str,
) {
let chunks =
Layout::horizontal([Constraint::Percentage(38), Constraint::Percentage(62)]).split(area);
let tree_area = chunks[0];
let preview_area = chunks[1];
// Tree panel
let items: Vec<ListItem> = if app.items.is_empty() {
vec![ListItem::new(" (empty — press 'n' to add a note)")]
} else {
app.items
.iter()
.map(|fi| {
let indent = " ".repeat(fi.depth);
let prefix = match fi.kind {
ItemKind::Collection => {
if app.expanded.contains(&fi.id.to_string()) {
""
} else {
""
}
}
_ => "",
};
ListItem::new(format!("{indent}{prefix}{}", fi.title))
})
.collect()
};
let tree_block = Block::default().title(format!(" {notebook_title} ")).borders(Borders::ALL);
let list = List::new(items)
.block(tree_block)
.highlight_style(Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
.highlight_symbol("> ");
f.render_stateful_widget(list, tree_area, &mut app.list_state);
// Preview panel
let preview_title = match app.list_state.selected().and_then(|i| app.items.get(i)) {
Some(fi) if fi.kind == ItemKind::Note => format!(" {} ", fi.title),
_ => " Preview ".to_string(),
};
let preview_block = Block::default().title(preview_title).borders(Borders::ALL);
let preview =
Paragraph::new(app.preview.as_str()).block(preview_block).wrap(Wrap { trim: false });
f.render_widget(preview, preview_area);
}
fn render_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let (text, style) = match &app.mode {
Mode::Normal => (app.status.clone(), Style::default().fg(Color::DarkGray)),
Mode::Typing { prompt, buffer, .. } => {
(format!("{prompt}{buffer}"), Style::default().fg(Color::White))
}
Mode::Confirming { message, .. } => (message.clone(), Style::default().fg(Color::Yellow)),
};
f.render_widget(Paragraph::new(text).style(style), area);
}
fn centered_rect(
percent_x: u16,
percent_y: u16,
area: ratatui::layout::Rect,
) -> ratatui::layout::Rect {
use ratatui::layout::Direction;
let vertical = Layout::new(
Direction::Vertical,
[
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
],
)
.split(area);
Layout::new(
Direction::Horizontal,
[
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
],
)
.split(vertical[1])[1]
app::run_tui(&store_path, log_config)
}
+700
View File
@@ -0,0 +1,700 @@
//! The Notes agent's screen: a notebook/collection/note tree browser backed by the daemon.
//!
//! This is the only agent with a real implementation so far; every other agent
//! renders a placeholder (see `placeholder.rs`).
use std::collections::HashSet;
use std::io;
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::Result;
use crossterm::{
event::{self, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
};
use serde_json::Value;
use synchronicity::documents::DocumentID;
use syn_daemon::client;
use syn_daemon::protocol::{CollectionCmd, DaemonCommand, NoteCmd, NotebookCmd};
#[derive(Clone)]
struct FlatItem {
id: DocumentID,
title: String,
depth: usize,
kind: ItemKind,
}
#[derive(Clone, PartialEq)]
enum ItemKind {
Notebook,
Collection,
Note,
}
enum AppScreen {
NotebookList,
NotebookView { notebook_id: DocumentID, notebook_title: String },
}
#[derive(Clone)]
enum InputAction {
NewNotebook,
NewCollection { parent_id: DocumentID },
NewNote { parent_id: DocumentID },
Rename { id: DocumentID, kind: ItemKind },
}
enum Mode {
Normal,
Typing { prompt: String, buffer: String, action: InputAction },
Confirming { message: String, target_id: DocumentID, kind: ItemKind },
}
pub struct NotesScreen {
store_path: PathBuf,
screen: AppScreen,
items: Vec<FlatItem>,
list_state: ListState,
expanded: HashSet<String>,
preview: String,
mode: Mode,
status: String,
pub error: Option<String>,
}
impl NotesScreen {
pub fn new(store_path: PathBuf) -> Self {
Self {
store_path,
screen: AppScreen::NotebookList,
items: Vec::new(),
list_state: ListState::default(),
expanded: HashSet::new(),
preview: String::new(),
mode: Mode::Normal,
status: String::new(),
error: None,
}
}
fn query(&self, cmd: &DaemonCommand) -> Result<Value> {
client::query_daemon(&self.store_path, cmd)
}
pub fn load_notebooks(&mut self) -> Result<()> {
let val = self.query(&DaemonCommand::Notebook(NotebookCmd::List))?;
let notebooks = val["notebooks"].as_array().cloned().unwrap_or_default();
self.items = notebooks
.into_iter()
.filter_map(|nb| {
let id = parse_id(nb["id"].as_str()?)?;
let title = nb["title"].as_str().unwrap_or("(untitled)").to_string();
Some(FlatItem { id, title, depth: 0, kind: ItemKind::Notebook })
})
.collect();
select_first(&mut self.list_state, &self.items);
self.preview.clear();
self.status =
"j/k: navigate Enter: open n: new notebook d: delete r: rename".to_string();
Ok(())
}
fn enter_notebook(&mut self, id: DocumentID, title: String) -> Result<()> {
self.expanded.clear();
self.screen = AppScreen::NotebookView { notebook_id: id, notebook_title: title };
self.reload_tree()
}
fn reload_tree(&mut self) -> Result<()> {
let AppScreen::NotebookView { ref notebook_id, .. } = self.screen else {
return Ok(());
};
let nb_id = notebook_id.clone();
let val = self.query(&DaemonCommand::Notebook(NotebookCmd::Tree { id: nb_id }))?;
// Remember where we were
let prev_id =
self.list_state.selected().and_then(|i| self.items.get(i)).map(|fi| fi.id.to_string());
self.items.clear();
if let Some(children) = val["children"].as_array() {
flatten_json_nodes(children, 0, &self.expanded, &mut self.items);
}
// Restore selection to same item if still present
if let Some(ref pid) = prev_id {
if let Some(pos) = self.items.iter().position(|fi| fi.id.to_string() == *pid) {
self.list_state.select(Some(pos));
} else {
select_first(&mut self.list_state, &self.items);
}
} else {
select_first(&mut self.list_state, &self.items);
}
self.refresh_preview();
self.status = "j/k: navigate Enter: expand/collapse e: edit n: new note \
f: new collection d: delete r: rename b: back"
.to_string();
Ok(())
}
fn refresh_preview(&mut self) {
let Some(item) = self.list_state.selected().and_then(|i| self.items.get(i)) else {
self.preview.clear();
return;
};
if item.kind != ItemKind::Note {
self.preview.clear();
return;
}
let id = item.id.clone();
self.preview = match self.query(&DaemonCommand::Note(NoteCmd::Show { id })) {
Ok(val) => val["content"].as_str().unwrap_or("").to_string(),
Err(e) => format!("Error loading note: {e}"),
};
}
/// Returns the DocumentID of the container that a new child should be created in:
/// the selected collection if one is selected, otherwise the notebook root.
fn current_container_id(&self) -> Option<DocumentID> {
let AppScreen::NotebookView { ref notebook_id, .. } = self.screen else { return None };
let container = match self.list_state.selected().and_then(|i| self.items.get(i)) {
Some(fi) if fi.kind == ItemKind::Collection => fi.id.clone(),
_ => notebook_id.clone(),
};
Some(container)
}
fn selected_item_cloned(&self) -> Option<FlatItem> {
self.list_state.selected().and_then(|i| self.items.get(i)).cloned()
}
fn move_down(&mut self) {
let len = self.items.len();
if len == 0 {
return;
}
let next = match self.list_state.selected() {
Some(i) if i + 1 < len => i + 1,
_ => 0,
};
self.list_state.select(Some(next));
self.refresh_preview();
}
fn move_up(&mut self) {
let len = self.items.len();
if len == 0 {
return;
}
let prev = match self.list_state.selected() {
Some(0) | None => len.saturating_sub(1),
Some(i) => i - 1,
};
self.list_state.select(Some(prev));
self.refresh_preview();
}
}
fn flatten_json_nodes(
nodes: &[Value],
depth: usize,
expanded: &HashSet<String>,
out: &mut Vec<FlatItem>,
) {
for node in nodes {
match node["kind"].as_str() {
Some("collection") => {
let Some(id) = parse_id(node["id"].as_str().unwrap_or("")) else { continue };
let title = node["title"].as_str().unwrap_or("(unnamed)").to_string();
let id_str = id.to_string();
out.push(FlatItem { id, title, depth, kind: ItemKind::Collection });
if expanded.contains(&id_str)
&& let Some(children) = node["children"].as_array()
{
flatten_json_nodes(children, depth + 1, expanded, out);
}
}
Some("note") => {
let Some(id) = parse_id(node["id"].as_str().unwrap_or("")) else { continue };
let base = node["title"].as_str().unwrap_or("(untitled)");
let ext = match node["format"].as_str() {
Some("markdown") => "md",
Some("typst") => "typ",
_ => "txt",
};
out.push(FlatItem {
id,
title: format!("{base}.{ext}"),
depth,
kind: ItemKind::Note,
});
}
_ => {}
}
}
}
fn parse_id(s: &str) -> Option<DocumentID> {
DocumentID::from_str(s).ok()
}
fn select_first(state: &mut ListState, items: &[FlatItem]) {
state.select(if items.is_empty() { None } else { Some(0) });
}
// ---------------------------------------------------------------------------
// Key handling
// ---------------------------------------------------------------------------
/// Handles a key press while the Notes screen has content focus. Returns
/// `true` if the app should quit.
pub fn handle_key(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
notes: &mut NotesScreen,
key: event::KeyEvent,
) -> Result<bool> {
match &notes.mode {
Mode::Normal => handle_normal(terminal, notes, key),
Mode::Typing { .. } => {
handle_typing(notes, key)?;
Ok(false)
}
Mode::Confirming { .. } => {
handle_confirming(notes, key)?;
Ok(false)
}
}
}
fn handle_normal(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
notes: &mut NotesScreen,
key: event::KeyEvent,
) -> Result<bool> {
match key.code {
KeyCode::Char('q') => return Ok(true),
KeyCode::Char('j') | KeyCode::Down => notes.move_down(),
KeyCode::Char('k') | KeyCode::Up => notes.move_up(),
KeyCode::Enter | KeyCode::Right => {
let is_list = matches!(notes.screen, AppScreen::NotebookList);
if is_list {
if let Some(fi) = notes.selected_item_cloned()
&& let Err(e) = notes.enter_notebook(fi.id, fi.title)
{
notes.error = Some(format!("{e}"));
}
} else if let Some(fi) = notes.selected_item_cloned() {
match fi.kind {
ItemKind::Collection => {
let id_str = fi.id.to_string();
if notes.expanded.contains(&id_str) {
notes.expanded.remove(&id_str);
} else {
notes.expanded.insert(id_str);
}
if let Err(e) = notes.reload_tree() {
notes.error = Some(format!("{e}"));
}
}
ItemKind::Note => {
if let Err(e) = edit_note(terminal, notes, fi.id) {
notes.error = Some(format!("{e}"));
}
}
ItemKind::Notebook => {}
}
}
}
KeyCode::Char('b') | KeyCode::Left => {
if matches!(notes.screen, AppScreen::NotebookView { .. }) {
notes.screen = AppScreen::NotebookList;
if let Err(e) = notes.load_notebooks() {
notes.error = Some(format!("{e}"));
}
}
}
KeyCode::Char('e') => {
if matches!(notes.screen, AppScreen::NotebookView { .. })
&& let Some(fi) = notes.selected_item_cloned()
&& fi.kind == ItemKind::Note
&& let Err(e) = edit_note(terminal, notes, fi.id)
{
notes.error = Some(format!("{e}"));
}
}
KeyCode::Char('n') => {
if matches!(notes.screen, AppScreen::NotebookList) {
notes.mode = Mode::Typing {
prompt: "New notebook title: ".to_string(),
buffer: String::new(),
action: InputAction::NewNotebook,
};
} else if let Some(parent_id) = notes.current_container_id() {
notes.mode = Mode::Typing {
prompt: "New note title: ".to_string(),
buffer: String::new(),
action: InputAction::NewNote { parent_id },
};
}
}
KeyCode::Char('f') => {
if matches!(notes.screen, AppScreen::NotebookView { .. })
&& let Some(parent_id) = notes.current_container_id()
{
notes.mode = Mode::Typing {
prompt: "New collection name: ".to_string(),
buffer: String::new(),
action: InputAction::NewCollection { parent_id },
};
}
}
KeyCode::Char('r') => {
if let Some(fi) = notes.selected_item_cloned() {
notes.mode = Mode::Typing {
prompt: format!("Rename '{}' to: ", fi.title),
buffer: fi.title.clone(),
action: InputAction::Rename { id: fi.id, kind: fi.kind },
};
}
}
KeyCode::Char('d') => {
if let Some(fi) = notes.selected_item_cloned() {
let kind_label = match fi.kind {
ItemKind::Notebook => "notebook",
ItemKind::Collection => "collection (and all contents)",
ItemKind::Note => "note",
};
notes.mode = Mode::Confirming {
message: format!("Delete {} '{}'? (y/n)", kind_label, fi.title),
target_id: fi.id,
kind: fi.kind,
};
}
}
_ => {}
}
Ok(false)
}
fn handle_typing(notes: &mut NotesScreen, key: event::KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
notes.mode = Mode::Normal;
}
KeyCode::Enter => {
let (title, action) = match &notes.mode {
Mode::Typing { buffer, action, .. } => (buffer.trim().to_string(), action.clone()),
_ => return Ok(()),
};
notes.mode = Mode::Normal;
if !title.is_empty()
&& let Err(e) = execute_input(notes, &action, &title)
{
notes.error = Some(format!("{e}"));
}
let is_list = matches!(notes.screen, AppScreen::NotebookList);
if is_list {
if let Err(e) = notes.load_notebooks() {
notes.error = Some(format!("{e}"));
}
} else if let Err(e) = notes.reload_tree() {
notes.error = Some(format!("{e}"));
}
}
KeyCode::Backspace => {
if let Mode::Typing { ref mut buffer, .. } = notes.mode {
buffer.pop();
}
}
KeyCode::Char(c) => {
if let Mode::Typing { ref mut buffer, .. } = notes.mode {
buffer.push(c);
}
}
_ => {}
}
Ok(())
}
fn execute_input(notes: &NotesScreen, action: &InputAction, title: &str) -> Result<()> {
match action {
InputAction::NewNotebook => {
notes.query(&DaemonCommand::Notebook(NotebookCmd::New { title: title.to_string() }))?;
}
InputAction::NewCollection { parent_id } => {
notes.query(&DaemonCommand::Collection(CollectionCmd::New {
parent_id: parent_id.clone(),
title: title.to_string(),
}))?;
}
InputAction::NewNote { parent_id } => {
notes.query(&DaemonCommand::Note(NoteCmd::New {
parent_id: parent_id.clone(),
title: title.to_string(),
}))?;
}
InputAction::Rename { id, kind } => {
let new_title = title.to_string();
let cmd = match kind {
ItemKind::Notebook => {
DaemonCommand::Notebook(NotebookCmd::Rename { id: id.clone(), new_title })
}
ItemKind::Collection => {
DaemonCommand::Collection(CollectionCmd::Rename { id: id.clone(), new_title })
}
ItemKind::Note => {
DaemonCommand::Note(NoteCmd::Rename { id: id.clone(), new_title })
}
};
notes.query(&cmd)?;
}
}
Ok(())
}
fn handle_confirming(notes: &mut NotesScreen, key: event::KeyEvent) -> Result<()> {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
let (target_id, kind) = match &notes.mode {
Mode::Confirming { target_id, kind, .. } => (target_id.clone(), kind.clone()),
_ => return Ok(()),
};
notes.mode = Mode::Normal;
let cmd = match kind {
ItemKind::Notebook => {
DaemonCommand::Notebook(NotebookCmd::Delete { id: target_id })
}
ItemKind::Collection => {
DaemonCommand::Collection(CollectionCmd::Delete { id: target_id })
}
ItemKind::Note => DaemonCommand::Note(NoteCmd::Delete { id: target_id }),
};
if let Err(e) = notes.query(&cmd) {
notes.error = Some(format!("{e}"));
}
let is_list = matches!(notes.screen, AppScreen::NotebookList);
if is_list {
if let Err(e) = notes.load_notebooks() {
notes.error = Some(format!("{e}"));
}
} else if let Err(e) = notes.reload_tree() {
notes.error = Some(format!("{e}"));
}
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
notes.mode = Mode::Normal;
}
_ => {}
}
Ok(())
}
fn edit_note(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
notes: &mut NotesScreen,
note_id: DocumentID,
) -> Result<()> {
let val = notes.query(&DaemonCommand::Note(NoteCmd::Show { id: note_id.clone() }))?;
let content = val["content"].as_str().unwrap_or("").to_string();
let mut tmp = tempfile::Builder::new().suffix(".md").tempfile()?;
use std::io::Write;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
let tmp_path = tmp.path().to_owned();
// Suspend TUI while editor runs
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let _ = std::process::Command::new(&editor).arg(&tmp_path).status();
enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.clear()?;
let new_content = std::fs::read_to_string(&tmp_path)?;
notes.query(&DaemonCommand::Note(NoteCmd::Update { id: note_id, content: new_content }))?;
notes.refresh_preview();
Ok(())
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Renders the Notes screen into `content_area` (the main body) and
/// `status_area` (a single line below it). `focused` colors the border to
/// show whether the content pane currently has keyboard focus.
pub fn render(
f: &mut Frame,
notes: &mut NotesScreen,
content_area: Rect,
status_area: Rect,
focused: bool,
) {
if let Some(ref msg) = notes.error {
let block = Block::default().title(" Error ").borders(Borders::ALL);
let para = Paragraph::new(format!("{msg}\n\n(press any key to dismiss)"))
.block(block)
.wrap(Wrap { trim: false })
.style(Style::default().fg(Color::Red));
let popup_area = centered_rect(80, 50, content_area);
f.render_widget(ratatui::widgets::Clear, popup_area);
f.render_widget(para, popup_area);
return;
}
match &notes.screen {
AppScreen::NotebookList => render_notebook_list(f, notes, content_area, focused),
AppScreen::NotebookView { notebook_title, .. } => {
let title = notebook_title.clone();
render_notebook_view(f, notes, content_area, &title, focused);
}
}
render_status(f, notes, status_area);
}
fn border_style(focused: bool) -> Style {
if focused { Style::default().fg(Color::Cyan) } else { Style::default().fg(Color::DarkGray) }
}
fn render_notebook_list(f: &mut Frame, notes: &mut NotesScreen, area: Rect, focused: bool) {
let items: Vec<ListItem> = if notes.items.is_empty() {
vec![ListItem::new(" (no notebooks — press 'n' to create one)")]
} else {
notes.items.iter().map(|fi| ListItem::new(fi.title.as_str())).collect()
};
let list = List::new(items)
.block(
Block::default()
.title(" Notebooks ")
.borders(Borders::ALL)
.border_style(border_style(focused)),
)
.highlight_style(Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut notes.list_state);
}
fn render_notebook_view(
f: &mut Frame,
notes: &mut NotesScreen,
area: Rect,
notebook_title: &str,
focused: bool,
) {
let chunks =
Layout::horizontal([Constraint::Percentage(38), Constraint::Percentage(62)]).split(area);
let tree_area = chunks[0];
let preview_area = chunks[1];
// Tree panel
let items: Vec<ListItem> = if notes.items.is_empty() {
vec![ListItem::new(" (empty — press 'n' to add a note)")]
} else {
notes
.items
.iter()
.map(|fi| {
let indent = " ".repeat(fi.depth);
let prefix = match fi.kind {
ItemKind::Collection => {
if notes.expanded.contains(&fi.id.to_string()) {
""
} else {
""
}
}
_ => "",
};
ListItem::new(format!("{indent}{prefix}{}", fi.title))
})
.collect()
};
let tree_block = Block::default()
.title(format!(" {notebook_title} "))
.borders(Borders::ALL)
.border_style(border_style(focused));
let list = List::new(items)
.block(tree_block)
.highlight_style(Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
.highlight_symbol("> ");
f.render_stateful_widget(list, tree_area, &mut notes.list_state);
// Preview panel
let preview_title = match notes.list_state.selected().and_then(|i| notes.items.get(i)) {
Some(fi) if fi.kind == ItemKind::Note => format!(" {} ", fi.title),
_ => " Preview ".to_string(),
};
let preview_block = Block::default()
.title(preview_title)
.borders(Borders::ALL)
.border_style(border_style(focused));
let preview =
Paragraph::new(notes.preview.as_str()).block(preview_block).wrap(Wrap { trim: false });
f.render_widget(preview, preview_area);
}
fn render_status(f: &mut Frame, notes: &NotesScreen, area: Rect) {
let (text, style) = match &notes.mode {
Mode::Normal => (notes.status.clone(), Style::default().fg(Color::DarkGray)),
Mode::Typing { prompt, buffer, .. } => {
(format!("{prompt}{buffer}"), Style::default().fg(Color::White))
}
Mode::Confirming { message, .. } => (message.clone(), Style::default().fg(Color::Yellow)),
};
f.render_widget(Paragraph::new(text).style(style), area);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
use ratatui::layout::Direction;
let vertical = Layout::new(
Direction::Vertical,
[
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
],
)
.split(area);
Layout::new(
Direction::Horizontal,
[
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
],
)
.split(vertical[1])[1]
}
+35
View File
@@ -0,0 +1,35 @@
//! Fallback screen for agents that don't have a real TUI implementation yet.
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
widgets::{Block, Borders, Paragraph, Wrap},
};
use synchronicity::agents::AgentMetadata;
pub fn render(
f: &mut Frame,
meta: &AgentMetadata,
content_area: Rect,
status_area: Rect,
focused: bool,
) {
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.title(format!(" {} ", meta.display_name))
.borders(Borders::ALL)
.border_style(border_style);
let text = format!("{}\n\nThis agent's TUI screen isn't implemented yet.", meta.description);
let para = Paragraph::new(text)
.block(block)
.wrap(Wrap { trim: false })
.style(Style::default().fg(Color::DarkGray));
f.render_widget(para, content_area);
f.render_widget(Paragraph::new("").style(Style::default().fg(Color::DarkGray)), status_area);
}
+93
View File
@@ -0,0 +1,93 @@
//! The left-hand agent list, mirroring the GUI app's agent sidebar: Activity
//! agents listed alphabetically, with Control agents (Ensemble, Settings)
//! pinned at the bottom below a divider.
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, List, ListItem},
};
use synchronicity::agents::AgentMetadata;
pub struct Sidebar {
/// Activity agents first (alphabetical), then Control agents (alphabetical).
pub agents: Vec<AgentMetadata>,
pub activity_count: usize,
pub selected: usize,
}
impl Sidebar {
pub fn new() -> Self {
use synchronicity::agents::{AgentRegistry, AgentType};
let registry = AgentRegistry::default();
let all = registry.get_agents();
let (activity, control): (Vec<_>, Vec<_>) =
all.into_iter().partition(|a| a.agent_type == AgentType::Activity);
let activity_count = activity.len();
let agents: Vec<AgentMetadata> = activity.into_iter().chain(control).cloned().collect();
Self { agents, activity_count, selected: 0 }
}
pub fn move_down(&mut self) {
self.selected = (self.selected + 1) % self.agents.len();
}
pub fn move_up(&mut self) {
self.selected = (self.selected + self.agents.len() - 1) % self.agents.len();
}
pub fn selected_agent(&self) -> &AgentMetadata {
&self.agents[self.selected]
}
}
pub fn render(f: &mut Frame, sidebar: &Sidebar, area: Rect, focused: bool) {
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let outer = Block::default().title(" Agents ").borders(Borders::ALL).border_style(border_style);
let inner = outer.inner(area);
f.render_widget(outer, area);
let control_count = sidebar.agents.len() - sidebar.activity_count;
let chunks = Layout::vertical([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(control_count as u16),
])
.split(inner);
let row = |i: usize, meta: &AgentMetadata| {
let style = if i == sidebar.selected {
Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)
} else {
Style::default()
};
ListItem::new(Line::styled(format!(" {}", meta.display_name), style))
};
let activity_items: Vec<ListItem> = sidebar.agents[..sidebar.activity_count]
.iter()
.enumerate()
.map(|(i, meta)| row(i, meta))
.collect();
f.render_widget(List::new(activity_items), chunks[0]);
f.render_widget(
Block::default().borders(Borders::TOP).border_style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
let control_items: Vec<ListItem> = sidebar.agents[sidebar.activity_count..]
.iter()
.enumerate()
.map(|(i, meta)| row(sidebar.activity_count + i, meta))
.collect();
f.render_widget(List::new(control_items), chunks[2]);
}