500 lines
18 KiB
Plaintext
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: List<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
|
|
}
|