Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ crates/telemetry/tests/golden/*.json text eol=lf
# always join rows with LF (golden_harness.rs::render_golden_text), so a
# CRLF checkout failed all eight Windows golden tests with a pure \r\n diff.
crates/tui/src/tui/goldens/*.txt text eol=lf
# FEAT-025 baseline export goldens (crates/tui/src/commands/fixtures/*.md) are
# include_str!()'d and compared byte for byte against a document the exporter
# builds with LF, so a CRLF checkout failed the four Windows golden tests with a
# pure \r\n diff (Windows CI caught this; Linux cannot see it).
crates/tui/src/commands/fixtures/*.md text eol=lf
crates/*/assets/**/*.json text eol=lf
crates/*/assets/**/*.md text eol=lf
crates/*/locales/*.json text eol=lf
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/command-contract/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ workspace = true

[dependencies]
codewhale-core = { path = "../core", version = "0.9.13" }
# FEAT-025 export projections carry JSON tool payloads. The workspace already
# pins serde_json with `preserve_order` (crates/core and crates/tui), so this
# adds no new external dependency to the graph.
serde_json = { workspace = true, features = ["preserve_order"] }
221 changes: 221 additions & 0 deletions crates/command-contract/src/facets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use std::path::{Path, PathBuf};

use codewhale_core::request::{Message, SystemPrompt};
use serde_json::Value;

use crate::types::{
CommandApprovalMode, CommandCurrency, CommandMode, CommandProviderId, CommandReasoningEffort,
Expand Down Expand Up @@ -1368,3 +1369,223 @@ pub trait CommandSessionControlContext {
/// errors.
fn resolve_hosted_work_target(&self) -> Option<HostedWorkTarget>;
}

// ---------------------------------------------------------------------------
// FEAT-025: session export slice (D1-D9).
//
// One independently optional session-export authority covering exactly the
// host work `/export` (and its `/daochu` alias) consumes. The shared
// `CommandSessionContext`, `CommandSessionLifecycleContext`, and
// `CommandSessionControlContext` facets are deliberately not widened: export
// authority exists only on this facet, and every delegate is an atomic host
// operation or a semantic projection so the portable handler keeps
// byte-identical composition. Hidden payloads are excluded while projections
// are built (D9), so internal reasoning, reasoning signatures, and inline or
// local image bytes never enter these DTOs. No `App`, clipboard handler,
// snapshot repository, history cell, session manager, configuration, client,
// filesystem handle, or host callback crosses this boundary (D1/D3/D5/D7).
// ---------------------------------------------------------------------------

/// Portable conversation metadata for the export header (D3).
///
/// Values that already have an authoritative host derivation keep it
/// (session-label truncation, provider identity, model label, mode display,
/// workspace basename, message count, clock); portable rendering adds only
/// export formatting and sanitization (D10).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExportMetadata {
/// Host-truncated session id, or the baseline `unsaved` fallback.
pub session_label: String,
pub provider: String,
pub model: String,
pub mode: String,
/// Workspace directory basename, or the baseline `workspace` fallback.
pub workspace_name: String,
/// `api_messages.len()` when authoritative, otherwise `history.len()`.
pub message_count: usize,
pub exported_at_unix: i64,
}

/// One tool-call caller projection (D3). Only the fields the baseline export
/// renders cross the boundary.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ToolCallerProjection {
pub caller_type: String,
pub tool_id: Option<String>,
}

/// One projected content block (D3/D9).
///
/// Visible text and structured content cross as portable data; internal
/// reasoning bodies, reasoning signatures, and inline or local image payloads
/// are replaced by typed omission markers at projection time and never cross.
#[derive(Clone, Debug, PartialEq)]
pub enum ExportBlock {
/// Visible text block; portable rendering sanitizes it.
Text {
text: String,
},
/// External image reference (`http`/`https` only); portable rendering
/// redacts credential-bearing URLs.
ImageReference {
url: String,
},
/// Inline or local image payload excluded at projection time (D9).
ImageOmitted,
/// Internal reasoning body and reasoning signature excluded (D9).
InternalReasoning,
ToolCall {
id: String,
name: String,
caller: Option<ToolCallerProjection>,
input: Value,
},
ToolResult {
tool_use_id: String,
content: String,
is_error: bool,
/// `Some` when the host message carried structured result blocks; the
/// host has already applied the safe-result filter (D9).
structured: Option<Value>,
},
ServerToolCall {
id: String,
name: String,
input: Value,
},
ToolSearchResult {
tool_use_id: String,
content: Value,
},
CodeExecutionResult {
tool_use_id: String,
content: Value,
},
}

/// One projected authoritative message (D3).
///
/// `prompt_snippet` is the host-computed `snapshot_label_prompt_snippet` of
/// the first visible text block. The parser and snippet algorithm stay
/// TUI-owned (D8), so correlation compares authoritative values instead of
/// re-deriving them portably.
///
/// `is_user_role` carries the host's exact `Role::User` comparison. `role` is
/// the rendered wire string, and comparing it textually would also match a
/// `Role::Unrecognized("user")`, which the baseline never treated as a user
/// turn. The flag keeps restore-point correlation faithful to the baseline.
#[derive(Clone, Debug, PartialEq)]
pub struct ExportMessage {
pub role: String,
/// Exact `message.role == Role::User`, not a string comparison.
pub is_user_role: bool,
pub blocks: Vec<ExportBlock>,
pub prompt_snippet: Option<String>,
}

/// One projected visible-history fallback entry (D3).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HistoryEntry {
/// Visible host content that portable rendering must still sanitize.
Sanitized { role: String, body: String },
/// An already-final baseline marker line that must not be sanitized again.
Literal { role: String, body: String },
}

/// Transcript source precedence (D3): authoritative API messages when
/// present, otherwise the sanitized visible-history fallback.
#[derive(Clone, Debug, PartialEq)]
pub enum TranscriptProjection {
Authoritative(Vec<ExportMessage>),
HistoryFallback(Vec<HistoryEntry>),
}

/// One snapshot projected to semantic fields (D8).
///
/// `kind`, `sequence`, and `prompt_snippet` are the host-parsed label fields;
/// the raw `label` is kept only for the human-readable table column. No
/// preformatted correlation line crosses the boundary.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RestoreSnapshot {
pub id: String,
pub label: String,
pub timestamp_unix: i64,
pub kind: String,
pub sequence: Option<u64>,
pub prompt_snippet: Option<String>,
}

/// Restore-point projection with distinct baseline states (D3/D8).
///
/// `None` means no snapshot repository exists, `Unreadable` preserves the host
/// failure reason, and `Recorded` distinguishes an existing-but-empty
/// repository from one with snapshots by the vector length.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RestorePointProjection {
None,
Unreadable { reason: String },
Recorded { snapshots: Vec<RestoreSnapshot> },
}

/// Full conversation projection (D3/D8/D9).
#[derive(Clone, Debug, PartialEq)]
pub struct ConversationExportProjection {
pub metadata: ExportMetadata,
pub transcript: TranscriptProjection,
pub restore_points: RestorePointProjection,
}

/// Turn-handoff projection (D2).
///
/// `markdown` is the unmodified shared TUI renderer output and
/// `workspace_path` is the value the portable handler replaces with `.` after
/// sanitizing; the renderer itself is neither moved nor duplicated.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TurnHandoffProjection {
pub markdown: String,
pub workspace_path: String,
}

/// Session-export authority for the `/export` slice (FEAT-025 D1-D9).
///
/// Operation-granular synchronous delegates over the exact minimum host work
/// the command consumes. The portable handler parses the request first, renders
/// the selected scope second, and then uses these delegates in baseline order:
/// clipboard exports call terminal-paste detection, recovery write, and
/// clipboard delivery exactly once each with the same Markdown; file exports
/// resolve the destination before writing it. A recovery-write `None` never
/// prevents the clipboard attempt, and a turn-only export never requests the
/// conversation projection (D6/D7).
pub trait CommandSessionExportContext {
/// Conversation export projection: metadata, authoritative-or-fallback
/// transcript, and restore-point state. Read-only; opens only an existing
/// snapshot repository and never creates one (D8).
fn conversation_projection(&self) -> ConversationExportProjection;

/// Turn-handoff projection: unmodified shared renderer Markdown plus the
/// workspace path value (D2).
fn turn_handoff_projection(&self) -> TurnHandoffProjection;

/// Whether clipboard delivery goes through the terminal-client (SSH/OSC 52
/// via tmux) path (D6).
fn clipboard_requires_terminal_paste(&self) -> bool;

/// Write the shared `last-copy.md` recovery file. `None` reproduces the
/// baseline silent failure; recovery writing never falls through to an
/// error (D5/D6).
fn write_recovery_copy(&self, markdown: &str) -> Option<PathBuf>;

/// Attempt clipboard delivery. `Err` carries the raw host clipboard error
/// text; the handler composes the exact failure wording (D6).
fn write_clipboard(&self, markdown: &str) -> Result<(), String>;

/// Resolve a file destination exactly as the baseline does (trim, empty
/// check, `..` rejection, workspace canonicalization and rebasing, filename
/// requirement). Errors are returned unwrapped (D7).
fn resolve_export_path(&self, raw: &str) -> Result<PathBuf, String>;

/// Write the rendered export to a resolved destination with the baseline
/// protection checks. Errors are returned unwrapped; the handler wraps them
/// in `Failed to export {label} to {path}: {err}` (D7).
fn write_export_file(&self, path: &Path, contents: &[u8], force: bool) -> Result<(), String>;
}
42 changes: 39 additions & 3 deletions crates/command-contract/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
use crate::facets::{
CommandCostContext, CommandMediaContext, CommandMemoryContext, CommandModePolicyContext,
CommandModelContext, CommandPluginContext, CommandPresentationContext, CommandProjectContext,
CommandSessionContext, CommandSessionControlContext, CommandSessionLifecycleContext,
CommandSkillGroupContext, CommandSkillsContext, CommandSystemPromptContext,
CommandWorkspaceContext,
CommandSessionContext, CommandSessionControlContext, CommandSessionExportContext,
CommandSessionLifecycleContext, CommandSkillGroupContext, CommandSkillsContext,
CommandSystemPromptContext, CommandWorkspaceContext,
};

/// Exact host capabilities exposed to one contextual command handler.
Expand Down Expand Up @@ -51,6 +51,30 @@ impl CommandCapabilities {
/// storage remains `u16` per the resolved maintainer review on FEAT-023 PR
/// #5902 — bit 14 is available, so no speculative widening is performed.
pub const SESSION_CONTROL: Self = Self(1 << 14);
/// Session-export host data (FEAT-025 D1), the next non-conflicting bit
/// after `SESSION_CONTROL`. Required only by the host-dependent `/export`
/// command and its `/daochu` alias; every concrete App, snapshot, clipboard,
/// filesystem, history, and turn-handoff access stays behind the TUI export
/// adapter.
///
/// **Capacity: this is the last free bit.** Bits 0-15 are now fully
/// allocated, so another capability cannot be added without widening the
/// backing storage to `u32`. FEAT-026 (session structcopy) needs its own
/// exact-minimum facet and therefore owns that widening decision; reusing
/// `SESSION_EXPORT` for it would break the least-capability invariant.
/// The `export_capability_space_is_exactly_full` test pins the capacity so
/// the next author gets a deliberate decision instead of a compile error
/// with no context.
pub const SESSION_EXPORT: Self = Self(1 << 15);

/// Raw bit pattern, for tests that pin the capability-space capacity.
///
/// Kept `#[cfg(test)]` so the `u16` backing stays an implementation detail
/// and nothing can widen it accidentally through a public accessor.
#[cfg(test)]
pub(crate) const fn bits_for_test(self) -> u16 {
self.0
}

pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
Expand Down Expand Up @@ -100,6 +124,7 @@ pub struct CommandContexts<'a> {
plugin: Option<&'a mut dyn CommandPluginContext>,
lifecycle: Option<&'a mut dyn CommandSessionLifecycleContext>,
control: Option<&'a mut dyn CommandSessionControlContext>,
export: Option<&'a mut dyn CommandSessionExportContext>,
}

/// Consumed envelope used when one handler needs several independent facets.
Expand All @@ -119,6 +144,7 @@ pub struct ContextParts<'a> {
pub plugin: Option<&'a mut dyn CommandPluginContext>,
pub lifecycle: Option<&'a mut dyn CommandSessionLifecycleContext>,
pub control: Option<&'a mut dyn CommandSessionControlContext>,
pub export: Option<&'a mut dyn CommandSessionExportContext>,
}

impl<'a> CommandContexts<'a> {
Expand All @@ -139,6 +165,7 @@ impl<'a> CommandContexts<'a> {
plugin: None,
lifecycle: None,
control: None,
export: None,
}
}

Expand All @@ -159,6 +186,7 @@ impl<'a> CommandContexts<'a> {
plugin: self.plugin,
lifecycle: self.lifecycle,
control: self.control,
export: self.export,
}
}

Expand Down Expand Up @@ -278,6 +306,14 @@ impl<'a> CommandContexts<'a> {
);
self
}

pub fn with_export(mut self, value: &'a mut dyn CommandSessionExportContext) -> Self {
assert!(
self.export.replace(value).is_none(),
"export facet already set"
);
self
}
}

impl Default for CommandContexts<'_> {
Expand Down
Loading
Loading