diff --git a/html_orig/_debug_replacements.php b/html_orig/_debug_replacements.php
index ef20620..93fdf77 100644
--- a/html_orig/_debug_replacements.php
+++ b/html_orig/_debug_replacements.php
@@ -11,7 +11,7 @@ return [
 	'%btn1%' => '1',
 	'%btn2%' => '2',
 	'%btn3%' => '3',
-	'%btn4%' => '4',
+	'%btn4%' => '',
 	'%btn5%' => '5',
 
 	'%screenData%' => 'HELLOxNRE3',//'\u000b\u0001\u001b\u0001\u0001\u0001\u0001\u0001\f\u0005\u0001\u0010\u0003HELLOx\u0002\u000b\u0001\u0001N\u0001RE\u00023\u0001', //,
diff --git a/html_orig/js/app.js b/html_orig/js/app.js
index e2744a5..3417498 100644
--- a/html_orig/js/app.js
+++ b/html_orig/js/app.js
@@ -1011,10 +1011,16 @@ $._loader = function(vis) {
 	$('#loader').toggleClass('show', vis);
 };
 
+function showPage() {
+	$('#content').addClass('load');
+}
+
 $.ready(function() {
-	setTimeout(function() {
-		$('#content').addClass('load');
-	}, 1);
+	if (window.noAutoShow !== true) {
+		setTimeout(function () {
+			showPage();
+		}, 1);
+	}
 });
 // Generated from PHP locale file
 var _tr = {
@@ -1333,8 +1339,7 @@ var Screen = (function () {
 	var SEQ_SET_COLOR = 1;
 	var SEQ_REPEAT = 2;
 
-	/** Load screen content from a binary sequence (new) */
-	function load(str) {
+	function _load_content(str) {
 		var i = 0, ci = 0, j, jc, num, num2, t = ' ', fg, bg, bold, cell;
 
 		if (!inited) _init();
@@ -1402,6 +1407,31 @@ var Screen = (function () {
 		_drawAll();
 	}
 
+	function _load_labels(str) {
+		var pieces = str.split('|');
+		qs('h1').textContent = pieces[0];
+		qsa('#buttons button').forEach(function(x, i) {
+			var s = pieces[i+1].trim();
+			x.innerHTML = s.length >0 ? e(s) : "&nbsp;";
+			x.style.opacity = s.length > 0 ? 1 : 0.2;
+		});
+	}
+
+	/** Load screen content from a binary sequence (new) */
+	function load(str) {
+		var content = str.substr(1);
+		switch(str.charAt(0)) {
+			case 'S':
+				_load_content(content);
+				break;
+			case 'T':
+				_load_labels(content);
+				break;
+			default:
+				console.warn("Bad data message type, ignoring.");
+		}
+	}
+
 	return  {
 		load: load, // full load (string)
 	};
@@ -1458,6 +1488,8 @@ var Conn = (function() {
 			if (status !== 200) location.reload(true);
 			console.log("Data received!");
 			Screen.load(resp);
+
+			showPage();
 		});
 	}
 
diff --git a/html_orig/jssrc/appcommon.js b/html_orig/jssrc/appcommon.js
index e350ed2..091f806 100644
--- a/html_orig/jssrc/appcommon.js
+++ b/html_orig/jssrc/appcommon.js
@@ -112,8 +112,14 @@ $._loader = function(vis) {
 	$('#loader').toggleClass('show', vis);
 };
 
+function showPage() {
+	$('#content').addClass('load');
+}
+
 $.ready(function() {
-	setTimeout(function() {
-		$('#content').addClass('load');
-	}, 1);
+	if (window.noAutoShow !== true) {
+		setTimeout(function () {
+			showPage();
+		}, 1);
+	}
 });
diff --git a/html_orig/jssrc/term.js b/html_orig/jssrc/term.js
index 50d3377..3155c57 100644
--- a/html_orig/jssrc/term.js
+++ b/html_orig/jssrc/term.js
@@ -147,8 +147,7 @@ var Screen = (function () {
 	var SEQ_SET_COLOR = 1;
 	var SEQ_REPEAT = 2;
 
-	/** Load screen content from a binary sequence (new) */
-	function load(str) {
+	function _load_content(str) {
 		var i = 0, ci = 0, j, jc, num, num2, t = ' ', fg, bg, bold, cell;
 
 		if (!inited) _init();
@@ -216,6 +215,31 @@ var Screen = (function () {
 		_drawAll();
 	}
 
+	function _load_labels(str) {
+		var pieces = str.split('|');
+		qs('h1').textContent = pieces[0];
+		qsa('#buttons button').forEach(function(x, i) {
+			var s = pieces[i+1].trim();
+			x.innerHTML = s.length >0 ? e(s) : "&nbsp;";
+			x.style.opacity = s.length > 0 ? 1 : 0.2;
+		});
+	}
+
+	/** Load screen content from a binary sequence (new) */
+	function load(str) {
+		var content = str.substr(1);
+		switch(str.charAt(0)) {
+			case 'S':
+				_load_content(content);
+				break;
+			case 'T':
+				_load_labels(content);
+				break;
+			default:
+				console.warn("Bad data message type, ignoring.");
+		}
+	}
+
 	return  {
 		load: load, // full load (string)
 	};
@@ -272,6 +296,8 @@ var Conn = (function() {
 			if (status !== 200) location.reload(true);
 			console.log("Data received!");
 			Screen.load(resp);
+
+			showPage();
 		});
 	}
 
diff --git a/html_orig/pages/help.php b/html_orig/pages/help.php
index e77ae20..bdd1ed1 100644
--- a/html_orig/pages/help.php
+++ b/html_orig/pages/help.php
@@ -338,6 +338,16 @@
 			<td>--</td>
 			<td>Query device status, replies with <code>\e[0n</code> "device is OK". Can be used to check if the UART works.</td>
 		</tr>
+		<tr>
+			<td>\e]TITLE=…\a</td>
+			<td>Title text</td>
+			<td>Set terminal title. Avoid ", ', &lt; and &gt;. This is a custom ESPTerm OSC command.</td>
+		</tr>
+		<tr>
+			<td>\e]BTNn=…\a</td>
+			<td>1-5, label</td>
+			<td>Set button label. Avoid ", ', &lt; and &gt;. This is a custom ESPTerm OSC command.</td>
+		</tr>
 		<tr>
 			<td>ASCII 24 (18h)</td>
 			<td></td>
diff --git a/html_orig/pages/term.php b/html_orig/pages/term.php
index 1f2480b..f7507d1 100644
--- a/html_orig/pages/term.php
+++ b/html_orig/pages/term.php
@@ -8,17 +8,17 @@
 	}, 2000);
 </script>
 
-<h1>%term_title%</h1>
+<h1></h1>
 
 <div id="termwrap">
 	<div id="screen" class="theme-%theme%"></div>
 
 	<div id="buttons">
-		<button data-n="1" class="btn-blue">%btn1%</button><!--
-		--><button data-n="2" class="btn-blue">%btn2%</button><!--
-		--><button data-n="3" class="btn-blue">%btn3%</button><!--
-		--><button data-n="4" class="btn-blue">%btn4%</button><!--
-		--><button data-n="5" class="btn-blue">%btn5%</button>
+		<button data-n="1" class="btn-blue"></button><!--
+		--><button data-n="2" class="btn-blue"></button><!--
+		--><button data-n="3" class="btn-blue"></button><!--
+		--><button data-n="4" class="btn-blue"></button><!--
+		--><button data-n="5" class="btn-blue"></button>
 	</div>
 </div>
 
@@ -31,10 +31,14 @@
 	--><a href="<?= url('about') ?>">About</a>
 </nav>
 
+<!-- this avoids having to escape quotes for JSON -->
+<span class="hidden" id="initial_data">T%term_title%|%btn1%|%btn2%|%btn3%|%btn4%|%btn5%</span>
+
 <script>
-	// TODO cleanup
 	try {
-		termInit();
+		window.noAutoShow = true;
+		termInit(); // the screen will be loaded via ajax
+		Screen.load(qs('#initial_data').textContent);
 
 		// auto-clear the input box
 		$('#softkb-input').on('input', function(e) {
diff --git a/user/ansi_parser_callbacks.c b/user/ansi_parser_callbacks.c
index 306fc00..2bd2e8c 100644
--- a/user/ansi_parser_callbacks.c
+++ b/user/ansi_parser_callbacks.c
@@ -276,14 +276,14 @@ void ICACHE_FLASH_ATTR
 apars_handle_OSC_SetButton(int num, const char *buffer)
 {
 	strncpy(termconf_scratch.btn[num-1], buffer, TERM_BTN_LEN);
-	dbg("Term set BTN%d = %s", num, buffer);
-	// TODO notify
+	info("OSC: Set BTN%d = %s", num, buffer);
+	screen_notifyChange(CHANGE_LABELS);
 }
 
 void ICACHE_FLASH_ATTR
 apars_handle_OSC_SetTitle(const char *buffer)
 {
 	strncpy(termconf_scratch.title, buffer, TERM_TITLE_LEN);
-	dbg("Term set TITLE = %s", buffer);
-	// TODO notify
+	info("OSC: Set TITLE = %s", buffer);
+	screen_notifyChange(CHANGE_LABELS);
 }
diff --git a/user/cgi_sockets.c b/user/cgi_sockets.c
index ff3357f..45fc5c1 100644
--- a/user/cgi_sockets.c
+++ b/user/cgi_sockets.c
@@ -9,9 +9,28 @@
 #define SOCK_BUF_LEN 2048
 static char sock_buff[SOCK_BUF_LEN];
 
-static void notifyTimCb(void *arg) {
+volatile bool notify_available = true;
+
+static ETSTimer notifyTim;
+static ETSTimer notifyTim2;
+
+// we're trying to do a kind of mutex here, without the actual primitives
+// this might glitch, very rarely.
+// it's recommended to put some delay between setting labels and updating the screen.
+
+static void ICACHE_FLASH_ATTR
+notifyTimCb(void *arg) {
 	void *data = NULL;
 
+	if (!notify_available) {
+		// postpone a little
+		os_timer_disarm(&notifyTim);
+		os_timer_setfn(&notifyTim, notifyTimCb, NULL);
+		os_timer_arm(&notifyTim, 1, 0);
+		return;
+	}
+	notify_available = false;
+
 	for (int i = 0; i < 20; i++) {
 		httpd_cgi_state cont = screenSerializeToBuffer(sock_buff, SOCK_BUF_LEN, &data);
 		int flg = 0;
@@ -21,21 +40,53 @@ static void notifyTimCb(void *arg) {
 		if (cont == HTTPD_CGI_DONE) break;
 	}
 
+	// cleanup
 	screenSerializeToBuffer(NULL, SOCK_BUF_LEN, &data);
+
+	notify_available = true;
+}
+
+static void ICACHE_FLASH_ATTR
+notifyLabelsTimCb(void *arg)
+{
+	if (!notify_available) {
+		// postpone a little
+		os_timer_disarm(&notifyTim2);
+		os_timer_setfn(&notifyTim2, notifyLabelsTimCb, NULL);
+		os_timer_arm(&notifyTim2, 1, 0);
+		return;
+	}
+	notify_available = false;
+
+	screenSerializeLabelsToBuffer(sock_buff, SOCK_BUF_LEN);
+	cgiWebsockBroadcast(URL_WS_UPDATE, sock_buff, (int) strlen(sock_buff), 0);
+
+	notify_available = true;
 }
 
-static ETSTimer notifyTim;
 
 /**
  * Broadcast screen state to sockets.
  * This is a callback for the Screen module,
  * called after each visible screen modification.
  */
-void ICACHE_FLASH_ATTR screen_notifyChange(void)
+void ICACHE_FLASH_ATTR screen_notifyChange(ScreenNotifyChangeTopic topic)
 {
-	os_timer_disarm(&notifyTim);
-	os_timer_setfn(&notifyTim, notifyTimCb, NULL);
-	os_timer_arm(&notifyTim, 20, 0);
+	// this is not the most ideal/cleanest implementation
+	// PRs are welcome for a nicer update "queue" solution
+
+	if (topic == CHANGE_LABELS) {
+		// separate timer from content change timer, to avoid losing that update
+		os_timer_disarm(&notifyTim2);
+		os_timer_setfn(&notifyTim2, notifyLabelsTimCb, NULL);
+		os_timer_arm(&notifyTim2, SCREEN_NOTIFY_DELAY_MS, 0);
+	}
+	else if (topic == CHANGE_CONTENT) {
+		// throttle delay
+		os_timer_disarm(&notifyTim);
+		os_timer_setfn(&notifyTim, notifyTimCb, NULL);
+		os_timer_arm(&notifyTim, SCREEN_NOTIFY_DELAY_MS, 0);
+	}
 }
 
 /** Socket received a message */
@@ -45,6 +96,8 @@ void ICACHE_FLASH_ATTR updateSockRx(Websock *ws, char *data, int len, int flags)
 	// Add terminator if missing (seems to randomly happen)
 	data[len] = 0;
 
+	// TODO re-implement those, use single byte markers and B2 encoding
+
 	dbg("Sock RX str: %s, len %d", data, len);
 
 	if (strstarts(data, "STR:")) {
diff --git a/user/cgi_sockets.h b/user/cgi_sockets.h
index 9a7f050..8aebbc9 100644
--- a/user/cgi_sockets.h
+++ b/user/cgi_sockets.h
@@ -3,7 +3,10 @@
 
 #define URL_WS_UPDATE "/term/update.ws"
 
+#include "screen.h"
+
 /** Update websocket connect callback */
 void updateSockConnect(Websock *ws);
+void screen_notifyChange(ScreenNotifyChangeTopic topic);
 
 #endif //CGI_SOCKETS_H
diff --git a/user/screen.c b/user/screen.c
index c096959..dfb5af5 100644
--- a/user/screen.c
+++ b/user/screen.c
@@ -96,7 +96,7 @@ static volatile int notifyLock = 0;
 
 #define NOTIFY_DONE()   do { \
 							if (notifyLock > 0) notifyLock--; \
-							if (notifyLock == 0) screen_notifyChange(); \
+							if (notifyLock == 0) screen_notifyChange(CHANGE_CONTENT); \
 						} while(0)
 
 /**
@@ -371,8 +371,8 @@ screen_cursor_move(int dy, int dx)
 
 	cursor.x += dx;
 	cursor.y += dy;
-	if (cursor.x >= W) cursor.x = W - 1;
-	if (cursor.x < 0) cursor.x = 0;
+	if (cursor.x >= (int)W) cursor.x = W - 1;
+	if (cursor.x < (int)0) cursor.x = 0;
 
 	if (cursor.y < 0) {
 		move = -cursor.y;
@@ -674,6 +674,25 @@ encode2B(u16 number, WordB2 *stru)
 	stru->lsb += 1;
 }
 
+/**
+ * buffer should be at least 64+5*10+6 long (title + buttons + 6), ie. 120
+ * @param buffer
+ * @param buf_len
+ */
+void ICACHE_FLASH_ATTR
+screenSerializeLabelsToBuffer(char *buffer, size_t buf_len)
+{
+	// let's just assume it's long enough - called with the huge websocket buffer
+	sprintf(buffer, "T%s|%s|%s|%s|%s|%s",
+			termconf_scratch.title,
+			termconf_scratch.btn[0],
+			termconf_scratch.btn[1],
+			termconf_scratch.btn[2],
+			termconf_scratch.btn[3],
+			termconf_scratch.btn[4]
+	);
+}
+
 /**
  * Serialize the screen to a data buffer. May need multiple calls if the buffer is insufficient in size.
  *
@@ -728,7 +747,7 @@ screenSerializeToBuffer(char *buffer, size_t buf_len, void **data)
 			, &w5);
 
 		// H W X Y Attribs
-		bufprint("%c%c%c%c%c%c%c%c%c%c", w1.lsb, w1.msb, w2.lsb, w2.msb, w3.lsb, w3.msb, w4.lsb, w4.msb, w5.lsb, w5.msb);
+		bufprint("S%c%c%c%c%c%c%c%c%c%c", w1.lsb, w1.msb, w2.lsb, w2.msb, w3.lsb, w3.msb, w4.lsb, w4.msb, w5.lsb, w5.msb);
 	}
 
 	int i = ss->index;
@@ -749,12 +768,6 @@ screenSerializeToBuffer(char *buffer, size_t buf_len, void **data)
 
 		if (repCnt == 0) {
 			// No repeat
-
-			// All this crap is needed because it's JSON and also
-			// embedded in HTML (hence the angle brackets)
-
-			// TODO use the encoding magic correctly
-
 			if (cell0->bold != ss->lastBold || cell0->fg != ss->lastFg || cell0->bg != ss->lastBg) {
 				encode2B((u16) (
 							 cell0->fg |
@@ -771,19 +784,6 @@ screenSerializeToBuffer(char *buffer, size_t buf_len, void **data)
 				bufprint("%c", c);
 			}
 
-			// TODO do correctly JSON encoding
-//
-//			char c = cell0->c;
-//			if (c == '"' || c == '\\') {
-//				bufprint("\\%c", c);
-//			}
-//			else if (c == '<' || c == '>' || c == '\'' || c == '/' || c == '&') {
-//				bufprint("\\u00%02X", (int)c);
-//			}
-//			else {
-//				bufprint("%c", c);
-//			}
-
 			ss->lastFg = cell0->fg;
 			ss->lastBg = cell0->bg;
 			ss->lastBold = cell0->bold;
diff --git a/user/screen.h b/user/screen.h
index 96f389a..a1d17fc 100644
--- a/user/screen.h
+++ b/user/screen.h
@@ -41,6 +41,13 @@
 #define TERM_BTN_LEN 10
 #define TERM_TITLE_LEN 64
 
+typedef enum {
+	CHANGE_CONTENT,
+	CHANGE_LABELS,
+} ScreenNotifyChangeTopic;
+
+#define SCREEN_NOTIFY_DELAY_MS 20
+
 typedef struct {
 	u32 width;
 	u32 height;
@@ -91,6 +98,9 @@ typedef uint8_t Color;
 
 httpd_cgi_state screenSerializeToBuffer(char *buffer, size_t buf_len, void **data);
 
+void screenSerializeLabelsToBuffer(char *buffer, size_t buf_len);
+
+
 typedef struct {
 	u8 lsb;
 	u8 msb;
@@ -187,6 +197,6 @@ void screen_putchar(const char *ch);
 void screen_dd(void);
 #endif
 
-extern void screen_notifyChange(void);
+extern void screen_notifyChange(ScreenNotifyChangeTopic topic);
 
 #endif // SCREEN_H
