Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

## [Unreleased]

### Fixed

* A conversation in Leon's own interface names what its agent runs with. The
chips under the field said "Default" for the model and the effort until
you chose one; they now say the model and the effort the agent is set up
with (Claude Code is asked for its settings as it weighs them, Codex for
its configuration, Grok for the model it marks), and the lists say what
"Default" is. What a turn says it answers with (`claude-opus-5-5`) is
shown by the name you know it by (Opus 5.5).
* Coming back to a conversation by its row no longer asks to resume it. Once
the agent had written its session, the row was the history's, and a click
on it only looked for a terminal: it offered to start the agent again in
one. The row is the conversation's, as it is a terminal's, also for the
context menu, the keys that act on the session under the cursor and the
Den.

## [0.10.0] - 2026-10-13

### Added
Expand Down
187 changes: 170 additions & 17 deletions crates/app/src/chat/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,14 @@ pub fn mode_rows(query: &str, options: &AgentOptions, current: Option<&str>) ->
/// The agent's models that answer `query`, after the agent's own choice;
/// `current` is the one the conversation asked for, if it asked.
pub fn model_rows(query: &str, options: &AgentOptions, current: Option<&str>) -> Vec<Row> {
// What "default" is, where the agent said.
let own = model_shown(Some(options), None, None);
let mut rows = vec![Row {
label: "Default".to_owned(),
detail: Some("The one the agent is set up with on this computer.".to_owned()),
detail: Some(match own {
Some(own) => format!("{own}: the one the agent is set up with."),
None => "The one the agent is set up with.".to_owned(),
}),
note: None,
current: current.is_none(),
warn: false,
Expand All @@ -269,27 +274,83 @@ pub fn model_rows(query: &str, options: &AgentOptions, current: Option<&str>) ->
rows
}

/// The model of the agent's list that `name` is: the one with that id, or
/// one that stands for it (`opus` for `claude-opus-5-5`, which is what a
/// turn says it answers with). Among several that stand for it, the agent's
/// own choice, then one that is not "whatever is default".
pub fn model_named<'a>(options: &'a AgentOptions, name: &str) -> Option<&'a AgentModel> {
let by_id = options.models.iter().find(|model| model.id == name);
let stands_for = |model: &&AgentModel| model.resolves_to.as_deref() == Some(name);
by_id
.or_else(|| {
options
.models
.iter()
.filter(stands_for)
.find(|model| Some(&model.id) == options.default_model.as_ref())
})
.or_else(|| {
options
.models
.iter()
.filter(stands_for)
.find(|model| model.id != "default")
})
.or_else(|| options.models.iter().find(stands_for))
}

/// The model a turn would answer with: the one `asked` for, else the one the
/// agent says is `answering`, else the first it lists, which is its own
/// choice wherever it marks one.
/// agent says is `answering`, else the one it is set up with, else the first
/// it lists, which is its own choice wherever it marks one.
pub fn model_in_use<'a>(
options: &'a AgentOptions,
asked: Option<&str>,
answering: Option<&str>,
) -> Option<&'a AgentModel> {
let named = |id: Option<&str>| {
options
.models
.iter()
.find(|model| Some(model.id.as_str()) == id)
};
match asked {
// One asked for that is not listed has no efforts anybody knows of.
Some(_) => named(asked),
None => named(answering).or(options.models.first()),
Some(asked) => options.models.iter().find(|model| model.id == asked),
None => answering
.and_then(|name| model_named(options, name))
.or_else(|| {
options
.default_model
.as_deref()
.and_then(|name| model_named(options, name))
})
.or(options.models.first()),
}
}

/// The name of the model the next turn answers with, for whoever looks at
/// the conversation: the one asked for, else the one the last turn said it
/// answered with, else the one the agent is set up with. A model nobody
/// lists is named as it is written. `None` until anything is known.
pub fn model_shown(
options: Option<&AgentOptions>,
asked: Option<&str>,
answering: Option<&str>,
) -> Option<String> {
let label = |name: &str| {
options
.and_then(|options| model_named(options, name))
.map_or_else(|| name.to_owned(), |model| model.label.clone())
};
asked
.or(answering)
.or(options.and_then(|options| options.default_model.as_deref()))
.map(label)
}

/// The effort the next turn thinks with: the one asked for, else the one the
/// agent is set up with. `None` when neither is known.
pub fn effort_shown<'a>(
options: Option<&'a AgentOptions>,
asked: Option<&'a str>,
) -> Option<&'a str> {
asked.or(options.and_then(|options| options.default_effort.as_deref()))
}

/// The efforts a turn can be asked for, the lightest first: those of
/// [`model_in_use`], none when it has no such setting.
pub fn efforts<'a>(
Expand Down Expand Up @@ -331,10 +392,18 @@ fn effort_detail(effort: &str) -> Option<&'static str> {

/// The efforts that answer `query`, the lightest first, after the agent's
/// own choice; `current` is the one the conversation asked for, if it asked.
pub fn effort_rows(query: &str, efforts: &[String], current: Option<&str>) -> Vec<Row> {
pub fn effort_rows(
query: &str,
efforts: &[String],
current: Option<&str>,
own: Option<&str>,
) -> Vec<Row> {
let mut rows = vec![Row {
label: "Default".to_owned(),
detail: Some("As the agent is set up on this computer.".to_owned()),
detail: Some(match own {
Some(own) => format!("{}: as the agent is set up.", effort_label(own)),
None => "As the agent is set up.".to_owned(),
}),
note: None,
current: current.is_none(),
warn: false,
Expand Down Expand Up @@ -522,18 +591,102 @@ mod tests {
label: "Opus".into(),
description: None,
efforts: vec!["low".into(), "high".into(), "xhigh".into()],
resolves_to: Some("claude-opus-5-5".into()),
},
AgentModel {
id: "anthropic/claude-haiku".into(),
label: "claude-haiku · anthropic".into(),
description: None,
efforts: Vec::new(),
resolves_to: None,
},
],
..AgentOptions::default()
}
}

/// The same agent, set up to answer with its first model at high effort.
fn set_up() -> AgentOptions {
AgentOptions {
default_model: Some("opus".into()),
default_effort: Some("high".into()),
..options()
}
}

#[test]
fn what_is_shown_is_what_the_agent_runs_with_until_something_else_is_asked_for() {
let set_up = set_up();
// Nothing asked for, no turn yet: the agent's own.
assert_eq!(
model_shown(Some(&set_up), None, None).as_deref(),
Some("Opus")
);
assert_eq!(effort_shown(Some(&set_up), None), Some("high"));
// What a turn says it answers with is the model's own name: it is
// shown as the entry a person knows it by.
assert_eq!(
model_shown(Some(&set_up), None, Some("claude-opus-5-5")).as_deref(),
Some("Opus")
);
// What is asked for here stands before both.
assert_eq!(
model_shown(
Some(&set_up),
Some("anthropic/claude-haiku"),
Some("claude-opus-5-5")
)
.as_deref(),
Some("claude-haiku \u{b7} anthropic")
);
assert_eq!(effort_shown(Some(&set_up), Some("low")), Some("low"));
// A model nobody lists is named as it is written.
assert_eq!(
model_shown(Some(&set_up), None, Some("my-model")).as_deref(),
Some("my-model")
);
// An agent that said nothing of how it is set up: nothing to show.
assert_eq!(model_shown(Some(&options()), None, None), None);
assert_eq!(effort_shown(Some(&options()), None), None);
assert_eq!(model_shown(None, None, None), None);
}

#[test]
fn the_efforts_offered_are_those_of_the_model_the_agent_runs_with() {
// Set up with a model that has none, nothing asked for.
let plain = AgentOptions {
default_model: Some("anthropic/claude-haiku".into()),
..options()
};
assert!(efforts(Some(&plain), None, None).is_empty());
assert_eq!(efforts(Some(&set_up()), None, None).len(), 3);
// The model a turn answered with, by its own name.
assert_eq!(
model_in_use(&plain, None, Some("claude-opus-5-5")).map(|model| model.id.as_str()),
Some("opus")
);
}

#[test]
fn the_lists_say_what_default_is() {
let set_up = set_up();
assert_eq!(
model_rows("", &set_up, None)[0].detail.as_deref(),
Some("Opus: the one the agent is set up with.")
);
assert_eq!(
model_rows("", &options(), None)[0].detail.as_deref(),
Some("The one the agent is set up with.")
);
let listed = efforts(Some(&set_up), None, None);
assert_eq!(
effort_rows("", listed, None, Some("high"))[0]
.detail
.as_deref(),
Some("High: as the agent is set up.")
);
}

fn labels(rows: &[Row]) -> Vec<&str> {
rows.iter().map(|row| row.label.as_str()).collect()
}
Expand Down Expand Up @@ -663,19 +816,19 @@ mod tests {
assert!(of(Some("gone"), None).is_empty());
assert!(efforts(None, None, None).is_empty());

let rows = effort_rows("", &of(None, None), Some("high"));
let rows = effort_rows("", &of(None, None), Some("high"), None);
assert_eq!(labels(&rows), ["Default", "Low", "High", "Extra high"]);
assert!(rows[2].current && !rows[0].current);
assert_eq!(rows[0].pick, Pick::Effort(None));
assert_eq!(rows[3].pick, Pick::Effort(Some("xhigh".into())));
assert!(effort_rows("", &of(None, None), None)[0].current);
assert!(effort_rows("", &of(None, None), None, None)[0].current);
// By its words, or by the word the agent has for it.
assert_eq!(
labels(&effort_rows("extra", &of(None, None), None)),
labels(&effort_rows("extra", &of(None, None), None, None)),
["Extra high"]
);
assert_eq!(
labels(&effort_rows("xh", &of(None, None), None)),
labels(&effort_rows("xh", &of(None, None), None, None)),
["Extra high"]
);
assert_eq!(effort_label("minimal"), "Minimal");
Expand Down
10 changes: 10 additions & 0 deletions crates/app/src/ui/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,16 @@ impl Shell {
}
}

/// What runs here for this history session: the terminal that has it, or
/// the conversation. A history row with either is that session's row, and
/// is brought forward, never started a second time.
pub(super) fn running_of_history(&self, history: &SessionId) -> Option<LiveId> {
self.live
.of_history(history)
.map(|live| live.id)
.or_else(|| self.chat_of_history(history))
}

/// The conversation started from this history session, if one is open.
pub(super) fn chat_of_history(&self, history: &SessionId) -> Option<LiveId> {
self.chats
Expand Down
11 changes: 8 additions & 3 deletions crates/app/src/ui/chat_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,14 @@ impl Chat {
MenuKind::Models => self.options.as_ref().map_or_else(Vec::new, |options| {
complete::model_rows(&filter(), options, self.model.as_deref())
}),
MenuKind::Efforts => {
complete::effort_rows(&filter(), self.efforts(), self.effort.as_deref())
}
MenuKind::Efforts => complete::effort_rows(
&filter(),
self.efforts(),
self.effort.as_deref(),
self.options
.as_ref()
.and_then(|options| options.default_effort.as_deref()),
),
MenuKind::Voices => complete::voice_rows(&filter(), voices),
}
}
Expand Down
22 changes: 11 additions & 11 deletions crates/app/src/ui/chat_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -766,16 +766,14 @@ impl Shell {
let current = chat.mode.as_ref().or(options.default_mode.as_ref())?;
options.modes.iter().find(|mode| &mode.id == current)
});
let model = chat
.model
.as_ref()
.or(chat.conversation.model.as_ref())
.map(|model| {
chat.options
.as_ref()
.and_then(|options| options.models.iter().find(|have| &have.id == model))
.map_or_else(|| model.clone(), |found| found.label.clone())
});
// What the next turn runs with: what was asked for here, else what
// the agent itself is set up with. "Default" is only said while
// nothing is known of the agent yet.
let model = crate::chat::complete::model_shown(
chat.options.as_ref(),
chat.model.as_deref(),
chat.conversation.model.as_deref(),
);
let can_pick_mode = chat
.options
.as_ref()
Expand Down Expand Up @@ -809,7 +807,9 @@ impl Shell {
);
}
if !chat.efforts().is_empty() {
let label = chat.effort.as_deref().map_or_else(
let effort =
crate::chat::complete::effort_shown(chat.options.as_ref(), chat.effort.as_deref());
let label = effort.map_or_else(
|| "DEFAULT EFFORT".to_owned(),
|effort| {
format!(
Expand Down
2 changes: 1 addition & 1 deletion crates/app/src/ui/den_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -921,7 +921,7 @@ impl Shell {
.iter()
.filter(|stored| self.slept.contains(&stored.id))
// One that runs again is in the room, not at home.
.filter(|stored| self.live.of_history(&stored.id).is_none())
.filter(|stored| self.running_of_history(&stored.id).is_none())
.map(|stored| {
(
Home::Slept(stored.id.clone()),
Expand Down
2 changes: 1 addition & 1 deletion crates/app/src/ui/history_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ impl Shell {
// A history row is one terminal's: never two.
let history = item
.history
.filter(|row| self.live.of_history(row).is_none());
.filter(|row| self.running_of_history(row).is_none());
if let Some(session) = self.live.get_mut(LiveId(item.id)) {
// An id already learned keeps how it was learned.
if item.how != "id" {
Expand Down
6 changes: 3 additions & 3 deletions crates/app/src/ui/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,15 +456,15 @@ impl Shell {
// by default: the transcript comes first, and resuming is "Resume
// here anyway".
if let (Some(_), Kind::Session(session)) = (held, &row.kind) {
if self.live.of_history(&session.id).is_none() {
if self.running_of_history(&session.id).is_none() {
items.retain(|item| item.command != Command::ResumeSession);
}
}
// A history session is in one of three states, and its menu starts
// with what that state does: running, asleep, or history only (which
// `items_for_held` already is).
if let Kind::Session(session) = &row.kind {
if self.live.of_history(&session.id).is_some() {
if self.running_of_history(&session.id).is_some() {
// The terminal's row: it is closed from here as a live row
// is, and removing it from the history would only bring the
// terminal back as a live row, so that is not offered.
Expand Down Expand Up @@ -503,7 +503,7 @@ impl Shell {
item.command = Command::BringBackSession;
}
}
} else if self.live.of_history(&session.id).is_some() {
} else if self.running_of_history(&session.id).is_some() {
items.retain(|item| item.command != Command::SettleSession);
}
}
Expand Down
Loading
Loading