Skip to content

Commit 48b73d7

Browse files
UnbreakableMJclaude
andcommitted
feat(construct-cli): Phase 5 — explore TUI (--format explore)
Adds a ratatui + crossterm interactive browser: Skills/Agents tabs, search (/), navigation, multi-select (Space), and triggers to install selected skills (i) or sync (s); Steelbore-themed. Launched by `--format explore` or a bare invocation on a real terminal; env-gated to fall back to JSON for agents/CI/non-TTY (never traps an agent). After exit the chosen action runs and prints normally. 28 tests, clippy -D warnings, reuse lint, nix build all pass. Completes the planned CLI+TUI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 82c2501 commit 48b73d7

10 files changed

Lines changed: 1024 additions & 46 deletions

File tree

construct-cli/Cargo.lock

Lines changed: 480 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

construct-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ toml = "0.8"
3030
# drops the optional `defmt` integration (and its unmaintained transitive deps).
3131
jiff = { version = "0.2", default-features = false, features = ["std"] }
3232
owo-colors = "4"
33+
ratatui = "0.29"
3334

3435
[dev-dependencies]
3536
assert_cmd = "2"
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// SPDX-FileCopyrightText: 2026 Mohamed Hammad <Mohamed.Hammad@SpacecraftSoftware.org>
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
//! `construct --format explore` (and a bare TTY invocation): run the [`crate::tui`]
5+
//! browser, then execute the action it returns once the terminal is restored,
6+
//! rendering the result as normal human output.
7+
8+
use crate::cli::SyncArgs;
9+
use crate::commands::{skill, sync};
10+
use crate::context::Context;
11+
use crate::install::{self, plan::AddOptions, plan::Scope};
12+
use crate::output::error::AppError;
13+
use crate::output::mode::OutputMode;
14+
use crate::output::{render, CommandOutput};
15+
use crate::tui::{self, TuiAction};
16+
17+
/// Launch the explore TUI and carry out the chosen action.
18+
pub(crate) fn run(ctx: &Context) -> Result<Option<CommandOutput>, AppError> {
19+
let action = tui::run(ctx)?;
20+
21+
// The TUI has restored the terminal; render any follow-up as human output.
22+
let mut hctx = ctx.clone();
23+
hctx.mode = OutputMode::HumanWithColor;
24+
hctx.color = true;
25+
26+
match action {
27+
TuiAction::Quit => Ok(None),
28+
TuiAction::Sync => {
29+
let output = sync::run(&hctx, &SyncArgs { flake_dir: None })?;
30+
render::emit(&hctx, &output);
31+
Ok(None)
32+
}
33+
TuiAction::Install(skills) => {
34+
let opts = AddOptions {
35+
source: install::plan::DEFAULT_SOURCE.to_owned(),
36+
skills,
37+
agents: Vec::new(),
38+
all_agents: false,
39+
scope: Scope::Project,
40+
copy: false,
41+
force: false,
42+
refresh: false,
43+
};
44+
let report = install::add(&hctx, &opts)?;
45+
render::emit(&hctx, &skill::report_output(&report));
46+
Ok(None)
47+
}
48+
}
49+
}

construct-cli/src/commands/mod.rs

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,27 @@
1010
1111
pub(crate) mod agent;
1212
pub(crate) mod describe;
13+
pub(crate) mod explore;
1314
pub(crate) mod schema;
1415
pub(crate) mod ship;
1516
pub(crate) mod skill;
1617
pub(crate) mod sync;
1718

18-
use clap::CommandFactory as _;
19-
2019
use crate::cli::{AgentCommand, Cli, Command, SkillCommand};
2120
use crate::context::Context;
2221
use crate::output::error::AppError;
22+
use crate::output::mode::OutputMode;
2323
use crate::output::CommandOutput;
2424

2525
/// Route the parsed command to its handler.
2626
///
2727
/// `Ok(Some(output))` is rendered by the caller; `Ok(None)` means the handler
2828
/// already emitted its own output (help, schema, describe).
2929
pub(crate) fn dispatch(cli: &Cli, ctx: &Context) -> Result<Option<CommandOutput>, AppError> {
30+
// `--format explore` launches the interactive TUI regardless of sub-command.
31+
if ctx.mode == OutputMode::Explore {
32+
return explore::run(ctx);
33+
}
3034
match &cli.command {
3135
Some(Command::Skill { verb }) => match verb {
3236
SkillCommand::Add(args) => skill::add(ctx, args).map(Some),
@@ -51,19 +55,17 @@ pub(crate) fn dispatch(cli: &Cli, ctx: &Context) -> Result<Option<CommandOutput>
5155
Ok(None)
5256
}
5357
None => {
54-
no_command(ctx);
55-
Ok(None)
58+
// A bare invocation on a real terminal opens the explore TUI;
59+
// agents and pipelines get the structured capability manifest.
60+
if matches!(
61+
ctx.mode,
62+
OutputMode::HumanWithColor | OutputMode::HumanNoColor
63+
) {
64+
explore::run(ctx)
65+
} else {
66+
let _ = describe::run(ctx, None, None);
67+
Ok(None)
68+
}
5669
}
5770
}
5871
}
59-
60-
/// Behavior when no sub-command is given. Agents and pipelines get the
61-
/// structured capability manifest; an interactive user gets help text. (A later
62-
/// phase replaces the interactive branch with the explore TUI.)
63-
fn no_command(ctx: &Context) {
64-
if ctx.is_machine() {
65-
let _ = describe::run(ctx, None, None);
66-
} else {
67-
let _ = Cli::command().print_long_help();
68-
}
69-
}

construct-cli/src/commands/skill.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ pub(crate) fn init(ctx: &Context, args: &InitArgs) -> Result<CommandOutput, AppE
153153
// ── helpers ───────────────────────────────────────────────────────────────-
154154

155155
/// Wrap an [`InstallReport`] into machine `data` plus a human table.
156-
fn report_output(report: &InstallReport) -> CommandOutput {
156+
pub(crate) fn report_output(report: &InstallReport) -> CommandOutput {
157157
let rows = report
158158
.items
159159
.iter()

construct-cli/src/context.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,6 @@ impl Context {
5858
absolute_time: g.absolute_time,
5959
}
6060
}
61-
62-
/// True when the resolved mode is one of the machine-readable formats.
63-
pub(crate) fn is_machine(&self) -> bool {
64-
self.mode.is_machine()
65-
}
6661
}
6762

6863
/// The full command line with `argv[0]` normalized to the canonical binary name

construct-cli/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ mod output;
3232
mod registry;
3333
mod sources;
3434
mod time;
35+
mod tui;
3536

3637
use std::panic::AssertUnwindSafe;
3738

construct-cli/src/output/mode.rs

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ pub(crate) fn resolve(g: &GlobalArgs) -> OutputMode {
5757
FormatArg::Jsonl => OutputMode::Jsonl,
5858
FormatArg::Yaml => OutputMode::Yaml,
5959
FormatArg::Csv => OutputMode::Csv,
60-
FormatArg::Explore => resolve_explore(g),
60+
FormatArg::Explore => resolve_explore(),
6161
};
6262
}
6363

@@ -78,22 +78,21 @@ pub(crate) fn resolve(g: &GlobalArgs) -> OutputMode {
7878
}
7979
}
8080

81-
/// Resolve `--format explore`. The interactive TUI is not yet implemented
82-
/// (planned for a later phase), so this always falls back — to human output on
83-
/// an interactive terminal, or to JSON otherwise — and warns on stderr. The
84-
/// never-trap-an-agent rule (Standard §5) is preserved: agents always get JSON.
85-
fn resolve_explore(g: &GlobalArgs) -> OutputMode {
86-
const REASON: &str = "interactive explore mode is not yet implemented";
87-
if is_agent_env() || is_ci() || is_dumb_term() || !std::io::stdout().is_terminal() {
88-
warn_tui_fallback(REASON, "json");
81+
/// Resolve `--format explore`. The interactive TUI runs only on a real
82+
/// terminal (both stdout and stdin must be TTYs); for agents, CI, dumb
83+
/// terminals, or pipes it falls back to JSON and warns — never trapping an
84+
/// agent in a render loop (Standard §5).
85+
fn resolve_explore() -> OutputMode {
86+
if is_agent_env()
87+
|| is_ci()
88+
|| is_dumb_term()
89+
|| !std::io::stdout().is_terminal()
90+
|| !std::io::stdin().is_terminal()
91+
{
92+
warn_tui_fallback("no interactive terminal available", "json");
8993
OutputMode::Json
9094
} else {
91-
warn_tui_fallback(REASON, "human");
92-
if should_use_color(g) {
93-
OutputMode::HumanWithColor
94-
} else {
95-
OutputMode::HumanNoColor
96-
}
95+
OutputMode::Explore
9796
}
9897
}
9998

0 commit comments

Comments
 (0)