Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1460915
feat(agents): configure explicit automatic resume arguments
WilliamWang1721 Oct 3, 2026
95df940
style: apply GitHub Actions rustfmt output
WilliamWang1721 Oct 3, 2026
d064178
test(ci): register resume settings fixture isolation
WilliamWang1721 Oct 3, 2026
825f980
fix(test): use static selectors for rendered resume controls
WilliamWang1721 Oct 3, 2026
eed61f0
fix(agents): preserve legacy PowerShell native resume arguments
WilliamWang1721 Oct 3, 2026
c0cbdf6
style(agents): apply remote formatter to native argv regression
WilliamWang1721 Oct 3, 2026
92bd3a9
fix(agents): keep native quoting in its platform adapter
WilliamWang1721 Oct 3, 2026
4305dc4
fix(platform): preserve acrylic feature gate
WilliamWang1721 Oct 3, 2026
57b171c
style(platform): apply remote native-adapter formatting
WilliamWang1721 Oct 3, 2026
ad1f4de
fix(agents): preserve explicit empty resume arguments
WilliamWang1721 Oct 3, 2026
7b40044
test(agents): exercise retry through authoritative shell events
WilliamWang1721 Oct 3, 2026
9b4be22
Merge main into codex/issue-323-agent-resume-args
WilliamWang1721 Oct 6, 2026
53e9cdc
fix(ci): keep both native test filters after merging main
WilliamWang1721 Oct 6, 2026
6bf9085
fix(agents): treat missing global settings as unconfigured resume arg…
WilliamWang1721 Oct 6, 2026
9880d4e
ci: rerun native tests after unrelated flaky failure
WilliamWang1721 Oct 6, 2026
c4ad676
merge: sync PR 457 with upstream while preserving resume test isolation
Cec1c Oct 9, 2026
61b2d1f
test(editor): make reader undo fixture independent of typing speed
Cec1c Oct 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
2 changes: 1 addition & 1 deletion .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
theme-studio = { max-threads = 1 }

[[profile.default.overrides]]
filter = 'test(gpui_shell::settings_pane::theme_studio_tests::) or test(ctrl_wheel_font_zoom_toggle_gates_zoom_and_terminal_scroll) or test(ctrl_wheel_font_zoom_setting_is_searchable_and_has_a_visible_switch) or test(environment_refresh_switch_is_searchable_and_persists) or test(pasted_proxy_scheme_updates_the_visible_protocol_and_saved_url) or test(capsule_uses_inset_thumb_full_hit_targets_and_keyboard_selection) or test(completion_capsules_keep_all_chinese_choices_at_large_font_size) or test(font_size_click_input_commits_cancels_and_bounds_values) or test(cjk_dropdown_selection_preserves_the_english_font_chain) or test(review_regression_font_fields_align_and_dropdown_toggles_with_search) or test(=gpui_shell::workspace::keyboard_bindings::tests::dispatch::hybrid_completion_uses_window_tab_dispatch_in_both_workspace_layouts)'
filter = 'test(gpui_shell::settings_pane::theme_studio_tests::) or test(ctrl_wheel_font_zoom_toggle_gates_zoom_and_terminal_scroll) or test(ctrl_wheel_font_zoom_setting_is_searchable_and_has_a_visible_switch) or test(environment_refresh_switch_is_searchable_and_persists) or test(pasted_proxy_scheme_updates_the_visible_protocol_and_saved_url) or test(capsule_uses_inset_thumb_full_hit_targets_and_keyboard_selection) or test(completion_capsules_keep_all_chinese_choices_at_large_font_size) or test(font_size_click_input_commits_cancels_and_bounds_values) or test(cjk_dropdown_selection_preserves_the_english_font_chain) or test(review_regression_font_fields_align_and_dropdown_toggles_with_search) or test(resume_arguments_save_reopen_reject_invalid_and_clear_through_real_controls) or test(=gpui_shell::workspace::keyboard_bindings::tests::dispatch::hybrid_completion_uses_window_tab_dispatch_in_both_workspace_layouts)'
test-group = 'theme-studio'

# 三模式夹具包含九个真实 Git/窗口场景;原生进程的墙钟期限不能用并发 UI 负载测试。
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Explicit automatic Agent resume arguments

## Status
Proposed for review, 2026-10-03.

## Context
[Issue #323](https://github.com/Kuddev/pebrel/issues/323) requests explicit per-Agent
arguments when restarting Pebrel, while preserving the default resume command for
users who have not configured them. The request does not establish a permission
regression in any provider.

## Evidence
`AgentSession::resume_command` already owns saved identity validation, including
Codex rollout normalization. GPUI workspace recovery calls
`TerminalView::restore_agent`, which waits for a new shell prompt before submission.
Pi resolves a native session file asynchronously before constructing its command.
`RuntimeSettings` and `SettingsPane::try_persist` already own preference loading,
persistence and the cached GPUI snapshot.

## Decision
Add eleven stable `agent_resume_args_<source>` preferences for providers with an
existing resume command. Each value is a JSON array of literal arguments;
missing/empty values and `[]` append nothing. `[""]` passes one explicit empty argument. The zero-dependency settings crate
preserves the serialized value; the application uses its existing JSON dependency
and the native quoting adapter in `platform/agent_resume`. Arrays are limited to 32 arguments and 64 KiB
quoted text; control characters are rejected. CMD rejects double quotes, `%` and
`!`, and quoted trailing backslashes are doubled for the native argv parser.
Windows PowerShell 5's legacy native argv binder additionally receives arguments
encoded through the existing Windows native argument encoder in `platform/elevation`.
PowerShell 7 uses the ordinary literal adapter. The pane's frozen native shell
program selects this distinction; guest/SSH arguments retain the guest adapter.

The GPUI Agent page edits drafts and validates them on Save, reports persistence
success/failure in place, and resets them with other preferences. Saving updates the
cached settings immediately for the next cold resume or retry. A command already
queued for submission retains its original snapshot. Automatic cold recovery,
including resolved Pi targets, appends arguments after the existing identity.
Invalid serialized arguments or unsupported shell quoting fail the recovery without
silently retrying a different command. The existing recovery UI retains the target.

## Rejected alternatives
Full command templates need a new substitution language and executable policy.
Replaying launch history may reproduce permissions the user never opted to resume.
Interpreting a raw shell suffix would execute expansions rather than literal args.
New parsing dependencies in the settings core violate its existing boundary.

## Consequences
No arguments or permission flags are enabled by default. Session identity and cwd
remain owned by existing recovery; session snapshots do not acquire launch history.
The setting applies to GPUI automatic recovery and retry, independent of hook
installation. Manual palette resume, forks and runtime Agent start retain their
existing commands. Legacy shell UI does not expose or apply this additive setting.
The new keys remain outside the existing WebDAV sync allowlist because arguments
can contain machine-specific values; this change does not broaden sync policy.

## Validation
Authored settings round-trip/reset tests, literal shell-quoting tests, rendered
Save/reopen/invalid/clear coverage and real cold recovery/PTY submission/retry tests.
All formatting, architecture, compile and native tests must run in GitHub Actions;
local inspection is not execution evidence. A native Windows test compiles a
small stdlib argv receiver, then runs the production adapter's generated command
through Windows PowerShell and PowerShell 7 and compares the complete argv array.
The [fork native argv experiment](https://github.com/WilliamWang1721/pebrel/actions/runs/37110686203)
confirmed that ordinary single quotes alone lose embedded quotes and argument
boundaries in Windows PowerShell 5. Native screenshot and real provider CLI
acceptance remain separate from the regression tests.

## Supersedes
None.

## Revisit when
A concrete wrapper executable/template or manual-resume configuration requirement
is accepted, or a supported shell's literal argument contract changes.
4 changes: 4 additions & 0 deletions nebula_app/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@
"save_failed": "Could not change startup settings: {error}"
},
"agents": {
"resume_title": "Automatic resume arguments",
"resume_description": "Enter a JSON array of literal arguments for each agent, such as [\"--flag\", \"value\"]. Empty keeps the default command. Saved values apply to the next automatic resume or retry; they never replay command history. CMD cannot accept arguments containing double quotes, % or !.",
"resume_invalid": "Use a JSON array of up to 32 strings, without control characters (at most 64 KiB after quoting).",
"resume_failed": "Could not save resume arguments: {error}",
"title": "Coding agents",
"description": "Show agent activity, completion and approval requests in Pebrel. Claude Code and Codex are enabled by default.",
"refresh": "Refresh",
Expand Down
4 changes: 4 additions & 0 deletions nebula_app/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@
"save_failed": "无法更改启动设置:{error}"
},
"agents": {
"resume_title": "自动接续附加参数",
"resume_description": "按 Agent 输入由字面参数组成的 JSON 数组,例如 [\"--flag\", \"value\"]。留空保持默认命令。保存后用于下次自动接续或重试,不重放命令历史。CMD 不支持包含双引号、% 或 ! 的参数。",
"resume_invalid": "请输入最多 32 个字符串组成的 JSON 数组,不包含控制字符(引用后最多 64 KiB)。",
"resume_failed": "无法保存接续参数:{error}",
"title": "编程智能体",
"description": "在 Pebrel 中显示运行状态、完成通知和授权请求。Claude Code 与 Codex 默认开启。",
"refresh": "刷新",
Expand Down
68 changes: 68 additions & 0 deletions nebula_app/src/agent_resume.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Literal argument arrays for automatic recovery, quoted for the pane's shell.

use crate::display::side_panel::PathQuote;

pub(crate) fn parse(value: &str) -> Option<Vec<String>> {
if value.trim().is_empty() {
return Some(Vec::new());
}
let args: Vec<String> = serde_json::from_str(value).ok()?;
if args.is_empty() {
return Some(args);
}
if args.len() > 32 || args.iter().any(|arg| arg.chars().any(char::is_control)) {
return None;
}
crate::platform::agent_resume::quote_args(&args, PathQuote::Posix, None)?;
Some(args)
}

pub(crate) fn append(
command: String,
value: &str,
shell: PathQuote,
program: Option<&str>,
) -> Option<String> {
let args = parse(value)?;
if args.is_empty() {
return Some(command);
}
let quoted = crate::platform::agent_resume::quote_args(&args, shell, program)?;
Some(format!("{command} {}", quoted.trim_end()))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn literal_arguments_cannot_become_shell_expressions() {
let value = r#"["--config", "name=a b;$(touch nope)&|", "it's"]"#;
assert_eq!(
append("codex resume id".into(), value, PathQuote::Posix, None).unwrap(),
"codex resume id --config 'name=a b;$(touch nope)&|' 'it'\\''s'"
);
assert_eq!(
append("codex resume id".into(), value, PathQuote::PowerShell, None).unwrap(),
"codex resume id --config 'name=a b;$(touch nope)&|' 'it''s'"
);
assert_eq!(
append("codex resume id".into(), r#"["a b\\"]"#, PathQuote::CommandPrompt, None)
.unwrap(),
"codex resume id \"a b\\\\\""
);
for value in [r#"["%PATH%"]"#, r#"["!VAR!"]"#, r#"["a\"b"]"#] {
assert!(
append("codex resume id".into(), value, PathQuote::CommandPrompt, None).is_none()
);
}
assert_eq!(parse(r#"[""]"#), Some(vec![String::new()]));
assert_eq!(
append("codex resume id".into(), r#"[""]"#, PathQuote::Posix, None),
Some("codex resume id ''".into())
);
for value in ["--yolo", r#"[1]"#, r#"[null]"#, r#"["\n"]"#] {
assert!(parse(value).is_none());
}
}
}
2 changes: 2 additions & 0 deletions nebula_app/src/gpui_shell/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ pub struct Settings {
pub dim_inactive_panes: bool,
/// Cached in-app toast preference, independent of native system notifications.
pub ai_toasts: bool,
pub(crate) agent_resume_args: nebula_settings::AgentResumeArgs,
/// Cached display lifetime; toast delivery and native notifications are independent.
pub notification_duration: nebula_settings::NotificationDuration,
/// 标签关闭按钮与标签插入动画都在渲染热路径读取,必须随全局设置驻留内存。
Expand Down Expand Up @@ -301,6 +302,7 @@ impl Settings {
.unwrap_or(raw.mouse.focus_follows_mouse),
dim_inactive_panes: runtime.dim_inactive_panes,
ai_toasts: runtime.ai_toasts,
agent_resume_args: runtime.agent_resume_args,
notification_duration: runtime.notification_duration,
tab_close_visible: runtime.tab_close_visible,
tab_reveal: runtime.tab_reveal,
Expand Down
6 changes: 5 additions & 1 deletion nebula_app/src/gpui_shell/file_editor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ fn returning_to_reader_keeps_source_edits_and_blocks_undo_until_source_is_open(
_ => "ctrl",
};
cx.simulate_keystrokes(&format!("{modifier}-/ {modifier}-a"));
cx.simulate_input("Updated");
// Keep this one edit: slow simulated typing can cross the undo grouping timeout.
cx.update(|_, cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string("Updated".to_owned()));
});
cx.simulate_keystrokes(&format!("{modifier}-v"));
cx.run_until_parked();
cx.simulate_keystrokes(&format!("{modifier}-/ {modifier}-z"));
cx.run_until_parked();
Expand Down
1 change: 1 addition & 0 deletions nebula_app/src/gpui_shell/settings_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ use crate::gpui_shell::prelude::*;
use crate::gpui_shell::widgets::{NebulaButton, settings_control_height};

mod about;
mod agent_resume;
mod agents;
mod app_icon;
mod appearance;
Expand Down
97 changes: 97 additions & 0 deletions nebula_app/src/gpui_shell/settings_pane/agent_resume.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
use super::*;
use crate::i18n::Message;
use nebula_settings::AGENT_RESUME_SETTINGS;

impl SettingsPane {
pub(super) fn save_agent_resume_args(&mut self, index: usize, cx: &mut Context<Self>) {
let value = self.agents.resume_inputs[index].read(cx).value().to_string();
let language = crate::gpui_shell::config::ui_language(cx);
let result = match crate::agent_resume::parse(&value) {
Some(args) => {
let value = if args.is_empty() {
String::new()
} else {
serde_json::to_string(&args).expect("string arguments serialize")
};
self.try_persist(&[(AGENT_RESUME_SETTINGS[index].1, value)], cx).map_err(|error| {
language.format(
Message::SettingsAgentsResumeFailed,
&[("error", &error.to_string())],
)
})
},
None => Err(language.text(Message::SettingsAgentsResumeInvalid).to_owned()),
};
self.agents.resume_feedback = Some((index, result));
cx.notify();
}

pub(super) fn agent_resume_settings(&self, cx: &mut Context<Self>) -> gpui::Div {
let language = crate::gpui_shell::config::ui_language(cx);
let muted = cx.theme().muted_foreground;
let mut content = v_flex()
.w_full()
.gap_3()
.child(
div()
.text_sm()
.font_weight(gpui::FontWeight::MEDIUM)
.child(language.text(Message::SettingsAgentsResumeTitle)),
)
.child(
div()
.text_sm()
.text_color(muted)
.child(language.text(Message::SettingsAgentsResumeDescription)),
);
for (index, (source, _)) in AGENT_RESUME_SETTINGS.iter().enumerate() {
let agent =
crate::ai_agents::AgentKind::parse(source).expect("registered resume source");
let feedback =
self.agents.resume_feedback.as_ref().filter(|(row, _)| *row == index).map(
|(_, result)| match result {
Ok(()) => language.text(Message::CommonSaved).to_owned(),
Err(error) => error.clone(),
},
);
content = content.child(
v_flex()
.w_full()
.gap_1()
.child(div().text_sm().child(agent.display_name()))
.child(
h_flex()
.w_full()
.gap_2()
.child(
div()
.debug_selector(move || format!("agent-resume-input-{index}"))
.flex_1()
.min_w_0()
.child(
Input::new(&self.agents.resume_inputs[index]).aria_label(
format!(
"{} {}",
agent.display_name(),
language.text(Message::SettingsAgentsResumeTitle)
),
),
),
)
.child(
Button::new(("agent-resume-save", index))
.debug_selector(move || format!("agent-resume-save-{index}"))
.label(language.text(Message::CommonSave))
.on_click(cx.listener(move |this, _, _, cx| {
this.save_agent_resume_args(index, cx);
})),
),
)
.when_some(feedback, |view, text| {
view.child(div().text_xs().text_color(muted).child(text))
}),
);
}
content
}
}
68 changes: 68 additions & 0 deletions nebula_app/src/gpui_shell/settings_pane/agent_resume_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use super::*;
use crate::gpui_shell::settings_fixture::{SettingsBytesGuard, lock_theme_studio};

fn draw(cx: &mut VisualTestContext) {
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear(cx);
});
}

fn click(selector: &'static str, cx: &mut VisualTestContext) {
let bounds = cx.debug_bounds(selector).expect("visible control");
cx.simulate_click(bounds.center(), Modifiers::default());
draw(cx);
}

fn edit_input(selector: &'static str, value: &str, cx: &mut VisualTestContext) {
click(selector, cx);
cx.simulate_keystrokes(if cfg!(target_os = "macos") { "cmd-a" } else { "ctrl-a" });
cx.simulate_input(value);
draw(cx);
}

#[gpui::test]
fn resume_arguments_save_reopen_reject_invalid_and_clear_through_real_controls(
cx: &mut TestAppContext,
) {
let _fixture_guard = lock_theme_studio();
let _settings_guard = SettingsBytesGuard::capture();
std::fs::create_dir_all(nebula_settings::settings_dir()).unwrap();
std::fs::write(nebula_settings::settings_path(), "theme=Nord\nlanguage=en-US\n").unwrap();
let (pane, window, reply, _) = fixture(cx);
drop(reply);
draw(window);
let value = r#"["--yolo", "--config", "model=example"]"#;
edit_input("agent-resume-input-1", value, window);
assert_eq!(RuntimeSettings::load().agent_resume_args.get("codex"), "");
click("agent-resume-save-1", window);
let persisted = r#"["--yolo","--config","model=example"]"#;
assert_eq!(RuntimeSettings::load().agent_resume_args.get("codex"), persisted);
window.read(|cx| {
assert_eq!(
cx.global::<crate::gpui_shell::config::Settings>().agent_resume_args.get("codex"),
persisted
);
});
pane.read_with(window, |pane, _| {
assert!(matches!(pane.agents.resume_feedback, Some((1, Ok(())))));
});
let reopened = window.update(|window, cx| cx.new(|cx| SettingsPane::new(window, cx)));
reopened.read_with(window, |pane, cx| {
assert_eq!(pane.agents.resume_inputs[1].read(cx).value().as_ref(), persisted);
assert_eq!(pane.runtime.agent_resume_args.get("claude"), "");
});
edit_input("agent-resume-input-1", "--yolo; echo invalid", window);
click("agent-resume-save-1", window);
assert_eq!(RuntimeSettings::load().agent_resume_args.get("codex"), persisted);
pane.read_with(window, |pane, _| {
assert!(matches!(pane.agents.resume_feedback, Some((1, Err(_)))));
});
edit_input("agent-resume-input-1", "[]", window);
click("agent-resume-save-1", window);
assert_eq!(RuntimeSettings::load().agent_resume_args.get("codex"), "");
let input = window.debug_bounds("agent-resume-input-1").unwrap();
let save = window.debug_bounds("agent-resume-save-1").unwrap();
assert!(input.right() <= save.left());
assert!(save.size.height >= px(24.0));
}
Loading
Loading