feat(cli): migrate tui to schema-driven adapter API (#269)#300
Merged
Conversation
Rewrites the terminal UI to talk through the active adapter and render every status / priority / filter using the resolved `SpecSchema`. Replaces hard-coded `SpecStatus` and `SpecPriority` references across all 8 TUI files with dynamic, schema-driven equivalents: `FilterState.fields` keyed by string, `SortOption::FieldDesc(key)`, `BoardGroup.value`, and `theme::field_style` / `field_symbol`. The TUI is no longer gated behind a markdown-only guard and runs uniformly against GitHub / ADO / Jira projects via `AdapterRegistry::from_project()`. https://claude.ai/code/session_01EUhnsCqrYncNjpzQnNCB46
CI's `cargo fmt --check` flagged a few struct/enum/closure layouts that exceeded rustfmt's preferred line lengths after the schema-driven rewrite. No behavioural change. https://claude.ai/code/session_01EUhnsCqrYncNjpzQnNCB46
There was a problem hiding this comment.
Pull request overview
This PR migrates the leanspec tui implementation from hard-coded SpecStatus / SpecPriority types to a schema-driven, adapter-based model, so the TUI can render fields (status/priority/filter/symbols/colors) from the active adapter’s resolved SpecSchema.
Changes:
- Rewires the TUI entry point to resolve an adapter and run the interactive event loop (plus headless scripting for e2e tests).
- Refactors core TUI state to store
Box<dyn Adapter>,SpecSchema, andVec<SpecDoc>; updates sorting/filtering/board grouping to use schema field keys and enum option order. - Updates all TUI views (board/list/detail/filter/search/deps) and theming to render symbols/styles based on schema enum options (with fallbacks).
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| rust/leanspec-cli/src/commands/tui/mod.rs | New adapter-resolving entry point, terminal setup/restore, main event loop, headless runner |
| rust/leanspec-cli/src/commands/tui/app.rs | Central refactor: adapter/schema/doc state, schema-driven filter/sort/grouping, dependency index, body extraction |
| rust/leanspec-cli/src/commands/tui/theme.rs | Schema-driven field styling/symbols; hex color parsing with non-truecolor downgrade |
| rust/leanspec-cli/src/commands/tui/board.rs | Board grouping/rendering now uses schema-driven status/priority fields |
| rust/leanspec-cli/src/commands/tui/list.rs | List rendering now uses SpecDoc ids and schema-based status/priority symbols |
| rust/leanspec-cli/src/commands/tui/detail.rs | Detail header becomes schema-driven inline metadata + adapter-loaded body rendering |
| rust/leanspec-cli/src/commands/tui/filter.rs | Filter popup rewritten to enumerate schema enum values and toggle (key, value) pairs |
| rust/leanspec-cli/src/commands/tui/search.rs | Search overlay rendering updated to display schema-based status symbol and SpecDoc id |
| rust/leanspec-cli/src/commands/tui/deps.rs | Dependency view switched to doc.links-derived index and schema-based status styling |
| rust/leanspec-cli/src/commands/tui/keybindings.rs | Mouse/key handling updated for schema-driven filter entries and sort cycling |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+99
to
+112
| // 2. --project resolves through the registry to a project + markdown adapter. | ||
| if let Some(name) = project_name { | ||
| if let Ok(registry) = leanspec_core::storage::ProjectRegistry::new() { | ||
| if let Some(project) = registry | ||
| .all() | ||
| .iter() | ||
| .find(|p| p.name == name || p.id == name) | ||
| .cloned() | ||
| .cloned() | ||
| { | ||
| let specs_dir = project.specs_dir.to_string_lossy().into_owned(); | ||
| let adapter: Box<dyn Adapter> = Box::new(MarkdownAdapter::new(&specs_dir)); | ||
| return Ok((adapter, Some(project))); | ||
| } |
Comment on lines
+102
to
112
| impl FilterState { | ||
| /// Build a filter from the schema. By default no filter is active | ||
| /// (all values pass) and archived items are hidden. | ||
| pub fn from_schema(_schema: &SpecSchema) -> Self { | ||
| Self { | ||
| statuses: Vec::new(), | ||
| priorities: Vec::new(), | ||
| fields: HashMap::new(), | ||
| assignees: Vec::new(), | ||
| tags: Vec::new(), | ||
| hide_archived: true, | ||
| } | ||
| } |
Comment on lines
88
to
99
| /// Active filter state for the spec list. | ||
| #[derive(Debug, Clone)] | ||
| /// | ||
| /// `fields` maps field key → list of selected values. Empty list (or absent | ||
| /// key) means "no filter applied for this field — show everything." The | ||
| /// `assignees` and `tags` lists are kept separately for semantic convenience; | ||
| /// they apply on top of `fields`. | ||
| #[derive(Debug, Clone, Default)] | ||
| pub struct FilterState { | ||
| pub statuses: Vec<SpecStatus>, | ||
| pub priorities: Vec<SpecPriority>, | ||
| pub fields: HashMap<String, Vec<String>>, | ||
| pub assignees: Vec<String>, | ||
| pub tags: Vec<String>, | ||
| pub hide_archived: bool, |
Comment on lines
+39
to
50
| /// | ||
| /// `FieldDesc(key)` / `FieldAsc(key)` sort by an enum field's declared | ||
| /// option order in the schema (e.g. priority). Documents whose value is | ||
| /// missing or unknown sort last regardless of direction. | ||
| #[derive(Debug, Clone, Default, PartialEq, Eq)] | ||
| pub enum SortOption { | ||
| #[default] | ||
| IdDesc, | ||
| IdAsc, | ||
| PriorityDesc, | ||
| FieldDesc(String), | ||
| TitleAsc, | ||
| UpdatedDesc, |
Comment on lines
+523
to
+541
| digits.parse::<u32>().unwrap_or(0) | ||
| } | ||
|
|
||
| impl App { | ||
| /// Construct the app against a resolved adapter. Loads all specs once and | ||
| /// derives the initial filter, sort, and board view. | ||
| pub fn new( | ||
| specs_dir: &str, | ||
| adapter: Box<dyn Adapter>, | ||
| initial_view: PrimaryView, | ||
| initial_project: Option<leanspec_core::storage::Project>, | ||
| ) -> Result<Self, Box<dyn Error>> { | ||
| let loader = SpecLoader::new(specs_dir); | ||
| let specs = loader.load_all_metadata()?; | ||
| let dep_graph = DependencyGraph::new(&specs); | ||
| let stats = SpecStats::compute(&specs); | ||
| let mut schema = adapter.schema().clone(); | ||
| // Best effort — adapters with static schemas (markdown) no-op here. | ||
| let _ = adapter.resolve_schema(&mut schema); | ||
| let status_key = schema | ||
| .key_for_semantic(semantic::STATUS) | ||
| .map(|k| k.to_string()); | ||
| let priority_key = schema | ||
| .key_for_semantic(semantic::PRIORITY) |
Comment on lines
705
to
732
| if let Some(ha) = prefs.hide_archived { | ||
| self.filter.hide_archived = ha; | ||
| } | ||
| } | ||
|
|
||
| /// Close any overlay and return to Normal mode. | ||
| pub fn close_overlay(&mut self) { | ||
| self.mode = AppMode::Normal; | ||
| self.project_switcher = None; | ||
| // Keep project_mgmt around so state survives a re-open? No — clear it. | ||
| self.project_mgmt = None; | ||
| } | ||
|
|
||
| /// Switch to a project: reload specs from its specs_dir, update last_accessed. | ||
| /// Switch to a project: instantiate its adapter via the registry and | ||
| /// reload state. Errors here are surfaced as `message` on the project | ||
| /// management view so the user can recover without crashing the TUI. | ||
| pub fn switch_project(&mut self, project: leanspec_core::storage::Project) { | ||
| // Save prefs for the current project before switching | ||
| self.save_prefs(); | ||
|
|
||
| // Update last_accessed in registry. | ||
| if let Ok(mut registry) = leanspec_core::storage::ProjectRegistry::new() { | ||
| let _ = registry.touch_last_accessed(&project.id); | ||
| } | ||
|
|
||
| let specs_dir = project.specs_dir.to_string_lossy().into_owned(); | ||
| self.current_project = Some(project); | ||
|
|
||
| // Load prefs for the new project | ||
| if let Some(ref p) = self.current_project.clone() { | ||
| let prefs = Self::load_prefs(&p.id); | ||
| self.apply_prefs(&prefs); | ||
| } | ||
|
|
||
| self.reload_specs(&specs_dir); | ||
| self.reload_with_markdown_dir(&specs_dir); | ||
| self.close_overlay(); | ||
| } |
Comment on lines
+506
to
+513
| .field(field_key) | ||
| .and_then(|f| match &f.kind { | ||
| FieldKind::Enum { options, .. } => options | ||
| .iter() | ||
| .position(|o| o.value == value) | ||
| .map(|i| i as u32), | ||
| _ => None, | ||
| }) |
- Resolve project adapter from `leanspec.adapter.yaml` in the project root rather than forcing markdown, so `--project foo` and the in-TUI project switcher honour GitHub/ADO/Jira configurations (new `resolve_adapter_for_project` helper). - Pre-populate `FilterState::from_schema` with every enum value, as spec #269 prescribes — popup defaults to all `[x]` and unchecking narrows the visible set. `is_empty(&schema)` now reports the schema-aware default-state semantic. - Drop unused `FilterState::assignees` / `tags`; the docstring no longer advertises behaviour that wasn't implemented. - Propagate adapter errors from `App::new` / `reload_with_adapter` instead of falling through to an empty spec list, so backend auth/network/schema failures surface at startup. - `id_order` falls back to case-insensitive string compare when the doc id has no numeric prefix, so `IdAsc/IdDesc` order correctly for adapters with keys like `ABC-123`. - Fix stale `FieldAsc` doc on `SortOption`. https://claude.ai/code/session_01EUhnsCqrYncNjpzQnNCB46
Clippy in rust 1.95.0 added `clippy::collapsible-match` to the default warning set, which deny-with-`-D warnings` turns into a CI failure on the two `if`-inside-`match` arms in `FilterState::matches`. Rewrite both as match guards — behaviour is unchanged. https://claude.ai/code/session_01EUhnsCqrYncNjpzQnNCB46
This was referenced May 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #269.
Rewrites the terminal UI to talk through the active
Adapterand render every status / priority / filter using the resolvedSpecSchema. Replaces hard-codedSpecStatusandSpecPriorityreferences across all 8 TUI files with dynamic, schema-driven equivalents in a single atomic migration, matching the design in the spec.Key changes:
tui/mod.rs— re-wires the previously stubbed module;run()resolves an adapter via--specs-dir/--project/AdapterRegistry::from_project()and drives the event loop. No more "TUI is not yet migrated" error; no markdown-only guard.tui/app.rs—Appnow holdsBox<dyn Adapter>,SpecSchema,status_key,priority_key, andVec<SpecDoc>instead ofSpecLoader+Vec<SpecInfo>.FilterState.fields: HashMap<String, Vec<String>>replaces typedstatuses/priorities.SortOption::FieldDesc(String)replacesPriorityDesc.BoardGroup.value: Stringreplaces typed status. NewDepsIndexbuilt fromdoc.links(link_type =depends_on) replaces the file-path-keyedDependencyGraph.tui/theme.rs—field_style(value, key, schema)andfield_symbol(value, key, schema)replacestatus_style/status_symbol/priority_symbol. Newparse_hex_colorhandlesEnumOption::colorstrings with a downgrade for non-truecolor terminals.tui/board.rs,list.rs,detail.rs,filter.rs,search.rs,deps.rs,keybindings.rs— all rendering and dispatch now goes through the schema. Detail metadata block iteratesschema.fieldsand renders each inline field generically. Filter popup is grouped by field key (status, priority, …) rather than hardcoded sections.SpecStatus/SpecPriority/SpecInfo/SpecFilterOptionsimports remain in TUI code.Board column order now follows schema declaration order (draft → planned → in-progress → complete → archived) instead of the legacy hardcoded order. The spec explicitly notes this and instructs to update snapshots in the same PR.
Test plan
cargo test -p leanspec-cli --bin leanspec— 195 passed; 1 unrelated pre-existing flake (test_regression_special_characters_in_name, fails onmaintoo).cargo test -p leanspec-cli --test tui_e2e— all 4 e2e headless tests pass.cargo clippy -p leanspec-cli --tests -- -D warnings— clean.cargo build --workspace— clean.leanspec --specs-dir tests/fixtures/tui-sample tui --headless "ss"returns expected schema-ordered board groups and the priority sort label.(key, value)pair and filtered counts update correctly whenESCcloses the overlay.https://claude.ai/code/session_01EUhnsCqrYncNjpzQnNCB46
Generated by Claude Code