Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 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
164 changes: 153 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,109 @@ 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();
// Compare effective resolved paths rather than the stored spellings: a
// favorite saved from relative input (e.g. `repo` against the host
// workspace) must not survive beside the same directory re-saved under
// its resolved absolute path — a re-alias, not a second directory.
favorites.retain(|favorite| {
!favorite.name.eq_ignore_ascii_case(name)
&& harnesses.resolve_workspace(&favorite.path) != path
});
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.after_saving_favorite(&path);
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.after_saving_favorite(&path);
}
Err(error) => self.set_status(format!("Could not save favorite ({error})")),
}
}

/// Re-anchor the launcher on the workspace that was just favorited.
///
/// Saving the favorite is the operator's way of saying Enter should start
/// the harness there, so the arrowed cursor — which pointed at the saved
/// row beforehand — must follow the saved workspace to the row it now
/// occupies rather than keep its old index and silently select a
/// *different* directory parked in that row. The ranking orders by match
/// score first and insertion order second, so the promoted favorite only
/// leads the list when it also scores best against the active query;
/// otherwise it lands at a non-zero row. Re-run the completions and place
/// the cursor on the actual row of the saved workspace, so the highlight
/// and Enter both follow the favorite.
///
/// When the rename happens under a filter whose query only matched the old
/// label, the saved favorite is absent from the refreshed list: the refresh
/// dropped the old match while the new name does not match the unchanged
/// query, leaving the list empty or leading with an unrelated row. Forcing
/// the cursor onto row 0 there would make Enter reject the workspace or
/// start the harness in that row, so the query is re-pointed at the saved
/// workspace instead — the one row Enter must still land on.
fn after_saving_favorite(&mut self, saved: &str) {
self.refresh_harness_workspace_choices();
if let Some(picker) = &mut self.session_picker {
if let Some(index) = picker
.workspace_choices
.iter()
.position(|choice| choice.path == saved)
{
picker.workspace_index = index;
picker.workspace_picked = true;
} else {
picker.workspace_query = saved.to_string();
picker.workspace_index = 0;
picker.workspace_picked = false;
self.refresh_harness_workspace_choices();
}
}
}

/// 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 +257,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 +322,8 @@ impl App {
folder_order + index,
WorkspaceChoice {
path,
source: "folder",
source: "folder".to_string(),
label: None,
},
)
}),
Expand All @@ -222,6 +347,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
Loading
Loading