Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
102 changes: 91 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,57 @@ 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;
}
let favorites = &mut self.loaded.config.harness.favorite_workspaces;
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,
Comment thread
senamakel marked this conversation as resolved.
Outdated
medulla::config::FavoriteWorkspace {
name: name.to_string(),
path: path.clone(),
},
);
let Some(config_path) = &self.config_path else {
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.set_status(format!("Saved favorite {name} · {path}"));
self.refresh_harness_workspace_choices();
}
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 +205,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 +270,8 @@ impl App {
folder_order + index,
WorkspaceChoice {
path,
source: "folder",
source: "folder".to_string(),
label: None,
},
)
}),
Expand All @@ -222,6 +295,13 @@ impl App {
}
}

/// Match a saved name first, falling back to the directory path's normal score.
pub(super) fn workspace_match_score(path: &str, label: Option<&str>, query: &str) -> Option<usize> {
label
.and_then(|label| match_score(label, query))
.or_else(|| match_score(path, query))
Comment thread
senamakel marked this conversation as resolved.
Outdated
}

/// 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
53 changes: 52 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,17 @@ 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 loose_known_matches_do_not_beat_concrete_folder_matches() {
let random_parent = match_score("/tmp/.tmpbQM6Hg", "pb").unwrap();
Expand Down Expand Up @@ -164,3 +175,43 @@ 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());
}
9 changes: 7 additions & 2 deletions src/tui/src/ui/app/render/session_modals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,16 @@ impl App {
} else {
Style::default()
};
let display = choice
.label
.as_deref()
.map(|label| format!("★ {label} · {}", choice.path))
.unwrap_or_else(|| choice.path.clone());
TLine::from(vec![
Span::styled(
format!(
"{marker}{}",
medulla::ui::util::clip_left(&choice.path, 43)
medulla::ui::util::clip_left(&display, 43)
),
Comment thread
senamakel marked this conversation as resolved.
Outdated
style,
),
Expand Down Expand Up @@ -171,7 +176,7 @@ impl App {
// and it is now a statement rather than a question, so it is said on
// both steps and never asked.
lines.push(TLine::from(Span::styled(
" unmanaged · the orchestrator will not dispatch into it",
" ↑/↓ choose · Tab complete · Shift+F save favorite · unmanaged",
Comment thread
senamakel marked this conversation as resolved.
Outdated
Style::default().add_modifier(Modifier::DIM),
)));
f.render_widget(Paragraph::new(Text::from(lines)), inner);
Expand Down
15 changes: 14 additions & 1 deletion src/tui/src/ui/app/session_control/picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use medulla::protocol::HarnessProvider;

use crate::ui::harness_pane::HarnessChoice;

use super::super::types::{tab_pos, App, SessionPicker, SessionPickerStep};
use super::super::types::{tab_pos, App, Prompt, PromptKind, SessionPicker, SessionPickerStep};
use crate::ui::composer::Draft;

impl App {
/// Open the "start a session" picker, or spawn directly when the command
Expand Down Expand Up @@ -156,6 +157,18 @@ impl App {
}
}
KeyCode::Tab => self.complete_harness_workspace(),
KeyCode::Char('F') if event.modifiers == KeyModifiers::SHIFT => {
let Some(workspace) = self.selected_picker_workspace() else {
self.set_status("Choose an existing directory before saving a favorite");
return;
};
self.prompt = Some(Prompt {
kind: PromptKind::FavoriteWorkspaceAdd(workspace.clone()),
title: format!("Save favorite for {workspace}"),
draft: Draft::new(),
});
Comment thread
senamakel marked this conversation as resolved.
self.set_status("Favorite name · Enter save · Esc cancel");
}
KeyCode::Backspace => {
if let Some(picker) = &mut self.session_picker {
picker.workspace_query.pop();
Expand Down
3 changes: 2 additions & 1 deletion src/tui/src/ui/app/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ fn enter_answers_the_harness_picker_not_the_harness_behind_it() {
if let Some(picker) = &mut a.session_picker {
picker.workspace_choices = vec![WorkspaceChoice {
path: ".".into(),
source: "recent",
source: "recent".into(),
label: None,
}];
picker.workspace_index = 0;
}
Expand Down
8 changes: 6 additions & 2 deletions src/tui/src/ui/app/types/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -613,8 +613,10 @@ pub(in crate::ui::app) enum SessionPickerStep {
pub(in crate::ui::app) struct WorkspaceChoice {
/// Absolute directory path.
pub(in crate::ui::app) path: String,
/// Short operator-facing provenance such as `recent` or `folder`.
pub(in crate::ui::app) source: &'static str,
/// Short operator-facing provenance such as `favorite`, `recent`, or `folder`.
pub(in crate::ui::app) source: String,
/// An operator-defined favorite name, when this is a saved shortcut.
pub(in crate::ui::app) label: Option<String>,
}

/// A pointer gesture a harness owns until the button comes back up.
Expand Down Expand Up @@ -741,6 +743,8 @@ pub(in crate::ui::app) enum PromptKind {
HostEditLabel(String),
/// Declare another directory this device may work in.
WorkspaceAdd,
/// Save the selected manual-launcher directory under an operator-chosen name.
FavoriteWorkspaceAdd(String),
Comment thread
senamakel marked this conversation as resolved.
Outdated
/// Add a named OpenRouter-backed coding harness.
CustomHarnessAdd,
/// Edit the custom harness with the given stable id.
Expand Down
Loading