Fix /init inventing placeholder paths in the TUI

The TUI system prompts never told the model the session's working directory: startup and on-device model load appended only the project instruction files, and the cloud-tier switch in the model picker passed the bare base prompt. With no cwd to anchor to, /init (issue #40) wrote AGENTS.md to the literal placeholder path /path/to/repo/AGENTS.md, the write failed, and the model misreported the repository as read-only — which the user saw as a permissions problem. ACP sessions and headless runs already push session_context_message (cwd guidance + instruction files) at every entry point. Give the TUI the same context: a shared tui_system_prompt helper composes the model-appropriate base prompt with that message, and all three TUI backend-construction sites use it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DU2U9Vqa7GWg645wQabokw

Claude committed Jul 17, 2026 at 13:19 UTC 4fe3c49a11a2c8167baeaea81944b184598a256b
2 files changed +46 -28
src/chat.rs
+5 -15
@@ -2672,19 +2672,10 @@ mod tui {
2672 };
2673
2674 // own thread + runtime so block_in_place doesn't starve the TUI loop.
2675 - // Fold in project instruction files (AGENTS.md / CLAUDE.md) for the launch
2676 - // directory so the on-device model gets the same always-on context the
2677 - // cloud and ACP paths get.
2678 - let system_prompt = {
2679 - let base = crate::system_prompt_for_model(model.tool_calling).to_string();
2680 - match std::env::current_dir()
2681 - .ok()
2682 - .and_then(|cwd| crate::instructions::load_project_instructions(&cwd))
2683 - {
2684 - Some(extra) => format!("{base}\n\n{extra}"),
2685 - None => base,
2686 - }
2687 - };
2675 + // Fold in the session context (cwd guidance + AGENTS.md / CLAUDE.md
2676 + // instruction files) for the launch directory so the on-device model
2677 + // gets the same always-on context the cloud and ACP paths get.
2678 + let system_prompt = crate::tui_system_prompt(model.tool_calling);
2679 let engine_handle = Arc::clone(&engine);
2680 let tool_calling = model.tool_calling;
2681 std::thread::spawn(move || {
@@ -2875,8 +2866,7 @@ mod tui {
2866 app.close_model_picker();
2867 match crate::provider::cloud_tier_provider(&tier) {
2868 Some(provider) => {
2878 - let system_prompt =
2879 - crate::system_prompt_for_model(true).to_string();
2869 + let system_prompt = crate::tui_system_prompt(true);
2870 app.backend = Arc::new(OpenAiBackend::new(
2871 provider.base_url,
2872 provider.api_key,
src/main.rs
+41 -13
@@ -298,6 +298,21 @@ fn session_context_message(cwd: &std::path::Path) -> String {
298 message
299 }
300
301 +/// The full system prompt for a TUI backend: the model-appropriate base prompt
302 +/// plus [`session_context_message`] for the launch directory. The TUI
303 +/// counterpart of the ACP entry points, which push that context as a separate
304 +/// system message — without the cwd guidance the model doesn't know where the
305 +/// project lives and invents placeholder paths (e.g. `/init` trying to write
306 +/// `/path/to/repo/AGENTS.md`).
307 +#[cfg(unix)]
308 +pub(crate) fn tui_system_prompt(tool_calling: bool) -> String {
309 + let base = system_prompt_for_model(tool_calling);
310 + match std::env::current_dir() {
311 + Ok(cwd) => format!("{base}\n\n{}", session_context_message(&cwd)),
312 + Err(_) => base.to_string(),
313 + }
314 +}
315 +
316 fn agent_tools_as_specs() -> Vec<ToolSpec> {
317 let mut specs: Vec<ToolSpec> = tools::all_tools()
318 .into_iter()
@@ -2922,16 +2937,12 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
2937 // channel so the loading-phase plumbing in `chat::run_with` is unchanged.
2938 let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
2939
2925 - // Project instruction files (AGENTS.md / CLAUDE.md) for the launch directory,
2926 - // injected into the system prompt so the TUI shares the same always-on
2927 - // project context the ACP sessions get.
2928 - let project_instructions = std::env::current_dir()
2929 - .ok()
2930 - .and_then(|cwd| instructions::load_project_instructions(&cwd));
2931 - let with_instructions = |base: String| match &project_instructions {
2932 - Some(extra) => format!("{base}\n\n{extra}"),
2933 - None => base,
2934 - };
2940 + // Session context (cwd guidance + AGENTS.md / CLAUDE.md instruction files)
2941 + // for the launch directory, injected into the system prompt so the TUI
2942 + // shares the same always-on project context the ACP sessions get — in
2943 + // particular the model must know the real working directory, or tools like
2944 + // `/init` write to invented paths.
2945 + let remote_system_prompt = tui_system_prompt(true);
2946
2947 // Pick the inference backend: a configured provider if present, else on-device.
2948 let (inference_backend, startup_model_name): (Arc<dyn InferenceBackend>, String) =
@@ -2951,7 +2962,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
2962 provider.base_url,
2963 provider.api_key,
2964 provider.model,
2954 - Some(with_instructions(SYSTEM_PROMPT.to_string())),
2965 + Some(remote_system_prompt.clone()),
2966 )) as Arc<dyn InferenceBackend>;
2967 (backend, label)
2968 }
@@ -2959,7 +2970,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
2970 // Honor the Local Inference toggle: when off and signed in, start
2971 // on a cloud tier. Otherwise bring up on-device WITHOUT loading a
2972 // model — the user loads it explicitly with /load (or /models), so
2962 - // the UI comes up immediately. Project instructions are injected at
2973 + // the UI comes up immediately. Session context is injected at
2974 // load time in `chat.rs`.
2975 let cloud_when_off = if settings::local_inference_enabled() {
2976 None
@@ -2981,7 +2992,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
2992 provider.base_url,
2993 provider.api_key,
2994 provider.model,
2984 - Some(with_instructions(SYSTEM_PROMPT.to_string())),
2995 + Some(remote_system_prompt.clone()),
2996 )) as Arc<dyn InferenceBackend>;
2997 (backend, label)
2998 }
@@ -3369,6 +3380,23 @@ mod tests {
3380 );
3381 }
3382
3383 + #[test]
3384 + #[cfg(unix)]
3385 + fn tui_system_prompt_names_the_real_working_directory() {
3386 + // The /init flow relies on the model knowing where the project lives:
3387 + // without the cwd guidance it invents placeholder paths like
3388 + // `/path/to/repo/AGENTS.md` and misreads the failure as a filesystem
3389 + // permissions problem (issue #40).
3390 + let _guard = ENV_TEST_LOCK
3391 + .lock()
3392 + .unwrap_or_else(|poisoned| poisoned.into_inner());
3393 + let cwd = std::env::current_dir().unwrap();
3394 + let prompt = tui_system_prompt(true);
3395 + assert!(prompt.starts_with(SYSTEM_PROMPT));
3396 + assert!(prompt.contains("project working directory"));
3397 + assert!(prompt.contains(&cwd.display().to_string()));
3398 + }
3399 +
3400 #[test]
3401 fn parse_slash_maps_init() {
3402 assert!(matches!(parse_slash("/init"), Some(SlashCommand::Init)));