Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
e996ec2
feat(tui): add named workspace favorites
senamakel Aug 8, 2026
5e2ba56
test(tui): cover named workspace favorites
senamakel Aug 8, 2026
3e71ef0
chore: files changed src/tui/src/ui/app/harness_workspace.rs
senamakel Aug 8, 2026
e6a3b61
chore: files changed src/tui/src/ui/app/harness_workspace.rs
senamakel Aug 8, 2026
c01ff6a
chore: files changed src/tui/src/ui/app/render/session_modals.rs
senamakel Aug 8, 2026
e1b29c3
chore: files changed src/tui/src/ui/app/render/session_modals.rs
senamakel Aug 8, 2026
fe9ac47
chore: files changed src/tui/src/ui/app/input/mouse.rs
senamakel Aug 8, 2026
5803583
chore: files changed src/tui/src/ui/app/overlays_tests.rs
senamakel Aug 8, 2026
96824c3
chore: files changed src/tui/src/ui/app/overlays_tests.rs
senamakel Aug 8, 2026
1bc944d
chore: files changed src/tui/src/ui/app/harness_workspace_tests.rs
senamakel Aug 8, 2026
8fa9e8e
chore: files changed src/tui/src/ui/app/harness_workspace_tests.rs
senamakel Aug 8, 2026
d04a9ce
chore: files changed src/tui/src/ui/app/harness_workspace_tests.rs
senamakel Aug 8, 2026
9acc804
chore: files changed src/tui/src/ui/app/render/session_modals.rs
senamakel Aug 8, 2026
978c748
chore: files changed src/tui/src/ui/app/render/session_modals.rs
senamakel Aug 8, 2026
988de49
chore: files changed src/tui/src/ui/app/render/session_modals.rs
senamakel Aug 8, 2026
8e51441
chore: files changed src/tui/src/ui/app/render/tests.rs
senamakel Aug 8, 2026
8948fa3
chore: files changed src/tui/src/ui/app/harness_workspace.rs
senamakel Aug 8, 2026
cb718ba
chore: files changed src/tui/src/ui/app/harness_workspace_tests.rs
senamakel Aug 8, 2026
249eb12
chore: files changed src/tui/src/ui/app/types/model.rs,src/tui/src/ui…
senamakel Aug 8, 2026
9b26214
chore: files changed src/tui/src/ui/app/types/mod.rs
senamakel Aug 8, 2026
5b731de
chore: files changed src/tui/src/ui/app/types/model.rs
senamakel Aug 8, 2026
392fc93
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/243
senamakel Aug 9, 2026
be4f3a7
chore(harness_workspace): remove unused import
senamakel Aug 9, 2026
faef0cb
fix(harness_workspace): handle missing workspace directory on startup
senamakel Aug 9, 2026
d385a0a
fix(harness_workspace): correct workspace path resolution for nested …
senamakel Aug 9, 2026
1d1e084
fix(harness_workspace): handle missing workspace directory on startup
senamakel Aug 9, 2026
7cffffc
fix(harness_workspace_tests): correct test assertion for workspace state
senamakel Aug 9, 2026
ad86883
chore(tui): apply rustfmt formatting
senamakel Aug 9, 2026
590df70
fix(harness_workspace): handle missing workspace directory on startup
senamakel Aug 9, 2026
48ac983
fix(harness): restore workspace test assertions
senamakel Aug 9, 2026
ac65510
fix(harness_workspace): handle missing workspace directory gracefully
senamakel Aug 9, 2026
39fa57e
fix(config): correct test for default config values
senamakel Aug 9, 2026
eb6096e
chore(config): add missing type exports
senamakel Aug 9, 2026
2b61b63
fix(config): correct test assertion for default config value
senamakel Aug 9, 2026
541320d
chore(tui): remove unused picker type
senamakel Aug 9, 2026
ab184af
fix(ui): handle empty picker list gracefully
senamakel Aug 9, 2026
a1ea318
fix(ui): handle empty picker list gracefully
senamakel Aug 9, 2026
dcb08c8
fix(ui): restore session modal rendering after refactor
senamakel Aug 9, 2026
3e19c1d
fix(harness_workspace): handle missing workspace directory on startup
senamakel Aug 9, 2026
6143cb0
chore(tui): remove unused harness workspace UI
senamakel Aug 9, 2026
fbbcc74
test(harness-workspace): add tests for saving favorites without confi…
senamakel Aug 9, 2026
0e9a1a7
chore: reformat function calls and fix missing newline
senamakel Aug 9, 2026
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
11 changes: 6 additions & 5 deletions src/sdk/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,12 @@ pub use persist::{
};
pub use types::{
wire_value, AttributionConfig, BackendConfig, BudgetConfig, ControlStyle, CoreConfig,
EvolveSettings, FieldPlacement, FieldVisibility, FleetConfig, HarnessNameStyle, HarnessSection,
HookDefaultsConfig, HostSection, HubSection, HubWorkerConfig, LinkConfig, LoadedConfig,
McpSection, MedullaConfig, OnboardingConfig, OpencodeConfig, PathStyle, Peer,
ProviderBudgetConfig, RouterConfig, RouterProviderConfig, StatusLineConfig, ThemeConfig,
TuiConfig, UpdateConfig, WorkflowConfig, WorkflowsConfig, DEFAULT_CONTEXT_WINDOW_TOKENS,
EvolveSettings, FavoriteWorkspace, FieldPlacement, FieldVisibility, FleetConfig,
HarnessNameStyle, HarnessSection, HookDefaultsConfig, HostSection, HubSection, HubWorkerConfig,
LinkConfig, LoadedConfig, McpSection, MedullaConfig, OnboardingConfig, OpencodeConfig,
PathStyle, Peer, ProviderBudgetConfig, RouterConfig, RouterProviderConfig, StatusLineConfig,
ThemeConfig, TuiConfig, UpdateConfig, WorkflowConfig, WorkflowsConfig,
DEFAULT_CONTEXT_WINDOW_TOKENS,
};
pub use urls::{
default_backend_base_url, display_host, is_staging, resolve_backend_base_url,
Expand Down
19 changes: 19 additions & 0 deletions src/sdk/src/config/types/orchestration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,24 @@ pub struct HarnessSection {
/// orchestrated tasks may run.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub recent_workspaces: Vec<String>,
/// Saved directory shortcuts for the manual harness launcher.
///
/// Favorites are deliberately separate from recent history: a favorite is
/// an operator's named destination and remains useful even after it has not
/// been used for a while. Like history, it does not grant the orchestrator
/// access to the directory.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub favorite_workspaces: Vec<FavoriteWorkspace>,
}

/// A durable, operator-chosen name for a directory used by the manual launcher.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct FavoriteWorkspace {
/// The short name shown in the directory picker and matched by search.
pub name: String,
/// The directory the favorite opens.
pub path: String,
}

impl Default for HarnessSection {
Expand All @@ -259,6 +277,7 @@ impl Default for HarnessSection {
handback: "ask".to_string(),
skip_permissions: false,
recent_workspaces: Vec::new(),
favorite_workspaces: Vec::new(),
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions src/sdk/src/config/types_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,20 @@ fn harness_recent_workspaces_round_trip_as_picker_history() {
assert!(encoded.contains("\"recentWorkspaces\""));
}

#[test]
fn harness_favorite_workspaces_round_trip_with_their_names() {
let cfg: TuiConfig = serde_json::from_str(
r#"{"harness":{"favoriteWorkspaces":[{"name":"Medulla","path":"/work/medulla"}]}}"#,
)
.unwrap();

assert_eq!(cfg.harness.favorite_workspaces[0].name, "Medulla");
assert_eq!(cfg.harness.favorite_workspaces[0].path, "/work/medulla");
assert!(serde_json::to_string(&cfg)
.unwrap()
.contains("\"favoriteWorkspaces\""));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

#[test]
fn backend_and_link_parse() {
let cfg: TuiConfig = serde_json::from_str(
Expand Down
4 changes: 4 additions & 0 deletions src/tui/src/ui/app/commands/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ impl App {
self.add_workspace(&text);
None
}
PromptKind::FavoriteWorkspaceAdd(workspace) => {
self.save_favorite_workspace(&text, &workspace);
None
}
PromptKind::CustomHarnessAdd => {
self.save_custom_harness(None, &text);
None
Expand Down
119 changes: 108 additions & 11 deletions src/tui/src/ui/app/harness_workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,64 @@ impl App {
.map_err(|error| format!("workspace history was not saved ({error})"))
}

/// Persist `workspace` as a named shortcut for the manual launcher.
///
/// A name replaces any older favorite with the same spelling, while the
/// path is de-duplicated so one directory cannot occupy several top-ranked
/// rows under different aliases.
pub(in crate::ui::app) fn save_favorite_workspace(&mut self, name: &str, workspace: &str) {
let name = name.trim();
if name.is_empty() {
self.set_status("Favorite name cannot be empty");
return;
}
let Some(harnesses) = &self.local_sessions else {
self.set_status("This device is not hosting, so it has no workspace favorites");
return;
};
let path = harnesses.resolve_workspace(workspace);
if !Path::new(&path).is_dir() {
self.set_status("Favorites must point to an existing directory");
return;
}
// Build the candidate list without touching the live one: persistence
// is the commit point, so a write failure must not leave a favorite in
// memory that the config file never recorded — the picker would then
// offer a save that is not there, and a later successful save could
// silently persist it.
let mut favorites = self.loaded.config.harness.favorite_workspaces.clone();
favorites
.retain(|favorite| !favorite.name.eq_ignore_ascii_case(name) && favorite.path != path);
Comment thread
senamakel marked this conversation as resolved.
Outdated
favorites.insert(
0,
medulla::config::FavoriteWorkspace {
name: name.to_string(),
path: path.clone(),
},
);
let Some(config_path) = &self.config_path else {
self.loaded.config.harness.favorite_workspaces = favorites;
self.set_status(format!(
"Saved favorite {name} · this run only — no config file"
));
self.refresh_harness_workspace_choices();
return;
};
match medulla::config::persist_setting(
config_path,
"harness",
"favoriteWorkspaces",
toml::Value::try_from(favorites.clone()).expect("favorite workspaces serialize"),
Comment thread
senamakel marked this conversation as resolved.
Outdated
) {
Ok(()) => {
self.loaded.config.harness.favorite_workspaces = favorites;
self.set_status(format!("Saved favorite {name} · {path}"));
self.refresh_harness_workspace_choices();
Comment thread
senamakel marked this conversation as resolved.
Outdated
}
Err(error) => self.set_status(format!("Could not save favorite ({error})")),
}
}

/// Rank recent, configured, and filesystem-derived workspace suggestions.
fn workspace_choices(&self, query: &str) -> Vec<WorkspaceChoice> {
let Some(harnesses) = &self.local_sessions else {
Expand All @@ -154,36 +212,57 @@ impl App {
let process_dir = std::env::current_dir().unwrap_or_else(|_| base.to_path_buf());
let resolved_query = harnesses.resolve_workspace(query);
let mut known = Vec::new();
for favorite in &self.loaded.config.harness.favorite_workspaces {
known.push((
absolute(&favorite.path, base),
"favorite".to_string(),
Some(favorite.name.clone()),
));
}
for path in &self.loaded.config.harness.recent_workspaces {
known.push((absolute(path, base), "recent"));
known.push((absolute(path, base), "recent".to_string(), None));
}
known.push((harnesses.workspace.clone(), "default"));
known.push((harnesses.workspace.clone(), "default".to_string(), None));
if !self.loaded.config.host.workspace.trim().is_empty() {
known.push((
absolute(&self.loaded.config.host.workspace, &process_dir),
"registered",
"registered".to_string(),
None,
));
}
for path in &self.loaded.config.host.workspaces {
known.push((absolute(path, &process_dir), "registered"));
known.push((absolute(path, &process_dir), "registered".to_string(), None));
}
for host in &self.loaded.config.hosts {
if !host.workspace.trim().is_empty() {
known.push((absolute(&host.workspace, &process_dir), "registered"));
known.push((
absolute(&host.workspace, &process_dir),
"registered".to_string(),
None,
));
}
for path in &host.workspaces {
known.push((absolute(path, &process_dir), "registered"));
known.push((absolute(path, &process_dir), "registered".to_string(), None));
}
}

let folder_order = known.len();
let mut ranked = known
.into_iter()
.enumerate()
.filter(|(_, (path, _))| Path::new(path).is_dir())
.filter_map(|(order, (path, source))| {
match_score(&path, query)
.map(|score| (score, order, WorkspaceChoice { path, source }))
.filter(|(_, (path, _, _))| Path::new(path).is_dir())
.filter_map(|(order, (path, source, label))| {
workspace_match_score(&path, label.as_deref(), query).map(|score| {
(
score,
order,
WorkspaceChoice {
path,
source,
label,
},
)
})
})
.collect::<Vec<_>>();

Expand All @@ -198,7 +277,8 @@ impl App {
folder_order + index,
WorkspaceChoice {
path,
source: "folder",
source: "folder".to_string(),
label: None,
},
)
}),
Expand All @@ -222,6 +302,23 @@ impl App {
}
}

/// Match a saved name and its path, keeping whichever scores better.
///
/// A favorite is searchable by both spellings an operator can use, and the two
/// can disagree: a query that names the directory exactly is a strictly better
/// match than one that only loosely resembles the label. `or_else` would skip
/// the path score whenever the label scored at all, so a favorite could rank
/// behind a plain filesystem completion for the same directory and lose the
/// row to path de-duplication — the exact case the name was added to fix.
pub(super) fn workspace_match_score(path: &str, label: Option<&str>, query: &str) -> Option<usize> {
let label_score = label.and_then(|label| match_score(label, query));
let path_score = match_score(path, query);
match (label_score, path_score) {
(Some(label_score), Some(path_score)) => Some(label_score.min(path_score)),
(label_score, path_score) => label_score.or(path_score),
}
}

/// Make a configured path absolute against its owning resolution directory.
pub(super) fn absolute(path: &str, base: &Path) -> String {
let path = Path::new(path);
Expand Down
93 changes: 92 additions & 1 deletion src/tui/src/ui/app/harness_workspace_tests.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Focused tests for bounded folder completion and fuzzy ranking.

use super::harness_workspace::{
absolute, folder_completions, fuzzy_subsequence_score, match_score,
absolute, folder_completions, fuzzy_subsequence_score, match_score, workspace_match_score,
};

#[test]
Expand Down Expand Up @@ -35,6 +35,35 @@ fn known_workspace_basename_prefixes_beat_filesystem_duplicates() {
assert_eq!(match_score("/work/project-beta", "project-b"), Some(1));
}

#[test]
fn favorite_names_are_searchable_as_well_as_their_paths() {
assert_eq!(
workspace_match_score("/work/medulla-public", Some("Primary Medulla"), "primary"),
Some(1)
);
assert!(
workspace_match_score("/work/medulla-public", Some("Primary Medulla"), "medulla").is_some()
);
}

#[test]
fn an_exact_path_match_outranks_a_loose_label_match_for_a_favorite() {
// A query that names the directory exactly must rank the favorite at the
// path score rather than the loose label score, or a plain filesystem
// completion for the same directory would outrank it and win the row after
// path de-duplication.
assert_eq!(
workspace_match_score("/work/medulla", Some("primary medulla"), "medulla"),
Some(0)
);
// The label's own exact match is still honoured when the path does not
// match at all.
assert_eq!(
workspace_match_score("/work/medulla-public", Some("Primary Medulla"), "primary"),
Some(1)
);
}

#[test]
fn loose_known_matches_do_not_beat_concrete_folder_matches() {
let random_parent = match_score("/tmp/.tmpbQM6Hg", "pb").unwrap();
Expand Down Expand Up @@ -164,3 +193,65 @@ fn arrowing_onto_a_completion_still_wins_over_the_typed_query() {
"a deliberately chosen completion is still what Enter uses"
);
}

#[test]
fn saving_a_named_favorite_persists_it_and_makes_its_name_searchable() {
let root = tempfile::tempdir().unwrap();
let workspace = root.path().join("medulla");
std::fs::create_dir(&workspace).unwrap();
let config = root.path().join("config.toml");
std::fs::write(&config, "[harness]\n").unwrap();
let mut app = picker_on_workspace_step(&workspace);
app.set_config_path(config.clone());

app.save_favorite_workspace("Daily Medulla", workspace.to_str().unwrap());

assert_eq!(app.loaded.config.harness.favorite_workspaces.len(), 1);
assert_eq!(
app.loaded.config.harness.favorite_workspaces[0].name,
"Daily Medulla"
);
assert_eq!(
app.loaded.config.harness.favorite_workspaces[0].path,
workspace.to_string_lossy()
);
assert!(std::fs::read_to_string(config)
.unwrap()
.contains("favoriteWorkspaces"));

let picker = app.session_picker.as_mut().unwrap();
picker.workspace_query = "daily".into();
picker.workspace_picked = false;
app.refresh_harness_workspace_choices();
let choice = app
.session_picker
.as_ref()
.unwrap()
.workspace_choices
.first()
.unwrap();
assert_eq!(choice.label.as_deref(), Some("Daily Medulla"));
assert_eq!(choice.path, workspace.to_string_lossy());
}

#[test]
fn a_failed_favorite_save_does_not_replace_the_in_memory_favorites() {
let root = tempfile::tempdir().unwrap();
let workspace = root.path().join("medulla");
std::fs::create_dir(&workspace).unwrap();
let mut app = picker_on_workspace_step(&workspace);
// An unparsable config file makes persistence fail before any write — a
// favorite the disk never recorded must not be visible in memory either,
// or a later successful save could silently persist it.
let config = root.path().join("config.toml");
std::fs::write(&config, "not [valid toml {{{").unwrap();
app.set_config_path(config);

app.save_favorite_workspace("Daily Medulla", workspace.to_str().unwrap());

assert!(
app.loaded.config.harness.favorite_workspaces.is_empty(),
"a favorite that could not be persisted must not appear to be saved"
);
assert!(app.status().contains("Could not save favorite"));
}
9 changes: 9 additions & 0 deletions src/tui/src/ui/app/input/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ impl App {
}
// Everything else the question is over is still swallowed, below.
}
// The inline prompt owns the pointer over the picker exactly as it owns
// the keyboard (see `on_key`, which routes the prompt before the picker):
// it is an edit on top of a modal, and a click that replayed Enter on a
// picker row behind it would start a harness while the favorite-name
// prompt was still on screen. The prompt itself is a text entry with no
// click targets, so every pointer event is swallowed while it is open.
if self.prompt.is_some() {
return None;
}
// The picker's rows are click targets too, and its list takes the wheel.
// Same reasoning as the question above: it is opened from a rail row the
// operator clicked (`+ New session`) or from `Ctrl-T`, so they arrive
Expand Down
Loading
Loading