Files
RuDS/specs/tui.allium

500 lines
18 KiB
Plaintext

-- 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<String> = {
"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
}