diff --git a/Cargo.lock b/Cargo.lock index d762abc..96f1c9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1161,7 +1161,7 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "deck-core" -version = "0.26.0" +version = "0.26.2" dependencies = [ "base64 0.22.1", "deck-host", @@ -1193,7 +1193,7 @@ dependencies = [ [[package]] name = "deck-host" -version = "0.26.0" +version = "0.26.2" dependencies = [ "alacritty_terminal", "base64 0.22.1", @@ -1212,7 +1212,7 @@ dependencies = [ [[package]] name = "deck-relay" -version = "0.26.0" +version = "0.26.2" dependencies = [ "anyhow", "axum", @@ -1237,7 +1237,7 @@ dependencies = [ [[package]] name = "deck-remote" -version = "0.26.0" +version = "0.26.2" dependencies = [ "base64 0.22.1", "blake2 0.10.6", @@ -1254,7 +1254,7 @@ dependencies = [ [[package]] name = "deck-service" -version = "0.26.0" +version = "0.26.2" dependencies = [ "base64 0.22.1", "deck-core", @@ -1292,7 +1292,7 @@ dependencies = [ [[package]] name = "deck-term" -version = "0.26.0" +version = "0.26.2" dependencies = [ "alacritty_terminal", "arboard", @@ -1315,7 +1315,7 @@ dependencies = [ [[package]] name = "deck-tsp" -version = "0.26.0" +version = "0.26.2" dependencies = [ "base64 0.22.1", "log", @@ -3697,7 +3697,7 @@ dependencies = [ [[package]] name = "omp-deck" -version = "0.26.0" +version = "0.26.2" dependencies = [ "anyhow", "deck-core", diff --git a/Cargo.toml b/Cargo.toml index 92a2e93..d849c42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/deck-core", "crates/deck-host", "crates/deck-term", "crates/d [workspace.package] edition = "2024" -version = "0.26.0" +version = "0.26.2" license = "MIT OR Apache-2.0" publish = false description = "Desktop wrapper for the omp coding-agent TUI: session sidebar, tabs, embedded terminal backed by tmux or its own session host (Linux, Windows)" diff --git a/README.md b/README.md index 841b51e..276935f 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,9 @@ Sessions, chat, terminal and agents on your phone — see [Mobile companion](#mo Hovering the context meter shows omp's `/context` breakdown (system prompt, tools, skills, messages, free space, auto-compact buffer, per-tool schema sizes) for tabs running with live control. + Changing the model or thinking effort from the picker, the palette or the phone leaves a half-typed message in omp's + composer untouched (no "discard draft?" question) for tabs running with live control; only a switch that first needs + compaction still goes through omp's `/switch`. The model picker shows IQ, output tokens/sec, context size and token rates where available. Hover a model for output limits, cache-read pricing, input modalities, reasoning support and speed measurement details. Locally measured speed takes precedence over catalog estimates (`~`). diff --git a/crates/deck-core/assets/ompdeck-ctl.ts b/crates/deck-core/assets/ompdeck-ctl.ts index da68d3f..2bb01bd 100644 --- a/crates/deck-core/assets/ompdeck-ctl.ts +++ b/crates/deck-core/assets/ompdeck-ctl.ts @@ -375,6 +375,72 @@ export default async function ompdeckCtl(api: any) { return { ok: true, value: Array.isArray(now) ? now[0] ?? null : now ?? null }; } + // omp never forwards model_changed / thinking_level_changed to extensions: after a change, refresh the cached context + // breakdown (it is keyed by model) and push a snapshot ourselves. + const refreshNow = () => { try { recompute(); } catch {} try { push(snapshot()); } catch {} }; + // == TUI `/switch ` (builtin-modes.ts + selector-controller.ts switchSessionModel/#applySessionModel), minus the UI + // notice. Not gated on idle, like the slash command. The compact-first branch is the caller's: over context -> needs_compact. + // Both routes lean on omp internals: anything missing or unexpected answers "unsupported" before changing anything, + // so the gateway falls back to the typed /switch / /effort. + const UNSUPPORTED = { ok: false, error: "unsupported" }; + async function switchModel(cmd: any): Promise { + const sess = mainSession(); + const selector = typeof cmd.selector === "string" ? cmd.selector.trim() : ""; + if (!sess || !selector) return { ok: false, error: "no model selector" }; + let resolver: any; + try { resolver = await import("@oh-my-pi/pi-coding-agent/config/model-resolver"); } catch (e) { log("model-resolver import failed: " + e); return UNSUPPORTED; } + const fn = (o: any, k: string) => typeof o?.[k] === "function"; + if (!fn(resolver, "resolveCliModel") || !fn(resolver, "getModelMatchPreferences") || !fn(sess, "setModelTemporary") + || !fn(sess, "resolveTemporaryModelThinkingLevel") || !fn(sess, "getContextUsage") || !sess.modelRegistry || !sess.settings) { + log("switch_model: an omp internal is missing"); + return UNSUPPORTED; + } + let resolved: any; + try { + const scoped = (Array.isArray(sess.scopedModels) ? sess.scopedModels : []).map((s: any) => s.model ?? s).filter(Boolean); + resolved = resolver.resolveCliModel({ + cliModel: selector, modelRegistry: sess.modelRegistry, availableModels: scoped.length > 0 ? scoped : undefined, + settings: sess.settings, preferences: resolver.getModelMatchPreferences(sess.settings), + }); + } catch (e) { log("resolveCliModel failed: " + e); return UNSUPPORTED; } + if (!resolved || typeof resolved !== "object" || !("model" in resolved)) return UNSUPPORTED; + const model = resolved.model; + if (!model) return { ok: false, error: `Unknown model: ${selector}` }; + if (typeof model.provider !== "string" || typeof model.id !== "string") return UNSUPPORTED; + // Exactly selector-controller.ts switchSessionModel: `contextWindow ?? 0`, `getContextUsage()?.tokens ?? 0` (unknown + // usage, e.g. before the first reply, counts as 0); only a present non-number value is drift. + const window = model.contextWindow ?? 0; + if (typeof window !== "number" || Number.isNaN(window)) return UNSUPPORTED; + if (window > 0) { + let tokens: unknown; + try { tokens = sess.getContextUsage()?.tokens ?? 0; } catch (e) { log("getContextUsage failed: " + e); return UNSUPPORTED; } + if (typeof tokens !== "number" || Number.isNaN(tokens)) return UNSUPPORTED; + if (tokens > window) return { ok: false, error: "needs_compact" }; + } + let level: any; + try { level = resolved.thinkingLevel ?? sess.resolveTemporaryModelThinkingLevel(model); } catch (e) { log("thinking level lookup failed: " + e); return UNSUPPORTED; } + try { await sess.setModelTemporary(model, level); } catch (e: any) { return { ok: false, error: String(e?.message ?? e) }; } + refreshNow(); + const key = `${model.provider}/${model.id}`; + const note = `Session-only model: ${key}`; + return { ok: true, value: { model: key, thinking: typeof sess.thinkingLevel === "string" ? sess.thinkingLevel : null }, message: resolved.warning ? `${note} (${resolved.warning})` : note }; + } + // == TUI `/effort `: resolveThinkingArgument + session.setThinkingLevel (not persisted). + async function setEffort(cmd: any): Promise { + const sess = mainSession(); + if (!sess) return { ok: false, error: "no session" }; + let helper: any; + try { helper = await import("@oh-my-pi/pi-coding-agent/slash-commands/helpers/effort"); } catch (e) { log("effort import failed: " + e); return UNSUPPORTED; } + if (typeof helper?.resolveThinkingArgument !== "function" || typeof sess.setThinkingLevel !== "function") return UNSUPPORTED; + let r: any; + try { r = helper.resolveThinkingArgument(sess, String(cmd.level ?? "")); } catch (e) { log("resolveThinkingArgument failed: " + e); return UNSUPPORTED; } + if (r && typeof r === "object" && r.error) return { ok: false, error: String(r.error) }; + if (!r || typeof r !== "object" || typeof r.level !== "string") return UNSUPPORTED; + try { sess.setThinkingLevel(r.level); } catch (e: any) { return { ok: false, error: String(e?.message ?? e) }; } + refreshNow(); + return { ok: true, value: { thinking: typeof sess.thinkingLevel === "string" ? sess.thinkingLevel : null }, message: `Thinking set to ${r.level}.` }; + } + async function handle(cmd: any): Promise { if (cmd.t === "list") return { ok: true, snapshot: snapshot() }; if (cmd.t === "fork_at" || cmd.t === "navigate") { @@ -465,6 +531,8 @@ export default async function ompdeckCtl(api: any) { if (cmd.t === "reanswer_ask") return reanswerAsk(cmd); if (cmd.t === "advisor_config") return advisorConfig(cmd); if (cmd.t === "task_model_override") return taskModelOverride(cmd); + if (cmd.t === "switch_model") return switchModel(cmd); + if (cmd.t === "set_effort") return setEffort(cmd); if (cmd.t !== "kill" && cmd.t !== "revive" && cmd.t !== "chat") return { ok: false, error: "unknown cmd" }; // The tab may have moved to another root session (/new, /resume) since the command was composed. const want = rootFile(); diff --git a/crates/deck-core/src/changes/tests.rs b/crates/deck-core/src/changes/tests.rs index 67fab7e..7b69205 100644 --- a/crates/deck-core/src/changes/tests.rs +++ b/crates/deck-core/src/changes/tests.rs @@ -701,7 +701,8 @@ fn untracked_directory_diff_shares_one_byte_budget_across_files() { #[cfg(unix)] #[test] fn repository_fsmonitor_hook_never_runs() { - use std::os::unix::fs::PermissionsExt; + // The hook reaches its executed inode through `install` (another process), so no sibling + // test's fork can inherit a write fd to it (git's exec would fail with ETXTBSY). if Command::new("git").arg("--version").output().is_err() { return; @@ -712,12 +713,19 @@ fn repository_fsmonitor_hook_never_runs() { commit_all(d); let marker = d.join(".git/fsmonitor-ran"); let hook = d.join(".git/fsmonitor-hook"); + let stage = d.join(".git/fsmonitor-hook.src"); fs::write( - &hook, + &stage, format!("#!/bin/sh\necho ran >> '{}'\nexit 1\n", marker.display()), ) .expect("write hook"); - fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).expect("chmod"); + let installed = Command::new("install") + .args(["-m", "755"]) + .arg(&stage) + .arg(&hook) + .status() + .expect("install hook"); + assert!(installed.success()); git(d, &["config", "core.fsmonitor", &hook.to_string_lossy()]); write(d, "f.txt", "two\n"); diff --git a/crates/deck-core/src/ctl.rs b/crates/deck-core/src/ctl.rs index 83f74a8..5b1e777 100644 --- a/crates/deck-core/src/ctl.rs +++ b/crates/deck-core/src/ctl.rs @@ -476,6 +476,20 @@ pub enum CtlCommand { root: Option, model: Option, }, + /// omp's TUI `/switch ` on the main session, minus the UI notice (never gated on + /// idle, like the slash command): the session-only model change. Reply `value` + /// `{model: "provider/id", thinking}`, `message` `Session-only model: provider/id` (plus the + /// resolver's warning). `Unknown model: ` when nothing matches; error + /// `needs_compact` (nothing changed) when the transcript exceeds the target's context window. + SwitchModel { + selector: String, + }, + /// omp's TUI `/effort ` on the main session: reply `value` `{thinking}`, `message` + /// `Thinking set to .`; omp's own text (`Unknown thinking level: … Available: …`) on a + /// refused level. + SetEffort { + level: String, + }, /// Answer the pending UI request `id` ([`PendingUi::id`]) with `value`, the dialog /// function's own result: a label (`select`), `true`/`false` (`confirm`), text /// (`input`/`editor`), omp's `{kind:"submit",results:[…]}` (`ask`), or `"once"` / `"session"` @@ -877,6 +891,12 @@ fn encode(rid: u64, cmd: &CtlCommand) -> Vec { CtlCommand::TaskModelOverride { root, model } => { serde_json::json!({"rid": rid, "t": "task_model_override", "root": root, "model": model}) } + CtlCommand::SwitchModel { selector } => { + serde_json::json!({"rid": rid, "t": "switch_model", "selector": selector}) + } + CtlCommand::SetEffort { level } => { + serde_json::json!({"rid": rid, "t": "set_effort", "level": level}) + } CtlCommand::PinAccount { root, provider, @@ -1230,6 +1250,44 @@ impl Worker { mod tests { use super::*; + #[test] + fn switch_and_effort_commands_round_trip_and_encode() { + for cmd in [ + CtlCommand::SwitchModel { + selector: "@slow:high".into(), + }, + CtlCommand::SetEffort { + level: "low".into(), + }, + ] { + let back: CtlCommand = + serde_json::from_str(&serde_json::to_string(&cmd).unwrap()).unwrap(); + assert_eq!(back, cmd); + } + let line = encode( + 7, + &CtlCommand::SwitchModel { + selector: "a/b".into(), + }, + ); + let v: serde_json::Value = serde_json::from_slice(line.trim_ascii()).unwrap(); + assert_eq!( + (v["t"].as_str(), v["selector"].as_str(), v["rid"].as_u64()), + (Some("switch_model"), Some("a/b"), Some(7)) + ); + let line = encode( + 8, + &CtlCommand::SetEffort { + level: "off".into(), + }, + ); + let v: serde_json::Value = serde_json::from_slice(line.trim_ascii()).unwrap(); + assert_eq!( + (v["t"].as_str(), v["level"].as_str()), + (Some("set_effort"), Some("off")) + ); + } + #[test] fn navigate_payloads_without_a_summary_still_decode() { let old: CtlCommand = diff --git a/crates/deck-core/src/status.rs b/crates/deck-core/src/status.rs index 7704985..c343788 100644 --- a/crates/deck-core/src/status.rs +++ b/crates/deck-core/src/status.rs @@ -260,6 +260,8 @@ pub struct SessionStatsReader { file_id: Option, parsed_to: u64, stats: SessionStats, + /// A `model_change` was applied: from then on only further changes set `stats.model`. + model_from_change: bool, titles: Titles, } @@ -270,6 +272,7 @@ impl SessionStatsReader { file_id: None, parsed_to: 0, stats: SessionStats::default(), + model_from_change: false, titles: Titles::default(), } } @@ -296,6 +299,7 @@ impl SessionStatsReader { if self.file_id != Some(id) || len < self.parsed_to { changed = self.stats != SessionStats::default(); self.stats = SessionStats::default(); + self.model_from_change = false; self.titles = Titles::default(); self.parsed_to = 0; self.file_id = Some(id); @@ -391,8 +395,13 @@ impl SessionStatsReader { b"model_usage" => { parse::(line).map(|e| self.add_usage(e.usage.as_ref(), true)) } - b"model_change" => parse::(line) - .map(|e| set(&mut self.stats.model, e.model.filter(|m| !m.is_empty()))), + b"model_change" => parse::(line).map(|e| { + let model = e.model.filter(|m| !m.is_empty()); + // The newest change is the session's model, whatever a later assistant record says (a switch mid-turn + // is followed by the old model's reply). + self.model_from_change |= model.is_some(); + set(&mut self.stats.model, model) + }), b"thinking_level_change" => parse::(line).map(|e| { let level = Some(e.level.unwrap_or_else(|| "off".to_owned())); set(&mut self.stats.thinking, level) @@ -460,6 +469,7 @@ impl SessionStatsReader { if let (Some(p), Some(m)) = (&msg.provider, &msg.model) && !p.is_empty() && !m.is_empty() + && !self.model_from_change { changed |= set(&mut self.stats.model, Some(format!("{p}/{m}"))); } @@ -1477,9 +1487,10 @@ mod tests { assert!(r.refresh().unwrap(), "model switch of the aborted entry"); assert!(!r.stats().last_turn_failed); assert_eq!(r.stats().context_tokens, ctx); + // The fixture has a model_change: an assistant record of another model does not move the selected model. assert_eq!( r.stats().model.as_deref(), - Some("openai-codex/gpt-5.6-luna") + Some("commandcode/deepseek/deepseek-v4.1-flash") ); } @@ -1532,6 +1543,54 @@ mod tests { assert_eq!(r.stats().thinking.as_deref(), Some("off")); } + #[test] + fn the_newest_model_change_wins_over_an_assistant_of_the_old_model() { + let change = |m: &str| { + format!( + r#"{{"type":"model_change","id":"c","parentId":null,"timestamp":"t","model":"{m}","role":"temporary"}}"# + ) + }; + let reply = |m: &str| assistant_line(m, 10, 2, 0.1, "stop", "ok"); + let model_of = |lines: &[String]| { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("s.jsonl"); + write_lines(&p, &lines.iter().map(String::as_str).collect::>()); + let mut r = SessionStatsReader::new(p); + r.refresh().unwrap(); + r.stats().model.clone() + }; + // A change after the last reply. + assert_eq!( + model_of(&[reply("a"), change("p/b")]).as_deref(), + Some("p/b") + ); + // A switch mid-turn: the turn's reply is still the old model's, written after the change. + assert_eq!( + model_of(&[change("p/b"), reply("a")]).as_deref(), + Some("p/b") + ); + // Older files without any model_change: the assistant tells. + assert_eq!(model_of(&[reply("a")]).as_deref(), Some("p/a")); + } + + #[test] + fn a_rewind_forgets_that_a_model_change_was_seen() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("s.jsonl"); + let change = r#"{"type":"model_change","id":"c","parentId":null,"timestamp":"t","model":"p/b","role":"temporary"}"#; + write_lines( + &p, + &[change, &assistant_line("a", 10, 2, 0.1, "stop", "ok")], + ); + let mut r = SessionStatsReader::new(p.clone()); + r.refresh().unwrap(); + assert_eq!(r.stats().model.as_deref(), Some("p/b")); + // Same inode, shorter: only a reply without any change remains. + write_lines(&p, &[&assistant_line("a", 1, 1, 0.1, "stop", "x")]); + r.refresh().unwrap(); + assert_eq!(r.stats().model.as_deref(), Some("p/a")); + } + #[test] fn title_slot_is_reread_in_place_and_beats_entries() { let d = tempfile::tempdir().unwrap(); @@ -1613,7 +1672,11 @@ mod tests { assert_eq!(s.output_tokens, base.output_tokens + 7); assert_eq!(s.last_assistant_text.as_deref(), Some("héllo → wörld ✓")); assert_eq!(s.context_tokens, Some(100)); - assert_eq!(s.model.as_deref(), Some("p/p/new")); + assert_eq!( + s.model.as_deref(), + Some("commandcode/deepseek/deepseek-v4.1-flash"), + "the fixture's model_change stays the session's model" + ); } #[test] diff --git a/crates/deck-core/tests/ctl_tree_live.rs b/crates/deck-core/tests/ctl_tree_live.rs index ee0017a..f85d083 100644 --- a/crates/deck-core/tests/ctl_tree_live.rs +++ b/crates/deck-core/tests/ctl_tree_live.rs @@ -78,11 +78,12 @@ fn navigate_summarises_the_branch_left_and_redraws_the_tab() { let rows = live.entries(); let (assistants, users) = (message_ids(&rows, "assistant"), message_ids(&rows, "user")); assert_eq!((assistants.len(), users.len()), (2, 2)); - let tab = live.peek().to_string(); - assert!( - tab.contains("Reply to two"), - "the tab shows the second answer before the move" - ); + wait_for("the tab to show the second answer", TURN, || { + live.peek() + .to_string() + .contains("Reply to two") + .then_some(()) + }); // Plain move: no summary, the tab is redrawn by omp's own command context. let plain = nav(&live, &assistants[0], BranchSummary::None); @@ -477,3 +478,211 @@ fn reanswering_an_ask_opens_omps_dialog_and_resumes_the_turn() { .unwrap(); assert!(last["message"]["content"].to_string().contains("alpha")); } + +/// Two more models next to `fake-1`: a thinking-capable one and one with a tiny context window. +fn two_more_models(live: &Live) { + let yml = live.model.models_yml().replace( + " input: [text, image]\n", + " input: [text, image]\n - id: fake-think\n name: Fake Think\n reasoning: true\n \ + contextWindow: 200000\n maxTokens: 4096\n input: [text]\n - id: fake-small\n \ + name: Fake Small\n contextWindow: 8\n maxTokens: 4\n input: [text]\n", + ); + std::fs::write(live.agent.join("models.yml"), yml).unwrap(); + let config = live.agent.join("config.yml"); + let text = std::fs::read_to_string(&config).unwrap(); + std::fs::write(config, text + " slow: fake/fake-think\n").unwrap(); +} + +/// `(role, model)` of every `model_change` and the level of every `thinking_level_change`. +fn model_and_thinking_entries(rows: &[Value]) -> (Vec<(String, String)>, Vec) { + let models = rows + .iter() + .filter(|e| e["type"] == "model_change") + .map(|e| { + ( + e["role"].as_str().unwrap_or("").to_owned(), + e["model"].as_str().unwrap_or("").to_owned(), + ) + }) + .collect(); + let levels = rows + .iter() + .filter(|e| e["type"] == "thinking_level_change") + .map(|e| e["thinkingLevel"].as_str().unwrap_or("").to_owned()) + .collect(); + (models, levels) +} + +fn snapshot_model(live: &Live) -> (String, String) { + let m = live + .client() + .latest() + .and_then(|s| s.model.clone()) + .expect("a model in the snapshot"); + (m.selector, m.thinking) +} + +/// Before the first reply omp may report no context usage; its TUI `/switch` counts that as 0 tokens +/// (`getContextUsage()?.tokens ?? 0`), so a fresh session switches directly instead of falling back. +#[test] +fn switch_model_before_the_first_reply_switches_directly() { + let Some(live) = Live::start("sw0", script, two_more_models, |_| None) else { + eprintln!("skipping: OMPDECK_TEST_OMP is not set"); + return; + }; + let r = live.ctl( + CtlCommand::SwitchModel { + selector: "fake/fake-think".to_owned(), + }, + TURN, + ); + assert!(r.ok, "{r:?}"); + wait_for("the snapshot to show the model", TURN, || { + (snapshot_model(&live).0 == "fake/fake-think").then_some(()) + }); +} + +#[test] +fn switch_model_and_set_effort_match_the_typed_commands() { + let Some(live) = Live::start("sw", script, two_more_models, |_| None) else { + eprintln!("skipping: OMPDECK_TEST_OMP is not set"); + return; + }; + turn(&live, "one"); + // An omp upgrade that breaks a route internal answers "unsupported" (the gateway then falls back to the typed + // commands): a current omp must never do so, in any case below. + let supported = |r: deck_core::ctl::CtlReply| { + assert_ne!( + r.error.as_deref(), + Some("unsupported"), + "omp's internals drifted: {r:?}" + ); + r + }; + let switch = |sel: &str| { + supported(live.ctl( + CtlCommand::SwitchModel { + selector: sel.to_owned(), + }, + TURN, + )) + }; + let effort = |lvl: &str| { + supported(live.ctl( + CtlCommand::SetEffort { + level: lvl.to_owned(), + }, + TURN, + )) + }; + let in_snapshot = |want: &str| { + wait_for("the snapshot to show the model", TURN, || { + (snapshot_model(&live).0 == want).then_some(()) + }); + }; + + // By provider/id, by @role, with a :level suffix. + let by_id = switch("fake/fake-think"); + assert!(by_id.ok, "{by_id:?}"); + assert_eq!(by_id.value.as_ref().unwrap()["model"], "fake/fake-think"); + assert_eq!( + by_id.message.as_deref(), + Some("Session-only model: fake/fake-think") + ); + in_snapshot("fake/fake-think"); + let back = switch("fake/fake-1"); + assert!(back.ok, "{back:?}"); + in_snapshot("fake/fake-1"); + let by_role = switch("@slow"); + assert!(by_role.ok, "{by_role:?}"); + in_snapshot("fake/fake-think"); + let with_level = switch("fake/fake-think:high"); + assert!(with_level.ok, "{with_level:?}"); + assert_eq!( + snapshot_model(&live), + ("fake/fake-think".to_owned(), "high".to_owned()) + ); + let (models, levels) = model_and_thinking_entries(&live.entries()); + assert!( + models.len() >= 4 && models.iter().skip(1).all(|(role, _)| role == "temporary"), + "{models:?}" + ); + assert_eq!(levels.last().map(String::as_str), Some("high")); + + // Unknown selector. + let unknown = switch("nope/none"); + assert_eq!( + (unknown.ok, unknown.error.as_deref()), + (false, Some("Unknown model: nope/none")) + ); + + // Effort: a valid level is written, a bad one is omp's text. + let ok = effort("low"); + assert!(ok.ok, "{ok:?}"); + assert_eq!(ok.message.as_deref(), Some("Thinking set to low.")); + assert_eq!(snapshot_model(&live).1, "low"); + assert_eq!( + model_and_thinking_entries(&live.entries()) + .1 + .last() + .map(String::as_str), + Some("low") + ); + let bad = effort("bogus"); + let err = bad.error.unwrap_or_default(); + assert!( + !bad.ok && err.starts_with("Unknown thinking level: bogus. Available:"), + "{err}" + ); + + // Identity with the typed commands: the same entries (roles, models, levels). + let before = model_and_thinking_entries(&live.entries()); + live.send("/switch fake/fake-1"); + wait_for("the typed /switch", TURN, || { + (model_and_thinking_entries(&live.entries()).0.len() > before.0.len()).then_some(()) + }); + let typed = model_and_thinking_entries(&live.entries()); + assert_eq!( + typed.0.last().unwrap(), + &("temporary".to_owned(), "fake/fake-1".to_owned()) + ); + live.send("/switch fake/fake-think"); + wait_for("the typed /switch back", TURN, || { + (snapshot_model(&live).0 == "fake/fake-think").then_some(()) + }); + let n = model_and_thinking_entries(&live.entries()).1.len(); + live.send("/effort medium"); + wait_for("the typed /effort", TURN, || { + (model_and_thinking_entries(&live.entries()).1.len() > n).then_some(()) + }); + let typed_levels = model_and_thinking_entries(&live.entries()).1; + assert_eq!(typed_levels.last().map(String::as_str), Some("medium")); + let ctl_medium = effort("high"); + assert!(ctl_medium.ok); + let after = model_and_thinking_entries(&live.entries()).1; + assert_eq!( + after.last().map(String::as_str), + Some("high"), + "same entry kind as the typed /effort" + ); + + // Over context: the target's window is smaller than the transcript -> nothing changes. + turn(&live, "fill the window"); + let before = ( + snapshot_model(&live), + model_and_thinking_entries(&live.entries()), + ); + let over = switch("fake/fake-small"); + assert_eq!( + (over.ok, over.error.as_deref()), + (false, Some("needs_compact")), + "{over:?}" + ); + assert_eq!( + ( + snapshot_model(&live), + model_and_thinking_entries(&live.entries()) + ), + before + ); +} diff --git a/crates/deck-core/tests/omp.rs b/crates/deck-core/tests/omp.rs index f9af2ae..132c8cd 100644 --- a/crates/deck-core/tests/omp.rs +++ b/crates/deck-core/tests/omp.rs @@ -30,16 +30,12 @@ fn sandboxed(agent: &Path) -> Option { #[cfg(unix)] #[test] fn an_rpc_omp_that_closes_its_pipes_but_stays_alive_is_killed_after_a_bounded_wait() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempfile::tempdir().unwrap(); let fake = tmp.path().join("omp"); - fs::write( + install_exec( &fake, "#!/bin/sh\necho boom >&2\nexec 0<&- 1>&-\nexec sleep 120\n", - ) - .unwrap(); - fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap(); + ); let started = std::time::Instant::now(); let err = Omp::at(&fake) .copy_session(&tmp.path().join("src.jsonl"), tmp.path()) diff --git a/crates/deck-core/tests/worktree.rs b/crates/deck-core/tests/worktree.rs index 7f6908d..4537027 100644 --- a/crates/deck-core/tests/worktree.rs +++ b/crates/deck-core/tests/worktree.rs @@ -853,7 +853,8 @@ fn branch_state_reports_existence_checkout_and_squash_merge() { #[cfg(unix)] #[test] fn add_existing_runs_omp_worktree_add_without_dash_b_and_checks_the_branch_out() { - use std::os::unix::fs::PermissionsExt; + // The script reaches its executed inode through `install` (another process), so no + // sibling test's fork can inherit a write fd to it (exec would fail with ETXTBSY). let f = fixture(); let parked = linked(&f, "pa", "wt/parked"); commit(&parked.path, "pa.txt", "work"); @@ -864,15 +865,22 @@ fn add_existing_runs_omp_worktree_add_without_dash_b_and_checks_the_branch_out() let log = f.root.join("args.log"); let fake = f.root.join("omp"); + let stage = f.root.join("omp.src"); fs::write( - &fake, + &stage, format!( "#!/bin/sh\necho \"$@\" > '{log}'\necho \"$PI_CONFIG_FILES\" >> '{log}'\nexec git -C \"$5\" worktree add -q \"$6\" \"$7\"\n", log = log.display() ), ) .unwrap(); - fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap(); + let installed = Command::new("install") + .args(["-m", "755"]) + .arg(&stage) + .arg(&fake) + .status() + .unwrap(); + assert!(installed.success()); let omp = Omp::at(&fake).with_env("XDG_DATA_HOME", f.root.join("data")); let dest = f.root.join("again"); diff --git a/crates/deck-service/src/controller.rs b/crates/deck-service/src/controller.rs index df35ec4..38cdadc 100644 --- a/crates/deck-service/src/controller.rs +++ b/crates/deck-service/src/controller.rs @@ -41,7 +41,7 @@ use serde::{Deserialize, Serialize}; use crate::composer::TspComposer; use crate::inject::{ComposeMode, End, Env, HOLD_TAIL, Injector, Job, JobId, Requires, Write}; -use crate::model_switch::{FRESH_TIMEOUT, Pending, SWITCH_TIMEOUT, plan_switch}; +use crate::model_switch::{Change, FRESH_TIMEOUT, Pending, SWITCH_TIMEOUT, plan_switch}; use crate::session_actions::{self, Action, ActionCtx}; use crate::status::TabStatus; use crate::statusfmt::Activity; @@ -929,19 +929,29 @@ impl SessionController { }) } + /// What a model or effort command changes, with the gating the typed path has (a no-op + /// switch, an effort on a session whose model is unknown); `None`: not a model command. + pub fn model_change(&self, command: &Command) -> Option> { + match command { + Command::SwitchModel { selector, level } => { + let current = self.current_model(); + Some( + plan_switch(current.as_deref(), selector, level.as_deref()) + .ok_or(Rejected::NoChange), + ) + } + Command::SetEffort { level } => Some( + self.current_model() + .ok_or(Rejected::ModelUnknown) + .and_then(|current| { + plan_switch(Some(¤t), ¤t, Some(level)).ok_or(Rejected::NoChange) + }), + ), + _ => None, + } + } + fn job_for(&self, command: &Command) -> Result<(Job, Option), Rejected> { - let switch = |current: Option<&str>, selector: &str, level: Option<&str>| { - let change = plan_switch(current, selector, level).ok_or(Rejected::NoChange)?; - let job = Job::slash(MODEL_SWITCH_LABEL, &change.command, Requires::Any); - Ok(( - job, - Some(Pending { - selector: change.selector, - level: change.level, - since: Instant::now(), - }), - )) - }; match command { Command::Action(action) => match session_actions::job(action) { Some(job) => Ok((job, None)), @@ -950,13 +960,17 @@ impl SessionController { } None => Err(Rejected::Empty), }, - Command::SwitchModel { selector, level } => { - let current = self.current_model(); - switch(current.as_deref(), selector, level.as_deref()) - } - Command::SetEffort { level } => { - let current = self.current_model().ok_or(Rejected::ModelUnknown)?; - switch(Some(¤t), ¤t, Some(level)) + Command::SwitchModel { .. } | Command::SetEffort { .. } => { + let change = self.model_change(command).ok_or(Rejected::Empty)??; + let job = Job::slash(MODEL_SWITCH_LABEL, &change.command, Requires::Any); + Ok(( + job, + Some(Pending { + selector: change.selector, + level: change.level, + since: Instant::now(), + }), + )) } Command::Review { text, send } => { if self.terminal.paste_bytes("x").is_none() { diff --git a/crates/deck-service/src/gateway/engine/deferred.rs b/crates/deck-service/src/gateway/engine/deferred.rs index d2d7b01..252a4c7 100644 --- a/crates/deck-service/src/gateway/engine/deferred.rs +++ b/crates/deck-service/src/gateway/engine/deferred.rs @@ -111,6 +111,8 @@ pub(super) struct Deferred { /// Forced resumes handed to a controller: the internal key it runs under -> the journaled /// mutation it finishes. resumes: HashMap, + /// Model and effort changes sent over live control, whose typed twin is the fallback. + pub(super) model_changes: HashMap, seq: u64, } @@ -252,6 +254,27 @@ impl Engine { Ok(key) } + /// Hands `command` to controller `i` for the tracked job `key`: it is typed under a fresh + /// internal key (the client's own sequence number may be behind the controller's watermark + /// by now), its draft confirmations go to `owner`, and its end finishes `key`'s journal + /// record. `Err`: the controller turned it away and nothing was queued. + pub(super) fn hand_over( + &mut self, + i: usize, + key: RequestKey, + owner: &str, + target: SessionTarget, + command: Command, + ) -> Result<(), Rejected> { + let session = target.key.clone(); + let internal = self.internal_submit(i, owner, target, command)?; + self.jobs.remove(&key); + self.dirty.gateway = true; + self.sess.submits.insert(internal.clone(), session); + self.defer.resumes.insert(internal, key); + Ok(()) + } + /// One step of the waiting commands. pub(super) fn step_deferred(&mut self, _now: Instant) { if self.defer.items.is_empty() { @@ -389,21 +412,14 @@ impl Engine { && m.ctl.activity() == Activity::Idle && !m.ctl.is_busy() { - // A fresh internal key: the client's own sequence number may be behind the - // controller's watermark by now. Its end finishes the journaled mutation. - match self.internal_submit( + match self.hand_over( i, + key.clone(), &item.owner, target.clone(), Command::Resume { file: file.clone() }, ) { - Ok(internal) => { - self.jobs.remove(&key); - self.dirty.gateway = true; - self.sess.submits.insert(internal.clone(), item.session); - self.defer.resumes.insert(internal, key); - return None; - } + Ok(()) => return None, Err(Rejected::Busy | Rejected::NoBracketedPaste | Rejected::InputHeld) => {} Err(r) => { self.job_failed(key, GatewayError::Rejected(r)); diff --git a/crates/deck-service/src/gateway/engine/mod.rs b/crates/deck-service/src/gateway/engine/mod.rs index 137a3eb..a221682 100644 --- a/crates/deck-service/src/gateway/engine/mod.rs +++ b/crates/deck-service/src/gateway/engine/mod.rs @@ -11,6 +11,7 @@ mod deferred; mod device; mod interest; +mod model_route; mod mutate; mod notify; mod remote; diff --git a/crates/deck-service/src/gateway/engine/model_route.rs b/crates/deck-service/src/gateway/engine/model_route.rs new file mode 100644 index 0000000..0c5a984 --- /dev/null +++ b/crates/deck-service/src/gateway/engine/model_route.rs @@ -0,0 +1,281 @@ +//! Model and effort changes over live control. +//! +//! omp's `/switch` and `/effort` typed into the composer would displace a draft, so a change on +//! a session whose live control can take it goes through the control extension instead: nothing +//! is typed and the composer is not touched. The typed command stays the fallback for what the +//! extension cannot do (`needs_compact`: omp compacts the transcript first, a TUI-only step) and +//! for a session that has no live control. A change is never sent both ways: once the command may +//! have reached omp, an unknown outcome is an error, not a retry (the journal never replays +//! uncertain work). + +use crate::controller::{Command, SessionKey, SessionTarget}; +use crate::gateway::protocol::{Done, GatewayError}; + +use super::Engine; +use deck_core::ctl::CtlReply; + +/// The control extension's refusal for a switch only omp's compact-first step can do. +const NEEDS_COMPACT: &str = "needs_compact"; +/// The wait for the control socket ended ([`Engine::queue_ctl`]): the command never left. +pub(super) const NO_SOCKET: &str = "could not reach the session's control socket"; +/// Control-client failures before anything was written (`deck_core::ctl`). +const NEVER_SENT: [&str; 2] = ["not connected", "control client stopped"]; +/// The extension cannot do it here, and changed nothing: omp's internals drifted from what it +/// was written for (`unsupported`), or it is an older extension of a session that still runs +/// (`model switching unavailable`, `effort unavailable`, `unknown cmd`). +const UNSUPPORTED: [&str; 4] = [ + "unsupported", + "model switching unavailable", + "effort unavailable", + "unknown cmd", +]; + +/// How a model change is applied. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Route { + /// Live control: the composer is untouched. + Ctl, + /// Typed `/switch` / `/effort` (with its draft confirmation). + Typed, +} + +/// What the session offers: `ctl_key` it runs with the control extension, `connected` the socket +/// is open now, `expected` the launch created it with the extension (the socket is on its way), +/// `quiet` nothing is being typed and no earlier model change waits (a typed one still queued +/// would apply after this one and win). +#[derive(Clone, Copy, Debug)] +pub(super) struct Facts { + pub(super) ctl_key: bool, + pub(super) connected: bool, + pub(super) expected: bool, + pub(super) quiet: bool, +} + +pub(super) fn route(f: Facts) -> Route { + if f.ctl_key && f.quiet && (f.connected || f.expected) { + Route::Ctl + } else { + Route::Typed + } +} + +/// What the control reply (or its failure) means for the change. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Verdict { + /// Applied; omp's own status text. + Done(String), + /// Nothing was changed and nothing was sent: type the command instead. + Typed, + /// omp refused, or the outcome is unknown: reported, never repeated. + Failed(String), +} + +pub(super) fn verdict(result: Result) -> Verdict { + match result { + Ok(Done::Ctl(CtlReply { + ok: true, message, .. + })) => Verdict::Done(message.unwrap_or_default()), + Ok(Done::Ctl(reply)) => { + let error = reply.error.unwrap_or_default(); + if error == NEEDS_COMPACT + || NEVER_SENT.contains(&error.as_str()) + || UNSUPPORTED.contains(&error.as_str()) + { + Verdict::Typed + } else if error.is_empty() { + Verdict::Failed("omp did not change the model".to_owned()) + } else if error == "connection closed" || error == "write failed" { + Verdict::Failed(format!( + "omp may not have changed the model ({error}): check the status bar" + )) + } else { + Verdict::Failed(error) + } + } + Ok(_) => Verdict::Failed("unexpected answer from the control socket".to_owned()), + Err(GatewayError::Failed(text)) if text == NO_SOCKET => Verdict::Typed, + Err(e) => Verdict::Failed(e.to_string()), + } +} + +/// A model change sent over live control and what it takes to type it instead. +pub(super) struct ModelChange { + pub(super) session: SessionKey, + pub(super) target: SessionTarget, + pub(super) command: Command, + /// The client that asked: it answers the draft confirmation of the typed fallback. + pub(super) owner: String, +} + +impl Engine { + /// Sends a model or effort `command` of controller `i` over live control when that is + /// possible; `false` leaves it to the typed path (which also reports its refusals). + pub(super) fn try_model_ctl( + &mut self, + conn: u64, + id: u64, + key: &crate::controller::RequestKey, + i: usize, + target: &SessionTarget, + command: &Command, + ) -> bool { + let m = &self.sess.managed[i]; + let Some(Ok(change)) = m.ctl.model_change(command) else { + return false; + }; + let facts = Facts { + ctl_key: m.ctl_key.is_some(), + connected: m.live.as_ref().is_some_and(|l| l.client.connected()), + expected: m.ctl_expect(), + quiet: !m.ctl.is_busy() && m.ctl.pending_switch().is_none(), + }; + if m.ctl.target() != target || m.exited() || route(facts) != Route::Ctl { + return false; + } + self.track(key, "model-change", super::mutate::Post::None); + self.accepted(conn, id, key); + self.defer.model_changes.insert( + key.clone(), + ModelChange { + session: target.key.clone(), + target: target.clone(), + command: command.clone(), + owner: key.client.clone(), + }, + ); + self.queue_ctl(i, key.clone(), change.ctl); + true + } + + /// The control socket answered (or could not): finish the change, or type it. + pub(super) fn model_change_finished( + &mut self, + key: crate::controller::RequestKey, + change: ModelChange, + result: Result, + ) { + match verdict(result) { + Verdict::Done(message) => self.job_finished(key, Ok(Done::Message(message))), + Verdict::Failed(text) => self.job_failed(key, GatewayError::Failed(text)), + Verdict::Typed => { + let Some(i) = self.sess.find(&change.session) else { + return self.job_failed( + key, + GatewayError::Failed("the session was closed".to_owned()), + ); + }; + if let Err(r) = + self.hand_over(i, key.clone(), &change.owner, change.target, change.command) + { + self.job_failed(key, GatewayError::Rejected(r)); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ok(message: &str) -> Result { + Ok(Done::Ctl(CtlReply { + ok: true, + message: Some(message.to_owned()), + ..CtlReply::default() + })) + } + + fn refused(error: &str) -> Result { + Ok(Done::Ctl(CtlReply { + ok: false, + error: Some(error.to_owned()), + ..CtlReply::default() + })) + } + + #[test] + fn live_control_is_used_only_when_the_session_has_it_and_nothing_is_typing() { + let base = Facts { + ctl_key: true, + connected: true, + expected: false, + quiet: true, + }; + assert_eq!(route(base), Route::Ctl); + // The socket is on its way for a session launched with the extension. + assert_eq!( + route(Facts { + connected: false, + expected: true, + ..base + }), + Route::Ctl + ); + // No extension, no socket and none expected, or an earlier command still typing. + assert_eq!( + route(Facts { + ctl_key: false, + ..base + }), + Route::Typed + ); + assert_eq!( + route(Facts { + connected: false, + ..base + }), + Route::Typed + ); + assert_eq!( + route(Facts { + quiet: false, + ..base + }), + Route::Typed + ); + } + + #[test] + fn a_reply_decides_between_done_typed_and_error() { + assert_eq!( + verdict(ok("Session-only model: a/b")), + Verdict::Done("Session-only model: a/b".to_owned()) + ); + // Only omp's compact-first switch and a command that never left fall back to typing. + for text in [ + "needs_compact", + "not connected", + "control client stopped", + // Nothing changed: the extension cannot do it in this omp. + "unsupported", + "model switching unavailable", + "effort unavailable", + "unknown cmd", + ] { + assert_eq!(verdict(refused(text)), Verdict::Typed, "{text}"); + } + assert_eq!( + verdict(Err(GatewayError::Failed(NO_SOCKET.to_owned()))), + Verdict::Typed + ); + // omp's own refusals are the error, never retried as typed text. + assert_eq!( + verdict(refused("Unknown model: nope")), + Verdict::Failed("Unknown model: nope".to_owned()) + ); + // The command may have been delivered: reported, not replayed. + for text in ["connection closed", "write failed"] { + assert!( + matches!(verdict(refused(text)), Verdict::Failed(t) if t.contains("may not have")), + "{text}" + ); + } + assert!(matches!( + verdict(Err(GatewayError::Failed( + "the session was closed".to_owned() + ))), + Verdict::Failed(_) + )); + } +} diff --git a/crates/deck-service/src/gateway/engine/mutate.rs b/crates/deck-service/src/gateway/engine/mutate.rs index ebb1c57..5d540c5 100644 --- a/crates/deck-service/src/gateway/engine/mutate.rs +++ b/crates/deck-service/src/gateway/engine/mutate.rs @@ -492,6 +492,8 @@ impl Engine { key: RequestKey, result: Result>, GatewayError>, ) { + // A model change that ended another way than through its reply (the session closed). + self.defer.model_changes.remove(&key); let state = self.jobs.remove(&key); let mut result = result.map(Into::into); let mut cancelled = false; @@ -645,6 +647,9 @@ impl Engine { let Some(i) = index else { return self.vanished(conn, id, key); }; + if self.try_model_ctl(conn, id, &key, i, &target, &command) { + return; + } self.submit_now(conn, id, key, i, target, command); } Mutation::Answer { diff --git a/crates/deck-service/src/gateway/engine/sessions.rs b/crates/deck-service/src/gateway/engine/sessions.rs index 01fdc86..5661665 100644 --- a/crates/deck-service/src/gateway/engine/sessions.rs +++ b/crates/deck-service/src/gateway/engine/sessions.rs @@ -141,6 +141,11 @@ pub(super) struct Managed { } impl Managed { + /// The launch created the session with the control extension. + pub(super) fn ctl_expect(&self) -> bool { + self.ctl_expect + } + pub(super) fn is_direct(&self) -> bool { self.mux.is_none() } @@ -1238,7 +1243,7 @@ impl Engine { finished.push(( rk, Err(GatewayError::Failed( - "could not reach the session's control socket".to_owned(), + super::model_route::NO_SOCKET.to_owned(), )), )); } @@ -1279,6 +1284,10 @@ impl Engine { } } for (key, result) in finished { + if let Some(change) = self.defer.model_changes.remove(&key) { + self.model_change_finished(key, change, result); + continue; + } let label = self.jobs.get(&key).map(|j| j.label); let result = match label { Some("answer-ui") => answer_ui_result(result), diff --git a/crates/deck-service/src/model_switch.rs b/crates/deck-service/src/model_switch.rs index ef91954..65decb0 100644 --- a/crates/deck-service/src/model_switch.rs +++ b/crates/deck-service/src/model_switch.rs @@ -3,6 +3,8 @@ use std::time::{Duration, Instant}; +use deck_core::ctl::CtlCommand; + /// How long an optimistic switch waits for the session file to show it. pub const SWITCH_TIMEOUT: Duration = Duration::from_secs(8); /// Sessions that record no model yet (fresh, nothing persisted) can never confirm; give up sooner. @@ -16,14 +18,27 @@ pub fn switch_command(selector: &str, level: Option<&str>) -> String { } } +/// `/switch`'s live-control twin: the selector carries the `:level` suffix like the slash +/// command's argument (omp's resolver reads it). +fn ctl_switch(selector: &str, level: Option<&str>) -> CtlCommand { + CtlCommand::SwitchModel { + selector: match level { + Some(l) => format!("{selector}:{l}"), + None => selector.to_owned(), + }, + } +} + pub fn effort_command(level: &str) -> String { format!("/effort {level}") } /// What picking something in the dropdown sends to omp. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct Change { pub command: String, + /// The same change as a live-control command (omp applies it without touching the composer). + pub ctl: CtlCommand, pub selector: Option, pub level: Option, } @@ -38,6 +53,7 @@ pub fn plan_switch(current: Option<&str>, selector: &str, level: Option<&str>) - if selector.starts_with('@') { return Some(Change { command: switch_command(selector, level), + ctl: ctl_switch(selector, level), selector: None, level: level.map(str::to_owned), }); @@ -45,12 +61,16 @@ pub fn plan_switch(current: Option<&str>, selector: &str, level: Option<&str>) - if current == Some(selector) { return level.map(|l| Change { command: effort_command(l), + ctl: CtlCommand::SetEffort { + level: l.to_owned(), + }, selector: None, level: Some(l.to_owned()), }); } Some(Change { command: switch_command(selector, level), + ctl: ctl_switch(selector, level), selector: Some(selector.to_owned()), level: level.map(str::to_owned), }) @@ -134,6 +154,38 @@ mod tests { assert_eq!(c.command, "/switch a/c"); } + #[test] + fn every_change_has_a_live_control_twin() { + let ctl = + |current, selector, level| plan_switch(current, selector, level).expect("a change").ctl; + assert_eq!( + ctl(Some("a/b"), "c/d", None), + CtlCommand::SwitchModel { + selector: "c/d".to_owned() + } + ); + // The level rides on the selector, like `/switch c/d:high`. + assert_eq!( + ctl(Some("a/b"), "c/d", Some("high")), + CtlCommand::SwitchModel { + selector: "c/d:high".to_owned() + } + ); + assert_eq!( + ctl(None, "@smol", Some("low")), + CtlCommand::SwitchModel { + selector: "@smol:low".to_owned() + } + ); + // The session's own model with a level is an effort change. + assert_eq!( + ctl(Some("a/b"), "a/b", Some("low")), + CtlCommand::SetEffort { + level: "low".to_owned() + } + ); + } + #[test] fn a_role_alias_is_switched_by_omp_and_expects_no_model() { let c = plan_switch(Some("a/b"), "@smol", None).expect("a role is never a no-op"); diff --git a/crates/deck-service/src/statusfmt.rs b/crates/deck-service/src/statusfmt.rs index 0ce28e0..56f4743 100644 --- a/crates/deck-service/src/statusfmt.rs +++ b/crates/deck-service/src/statusfmt.rs @@ -1,9 +1,6 @@ //! Pure formatting/decision helpers for the status bar, mirroring omp's own status line //! (docs/notes/omp-status.md §1.3, §2c): token formatter, context thresholds, title glyph. -use std::path::Path; - -use deck_core::sessions::abbreviate_home; use deck_term::{RunPhase, RunState, Terminal, TspFacts}; /// omp's token formatter: `999`, `1.2K`, `23K`, `1M`, `1.5M`, `120M`, `2.5B`. @@ -207,11 +204,6 @@ pub fn thinking_short(level: &str) -> &str { } } -/// `~`-abbreviated directory, left-ellipsized to `max_chars` (omp truncates the left side). -pub fn display_path(cwd: &Path, max_chars: usize) -> String { - ellipsize_left(&abbreviate_home(cwd), max_chars) -} - pub fn ellipsize_left(s: &str, max_chars: usize) -> String { let n = s.chars().count(); if n <= max_chars || max_chars < 2 { diff --git a/crates/deck-service/tests/session_screens_live.rs b/crates/deck-service/tests/session_screens_live.rs index a82d875..dbbc3d7 100644 --- a/crates/deck-service/tests/session_screens_live.rs +++ b/crates/deck-service/tests/session_screens_live.rs @@ -499,3 +499,185 @@ fn the_screens_mutations_reach_a_running_session() { }); let _ = (prompt.outcome.wait(TURN), rename.outcome.wait(TURN)); } + +/// What the composer holds, per the document. +fn draft(doc: &Value) -> Option { + Some(editor(doc)?["p"]["text"].as_str()?.to_owned()) +} + +/// The `model_change` and `thinking_level_change` entries of a session file, in order. +fn changes(file: &Path) -> Vec { + std::fs::read_to_string(file) + .unwrap_or_default() + .lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| v["type"] == "model_change" || v["type"] == "thinking_level_change") + .collect() +} + +/// A model and an effort change from the picker, with a draft in omp's composer, go over live +/// control: no confirmation, nothing typed, the draft untouched, and omp's own entries in the +/// session file. An unknown model is omp's error, not typed text. +#[test] +fn model_and_effort_changes_leave_the_composer_draft_alone() { + let _serial = SERIAL.lock().unwrap_or_else(PoisonError::into_inner); + if which(if cfg!(windows) { "omp.exe" } else { "omp" }).is_none() { + eprintln!("skipped: omp is not installed"); + return; + } + let config = format!("{OMP_CONFIG}modelRoles:\n default: {}\n", fm::MODEL); + let sb = Sandbox::new("scrm", &config); + sb.report_failures(); + ensure_host_exe(); + let model = FakeModel::start(script()); + // A second model that can think, to switch to. + let models = model.models_yml() + + " - id: fake-2\n name: Fake Two\n reasoning: true\n \ + contextWindow: 200000\n maxTokens: 4096\n input: [text, image]\n"; + std::fs::write(sb.agent().join("models.yml"), models).expect("models.yml"); + let ns = Namespace::new(&sb.ns).expect("namespace"); + let _cleanup = Cleanup(Namespace::new(&sb.ns).expect("namespace")); + + let client = sb.client(); + wait_for("the gateway link", Duration::from_secs(30), || { + matches!(client.link(), Link::Up(_)).then_some(()) + }); + let Ok(Outcome::Done(done)) = run( + &client, + Mutation::Launch(LaunchSpec { + mux_name: Some("omp-modelsw".to_owned()), + cwd: sb.proj(), + session_path: None, + label: "Chat".into(), + argv: vec!["omp".into()], + role: SessionRole::Session, + copy_of: None, + view: SessionView::Chat, + }), + ) else { + panic!("launch") + }; + let Done::Session(target, _) = *done else { + panic!("launch result") + }; + let name = wait_for("the host session", TURN, || ns.list().into_iter().next()); + let peek = || deck_host::tsp_peek(&ns, &name).expect("tsp_peek"); + wait_for("a sendable composer", TURN, || editor(&peek()).map(|_| ())); + wait_idle(&client, &target.key); + let command = |c| Mutation::Submit { + target: target.clone(), + command: c, + }; + // A prompt makes omp write the session file. + let Ok(Outcome::Done(_)) = run( + &client, + command(deck_service::controller::Command::Prompt { + text: "Reply with ok. model".to_owned(), + }), + ) else { + panic!("prompt") + }; + let info = wait_for("the session file", TURN, || { + controller(&client, &target.key).filter(|c| { + c.activity == Activity::Idle && c.session_path.as_ref().is_some_and(|p| p.is_file()) + }) + }); + let file = info.session_path.expect("session file"); + let before = changes(&file).len(); + + // A draft in the composer. + let Ok(Outcome::Done(_)) = run( + &client, + command(deck_service::controller::Command::Review { + text: "my half-typed draft".to_owned(), + send: false, + }), + ) else { + panic!("insert the draft") + }; + wait_for("the draft in the composer", TURN, || { + (draft(&peek()).as_deref() == Some("my half-typed draft")).then_some(()) + }); + let requests = |client: &GatewayClient| { + controller(client, &target.key) + .map(|c| c.requests.len()) + .unwrap_or_default() + }; + let typed_before = requests(&client); + + // The picker's model change: omp's own answer, no confirmation, nothing typed. + let switched = run( + &client, + command(deck_service::controller::Command::SwitchModel { + selector: "fake/fake-2".to_owned(), + level: None, + }), + ); + let Ok(Outcome::Done(done)) = switched else { + panic!("model switch: {switched:?}") + }; + assert!( + matches!(&*done, Done::Message(m) if m.contains("fake/fake-2")), + "{done:?}" + ); + assert_eq!( + requests(&client), + typed_before, + "nothing was typed into omp" + ); + assert_eq!(draft(&peek()).as_deref(), Some("my half-typed draft")); + + // The thinking level (`model:level`, as the picker's level chips send it): the same. + let effort = run( + &client, + command(deck_service::controller::Command::SwitchModel { + selector: "fake/fake-2".to_owned(), + level: Some("low".to_owned()), + }), + ); + let Ok(Outcome::Done(done)) = effort else { + panic!("effort: {effort:?}") + }; + assert!( + matches!(&*done, Done::Message(m) if m.contains("fake/fake-2")), + "{done:?}" + ); + assert_eq!( + requests(&client), + typed_before, + "nothing was typed into omp" + ); + assert_eq!(draft(&peek()).as_deref(), Some("my half-typed draft")); + let entries = wait_for("omp's entries", TURN, || { + let all = changes(&file); + (all.len() >= before + 2).then_some(all) + }); + let new = &entries[before..]; + assert!( + new.iter().any(|e| e["type"] == "model_change" + && e["model"] == "fake/fake-2" + && e["role"] == "temporary"), + "{new:?}" + ); + assert!( + new.iter() + .any(|e| e["type"] == "thinking_level_change" && e["thinkingLevel"] == "low"), + "{new:?}" + ); + + // omp's refusal is the error, and nothing is typed instead. + let unknown = run( + &client, + command(deck_service::controller::Command::SwitchModel { + selector: "fake/nope-9".to_owned(), + level: None, + }), + ); + assert!( + matches!(&unknown, Ok(Outcome::Failed(t)) if t.contains("Unknown model")) + || unknown.is_err(), + "{unknown:?}" + ); + assert_eq!(requests(&client), typed_before); + assert_eq!(draft(&peek()).as_deref(), Some("my half-typed draft")); +} diff --git a/crates/omp-deck/assets/fonts/SymbolsNerdFontMono-Regular.ttf b/crates/omp-deck/assets/fonts/SymbolsNerdFontMono-Regular.ttf new file mode 100644 index 0000000..3b5a184 Binary files /dev/null and b/crates/omp-deck/assets/fonts/SymbolsNerdFontMono-Regular.ttf differ diff --git a/crates/omp-deck/assets/fonts/nerd-fonts-LICENSE.txt b/crates/omp-deck/assets/fonts/nerd-fonts-LICENSE.txt new file mode 100644 index 0000000..b636e2e --- /dev/null +++ b/crates/omp-deck/assets/fonts/nerd-fonts-LICENSE.txt @@ -0,0 +1,24 @@ +The Nerd Fonts Symbols font is distributed under the MIT License. + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Font source: Nerd Fonts v3.5.1, NerdFontsSymbolsOnly.zip. +https://github.com/ryanoasis/nerd-fonts/releases/tag/v3.5.1 diff --git a/crates/omp-deck/src/app/bar.rs b/crates/omp-deck/src/app/bar.rs index 6b61bc5..6e6b0ef 100644 --- a/crates/omp-deck/src/app/bar.rs +++ b/crates/omp-deck/src/app/bar.rs @@ -6,7 +6,9 @@ //! [`SessionTarget`] the user acted on; this module only turns the controller's events into //! dialogs and toasts. +use std::cell::Cell; use std::path::PathBuf; +use std::rc::Rc; use std::sync::Arc; use std::time::Duration; @@ -483,22 +485,37 @@ impl App { } /// Submits `command` to the session `target` names. A refusal is told to the user (one that - /// only restates a no-op stays silent). The final delivery arrives as a controller event. + /// only restates a no-op stays silent). The final delivery arrives as a controller event, + /// except for a change the gateway made over live control without typing anything: that one + /// ends with omp's own message (a quiet toast) or its error. pub(super) fn send_command(&mut self, target: &SessionTarget, command: Command) { // Actions and model switches that find omp gone stay silent, as the bar always did. let quiet_exit = matches!( command, Command::Action(_) | Command::SwitchModel { .. } | Command::SetEffort { .. } ); + let direct = Rc::new(Cell::new(false)); + let handled = Rc::clone(&direct); self.gw.submit( target.clone(), command, move |app, _key, result| match result { - Ok(_) => app.ctx.request_repaint(), + Ok(submitted) => { + handled.set(submitted.is_none()); + app.ctx.request_repaint(); + } Err(GatewayError::Rejected(why)) => app.report_rejected(&why, quiet_exit), Err(e) => app.toasts.error(&app.ctx, format!("Nothing was sent: {e}")), }, - |app, result| { + move |app, result| { + if direct.get() { + match direct_notice(result, quiet_exit) { + DirectNotice::Success(text) => app.toasts.success(&app.ctx, text), + DirectNotice::Failure(text) => app.toasts.error(&app.ctx, text), + DirectNotice::Quiet => {} + } + return; + } // The delivery itself is reported by the controller's events; what is left is a // request whose fate the gateway could not record. if let Err(e) = result @@ -786,3 +803,81 @@ fn focus_editor(ui: &mut Ui, text: &mut String, first: &mut bool, multiline: boo *first = false; } } + +/// What the end of a change the gateway made over live control (nothing typed into omp) tells +/// the user. +#[derive(Debug, PartialEq, Eq)] +enum DirectNotice { + /// omp's own words about what it did (`Session-only model: p/m`). + Success(String), + /// omp (or the gateway) could not make the change. + Failure(String), + /// Nothing to say: the typed fallback reports itself through the controller's events, and a + /// refusal that only restates the session's state stays silent as it always did. + Quiet, +} + +/// The final result of a live-control change. A typed fallback (omp must compact first) ends +/// with some other [`Done`] after its draft confirm; cancelling that confirm is not an error. +fn direct_notice(result: Result, quiet_exit: bool) -> DirectNotice { + match result { + Ok(Done::Message(text)) if !text.trim().is_empty() => DirectNotice::Success(text), + Ok(_) => DirectNotice::Quiet, + Err(e) => { + let rejected = |why: Rejected| GatewayError::Rejected(why).to_string(); + let restated = e == rejected(Rejected::NoChange) + || e == rejected(Rejected::NotInjected) + || (quiet_exit && e == rejected(Rejected::Exited)); + if restated || e == GatewayError::Cancelled.to_string() { + DirectNotice::Quiet + } else { + DirectNotice::Failure(e) + } + } + } +} + +#[cfg(test)] +mod direct_tests { + use super::*; + + #[test] + fn omps_message_of_a_live_control_change_is_a_quiet_success() { + assert_eq!( + direct_notice( + Ok(Done::Message("Session-only model: fake/fake-2".into())), + true + ), + DirectNotice::Success("Session-only model: fake/fake-2".into()) + ); + assert_eq!( + direct_notice(Ok(Done::Message(" ".into())), true), + DirectNotice::Quiet + ); + } + + #[test] + fn a_failed_live_control_change_is_told_and_a_restated_state_is_not() { + assert_eq!( + direct_notice(Err("Unknown model: nope".into()), true), + DirectNotice::Failure("Unknown model: nope".into()) + ); + let text = |why| GatewayError::Rejected(why).to_string(); + for quiet in [Rejected::NoChange, Rejected::NotInjected] { + assert_eq!(direct_notice(Err(text(quiet)), true), DirectNotice::Quiet); + } + assert_eq!( + direct_notice(Err(text(Rejected::Exited)), true), + DirectNotice::Quiet + ); + assert_eq!( + direct_notice(Err(text(Rejected::Exited)), false), + DirectNotice::Failure(text(Rejected::Exited)) + ); + // The typed fallback: its draft confirm is the report, and declining it is not an error. + assert_eq!( + direct_notice(Err("cancelled".into()), true), + DirectNotice::Quiet + ); + } +} diff --git a/crates/omp-deck/src/chat_view/ctx.rs b/crates/omp-deck/src/chat_view/ctx.rs index eebc2b3..b78f8e7 100644 --- a/crates/omp-deck/src/chat_view/ctx.rs +++ b/crates/omp-deck/src/chat_view/ctx.rs @@ -112,6 +112,10 @@ pub struct CtxMenu { #[derive(Default)] pub struct Local { pub folds: HashMap, + /// The model name each assistant block was first drawn under (by node id): omp tells the + /// composer's model only, so a block keeps the name it had when it appeared and a later + /// switch does not relabel replies the earlier model wrote. + pub bylines: HashMap, pub heights: HashMap, /// Estimated heights of items not measured yet, by the same key. pub estimates: HashMap, diff --git a/crates/omp-deck/src/chat_view/transcript.rs b/crates/omp-deck/src/chat_view/transcript.rs index ec5b10b..86ff868 100644 --- a/crates/omp-deck/src/chat_view/transcript.rs +++ b/crates/omp-deck/src/chat_view/transcript.rs @@ -327,7 +327,8 @@ pub fn role_node(cx: &Cx, ui: &mut Ui, n: &SNode) -> bool { fn assistant(cx: &Cx, ui: &mut Ui, n: &SNode, byline: bool) { let ink = cx.ink; if byline { - let model = model_name(cx); + let model = model_name(cx) + .map(|now| cx.loc(|l| l.bylines.entry(n.id.clone()).or_insert(now).clone())); let label = match model { Some(m) => format!("omp · {m}"), None => "omp".to_owned(), diff --git a/crates/omp-deck/src/gw.rs b/crates/omp-deck/src/gw.rs index cfca0d0..cc24797 100644 --- a/crates/omp-deck/src/gw.rs +++ b/crates/omp-deck/src/gw.rs @@ -11,7 +11,8 @@ //! not accepted (offline, stale epoch, refused) fails at once; an accepted one whose link breaks //! is looked up again after the reconnect and is never sent twice. //! - [`Gw::submit`] additionally reports the controller's [`Submitted`] id once the gateway has -//! accepted the command: until then the request is only locally pending. +//! accepted the command (`None`: it handled the command without typing it into omp); until then +//! the request is only locally pending. //! - Closing the window drops these closures; accepted work belongs to the gateway. use std::cell::RefCell; @@ -44,7 +45,7 @@ pub type Job = Box; type Poll = Box Option>>; type DoneFn = Box)>; type ProgressFn = Rc>; -type AcceptedFn = Box)>; +type AcceptedFn = Box, GatewayError>)>; /// What the gateway answered to opening a pairing window. The invite itself is dropped (and /// wiped) on arrival: only the link the QR code carries is kept. @@ -330,13 +331,15 @@ impl Gw { } /// Sends a command to a session's controller. `accepted` runs when the gateway accepted it - /// (with the controller's request id) or refused it; `done` when it reached its final result. - /// Between the call and `accepted` the request is only locally pending. + /// (with the controller's request id, or `None` when the gateway handled it without typing + /// anything into omp: a model change over live control, whose final [`Done`] arrives in `done`) + /// or refused it; `done` when it reached its final result. Between the call and `accepted` the + /// request is only locally pending. pub fn submit( &mut self, target: SessionTarget, command: Command, - accepted: impl FnOnce(&mut App, RequestKey, Result) + 'static, + accepted: impl FnOnce(&mut App, RequestKey, Result, GatewayError>) + 'static, done: impl FnOnce(&mut App, Result) + 'static, ) -> RequestKey { let m = self.client.submit(target, command); @@ -406,10 +409,8 @@ impl Gw { t.accepted = true; } if let Some(cb) = a.on_accepted { - let id = acc.submitted.ok_or_else(|| { - GatewayError::Failed("the gateway returned no request id".to_owned()) - }); let key = a.key.clone(); + let id = Ok(acc.submitted); jobs.push(Box::new(move |app: &mut App| cb(app, key, id))); } } @@ -723,11 +724,12 @@ impl Gw { let refusal = Rc::new(std::cell::Cell::new(None)); let seen = Rc::clone(&refusal); // The acceptance callback runs before the completion for the same request. - let on_accepted = move |_: &mut App, _: RequestKey, r: Result| { - if let Err(GatewayError::Rejected(why)) = r { - seen.set(Some(why)); - } - }; + let on_accepted = + move |_: &mut App, _: RequestKey, r: Result, GatewayError>| { + if let Err(GatewayError::Rejected(why)) = r { + seen.set(Some(why)); + } + }; self.track( m.key, m.accepted, diff --git a/crates/omp-deck/src/statusbar.rs b/crates/omp-deck/src/statusbar.rs index ada2ed4..05008c5 100644 --- a/crates/omp-deck/src/statusbar.rs +++ b/crates/omp-deck/src/statusbar.rs @@ -16,6 +16,7 @@ use crate::icons::{ }; use deck_core::ctl::{ContextBreakdown, LiveAccount, LiveModel}; use deck_core::omp::ModelInfo; +use deck_core::sessions::abbreviate_home; use deck_core::status::{GitInfo, SessionStats, SessionStatus}; use egui::{ Align, Align2, Color32, CursorIcon, Frame, Layout, Margin, Painter, Pos2, Rect, Response, @@ -50,7 +51,11 @@ use deck_service::statusfmt::{ /// inside 26 px pills (was 29) on a 40 px strip (was 41). pub const BAR_H: f32 = 40.0; const CHIP_H: f32 = 26.0; -const PATH_CHARS: usize = 44; +/// The path chip never shrinks below its last component, ellipsized to this many characters. +const PATH_MIN_CHARS: usize = 20; +/// The path's available width is rounded down to a multiple of this (px) before it is elided, so +/// dragging the sidebar does not re-measure the path every pixel. +const PATH_STEP: f32 = 8.0; const CHIP_PAD: f32 = 8.0; /// Horizontal space a chip adds around its content: both pads plus the one item gap measured /// on screen (so a chip asked to be N px wide really is N px wide). @@ -148,6 +153,7 @@ pub struct BarView { pub configured: Option, pub model_tip: ModelTip, + /// The working directory, `~`-abbreviated and whole: the chip elides it to the room it gets. pub path_text: String, pub path_full: String, @@ -184,9 +190,10 @@ pub struct Defaults { pub level: Option, } -/// The session's model and thinking level: the file's once it records a model, else omp's live -/// in-memory state (omp persists a session only with its first message, so model and thinking -/// changes before it never reach the file). +/// The session's model and thinking level: omp's live in-memory state while live control is +/// connected (what the session runs *now*: a switch made mid-turn shows at once, and the reply +/// the previous model finishes afterwards does not undo it), else the session file's newest +/// record (omp also persists nothing before the first message, which live state covers). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct ModelState<'a> { pub model: Option<&'a str>, @@ -195,7 +202,7 @@ pub struct ModelState<'a> { } pub fn model_state<'a>(stats: &'a SessionStats, live: Option<&'a LiveModel>) -> ModelState<'a> { - match live.filter(|_| stats.model.is_none()) { + match live { Some(l) => ModelState { model: Some(&l.selector), thinking: Some(&l.thinking), @@ -346,7 +353,7 @@ impl BarView { ); self.path_full = s.cwd.to_string_lossy().into_owned(); - self.path_text = statusfmt::display_path(&s.cwd, PATH_CHARS); + self.path_text = abbreviate_home(&s.cwd); self.git = s.git.as_ref().map(git_view); @@ -606,38 +613,47 @@ fn paint_strip(p: &Painter, rect: Rect, ink: &Ink) { ); } -/// The droppable segments of the info row, most important first. +/// The droppable segments of the info row in priority order (the discriminant is the index into +/// [`SEGS`] and [`SegWidths::w`]): the least important goes first when the bar narrows. Path comes +/// last: it keeps only its last folder when nothing else is dropped, and grows into the room the +/// rest leaves. #[derive(Clone, Copy, PartialEq, Eq)] enum Seg { Ctx, + Usage, Git, - Path, Cost, Tokens, - Usage, + Path, } const SEGS: [Seg; 6] = [ Seg::Ctx, + Seg::Usage, Seg::Git, - Seg::Path, Seg::Cost, Seg::Tokens, - Seg::Usage, + Seg::Path, ]; +type SegKey = ( + u64, + Option, + u32, + deck_core::config::UiFont, + &'static str, +); + /// Real widths (the chip, without the spacing before it) of every segment for one view /// revision, usage chip page and pixel density. #[derive(Clone, Copy)] struct SegWidths { - key: ( - u64, - Option, - u32, - deck_core::config::UiFont, - &'static str, - ), + key: SegKey, + /// The path's entry is its smallest chip (last folder only). w: [f32; 6], + /// What the path chip adds around its text, and the width of the whole path as text. + path_chrome: f32, + path_text_w: f32, } /// Lays `f` out once in an invisible child and returns the width it took. @@ -654,7 +670,19 @@ fn measure(ui: &mut Ui, f: impl FnOnce(&mut Ui)) -> f32 { child.min_rect().width() } -fn seg_widths(ui: &mut Ui, ink: &Ink, v: &BarView) -> [f32; 6] { +/// Width of `s` as chip text. +fn text_width(ui: &Ui, s: &str) -> f32 { + ui.painter() + .layout_no_wrap( + s.to_owned(), + theme::label_font(TEXT_PX), + Color32::PLACEHOLDER, + ) + .size() + .x +} + +fn seg_widths(ui: &mut Ui, ink: &Ink, v: &BarView) -> SegWidths { let key = ( v.rev, // The pager's pick lives in egui memory, outside `rev`. @@ -668,15 +696,88 @@ fn seg_widths(ui: &mut Ui, ink: &Ink, v: &BarView) -> [f32; 6] { if let Some(c) = ui.data(|d| d.get_temp::(id)) && c.key == key { - return c.w; + return c; } let mut w = [0.0; 6]; let mut sink = Vec::new(); for (i, s) in SEGS.into_iter().enumerate() { - w[i] = measure(ui, |ui| segment(ui, ink, v, s, &mut sink)); + w[i] = if s == Seg::Path { + let min = elide_path(&v.path_text, |_| false); + measure(ui, |ui| segment(ui, ink, v, s, &min, &mut sink)) + } else { + measure(ui, |ui| segment(ui, ink, v, s, "", &mut sink)) + }; + } + let path_text_w = text_width(ui, &v.path_text); + let path_full = measure(ui, |ui| { + segment(ui, ink, v, Seg::Path, &v.path_text, &mut sink); + }); + let c = SegWidths { + key, + w, + path_chrome: path_full - path_text_w, + path_text_w, + }; + ui.data_mut(|d| d.insert_temp(id, c)); + c +} + +/// `path` shortened from the front until `fits`: the whole path, then `…/` and fewer and fewer +/// of its trailing folders, down to the last one (itself ellipsized to [`PATH_MIN_CHARS`]), which +/// is returned when nothing fits. +fn elide_path(path: &str, mut fits: impl FnMut(&str) -> bool) -> String { + if fits(path) { + return path.to_owned(); + } + // A separator that starts a component (not the leading or the trailing one) is a cut point. + let cuts = path.char_indices().filter_map(|(i, c)| { + (i > 0 && matches!(c, '/' | '\\') && i + c.len_utf8() < path.len()).then_some(i) + }); + let mut last = String::new(); + for i in cuts { + last = format!("…{}", &path[i..]); + if fits(&last) { + return last; + } + } + if last.is_empty() { + last = path.to_owned(); + } + statusfmt::ellipsize_left(&last, PATH_MIN_CHARS) +} + +type PathKey = (u64, u32, u32, deck_core::config::UiFont, &'static str); + +/// The path chip's text for a chip `chip_w` wide, elided once per view revision and width step +/// (the cache holds a shared string, so a frame that hits it allocates nothing). +fn path_text(ui: &Ui, v: &BarView, w: &SegWidths, chip_w: f32) -> Arc { + let room = chip_w - w.path_chrome; + let step = if room >= w.path_text_w { + u32::MAX + } else { + (room.max(0.0) / PATH_STEP) as u32 + }; + let key: PathKey = ( + v.rev, + step, + ui.ctx().pixels_per_point().to_bits(), + theme::ui_font(), + theme::style().key(), + ); + let id = ui.scope_id().with("path-text"); + if let Some((k, text)) = ui.data(|d| d.get_temp::<(PathKey, Arc)>(id)) + && k == key + { + return text; } - ui.data_mut(|d| d.insert_temp(id, SegWidths { key, w })); - w + let limit = if step == u32::MAX { + f32::INFINITY + } else { + step as f32 * PATH_STEP + }; + let text: Arc = elide_path(&v.path_text, |t| text_width(ui, t) <= limit).into(); + ui.data_mut(|d| d.insert_temp(id, (key, text.clone()))); + text } fn info_row( @@ -691,13 +792,8 @@ fn info_row( activity_dot(ui, input.activity); ui.add_space(2.0); - // The model chip has one width in every session, but never takes over the bar. let widths = seg_widths(ui, ink, v); let gap = ui.spacing().item_spacing.x; - let (chip_w, cap) = model_chip_width(ui.available_width()); - model_chip(ui, ink, v, picker, input, (chip_w, cap), out); - - // Priority-based fit on measured widths: drop the least important segments first. let present = SEGS.map(|s| match s { Seg::Git => v.git.is_some(), Seg::Cost => v.show_cost, @@ -705,7 +801,23 @@ fn info_row( Seg::Usage => v.usage.as_ref().is_some_and(|u| u.chip.is_some()), Seg::Ctx | Seg::Path => true, }); - let shown = fit_segments(&widths, &present, ui.available_width() - gap, gap); + // The model chip has one width in every session, but never takes over the bar: it yields + // down to its minimum for the context meter and the usage chip. + let keep: f32 = [Seg::Ctx, Seg::Usage] + .into_iter() + .filter(|s| present[*s as usize]) + .map(|s| widths.w[s as usize] + gap) + .sum(); + let (chip_w, cap) = model_chip_width(ui.available_width(), keep + gap); + model_chip(ui, ink, v, picker, input, (chip_w, cap), out); + + // Priority-based fit on measured widths: drop the least important segments first. + let budget = ui.available_width() - gap; + let shown = fit_segments(&widths.w, &present, budget, gap); + // The path chip grows into what the others leave. + let spare = budget - spent(&widths.w, &shown, gap); + let path = shown[Seg::Path as usize] + .then(|| path_text(ui, v, &widths, widths.w[Seg::Path as usize] + spare)); // Drawn in reading order: path, git, ctx, tokens, cost, usage. for s in [ Seg::Path, @@ -715,17 +827,21 @@ fn info_row( Seg::Cost, Seg::Usage, ] { - if shown[SEGS.iter().position(|x| *x == s).unwrap_or(0)] { - segment(ui, ink, v, s, out); + if shown[s as usize] { + segment(ui, ink, v, s, path.as_deref().unwrap_or_default(), out); } } } /// Model chip `(width, cap)`: [`MODEL_CHIP_W`], capped at [`MODEL_CHIP_SHARE`] of the bar (never -/// below [`MODEL_CHIP_MIN`]) so it never takes over a narrow bar. -fn model_chip_width(bar_w: f32) -> (f32, f32) { +/// below [`MODEL_CHIP_MIN`]) so it never takes over a narrow bar. `reserved` is the width the +/// segments that must stay (and their gaps) need: the chip gives way to them down to +/// [`MODEL_CHIP_MIN`]. +fn model_chip_width(bar_w: f32, reserved: f32) -> (f32, f32) { let cap = (bar_w * MODEL_CHIP_SHARE).max(MODEL_CHIP_MIN); - (MODEL_CHIP_W.min(cap), cap) + // A pixel of slack: layout rounds, and a segment must never miss its room by a hair. + let room = (bar_w - reserved - 1.0).max(MODEL_CHIP_MIN); + (MODEL_CHIP_W.min(cap).min(room), cap.min(room)) } /// Which segments fit `budget`, in priority order: the first present one that does not fit ends @@ -751,14 +867,25 @@ fn fit_segments( shown } -/// One segment: a pill (the pills are separated by the row's gap alone). -fn segment(ui: &mut Ui, ink: &Ink, v: &BarView, seg: Seg, out: &mut Vec) { +/// What the `shown` segments take, each with its gap. +fn spent(widths: &[f32; N], shown: &[bool; N], gap: f32) -> f32 { + widths + .iter() + .zip(shown) + .filter(|(_, s)| **s) + .map(|(w, _)| w + gap) + .sum() +} + +/// One segment: a pill (the pills are separated by the row's gap alone). `path` is the path +/// chip's text. +fn segment(ui: &mut Ui, ink: &Ink, v: &BarView, seg: Seg, path: &str, out: &mut Vec) { let pal = ink.p; match seg { Seg::Path => { let r = chip(ui, ink, "path", Sense::click(), Tone::Plain, |ui| { ui.label(icon_text(ICON_FOLDER, ICON_PX, pal.weak)); - ui.add(text(&v.path_text, TEXT_PX, pal.text)); + ui.add(text(path, TEXT_PX, pal.text)); }); let r = r.on_hover_cursor(CursorIcon::PointingHand); path_hover(&r, &v.path_full); @@ -2103,41 +2230,54 @@ mod tests { v.set_live_model(Some(&high)); assert!(v.update(Some(&fresh), Path::new("/w"), &catalog())); assert_eq!(v.current_level(), Some("high")); - // Once the file records a model (first message on), the file wins. - let persisted = Arc::new(status()); - assert!(v.update(Some(&persisted), Path::new("/w"), &catalog())); - assert_eq!(v.level.as_deref(), Some("high")); - assert_eq!(v.current_level(), Some("auto")); + // The file recording a model (first message on) does not displace omp's live state: a + // reply written by the model before a mid-turn switch must not show as the model now. let mut file_model = status(); file_model.stats.model = Some("openai/gpt-x".into()); + assert!(v.update( + Some(&Arc::new(file_model.clone())), + Path::new("/w"), + &catalog() + )); + assert_eq!(v.model_name, "Claude Opus 5.5"); + assert_eq!(v.current_level(), Some("high")); + // Without live control the file is all there is. + v.set_live_model(None); assert!(v.update(Some(&Arc::new(file_model)), Path::new("/w"), &catalog())); assert_eq!(v.model_name, "gpt-x"); } #[test] - fn model_state_prefers_the_file_once_it_records_a_model() { + fn model_state_follows_live_control_and_falls_back_to_the_file() { let live = LiveModel { selector: "p/live".into(), thinking: "off".into(), thinking_configured: Some("off".into()), }; + let live_state = ModelState { + model: Some("p/live"), + thinking: Some("off"), + configured: Some("off"), + }; // A fresh file may carry a thinking_level_change without any model: live wins whole. let fresh = SessionStats { thinking: Some("max".into()), ..SessionStats::default() }; + assert_eq!(model_state(&fresh, Some(&live)), live_state); + assert_eq!(model_state(&fresh, None).model, None); + // The file's last record can name the model that wrote a reply after the user switched + // away mid-turn (omp's `model_change` precedes that reply): the live model is the + // selected one. + let stale = status().stats; assert_eq!( - model_state(&fresh, Some(&live)), - ModelState { - model: Some("p/live"), - thinking: Some("off"), - configured: Some("off"), - } + stale.model.as_deref(), + Some("anthropic/claude-opus-5-5"), + "the fixture's file model" ); - assert_eq!(model_state(&fresh, None).model, None); - let stats = status().stats; + assert_eq!(model_state(&stale, Some(&live)), live_state); assert_eq!( - model_state(&stats, Some(&live)), + model_state(&stale, None), ModelState { model: Some("anthropic/claude-opus-5-5"), thinking: Some("high"), @@ -2166,19 +2306,19 @@ mod tests { #[test] fn segments_drop_monotonically_as_the_bar_narrows() { - // ctx is narrow, path wide: greedy skipping would show ctx but not path at some width, - // and path but not ctx at another. - let widths = [60.0, 90.0, 300.0, 70.0, 80.0]; - let all = [true; 5]; - let mut prev = [true; 5]; - let mut w = 800.0; + // In priority order: ctx, usage, git, cost, tokens, path (smallest chip). Greedy skipping + // would show a narrow low-priority segment while a wider higher one is gone. + let widths = [60.0, 90.0, 300.0, 70.0, 80.0, 130.0]; + let all = [true; 6]; + let mut prev = [true; 6]; + let mut w = 900.0; while w >= 0.0 { let now = fit_segments(&widths, &all, w, 6.0); - for i in 0..5 { + for i in 0..6 { assert!(prev[i] || !now[i], "segment {i} came back at {w}px"); } // Never a lower-priority segment without every higher-priority one. - for i in 1..5 { + for i in 1..6 { assert!( !now[i] || now[i - 1], "segment {i} shown without {} at {w}", @@ -2191,21 +2331,42 @@ mod tests { } #[test] - fn model_chip_is_as_wide_in_every_session_and_segments_still_fit_monotonically() { - assert_eq!(model_chip_width(1400.0).0, MODEL_CHIP_W); - assert_eq!(model_chip_width(400.0).0, MODEL_CHIP_MIN); - // A worktree's long path chip leaves the model chip alone, so every segment still fits - // a 1100 px bar (sized from the path, the chip took 546 px and usage was dropped). - let widths = [60.0, 90.0, 420.0, 70.0, 80.0]; - let all = [true; 5]; - let (chip, _) = model_chip_width(1100.0); - assert_eq!(fit_segments(&widths, &all, 1100.0 - chip - 6.0, 6.0), all); - let mut prev = [true; 5]; + fn model_chip_is_as_wide_in_every_session_and_yields_to_ctx_and_usage_down_to_its_minimum() { + assert_eq!(model_chip_width(1400.0, 0.0).0, MODEL_CHIP_W); + assert_eq!(model_chip_width(400.0, 0.0).0, MODEL_CHIP_MIN); + // Ctx (150) and usage (250) with their gaps need 418 px. + let reserved = 150.0 + 6.0 + 250.0 + 6.0 + 6.0; + assert_eq!(model_chip_width(1100.0, reserved).0, MODEL_CHIP_W); + let (chip, cap) = model_chip_width(600.0, reserved); + assert!(chip < MODEL_CHIP_W && chip > MODEL_CHIP_MIN, "{chip}"); + assert!( + cap <= chip + 0.01, + "the chip may not grow past what is left: {cap} vs {chip}" + ); + assert_eq!(model_chip_width(300.0, reserved).0, MODEL_CHIP_MIN); + + // Usage (index 1) is dropped only once the chip is at its minimum and still leaves no + // room; every segment still drops monotonically. + let widths = [150.0, 250.0, 420.0, 70.0, 80.0, 130.0]; + let all = [true; 6]; + let fit = |bar: f32| { + let (chip, _) = model_chip_width(bar, reserved); + fit_segments(&widths, &all, bar - chip - 6.0, 6.0) + }; + let least = MODEL_CHIP_MIN + reserved + 1.0; + assert!( + fit(least)[..2].iter().all(|s| *s), + "ctx and usage at {least}px" + ); + assert!( + !fit(least - 20.0)[1], + "usage goes once the chip cannot yield more" + ); + let mut prev = [true; 6]; let mut bar = 1400.0; while bar >= 0.0 { - let (chip, _) = model_chip_width(bar); - let now = fit_segments(&widths, &all, bar - chip - 6.0, 6.0); - for i in 0..5 { + let now = fit(bar); + for i in 0..6 { assert!(prev[i] || !now[i], "segment {i} came back at {bar}px"); } prev = now; @@ -2221,12 +2382,56 @@ mod tests { fit_segments(&widths, &present, 400.0, 6.0), [true, false, true, false, true] ); - // Path no longer fits: tokens (lower priority) goes too. + // The last one no longer fits: nothing after it comes back either. assert_eq!( fit_segments(&widths, &present, 150.0, 6.0), [true, false, false, false, false] ); } + + /// Width of a path as characters: the elision's `fits` stand-in. + fn elide(path: &str, chars: usize) -> String { + elide_path(path, |t| t.chars().count() <= chars) + } + + #[test] + fn path_keeps_its_last_folders_behind_an_ellipsis() { + let p = "~/a/PersProject/OmpDeck"; + assert_eq!(elide(p, 40), p, "room for all of it"); + assert_eq!(elide(p, 23), p); + assert_eq!(elide(p, 22), "…/PersProject/OmpDeck"); + assert_eq!( + elide(p, 20), + "…/OmpDeck", + "drops the folders from the front" + ); + assert_eq!(elide(p, 9), "…/OmpDeck"); + assert_eq!( + elide(p, 3), + "…/OmpDeck", + "the last folder stays whatever the room" + ); + let w = "/work/OmpDeck-worktrees/bar-usage-fix"; + assert_eq!(elide(w, 34), "…/OmpDeck-worktrees/bar-usage-fix"); + assert_eq!(elide(w, 10), "…/bar-usage-fix"); + } + + #[test] + fn path_elision_handles_roots_windows_separators_and_unicode() { + assert_eq!(elide("~", 0), "~"); + assert_eq!(elide("/", 0), "/"); + assert_eq!( + elide("/proj", 0), + "/proj", + "a root folder has no parent to cut" + ); + assert_eq!(elide("C:\\Users\\me\\proj", 10), "…\\me\\proj"); + assert_eq!(elide("~/café/ünï", 8), "…/ünï"); + // A folder name longer than the minimum is ellipsized itself, never split mid-character. + let long = elide("/a/b/ÀÉÎÕÜÀÉÎÕÜÀÉÎÕÜÀÉÎÕÜÀÉÎÕÜ", 0); + assert_eq!(long.chars().count(), PATH_MIN_CHARS); + assert!(long.starts_with('…') && long.ends_with('Ü')); + } } #[cfg(test)] @@ -2398,3 +2603,256 @@ mod centering_tests { assert_eq!(before.bottom() - after.bottom(), BAR_H); } } + +#[cfg(test)] +mod layout_tests { + use super::*; + use deck_core::config::{Density, UiFont, UiStyle}; + use deck_core::status::PrInfo; + + /// An Anthropic account with a 5 h and a weekly window. + const USAGE: &str = r#"{"generatedAt":1000,"reports":[{"provider":"anthropic","fetchedAt":1000, + "limits":[ + {"id":"a:5h","label":"Claude 5 Hour","scope":{"windowId":"5h"},"window":{"id":"5h","label":"5 Hour","resetsAt":8040000}, + "amount":{"usedFraction":0.37,"unit":"percent"},"status":"ok"}, + {"id":"a:7d","label":"Claude 7 Day","scope":{"windowId":"7d"},"window":{"id":"7d","label":"7 Day","resetsAt":277200000}, + "amount":{"usedFraction":0.815,"unit":"percent"},"status":"ok"}], + "metadata":{"email":"me@example.test","planType":"pro"}}], + "accountsWithoutUsage":[],"disabledCredentials":[]}"#; + + const FOLDER: &str = "bar-usage-fix"; + const WORKTREE: &str = "/work/OmpDeck-worktrees/bar-usage-fix"; + const BRANCH: &str = "feat/statusbar-usage"; + + /// What of the bar's droppable segments a render painted. + #[derive(Debug, Default, PartialEq)] + struct Shown { + ctx: bool, + usage: bool, + git: bool, + cost: bool, + tokens: bool, + /// The path chip's text. + path: Option, + } + + impl Shown { + /// `[ctx, usage, git, cost, tokens, path]` in priority order. + fn flags(&self) -> [bool; 6] { + [ + self.ctx, + self.usage, + self.git, + self.cost, + self.tokens, + self.path.is_some(), + ] + } + } + + /// The bar of a session with everything to show, rendered in the user's setup (Lexend UI + /// font, Neutral style, Compact density). + struct Rig { + ctx: egui::Context, + view: BarView, + cat: ModelsCache, + picker: PickerState, + } + + impl Rig { + fn new(cwd: &str) -> Rig { + let ctx = egui::Context::default(); + crate::ui_fonts::install(&ctx, crate::ui_fonts::UiFonts::none()); + ctx.run_ui(egui::RawInput::default(), |_| {}) + .textures_delta + .clear(); + crate::ui_fonts::finalize(&ctx); + theme::apply( + &ctx, + UiStyle::Neutral, + Density::Compact, + UiFont::Lexend, + true, + ); + let mut cat = ModelsCache::default(); + cat.finish(Ok(vec![ModelInfo { + selector: "anthropic/claude-opus-5-5".to_owned(), + provider: "anthropic".to_owned(), + id: "claude-opus-5-5".to_owned(), + name: "Claude Opus 5.5".to_owned(), + context_window: Some(1_000_000), + thinking: vec!["low".into(), "high".into()], + ..Default::default() + }])); + let st = SessionStatus { + cwd: cwd.into(), + stats: SessionStats { + model: Some("anthropic/claude-opus-5-5".into()), + thinking: Some("high".into()), + input_tokens: 50_000, + output_tokens: 200, + cost: 7.0, + context_tokens: Some(173_000), + ..SessionStats::default() + }, + git: Some(GitInfo { + branch: BRANCH.into(), + ahead: 2, + behind: 1, + staged: 1, + unstaged: 3, + untracked: 2, + pr: Some(PrInfo { + number: 12, + url: "u".into(), + }), + ..GitInfo::default() + }), + ..SessionStatus::default() + }; + let mut view = BarView::default(); + view.update(Some(&Arc::new(st)), Path::new("/x"), &cat); + let usage = deck_core::usage::UsageSnapshot::parse(USAGE).expect("fixture parses"); + view.set_usage(Some(&Arc::new(Summary::build(&usage, 0)))); + Rig { + ctx, + view, + cat, + picker: PickerState::default(), + } + } + + /// Renders the bar at `width` and reports which segments it painted. + fn shown(&mut self, width: f32) -> Shown { + let input = BarInput { + catalog: &self.cat, + favorites: &[], + provider: None, + activity: Activity::Idle, + pending: None, + assumed: None, + actions: ActionCtx::default(), + agents: AgentsChip::default(), + session: SessionExtras { + tab: 0, + task_unavailable: None, + }, + }; + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 700.0))), + ..Default::default() + }; + let mut texts = Vec::new(); + for i in 0..3 { + let mut out = self.ctx.run_ui(raw.clone(), |ui| { + show(ui, &self.view, &mut self.picker, &input); + }); + out.textures_delta.clear(); + if i == 2 { + for cs in &out.shapes { + if let Shape::Text(t) = &cs.shape { + let r = t.galley.rect.translate(t.pos.to_vec2()); + texts.push((t.galley.text().to_owned(), r)); + } + } + } + } + // The info row is what lies left of the actions row (it starts at "Agents"). + let actions = texts + .iter() + .find(|(t, _)| t == "Agents") + .map_or(width, |(_, r)| r.left()); + let info: Vec<&str> = texts + .iter() + .filter(|(_, r)| r.right() <= actions) + .map(|(t, _)| t.as_str()) + .collect(); + let has = |s: &str| info.contains(&s); + Shown { + ctx: has("17.3%/1M"), + usage: has("5h") && has("wk"), + git: has(BRANCH), + cost: has("$7.00"), + tokens: has("50K"), + path: info + .iter() + .find(|t| t.ends_with(FOLDER)) + .map(|t| (*t).to_owned()), + } + } + } + + #[test] + fn usage_chip_shows_with_ctx_and_git_at_the_users_bar_width() { + // 1920 px window, 425 px sidebar: the bar is about 1470 px. + let mut rig = Rig::new(WORKTREE); + let at_user = rig.shown(1470.0); + assert!( + at_user.ctx && at_user.usage && at_user.git, + "1470 px shows ctx, usage and git: {at_user:?}" + ); + let narrow = rig.shown(1100.0); + assert!( + narrow.ctx && narrow.usage, + "1100 px keeps ctx and usage: {narrow:?}" + ); + // The usage chip carries both windows. + assert!(rig.view.usage.as_ref().is_some_and(|u| u.chip.is_some())); + } + + #[test] + fn path_elides_instead_of_pushing_usage_out() { + let mut rig = Rig::new(WORKTREE); + // A bar wide enough for everything shows the path whole. + let wide = rig.shown(3200.0); + assert_eq!(wide.path.as_deref(), Some(WORKTREE), "{wide:?}"); + assert!(wide.flags().iter().all(|s| *s), "{wide:?}"); + + // Narrowing keeps usage and elides the path from the front until it goes. + let mut elided = None; + let mut width = 3200.0; + let mut last_len = usize::MAX; + while width >= 1100.0 { + let s = rig.shown(width); + assert!(s.usage, "usage dropped at {width}px for the path: {s:?}"); + if let Some(p) = &s.path { + let len = p.chars().count(); + assert!(len <= last_len, "the path grew at {width}px: {p}"); + last_len = len; + if p.starts_with('…') { + assert!(p.ends_with(FOLDER), "{p}"); + elided.get_or_insert(p.clone()); + } + } + width -= 5.0; + } + assert!( + elided.is_some(), + "the path never elided between 3200 and 1100 px" + ); + // The hover and click-to-copy keep the whole path. + assert_eq!(rig.view.path_full, WORKTREE); + assert_eq!(rig.view.path_text, WORKTREE); + } + + #[test] + fn segments_drop_in_priority_order_and_never_come_back_on_a_narrower_bar() { + let mut rig = Rig::new(WORKTREE); + let mut prev = [true; 6]; + let mut width = 3200.0; + while width >= 500.0 { + let now = rig.shown(width).flags(); + for i in 0..6 { + assert!(prev[i] || !now[i], "segment {i} came back at {width}px"); + // ctx, usage, git, cost, tokens, path: never one without all before it. + assert!( + i == 0 || !now[i] || now[i - 1], + "segment {i} shown without {} at {width}px", + i - 1 + ); + } + prev = now; + width -= 5.0; + } + } +} diff --git a/crates/omp-deck/src/ui_fonts.rs b/crates/omp-deck/src/ui_fonts.rs index cd55414..4b7fb0d 100644 --- a/crates/omp-deck/src/ui_fonts.rs +++ b/crates/omp-deck/src/ui_fonts.rs @@ -118,6 +118,7 @@ static FACES: [(UiFont, &str, &[u8]); 18] = [ /// Font-data key of the face of the flat styles' default UI font. const NEUTRAL_FACE_KEY: &str = "ui-font-jetbrains-mono"; static LUCIDE: &[u8] = include_bytes!("../assets/fonts/lucide.ttf"); +static NERD_SYMBOLS: &[u8] = include_bytes!("../assets/fonts/SymbolsNerdFontMono-Regular.ttf"); /// Name of the UI text family of `FACES[i]`: that face, then the whole Proportional chain. fn text_family_name(i: usize) -> String { @@ -239,10 +240,9 @@ fn neutral_family() -> FontFamily { FontFamily::Name(NEUTRAL_FAMILY.into()) } -/// Installs every bundled UI face (heading its preview family and its UI text family, so both -/// exist from the first frame; JetBrains Mono also heads the Neutral family), the icon font and -/// the preloaded system UI faces (no I/O). The first system face becomes the primary -/// Proportional face; the rest are appended as extra glyph fallbacks. +/// Installs bundled UI and icon faces, plus available system UI faces. Nerd Font Symbols supplies +/// private-use glyphs commonly present in session titles; Lucide supplies OmpDeck's app icons. +/// The first system face is the primary Proportional face; all later faces become glyph fallbacks. pub fn install(ctx: &Context, fonts: UiFonts) { for (i, (_, key, bytes)) in FACES.iter().enumerate() { let mut families = vec![ @@ -295,6 +295,14 @@ pub fn install(ctx: &Context, fonts: UiFonts) { }, ], )); + ctx.add_font(FontInsert::new( + "nerd-font-symbols", + FontData::from_static(NERD_SYMBOLS), + vec![InsertFontFamily { + family: FontFamily::Proportional, + priority: FontPriority::Lowest, + }], + )); } /// `first` followed by every other face of the Proportional chain `prop`, in order. @@ -304,12 +312,10 @@ fn chain(first: &str, prop: &[String]) -> Vec { .collect() } -/// Makes the Neutral family and every bundled face's UI text family that face followed by every -/// Proportional face in order (system UI faces, egui's emoji faces, deck-term's symbol / CJK / -/// color-emoji fallbacks, Lucide), so none covers fewer glyphs than Neon. Call once from the -/// first frame, when the final definitions (after every queued `add_font`) are readable. -/// Every UI font choice is prepared here, so switching fonts later only selects another family: -/// a runtime `set_fonts` would rebuild the glyph atlas under every cached galley. +/// Makes the Neutral family and every bundled face's UI text family that face followed by the full +/// Proportional chain (system UI faces, deck-term symbols, emoji, Lucide, Nerd Font Symbols), so no +/// bundled choice covers fewer glyphs than the default. Call once after queued font inserts settle. +/// Font choices are prepared once; runtime `set_fonts` would rebuild glyph atlases under cached galleys. pub fn finalize(ctx: &Context) { let mut defs: FontDefinitions = ctx.fonts(|f| f.definitions().clone()); let prop = defs @@ -380,4 +386,23 @@ mod tests { assert_eq!(&chain[1..], prop.as_slice(), "{font:?}"); } } + #[test] + fn nerd_font_private_use_glyphs_resolve_through_proportional_fallback() { + let ctx = Context::default(); + install(&ctx, UiFonts::none()); + ctx.run_ui(egui::RawInput::default(), |_| {}) + .textures_delta + .clear(); + finalize(&ctx); + ctx.run_ui(egui::RawInput::default(), |_| {}) + .textures_delta + .clear(); + let width = ctx.fonts_mut(|fonts| { + fonts.glyph_width( + &egui::FontId::new(14.0, FontFamily::Proportional), + '\u{f082b}', + ) + }); + assert!(width > 0.0, "Nerd Font U+F082B must resolve to a glyph"); + } } diff --git a/docs/notes/contracts.md b/docs/notes/contracts.md index 98cc91a..21ec44d 100644 --- a/docs/notes/contracts.md +++ b/docs/notes/contracts.md @@ -986,8 +986,8 @@ tuned for contrast on white (zinc/tailwind-600 family). OSC 10/11/4 replies foll - Icons: lucide icon font (ISC; license shipped) replaces Material Symbols everywhere (both styles); `egui_material_icons` dependency removed. - Density Compact: tighter sidebar rows/headers, tab row, title row, menus, tree/picker rows, paddings. - The status bar keeps its user-tuned size (41 px bar, model chip 1.3 × 213 px in every session, at most 52 % of - the bar; it does not follow the path chip, so a long worktree path cannot push usage out). + The status bar keeps its user-tuned size (bar height and model chip width in `statusbar.rs`; since 0.26.1 the model + chip yields toward `MODEL_CHIP_MIN` so Ctx + Usage fit, and the path chip is elastic, so a long path cannot push usage out). - Settings → Appearance: Theme (Dark/Light), Style (Neutral/Midnight neon), Density (Compact/Comfortable); live switch, persisted. - Sidebar expansion: open(group) = filtering || override(group) ?? (group == active tab's project group). @@ -1071,8 +1071,10 @@ shape, stats.db schema, extension imports, timings). - Usage state owned by the app: background worker runs `fetch_usage` ~3 s after startup, every 5 min, when the Usage view opens and data is > 60 s old, and on manual refresh (`Mutation::UsageRefresh`, no fields); and `activity` when the view opens (≥ 10 min apart). UI thread never blocks. -- Status bar **usage chip** (gauge icon + tightest `NN% left`, coloured by status; dropped first when - narrow; hidden when there are no reports). Hover: compact per-provider cards (bars = remaining, +- Status bar **usage chip** (gauge icon + one pill per window of the model's provider, 5 h / weekly; fallback tightest + `NN% left`; coloured by status). Since 0.26.1 segment priority is Ctx, Usage, Git, Cost, Tokens, Path (path elided from + the front as `…/last/folders` into the leftover width, dropped below its last folder); hidden when there are no reports. + Hover: compact per-provider cards (bars = remaining, `resets in …`). Click, ⋯ menu "Usage…", app menu, Ctrl+Shift+U → **Usage view**. A multi-account provider shows one account in the chip and in the hover, both from `usage_view::shown_page`: the page whose report matches the focused tab's `LiveSnapshot.account` (`CardView::in_use`, same provider only; labelled `email · in use`, or @@ -3765,3 +3767,46 @@ Go here is inert on an excluded row. Advisor banner shows its own save/validatio `Namespace::list` + `kill`, then the root) — host sessions used to survive with their sockets deleted. - Not covered: mobile `Op`s for the new mutations (desktop only); spending a saved reset was not exercised live (no accounts in the sandbox). + +## 0.26.2: model / thinking changes without touching the composer (user, 2026-11-03) + +User decision: an **explicit, scoped exception** to the "runtime commands migrate to ctl only all together" rule, for +`/switch` and `/effort` only (every other runtime command stays typed). Facts: `agent://SwitchParity` / +`agent://DraftStash` (omp 18.16.0). Goal: changing model or thinking from OmpDeck never asks to discard the composer +draft and never touches it (text, caret, pasted blocks, images stay). + +- **ctl** (`deck_core::ctl`, `assets/ompdeck-ctl.ts`), on the main session, same root check as other commands: + - `CtlCommand::SwitchModel { selector: String }` — exactly omp's TUI `/switch ` minus the UI notice: + `resolveCliModel({cliModel, modelRegistry: sess.modelRegistry, availableModels: scoped models or undefined, + settings: sess.settings, preferences: getModelMatchPreferences(sess.settings)})`; no model → error + `Unknown model: ` (resolver `warning` → `CtlReply.message`); **over context** (`contextWindow > 0 && + getContextUsage().tokens > contextWindow`) → `ok:false, error:"needs_compact"` without changing anything; + else `level = resolved.thinkingLevel ?? sess.resolveTemporaryModelThinkingLevel(model)`, + `await sess.setModelTemporary(model, level)` (thrown error → error text). Reply `value`: `{model:"provider/id", + thinking}`; `message`: `Session-only model: provider/id` (+ warning). + - `CtlCommand::SetEffort { level: String }` — `resolveThinkingArgument(sess, level)` (error → its text), then + `sess.setThinkingLevel(r.level)`; reply `message` `Thinking set to .`, `value` `{thinking}`. + - Both push a fresh snapshot (model / thinking) after the change; never gated on idle (omp's slash commands are not). +- **Gateway** (`model_switch.rs` / controller): a picker/palette/PWA model or effort change on a session whose live + control is connected and matches the target goes through these ctl commands first; it never raises the draft + confirm. `needs_compact`, no/mismatched live control, or a transport failure before the command was delivered → + the existing typed `/switch` / `/effort` path (with its draft confirm) — never both. omp's errors surface as they + do today for the typed path (toast); the success `message` becomes a quiet toast. Pending-model tracking is + satisfied by the reply/snapshot. Existing gating for model changes stays as it is. + +### As built (0.26.2) +- Gateway: `engine/model_route.rs` from the `Mutation::Submit` arm (GUI, palette, PWA all benefit); `Controller::model_change` + gives the gating + `CtlCommand` mapping (`:level` stays in the selector; same model + level → `SetEffort`). Success + → `Accepted { submitted: None }` then `Done::Message()`; `needs_compact` / not delivered → typed path via the + shared deferred `hand_over`; delivered-but-unknown → `Failed("omp may not have changed the model …")`, never retyped. +- GUI: `Gw::submit`'s accepted callback takes `Option`; `None` = handled without injection (quiet toast from + `Done::Message`, error toast on failure). The bar's model prefers the live-control model; `deck_core::status` follows + the newest `model_change` by file order (an old model's reply after a mid-turn switch no longer reverts it). Chat + reply headers keep the model name they were first drawn under (`chat_view/ctx.rs` `Local.bylines`). +- **omp version drift**: both routes use omp internals (`config/model-resolver`, `slash-commands/helpers/effort`, + `AgentSession.setModelTemporary` / `resolveTemporaryModelThinkingLevel` / `getContextUsage` / `setThinkingLevel`), + never patched or overridden. Any missing/unexpected piece answers `unsupported` before changing anything and the + gateway falls back to the typed command (also for the older `… unavailable` / `unknown cmd` replies). The + over-context check mirrors `selector-controller.ts` exactly (`contextWindow ?? 0`, `tokens ?? 0`). Live tests + (`ctl_tree_live`) assert a current omp never answers `unsupported`; CI's Windows job installs the latest omp but runs + only `ctl_live`, so drift is caught by local live runs.