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