Add the surface to the commit trailer's version

Issue #75 now asks for the surface next to the version, so the trailer reads "siGit Code v1.5.10-acp" from an editor and "-tui" from the terminal UI. `sigit run` gets "-headless", since it is a third way in. main records the surface with tools::set_surface before it builds any prompt, and commit_co_author_trailer() reads it. The system prompt can't be a finished const anymore, so it holds a placeholder that system_prompt_for_model fills in. The test that keeps the prompt and the safety net in step now checks the filled-in prompt. Surface::Tui is only built on Unix, so its dead-code lint is allowed on other targets, like the rest of the TUI path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013UytgR6hjZgxewqvZ6gtJ7

Claude committed Sep 28, 2026 at 13:21 UTC d7ea7fdaa1b154eab2bfa4e4636cc2fff5357289
6 files changed +113 -41
.agents/AGENTS.md
+2 -1
@@ -139,7 +139,8 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
139 `multi_edit`, `delete_file`, `run_command`, `write_todos`, `remember`. Add a tool in both the
140 spec list (`all_tools`) and the execute `match` (`execute_tool`). `run_command` also enforces
141 commit attribution: when a command creates a new commit that lacks the
142 - `Co-Authored-By: siGit Code` trailer (`COMMIT_CO_AUTHOR_TRAILER`), it amends the trailer in —
142 + `Co-Authored-By: siGit Code` trailer (`commit_co_author_trailer()`, which reads
143 + `siGit Code v<version>-<acp|tui|headless>` from the surface `main` sets via `set_surface`), it amends the trailer in —
144 unless the commit already exists on a remote, which is never rewritten. Every child process it
145 spawns (`spawn_shell`, the `git` helpers, and `hooks.rs`) sets `stdin` to null and never
146 inherits it: in ACP mode sigit's stdin is the JSON-RPC pipe from the editor, so a command that
CHANGELOG.md
+7 -4
@@ -17,10 +17,13 @@
17
18 ### Changed
19
20 -- **The commit trailer names the siGit Code version.** Commits siGit Code
21 - makes now end with `Co-Authored-By: siGit Code v<version> <noreply@sigit.si>`
22 - instead of the bare name. The model changes from session to session; the
23 - version tells you which build of the agent wrote the commit. GitHub still
20 +- **The commit trailer names the siGit Code version and surface.** Commits
21 + siGit Code makes now end with
22 + `Co-Authored-By: siGit Code v<version>-<surface> <noreply@sigit.si>` instead
23 + of the bare name, where the surface is `acp` (an editor), `tui` (the
24 + terminal UI), or `headless` (`sigit run`). The model changes from session to
25 + session; the version and surface tell you which build of the agent wrote the
26 + commit and how it was driven. GitHub still
27 credits the co-author, since it matches on the address. A commit that
28 already carries a siGit Code trailer from an older version is left alone.
29
src/chat.rs
+2 -3
@@ -2765,7 +2765,7 @@ mod tui {
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();
2768 + let base = crate::system_prompt_for_model(model.tool_calling);
2769 match std::env::current_dir()
2770 .ok()
2771 .and_then(|cwd| crate::instructions::load_project_instructions(&cwd))
@@ -2968,8 +2968,7 @@ mod tui {
2968 app.close_model_picker();
2969 match crate::provider::cloud_tier_provider(&tier) {
2970 Some(provider) => {
2971 - let system_prompt =
2972 - crate::system_prompt_for_model(true).to_string();
2971 + let system_prompt = crate::system_prompt_for_model(true);
2972 app.backend = Arc::new(OpenAiBackend::new(
2973 provider.base_url,
2974 provider.api_key,
src/headless.rs
+1 -1
@@ -269,7 +269,7 @@ pub async fn run(config: HeadlessConfig) -> i32 {
269 // Same always-on project context the other surfaces inject: cwd guidance
270 // plus AGENTS.md / CLAUDE.md instruction files.
271 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
272 - let mut system_prompt = crate::system_prompt_for_model(true).to_string();
272 + let mut system_prompt = crate::system_prompt_for_model(true);
273 system_prompt.push_str("\n\n");
274 system_prompt.push_str(&crate::session_context_message(
275 &cwd,
src/main.rs
+29 -16
@@ -99,6 +99,10 @@ use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
99 #[cfg(unix)]
100 use std::os::unix::io::{AsRawFd, FromRawFd};
101
102 +/// Stands in for the commit trailer inside [`SYSTEM_PROMPT`]; see
103 +/// [`system_prompt_for_model`].
104 +const CO_AUTHOR_TRAILER_PLACEHOLDER: &str = "{commit_co_author_trailer}";
105 +
106 const SYSTEM_PROMPT: &str = concat!(
107 "\
108 Your name is siGit — lowercase 's', uppercase 'G', no spaces. \
@@ -157,11 +161,11 @@ Git operations — always use run_command:
161 directory, permissions), and retry
162 - when you create a commit, always end the commit message with a blank line and \
163 then this trailer on its own line: ",
160 - // Same text as tools::COMMIT_CO_AUTHOR_TRAILER; concat! takes only
161 - // literals, so it is spelled out here and a test keeps the two in step.
162 - "Co-Authored-By: siGit Code v",
163 - env!("CARGO_PKG_VERSION"),
164 - " <noreply@sigit.si> \
164 + // CO_AUTHOR_TRAILER_PLACEHOLDER (concat! takes only literals), filled in
165 + // by `system_prompt_for_model` with tools::commit_co_author_trailer(),
166 + // which depends on the surface picked at startup.
167 + "{commit_co_author_trailer}",
168 + " \
169 — GitHub reads that exact format and credits siGit as co-author. If a commit \
170 lands without it, siGit Code amends the trailer in automatically and the tool \
171 output says so; do not amend again yourself.
@@ -246,11 +250,14 @@ Answer any question the user asks — programming, general knowledge, or casual
250 When debugging, address the root cause, not the symptom. \
251 Be direct and brief.";
252
249 -pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str {
253 +pub(crate) fn system_prompt_for_model(tool_calling: bool) -> String {
254 if tool_calling {
251 - SYSTEM_PROMPT
255 + SYSTEM_PROMPT.replace(
256 + CO_AUTHOR_TRAILER_PLACEHOLDER,
257 + &tools::commit_co_author_trailer(),
258 + )
259 } else {
253 - SIMPLE_SYSTEM_PROMPT
260 + SIMPLE_SYSTEM_PROMPT.to_string()
261 }
262 }
263
@@ -902,7 +909,7 @@ impl SiGitAgent {
909 };
910
911 let loader_engine = Arc::clone(&self.engine);
905 - let loader_system_prompt = system_prompt_for_model(tool_calling).to_string();
912 + let loader_system_prompt = system_prompt_for_model(tool_calling);
913 let model_ready = Arc::clone(&self.model_ready);
914 let model_load_error = Arc::clone(&self.model_load_error);
915
@@ -1333,7 +1340,7 @@ impl SiGitAgent {
1340 let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
1341 let loader_engine = Arc::clone(&self.engine);
1342 let loader_config = new_config.clone();
1336 - let loader_system_prompt = system_prompt_for_model(new_tool_calling).to_string();
1343 + let loader_system_prompt = system_prompt_for_model(new_tool_calling);
1344 let loader_sampling = sampling;
1345
1346 std::thread::spawn(move || {
@@ -2653,7 +2660,7 @@ impl SiGitAgent {
2660 &cwd,
2661 &workspace::additional_roots(),
2662 )),
2656 - None => system_prompt_for_model(true).to_string(),
2663 + None => system_prompt_for_model(true),
2664 };
2665 let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
2666 cfg.base_url,
@@ -4102,7 +4109,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
4109 provider.base_url,
4110 provider.api_key,
4111 provider.model,
4105 - Some(with_instructions(SYSTEM_PROMPT.to_string())),
4112 + Some(with_instructions(system_prompt_for_model(true))),
4113 )) as Arc<dyn InferenceBackend>;
4114 (backend, label)
4115 }
@@ -4132,7 +4139,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
4139 provider.base_url,
4140 provider.api_key,
4141 provider.model,
4135 - Some(with_instructions(SYSTEM_PROMPT.to_string())),
4142 + Some(with_instructions(system_prompt_for_model(true))),
4143 )) as Arc<dyn InferenceBackend>;
4144 (backend, label)
4145 }
@@ -4285,7 +4292,7 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
4292 cfg.base_url,
4293 cfg.api_key,
4294 cfg.model,
4288 - Some(system_prompt_for_model(true).to_string()),
4295 + Some(system_prompt_for_model(true)),
4296 ));
4297 *state.backend.lock().await = override_backend;
4298 } else {
@@ -4545,6 +4552,7 @@ async fn main() -> anyhow::Result<()> {
4552 std::process::exit(2);
4553 }
4554 Ok(true) => {
4555 + tools::set_surface(tools::Surface::Acp);
4556 init_logging(false);
4557 setup::setup_shared_model_cache();
4558 mcp::init().await;
@@ -4559,6 +4567,7 @@ async fn main() -> anyhow::Result<()> {
4567 match headless::parse_args(&cli_args) {
4568 Ok(None) => {}
4569 Ok(Some(config)) => {
4570 + tools::set_surface(tools::Surface::Headless);
4571 // Logs to stderr; stdout stays clean for the assistant's answer.
4572 init_logging(false);
4573 // Enter --cwd before anything loads, so instruction files and
@@ -4607,6 +4616,7 @@ async fn main() -> anyhow::Result<()> {
4616 // must redirect before any library code touches stdout
4617 #[cfg(unix)]
4618 {
4619 + tools::set_surface(tools::Surface::Tui);
4620 let (tty, cleanup_tty) = redirect_output_to_log()?;
4621 init_logging(true);
4622 setup::setup_shared_model_cache();
@@ -4622,6 +4632,7 @@ async fn main() -> anyhow::Result<()> {
4632 } else {
4633 // ACP mode: keep stdout untouched for protocol JSON only.
4634 // Logs already go to stderr via `init_logging(false)`.
4635 + tools::set_surface(tools::Surface::Acp);
4636 init_logging(false);
4637 setup::setup_shared_model_cache();
4638 // Best-effort MCP discovery (incl. the official server) before serving.
@@ -5174,10 +5185,12 @@ mod tests {
5185 // The prompt instructs the model with the exact trailer that
5186 // `tools::ensure_commit_co_author` enforces; if the two drift apart the
5187 // safety net would re-amend commits the model already attributed.
5188 + let prompt = system_prompt_for_model(true);
5189 assert!(
5178 - SYSTEM_PROMPT.contains(tools::COMMIT_CO_AUTHOR_TRAILER),
5179 - "SYSTEM_PROMPT must quote tools::COMMIT_CO_AUTHOR_TRAILER verbatim"
5190 + prompt.contains(&tools::commit_co_author_trailer()),
5191 + "the system prompt must quote tools::commit_co_author_trailer() verbatim"
5192 );
5193 + assert!(!prompt.contains(CO_AUTHOR_TRAILER_PLACEHOLDER));
5194 }
5195
5196 #[test]
src/tools.rs
+72 -16
@@ -2014,11 +2014,53 @@ fn kill_shell_tree(child: &mut std::process::Child) {
2014 /// session to the next, but the version says which siGit Code made it. The
2015 /// system prompt asks the model to add this itself; [`ensure_commit_co_author`]
2016 /// is the safety net when it forgets.
2017 -pub const COMMIT_CO_AUTHOR_TRAILER: &str = concat!(
2018 - "Co-Authored-By: siGit Code v",
2019 - env!("CARGO_PKG_VERSION"),
2020 - " <noreply@sigit.si>"
2021 -);
2017 +///
2018 +/// The version carries a suffix naming the surface the process serves
2019 +/// (`v1.5.10-acp`, `v1.5.10-tui`), set once at startup by [`set_surface`], so
2020 +/// a commit also says whether it came from the editor or the terminal.
2021 +pub fn commit_co_author_trailer() -> String {
2022 + co_author_trailer_for(SURFACE.get().copied())
2023 +}
2024 +
2025 +fn co_author_trailer_for(surface: Option<Surface>) -> String {
2026 + let suffix = surface.map_or(String::new(), |surface| format!("-{}", surface.tag()));
2027 + format!(
2028 + "Co-Authored-By: siGit Code v{}{suffix} <noreply@sigit.si>",
2029 + env!("CARGO_PKG_VERSION")
2030 + )
2031 +}
2032 +
2033 +/// Which way this process is exposed. One binary serves all of them, chosen in
2034 +/// `main` before any prompt is built.
2035 +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2036 +pub enum Surface {
2037 + /// Agent Client Protocol over stdio (editors).
2038 + Acp,
2039 + /// The interactive terminal UI.
2040 + // Unix-only, like the TUI itself.
2041 + #[cfg_attr(not(unix), allow(dead_code))]
2042 + Tui,
2043 + /// `sigit run` / `sigit -p`.
2044 + Headless,
2045 +}
2046 +
2047 +impl Surface {
2048 + fn tag(self) -> &'static str {
2049 + match self {
2050 + Surface::Acp => "acp",
2051 + Surface::Tui => "tui",
2052 + Surface::Headless => "headless",
2053 + }
2054 + }
2055 +}
2056 +
2057 +static SURFACE: OnceLock<Surface> = OnceLock::new();
2058 +
2059 +/// Record the surface this process serves. The first call wins; `main` makes
2060 +/// it once, before anything builds a system prompt or runs a tool.
2061 +pub fn set_surface(surface: Surface) {
2062 + let _ = SURFACE.set(surface);
2063 +}
2064
2065 /// Run `git <args>` in `cwd`, returning trimmed stdout on success.
2066 ///
@@ -2061,9 +2103,10 @@ fn ensure_commit_co_author(cwd: &Path) -> Option<String> {
2103 Some(remotes) if remotes.is_empty() => {}
2104 _ => return None,
2105 }
2106 + let trailer = commit_co_author_trailer();
2107 let amend = Command::new("git")
2108 .args(["commit", "--amend", "--no-edit", "--trailer"])
2066 - .arg(COMMIT_CO_AUTHOR_TRAILER)
2109 + .arg(&trailer)
2110 .stdin(std::process::Stdio::null())
2111 .current_dir(cwd)
2112 .output()
@@ -2075,7 +2118,7 @@ fn ensure_commit_co_author(cwd: &Path) -> Option<String> {
2118 );
2119 Some(format!(
2120 "[siGit Code] The new commit was amended to append the co-author trailer \
2078 - \"{COMMIT_CO_AUTHOR_TRAILER}\" (its hash changed)."
2121 + \"{trailer}\" (its hash changed)."
2122 ))
2123 } else {
2124 log::warn!(
@@ -3281,12 +3324,13 @@ mod tests {
3324 assert!(result.contains("co-author trailer"), "got: {result}");
3325
3326 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
3327 + let trailer = commit_co_author_trailer();
3328 assert!(
3285 - message.ends_with(COMMIT_CO_AUTHOR_TRAILER),
3329 + message.ends_with(&trailer),
3330 "trailer must be the last line: {message:?}"
3331 );
3332 assert!(
3289 - message.contains(&format!("\n\n{COMMIT_CO_AUTHOR_TRAILER}")),
3333 + message.contains(&format!("\n\n{trailer}")),
3334 "trailer needs a blank line before it for GitHub to detect it: {message:?}"
3335 );
3336 let _ = fs::remove_dir_all(&dir);
@@ -3308,7 +3352,7 @@ mod tests {
3352 "commit",
3353 "-q",
3354 "-m",
3311 - &format!("Update file\n\n{COMMIT_CO_AUTHOR_TRAILER}"),
3355 + &format!("Update file\n\n{}", commit_co_author_trailer()),
3356 ],
3357 );
3358 let args = serde_json::json!({
@@ -3368,13 +3412,25 @@ mod tests {
3412 }
3413
3414 #[test]
3371 - fn co_author_trailer_names_the_harness_version() {
3415 + fn co_author_trailer_names_the_harness_version_and_surface() {
3416 + let version = env!("CARGO_PKG_VERSION");
3417 + assert_eq!(
3418 + co_author_trailer_for(Some(Surface::Acp)),
3419 + format!("Co-Authored-By: siGit Code v{version}-acp <noreply@sigit.si>")
3420 + );
3421 + assert_eq!(
3422 + co_author_trailer_for(Some(Surface::Tui)),
3423 + format!("Co-Authored-By: siGit Code v{version}-tui <noreply@sigit.si>")
3424 + );
3425 + assert_eq!(
3426 + co_author_trailer_for(Some(Surface::Headless)),
3427 + format!("Co-Authored-By: siGit Code v{version}-headless <noreply@sigit.si>")
3428 + );
3429 + // Before `main` picks a surface (tests, or a tool run outside a
3430 + // session) the version stands alone rather than guessing.
3431 assert_eq!(
3373 - COMMIT_CO_AUTHOR_TRAILER,
3374 - format!(
3375 - "Co-Authored-By: siGit Code v{} <noreply@sigit.si>",
3376 - env!("CARGO_PKG_VERSION")
3377 - )
3432 + co_author_trailer_for(None),
3433 + format!("Co-Authored-By: siGit Code v{version} <noreply@sigit.si>")
3434 );
3435 }
3436