Skip to content
Open
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
163 changes: 159 additions & 4 deletions crates/tui/src/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,57 @@ pub fn set_authority_recap_override(s: String) -> Result<(), String> {
set_prompt_override(&AUTHORITY_RECAP_OVERRIDE, s)
}

// ── Static-layer composer override ──
// Per-constant overrides above let an embedder swap individual blocks, but
// any block upstream adds later still leaks into the embedder's prompt.
// The composer override is the sealing variant: when set, the embedder
// takes over ALL compile-time static doctrine — tool taxonomy, base
// prompt, personality, mode delta, approval policy, `## Context
// Management`, and the compaction relay template — and upstream additions
// to those layers only affect the default composition. Dynamic structural
// blocks (project context, skills, environment, instructions, memory,
// goal, handoff relay, locale bookends, authority recap) keep their
// existing rendering and hooks.

/// Inputs handed to a [`set_static_prompt_composer_override`] composer.
///
/// `#[non_exhaustive]` so upstream can add fields without breaking
/// embedders; construct only via the composition pipeline.
#[non_exhaustive]
#[derive(Debug)]
pub struct StaticPromptCtx<'a> {
/// Active app mode (Agent / Plan / Yolo).
pub mode: crate::tui::app::AppMode,
/// Effective approval mode for this session.
pub approval_mode: crate::tui::approval::ApprovalMode,
/// Active model identifier (replaces `{model_id}` in base prompts).
pub model_id: &'a str,
/// Whether shell tools are exposed to the model this session.
pub allow_shell: bool,
/// The static layers the default composition would have produced —
/// for reference or partial reuse by the composer.
pub default_layers: &'a str,
Comment on lines +375 to +383
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 personality is absent from StaticPromptCtx. It is one of the five parameters that reach compose_default_static_layers and directly affects ctx.default_layers content (the personality.prompt().trim() slot). An embedder who calls the public compose_prompt(mode, Personality::Lively) path with a composer installed receives default_layers shaped by Personality::Lively but has no ctx.personality field to branch on — they must resort to string-scanning ctx.default_layers to detect it. The struct is already #[non_exhaustive], so adding a pub personality: crate::tui::app::Personality field is non-breaking and completes the contract of "all inputs that shaped default_layers are visible to the composer".

Fix in Codex Fix in Claude Code Fix in Cursor

}
Comment on lines +372 to +384
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The StaticPromptCtx struct is missing the personality field. The personality (e.g., Personality::Calm vs Personality::Playful) is a key part of the static prompt layers. If an embedder wants to take over the static prompt composition, they need to know the active personality voice/tone overlay to customize the prompt correctly.

Additionally, we can simplify the redundant fully-qualified type paths crate::tui::app::AppMode and crate::tui::approval::ApprovalMode to AppMode and ApprovalMode since they are already imported in this file.

Suggested change
pub struct StaticPromptCtx<'a> {
/// Active app mode (Agent / Plan / Yolo).
pub mode: crate::tui::app::AppMode,
/// Effective approval mode for this session.
pub approval_mode: crate::tui::approval::ApprovalMode,
/// Active model identifier (replaces `{model_id}` in base prompts).
pub model_id: &'a str,
/// Whether shell tools are exposed to the model this session.
pub allow_shell: bool,
/// The static layers the default composition would have produced —
/// for reference or partial reuse by the composer.
pub default_layers: &'a str,
}
pub struct StaticPromptCtx<'a> {
/// Active app mode (Agent / Plan / Yolo).
pub mode: AppMode,
/// Active personality voice/tone overlay.
pub personality: Personality,
/// Effective approval mode for this session.
pub approval_mode: ApprovalMode,
/// Active model identifier (replaces `{model_id}` in base prompts).
pub model_id: &'a str,
/// Whether shell tools are exposed to the model this session.
pub allow_shell: bool,
/// The static layers the default composition would have produced —
/// for reference or partial reuse by the composer.
pub default_layers: &'a str,
}


/// A composer that replaces the compile-time static prompt layers.
pub type StaticPromptComposer = dyn Fn(&StaticPromptCtx<'_>) -> String + Send + Sync;

static STATIC_PROMPT_COMPOSER: std::sync::OnceLock<Box<StaticPromptComposer>> =
std::sync::OnceLock::new();

/// Install a composer that replaces ALL compile-time static prompt layers
/// (taxonomy, base, personality, mode, approval, context management,
/// compaction relay template). First call wins; later calls return
/// `Err(())`. Set before spawning any engine. When set, the per-constant
/// overrides for those layers are bypassed entirely.
pub fn set_static_prompt_composer_override(f: Box<StaticPromptComposer>) -> Result<(), ()> {
STATIC_PROMPT_COMPOSER.set(f).map_err(|_| ())
}
Comment on lines +397 to +399
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 set_static_prompt_composer_override returns Result<(), ()> while every other set_*_override in this file returns Result<(), String>. The unit error gives callers no diagnostic information — they can't even log "already set by X". The OnceLock gives back the rejected value on Err; mapping it to () discards the only actionable detail. Returning Err(()) is also a departure from the rest of the override family's contract, which may surprise embedders who rely on the common signature.

Suggested change
pub fn set_static_prompt_composer_override(f: Box<StaticPromptComposer>) -> Result<(), ()> {
STATIC_PROMPT_COMPOSER.set(f).map_err(|_| ())
}
pub fn set_static_prompt_composer_override(
f: Box<StaticPromptComposer>,
) -> Result<(), Box<StaticPromptComposer>> {
STATIC_PROMPT_COMPOSER.set(f)
}

Fix in Codex Fix in Claude Code Fix in Cursor


fn static_prompt_composer() -> Option<&'static StaticPromptComposer> {
STATIC_PROMPT_COMPOSER.get().map(Box::as_ref)
}

fn set_prompt_override(cell: &std::sync::OnceLock<String>, s: String) -> Result<(), String> {
cell.set(s)
}
Expand Down Expand Up @@ -790,6 +841,48 @@ fn compose_prompt_with_approval_model_and_shell(
approval_mode: ApprovalMode,
model_id: &str,
allow_shell: bool,
) -> String {
let default_layers =
compose_default_static_layers(mode, personality, approval_mode, model_id, allow_shell);
apply_static_prompt_composer(
static_prompt_composer(),
mode,
approval_mode,
model_id,
allow_shell,
default_layers,
)
}

/// Apply a static-layer composer over the default composition. Split out
/// from the global-`OnceLock` lookup so tests can inject a composer
/// without poisoning process-global state.
fn apply_static_prompt_composer(
composer: Option<&StaticPromptComposer>,
mode: AppMode,
approval_mode: ApprovalMode,
model_id: &str,
allow_shell: bool,
default_layers: String,
) -> String {
match composer {
Some(compose) => compose(&StaticPromptCtx {
mode,
approval_mode,
model_id,
allow_shell,
default_layers: &default_layers,
}),
None => default_layers,
}
}
Comment on lines +844 to +878
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass the personality parameter to apply_static_prompt_composer and populate it in StaticPromptCtx so that the custom static prompt composer has access to the active personality voice/tone overlay.

) -> String {
    let default_layers = 
        compose_default_static_layers(mode, personality, approval_mode, model_id, allow_shell);
    apply_static_prompt_composer(
        static_prompt_composer(),
        mode,
        personality,
        approval_mode,
        model_id,
        allow_shell,
        default_layers,
    )
}

/// Apply a static-layer composer over the default composition. Split out
/// from the global-`OnceLock` lookup so tests can inject a composer
/// without poisoning process-global state.
fn apply_static_prompt_composer(
    composer: Option<&StaticPromptComposer>,
    mode: AppMode,
    personality: Personality,
    approval_mode: ApprovalMode,
    model_id: &str,
    allow_shell: bool,
    default_layers: String,
) -> String {
    match composer {
        Some(compose) => compose(&StaticPromptCtx {
            mode,
            personality,
            approval_mode,
            model_id,
            allow_shell,
            default_layers: &default_layers,
        }),
        None => default_layers,
    }
}


fn compose_default_static_layers(
mode: AppMode,
personality: Personality,
approval_mode: ApprovalMode,
model_id: &str,
allow_shell: bool,
) -> String {
let tool_taxonomy = render_core_tool_taxonomy_block(mode);
let shell_tools_available = allow_shell && mode != AppMode::Plan;
Expand Down Expand Up @@ -1079,8 +1172,11 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
full_prompt = format!("{full_prompt}\n\n{block}");
}

// 4. Context Management (Agent / Yolo only).
if matches!(mode, AppMode::Agent | AppMode::Yolo) {
// 4. Context Management (Agent / Yolo only). Suppressed when a
// static-prompt composer is installed — the composer owns ALL
// compile-time static doctrine (see
// `set_static_prompt_composer_override`).
if static_prompt_composer().is_none() && matches!(mode, AppMode::Agent | AppMode::Yolo) {
full_prompt.push_str(
"\n\n## Context Management\n\n\
When the conversation gets long (you'll see a context usage indicator), you can:\n\
Expand All @@ -1101,8 +1197,13 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(

// 5. Compaction relay template — so the model knows the format to use
// when writing `.codewhale/handoff.md` on exit / `/compact`.
full_prompt.push_str("\n\n");
full_prompt.push_str(COMPACT_TEMPLATE);
// Also composer-owned static doctrine: a static-prompt composer
// that wants the template (or a trimmed variant) includes it in
// its own output.
if static_prompt_composer().is_none() {
full_prompt.push_str("\n\n");
full_prompt.push_str(COMPACT_TEMPLATE);
}

// ── Volatile-content boundary ─────────────────────────────────────────
// Everything below drifts mid-session and busts the prefix cache for
Expand Down Expand Up @@ -1235,6 +1336,60 @@ mod tests {
assert_eq!(effective_prompt_override(&cell, "fallback"), "first");
}

// NOTE: these tests inject the composer as a parameter instead of
// calling `set_static_prompt_composer_override` — the global is a
// process-wide OnceLock and setting it here would poison every other
// prompt test in this binary.
#[test]
fn static_prompt_composer_replaces_default_layers() {
let default_layers = compose_default_static_layers(
AppMode::Agent,
Personality::Calm,
ApprovalMode::Suggest,
"test-model",
true,
);
assert!(default_layers.contains("Personality: Calm"));

let composer = |ctx: &StaticPromptCtx<'_>| -> String {
assert_eq!(ctx.mode, AppMode::Agent);
assert_eq!(ctx.approval_mode, ApprovalMode::Suggest);
assert_eq!(ctx.model_id, "test-model");
assert!(ctx.allow_shell);
assert!(ctx.default_layers.contains("Personality: Calm"));
"EMBEDDER STATIC LAYERS".to_string()
};
let composed = apply_static_prompt_composer(
Some(&composer),
AppMode::Agent,
ApprovalMode::Suggest,
"test-model",
true,
default_layers,
);
assert_eq!(composed, "EMBEDDER STATIC LAYERS");
}

#[test]
fn static_prompt_composer_unset_keeps_default_layers_byte_identical() {
let default_layers = compose_default_static_layers(
AppMode::Agent,
Personality::Calm,
ApprovalMode::Suggest,
"test-model",
true,
);
let composed = apply_static_prompt_composer(
None,
AppMode::Agent,
ApprovalMode::Suggest,
"test-model",
true,
default_layers.clone(),
);
Comment on lines +1354 to +1389
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the tests to verify that the personality field is correctly passed through to the StaticPromptCtx and handled by apply_static_prompt_composer.

        let composer = |ctx: &StaticPromptCtx<'_>| -> String {
            assert_eq!(ctx.mode, AppMode::Agent);
            assert_eq!(ctx.personality, Personality::Calm);
            assert_eq!(ctx.approval_mode, ApprovalMode::Suggest);
            assert_eq!(ctx.model_id, "test-model");
            assert!(ctx.allow_shell);
            assert!(ctx.default_layers.contains("Personality: Calm"));
            "EMBEDDER STATIC LAYERS".to_string()
        };
        let composed = apply_static_prompt_composer(
            Some(&composer),
            AppMode::Agent,
            Personality::Calm,
            ApprovalMode::Suggest,
            "test-model",
            true,
            default_layers,
        );
        assert_eq!(composed, "EMBEDDER STATIC LAYERS");
    }

    #[test]
    fn static_prompt_composer_unset_keeps_default_layers_byte_identical() {
        let default_layers = compose_default_static_layers(
            AppMode::Agent,
            Personality::Calm,
            ApprovalMode::Suggest,
            "test-model",
            true,
        );
        let composed = apply_static_prompt_composer(
            None,
            AppMode::Agent,
            Personality::Calm,
            ApprovalMode::Suggest,
            "test-model",
            true,
            default_layers.clone(),
        );

assert_eq!(composed, default_layers);
}

fn contains_cjk(text: &str) -> bool {
text.chars().any(|ch| {
matches!(
Expand Down
Loading