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
21 changes: 21 additions & 0 deletions crates/adapter-smith/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod provider_watchdog;
mod sandbox;
mod skills;
mod tasks;
mod suggest_mode;
mod title_mode;
mod tools;

Expand Down Expand Up @@ -176,6 +177,26 @@ fn model_hint(params: &SessionStartParams) -> String {
.unwrap_or_else(|| "(auto-detect from available credentials)".to_string())
}

/// One-shot suggestion generation (spec 0106): read the rendered
/// transcript tail from stdin, print the generated hand as JSON.
/// Non-zero exit on any failure so the daemon treats the attempt as
/// best-effort and drops it.
pub async fn run_suggest_mode() -> anyhow::Result<()> {
use tokio::io::AsyncReadExt;
let mut context = String::new();
tokio::io::stdin().read_to_string(&mut context).await?;
match suggest_mode::suggest_hand(&context).await {
Ok(hand) => {
println!("{}", serde_json::to_string(&hand)?);
Ok(())
}
Err(e) => {
eprintln!("suggest-mode failed: {e}");
std::process::exit(1);
}
}
}

pub async fn run_title_mode(prompt: &str) -> anyhow::Result<()> {
match title_mode::suggest_title(prompt).await {
Ok(title) => {
Expand Down
85 changes: 85 additions & 0 deletions crates/adapter-smith/src/suggest_mode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//! One-shot next-prompt suggestion generation (spec 0109).
//!
//! For smith sessions the "same harness" generator is simply another
//! smith completion, so the daemon shells out to
//! `construct-adapter-smith --suggest-mode`, writing a rendered
//! transcript tail to stdin. Same model-resolution ladder as
//! title-mode, one tools-disabled completion, then the shared
//! [`SuggestionHand::parse_loose`] contract from the protocol crate.
//! The normalized hand is printed to stdout as JSON for the daemon to
//! broadcast verbatim.

use crate::provider::{Content, Message, Role, TextSink, ToolSpec};
use crate::title_mode::{pick_default_spec_str, provider_for};
use anyhow::{anyhow, Result};
use construct_protocol::SuggestionHand;

/// Sink that captures streamed deltas in memory.
#[derive(Default)]
struct CaptureSink {
text: String,
}
impl TextSink for CaptureSink {
fn delta(&mut self, text: &str) {
self.text.push_str(text);
}
}

const MAX_CONTEXT_CHARS: usize = 24_000;

/// Run one suggestion completion over the rendered transcript tail and
/// return the clamped hand. Fails fast on missing credentials / network
/// errors / unparseable output so the daemon can silently drop the
/// attempt — suggestions are best-effort by contract.
pub async fn suggest_hand(context: &str) -> Result<SuggestionHand> {
let context = tail_chars(context, MAX_CONTEXT_CHARS);
if context.trim().is_empty() {
return Err(anyhow!("empty transcript context"));
}
let spec = crate::provider::routing::parse_model_spec(&pick_default_spec_str()?)
.map_err(|e| anyhow!("model-spec parse: {e}"))?;
let provider = provider_for(spec.provider)?;
let messages = vec![Message {
role: Role::User,
content: Content::Text {
text: format!("Transcript tail:\n\n{context}\n\nJSON:"),
},
}];
let tools: Vec<ToolSpec> = Vec::new();
let mut sink = CaptureSink::default();
let _turn = provider
.complete(
&spec.model,
SuggestionHand::PROMPT_INSTRUCTIONS,
&messages,
&tools,
&mut sink,
)
.await?;
SuggestionHand::parse_loose(&sink.text).map_err(|e| anyhow!(e))
}

/// Last `max` chars of `s`, on a char boundary.
fn tail_chars(s: &str, max: usize) -> &str {
let count = s.chars().count();
if count <= max {
return s;
}
let skip = count - max;
let (idx, _) = s.char_indices().nth(skip).unwrap_or((0, ' '));
&s[idx..]
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn tail_chars_keeps_suffix() {
assert_eq!(tail_chars("abcdef", 3), "def");
assert_eq!(tail_chars("abc", 10), "abc");
}

// parse_loose behavior (fences, clamps, empty-top rejection) is
// covered in the protocol crate where the function lives.
}
4 changes: 2 additions & 2 deletions crates/adapter-smith/src/title_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ impl TextSink for CaptureSink {
/// just means a session with no configured credential quietly keeps its
/// default (hash-derived) name instead of shelling out to an Ollama server
/// that likely isn't running.
fn pick_default_spec_str() -> Result<String> {
pub(crate) fn pick_default_spec_str() -> Result<String> {
if let Ok(s) = std::env::var("CONSTRUCT_SMITH_MODEL") {
if !s.trim().is_empty() {
return Ok(s);
Expand All @@ -60,7 +60,7 @@ fn pick_default_spec_str() -> Result<String> {
))
}

fn provider_for(p: Provider) -> Result<Box<dyn LlmProvider>> {
pub(crate) fn provider_for(p: Provider) -> Result<Box<dyn LlmProvider>> {
Ok(match p {
Provider::OpenAI => Box::new(provider::openai::OpenAi::from_env()?),
Provider::Anthropic => Box::new(provider::anthropic::Anthropic::from_env()?),
Expand Down
66 changes: 66 additions & 0 deletions crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ mod program_popup;
mod session_picker;
mod session_title_menu;
mod session_title_rename;
pub mod suggest_deck;
mod tutorial;
pub use configure::{
harness_guidance, no_agent_harness_available, smith_method_guidance, ConfigurePopup,
Expand Down Expand Up @@ -800,6 +801,7 @@ fn chat_scroll_kind(ev: &SessionEvent) -> ChatScrollKind {
SessionEvent::Pty { .. }
| SessionEvent::PtyResize { .. }
| SessionEvent::EditorState { .. }
| SessionEvent::Suggestions(_)
| SessionEvent::ClientCommand { .. }
| SessionEvent::ToolApprovalResolved { .. }
| SessionEvent::ApprovalModeChanged { .. }
Expand Down Expand Up @@ -1782,6 +1784,11 @@ pub struct App {
/// Latest browser preview per session, fed by `SessionEvent::BrowserPreview`
/// and rendered as a top-right overlay in the terminal view.
pub browser_previews: HashMap<String, BrowserPreviewState>,
/// Per-session suggestion deck (spec 0109), fed by
/// `SessionEvent::Suggestions` at turn end and rendered as the corner
/// orb / fan / card stack on the session view. Dropped the moment the
/// session runs again — a hand only describes the turn it was dealt for.
pub suggestions: HashMap<String, suggest_deck::SuggestionDeckState>,
/// Decoded program-attachment images (spec 0099), keyed by path with the
/// file's mtime so an overwritten attachment re-decodes. `None` records a
/// failed decode, so a broken file isn't re-read every frame.
Expand Down Expand Up @@ -3304,6 +3311,15 @@ impl LineageBoxHit {
pub struct LayoutSnapshot {
pub list_area: Option<ratatui::layout::Rect>,
pub view_area: Option<ratatui::layout::Rect>,
/// Suggestion-deck orb badge bounds from the last frame (spec 0109).
pub suggestion_orb_hit: Option<ratatui::layout::Rect>,
/// Session-view rect the deck overlay anchors its geometry to.
pub suggestion_anchor: Option<ratatui::layout::Rect>,
/// Fan chip bounds from the last frame: `(rect, fan_index)` where
/// index 0 is the top pick and 1.. are verbs.
pub suggestion_chip_hits: Vec<(ratatui::layout::Rect, usize)>,
/// Card-stack row bounds from the last frame: `(rect, card_index)`.
pub suggestion_card_hits: Vec<(ratatui::layout::Rect, usize)>,
pub main_window_areas: Vec<WindowPaneHit>,
pub main_window_dividers: Vec<WindowDividerHit>,
pub pin_strip_area: Option<ratatui::layout::Rect>,
Expand Down Expand Up @@ -3924,6 +3940,7 @@ async fn run_with_socket_initial_selection(
session_picker: None,
program_popup: None,
program_popups: HashMap::new(),
suggestions: HashMap::new(),
program_view_memory: HashMap::new(),
program_runs: HashMap::new(),
program_run_dispatch: HashMap::new(),
Expand Down Expand Up @@ -7591,12 +7608,39 @@ impl App {
self.clear_pending_tool_approval(&payload.session_id, call_id);
self.dismiss_approval_prompt(&payload.session_id, call_id);
}
// Suggestion hand arrived (spec 0109): store it and
// auto-open the fan — the user explicitly requested
// it via the orb, so no second activation needed.
if let SessionEvent::Suggestions(hand) = &payload.event {
self.suggestions
.entry(payload.session_id.clone())
.or_default()
.deal(hand.clone());
}
// A new turn starting (or the session ending)
// makes a dealt hand stale — drop it.
match &payload.event {
SessionEvent::Status {
state: construct_protocol::SessionState::Running,
..
}
| SessionEvent::Message {
role: construct_protocol::MessageRole::User,
..
}
| SessionEvent::Done { .. }
| SessionEvent::Error { .. } => {
self.suggestions.remove(&payload.session_id);
}
_ => {}
}
if matches!(payload.event, SessionEvent::Reset) {
self.histories.remove(&payload.session_id);
self.block_hits.remove(&payload.session_id);
self.editor_states.remove(&payload.session_id);
self.agent_statuses.remove(&payload.session_id);
self.pending_tool_approvals.remove(&payload.session_id);
self.suggestions.remove(&payload.session_id);
self.browser_previews.remove(&payload.session_id);
self.ui_panels.remove(&payload.session_id);
self.pty_activity.remove(&payload.session_id);
Expand Down Expand Up @@ -8789,6 +8833,11 @@ impl App {
// already routes in-modal clicks to the modal and out-of-modal
// clicks to dismissal.
&& self.layout.modal_area.is_none()
// The suggestion orb and its open fan/stack overlay (spec 0109)
// paint over the pane and must win over a mouse-grabbing child
// (e.g. Claude Code fullscreen) — otherwise the child swallows
// the very click that opens the deck.
&& !self.mouse_over_suggestion_deck(ev.column, ev.row)
&& self.forward_mouse_to_child(&ev)
{
return;
Expand Down Expand Up @@ -9236,6 +9285,11 @@ impl App {
fn contains(r: ratatui::layout::Rect, c: u16, y: u16) -> bool {
c >= r.x && c < r.x + r.width && y >= r.y && y < r.y + r.height
}
// Suggestion deck (spec 0109): orb toggle, fan chips, stack cards,
// and backdrop-close while the overlay is open.
if self.suggestion_deck_handle_click(col, row).await {
return;
}
if let Some(menu) = self.session_title_menu.clone() {
if let Some(action) = menu.item_at(col, row) {
self.run_session_title_menu_action(menu.session_id, action)
Expand Down Expand Up @@ -9926,6 +9980,12 @@ impl App {
return;
}
}
// Suggestion deck (spec 0109): consumes keys only while its
// fan/stack overlay is open; a printable key closes it and falls
// through so typing always wins.
if self.suggestion_deck_handle_key(&key).await {
return;
}
// A pinned clip's inline terminal owns keyboard focus while pinned
// (same tier as the program popup itself, checked first so the pinned
// terminal — not the Program markdown editor — receives keystrokes).
Expand Down Expand Up @@ -10244,6 +10304,7 @@ impl App {
self.tutorial_observe_action(action);
match action {
Quit => self.should_quit = true,
ToggleSuggestions => self.suggestion_deck_toggle().await,
NextSession => self.step_selection(1).await,
PrevSession => self.step_selection(-1).await,
Refresh => {
Expand Down Expand Up @@ -13663,6 +13724,10 @@ mod tests {
LayoutSnapshot {
list_area: Some(Rect::new(0, 0, 20, 10)),
view_area: Some(Rect::new(20, 0, 80, 20)),
suggestion_orb_hit: None,
suggestion_anchor: None,
suggestion_chip_hits: Vec::new(),
suggestion_card_hits: Vec::new(),
main_window_areas: vec![WindowPaneHit {
id: 1,
area: Rect::new(20, 0, 80, 20),
Expand Down Expand Up @@ -13751,6 +13816,7 @@ mod tests {
client,
last_reported_view: None,
sessions,
suggestions: HashMap::new(),
groups: Vec::new(),
selection: Selection::Session("s1".into()),
focus: PaneFocus::View,
Expand Down
Loading
Loading