-- allium: 1 -- Terminal UI (issue #26, phase 4) -- Scope: extension (second renderer over the shared UI core) -- Distilled from: lib/bds/tui.ex, lib/bds/ui/sidebar.ex, lib/bds/ui/post_editor/*.ex entity TuiState { view: posts | media | templates | scripts | tags | settings | git focus: sidebar | editor selected_index: Integer editing_post: Boolean editor_preview: Boolean -- true while the read-only rendered-Markdown preview is shown -- The editor drives the same BDS.UI.PostEditor workflow as the GUI: -- canonical-language edits update the post, other languages update -- translations; publish routes through the same publishing pipeline. } config { -- Same preference sections as the settings backends; matches the -- GUI settings nav minus the GUI-only Style tab. tui_settings_sections: Set = { "project", "editor", "content", "ai", "technology", "publishing", "data", "mcp" } } surface TuiSurface { facing _: TuiRuntime provides: TuiKeyPressed(code, modifiers) TuiEventReceived(entity, entity_id, action) TuiSettingsChanged(key) ShellTaskCompleted(route) ProjectsOverlaySelectionConfirmed(project) FolderPathPromptTabPressed(input) FolderPathPromptSubmitted(path) SearchPromptChanged(query) SearchPromptConfirmed(query) SearchPromptCancelled() CommandListSelectionConfirmed(command) GitCommitPromptSubmitted(message) @guarantee OverlayPrecedence -- Open overlays and status-line prompts capture key input -- before the sidebar/editor key rules below apply. Overlays -- clear the terminal cells beneath them. @guarantee EditorRendering -- The post editor renders the buffer with syntax highlighting -- (markdown with [[macro]] accents for posts, HTML+Liquid for -- templates, Lua for scripts), soft-wraps long lines to the -- viewport width, keeps the cursor's visual row visible while -- scrolling, and highlights the cursor's source line. There is -- no line-number gutter. } -- ─── Sidebar navigation ───────────────────────────────────── rule SidebarNavigation { when: TuiKeyPressed(code, _) requires: code = "up" or code = "down" or code = "j" or code = "k" requires: TuiState.focus = sidebar let items = flattened_sidebar_items() -- BDS.UI.Sidebar.view data, identical to the GUI sidebar ensures: if code = "up" or code = "k": TuiState.selected_index = previous_selectable_index(items, TuiState.selected_index) else: TuiState.selected_index = next_selectable_index(items, TuiState.selected_index) -- section headers are not selectable; movement skips them } rule OpenEntry { when: TuiKeyPressed(code, _) requires: code = "enter" requires: TuiState.focus = sidebar let entry = selected_sidebar_entry(TuiState.selected_index) ensures: if entry.kind = "post": TuiState.editing_post = true TuiState.focus = editor -- editor seeded from the persisted form (title + textarea); -- syntax highlighting and word wrap on. New empty posts -- created with "n" open in the editor the same way. if entry.kind = "image": TerminalImagePreviewOpened(entry) if entry.kind != "post" and entry.kind != "image": StatusMessageShown(message_key: "tui.editing_not_available") } -- ─── Post editor keys ─────────────────────────────────────── rule SaveAndPublish { when: TuiKeyPressed(code, modifiers) requires: code = "s" or code = "p" requires: modifiers = "ctrl" requires: TuiState.editing_post ensures: if code = "s": PostDraftPersisted() else: PostPersistedAndPublished() -- Both persist through BDS.UI.PostEditor.Persistence, so files, -- embeddings, search index, and the event bus behave exactly as a -- GUI save. } rule EditorMode { when: TuiKeyPressed(code, modifiers) requires: code = "e" requires: modifiers = "ctrl" requires: TuiState.editing_post ensures: TuiState.editor_preview = not TuiState.editor_preview -- toggles between the syntax-highlighted editor (the default -- when opening a post) and the read-only rendered-Markdown -- preview (tui-markdown: headings/emphasis/lists styled); -- keys in preview never edit content } rule EditorEscape { when: TuiKeyPressed(code, _) requires: code = "esc" requires: TuiState.focus = editor ensures: TuiState.focus = sidebar -- esc returns to the sidebar from either editor mode } rule AiQuickAction { when: TuiKeyPressed(code, modifiers) requires: code = "g" requires: modifiers = "ctrl" requires: TuiState.editing_post ensures: if active_endpoint_configured: AISuggestionRequested(entity_type: "post", entity_id: edited_post_id()) -- one-shot AI post analysis (title/excerpt suggestions) else: StatusMessageShown(message_key: "tui.ai_unavailable_offline") -- airplane gating: without a local endpoint the TUI shows -- a status message instead of calling out } -- ─── Project switcher ─────────────────────────────────────── rule ProjectSwitcher { when: TuiKeyPressed(code, modifiers) requires: code = "p" requires: modifiers != "ctrl" requires: TuiState.focus = sidebar ensures: ProjectsOverlayOpened(projects: all_projects(), active: active_project()) -- all projects from the caching database, active one marked } rule ProjectOverlayActivate { when: ProjectsOverlaySelectionConfirmed(project) ensures: ActiveProjectChanged(project) -- BDS.Projects.set_active_project ensures: SidebarReloaded() } rule ProjectOverlayFolderPrompt { when: TuiKeyPressed(code, _) requires: code = "o" requires: projects_overlay_open() ensures: FolderPathPromptOpened() -- switches the overlay to a folder-path prompt with bash-style -- tab completion } rule FolderPromptTabCompletion { when: FolderPathPromptTabPressed(input) let expanded = expand_home(input) -- a leading "~" expands to the home directory let candidates = completion_candidates(expanded) -- only directories are offered; dotfolders only when the last -- path segment starts with "." ensures: if candidates.count = 1: FolderPromptInputReplaced(candidates.first + "/") -- a unique directory completes with a trailing slash if candidates.count > 1: FolderPromptInputReplaced(longest_common_prefix(candidates)) FolderPromptCandidatesListed(candidates) -- ambiguous prefix: longest common prefix + candidate list if candidates.count = 0: FolderPromptUnchanged() } rule FolderPromptSubmit { when: FolderPathPromptSubmitted(path) let expanded = expand_home(path) ensures: if directory_exists(expanded): ProjectOpenedFromFolder(expanded) -- BDS.Projects.create_project with data_path, named after -- the folder, then activated DatabaseRebuildQueued(expanded) -- queued automatically through the rebuild_database shell -- command so the folder's content is loaded into the -- caching database else: FolderPromptErrorShown(path) -- invalid paths keep the prompt open and report the error } -- ─── Sidebar search ───────────────────────────────────────── enum TuiSearchTokenKind { text -- plain word: text search tag_filter -- "tag:x": filters like the GUI tag chip category_filter -- "category:x": filters like the GUI category chip day_filter -- ISO date (yyyy-mm-dd): narrows to that day; extends the -- shared sidebar queries } value TuiSearchToken { kind: TuiSearchTokenKind argument: String -- the search word, tag/category name, or ISO date } surface TuiSearchTokenSurface { context token: TuiSearchToken exposes: token.kind token.argument } rule SidebarSearch { when: TuiKeyPressed(code, _) requires: code = "/" requires: TuiState.focus = sidebar ensures: SearchPromptOpened() -- vi-style search prompt in the status line } rule SidebarSearchInput { when: SearchPromptChanged(query) let tokens = parse_search_tokens(query) -- black box: each whitespace-separated token classified as a -- TuiSearchToken; tokens combine with AND ensures: SidebarFiltered(filters: tokens) -- live-filters the current view through the same shared -- BDS.UI.Sidebar filter params the GUI sidebar uses -- (posts, pages, media) } rule SidebarSearchCommit { when: SearchPromptConfirmed(query) ensures: SidebarFilterKept(view: TuiState.view, query: query) -- enter keeps the filter, shown appended to the sidebar title; -- filters are per view } rule SidebarSearchCancel { when: SearchPromptCancelled() ensures: SidebarFilterCleared(view: TuiState.view) -- esc clears the filter } -- ─── Command prompt ───────────────────────────────────────── rule CommandPrompt { when: TuiKeyPressed(code, _) requires: code = ":" requires: TuiState.focus = sidebar ensures: CommandListOpened(commands: parameterless_shell_commands()) -- vi-style prompt: filterable list of the parameterless -- Blog-menu shell commands (metadata diff, validate site, -- force render, rebuilds, reindex, translations, duplicates, -- upload, browser preview URL) — the same -- BDS.Desktop.ShellCommands backend as the GUI menu. ":?" -- shows the full list as help. Commands whose follow-up UI is -- GUI-only (validate translations, find duplicates) are marked. } rule CommandPromptExecute { when: CommandListSelectionConfirmed(command) ensures: ShellCommandExecuted(command) ensures: TaskProgressShownInStatusLine() -- queued tasks report progress in the status line until all -- tasks finish } -- ─── Settings panel (issue #29) ───────────────────────────── rule SettingsPanel { when: TuiKeyPressed(code, _) requires: code = "6" requires: TuiState.focus = sidebar -- "6" matches the GUI panel numbering ensures: TuiState.view = settings ensures: SettingsSectionsListed(sections: config.tui_settings_sections) -- enter on a section opens a section-specific editor in the -- main area: a generic typed-field form from BDS.UI.SettingsForm } rule SettingsFieldEdit { when: TuiKeyPressed(code, _) requires: code = "enter" requires: TuiState.view = settings let row = selected_settings_row() requires: not row.read_only -- read-only info rows are skipped by the selection ensures: if row.kind = "text": StatusLinePromptOpened(row) -- esc cancels the prompt, then closes the form if row.kind = "boolean": SettingToggled(row) if row.kind = "enum": SettingCycled(row) -- cycles the enum through its options } rule SettingsSave { when: TuiKeyPressed(code, modifiers) requires: code = "s" requires: modifiers = "ctrl" requires: TuiState.view = settings ensures: SettingsSectionSaved(selected_settings_section()) -- saves through the same backends as the GUI editor -- (BDS.Metadata, BDS.Settings, BDS.AI, BDS.MCP.AgentConfig) -- and reloads the form. Category removal and AI model -- discovery remain GUI-only. } -- ─── Tags panel (issue #34) ───────────────────────────────── rule TagsPanel { when: TuiKeyPressed(code, _) requires: code = "5" requires: TuiState.focus = sidebar -- "5" matches the GUI panel numbering ensures: TuiState.view = tags ensures: TagsSectionsListed() -- the same three sections as the GUI tags editor (Tag Cloud, -- Create / Edit, Merge Tags — from BDS.UI.Sidebar's tags nav -- view); enter opens the section in the main area, all backed -- by BDS.UI.TagsPanel over the same BDS.Tags operations as the -- GUI editor. Cloud lists tags with post usage counts ordered -- by usage; manage is alphabetical. Text prompts live in the -- status line; esc cancels the prompt, then closes the panel. -- Domain tag events reload an open panel, keeping it in sync -- with GUI and CLI writes. } rule TagsManageKeys { when: TuiKeyPressed(code, _) requires: TuiState.view = tags requires: tags_manage_section_active() ensures: if code = "n": TagCreatePromptOpened() if code = "enter": TagRenamePromptOpened(selected_tag()) if code = "c": TagColourCycled(selected_tag()) -- cycles through the shared colour presets if code = "t": TagTemplateCycled(selected_tag()) -- cycles the tag's post template if code = "d": TagDeleteConfirmArmed(selected_tag()) -- "d" then "y" deletes with post-count confirmation; -- any other key cancels if code = "s": TagsSyncedFromPosts() } rule TagsMergeKeys { when: TuiKeyPressed(code, _) requires: TuiState.view = tags requires: tags_merge_section_active() ensures: if code = "space": TagMarkToggled(selected_tag()) if code = "m" and marked_tags().count >= 2: TagsMerged(source_tags: marked_tags(), target_tag: selected_tag()) -- merges the marked tags into the selected one (target -- included, at least two marked) } -- ─── Git panel (issue #30) ────────────────────────────────── rule GitPanel { when: TuiKeyPressed(code, _) requires: code = "7" requires: TuiState.focus = sidebar -- "7" matches the GUI panel numbering; content sync panel ensures: TuiState.view = git ensures: GitPanelOpened() -- sidebar: changed files from git status (code + path, branch -- with ahead/behind counts in the title), commit history in -- the lower half like the GUI (short hash + subject; ↑ marks -- commits that still need a push, ↓ remote-only ones). -- Main area: scrollable whole-folder diff (staged + unstaged, -- capped) paged with pgup/pgdn. Every action is BDS.Git, the -- same backend as the GUI git panel. } rule GitPanelKeys { when: TuiKeyPressed(code, _) requires: TuiState.view = git requires: is_git_repository(active_project()) ensures: if code = "enter": GitDiffJumpedToFile(selected_changed_file()) if code = "c": GitCommitPromptOpened() -- status-line commit prompt if code = "u": GitPullStarted() -- ff-only, runs off the UI process, reports back as a -- status toast if code = "s": GitPushStarted() -- runs off the UI process, reports back as a status toast } rule GitPanelNonRepo { when: TuiKeyPressed(code, _) requires: TuiState.view = git requires: not is_git_repository(active_project()) requires: code = "c" or code = "u" or code = "s" ensures: StatusMessageShown(message_key: "tui.not_a_git_repository") -- a project folder that is not a git repository shows a -- message and refuses the actions } rule GitCommitSubmit { when: GitCommitPromptSubmitted(message) ensures: if message = "": GitCommitRejectedEmptyMessage() -- empty messages are rejected else: GitCommitExecuted(message) -- git add -A + commit } -- ─── Report panels ────────────────────────────────────────── rule ReportPanels { when: ShellTaskCompleted(route) requires: route = "metadata_diff" or route = "site_validation" requires: task_completed_in_current_session(route) -- reports completed before this session started never pop up ensures: ReportPanelOpened(route) -- scrollable report panel showing the differences } rule ReportPanelApply { when: TuiKeyPressed(code, _) requires: code = "enter" requires: report_panel_open() ensures: ReportApplied(open_report()) -- enter applies the whole report: metadata diff repairs all -- items from the filesystem (file -> db) and imports orphan -- files; site validation applies the full report incrementally -- (render missing, remove extra, re-render updated) — matching -- the GUI defaults ensures: ReportPanelClosed() } rule ReportPanelDismiss { when: TuiKeyPressed(code, _) requires: code = "esc" requires: report_panel_open() ensures: ReportPanelClosed() -- esc closes the report without applying } -- ─── Multi-client sync ────────────────────────────────────── rule MultiClientSync { when: TuiEventReceived(entity, entity_id, action) -- Keeps TUI sessions synchronized with GUI clients and pipeline -- ingestion. ensures: SidebarReloaded() ensures: if entity = "post" and action = "deleted" and edited_post_id() = entity_id: TuiState.editing_post = false -- a post deleted elsewhere closes the open editor } rule ServerSideLocale { when: TuiSettingsChanged(key) requires: key = "ui.language" ensures: TuiRelocalized() -- the TUI re-reads the server-side UI language and re-renders; -- all clients always share one language }