Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`~`).
Expand Down
68 changes: 68 additions & 0 deletions crates/deck-core/assets/ompdeck-ctl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <selector>` (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<any> {
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 <level>`: resolveThinkingArgument + session.setThinkingLevel (not persisted).
async function setEffort(cmd: any): Promise<any> {
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<any> {
if (cmd.t === "list") return { ok: true, snapshot: snapshot() };
if (cmd.t === "fork_at" || cmd.t === "navigate") {
Expand Down Expand Up @@ -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();
Expand Down
14 changes: 11 additions & 3 deletions crates/deck-core/src/changes/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");

Expand Down
58 changes: 58 additions & 0 deletions crates/deck-core/src/ctl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,20 @@ pub enum CtlCommand {
root: Option<PathBuf>,
model: Option<String>,
},
/// omp's TUI `/switch <selector>` 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: <selector>` when nothing matches; error
/// `needs_compact` (nothing changed) when the transcript exceeds the target's context window.
SwitchModel {
selector: String,
},
/// omp's TUI `/effort <level>` on the main session: reply `value` `{thinking}`, `message`
/// `Thinking set to <level>.`; 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"`
Expand Down Expand Up @@ -877,6 +891,12 @@ fn encode(rid: u64, cmd: &CtlCommand) -> Vec<u8> {
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,
Expand Down Expand Up @@ -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 =
Expand Down
Loading
Loading