This is an automated email from the git hooks/post-receive script.
git pushed a commit to branch add-term-sync
in repository terminology.
View the commit online.
commit e04aa50f86a8b273a45461dff0245f5b41c7ec27
Author: [email protected] <[email protected]>
AuthorDate: Mon Apr 27 11:11:31 2026 -0600
feat: add Synchronized Output (DECSET/DECRST 2026) protocol support
Modern TUI applications (Claude Code, neovim, htop, lazygit, etc.) use the
Synchronized Output protocol to frame each logical update in ESC[?2026h…
ESC[?2026l brackets. Terminals that support it (kitty, foot, alacritty,
wezterm, ghostty, contour, iTerm2, Windows Terminal) buffer between Begin
and End markers and flip the frame atomically. Without support, terminals
render every intermediate state—partial cursor moves, erasures, writes—
leaving visible glyph fragments scattered during live TUI sessions.
This implementation follows the Synchronized Output proposal:
- ESC[?2026h (BSU): enable sync; stop scheduling render updates
- ESC[?2026l (ESU): disable sync; schedule one pending render tick
- ESC[?2026$p (DECRQM): query sync state
- 150 ms watchdog: force end-of-sync if app forgets ESU, with one warning
- Soft/hard reset clears stuck sync state (covers RIS via delegation)
The render gate lives in termio_smart_update_queue—while sync is active,
returns without scheduling; at ESU, schedules one tick. Extends the
existing per-read coalesce window to span an entire BSU…ESU bracket
without changing render internals or Evas behavior.
Zero behavior change for apps that don't send mode 2026.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
---
src/bin/termio.c | 6 ++++
src/bin/termpty.c | 16 +++++++++++
src/bin/termpty.h | 5 ++++
src/bin/termptyesc.c | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++
src/bin/termptyops.c | 1 +
5 files changed, 107 insertions(+)
diff --git a/src/bin/termio.c b/src/bin/termio.c
index baad9d0d..49948b00 100644
--- a/src/bin/termio.c
+++ b/src/bin/termio.c
@@ -3360,6 +3360,12 @@ _smart_cb_change(void *data)
void
termio_smart_update_queue(Termio *sd)
{
+ /* While a Synchronized Output window is open, defer rendering.
+ * Cell state continues to update; only presentation is gated here.
+ * _sync_output_end (explicit ESU or watchdog) calls cb.change.func,
+ * which reaches here with active == EINA_FALSE and schedules the tick. */
+ if (sd->pty && sd->pty->sync_output.active)
+ return;
if (sd->anim)
return;
sd->anim = ecore_animator_add(_smart_cb_change, sd->self);
diff --git a/src/bin/termpty.c b/src/bin/termpty.c
index 8f389d90..cd105705 100644
--- a/src/bin/termpty.c
+++ b/src/bin/termpty.c
@@ -880,6 +880,11 @@ termpty_free(Termpty *ty)
ty->pid = -1;
}
}
+ if (ty->sync_output.watchdog)
+ {
+ ecore_timer_del(ty->sync_output.watchdog);
+ ty->sync_output.watchdog = NULL;
+ }
if (ty->hand_exe_exit)
ecore_event_handler_del(ty->hand_exe_exit);
if (ty->hand_fd)
@@ -909,6 +914,17 @@ termpty_free(Termpty *ty)
free(ty);
}
+void
+termpty_sync_output_reset(Termpty *ty)
+{
+ if (ty->sync_output.watchdog)
+ {
+ ecore_timer_del(ty->sync_output.watchdog);
+ ty->sync_output.watchdog = NULL;
+ }
+ ty->sync_output.active = EINA_FALSE;
+}
+
void
termpty_config_update(Termpty *ty, Config *config)
{
diff --git a/src/bin/termpty.h b/src/bin/termpty.h
index f907b252..a2e0f3dc 100644
--- a/src/bin/termpty.h
+++ b/src/bin/termpty.h
@@ -155,6 +155,10 @@ struct tag_Termpty
void *data;
} change, set_title, set_icon, cancel_sel, exited, bell, command;
} cb;
+ struct {
+ Ecore_Timer *watchdog;
+ Eina_Bool active : 1;
+ } sync_output;
struct {
const char *icon;
/* dynamic title set by xterm, keep it in case user don't want a
@@ -291,6 +295,7 @@ Termpty *termpty_new(const char *cmd, Eina_Bool login_shell, const char *cd,
int w, int h, Config *config, const char *title,
Ecore_Window window_id);
void termpty_free(Termpty *ty);
+void termpty_sync_output_reset(Termpty *ty);
void termpty_config_update(Termpty *ty, Config *config);
Termcell *termpty_cellrow_get(Termpty *ty, int y, ssize_t *wret);
diff --git a/src/bin/termptyesc.c b/src/bin/termptyesc.c
index e80c7bd5..6ed26400 100644
--- a/src/bin/termptyesc.c
+++ b/src/bin/termptyesc.c
@@ -279,6 +279,54 @@ _move_cursor_to_origin(Termpty *ty)
}
+/* --- Synchronized Output (DECSET/DECRST 2026) helpers --- */
+
+static void
+_sync_output_end(Termpty *ty)
+{
+ if (!ty->sync_output.active) return;
+
+ ty->sync_output.active = EINA_FALSE;
+ if (ty->sync_output.watchdog)
+ {
+ ecore_timer_del(ty->sync_output.watchdog);
+ ty->sync_output.watchdog = NULL;
+ }
+ /* Nudge the existing change pipeline: cb.change.func passes the gate
+ * (active is now FALSE) and schedules the deferred animator tick. */
+ if (ty->cb.change.func)
+ ty->cb.change.func(ty->cb.change.data);
+}
+
+static Eina_Bool
+_sync_output_watchdog_cb(void *data)
+{
+ Termpty *ty = data;
+
+ WRN("Synchronized Output watchdog fired — ESU missing after 150 ms, "
+ "forcing render (mode 2026)");
+ ty->sync_output.watchdog = NULL; /* timer owns itself; NULL before _end */
+ _sync_output_end(ty);
+ return ECORE_CALLBACK_CANCEL;
+}
+
+static void
+_sync_output_begin(Termpty *ty)
+{
+ if (ty->sync_output.active)
+ {
+ /* Nested BSU: ignore but reset the watchdog deadline. */
+ if (ty->sync_output.watchdog)
+ ecore_timer_reset(ty->sync_output.watchdog);
+ return;
+ }
+ ty->sync_output.active = EINA_TRUE;
+ ty->sync_output.watchdog =
+ ecore_timer_add(0.150, _sync_output_watchdog_cb, ty);
+}
+
+/* --------------------------------------------------------- */
+
static void
_handle_esc_csi_reset_mode(Termpty *ty, Eina_Unicode cc, Eina_Unicode *b,
const Eina_Unicode * const end)
@@ -540,6 +588,12 @@ _handle_esc_csi_reset_mode(Termpty *ty, Eina_Unicode cc, Eina_Unicode *b,
case 2004:
ty->bracketed_paste = mode;
break;
+ case 2026: /* Synchronized Output (BSU/ESU) */
+ if (mode)
+ _sync_output_begin(ty);
+ else
+ _sync_output_end(ty);
+ break;
case 7700: // ignore
WRN("TODO: ambiguous width reporting %i", mode);
ty->decoding_error = EINA_TRUE;
@@ -3570,6 +3624,31 @@ _handle_esc_csi(Termpty *ty, const Eina_Unicode *c, const Eina_Unicode *ce)
DBG("soft reset (DECSTR)");
termpty_soft_reset_state(ty);
}
+ else if (*(cc-1) == '$' && b && *b == '?')
+ {
+ /* DECRQM — DEC Request Mode (private).
+ * Parse ?<mode>$p and reply \e[?<mode>;<status>$y
+ * Status: 1 = set, 2 = reset, 0 = not recognized.
+ */
+ char bf[32];
+ int len, qarg;
+ b++; /* skip '?' */
+ qarg = _csi_arg_get(ty, &b);
+ if (qarg < 0) break; /* malformed: no response */
+ if (qarg == 2026)
+ {
+ /* Known mode: report current state */
+ len = snprintf(bf, sizeof(bf), "\033[?2026;%d$y",
+ ty->sync_output.active ? 1 : 2);
+ termpty_write(ty, bf, len);
+ }
+ else
+ {
+ /* Unknown/unrecognized mode: report 0 */
+ len = snprintf(bf, sizeof(bf), "\033[?%d;0$y", qarg);
+ termpty_write(ty, bf, len);
+ }
+ }
else
{
goto unhandled;
diff --git a/src/bin/termptyops.c b/src/bin/termptyops.c
index e07ad68a..b60db82a 100644
--- a/src/bin/termptyops.c
+++ b/src/bin/termptyops.c
@@ -456,6 +456,7 @@ termpty_soft_reset_state(Termpty *ty)
ty->mouse_mode = MOUSE_OFF;
ty->mouse_ext = MOUSE_EXT_NONE;
ty->bracketed_paste = 0;
+ termpty_sync_output_reset(ty);
termio_reset_main_colors(ty->obj);
termpty_clear_tabs_on_screen(ty);
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.