diff --git a/Cargo.lock b/Cargo.lock index 6d576c2..5fb7880 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -362,9 +362,9 @@ dependencies = [ [[package]] name = "candid" -version = "0.10.20" +version = "0.10.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8037a01ec09d6c06883a38bad4f47b8d06158ad360b841e0ae5707c9884dfaf6" +checksum = "1ba5b4833b63bf7b785fecdd2cc918ed90d988fd974825d5c48e7b407c39ef38" dependencies = [ "anyhow", "binread", @@ -385,9 +385,9 @@ dependencies = [ [[package]] name = "candid_derive" -version = "0.10.20" +version = "0.10.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb45f4d5eff3805598ee633dd80f8afb306c023249d34b5b7dfdc2080ea1df2e" +checksum = "7b60664b6324832dfb4863f3b19eb4d58819cd38fba6d3941b101213cea0d9ec" dependencies = [ "lazy_static", "proc-macro2", diff --git a/README.md b/README.md index 139d0d9..1846701 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,21 @@ What to understand first: - `Memories`: list, search, detail view, and chat - `Insert`: add files, text, or manual embeddings - `Create`: create a new memory -- `Market`: reserved and not implemented yet +- `Wiki`: browse databases from the configured wiki canister; `KINIC_WIKI_CANISTER_ID` can override the default - `Settings`: principal, balance, default memory, saved tags, and retrieval settings +For local deploys, `scripts/setup.sh` deploys the bundled Wiki wasm at the default Wiki canister id. +The TUI groups Wiki databases under `Private / Shared` and `Public`, marks rows based on anonymous access and the current identity's role, reads public databases with an anonymous agent, and opens editing only for `Owner` or `Writer` databases. + +Wiki write/delete and database management are available from the CLI: + +```bash +kinic-cli --ic --identity alice wiki database list +kinic-cli --ic --identity alice wiki read --database-id DATABASE_ID --path /Wiki/index.md +kinic-cli --ic --identity alice wiki write --database-id DATABASE_ID --path /Wiki/new.md --input ./new.md +kinic-cli --ic --identity alice wiki delete --database-id DATABASE_ID --path /Wiki/new.md --yes +``` + ### Step 2: Confirm Identity and Balance Open `Settings` and confirm that `Principal ID` matches the identity you launched with. Then check whether `KINIC balance` is high enough to create a memory. If needed, you can transfer tokens from the transfer modal, and `Ctrl+R` refreshes both `Principal ID` and `KINIC balance`. diff --git a/dfx.json b/dfx.json index 84879cf..76ddc67 100644 --- a/dfx.json +++ b/dfx.json @@ -15,6 +15,11 @@ "type": "custom", "candid": "https://github.com/dfinity/internet-identity/releases/latest/download/internet_identity.did", "wasm": "https://github.com/dfinity/internet-identity/releases/latest/download/internet_identity_dev.wasm.gz" + }, + "wiki": { + "type": "custom", + "candid": "wasm/wiki/vfs.did", + "wasm": "wasm/wiki/vfs_canister_nowasi.wasm" } }, "defaults": { diff --git a/docs/cli.md b/docs/cli.md index c11adb5..2748336 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -65,6 +65,7 @@ Agent-friendly discovery tips: - Start with `kinic-cli --help` for auth mode guidance and top-level entrypoints - Start with `kinic-cli capabilities` to get a JSON execution contract for global flags, auth sources, output behavior, major arguments, and arg-group constraints - Use `kinic-cli prefs --help` to inspect the JSON contract for shared local preferences +- Use `kinic-cli wiki --help` to inspect Wiki database, read/search, and write/delete operations against the configured Wiki canister - `capabilities` and `prefs` commands return JSON; `list`, `show`, and `search` also support `--json` for agent/script consumption while keeping human-friendly text output by default - Keychain failures use stable text prefixes such as `KEYCHAIN_LOOKUP_FAILED`, `KEYCHAIN_ACCESS_DENIED`, `KEYCHAIN_INTERACTION_NOT_ALLOWED`, and `KEYCHAIN_ERROR`; agents should branch on the leading `[KEYCHAIN_*]` code instead of parsing the rest of the sentence @@ -132,6 +133,32 @@ Notes: - Delegations are stored at `~/.config/kinic/identity.json`. - The login flow uses a local callback on port `8620`. +### Wiki CLI + +`wiki` operates the configured Wiki canister. It uses `xis3j-paaaa-aaaai-axumq-cai` by default and `KINIC_WIKI_CANISTER_ID` can override that target. +For local deploys, `scripts/setup.sh` deploys the bundled Wiki wasm at that same canister id. + +```bash +cargo run -- --ic --identity alice wiki database list +cargo run -- --ic --identity alice wiki children --database-id DATABASE_ID --path /Wiki +cargo run -- --ic --identity alice wiki read --database-id DATABASE_ID --path /Wiki/index.md +cargo run -- --ic --identity alice wiki search --database-id DATABASE_ID "release notes" +``` + +Write operations are CLI-only: + +```bash +cargo run -- --ic --identity alice wiki write \ + --database-id DATABASE_ID \ + --path /Wiki/new.md \ + --input ./new.md + +cargo run -- --ic --identity alice wiki delete \ + --database-id DATABASE_ID \ + --path /Wiki/new.md \ + --yes +``` + ### Convert PDF to markdown (inspect only) ```bash diff --git a/docs/tui.md b/docs/tui.md index ad316ee..3dff9cf 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -80,10 +80,12 @@ The TUI has five tabs. - `Memories`: list, search, and details - `Insert`: add data - `Create`: create a new memory -- `Market`: reserved for future use and currently not implemented +- `Wiki`: browse databases from the configured wiki canister; `KINIC_WIKI_CANISTER_ID` can override the default - `Settings`: view and change current connection info and saved settings The `Memories` tab opens first when the TUI starts. +By default, the Wiki tab uses `xis3j-paaaa-aaaai-axumq-cai`. +For local deploys, `scripts/setup.sh` deploys the bundled Wiki wasm at that same canister id. The local preferences shown in `Settings`, including the default memory, saved tags, and manually tracked memories, can also be managed from the CLI with `kinic-cli prefs ...`. ## Basic Controls @@ -91,7 +93,7 @@ The local preferences shown in `Settings`, including the default memory, saved t - `1` to `5`: switch tabs - `Tab`: move focus forward within the screen - `Shift+Tab`: move focus backward -- `/`: focus the search field +- `/`: focus the `Memories` search field - `↑` `↓`: move through lists and form fields - `Enter`: open, confirm, or submit the current item - `Esc`: go back one step, return to the list, or close the picker @@ -104,6 +106,15 @@ The local preferences shown in `Settings`, including the default memory, saved t The status bar at the bottom also shows the keys available in the current context. +In the `Wiki` tab, `Tab` and `Shift+Tab` switch between the Browser pane and the Document pane. +Wiki databases are grouped under `Private / Shared` and `Public`. +Database rows show `Public` when `anonymous` has access, `Private` for owned databases without anonymous access, and `Shared` for non-public databases shared with the current identity. +Public database reads use an anonymous agent so identities without direct membership can still browse anonymous-readable databases. +Wiki edit opens only for databases where the current identity is `Owner` or `Writer`. +When Browser is focused, `↑` `↓` moves the selected database or wiki node, `Enter` opens it, and `e` edits a loaded `/Wiki/*.md` file. +When Document is focused, `↑` `↓`, `PageUp`, `PageDown`, `Home`, and `End` scroll the document. +In Wiki edit mode, `Ctrl+S` saves, `Tab` moves to the footer `Save` / `Cancel` controls, and `Esc` exits or asks to discard dirty changes. + `?` and `q` are intended for normal list and tab navigation, not while typing in a search field or form, and not while the chat input is focused. `Shift+C` (toggle chat on `Memories`) follows the same focus rules. `Shift+S` toggles the settings overlay from the search field, lists, and other panes, but not while editing a form field or while the chat input is focused. @@ -307,7 +318,6 @@ The main saved values are: - `--identity` is required - `--ii` is not supported yet -- The `Market` tab is not implemented yet - `pdftotext` is required for PDF insertion - There is no dedicated copy shortcut for Principal ID - Local use requires a prepared local replica and supporting canisters diff --git a/rust/cli_defs.rs b/rust/cli_defs.rs index f76794f..ecc713c 100644 --- a/rust/cli_defs.rs +++ b/rust/cli_defs.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use clap::{ArgGroup, Args, Parser, Subcommand}; +use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; pub fn parse_identity_arg(value: &str) -> Result { if value.trim().is_empty() { @@ -153,6 +153,10 @@ pub enum Command { after_help = "Configuration:\n Set KINIC_TOOL_IDENTITY=\n Set KINIC_TOOL_NETWORK=local|mainnet\n\nNotes:\n tools serve does not accept global --identity, --ii, --ic, or --identity-path." )] Tools(ToolsArgs), + #[command( + about = "Operate the configured Wiki canister. Requires --identity or --ii. Returns text output by default." + )] + Wiki(WikiArgs), #[command( about = "Launch the Kinic terminal UI. Requires global --identity . --ii is not supported. Returns an interactive TUI, not JSON.", after_help = "Requires:\n kinic-cli --identity tui\n\nReturns:\n Interactive terminal UI.\n\nExample:\n kinic-cli --identity alice tui" @@ -175,6 +179,180 @@ pub struct CreateArgs { #[derive(Args, Debug, Default)] pub struct TuiArgs {} +#[derive(Args, Debug)] +pub struct WikiArgs { + #[command(subcommand)] + pub command: WikiCommand, +} + +#[derive(Subcommand, Debug)] +pub enum WikiCommand { + #[command(about = "Manage Wiki databases")] + Database(WikiDatabaseArgs), + #[command(about = "Read a Wiki node")] + Read(WikiReadArgs), + #[command(about = "List Wiki children under a path")] + Children(WikiChildrenArgs), + #[command(about = "Search Wiki nodes")] + Search(WikiSearchArgs), + #[command(about = "Write a Wiki node from a file")] + Write(WikiWriteArgs), + #[command(about = "Append file contents to a Wiki node")] + Append(WikiAppendArgs), + #[command(about = "Replace text in a Wiki node")] + Edit(WikiEditArgs), + #[command(about = "Delete a Wiki node. Requires --yes.")] + Delete(WikiDeleteArgs), +} + +#[derive(Args, Debug)] +pub struct WikiDatabaseArgs { + #[command(subcommand)] + pub command: WikiDatabaseCommand, +} + +#[derive(Subcommand, Debug)] +pub enum WikiDatabaseCommand { + #[command(about = "List databases visible to the caller")] + List(WikiJsonArgs), + #[command(about = "Create a new database")] + Create(WikiJsonArgs), + #[command(about = "Grant database access to a principal")] + Grant(WikiDatabaseGrantArgs), +} + +#[derive(Args, Debug, Default)] +pub struct WikiJsonArgs { + #[arg(long, help = "Return machine-readable JSON output")] + pub json: bool, +} + +#[derive(Args, Debug)] +pub struct WikiDatabaseGrantArgs { + pub database_id: String, + pub principal: String, + #[arg(value_enum)] + pub role: WikiDatabaseRoleArg, + #[arg(long, help = "Return machine-readable JSON output")] + pub json: bool, +} + +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum WikiDatabaseRoleArg { + Owner, + Writer, + Reader, +} + +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum WikiNodeKindArg { + File, + Source, +} + +#[derive(Args, Debug)] +pub struct WikiReadArgs { + #[arg(long, required = true)] + pub database_id: String, + #[arg(long, required = true)] + pub path: String, + #[arg(long, help = "Return machine-readable JSON output")] + pub json: bool, +} + +#[derive(Args, Debug)] +pub struct WikiChildrenArgs { + #[arg(long, required = true)] + pub database_id: String, + #[arg(long, default_value = "/Wiki")] + pub path: String, + #[arg(long, help = "Return machine-readable JSON output")] + pub json: bool, +} + +#[derive(Args, Debug)] +pub struct WikiSearchArgs { + #[arg(long, required = true)] + pub database_id: String, + pub query: String, + #[arg(long, default_value = "/Wiki")] + pub prefix: String, + #[arg(long, default_value_t = 10)] + pub top_k: u32, + #[arg(long, help = "Return machine-readable JSON output")] + pub json: bool, +} + +#[derive(Args, Debug)] +pub struct WikiWriteArgs { + #[arg(long, required = true)] + pub database_id: String, + #[arg(long, required = true)] + pub path: String, + #[arg(long, value_name = "PATH", required = true)] + pub input: PathBuf, + #[arg(long, value_enum, default_value_t = WikiNodeKindArg::File)] + pub kind: WikiNodeKindArg, + #[arg(long, default_value = "{}")] + pub metadata_json: String, + #[arg(long)] + pub expected_etag: Option, + #[arg(long, help = "Return machine-readable JSON output")] + pub json: bool, +} + +#[derive(Args, Debug)] +pub struct WikiAppendArgs { + #[arg(long, required = true)] + pub database_id: String, + #[arg(long, required = true)] + pub path: String, + #[arg(long, value_name = "PATH", required = true)] + pub input: PathBuf, + #[arg(long)] + pub expected_etag: Option, + #[arg(long)] + pub separator: Option, + #[arg(long, value_enum)] + pub kind: Option, + #[arg(long)] + pub metadata_json: Option, + #[arg(long, help = "Return machine-readable JSON output")] + pub json: bool, +} + +#[derive(Args, Debug)] +pub struct WikiEditArgs { + #[arg(long, required = true)] + pub database_id: String, + #[arg(long, required = true)] + pub path: String, + #[arg(long, required = true)] + pub old_text: String, + #[arg(long, required = true)] + pub new_text: String, + #[arg(long)] + pub replace_all: bool, + #[arg(long)] + pub expected_etag: Option, + #[arg(long, help = "Return machine-readable JSON output")] + pub json: bool, +} + +#[derive(Args, Debug)] +pub struct WikiDeleteArgs { + #[arg(long, required = true)] + pub database_id: String, + #[arg(long, required = true)] + pub path: String, + #[arg(long)] + pub expected_etag: Option, + #[arg(long, help = "Confirm deletion")] + pub yes: bool, + #[arg(long, help = "Return machine-readable JSON output")] + pub json: bool, +} + #[derive(Args, Debug)] pub struct ToolsArgs { #[command(subcommand)] diff --git a/rust/cli_policy.rs b/rust/cli_policy.rs index 55d2270..87cbae9 100644 --- a/rust/cli_policy.rs +++ b/rust/cli_policy.rs @@ -116,6 +116,17 @@ pub fn command_policy_for_path(path: &str) -> CommandPolicy { }; } + if path == "wiki" || path.starts_with("wiki.") { + return CommandPolicy { + auth_sources: &["global_identity", "global_ii"], + conditional_auth: &[], + output_default: "text", + output_supported: &["text", "json"], + interactive: false, + global_flags_supported: GLOBAL_FLAGS_ALL, + }; + } + CommandPolicy { auth_sources: &["global_identity", "global_ii"], conditional_auth: &[], diff --git a/rust/commands/mod.rs b/rust/commands/mod.rs index d6ec654..cc708bb 100644 --- a/rust/commands/mod.rs +++ b/rust/commands/mod.rs @@ -25,6 +25,7 @@ pub mod show; pub mod tagged_embeddings; pub mod transfer; pub mod update; +pub mod wiki; #[derive(Clone)] pub struct CommandContext { @@ -58,6 +59,7 @@ pub async fn run_command(command: Command, ctx: CommandContext) -> Result<()> { Command::AskAi(args) => ask_ai::handle(args, &ctx).await, Command::Login(args) => ii_login::handle(args, &ctx).await, Command::Tools(_) => unreachable!("tools command is handled before agent setup"), + Command::Wiki(args) => wiki::handle(args, &ctx).await, Command::Tui(_) => unreachable!("TUI command is handled before command dispatch"), } } diff --git a/rust/commands/wiki.rs b/rust/commands/wiki.rs new file mode 100644 index 0000000..db71299 --- /dev/null +++ b/rust/commands/wiki.rs @@ -0,0 +1,486 @@ +use std::fs; + +use anyhow::{Result, bail}; + +use crate::{ + cli::{ + WikiAppendArgs, WikiArgs, WikiChildrenArgs, WikiCommand, WikiDatabaseCommand, + WikiDatabaseRoleArg, WikiDeleteArgs, WikiEditArgs, WikiJsonArgs, WikiNodeKindArg, + WikiReadArgs, WikiSearchArgs, WikiWriteArgs, + }, + commands::CommandContext, + wiki_bridge::{ + AppendNodeRequest, DatabaseRole, DeleteNodeRequest, EditNodeRequest, ListChildrenRequest, + NodeKind, SearchNodesRequest, SearchPreviewMode, WikiClient, WriteNodeRequest, + wiki_canister_id_from_env, + }, +}; + +pub async fn handle(args: WikiArgs, ctx: &CommandContext) -> Result<()> { + let agent = ctx.agent_factory.build().await?; + let client = WikiClient::new(agent, wiki_canister_id_from_env())?; + match args.command { + WikiCommand::Database(database) => match database.command { + WikiDatabaseCommand::List(args) => list_databases(&client, args).await, + WikiDatabaseCommand::Create(args) => create_database(&client, args).await, + WikiDatabaseCommand::Grant(args) => grant_database(&client, args).await, + }, + WikiCommand::Read(args) => read_node(&client, args).await, + WikiCommand::Children(args) => list_children(&client, args).await, + WikiCommand::Search(args) => search_nodes(&client, args).await, + WikiCommand::Write(args) => write_node(&client, args).await, + WikiCommand::Append(args) => append_node(&client, args).await, + WikiCommand::Edit(args) => edit_node(&client, args).await, + WikiCommand::Delete(args) => delete_node(&client, args).await, + } +} + +async fn list_databases(client: &WikiClient, args: WikiJsonArgs) -> Result<()> { + let databases = client.list_databases().await?; + if args.json { + println!("{}", serde_json::to_string_pretty(&databases)?); + } else { + for database in databases { + println!( + "{}\t{:?}\t{:?}\t{}", + database.database_id, database.status, database.role, database.logical_size_bytes + ); + } + } + Ok(()) +} + +async fn create_database(client: &WikiClient, args: WikiJsonArgs) -> Result<()> { + let database_id = client.create_database().await?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ "database_id": database_id }))? + ); + } else { + println!("{database_id}"); + } + Ok(()) +} + +async fn grant_database( + client: &WikiClient, + args: crate::cli::WikiDatabaseGrantArgs, +) -> Result<()> { + let role = database_role(args.role); + client + .grant_database_access(&args.database_id, &args.principal, role) + .await?; + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "database_id": args.database_id, + "principal": args.principal, + "role": format!("{role:?}"), + }))? + ); + } else { + println!( + "granted {:?} {} on {}", + role, args.principal, args.database_id + ); + } + Ok(()) +} + +async fn read_node(client: &WikiClient, args: WikiReadArgs) -> Result<()> { + let Some(node) = client.read_node(&args.database_id, &args.path).await? else { + bail!("node not found: {}", args.path); + }; + if args.json { + println!("{}", serde_json::to_string_pretty(&node)?); + } else { + println!("{}", node.content); + } + Ok(()) +} + +async fn list_children(client: &WikiClient, args: WikiChildrenArgs) -> Result<()> { + let children = client + .list_children(ListChildrenRequest { + database_id: args.database_id, + path: args.path, + }) + .await?; + if args.json { + println!("{}", serde_json::to_string_pretty(&children)?); + } else { + for child in children { + println!( + "{}\t{:?}\t{}", + child.path, + child.kind, + child.etag.unwrap_or_default() + ); + } + } + Ok(()) +} + +async fn search_nodes(client: &WikiClient, args: WikiSearchArgs) -> Result<()> { + let hits = client + .search_nodes(SearchNodesRequest { + database_id: args.database_id, + query_text: args.query, + prefix: Some(args.prefix), + top_k: args.top_k, + preview_mode: Some(SearchPreviewMode::ContentStart), + }) + .await?; + if args.json { + println!("{}", serde_json::to_string_pretty(&hits)?); + } else { + for hit in hits { + let snippet = hit + .preview + .and_then(|preview| preview.excerpt) + .or(hit.snippet) + .unwrap_or_default(); + println!("{:.3}\t{}\t{}", hit.score, hit.path, snippet); + } + } + Ok(()) +} + +async fn write_node(client: &WikiClient, args: WikiWriteArgs) -> Result<()> { + let kind = node_kind(args.kind); + validate_source_path(&args.path, &kind)?; + let content = fs::read_to_string(&args.input)?; + let result = client + .write_node(WriteNodeRequest { + database_id: args.database_id, + path: args.path, + kind, + content, + metadata_json: args.metadata_json, + expected_etag: args.expected_etag, + }) + .await?; + if args.json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!("{}", result.node.etag); + } + Ok(()) +} + +async fn append_node(client: &WikiClient, args: WikiAppendArgs) -> Result<()> { + let kind = args.kind.map(node_kind); + if let Some(kind) = kind.as_ref() { + validate_source_path(&args.path, kind)?; + } + let json = args.json; + let content = fs::read_to_string(&args.input)?; + let result = client + .append_node(append_request(args, content, kind)) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!("{}", result.node.etag); + } + Ok(()) +} + +fn append_request( + args: WikiAppendArgs, + content: String, + kind: Option, +) -> AppendNodeRequest { + AppendNodeRequest { + database_id: args.database_id, + path: args.path, + content, + expected_etag: args.expected_etag, + separator: args.separator, + metadata_json: args.metadata_json, + kind, + } +} + +async fn edit_node(client: &WikiClient, args: WikiEditArgs) -> Result<()> { + let result = client + .edit_node(EditNodeRequest { + database_id: args.database_id, + path: args.path, + old_text: args.old_text, + new_text: args.new_text, + expected_etag: args.expected_etag, + replace_all: args.replace_all, + }) + .await?; + if args.json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!("{}\t{}", result.replacement_count, result.node.etag); + } + Ok(()) +} + +async fn delete_node(client: &WikiClient, args: WikiDeleteArgs) -> Result<()> { + if !args.yes { + bail!("--yes is required to delete a wiki node"); + } + let result = client + .delete_node(DeleteNodeRequest { + database_id: args.database_id, + path: args.path, + expected_etag: args.expected_etag, + }) + .await?; + if args.json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!("{}", result.path); + } + Ok(()) +} + +fn database_role(role: WikiDatabaseRoleArg) -> DatabaseRole { + match role { + WikiDatabaseRoleArg::Owner => DatabaseRole::Owner, + WikiDatabaseRoleArg::Writer => DatabaseRole::Writer, + WikiDatabaseRoleArg::Reader => DatabaseRole::Reader, + } +} + +fn node_kind(kind: WikiNodeKindArg) -> NodeKind { + match kind { + WikiNodeKindArg::File => NodeKind::File, + WikiNodeKindArg::Source => NodeKind::Source, + } +} + +fn validate_source_path(path: &str, kind: &NodeKind) -> Result<()> { + let is_source_path = path_matches_prefix_boundary(path, "/Sources/raw") + || path_matches_prefix_boundary(path, "/Sources/sessions"); + if *kind != NodeKind::Source { + if is_source_path { + bail!( + "source path must use source kind under /Sources/raw or /Sources/sessions: {path}" + ); + } + return Ok(()); + } + if path_matches_prefix_boundary(path, "/Sources/raw") { + return validate_source_path_under_prefix(path, "/Sources/raw"); + } + if path_matches_prefix_boundary(path, "/Sources/sessions") { + return validate_source_path_under_prefix(path, "/Sources/sessions"); + } + bail!("source path must stay under /Sources/raw or /Sources/sessions: {path}"); +} + +fn path_matches_prefix_boundary(path: &str, prefix: &str) -> bool { + path == prefix + || path + .strip_prefix(prefix) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +fn validate_source_path_under_prefix(path: &str, prefix: &str) -> Result<()> { + let relative = path + .strip_prefix(prefix) + .ok_or_else(|| anyhow::anyhow!("source path must stay under {prefix}: {path}"))?; + let segments = relative + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + if segments.len() != 2 { + bail!("source path must use canonical form {prefix}//.md: {path}"); + } + let [directory_name, file_name] = segments.as_slice() else { + unreachable!(); + }; + if directory_name.is_empty() || *file_name != format!("{directory_name}.md") { + bail!("source path must use canonical form {prefix}//.md: {path}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::{Cli, Command, WikiDatabaseCommand}; + use clap::Parser; + + #[test] + fn wiki_read_command_parses() { + let cli = Cli::try_parse_from([ + "kinic-cli", + "--identity", + "alice", + "wiki", + "read", + "--database-id", + "db", + "--path", + "/Wiki/a.md", + ]) + .expect("wiki read should parse"); + + match cli.command { + Command::Wiki(args) => match args.command { + crate::cli::WikiCommand::Read(args) => { + assert_eq!(args.database_id, "db"); + assert_eq!(args.path, "/Wiki/a.md"); + } + _ => panic!("expected wiki read"), + }, + _ => panic!("expected wiki command"), + } + } + + #[test] + fn wiki_database_grant_command_parses_role() { + let cli = Cli::try_parse_from([ + "kinic-cli", + "--identity", + "alice", + "wiki", + "database", + "grant", + "db", + "aaaaa-aa", + "reader", + ]) + .expect("wiki grant should parse"); + + match cli.command { + Command::Wiki(args) => match args.command { + crate::cli::WikiCommand::Database(database) => match database.command { + WikiDatabaseCommand::Grant(args) => { + assert_eq!(args.role, WikiDatabaseRoleArg::Reader); + assert!(!args.json); + } + _ => panic!("expected wiki database grant"), + }, + _ => panic!("expected wiki database"), + }, + _ => panic!("expected wiki command"), + } + } + + #[test] + fn wiki_database_grant_accepts_json() { + let cli = Cli::try_parse_from([ + "kinic-cli", + "--identity", + "alice", + "wiki", + "database", + "grant", + "db", + "aaaaa-aa", + "reader", + "--json", + ]) + .expect("wiki grant json should parse"); + + match cli.command { + Command::Wiki(args) => match args.command { + crate::cli::WikiCommand::Database(database) => match database.command { + WikiDatabaseCommand::Grant(args) => assert!(args.json), + _ => panic!("expected wiki database grant"), + }, + _ => panic!("expected wiki database"), + }, + _ => panic!("expected wiki command"), + } + } + + #[test] + fn wiki_append_accepts_kind_and_metadata() { + let cli = Cli::try_parse_from([ + "kinic-cli", + "--identity", + "alice", + "wiki", + "append", + "--database-id", + "db", + "--path", + "/Sources/raw/a/a.md", + "--input", + "note.md", + "--kind", + "source", + "--metadata-json", + "{}", + ]) + .expect("wiki append should parse"); + + match cli.command { + Command::Wiki(args) => match args.command { + crate::cli::WikiCommand::Append(args) => { + assert_eq!(args.kind, Some(WikiNodeKindArg::Source)); + assert_eq!(args.metadata_json.as_deref(), Some("{}")); + } + _ => panic!("expected wiki append"), + }, + _ => panic!("expected wiki command"), + } + } + + #[test] + fn append_request_keeps_kind_and_metadata() { + let request = append_request( + WikiAppendArgs { + database_id: "db".to_string(), + path: "/Sources/raw/a/a.md".to_string(), + input: "unused.md".into(), + expected_etag: Some("etag".to_string()), + separator: Some("\n".to_string()), + kind: Some(WikiNodeKindArg::Source), + metadata_json: Some("{}".to_string()), + json: false, + }, + "body".to_string(), + Some(NodeKind::Source), + ); + + assert_eq!(request.kind, Some(NodeKind::Source)); + assert_eq!(request.metadata_json.as_deref(), Some("{}")); + } + + #[test] + fn source_path_validation_matches_wiki_domain_rules() { + assert!(validate_source_path("/Sources/raw/a/a.md", &NodeKind::Source).is_ok()); + assert!(validate_source_path("/Sources/sessions/a/a.md", &NodeKind::Source).is_ok()); + assert!(validate_source_path("/Sources/raw/a/b.md", &NodeKind::Source).is_err()); + assert!(validate_source_path("/Sources/raw/a.md", &NodeKind::Source).is_err()); + assert!(validate_source_path("/Sources/rawfoo/a/a.md", &NodeKind::Source).is_err()); + assert!(validate_source_path("/Sources/raw/a/a.md", &NodeKind::File).is_err()); + assert!(validate_source_path("/Wiki/a.md", &NodeKind::File).is_ok()); + } + + #[test] + fn wiki_delete_parses_without_yes_for_runtime_rejection() { + let cli = Cli::try_parse_from([ + "kinic-cli", + "--identity", + "alice", + "wiki", + "delete", + "--database-id", + "db", + "--path", + "/Wiki/a.md", + ]) + .expect("delete command should parse so handler can reject missing --yes"); + + match cli.command { + Command::Wiki(args) => match args.command { + crate::cli::WikiCommand::Delete(args) => assert!(!args.yes), + _ => panic!("expected wiki delete"), + }, + _ => panic!("expected wiki command"), + } + } +} diff --git a/rust/lib.rs b/rust/lib.rs index 00ef50a..c02ef54 100644 --- a/rust/lib.rs +++ b/rust/lib.rs @@ -21,6 +21,7 @@ mod python; pub(crate) mod shared; pub mod tools; pub mod tui; +pub mod wiki_bridge; use anyhow::{Result, anyhow}; use clap::{CommandFactory, Parser, error::ErrorKind}; diff --git a/rust/tui/adapter.rs b/rust/tui/adapter.rs index 57dc965..8c0796d 100644 --- a/rust/tui/adapter.rs +++ b/rust/tui/adapter.rs @@ -19,6 +19,7 @@ pub fn to_content(record: &KinicRecord, memory_summary: Option<&str>) -> UiItemC match record.group.as_str() { "search-result" => search_result_content(record), "memories" => memory_content(record, memory_summary), + "wiki" => generic_content(record), _ => generic_content(record), } } @@ -27,6 +28,7 @@ fn summary_kind(record: &KinicRecord) -> UiItemKind { match record.group.as_str() { "search-result" => UiItemKind::Custom(String::new()), "memories" => UiItemKind::Custom("memory".to_string()), + "wiki" => UiItemKind::Custom("wiki".to_string()), other => UiItemKind::Custom(other.to_string()), } } diff --git a/rust/tui/bridge.rs b/rust/tui/bridge.rs index 93492a4..5a0430f 100644 --- a/rust/tui/bridge.rs +++ b/rust/tui/bridge.rs @@ -1,4 +1,4 @@ -use std::cmp::Ordering; +use std::{cmp::Ordering, collections::HashMap}; use super::chat_prompt::ActiveMemoryContext; use crate::{ @@ -23,11 +23,14 @@ use crate::{ }; use anyhow::{Context, Result}; -use ic_agent::{Agent, export::Principal}; +#[cfg(test)] +use candid::{Decode, Encode}; +use ic_agent::{Agent, export::Principal, identity::AnonymousIdentity}; use kinic_core::amount::format_e8s_to_kinic_string_nat; use tui_kit_runtime::{AccessControlAction, AccessControlRole, ChatScope, SessionAccountOverview}; pub(crate) use crate::shared::memory_metadata::DescriptionUpdate; +pub use crate::wiki_bridge::{DatabaseRole, DatabaseStatus, DatabaseSummary, NodeEntryKind}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct MemorySummary { @@ -44,6 +47,50 @@ pub struct MemorySummary { pub users: Option>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstanceSummaries { + pub memories: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WikiDatabases { + pub databases: Vec, + pub anonymous_access: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WikiChildNode { + pub path: String, + pub name: String, + pub kind: String, + pub has_children: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WikiNode { + pub path: String, + pub content: String, + pub etag: String, + pub metadata_json: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WikiWriteNodeInput { + pub wiki_id: String, + pub database_id: String, + pub path: String, + pub content: String, + pub metadata_json: String, + pub expected_etag: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct WikiSearchHit { + pub path: String, + pub score: f32, + pub snippet: Option, +} + pub type SearchResultItem = SearchHit; #[derive(Debug, Clone, PartialEq)] @@ -89,12 +136,20 @@ pub struct MemoryDetails { } #[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] pub struct CreateMemorySuccess { pub id: String, pub memories: Option>, pub refresh_warning: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateInstanceSuccess { + pub id: String, + pub instances: Option, + pub refresh_warning: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct InsertMemorySuccess { pub memory_id: String, @@ -178,6 +233,7 @@ pub async fn build_search_agent(use_mainnet: bool, auth: TuiAuth) -> Result Result> { let factory = resolve_agent_factory(use_mainnet, &auth)?; let agent = factory.build().await?; @@ -186,6 +242,15 @@ pub async fn list_memories(use_mainnet: bool, auth: TuiAuth) -> Result Result { + let factory = resolve_agent_factory(use_mainnet, &auth)?; + let agent = factory.build().await?; + let client = LauncherClient::new(agent); + let states = client.list_memories().await?; + Ok(instance_summaries_from_states(states)) +} + +#[allow(dead_code)] pub async fn create_memory( use_mainnet: bool, auth: TuiAuth, @@ -247,6 +312,259 @@ pub async fn create_memory( }) } +pub async fn create_instance( + use_mainnet: bool, + auth: TuiAuth, + name: String, + description: String, +) -> Result { + let factory = resolve_agent_factory(use_mainnet, &auth) + .map_err(|error| CreateMemoryError::Principal(short_error(&error.to_string())))?; + let agent = factory + .build() + .await + .map_err(|error| CreateMemoryError::Principal(short_error(&error.to_string())))?; + let client = LauncherClient::new(agent.clone()); + let (balance, price) = tokio::join!(fetch_balance(&agent), client.fetch_deployment_price()); + let balance = + balance.map_err(|error| CreateMemoryError::Balance(short_error(&error.to_string())))?; + let price = price.map_err(|error| CreateMemoryError::Price(short_error(&error.to_string())))?; + let fee = fetch_fee(&agent) + .await + .map_err(|error| CreateMemoryError::Fee(short_error(&error.to_string())))?; + match balance_delta(&price, balance, fee) { + BalanceDelta::Surplus(_) => {} + BalanceDelta::Shortfall(shortfall) => { + let required_total = required_balance(&price, fee); + return Err(CreateMemoryError::InsufficientBalance { + required_total_kinic: format_e8s_to_kinic_string_nat(&required_total), + required_total_base_units: required_total.to_string(), + shortfall_kinic: format_e8s_to_kinic_string_nat(&shortfall), + shortfall_base_units: shortfall.to_string(), + }); + } + } + client + .approve_launcher(&price, fee) + .await + .map_err(|error| CreateMemoryError::Approve(short_error(&error.to_string())))?; + let id = client + .deploy_memory(&name, &description) + .await + .map_err(|error| CreateMemoryError::Deploy(short_error(&error.to_string())))?; + let (instances, refresh_warning) = match client.list_memories().await { + Ok(states) => (Some(instance_summaries_from_states(states)), None), + Err(error) => ( + None, + Some(format!( + "Automatic reload failed after create. Press Ctrl-R to refresh. Cause: {}", + short_error(&error.to_string()) + )), + ), + }; + + Ok(CreateInstanceSuccess { + id, + instances, + refresh_warning, + }) +} + +pub async fn list_wiki_children( + use_mainnet: bool, + auth: TuiAuth, + read_as_anonymous: bool, + wiki_id: String, + database_id: String, + path: String, +) -> Result> { + let agent = build_wiki_read_agent(use_mainnet, auth, read_as_anonymous).await?; + let client = crate::wiki_bridge::WikiClient::new(agent, wiki_id)?; + Ok(client + .list_children(crate::wiki_bridge::ListChildrenRequest { database_id, path }) + .await? + .into_iter() + .map(WikiChildNode::from) + .collect()) +} + +pub async fn read_wiki_node( + use_mainnet: bool, + auth: TuiAuth, + read_as_anonymous: bool, + wiki_id: String, + database_id: String, + path: String, +) -> Result> { + let agent = build_wiki_read_agent(use_mainnet, auth, read_as_anonymous).await?; + let client = crate::wiki_bridge::WikiClient::new(agent, wiki_id)?; + Ok(client + .read_node(&database_id, &path) + .await? + .map(WikiNode::from)) +} + +pub async fn write_wiki_node( + use_mainnet: bool, + auth: TuiAuth, + input: WikiWriteNodeInput, +) -> Result { + let agent = build_search_agent(use_mainnet, auth).await?; + let client = crate::wiki_bridge::WikiClient::new(agent, input.wiki_id)?; + let content = input.content.clone(); + let metadata_json = input.metadata_json.clone(); + let result = client + .write_node(crate::wiki_bridge::WriteNodeRequest { + database_id: input.database_id.clone(), + path: input.path.clone(), + kind: crate::wiki_bridge::NodeKind::File, + content: input.content, + metadata_json: input.metadata_json, + expected_etag: Some(input.expected_etag), + }) + .await?; + Ok(wiki_node_from_write_result(result, content, metadata_json)) +} + +fn wiki_node_from_write_result( + result: crate::wiki_bridge::WriteNodeResult, + content: String, + metadata_json: String, +) -> WikiNode { + WikiNode { + path: result.node.path, + content, + etag: result.node.etag, + metadata_json, + } +} + +pub async fn search_wiki_nodes( + use_mainnet: bool, + auth: TuiAuth, + read_as_anonymous: bool, + wiki_id: String, + database_id: String, + query: String, +) -> Result> { + let agent = build_wiki_read_agent(use_mainnet, auth, read_as_anonymous).await?; + let client = crate::wiki_bridge::WikiClient::new(agent, wiki_id)?; + Ok(client + .search_nodes(crate::wiki_bridge::SearchNodesRequest { + database_id, + query_text: query, + prefix: Some("/Wiki".to_string()), + top_k: 20, + preview_mode: Some(crate::wiki_bridge::SearchPreviewMode::ContentStart), + }) + .await? + .into_iter() + .map(WikiSearchHit::from) + .collect()) +} + +async fn build_wiki_read_agent( + use_mainnet: bool, + auth: TuiAuth, + read_as_anonymous: bool, +) -> Result { + if read_as_anonymous { + return crate::agent::AgentFactory::new_with_identity(use_mainnet, AnonymousIdentity {}) + .build() + .await; + } + build_search_agent(use_mainnet, auth).await +} + +pub async fn list_wiki_databases( + use_mainnet: bool, + auth: TuiAuth, + wiki_id: String, +) -> Result { + let agent = build_search_agent(use_mainnet, auth).await?; + let wiki_id_for_anonymous = wiki_id.clone(); + let client = crate::wiki_bridge::WikiClient::new(agent, wiki_id)?; + let mut anonymous_access = HashMap::new(); + let authenticated_databases = client.list_databases().await; + let mut databases = authenticated_databases.unwrap_or_default(); + for database in databases.iter() { + apply_database_member_lookup( + &mut anonymous_access, + database.database_id.as_str(), + client + .list_database_members(database.database_id.as_str()) + .await, + ); + } + let anonymous_agent = + crate::agent::AgentFactory::new_with_identity(use_mainnet, AnonymousIdentity {}) + .build() + .await?; + let anonymous_client = + crate::wiki_bridge::WikiClient::new(anonymous_agent, wiki_id_for_anonymous)?; + merge_anonymous_wiki_databases( + &mut databases, + &mut anonymous_access, + anonymous_client.list_databases().await?, + ); + Ok(WikiDatabases { + databases, + anonymous_access, + }) +} + +fn apply_database_member_lookup( + anonymous_access: &mut HashMap, + database_id: &str, + members_result: Result>, +) { + if let Ok(members) = members_result { + apply_anonymous_member_access(anonymous_access, database_id, members); + } +} + +fn apply_anonymous_member_access( + anonymous_access: &mut HashMap, + database_id: &str, + members: Vec, +) { + if let Some(member) = members + .into_iter() + .find(|member| matches!(member.principal.as_str(), "anonymous" | "2vxsx-fae")) + { + anonymous_access.insert(database_id.to_string(), member.role); + } +} + +fn merge_anonymous_wiki_databases( + databases: &mut Vec, + anonymous_access: &mut HashMap, + anonymous_databases: Vec, +) { + for database in anonymous_databases { + anonymous_access + .entry(database.database_id.clone()) + .or_insert(database.role); + if databases + .iter() + .all(|candidate| candidate.database_id != database.database_id) + { + databases.push(database); + } + } +} + +pub async fn create_wiki_database( + use_mainnet: bool, + auth: TuiAuth, + wiki_id: String, +) -> Result { + let agent = build_search_agent(use_mainnet, auth).await?; + crate::wiki_bridge::WikiClient::new(agent, wiki_id)? + .create_database() + .await +} + pub async fn load_session_account_overview( use_mainnet: bool, auth: TuiAuth, @@ -558,6 +876,12 @@ pub async fn run_insert( }) } +fn instance_summaries_from_states(states: Vec) -> InstanceSummaries { + InstanceSummaries { + memories: states.into_iter().map(memory_summary_from_state).collect(), + } +} + fn memory_summary_from_state(state: State) -> MemorySummary { match state { State::Empty(message) => MemorySummary { @@ -700,6 +1024,46 @@ fn role_code(role: AccessControlRole, principal_id: &str) -> Result { Ok(memory_role.code()) } +impl From for WikiChildNode { + fn from(node: crate::wiki_bridge::ChildNode) -> Self { + Self { + path: node.path, + name: node.name, + kind: match node.kind { + NodeEntryKind::File => "file", + NodeEntryKind::Source => "source", + NodeEntryKind::Directory => "directory", + } + .to_string(), + has_children: node.has_children, + } + } +} + +impl From for WikiNode { + fn from(node: crate::wiki_bridge::Node) -> Self { + Self { + path: node.path, + content: node.content, + etag: node.etag, + metadata_json: node.metadata_json, + } + } +} + +impl From for WikiSearchHit { + fn from(hit: crate::wiki_bridge::SearchNodeHit) -> Self { + Self { + path: hit.path, + score: hit.score, + snippet: hit + .preview + .and_then(|preview| preview.excerpt) + .or(hit.snippet), + } + } +} + fn short_error(message: &str) -> String { message.lines().next().unwrap_or(message).trim().to_string() } @@ -710,6 +1074,10 @@ fn format_insert_execute_error(message: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::wiki_bridge::{ + ChildNode, DatabaseMember, DatabaseRole, Node, NodeKind, SearchNodeHit, SearchPreview, + SearchPreviewField, + }; use candid::Nat; use ic_agent::identity::AnonymousIdentity; use std::sync::Arc; @@ -769,6 +1137,159 @@ mod tests { ); } + #[test] + fn wiki_database_summary_decodes_list_databases_shape() { + let bytes = Encode!(&Ok::<_, String>(vec![DatabaseSummary { + database_id: "default".to_string(), + status: DatabaseStatus::Hot, + role: DatabaseRole::Owner, + logical_size_bytes: 42, + archived_at_ms: None, + deleted_at_ms: None, + }])) + .expect("database list should encode"); + let decoded = Decode!(&bytes, Result, String>) + .expect("database list should decode"); + + assert_eq!(decoded.unwrap()[0].database_id, "default"); + } + + #[test] + fn merge_anonymous_wiki_databases_adds_public_only_entries() { + let mut databases = vec![DatabaseSummary { + database_id: "private".to_string(), + status: DatabaseStatus::Hot, + role: DatabaseRole::Owner, + logical_size_bytes: 1, + archived_at_ms: None, + deleted_at_ms: None, + }]; + let mut anonymous_access = HashMap::new(); + + merge_anonymous_wiki_databases( + &mut databases, + &mut anonymous_access, + vec![DatabaseSummary { + database_id: "public".to_string(), + status: DatabaseStatus::Hot, + role: DatabaseRole::Reader, + logical_size_bytes: 2, + archived_at_ms: None, + deleted_at_ms: None, + }], + ); + + assert_eq!(databases.len(), 2); + assert_eq!(databases[1].database_id, "public"); + assert_eq!(anonymous_access.get("public"), Some(&DatabaseRole::Reader)); + } + + #[test] + fn list_wiki_databases_ignores_member_lookup_failure() { + let mut anonymous_access = HashMap::new(); + + apply_database_member_lookup( + &mut anonymous_access, + "db-a", + Err(anyhow::anyhow!("members denied")), + ); + + assert!(anonymous_access.is_empty()); + + apply_database_member_lookup( + &mut anonymous_access, + "db-a", + Ok(vec![DatabaseMember { + database_id: "db-a".to_string(), + principal: "anonymous".to_string(), + role: DatabaseRole::Reader, + created_at_ms: 1, + }]), + ); + + assert_eq!(anonymous_access.get("db-a"), Some(&DatabaseRole::Reader)); + } + + #[test] + fn wiki_child_node_from_canister_types_projects_display_fields() { + let child = WikiChildNode::from(ChildNode { + path: "/Wiki/index.md".to_string(), + name: "index.md".to_string(), + kind: NodeEntryKind::File, + updated_at: Some(1), + etag: Some("abc".to_string()), + size_bytes: Some(10), + is_virtual: false, + has_children: false, + }); + + assert_eq!(child.path, "/Wiki/index.md"); + assert_eq!(child.name, "index.md"); + assert_eq!(child.kind, "file"); + assert!(!child.has_children); + } + + #[test] + fn wiki_node_from_canister_types_keeps_render_payload() { + let node = WikiNode::from(Node { + path: "/Wiki/index.md".to_string(), + kind: NodeKind::File, + content: "# Index".to_string(), + created_at: 1, + updated_at: 2, + etag: "etag".to_string(), + metadata_json: "{}".to_string(), + }); + + assert_eq!(node.path, "/Wiki/index.md"); + assert_eq!(node.content, "# Index"); + assert_eq!(node.etag, "etag"); + assert_eq!(node.metadata_json, "{}"); + } + + #[test] + fn wiki_node_from_write_result_uses_result_path_etag_and_input_payload() { + let node = wiki_node_from_write_result( + crate::wiki_bridge::WriteNodeResult { + created: false, + node: crate::wiki_bridge::RecentNodeHit { + path: "/Wiki/saved.md".to_string(), + kind: NodeKind::File, + etag: "etag-2".to_string(), + updated_at: 3, + }, + }, + "# Saved".to_string(), + "{\"title\":\"Saved\"}".to_string(), + ); + + assert_eq!(node.path, "/Wiki/saved.md"); + assert_eq!(node.content, "# Saved"); + assert_eq!(node.etag, "etag-2"); + assert_eq!(node.metadata_json, "{\"title\":\"Saved\"}"); + } + + #[test] + fn wiki_search_hit_prefers_preview_excerpt() { + let hit = WikiSearchHit::from(SearchNodeHit { + path: "/Wiki/index.md".to_string(), + kind: NodeKind::File, + snippet: Some("fallback".to_string()), + preview: Some(SearchPreview { + field: SearchPreviewField::Content, + match_reason: "content".to_string(), + char_offset: 0, + excerpt: Some("preview".to_string()), + }), + score: 0.75, + match_reasons: vec!["content".to_string()], + }); + + assert_eq!(hit.path, "/Wiki/index.md"); + assert_eq!(hit.score, 0.75); + assert_eq!(hit.snippet.as_deref(), Some("preview")); + } + #[test] fn insert_error_variants_keep_failure_stage() { let resolve = InsertMemoryError::ResolveAgentFactory("auth missing".to_string()); diff --git a/rust/tui/mod.rs b/rust/tui/mod.rs index 2db2e70..cef85d4 100644 --- a/rust/tui/mod.rs +++ b/rust/tui/mod.rs @@ -116,6 +116,7 @@ impl TuiAuth { pub struct TuiLaunchConfig { pub auth: TuiAuth, pub use_mainnet: bool, + pub wiki_canister_id: Option, } pub fn run(global: &GlobalOpts) -> Result<()> { @@ -126,6 +127,7 @@ pub fn build_launch_config(identity: String, use_mainnet: bool) -> Result Result Option { + Some(crate::wiki_bridge::wiki_canister_id_from_env()) +} + pub fn run_with_config(config: TuiLaunchConfig) -> Result<()> { let mut provider = provider::KinicProvider::new(TuiConfig { auth: config.auth, use_mainnet: config.use_mainnet, + wiki_canister_id: config.wiki_canister_id, }); let mut hooks = KinicRuntimeHooks; @@ -164,6 +172,7 @@ impl RuntimeLoopHooks for KinicRuntimeHooks { if state.create_submit_state == CreateSubmitState::Submitting || matches!(state.create_cost_state, CreateCostState::Loading) || state.insert_submit_state == CreateSubmitState::Submitting + || state.wiki_editor.submit_state == CreateSubmitState::Submitting { state.create_spinner_frame = state.create_spinner_frame.wrapping_add(1); state.insert_spinner_frame = state.insert_spinner_frame.wrapping_add(1); @@ -208,6 +217,10 @@ mod tests { assert!(matches!(config.auth, TuiAuth::DeferredIdentity { .. })); assert!(config.use_mainnet); + assert_eq!( + config.wiki_canister_id.as_deref(), + Some(crate::wiki_bridge::DEFAULT_WIKI_CANISTER_ID) + ); } #[test] @@ -241,4 +254,16 @@ mod tests { assert_eq!(config.tab_ids, &kinic_tabs::KINIC_TAB_IDS); assert_eq!(config.initial_focus, PaneFocus::Search); } + + #[test] + fn ui_config_omits_market_tab() { + let config = ui_config::kinic_ui_config(); + let titles = config + .tabs + .iter() + .map(|tab| tab.title.as_str()) + .collect::>(); + + assert_eq!(titles, ["Memories", "Insert", "Create", "Wiki", "Settings"]); + } } diff --git a/rust/tui/provider/mod.rs b/rust/tui/provider/mod.rs index b844754..a7c2334 100644 --- a/rust/tui/provider/mod.rs +++ b/rust/tui/provider/mod.rs @@ -41,13 +41,15 @@ use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; use tui_kit_runtime::{ AccessControlAction, AccessControlMode, AccessControlRole, ChatScope, CoreAction, CoreEffect, - CoreResult, CoreState, CreateCostState, DataProvider, FILE_MODE_ALLOWED_EXTENSIONS, InsertMode, - LoadedCreateCost, MemorySelection, PaneFocus, PickerConfirmKind, PickerContext, PickerItem, - PickerItemKind, PickerListMode, PickerState, ProviderOutput, ProviderSnapshot, SearchScope, - SessionAccountOverview, SessionSettingsSnapshot, TransferModalMode, + CoreResult, CoreState, CreateCostState, DataProvider, DiagnosticSnapshot, DocumentSnapshot, + FILE_MODE_ALLOWED_EXTENSIONS, InsertMode, LoadedCreateCost, MemorySelection, PaneFocus, + PaneRow, PaneSnapshot, PickerConfirmKind, PickerContext, PickerItem, PickerItemKind, + PickerListMode, PickerState, ProviderOutput, ProviderSnapshot, SearchScope, + SessionAccountOverview, SessionSettingsSnapshot, ThreePaneMode, ThreePaneSnapshot, + TransferModalMode, kinic_tabs::{ - KINIC_CREATE_TAB_ID, KINIC_INSERT_TAB_ID, KINIC_MARKET_TAB_ID, KINIC_MEMORIES_TAB_ID, - KINIC_SETTINGS_TAB_ID, + KINIC_CREATE_TAB_ID, KINIC_INSERT_TAB_ID, KINIC_MEMORIES_TAB_ID, KINIC_SETTINGS_TAB_ID, + KINIC_WIKI_TAB_ID, }, }; @@ -55,6 +57,7 @@ use tui_kit_runtime::{ pub struct TuiConfig { pub auth: TuiAuth, pub use_mainnet: bool, + pub wiki_canister_id: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -66,6 +69,8 @@ pub struct KinicRecord { pub content_md: String, pub searchable_memory_id: Option, pub source_memory_id: Option, + pub source_wiki_id: Option, + pub source_wiki_database_id: Option, } impl KinicRecord { @@ -84,6 +89,8 @@ impl KinicRecord { content_md: content_md.into(), searchable_memory_id: None, source_memory_id: None, + source_wiki_id: None, + source_wiki_database_id: None, } } @@ -96,6 +103,16 @@ impl KinicRecord { self.source_memory_id = Some(memory_id.into()); self } + + pub fn with_source_wiki_id(mut self, wiki_id: impl Into) -> Self { + self.source_wiki_id = Some(wiki_id.into()); + self + } + + pub fn with_source_wiki_database_id(mut self, database_id: impl Into) -> Self { + self.source_wiki_database_id = Some(database_id.into()); + self + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -125,6 +142,7 @@ struct SaveTagOutcome { const MAX_CONCURRENT_MEMORY_SEARCHES: usize = 10; const MAX_CONCURRENT_MEMORY_DETAIL_PREFETCHES: usize = 4; const ADD_MEMORY_ACTION_ID: &str = "kinic-action-add-memory"; +const WIKI_CREATE_DATABASE_ACTION_ID: &str = "wiki-create-database-action"; const ALL_MEMORIES_CHAT_THREAD_KEY: &str = "all-memories"; #[cfg_attr(test, allow(dead_code))] const MEMORY_SUMMARY_QUERY: &str = "Summarize the contents of this memory concisely. Explain the main topics, what kinds of information it contains, and what the memory appears to be for, in 3 to 5 sentences."; @@ -148,6 +166,13 @@ pub struct KinicProvider { active_memory: Option, memory_summaries: Vec, memory_records: Vec, + wiki_databases: Vec, + wiki_database_anonymous_access: HashMap, + wiki_records: Vec, + wiki_load_error: Option, + wiki_view_mode: WikiViewMode, + active_wiki_database_id: Option, + pending_wiki_reload: Option, result_records: Vec, memories_mode: MemoriesMode, pending_initial_memories: Option>, @@ -187,6 +212,23 @@ pub struct KinicProvider { next_memory_detail_request_id: u64, pending_memory_detail_memory_id: Option, memory_detail_prefetch: MemoryDetailPrefetchState, + wiki_children_cache: HashMap, + wiki_children_task: RequestTaskState, + next_wiki_children_request_id: u64, + pending_wiki_children_database_id: Option, + pending_wiki_children_path: Option, + wiki_current_path: String, + wiki_preview_path: Option, + wiki_expanded_paths: HashSet, + selected_wiki_browser_index: usize, + wiki_search_task: RequestTaskState, + next_wiki_search_request_id: u64, + wiki_databases_task: RequestTaskState, + next_wiki_databases_request_id: u64, + wiki_create_database_task: RequestTaskState, + next_wiki_create_database_request_id: u64, + wiki_save_task: RequestTaskState, + next_wiki_save_request_id: u64, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -205,6 +247,102 @@ struct SearchTaskOutput { result: Result, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct WikiChildrenContent { + entries: Vec, + body_lines: Vec, + index_preview: Option>, + node_content: Option, + node_etag: Option, + node_metadata_json: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WikiBrowserEntry { + path: String, + name: String, + kind: WikiBrowserEntryKind, + size_bytes: Option, + has_children: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WikiVisibleEntry { + entry: WikiBrowserEntry, + depth: usize, + expanded: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WikiBrowserEntryKind { + Directory, + File, + Source, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WikiViewMode { + DatabaseList, + DatabaseBrowser, + Diagnostic, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WikiReloadState { + database_id: Option, + view_mode: WikiViewMode, + current_path: String, + preview_path: Option, + expanded_paths: HashSet, + browser_index: usize, + had_search_results: bool, +} + +struct WikiChildrenTaskOutput { + request_id: u64, + database_id: String, + path: String, + result: Result, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WikiChildrenLoadMode { + Directory, + Node, +} + +struct WikiSearchTaskOutput { + request_id: u64, + wiki_id: String, + database_id: String, + result: Result, String>, +} + +struct WikiDatabasesTaskOutput { + request_id: u64, + result: Result, +} + +struct WikiCreateDatabaseTaskOutput { + request_id: u64, + result: Result, +} + +struct WikiSaveTaskOutput { + request_id: u64, + database_id: String, + result: Result, +} + +struct WikiSaveNodeInput { + wiki_id: String, + database_id: String, + path: String, + content: String, + metadata_json: String, + expected_etag: String, +} + /// In-flight memory search with explicit cancellation. Kept separate from /// `RequestTaskState` because workers use `CancellationToken` and batching /// differs from other request/response tasks. @@ -253,7 +391,7 @@ fn fold_live_search_results( } struct InitialMemoriesTaskOutput { - result: Result, String>, + result: Result, } struct CreateCostTaskOutput { @@ -263,7 +401,7 @@ struct CreateCostTaskOutput { struct CreateSubmitTaskOutput { request_id: u64, - result: Result, + result: Result, } struct ChatTaskOutput { @@ -938,6 +1076,256 @@ fn load_memory_details_task_result( .map_err(|error| error.to_string()) } +fn load_wiki_children_content( + use_mainnet: bool, + auth: TuiAuth, + read_as_anonymous: bool, + wiki_id: String, + database_id: String, + path: String, + mode: WikiChildrenLoadMode, +) -> Result { + let runtime = Runtime::new().expect("failed to create tokio runtime for wiki browser load"); + if mode == WikiChildrenLoadMode::Node + && path != "/" + && let Some(node) = runtime + .block_on(bridge::read_wiki_node( + use_mainnet, + auth.clone(), + read_as_anonymous, + wiki_id.clone(), + database_id.clone(), + path.clone(), + )) + .map_err(|error| error.to_string())? + { + let preview = node.content.lines().map(str::to_string).collect(); + return Ok(WikiChildrenContent { + entries: Vec::new(), + body_lines: Vec::new(), + index_preview: Some(preview), + node_content: Some(node.content), + node_etag: Some(node.etag), + node_metadata_json: Some(node.metadata_json), + }); + } + + let entries = if path == "/" { + wiki_root_entries() + } else { + let children = runtime + .block_on(bridge::list_wiki_children( + use_mainnet, + auth.clone(), + read_as_anonymous, + wiki_id.clone(), + database_id.clone(), + path.clone(), + )) + .map_err(|error| error.to_string())?; + children.into_iter().map(WikiBrowserEntry::from).collect() + }; + let body_lines = if entries.is_empty() { + vec!["No /Wiki or /Sources children found.".to_string()] + } else { + entries.iter().map(wiki_entry_line).collect() + }; + let index_preview = if path == "/" { + runtime + .block_on(bridge::read_wiki_node( + use_mainnet, + auth.clone(), + read_as_anonymous, + wiki_id.clone(), + database_id.clone(), + "/Wiki/index.md".to_string(), + )) + .map_err(|error| error.to_string())? + .map(|node| node.content.lines().take(8).map(str::to_string).collect()) + } else { + None + }; + Ok(WikiChildrenContent { + entries, + body_lines, + index_preview, + node_content: None, + node_etag: None, + node_metadata_json: None, + }) +} + +fn save_wiki_node_content( + use_mainnet: bool, + auth: TuiAuth, + input: WikiSaveNodeInput, +) -> Result { + let runtime = Runtime::new().expect("failed to create tokio runtime for wiki save"); + runtime + .block_on(bridge::write_wiki_node( + use_mainnet, + auth, + bridge::WikiWriteNodeInput { + wiki_id: input.wiki_id, + database_id: input.database_id, + path: input.path, + content: input.content, + metadata_json: input.metadata_json, + expected_etag: input.expected_etag, + }, + )) + .map_err(|error| error.to_string()) +} + +fn wiki_entry_line(entry: &WikiBrowserEntry) -> String { + let marker = if entry.has_children { "+" } else { "-" }; + format!( + "{marker} {} ({})", + entry.path, + wiki_entry_kind_label(entry.kind) + ) +} + +fn wiki_root_entries() -> Vec { + ["/Wiki", "/Sources"] + .into_iter() + .map(|path| WikiBrowserEntry { + path: path.to_string(), + name: path.trim_start_matches('/').to_string(), + kind: WikiBrowserEntryKind::Directory, + size_bytes: None, + has_children: true, + }) + .collect() +} + +fn wiki_entry_kind_label(kind: WikiBrowserEntryKind) -> &'static str { + match kind { + WikiBrowserEntryKind::Directory => "directory", + WikiBrowserEntryKind::File => "file", + WikiBrowserEntryKind::Source => "source", + } +} + +fn wiki_database_visibility_label( + database: &bridge::DatabaseSummary, + anonymous_role: Option<&bridge::DatabaseRole>, +) -> &'static str { + if anonymous_role.is_some() { + return "Public"; + } + if database.role == bridge::DatabaseRole::Owner { + "Private" + } else { + "Shared" + } +} + +fn wiki_database_section_row(label: &str) -> PaneRow { + PaneRow { + label: label.to_string(), + detail: String::new(), + selected: false, + } +} + +fn wiki_entry_is_editable_markdown(entry: &WikiBrowserEntry) -> bool { + entry.kind == WikiBrowserEntryKind::File + && entry.path.starts_with("/Wiki/") + && entry.path.ends_with(".md") +} + +impl From for WikiBrowserEntry { + fn from(child: bridge::WikiChildNode) -> Self { + Self { + path: child.path, + name: child.name, + kind: match child.kind.as_str() { + "source" => WikiBrowserEntryKind::Source, + "directory" => WikiBrowserEntryKind::Directory, + _ => WikiBrowserEntryKind::File, + }, + size_bytes: None, + has_children: child.has_children, + } + } +} + +fn wiki_children_cache_key(database_id: &str, path: &str) -> String { + format!("{database_id}\n{path}") +} + +fn wiki_document_content_lines(path: &str, content: &WikiChildrenContent) -> Vec { + if let Some(lines) = &content.index_preview { + return lines.clone(); + } + if !content.body_lines.is_empty() { + return content.body_lines.clone(); + } + if !content.entries.is_empty() { + return content.entries.iter().map(wiki_entry_line).collect(); + } + vec![format!("No entries in {path}.")] +} + +fn wiki_loading_line(path: &str) -> String { + if path == "/" { + "Loading /Wiki and /Sources...".to_string() + } else { + format!("Loading {path}...") + } +} + +fn wiki_display_name(path: &str) -> String { + path.trim_end_matches('/') + .rsplit('/') + .find(|part| !part.is_empty()) + .unwrap_or(path) + .to_string() +} + +fn wiki_tree_row_label(entry: &WikiBrowserEntry, depth: usize) -> String { + format!("{}{}", " ".repeat(depth), entry.name) +} + +fn wiki_tree_row_detail(entry: &WikiBrowserEntry, expanded: bool) -> String { + match entry.kind { + WikiBrowserEntryKind::Directory if expanded => "directory expanded".to_string(), + WikiBrowserEntryKind::Directory => "directory collapsed".to_string(), + WikiBrowserEntryKind::File | WikiBrowserEntryKind::Source => match entry.size_bytes { + Some(size) => format!("{} {} bytes", wiki_entry_kind_label(entry.kind), size), + None => wiki_entry_kind_label(entry.kind).to_string(), + }, + } +} + +fn wiki_path_contains(parent: &str, path: &str) -> bool { + if parent == "/" { + return path.starts_with('/'); + } + path == parent + || path + .strip_prefix(parent) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +fn wiki_parent_path(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed == "/Wiki" || trimmed == "/Sources" || trimmed.is_empty() { + return "/".to_string(); + } + trimmed + .rsplit_once('/') + .map(|(parent, _)| { + if parent.is_empty() { + "/".to_string() + } else { + parent.to_string() + } + }) + .unwrap_or_else(|| "/".to_string()) +} + #[derive(Debug, Clone, PartialEq, Eq)] enum MemoryContentSelection<'a> { RenameMemory, @@ -981,6 +1369,13 @@ impl KinicProvider { active_memory: None, memory_summaries: Vec::new(), memory_records: Vec::new(), + wiki_databases: Vec::new(), + wiki_database_anonymous_access: HashMap::new(), + wiki_records: Vec::new(), + wiki_load_error: None, + wiki_view_mode: WikiViewMode::DatabaseList, + active_wiki_database_id: None, + pending_wiki_reload: None, result_records: Vec::new(), memories_mode: MemoriesMode::Browser, pending_initial_memories: None, @@ -1020,6 +1415,23 @@ impl KinicProvider { next_memory_detail_request_id: 0, pending_memory_detail_memory_id: None, memory_detail_prefetch: MemoryDetailPrefetchState::default(), + wiki_children_cache: HashMap::new(), + wiki_children_task: RequestTaskState::default(), + next_wiki_children_request_id: 0, + pending_wiki_children_database_id: None, + pending_wiki_children_path: None, + wiki_current_path: "/".to_string(), + wiki_preview_path: None, + wiki_expanded_paths: HashSet::new(), + selected_wiki_browser_index: 0, + wiki_search_task: RequestTaskState::default(), + next_wiki_search_request_id: 0, + wiki_databases_task: RequestTaskState::default(), + next_wiki_databases_request_id: 0, + wiki_create_database_task: RequestTaskState::default(), + next_wiki_create_database_request_id: 0, + wiki_save_task: RequestTaskState::default(), + next_wiki_save_request_id: 0, } } @@ -1041,11 +1453,15 @@ impl KinicProvider { self.query.clear(); } self.result_records.clear(); + self.wiki_load_error = None; self.invalidate_pending_search(); self.all = vec![loading_memories_record()]; self.memory_summaries.clear(); self.memory_records.clear(); + self.wiki_databases.clear(); + self.wiki_database_anonymous_access.clear(); + self.wiki_records.clear(); self.memory_content_summaries.clear(); self.failed_memory_content_summaries.clear(); self.memory_summary_tasks.clear(); @@ -1065,7 +1481,7 @@ impl KinicProvider { let runtime = Runtime::new().expect("failed to create tokio runtime for initial memories load"); let result = runtime - .block_on(bridge::list_memories(use_mainnet, auth)) + .block_on(bridge::list_instances(use_mainnet, auth)) .map_err(|error| error.to_string()); let _ = tx.send(InitialMemoriesTaskOutput { result }); }); @@ -1083,6 +1499,7 @@ impl KinicProvider { match self.tab_id.as_str() { KINIC_CREATE_TAB_ID => self.start_create_cost_refresh().into_iter().collect(), KINIC_INSERT_TAB_ID => Vec::new(), + KINIC_WIKI_TAB_ID => vec![self.start_wiki_databases_load(false)], KINIC_MEMORIES_TAB_ID => self .start_live_memories_load(None, true) .into_iter() @@ -1092,7 +1509,167 @@ impl KinicProvider { } } + fn enter_wiki_tab(&mut self) -> Vec { + if self.wiki_databases_task.in_flight + || self.wiki_load_error.is_some() + || !self.wiki_records.is_empty() + || !self.wiki_databases.is_empty() + { + return Vec::new(); + } + vec![self.start_wiki_databases_load(true)] + } + + fn start_wiki_databases_load(&mut self, reset_view: bool) -> CoreEffect { + if reset_view { + self.result_records.clear(); + self.pending_wiki_reload = None; + self.active_wiki_database_id = None; + self.wiki_current_path = "/".to_string(); + self.wiki_preview_path = None; + self.wiki_expanded_paths.clear(); + self.selected_wiki_browser_index = 0; + self.wiki_view_mode = WikiViewMode::DatabaseList; + } else { + self.pending_wiki_reload = Some(WikiReloadState { + database_id: self.active_wiki_database_id.clone(), + view_mode: self.wiki_view_mode, + current_path: self.wiki_current_path.clone(), + preview_path: self.wiki_preview_path.clone(), + expanded_paths: self.wiki_expanded_paths.clone(), + browser_index: self.selected_wiki_browser_index, + had_search_results: !self.result_records.is_empty(), + }); + } + self.invalidate_pending_search(); + reset_request_task(&mut self.wiki_children_task); + reset_request_task(&mut self.wiki_search_task); + self.pending_wiki_children_database_id = None; + self.pending_wiki_children_path = None; + + let Some(wiki_canister_id) = self.config.wiki_canister_id.clone() else { + self.wiki_databases.clear(); + self.wiki_database_anonymous_access.clear(); + self.wiki_records = vec![wiki_not_configured_record()]; + self.wiki_load_error = Some("Wiki canister is not configured.".to_string()); + self.wiki_view_mode = WikiViewMode::Diagnostic; + self.active_wiki_database_id = None; + self.pending_wiki_reload = None; + self.wiki_children_cache.clear(); + return CoreEffect::Notify("Wiki canister is not configured.".to_string()); + }; + + let auth = self.config.auth.clone(); + let use_mainnet = self.config.use_mainnet; + spawn_request_task( + &mut self.next_wiki_databases_request_id, + &mut self.wiki_databases_task, + move |request_id, tx| { + let runtime = + Runtime::new().expect("failed to create tokio runtime for wiki databases load"); + let result = runtime + .block_on(bridge::list_wiki_databases( + use_mainnet, + auth, + wiki_canister_id, + )) + .map_err(|error| error.to_string()); + let _ = tx.send(WikiDatabasesTaskOutput { request_id, result }); + }, + ); + CoreEffect::Notify("Refreshing wiki databases...".to_string()) + } + + fn start_wiki_create_database(&mut self) -> CoreEffect { + if self.wiki_create_database_task.in_flight { + return CoreEffect::Notify("Creating wiki database...".to_string()); + } + let Some(wiki_canister_id) = self.config.wiki_canister_id.clone() else { + return CoreEffect::Notify("Wiki canister is not configured.".to_string()); + }; + let auth = self.config.auth.clone(); + let use_mainnet = self.config.use_mainnet; + spawn_request_task( + &mut self.next_wiki_create_database_request_id, + &mut self.wiki_create_database_task, + move |request_id, tx| { + let runtime = Runtime::new() + .expect("failed to create tokio runtime for wiki database create"); + let result = runtime + .block_on(bridge::create_wiki_database( + use_mainnet, + auth, + wiki_canister_id, + )) + .map_err(|error| error.to_string()); + let _ = tx.send(WikiCreateDatabaseTaskOutput { request_id, result }); + }, + ); + CoreEffect::Notify("Creating wiki database...".to_string()) + } + + fn start_wiki_save(&mut self, state: &CoreState) -> Vec { + if self.wiki_save_task.in_flight { + return vec![CoreEffect::Notify("Wiki save already running.".to_string())]; + } + if !state.wiki_editor.open { + return vec![CoreEffect::Notify( + "Open a wiki file before saving.".to_string(), + )]; + } + if !state.wiki_editor.dirty { + return vec![CoreEffect::Notify("No changes.".to_string())]; + } + let Some((wiki_id, database_id)) = self.selected_wiki_target(state) else { + return vec![CoreEffect::WikiEditorError(Some( + "Select a wiki database before saving.".to_string(), + ))]; + }; + let auth = self.config.auth.clone(); + let use_mainnet = self.config.use_mainnet; + let path = state.wiki_editor.path.clone(); + let content = state.wiki_editor.draft_content.clone(); + let metadata_json = state.wiki_editor.metadata_json.clone(); + let expected_etag = state.wiki_editor.etag.clone(); + let database_id_for_task = database_id.clone(); + let path_for_task = path.clone(); + spawn_request_task( + &mut self.next_wiki_save_request_id, + &mut self.wiki_save_task, + move |request_id, tx| { + let result = save_wiki_node_content( + use_mainnet, + auth, + WikiSaveNodeInput { + wiki_id, + database_id: database_id_for_task.clone(), + path: path_for_task.clone(), + content, + metadata_json, + expected_etag, + }, + ); + let _ = tx.send(WikiSaveTaskOutput { + request_id, + database_id: database_id_for_task, + result, + }); + }, + ); + vec![ + CoreEffect::WikiEditorSaving, + CoreEffect::Notify(format!("Saving {path}...")), + ] + } + fn current_records(&self) -> Vec<&KinicRecord> { + if self.tab_id == KINIC_WIKI_TAB_ID { + if self.result_records.is_empty() { + return self.wiki_records.iter().collect(); + } + return self.result_records.iter().collect(); + } + if self.memories_mode == MemoriesMode::Browser && self.memory_records.is_empty() { return self.all.iter().collect(); } @@ -1105,6 +1682,653 @@ impl KinicProvider { base.iter().collect() } + fn build_wiki_three_pane_snapshot(&self, state: &CoreState) -> ThreePaneSnapshot { + if self.tab_id != KINIC_WIKI_TAB_ID { + return ThreePaneSnapshot::default(); + } + let mode = self.wiki_snapshot_mode(); + let selected_index = self.wiki_database_index_for_state(state, mode).unwrap_or(0); + let mut database_rows = self.wiki_database_rows(selected_index); + if mode == WikiViewMode::DatabaseList { + database_rows.push(PaneRow { + label: "+ Create database".to_string(), + detail: if self.wiki_create_database_task.in_flight { + "creating database".to_string() + } else { + "create new database".to_string() + }, + selected: self.is_wiki_create_database_action_selected(state), + }); + } + let document_lines = match mode { + WikiViewMode::DatabaseList => Vec::new(), + WikiViewMode::DatabaseBrowser => self.wiki_document_lines(selected_index), + WikiViewMode::Diagnostic => self.wiki_diagnostic_lines(), + }; + ThreePaneSnapshot { + left: PaneSnapshot { + title: "Databases".to_string(), + rows: database_rows, + empty_message: "No databases".to_string(), + loading: self.wiki_databases_task.in_flight, + }, + middle: PaneSnapshot { + title: self.wiki_current_path.clone(), + rows: if mode == WikiViewMode::DatabaseBrowser { + self.wiki_browser_rows(selected_index) + } else { + Vec::new() + }, + empty_message: "Select a database or run search.".to_string(), + loading: self.wiki_children_task.in_flight || self.wiki_search_task.in_flight, + }, + document: DocumentSnapshot { + title: self.wiki_document_title(mode, selected_index), + lines: document_lines, + }, + diagnostic: self.wiki_diagnostic(), + mode: self.wiki_three_pane_mode(mode), + } + } + + fn wiki_database_rows(&self, selected_index: usize) -> Vec { + let mut rows = Vec::new(); + let mut public_rows = Vec::new(); + let mut private_rows = Vec::new(); + for (index, record) in self.wiki_records.iter().enumerate() { + let row = self.wiki_database_row(record, index == selected_index); + if record + .source_wiki_database_id + .as_deref() + .is_some_and(|database_id| self.wiki_database_is_public(database_id)) + { + public_rows.push(row); + } else { + private_rows.push(row); + } + } + if !private_rows.is_empty() { + rows.push(wiki_database_section_row("Private / Shared")); + rows.extend(private_rows); + } + if !public_rows.is_empty() { + rows.push(wiki_database_section_row("Public")); + rows.extend(public_rows); + } + rows + } + + fn wiki_database_row(&self, record: &KinicRecord, selected: bool) -> PaneRow { + let database = self.wiki_databases.iter().find(|database| { + Some(database.database_id.as_str()) == record.source_wiki_database_id.as_deref() + }); + let status = database + .map(|database| format!("{:?}", database.status)) + .unwrap_or_else(|| "-".to_string()); + let role = database + .map(|database| format!("{:?}", database.role)) + .unwrap_or_else(|| "-".to_string()); + let visibility = database + .map(|database| { + wiki_database_visibility_label( + database, + self.wiki_database_anonymous_access + .get(database.database_id.as_str()), + ) + }) + .unwrap_or("-"); + let size = database + .map(|database| database.logical_size_bytes.to_string()) + .unwrap_or_else(|| "0".to_string()); + PaneRow { + label: record.title.clone(), + detail: format!("{status} {visibility} {role} {size} bytes"), + selected, + } + } + + fn wiki_database_is_public(&self, database_id: &str) -> bool { + self.wiki_database_anonymous_access + .contains_key(database_id) + } + + fn wiki_database_can_write(&self, database_id: &str) -> bool { + self.wiki_databases + .iter() + .find(|database| database.database_id == database_id) + .is_some_and(|database| { + matches!( + database.role, + bridge::DatabaseRole::Owner | bridge::DatabaseRole::Writer + ) + }) + } + + fn wiki_snapshot_mode(&self) -> WikiViewMode { + if self.wiki_load_error.is_some() { + WikiViewMode::Diagnostic + } else { + self.wiki_view_mode + } + } + + fn wiki_database_index_for_state( + &self, + state: &CoreState, + mode: WikiViewMode, + ) -> Option { + if mode == WikiViewMode::DatabaseBrowser + && let Some(database_id) = self.active_wiki_database_id.as_deref() + && let Some(index) = self + .wiki_records + .iter() + .position(|record| record.source_wiki_database_id.as_deref() == Some(database_id)) + { + return Some(index); + } + state.selected_index + } + + fn wiki_selected_database_index(&self, state: &CoreState) -> Option { + if self.is_wiki_create_database_action_selected(state) { + return None; + } + self.wiki_database_index_for_state(state, self.wiki_snapshot_mode()) + .filter(|index| *index < self.wiki_records.len()) + } + + fn active_wiki_database_index(&self) -> Option { + let database_id = self.active_wiki_database_id.as_deref()?; + self.wiki_records + .iter() + .position(|record| record.source_wiki_database_id.as_deref() == Some(database_id)) + } + + fn is_wiki_create_database_action_selected(&self, state: &CoreState) -> bool { + self.should_show_wiki_create_database_action(state) + && state.selected_index == Some(self.wiki_records.len()) + } + + fn should_show_wiki_create_database_action(&self, state: &CoreState) -> bool { + state.current_tab_id == KINIC_WIKI_TAB_ID + && self.tab_id == KINIC_WIKI_TAB_ID + && self.wiki_snapshot_mode() == WikiViewMode::DatabaseList + } + + fn wiki_three_pane_mode(&self, mode: WikiViewMode) -> ThreePaneMode { + match mode { + WikiViewMode::DatabaseList => ThreePaneMode::List, + WikiViewMode::DatabaseBrowser if self.result_records.is_empty() => { + ThreePaneMode::Browse + } + WikiViewMode::DatabaseBrowser => ThreePaneMode::Search, + WikiViewMode::Diagnostic => ThreePaneMode::Diagnostic, + } + } + + fn wiki_document_title(&self, mode: WikiViewMode, _selected_index: usize) -> String { + match mode { + WikiViewMode::DatabaseList => "Databases".to_string(), + WikiViewMode::Diagnostic => "Diagnostics".to_string(), + WikiViewMode::DatabaseBrowser if self.result_records.is_empty() => self + .wiki_preview_path + .as_deref() + .map(wiki_display_name) + .unwrap_or_default(), + WikiViewMode::DatabaseBrowser => self + .result_records + .get(self.selected_wiki_browser_index) + .map(|record| wiki_display_name(record.title.as_str())) + .unwrap_or_else(|| "Wiki search".to_string()), + } + } + + fn wiki_browser_rows(&self, selected_index: usize) -> Vec { + if !self.result_records.is_empty() { + return self + .result_records + .iter() + .enumerate() + .map(|(index, record)| PaneRow { + label: record.title.clone(), + detail: record.summary.clone(), + selected: index == self.selected_wiki_browser_index, + }) + .collect(); + } + let Some(record) = self.wiki_records.get(selected_index) else { + return Vec::new(); + }; + let Some(database_id) = record.source_wiki_database_id.as_deref() else { + return Vec::new(); + }; + let entries = self.wiki_visible_browser_entries(database_id); + if !entries.is_empty() { + return entries + .into_iter() + .enumerate() + .map(|(index, visible)| PaneRow { + label: wiki_tree_row_label(&visible.entry, visible.depth), + detail: wiki_tree_row_detail(&visible.entry, visible.expanded), + selected: index == self.selected_wiki_browser_index, + }) + .collect(); + } + if self.pending_wiki_children_database_id.as_deref() == Some(database_id) { + return vec![PaneRow { + label: "Loading /Wiki and /Sources...".to_string(), + detail: database_id.to_string(), + selected: false, + }]; + } + vec![ + PaneRow { + label: "Wiki".to_string(), + detail: "directory".to_string(), + selected: false, + }, + PaneRow { + label: "Sources".to_string(), + detail: "directory".to_string(), + selected: false, + }, + ] + } + + fn wiki_document_lines(&self, selected_index: usize) -> Vec { + if !self.result_records.is_empty() + && let Some(record) = self.result_records.get(self.selected_wiki_browser_index) + && let Some(database_id) = record.source_wiki_database_id.as_deref() + { + if let Some(content) = + self.wiki_cached_children_content(database_id, record.title.as_str()) + { + return wiki_document_content_lines(record.title.as_str(), content); + } + if self.pending_wiki_children_database_id.as_deref() == Some(database_id) + && self.pending_wiki_children_path.as_deref() == Some(record.title.as_str()) + { + return vec![wiki_loading_line(record.title.as_str())]; + } + } + if self.result_records.is_empty() + && let Some(document_path) = self.wiki_preview_path.as_deref() + && let Some(database_id) = self + .wiki_records + .get(selected_index) + .and_then(|record| record.source_wiki_database_id.as_deref()) + { + if let Some(content) = self.wiki_cached_children_content(database_id, document_path) { + return wiki_document_content_lines(document_path, content); + } + if self.pending_wiki_children_database_id.as_deref() == Some(database_id) + && self.pending_wiki_children_path.as_deref() == Some(document_path) + { + return vec![wiki_loading_line(document_path)]; + } + return vec![wiki_loading_line(document_path)]; + } + Vec::new() + } + + fn wiki_cached_children_content( + &self, + database_id: &str, + path: &str, + ) -> Option<&WikiChildrenContent> { + self.wiki_children_cache + .get(wiki_children_cache_key(database_id, path).as_str()) + .or_else(|| { + if path == "/" { + self.wiki_children_cache.get(database_id) + } else { + None + } + }) + } + + fn wiki_visible_browser_entries(&self, database_id: &str) -> Vec { + let mut visible = Vec::new(); + let root_entries = self + .wiki_cached_children_content(database_id, "/") + .map(|content| content.entries.clone()) + .unwrap_or_else(|| { + self.wiki_cached_children_content(database_id, self.wiki_current_path.as_str()) + .map(|content| content.entries.clone()) + .unwrap_or_default() + }); + + for entry in root_entries { + self.push_wiki_visible_entry(database_id, entry, 0, &mut visible); + } + visible + } + + fn push_wiki_visible_entry( + &self, + database_id: &str, + entry: WikiBrowserEntry, + depth: usize, + visible: &mut Vec, + ) { + let expanded = entry.kind == WikiBrowserEntryKind::Directory + && self.wiki_expanded_paths.contains(entry.path.as_str()); + visible.push(WikiVisibleEntry { + entry: entry.clone(), + depth, + expanded, + }); + if !expanded { + return; + } + let Some(content) = self.wiki_cached_children_content(database_id, entry.path.as_str()) + else { + return; + }; + for child in &content.entries { + self.push_wiki_visible_entry(database_id, child.clone(), depth + 1, visible); + } + } + + fn wiki_diagnostic(&self) -> Option { + let error = self.wiki_load_error.as_ref()?; + let principal = match self.session_overview.session.principal_id.trim() { + "" | "unavailable" => None, + value => Some(value.to_string()), + }; + let mut rows = vec![ + PaneRow { + label: "canister".to_string(), + detail: self + .config + .wiki_canister_id + .clone() + .unwrap_or_else(|| "not configured".to_string()), + selected: false, + }, + PaneRow { + label: "network".to_string(), + detail: self.network_label().to_string(), + selected: false, + }, + PaneRow { + label: "auth".to_string(), + detail: self.config.auth.identity_label().to_string(), + selected: false, + }, + ]; + if let Some(principal) = principal { + rows.push(PaneRow { + label: "principal".to_string(), + detail: principal, + selected: false, + }); + } + Some(DiagnosticSnapshot { + rows, + message: short_error(error.as_str()), + }) + } + + fn wiki_diagnostic_lines(&self) -> Vec { + let Some(diagnostic) = self.wiki_diagnostic() else { + return Vec::new(); + }; + let mut lines = diagnostic + .rows + .into_iter() + .map(|row| format!("{}: {}", row.label, row.detail)) + .collect::>(); + lines.push(String::new()); + lines.push(diagnostic.message); + lines + } + + fn enter_selected_wiki_database(&mut self, state: &CoreState) -> Vec { + let Some(selected_index) = self.wiki_selected_database_index(state) else { + return Vec::new(); + }; + self.result_records.clear(); + self.active_wiki_database_id = self + .wiki_records + .get(selected_index) + .and_then(|record| record.source_wiki_database_id.clone()); + self.wiki_view_mode = WikiViewMode::DatabaseBrowser; + self.wiki_current_path = "/".to_string(); + self.wiki_expanded_paths.clear(); + self.selected_wiki_browser_index = 0; + self.start_selected_wiki_children_load(state); + vec![CoreEffect::FocusPane(PaneFocus::Items)] + } + + fn navigate_wiki_browser(&mut self, state: &CoreState, action: &CoreAction) { + let len = if self.result_records.is_empty() { + self.current_wiki_entries(state).len() + } else { + self.result_records.len() + }; + if len == 0 { + self.selected_wiki_browser_index = 0; + return; + } + let last = len.saturating_sub(1); + self.selected_wiki_browser_index = match action { + CoreAction::MoveNext => (self.selected_wiki_browser_index + 1).min(last), + CoreAction::MovePrev => self.selected_wiki_browser_index.saturating_sub(1), + CoreAction::MoveHome => 0, + CoreAction::MoveEnd => last, + CoreAction::MovePageDown => (self.selected_wiki_browser_index + 10).min(last), + CoreAction::MovePageUp => self.selected_wiki_browser_index.saturating_sub(10), + _ => self.selected_wiki_browser_index.min(last), + }; + } + + fn current_wiki_entries(&self, state: &CoreState) -> Vec { + let selected_index = self + .wiki_database_index_for_state(state, self.wiki_snapshot_mode()) + .unwrap_or(0); + let Some(database_id) = self + .wiki_records + .get(selected_index) + .and_then(|record| record.source_wiki_database_id.as_deref()) + else { + return Vec::new(); + }; + self.wiki_visible_browser_entries(database_id) + .into_iter() + .map(|visible| visible.entry) + .collect() + } + + fn select_wiki_browser_path(&mut self, state: &CoreState, path: &str) { + if let Some(index) = self + .current_wiki_entries(state) + .iter() + .position(|entry| entry.path == path) + { + self.selected_wiki_browser_index = index; + } + } + + fn selected_wiki_browser_entry(&self, state: &CoreState) -> Option { + if !self.result_records.is_empty() { + return None; + } + self.current_wiki_entries(state) + .get(self.selected_wiki_browser_index) + .cloned() + } + + fn open_wiki_editor(&mut self, state: &CoreState) -> Vec { + if self.wiki_save_task.in_flight { + return vec![CoreEffect::Notify("Wiki save already running.".to_string())]; + } + if !self.result_records.is_empty() { + return vec![CoreEffect::Notify( + "Open the node in the browser before editing.".to_string(), + )]; + } + let Some((_wiki_id, database_id)) = self.selected_wiki_target(state) else { + return vec![CoreEffect::Notify( + "Select a wiki database first.".to_string(), + )]; + }; + if !self.wiki_database_can_write(database_id.as_str()) { + return vec![CoreEffect::Notify( + "Selected wiki database is read-only.".to_string(), + )]; + } + let Some(entry) = self.selected_wiki_browser_entry(state) else { + self.start_selected_wiki_children_load(state); + return vec![CoreEffect::Notify( + "Load a wiki file before editing.".to_string(), + )]; + }; + if !wiki_entry_is_editable_markdown(&entry) { + return vec![CoreEffect::Notify( + "Only /Wiki/*.md files are editable.".to_string(), + )]; + } + let Some(content) = self.wiki_cached_children_content(database_id.as_str(), &entry.path) + else { + self.start_wiki_node_load(state, entry.path.clone()); + return vec![CoreEffect::Notify(format!( + "Loading wiki node {}", + entry.path + ))]; + }; + let Some(etag) = content.node_etag.clone() else { + self.start_wiki_node_load(state, entry.path.clone()); + return vec![CoreEffect::Notify(format!( + "Loading wiki node {}", + entry.path + ))]; + }; + vec![ + CoreEffect::OpenWikiEditor { + path: entry.path.clone(), + content: content.node_content.clone().unwrap_or_default(), + etag, + metadata_json: content + .node_metadata_json + .clone() + .unwrap_or_else(|| "{}".to_string()), + }, + CoreEffect::ResetContentScroll, + CoreEffect::Notify(format!("Editing {}", entry.path)), + ] + } + + fn open_selected_wiki_browser_entry(&mut self, state: &CoreState) -> Vec { + if !self.result_records.is_empty() { + let Some(record) = self.result_records.get(self.selected_wiki_browser_index) else { + return vec![CoreEffect::Notify( + "Select a wiki search result.".to_string(), + )]; + }; + let title = record.title.clone(); + if let Some(database_id) = record.source_wiki_database_id.clone() { + self.wiki_current_path = title.clone(); + let cached = self + .wiki_cached_children_content( + database_id.as_str(), + self.wiki_current_path.as_str(), + ) + .is_some(); + self.refresh_wiki_node_load(state, self.wiki_current_path.clone()); + return vec![CoreEffect::Notify(if cached { + format!("Opened {title}") + } else { + format!("Loading wiki node {title}") + })]; + } + return Vec::new(); + } + + let entries = self.current_wiki_entries(state); + let Some(entry) = entries.get(self.selected_wiki_browser_index) else { + self.start_selected_wiki_children_load(state); + return Vec::new(); + }; + match entry.kind { + WikiBrowserEntryKind::Directory => { + let entry_path = entry.path.clone(); + let closed = if self.wiki_expanded_paths.contains(entry.path.as_str()) { + self.wiki_expanded_paths + .retain(|path| !wiki_path_contains(entry.path.as_str(), path.as_str())); + if wiki_path_contains(entry.path.as_str(), self.wiki_current_path.as_str()) { + self.wiki_current_path = wiki_parent_path(entry.path.as_str()); + } + true + } else { + self.wiki_expanded_paths.insert(entry.path.clone()); + self.wiki_current_path = entry.path.clone(); + false + }; + if closed { + self.start_selected_wiki_children_load(state); + } else { + self.start_wiki_directory_children_load(state, self.wiki_current_path.clone()); + } + self.select_wiki_browser_path(state, entry_path.as_str()); + vec![CoreEffect::Notify(if closed { + format!("Closed {}", entry.path) + } else { + format!("Opened {}", entry.path) + })] + } + WikiBrowserEntryKind::File | WikiBrowserEntryKind::Source => { + self.wiki_preview_path = Some(entry.path.clone()); + let cached = self + .wiki_records + .get( + self.wiki_database_index_for_state(state, self.wiki_snapshot_mode()) + .unwrap_or(0), + ) + .and_then(|record| record.source_wiki_database_id.as_deref()) + .and_then(|database_id| { + self.wiki_cached_children_content(database_id, entry.path.as_str()) + }) + .is_some(); + self.refresh_wiki_node_load(state, entry.path.clone()); + vec![CoreEffect::Notify(if cached { + format!("Opened {}", entry.path) + } else { + format!("Loading wiki node {}", entry.path) + })] + } + } + } + + fn back_wiki_browser(&mut self) -> Vec { + if self.wiki_view_mode == WikiViewMode::DatabaseList { + return vec![CoreEffect::FocusPane(PaneFocus::Tabs)]; + } + if !self.result_records.is_empty() { + self.result_records.clear(); + self.selected_wiki_browser_index = 0; + self.wiki_view_mode = WikiViewMode::DatabaseBrowser; + return vec![CoreEffect::Notify( + "Closed wiki search results.".to_string(), + )]; + } + if self.wiki_current_path == "/" { + self.wiki_view_mode = WikiViewMode::DatabaseList; + self.wiki_preview_path = None; + let mut effects = vec![CoreEffect::FocusPane(PaneFocus::Items)]; + if let Some(index) = self.active_wiki_database_index() { + effects.push(CoreEffect::SelectListItem(index)); + } + return effects; + } + self.wiki_current_path = wiki_parent_path(self.wiki_current_path.as_str()); + self.selected_wiki_browser_index = 0; + vec![CoreEffect::Notify(format!( + "Opened {}", + self.wiki_current_path + ))] + } + fn visible_memory_records(&self) -> Vec<&KinicRecord> { if self.memories_mode != MemoriesMode::Browser { return Vec::new(); @@ -1714,10 +2938,157 @@ impl KinicProvider { let mut content = adapter::to_content(record, summary_text.as_deref()); if record.group == "memories" { self.apply_access_content(&mut content, state); + } else if record.group == "wiki" && self.tab_id == KINIC_WIKI_TAB_ID { + self.apply_wiki_children_content(&mut content, record); } content } + fn apply_wiki_children_content( + &self, + content: &mut tui_kit_model::UiItemContent, + record: &KinicRecord, + ) { + let Some(database_id) = record.source_wiki_database_id.as_ref() else { + return; + }; + let (body_lines, index_preview) = match self.wiki_cached_children_content(database_id, "/") + { + Some(content) => (content.body_lines.clone(), content.index_preview.clone()), + None if self.pending_wiki_children_database_id.as_deref() + == Some(database_id.as_str()) => + { + (vec!["Loading /Wiki children...".to_string()], None) + } + None => ( + vec!["Select or refresh this database to load /Wiki children.".to_string()], + None, + ), + }; + content.sections.push(tui_kit_model::UiSection { + heading: "/Wiki".to_string(), + rows: vec![], + body_lines, + }); + if let Some(body_lines) = index_preview { + content.sections.push(tui_kit_model::UiSection { + heading: "/Wiki/index.md".to_string(), + rows: vec![], + body_lines, + }); + } + } + + fn start_selected_wiki_children_load(&mut self, state: &CoreState) { + self.start_wiki_directory_children_load(state, self.wiki_current_path.clone()); + } + + fn start_wiki_node_load(&mut self, state: &CoreState, path: String) { + self.start_wiki_children_load_with_cache_policy( + state, + path, + false, + WikiChildrenLoadMode::Node, + ); + } + + fn start_wiki_directory_children_load(&mut self, state: &CoreState, path: String) { + self.start_wiki_children_load_with_cache_policy( + state, + path, + false, + WikiChildrenLoadMode::Directory, + ); + } + + fn refresh_wiki_node_load(&mut self, state: &CoreState, path: String) { + self.start_wiki_children_load_with_cache_policy( + state, + path, + true, + WikiChildrenLoadMode::Node, + ); + } + + fn refresh_wiki_directory_children_load(&mut self, state: &CoreState, path: String) { + self.start_wiki_children_load_with_cache_policy( + state, + path, + true, + WikiChildrenLoadMode::Directory, + ); + } + + fn start_wiki_children_load_with_cache_policy( + &mut self, + state: &CoreState, + path: String, + refresh_cached: bool, + mode: WikiChildrenLoadMode, + ) { + if self.tab_id != KINIC_WIKI_TAB_ID { + return; + } + let Some((wiki_id, database_id)) = self.selected_wiki_target(state) else { + return; + }; + let cache_key = wiki_children_cache_key(database_id.as_str(), path.as_str()); + let already_pending = self.pending_wiki_children_database_id.as_deref() + == Some(database_id.as_str()) + && self.pending_wiki_children_path.as_deref() == Some(path.as_str()); + if already_pending + || (!refresh_cached && self.wiki_children_cache.contains_key(cache_key.as_str())) + { + return; + } + + let auth = self.config.auth.clone(); + let read_as_anonymous = self.wiki_database_is_public(database_id.as_str()); + let use_mainnet = self.config.use_mainnet; + self.pending_wiki_children_database_id = Some(database_id.clone()); + self.pending_wiki_children_path = Some(path.clone()); + spawn_request_task( + &mut self.next_wiki_children_request_id, + &mut self.wiki_children_task, + move |request_id, tx| { + let requested_database_id = database_id.clone(); + let requested_path = path.clone(); + let result = load_wiki_children_content( + use_mainnet, + auth, + read_as_anonymous, + wiki_id, + database_id, + path, + mode, + ); + let _ = tx.send(WikiChildrenTaskOutput { + request_id, + database_id: requested_database_id, + path: requested_path, + result, + }); + }, + ); + } + + fn apply_saved_wiki_node(&mut self, database_id: &str, node: &bridge::WikiNode) { + self.wiki_children_cache.insert( + wiki_children_cache_key(database_id, node.path.as_str()), + WikiChildrenContent { + entries: Vec::new(), + body_lines: Vec::new(), + index_preview: Some(node.content.lines().map(str::to_string).collect()), + node_content: Some(node.content.clone()), + node_etag: Some(node.etag.clone()), + node_metadata_json: Some(node.metadata_json.clone()), + }, + ); + let parent = wiki_parent_path(node.path.as_str()); + self.wiki_children_cache + .remove(wiki_children_cache_key(database_id, parent.as_str()).as_str()); + } + fn active_rename_target( &self, state: &CoreState, @@ -1909,6 +3280,115 @@ impl KinicProvider { self.all = self.memory_records.clone(); } + fn refresh_wiki_records_from_databases(&mut self) { + let Some(wiki_canister_id) = self.config.wiki_canister_id.as_deref() else { + self.wiki_records = vec![wiki_not_configured_record()]; + self.wiki_children_cache.clear(); + return; + }; + let mut databases = self.wiki_databases.clone(); + databases.sort_by_key(|database| { + ( + database.status != bridge::DatabaseStatus::Hot, + database.database_id.clone(), + ) + }); + let (mut private_databases, public_databases): ( + Vec, + Vec, + ) = databases.into_iter().partition(|database| { + !self + .wiki_database_anonymous_access + .contains_key(database.database_id.as_str()) + }); + private_databases.extend(public_databases); + self.wiki_records = private_databases + .into_iter() + .map(|database| record_from_wiki_database(wiki_canister_id, database)) + .collect(); + let database_ids = self + .wiki_records + .iter() + .filter_map(|record| record.source_wiki_database_id.clone()) + .collect::>(); + self.wiki_database_anonymous_access + .retain(|database_id, _| database_ids.contains(database_id)); + self.wiki_children_cache.retain(|cache_key, _| { + cache_key + .split_once('\n') + .map(|(database_id, _)| database_ids.contains(database_id)) + .unwrap_or_else(|| database_ids.contains(cache_key)) + }); + } + + fn clear_wiki_cache_for_visibility_changes(&mut self, previous_public_ids: &HashSet) { + let current_public_ids = self + .wiki_database_anonymous_access + .keys() + .cloned() + .collect::>(); + let changed_database_ids = previous_public_ids + .symmetric_difference(¤t_public_ids) + .cloned() + .collect::>(); + if changed_database_ids.is_empty() { + return; + } + self.wiki_children_cache.retain(|cache_key, _| { + cache_key + .split_once('\n') + .map(|(database_id, _)| !changed_database_ids.contains(database_id)) + .unwrap_or_else(|| !changed_database_ids.contains(cache_key)) + }); + } + + fn restore_wiki_reload_state(&mut self, reload_state: Option) { + let Some(reload_state) = reload_state else { + if self.active_wiki_database_id.is_none() { + self.wiki_view_mode = WikiViewMode::DatabaseList; + } + return; + }; + let Some(database_id) = reload_state.database_id else { + self.active_wiki_database_id = None; + self.wiki_view_mode = WikiViewMode::DatabaseList; + self.wiki_current_path = "/".to_string(); + self.wiki_preview_path = None; + self.wiki_expanded_paths.clear(); + self.selected_wiki_browser_index = 0; + self.result_records.clear(); + return; + }; + let database_still_exists = self + .wiki_records + .iter() + .any(|record| record.source_wiki_database_id.as_deref() == Some(database_id.as_str())); + if !database_still_exists { + self.active_wiki_database_id = None; + self.wiki_view_mode = WikiViewMode::DatabaseList; + self.wiki_current_path = "/".to_string(); + self.wiki_preview_path = None; + self.wiki_expanded_paths.clear(); + self.selected_wiki_browser_index = 0; + self.result_records.clear(); + return; + } + self.active_wiki_database_id = Some(database_id); + self.wiki_view_mode = reload_state.view_mode; + self.wiki_current_path = reload_state.current_path; + self.wiki_preview_path = reload_state.preview_path; + self.wiki_expanded_paths = reload_state.expanded_paths; + self.selected_wiki_browser_index = reload_state.browser_index; + if !reload_state.had_search_results { + self.result_records.clear(); + } + } + + fn apply_instance_summaries(&mut self, instances: bridge::InstanceSummaries) { + self.memory_summaries = instances.memories; + self.refresh_memory_records_from_summaries(); + } + fn normalize_memory_summaries(&mut self) { let mut seen_ids = HashSet::new(); self.memory_summaries @@ -2288,8 +3768,15 @@ impl KinicProvider { if self.should_show_add_memory_action(state) { items.push(adapter::to_summary(&add_memory_action_record())); } + if self.should_show_wiki_create_database_action(state) { + items.push(adapter::to_summary(&wiki_create_database_action_record())); + } let selected_content = if state.current_tab_id == KINIC_SETTINGS_TAB_ID { None + } else if state.current_tab_id == KINIC_WIKI_TAB_ID { + self.wiki_selected_database_index(state) + .and_then(|index| filtered.get(index)) + .map(|record| self.selected_content_for_record(record, state)) } else if self.memories_mode == MemoriesMode::Browser { if self.active_memory.is_none() && self.memory_records.is_empty() { filtered @@ -2308,6 +3795,15 @@ impl KinicProvider { }; let selected_index = if state.current_tab_id == KINIC_SETTINGS_TAB_ID { None + } else if state.current_tab_id == KINIC_WIKI_TAB_ID { + let current = state.selected_index.unwrap_or(0); + let max_index = if self.wiki_snapshot_mode() == WikiViewMode::DatabaseList { + filtered.len() + } else { + filtered.len().saturating_sub(1) + }; + (self.wiki_snapshot_mode() == WikiViewMode::DatabaseList || !filtered.is_empty()) + .then_some(current.min(max_index)) } else if self.is_add_memory_action_selected(state) { Some(filtered.len()) } else if self.memories_mode == MemoriesMode::Browser { @@ -2325,6 +3821,7 @@ impl KinicProvider { selected_index, selected_content, selected_context: None, + three_pane: self.build_wiki_three_pane_snapshot(state), total_count: filtered.len(), status_message: Some(self.status_message(state, filtered.len())), selected_memory: self.active_memory.clone(), @@ -2647,8 +4144,12 @@ impl KinicProvider { move |request_id, tx| { let runtime = Runtime::new().expect("failed to create tokio runtime for create submit"); - let result = - runtime.block_on(bridge::create_memory(use_mainnet, auth, name, description)); + let result = runtime.block_on(bridge::create_instance( + use_mainnet, + auth, + name, + description, + )); let _ = tx.send(CreateSubmitTaskOutput { request_id, result }); }, ); @@ -2656,6 +4157,73 @@ impl KinicProvider { CoreEffect::Notify("Creating memory...".to_string()) } + fn run_wiki_search(&mut self, state: &CoreState) -> CoreEffect { + let query = state.query.trim(); + if query.is_empty() { + self.result_records.clear(); + return CoreEffect::Notify("Enter a wiki search query.".to_string()); + } + let Some((wiki_id, database_id)) = self.selected_wiki_target(state) else { + return CoreEffect::Notify("Select a wiki database before searching.".to_string()); + }; + if self.wiki_search_task.in_flight { + return CoreEffect::Notify("Wiki search request already running.".to_string()); + } + let auth = self.config.auth.clone(); + let read_as_anonymous = self.wiki_database_is_public(database_id.as_str()); + let use_mainnet = self.config.use_mainnet; + let query = query.to_string(); + self.selected_wiki_browser_index = 0; + self.wiki_view_mode = WikiViewMode::DatabaseBrowser; + spawn_request_task( + &mut self.next_wiki_search_request_id, + &mut self.wiki_search_task, + move |request_id, tx| { + let result = + Runtime::new() + .map_err(|error| error.to_string()) + .and_then(|runtime| { + runtime + .block_on(bridge::search_wiki_nodes( + use_mainnet, + auth, + read_as_anonymous, + wiki_id.clone(), + database_id.clone(), + query, + )) + .map_err(|error| error.to_string()) + }); + let _ = tx.send(WikiSearchTaskOutput { + request_id, + wiki_id, + database_id, + result, + }); + }, + ); + CoreEffect::Notify("Searching wiki nodes...".to_string()) + } + + fn selected_wiki_target(&self, state: &CoreState) -> Option<(String, String)> { + if !self.result_records.is_empty() { + let record = self + .result_records + .get(self.selected_wiki_browser_index) + .or_else(|| self.result_records.first())?; + return Some(( + record.source_wiki_id.clone()?, + record.source_wiki_database_id.clone()?, + )); + } + let index = self.wiki_selected_database_index(state)?; + let record = self.wiki_records.get(index)?; + Some(( + record.source_wiki_id.clone()?, + record.source_wiki_database_id.clone()?, + )) + } + fn start_insert_submit(&mut self, request: InsertRequest) -> CoreEffect { let auth = self.config.auth.clone(); let use_mainnet = self.config.use_mainnet; @@ -3009,8 +4577,11 @@ impl KinicProvider { if self.tab_id == KINIC_SETTINGS_TAB_ID { return "Review session details and default memory settings here.".to_string(); } - if self.tab_id == KINIC_MARKET_TAB_ID { - return "Market is not implemented yet.".to_string(); + if self.tab_id == KINIC_WIKI_TAB_ID { + if self.wiki_snapshot_mode() == WikiViewMode::DatabaseList { + return "Enter: open/create wiki database.".to_string(); + } + return "Browse wiki databases and search wiki nodes.".to_string(); } let base = match self.memories_mode { MemoriesMode::Browser => self.browser_status_message(state), @@ -3841,10 +5412,9 @@ impl KinicProvider { self.pending_initial_memories = None; self.initial_memories_in_flight = false; match output.result { - Ok(memories) => { + Ok(instances) => { let previous_active_memory = self.active_memory.clone(); - self.memory_summaries = memories; - self.refresh_memory_records_from_summaries(); + self.apply_instance_summaries(instances); let initial_memory_id = self .preferred_memory_after_refresh .take() @@ -3892,6 +5462,9 @@ impl KinicProvider { Err(error) => { let previous_active_memory = self.active_memory.clone(); self.memory_records.clear(); + self.wiki_records.clear(); + self.wiki_databases.clear(); + self.wiki_database_anonymous_access.clear(); self.result_records.clear(); self.memories_mode = MemoriesMode::Browser; let notify_message = format_live_load_failure_message(&error); @@ -4115,11 +5688,10 @@ impl KinicProvider { let mut effects = Vec::new(); match output.result { Ok(success) => { - if let Some(memories) = success.memories { - self.memory_summaries = memories; + if let Some(instances) = success.instances { + self.apply_instance_summaries(instances); self.loaded_memory_details.clear(); self.memory_detail_prefetch.reset(); - self.refresh_memory_records_from_summaries(); if let Some(index) = self.memory_records.iter().position(|r| r.id == success.id) { let record = self.memory_records.remove(index); @@ -4135,6 +5707,7 @@ impl KinicProvider { } } } + let target_tab = KINIC_MEMORIES_TAB_ID; self.set_active_memory_by_id(success.id.clone()); self.memories_mode = MemoriesMode::Browser; self.result_records.clear(); @@ -4144,10 +5717,10 @@ impl KinicProvider { self.start_active_memory_detail_load(); self.start_selected_memory_summary_load(false); let _ = self.start_create_cost_refresh(); - effects.extend(self.set_tab(KINIC_MEMORIES_TAB_ID)); + effects.extend(self.set_tab(target_tab)); effects.push(CoreEffect::SelectFirstListItem); effects.push(CoreEffect::ResetCreateFormAndSetTab { - tab_id: KINIC_MEMORIES_TAB_ID.to_string(), + tab_id: target_tab.to_string(), }); effects.push(CoreEffect::FocusPane(PaneFocus::Items)); let status = if let Some(warning) = success.refresh_warning { @@ -4202,6 +5775,256 @@ impl KinicProvider { Some(self.snapshot_output(state, effects)) } + fn poll_wiki_children_background(&mut self, state: &CoreState) -> Option { + let receiver = self.wiki_children_task.receiver.as_ref()?; + let output = match poll_pending_task(receiver) { + PendingTaskPoll::Pending => return None, + PendingTaskPoll::Ready(output) => output, + PendingTaskPoll::Disconnected => { + reset_request_task(&mut self.wiki_children_task); + self.pending_wiki_children_database_id = None; + self.pending_wiki_children_path = None; + return Some(self.disconnected_request_output( + state, + CoreEffect::Notify("Wiki browser load failed unexpectedly.".to_string()), + )); + } + }; + + let is_current = finish_request_task(&mut self.wiki_children_task, output.request_id); + self.pending_wiki_children_database_id = None; + self.pending_wiki_children_path = None; + if !is_current { + return Some(self.stale_request_output(state)); + } + + let effects = match output.result { + Ok(content) => { + self.wiki_children_cache.insert( + wiki_children_cache_key(output.database_id.as_str(), output.path.as_str()), + content, + ); + Vec::new() + } + Err(error) => vec![CoreEffect::Notify(format!( + "Wiki browser load failed: {}", + short_error(error.as_str()) + ))], + }; + + Some(self.snapshot_output(state, effects)) + } + + fn poll_wiki_databases_background(&mut self, state: &CoreState) -> Option { + let receiver = self.wiki_databases_task.receiver.as_ref()?; + let output = match poll_pending_task(receiver) { + PendingTaskPoll::Pending => return None, + PendingTaskPoll::Ready(output) => output, + PendingTaskPoll::Disconnected => { + reset_request_task(&mut self.wiki_databases_task); + self.pending_wiki_reload = None; + self.wiki_load_error = Some("Wiki databases load failed unexpectedly.".to_string()); + self.wiki_view_mode = WikiViewMode::Diagnostic; + return Some(self.disconnected_request_output( + state, + CoreEffect::Notify("Wiki databases load failed unexpectedly.".to_string()), + )); + } + }; + + let is_current = finish_request_task(&mut self.wiki_databases_task, output.request_id); + if !is_current { + return Some(self.stale_request_output(state)); + } + + let effects = match output.result { + Ok(databases) => { + let reload_state = self.pending_wiki_reload.take(); + self.wiki_load_error = None; + let previous_public_ids = self + .wiki_database_anonymous_access + .keys() + .cloned() + .collect::>(); + self.wiki_database_anonymous_access = databases.anonymous_access; + self.wiki_databases = databases.databases; + self.clear_wiki_cache_for_visibility_changes(&previous_public_ids); + self.refresh_wiki_records_from_databases(); + self.restore_wiki_reload_state(reload_state); + if self.wiki_view_mode == WikiViewMode::DatabaseBrowser { + if let Some(path) = self.wiki_preview_path.clone() { + self.refresh_wiki_node_load(state, path); + } else { + self.refresh_wiki_directory_children_load( + state, + self.wiki_current_path.clone(), + ); + } + } + if self.wiki_records.is_empty() { + vec![CoreEffect::Notify("No wiki databases found.".to_string())] + } else { + let selection_effect = self + .active_wiki_database_index() + .map(CoreEffect::SelectListItem) + .unwrap_or(CoreEffect::SelectFirstListItem); + vec![ + selection_effect, + CoreEffect::Notify(format!( + "Loaded {} wiki databases.", + self.wiki_records.len() + )), + ] + } + } + Err(error) => { + self.pending_wiki_reload = None; + self.wiki_load_error = Some(error.clone()); + self.wiki_view_mode = WikiViewMode::Diagnostic; + vec![CoreEffect::Notify(format!( + "Wiki databases load failed: {}", + short_error(error.as_str()) + ))] + } + }; + + Some(self.snapshot_output(state, effects)) + } + + fn poll_wiki_create_database_background( + &mut self, + state: &CoreState, + ) -> Option { + let receiver = self.wiki_create_database_task.receiver.as_ref()?; + let output = match poll_pending_task(receiver) { + PendingTaskPoll::Pending => return None, + PendingTaskPoll::Ready(output) => output, + PendingTaskPoll::Disconnected => { + reset_request_task(&mut self.wiki_create_database_task); + return Some(self.disconnected_request_output( + state, + CoreEffect::Notify("Wiki database create failed unexpectedly.".to_string()), + )); + } + }; + + let is_current = + finish_request_task(&mut self.wiki_create_database_task, output.request_id); + if !is_current { + return Some(self.stale_request_output(state)); + } + + let effects = match output.result { + Ok(database_id) => { + self.active_wiki_database_id = Some(database_id.clone()); + self.wiki_view_mode = WikiViewMode::DatabaseList; + self.wiki_current_path = "/".to_string(); + self.wiki_preview_path = None; + self.wiki_expanded_paths.clear(); + self.selected_wiki_browser_index = 0; + vec![ + self.start_wiki_databases_load(false), + CoreEffect::Notify(format!("Created wiki database {database_id}.")), + ] + } + Err(error) => vec![CoreEffect::Notify(format!( + "Wiki database create failed: {}", + short_error(error.as_str()) + ))], + }; + + Some(self.snapshot_output(state, effects)) + } + + fn poll_wiki_save_background(&mut self, state: &CoreState) -> Option { + let receiver = self.wiki_save_task.receiver.as_ref()?; + let output = match poll_pending_task(receiver) { + PendingTaskPoll::Pending => return None, + PendingTaskPoll::Ready(output) => output, + PendingTaskPoll::Disconnected => { + reset_request_task(&mut self.wiki_save_task); + return Some(self.disconnected_request_output( + state, + CoreEffect::WikiEditorError(Some("Wiki save failed unexpectedly.".to_string())), + )); + } + }; + + let is_current = finish_request_task(&mut self.wiki_save_task, output.request_id); + if !is_current { + return Some(self.stale_request_output(state)); + } + + let effects = match output.result { + Ok(node) => { + self.apply_saved_wiki_node(output.database_id.as_str(), &node); + vec![ + CoreEffect::WikiEditorSaved { + path: node.path.clone(), + content: node.content.clone(), + etag: node.etag.clone(), + metadata_json: node.metadata_json.clone(), + }, + CoreEffect::ResetContentScroll, + CoreEffect::Notify(format!("Saved {}", node.path)), + ] + } + Err(error) => vec![CoreEffect::WikiEditorError(Some(format!( + "Wiki save failed: {}", + short_error(error.as_str()) + )))], + }; + + Some(self.snapshot_output(state, effects)) + } + + fn poll_wiki_search_background(&mut self, state: &CoreState) -> Option { + let receiver = self.wiki_search_task.receiver.as_ref()?; + let output = match poll_pending_task(receiver) { + PendingTaskPoll::Pending => return None, + PendingTaskPoll::Ready(output) => output, + PendingTaskPoll::Disconnected => { + reset_request_task(&mut self.wiki_search_task); + return Some(self.disconnected_request_output( + state, + CoreEffect::Notify("Wiki search failed unexpectedly.".to_string()), + )); + } + }; + + let is_current = finish_request_task(&mut self.wiki_search_task, output.request_id); + if !is_current { + return Some(self.stale_request_output(state)); + } + + let effects = match output.result { + Ok(hits) => { + self.result_records = hits + .into_iter() + .enumerate() + .map(|(index, hit)| { + record_from_wiki_search_hit( + &output.wiki_id, + &output.database_id, + index, + hit, + ) + }) + .collect(); + vec![CoreEffect::Notify(format!( + "Loaded {} wiki search results.", + self.result_records.len() + ))] + } + Err(error) => vec![CoreEffect::Notify(format!( + "Wiki search failed: {}", + short_error(error.as_str()) + ))], + }; + + Some(self.snapshot_output(state, effects)) + } + fn poll_transfer_prerequisites_background( &mut self, state: &CoreState, @@ -4339,11 +6162,7 @@ impl KinicProvider { } effects } - KINIC_MARKET_TAB_ID => { - vec![CoreEffect::Notify( - "Market is not implemented yet.".to_string(), - )] - } + KINIC_WIKI_TAB_ID => self.enter_wiki_tab(), KINIC_SETTINGS_TAB_ID => self.start_session_settings_refresh().into_iter().collect(), _ => vec![CoreEffect::Notify(format!("Switched kinic tab: {tab_id}"))], } @@ -4468,7 +6287,10 @@ impl DataProvider for KinicProvider { } } CoreAction::SearchSubmit => { - if let Some(effect) = self.run_live_search(state.search_scope) { + if self.tab_id == KINIC_WIKI_TAB_ID { + effects.push(self.run_wiki_search(state)); + self.start_selected_wiki_children_load(state); + } else if let Some(effect) = self.run_live_search(state.search_scope) { effects.push(effect); } } @@ -4490,12 +6312,49 @@ impl DataProvider for KinicProvider { CoreAction::MovePageUp if self.should_handle_memory_navigation(state) => { self.navigate_active_memory(state, action) } + CoreAction::MoveNext + | CoreAction::MovePrev + | CoreAction::MoveHome + | CoreAction::MoveEnd + | CoreAction::MovePageDown + | CoreAction::MovePageUp + if self.tab_id == KINIC_WIKI_TAB_ID + && self.wiki_snapshot_mode() == WikiViewMode::DatabaseBrowser => + { + self.navigate_wiki_browser(state, action); + if let Some(index) = self.active_wiki_database_index() { + effects.push(CoreEffect::SelectListItem(index)); + } + } CoreAction::OpenSelected => { - if self.is_add_memory_action_selected(state) { + if self.tab_id == KINIC_WIKI_TAB_ID { + match self.wiki_snapshot_mode() { + WikiViewMode::DatabaseList => { + if self.is_wiki_create_database_action_selected(state) { + effects.push(self.start_wiki_create_database()); + } else { + effects.extend(self.enter_selected_wiki_database(state)); + } + } + WikiViewMode::DatabaseBrowser => { + effects.extend(self.open_selected_wiki_browser_entry(state)); + } + WikiViewMode::Diagnostic => {} + } + } else if self.is_add_memory_action_selected(state) { effects.push(CoreEffect::OpenAddMemory); effects.push(CoreEffect::FocusPane(PaneFocus::Items)); } } + CoreAction::OpenWikiEditor if self.tab_id == KINIC_WIKI_TAB_ID => { + effects.extend(self.open_wiki_editor(state)); + } + CoreAction::SaveWikiEditor if self.tab_id == KINIC_WIKI_TAB_ID => { + effects.extend(self.start_wiki_save(state)); + } + CoreAction::Back if self.tab_id == KINIC_WIKI_TAB_ID => { + effects.extend(self.back_wiki_browser()); + } CoreAction::SetTab(id) => { effects.extend(self.set_tab(id.0.as_str())); if id.0.as_str() == KINIC_INSERT_TAB_ID { @@ -4978,6 +6837,8 @@ impl DataProvider for KinicProvider { } } } + CoreAction::ScrollContentLineDown => {} + CoreAction::ScrollContentLineUp => {} CoreAction::ScrollContentPageDown => {} CoreAction::ScrollContentPageUp => {} CoreAction::ScrollContentHome => {} @@ -5085,6 +6946,7 @@ impl DataProvider for KinicProvider { } _ => self.build_snapshot(state), }; + self.start_selected_wiki_children_load(state); let chat_scope_follows_active_memory = state.chat_scope == ChatScope::Selected && !matches!( action, @@ -5123,6 +6985,11 @@ impl DataProvider for KinicProvider { .or_else(|| self.poll_insert_submit_background(state)) .or_else(|| self.poll_create_cost_background(state)) .or_else(|| self.poll_session_settings_background(state)) + .or_else(|| self.poll_wiki_create_database_background(state)) + .or_else(|| self.poll_wiki_databases_background(state)) + .or_else(|| self.poll_wiki_children_background(state)) + .or_else(|| self.poll_wiki_save_background(state)) + .or_else(|| self.poll_wiki_search_background(state)) .or_else(|| self.poll_search_background(state)) } } @@ -5185,6 +7052,16 @@ fn add_memory_action_record() -> KinicRecord { ) } +fn wiki_create_database_action_record() -> KinicRecord { + KinicRecord::new( + WIKI_CREATE_DATABASE_ACTION_ID, + "+ Create database", + "wiki", + "create new database", + "## Create database\n\nCreate a new Wiki database.\n", + ) +} + fn manual_memory_summary(id: &str) -> MemorySummary { MemorySummary { id: id.to_string(), @@ -5352,6 +7229,74 @@ fn record_from_memory_summary(memory: MemorySummary) -> KinicRecord { .with_searchable_memory_id_option(memory.searchable_memory_id) } +fn wiki_not_configured_record() -> KinicRecord { + KinicRecord::new( + "wiki-not-configured", + "Wiki canister is not configured", + "wiki", + "Set KINIC_WIKI_CANISTER_ID to browse wiki databases.", + "## Wiki\n\nWiki canister is not configured.\n\nSet `KINIC_WIKI_CANISTER_ID` and refresh this tab.", + ) +} + +fn record_from_wiki_database( + wiki_canister_id: &str, + database: bridge::DatabaseSummary, +) -> KinicRecord { + let status = format!("{:?}", database.status); + let role = format!("{:?}", database.role); + let summary = format!( + "Status: {status}\nRole: {role}\nSize: {} bytes", + database.logical_size_bytes + ); + KinicRecord::new( + format!("wiki-db:{}", database.database_id), + database.database_id.clone(), + "wiki", + summary, + format!( + "## Wiki Database\n\n- Canister: `{wiki_canister_id}`\n- Database: `{}`\n- Status: `{status}`\n- Role: `{role}`\n- Logical size: `{}` bytes\n- Archived at ms: `{}`\n- Deleted at ms: `{}`\n\n### Browser\nSelect this database to browse `/Wiki` children and search wiki nodes.\n", + database.database_id, + database.logical_size_bytes, + database + .archived_at_ms + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + database + .deleted_at_ms + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + ) + .with_source_wiki_id(wiki_canister_id.to_string()) + .with_source_wiki_database_id(database.database_id) +} + +fn record_from_wiki_search_hit( + wiki_id: &str, + database_id: &str, + index: usize, + hit: bridge::WikiSearchHit, +) -> KinicRecord { + let snippet = hit + .snippet + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("No preview available."); + KinicRecord::new( + format!("wiki-search:{wiki_id}:{database_id}:{index}"), + hit.path.clone(), + "wiki", + format!("Score: {:.3}", hit.score), + format!( + "## Wiki Search Hit\n\n- Wiki: `{wiki_id}`\n- Database: `{database_id}`\n- Path: `{}`\n- Score: `{:.3}`\n\n### Preview\n{}\n", + hit.path, hit.score, snippet + ), + ) + .with_source_wiki_id(wiki_id.to_string()) + .with_source_wiki_database_id(database_id.to_string()) +} + fn display_memory_name(name: &str, detail_name: Option<&str>) -> String { if let Some(detail_name) = detail_name { return detail_name.to_string(); diff --git a/rust/tui/provider/tests.rs b/rust/tui/provider/tests.rs index 881834c..8415259 100644 --- a/rust/tui/provider/tests.rs +++ b/rust/tui/provider/tests.rs @@ -6,8 +6,8 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; use tui_kit_runtime::{ - CreateCostState, LoadedCreateCost, PickerConfirmKind, PickerContext, PickerItem, - PickerListMode, PickerState, RenameMemoryModalState, SessionAccountOverview, + CoreEffect, CreateCostState, LoadedCreateCost, PaneFocus, PickerConfirmKind, PickerContext, + PickerItem, PickerListMode, PickerState, RenameMemoryModalState, SessionAccountOverview, TextInputModalState, TransferModalState, }; @@ -24,6 +24,7 @@ fn live_config() -> TuiConfig { TuiConfig { auth: TuiAuth::resolved_for_tests(), use_mainnet: false, + wiki_canister_id: None, } } @@ -229,3 +230,6 @@ mod rename; #[path = "tests/chat.rs"] mod chat; + +#[path = "tests/wiki.rs"] +mod wiki; diff --git a/rust/tui/provider/tests/live_browser.rs b/rust/tui/provider/tests/live_browser.rs index bccea2b..0dfc0cc 100644 --- a/rust/tui/provider/tests/live_browser.rs +++ b/rust/tui/provider/tests/live_browser.rs @@ -22,10 +22,12 @@ fn poll_initial_memories_background_applies_loaded_memories_and_prefers_saved_de provider.pending_initial_memories = Some(rx); provider.initial_memories_in_flight = true; tx.send(InitialMemoriesTaskOutput { - result: Ok(vec![ - running_memory_summary("aaaaa-aa", "first"), - running_memory_summary("bbbbb-bb", "second"), - ]), + result: Ok(bridge::InstanceSummaries { + memories: vec![ + running_memory_summary("aaaaa-aa", "first"), + running_memory_summary("bbbbb-bb", "second"), + ], + }), }) .unwrap(); @@ -49,10 +51,12 @@ fn poll_initial_memories_background_prefetches_active_memory_too() { push_test_memory_detail_result("aaaaa-aa", Ok(memory_details("Alpha loaded"))); push_test_memory_detail_result("bbbbb-bb", Ok(memory_details("Beta loaded"))); tx.send(InitialMemoriesTaskOutput { - result: Ok(vec![ - running_memory_summary("aaaaa-aa", "first"), - running_memory_summary("bbbbb-bb", "second"), - ]), + result: Ok(bridge::InstanceSummaries { + memories: vec![ + running_memory_summary("aaaaa-aa", "first"), + running_memory_summary("bbbbb-bb", "second"), + ], + }), }) .unwrap(); @@ -123,10 +127,12 @@ fn poll_initial_memories_background_falls_back_to_first_when_default_missing() { provider.pending_initial_memories = Some(rx); provider.initial_memories_in_flight = true; tx.send(InitialMemoriesTaskOutput { - result: Ok(vec![ - running_memory_summary("aaaaa-aa", "first"), - running_memory_summary("bbbbb-bb", "second"), - ]), + result: Ok(bridge::InstanceSummaries { + memories: vec![ + running_memory_summary("aaaaa-aa", "first"), + running_memory_summary("bbbbb-bb", "second"), + ], + }), }) .unwrap(); diff --git a/rust/tui/provider/tests/settings.rs b/rust/tui/provider/tests/settings.rs index 88ada4e..d47dc28 100644 --- a/rust/tui/provider/tests/settings.rs +++ b/rust/tui/provider/tests/settings.rs @@ -270,9 +270,9 @@ fn poll_background_keeps_create_success_and_default_memory_when_reload_fails() { provider.create_submit_task.in_flight = true; tx.send(CreateSubmitTaskOutput { request_id: 5, - result: Ok(bridge::CreateMemorySuccess { + result: Ok(bridge::CreateInstanceSuccess { id: "aaaaa-aa".to_string(), - memories: None, + instances: None, refresh_warning: Some( "Automatic reload failed after create. Press F5 to refresh. Cause: boom" .to_string(), diff --git a/rust/tui/provider/tests/wiki.rs b/rust/tui/provider/tests/wiki.rs new file mode 100644 index 0000000..76bd086 --- /dev/null +++ b/rust/tui/provider/tests/wiki.rs @@ -0,0 +1,1901 @@ +use super::*; + +fn wiki_database(database_id: &str, status: bridge::DatabaseStatus) -> bridge::DatabaseSummary { + bridge::DatabaseSummary { + database_id: database_id.to_string(), + status, + role: crate::wiki_bridge::DatabaseRole::Owner, + logical_size_bytes: 42, + archived_at_ms: None, + deleted_at_ms: None, + } +} + +fn wiki_database_with_role( + database_id: &str, + role: crate::wiki_bridge::DatabaseRole, +) -> bridge::DatabaseSummary { + let mut database = wiki_database(database_id, bridge::DatabaseStatus::Hot); + database.role = role; + database +} + +fn cached_wiki_file(content: &str) -> WikiChildrenContent { + WikiChildrenContent { + entries: Vec::new(), + body_lines: Vec::new(), + index_preview: Some(content.lines().map(str::to_string).collect()), + node_content: Some(content.to_string()), + node_etag: Some("etag-1".to_string()), + node_metadata_json: Some("{\"title\":\"Index\"}".to_string()), + } +} + +#[test] +fn wiki_not_configured_record_is_not_queryable() { + let record = wiki_not_configured_record(); + + assert_eq!(record.source_wiki_id, None); + assert_eq!(record.source_wiki_database_id, None); + assert!( + record + .content_md + .contains("Wiki canister is not configured") + ); +} + +#[test] +fn wiki_database_record_keeps_canister_and_database_ids() { + let record = record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-owner-123", bridge::DatabaseStatus::Hot), + ); + + assert_eq!(record.source_wiki_id.as_deref(), Some("aaaaa-aa")); + assert_eq!( + record.source_wiki_database_id.as_deref(), + Some("db-owner-123") + ); + assert!(record.summary.contains("Status: Hot")); + assert!(record.summary.contains("Role: Owner")); + assert!(!record.summary.contains("Schema:")); +} + +#[test] +fn wiki_database_records_sort_hot_database_first() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.wiki_databases = vec![ + wiki_database("archived-db", bridge::DatabaseStatus::Archived), + wiki_database("hot-db", bridge::DatabaseStatus::Hot), + ]; + + provider.refresh_wiki_records_from_databases(); + + assert_eq!( + provider.wiki_records[0].source_wiki_database_id.as_deref(), + Some("hot-db") + ); +} + +#[test] +fn wiki_three_pane_exposes_database_rows_separately_from_memory_items() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_databases = vec![wiki_database("db-a", bridge::DatabaseStatus::Hot)]; + provider.refresh_wiki_records_from_databases(); + + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(0), + ..CoreState::default() + }); + + assert_eq!(snapshot.three_pane.left.rows.len(), 3); + assert_eq!(snapshot.three_pane.left.rows[0].label, "Private / Shared"); + assert!(!snapshot.three_pane.left.rows[0].selected); + assert_eq!(snapshot.three_pane.left.rows[1].label, "db-a"); + assert!(snapshot.three_pane.left.rows[1].selected); + assert_eq!(snapshot.three_pane.left.rows[2].label, "+ Create database"); + assert_eq!(snapshot.three_pane.mode, ThreePaneMode::List); + assert_eq!(snapshot.items.len(), 2); + assert_eq!(snapshot.items[1].id, WIKI_CREATE_DATABASE_ACTION_ID); + assert_eq!(snapshot.total_count, 1); +} + +#[test] +fn wiki_database_records_group_private_shared_before_public() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + let mut shared = wiki_database("db-shared", bridge::DatabaseStatus::Hot); + shared.role = crate::wiki_bridge::DatabaseRole::Reader; + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_databases = vec![ + wiki_database("db-private", bridge::DatabaseStatus::Hot), + wiki_database("db-public", bridge::DatabaseStatus::Hot), + shared, + ]; + provider.wiki_database_anonymous_access.insert( + "db-public".to_string(), + crate::wiki_bridge::DatabaseRole::Reader, + ); + provider.refresh_wiki_records_from_databases(); + + assert_eq!( + provider + .wiki_records + .iter() + .map(|record| record.title.as_str()) + .collect::>(), + vec!["db-private", "db-shared", "db-public"] + ); +} + +#[test] +fn wiki_database_list_renders_visibility_sections() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + let mut shared = wiki_database("db-shared", bridge::DatabaseStatus::Hot); + shared.role = crate::wiki_bridge::DatabaseRole::Reader; + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_databases = vec![ + wiki_database("db-private", bridge::DatabaseStatus::Hot), + wiki_database("db-public", bridge::DatabaseStatus::Hot), + shared, + ]; + provider.wiki_database_anonymous_access.insert( + "db-public".to_string(), + crate::wiki_bridge::DatabaseRole::Reader, + ); + provider.refresh_wiki_records_from_databases(); + + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(0), + ..CoreState::default() + }); + + let rows = snapshot + .three_pane + .left + .rows + .iter() + .map(|row| (row.label.as_str(), row.detail.as_str(), row.selected)) + .collect::>(); + assert_eq!( + rows, + vec![ + ("Private / Shared", "", false), + ("db-private", "Hot Private Owner 42 bytes", true), + ("db-shared", "Hot Shared Reader 42 bytes", false), + ("Public", "", false), + ("db-public", "Hot Public Owner 42 bytes", false), + ("+ Create database", "create new database", false), + ] + ); +} + +#[test] +fn wiki_database_list_includes_create_database_action_row() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_databases = vec![wiki_database("db-a", bridge::DatabaseStatus::Hot)]; + provider.refresh_wiki_records_from_databases(); + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(1), + ..CoreState::default() + }); + + assert_eq!(snapshot.three_pane.left.rows.len(), 3); + assert_eq!(snapshot.three_pane.left.rows[2].label, "+ Create database"); + assert_eq!( + snapshot.three_pane.left.rows[2].detail, + "create new database" + ); + assert!(snapshot.three_pane.left.rows[2].selected); + assert_eq!(snapshot.items.len(), 2); + assert_eq!(snapshot.items[1].id, WIKI_CREATE_DATABASE_ACTION_ID); + assert_eq!(snapshot.items[1].name, "+ Create database"); + assert_eq!( + snapshot.items[1].subtitle.as_deref(), + Some("create new database") + ); + assert_eq!(snapshot.selected_index, Some(1)); + assert_eq!(snapshot.total_count, 1); +} + +#[test] +fn wiki_database_section_rows_are_not_selectable() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_databases = vec![ + wiki_database("db-private", bridge::DatabaseStatus::Hot), + wiki_database("db-public", bridge::DatabaseStatus::Hot), + ]; + provider.wiki_database_anonymous_access.insert( + "db-public".to_string(), + crate::wiki_bridge::DatabaseRole::Reader, + ); + provider.refresh_wiki_records_from_databases(); + + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(1), + ..CoreState::default() + }); + + assert!(!snapshot.three_pane.left.rows[0].selected); + assert!(!snapshot.three_pane.left.rows[2].selected); + assert_eq!(snapshot.three_pane.left.rows[3].label, "db-public"); + assert!(snapshot.three_pane.left.rows[3].selected); +} + +#[test] +fn wiki_database_create_action_index_unchanged_with_sections() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_databases = vec![ + wiki_database("db-private", bridge::DatabaseStatus::Hot), + wiki_database("db-public", bridge::DatabaseStatus::Hot), + ]; + provider.wiki_database_anonymous_access.insert( + "db-public".to_string(), + crate::wiki_bridge::DatabaseRole::Reader, + ); + provider.refresh_wiki_records_from_databases(); + + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(provider.wiki_records.len()), + ..CoreState::default() + }); + + assert_eq!(snapshot.items.len(), 3); + assert_eq!(snapshot.items[2].id, WIKI_CREATE_DATABASE_ACTION_ID); + assert_eq!( + snapshot + .three_pane + .left + .rows + .last() + .map(|row| (row.label.as_str(), row.selected)), + Some(("+ Create database", true)) + ); +} + +#[test] +fn wiki_refresh_clears_cache_when_public_visibility_changes() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.wiki_database_anonymous_access.insert( + "db-public-to-private".to_string(), + crate::wiki_bridge::DatabaseRole::Reader, + ); + let previous_public_ids = provider + .wiki_database_anonymous_access + .keys() + .cloned() + .collect::>(); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-private-to-public", "/"), + cached_wiki_file("stale public"), + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-public-to-private", "/"), + cached_wiki_file("stale private"), + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-unchanged", "/"), + cached_wiki_file("fresh"), + ); + provider.wiki_database_anonymous_access.clear(); + provider.wiki_database_anonymous_access.insert( + "db-private-to-public".to_string(), + crate::wiki_bridge::DatabaseRole::Reader, + ); + + provider.clear_wiki_cache_for_visibility_changes(&previous_public_ids); + + assert!( + provider + .wiki_cached_children_content("db-private-to-public", "/") + .is_none() + ); + assert!( + provider + .wiki_cached_children_content("db-public-to-private", "/") + .is_none() + ); + assert!( + provider + .wiki_cached_children_content("db-unchanged", "/") + .is_some() + ); +} + +#[test] +fn wiki_create_database_action_is_not_treated_as_database_selection() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_databases = vec![wiki_database("db-a", bridge::DatabaseStatus::Hot)]; + provider.refresh_wiki_records_from_databases(); + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(1), + ..CoreState::default() + }; + let snapshot = provider.build_snapshot(&state); + + assert_eq!(provider.wiki_selected_database_index(&state), None); + assert_eq!(provider.selected_wiki_target(&state), None); + assert!(snapshot.selected_content.is_none()); + assert_eq!( + snapshot.status_message.as_deref(), + Some("Enter: open/create wiki database.") + ); +} + +#[test] +fn wiki_database_load_error_uses_diagnostic_not_error_record() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_load_error = Some("wiki query failed for list_databases".to_string()); + provider.wiki_records.clear(); + + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + ..CoreState::default() + }); + + assert!(snapshot.items.is_empty()); + assert_eq!(snapshot.three_pane.mode, ThreePaneMode::Diagnostic); + assert!(snapshot.three_pane.diagnostic.is_some()); + assert_eq!( + snapshot + .three_pane + .diagnostic + .as_ref() + .and_then(|d| d.rows.iter().find(|row| row.label == "canister")) + .map(|row| row.detail.as_str()), + Some("aaaaa-aa") + ); +} + +#[test] +fn wiki_diagnostic_uses_session_principal_without_resolving_auth() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.session_overview.session.principal_id = "principal-from-session".to_string(); + provider.wiki_load_error = Some("wiki query failed for list_databases".to_string()); + + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + ..CoreState::default() + }); + + assert_eq!( + snapshot + .three_pane + .diagnostic + .and_then(|d| d.rows.into_iter().find(|row| row.label == "principal")) + .map(|row| row.detail), + Some("principal-from-session".to_string()) + ); +} + +#[test] +fn wiki_database_list_enter_drills_into_browser() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(0), + ..CoreState::default() + }; + + let effects = provider.enter_selected_wiki_database(&state); + let snapshot = provider.build_snapshot(&state); + + assert_eq!(provider.wiki_view_mode, WikiViewMode::DatabaseBrowser); + assert_eq!(provider.wiki_current_path, "/"); + assert_eq!(provider.selected_wiki_browser_index, 0); + assert!(provider.wiki_children_task.in_flight); + assert_eq!(snapshot.three_pane.mode, ThreePaneMode::Browse); + assert!( + effects + .iter() + .any(|effect| matches!(effect, CoreEffect::FocusPane(PaneFocus::Items))) + ); +} + +#[test] +fn wiki_browser_document_stays_empty_before_file_open() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_current_path = "/".to_string(); + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(0), + ..CoreState::default() + }); + + assert!(snapshot.three_pane.document.title.is_empty()); + assert!(snapshot.three_pane.document.lines.is_empty()); + assert!( + !snapshot + .three_pane + .document + .lines + .iter() + .any(|line| line.contains("kinic::wiki")) + ); +} + +#[test] +fn wiki_database_list_enter_uses_database_even_when_focus_is_content() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseList; + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/"), + WikiChildrenContent { + entries: wiki_root_entries(), + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + selected_index: Some(0), + ..CoreState::default() + }; + + let output = provider + .handle_action(&CoreAction::OpenSelected, &state) + .expect("open selected should build output"); + + assert_eq!(provider.wiki_view_mode, WikiViewMode::DatabaseBrowser); + assert_eq!(provider.wiki_current_path, "/"); + assert_eq!( + output + .snapshot + .as_ref() + .map(|snapshot| snapshot.three_pane.mode), + Some(ThreePaneMode::Browse) + ); + assert!( + output + .effects + .iter() + .any(|effect| matches!(effect, CoreEffect::FocusPane(PaneFocus::Items))) + ); +} + +#[test] +fn wiki_database_list_enter_on_create_action_starts_create_task() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(1), + ..CoreState::default() + }; + + let output = provider + .handle_action(&CoreAction::OpenSelected, &state) + .expect("open selected should build output"); + + assert!(provider.wiki_create_database_task.in_flight); + assert!(output.effects.iter().any(|effect| { + matches!(effect, CoreEffect::Notify(message) if message == "Creating wiki database...") + })); +} + +#[test] +fn wiki_database_create_action_does_not_start_duplicate_task() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_create_database_task.in_flight = true; + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(0), + ..CoreState::default() + }; + + let output = provider + .handle_action(&CoreAction::OpenSelected, &state) + .expect("open selected should build output"); + + assert!(provider.wiki_create_database_task.in_flight); + assert!(output.effects.iter().any(|effect| { + matches!(effect, CoreEffect::Notify(message) if message == "Creating wiki database...") + })); +} + +#[test] +fn wiki_database_create_success_refreshes_and_preserves_created_database() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseList; + let (tx, rx) = std::sync::mpsc::channel(); + provider.wiki_create_database_task.receiver = Some(rx); + provider.wiki_create_database_task.in_flight = true; + provider.wiki_create_database_task.request_id = Some(7); + tx.send(WikiCreateDatabaseTaskOutput { + request_id: 7, + result: Ok("db-new".to_string()), + }) + .expect("send create result"); + + let output = provider + .poll_wiki_create_database_background(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(0), + ..CoreState::default() + }) + .expect("create result should produce output"); + + assert!(!provider.wiki_create_database_task.in_flight); + assert!(provider.wiki_databases_task.in_flight); + assert_eq!(provider.active_wiki_database_id.as_deref(), Some("db-new")); + assert_eq!(provider.wiki_view_mode, WikiViewMode::DatabaseList); + assert!(output.effects.iter().any(|effect| { + matches!(effect, CoreEffect::Notify(message) if message == "Created wiki database db-new.") + })); +} + +#[test] +fn wiki_database_create_failure_keeps_database_list_state() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseList; + let (tx, rx) = std::sync::mpsc::channel(); + provider.wiki_create_database_task.receiver = Some(rx); + provider.wiki_create_database_task.in_flight = true; + provider.wiki_create_database_task.request_id = Some(3); + tx.send(WikiCreateDatabaseTaskOutput { + request_id: 3, + result: Err("caller is not allowed".to_string()), + }) + .expect("send create result"); + + let output = provider + .poll_wiki_create_database_background(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(0), + ..CoreState::default() + }) + .expect("create result should produce output"); + + assert!(!provider.wiki_create_database_task.in_flight); + assert!(!provider.wiki_databases_task.in_flight); + assert_eq!(provider.wiki_view_mode, WikiViewMode::DatabaseList); + assert!(output.effects.iter().any(|effect| { + matches!(effect, CoreEffect::Notify(message) if message == "Wiki database create failed: caller is not allowed") + })); +} + +#[test] +fn wiki_browser_back_at_root_returns_to_database_list() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_databases = vec![wiki_database("db-a", bridge::DatabaseStatus::Hot)]; + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.wiki_current_path = "/".to_string(); + + let effects = provider.back_wiki_browser(); + + assert_eq!(provider.wiki_view_mode, WikiViewMode::DatabaseList); + assert!( + effects + .iter() + .any(|effect| matches!(effect, CoreEffect::FocusPane(PaneFocus::Items))) + ); + assert!( + effects + .iter() + .any(|effect| matches!(effect, CoreEffect::SelectListItem(0))) + ); +} + +#[test] +fn wiki_database_list_back_focuses_tabs() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseList; + + let effects = provider.back_wiki_browser(); + + assert!( + effects + .iter() + .any(|effect| matches!(effect, CoreEffect::FocusPane(PaneFocus::Tabs))) + ); +} + +#[test] +fn wiki_browser_back_inside_directory_moves_to_parent_path() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.wiki_current_path = "/Wiki/docs".to_string(); + + provider.back_wiki_browser(); + + assert_eq!(provider.wiki_view_mode, WikiViewMode::DatabaseBrowser); + assert_eq!(provider.wiki_current_path, "/Wiki"); +} + +#[test] +fn wiki_search_back_only_closes_results() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.result_records = vec![record_from_wiki_search_hit( + "aaaaa-aa", + "db-a", + 0, + bridge::WikiSearchHit { + path: "/Wiki/a.md".to_string(), + score: 1.0, + snippet: None, + }, + )]; + + provider.back_wiki_browser(); + + assert_eq!(provider.wiki_view_mode, WikiViewMode::DatabaseBrowser); + assert!(provider.result_records.is_empty()); +} + +#[test] +fn wiki_browser_enter_on_directory_updates_current_path() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/"), + WikiChildrenContent { + entries: vec![WikiBrowserEntry { + path: "/Wiki".to_string(), + name: "Wiki".to_string(), + kind: WikiBrowserEntryKind::Directory, + size_bytes: None, + has_children: true, + }], + body_lines: vec!["+ /Wiki (directory)".to_string()], + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki"), + WikiChildrenContent { + entries: Vec::new(), + body_lines: Vec::new(), + index_preview: Some(vec!["cached wiki".to_string()]), + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + + let effects = provider.open_selected_wiki_browser_entry(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + selected_index: Some(0), + ..CoreState::default() + }); + + assert_eq!(provider.wiki_current_path, "/Wiki"); + assert!(!provider.wiki_children_task.in_flight); + assert!(effects.iter().any(|effect| { + matches!(effect, CoreEffect::Notify(message) if message.contains("Opened /Wiki")) + })); +} + +#[test] +fn wiki_root_entries_only_include_top_level_directories() { + let entries = wiki_root_entries(); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].path, "/Wiki"); + assert_eq!(entries[0].kind, WikiBrowserEntryKind::Directory); + assert_eq!(entries[1].path, "/Sources"); + assert_eq!(entries[1].kind, WikiBrowserEntryKind::Directory); +} + +#[test] +fn wiki_browser_can_move_to_sources_and_enter_directory() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/"), + WikiChildrenContent { + entries: wiki_root_entries(), + body_lines: vec![ + "+ /Wiki (directory)".to_string(), + "+ /Sources (directory)".to_string(), + ], + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Sources"), + WikiChildrenContent { + entries: Vec::new(), + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + selected_index: Some(0), + ..CoreState::default() + }; + + provider.navigate_wiki_browser(&state, &CoreAction::MoveNext); + let effects = provider.open_selected_wiki_browser_entry(&state); + + assert_eq!(provider.selected_wiki_browser_index, 1); + assert_eq!(provider.wiki_current_path, "/Sources"); + assert!(effects.iter().any(|effect| { + matches!(effect, CoreEffect::Notify(message) if message.contains("Opened /Sources")) + })); +} + +#[test] +fn wiki_browser_enter_on_file_keeps_directory_list_visible() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki"), + WikiChildrenContent { + entries: vec![ + WikiBrowserEntry { + path: "/Wiki/a.md".to_string(), + name: "a.md".to_string(), + kind: WikiBrowserEntryKind::File, + size_bytes: Some(12), + has_children: false, + }, + WikiBrowserEntry { + path: "/Wiki/b.md".to_string(), + name: "b.md".to_string(), + kind: WikiBrowserEntryKind::File, + size_bytes: Some(34), + has_children: false, + }, + ], + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki/b.md"), + WikiChildrenContent { + entries: Vec::new(), + body_lines: Vec::new(), + index_preview: Some(vec!["preview b".to_string()]), + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.wiki_current_path = "/Wiki".to_string(); + provider.selected_wiki_browser_index = 1; + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + selected_index: Some(0), + ..CoreState::default() + }; + + provider.open_selected_wiki_browser_entry(&state); + let rows = provider.wiki_browser_rows(0); + let snapshot = provider.build_snapshot(&state); + + assert_eq!(provider.wiki_current_path, "/Wiki"); + assert_eq!(provider.selected_wiki_browser_index, 1); + assert_eq!(provider.wiki_preview_path.as_deref(), Some("/Wiki/b.md")); + assert_eq!(rows.len(), 2); + assert!(rows[1].selected); + assert_eq!(rows[1].label, "b.md"); + assert!(provider.wiki_children_task.in_flight); + assert_eq!(snapshot.three_pane.document.lines, vec!["preview b"]); +} + +#[test] +fn wiki_browser_renders_cached_tree_and_keeps_document_on_directory_open() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_databases = vec![wiki_database("db-a", bridge::DatabaseStatus::Hot)]; + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/"), + WikiChildrenContent { + entries: wiki_root_entries(), + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki"), + WikiChildrenContent { + entries: vec![ + WikiBrowserEntry { + path: "/Wiki/a.md".to_string(), + name: "a.md".to_string(), + kind: WikiBrowserEntryKind::File, + size_bytes: None, + has_children: false, + }, + WikiBrowserEntry { + path: "/Wiki/b.md".to_string(), + name: "b.md".to_string(), + kind: WikiBrowserEntryKind::File, + size_bytes: None, + has_children: false, + }, + ], + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki/b.md"), + WikiChildrenContent { + entries: Vec::new(), + body_lines: Vec::new(), + index_preview: Some(vec!["preview b".to_string()]), + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_preview_path = Some("/Wiki/b.md".to_string()); + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + selected_index: Some(0), + ..CoreState::default() + }; + + provider.open_selected_wiki_browser_entry(&state); + let snapshot = provider.build_snapshot(&state); + + assert_eq!(provider.wiki_current_path, "/Wiki"); + assert_eq!(provider.wiki_preview_path.as_deref(), Some("/Wiki/b.md")); + assert_eq!( + snapshot + .three_pane + .middle + .rows + .iter() + .map(|row| row.label.as_str()) + .collect::>(), + vec!["Wiki", " a.md", " b.md", "Sources"] + ); + assert_eq!(snapshot.three_pane.document.title, "b.md"); + assert_eq!(snapshot.three_pane.document.lines, vec!["preview b"]); +} + +#[test] +fn wiki_browser_keeps_previously_opened_directories_expanded() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/"), + WikiChildrenContent { + entries: wiki_root_entries(), + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki"), + WikiChildrenContent { + entries: vec![WikiBrowserEntry { + path: "/Wiki/a.md".to_string(), + name: "a.md".to_string(), + kind: WikiBrowserEntryKind::File, + size_bytes: None, + has_children: false, + }], + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Sources"), + WikiChildrenContent { + entries: vec![WikiBrowserEntry { + path: "/Sources/s.md".to_string(), + name: "s.md".to_string(), + kind: WikiBrowserEntryKind::Source, + size_bytes: None, + has_children: false, + }], + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + selected_index: Some(0), + ..CoreState::default() + }; + + provider.open_selected_wiki_browser_entry(&state); + provider.selected_wiki_browser_index = 2; + provider.open_selected_wiki_browser_entry(&state); + let rows = provider.wiki_browser_rows(0); + + assert!(provider.wiki_expanded_paths.contains("/Wiki")); + assert!(provider.wiki_expanded_paths.contains("/Sources")); + assert_eq!( + rows.iter() + .map(|row| row.label.as_str()) + .collect::>(), + vec!["Wiki", " a.md", "Sources", " s.md"] + ); +} + +#[test] +fn wiki_browser_keeps_cursor_on_nested_directory_after_open() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/"), + WikiChildrenContent { + entries: wiki_root_entries(), + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki"), + WikiChildrenContent { + entries: vec![WikiBrowserEntry { + path: "/Wiki/skills".to_string(), + name: "skills".to_string(), + kind: WikiBrowserEntryKind::Directory, + size_bytes: None, + has_children: true, + }], + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki/skills"), + WikiChildrenContent { + entries: vec![WikiBrowserEntry { + path: "/Wiki/skills/ckbtc".to_string(), + name: "ckbtc".to_string(), + kind: WikiBrowserEntryKind::Directory, + size_bytes: None, + has_children: true, + }], + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki/skills/ckbtc"), + WikiChildrenContent { + entries: vec![WikiBrowserEntry { + path: "/Wiki/skills/ckbtc/SKILL.md".to_string(), + name: "SKILL.md".to_string(), + kind: WikiBrowserEntryKind::File, + size_bytes: None, + has_children: false, + }], + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_expanded_paths = HashSet::from(["/Wiki".to_string(), "/Wiki/skills".to_string()]); + provider.wiki_current_path = "/Wiki/skills".to_string(); + provider.selected_wiki_browser_index = 2; + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + selected_index: Some(0), + ..CoreState::default() + }; + + provider.open_selected_wiki_browser_entry(&state); + let rows = provider.wiki_browser_rows(0); + + assert_eq!(provider.wiki_current_path, "/Wiki/skills/ckbtc"); + assert_eq!(provider.selected_wiki_browser_index, 2); + assert!(!provider.wiki_children_task.in_flight); + assert_eq!(rows[2].label, " ckbtc"); + assert!(rows[2].selected); + assert_eq!(rows[3].label, " SKILL.md"); +} + +#[test] +fn wiki_editor_opens_only_cached_markdown_files() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_databases = vec![wiki_database("db-a", bridge::DatabaseStatus::Hot)]; + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/"), + WikiChildrenContent { + entries: vec![WikiBrowserEntry { + path: "/Wiki/index.md".to_string(), + name: "index.md".to_string(), + kind: WikiBrowserEntryKind::File, + size_bytes: Some(7), + has_children: false, + }], + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki/index.md"), + cached_wiki_file("# Index\nbody"), + ); + + let effects = provider.open_wiki_editor(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Items, + selected_index: Some(0), + ..CoreState::default() + }); + + assert!(effects.iter().any(|effect| { + matches!( + effect, + CoreEffect::OpenWikiEditor { + path, + content, + etag, + metadata_json, + } if path == "/Wiki/index.md" + && content == "# Index\nbody" + && etag == "etag-1" + && metadata_json == "{\"title\":\"Index\"}" + ) + })); +} + +#[test] +fn wiki_editor_rejects_reader_database() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + let database = wiki_database_with_role("db-a", crate::wiki_bridge::DatabaseRole::Reader); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_databases = vec![database.clone()]; + provider.wiki_records = vec![record_from_wiki_database("aaaaa-aa", database)]; + + let effects = provider.open_wiki_editor(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Items, + selected_index: Some(0), + ..CoreState::default() + }); + + assert!(effects.iter().any(|effect| { + matches!(effect, CoreEffect::Notify(message) if message == "Selected wiki database is read-only.") + })); + assert!( + !effects + .iter() + .any(|effect| matches!(effect, CoreEffect::OpenWikiEditor { .. })) + ); +} + +#[test] +fn wiki_editor_allows_writer_database() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + let database = wiki_database_with_role("db-a", crate::wiki_bridge::DatabaseRole::Writer); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_databases = vec![database.clone()]; + provider.wiki_records = vec![record_from_wiki_database("aaaaa-aa", database)]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/"), + WikiChildrenContent { + entries: vec![WikiBrowserEntry { + path: "/Wiki/index.md".to_string(), + name: "index.md".to_string(), + kind: WikiBrowserEntryKind::File, + size_bytes: Some(7), + has_children: false, + }], + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki/index.md"), + cached_wiki_file("# Index\nbody"), + ); + + let effects = provider.open_wiki_editor(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Items, + selected_index: Some(0), + ..CoreState::default() + }); + + assert!( + effects + .iter() + .any(|effect| matches!(effect, CoreEffect::OpenWikiEditor { .. })) + ); +} + +#[test] +fn wiki_editor_rejects_source_nodes() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_databases = vec![wiki_database("db-a", bridge::DatabaseStatus::Hot)]; + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/"), + WikiChildrenContent { + entries: vec![WikiBrowserEntry { + path: "/Sources/page.md".to_string(), + name: "page.md".to_string(), + kind: WikiBrowserEntryKind::Source, + size_bytes: Some(7), + has_children: false, + }], + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + + let effects = provider.open_wiki_editor(&CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Items, + selected_index: Some(0), + ..CoreState::default() + }); + + assert!(effects.iter().any(|effect| { + matches!(effect, CoreEffect::Notify(message) if message == "Only /Wiki/*.md files are editable.") + })); +} + +#[test] +fn wiki_browser_enter_on_expanded_directory_closes_it() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/"), + WikiChildrenContent { + entries: wiki_root_entries(), + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki"), + WikiChildrenContent { + entries: vec![WikiBrowserEntry { + path: "/Wiki/a.md".to_string(), + name: "a.md".to_string(), + kind: WikiBrowserEntryKind::File, + size_bytes: None, + has_children: false, + }], + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_current_path = "/Wiki".to_string(); + provider.wiki_expanded_paths = HashSet::from(["/Wiki".to_string()]); + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + selected_index: Some(0), + ..CoreState::default() + }; + + let effects = provider.open_selected_wiki_browser_entry(&state); + let rows = provider.wiki_browser_rows(0); + + assert!(!provider.wiki_expanded_paths.contains("/Wiki")); + assert_eq!(provider.wiki_current_path, "/"); + assert_eq!( + rows.iter() + .map(|row| row.label.as_str()) + .collect::>(), + vec!["Wiki", "Sources"] + ); + assert!(effects.iter().any(|effect| { + matches!(effect, CoreEffect::Notify(message) if message == "Closed /Wiki") + })); +} + +#[test] +fn wiki_search_open_shows_cached_node_while_refreshing_content() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.selected_wiki_browser_index = 0; + provider.result_records = vec![record_from_wiki_search_hit( + "aaaaa-aa", + "db-a", + 0, + bridge::WikiSearchHit { + path: "/Wiki/a.md".to_string(), + score: 1.0, + snippet: None, + }, + )]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki/a.md"), + WikiChildrenContent { + entries: Vec::new(), + body_lines: Vec::new(), + index_preview: Some(vec!["cached a".to_string()]), + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + selected_index: Some(0), + ..CoreState::default() + }; + + let effects = provider.open_selected_wiki_browser_entry(&state); + let snapshot = provider.build_snapshot(&state); + + assert_eq!(provider.wiki_current_path, "/Wiki/a.md"); + assert!(provider.wiki_children_task.in_flight); + assert_eq!(snapshot.three_pane.document.lines, vec!["cached a"]); + assert!(effects.iter().any(|effect| { + matches!(effect, CoreEffect::Notify(message) if message == "Opened /Wiki/a.md") + })); +} + +#[test] +fn wiki_search_results_use_browser_selection_for_navigation() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.result_records = vec![ + record_from_wiki_search_hit( + "aaaaa-aa", + "db-a", + 0, + bridge::WikiSearchHit { + path: "/Wiki/a.md".to_string(), + score: 1.0, + snippet: None, + }, + ), + record_from_wiki_search_hit( + "aaaaa-aa", + "db-a", + 1, + bridge::WikiSearchHit { + path: "/Wiki/b.md".to_string(), + score: 0.8, + snippet: None, + }, + ), + ]; + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + selected_index: Some(0), + ..CoreState::default() + }; + + provider.navigate_wiki_browser(&state, &CoreAction::MoveNext); + let rows = provider.wiki_browser_rows(0); + + assert_eq!(provider.selected_wiki_browser_index, 1); + assert!(!rows[0].selected); + assert!(rows[1].selected); + assert_eq!(rows[1].label, "/Wiki/b.md"); +} + +#[test] +fn selected_wiki_target_follows_displayed_search_results() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.result_records = vec![ + record_from_wiki_search_hit( + "aaaaa-aa", + "db-a", + 0, + bridge::WikiSearchHit { + path: "/Wiki/a.md".to_string(), + score: 1.0, + snippet: None, + }, + ), + record_from_wiki_search_hit( + "aaaaa-aa", + "db-a", + 1, + bridge::WikiSearchHit { + path: "/Wiki/b.md".to_string(), + score: 0.8, + snippet: None, + }, + ), + ]; + + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(1), + ..CoreState::default() + }; + assert_eq!( + provider.selected_wiki_target(&state), + Some(("aaaaa-aa".to_string(), "db-a".to_string())) + ); +} + +#[test] +fn wiki_content_render_uses_database_cache_without_starting_query() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_children_cache.insert( + "db-a".to_string(), + WikiChildrenContent { + entries: vec![WikiBrowserEntry { + path: "/Wiki/index.md".to_string(), + name: "index.md".to_string(), + kind: WikiBrowserEntryKind::File, + size_bytes: None, + has_children: false, + }], + body_lines: vec!["- /Wiki/index.md (file)".to_string()], + index_preview: Some(vec!["cached preview".to_string()]), + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + let record = record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + ); + + let content = provider.selected_content_for_record(&record, &CoreState::default()); + + assert!(content.sections.iter().any(|section| { + section.heading == "/Wiki" && section.body_lines == vec!["- /Wiki/index.md (file)"] + })); + assert!(content.sections.iter().any(|section| { + section.heading == "/Wiki/index.md" && section.body_lines == vec!["cached preview"] + })); + assert!(!provider.wiki_children_task.in_flight); +} + +#[test] +fn set_wiki_tab_starts_database_load_once() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + let state = CoreState::default(); + + let output = provider + .handle_action(&CoreAction::SetTab(KINIC_WIKI_TAB_ID.into()), &state) + .expect("set tab should build output"); + + let refresh_count = output + .effects + .iter() + .filter(|effect| { + matches!( + effect, + CoreEffect::Notify(message) if message == "Refreshing wiki databases..." + ) + }) + .count(); + assert_eq!(refresh_count, 1); +} + +#[test] +fn set_wiki_tab_keeps_existing_browser_state() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_MEMORIES_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_current_path = "/Wiki/docs".to_string(); + provider.selected_wiki_browser_index = 2; + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + let state = CoreState { + current_tab_id: KINIC_MEMORIES_TAB_ID.to_string(), + ..CoreState::default() + }; + + let output = provider + .handle_action(&CoreAction::SetTab(KINIC_WIKI_TAB_ID.into()), &state) + .expect("set tab should build output"); + + assert_eq!(provider.wiki_view_mode, WikiViewMode::DatabaseBrowser); + assert_eq!(provider.active_wiki_database_id.as_deref(), Some("db-a")); + assert_eq!(provider.wiki_current_path, "/Wiki/docs"); + assert_eq!(provider.selected_wiki_browser_index, 2); + assert!(!provider.wiki_databases_task.in_flight); + assert!(!output.effects.iter().any(|effect| { + matches!( + effect, + CoreEffect::Notify(message) if message == "Refreshing wiki databases..." + ) + })); +} + +#[test] +fn wiki_reload_restores_database_browser_state_when_database_remains() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_databases = vec![ + wiki_database("db-a", bridge::DatabaseStatus::Hot), + wiki_database("db-b", bridge::DatabaseStatus::Hot), + ]; + provider.refresh_wiki_records_from_databases(); + provider.restore_wiki_reload_state(Some(WikiReloadState { + database_id: Some("db-b".to_string()), + view_mode: WikiViewMode::DatabaseBrowser, + current_path: "/Wiki/docs".to_string(), + preview_path: Some("/Wiki/docs/a.md".to_string()), + expanded_paths: HashSet::from(["/Wiki".to_string(), "/Wiki/docs".to_string()]), + browser_index: 3, + had_search_results: false, + })); + + assert_eq!(provider.wiki_view_mode, WikiViewMode::DatabaseBrowser); + assert_eq!(provider.active_wiki_database_id.as_deref(), Some("db-b")); + assert_eq!(provider.wiki_current_path, "/Wiki/docs"); + assert_eq!( + provider.wiki_preview_path.as_deref(), + Some("/Wiki/docs/a.md") + ); + assert!(provider.wiki_expanded_paths.contains("/Wiki")); + assert!(provider.wiki_expanded_paths.contains("/Wiki/docs")); + assert_eq!(provider.selected_wiki_browser_index, 3); +} + +#[test] +fn wiki_refresh_starts_reload_without_clearing_current_browser_state() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_current_path = "/Wiki/docs".to_string(); + provider.wiki_preview_path = Some("/Wiki/docs/a.md".to_string()); + provider.wiki_expanded_paths = HashSet::from(["/Wiki".to_string(), "/Wiki/docs".to_string()]); + provider.selected_wiki_browser_index = 1; + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + selected_index: Some(0), + ..CoreState::default() + }; + + provider + .handle_action(&CoreAction::RefreshCurrentView, &state) + .expect("refresh should build output"); + + assert!(provider.wiki_databases_task.in_flight); + assert_eq!(provider.wiki_view_mode, WikiViewMode::DatabaseBrowser); + assert_eq!(provider.wiki_current_path, "/Wiki/docs"); + assert_eq!(provider.selected_wiki_browser_index, 1); + assert_eq!( + provider + .pending_wiki_reload + .as_ref() + .and_then(|reload| reload.database_id.as_deref()), + Some("db-a") + ); + assert_eq!( + provider + .pending_wiki_reload + .as_ref() + .and_then(|reload| reload.preview_path.as_deref()), + Some("/Wiki/docs/a.md") + ); + assert_eq!( + provider + .pending_wiki_reload + .as_ref() + .map(|reload| reload.expanded_paths.clone()), + Some(HashSet::from([ + "/Wiki".to_string(), + "/Wiki/docs".to_string() + ])) + ); +} + +#[test] +fn wiki_reload_returns_to_database_list_when_selected_database_disappears() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_databases = vec![wiki_database("db-a", bridge::DatabaseStatus::Hot)]; + provider.refresh_wiki_records_from_databases(); + provider.restore_wiki_reload_state(Some(WikiReloadState { + database_id: Some("db-missing".to_string()), + view_mode: WikiViewMode::DatabaseBrowser, + current_path: "/Wiki/docs".to_string(), + preview_path: Some("/Wiki/docs/a.md".to_string()), + expanded_paths: HashSet::from(["/Wiki".to_string()]), + browser_index: 3, + had_search_results: true, + })); + + assert_eq!(provider.wiki_view_mode, WikiViewMode::DatabaseList); + assert_eq!(provider.active_wiki_database_id, None); + assert_eq!(provider.wiki_current_path, "/"); + assert_eq!(provider.selected_wiki_browser_index, 0); + assert!(provider.result_records.is_empty()); +} + +#[test] +fn wiki_browser_items_focus_enter_opens_browser_entry() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-a".to_string()); + provider.wiki_records = vec![record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + )]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/"), + WikiChildrenContent { + entries: wiki_root_entries(), + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-a", "/Wiki"), + WikiChildrenContent { + entries: Vec::new(), + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Items, + selected_index: Some(0), + ..CoreState::default() + }; + + provider + .handle_action(&CoreAction::OpenSelected, &state) + .expect("open selected should build output"); + + assert_eq!(provider.wiki_view_mode, WikiViewMode::DatabaseBrowser); + assert_eq!(provider.wiki_current_path, "/Wiki"); + assert_eq!(provider.selected_wiki_browser_index, 0); +} + +#[test] +fn wiki_browser_navigation_restores_database_list_selection() { + let mut provider = KinicProvider::new(TuiConfig { + wiki_canister_id: Some("aaaaa-aa".to_string()), + ..live_config() + }); + provider.tab_id = KINIC_WIKI_TAB_ID.to_string(); + provider.wiki_view_mode = WikiViewMode::DatabaseBrowser; + provider.active_wiki_database_id = Some("db-b".to_string()); + provider.wiki_records = vec![ + record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-a", bridge::DatabaseStatus::Hot), + ), + record_from_wiki_database( + "aaaaa-aa", + wiki_database("db-b", bridge::DatabaseStatus::Hot), + ), + ]; + provider.wiki_children_cache.insert( + wiki_children_cache_key("db-b", "/"), + WikiChildrenContent { + entries: wiki_root_entries(), + body_lines: Vec::new(), + index_preview: None, + node_content: None, + node_etag: None, + node_metadata_json: None, + }, + ); + let state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Items, + selected_index: Some(0), + ..CoreState::default() + }; + + let output = provider + .handle_action(&CoreAction::MoveNext, &state) + .expect("move should build output"); + + assert_eq!(provider.selected_wiki_browser_index, 1); + assert!( + output + .effects + .iter() + .any(|effect| matches!(effect, CoreEffect::SelectListItem(1))) + ); +} diff --git a/rust/tui/ui_config.rs b/rust/tui/ui_config.rs index 1e0c5dc..735d43b 100644 --- a/rust/tui/ui_config.rs +++ b/rust/tui/ui_config.rs @@ -1,7 +1,7 @@ use tui_kit_render::ui::{BrandingText, HeaderText, TabId, TabSpec, UiConfig}; pub use tui_kit_runtime::kinic_tabs::{ - KINIC_CREATE_TAB_ID, KINIC_INSERT_TAB_ID, KINIC_MARKET_TAB_ID, KINIC_MEMORIES_TAB_ID, - KINIC_SETTINGS_TAB_ID, + KINIC_CREATE_TAB_ID, KINIC_INSERT_TAB_ID, KINIC_MEMORIES_TAB_ID, KINIC_SETTINGS_TAB_ID, + KINIC_WIKI_TAB_ID, }; pub fn kinic_ui_config() -> UiConfig { @@ -42,9 +42,9 @@ pub fn kinic_ui_config() -> UiConfig { search_placeholder: "Create a memory...".to_string(), }, TabSpec { - id: TabId::new(KINIC_MARKET_TAB_ID), - title: "Market".to_string(), - search_placeholder: "Market is coming soon...".to_string(), + id: TabId::new(KINIC_WIKI_TAB_ID), + title: "Wiki".to_string(), + search_placeholder: "Browse wiki databases...".to_string(), }, TabSpec { id: TabId::new(KINIC_SETTINGS_TAB_ID), diff --git a/rust/wiki_bridge.rs b/rust/wiki_bridge.rs new file mode 100644 index 0000000..40ab292 --- /dev/null +++ b/rust/wiki_bridge.rs @@ -0,0 +1,445 @@ +use anyhow::{Context, Result}; +use candid::{CandidType, Decode, Encode}; +use ic_agent::{Agent, export::Principal}; +use serde::{Deserialize, Serialize}; + +pub const WIKI_CANISTER_ID_ENV_VAR: &str = "KINIC_WIKI_CANISTER_ID"; +pub const DEFAULT_WIKI_CANISTER_ID: &str = "xis3j-paaaa-aaaai-axumq-cai"; + +pub fn wiki_canister_id_from_env() -> String { + std::env::var(WIKI_CANISTER_ID_ENV_VAR) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_WIKI_CANISTER_ID.to_string()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub enum DatabaseRole { + Owner, + Writer, + Reader, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub enum DatabaseStatus { + Hot, + Restoring, + Archiving, + Archived, + Deleted, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct DatabaseSummary { + pub database_id: String, + pub status: DatabaseStatus, + pub role: DatabaseRole, + pub logical_size_bytes: u64, + pub archived_at_ms: Option, + pub deleted_at_ms: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct DatabaseMember { + pub database_id: String, + pub principal: String, + pub role: DatabaseRole, + pub created_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct ListChildrenRequest { + pub database_id: String, + pub path: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub enum NodeEntryKind { + File, + Source, + Directory, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct ChildNode { + pub path: String, + pub name: String, + pub kind: NodeEntryKind, + pub updated_at: Option, + pub etag: Option, + pub size_bytes: Option, + pub is_virtual: bool, + pub has_children: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub enum NodeKind { + File, + Source, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct Node { + pub path: String, + pub kind: NodeKind, + pub content: String, + pub created_at: i64, + pub updated_at: i64, + pub etag: String, + pub metadata_json: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub enum SearchPreviewMode { + Light, + ContentStart, + None, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub enum SearchPreviewField { + Path, + Content, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct SearchPreview { + pub field: SearchPreviewField, + pub char_offset: u32, + pub match_reason: String, + pub excerpt: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, CandidType)] +pub struct SearchNodeHit { + pub path: String, + pub kind: NodeKind, + pub snippet: Option, + pub preview: Option, + pub score: f32, + pub match_reasons: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct SearchNodesRequest { + pub database_id: String, + pub query_text: String, + pub prefix: Option, + pub top_k: u32, + pub preview_mode: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct WriteNodeRequest { + pub database_id: String, + pub path: String, + pub kind: NodeKind, + pub content: String, + pub metadata_json: String, + pub expected_etag: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct AppendNodeRequest { + pub database_id: String, + pub path: String, + pub content: String, + pub expected_etag: Option, + pub separator: Option, + pub metadata_json: Option, + pub kind: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct EditNodeRequest { + pub database_id: String, + pub path: String, + pub old_text: String, + pub new_text: String, + pub expected_etag: Option, + pub replace_all: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct DeleteNodeRequest { + pub database_id: String, + pub path: String, + pub expected_etag: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct RecentNodeHit { + pub path: String, + pub kind: NodeKind, + pub etag: String, + pub updated_at: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct WriteNodeResult { + pub created: bool, + pub node: RecentNodeHit, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct EditNodeResult { + pub replacement_count: u32, + pub node: RecentNodeHit, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct DeleteNodeResult { + pub path: String, +} + +#[derive(Clone)] +pub struct WikiClient { + agent: Agent, + canister_id: Principal, +} + +impl WikiClient { + pub fn new(agent: Agent, canister_id: impl AsRef) -> Result { + Ok(Self { + agent, + canister_id: Principal::from_text(canister_id.as_ref()) + .context("failed to parse wiki canister principal")?, + }) + } + + async fn query0(&self, method: &str) -> Result + where + Out: for<'de> candid::Deserialize<'de> + CandidType, + { + let bytes = self + .agent + .query(&self.canister_id, method) + .with_arg(Encode!().context("failed to encode wiki query args")?) + .call() + .await + .with_context(|| format!("wiki query failed for {method}"))?; + Decode!(&bytes, Out).with_context(|| format!("failed to decode wiki response for {method}")) + } + + async fn query(&self, method: &str, arg: &Arg) -> Result + where + Arg: CandidType, + Out: for<'de> candid::Deserialize<'de> + CandidType, + { + let bytes = self + .agent + .query(&self.canister_id, method) + .with_arg(Encode!(arg).context("failed to encode wiki query args")?) + .call() + .await + .with_context(|| format!("wiki query failed for {method}"))?; + Decode!(&bytes, Out).with_context(|| format!("failed to decode wiki response for {method}")) + } + + async fn query2(&self, method: &str, a: &A, b: &B) -> Result + where + A: CandidType, + B: CandidType, + Out: for<'de> candid::Deserialize<'de> + CandidType, + { + let bytes = self + .agent + .query(&self.canister_id, method) + .with_arg(Encode!(a, b).context("failed to encode wiki query args")?) + .call() + .await + .with_context(|| format!("wiki query failed for {method}"))?; + Decode!(&bytes, Out).with_context(|| format!("failed to decode wiki response for {method}")) + } + + async fn update0(&self, method: &str) -> Result + where + Out: for<'de> candid::Deserialize<'de> + CandidType, + { + let bytes = self + .agent + .update(&self.canister_id, method) + .with_arg(Encode!().context("failed to encode wiki update args")?) + .call_and_wait() + .await + .with_context(|| format!("wiki update failed for {method}"))?; + Decode!(&bytes, Out).with_context(|| format!("failed to decode wiki response for {method}")) + } + + async fn update(&self, method: &str, arg: &Arg) -> Result + where + Arg: CandidType, + Out: for<'de> candid::Deserialize<'de> + CandidType, + { + let bytes = self + .agent + .update(&self.canister_id, method) + .with_arg(Encode!(arg).context("failed to encode wiki update args")?) + .call_and_wait() + .await + .with_context(|| format!("wiki update failed for {method}"))?; + Decode!(&bytes, Out).with_context(|| format!("failed to decode wiki response for {method}")) + } + + async fn update3(&self, method: &str, a: &A, b: &B, c: &C) -> Result + where + A: CandidType, + B: CandidType, + C: CandidType, + Out: for<'de> candid::Deserialize<'de> + CandidType, + { + let bytes = self + .agent + .update(&self.canister_id, method) + .with_arg(Encode!(a, b, c).context("failed to encode wiki update args")?) + .call_and_wait() + .await + .with_context(|| format!("wiki update failed for {method}"))?; + Decode!(&bytes, Out).with_context(|| format!("failed to decode wiki response for {method}")) + } + + pub async fn list_databases(&self) -> Result> { + let result: Result, String> = self.query0("list_databases").await?; + result.map_err(anyhow::Error::msg) + } + + pub async fn list_database_members(&self, database_id: &str) -> Result> { + let result: Result, String> = self + .query("list_database_members", &database_id.to_string()) + .await?; + result.map_err(anyhow::Error::msg) + } + + pub async fn create_database(&self) -> Result { + let result: Result = self.update0("create_database").await?; + result.map_err(anyhow::Error::msg) + } + + pub async fn grant_database_access( + &self, + database_id: &str, + principal: &str, + role: DatabaseRole, + ) -> Result<()> { + let result: Result<(), String> = self + .update3( + "grant_database_access", + &database_id.to_string(), + &principal.to_string(), + &role, + ) + .await?; + result.map_err(anyhow::Error::msg) + } + + pub async fn list_children(&self, request: ListChildrenRequest) -> Result> { + let result: Result, String> = self.query("list_children", &request).await?; + result.map_err(anyhow::Error::msg) + } + + pub async fn read_node(&self, database_id: &str, path: &str) -> Result> { + let result: Result, String> = self + .query2("read_node", &database_id.to_string(), &path.to_string()) + .await?; + result.map_err(anyhow::Error::msg) + } + + pub async fn search_nodes(&self, request: SearchNodesRequest) -> Result> { + let result: Result, String> = + self.query("search_nodes", &request).await?; + result.map_err(anyhow::Error::msg) + } + + pub async fn write_node(&self, request: WriteNodeRequest) -> Result { + let result: Result = self.update("write_node", &request).await?; + result.map_err(anyhow::Error::msg) + } + + pub async fn append_node(&self, request: AppendNodeRequest) -> Result { + let result: Result = self.update("append_node", &request).await?; + result.map_err(anyhow::Error::msg) + } + + pub async fn edit_node(&self, request: EditNodeRequest) -> Result { + let result: Result = self.update("edit_node", &request).await?; + result.map_err(anyhow::Error::msg) + } + + pub async fn delete_node(&self, request: DeleteNodeRequest) -> Result { + let result: Result = self.update("delete_node", &request).await?; + result.map_err(anyhow::Error::msg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn database_summary_decodes_list_databases_shape() { + let bytes = Encode!(&Ok::<_, String>(vec![DatabaseSummary { + database_id: "default".to_string(), + status: DatabaseStatus::Hot, + role: DatabaseRole::Owner, + logical_size_bytes: 42, + archived_at_ms: None, + deleted_at_ms: None, + }])) + .expect("database list should encode"); + let decoded = Decode!(&bytes, Result, String>) + .expect("database list should decode"); + + assert_eq!(decoded.unwrap()[0].database_id, "default"); + } + + #[test] + fn no_arg_methods_encode_empty_candid_args() { + let no_args = Encode!().expect("empty args should encode"); + let empty_tuple_args = candid::encode_args(()).expect("empty tuple args should encode"); + let unit_value_arg = Encode!(&()).expect("unit value arg should encode"); + + assert_eq!(no_args, empty_tuple_args); + assert_ne!(unit_value_arg, empty_tuple_args); + } + + #[test] + fn database_member_decodes_list_database_members_shape() { + let bytes = Encode!(&Ok::<_, String>(vec![DatabaseMember { + database_id: "default".to_string(), + principal: "2vxsx-fae".to_string(), + role: DatabaseRole::Reader, + created_at_ms: 1, + }])) + .expect("database members should encode"); + let decoded = Decode!(&bytes, Result, String>) + .expect("database members should decode"); + + assert_eq!(decoded.unwrap()[0].principal, "2vxsx-fae"); + } + + #[test] + fn write_delete_and_grant_shapes_encode() { + Encode!(&WriteNodeRequest { + database_id: "db".to_string(), + path: "/Wiki/a.md".to_string(), + kind: NodeKind::File, + content: "body".to_string(), + metadata_json: "{}".to_string(), + expected_etag: Some("etag".to_string()), + }) + .expect("write request should encode"); + Encode!(&DeleteNodeRequest { + database_id: "db".to_string(), + path: "/Wiki/a.md".to_string(), + expected_etag: None, + }) + .expect("delete request should encode"); + Encode!( + &"db".to_string(), + &"aaaaa-aa".to_string(), + &DatabaseRole::Reader + ) + .expect("grant args should encode"); + } +} diff --git a/scripts/setup.sh b/scripts/setup.sh index 6bb71d1..36d8da4 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -53,6 +53,7 @@ dfx identity use "${USER_NAME}" dfx deploy internet_identity --specified-id rdmx6-jaaaa-aaaaa-aaadq-cai dfx deploy launcher --specified-id xfug4-5qaaa-aaaak-afowa-cai --argument='(variant {minor})' +dfx deploy wiki --specified-id xis3j-paaaa-aaaai-axumq-cai # dfx canister call launcher change_key_id '("test_key_1")' dfx ledger fabricate-cycles --cycles 100T --canister $(dfx canister id launcher) diff --git a/tests/fixtures/capabilities_golden.json b/tests/fixtures/capabilities_golden.json index df036ed..187fb41 100644 --- a/tests/fixtures/capabilities_golden.json +++ b/tests/fixtures/capabilities_golden.json @@ -1380,6 +1380,611 @@ } ] }, + { + "name": "wiki", + "summary": "Operate the configured Wiki canister. Requires --identity or --ii. Returns text output by default.", + "auth": { + "required": true, + "sources": [ + "global_identity", + "global_ii" + ] + }, + "output": { + "default": "text", + "supported": [ + "text", + "json" + ], + "interactive": false + }, + "global_flags_supported": [ + "verbose", + "ic", + "identity", + "ii", + "identity_path" + ], + "arguments": [], + "subcommands": [ + { + "name": "database", + "summary": "Manage Wiki databases", + "auth": { + "required": true, + "sources": [ + "global_identity", + "global_ii" + ] + }, + "output": { + "default": "text", + "supported": [ + "text", + "json" + ], + "interactive": false + }, + "global_flags_supported": [ + "verbose", + "ic", + "identity", + "ii", + "identity_path" + ], + "arguments": [], + "subcommands": [ + { + "name": "list", + "summary": "List databases visible to the caller", + "auth": { + "required": true, + "sources": [ + "global_identity", + "global_ii" + ] + }, + "output": { + "default": "text", + "supported": [ + "text", + "json" + ], + "interactive": false + }, + "global_flags_supported": [ + "verbose", + "ic", + "identity", + "ii", + "identity_path" + ], + "arguments": [ + { + "name": "json", + "required": false, + "input_shape": "flag", + "value_kind": "boolean" + } + ] + }, + { + "name": "create", + "summary": "Create a new database", + "auth": { + "required": true, + "sources": [ + "global_identity", + "global_ii" + ] + }, + "output": { + "default": "text", + "supported": [ + "text", + "json" + ], + "interactive": false + }, + "global_flags_supported": [ + "verbose", + "ic", + "identity", + "ii", + "identity_path" + ], + "arguments": [ + { + "name": "json", + "required": false, + "input_shape": "flag", + "value_kind": "boolean" + } + ] + }, + { + "name": "grant", + "summary": "Grant database access to a principal", + "auth": { + "required": true, + "sources": [ + "global_identity", + "global_ii" + ] + }, + "output": { + "default": "text", + "supported": [ + "text", + "json" + ], + "interactive": false + }, + "global_flags_supported": [ + "verbose", + "ic", + "identity", + "ii", + "identity_path" + ], + "arguments": [ + { + "name": "json", + "required": false, + "input_shape": "flag", + "value_kind": "boolean" + } + ], + "arg_groups": [ + { + "id": "WikiDatabaseGrantArgs", + "required": false, + "multiple": true, + "members": [ + "database_id", + "principal", + "role", + "json" + ] + } + ] + } + ] + }, + { + "name": "read", + "summary": "Read a Wiki node", + "auth": { + "required": true, + "sources": [ + "global_identity", + "global_ii" + ] + }, + "output": { + "default": "text", + "supported": [ + "text", + "json" + ], + "interactive": false + }, + "global_flags_supported": [ + "verbose", + "ic", + "identity", + "ii", + "identity_path" + ], + "arguments": [ + { + "name": "database_id", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "path", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "json", + "required": false, + "input_shape": "flag", + "value_kind": "boolean" + } + ] + }, + { + "name": "children", + "summary": "List Wiki children under a path", + "auth": { + "required": true, + "sources": [ + "global_identity", + "global_ii" + ] + }, + "output": { + "default": "text", + "supported": [ + "text", + "json" + ], + "interactive": false + }, + "global_flags_supported": [ + "verbose", + "ic", + "identity", + "ii", + "identity_path" + ], + "arguments": [ + { + "name": "database_id", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "path", + "required": false, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "json", + "required": false, + "input_shape": "flag", + "value_kind": "boolean" + } + ] + }, + { + "name": "search", + "summary": "Search Wiki nodes", + "auth": { + "required": true, + "sources": [ + "global_identity", + "global_ii" + ] + }, + "output": { + "default": "text", + "supported": [ + "text", + "json" + ], + "interactive": false + }, + "global_flags_supported": [ + "verbose", + "ic", + "identity", + "ii", + "identity_path" + ], + "arguments": [ + { + "name": "database_id", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "prefix", + "required": false, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "top_k", + "required": false, + "input_shape": "single_value", + "value_kind": "integer" + }, + { + "name": "json", + "required": false, + "input_shape": "flag", + "value_kind": "boolean" + } + ], + "arg_groups": [ + { + "id": "WikiSearchArgs", + "required": false, + "multiple": true, + "members": [ + "database_id", + "query", + "prefix", + "top_k", + "json" + ] + } + ] + }, + { + "name": "write", + "summary": "Write a Wiki node from a file", + "auth": { + "required": true, + "sources": [ + "global_identity", + "global_ii" + ] + }, + "output": { + "default": "text", + "supported": [ + "text", + "json" + ], + "interactive": false + }, + "global_flags_supported": [ + "verbose", + "ic", + "identity", + "ii", + "identity_path" + ], + "arguments": [ + { + "name": "database_id", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "path", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "input", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "kind", + "required": false, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "metadata_json", + "required": false, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "expected_etag", + "required": false, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "json", + "required": false, + "input_shape": "flag", + "value_kind": "boolean" + } + ] + }, + { + "name": "append", + "summary": "Append file contents to a Wiki node", + "auth": { + "required": true, + "sources": [ + "global_identity", + "global_ii" + ] + }, + "output": { + "default": "text", + "supported": [ + "text", + "json" + ], + "interactive": false + }, + "global_flags_supported": [ + "verbose", + "ic", + "identity", + "ii", + "identity_path" + ], + "arguments": [ + { + "name": "database_id", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "path", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "input", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "expected_etag", + "required": false, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "separator", + "required": false, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "kind", + "required": false, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "metadata_json", + "required": false, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "json", + "required": false, + "input_shape": "flag", + "value_kind": "boolean" + } + ] + }, + { + "name": "edit", + "summary": "Replace text in a Wiki node", + "auth": { + "required": true, + "sources": [ + "global_identity", + "global_ii" + ] + }, + "output": { + "default": "text", + "supported": [ + "text", + "json" + ], + "interactive": false + }, + "global_flags_supported": [ + "verbose", + "ic", + "identity", + "ii", + "identity_path" + ], + "arguments": [ + { + "name": "database_id", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "path", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "old_text", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "new_text", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "replace_all", + "required": false, + "input_shape": "flag", + "value_kind": "boolean" + }, + { + "name": "expected_etag", + "required": false, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "json", + "required": false, + "input_shape": "flag", + "value_kind": "boolean" + } + ] + }, + { + "name": "delete", + "summary": "Delete a Wiki node. Requires --yes.", + "auth": { + "required": true, + "sources": [ + "global_identity", + "global_ii" + ] + }, + "output": { + "default": "text", + "supported": [ + "text", + "json" + ], + "interactive": false + }, + "global_flags_supported": [ + "verbose", + "ic", + "identity", + "ii", + "identity_path" + ], + "arguments": [ + { + "name": "database_id", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "path", + "required": true, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "expected_etag", + "required": false, + "input_shape": "single_value", + "value_kind": "string" + }, + { + "name": "yes", + "required": false, + "input_shape": "flag", + "value_kind": "boolean" + }, + { + "name": "json", + "required": false, + "input_shape": "flag", + "value_kind": "boolean" + } + ] + } + ] + }, { "name": "tui", "summary": "Launch the Kinic terminal UI. Requires global --identity . --ii is not supported. Returns an interactive TUI, not JSON.", diff --git a/tui/crates/tui-kit-host/src/lib.rs b/tui/crates/tui-kit-host/src/lib.rs index b18ed01..3e51ebe 100644 --- a/tui/crates/tui-kit-host/src/lib.rs +++ b/tui/crates/tui-kit-host/src/lib.rs @@ -57,6 +57,7 @@ pub fn key_to_core_key(code: KeyCode) -> Option { KeyCode::Char(c) => Some(CoreKey::Char(c)), KeyCode::Tab => Some(CoreKey::Tab), KeyCode::BackTab => Some(CoreKey::BackTab), + KeyCode::Esc => Some(CoreKey::Esc), KeyCode::Backspace => Some(CoreKey::Backspace), KeyCode::Enter => Some(CoreKey::Enter), KeyCode::Down => Some(CoreKey::Down), @@ -189,6 +190,10 @@ pub fn global_command_for_key( if code == KeyCode::Esc { let tab_specific = if focus == PaneFocus::Form && focus_policy.allows_form { HostGlobalCommand::BackFromFormToTabs + } else if current_tab_id == tui_kit_runtime::kinic_tabs::KINIC_WIKI_TAB_ID + && matches!(focus, PaneFocus::Items | PaneFocus::Content) + { + HostGlobalCommand::None } else if current_tab_id == tui_kit_runtime::kinic_tabs::KINIC_MEMORIES_TAB_ID && focus == PaneFocus::Content { @@ -334,6 +339,34 @@ pub fn execute_effects_to_status(state: &mut CoreState, effects: Vec }; state.insert_error = message.clone(); } + CoreEffect::ResetContentScroll => { + state.content_scroll_reset_epoch = state.content_scroll_reset_epoch.wrapping_add(1); + } + CoreEffect::OpenWikiEditor { + path, + content, + etag, + metadata_json, + } => { + state.wiki_editor.open(path, content, etag, metadata_json); + state.focus = PaneFocus::Content; + } + CoreEffect::WikiEditorSaving => { + state.wiki_editor.begin_save(); + } + CoreEffect::WikiEditorSaved { + path, + content, + etag, + metadata_json, + } => { + state.wiki_editor.open(path, content, etag, metadata_json); + state.wiki_editor.close(); + state.focus = PaneFocus::Items; + } + CoreEffect::WikiEditorError(message) => { + state.wiki_editor.apply_error(message); + } CoreEffect::SelectFirstListItem => { state.selected_index = if state.list_items.is_empty() { None @@ -341,6 +374,13 @@ pub fn execute_effects_to_status(state: &mut CoreState, effects: Vec Some(0) }; } + CoreEffect::SelectListItem(index) => { + state.selected_index = if state.list_items.is_empty() { + None + } else { + Some(index.min(state.list_items.len().saturating_sub(1))) + }; + } CoreEffect::FocusPane(pane) => { let focus_policy = tab_focus_policy(state.current_tab_id.as_str()); let allows_focus = match pane { diff --git a/tui/crates/tui-kit-host/src/lib_tests.rs b/tui/crates/tui-kit-host/src/lib_tests.rs index 8086a3c..6e07640 100644 --- a/tui/crates/tui-kit-host/src/lib_tests.rs +++ b/tui/crates/tui-kit-host/src/lib_tests.rs @@ -1,6 +1,7 @@ use super::*; +use tui_kit_render::{UiItemKind, UiItemSummary, UiVisibility}; use tui_kit_runtime::kinic_tabs::{ - KINIC_CREATE_TAB_ID, KINIC_MARKET_TAB_ID, KINIC_MEMORIES_TAB_ID, KINIC_SETTINGS_TAB_ID, + KINIC_CREATE_TAB_ID, KINIC_MEMORIES_TAB_ID, KINIC_SETTINGS_TAB_ID, KINIC_WIKI_TAB_ID, }; use tui_kit_runtime::{CoreState, ProviderSnapshot, TransferModalState, apply_snapshot}; @@ -147,6 +148,46 @@ mod effect_application { assert_eq!(state.focus, PaneFocus::Form); } + #[test] + fn select_list_item_effect_clamps_to_visible_items() { + let mut state = CoreState { + list_items: vec![test_item("a"), test_item("b")], + selected_index: Some(0), + ..CoreState::default() + }; + + execute_effects_to_status(&mut state, vec![CoreEffect::SelectListItem(3)]); + + assert_eq!(state.selected_index, Some(1)); + } + + #[test] + fn select_list_item_effect_can_select_wiki_create_action_row() { + let mut state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + list_items: vec![test_item("db-a"), test_item("wiki-create-database-action")], + selected_index: Some(0), + ..CoreState::default() + }; + + execute_effects_to_status(&mut state, vec![CoreEffect::SelectListItem(1)]); + + assert_eq!(state.selected_index, Some(1)); + } + + fn test_item(id: &str) -> UiItemSummary { + UiItemSummary { + id: id.to_string(), + name: id.to_string(), + leading_marker: None, + kind: UiItemKind::Custom("test".to_string()), + visibility: UiVisibility::Private, + qualified_name: None, + subtitle: None, + tags: Vec::new(), + } + } + #[test] fn set_insert_tag_effect_updates_insert_tag() { let mut state = CoreState { @@ -417,14 +458,15 @@ mod global_commands { ), ( PaneFocus::Content, - KINIC_MARKET_TAB_ID, + KINIC_SETTINGS_TAB_ID, HostGlobalCommand::BackToTabs, ), ( PaneFocus::Content, - KINIC_SETTINGS_TAB_ID, - HostGlobalCommand::BackToTabs, + KINIC_WIKI_TAB_ID, + HostGlobalCommand::None, ), + (PaneFocus::Items, KINIC_WIKI_TAB_ID, HostGlobalCommand::None), ]; for (focus, tab_id, expected) in cases { diff --git a/tui/crates/tui-kit-host/src/runtime_loop.rs b/tui/crates/tui-kit-host/src/runtime_loop.rs index d18e485..be6402e 100644 --- a/tui/crates/tui-kit-host/src/runtime_loop.rs +++ b/tui/crates/tui-kit-host/src/runtime_loop.rs @@ -71,6 +71,7 @@ enum ActiveTextarea { CreateDescription, RenameDescription, InsertText, + WikiDocument, ChatInput, } @@ -105,6 +106,7 @@ struct FormTextareas { create_description: TextArea<'static>, rename_description: TextArea<'static>, insert_text: TextArea<'static>, + wiki_document: TextArea<'static>, chat_input: ChatInputState, chat_command_selected: usize, } @@ -115,6 +117,7 @@ impl Default for FormTextareas { create_description: textarea_from_text(""), rename_description: textarea_from_text(""), insert_text: textarea_from_text(""), + wiki_document: textarea_from_text(""), chat_input: ChatInputState::default(), chat_command_selected: 0, } @@ -154,6 +157,7 @@ pub fn run_provider_app_with_hooks>( let mut animation = AnimationState::new(); let mut last_selected_index: Option = None; let mut last_tab_id = state.current_tab_id.clone(); + let mut last_scroll_reset_epoch = state.content_scroll_reset_epoch; let mut list_scroll_offset: usize = 0; let mut textareas = FormTextareas::default(); let mut provider_render_state = ProviderRenderState::default(); @@ -165,6 +169,10 @@ pub fn run_provider_app_with_hooks>( loop { hooks.on_tick(provider, &mut state); + if state.content_scroll_reset_epoch != last_scroll_reset_epoch { + inspector_scroll = 0; + last_scroll_reset_epoch = state.content_scroll_reset_epoch; + } animation.update(); sync_form_textareas_from_state(&mut textareas, &state); @@ -197,6 +205,7 @@ pub fn run_provider_app_with_hooks>( .ui_config(ui_config()) .ui_summaries(&state.list_items) .ui_selected_content(state.selected_content.as_ref()) + .three_pane_snapshot(&state.three_pane) .ui_total_count(state.total_count) .list_selected(state.selected_index) .list_scroll(list_scroll_offset) @@ -245,6 +254,12 @@ pub fn run_provider_app_with_hooks>( .insert_spinner_frame(state.insert_spinner_frame) .insert_error(state.insert_error.as_deref()) .insert_focus(state.insert_focus) + .wiki_editor(state.wiki_editor.clone()) + .wiki_editor_cursor(textarea_cursor( + active_textarea(&state), + ActiveTextarea::WikiDocument, + &textareas.wiki_document, + )) .access_control_modal(state.access_control.clone()) .add_memory_modal(state.add_memory.clone()) .remove_memory_modal(&state.remove_memory) @@ -358,6 +373,18 @@ pub fn run_provider_app_with_hooks>( continue; } + if let Some(action) = wiki_editor_key_action(&state, &input) { + match dispatch_action_with_persistent_clear(provider, &mut state, &action) { + Ok((effects, next_render_state)) => { + provider_render_state = next_render_state; + hooks.on_effects(provider, &mut state, &effects); + execute_effects_to_status(&mut state, effects); + } + Err(e) => state.status_message = Some(dispatch_error_message(&e)), + } + continue; + } + if let (Some(code), Some(modifiers)) = (code, modifiers) { match global_command_for_key( code, @@ -565,10 +592,13 @@ pub fn run_provider_app_with_hooks>( } continue; } + let previous_tab_id = state.current_tab_id.clone(); match dispatch_action_with_persistent_clear(provider, &mut state, &action) { Ok((effects, next_render_state)) => { provider_render_state = next_render_state; - if matches!(&action, CoreAction::SetTab(_)) { + if matches!(&action, CoreAction::SetTab(_)) + && state.current_tab_id != previous_tab_id + { normalize_focus_after_set_tab(&mut state); } hooks.on_effects(provider, &mut state, &effects); @@ -682,6 +712,7 @@ fn build_ui<'a>( .ui_config((cfg.ui_config)()) .ui_summaries(&state.list_items) .ui_selected_content(state.selected_content.as_ref()) + .three_pane_snapshot(&state.three_pane) .ui_total_count(state.total_count) .list_selected(state.selected_index) .list_scroll(list_scroll_offset) @@ -730,6 +761,12 @@ fn build_ui<'a>( .insert_spinner_frame(state.insert_spinner_frame) .insert_error(state.insert_error.as_deref()) .insert_focus(state.insert_focus) + .wiki_editor(state.wiki_editor.clone()) + .wiki_editor_cursor(textarea_cursor( + active_textarea(state), + ActiveTextarea::WikiDocument, + &textareas.wiki_document, + )) .access_control_modal(state.access_control.clone()) .add_memory_modal(state.add_memory.clone()) .remove_memory_modal(&state.remove_memory) @@ -882,6 +919,15 @@ fn handle_textarea_input>( let Some(target) = active_textarea(state) else { return Ok(false); }; + if let Some(action) = wiki_editor_textarea_action(state, input) { + match dispatch_with_effects(provider, state, hooks, provider_render_state, &action) { + Ok(()) => return Ok(true), + Err(error) => { + state.status_message = Some(error); + return Ok(true); + } + } + } let textarea = textarea_mut(textareas, target); let HostInputEvent::Key { key_event, code, .. @@ -983,6 +1029,70 @@ fn handle_chat_input_event>( } } +fn wiki_editor_textarea_action(state: &CoreState, input: &HostInputEvent) -> Option { + if active_textarea(state) != Some(ActiveTextarea::WikiDocument) { + return None; + } + let HostInputEvent::Key { + code, modifiers, .. + } = input + else { + return None; + }; + match (*code, *modifiers) { + (crossterm::event::KeyCode::Char('s'), modifiers) + if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => + { + Some(CoreAction::SaveWikiEditor) + } + (crossterm::event::KeyCode::Esc, _) => Some(CoreAction::CancelWikiEditor), + _ => None, + } +} + +fn wiki_editor_key_action(state: &CoreState, input: &HostInputEvent) -> Option { + if state.current_tab_id != tui_kit_runtime::kinic_tabs::KINIC_WIKI_TAB_ID + || state.focus != PaneFocus::Content + || !state.wiki_editor.open + { + return None; + } + let HostInputEvent::Key { + code, modifiers, .. + } = input + else { + return None; + }; + if state.wiki_editor.discard_confirm { + return match code { + crossterm::event::KeyCode::Enter => Some(CoreAction::ConfirmDiscardWikiEditor), + crossterm::event::KeyCode::Esc => Some(CoreAction::AbortDiscardWikiEditor), + _ => None, + }; + } + match (*code, *modifiers) { + (crossterm::event::KeyCode::Char('s'), modifiers) + if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => + { + Some(CoreAction::SaveWikiEditor) + } + (crossterm::event::KeyCode::Tab, _) => Some(CoreAction::WikiEditorNextField), + (crossterm::event::KeyCode::BackTab, _) => Some(CoreAction::WikiEditorPrevField), + (crossterm::event::KeyCode::Esc, _) => Some(CoreAction::CancelWikiEditor), + (crossterm::event::KeyCode::Enter, _) + if state.wiki_editor.footer_focus == tui_kit_runtime::WikiEditorFooterFocus::Save => + { + Some(CoreAction::SaveWikiEditor) + } + (crossterm::event::KeyCode::Enter, _) + if state.wiki_editor.footer_focus == tui_kit_runtime::WikiEditorFooterFocus::Cancel => + { + Some(CoreAction::CancelWikiEditor) + } + _ => None, + } +} + fn handle_paste_input>( provider: &mut P, state: &mut CoreState, @@ -1389,6 +1499,7 @@ fn textarea_next_field_action(target: ActiveTextarea) -> CoreAction { ActiveTextarea::CreateDescription => CoreAction::CreateNextField, ActiveTextarea::RenameDescription => CoreAction::RenameMemoryNextField, ActiveTextarea::InsertText => CoreAction::InsertNextField, + ActiveTextarea::WikiDocument => CoreAction::WikiEditorNextField, ActiveTextarea::ChatInput => CoreAction::FocusNext, } } @@ -1398,6 +1509,7 @@ fn textarea_prev_field_action(target: ActiveTextarea) -> CoreAction { ActiveTextarea::CreateDescription => CoreAction::CreatePrevField, ActiveTextarea::RenameDescription => CoreAction::RenameMemoryPrevField, ActiveTextarea::InsertText => CoreAction::InsertPrevField, + ActiveTextarea::WikiDocument => CoreAction::WikiEditorPrevField, ActiveTextarea::ChatInput => CoreAction::FocusPrev, } } @@ -1417,6 +1529,15 @@ fn active_textarea(state: &CoreState) -> Option { return Some(ActiveTextarea::RenameDescription); } + if state.current_tab_id == tui_kit_runtime::kinic_tabs::KINIC_WIKI_TAB_ID + && state.focus == PaneFocus::Content + && state.wiki_editor.open + && state.wiki_editor.footer_focus == tui_kit_runtime::WikiEditorFooterFocus::Body + && !state.wiki_editor.discard_confirm + { + return Some(ActiveTextarea::WikiDocument); + } + if state.focus != PaneFocus::Form && state.focus != PaneFocus::Extra { return None; } @@ -1449,6 +1570,7 @@ fn textarea_mut(textareas: &mut FormTextareas, target: ActiveTextarea) -> &mut T ActiveTextarea::CreateDescription => &mut textareas.create_description, ActiveTextarea::RenameDescription => &mut textareas.rename_description, ActiveTextarea::InsertText => &mut textareas.insert_text, + ActiveTextarea::WikiDocument => &mut textareas.wiki_document, ActiveTextarea::ChatInput => unreachable!("chat input no longer uses textarea"), } } @@ -1463,6 +1585,10 @@ fn sync_form_textareas_from_state(textareas: &mut FormTextareas, state: &CoreSta state.rename_memory.description.as_str(), ); sync_textarea_from_string(&mut textareas.insert_text, state.insert_text.as_str()); + sync_textarea_from_string( + &mut textareas.wiki_document, + state.wiki_editor.draft_content.as_str(), + ); sync_chat_input_from_state(&mut textareas.chat_input, state.chat_input.as_str()); } @@ -1548,6 +1674,18 @@ fn sync_state_from_textareas(state: &mut CoreState, textareas: &FormTextareas) { } } + let wiki_document = textareas.wiki_document.lines().join("\n"); + if state.wiki_editor.open && state.wiki_editor.draft_content != wiki_document { + state.wiki_editor.draft_content = wiki_document; + state.wiki_editor.dirty = + state.wiki_editor.draft_content != state.wiki_editor.original_content; + state.wiki_editor.error = None; + state.wiki_editor.discard_confirm = false; + if state.wiki_editor.submit_state == tui_kit_runtime::CreateSubmitState::Error { + state.wiki_editor.submit_state = tui_kit_runtime::CreateSubmitState::Idle; + } + } + let display_chat_input = chat_input_display_value(&textareas.chat_input); if state.chat_input != display_chat_input { state.chat_input = display_chat_input; @@ -1892,11 +2030,13 @@ fn open_form_tab>( && state.current_tab_id != tab_id && matches!(tab_kind(tab_id), TabKind::InsertForm | TabKind::CreateForm); match dispatch_tab_with_rollback(provider, state, hooks, provider_render_state, tab_id) { - Ok(()) => { - if should_reset_form_state { + Ok(tab_changed) => { + if tab_changed && should_reset_form_state { reset_form_state_for_tab(state, tab_id); } - normalize_focus_after_set_tab(state); + if tab_changed { + normalize_focus_after_set_tab(state); + } } Err(error) => state.status_message = Some(error), } @@ -1981,8 +2121,9 @@ fn switch_to_tab>( provider_render_state: &mut ProviderRenderState, tab_id: &str, ) -> Result<(), String> { - dispatch_tab_with_rollback(provider, state, hooks, provider_render_state, tab_id)?; - normalize_focus_after_set_tab(state); + if dispatch_tab_with_rollback(provider, state, hooks, provider_render_state, tab_id)? { + normalize_focus_after_set_tab(state); + } Ok(()) } @@ -1992,9 +2133,10 @@ fn dispatch_tab_with_rollback>( hooks: &mut H, provider_render_state: &mut ProviderRenderState, tab_id: &str, -) -> Result<(), String> { +) -> Result { let previous_state = state.clone(); let previous_render_state = provider_render_state.clone(); + let previous_tab_id = state.current_tab_id.clone(); match dispatch_with_effects( provider, state, @@ -2002,7 +2144,7 @@ fn dispatch_tab_with_rollback>( provider_render_state, &CoreAction::SetTab(tab_id.into()), ) { - Ok(()) => Ok(()), + Ok(()) => Ok(state.current_tab_id != previous_tab_id), Err(error) => { *state = previous_state; *provider_render_state = previous_render_state; @@ -2039,6 +2181,14 @@ fn keep_selection_visible_scroll( fn apply_content_scroll_action(action: &CoreAction, inspector_scroll: &mut usize) -> bool { match action { + CoreAction::ScrollContentLineDown => { + *inspector_scroll = inspector_scroll.saturating_add(1); + true + } + CoreAction::ScrollContentLineUp => { + *inspector_scroll = inspector_scroll.saturating_sub(1); + true + } CoreAction::ScrollContentPageDown => { *inspector_scroll = inspector_scroll.saturating_add(10); true diff --git a/tui/crates/tui-kit-host/src/runtime_loop_tests.rs b/tui/crates/tui-kit-host/src/runtime_loop_tests.rs index 5d44b6f..b832fcb 100644 --- a/tui/crates/tui-kit-host/src/runtime_loop_tests.rs +++ b/tui/crates/tui-kit-host/src/runtime_loop_tests.rs @@ -3,11 +3,13 @@ use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; use std::path::PathBuf; use tui_kit_render::ui::UiConfig; use tui_kit_runtime::kinic_tabs::{ - KINIC_CREATE_TAB_ID, KINIC_INSERT_TAB_ID, KINIC_MARKET_TAB_ID, KINIC_MEMORIES_TAB_ID, + KINIC_CREATE_TAB_ID, KINIC_INSERT_TAB_ID, KINIC_MEMORIES_TAB_ID, KINIC_SETTINGS_TAB_ID, + KINIC_WIKI_TAB_ID, }; use tui_kit_runtime::{ CoreError, CoreResult, InsertFormFocus, InsertMode, PaneFocus, PickerContext, PickerListMode, PickerState, ProviderOutput, ProviderSnapshot, RenameMemoryModalState, TextInputModalState, + apply_core_action, }; struct TestProvider { @@ -53,7 +55,7 @@ fn test_runtime_config() -> RuntimeLoopConfig { KINIC_MEMORIES_TAB_ID, KINIC_CREATE_TAB_ID, KINIC_INSERT_TAB_ID, - KINIC_MARKET_TAB_ID, + KINIC_SETTINGS_TAB_ID, ], initial_focus: PaneFocus::Form, ui_config: test_ui_config, @@ -76,6 +78,68 @@ fn host_paste(text: &str) -> HostInputEvent { HostInputEvent::Paste(text.to_string()) } +fn wiki_editor_state() -> CoreState { + let mut state = CoreState { + current_tab_id: KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + ..CoreState::default() + }; + state.wiki_editor.open( + "/Wiki/index.md".to_string(), + "# Index".to_string(), + "etag-1".to_string(), + "{}".to_string(), + ); + state +} + +#[test] +fn wiki_textarea_sync_marks_dirty_and_clears_error() { + let mut state = wiki_editor_state(); + state.wiki_editor.error = Some("old error".to_string()); + state.wiki_editor.submit_state = tui_kit_runtime::CreateSubmitState::Error; + let mut textareas = FormTextareas::default(); + sync_form_textareas_from_state(&mut textareas, &state); + + textareas.wiki_document = textarea_from_text("# Index\nchanged"); + sync_state_from_textareas(&mut state, &textareas); + + assert_eq!(state.wiki_editor.draft_content, "# Index\nchanged"); + assert!(state.wiki_editor.dirty); + assert_eq!(state.wiki_editor.error, None); + assert_eq!( + state.wiki_editor.submit_state, + tui_kit_runtime::CreateSubmitState::Idle + ); +} + +#[test] +fn wiki_textarea_ctrl_s_dispatches_save_without_text_input() { + let state = wiki_editor_state(); + let input = host_key( + crossterm::event::KeyCode::Char('s'), + crossterm::event::KeyModifiers::CONTROL, + ); + + assert_eq!( + wiki_editor_textarea_action(&state, &input), + Some(CoreAction::SaveWikiEditor) + ); +} + +#[test] +fn wiki_editor_escape_on_dirty_does_not_discard_immediately() { + let mut state = wiki_editor_state(); + state.wiki_editor.draft_content.push_str("\nchanged"); + state.wiki_editor.dirty = true; + + apply_core_action(&mut state, &CoreAction::CancelWikiEditor); + + assert!(state.wiki_editor.open); + assert!(state.wiki_editor.discard_confirm); + assert_eq!(state.wiki_editor.draft_content, "# Index\nchanged"); +} + #[test] fn normalize_focus_keeps_memories_on_tabs_after_tab_switch() { let mut state = CoreState { @@ -122,7 +186,7 @@ fn normalize_focus_resets_insert_tab_to_tabs_and_mode_field() { #[test] fn normalize_focus_keeps_placeholder_tabs_on_tabs() { let mut state = CoreState { - current_tab_id: KINIC_MARKET_TAB_ID.to_string(), + current_tab_id: KINIC_SETTINGS_TAB_ID.to_string(), focus: PaneFocus::Content, ..CoreState::default() }; @@ -132,6 +196,28 @@ fn normalize_focus_keeps_placeholder_tabs_on_tabs() { assert_eq!(state.focus, PaneFocus::Tabs); } +#[test] +fn blocked_dirty_wiki_tab_switch_keeps_editor_focus() { + let mut state = wiki_editor_state(); + state.wiki_editor.dirty = true; + let mut provider = TestProvider::ok(); + let mut hooks = NoopRuntimeHooks; + let mut provider_render_state = ProviderRenderState::default(); + + switch_to_tab( + &mut provider, + &mut state, + &mut hooks, + &mut provider_render_state, + KINIC_MEMORIES_TAB_ID, + ) + .expect("blocked tab switch should not fail provider dispatch"); + + assert_eq!(state.current_tab_id, KINIC_WIKI_TAB_ID); + assert_eq!(state.focus, PaneFocus::Content); + assert!(state.wiki_editor.discard_confirm); +} + #[test] fn picker_overlay_action_maps_generic_picker_keys() { assert_eq!( @@ -1833,7 +1919,7 @@ fn switch_to_tab_failure_keeps_existing_focus_when_target_tab_allows_it() { let mut provider = TestProvider::err("tab failed"); let mut hooks = NoopRuntimeHooks; let mut state = CoreState { - current_tab_id: KINIC_MARKET_TAB_ID.to_string(), + current_tab_id: KINIC_SETTINGS_TAB_ID.to_string(), focus: PaneFocus::Content, ..CoreState::default() }; @@ -1948,6 +2034,18 @@ fn dispatch_with_effects_keeps_non_tab_reducer_state_on_failure() { fn content_scroll_helper_handles_scroll_end_only() { let mut inspector_scroll = 3usize; + assert!(apply_content_scroll_action( + &CoreAction::ScrollContentLineDown, + &mut inspector_scroll + )); + assert_eq!(inspector_scroll, 4); + + assert!(apply_content_scroll_action( + &CoreAction::ScrollContentLineUp, + &mut inspector_scroll + )); + assert_eq!(inspector_scroll, 3); + assert!(apply_content_scroll_action( &CoreAction::ScrollContentEnd, &mut inspector_scroll diff --git a/tui/crates/tui-kit-render/src/ui/app/render_root.rs b/tui/crates/tui-kit-render/src/ui/app/render_root.rs index 6992396..afe4f77 100644 --- a/tui/crates/tui-kit-render/src/ui/app/render_root.rs +++ b/tui/crates/tui-kit-render/src/ui/app/render_root.rs @@ -57,6 +57,7 @@ impl<'a> TuiKitUi<'a> { match tab_kind(self.current_tab_id.0.as_str()) { TabKind::CreateForm => return self.create_cursor_position_for_area(area), TabKind::InsertForm => return self.insert_cursor_position_for_area(area), + TabKind::Wiki => return self.wiki_cursor_position_for_area(area), _ => {} } self.memories_cursor_position_for_area(area) diff --git a/tui/crates/tui-kit-render/src/ui/app/screens/mod.rs b/tui/crates/tui-kit-render/src/ui/app/screens/mod.rs index a9fbc04..36bc2fc 100644 --- a/tui/crates/tui-kit-render/src/ui/app/screens/mod.rs +++ b/tui/crates/tui-kit-render/src/ui/app/screens/mod.rs @@ -3,8 +3,8 @@ pub mod create; pub mod insert; pub mod memories; -pub mod placeholder; pub mod settings; +pub mod wiki; use ratatui::{buffer::Buffer, layout::Rect, text::Line}; use tui_kit_runtime::CreateSubmitState; @@ -13,24 +13,6 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::ui::app::TuiKitUi; -struct PlaceholderScreenSpec<'a> { - title: &'a str, - lead: &'a str, - detail: &'a str, -} - -fn placeholder_screen_spec(kind: TabKind) -> Option> { - match kind { - TabKind::PlaceholderMarket => Some(PlaceholderScreenSpec { - title: "Market", - lead: "Market tab is reserved for future discovery and purchase flows.", - detail: "Use Memories to browse and Create to provision a new memory today.", - }), - TabKind::PlaceholderSettings => None, - _ => None, - } -} - impl<'a> TuiKitUi<'a> { pub(crate) fn render_tab_screen(&self, area: Rect, buf: &mut Buffer) -> bool { match tab_kind(self.current_tab_id.0.as_str()) { @@ -46,13 +28,11 @@ impl<'a> TuiKitUi<'a> { self.render_settings_screen(area, buf); true } - kind => { - let Some(spec) = placeholder_screen_spec(kind) else { - return false; - }; - self.render_placeholder_screen(area, buf, spec.title, spec.lead, spec.detail); + TabKind::Wiki => { + self.render_wiki_screen(area, buf); true } + _ => false, } } } @@ -71,7 +51,7 @@ fn submit_button_text( } } -fn spinner_frame(frame: usize) -> &'static str { +pub(crate) fn spinner_frame(frame: usize) -> &'static str { const FRAMES: [&str; 4] = ["|", "/", "-", "\\"]; FRAMES[frame % FRAMES.len()] } diff --git a/tui/crates/tui-kit-render/src/ui/app/screens/placeholder/mod.rs b/tui/crates/tui-kit-render/src/ui/app/screens/placeholder/mod.rs deleted file mode 100644 index b2fa3e9..0000000 --- a/tui/crates/tui-kit-render/src/ui/app/screens/placeholder/mod.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Placeholder bodies for non-interactive tabs. - -use ratatui::{ - buffer::Buffer, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Widget, Wrap}, -}; - -use crate::ui::app::{Focus, TuiKitUi}; - -impl<'a> TuiKitUi<'a> { - pub(crate) fn render_placeholder_screen( - &self, - area: ratatui::layout::Rect, - buf: &mut Buffer, - title: &str, - lead: &str, - detail: &str, - ) { - let body_area = crate::ui::app::shared::layout::body_rect_for_area_with_tabs( - area, - !self.tab_specs.is_empty(), - ); - let body = vec![ - Line::from(""), - Line::from(Span::styled(lead, self.theme.style_normal())), - Line::from(""), - Line::from(Span::styled(detail, self.theme.style_muted())), - Line::from(""), - Line::from(vec![ - Span::styled(" 1 ", self.theme.style_accent()), - Span::styled("Memories", self.theme.style_muted()), - Span::styled(" │ ", self.theme.style_dim()), - Span::styled(" 2 ", self.theme.style_accent()), - Span::styled("Create", self.theme.style_muted()), - ]), - ]; - Paragraph::new(body) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(if self.focus == Focus::Tabs { - self.theme.style_border() - } else { - self.theme.style_border_focused() - }) - .title(format!(" {} ", title)) - .style(Style::default().bg(self.theme.bg_panel)), - ) - .wrap(Wrap { trim: false }) - .render(body_area, buf); - } -} diff --git a/tui/crates/tui-kit-render/src/ui/app/screens/wiki.rs b/tui/crates/tui-kit-render/src/ui/app/screens/wiki.rs new file mode 100644 index 0000000..76715ca --- /dev/null +++ b/tui/crates/tui-kit-render/src/ui/app/screens/wiki.rs @@ -0,0 +1,726 @@ +//! Wiki browser screen composition. + +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph, Widget, Wrap}, +}; +use tui_kit_runtime::{ + CreateSubmitState, PaneRow, ThreePaneMode, ThreePaneSnapshot, WikiEditorFooterFocus, +}; + +use super::{multiline_cursor_x, multiline_visible_row, spinner_frame, visible_multiline_rows}; +use crate::ui::app::{Focus, TuiKitUi, shared}; + +impl<'a> TuiKitUi<'a> { + pub(crate) fn render_wiki_screen(&self, area: Rect, buf: &mut Buffer) { + let body = shared::layout::body_rect_for_area_with_tabs(area, !self.tab_specs.is_empty()); + let default_snapshot = ThreePaneSnapshot::default(); + let snapshot = self.three_pane.unwrap_or(&default_snapshot); + match snapshot.mode { + ThreePaneMode::List => { + self.render_wiki_databases(body, buf, snapshot); + return; + } + ThreePaneMode::Diagnostic => { + self.render_wiki_diagnostic(body, buf, snapshot); + return; + } + _ => {} + } + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(38), + Constraint::Length(1), + Constraint::Min(36), + ]) + .split(body); + + self.render_wiki_browser(chunks[0], buf, snapshot); + self.render_wiki_divider(chunks[1], buf); + self.render_wiki_document(chunks[2], buf, snapshot); + } + + fn render_wiki_databases(&self, area: Rect, buf: &mut Buffer, snapshot: &ThreePaneSnapshot) { + let title = if snapshot.left.loading { + format!( + " Databases | {} Loading... ", + spinner_frame(self.insert_spinner_frame) + ) + } else { + " Databases ".to_string() + }; + let items = if snapshot.left.rows.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + if snapshot.left.loading { + format!(" {} Loading...", spinner_frame(self.insert_spinner_frame)) + } else { + format!(" {}", snapshot.left.empty_message) + }, + self.theme.style_dim(), + )))] + } else { + snapshot + .left + .rows + .iter() + .map(|row| self.render_wiki_database_row(row)) + .collect() + }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(if self.focus == Focus::Items { + self.theme.style_border_focused() + } else { + self.theme.style_border() + }) + .style(Style::default().bg(self.theme.bg_panel)) + .title(title); + List::new(items).block(block).render(area, buf); + } + + fn render_wiki_database_row(&self, row: &PaneRow) -> ListItem<'static> { + let marker = if row.selected { "▸" } else { " " }; + let style = if row.selected { + self.theme.style_normal().add_modifier(Modifier::BOLD) + } else { + self.theme.style_dim() + }; + ListItem::new(vec![ + Line::from(vec![ + Span::styled(format!("{marker} "), self.theme.style_accent()), + Span::styled(row.label.clone(), style), + ]), + Line::from(vec![Span::styled( + format!(" {}", row.detail), + self.theme.style_muted(), + )]), + ]) + } + + fn render_wiki_browser(&self, area: Rect, buf: &mut Buffer, snapshot: &ThreePaneSnapshot) { + let path = selected_browser_path(snapshot); + let title = if snapshot.middle.loading { + format!( + " Browser | {} Loading... ", + spinner_frame(self.insert_spinner_frame) + ) + } else if snapshot.mode == ThreePaneMode::Search { + " Browser (search) ".to_string() + } else { + format!(" Browser | {path} ") + }; + let rows = if snapshot.middle.rows.is_empty() { + vec![PaneRow { + label: if snapshot.middle.loading { + format!("{} Loading...", spinner_frame(self.insert_spinner_frame)) + } else { + "No entries".to_string() + }, + detail: snapshot.middle.empty_message.clone(), + selected: false, + }] + } else { + snapshot.middle.rows.clone() + }; + let items = rows + .iter() + .map(|row| self.render_wiki_browser_row(row, snapshot.mode)) + .collect::>(); + let block = Block::default() + .borders(Borders::ALL) + .border_style(if self.focus == Focus::Items { + self.theme.style_border_focused() + } else { + self.theme.style_border() + }) + .style(Style::default().bg(self.theme.bg_panel)) + .title(title); + List::new(items).block(block).render(area, buf); + } + + fn render_wiki_browser_row(&self, row: &PaneRow, mode: ThreePaneMode) -> ListItem<'static> { + let marker = if row.selected { ">" } else { " " }; + let icon = wiki_browser_icon(row.detail.as_str()); + let style = if row.selected { + self.theme.style_selected() + } else { + self.theme.style_dim() + }; + let (indent, label) = split_tree_indent(row.label.as_str()); + let mut first_line = vec![ + Span::styled(format!("{marker} "), self.theme.style_accent()), + Span::styled(indent, style), + Span::styled(icon, style), + Span::styled(label, style), + ]; + + if mode != ThreePaneMode::Search + && let Some(meta) = wiki_browser_inline_meta(row.detail.as_str()) + { + first_line.push(Span::styled(format!(" {meta}"), self.theme.style_muted())); + } + + let mut lines = vec![Line::from(first_line)]; + if mode == ThreePaneMode::Search && !row.detail.trim().is_empty() { + lines.push(Line::from(Span::styled( + format!(" {}", row.detail), + self.theme.style_muted(), + ))); + } + ListItem::new(lines) + } + + fn render_wiki_divider(&self, area: Rect, buf: &mut Buffer) { + let style = self.theme.style_border(); + for y in area.top()..area.bottom() { + if area.width > 0 + && let Some(cell) = buf.cell_mut((area.x, y)) + { + cell.set_symbol("│").set_style(style); + } + } + } + + fn render_wiki_diagnostic(&self, area: Rect, buf: &mut Buffer, snapshot: &ThreePaneSnapshot) { + let mut lines = Vec::new(); + if let Some(diagnostic) = &snapshot.diagnostic { + for row in &diagnostic.rows { + lines.push(Line::from(vec![ + Span::styled(format!("{}: ", row.label), self.theme.style_muted()), + Span::raw(row.detail.clone()), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(diagnostic.message.clone())); + } else { + lines.push(Line::from("No diagnostics.")); + } + let block = Block::default() + .borders(Borders::ALL) + .border_style(self.theme.style_border_focused()) + .style(Style::default().bg(self.theme.bg_panel)) + .title(" Wiki diagnostics "); + Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + + fn render_wiki_document(&self, area: Rect, buf: &mut Buffer, snapshot: &ThreePaneSnapshot) { + if self.wiki_editor.open { + self.render_wiki_editor(area, buf); + return; + } + let mut lines = Vec::new(); + if let Some(diagnostic) = &snapshot.diagnostic { + for row in &diagnostic.rows { + lines.push(Line::from(vec![ + Span::styled(format!("{}: ", row.label), self.theme.style_muted()), + Span::raw(row.detail.clone()), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(diagnostic.message.clone())); + } else { + lines.extend(snapshot.document.lines.iter().cloned().map(Line::from)); + } + let title = if snapshot.document.title.trim().is_empty() { + " Document ".to_string() + } else { + format!(" Document: {} ", snapshot.document.title) + }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(if self.focus == Focus::Content { + self.theme.style_border_focused() + } else { + self.theme.style_border() + }) + .style(Style::default().bg(self.theme.bg_panel)) + .title(title); + Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((self.inspector_scroll as u16, 0)) + .render(area, buf); + } + + fn render_wiki_editor(&self, area: Rect, buf: &mut Buffer) { + let status = match self.wiki_editor.submit_state { + CreateSubmitState::Submitting => "saving", + CreateSubmitState::Idle | CreateSubmitState::Error if self.wiki_editor.dirty => "dirty", + CreateSubmitState::Idle | CreateSubmitState::Error => "clean", + }; + let title = format!(" Editing: {} [{status}] ", self.wiki_editor.path); + let block = Block::default() + .borders(Borders::ALL) + .border_style(self.wiki_editor_border_style()) + .style(Style::default().bg(self.theme.bg_panel)) + .title(title); + let inner = block.inner(area); + block.render(area, buf); + + if inner.height == 0 { + return; + } + let message_height = + u16::from(self.wiki_editor.discard_confirm || self.wiki_editor.error.is_some()); + let footer_height = 1; + let body_height = inner + .height + .saturating_sub(footer_height) + .saturating_sub(message_height) + .max(1); + let body_area = Rect::new(inner.x, inner.y, inner.width, body_height); + let footer_y = body_area.y.saturating_add(body_area.height); + let footer_area = Rect::new(inner.x, footer_y, inner.width, footer_height); + let message_area = Rect::new( + inner.x, + footer_y.saturating_add(footer_height), + inner.width, + message_height, + ); + + let cursor_row = self + .wiki_editor_cursor + .map(|(row, _)| row) + .unwrap_or_default(); + let visible = visible_multiline_rows( + self.wiki_editor.draft_content.as_str(), + "", + body_height, + cursor_row, + body_area.width, + ); + let lines = visible.rows.into_iter().map(Line::from).collect::>(); + Paragraph::new(lines) + .style(Style::default().bg(self.theme.bg_panel)) + .wrap(Wrap { trim: false }) + .render(body_area, buf); + + Paragraph::new(self.wiki_editor_footer_line()) + .style(Style::default().bg(self.theme.bg_panel)) + .render(footer_area, buf); + + if message_height > 0 { + let message = if self.wiki_editor.discard_confirm { + Line::from(vec![ + Span::styled("Unsaved changes. ", self.theme.style_warning()), + Span::raw("Confirm before leaving edit mode."), + ]) + } else { + Line::from(Span::styled( + self.wiki_editor.error.as_deref().unwrap_or_default(), + self.theme.style_error(), + )) + }; + Paragraph::new(message) + .style(Style::default().bg(self.theme.bg_panel)) + .render(message_area, buf); + } + } + + fn wiki_editor_border_style(&self) -> Style { + if self.wiki_editor.submit_state == CreateSubmitState::Submitting { + return self.theme.style_info().add_modifier(Modifier::BOLD); + } + if self.wiki_editor.error.is_some() { + return self.theme.style_error().add_modifier(Modifier::BOLD); + } + if self.wiki_editor.dirty || self.wiki_editor.discard_confirm { + return self.theme.style_warning().add_modifier(Modifier::BOLD); + } + if self.focus == Focus::Content { + return self.theme.style_accent_bold(); + } + self.theme.style_border() + } + + fn wiki_editor_footer_line(&self) -> Line<'_> { + if self.wiki_editor.discard_confirm { + return Line::from(vec![ + Span::styled( + " Enter ", + self.theme.style_warning().add_modifier(Modifier::BOLD), + ), + Span::styled(" discard changes ", self.theme.style_muted()), + Span::raw(" "), + Span::styled(" Esc ", self.theme.style_accent_bold()), + Span::styled(" continue editing ", self.theme.style_muted()), + ]); + } + Line::from(vec![ + Span::styled( + " Body ", + self.wiki_footer_style(WikiEditorFooterFocus::Body), + ), + Span::raw(" "), + Span::styled( + " Save ", + self.wiki_footer_style(WikiEditorFooterFocus::Save), + ), + Span::raw(" "), + Span::styled( + " Cancel ", + self.wiki_footer_style(WikiEditorFooterFocus::Cancel), + ), + Span::styled(" Ctrl+S save", self.theme.style_muted()), + ]) + } + + fn wiki_footer_style(&self, focus: WikiEditorFooterFocus) -> Style { + if self.wiki_editor.footer_focus == focus && !self.wiki_editor.discard_confirm { + self.theme.style_selected() + } else { + self.theme.style_muted() + } + } + + pub(crate) fn wiki_cursor_position_for_area(&self, area: Rect) -> Option<(u16, u16)> { + if self.focus != Focus::Content || !self.wiki_editor.open { + return None; + } + if self.wiki_editor.footer_focus != WikiEditorFooterFocus::Body + || self.wiki_editor.discard_confirm + { + return None; + } + let body = shared::layout::body_rect_for_area_with_tabs(area, !self.tab_specs.is_empty()); + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(38), + Constraint::Length(1), + Constraint::Min(36), + ]) + .split(body); + let document = chunks[2]; + let inner = Block::default().borders(Borders::ALL).inner(document); + let (cursor_row, cursor_col) = self.wiki_editor_cursor?; + let message_height = + u16::from(self.wiki_editor.discard_confirm || self.wiki_editor.error.is_some()); + let body_height = inner + .height + .saturating_sub(1) + .saturating_sub(message_height) + .max(1); + let visible = visible_multiline_rows( + self.wiki_editor.draft_content.as_str(), + "", + body_height, + cursor_row, + inner.width, + ); + let visible_row = multiline_visible_row(cursor_row, visible.scroll_row, body_height); + let x = inner.x + + multiline_cursor_x( + visible.rows[visible_row as usize].as_str(), + cursor_col, + inner.width, + ); + Some(( + x.min(inner.right().saturating_sub(1)), + inner.y + visible_row, + )) + } +} + +fn selected_browser_path(snapshot: &ThreePaneSnapshot) -> String { + if snapshot.middle.title.trim().is_empty() { + "/".to_string() + } else { + snapshot.middle.title.clone() + } +} + +fn wiki_browser_icon(detail: &str) -> &'static str { + if detail.starts_with("directory expanded") { + "▾ " + } else if detail.starts_with("directory") { + "› " + } else if detail.starts_with("source") { + " S " + } else if detail.starts_with("file") || detail.trim().is_empty() { + " " + } else { + "? " + } +} + +fn split_tree_indent(label: &str) -> (String, String) { + let indent_width = label.len() - label.trim_start_matches(' ').len(); + ( + label[..indent_width].to_string(), + label[indent_width..].to_string(), + ) +} + +fn wiki_browser_inline_meta(detail: &str) -> Option { + if detail.starts_with("directory") || detail == "file" || detail == "source" { + return None; + } + ["file ", "source "].into_iter().find_map(|prefix| { + detail + .strip_prefix(prefix) + .and_then(|value| value.strip_suffix(" bytes")) + .map(|value| format!("{value} bytes")) + }) +} + +#[cfg(test)] +mod tests { + use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; + use tui_kit_runtime::{ + DiagnosticSnapshot, DocumentSnapshot, PaneRow, PaneSnapshot, ThreePaneMode, + ThreePaneSnapshot, kinic_tabs::KINIC_WIKI_TAB_ID, + }; + + use crate::ui::{app::TabId, theme::Theme}; + + use super::*; + + fn rendered(buf: &Buffer) -> String { + buf.content + .iter() + .map(|cell| cell.symbol()) + .collect::>() + .join("") + } + + #[test] + fn wiki_screen_renders_database_list_mode_as_single_pane() { + let theme = Theme::default(); + let snapshot = ThreePaneSnapshot { + left: PaneSnapshot { + rows: vec![ + PaneRow { + label: "db-a".to_string(), + detail: "Hot Owner 42 bytes".to_string(), + selected: false, + }, + PaneRow { + label: "+ Create database".to_string(), + detail: "create new database".to_string(), + selected: true, + }, + ], + ..PaneSnapshot::default() + }, + mode: ThreePaneMode::List, + ..ThreePaneSnapshot::default() + }; + let mut buf = Buffer::empty(Rect::new(0, 0, 110, 30)); + TuiKitUi::new(&theme) + .current_tab_id(TabId::new(KINIC_WIKI_TAB_ID)) + .three_pane_snapshot(&snapshot) + .render(Rect::new(0, 0, 110, 30), &mut buf); + let text = rendered(&buf); + assert!(text.contains("Databases")); + assert!(text.contains("db-a")); + assert!(text.contains("+ Create database")); + assert!(text.contains("create new database")); + assert!(!text.contains("Browser")); + assert!(!text.contains("Document")); + } + + #[test] + fn wiki_screen_renders_browser_mode_as_two_panes() { + let theme = Theme::default(); + let snapshot = ThreePaneSnapshot { + middle: PaneSnapshot { + rows: vec![PaneRow { + label: "/Wiki/index.md".to_string(), + detail: "file".to_string(), + selected: true, + }], + ..PaneSnapshot::default() + }, + document: DocumentSnapshot { + title: "db-a /Wiki/index.md".to_string(), + lines: vec!["hello wiki".to_string()], + }, + mode: ThreePaneMode::Browse, + ..ThreePaneSnapshot::default() + }; + let mut buf = Buffer::empty(Rect::new(0, 0, 110, 30)); + TuiKitUi::new(&theme) + .current_tab_id(TabId::new(KINIC_WIKI_TAB_ID)) + .three_pane_snapshot(&snapshot) + .render(Rect::new(0, 0, 110, 30), &mut buf); + let text = rendered(&buf); + assert!(text.contains("Browser")); + assert!(text.contains("Document")); + assert!(text.contains("/Wiki/index.md")); + assert!(text.contains("hello wiki")); + assert!(!text.contains("Databases")); + } + + #[test] + fn wiki_screen_renders_editor_state_and_discard_confirm() { + let theme = Theme::default(); + let mut editor = tui_kit_runtime::WikiEditorState::default(); + editor.open( + "/Wiki/index.md".to_string(), + "# Index\nbody".to_string(), + "etag".to_string(), + "{}".to_string(), + ); + editor.dirty = true; + editor.discard_confirm = true; + let snapshot = ThreePaneSnapshot { + middle: PaneSnapshot { + title: "/".to_string(), + ..PaneSnapshot::default() + }, + document: DocumentSnapshot { + title: "/Wiki/index.md".to_string(), + lines: vec!["readonly".to_string()], + ..DocumentSnapshot::default() + }, + mode: ThreePaneMode::Browse, + ..ThreePaneSnapshot::default() + }; + let ui = TuiKitUi::new(&theme) + .current_tab_id(TabId::new(KINIC_WIKI_TAB_ID)) + .focus(Focus::Content) + .three_pane_snapshot(&snapshot) + .wiki_editor(editor) + .wiki_editor_cursor(Some((1, 4))); + let mut buf = Buffer::empty(Rect::new(0, 0, 100, 24)); + + ui.render(buf.area, &mut buf); + let text = rendered(&buf); + + assert!(text.contains("Editing: /Wiki/index.md")); + assert!(text.contains("body")); + assert!(text.contains("Unsaved changes.")); + assert!(text.contains("continue editing")); + } + + #[test] + fn wiki_browser_directory_rows_stay_single_line() { + let theme = Theme::default(); + let snapshot = ThreePaneSnapshot { + middle: PaneSnapshot { + rows: vec![ + PaneRow { + label: "Wiki".to_string(), + detail: "directory expanded".to_string(), + selected: true, + }, + PaneRow { + label: "Sources".to_string(), + detail: "directory collapsed".to_string(), + selected: false, + }, + ], + ..PaneSnapshot::default() + }, + document: DocumentSnapshot { + title: "db-a /".to_string(), + lines: vec!["Loading /Wiki and /Sources...".to_string()], + }, + mode: ThreePaneMode::Browse, + ..ThreePaneSnapshot::default() + }; + let mut buf = Buffer::empty(Rect::new(0, 0, 110, 30)); + TuiKitUi::new(&theme) + .current_tab_id(TabId::new(KINIC_WIKI_TAB_ID)) + .three_pane_snapshot(&snapshot) + .render(Rect::new(0, 0, 110, 30), &mut buf); + let text = rendered(&buf); + assert!(text.contains("▾ Wiki")); + assert!(text.contains("› Sources")); + assert!(!text.contains("directory")); + } + + #[test] + fn wiki_screen_renders_database_loading_spinner() { + let theme = Theme::default(); + let snapshot = ThreePaneSnapshot { + left: PaneSnapshot { + empty_message: "No databases".to_string(), + loading: true, + ..PaneSnapshot::default() + }, + mode: ThreePaneMode::List, + ..ThreePaneSnapshot::default() + }; + let mut buf = Buffer::empty(Rect::new(0, 0, 110, 30)); + TuiKitUi::new(&theme) + .current_tab_id(TabId::new(KINIC_WIKI_TAB_ID)) + .insert_spinner_frame(1) + .three_pane_snapshot(&snapshot) + .render(Rect::new(0, 0, 110, 30), &mut buf); + let text = rendered(&buf); + assert!(text.contains("Databases")); + assert!(text.contains("Loading")); + assert!(text.contains("/")); + } + + #[test] + fn wiki_screen_renders_browser_loading_spinner() { + let theme = Theme::default(); + let snapshot = ThreePaneSnapshot { + middle: PaneSnapshot { + empty_message: "Loading /Wiki".to_string(), + loading: true, + ..PaneSnapshot::default() + }, + document: DocumentSnapshot { + title: "db-a /".to_string(), + lines: Vec::new(), + }, + mode: ThreePaneMode::Browse, + ..ThreePaneSnapshot::default() + }; + let mut buf = Buffer::empty(Rect::new(0, 0, 110, 30)); + TuiKitUi::new(&theme) + .current_tab_id(TabId::new(KINIC_WIKI_TAB_ID)) + .insert_spinner_frame(1) + .three_pane_snapshot(&snapshot) + .render(Rect::new(0, 0, 110, 30), &mut buf); + let text = rendered(&buf); + assert!(text.contains("Browser")); + assert!(text.contains("Loading")); + assert!(text.contains("/")); + } + + #[test] + fn wiki_screen_renders_diagnostic_panel() { + let theme = Theme::default(); + let snapshot = ThreePaneSnapshot { + diagnostic: Some(DiagnosticSnapshot { + rows: vec![ + PaneRow { + label: "canister".to_string(), + detail: "aaaaa-aa".to_string(), + selected: false, + }, + PaneRow { + label: "principal".to_string(), + detail: "2vxsx-fae".to_string(), + selected: false, + }, + ], + message: "wiki query failed for list_databases".to_string(), + }), + mode: ThreePaneMode::Diagnostic, + ..ThreePaneSnapshot::default() + }; + let mut buf = Buffer::empty(Rect::new(0, 0, 110, 30)); + TuiKitUi::new(&theme) + .current_tab_id(TabId::new(KINIC_WIKI_TAB_ID)) + .three_pane_snapshot(&snapshot) + .render(Rect::new(0, 0, 110, 30), &mut buf); + let text = rendered(&buf); + assert!(text.contains("Wiki diagnostics")); + assert!(text.contains("wiki query failed for list_databases")); + assert!(text.contains("aaaaa-aa")); + assert!(text.contains("2vxsx-fae")); + } +} diff --git a/tui/crates/tui-kit-render/src/ui/app/shared/status.rs b/tui/crates/tui-kit-render/src/ui/app/shared/status.rs index 81da985..3bdc4b3 100644 --- a/tui/crates/tui-kit-render/src/ui/app/shared/status.rs +++ b/tui/crates/tui-kit-render/src/ui/app/shared/status.rs @@ -28,10 +28,7 @@ impl<'a> TuiKitUi<'a> { let status_line = if matches!(tab_kind(tab_id), TabKind::InsertForm | TabKind::CreateForm) { self.form_status_line(tab_id) - } else if matches!( - tab_kind(tab_id), - TabKind::PlaceholderMarket | TabKind::PlaceholderSettings - ) { + } else if matches!(tab_kind(tab_id), TabKind::PlaceholderSettings) { self.placeholder_status_line(tab_id) } else if self.show_context_panel && self.in_context_items_view { self.context_items_status_line() @@ -293,6 +290,9 @@ impl<'a> TuiKitUi<'a> { Span::styled(" search ", self.theme.style_muted()), ]); } + if matches!(tab_kind(tab_id), TabKind::Wiki) { + return self.wiki_status_line(); + } spans.extend([ Span::styled("Tab", self.theme.style_accent()), Span::styled(" focus ", self.theme.style_muted()), @@ -341,6 +341,78 @@ impl<'a> TuiKitUi<'a> { Line::from(spans) } + fn wiki_status_line(&self) -> Line<'_> { + if self.wiki_editor.open { + if self.wiki_editor.discard_confirm { + return Line::from(vec![ + Span::styled("Wiki Edit", self.theme.style_accent_bold()), + Span::styled(" │ ", self.theme.style_dim()), + Span::styled("Enter", self.theme.style_warning()), + Span::styled(" discard ", self.theme.style_muted()), + Span::styled("Esc", self.theme.style_accent()), + Span::styled(" continue editing ", self.theme.style_muted()), + ]); + } + return Line::from(vec![ + Span::styled("Wiki Edit", self.theme.style_accent_bold()), + Span::styled(" │ ", self.theme.style_dim()), + Span::styled("Ctrl+S", self.theme.style_accent()), + Span::styled(" save ", self.theme.style_muted()), + Span::styled("Tab", self.theme.style_accent()), + Span::styled(" footer ", self.theme.style_muted()), + Span::styled("Esc", self.theme.style_accent()), + Span::styled(" cancel ", self.theme.style_muted()), + ]); + } + let spans = match self.focus { + Focus::Content => vec![ + Span::styled("Wiki Document", self.theme.style_accent_bold()), + Span::styled(" │ ", self.theme.style_dim()), + Span::styled("↑/↓", self.theme.style_accent()), + Span::styled(" scroll ", self.theme.style_muted()), + Span::styled("PgUp/PgDn", self.theme.style_accent()), + Span::styled(" page ", self.theme.style_muted()), + Span::styled("Home/End", self.theme.style_accent()), + Span::styled(" jump ", self.theme.style_muted()), + Span::styled("Esc", self.theme.style_accent()), + Span::styled(" browser ", self.theme.style_muted()), + Span::styled("Tab", self.theme.style_accent()), + Span::styled(" tabs ", self.theme.style_muted()), + ], + Focus::Items => vec![ + Span::styled("Wiki Browser", self.theme.style_accent_bold()), + Span::styled(" │ ", self.theme.style_dim()), + Span::styled("↑/↓", self.theme.style_accent()), + Span::styled(" choose ", self.theme.style_muted()), + Span::styled("Enter", self.theme.style_accent()), + Span::styled(" open ", self.theme.style_muted()), + Span::styled("e", self.theme.style_accent()), + Span::styled(" edit ", self.theme.style_muted()), + Span::styled("Tab", self.theme.style_accent()), + Span::styled(" document ", self.theme.style_muted()), + Span::styled("Esc", self.theme.style_accent()), + Span::styled(" back ", self.theme.style_muted()), + ], + Focus::Tabs => vec![ + Span::styled("Wiki Tabs", self.theme.style_accent_bold()), + Span::styled(" │ ", self.theme.style_dim()), + Span::styled("←/→", self.theme.style_accent()), + Span::styled(" switch ", self.theme.style_muted()), + Span::styled("Enter/Tab", self.theme.style_accent()), + Span::styled(" browser ", self.theme.style_muted()), + Span::styled("1-5", self.theme.style_accent()), + Span::styled(" tabs ", self.theme.style_muted()), + ], + _ => vec![ + Span::styled("Wiki", self.theme.style_accent_bold()), + Span::styled(" │ ", self.theme.style_dim()), + Span::styled("Tab", self.theme.style_accent()), + Span::styled(" focus ", self.theme.style_muted()), + ], + }; + prepend_status_message(self, spans) + } + fn focus_indicator(&self) -> (&'static str, &'static str) { match self.focus { Focus::Search => ("🔍", "Search"), @@ -456,7 +528,7 @@ mod tests { use crate::ui::theme::Theme; use tui_kit_runtime::InsertFormFocus; use tui_kit_runtime::kinic_tabs::{ - KINIC_CREATE_TAB_ID, KINIC_INSERT_TAB_ID, KINIC_SETTINGS_TAB_ID, + KINIC_CREATE_TAB_ID, KINIC_INSERT_TAB_ID, KINIC_SETTINGS_TAB_ID, KINIC_WIKI_TAB_ID, }; fn render_status_line(ui: &TuiKitUi<'_>) -> String { @@ -552,6 +624,40 @@ mod tests { assert!(rendered.contains("hide chat")); } + #[test] + fn wiki_edit_status_switches_to_discard_confirmation_hints() { + let theme = Theme::default(); + let mut editor = tui_kit_runtime::WikiEditorState::default(); + editor.open( + "/Wiki/index.md".to_string(), + "# Index".to_string(), + "etag".to_string(), + "{}".to_string(), + ); + let edit_ui = TuiKitUi::new(&theme) + .current_tab_id(crate::ui::TabId::new(KINIC_WIKI_TAB_ID)) + .focus(Focus::Content) + .wiki_editor(editor.clone()); + let mut discard_editor = editor; + discard_editor.dirty = true; + discard_editor.discard_confirm = true; + let discard_ui = TuiKitUi::new(&theme) + .current_tab_id(crate::ui::TabId::new(KINIC_WIKI_TAB_ID)) + .focus(Focus::Content) + .wiki_editor(discard_editor); + + let edit_status = render_status_line(&edit_ui); + let discard_status = render_status_line(&discard_ui); + + assert!(edit_status.contains("Ctrl+S")); + assert!(edit_status.contains("footer")); + assert!(discard_status.contains("Enter")); + assert!(discard_status.contains("discard")); + assert!(discard_status.contains("Esc")); + assert!(discard_status.contains("continue editing")); + assert!(!discard_status.contains("Ctrl+S")); + } + #[test] fn insert_form_status_keeps_hints_when_status_message_exists() { let theme = Theme::default(); diff --git a/tui/crates/tui-kit-render/src/ui/app/ui_builder.rs b/tui/crates/tui-kit-render/src/ui/app/ui_builder.rs index 00c6731..e02f5bb 100644 --- a/tui/crates/tui-kit-render/src/ui/app/ui_builder.rs +++ b/tui/crates/tui-kit-render/src/ui/app/ui_builder.rs @@ -6,7 +6,7 @@ use crate::ui::search::CompletionCandidate; use tui_kit_runtime::{ AccessControlModalState, CreateCostState, CreateModalFocus, CreateSubmitState, InsertFormFocus, InsertMode, MemorySelection, PickerState, RemoveMemoryModalState, RenameMemoryModalState, - SearchScope, SettingsSnapshot, TextInputModalState, TransferModalState, + SearchScope, SettingsSnapshot, TextInputModalState, ThreePaneSnapshot, TransferModalState, }; use super::{Focus, TabId, TabSpec, TuiKitUi, UiConfig}; @@ -30,6 +30,12 @@ impl<'a> TuiKitUi<'a> { self } + #[must_use] + pub fn three_pane_snapshot(mut self, snapshot: &'a ThreePaneSnapshot) -> Self { + self.three_pane = Some(snapshot); + self + } + #[must_use] pub fn ui_total_count(mut self, count: usize) -> Self { self.ui_total_count = count; @@ -319,6 +325,18 @@ impl<'a> TuiKitUi<'a> { self } + #[must_use] + pub fn wiki_editor(mut self, value: tui_kit_runtime::WikiEditorState) -> Self { + self.wiki_editor = value; + self + } + + #[must_use] + pub fn wiki_editor_cursor(mut self, value: Option<(usize, usize)>) -> Self { + self.wiki_editor_cursor = value; + self + } + #[must_use] pub fn access_control_modal(mut self, value: AccessControlModalState) -> Self { self.access_control = value; diff --git a/tui/crates/tui-kit-render/src/ui/app/ui_state.rs b/tui/crates/tui-kit-render/src/ui/app/ui_state.rs index f2b72c9..0b99e4e 100644 --- a/tui/crates/tui-kit-render/src/ui/app/ui_state.rs +++ b/tui/crates/tui-kit-render/src/ui/app/ui_state.rs @@ -7,7 +7,8 @@ use crate::ui::theme::Theme; use tui_kit_runtime::{ AccessControlModalState, ChatScope, CreateCostState, CreateModalFocus, CreateSubmitState, InsertFormFocus, InsertMode, MemorySelection, PickerState, RemoveMemoryModalState, - RenameMemoryModalState, SearchScope, SettingsSnapshot, TextInputModalState, TransferModalState, + RenameMemoryModalState, SearchScope, SettingsSnapshot, TextInputModalState, ThreePaneSnapshot, + TransferModalState, }; use super::{Focus, TabId, TabSpec, UiConfig, default_tab_specs}; @@ -22,6 +23,7 @@ pub struct TuiKitUi<'a> { pub(super) ui_summaries: &'a [UiItemSummary], pub(super) ui_selected_content: Option<&'a UiItemContent>, pub(super) ui_context_node: Option<&'a UiContextNode>, + pub(super) three_pane: Option<&'a ThreePaneSnapshot>, pub(super) ui_total_count: usize, pub(super) in_context_items_view: bool, pub(super) show_context_panel: bool, @@ -65,6 +67,8 @@ pub struct TuiKitUi<'a> { pub(super) insert_spinner_frame: usize, pub(super) insert_error: Option<&'a str>, pub(super) insert_focus: InsertFormFocus, + pub(super) wiki_editor: tui_kit_runtime::WikiEditorState, + pub(super) wiki_editor_cursor: Option<(usize, usize)>, pub(super) access_control: AccessControlModalState, pub(super) add_memory: TextInputModalState, pub(super) remove_memory: RemoveMemoryModalState, @@ -98,6 +102,7 @@ impl<'a> TuiKitUi<'a> { ui_summaries: &[], ui_selected_content: None, ui_context_node: None, + three_pane: None, ui_total_count: 0, in_context_items_view: false, show_context_panel: false, @@ -141,6 +146,8 @@ impl<'a> TuiKitUi<'a> { insert_spinner_frame: 0, insert_error: None, insert_focus: InsertFormFocus::Mode, + wiki_editor: tui_kit_runtime::WikiEditorState::default(), + wiki_editor_cursor: None, access_control: AccessControlModalState::default(), add_memory: TextInputModalState::default(), remove_memory: RemoveMemoryModalState::default(), diff --git a/tui/crates/tui-kit-runtime/src/kinic_tabs.rs b/tui/crates/tui-kit-runtime/src/kinic_tabs.rs index fd3ec99..e1de990 100644 --- a/tui/crates/tui-kit-runtime/src/kinic_tabs.rs +++ b/tui/crates/tui-kit-runtime/src/kinic_tabs.rs @@ -3,14 +3,14 @@ pub const KINIC_MEMORIES_TAB_ID: &str = "kinic-memories"; pub const KINIC_INSERT_TAB_ID: &str = "kinic-insert"; pub const KINIC_CREATE_TAB_ID: &str = "kinic-create"; -pub const KINIC_MARKET_TAB_ID: &str = "kinic-market"; +pub const KINIC_WIKI_TAB_ID: &str = "kinic-wiki"; pub const KINIC_SETTINGS_TAB_ID: &str = "kinic-settings"; pub const KINIC_TAB_IDS: [&str; 5] = [ KINIC_MEMORIES_TAB_ID, KINIC_INSERT_TAB_ID, KINIC_CREATE_TAB_ID, - KINIC_MARKET_TAB_ID, + KINIC_WIKI_TAB_ID, KINIC_SETTINGS_TAB_ID, ]; @@ -19,7 +19,7 @@ pub enum TabKind { Memories, InsertForm, CreateForm, - PlaceholderMarket, + Wiki, PlaceholderSettings, Unknown, } @@ -29,7 +29,7 @@ pub fn tab_kind(tab_id: &str) -> TabKind { KINIC_MEMORIES_TAB_ID => TabKind::Memories, KINIC_INSERT_TAB_ID => TabKind::InsertForm, KINIC_CREATE_TAB_ID => TabKind::CreateForm, - KINIC_MARKET_TAB_ID => TabKind::PlaceholderMarket, + KINIC_WIKI_TAB_ID => TabKind::Wiki, KINIC_SETTINGS_TAB_ID => TabKind::PlaceholderSettings, _ => TabKind::Unknown, } @@ -51,8 +51,8 @@ pub fn is_kinic_create_tab(tab_id: &str) -> bool { matches!(tab_kind(tab_id), TabKind::CreateForm) } -pub fn is_kinic_market_tab(tab_id: &str) -> bool { - matches!(tab_kind(tab_id), TabKind::PlaceholderMarket) +pub fn is_kinic_wiki_tab(tab_id: &str) -> bool { + matches!(tab_kind(tab_id), TabKind::Wiki) } pub fn is_kinic_settings_tab(tab_id: &str) -> bool { @@ -68,7 +68,7 @@ mod tests { assert_eq!(tab_kind(KINIC_MEMORIES_TAB_ID), TabKind::Memories); assert_eq!(tab_kind(KINIC_INSERT_TAB_ID), TabKind::InsertForm); assert_eq!(tab_kind(KINIC_CREATE_TAB_ID), TabKind::CreateForm); - assert_eq!(tab_kind(KINIC_MARKET_TAB_ID), TabKind::PlaceholderMarket); + assert_eq!(tab_kind(KINIC_WIKI_TAB_ID), TabKind::Wiki); assert_eq!( tab_kind(KINIC_SETTINGS_TAB_ID), TabKind::PlaceholderSettings @@ -76,10 +76,10 @@ mod tests { assert_eq!(tab_kind("unknown"), TabKind::Unknown); assert!(is_kinic_insert_tab(KINIC_INSERT_TAB_ID)); assert!(is_kinic_create_tab(KINIC_CREATE_TAB_ID)); + assert!(is_kinic_wiki_tab(KINIC_WIKI_TAB_ID)); assert!(is_form_tab(KINIC_CREATE_TAB_ID)); assert!(is_form_tab(KINIC_INSERT_TAB_ID)); assert!(is_kinic_memories_tab(KINIC_MEMORIES_TAB_ID)); - assert!(is_kinic_market_tab(KINIC_MARKET_TAB_ID)); assert!(is_kinic_settings_tab(KINIC_SETTINGS_TAB_ID)); assert!(!is_form_tab(KINIC_MEMORIES_TAB_ID)); } diff --git a/tui/crates/tui-kit-runtime/src/lib.rs b/tui/crates/tui-kit-runtime/src/lib.rs index bbff9f0..10a83b8 100644 --- a/tui/crates/tui-kit-runtime/src/lib.rs +++ b/tui/crates/tui-kit-runtime/src/lib.rs @@ -94,6 +94,15 @@ pub fn tab_focus_policy(tab_id: &str) -> TabFocusPolicy { allows_form: false, allows_chat: true, }, + kinic_tabs::TabKind::Wiki => TabFocusPolicy { + default_focus: PaneFocus::Items, + allows_search: true, + allows_items: true, + allows_tabs: true, + allows_content: true, + allows_form: false, + allows_chat: false, + }, kinic_tabs::TabKind::InsertForm | kinic_tabs::TabKind::CreateForm => TabFocusPolicy { default_focus: PaneFocus::Tabs, allows_search: false, @@ -103,17 +112,15 @@ pub fn tab_focus_policy(tab_id: &str) -> TabFocusPolicy { allows_form: true, allows_chat: false, }, - kinic_tabs::TabKind::PlaceholderMarket | kinic_tabs::TabKind::PlaceholderSettings => { - TabFocusPolicy { - default_focus: PaneFocus::Tabs, - allows_search: false, - allows_items: false, - allows_tabs: true, - allows_content: true, - allows_form: false, - allows_chat: false, - } - } + kinic_tabs::TabKind::PlaceholderSettings => TabFocusPolicy { + default_focus: PaneFocus::Tabs, + allows_search: false, + allows_items: false, + allows_tabs: true, + allows_content: true, + allows_form: false, + allows_chat: false, + }, } } @@ -124,10 +131,11 @@ fn chat_supported_for_tab(tab_id: &str) -> bool { pub fn tab_entry_focus(tab_id: &str) -> Option { match kinic_tabs::tab_kind(tab_id) { kinic_tabs::TabKind::Memories => Some(PaneFocus::Search), + kinic_tabs::TabKind::Wiki => Some(PaneFocus::Items), kinic_tabs::TabKind::InsertForm | kinic_tabs::TabKind::CreateForm => Some(PaneFocus::Form), - kinic_tabs::TabKind::PlaceholderMarket - | kinic_tabs::TabKind::PlaceholderSettings - | kinic_tabs::TabKind::Unknown => Some(PaneFocus::Content), + kinic_tabs::TabKind::PlaceholderSettings | kinic_tabs::TabKind::Unknown => { + Some(PaneFocus::Content) + } } } @@ -227,6 +235,82 @@ pub enum RenameModalFocus { Submit, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WikiEditorFooterFocus { + #[default] + Body, + Save, + Cancel, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WikiEditorState { + pub open: bool, + pub path: String, + pub original_content: String, + pub draft_content: String, + pub etag: String, + pub metadata_json: String, + pub dirty: bool, + pub submit_state: CreateSubmitState, + pub error: Option, + pub discard_confirm: bool, + pub footer_focus: WikiEditorFooterFocus, +} + +impl Default for WikiEditorState { + fn default() -> Self { + Self { + open: false, + path: String::new(), + original_content: String::new(), + draft_content: String::new(), + etag: String::new(), + metadata_json: "{}".to_string(), + dirty: false, + submit_state: CreateSubmitState::Idle, + error: None, + discard_confirm: false, + footer_focus: WikiEditorFooterFocus::Body, + } + } +} + +impl WikiEditorState { + pub fn open(&mut self, path: String, content: String, etag: String, metadata_json: String) { + self.open = true; + self.path = path; + self.original_content = content.clone(); + self.draft_content = content; + self.etag = etag; + self.metadata_json = metadata_json; + self.dirty = false; + self.submit_state = CreateSubmitState::Idle; + self.error = None; + self.discard_confirm = false; + self.footer_focus = WikiEditorFooterFocus::Body; + } + + pub fn close(&mut self) { + *self = Self::default(); + } + + pub fn begin_save(&mut self) { + self.submit_state = CreateSubmitState::Submitting; + self.error = None; + self.discard_confirm = false; + } + + pub fn apply_error(&mut self, message: Option) { + self.submit_state = if message.is_some() { + CreateSubmitState::Error + } else { + CreateSubmitState::Idle + }; + self.error = message; + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TransferModalMode { #[default] @@ -622,6 +706,51 @@ pub struct SettingsSnapshot { pub sections: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PaneRow { + pub label: String, + pub detail: String, + pub selected: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PaneSnapshot { + pub title: String, + pub rows: Vec, + pub empty_message: String, + pub loading: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct DocumentSnapshot { + pub title: String, + pub lines: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct DiagnosticSnapshot { + pub rows: Vec, + pub message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ThreePaneMode { + #[default] + List, + Browse, + Search, + Diagnostic, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ThreePaneSnapshot { + pub left: PaneSnapshot, + pub middle: PaneSnapshot, + pub document: DocumentSnapshot, + pub diagnostic: Option, + pub mode: ThreePaneMode, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MemorySelection { pub id: String, @@ -638,7 +767,9 @@ pub struct CoreState { pub list_items: Vec, pub selected_content: Option, pub selected_context: Option, + pub three_pane: ThreePaneSnapshot, pub total_count: usize, + pub content_scroll_reset_epoch: u64, pub status_message: Option, pub persistent_status_message: Option, pub chat_open: bool, @@ -675,6 +806,7 @@ pub struct CoreState { pub insert_spinner_frame: usize, pub insert_error: Option, pub insert_focus: InsertFormFocus, + pub wiki_editor: WikiEditorState, pub access_list_index: usize, pub memory_content_action_index: usize, pub access_control: AccessControlModalState, @@ -694,7 +826,9 @@ impl Default for CoreState { list_items: Vec::new(), selected_content: None, selected_context: None, + three_pane: ThreePaneSnapshot::default(), total_count: 0, + content_scroll_reset_epoch: 0, status_message: None, persistent_status_message: None, chat_open: false, @@ -731,6 +865,7 @@ impl Default for CoreState { insert_spinner_frame: 0, insert_error: None, insert_focus: InsertFormFocus::default(), + wiki_editor: WikiEditorState::default(), access_list_index: 0, memory_content_action_index: 0, access_control: AccessControlModalState::default(), @@ -751,6 +886,8 @@ pub enum CoreAction { MovePageUp, MoveHome, MoveEnd, + ScrollContentLineDown, + ScrollContentLineUp, ScrollContentPageDown, ScrollContentPageUp, ScrollContentHome, @@ -840,6 +977,13 @@ pub enum CoreAction { InsertPrevMode, InsertNextMode, InsertSubmit, + OpenWikiEditor, + SaveWikiEditor, + CancelWikiEditor, + ConfirmDiscardWikiEditor, + AbortDiscardWikiEditor, + WikiEditorNextField, + WikiEditorPrevField, Submit, Cancel, ChatInput(char), @@ -914,8 +1058,25 @@ pub enum CoreEffect { CreateFormError(Option), /// Validation or async error for the insert form (clears submitting state). InsertFormError(Option), + ResetContentScroll, + OpenWikiEditor { + path: String, + content: String, + etag: String, + metadata_json: String, + }, + WikiEditorSaving, + WikiEditorSaved { + path: String, + content: String, + etag: String, + metadata_json: String, + }, + WikiEditorError(Option), /// Select the first row in the list (no-op when empty). SelectFirstListItem, + /// Select a specific row in the list (clamped by the host after snapshots apply). + SelectListItem(usize), /// Move keyboard focus to a pane. FocusPane(PaneFocus), /// Clear create form fields and switch the active tab (e.g. after successful create). @@ -980,6 +1141,7 @@ pub struct ProviderSnapshot { pub selected_index: Option, pub selected_content: Option, pub selected_context: Option, + pub three_pane: ThreePaneSnapshot, pub total_count: usize, pub status_message: Option, pub selected_memory: Option, @@ -1016,6 +1178,7 @@ pub enum CoreKey { Slash, Tab, BackTab, + Esc, Backspace, Enter, Down, @@ -1438,6 +1601,43 @@ pub fn apply_core_action(state: &mut CoreState, action: &CoreAction) { state.create_cost_state = CreateCostState::Loading; state.create_spinner_frame = 0; } + CoreAction::WikiEditorNextField => { + if state.wiki_editor.submit_state == CreateSubmitState::Submitting { + return; + } + state.wiki_editor.footer_focus = next_wiki_editor_focus(state.wiki_editor.footer_focus); + state.wiki_editor.error = None; + state.wiki_editor.discard_confirm = false; + } + CoreAction::WikiEditorPrevField => { + if state.wiki_editor.submit_state == CreateSubmitState::Submitting { + return; + } + state.wiki_editor.footer_focus = prev_wiki_editor_focus(state.wiki_editor.footer_focus); + state.wiki_editor.error = None; + state.wiki_editor.discard_confirm = false; + } + CoreAction::CancelWikiEditor => { + if state.wiki_editor.submit_state == CreateSubmitState::Submitting { + return; + } + if state.wiki_editor.dirty { + state.wiki_editor.discard_confirm = true; + } else { + state.wiki_editor.close(); + state.focus = PaneFocus::Items; + } + } + CoreAction::ConfirmDiscardWikiEditor => { + if state.wiki_editor.submit_state != CreateSubmitState::Submitting { + state.wiki_editor.close(); + state.focus = PaneFocus::Items; + } + } + CoreAction::AbortDiscardWikiEditor => { + state.wiki_editor.discard_confirm = false; + } + CoreAction::OpenWikiEditor | CoreAction::SaveWikiEditor => {} CoreAction::SetQuery(q) => { state.query = q.clone(); state.selected_index = Some(0); @@ -1461,6 +1661,13 @@ pub fn apply_core_action(state: &mut CoreState, action: &CoreAction) { } } CoreAction::SetTab(tab_id) => { + if state.wiki_editor.submit_state == CreateSubmitState::Submitting { + return; + } + if state.wiki_editor.open && state.wiki_editor.dirty { + state.wiki_editor.discard_confirm = true; + return; + } state.current_tab_id = tab_id.0.clone(); if !chat_supported_for_tab(state.current_tab_id.as_str()) { state.chat_open = false; @@ -1477,93 +1684,150 @@ pub fn apply_core_action(state: &mut CoreState, action: &CoreAction) { close_remove_memory_modal(state); close_rename_memory_modal(state); close_transfer_modal(state); + state.wiki_editor.close(); } CoreAction::SelectTabIndex(index) => { state.current_tab_id = format!("tab-{}", index + 1); state.selected_index = Some(0); } CoreAction::FocusNext => { - state.focus = match state.focus { - PaneFocus::Search => PaneFocus::Items, - PaneFocus::Items => { - if memories_chat_replaces_content(state) { - PaneFocus::Extra - } else { - PaneFocus::Content - } + let next_focus = if state.current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID { + match state.focus { + PaneFocus::Tabs => PaneFocus::Items, + PaneFocus::Items => PaneFocus::Content, + PaneFocus::Content => PaneFocus::Tabs, + _ => tab_entry_focus(state.current_tab_id.as_str()).unwrap_or(PaneFocus::Items), } - PaneFocus::Content => { - if state.chat_open { - PaneFocus::Extra - } else if has_tabs { - PaneFocus::Tabs - } else { - PaneFocus::Search + } else { + match state.focus { + PaneFocus::Search => PaneFocus::Items, + PaneFocus::Items => { + if memories_chat_replaces_content(state) { + PaneFocus::Extra + } else { + PaneFocus::Content + } } - } - PaneFocus::Form => PaneFocus::Tabs, - PaneFocus::Extra => { - if has_tabs { - PaneFocus::Tabs - } else { - PaneFocus::Search + PaneFocus::Content => { + if state.chat_open { + PaneFocus::Extra + } else if has_tabs { + PaneFocus::Tabs + } else { + PaneFocus::Search + } } + PaneFocus::Form => PaneFocus::Tabs, + PaneFocus::Extra => { + if has_tabs { + PaneFocus::Tabs + } else { + PaneFocus::Search + } + } + PaneFocus::Tabs => PaneFocus::Search, } - PaneFocus::Tabs => PaneFocus::Search, }; + if is_focus_allowed_for_policy( + tab_focus_policy(state.current_tab_id.as_str()), + next_focus, + ) { + state.focus = next_focus; + } } CoreAction::FocusPrev => { - state.focus = match state.focus { - PaneFocus::Search => { - if has_tabs { - PaneFocus::Tabs - } else if state.chat_open { - PaneFocus::Extra - } else { - PaneFocus::Content - } + let next_focus = if state.current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID { + match state.focus { + PaneFocus::Tabs => PaneFocus::Content, + PaneFocus::Content => PaneFocus::Items, + PaneFocus::Items => PaneFocus::Tabs, + _ => PaneFocus::Tabs, } - PaneFocus::Items => PaneFocus::Search, - PaneFocus::Content => PaneFocus::Items, - PaneFocus::Form => PaneFocus::Tabs, - PaneFocus::Extra => { - if memories_chat_replaces_content(state) { - PaneFocus::Items - } else { - PaneFocus::Content + } else { + match state.focus { + PaneFocus::Search => { + if has_tabs { + PaneFocus::Tabs + } else if state.chat_open { + PaneFocus::Extra + } else { + PaneFocus::Content + } } - } - PaneFocus::Tabs => { - if state.chat_open { - PaneFocus::Extra - } else { - PaneFocus::Content + PaneFocus::Items => PaneFocus::Search, + PaneFocus::Content => PaneFocus::Items, + PaneFocus::Form => PaneFocus::Tabs, + PaneFocus::Extra => { + if memories_chat_replaces_content(state) { + PaneFocus::Items + } else { + PaneFocus::Content + } + } + PaneFocus::Tabs => { + if state.chat_open { + PaneFocus::Extra + } else { + PaneFocus::Content + } } } }; + if is_focus_allowed_for_policy( + tab_focus_policy(state.current_tab_id.as_str()), + next_focus, + ) { + state.focus = next_focus; + } + } + CoreAction::FocusSearch => { + if is_focus_allowed_for_policy( + tab_focus_policy(state.current_tab_id.as_str()), + PaneFocus::Search, + ) { + state.focus = PaneFocus::Search; + } + } + CoreAction::FocusItems => { + if is_focus_allowed_for_policy( + tab_focus_policy(state.current_tab_id.as_str()), + PaneFocus::Items, + ) { + state.focus = PaneFocus::Items; + } } - CoreAction::FocusSearch => state.focus = PaneFocus::Search, - CoreAction::FocusItems => state.focus = PaneFocus::Items, CoreAction::FocusContent => { - state.focus = PaneFocus::Content; - if state.current_tab_id == kinic_tabs::KINIC_MEMORIES_TAB_ID { - state.memory_content_action_index = 0; + if is_focus_allowed_for_policy( + tab_focus_policy(state.current_tab_id.as_str()), + PaneFocus::Content, + ) { + state.focus = PaneFocus::Content; + if state.current_tab_id == kinic_tabs::KINIC_MEMORIES_TAB_ID { + state.memory_content_action_index = 0; + } } } CoreAction::FocusForm => { - state.focus = PaneFocus::Form; - match kinic_tabs::tab_kind(state.current_tab_id.as_str()) { - kinic_tabs::TabKind::CreateForm => { - state.create_focus = CreateModalFocus::Name; - } - kinic_tabs::TabKind::InsertForm => { - state.insert_focus = InsertFormFocus::Mode; + if is_focus_allowed_for_policy( + tab_focus_policy(state.current_tab_id.as_str()), + PaneFocus::Form, + ) { + state.focus = PaneFocus::Form; + match kinic_tabs::tab_kind(state.current_tab_id.as_str()) { + kinic_tabs::TabKind::CreateForm => { + state.create_focus = CreateModalFocus::Name; + } + kinic_tabs::TabKind::InsertForm => { + state.insert_focus = InsertFormFocus::Mode; + } + _ => {} } - _ => {} } } CoreAction::OpenSelected => { - if memories_chat_replaces_content(state) { + if state.current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID { + state.focus = PaneFocus::Items; + } else if memories_chat_replaces_content(state) { state.focus = PaneFocus::Extra; } else { state.focus = PaneFocus::Content; @@ -1575,11 +1839,17 @@ pub fn apply_core_action(state: &mut CoreState, action: &CoreAction) { } } CoreAction::Back => { - state.focus = if state.focus == PaneFocus::Extra { + let next_focus = if state.focus == PaneFocus::Extra { PaneFocus::Content } else { PaneFocus::Items }; + if is_focus_allowed_for_policy( + tab_focus_policy(state.current_tab_id.as_str()), + next_focus, + ) { + state.focus = next_focus; + } } CoreAction::ToggleChat => { if !chat_supported_for_tab(state.current_tab_id.as_str()) { @@ -1926,6 +2196,22 @@ fn prev_create_focus(focus: CreateModalFocus) -> CreateModalFocus { } } +fn next_wiki_editor_focus(focus: WikiEditorFooterFocus) -> WikiEditorFooterFocus { + match focus { + WikiEditorFooterFocus::Body => WikiEditorFooterFocus::Save, + WikiEditorFooterFocus::Save => WikiEditorFooterFocus::Cancel, + WikiEditorFooterFocus::Cancel => WikiEditorFooterFocus::Body, + } +} + +fn prev_wiki_editor_focus(focus: WikiEditorFooterFocus) -> WikiEditorFooterFocus { + match focus { + WikiEditorFooterFocus::Body => WikiEditorFooterFocus::Cancel, + WikiEditorFooterFocus::Save => WikiEditorFooterFocus::Body, + WikiEditorFooterFocus::Cancel => WikiEditorFooterFocus::Save, + } +} + fn next_insert_focus(mode: InsertMode, focus: InsertFormFocus) -> InsertFormFocus { let order = insert_focus_order(mode); let current = order @@ -2508,7 +2794,9 @@ pub fn action_for_key(key: CoreKey, focus: PaneFocus, current_tab_id: &str) -> O } match key { - CoreKey::Slash => Some(CoreAction::FocusSearch), + CoreKey::Slash if tab_focus_policy(current_tab_id).allows_search => { + Some(CoreAction::FocusSearch) + } CoreKey::Tab => Some(CoreAction::FocusNext), CoreKey::BackTab => Some(CoreAction::FocusPrev), CoreKey::Char(c) if c.is_ascii_digit() && c != '0' => { @@ -2530,19 +2818,53 @@ pub fn action_for_key(key: CoreKey, focus: PaneFocus, current_tab_id: &str) -> O _ => None, }, PaneFocus::Items => match key { + CoreKey::Esc if current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID => { + Some(CoreAction::Back) + } + CoreKey::Char('e') if current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID => { + Some(CoreAction::OpenWikiEditor) + } CoreKey::Down => Some(CoreAction::MoveNext), CoreKey::Up => Some(CoreAction::MovePrev), CoreKey::PageDown => Some(CoreAction::MovePageDown), CoreKey::PageUp => Some(CoreAction::MovePageUp), CoreKey::Home | CoreKey::Char('g') => Some(CoreAction::MoveHome), CoreKey::End | CoreKey::Char('G') => Some(CoreAction::MoveEnd), - CoreKey::Enter | CoreKey::Right | CoreKey::Char('l') => { - Some(CoreAction::OpenSelected) - } + CoreKey::Enter | CoreKey::Char('l') => Some(CoreAction::OpenSelected), _ => None, }, PaneFocus::Tabs => None, PaneFocus::Content => match key { + CoreKey::Esc if current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID => { + Some(CoreAction::FocusItems) + } + CoreKey::Down if current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID => { + Some(CoreAction::ScrollContentLineDown) + } + CoreKey::Up if current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID => { + Some(CoreAction::ScrollContentLineUp) + } + CoreKey::PageDown if current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID => { + Some(CoreAction::ScrollContentPageDown) + } + CoreKey::PageUp if current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID => { + Some(CoreAction::ScrollContentPageUp) + } + CoreKey::Home | CoreKey::Char('g') + if current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID => + { + Some(CoreAction::ScrollContentHome) + } + CoreKey::End | CoreKey::Char('G') + if current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID => + { + Some(CoreAction::ScrollContentEnd) + } + CoreKey::Left | CoreKey::Char('h') + if current_tab_id == kinic_tabs::KINIC_WIKI_TAB_ID => + { + Some(CoreAction::FocusItems) + } CoreKey::Enter if is_settings_content(current_tab_id, PaneFocus::Content) => None, CoreKey::Enter if current_tab_id == kinic_tabs::KINIC_MEMORIES_TAB_ID => { Some(CoreAction::MemoryContentOpenSelected) @@ -2553,7 +2875,11 @@ pub fn action_for_key(key: CoreKey, focus: PaneFocus, current_tab_id: &str) -> O CoreKey::Up if current_tab_id == kinic_tabs::KINIC_MEMORIES_TAB_ID => { Some(CoreAction::MemoryContentMovePrev) } - CoreKey::Left | CoreKey::Char('h') => Some(CoreAction::Back), + CoreKey::Left | CoreKey::Char('h') + if current_tab_id != kinic_tabs::KINIC_WIKI_TAB_ID => + { + Some(CoreAction::Back) + } _ if is_settings_content(current_tab_id, PaneFocus::Content) => { settings_content_action_for_key(key) } @@ -2597,6 +2923,7 @@ pub fn apply_snapshot(state: &mut CoreState, snapshot: ProviderSnapshot) -> Prov let snapshot_selected_index = snapshot.selected_index; state.selected_content = snapshot.selected_content; state.selected_context = snapshot.selected_context; + state.three_pane = snapshot.three_pane; state.total_count = snapshot.total_count; if state.persistent_status_message.is_none() { state.status_message = snapshot.status_message; @@ -3095,6 +3422,55 @@ mod tests { assert_eq!(state.selected_index, Some(1)); } + #[test] + fn wiki_database_list_move_next_reaches_create_action_row() { + let mut state = CoreState { + current_tab_id: kinic_tabs::KINIC_WIKI_TAB_ID.to_string(), + list_items: vec![runtime_test_item("db-a"), runtime_test_item("wiki-create")], + selected_index: Some(0), + ..CoreState::default() + }; + + apply_core_action(&mut state, &CoreAction::MoveNext); + + assert_eq!(state.selected_index, Some(1)); + } + + #[test] + fn apply_snapshot_preserves_wiki_create_action_selection() { + let mut state = CoreState { + current_tab_id: kinic_tabs::KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(0), + ..CoreState::default() + }; + let snapshot = ProviderSnapshot { + items: vec![runtime_test_item("db-a"), runtime_test_item("wiki-create")], + selected_index: Some(1), + selected_content: None, + total_count: 1, + ..ProviderSnapshot::default() + }; + + apply_snapshot(&mut state, snapshot); + + assert_eq!(state.selected_index, Some(1)); + assert_eq!(state.selected_content, None); + assert_eq!(state.total_count, 1); + } + + fn runtime_test_item(id: &str) -> UiItemSummary { + UiItemSummary { + id: id.to_string(), + name: id.to_string(), + leading_marker: None, + kind: tui_kit_model::UiItemKind::Custom("x".to_string()), + visibility: tui_kit_model::UiVisibility::Private, + qualified_name: None, + subtitle: None, + tags: vec![], + } + } + #[test] fn focus_content_resets_memory_content_action_index_on_memories_tab() { let mut state = CoreState { @@ -3139,6 +3515,19 @@ mod tests { assert_eq!(state.memory_content_action_index, 2); } + #[test] + fn open_selected_keeps_focus_on_wiki_browser() { + let mut state = CoreState { + current_tab_id: kinic_tabs::KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Items, + ..CoreState::default() + }; + + apply_core_action(&mut state, &CoreAction::OpenSelected); + + assert_eq!(state.focus, PaneFocus::Items); + } + #[test] fn test_dispatch_action_applies_provider_snapshot() { struct DispatchTestProvider; @@ -3198,6 +3587,69 @@ mod tests { assert!(!state.chat_open); } + #[test] + fn set_tab_prompts_before_discarding_dirty_wiki_editor() { + let mut state = CoreState { + current_tab_id: kinic_tabs::KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(2), + ..CoreState::default() + }; + state.wiki_editor.open = true; + state.wiki_editor.dirty = true; + + apply_core_action( + &mut state, + &CoreAction::SetTab(CoreTabId::new(kinic_tabs::KINIC_MEMORIES_TAB_ID)), + ); + + assert_eq!(state.current_tab_id, kinic_tabs::KINIC_WIKI_TAB_ID); + assert_eq!(state.selected_index, Some(2)); + assert!(state.wiki_editor.open); + assert!(state.wiki_editor.dirty); + assert!(state.wiki_editor.discard_confirm); + } + + #[test] + fn set_tab_closes_clean_wiki_editor() { + let mut state = CoreState { + current_tab_id: kinic_tabs::KINIC_WIKI_TAB_ID.to_string(), + ..CoreState::default() + }; + state.wiki_editor.open = true; + + apply_core_action( + &mut state, + &CoreAction::SetTab(CoreTabId::new(kinic_tabs::KINIC_MEMORIES_TAB_ID)), + ); + + assert_eq!(state.current_tab_id, kinic_tabs::KINIC_MEMORIES_TAB_ID); + assert!(!state.wiki_editor.open); + } + + #[test] + fn set_tab_is_blocked_while_wiki_editor_is_submitting() { + let mut state = CoreState { + current_tab_id: kinic_tabs::KINIC_WIKI_TAB_ID.to_string(), + selected_index: Some(3), + ..CoreState::default() + }; + state.wiki_editor.open = true; + state.wiki_editor.submit_state = CreateSubmitState::Submitting; + + apply_core_action( + &mut state, + &CoreAction::SetTab(CoreTabId::new(kinic_tabs::KINIC_MEMORIES_TAB_ID)), + ); + + assert_eq!(state.current_tab_id, kinic_tabs::KINIC_WIKI_TAB_ID); + assert_eq!(state.selected_index, Some(3)); + assert!(state.wiki_editor.open); + assert_eq!( + state.wiki_editor.submit_state, + CreateSubmitState::Submitting + ); + } + #[test] fn focus_search_is_blocked_on_create_tab() { let mut state = CoreState { @@ -3210,15 +3662,51 @@ mod tests { assert_eq!(state.focus, PaneFocus::Tabs); } + #[test] + fn focus_search_is_allowed_on_wiki_tab() { + let mut state = CoreState { + current_tab_id: kinic_tabs::KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Content, + ..CoreState::default() + }; + + apply_core_action(&mut state, &CoreAction::FocusSearch); + assert_eq!(state.focus, PaneFocus::Search); + } + #[test] fn focus_next_stays_visible_on_placeholder_tabs() { let mut state = CoreState { - current_tab_id: kinic_tabs::KINIC_MARKET_TAB_ID.to_string(), + current_tab_id: kinic_tabs::KINIC_SETTINGS_TAB_ID.to_string(), + focus: PaneFocus::Tabs, + ..CoreState::default() + }; + + apply_core_action(&mut state, &CoreAction::FocusNext); + assert_eq!(state.focus, PaneFocus::Tabs); + } + + #[test] + fn focus_next_enters_browser_on_wiki() { + let mut state = CoreState { + current_tab_id: kinic_tabs::KINIC_WIKI_TAB_ID.to_string(), focus: PaneFocus::Tabs, ..CoreState::default() }; apply_core_action(&mut state, &CoreAction::FocusNext); + assert_eq!(state.focus, PaneFocus::Items); + } + + #[test] + fn focus_prev_returns_from_browser_to_tabs_on_wiki() { + let mut state = CoreState { + current_tab_id: kinic_tabs::KINIC_WIKI_TAB_ID.to_string(), + focus: PaneFocus::Items, + ..CoreState::default() + }; + + apply_core_action(&mut state, &CoreAction::FocusPrev); assert_eq!(state.focus, PaneFocus::Tabs); } @@ -3389,6 +3877,50 @@ mod tests { ); } + #[test] + fn tabs_enter_targets_browser_on_wiki() { + assert_eq!( + action_for_key( + CoreKey::Enter, + PaneFocus::Tabs, + kinic_tabs::KINIC_WIKI_TAB_ID + ), + Some(CoreAction::FocusItems) + ); + } + + #[test] + fn tabs_tab_targets_browser_on_wiki() { + assert_eq!( + action_for_key(CoreKey::Tab, PaneFocus::Tabs, kinic_tabs::KINIC_WIKI_TAB_ID), + Some(CoreAction::FocusItems) + ); + } + + #[test] + fn slash_targets_search_on_wiki() { + assert_eq!( + action_for_key( + CoreKey::Slash, + PaneFocus::Content, + kinic_tabs::KINIC_WIKI_TAB_ID + ), + Some(CoreAction::FocusSearch) + ); + } + + #[test] + fn wiki_browser_e_opens_editor_action() { + assert_eq!( + action_for_key( + CoreKey::Char('e'), + PaneFocus::Items, + kinic_tabs::KINIC_WIKI_TAB_ID + ), + Some(CoreAction::OpenWikiEditor) + ); + } + #[test] fn tabs_jk_no_longer_switch_tabs() { assert_eq!( @@ -3439,7 +3971,7 @@ mod tests { action_for_key( CoreKey::Enter, PaneFocus::Tabs, - kinic_tabs::KINIC_MARKET_TAB_ID + kinic_tabs::KINIC_SETTINGS_TAB_ID ), Some(CoreAction::FocusContent) ); @@ -3451,7 +3983,7 @@ mod tests { action_for_key( CoreKey::Tab, PaneFocus::Tabs, - kinic_tabs::KINIC_MARKET_TAB_ID + kinic_tabs::KINIC_SETTINGS_TAB_ID ), Some(CoreAction::FocusContent) ); @@ -3536,6 +4068,80 @@ mod tests { ); } + #[test] + fn wiki_items_keys_move_browser_selection() { + let cases = [ + (CoreKey::Down, CoreAction::MoveNext), + (CoreKey::Up, CoreAction::MovePrev), + (CoreKey::PageDown, CoreAction::MovePageDown), + (CoreKey::PageUp, CoreAction::MovePageUp), + (CoreKey::Home, CoreAction::MoveHome), + (CoreKey::Char('g'), CoreAction::MoveHome), + (CoreKey::End, CoreAction::MoveEnd), + (CoreKey::Char('G'), CoreAction::MoveEnd), + (CoreKey::Enter, CoreAction::OpenSelected), + (CoreKey::Char('l'), CoreAction::OpenSelected), + (CoreKey::Esc, CoreAction::Back), + ]; + + for (key, action) in cases { + assert_eq!( + action_for_key(key, PaneFocus::Items, kinic_tabs::KINIC_WIKI_TAB_ID), + Some(action) + ); + } + + for key in [CoreKey::Left, CoreKey::Right] { + assert_eq!( + action_for_key(key, PaneFocus::Items, kinic_tabs::KINIC_WIKI_TAB_ID), + None + ); + } + } + + #[test] + fn wiki_content_keys_scroll_document() { + let cases = [ + (CoreKey::Down, CoreAction::ScrollContentLineDown), + (CoreKey::Up, CoreAction::ScrollContentLineUp), + (CoreKey::PageDown, CoreAction::ScrollContentPageDown), + (CoreKey::PageUp, CoreAction::ScrollContentPageUp), + (CoreKey::Home, CoreAction::ScrollContentHome), + (CoreKey::Char('g'), CoreAction::ScrollContentHome), + (CoreKey::End, CoreAction::ScrollContentEnd), + (CoreKey::Char('G'), CoreAction::ScrollContentEnd), + ]; + + for (key, action) in cases { + assert_eq!( + action_for_key(key, PaneFocus::Content, kinic_tabs::KINIC_WIKI_TAB_ID), + Some(action) + ); + } + } + + #[test] + fn wiki_content_left_h_and_escape_return_to_browser() { + for key in [CoreKey::Left, CoreKey::Char('h'), CoreKey::Esc] { + assert_eq!( + action_for_key(key, PaneFocus::Content, kinic_tabs::KINIC_WIKI_TAB_ID), + Some(CoreAction::FocusItems) + ); + } + } + + #[test] + fn wiki_items_esc_goes_back() { + assert_eq!( + action_for_key( + CoreKey::Esc, + PaneFocus::Items, + kinic_tabs::KINIC_WIKI_TAB_ID + ), + Some(CoreAction::Back) + ); + } + #[test] fn memories_items_tab_moves_focus_to_content() { let mut state = CoreState { diff --git a/wasm/wiki/vfs.did b/wasm/wiki/vfs.did new file mode 100644 index 0000000..5d8224b --- /dev/null +++ b/wasm/wiki/vfs.did @@ -0,0 +1,347 @@ +type AppendNodeRequest = record { + content : text; + separator : opt text; + kind : opt NodeKind; + path : text; + expected_etag : opt text; + metadata_json : opt text; + database_id : text; +}; +type CanisterHealth = record { cycles_balance : nat }; +type CanonicalRole = record { + name : text; + path_pattern : text; + purpose : text; +}; +type ChildNode = record { + updated_at : opt int64; + etag : opt text; + kind : NodeEntryKind; + name : text; + size_bytes : opt nat64; + path : text; + has_children : bool; + is_virtual : bool; +}; +type DatabaseArchiveChunk = record { bytes : blob }; +type DatabaseArchiveInfo = record { size_bytes : nat64; database_id : text }; +type DatabaseMember = record { + "principal" : text; + role : DatabaseRole; + created_at_ms : int64; + database_id : text; +}; +type DatabaseRestoreChunkRequest = record { + offset : nat64; + database_id : text; + bytes : blob; +}; +type DatabaseRole = variant { Reader; Writer; Owner }; +type DatabaseStatus = variant { Hot; Restoring; Archiving; Archived; Deleted }; +type DatabaseSummary = record { + status : DatabaseStatus; + role : DatabaseRole; + logical_size_bytes : nat64; + database_id : text; + archived_at_ms : opt int64; + deleted_at_ms : opt int64; +}; +type DeleteNodeRequest = record { + path : text; + expected_etag : opt text; + database_id : text; +}; +type DeleteNodeResult = record { path : text }; +type EditNodeRequest = record { + path : text; + old_text : text; + replace_all : bool; + expected_etag : opt text; + new_text : text; + database_id : text; +}; +type EditNodeResult = record { + node : NodeMutationAck; + replacement_count : nat32; +}; +type ExportSnapshotRequest = record { + snapshot_revision : opt text; + cursor : opt text; + limit : nat32; + database_id : text; + prefix : opt text; + snapshot_session_id : opt text; +}; +type ExportSnapshotResponse = record { + snapshot_revision : text; + nodes : vec Node; + next_cursor : opt text; + snapshot_session_id : opt text; +}; +type FetchUpdatesRequest = record { + known_snapshot_revision : text; + cursor : opt text; + limit : nat32; + database_id : text; + prefix : opt text; + target_snapshot_revision : opt text; +}; +type FetchUpdatesResponse = record { + removed_paths : vec text; + snapshot_revision : text; + changed_nodes : vec Node; + next_cursor : opt text; +}; +type GlobNodeHit = record { + kind : NodeEntryKind; + path : text; + has_children : bool; +}; +type GlobNodeType = variant { Any; File; Directory }; +type GlobNodesRequest = record { + node_type : opt GlobNodeType; + pattern : text; + path : opt text; + database_id : text; +}; +type GraphLinksRequest = record { + limit : nat32; + database_id : text; + prefix : text; +}; +type GraphNeighborhoodRequest = record { + center_path : text; + limit : nat32; + database_id : text; + depth : nat32; +}; +type IncomingLinksRequest = record { + path : text; + limit : nat32; + database_id : text; +}; +type OutgoingLinksRequest = record { path : text; limit : nat32; database_id : text }; +type LinkEdge = record { + updated_at : int64; + link_kind : text; + link_text : text; + source_path : text; + raw_href : text; + target_path : text; +}; +type ListChildrenRequest = record { path : text; database_id : text }; +type ListNodesRequest = record { + recursive : bool; + database_id : text; + prefix : text; +}; +type MemoryCapability = record { name : text; description : text }; +type MemoryManifest = record { + recommended_entrypoint : text; + api_version : text; + capabilities : vec MemoryCapability; + write_policy : text; + budget_unit : text; + canonical_roles : vec CanonicalRole; + max_depth : nat32; + max_query_limit : nat32; + purpose : text; + roots : vec MemoryRoot; +}; +type MemoryRoot = record { kind : text; path : text }; +type MkdirNodeRequest = record { path : text; database_id : text }; +type MkdirNodeResult = record { created : bool; path : text }; +type MoveNodeRequest = record { + from_path : text; + to_path : text; + expected_etag : opt text; + overwrite : bool; + database_id : text; +}; +type MoveNodeResult = record { + from_path : text; + node : NodeMutationAck; + overwrote : bool; +}; +type MultiEdit = record { old_text : text; new_text : text }; +type MultiEditNodeRequest = record { + path : text; + edits : vec MultiEdit; + expected_etag : opt text; + database_id : text; +}; +type Node = record { + updated_at : int64; + content : text; + etag : text; + kind : NodeKind; + path : text; + created_at : int64; + metadata_json : text; +}; +type NodeContext = record { + incoming_links : vec LinkEdge; + node : Node; + outgoing_links : vec LinkEdge; +}; +type NodeContextRequest = record { + link_limit : nat32; + path : text; + database_id : text; +}; +type NodeEntry = record { + updated_at : int64; + etag : text; + kind : NodeEntryKind; + path : text; + has_children : bool; +}; +type NodeEntryKind = variant { File; Source; Directory }; +type NodeKind = variant { File; Source }; +type NodeMutationAck = record { + updated_at : int64; + etag : text; + kind : NodeKind; + path : text; +}; +type QueryContext = record { + truncated : bool; + task : text; + evidence : vec SourceEvidence; + nodes : vec NodeContext; + graph_links : vec LinkEdge; + search_hits : vec SearchNodeHit; + namespace : text; +}; +type QueryContextRequest = record { + task : text; + include_evidence : bool; + entities : vec text; + budget_tokens : nat32; + database_id : text; + depth : nat32; + namespace : opt text; +}; +type RecentNodeHit = record { + updated_at : int64; + etag : text; + kind : NodeKind; + path : text; +}; +type RecentNodesRequest = record { + path : opt text; + limit : nat32; + database_id : text; +}; +type Result = variant { Ok : WriteNodeResult; Err : text }; +type Result_1 = variant { Ok : DatabaseArchiveInfo; Err : text }; +type Result_10 = variant { Ok : vec ChildNode; Err : text }; +type Result_11 = variant { Ok : vec DatabaseMember; Err : text }; +type Result_12 = variant { Ok : vec DatabaseSummary; Err : text }; +type Result_13 = variant { Ok : vec NodeEntry; Err : text }; +type Result_14 = variant { Ok : MkdirNodeResult; Err : text }; +type Result_15 = variant { Ok : MoveNodeResult; Err : text }; +type Result_16 = variant { Ok : QueryContext; Err : text }; +type Result_17 = variant { Ok : DatabaseArchiveChunk; Err : text }; +type Result_18 = variant { Ok : opt Node; Err : text }; +type Result_19 = variant { Ok : opt NodeContext; Err : text }; +type Result_2 = variant { Ok; Err : text }; +type Result_20 = variant { Ok : vec RecentNodeHit; Err : text }; +type Result_21 = variant { Ok : vec SearchNodeHit; Err : text }; +type Result_22 = variant { Ok : SourceEvidence; Err : text }; +type Result_3 = variant { Ok : text; Err : text }; +type Result_4 = variant { Ok : DeleteNodeResult; Err : text }; +type Result_5 = variant { Ok : EditNodeResult; Err : text }; +type Result_6 = variant { Ok : ExportSnapshotResponse; Err : text }; +type Result_7 = variant { Ok : FetchUpdatesResponse; Err : text }; +type Result_8 = variant { Ok : vec GlobNodeHit; Err : text }; +type Result_9 = variant { Ok : vec LinkEdge; Err : text }; +type SearchNodeHit = record { + preview : opt SearchPreview; + kind : NodeKind; + path : text; + match_reasons : vec text; + snippet : opt text; + score : float32; +}; +type SearchNodePathsRequest = record { + top_k : nat32; + database_id : text; + preview_mode : opt SearchPreviewMode; + prefix : opt text; + query_text : text; +}; +type SearchNodesRequest = record { + top_k : nat32; + database_id : text; + preview_mode : opt SearchPreviewMode; + prefix : opt text; + query_text : text; +}; +type SearchPreview = record { + field : SearchPreviewField; + char_offset : nat32; + match_reason : text; + excerpt : opt text; +}; +type SearchPreviewField = variant { Path; Content }; +type SearchPreviewMode = variant { Light; ContentStart; None }; +type SourceEvidence = record { node_path : text; refs : vec SourceEvidenceRef }; +type SourceEvidenceRef = record { + link_text : text; + via_path : text; + source_path : text; + raw_href : text; +}; +type SourceEvidenceRequest = record { node_path : text; database_id : text }; +type Status = record { source_count : nat64; file_count : nat64 }; +type WriteNodeRequest = record { + content : text; + kind : NodeKind; + path : text; + expected_etag : opt text; + metadata_json : text; + database_id : text; +}; +type WriteNodeResult = record { created : bool; node : RecentNodeHit }; +service : () -> { + append_node : (AppendNodeRequest) -> (Result); + begin_database_archive : (text) -> (Result_1); + begin_database_restore : (text, blob, nat64) -> (Result_2); + cancel_database_archive : (text) -> (Result_2); + canister_health : () -> (CanisterHealth) query; + create_database : () -> (Result_3); + delete_database : (text) -> (Result_2); + delete_node : (DeleteNodeRequest) -> (Result_4); + edit_node : (EditNodeRequest) -> (Result_5); + export_snapshot : (ExportSnapshotRequest) -> (Result_6) query; + fetch_updates : (FetchUpdatesRequest) -> (Result_7) query; + finalize_database_archive : (text, blob) -> (Result_2); + finalize_database_restore : (text) -> (Result_2); + glob_nodes : (GlobNodesRequest) -> (Result_8) query; + grant_database_access : (text, text, DatabaseRole) -> (Result_2); + graph_links : (GraphLinksRequest) -> (Result_9) query; + graph_neighborhood : (GraphNeighborhoodRequest) -> (Result_9) query; + incoming_links : (IncomingLinksRequest) -> (Result_9) query; + list_children : (ListChildrenRequest) -> (Result_10) query; + list_database_members : (text) -> (Result_11) query; + list_databases : () -> (Result_12) query; + list_nodes : (ListNodesRequest) -> (Result_13) query; + memory_manifest : () -> (MemoryManifest) query; + mkdir_node : (MkdirNodeRequest) -> (Result_14); + move_node : (MoveNodeRequest) -> (Result_15); + multi_edit_node : (MultiEditNodeRequest) -> (Result_5); + outgoing_links : (OutgoingLinksRequest) -> (Result_9) query; + query_context : (QueryContextRequest) -> (Result_16) query; + read_database_archive_chunk : (text, nat64, nat32) -> (Result_17) query; + read_node : (text, text) -> (Result_18) query; + read_node_context : (NodeContextRequest) -> (Result_19) query; + recent_nodes : (RecentNodesRequest) -> (Result_20) query; + revoke_database_access : (text, text) -> (Result_2); + search_node_paths : (SearchNodePathsRequest) -> (Result_21) query; + search_nodes : (SearchNodesRequest) -> (Result_21) query; + source_evidence : (SourceEvidenceRequest) -> (Result_22) query; + status : (text) -> (Status) query; + write_database_restore_chunk : (DatabaseRestoreChunkRequest) -> (Result_2); + write_node : (WriteNodeRequest) -> (Result); +} diff --git a/wasm/wiki/vfs_canister_nowasi.wasm b/wasm/wiki/vfs_canister_nowasi.wasm new file mode 100644 index 0000000..19e2192 Binary files /dev/null and b/wasm/wiki/vfs_canister_nowasi.wasm differ