Skip to content

Commit 2f200ee

Browse files
authored
Merge pull request #5717 from aboimpinto/feat/FEAT-021-adopt-command-shapes-in-tui-project-group
refactor(tui): adopt command shapes in project group (FEAT-021)
2 parents ba04cad + 69274ae commit 2f200ee

12 files changed

Lines changed: 1486 additions & 235 deletions

File tree

crates/command-contract/src/facets.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,97 @@ pub trait CommandMediaContext {
111111
/// Validate and insert a resolved media path atomically.
112112
fn attach_media(&mut self, resolved_path: &Path) -> Result<MediaAttachmentReceipt, String>;
113113
}
114+
115+
// Project (FEAT-021 D1/D2/D3/D4)
116+
// ---------------------------------------------------------------------------
117+
118+
/// Portable goal status for the project facet (FEAT-021 D1).
119+
///
120+
/// Mirrors the four TUI-owned `tools::goal::GoalStatus` variants without
121+
/// naming the TUI type. The adapter maps host state onto this enum; handlers
122+
/// compare and render it directly.
123+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
124+
pub enum ProjectGoalStatus {
125+
#[default]
126+
Active,
127+
Paused,
128+
Complete,
129+
Blocked,
130+
}
131+
132+
/// Portable session-share projection (FEAT-021 D1).
133+
///
134+
/// Carries only the emptiness/length and the model/mode labels the live
135+
/// `/share` handler consumes. The session history itself, exporter I/O, and
136+
/// all `App` state stay host-side.
137+
#[derive(Debug, Clone, PartialEq, Eq)]
138+
pub struct ProjectShareProjection {
139+
/// Whether the session history is empty (drives the empty-share error).
140+
pub history_is_empty: bool,
141+
/// Session history length used in the export message and action.
142+
pub history_len: usize,
143+
/// Current model label.
144+
pub model: String,
145+
/// Current operating-mode label.
146+
pub mode_label: String,
147+
}
148+
149+
/// Portable goal projection (FEAT-021 D1).
150+
///
151+
/// Carries the visible goal state, the effective pending-control view, and the
152+
/// session-derived token fallback the live `/goal` handler consumes. Concrete
153+
/// goal-service, session-manager, and `App` types never cross the boundary.
154+
#[derive(Debug, Clone, PartialEq, Eq)]
155+
pub struct ProjectGoalState {
156+
/// Visible goal objective.
157+
pub objective: Option<String>,
158+
/// Visible goal status.
159+
pub status: ProjectGoalStatus,
160+
/// Pause reason label when the goal is paused (already rendered).
161+
pub pause_reason: Option<String>,
162+
/// Elapsed seconds from `started_at` when present (host-computed).
163+
pub started_at_elapsed_seconds: Option<u64>,
164+
/// Seconds of goal time used (stable budget/elapsed source).
165+
pub time_used_seconds: u64,
166+
/// Optional token budget.
167+
pub token_budget: Option<u32>,
168+
/// Tokens used by the goal engine.
169+
pub tokens_used: u64,
170+
/// Session conversation-token total (fallback when tokens_used == 0).
171+
pub session_total_tokens: u32,
172+
/// Goal continuation count.
173+
pub continuation_count: u32,
174+
/// Whether pending goal controls are queued (effective-state gate).
175+
pub pending_controls: bool,
176+
/// Last-known durable objective (session-derived effective source).
177+
pub last_known_objective: Option<String>,
178+
/// Last-known durable status (session-derived effective source).
179+
pub last_known_status: Option<ProjectGoalStatus>,
180+
/// Whether the conversation has API messages (bare `/goal` context gate).
181+
pub conversation_present: bool,
182+
/// Whether the host is currently loading (idle-hint gate).
183+
pub is_loading: bool,
184+
/// Whether the goal continuation loop is waiting (idle-hint gate).
185+
pub goal_continuation_waiting: bool,
186+
}
187+
188+
/// Host project data for the project command group (FEAT-021 D1).
189+
///
190+
/// Exposes the typed, exact-minimum operations the live project handlers
191+
/// consume: `/lsp` status/set state, `/share` session payload data, and
192+
/// `/goal` goal state including the session-derived effective values.
193+
/// `/init` host data flows through the existing `WORKSPACE` facet (D2), so
194+
/// `/init` destructures exactly `WORKSPACE` (D4) and consumes no
195+
/// project-facet method. All results are contract-owned portable values; implementation
196+
/// errors cross as safe text. The TUI adapter is the only place that touches
197+
/// `App`, `config::config`, the goal service, or the session manager.
198+
pub trait CommandProjectContext {
199+
/// `/lsp` status: whether LSP diagnostics are enabled.
200+
fn lsp_enabled(&self) -> bool;
201+
/// `/lsp` set: enable or disable LSP diagnostics.
202+
fn lsp_set(&mut self, enabled: bool) -> Result<(), String>;
203+
/// `/share` projection: session emptiness, length, model, and mode label.
204+
fn share_projection(&self) -> ProjectShareProjection;
205+
/// `/goal` projection: visible and effective goal state.
206+
fn goal_state(&self) -> ProjectGoalState;
207+
}

crates/command-contract/src/handler.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
77
use crate::facets::{
88
CommandCostContext, CommandMediaContext, CommandModePolicyContext, CommandModelContext,
9-
CommandPresentationContext, CommandSessionContext, CommandSkillsContext,
9+
CommandPresentationContext, CommandProjectContext, CommandSessionContext, CommandSkillsContext,
1010
CommandSystemPromptContext, CommandWorkspaceContext,
1111
};
12-
1312
/// A command handler that is either argument-only or capability-scoped.
1413
#[derive(Clone, Copy)]
1514
pub enum CommandHandler<R> {
@@ -28,6 +27,7 @@ pub struct CommandContexts<'a> {
2827
workspace: Option<&'a mut dyn CommandWorkspaceContext>,
2928
presentation: Option<&'a mut dyn CommandPresentationContext>,
3029
media: Option<&'a mut dyn CommandMediaContext>,
30+
project: Option<&'a mut dyn CommandProjectContext>,
3131
}
3232

3333
/// Consumed envelope used when one handler needs several independent facets.
@@ -41,6 +41,7 @@ pub struct ContextParts<'a> {
4141
pub workspace: Option<&'a mut dyn CommandWorkspaceContext>,
4242
pub presentation: Option<&'a mut dyn CommandPresentationContext>,
4343
pub media: Option<&'a mut dyn CommandMediaContext>,
44+
pub project: Option<&'a mut dyn CommandProjectContext>,
4445
}
4546

4647
impl<'a> CommandContexts<'a> {
@@ -55,6 +56,7 @@ impl<'a> CommandContexts<'a> {
5556
workspace: None,
5657
presentation: None,
5758
media: None,
59+
project: None,
5860
}
5961
}
6062

@@ -69,6 +71,7 @@ impl<'a> CommandContexts<'a> {
6971
workspace: self.workspace,
7072
presentation: self.presentation,
7173
media: self.media,
74+
project: self.project,
7275
}
7376
}
7477

@@ -140,6 +143,14 @@ impl<'a> CommandContexts<'a> {
140143
);
141144
self
142145
}
146+
147+
pub fn with_project(mut self, value: &'a mut dyn CommandProjectContext) -> Self {
148+
assert!(
149+
self.project.replace(value).is_none(),
150+
"project facet already set"
151+
);
152+
self
153+
}
143154
}
144155

145156
impl Default for CommandContexts<'_> {

crates/command-contract/src/tests.rs

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,157 @@ fn envelope_rejects_duplicate_new_slots_deterministically() {
344344
}));
345345
assert!(result.is_err(), "duplicate media slot must assert");
346346
}
347+
348+
// Project facet (FEAT-021 D1/D4)
349+
// ---------------------------------------------------------------------------
350+
351+
/// Deterministic fake project facet over portable values only.
352+
struct FakeProject {
353+
lsp_enabled: bool,
354+
share: ProjectShareProjection,
355+
goal: ProjectGoalState,
356+
}
357+
358+
impl FakeProject {
359+
fn new() -> Self {
360+
Self {
361+
lsp_enabled: false,
362+
share: ProjectShareProjection {
363+
history_is_empty: true,
364+
history_len: 0,
365+
model: "deepseek-chat".to_string(),
366+
mode_label: "ACT".to_string(),
367+
},
368+
goal: ProjectGoalState {
369+
objective: Some("Ship FEAT-021".to_string()),
370+
status: ProjectGoalStatus::Active,
371+
pause_reason: None,
372+
started_at_elapsed_seconds: Some(42),
373+
time_used_seconds: 42,
374+
token_budget: Some(50_000),
375+
tokens_used: 1_000,
376+
session_total_tokens: 2_000,
377+
continuation_count: 3,
378+
pending_controls: false,
379+
last_known_objective: None,
380+
last_known_status: None,
381+
conversation_present: true,
382+
is_loading: false,
383+
goal_continuation_waiting: false,
384+
},
385+
}
386+
}
387+
}
388+
389+
impl CommandProjectContext for FakeProject {
390+
fn lsp_enabled(&self) -> bool {
391+
self.lsp_enabled
392+
}
393+
394+
fn lsp_set(&mut self, enabled: bool) -> Result<(), String> {
395+
self.lsp_enabled = enabled;
396+
Ok(())
397+
}
398+
399+
fn share_projection(&self) -> ProjectShareProjection {
400+
self.share.clone()
401+
}
402+
403+
fn goal_state(&self) -> ProjectGoalState {
404+
self.goal.clone()
405+
}
406+
}
407+
408+
#[test]
409+
fn project_facet_is_object_safe_and_typed() {
410+
fn project(_: &dyn CommandProjectContext) {}
411+
project(&FakeProject::new());
412+
413+
let mut project = FakeProject::new();
414+
assert!(!project.lsp_enabled());
415+
project.lsp_set(true).unwrap();
416+
assert!(project.lsp_enabled());
417+
project.lsp_set(false).unwrap();
418+
assert!(!project.lsp_enabled());
419+
}
420+
421+
#[test]
422+
fn project_share_projection_preserves_semantic_values() {
423+
let project = FakeProject::new();
424+
let share = project.share_projection();
425+
assert!(share.history_is_empty);
426+
assert_eq!(share.history_len, 0);
427+
assert_eq!(share.model, "deepseek-chat");
428+
assert_eq!(share.mode_label, "ACT");
429+
}
430+
431+
#[test]
432+
fn project_goal_state_preserves_semantic_values() {
433+
let project = FakeProject::new();
434+
let goal = project.goal_state();
435+
assert_eq!(goal.objective.as_deref(), Some("Ship FEAT-021"));
436+
assert_eq!(goal.status, ProjectGoalStatus::Active);
437+
assert_eq!(goal.pause_reason, None);
438+
assert_eq!(goal.started_at_elapsed_seconds, Some(42));
439+
assert_eq!(goal.time_used_seconds, 42);
440+
assert_eq!(goal.token_budget, Some(50_000));
441+
assert_eq!(goal.tokens_used, 1_000);
442+
assert_eq!(goal.session_total_tokens, 2_000);
443+
assert_eq!(goal.continuation_count, 3);
444+
assert!(!goal.pending_controls);
445+
assert_eq!(goal.last_known_objective, None);
446+
assert_eq!(goal.last_known_status, None);
447+
assert!(goal.conversation_present);
448+
assert!(!goal.is_loading);
449+
assert!(!goal.goal_continuation_waiting);
450+
}
451+
452+
#[test]
453+
fn project_goal_status_variants_are_distinguishable() {
454+
let paused = ProjectGoalState {
455+
status: ProjectGoalStatus::Paused,
456+
pause_reason: Some("user".to_string()),
457+
..FakeProject::new().goal
458+
};
459+
assert_eq!(paused.status, ProjectGoalStatus::Paused);
460+
assert_eq!(paused.pause_reason.as_deref(), Some("user"));
461+
462+
let complete = ProjectGoalState {
463+
status: ProjectGoalStatus::Complete,
464+
..paused
465+
};
466+
assert_eq!(complete.status, ProjectGoalStatus::Complete);
467+
assert_ne!(complete.status, ProjectGoalStatus::Blocked);
468+
}
469+
470+
#[test]
471+
fn project_facet_transports_through_envelope_when_declared() {
472+
let mut project = FakeProject::new();
473+
let parts = CommandContexts::empty()
474+
.with_project(&mut project)
475+
.into_parts();
476+
assert!(parts.project.is_some());
477+
assert!(parts.session.is_none());
478+
479+
// PROJECT combined with WORKSPACE (init) and PRESENTATION (goal).
480+
let mut workspace = Workspace;
481+
let parts = CommandContexts::empty()
482+
.with_project(&mut project)
483+
.with_workspace(&mut workspace)
484+
.into_parts();
485+
assert!(parts.project.is_some());
486+
assert!(parts.workspace.is_some());
487+
assert!(parts.presentation.is_none());
488+
}
489+
490+
#[test]
491+
fn envelope_rejects_duplicate_project_slot_deterministically() {
492+
let mut a = FakeProject::new();
493+
let mut b = FakeProject::new();
494+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
495+
CommandContexts::empty()
496+
.with_project(&mut a)
497+
.with_project(&mut b);
498+
}));
499+
assert!(result.is_err(), "duplicate project slot must assert");
500+
}

0 commit comments

Comments
 (0)