Tell the terminal UI's model where the project is

The editor (ACP) and headless paths put the working directory in the system prompt, but the terminal UI only added the project's instruction files. Nothing in its prompt said which directory the project was in. On /init, a small on-device model filled that gap by making up /path/to/repo/AGENTS.md. The write failed, and the model told the user the repository was read-only. All three places the terminal UI builds a prompt (startup, loading an on-device model, switching to a cloud tier) now go through with_launch_context. It appends the same session_context_message the ACP sessions get, built from the launch directory. The cloud tier switch had been dropping the instruction files as well, so that is fixed too. Fixes #40

Seto Elkahfi committed Sep 26, 2026 at 20:33 UTC f6a457c44ecd673296f727bd578ef1e37d13e4fc
3 files changed +43 -26
src/chat.rs
+8 -15
@@ -2761,19 +2761,11 @@ mod tui {
2761 };
2762
2763 // own thread + runtime so block_in_place doesn't starve the TUI loop.
2764 - // Fold in project instruction files (AGENTS.md / CLAUDE.md) for the launch
2765 - // directory so the on-device model gets the same always-on context the
2766 - // cloud and ACP paths get.
2767 - let system_prompt = {
2768 - let base = crate::system_prompt_for_model(model.tool_calling).to_string();
2769 - match std::env::current_dir()
2770 - .ok()
2771 - .and_then(|cwd| crate::instructions::load_project_instructions(&cwd))
2772 - {
2773 - Some(extra) => format!("{base}\n\n{extra}"),
2774 - None => base,
2775 - }
2776 - };
2764 + // Fold in the launch directory and its project instruction files
2765 + // (AGENTS.md / CLAUDE.md) so the on-device model gets the same always-on
2766 + // context the cloud and ACP paths get.
2767 + let system_prompt =
2768 + crate::with_launch_context(crate::system_prompt_for_model(model.tool_calling));
2769 let engine_handle = Arc::clone(&engine);
2770 let tool_calling = model.tool_calling;
2771 std::thread::spawn(move || {
@@ -2968,8 +2960,9 @@ mod tui {
2960 app.close_model_picker();
2961 match crate::provider::cloud_tier_provider(&tier) {
2962 Some(provider) => {
2971 - let system_prompt =
2972 - crate::system_prompt_for_model(true).to_string();
2963 + let system_prompt = crate::with_launch_context(
2964 + crate::system_prompt_for_model(true),
2965 + );
2966 app.backend = Arc::new(OpenAiBackend::new(
2967 provider.base_url,
2968 provider.api_key,
src/instructions.rs
+3 -1
@@ -61,7 +61,9 @@ const MAX_TOTAL_BYTES: usize = 64 * 1024;
61
62 /// Load and combine project instruction files for `cwd`, returning a single
63 /// block ready to append to the session's system context, or `None` if none are
64 -/// found.
64 +/// found. The single-root shorthand the tests use; every live caller goes
65 +/// through [`load_workspace_instructions`].
66 +#[cfg(test)]
67 pub fn load_project_instructions(cwd: &Path) -> Option<String> {
68 load_workspace_instructions(cwd, &[])
69 }
src/main.rs
+32 -10
@@ -610,6 +610,22 @@ fn remote_system_prompt(session_context: &str) -> String {
610 format!("{}\n\n{session_context}", system_prompt_for_model(true))
611 }
612
613 +/// `base` followed by the session context for the directory sigit was launched
614 +/// in. The terminal UI has no ACP session to carry a cwd, so it uses this for
615 +/// every system prompt it builds. Without the working directory the model has no
616 +/// idea where the project is, and a small on-device model will happily invent
617 +/// one (`/path/to/repo/AGENTS.md` on `/init`).
618 +#[cfg(unix)]
619 +pub(crate) fn with_launch_context(base: &str) -> String {
620 + match std::env::current_dir() {
621 + Ok(cwd) => format!(
622 + "{base}\n\n{}",
623 + session_context_message(&cwd, &workspace::additional_roots())
624 + ),
625 + Err(_) => base.to_string(),
626 + }
627 +}
628 +
629 fn session_context_message(cwd: &std::path::Path, additional_roots: &[PathBuf]) -> String {
630 let mut message = if additional_roots.is_empty() {
631 format!(
@@ -4066,16 +4082,10 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
4082 // channel so the loading-phase plumbing in `chat::run_with` is unchanged.
4083 let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
4084
4069 - // Project instruction files (AGENTS.md / CLAUDE.md) for the launch directory,
4070 - // injected into the system prompt so the TUI shares the same always-on
4071 - // project context the ACP sessions get.
4072 - let project_instructions = std::env::current_dir()
4073 - .ok()
4074 - .and_then(|cwd| instructions::load_project_instructions(&cwd));
4075 - let with_instructions = |base: String| match &project_instructions {
4076 - Some(extra) => format!("{base}\n\n{extra}"),
4077 - None => base,
4078 - };
4085 + // The launch directory and its project instruction files, injected into the
4086 + // system prompt so the TUI shares the same always-on project context the
4087 + // ACP sessions get.
4088 + let with_instructions = |base: String| with_launch_context(&base);
4089
4090 // Pick the inference backend: a configured provider if present, else on-device.
4091 let (inference_backend, startup_model_name): (Arc<dyn InferenceBackend>, String) =
@@ -4906,6 +4916,18 @@ mod tests {
4916 assert!(multi.contains("absolute paths"));
4917 }
4918
4919 + #[cfg(unix)]
4920 + #[test]
4921 + fn launch_context_tells_the_tui_model_where_the_project_is() {
4922 + // The TUI has no ACP session carrying a cwd; without this the model
4923 + // guesses paths like `/path/to/repo/AGENTS.md`. Only assert on the
4924 + // guidance, not the path: the cwd is process-global and other tests
4925 + // move it briefly.
4926 + let prompt = with_launch_context("BASE PROMPT");
4927 + assert!(prompt.starts_with("BASE PROMPT\n\n"));
4928 + assert!(prompt.contains("absolute paths"));
4929 + }
4930 +
4931 #[test]
4932 fn history_replay_redraws_the_conversation_for_the_client() {
4933 // A fixed path, not `std::env::current_dir()`: the cwd is process-global