From e003f7b2f701fb0478e69bdce63d4697d360ed3a Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Tue, 25 Aug 2026 21:57:56 +0800 Subject: [PATCH 1/4] fix(tui): preserve composer status footer --- crates/tui/src/chatwidget/render.rs | 2 +- crates/tui/src/chatwidget_layout_tests.rs | 68 +++++++++++++++++++++++ crates/tui/src/lib.rs | 2 + 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 crates/tui/src/chatwidget_layout_tests.rs diff --git a/crates/tui/src/chatwidget/render.rs b/crates/tui/src/chatwidget/render.rs index bfe17edb..03afdf6b 100644 --- a/crates/tui/src/chatwidget/render.rs +++ b/crates/tui/src/chatwidget/render.rs @@ -132,7 +132,7 @@ impl ChatWidget { if self.bottom_pane.is_resume_picker_open() { area.height.saturating_sub(3).max(3) } else { - area.height.saturating_sub(1).max(3) + area.height }, ); let subagent_height = self diff --git a/crates/tui/src/chatwidget_layout_tests.rs b/crates/tui/src/chatwidget_layout_tests.rs new file mode 100644 index 00000000..153ef9c8 --- /dev/null +++ b/crates/tui/src/chatwidget_layout_tests.rs @@ -0,0 +1,68 @@ +use std::path::PathBuf; + +use devo_protocol::Model; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Line; +use tokio::sync::mpsc; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::chatwidget::ChatWidget; +use crate::chatwidget::ChatWidgetInit; +use crate::chatwidget::TuiSessionState; +use crate::render::renderable::Renderable; +use crate::tui::frame_requester::FrameRequester; + +fn widget() -> ChatWidget { + let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel::(); + ChatWidget::new_with_app_event(ChatWidgetInit { + frame_requester: FrameRequester::test_dummy(), + app_event_tx: AppEventSender::new(app_event_tx), + initial_session: TuiSessionState::new(PathBuf::from("."), Some(Model::default())), + initial_reasoning_effort_selection: None, + initial_permission_preset: devo_protocol::PermissionPreset::Default, + initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, + initial_user_message: None, + enhanced_keys_supported: true, + is_first_run: false, + available_models: Vec::new(), + saved_models: Vec::new(), + show_model_onboarding: false, + exit_after_onboarding: false, + startup_tooltip_override: None, + initial_theme_name: None, + initial_collapse_reasoning: false, + }) +} + +#[test] +fn composer_footer_is_kept_at_content_height_for_single_and_wrapped_drafts() { + for draft in ["draft", "first line\nsecond line"] { + let mut widget = widget(); + let bottom_pane = widget.bottom_pane_mut_for_test(); + bottom_pane.set_text_content(draft.to_string(), Vec::new(), Vec::new()); + bottom_pane.set_status_line(Some(Line::from("status sentinel"))); + bottom_pane.set_status_line_enabled(true); + + let width = 80; + let height = widget.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buffer = Buffer::empty(area); + widget.render(area, &mut buffer); + + let rendered = (0..area.height) + .map(|row| { + (0..area.width) + .map(|column| buffer[(column, row)].symbol()) + .collect::() + }) + .collect::>(); + assert!( + rendered.iter().any(|row| row.contains("status sentinel")), + "status line disappeared for draft {draft:?}: {rendered:?}" + ); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index f5639c3c..66de9090 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -10,6 +10,8 @@ mod app_event_sender; mod bottom_pane; mod chatwidget; #[cfg(test)] +mod chatwidget_layout_tests; +#[cfg(test)] mod chatwidget_tail_follow_tests; #[cfg(test)] mod chatwidget_tests; From fd384b0e7e14e7dc3b771e084caedfd87a3adc6c Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Tue, 25 Aug 2026 22:01:47 +0800 Subject: [PATCH 2/4] fix(tui): remove slash command selection marker --- crates/tui/src/bottom_pane/command_popup.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/bottom_pane/command_popup.rs b/crates/tui/src/bottom_pane/command_popup.rs index 91af2389..ba8532e3 100644 --- a/crates/tui/src/bottom_pane/command_popup.rs +++ b/crates/tui/src/bottom_pane/command_popup.rs @@ -180,14 +180,13 @@ impl CommandPopup { matches .into_iter() .enumerate() - .map(|(idx, (item, indices))| { + .map(|(_, (item, indices))| { let CommandItem::Builtin(cmd) = item; let name = format!("/{}", cmd.command()); let description = cmd.description().to_string(); - let selected = self.state.selected_idx == Some(idx); GenericDisplayRow { name, - name_prefix_spans: vec![if selected { "> " } else { " " }.into()], + name_prefix_spans: vec![" ".into()], match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), display_shortcut: None, description: Some(description), @@ -340,6 +339,17 @@ mod tests { ); } + #[test] + fn selected_command_does_not_show_selection_marker() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Color::Cyan); + popup.on_composer_text_change("/model".to_string()); + + let height = popup.calculate_required_height(80); + let rendered = render_popup(&popup, 80, height); + + assert!(rendered.contains(" /model")); + } + #[test] fn prefix_filter_limits_matches_for_ac() { let mut popup = CommandPopup::new(CommandPopupFlags::default(), Color::Cyan); From 2c9447c34e757434bd2edd5152d4d5ac40d5ed60 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 26 Aug 2026 11:25:32 +0800 Subject: [PATCH 3/4] Remove provider response timeouts --- README.md | 7 ++ README.zh-Hans.md | 5 + .../src/main/native-stdio-client.test.ts | 2 + apps/desktop/src/main/native-stdio-client.ts | 29 +++-- .../settings/provider-settings.test.tsx | 21 ++++ .../settings/provider-vendor-dialog.tsx | 31 ++++- .../docs/reference/config-reference.mdx | 4 + .../docs/reference/config-reference.zh.mdx | 3 + crates/client/src/client_core.rs | 68 +++++++++-- crates/core/src/query/provider_retry.rs | 8 -- crates/core/src/query/tests.rs | 13 -- crates/provider/src/anthropic/messages.rs | 15 +-- crates/provider/src/http.rs | 12 +- .../src/openai/chat_completions/stream.rs | 15 +-- crates/provider/src/openai/responses.rs | 15 +-- crates/provider/src/recovery_hint.rs | 19 +-- crates/provider/src/timeout.rs | 114 ++---------------- .../server/src/runtime/provider_vendor_api.rs | 17 +-- crates/tui/src/chatwidget/input.rs | 6 + crates/tui/src/interactive.rs | 4 + crates/tui/src/onboarding_widget.rs | 4 + crates/tui/src/worker.rs | 100 +++++++++++---- .../app/L2-DES-APP-005-config-toml-schema.md | 7 +- 23 files changed, 267 insertions(+), 252 deletions(-) diff --git a/README.md b/README.md index e372322f..ebe1c040 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,13 @@ To bring your own key with a custom model manually: Full worked example (custom model parameters + API key) and protocol details: [Configuration](./docs/configuration.md#bring-your-own-api-key). +## Slow Providers + +Provider responses are not subject to an application-level response timeout, so +local models may take as long as necessary to load or generate output. Provider +connections still have an internal connection-establishment deadline, and every +request can be cancelled by the user. + ## Docs - [Offline Installation](./docs/offline-installation.md) diff --git a/README.zh-Hans.md b/README.zh-Hans.md index f634be8d..0962f7c5 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -214,6 +214,11 @@ devo resume 完整示例(自定义模型参数 + API key)与协议说明见 [配置](./docs/configuration.zh-Hans.md#接入自有-api-key)。 +## 响应较慢的 Provider + +Provider 响应没有应用层总超时,因此本地模型可以按需要花费时间加载或生成输出。 +Provider 连接建立仍有内部期限,并且用户可以随时取消请求。 + ## Docs - [离线安装](./docs/offline-installation.zh-Hans.md) diff --git a/apps/desktop/src/main/native-stdio-client.test.ts b/apps/desktop/src/main/native-stdio-client.test.ts index 20eda813..b7033f90 100644 --- a/apps/desktop/src/main/native-stdio-client.test.ts +++ b/apps/desktop/src/main/native-stdio-client.test.ts @@ -182,10 +182,12 @@ describe("StdioNativeClient", () => { sessionList: requestTimeoutMsForMethod("session/list", 10_000), mcpTools: requestTimeoutMsForMethod("mcp/tools", 10_000), mcpSetEnabled: requestTimeoutMsForMethod("mcp/set_enabled", 5), + providerValidate: requestTimeoutMsForMethod("provider/validate", 10_000), }).toEqual({ sessionList: 10_000, mcpTools: 60_000, mcpSetEnabled: 60_000, + providerValidate: undefined, }) }) diff --git a/apps/desktop/src/main/native-stdio-client.ts b/apps/desktop/src/main/native-stdio-client.ts index cec5bad3..116434b5 100644 --- a/apps/desktop/src/main/native-stdio-client.ts +++ b/apps/desktop/src/main/native-stdio-client.ts @@ -29,14 +29,17 @@ export type JsonRpcId = number | string type PendingRequest = { resolve: (value: unknown) => void reject: (error: Error) => void - timer: ReturnType + timer?: ReturnType } const REQUEST_TIMEOUT_MS = 10_000 /** MCP admin RPCs may start a lazy server before listing tools. */ export const MCP_ADMIN_REQUEST_TIMEOUT_MS = 60_000 -export function requestTimeoutMsForMethod(method: string, fallbackMs: number): number { +export function requestTimeoutMsForMethod(method: string, fallbackMs: number): number | undefined { + if (method === "provider/validate") { + return undefined + } if (method === "mcp/tools" || method === "mcp/set_enabled") { return Math.max(fallbackMs, MCP_ADMIN_REQUEST_TIMEOUT_MS) } @@ -295,11 +298,17 @@ export class StdioNativeClient implements NativeTransport { const scopedParams = scopeRequestParams(method, params, directory) const payload = { jsonrpc: "2.0", id, method, params: scopedParams } const response = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - if (!this.pending.delete(id)) return - this.pendingMethods.delete(id) - reject(new Error(`${method} request ${id} timed out`)) - }, requestTimeoutMsForMethod(method, this.options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS)) + const timeoutMs = requestTimeoutMsForMethod( + method, + this.options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS, + ) + const timer = timeoutMs === undefined + ? undefined + : setTimeout(() => { + if (!this.pending.delete(id)) return + this.pendingMethods.delete(id) + reject(new Error(`${method} request ${id} timed out`)) + }, timeoutMs) this.pending.set(id, { resolve, reject, timer }) }) this.pendingMethods.set(id, method) @@ -315,7 +324,7 @@ export class StdioNativeClient implements NativeTransport { } catch (error) { const reason = toError(error) const pending = this.pending.get(id) - if (pending) clearTimeout(pending.timer) + if (pending?.timer !== undefined) clearTimeout(pending.timer) this.pending.delete(id) this.pendingMethods.delete(id) this.close(reason) @@ -399,7 +408,7 @@ export class StdioNativeClient implements NativeTransport { const pending = this.pending.get(routed.id) if (!pending) return this.pending.delete(routed.id) - clearTimeout(pending.timer) + if (pending.timer !== undefined) clearTimeout(pending.timer) const error = routed.message.error as { message?: string } | undefined if (error) { pending.reject(new Error(error.message ?? "Devo Native request failed")) @@ -485,7 +494,7 @@ export class StdioNativeClient implements NativeTransport { payload: { error: error.message }, }) for (const pending of this.pending.values()) { - clearTimeout(pending.timer) + if (pending.timer !== undefined) clearTimeout(pending.timer) pending.reject(error) } this.pending.clear() diff --git a/apps/desktop/src/renderer/components/settings/provider-settings.test.tsx b/apps/desktop/src/renderer/components/settings/provider-settings.test.tsx index b5e26998..d85c7335 100644 --- a/apps/desktop/src/renderer/components/settings/provider-settings.test.tsx +++ b/apps/desktop/src/renderer/components/settings/provider-settings.test.tsx @@ -196,6 +196,27 @@ describe("ProviderSettings", () => { expect(calls).toEqual(["validate"]) }) + test("cancelled validation does not upsert", async () => { + const calls: string[] = [] + const params = buildProviderUpsertParams(formValues, null) + const client = { + provider: { + validate: async () => { + calls.push("validate") + return { data: { reply_preview: "OK" } } + }, + upsert: async () => { + calls.push("upsert") + return { data: { provider_vendor: providerVendor } } + }, + }, + } + + const cancelled = true + await saveProviderVendor(client, params, () => !cancelled) + expect(calls).toEqual([]) + }) + test("provider dialog scrolls form body while keeping footer actions outside", () => { const queryClient = new QueryClient() const markup = renderToStaticMarkup( diff --git a/apps/desktop/src/renderer/components/settings/provider-vendor-dialog.tsx b/apps/desktop/src/renderer/components/settings/provider-vendor-dialog.tsx index ae5d116f..7e947876 100644 --- a/apps/desktop/src/renderer/components/settings/provider-vendor-dialog.tsx +++ b/apps/desktop/src/renderer/components/settings/provider-vendor-dialog.tsx @@ -38,7 +38,7 @@ import { Spinner } from "@devo/ui/components/spinner" import { Textarea } from "@devo/ui/components/textarea" import { useQueryClient } from "@tanstack/react-query" import { SaveIcon } from "lucide-react" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { queryKeys } from "../../hooks/use-devo-data" import { createLogger } from "../../lib/logger" import { getBaseClient, invalidateConfigOptionCaches } from "../../services/connection-manager" @@ -170,16 +170,19 @@ export function buildProviderUpsertParams( export async function saveProviderVendor( client: ProviderVendorClient, params: ProviderVendorUpsertParams, + shouldContinue: () => boolean = () => true, ) { if (!params.model_binding) { throw new Error("Model binding is required") } + if (!shouldContinue()) return const validateParams: ProviderValidateParams = { provider_vendor: params.provider_vendor, model_binding: params.model_binding, ...(params.api_key ? { api_key: params.api_key } : {}), } await client.provider.validate(validateParams) + if (!shouldContinue()) return return client.provider.upsert(params) } @@ -193,8 +196,10 @@ export function ProviderVendorDialog({ const [values, setValues] = useState(() => initialValues(providerVendor)) const [error, setError] = useState(null) const [saving, setSaving] = useState(false) + const saveAttemptRef = useRef(0) useEffect(() => { + saveAttemptRef.current += 1 if (!open) return setValues(initialValues(providerVendor)) setError(null) @@ -211,13 +216,15 @@ export function ProviderVendorDialog({ const handleSubmit = useCallback( async (event: React.FormEvent) => { event.preventDefault() + const saveAttempt = ++saveAttemptRef.current setSaving(true) setError(null) try { const client = getBaseClient() if (!client) throw new Error("Not connected to server") const params = buildProviderUpsertParams(values, providerVendor) - await saveProviderVendor(client, params) + await saveProviderVendor(client, params, () => saveAttemptRef.current === saveAttempt) + if (saveAttemptRef.current !== saveAttempt) return invalidateConfigOptionCaches() queryClient.invalidateQueries({ queryKey: queryKeys.providerVendors }) queryClient.invalidateQueries({ @@ -226,18 +233,32 @@ export function ProviderVendorDialog({ onSaved() onOpenChange(false) } catch (err) { + if (saveAttemptRef.current !== saveAttempt) return const message = err instanceof Error ? err.message : "Failed to save provider" log.error("Failed to save provider", { error: err }) setError(message) } finally { - setSaving(false) + if (saveAttemptRef.current === saveAttempt) setSaving(false) } }, [values, providerVendor, queryClient, onSaved, onOpenChange], ) + const handleDialogOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + // Closing while validation is pending invalidates the continuation so + // a late validation response cannot persist the provider. + saveAttemptRef.current += 1 + setSaving(false) + } + onOpenChange(nextOpen) + }, + [onOpenChange], + ) + return ( - +
@@ -399,7 +420,7 @@ export function ProviderVendorDialog({ className="shrink-0 bg-background px-6 py-4" data-testid="provider-dialog-footer" > -