fix(onboarding): honest first run: chat-capable Ollama pick, no-model launch line, turn-scoped Deny (U2-U6)

0.10.1 addendum Experience items U2, U3, U4, U5 and U6 (U1 stays with the
Engine lane).

U2 (UX-2): Ollama adoption no longer takes the alphabetically first tag,
which on the re-run was nomic-embed-text:latest. The probe asks
POST /api/show (bounded to 8 concurrent lookups) for capabilities and
context length. A tag qualifies when it reports `completion`, or, when the
daemon reports no capabilities, when its name is not embed/bge/rerank/minilm.
Ranking: coder or tool-capable first, then largest context, then name.
An embed-only catalog adopts nothing. The test that pinned tags.first()
is replaced.

U3 (UX-3): a keyless launch card now says "no model connected · run
/provider" in warning ink. The fit ladder sheds this line last, after the
recent list. README no longer claims the first run walks you through
setup. The route chip is not changed: widening it to onboarding_needs_api_key
flipped 8 frame/ui tests whose fixtures carry no key, so that is left for a
fixture pass.

U4 (UX-11): the Fleet intro is no longer pushed at launch or when Ready
finishes. It shows the first time the user opens /fleet or enters Operate.
The Ready rail drops the untypeable "/rc" hint, and "C" reads "change the
look".

U5 (UX-8): a Deny now lasts one user turn (cleared on TurnStarted), not the
whole process. /new, /clear and a session switch/resume also clear
"approve for session" grants. The notice now says "Send a new message to
be asked again" instead of "Restart Codewhale", in all 15 packs.

U6 (UX-4): the telemetry disclosure is a System transcript cell instead of
a 12 s toast (at 100 columns the toast showed only its first sentence). It
is recorded as presented only after a frame containing it has been drawn.
It waits until the launch card starts to leave (and never lands under a
live active cell), so it cannot hide the no-model line.

Checks (targeted):
- cargo test -p codewhale-tui --lib -- tui::ui:: tui::underwater
  tui::infoline golden tui::widgets tui::onboarding tui::app:: commands::
  local_ollama: 2538 passed, 0 failed, 1 ignored
- cargo test -p codewhale-localization: 50 passed, 0 failed
- cargo clippy -p codewhale-tui --lib: no findings in touched files
- not run: the PTY suites or a live Ollama daemon

Refs: 0.10.1 addendum U2-U6 (codewhale-ops/releases/0.10.1/ADDENDUM-EXPERIENCE.md)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 16:54:28 -07:00
1 parent 55b6dea791
commit 28a0fea551
26 files changed
+526 -78

No files matched your search

+5 -2
View File
@@ -43,8 +43,11 @@ For an existing direct install, run `codewhale update` (or `codewhale update --c
to inspect it). The updater prints the executable path and keeps newer builds.
The first run helps you connect a provider or configure Codewhale offline.
Model replies require a connected hosted or local model. Codewhale also
The first run opens straight to the composer; it does not walk you through
setup. Model replies require a connected hosted or local model: until one is
connected, the launch screen says "no model connected". Run `/provider` (or
press F3) to add a hosted key or pick a local runtime. If Ollama is already
running with a chat model, Codewhale switches to it on its own. Codewhale also
supports npm and Cargo as secondary packaging routes, plus Docker, Nix, Scoop,
Android/Termux, and an optional CNB mirror. Existing package-managed installs
receive migration instructions. See [installation and PATH help](docs/INSTALL.md).
+1 -1
View File
@@ -1097,7 +1097,7 @@
"ApprovalChooseAction": "Enter per a l'opció seleccionada, o prem y/a/d directament",
"ApprovalIntentLabel": "Intenció: ",
"ApprovalMoreLines": " … (+{count} línies)",
"ApprovalAutoDeniedSession": "{tool} denegat automàticament: una sol·licitud coincident es va denegar abans en aquesta execució de Codewhale. Reinicia Codewhale per reconsiderar-la.",
"ApprovalAutoDeniedSession": "{tool} denegat automàticament: ja has denegat una sol·licitud coincident en aquest torn. Envia un missatge nou perquè se't torni a preguntar.",
"ElevationTitleSandboxDenied": " ⚠, Sandbox denegat ",
"ElevationTitleRequired": " Cal elevació del sandbox ",
"ElevationFieldTool": " Eina: ",
+1 -1
View File
@@ -1097,7 +1097,7 @@
"ApprovalChooseAction": "Enter wählt die Option, oder direkt y/a/d drücken",
"ApprovalIntentLabel": "Absicht: ",
"ApprovalMoreLines": " … (+{count} Zeilen)",
"ApprovalAutoDeniedSession": "Auto-abgelehnt {tool}: eine passende Anfrage wurde früher in diesem Codewhale-Lauf abgelehnt. Codewhale neu starten, um sie erneut zu prüfen.",
"ApprovalAutoDeniedSession": "Auto-abgelehnt {tool}: Sie haben eine passende Anfrage in dieser Runde bereits abgelehnt. Senden Sie eine neue Nachricht, um erneut gefragt zu werden.",
"ElevationTitleSandboxDenied": " ⚠, Sandbox verweigert ",
"ElevationTitleRequired": " Sandbox-Elevation erforderlich ",
"ElevationFieldTool": " Tool: ",
+2 -2
View File
@@ -870,7 +870,7 @@
"OnboardReadyTitle": "You're ready.",
"OnboardReadyLead": "Tell Codewhale what you want done.",
"OnboardReadyStart": "start",
"OnboardReadyCustomize": "customize the look later",
"OnboardReadyCustomize": "change the look",
"OnboardSeedCodeProject": "Explain this project and list its main entry points.",
"OnboardSeedFolder": "Look at this folder and suggest a good first task.",
"SetupWizardTitle": "Setup",
@@ -1120,7 +1120,7 @@
"ApprovalChooseAction": "Enter selected option, or press y/a/d directly",
"ApprovalIntentLabel": "Intent: ",
"ApprovalMoreLines": " … (+{count} lines)",
"ApprovalAutoDeniedSession": "Auto-denied {tool}: a matching request was denied earlier during this Codewhale run. Restart Codewhale to reconsider it.",
"ApprovalAutoDeniedSession": "Auto-denied {tool}: you denied a matching request earlier in this turn. Send a new message to be asked again.",
"ElevationTitleSandboxDenied": " ⚠, Sandbox Denied ",
"ElevationTitleRequired": " Sandbox Elevation Required ",
"ElevationFieldTool": " Tool: ",
+1 -1
View File
@@ -1118,7 +1118,7 @@
"ApprovalChooseAction": "Enter para seleccionar, o presione y/a/d directamente",
"ApprovalIntentLabel": "Intención: ",
"ApprovalMoreLines": " … (+{count} líneas)",
"ApprovalAutoDeniedSession": "Se rechazó automáticamente {tool}: se rechazó antes una solicitud coincidente durante esta ejecución de Codewhale. Reinicia Codewhale para reconsiderarla.",
"ApprovalAutoDeniedSession": "Se rechazó automáticamente {tool}: rechazaste antes una solicitud coincidente en este turno. Envía un mensaje nuevo para que se te vuelva a preguntar.",
"ElevationTitleSandboxDenied": " ⚠, Sandbox Denegado ",
"ElevationTitleRequired": " Elevación de Sandbox Requerida ",
"ElevationFieldTool": " Herramienta: ",
+1 -1
View File
@@ -1097,7 +1097,7 @@
"ApprovalChooseAction": "Enter pour l'option sélectionnée, ou appuyez directement sur y/a/d",
"ApprovalIntentLabel": "Intention : ",
"ApprovalMoreLines": " … (+{count} lignes)",
"ApprovalAutoDeniedSession": "{tool} refusé automatiquement : une demande correspondante a été refusée plus tôt pendant cette exécution de Codewhale. Redémarrez Codewhale pour la reconsidérer.",
"ApprovalAutoDeniedSession": "{tool} refusé automatiquement : vous avez déjà refusé une demande correspondante pendant ce tour. Envoyez un nouveau message pour être à nouveau sollicité.",
"ElevationTitleSandboxDenied": " ⚠, Sandbox refusé ",
"ElevationTitleRequired": " Élévation du sandbox requise ",
"ElevationFieldTool": " Outil : ",
+1 -1
View File
@@ -1097,7 +1097,7 @@
"ApprovalChooseAction": "चुना विकल्प भेजें, या सीधे y/a/d दबाएँ",
"ApprovalIntentLabel": "इरादा: ",
"ApprovalMoreLines": " … (+{count} पंक्तियाँ)",
"ApprovalAutoDeniedSession": "स्वतः अस्वीकृत {tool}: इस Codewhale रन में पहले मिलता-जुलता अनुरोध अस्वीकृत हुआ था। पुनर्विचार के लिए Codewhale फिर शुरू करें।",
"ApprovalAutoDeniedSession": "स्वतः अस्वीकृत {tool}: आपने इसी टर्न में पहले मिलता-जुलता अनुरोध अस्वीकार किया था। फिर से पूछे जाने के लिए नया संदेश भेजें।",
"ElevationTitleSandboxDenied": " ⚠, सैंडबॉक्स अस्वीकृत ",
"ElevationTitleRequired": " सैंडबॉक्स एलिवेशन आवश्यक ",
"ElevationFieldTool": " टूल: ",
+1 -1
View File
@@ -1097,7 +1097,7 @@
"ApprovalChooseAction": "Enter untuk opsi terpilih, atau tekan y/a/d langsung",
"ApprovalIntentLabel": "Maksud: ",
"ApprovalMoreLines": " … (+{count} baris)",
"ApprovalAutoDeniedSession": "Ditolak otomatis {tool}: permintaan yang cocok telah ditolak sebelumnya selama run Codewhale ini. Mulai ulang Codewhale untuk meninjaunya kembali.",
"ApprovalAutoDeniedSession": "Ditolak otomatis {tool}: Anda sudah menolak permintaan yang cocok pada giliran ini. Kirim pesan baru agar ditanya lagi.",
"ElevationTitleSandboxDenied": " ⚠ Sandbox Ditolak ",
"ElevationTitleRequired": " Perlu Elevasi Sandbox ",
"ElevationFieldTool": " Tool: ",
+1 -1
View File
@@ -1118,7 +1118,7 @@
"ApprovalChooseAction": "Enterで選択、または y/a/d を直接入力",
"ApprovalIntentLabel": "意図:",
"ApprovalMoreLines": " … (+{count} 行)",
"ApprovalAutoDeniedSession": "{tool} を自動的に拒否しました: この Codewhale の実行中に一致するリクエストが以前拒否されています。再検討するには Codewhale を再起動してください。",
"ApprovalAutoDeniedSession": "{tool} を自動的に拒否しました: このターンで一致するリクエストをすでに拒否しています。もう一度確認するには新しいメッセージを送信してください。",
"ElevationTitleSandboxDenied": " ⚠, サンドボックス拒否 ",
"ElevationTitleRequired": " サンドボックス昇格 ",
"ElevationFieldTool": " ツール:",
+1 -1
View File
@@ -1120,7 +1120,7 @@
"ApprovalChooseAction": "Enter로 선택 항목 적용, 또는 y/a/d를 바로 누르세요",
"ApprovalIntentLabel": "의도: ",
"ApprovalMoreLines": " … (+{count}줄)",
"ApprovalAutoDeniedSession": "{tool} 자동 거부: 이번 Codewhale 실행 중 일치하는 요청이 이전에 거부되었습니다. 다시 검토하려면 Codewhale을 재시작하세요.",
"ApprovalAutoDeniedSession": "{tool} 자동 거부: 이번 턴에서 일치하는 요청을 이미 거부했습니다. 다시 확인받으려면 새 메시지를 보내세요.",
"ElevationTitleSandboxDenied": " ⚠, 샌드박스 거부됨 ",
"ElevationTitleRequired": " 샌드박스 권한 상승 필요 ",
"ElevationFieldTool": " 도구: ",
+1 -1
View File
@@ -1118,7 +1118,7 @@
"ApprovalChooseAction": "Enter para selecionar, ou pressione y/a/d diretamente",
"ApprovalIntentLabel": "Intenção: ",
"ApprovalMoreLines": " … (+{count} linhas)",
"ApprovalAutoDeniedSession": "{tool} foi negado automaticamente: uma solicitação correspondente foi negada anteriormente nesta execução do Codewhale. Reinicie o Codewhale para reconsiderá-la.",
"ApprovalAutoDeniedSession": "{tool} foi negado automaticamente: você negou uma solicitação correspondente antes neste turno. Envie uma nova mensagem para ser perguntado de novo.",
"ElevationTitleSandboxDenied": " ⚠, Sandbox Negado ",
"ElevationTitleRequired": " Elevação de Sandbox Necessária ",
"ElevationFieldTool": " Ferramenta: ",
+1 -1
View File
@@ -1097,7 +1097,7 @@
"ApprovalChooseAction": "Enter — выбранный вариант, или нажмите y/a/d напрямую",
"ApprovalIntentLabel": "Намерение: ",
"ApprovalMoreLines": " … (ещё {count} строк)",
"ApprovalAutoDeniedSession": "{tool} отклонён автоматически: похожий запрос уже был отклонён в этом запуске Codewhale. Перезапустите Codewhale, чтобы пересмотреть.",
"ApprovalAutoDeniedSession": "{tool} отклонён автоматически: вы уже отклонили похожий запрос в этом ходе. Отправьте новое сообщение, чтобы вас спросили снова.",
"ElevationTitleSandboxDenied": " ⚠, Отказ песочницы ",
"ElevationTitleRequired": " Требуется повышение прав песочницы ",
"ElevationFieldTool": " Инструмент: ",
+1 -1
View File
@@ -1097,7 +1097,7 @@
"ApprovalChooseAction": "Enter — вибрати опцію, або натисніть y/a/d напряму",
"ApprovalIntentLabel": "Намір: ",
"ApprovalMoreLines": " … (+{count} рядків)",
"ApprovalAutoDeniedSession": "Автоматично відхилено {tool}: подібний запит уже було відхилено під час цього запуску Codewhale. Перезапустіть Codewhale, щоб переглянути рішення.",
"ApprovalAutoDeniedSession": "Автоматично відхилено {tool}: ви вже відхилили подібний запит у цьому ході. Надішліть нове повідомлення, щоб вас запитали знову.",
"ElevationTitleSandboxDenied": " ⚠, Пісочниця відхилена ",
"ElevationTitleRequired": " Потрібне підвищення прав пісочниці ",
"ElevationFieldTool": " Інструмент: ",
+1 -1
View File
@@ -1118,7 +1118,7 @@
"ApprovalChooseAction": "Enter để chọn, hoặc nhấn y/a/d trực tiếp",
"ApprovalIntentLabel": "Ý định: ",
"ApprovalMoreLines": " … (+{count} dòng)",
"ApprovalAutoDeniedSession": "Đã tự động từ chối {tool}: một yêu cầu khớp đã bị từ chối trước đó trong lần chạy Codewhale này. Hãy khởi động lại Codewhale để xem xét lại.",
"ApprovalAutoDeniedSession": "Đã tự động từ chối {tool}: bạn đã từ chối một yêu cầu khớp trước đó trong lượt này. Hãy gửi tin nhắn mới để được hỏi lại.",
"ElevationTitleSandboxDenied": " ⚠ Sandbox Bị Từ Chối ",
"ElevationTitleRequired": " Yêu Cầu Nâng Cấp Sandbox ",
"ElevationFieldTool": " Công cụ: ",
+1 -1
View File
@@ -1118,7 +1118,7 @@
"ApprovalChooseAction": "Enter 执行选中项,或直接按 y/a/d",
"ApprovalIntentLabel": "意图:",
"ApprovalMoreLines": " … (还有 {count} 行)",
"ApprovalAutoDeniedSession": "已自动拒绝 {tool}:在本次 Codewhale 运行期间,已有匹配请求被拒绝。若要重新考虑,请重启 Codewhale。",
"ApprovalAutoDeniedSession": "已自动拒绝 {tool}:本轮中已有匹配请求被你拒绝。若要重新询问,请发送新消息。",
"ElevationTitleSandboxDenied": " ⚠ 沙箱拒绝 ",
"ElevationTitleRequired": " 沙箱提权 ",
"ElevationFieldTool": " 工具:",
+1 -1
View File
@@ -124,7 +124,7 @@
"AppModePlanHint": "先唯讀調研,行動前提出計畫",
"AppModeYolo": "完全存取(已棄用標籤)",
"AppModeYoloHint": "僅相容 — Act + 完全存取,不是可見模式",
"ApprovalAutoDeniedSession": "已自動拒絕 {tool}:在本次 Codewhale 執行期間,已有相符請求被拒絕。若要重新考慮,請重新啟動 Codewhale。",
"ApprovalAutoDeniedSession": "已自動拒絕 {tool}:本輪中已有相符請求被你拒絕。若要重新詢問,請傳送新訊息。",
"ApprovalBlockTitle": "審批",
"ApprovalCategoryAgent": "子代理",
"ApprovalCategoryFileWrite": "檔案寫入",
@@ -246,6 +246,10 @@ pub(crate) fn reset_conversation_state(app: &mut App) -> bool {
app.session.last_warmup_key = None;
app.session.last_tool_catalog = None;
app.session.last_base_url = None;
// A fresh conversation inherits neither this one's denials nor its
// "approve for session" grants (UX-8).
app.approval_session_denied.clear();
app.approval_session_approved.clear();
true
}
@@ -330,6 +330,31 @@ fn new_session_from_resumed_state_creates_distinct_empty_session() {
}
}
#[test]
fn new_session_forgets_denials_and_session_grants() {
// UX-8: a Deny used to outlive `/new` for the whole process ("Restart
// Codewhale to reconsider it"); a fresh conversation starts clean.
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.current_session_id = Some("old-session".to_string());
app.approval_session_denied
.insert("shell:rm -rf build:call-1".to_string());
app.approval_session_approved
.insert("shell:git status".to_string());
let result = new_session(&mut app, None);
assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
assert!(
app.approval_session_denied.is_empty(),
"a denied call must prompt again after /new"
);
assert!(
app.approval_session_approved.is_empty(),
"an approve-for-session grant must not follow the user into /new"
);
}
#[test]
fn new_session_blocks_unsent_input_without_force() {
let tmpdir = TempDir::new().unwrap();
+254 -12
View File
@@ -17,21 +17,116 @@ use crate::config::{ApiProvider, Config, DEFAULT_OLLAMA_BASE_URL};
const TAGS_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// Upper bound on `/api/show` lookups per probe. A developer box can hold
/// dozens of tags; ranking needs only the plausible chat candidates.
const SHOW_PROBE_LIMIT: usize = 8;
/// Result of a successful local Ollama tags/models probe.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LiveLocalOllamaCatalog {
pub(crate) endpoint_v1: String,
pub(crate) tags: Vec<String>,
/// The tag adoption may switch to: a model that can hold a conversation.
/// `None` when every live tag is an embedding/reranker model — adopting
/// one of those would make every first message fail.
pub(crate) chat_tag: Option<String>,
}
impl LiveLocalOllamaCatalog {
/// Prefer the alphabetically first live tag (matches route_runtime's
/// Ollama default when tags have no `default_for_provider` flag).
/// The chat-capable tag to adopt, if the catalog has one.
pub(crate) fn preferred_tag(&self) -> Option<&str> {
self.tags.first().map(String::as_str)
self.chat_tag.as_deref()
}
}
/// What `/api/show` reports about one tag. Both fields are optional because
/// older daemons omit `capabilities` and some architectures omit a context
/// length; a missing fact is unknown, never a "no".
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct OllamaTagProfile {
pub(crate) capabilities: Option<Vec<String>>,
pub(crate) context_length: Option<u64>,
}
impl OllamaTagProfile {
fn has_capability(&self, name: &str) -> Option<bool> {
self.capabilities
.as_ref()
.map(|caps| caps.iter().any(|cap| cap.eq_ignore_ascii_case(name)))
}
}
/// Name heuristic for tags that cannot chat: embedding and reranking models.
/// Used only when the daemon did not report capabilities.
pub(crate) fn looks_like_non_chat_tag(tag: &str) -> bool {
let lower = tag.to_ascii_lowercase();
["embed", "bge", "rerank", "minilm"]
.iter()
.any(|needle| lower.contains(needle))
}
fn looks_like_coder_tag(tag: &str) -> bool {
let lower = tag.to_ascii_lowercase();
lower.contains("coder") || lower.contains("code")
}
/// Pick the tag adoption should switch to.
///
/// A tag is a chat candidate when `/api/show` lists `completion`, or — when
/// the daemon reported no capabilities — when its name is not an embedding or
/// reranker. Among candidates: coder or tool-capable models first, then the
/// largest reported context, then alphabetical order for stability.
pub(crate) fn choose_chat_tag(
tags: &[String],
profiles: &std::collections::HashMap<String, OllamaTagProfile>,
) -> Option<String> {
let unknown = OllamaTagProfile::default();
tags.iter()
.filter_map(|tag| {
let profile = profiles.get(tag).unwrap_or(&unknown);
let chat = match profile.has_capability("completion") {
Some(known) => known,
None => !looks_like_non_chat_tag(tag),
};
if !chat {
return None;
}
let preferred =
looks_like_coder_tag(tag) || profile.has_capability("tools").unwrap_or(false);
Some((
preferred,
profile.context_length.unwrap_or(0),
std::cmp::Reverse(tag.as_str()),
tag,
))
})
.max_by(|a, b| (a.0, a.1, &a.2).cmp(&(b.0, b.1, &b.2)))
.map(|(_, _, _, tag)| tag.clone())
}
#[derive(Debug, Deserialize)]
struct OllamaShowResponse {
#[serde(default)]
capabilities: Option<Vec<String>>,
#[serde(default)]
model_info: Option<serde_json::Map<String, serde_json::Value>>,
}
/// Parse `POST /api/show` JSON into the facts adoption ranks on.
pub(crate) fn parse_ollama_show_response(payload: &str) -> anyhow::Result<OllamaTagProfile> {
let parsed: OllamaShowResponse = serde_json::from_str(payload)
.map_err(|err| anyhow::anyhow!("Failed to parse Ollama /api/show JSON: {err}"))?;
let context_length = parsed.model_info.as_ref().and_then(|info| {
info.iter()
.filter(|(key, _)| key.ends_with(".context_length"))
.find_map(|(_, value)| value.as_u64())
});
Ok(OllamaTagProfile {
capabilities: parsed.capabilities,
context_length,
})
}
/// True when this session should adopt a live local catalog into chrome.
///
/// First-run and missing-key recovery paint DeepSeek by default; a live local
@@ -139,20 +234,68 @@ fn record_ollama_tags_into_lake(endpoint_v1: &str, tags: &[String]) {
);
}
async fn fetch_text(url: &str) -> anyhow::Result<String> {
fn probe_client() -> anyhow::Result<reqwest::Client> {
// The first-run probe can run before any provider client has installed
// the rustls crypto provider; the shared builder installs it (the bare
// `reqwest::Client::builder()` panics under `rustls-no-provider`).
let client = crate::tls::reqwest_client_builder()
Ok(crate::tls::reqwest_client_builder()
.timeout(TAGS_PROBE_TIMEOUT)
.build()?;
let response = client.get(url).send().await?;
.build()?)
}
async fn fetch_text(url: &str) -> anyhow::Result<String> {
let response = probe_client()?.get(url).send().await?;
if !response.status().is_success() {
anyhow::bail!("HTTP {}", response.status());
}
Ok(response.text().await?)
}
async fn fetch_tag_profile(origin: &str, tag: &str) -> anyhow::Result<OllamaTagProfile> {
let response = probe_client()?
.post(format!("{origin}/api/show"))
.json(&serde_json::json!({ "model": tag }))
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("HTTP {}", response.status());
}
parse_ollama_show_response(&response.text().await?)
}
/// Ask `/api/show` about the plausible chat tags. Failures leave a tag
/// unprofiled, so the name heuristic decides for it.
async fn fetch_tag_profiles(
origin: &str,
tags: &[String],
) -> std::collections::HashMap<String, OllamaTagProfile> {
let candidates: Vec<&String> = tags
.iter()
.filter(|tag| !looks_like_non_chat_tag(tag))
.take(SHOW_PROBE_LIMIT)
.collect();
let lookups = candidates
.iter()
.map(|tag| fetch_tag_profile(origin, tag.as_str()));
let results = futures_util::future::join_all(lookups).await;
candidates
.into_iter()
.zip(results)
.filter_map(|(tag, result)| match result {
Ok(profile) => Some((tag.clone(), profile)),
Err(err) => {
tracing::debug!(
target: "local_ollama",
error = %err,
tag = %tag,
"POST /api/show probe failed"
);
None
}
})
.collect()
}
/// Probe local Ollama for a live catalog. Prefers native `/api/tags`, falls
/// back to OpenAI-compat `/v1/models`. Returns `None` when nothing useful
/// answered — never invents a tag.
@@ -202,7 +345,13 @@ pub(crate) async fn probe_live_local_ollama_catalog(
};
record_ollama_tags_into_lake(&endpoint_v1, &tags);
Some(LiveLocalOllamaCatalog { endpoint_v1, tags })
let profiles = fetch_tag_profiles(&origin, &tags).await;
let chat_tag = choose_chat_tag(&tags, &profiles);
Some(LiveLocalOllamaCatalog {
endpoint_v1,
tags,
chat_tag,
})
}
/// Env opt-out for harnesses that must not see the developer's machine.
@@ -246,6 +395,7 @@ pub(crate) fn spawn_local_ollama_adoption_probe(
mod tests {
use super::*;
use crate::test_support::{EnvVarGuard, lock_test_env};
use std::collections::HashMap;
#[test]
fn parse_ollama_tags_response_reads_name_field() {
@@ -277,15 +427,107 @@ mod tests {
);
}
#[test]
fn preferred_tag_is_alphabetically_first_after_sort() {
let mut tags = vec!["zeta:tag".into(), "alpha:tag".into()];
fn tags(names: &[&str]) -> Vec<String> {
let mut tags: Vec<String> = names.iter().map(|name| (*name).to_string()).collect();
tags.sort();
tags
}
#[test]
fn chat_tag_never_picks_an_embedding_model() {
// The installed-0.10.0 re-run adopted `nomic-embed-text:latest`
// because it sorted first; with no /api/show facts the name decides.
let tags = tags(&["qwen2.5-coder:7b", "qwen3:4b", "nomic-embed-text:latest"]);
let chosen = choose_chat_tag(&tags, &HashMap::new());
assert_eq!(chosen.as_deref(), Some("qwen2.5-coder:7b"));
}
#[test]
fn embed_only_catalog_adopts_nothing() {
let tags = tags(&[
"nomic-embed-text:latest",
"bge-m3:latest",
"all-minilm:l6-v2",
"qllama/bge-reranker-v2-m3:latest",
]);
assert_eq!(choose_chat_tag(&tags, &HashMap::new()), None);
let catalog = LiveLocalOllamaCatalog {
endpoint_v1: "http://localhost:11434/v1".into(),
chat_tag: choose_chat_tag(&tags, &HashMap::new()),
tags,
};
assert_eq!(catalog.preferred_tag(), Some("alpha:tag"));
assert_eq!(catalog.preferred_tag(), None);
}
#[test]
fn reported_capabilities_outrank_the_name_heuristic() {
let tags = tags(&["alpha:1b", "mystery:latest", "zeta:8b"]);
let mut profiles = HashMap::new();
// An embedding model with an innocent name is excluded by its facts.
profiles.insert(
"alpha:1b".to_string(),
OllamaTagProfile {
capabilities: Some(vec!["embedding".into()]),
context_length: Some(8_192),
},
);
// Tool support is preferred over a larger context without it.
profiles.insert(
"mystery:latest".to_string(),
OllamaTagProfile {
capabilities: Some(vec!["completion".into(), "tools".into()]),
context_length: Some(32_768),
},
);
profiles.insert(
"zeta:8b".to_string(),
OllamaTagProfile {
capabilities: Some(vec!["completion".into()]),
context_length: Some(131_072),
},
);
assert_eq!(
choose_chat_tag(&tags, &profiles).as_deref(),
Some("mystery:latest")
);
}
#[test]
fn larger_context_then_name_breaks_ties_among_equals() {
let tags = tags(&["b-model:7b", "a-model:7b", "c-model:7b"]);
let mut profiles = HashMap::new();
profiles.insert(
"c-model:7b".to_string(),
OllamaTagProfile {
capabilities: Some(vec!["completion".into()]),
context_length: Some(65_536),
},
);
assert_eq!(
choose_chat_tag(&tags, &profiles).as_deref(),
Some("c-model:7b")
);
assert_eq!(
choose_chat_tag(&tags, &HashMap::new()).as_deref(),
Some("a-model:7b"),
"without facts the first chat tag wins, as before"
);
}
#[test]
fn parse_ollama_show_response_reads_capabilities_and_context() {
let body = r#"{
"capabilities": ["completion", "tools"],
"model_info": {"general.architecture": "qwen2", "qwen2.context_length": 32768}
}"#;
let profile = parse_ollama_show_response(body).expect("parse");
assert_eq!(
profile.capabilities,
Some(vec!["completion".to_string(), "tools".to_string()])
);
assert_eq!(profile.context_length, Some(32_768));
let legacy = parse_ollama_show_response(r#"{"modelfile":""}"#).expect("parse");
assert_eq!(legacy, OllamaTagProfile::default());
}
#[tokio::test]
+4 -4
View File
@@ -3024,10 +3024,10 @@ impl App {
self.needs_redraw = true;
}
/// Mark the first-run follow-up as seen without inserting a transcript
/// message. The empty underwater launch surface owns setup guidance; a
/// synthetic history cell would hide that surface before the user sends
/// anything.
/// Show the one-time Fleet intro as a status line, the first time the
/// user opens `/fleet` or enters Operate — never as a first-run push.
/// It inserts no transcript message: a synthetic history cell would hide
/// the empty launch surface before the user sends anything.
pub fn maybe_show_feature_intro(&mut self) {
if self.onboarding != OnboardingState::None {
return;
+17 -4
View File
@@ -146,11 +146,9 @@ fn action_hints(app: &App) -> Vec<ActionHint> {
ActionHint::new("3/N", app.tr(MessageId::OnboardTrustActionQuit).to_string()),
],
OnboardingState::Ready => vec![
// Only keys this screen handles: a `/rc` hint here could not be
// typed, because the Ready screen owns the keyboard.
ActionHint::new("Enter", app.tr(MessageId::OnboardReadyStart).to_string()),
ActionHint::new(
"/rc",
app.tr(MessageId::CmdRemoteControlDescription).to_string(),
),
ActionHint::new("C", app.tr(MessageId::OnboardReadyCustomize).to_string()),
],
OnboardingState::None => Vec::new(),
@@ -952,6 +950,21 @@ mod tests {
}
}
#[test]
fn ready_screen_advertises_only_keys_it_handles() {
use crate::tui::views::action_footer_lines;
let mut app = test_app_with_locale(Locale::En);
app.onboarding = OnboardingState::Ready;
let rail = flattened(action_footer_lines(&action_hints(&app), 80));
assert!(rail.contains("Enter"), "{rail}");
assert!(rail.contains("change the look"), "{rail}");
assert!(
!rail.contains("/rc"),
"the Ready screen owns the keyboard, so /rc cannot be typed: {rail}"
);
}
#[test]
fn provider_screen_advertises_the_offline_choice() {
use crate::tui::views::action_footer_lines;
+5
View File
@@ -710,6 +710,8 @@ pub(crate) async fn apply_mode_update(
app.report_mode_selection(mode, outcome);
if mode == AppMode::Operate {
present_operate_board(app, config).await;
// First contact with the fleet, not first launch, owns its intro.
app.maybe_show_feature_intro();
}
if outcome.changed_live_state() {
sync_mode_update(app, engine_handle).await;
@@ -2200,6 +2202,8 @@ pub(crate) async fn apply_command_result(
app, config,
));
}
// `/fleet` is where the one-time Fleet intro belongs.
app.maybe_show_feature_intro();
}
AppAction::OpenFleetSetup => {
open_fleet_setup_target(app, config, None);
@@ -3844,6 +3848,7 @@ pub(crate) fn apply_loaded_session_with_goal(
std::time::Duration::from_secs(session.metadata.cumulative_turn_secs);
app.current_session_id = Some(session.metadata.id.clone());
app.current_session_metadata = Some(session.metadata.clone());
reset_approval_scope_for_new_conversation(app);
if let Some(binding) = recovered_binding {
if let Some(metadata) = app.current_session_metadata.as_mut() {
metadata.runtime_store = Some(binding);
+16
View File
@@ -23,6 +23,22 @@ pub(super) fn is_session_denied_for_key(app: &App, approval_key: &str) -> bool {
app.approval_session_denied.contains(approval_key)
}
/// A Deny holds for the rest of the user turn it was given in: the model's
/// retry loop must not re-prompt for the same call, but the user's next
/// message is a new intent and may deserve a different answer.
pub(super) fn end_turn_scoped_denials(app: &mut App) {
app.approval_session_denied.clear();
}
/// A different conversation (a session switch or resume) inherits neither
/// this conversation's denials nor its "approve for session" grants: both
/// describe work the user was looking at here. `/new` and `/clear` do the
/// same in `reset_conversation_state`.
pub(super) fn reset_approval_scope_for_new_conversation(app: &mut App) {
app.approval_session_denied.clear();
app.approval_session_approved.clear();
}
pub(super) fn session_denied_notice(app: &App, tool_name: &str) -> String {
app.tr(MessageId::ApprovalAutoDeniedSession)
.replace("{tool}", tool_name)
+60 -13
View File
@@ -784,14 +784,9 @@ pub async fn run_tui(
surface_prompt_override_notices(&mut app);
if options.resume_session_id.is_none() && !app.launch.visible {
let opened_setup = open_setup_checkpoint_if_due(&mut app, config, options.skip_onboarding);
// One-time Fleet + Hotbar intro for returning (non-resuming) users.
// First-time users see it when they finish onboarding. Gated by a
// persisted flag, so it shows exactly once and never inside a resumed
// session transcript or behind the constitution checkpoint.
if !opened_setup {
app.maybe_show_feature_intro();
}
// The one-time Fleet intro is no longer a launch push: it appears the
// first time the user opens `/fleet` or enters Operate (apply.rs).
let _ = open_setup_checkpoint_if_due(&mut app, config, options.skip_onboarding);
}
// Load existing session if resuming.
@@ -1433,6 +1428,9 @@ pub(crate) async fn run_event_loop(
let (translation_tx, mut translation_rx) =
tokio::sync::mpsc::unbounded_channel::<TranslationEvent>();
let fallback_translation_client = translation_client;
// Set when the telemetry disclosure cell is queued; cleared (and the
// disclosure recorded) by the first draw that paints it.
let mut telemetry_notice_awaiting_render = false;
let mut active_translation_client = fallback_translation_client.clone();
let mut active_translation_route: Option<crate::core::events::TurnRoute> = None;
let mut translation_sequence = 0_u64;
@@ -1596,11 +1594,20 @@ pub(crate) async fn run_event_loop(
force_terminal_repaint = true;
}
if app.onboarding == OnboardingState::None && pending_telemetry_notice.take().is_some() {
let receipt = app.tr(MessageId::TelemetryNoticeDefaultOn);
app.push_status_toast(receipt.into_owned(), StatusToastLevel::Info, Some(12_000));
// The disclosure is a transcript cell, not a toast: a 12 s toast
// showed only its first sentence at 100 columns and hid the opt-out.
// A transcript cell would also replace the launch card, whose
// "no model connected" line is the first-run recovery, so the cell
// waits until the card starts to leave. It counts as presented only
// once a frame containing it was drawn; quitting first re-owes it.
if app.onboarding == OnboardingState::None
&& telemetry_notice_may_enter_transcript(app)
&& pending_telemetry_notice.take().is_some()
{
let notice = app.tr(MessageId::TelemetryNoticeDefaultOn).into_owned();
app.add_message(HistoryCell::System { content: notice });
app.needs_redraw = true;
crate::telemetry_notice::record_presented();
telemetry_notice_awaiting_render = true;
}
// A manual compaction deferred by a full engine mailbox retries here
@@ -2406,6 +2413,8 @@ pub(crate) async fn run_event_loop(
// A prior turn that died without its `TurnComplete`
// must not leak its provisional estimate into this one.
app.clear_pending_turn_cost();
// A Deny is scoped to the turn it answered (UX-8).
end_turn_scoped_denials(app);
app.goal_continuation_waiting = false;
app.session.last_tool_request_snapshot = None;
app.ocean_completion_started_at = None;
@@ -4589,6 +4598,9 @@ pub(crate) async fn run_event_loop(
force_terminal_repaint = false;
frame_rate_limiter.mark_emitted(Instant::now());
app.needs_redraw = false;
if std::mem::take(&mut telemetry_notice_awaiting_render) {
crate::telemetry_notice::record_presented();
}
}
let mut poll_timeout =
@@ -5366,7 +5378,6 @@ pub(crate) async fn run_event_loop(
// pre-seeded with a first task for this folder —
// never another educational surface.
onboarding::finish_ready_and_open_composer(app);
app.maybe_show_feature_intro();
}
OnboardingState::None => {}
},
@@ -6987,6 +6998,19 @@ pub(crate) async fn run_cache_warmup(app: &App, config: &Config) -> Result<Cache
})
}
/// Whether the telemetry disclosure may become a transcript cell now: never
/// while the launch card is still the screen, since a cell hides the card,
/// and never under a live active cell, whose tool indices address
/// `history ++ active_cell`.
fn telemetry_notice_may_enter_transcript(app: &App) -> bool {
let card_leaving = !app.launch.visible || app.launch.dissolve_started_ms.is_some();
let no_live_cell = app
.active_cell
.as_ref()
.is_none_or(crate::tui::active_cell::ActiveCell::is_empty);
card_leaving && no_live_cell
}
/// Switch a first-run / missing-key session onto a live local Ollama tag.
async fn adopt_live_local_ollama_catalog(
app: &mut App,
@@ -7450,6 +7474,29 @@ mod session_boot_event_tests {
}
}
#[cfg(test)]
mod telemetry_notice_tests {
use super::telemetry_notice_may_enter_transcript;
#[test]
fn telemetry_notice_waits_for_the_launch_card_to_leave() {
let mut app = crate::test_support::test_app_with_options(
crate::test_support::test_tui_options(std::env::temp_dir()),
);
app.launch.visible = true;
app.launch.dissolve_started_ms = None;
assert!(
!telemetry_notice_may_enter_transcript(&app),
"a transcript cell would hide the launch card's no-model line"
);
app.launch.dissolve_started_ms = Some(0);
assert!(telemetry_notice_may_enter_transcript(&app));
app.launch.visible = false;
app.launch.dissolve_started_ms = None;
assert!(telemetry_notice_may_enter_transcript(&app));
}
}
#[cfg(test)]
mod fleet_workers_status_tests {
use super::current_session_fleet_workers_status;
+51 -14
View File
@@ -5817,6 +5817,46 @@ fn session_denied_cache_matches_only_approval_key() {
assert!(is_session_denied_for_key(&app, "file:edit_file:retry"));
}
#[test]
fn a_deny_holds_for_its_turn_and_prompts_again_after_the_next_message() {
let mut app = create_test_app();
let denied_key = "shell:rm -rf build:call-1";
app.approval_session_denied.insert(denied_key.to_string());
app.approval_session_approved
.insert("shell:git status".to_string());
assert!(
is_session_denied_for_key(&app, denied_key),
"the model's retry inside the same turn stays auto-denied"
);
// What the event loop runs on `TurnStarted` for the user's next message.
end_turn_scoped_denials(&mut app);
assert!(
!is_session_denied_for_key(&app, denied_key),
"a new user message must be able to reconsider the Deny"
);
assert!(
is_session_approved_for_tool(&app, "exec_shell", "shell:git status"),
"an approve-for-session grant is session-scoped, not turn-scoped"
);
}
#[test]
fn switching_sessions_drops_denials_and_session_grants() {
let mut app = create_test_app();
app.approval_session_denied
.insert("shell:rm -rf build:call-1".to_string());
app.approval_session_approved
.insert("shell:git status".to_string());
let session = saved_session_with_messages(vec![]);
apply_loaded_session(&mut app, &mut Config::default(), &session).expect("restore session");
assert!(app.approval_session_denied.is_empty());
assert!(app.approval_session_approved.is_empty());
}
fn render_underwater_test_app(app: &mut App, width: u16, height: u16) -> String {
app.onboarding_workspace_trust_gate = false;
app.onboarding = OnboardingState::None;
@@ -7246,9 +7286,9 @@ async fn session_denied_cache_auto_deny_explains_the_cached_rejection() {
let toast = app.status_toasts.back().expect("auto-deny warning toast");
assert_eq!(toast.level, StatusToastLevel::Warning);
assert_eq!(toast.ttl_ms, Some(12_000));
assert!(toast.text.contains("matching request was denied earlier"));
assert!(toast.text.contains("during this Codewhale run"));
assert!(toast.text.contains("Restart Codewhale"));
assert!(toast.text.contains("denied a matching request earlier"));
assert!(toast.text.contains("in this turn"));
assert!(toast.text.contains("Send a new message"));
assert!(toast.text.contains("exec_shell"));
let history_notice = app
.history
@@ -7273,10 +7313,7 @@ async fn session_denied_cache_auto_deny_explains_the_cached_rejection() {
let rendered = render_underwater_test_app(&mut app, 40, 12);
assert!(rendered.contains("Auto-denied"), "{rendered:?}");
assert!(
rendered.contains("Restart") && rendered.contains("Codewhale"),
"{rendered:?}"
);
assert!(rendered.contains("Send a new message"), "{rendered:?}");
}
#[tokio::test]
@@ -7501,7 +7538,7 @@ async fn session_denied_cache_notice_renders_host_scope_in_zh_hans() {
_ => None,
})
.expect("localized persistent auto-deny explanation");
assert!(notice.contains("本次 Codewhale 运行期间"));
assert!(notice.contains("本轮"));
assert!(notice.contains("匹配请求"));
assert!(!notice.contains("example.com"));
@@ -7513,7 +7550,7 @@ async fn session_denied_cache_notice_renders_host_scope_in_zh_hans() {
assert!(rendered_compact.contains("已自动拒绝"), "{rendered:?}");
assert!(rendered_compact.contains("匹配请求"), "{rendered:?}");
assert!(
rendered_compact.contains("重启") && rendered_compact.contains("Codewhale"),
rendered_compact.contains("发送") && rendered_compact.contains("新消息"),
"{rendered:?}"
);
}
@@ -7524,9 +7561,9 @@ fn session_denied_notice_explains_cached_decision_and_recovery() {
let notice = session_denied_notice(&app, "exec_shell");
assert!(notice.contains("exec_shell"));
assert!(notice.contains("matching request was denied earlier"));
assert!(notice.contains("during this Codewhale run"));
assert!(notice.contains("Restart Codewhale"));
assert!(notice.contains("denied a matching request earlier"));
assert!(notice.contains("in this turn"));
assert!(notice.contains("Send a new message"));
}
#[tokio::test]
@@ -7595,7 +7632,7 @@ async fn cached_denial_explanation_survives_tool_completion_and_done_render() {
cell,
HistoryCell::System { content }
if content.contains("Auto-denied exec_shell")
&& content.contains("Restart Codewhale")
&& content.contains("Send a new message")
)
})
.expect("cached denial must leave a durable recovery receipt");
@@ -7632,7 +7669,7 @@ async fn cached_denial_explanation_survives_tool_completion_and_done_render() {
"cached-decision explanation disappeared after completion:\n{rendered}"
);
assert!(
rendered.contains("Restart Codewhale"),
rendered.contains("new message to be asked again"),
"cached-denial recovery path disappeared after completion:\n{rendered}"
);
assert_eq!(
+69 -13
View File
@@ -1498,6 +1498,8 @@ struct LaunchFit {
context: bool,
help: bool,
notice: bool,
/// The "no model connected · run /provider" line (UX-3).
setup: bool,
heading: bool,
blanks: usize,
shown: usize,
@@ -1520,6 +1522,7 @@ impl LaunchFit {
+ (self.context as usize)
+ (self.help as usize)
+ (self.notice as usize)
+ (self.setup as usize)
+ self.blanks * self.gap
+ 1
+ (self.heading as usize)
@@ -1533,17 +1536,27 @@ impl LaunchFit {
/// Shed the card down to `height`, in a fixed order: rhythm, the migration
/// notice, the MCP block's detail, identity/help chrome, then the tail of
/// the recent list. The overflow row keeps any hidden sessions reachable.
/// The "no model connected" line goes last of all: on a keyless first run
/// it is the only thing on the card that explains why nothing will answer.
///
/// The MCP block gives up its rows before the recent list does (recent work
/// is what the screen is *for*) but keeps its summary line until almost
/// everything else has gone, because "2 failed" in one row still tells the
/// truth that the footer chip could not.
fn launch_fit(height: usize, recent: usize, has_more: bool, notice: bool, mcp: usize) -> LaunchFit {
fn launch_fit(
height: usize,
recent: usize,
has_more: bool,
notice: bool,
mcp: usize,
setup: bool,
) -> LaunchFit {
let mut fit = LaunchFit {
brand: true,
context: true,
help: true,
notice,
setup,
heading: recent > 0 || has_more,
blanks: LAUNCH_SEPARATORS,
shown: recent,
@@ -1576,6 +1589,7 @@ fn launch_fit(height: usize, recent: usize, has_more: bool, notice: bool, mcp: u
}
10 => fit.mcp = 0,
11 => fit.see_all = false,
12 => fit.setup = false,
_ => break,
}
step += 1;
@@ -1623,6 +1637,9 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
}
let (entries, has_more) = launch_recent_entries(app);
// Nothing will answer a message until a model is connected; the card
// says so instead of letting the first Enter fail silently (UX-3).
let no_model_connected = app.onboarding_needs_api_key;
// Built before the fit ladder runs: how many rows the block wants is a
// fact about this workspace's servers, not about the pane.
let mcp_block = mcp_launch_lines(app, text_width);
@@ -1647,6 +1664,7 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
has_more,
app.launch.claude_code_detected,
mcp_block.lines.len(),
no_model_connected,
);
if mark.is_some() && !(fit.brand && fit.context) {
// At the absolute height floor the wordmark yields to the actions too.
@@ -1657,6 +1675,7 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
has_more,
app.launch.claude_code_detected,
mcp_block.lines.len(),
no_model_connected,
);
}
let header_width =
@@ -1729,6 +1748,18 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
text[row] = Some(Line::from(spans));
}
}
if fit.setup {
let line = format!(
"{}{}{}",
tr(locale, MessageId::LaunchNoModelConnected),
crate::tui::session_boot::ITEM_SEPARATOR,
tr(locale, MessageId::LaunchRunCommand).replace("{command}", "/provider"),
);
text.push(Some(Line::from(Span::styled(
semantic_truncate(&line, text_width),
Style::default().fg(theme.warning),
))));
}
// The migration notice, while there is still a question to answer. It
// retires for good once `/import-claude` has been run.
if fit.notice {
@@ -2329,15 +2360,24 @@ mod launch_card_tests {
for has_more in [false, true] {
for notice in [false, true] {
for mcp in 0usize..=4 {
let fit = launch_fit(height, recent, has_more, notice, mcp);
assert!(fit.rows() <= height.max(1), "{height} {recent}: {fit:?}");
assert!(fit.shown <= recent);
assert!(fit.mcp <= mcp);
if fit.shown < recent {
assert!(
fit.see_all || fit.rows() >= height,
"shed rows became unreachable: {fit:?}",
);
for setup in [false, true] {
let fit = launch_fit(height, recent, has_more, notice, mcp, setup);
assert!(fit.rows() <= height.max(1), "{height} {recent}: {fit:?}");
assert!(fit.shown <= recent);
assert!(fit.mcp <= mcp);
if fit.shown < recent {
assert!(
fit.see_all || fit.rows() >= height,
"shed rows became unreachable: {fit:?}",
);
}
if setup && !fit.setup {
assert_eq!(
(fit.shown, fit.mcp, fit.see_all),
(0, 0, false),
"the no-model line outlived other rows: {fit:?}",
);
}
}
}
}
@@ -2346,6 +2386,22 @@ mod launch_card_tests {
}
}
#[test]
fn a_keyless_launch_says_no_model_is_connected_and_how_to_fix_it() {
let mut app = app_with_recent(&["Fix the parser"], 1);
app.onboarding_needs_api_key = true;
let lines = painted(&app, 100, 30).join("\n");
assert!(lines.contains("no model connected"), "{lines}");
assert!(lines.contains("/provider"), "{lines}");
// Even a pane too short for the recent list keeps the recovery line.
let short = painted(&app, 100, 3).join("\n");
assert!(short.contains("no model connected"), "{short}");
app.onboarding_needs_api_key = false;
let lines = painted(&app, 100, 30).join("\n");
assert!(!lines.contains("no model connected"), "{lines}");
}
#[test]
fn empty_workspace_omits_recent_section_but_hidden_history_stays_reachable() {
let app = app_with_recent(&[], 0);
@@ -2353,9 +2409,9 @@ mod launch_card_tests {
assert!(text.contains("New session"));
assert!(!text.contains("Recent"));
assert!(!text.contains("No recent sessions"));
assert!(!launch_fit(24, 0, false, false, 0).heading);
assert!(launch_fit(24, 0, true, false, 0).heading);
assert!(launch_fit(24, 0, true, false, 0).see_all);
assert!(!launch_fit(24, 0, false, false, 0, false).heading);
assert!(launch_fit(24, 0, true, false, 0, false).heading);
assert!(launch_fit(24, 0, true, false, 0, false).see_all);
}
// --- the row reads as one object -----------------------------------