Summary
vimcode's "Unsaved Changes" confirmation is an in-canvas box painted by quadraui::Dialog; VS Code's equivalent is a real OS dialog. Once quadraui grows a native message-dialog seam (quadraui#666), route GTK's confirmation dialogs through it. TUI keeps the in-canvas primitive — that is the point of the split, not a limitation.
Depends on quadraui#666. Needs a src/quadraui_pin.rs bump; do not start until that has landed.
The edge-trigger problem — this is the actual work
vimcode's dialogs are frame-driven state. engine.dialog: Option<Dialog> (src/core/engine/mod.rs:363) is converted to a quadraui::Dialog by dialog_panel_to_quadraui_dialog (src/render.rs:4887) and repainted every frame via backend.draw_dialog (src/gtk/mod.rs:9333). A native dialog cannot be re-presented every frame — it must be presented once, on the transition from no-dialog to dialog.
So:
- Present once, keyed on the dialog's identity, on the no-dialog to dialog edge.
- While a native dialog is in flight, suppress the in-canvas draw.
- Map the returned
WidgetId back to exactly the action the in-canvas DialogHit::Button(id) path already produces at src/gtk/mod.rs:4399, so the engine sees one code path and one set of outcomes.
The blocking call shape is already established here — backend.services().show_file_open_dialog(...) at src/gtk/mod.rs:1773 blocks on quadraui's nested-mainloop pump, and PendingFileDialog (src/gtk/mod.rs:1089) already models "a dialog is in flight". Follow that, don't invent a second mechanism.
What stays in-canvas
- TUI, always.
src/tui_main/** is untouched by this issue and its snapshots must not move.
- Any dialog quadraui reports as not natively expressible — i.e. carrying a table or a text input. That covers the help dialog (
src/render.rs:8177 sc_help_dialog, a DialogTable), the text-input dialogs (src/render.rs:4915), and source control's (src/core/engine/source_control.rs:269). These must keep working exactly as today.
The native/in-canvas decision comes from quadraui's native_dialog_options returning None — do not hand-maintain a list of dialog kinds in this repo.
Acceptance
- Closing a modified buffer in the GTK build shows a real OS dialog. Save / Don't Save / Cancel produce the same engine outcomes as today's in-canvas buttons.
- The dialog is presented exactly once per open, not once per frame — assert the present count stays at 1 across N frames with the dialog unchanged.
- Both entry points go native:
:q with unsaved changes, and the tab-x close path (Msg variants at src/gtk/mod.rs:1380-1388).
- TUI behaviour byte-identical.
- Help dialog and text-input dialogs still render in-canvas on GTK.
Files
src/gtk/mod.rs — present-once edge trigger, response-to-action mapping, in-canvas suppression while native is in flight
src/quadraui_pin.rs — pin bump
src/render.rs — only if the descriptor must carry the native/in-canvas decision through
Supersedes
#209, option 3 ("native widget escape hatch"). When this lands, either close #209 or retitle it to the styling-only options that remain.
Surfaced by
Operator smoke against VS Code, 2026-09-01.
Summary
vimcode's "Unsaved Changes" confirmation is an in-canvas box painted by
quadraui::Dialog; VS Code's equivalent is a real OS dialog. Once quadraui grows a native message-dialog seam (quadraui#666), route GTK's confirmation dialogs through it. TUI keeps the in-canvas primitive — that is the point of the split, not a limitation.Depends on quadraui#666. Needs a
src/quadraui_pin.rsbump; do not start until that has landed.The edge-trigger problem — this is the actual work
vimcode's dialogs are frame-driven state.
engine.dialog: Option<Dialog>(src/core/engine/mod.rs:363) is converted to aquadraui::Dialogbydialog_panel_to_quadraui_dialog(src/render.rs:4887) and repainted every frame viabackend.draw_dialog(src/gtk/mod.rs:9333). A native dialog cannot be re-presented every frame — it must be presented once, on the transition from no-dialog to dialog.So:
WidgetIdback to exactly the action the in-canvasDialogHit::Button(id)path already produces atsrc/gtk/mod.rs:4399, so the engine sees one code path and one set of outcomes.The blocking call shape is already established here —
backend.services().show_file_open_dialog(...)atsrc/gtk/mod.rs:1773blocks on quadraui's nested-mainloop pump, andPendingFileDialog(src/gtk/mod.rs:1089) already models "a dialog is in flight". Follow that, don't invent a second mechanism.What stays in-canvas
src/tui_main/**is untouched by this issue and its snapshots must not move.src/render.rs:8177sc_help_dialog, aDialogTable), the text-input dialogs (src/render.rs:4915), and source control's (src/core/engine/source_control.rs:269). These must keep working exactly as today.The native/in-canvas decision comes from quadraui's
native_dialog_optionsreturningNone— do not hand-maintain a list of dialog kinds in this repo.Acceptance
:qwith unsaved changes, and the tab-x close path (Msgvariants atsrc/gtk/mod.rs:1380-1388).Files
src/gtk/mod.rs— present-once edge trigger, response-to-action mapping, in-canvas suppression while native is in flightsrc/quadraui_pin.rs— pin bumpsrc/render.rs— only if the descriptor must carry the native/in-canvas decision throughSupersedes
#209, option 3 ("native widget escape hatch"). When this lands, either close #209 or retitle it to the styling-only options that remain.
Surfaced by
Operator smoke against VS Code, 2026-09-01.