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:
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ¬es.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 ¬es.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 ¬es.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 ¬es.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 ¬es.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]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user