Sitelet https://github.com/Ameyanagi/aibo/commit/593b355c220de279e442df1e437cb043a214fa47
Skip to content

Commit 593b355

Browse files
committed
Add folder completion and restore model selection
1 parent 4ba69e3 commit 593b355

7 files changed

Lines changed: 679 additions & 139 deletions

File tree

‎crates/aibo-session/src/config.rs‎

Lines changed: 112 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,12 @@ pub struct AzureSttSettings {
633633
pub struct UiSettings {
634634
/// BCP-47 language tag. Unsupported tags fall back in the UI layer.
635635
pub language: Option<String>,
636+
/// Model chosen in the panel quick-pick, as `provider/model`.
637+
///
638+
/// Kept separate from `[codex].model`: the picker covers every configured
639+
/// provider, including user-named Azure deployments.
640+
#[serde(default)]
641+
pub selected_model: Option<String>,
636642
/// Let aibo switch on an application's accessibility tree so its content
637643
/// can be read (§8, macOS only).
638644
///
@@ -696,6 +702,7 @@ impl Default for UiSettings {
696702
fn default() -> Self {
697703
Self {
698704
language: None,
705+
selected_model: None,
699706
allow_ax_tree_activation: false,
700707
panel_hotkey: None,
701708
screen_capture_hotkey: None,
@@ -1043,7 +1050,7 @@ impl Config {
10431050
registry: &ProviderRegistry,
10441051
codex_authenticated: bool,
10451052
) -> Result<RoleBindings, ConfigError> {
1046-
if self.roles.is_empty() {
1053+
let mut bindings = if self.roles.is_empty() {
10471054
// §4's shipped table, filtered to what is actually usable. A chain
10481055
// whose primary is a provider the user never configured spends its
10491056
// first request discovering that.
@@ -1055,36 +1062,87 @@ impl Config {
10551062
if codex_authenticated {
10561063
self.retarget_codex_model(&mut seeded)?;
10571064
}
1058-
return Ok(seeded);
1059-
}
1060-
1061-
let mut chains = Vec::new();
1062-
for (name, role_config) in &self.roles {
1063-
let role = parse_role(name)?;
1064-
let entries: Vec<ModelBinding> = role_config
1065-
.entries
1066-
.iter()
1067-
.map(|e| ModelBinding {
1068-
provider: ProviderId::new(e.provider.clone()),
1069-
model: e.model.clone(),
1070-
})
1071-
.collect();
1072-
// Same pre-dispatch refusal as the seeded path, applied to a chain
1073-
// the user typed. §4's no-fallback-on-400 rule makes a rejected
1074-
// Codex id a dead end wherever it came from.
1075-
for binding in &entries {
1076-
if binding.provider == ProviderId::CODEX {
1077-
check_codex_model(&binding.model)?;
1065+
seeded
1066+
} else {
1067+
let mut chains = Vec::new();
1068+
for (name, role_config) in &self.roles {
1069+
let role = parse_role(name)?;
1070+
let entries: Vec<ModelBinding> = role_config
1071+
.entries
1072+
.iter()
1073+
.map(|e| ModelBinding {
1074+
provider: ProviderId::new(e.provider.clone()),
1075+
model: e.model.clone(),
1076+
})
1077+
.collect();
1078+
// Same pre-dispatch refusal as the seeded path, applied to a chain
1079+
// the user typed. §4's no-fallback-on-400 rule makes a rejected
1080+
// Codex id a dead end wherever it came from.
1081+
for binding in &entries {
1082+
if binding.provider == ProviderId::CODEX {
1083+
check_codex_model(&binding.model)?;
1084+
}
10781085
}
1086+
chains.push(RoleChain {
1087+
role,
1088+
entries,
1089+
fallback_enabled: role_config.fallback,
1090+
allow_crossing_trust_boundary: role_config.allow_crossing_trust_boundary,
1091+
});
10791092
}
1080-
chains.push(RoleChain {
1093+
RoleBindings::from_chains(chains)?
1094+
};
1095+
self.apply_selected_model(&mut bindings, registry)?;
1096+
Ok(bindings)
1097+
}
1098+
1099+
/// Put the quick-pick's explicit choice at the front of the roles that
1100+
/// represent a user-selected general model. The provider must still exist
1101+
/// in the built registry; a stale preference after provider removal is
1102+
/// ignored rather than breaking startup.
1103+
fn apply_selected_model(
1104+
&self,
1105+
bindings: &mut RoleBindings,
1106+
registry: &ProviderRegistry,
1107+
) -> Result<(), ConfigError> {
1108+
let Some(raw) = self.ui.selected_model.as_deref() else {
1109+
return Ok(());
1110+
};
1111+
let Some((provider, model)) = raw.split_once('/') else {
1112+
tracing::warn!(selected_model = raw, "ignoring malformed selected model");
1113+
return Ok(());
1114+
};
1115+
if provider.is_empty() || model.is_empty() {
1116+
tracing::warn!(selected_model = raw, "ignoring malformed selected model");
1117+
return Ok(());
1118+
}
1119+
let selected = ModelBinding {
1120+
provider: ProviderId::new(provider.to_owned()),
1121+
model: model.to_owned(),
1122+
};
1123+
if registry.get(&selected.provider).is_none() {
1124+
tracing::warn!(
1125+
selected_model = raw,
1126+
"selected model provider is not configured"
1127+
);
1128+
return Ok(());
1129+
}
1130+
if selected.provider == ProviderId::CODEX {
1131+
check_codex_model(&selected.model)?;
1132+
}
1133+
1134+
for role in [Role::Smart, Role::Vision, Role::Agent] {
1135+
let mut chain = bindings.chain(role).cloned().unwrap_or(RoleChain {
10811136
role,
1082-
entries,
1083-
fallback_enabled: role_config.fallback,
1084-
allow_crossing_trust_boundary: role_config.allow_crossing_trust_boundary,
1137+
entries: Vec::new(),
1138+
fallback_enabled: false,
1139+
allow_crossing_trust_boundary: false,
10851140
});
1141+
chain.entries.retain(|binding| binding != &selected);
1142+
chain.entries.insert(0, selected.clone());
1143+
bindings.set_chain(chain)?;
10861144
}
1087-
Ok(RoleBindings::from_chains(chains)?)
1145+
Ok(())
10881146
}
10891147

10901148
/// Point every seeded Codex entry at the model the user actually chose.
@@ -1318,6 +1376,34 @@ mod tests {
13181376
);
13191377
}
13201378

1379+
#[test]
1380+
fn a_selected_azure_deployment_becomes_the_general_model() {
1381+
let config = Config::from_toml_str(
1382+
r#"
1383+
[ui]
1384+
selected_model = "azure-openai/team-gpt"
1385+
1386+
[[providers]]
1387+
backend = "azure"
1388+
base_url = "https://team.services.ai.azure.com"
1389+
models = ["team-gpt"]
1390+
"#,
1391+
)
1392+
.unwrap();
1393+
1394+
let (_, engine) = config.build(&FixedKey, PriceTable::empty()).unwrap();
1395+
for role in [Role::Smart, Role::Vision, Role::Agent] {
1396+
assert_eq!(
1397+
engine.bindings.primary(role),
1398+
Some(&ModelBinding {
1399+
provider: ProviderId::AZURE_OPENAI,
1400+
model: "team-gpt".to_owned(),
1401+
}),
1402+
"{role:?} must use the explicit quick-pick choice"
1403+
);
1404+
}
1405+
}
1406+
13211407
#[test]
13221408
fn the_fast_never_codex_rule_is_enforced_on_a_user_config() {
13231409
let config = Config::from_toml_str(

‎crates/aibo-ui/src/app.rs‎

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,8 @@ pub struct Aibo {
473473
settings: SettingsState,
474474
/// Last issued async file-list generation.
475475
file_list_generation: u64,
476+
/// Last issued Files-settings directory-list generation.
477+
directory_list_generation: u64,
476478
/// Last issued async workdir-list generation.
477479
workdir_list_generation: u64,
478480
/// Current dictation turn id; terminal events from older turns are ignored.
@@ -1133,6 +1135,7 @@ fn boot(config: UiConfig, requests: Sender<UiRequest>) -> (Aibo, Task<Message>)
11331135
settings_window: None,
11341136
settings: SettingsState::default(),
11351137
file_list_generation: 0,
1138+
directory_list_generation: 0,
11361139
workdir_list_generation: 0,
11371140
dictation_turn: 0,
11381141
dictation_stopping: false,
@@ -1766,6 +1769,14 @@ fn request_file_list(state: &mut Aibo) {
17661769
});
17671770
}
17681771

1772+
fn request_directory_list(state: &mut Aibo, parent: String) {
1773+
state.directory_list_generation = state.directory_list_generation.wrapping_add(1);
1774+
state.send(UiRequest::ListDirectories {
1775+
generation: state.directory_list_generation,
1776+
parent,
1777+
});
1778+
}
1779+
17691780
fn request_workdir_list(state: &mut Aibo) {
17701781
state.workdir_list_generation = state.workdir_list_generation.wrapping_add(1);
17711782
state.send(UiRequest::ListWorkdirs {
@@ -2843,6 +2854,18 @@ fn panel_update(state: &mut Aibo, message: panel::Message) -> Task<Message> {
28432854
state.panel.perform_response_action(action);
28442855
Task::none()
28452856
}
2857+
M::TurnUserAction(index, action) => {
2858+
state.panel.perform_turn_user_action(index, action);
2859+
Task::none()
2860+
}
2861+
M::TurnAssistantAction(index, action) => {
2862+
state.panel.perform_turn_assistant_action(index, action);
2863+
Task::none()
2864+
}
2865+
M::ActiveUserAction(action) => {
2866+
state.panel.perform_active_user_action(action);
2867+
Task::none()
2868+
}
28462869

28472870
M::ToggleContext => {
28482871
if state.panel.includes_selection() {
@@ -3363,6 +3386,18 @@ fn settings_update(state: &mut Aibo, message: settings::Message) -> Task<Message
33633386
return Task::none();
33643387
}
33653388
state.settings.root_draft = draft;
3389+
if let Some(parent) = state
3390+
.settings
3391+
.root_completer
3392+
.update_draft(&state.settings.root_draft)
3393+
{
3394+
request_directory_list(state, parent);
3395+
}
3396+
Task::none()
3397+
}
3398+
M::RootComplete(path) => {
3399+
state.settings.root_draft = path;
3400+
state.settings.root_completer.dismiss();
33663401
Task::none()
33673402
}
33683403
M::RootAdd => {
@@ -3385,6 +3420,7 @@ fn settings_update(state: &mut Aibo, message: settings::Message) -> Task<Message
33853420
PersistenceSnapshot::FileRoots(state.settings.file_roots.clone()),
33863421
);
33873422
state.settings.root_draft.clear();
3423+
let _ = state.settings.root_completer.update_draft("");
33883424
state.settings.file_roots = Some(roots.clone());
33893425
state.send(UiRequest::SetFileRoots {
33903426
generation,
@@ -3858,9 +3894,6 @@ fn backend_update(state: &mut Aibo, event: UiEvent) -> Task<Message> {
38583894
}
38593895
StreamEvent::Done(reason) => {
38603896
state.panel.phase = Phase::Finished { reason };
3861-
// Typeset any display equations now that the text is
3862-
// final (`crate::math`); streaming showed the raw TeX.
3863-
state.panel.segment_finished_response();
38643897
aibo_platform::announce_accessibility(i18n::t(crate::i18n::Key::TaskCompleted));
38653898
// Give the caret back, **once, on completion**.
38663899
//
@@ -4245,6 +4278,14 @@ fn backend_update(state: &mut Aibo, event: UiEvent) -> Task<Message> {
42454278
Task::none()
42464279
}
42474280

4281+
UiEvent::DirectoryCandidates { generation, dirs } => {
4282+
if generation != state.directory_list_generation {
4283+
return Task::none();
4284+
}
4285+
state.settings.root_completer.set_candidates(dirs);
4286+
Task::none()
4287+
}
4288+
42484289
UiEvent::WorkdirCandidates {
42494290
generation,
42504291
recents,
@@ -5327,6 +5368,10 @@ mod tests {
53275368
));
53285369

53295370
let _ = settings_update(&mut state, settings::Message::RootDraft("~/dev".to_owned()));
5371+
assert!(matches!(
5372+
received.try_recv(),
5373+
Ok(UiRequest::ListDirectories { parent, .. }) if parent == "~/"
5374+
));
53305375
let _ = settings_update(&mut state, settings::Message::RootAdd);
53315376
assert_eq!(
53325377
state.settings.file_roots,
@@ -5336,7 +5381,11 @@ mod tests {
53365381
state.settings.root_draft.is_empty(),
53375382
"the draft is consumed"
53385383
);
5339-
let _ = received.try_recv();
5384+
assert!(matches!(
5385+
received.try_recv(),
5386+
Ok(UiRequest::SetFileRoots { roots: Some(roots), .. })
5387+
if roots == ["/d/Desktop", "~/dev"]
5388+
));
53405389

53415390
let _ = settings_update(&mut state, settings::Message::RootsReset);
53425391
assert_eq!(state.settings.file_roots, None);

‎crates/aibo-ui/src/bridge.rs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,15 @@ pub enum UiRequest {
497497
generation: u64,
498498
},
499499

500+
/// List the immediate child directories of one path for the Files settings
501+
/// autocomplete. Answered by [`UiEvent::DirectoryCandidates`].
502+
ListDirectories {
503+
/// Monotonic UI generation; stale listings must not replace newer results.
504+
generation: u64,
505+
/// Parent path in the spelling used by the field (including `~/`).
506+
parent: String,
507+
},
508+
500509
/// Read one picked file so it can ride the fenced selection pipeline.
501510
/// Answered by [`UiEvent::FileAttached`] or [`UiEvent::FileAttachFailed`].
502511
AttachFile {
@@ -873,6 +882,14 @@ pub enum UiEvent {
873882
files: Vec<FileCandidate>,
874883
},
875884

885+
/// Folder-only candidates for the Files settings path autocomplete.
886+
DirectoryCandidates {
887+
/// Request generation, for last-request-wins delivery.
888+
generation: u64,
889+
/// Home-relative or absolute paths suitable for inserting into the field.
890+
dirs: Vec<String>,
891+
},
892+
876893
/// Candidate agent working directories, answering
877894
/// [`UiRequest::ListWorkdirs`].
878895
WorkdirCandidates {

0 commit comments

Comments
 (0)