Skip to content

Commit 3eff567

Browse files
theahuranori-agent
andauthored
feat(tui): Show skillset picker on startup when skillset_per_session is enabled (#351)
## Summary 🤖 Generated with [Nori](https://www.npmjs.com/package/nori-ai) - Remove the `.worktrees/` directory guard from the `skillset_per_session` startup checks so the skillset picker always appears when the setting is enabled, regardless of directory context - Add `on_dismiss` callback to `SelectionViewParams` so dismissing the picker without selection triggers deferred agent spawn (behaving as if the feature is disabled) - Add `SkillsetPickerDismissed` AppEvent variant to handle picker dismissal gracefully ## Test Plan - [x] 3 new tests: `on_dismiss_callback_fires_on_ctrl_c`, `on_dismiss_callback_not_fired_on_accept`, `test_skillset_picker_dismiss_sends_event` - [x] All 918 existing tests pass with no regressions - [x] Clippy passes clean - [ ] Manual: Enable `skillset_per_session` in config, launch nori outside a `.worktrees/` directory, verify picker appears - [ ] Manual: Dismiss the picker (Escape), verify the agent starts normally - [ ] Manual: Select a skillset from the picker, verify the agent starts with the skillset active Share Nori with your team: https://www.npmjs.com/package/nori-ai Co-authored-by: Nori <contact@tilework.tech>
1 parent fca7ed3 commit 3eff567

10 files changed

Lines changed: 190 additions & 57 deletions

File tree

codex-rs/tui/docs.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ During background system info collection on unix, `check_worktree_cleanup()` run
136136

137137
The `desc_col` is computed once per render pass from the widest visible name plus 2 columns of padding. The stacked fallback prevents descriptions from being squeezed into 1-2 characters of horizontal space on narrow terminals. Because both `render_rows()` and `measure_rows_height()` call the same `wrap_row()` function, layout and height calculation are always consistent.
138138

139+
`SelectionViewParams` supports an optional `on_dismiss: Option<SelectionAction>` callback that fires when the picker is dismissed without selection (Escape or Ctrl-C). The callback is invoked in `ListSelectionView::on_ctrl_c()` before marking the view as complete. It does not fire when the user makes a selection via `accept()`. This is used by the skillset picker to send `SkillsetPickerDismissed` when the deferred agent spawn needs a fallback trigger.
140+
139141
**Undo Snapshot Picker (`/undo`):**
140142

141143
The `/undo` slash command sends `Op::UndoList` (not `Op::Undo`) to the ACP backend. When the backend responds with `UndoListResult`, the TUI opens a `ListSelectionView` modal (the same pattern used by the approvals popup, etc.) displaying all available snapshots. Each item shows `[short_id] truncated_label` where the label is truncated to 60 characters. Selecting a snapshot dispatches `Op::UndoTo { index }` to restore to that point. If no snapshots are available, an info message is displayed instead of the modal.
@@ -199,9 +201,9 @@ The `/switch-skillset` command integrates with the external `nori-skillsets` CLI
199201

200202
The worktree context is detected by `handle_switch_skillset_command()`: if the cwd's parent directory is named `.worktrees`, the cwd is passed as `install_dir`. When `skillset_per_session` is enabled, the cwd is used as `install_dir` even when not in a worktree. This enables per-worktree or per-session skillset installation.
201203

202-
When `skillset_per_session` is enabled in `NoriConfig`, the skillset picker is automatically triggered at startup in `App::run()`, regardless of whether the session is in a worktree.
204+
When `skillset_per_session` is enabled in `NoriConfig`, the skillset picker is automatically triggered at startup in `App::run()`, regardless of whether the session is in a worktree. The agent spawn is deferred (`ChatWidgetInit::deferred_spawn = true`) so that `nori-skillsets switch` can write `.claude/CLAUDE.md` to disk before the agent reads it. During the deferred period, a dummy channel is created in `constructors.rs` so the widget has a valid `op_tx`. The real agent spawns after the user picks a skillset (`SkillsetSwitchResult` triggers `spawn_deferred_agent()`). If the user dismisses the picker without selecting a skillset (Escape/Ctrl-C), the picker's `on_dismiss` callback sends `AppEvent::SkillsetPickerDismissed`, which also triggers `spawn_deferred_agent()` -- the agent starts without a skillset, behaving as if the feature were disabled. The `server_for_deferred_spawn` field on `App` holds the `ConversationManager` until one of these paths consumes it via `.take()`.
203205

204-
Events: `AppEvent::SkillsetListResult` (carries `install_dir: Option<PathBuf>`), `AppEvent::InstallSkillset`, `AppEvent::SwitchSkillset`, `AppEvent::SkillsetInstallResult`, `AppEvent::SkillsetSwitchResult`, `AppEvent::OpenSkillsetPerSessionWorktreeChoice`
206+
Events: `AppEvent::SkillsetListResult` (carries `install_dir: Option<PathBuf>`), `AppEvent::InstallSkillset`, `AppEvent::SwitchSkillset`, `AppEvent::SkillsetInstallResult`, `AppEvent::SkillsetSwitchResult`, `AppEvent::SkillsetPickerDismissed`, `AppEvent::OpenSkillsetPerSessionWorktreeChoice`
205207

206208
The "Per Session Skillsets" toggle in `/config` is built in `nori/config_picker.rs`. Toggling it on emits `AppEvent::OpenSkillsetPerSessionWorktreeChoice`, which opens a worktree choice modal (`skillset_worktree_choice_params()`) letting the user choose between "With Auto Worktrees" and "Without Auto Worktrees". The choice determines whether `auto_worktree` is also enabled. Toggling it off emits `AppEvent::SetConfigSkillsetPerSession`, handled in `app/config_persistence.rs` via `persist_skillset_per_session_setting()` to write `skillset_per_session` under `[tui]` in `config.toml`. The "Auto Worktree" toggle is always independently toggleable regardless of the `skillset_per_session` state.
207209

codex-rs/tui/src/app/event_handling.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,19 @@ impl App {
822822
);
823823
}
824824
}
825+
AppEvent::SkillsetPickerDismissed => {
826+
// The skillset picker was dismissed without selection. If the
827+
// agent spawn was deferred, spawn it now without a skillset
828+
// (behaves as if skillset_per_session is disabled).
829+
#[cfg(feature = "nori-config")]
830+
if let Some(server) = self.server_for_deferred_spawn.take() {
831+
self.chat_widget.spawn_deferred_agent(
832+
self.config.clone(),
833+
self.app_event_tx.clone(),
834+
server,
835+
);
836+
}
837+
}
825838
AppEvent::ExecuteScript { prompt, args } => {
826839
let tx = self.app_event_tx.clone();
827840
let nori_config = codex_acp::config::NoriConfig::load().unwrap_or_default();

codex-rs/tui/src/app/mod.rs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -302,14 +302,14 @@ impl App {
302302

303303
let enhanced_keys_supported = tui.enhanced_keys_supported();
304304

305-
// When skillset_per_session is enabled and we're in a worktree, defer
306-
// spawning the agent until after the user picks a skillset and the switch
307-
// writes `.claude/CLAUDE.md` to disk.
305+
// When skillset_per_session is enabled, defer spawning the agent until
306+
// after the user picks a skillset and the switch writes
307+
// `.claude/CLAUDE.md` to disk. If the user dismisses the picker, the
308+
// agent spawns without a skillset.
308309
#[cfg(feature = "nori-config")]
309310
let needs_deferred_spawn = {
310311
let nori_cfg = codex_acp::config::NoriConfig::load().unwrap_or_default();
311312
nori_cfg.skillset_per_session
312-
&& crate::system_info::extract_worktree_name(&config.cwd).is_some()
313313
};
314314
#[cfg(not(feature = "nori-config"))]
315315
let needs_deferred_spawn = false;
@@ -428,15 +428,15 @@ impl App {
428428
);
429429
}
430430

431-
// If skillset_per_session is enabled and we're in a worktree, show the
432-
// skillset picker. The agent spawn was deferred so that
433-
// `nori-skillsets switch` can write `.claude/CLAUDE.md` before the agent
434-
// reads it. Once the user picks a skillset and the switch completes,
435-
// `event_handling.rs` triggers `spawn_deferred_agent()`.
431+
// If skillset_per_session is enabled, show the skillset picker. The
432+
// agent spawn was deferred so that `nori-skillsets switch` can write
433+
// `.claude/CLAUDE.md` before the agent reads it. Once the user picks a
434+
// skillset and the switch completes, `event_handling.rs` triggers
435+
// `spawn_deferred_agent()`. If the user dismisses the picker, the
436+
// `SkillsetPickerDismissed` event triggers the deferred spawn without a
437+
// skillset.
436438
#[cfg(feature = "nori-config")]
437-
if nori_config.skillset_per_session
438-
&& crate::system_info::extract_worktree_name(&app.config.cwd).is_some()
439-
{
439+
if nori_config.skillset_per_session {
440440
app.chat_widget.handle_switch_skillset_command();
441441
}
442442

codex-rs/tui/src/app_event.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,11 @@ pub(crate) enum AppEvent {
374374
message: String,
375375
},
376376

377+
/// The skillset picker was dismissed without selection. When agent spawn was
378+
/// deferred for skillset_per_session, this triggers spawning the agent
379+
/// without a skillset (behaves as if the feature is disabled).
380+
SkillsetPickerDismissed,
381+
377382
/// Execute a custom prompt script asynchronously.
378383
ExecuteScript {
379384
/// The custom prompt to execute.

codex-rs/tui/src/bottom_pane/footer.rs

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -973,8 +973,10 @@ mod tests {
973973

974974
#[test]
975975
fn footer_with_worktree_name_disabled() {
976-
let mut segment_config = FooterSegmentConfig::default();
977-
segment_config.worktree_name = false;
976+
let segment_config = FooterSegmentConfig {
977+
worktree_name: false,
978+
..Default::default()
979+
};
978980

979981
snapshot_footer(
980982
"footer_with_worktree_name_disabled",
@@ -1021,8 +1023,10 @@ mod tests {
10211023

10221024
#[test]
10231025
fn footer_with_git_branch_disabled() {
1024-
let mut segment_config = FooterSegmentConfig::default();
1025-
segment_config.git_branch = false;
1026+
let segment_config = FooterSegmentConfig {
1027+
git_branch: false,
1028+
..Default::default()
1029+
};
10261030

10271031
snapshot_footer(
10281032
"footer_with_git_branch_disabled",
@@ -1038,10 +1042,12 @@ mod tests {
10381042

10391043
#[test]
10401044
fn footer_with_multiple_segments_disabled() {
1041-
let mut segment_config = FooterSegmentConfig::default();
1042-
segment_config.git_branch = false;
1043-
segment_config.git_stats = false;
1044-
segment_config.token_usage = false;
1045+
let segment_config = FooterSegmentConfig {
1046+
git_branch: false,
1047+
git_stats: false,
1048+
token_usage: false,
1049+
..Default::default()
1050+
};
10451051

10461052
snapshot_footer(
10471053
"footer_with_multiple_segments_disabled",
@@ -1063,17 +1069,18 @@ mod tests {
10631069

10641070
#[test]
10651071
fn footer_with_all_segments_disabled() {
1066-
let mut segment_config = FooterSegmentConfig::default();
1067-
segment_config.prompt_summary = false;
1068-
segment_config.vim_mode = false;
1069-
segment_config.git_branch = false;
1070-
segment_config.worktree_name = false;
1071-
segment_config.git_stats = false;
1072-
segment_config.context = false;
1073-
segment_config.approval_mode = false;
1074-
segment_config.nori_profile = false;
1075-
segment_config.nori_version = false;
1076-
segment_config.token_usage = false;
1072+
let segment_config = FooterSegmentConfig {
1073+
prompt_summary: false,
1074+
vim_mode: false,
1075+
git_branch: false,
1076+
worktree_name: false,
1077+
git_stats: false,
1078+
context: false,
1079+
approval_mode: false,
1080+
nori_profile: false,
1081+
nori_version: false,
1082+
token_usage: false,
1083+
};
10771084

10781085
snapshot_footer(
10791086
"footer_with_all_segments_disabled",
@@ -1098,9 +1105,11 @@ mod tests {
10981105

10991106
#[test]
11001107
fn footer_vertical_with_segments_disabled() {
1101-
let mut segment_config = FooterSegmentConfig::default();
1102-
segment_config.nori_profile = false;
1103-
segment_config.nori_version = false;
1108+
let segment_config = FooterSegmentConfig {
1109+
nori_profile: false,
1110+
nori_version: false,
1111+
..Default::default()
1112+
};
11041113

11051114
snapshot_footer(
11061115
"footer_vertical_with_segments_disabled",

codex-rs/tui/src/bottom_pane/list_selection_view.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ pub(crate) struct SelectionViewParams {
5353
pub search_placeholder: Option<String>,
5454
pub header: Box<dyn Renderable>,
5555
pub initial_selected_idx: Option<usize>,
56+
/// Optional callback fired when the picker is dismissed without selection
57+
/// (e.g. via Escape or Ctrl-C).
58+
pub on_dismiss: Option<SelectionAction>,
5659
}
5760

5861
impl Default for SelectionViewParams {
@@ -66,6 +69,7 @@ impl Default for SelectionViewParams {
6669
search_placeholder: None,
6770
header: Box::new(()),
6871
initial_selected_idx: None,
72+
on_dismiss: None,
6973
}
7074
}
7175
}
@@ -83,6 +87,7 @@ pub(crate) struct ListSelectionView {
8387
last_selected_actual_idx: Option<usize>,
8488
header: Box<dyn Renderable>,
8589
initial_selected_idx: Option<usize>,
90+
on_dismiss: Option<SelectionAction>,
8691
}
8792

8893
impl ListSelectionView {
@@ -114,6 +119,7 @@ impl ListSelectionView {
114119
last_selected_actual_idx: None,
115120
header,
116121
initial_selected_idx: params.initial_selected_idx,
122+
on_dismiss: params.on_dismiss,
117123
};
118124
s.apply_filter();
119125
s
@@ -331,6 +337,9 @@ impl BottomPaneView for ListSelectionView {
331337
}
332338

333339
fn on_ctrl_c(&mut self) -> CancellationEvent {
340+
if let Some(cb) = self.on_dismiss.take() {
341+
cb(&self.app_event_tx);
342+
}
334343
self.complete = true;
335344
CancellationEvent::Handled
336345
}
@@ -607,6 +616,71 @@ mod tests {
607616
);
608617
}
609618

619+
#[test]
620+
fn on_dismiss_callback_fires_on_ctrl_c() {
621+
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
622+
let tx = AppEventSender::new(tx_raw);
623+
let dismissed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
624+
let dismissed_clone = dismissed.clone();
625+
let items = vec![SelectionItem {
626+
name: "Option A".to_string(),
627+
dismiss_on_select: true,
628+
..Default::default()
629+
}];
630+
let mut view = ListSelectionView::new(
631+
SelectionViewParams {
632+
title: Some("Test".to_string()),
633+
items,
634+
on_dismiss: Some(Box::new(move |_tx| {
635+
dismissed_clone.store(true, std::sync::atomic::Ordering::SeqCst);
636+
})),
637+
..Default::default()
638+
},
639+
tx,
640+
);
641+
642+
// Dismiss via Ctrl-C
643+
view.on_ctrl_c();
644+
645+
assert!(
646+
dismissed.load(std::sync::atomic::Ordering::SeqCst),
647+
"on_dismiss callback should fire when picker is dismissed via Ctrl-C"
648+
);
649+
assert!(view.is_complete(), "view should be complete after dismiss");
650+
}
651+
652+
#[test]
653+
fn on_dismiss_callback_not_fired_on_accept() {
654+
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
655+
let tx = AppEventSender::new(tx_raw);
656+
let dismissed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
657+
let dismissed_clone = dismissed.clone();
658+
let items = vec![SelectionItem {
659+
name: "Option A".to_string(),
660+
dismiss_on_select: true,
661+
..Default::default()
662+
}];
663+
let mut view = ListSelectionView::new(
664+
SelectionViewParams {
665+
title: Some("Test".to_string()),
666+
items,
667+
on_dismiss: Some(Box::new(move |_tx| {
668+
dismissed_clone.store(true, std::sync::atomic::Ordering::SeqCst);
669+
})),
670+
..Default::default()
671+
},
672+
tx,
673+
);
674+
675+
// Accept via Enter (should NOT fire on_dismiss)
676+
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
677+
678+
assert!(
679+
!dismissed.load(std::sync::atomic::Ordering::SeqCst),
680+
"on_dismiss callback should NOT fire when an item is selected"
681+
);
682+
}
683+
610684
#[test]
611685
fn narrow_width_keeps_all_rows_visible() {
612686
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();

codex-rs/tui/src/bottom_pane/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ mod footer;
2727
mod history_search_popup;
2828
mod list_selection_view;
2929
mod prompt_args;
30+
#[cfg(test)]
31+
pub(crate) use list_selection_view::ListSelectionView;
3032
pub(crate) use list_selection_view::SelectionViewParams;
3133
mod paste_burst;
3234
pub mod popup_consts;

codex-rs/tui/src/chatwidget/pickers.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,14 @@ impl ChatWidget {
303303
) {
304304
match (names, error) {
305305
(Some(names), None) if !names.is_empty() => {
306-
let params =
307-
crate::nori::skillset_picker::skillset_picker_params(names, install_dir);
306+
let on_dismiss: SelectionAction = Box::new(|tx| {
307+
tx.send(AppEvent::SkillsetPickerDismissed);
308+
});
309+
let params = crate::nori::skillset_picker::skillset_picker_params(
310+
names,
311+
install_dir,
312+
Some(on_dismiss),
313+
);
308314
self.bottom_pane.show_selection_view(params);
309315
}
310316
(_, Some(error)) => {

codex-rs/tui/src/chatwidget/tests/mod.rs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ use codex_core::protocol::FileChange;
2828
use codex_core::protocol::Op;
2929
use codex_core::protocol::PatchApplyBeginEvent;
3030
use codex_core::protocol::PatchApplyEndEvent;
31-
use codex_core::protocol::RateLimitWindow;
3231
use codex_core::protocol::StreamErrorEvent;
3332
use codex_core::protocol::TaskCompleteEvent;
3433
use codex_core::protocol::TaskStartedEvent;
@@ -71,18 +70,6 @@ fn test_config() -> Config {
7170
.expect("config")
7271
}
7372

74-
fn snapshot(percent: f64) -> RateLimitSnapshot {
75-
RateLimitSnapshot {
76-
primary: Some(RateLimitWindow {
77-
used_percent: percent,
78-
window_minutes: Some(60),
79-
resets_at: None,
80-
}),
81-
secondary: None,
82-
credits: None,
83-
}
84-
}
85-
8673
fn drain_insert_history(
8774
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
8875
) -> Vec<Vec<ratatui::text::Line<'static>>> {

0 commit comments

Comments
 (0)