Index: uspace/lib/clui/src/tinput.c
===================================================================
--- uspace/lib/clui/src/tinput.c	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/lib/clui/src/tinput.c	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
@@ -113,50 +113,45 @@
 static void tinput_display_tail(tinput_t *ti, size_t start, size_t pad)
 {
-	char32_t *dbuf = malloc((INPUT_MAX_SIZE + 1) * sizeof(char32_t));
-	if (!dbuf)
-		return;
-
+	char32_t stash;
 	size_t sa;
 	size_t sb;
 	tinput_sel_get_bounds(ti, &sa, &sb);
+	assert(sa <= sb);
 
 	tinput_console_set_lpos(ti, ti->text_coord + start);
 	console_set_style(ti->console, STYLE_NORMAL);
 
-	size_t p = start;
-	if (p < sa) {
-		memcpy(dbuf, ti->buffer + p, (sa - p) * sizeof(char32_t));
-		dbuf[sa - p] = '\0';
-		printf("%ls", dbuf);
-		p = sa;
-	}
-
-	if (p < sb) {
+	sa = max(start, sa);
+	sb = max(start, sb);
+
+	if (start < sa) {
+		stash = ti->buffer[sa];
+		ti->buffer[sa] = L'\0';
+		printf("%ls", &ti->buffer[start]);
+		ti->buffer[sa] = stash;
+	}
+
+	if (sa < sb) {
 		console_flush(ti->console);
 		console_set_style(ti->console, STYLE_SELECTED);
 
-		memcpy(dbuf, ti->buffer + p,
-		    (sb - p) * sizeof(char32_t));
-		dbuf[sb - p] = '\0';
-		printf("%ls", dbuf);
-		p = sb;
-	}
+		stash = ti->buffer[sb];
+		ti->buffer[sb] = L'\0';
+		printf("%ls", &ti->buffer[sa]);
+		ti->buffer[sb] = stash;
+
+		console_flush(ti->console);
+		console_set_style(ti->console, STYLE_NORMAL);
+	}
+
+	if (sb < ti->nc) {
+		ti->buffer[ti->nc] = L'\0';
+		printf("%ls", &ti->buffer[sb]);
+	}
+
+	for (; pad > 0; pad--)
+		putuchar(' ');
 
 	console_flush(ti->console);
-	console_set_style(ti->console, STYLE_NORMAL);
-
-	if (p < ti->nc) {
-		memcpy(dbuf, ti->buffer + p,
-		    (ti->nc - p) * sizeof(char32_t));
-		dbuf[ti->nc - p] = '\0';
-		printf("%ls", dbuf);
-	}
-
-	for (p = 0; p < pad; p++)
-		putuchar(' ');
-
-	console_flush(ti->console);
-
-	free(dbuf);
 }
 
@@ -218,5 +213,5 @@
 	tinput_display_prompt(ti);
 
-	/* The screen might have scrolled after priting the prompt */
+	/* The screen might have scrolled after printing the prompt */
 	tinput_update_origin_coord(ti, ti->prompt_coord + str_width(ti->prompt));
 
@@ -237,13 +232,8 @@
 		return;
 
-	unsigned new_width = LIN_TO_COL(ti, ti->text_coord) + ti->nc + 1;
-	if (new_width % ti->con_cols == 0) {
-		/* Advancing to new line. */
-		sysarg_t new_height = (new_width / ti->con_cols) + 1;
-		if (new_height >= ti->con_rows) {
-			/* Disallow text longer than 1 page for now. */
-			return;
-		}
-	}
+	/* Disallow text longer than 1 page for now. */
+	unsigned prompt_len = ti->text_coord - ti->prompt_coord;
+	if (prompt_len + ti->nc + 1 >= ti->con_cols * ti->con_rows)
+		return;
 
 	size_t i;
@@ -881,4 +871,71 @@
 }
 
+static errno_t tinput_resize(tinput_t *ti)
+{
+	assert(ti->prompt_coord % ti->con_cols == 0);
+
+	errno_t rc = console_get_size(ti->console, &ti->con_cols, &ti->con_rows);
+	if (rc != EOK)
+		return rc;
+
+	sysarg_t col, row;
+	rc = console_get_pos(ti->console, &col, &row);
+	if (rc != EOK)
+		return rc;
+
+	assert(ti->prompt_coord <= ti->text_coord);
+	unsigned prompt_len = ti->text_coord - ti->prompt_coord;
+
+	size_t new_caret_coord = row * ti->con_cols + col;
+
+	if (prompt_len <= new_caret_coord && ti->pos <= new_caret_coord - prompt_len) {
+		ti->text_coord = new_caret_coord - ti->pos;
+		ti->prompt_coord = ti->text_coord - prompt_len;
+
+		unsigned prompt_col = ti->prompt_coord % ti->con_cols;
+		if (prompt_col != 0) {
+			/*
+			 * Prompt doesn't seem to start at column 0, which means
+			 * the console didn't reflow the line like we expected it to.
+			 * Change offsets a bit to recover.
+			 */
+			fprintf(stderr, "Unexpected prompt position after resize.\n");
+			ti->prompt_coord -= prompt_col;
+			ti->text_coord -= prompt_col;
+
+			console_cursor_visibility(ti->console, false);
+			tinput_display_prompt(ti);
+			tinput_display_tail(ti, 0, prompt_col);
+			tinput_position_caret(ti);
+			console_cursor_visibility(ti->console, true);
+		}
+
+		assert(ti->prompt_coord % ti->con_cols == 0);
+	} else {
+		/*
+		 * Overflown screen.
+		 * We will just trim the buffer and rewrite everything.
+		 */
+		console_clear(ti->console);
+
+		ti->nc = min(ti->nc, ti->con_cols * ti->con_rows - prompt_len - 1);
+		ti->pos = min(ti->pos, ti->nc);
+		ti->sel_start = min(ti->sel_start, ti->nc);
+
+		ti->prompt_coord = 0;
+		ti->text_coord = prompt_len;
+
+		console_cursor_visibility(ti->console, false);
+		tinput_display_prompt(ti);
+		tinput_display_tail(ti, 0, 0);
+		tinput_position_caret(ti);
+		console_cursor_visibility(ti->console, true);
+	}
+
+	assert(ti->nc + ti->text_coord < ti->con_cols * ti->con_rows);
+
+	return EOK;
+}
+
 /** Read in one line of input with initial text provided.
  *
@@ -927,4 +984,7 @@
 			tinput_pos(ti, &ev.ev.pos);
 			break;
+		case CEV_RESIZE:
+			tinput_resize(ti);
+			break;
 		}
 	}
Index: uspace/lib/console/include/io/cons_event.h
===================================================================
--- uspace/lib/console/include/io/cons_event.h	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/lib/console/include/io/cons_event.h	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
@@ -44,5 +44,7 @@
 	CEV_KEY,
 	/** Position event */
-	CEV_POS
+	CEV_POS,
+	/** Resize event */
+	CEV_RESIZE,
 } cons_event_type_t;
 
Index: uspace/lib/console/src/con_srv.c
===================================================================
--- uspace/lib/console/src/con_srv.c	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/lib/console/src/con_srv.c	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
@@ -53,5 +53,5 @@
 		ipc_set_arg4(icall, event->ev.key.mods);
 		ipc_set_arg5(icall, event->ev.key.c);
-		break;
+		return EOK;
 	case CEV_POS:
 		ipc_set_arg2(icall, (event->ev.pos.pos_id << 16) | (event->ev.pos.type & 0xffff));
@@ -59,10 +59,14 @@
 		ipc_set_arg4(icall, event->ev.pos.hpos);
 		ipc_set_arg5(icall, event->ev.pos.vpos);
-		break;
-	default:
-		return EIO;
-	}
-
-	return EOK;
+		return EOK;
+	case CEV_RESIZE:
+		ipc_set_arg2(icall, 0);
+		ipc_set_arg3(icall, 0);
+		ipc_set_arg4(icall, 0);
+		ipc_set_arg5(icall, 0);
+		return EOK;
+	}
+
+	return EIO;
 }
 
Index: uspace/lib/console/src/console.c
===================================================================
--- uspace/lib/console/src/console.c	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/lib/console/src/console.c	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
@@ -193,5 +193,5 @@
 		event->ev.key.mods = ipc_get_arg4(call);
 		event->ev.key.c = ipc_get_arg5(call);
-		break;
+		return EOK;
 	case CEV_POS:
 		event->ev.pos.pos_id = ipc_get_arg2(call) >> 16;
@@ -200,10 +200,10 @@
 		event->ev.pos.hpos = ipc_get_arg4(call);
 		event->ev.pos.vpos = ipc_get_arg5(call);
-		break;
-	default:
-		return EIO;
-	}
-
-	return EOK;
+		return EOK;
+	case CEV_RESIZE:
+		return EOK;
+	}
+
+	return EIO;
 }
 
Index: uspace/lib/meson.build
===================================================================
--- uspace/lib/meson.build	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/lib/meson.build	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
@@ -91,4 +91,5 @@
 	'sif',
 	'tbarcfg',
+	'termui',
 	'trackmod',
 	'untar',
Index: uspace/lib/termui/include/termui.h
===================================================================
--- uspace/lib/termui/include/termui.h	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
+++ uspace/lib/termui/include/termui.h	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
@@ -0,0 +1,140 @@
+/*
+ * Copyright (c) 2024 Jiří Zárevúcky
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ * - The name of the author may not be used to endorse or promote products
+ *   derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#ifndef USPACE_LIB_TERMUI_TERMUI_H_
+#define USPACE_LIB_TERMUI_TERMUI_H_
+
+#include <errno.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#define GLYPH_IDX_
+#define GLYPH_IDX_ENDL 0xffffffu
+
+struct termui;
+typedef struct termui termui_t;
+
+/* RGB555 color representation. See termui_color_from/to_rgb() */
+typedef uint16_t termui_color_t;
+#define TERMUI_COLOR_DEFAULT 0
+
+typedef struct {
+	unsigned italic : 1;
+	unsigned bold : 1;
+	unsigned underline : 1;
+	unsigned blink : 1;
+	unsigned strike : 1;
+	unsigned inverted : 1;
+	unsigned cursor : 1;
+	/*
+	 * Padding cells for wide characters.
+	 * Placed at the end of rows where a wide character should have gone
+	 * but didn't fit, and after wide characters to mark out the full space
+	 * taken.
+	 */
+	unsigned padding : 1;
+	// This is enough range for full Unicode coverage several times over.
+	// The library is almost completely oblivious to the meaning of glyph index,
+	// with the sole exception that zero is assumed to mean no glyph/empty cell.
+	// User application can utilize the extended range to, for example:
+	//  - support multiple fonts / fallback fonts
+	//  - support select combining character sequences that don't
+	//    have equivalent precomposed characters in Unicode
+	//  - support additional graphical features that aren't included in
+	//    this structure
+	// Empty cells are initialized to all zeros.
+	unsigned glyph_idx : 24;
+	termui_color_t fgcolor;
+	termui_color_t bgcolor;
+} termui_cell_t;
+
+/** Update callback for viewport contents. The updated region is always limited
+ * to a single row. One row can be updated by multiple invocations.
+ * @param userdata
+ * @param col   First column of the updated region.
+ * @param row   Viewport row of the updated region.
+ * @param cell  Updated cell data array.
+ * @param len   Length of the updated region.
+ */
+typedef void (*termui_update_cb_t)(void *userdata, int col, int row, const termui_cell_t *cell, int len);
+
+/** Scrolling callback.
+ * The entire viewport was shifted by the given number of rows.
+ * For example, when a new line is added at the bottom of a full screen,
+ * this is called with delta = +1.
+ * The recipient must call termui_force_viewport_update() for previously
+ * off-screen rows manually (allowing this callback to be implemented
+ * the same as refresh).
+ *
+ * @param userdata
+ * @param delta  Number of rows. Positive when viewport content moved up.
+ */
+typedef void (*termui_scroll_cb_t)(void *userdata, int delta);
+
+/** Refresh callback. Instructs user to re-render the entire screen.
+ *
+ * @param userdata
+ */
+typedef void (*termui_refresh_cb_t)(void *userdata);
+
+termui_t *termui_create(int cols, int rows, size_t history_lines);
+void termui_destroy(termui_t *termui);
+
+errno_t termui_resize(termui_t *termui, int cols, int rows, size_t history_lines);
+
+void termui_set_scroll_cb(termui_t *termui, termui_scroll_cb_t cb, void *userdata);
+void termui_set_update_cb(termui_t *termui, termui_update_cb_t cb, void *userdata);
+void termui_set_refresh_cb(termui_t *termui, termui_refresh_cb_t cb, void *userdata);
+
+void termui_put_lf(termui_t *termui);
+void termui_put_cr(termui_t *termui);
+void termui_put_crlf(termui_t *termui);
+void termui_put_tab(termui_t *termui);
+void termui_put_backspace(termui_t *termui);
+void termui_put_glyph(termui_t *termui, uint32_t glyph, int width);
+void termui_clear_screen(termui_t *termui);
+void termui_wipe_screen(termui_t *termui, int first_row);
+
+void termui_set_style(termui_t *termui, termui_cell_t style);
+void termui_set_pos(termui_t *termui, int col, int row);
+void termui_get_pos(const termui_t *termui, int *col, int *row);
+int termui_get_cols(const termui_t *termui);
+int termui_get_rows(const termui_t *termui);
+
+bool termui_get_cursor_visibility(const termui_t *termui);
+void termui_set_cursor_visibility(termui_t *termui, bool visible);
+termui_cell_t *termui_get_active_row(termui_t *termui, int row);
+void termui_history_scroll(termui_t *termui, int delta);
+void termui_force_viewport_update(const termui_t *termui, int first_row, int rows);
+bool termui_scrollback_is_active(const termui_t *termui);
+
+termui_color_t termui_color_from_rgb(uint8_t r, uint8_t g, uint8_t b);
+void termui_color_to_rgb(termui_color_t c, uint8_t *r, uint8_t *g, uint8_t *b);
+
+#endif /* USPACE_LIB_TERMUI_TERMUI_H_ */
Index: uspace/lib/termui/meson.build
===================================================================
--- uspace/lib/termui/meson.build	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
+++ uspace/lib/termui/meson.build	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
@@ -0,0 +1,29 @@
+#
+# Copyright (c) 2024 Jiří Zárevúcky
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# - Redistributions of source code must retain the above copyright
+#   notice, this list of conditions and the following disclaimer.
+# - Redistributions in binary form must reproduce the above copyright
+#   notice, this list of conditions and the following disclaimer in the
+#   documentation and/or other materials provided with the distribution.
+# - The name of the author may not be used to endorse or promote products
+#   derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+
+src = files('src/termui.c', 'src/history.c')
Index: uspace/lib/termui/src/history.c
===================================================================
--- uspace/lib/termui/src/history.c	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
+++ uspace/lib/termui/src/history.c	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
@@ -0,0 +1,737 @@
+/*
+ * Copyright (c) 2024 Jiří Zárevúcky
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ * - The name of the author may not be used to endorse or promote products
+ *   derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "history.h"
+
+#include <assert.h>
+#include <limits.h>
+#include <macros.h>
+#include <mem.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#define BLANK_CELLS_LEN 64
+static const termui_cell_t _blank_cells[BLANK_CELLS_LEN];
+
+static bool _lines_empty(struct line_buffer *lines)
+{
+	return lines->head == lines->tail;
+}
+
+static void _line_idx_inc(const struct line_buffer *lines, size_t *idx)
+{
+	if (*idx == lines->buf_len - 1)
+		*idx = 0;
+	else
+		(*idx)++;
+}
+
+static void _line_idx_dec(const struct line_buffer *lines, size_t *idx)
+{
+	if (*idx == 0)
+		*idx = lines->buf_len - 1;
+	else
+		(*idx)--;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+static void _cell_buffer_shrink(struct cell_buffer *cells)
+{
+	assert(cells->max_len > 0);
+	assert(cells->buf_len > cells->max_len);
+
+	size_t new_len = max(cells->max_len, cells->head_top);
+
+	termui_cell_t *new_buf = reallocarray(cells->buf,
+	    new_len, sizeof(termui_cell_t));
+
+	if (new_buf) {
+		cells->buf = new_buf;
+		cells->buf_len = new_len;
+	}
+}
+
+static void _line_buffer_shrink(struct line_buffer *lines)
+{
+	assert(lines->max_len > 0);
+	assert(lines->buf_len > lines->max_len);
+	assert(lines->head <= lines->tail);
+
+	size_t new_len = max(lines->max_len, lines->tail + 1);
+
+	struct history_line *new_buf = reallocarray(lines->buf,
+	    new_len, sizeof(struct history_line));
+
+	if (new_buf) {
+		lines->buf = new_buf;
+		lines->buf_len = new_len;
+	}
+}
+
+static void _evict_cells(struct cell_buffer *cells, size_t idx, size_t len)
+{
+	assert(idx == cells->head_offset);
+	assert(len <= cells->head_top);
+	assert(idx <= cells->head_top - len);
+
+	cells->head_offset += len;
+
+	if (cells->head_offset >= cells->head_top) {
+
+		cells->head_offset = 0;
+		cells->head_top = cells->tail_top;
+		cells->tail_top = 0;
+
+		if (cells->buf_len > cells->max_len)
+			_cell_buffer_shrink(cells);
+	}
+}
+
+static bool _index_valid(const struct history *history, size_t idx)
+{
+	const struct line_buffer *lines = &history->lines;
+
+	if (lines->head <= lines->tail)
+		return idx >= lines->head && idx < lines->tail;
+	else
+		return (idx >= lines->head && idx < lines->buf_len) ||
+		    (idx < lines->tail);
+}
+
+#define _history_check(history) do { \
+	assert(history->lines.head < history->lines.buf_len); \
+	assert(history->lines.tail < history->lines.buf_len); \
+	assert(history->cells.tail_top <= history->cells.head_offset); \
+	assert(history->cells.head_offset <= history->cells.head_top); \
+	assert(history->cells.head_top <= history->cells.buf_len); \
+	assert(_index_valid(history, history->viewport_top) || history->viewport_top == history->lines.tail); \
+	if (history->append) assert(!_lines_empty(&history->lines)); \
+} while (false)
+
+static void _evict_oldest_line(struct history *history)
+{
+	struct line_buffer *lines = &history->lines;
+	struct cell_buffer *cells = &history->cells;
+
+	_history_check(history);
+
+	bool head = (history->viewport_top == lines->head);
+
+	struct history_line line = lines->buf[lines->head];
+	_line_idx_inc(lines, &lines->head);
+
+	if (lines->head == lines->tail) {
+		lines->head = 0;
+		lines->tail = 0;
+		history->viewport_top = 0;
+		history->append = false;
+		history->row_delta = 0;
+	}
+
+	if (head) {
+		history->viewport_top = lines->head;
+		history->row_delta = 0;
+	}
+
+	_history_check(history);
+
+	if (lines->head == 0 && lines->buf_len > lines->max_len)
+		_line_buffer_shrink(lines);
+
+	_history_check(history);
+
+	_evict_cells(cells, line.idx, line.len);
+
+	_history_check(history);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+static void _cell_buffer_try_extend(struct cell_buffer *cells, size_t len)
+{
+	static const size_t MIN_EXTEND_LEN = 128;
+
+	if (cells->buf_len >= cells->max_len)
+		return;
+
+	if (cells->tail_top > 0 && len <= cells->buf_len - cells->tail_top) {
+		/*
+		 * Don't extend when we will have enough space, since head is gonna get
+		 * wiped either way (we don't move already existing lines).
+		 * This only matters when allocation has failed previously.
+		 */
+		return;
+	}
+
+	/* Specify a minimum initial allocation size. */
+	len = max(len, MIN_EXTEND_LEN);
+
+	/* Try to roughly double the buffer size. */
+	len = max(len, cells->buf_len);
+
+	/* Limit the new size to max_len. */
+	len = min(len, cells->max_len - cells->head_top);
+
+	size_t new_len =
+	    min(cells->head_top + len, SIZE_MAX / sizeof(termui_cell_t));
+
+	assert(new_len > cells->buf_len);
+	assert(new_len <= cells->max_len);
+
+	termui_cell_t *new_buf =
+	    realloc(cells->buf, new_len * sizeof(termui_cell_t));
+	if (!new_buf) {
+		fprintf(stderr, "termui: Out of memory for scrollback\n");
+		return;
+	}
+
+	cells->buf = new_buf;
+	cells->buf_len = new_len;
+}
+
+static void _line_buffer_try_extend(struct line_buffer *lines)
+{
+	static const size_t MIN_EXTEND_LEN = 128;
+
+	if (lines->buf_len >= lines->max_len)
+		return;
+
+	if (lines->tail < lines->head)
+		return;
+
+	/* Specify a minimum initial allocation size. */
+	size_t len = MIN_EXTEND_LEN;
+
+	/* Try to roughly double the buffer size. */
+	len = max(len, lines->buf_len);
+
+	/* Limit the new size to max_len. */
+	len = min(len, lines->max_len - lines->buf_len);
+
+	size_t new_len = min(lines->buf_len + len, SIZE_MAX - sizeof(struct history_line));
+
+	assert(new_len >= lines->buf_len);
+	assert(new_len <= lines->max_len);
+
+	if (new_len == lines->buf_len)
+		return;
+
+	struct history_line *new_buf =
+	    realloc(lines->buf, new_len * sizeof(struct history_line));
+	if (!new_buf) {
+		fprintf(stderr, "termui: Out of memory for scrollback\n");
+		return;
+	}
+
+	lines->buf = new_buf;
+	lines->buf_len = new_len;
+}
+
+static bool _cell_buffer_fits_line(const struct cell_buffer *cells, size_t len)
+{
+	if (cells->tail_top > 0) {
+		return len <= cells->head_offset - cells->tail_top;
+	} else {
+		return len <= cells->buf_len - cells->head_top || len <= cells->head_offset;
+	}
+}
+
+static struct history_line *_current_line(struct line_buffer *lines)
+{
+	assert(!_lines_empty(lines));
+	return &lines->buf[(lines->tail ? lines->tail : lines->buf_len) - 1];
+}
+
+static void _alloc_line(struct history *history)
+{
+	struct line_buffer *lines = &history->lines;
+
+	size_t idx = 0;
+	if (!_lines_empty(lines))
+		idx = _current_line(lines)->idx + _current_line(lines)->len;
+
+	if (lines->buf_len == 0) {
+		/* Initial allocation. */
+		_line_buffer_try_extend(lines);
+
+		if (lines->buf_len == 0) {
+			fprintf(stderr, "termui: Could not allocate initial scrollback buffer\n");
+			return;
+		}
+	}
+
+	assert(lines->tail < lines->buf_len);
+
+	bool viewport_inactive = (history->viewport_top == lines->tail);
+
+	lines->tail++;
+
+	if (lines->tail >= lines->buf_len)
+		_line_buffer_try_extend(lines);
+
+	if (lines->tail >= lines->buf_len)
+		lines->tail = 0;
+
+	if (lines->tail == lines->head)
+		_evict_oldest_line(history);
+
+	assert(lines->tail != lines->head);
+
+	if (viewport_inactive)
+		history->viewport_top = lines->tail;
+
+	_current_line(lines)->idx = idx;
+	_current_line(lines)->len = 0;
+
+	history->append = true;
+
+	_history_check(history);
+}
+
+/** Allocate a line of cells in the cell buffer.
+ * @return Index of first allocated cell in the buffer.
+ */
+static size_t _alloc_cells(struct cell_buffer *cells, size_t len)
+{
+	assert(_cell_buffer_fits_line(cells, len));
+
+	size_t idx;
+
+	if (cells->tail_top == 0 && cells->buf_len - cells->head_top >= len) {
+		idx = cells->head_top;
+		cells->head_top += len;
+		assert(cells->head_top <= cells->buf_len);
+	} else {
+		idx = cells->tail_top;
+		cells->tail_top += len;
+		assert(cells->tail_top <= cells->head_offset);
+	}
+
+	return idx;
+}
+
+static termui_cell_t *_history_append(struct history *history, size_t len)
+{
+	struct line_buffer *lines = &history->lines;
+	struct cell_buffer *cells = &history->cells;
+
+	/*
+	 * Ideally, buffer gets reallocated to its maximum size
+	 * before we start recycling it.
+	 */
+	if (!_cell_buffer_fits_line(cells, len))
+		_cell_buffer_try_extend(cells, len);
+
+	if (len > cells->buf_len) {
+		/*
+		 * This can only happen if allocation fails early on,
+		 * since len is normally limited to row width.
+		 */
+		return NULL;
+	}
+
+	/* Recycle old lines to make space in the buffer. */
+	while (!_cell_buffer_fits_line(cells, len)) {
+		assert(!_lines_empty(lines));
+		_evict_oldest_line(history);
+	}
+
+	/* Allocate cells for the line. */
+	size_t idx = _alloc_cells(cells, len);
+
+	/* Allocate the line, if necessary. */
+	if (!history->append || _lines_empty(lines)) {
+		_alloc_line(history);
+
+		if (_lines_empty(lines)) {
+			/* Initial allocation failed. */
+			return NULL;
+		}
+	}
+
+	struct history_line *line = _current_line(lines);
+
+	assert(idx == line->idx + line->len || idx == 0);
+
+	/* Deal with crossing the buffer's edge. */
+	if (idx != line->idx + line->len) {
+		if (line->len > 0) {
+			/* Breaks off an incomplete line at the end of buffer. */
+			_alloc_line(history);
+			line = _current_line(lines);
+		}
+
+		line->idx = 0;
+	}
+
+	line->len += len;
+
+	return &cells->buf[idx];
+}
+
+/**
+ * @param history
+ * @return True if the top row of the viewport is a scrollback row.
+ */
+bool _scrollback_active(const struct history *history)
+{
+	if (history->viewport_top == history->lines.tail)
+		return false;
+
+	assert(_index_valid(history, history->viewport_top));
+	return true;
+}
+
+static size_t _history_line_rows(const struct history *history, size_t idx)
+{
+	assert(_index_valid(history, idx));
+
+	struct history_line line = history->lines.buf[idx];
+
+	if (line.len == 0)
+		return 1;
+
+	return (line.len - 1) / history->cols + 1;
+}
+
+static int _history_scroll_down(struct history *history, int requested)
+{
+	assert(requested > 0);
+
+	size_t delta = requested;
+
+	/* Skip first line. */
+
+	if (history->row_delta > 0) {
+		size_t rows = _history_line_rows(history, history->viewport_top);
+		assert(rows > history->row_delta);
+
+		if (delta < rows - history->row_delta) {
+			history->row_delta += delta;
+			_history_check(history);
+			return requested;
+		}
+
+		delta -= rows - history->row_delta;
+		history->row_delta = 0;
+
+		_line_idx_inc(&history->lines, &history->viewport_top);
+	}
+
+	/* Skip as many lines as necessary. */
+
+	while (_scrollback_active(history)) {
+		size_t rows = _history_line_rows(history, history->viewport_top);
+
+		if (delta < rows) {
+			/* Found the right line. */
+			history->row_delta = delta;
+			_history_check(history);
+			return requested;
+		}
+
+		delta -= rows;
+
+		_line_idx_inc(&history->lines, &history->viewport_top);
+	}
+
+	/* Scrolled past the end of history. */
+	_history_check(history);
+	return requested - delta;
+}
+
+static int _history_scroll_up(struct history *history, int requested)
+{
+	assert(requested < 0);
+
+	/* Prevent overflow. */
+	if (history->row_delta > INT_MAX) {
+		history->row_delta += requested;
+		_history_check(history);
+		return requested;
+	}
+
+	int delta = requested + (int) history->row_delta;
+	history->row_delta = 0;
+
+	while (delta < 0 && history->viewport_top != history->lines.head) {
+		_line_idx_dec(&history->lines, &history->viewport_top);
+
+		size_t rows = _history_line_rows(history, history->viewport_top);
+
+		if (rows > INT_MAX) {
+			history->row_delta = rows + delta;
+			_history_check(history);
+			return requested;
+		}
+
+		delta += (int) rows;
+	}
+
+	_history_check(history);
+
+	if (delta < 0)
+		return requested - delta;
+
+	assert(delta >= 0);
+	history->row_delta = (size_t) delta;
+	return requested;
+}
+
+static int _history_scroll_to_top(struct history *history)
+{
+	history->viewport_top = history->lines.head;
+	history->row_delta = 0;
+	_history_check(history);
+	return INT_MIN;
+}
+
+static int _history_scroll_to_bottom(struct history *history)
+{
+	history->viewport_top = history->lines.tail;
+	_history_check(history);
+	return INT_MAX;
+}
+
+/** Scroll the viewport by the given number of rows.
+ *
+ * @param history
+ * @param delta  How many rows to scroll. Negative delta scrolls upward.
+ * @return  How many rows have actually been scrolled before top/bottom.
+ */
+int _history_scroll(struct history *history, int delta)
+{
+	if (delta == INT_MIN)
+		return _history_scroll_to_top(history);
+	if (delta == INT_MAX)
+		return _history_scroll_to_bottom(history);
+	if (delta > 0)
+		return _history_scroll_down(history, delta);
+	if (delta < 0)
+		return _history_scroll_up(history, delta);
+
+	return 0;
+}
+
+/** Sets new width for the viewport, recalculating current position so that the
+ * top viewport row remains in place, and returning a piece of the last history
+ * line if the top active screen row is a continuation of it.
+ *
+ * @param history
+ * @param new_cols       New column width of the viewport.
+ * @param[out] recouped  Number of cells returned to active screen.
+ * @return  Pointer to the cell data for returned cells.
+ */
+const termui_cell_t *_history_reflow(struct history *history, size_t new_cols, size_t *recouped)
+{
+	history->row_delta = (history->row_delta * history->cols) / new_cols;
+	history->cols = new_cols;
+
+	if (!history->append) {
+		*recouped = 0;
+		return NULL;
+	}
+
+	/* Return the part of last line that's not aligned at row boundary. */
+	assert(!_lines_empty(&history->lines));
+
+	size_t last_idx = history->lines.tail;
+	_line_idx_dec(&history->lines, &last_idx);
+
+	struct history_line *last = &history->lines.buf[last_idx];
+	*recouped = last->len % new_cols;
+
+	if (last->idx + last->len == history->cells.head_top) {
+		history->cells.head_top -= *recouped;
+	} else {
+		assert(last->idx + last->len == history->cells.tail_top);
+		history->cells.tail_top -= *recouped;
+	}
+
+	last->len -= *recouped;
+	if (last->len == 0 && last->idx == 0) {
+		assert(history->cells.tail_top == 0);
+		last->idx = history->cells.head_top;
+	}
+
+	return &history->cells.buf[last->idx + last->len];
+}
+
+/** Counts the number of scrollback rows present in the viewport.
+ *
+ * @param history
+ * @param max  Number of viewport rows.
+ * @return Count.
+ */
+int _history_viewport_rows(const struct history *history, size_t max)
+{
+	if (!_scrollback_active(history))
+		return 0;
+
+	size_t current = history->viewport_top;
+	size_t rows = _history_line_rows(history, current) - history->row_delta;
+	_line_idx_inc(&history->lines, &current);
+
+	while (rows < max && current != history->lines.tail) {
+		rows += _history_line_rows(history, current);
+		_line_idx_inc(&history->lines, &current);
+	}
+
+	//printf("Counted at least %d viewport rows.\n", rows);
+
+	return (rows > max) ? max : rows;
+}
+
+static void _update_blank(int col, int row, int len, termui_update_cb_t cb, void *udata)
+{
+	while (len > BLANK_CELLS_LEN) {
+		cb(udata, col, row, _blank_cells, BLANK_CELLS_LEN);
+		col += BLANK_CELLS_LEN;
+		len -= BLANK_CELLS_LEN;
+	}
+
+	if (len > 0)
+		cb(udata, col, row, _blank_cells, len);
+}
+
+static void _adjust_row_delta(const struct history *history, size_t *line_idx, size_t *delta)
+{
+	while (*line_idx != history->lines.tail) {
+		size_t rows = _history_line_rows(history, *line_idx);
+		if (*delta < rows)
+			return;
+
+		*delta -= rows;
+		_line_idx_inc(&history->lines, line_idx);
+	}
+}
+
+/** Run update callback for a range of visible scrollback srows.
+ *
+ * @param history
+ * @param row    First viewport row we want to update.
+ * @param count  Number of viewport rows we want to update.
+ * @param cb     Callback to call for every row.
+ * @param udata  Callback userdata.
+ * @return  Actual number of rows updated (may be less than count if
+ *     the rest of rows are from the active screen).
+ */
+int _history_iter_rows(const struct history *history, int row, int count, termui_update_cb_t cb, void *udata)
+{
+	assert(history->row_delta <= SIZE_MAX - row);
+
+	//printf("Iterating history rows: %d..%d\n", row, row + count);
+
+	size_t current_line = history->viewport_top;
+	size_t delta = history->row_delta + (size_t) row;
+	/* Get to the first row to be returned. */
+	_adjust_row_delta(history, &current_line, &delta);
+
+	int initial_count = count;
+
+	while (count > 0 && current_line != history->lines.tail) {
+		/* Process each line. */
+		assert(_index_valid(history, current_line));
+
+		struct history_line line = history->lines.buf[current_line];
+		assert(line.len <= history->cells.buf_len);
+		assert(line.idx <= history->cells.buf_len - line.len);
+
+		if (line.len == 0) {
+			/* Special case for empty line. */
+			_update_blank(0, row, history->cols, cb, udata);
+			row++;
+			count--;
+			_line_idx_inc(&history->lines, &current_line);
+			continue;
+		}
+
+		const termui_cell_t *cells = &history->cells.buf[line.idx];
+		size_t line_offset = delta * history->cols;
+		assert(line_offset < line.len);
+		delta = 0;
+
+		//printf("Line %zu, %zu rows\n", current_line, _history_line_rows(history, current_line));
+
+		/* Callback for each full row. */
+		while (count > 0 && line_offset + history->cols <= line.len) {
+			assert(line.idx + line_offset <= history->cells.buf_len - history->cols);
+			//printf("Iterating row %d in line %zu\n", row, current_line);
+			cb(udata, 0, row, &cells[line_offset], history->cols);
+
+			line_offset += history->cols;
+			row++;
+			count--;
+		}
+
+		if (count > 0 && line_offset < line.len) {
+			//printf("Iterating last row %d of line %zu\n", row, current_line);
+			/* Callback for the last (incomplete) row. */
+
+			cb(udata, 0, row, &cells[line_offset], line.len - line_offset);
+
+			size_t col = line.len - line_offset;
+			assert(col < history->cols);
+
+			/* Callbacks for the blank section in the last row. */
+			//printf("Updating %zu blank cells.\n", history->cols - col);
+			_update_blank(col, row, history->cols - col, cb, udata);
+
+			row++;
+			count--;
+		}
+
+		_line_idx_inc(&history->lines, &current_line);
+	}
+
+	return initial_count - count;
+}
+
+/** Append a row from active screen to scrollback history.
+ *
+ * @param history
+ * @param b     Pointer to the row in active screen buffer.
+ * @param last  False if the row was overflowed, meaning the next row will be
+ *              appended to the same history line as this row.
+ */
+void _history_append_row(struct history *history, const termui_cell_t *b, bool last)
+{
+	size_t len = history->cols;
+
+	/* Reduce multiple trailing empty cells to just one. */
+	if (last) {
+		while (len > 1 && _cell_is_empty(b[len - 1]) && _cell_is_empty(b[len - 2]))
+			len--;
+	}
+
+	memcpy(_history_append(history, len), b, sizeof(termui_cell_t) * len);
+
+	if (last)
+		history->append = false;
+}
Index: uspace/lib/termui/src/history.h
===================================================================
--- uspace/lib/termui/src/history.h	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
+++ uspace/lib/termui/src/history.h	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2024 Jiří Zárevúcky
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ * - The name of the author may not be used to endorse or promote products
+ *   derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <termui.h>
+#include <stdbool.h>
+
+#define INTERNAL __attribute__((visibility("internal")))
+
+static bool _cell_is_empty(const termui_cell_t cell)
+{
+	return cell.glyph_idx == 0 && cell.bgcolor == 0 && cell.fgcolor == 0 &&
+	    cell.padding == 0;
+}
+
+struct cell_buffer {
+	termui_cell_t *buf;
+
+	size_t head_offset;
+	size_t head_top;
+
+	/* Tail offset is implicitly zero. */
+	size_t tail_top;
+
+	size_t buf_len;
+	size_t max_len;
+};
+
+struct history_line {
+	size_t idx;
+	size_t len;
+};
+
+struct line_buffer {
+	struct history_line *buf;
+
+	size_t head;
+	size_t tail;
+
+	size_t buf_len;
+	size_t max_len;
+};
+
+struct history {
+	size_t viewport_top;
+	size_t row_delta;
+
+	size_t cols;
+
+	struct cell_buffer cells;
+	struct line_buffer lines;
+
+	bool append;
+};
+
+INTERNAL bool _scrollback_active(const struct history *history);
+INTERNAL void _history_append_row(struct history *history, const termui_cell_t *b, bool last);
+INTERNAL int _history_viewport_rows(const struct history *history, size_t max);
+INTERNAL int _history_iter_rows(const struct history *history, int row, int count, termui_update_cb_t cb, void *udata);
+INTERNAL int _history_scroll(struct history *history, int delta);
+INTERNAL const termui_cell_t *_history_reflow(struct history *history, size_t new_cols, size_t *recouped);
Index: uspace/lib/termui/src/termui.c
===================================================================
--- uspace/lib/termui/src/termui.c	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
+++ uspace/lib/termui/src/termui.c	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
@@ -0,0 +1,733 @@
+/*
+ * Copyright (c) 2024 Jiří Zárevúcky
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ * - The name of the author may not be used to endorse or promote products
+ *   derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <termui.h>
+
+#include <assert.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "history.h"
+
+struct termui {
+	int cols;
+	int rows;
+
+	int col;
+	int row;
+
+	bool cursor_visible;
+
+	// How much of the screen is in use. Relevant for clrscr.
+	int used_rows;
+
+	// Row index of the first screen row in the circular screen buffer.
+	int first_row;
+	// rows * cols circular buffer of the current virtual screen contents.
+	// Does not necessarily correspond to the currently visible text,
+	// if scrollback is active.
+	termui_cell_t *screen;
+	// Set to one if the corresponding row has overflowed into the next row.
+	uint8_t *overflow_flags;
+
+	/* Used to remove extra newline when CRLF is placed exactly on row boundary. */
+	bool overflow;
+
+	struct history history;
+
+	termui_cell_t style;
+	termui_cell_t default_cell;
+
+	termui_scroll_cb_t scroll_cb;
+	termui_update_cb_t update_cb;
+	termui_refresh_cb_t refresh_cb;
+	void *scroll_udata;
+	void *update_udata;
+	void *refresh_udata;
+};
+
+static int _real_row(const termui_t *termui, int row)
+{
+	row += termui->first_row;
+	if (row >= termui->rows)
+		row -= termui->rows;
+
+	return row;
+}
+
+#define _screen_cell(termui, col, row) \
+	((termui)->screen[(termui)->cols * _real_row((termui), (row)) + (col)])
+
+#define _current_cell(termui) \
+	_screen_cell((termui), (termui)->col, (termui)->row)
+
+#define _overflow_flag(termui, row) \
+	((termui)->overflow_flags[_real_row((termui), (row))])
+
+/** Sets current cell style/color.
+ */
+void termui_set_style(termui_t *termui, termui_cell_t style)
+{
+	termui->style = style;
+}
+
+static void _termui_evict_row(termui_t *termui)
+{
+	if (termui->used_rows <= 0)
+		return;
+
+	bool last = !_overflow_flag(termui, 0);
+
+	for (int col = 0; col < termui->cols; col++)
+		_screen_cell(termui, col, 0).cursor = 0;
+
+	/* Append first row of the screen to history. */
+	_history_append_row(&termui->history, &_screen_cell(termui, 0, 0), last);
+
+	_overflow_flag(termui, 0) = false;
+
+	/* Clear the row we moved to history. */
+	for (int col = 0; col < termui->cols; col++)
+		_screen_cell(termui, col, 0) = termui->default_cell;
+
+	termui->used_rows--;
+
+	termui->row--;
+	if (termui->row < 0) {
+		termui->row = 0;
+		termui->col = 0;
+	}
+
+	termui->first_row++;
+	if (termui->first_row >= termui->rows)
+		termui->first_row -= termui->rows;
+
+	assert(termui->first_row < termui->rows);
+}
+
+/**
+ * Get active screen row. This always points to the primary output buffer,
+ * unaffected by viewport shifting. Can be used for modifying the screen
+ * directly. For displaying viewport, use termui_force_viewport_update().
+ */
+termui_cell_t *termui_get_active_row(termui_t *termui, int row)
+{
+	assert(row >= 0);
+	assert(row < termui->rows);
+
+	return &_screen_cell(termui, 0, row);
+}
+
+static void _update_active_cells(termui_t *termui, int col, int row, int cells)
+{
+	int viewport_rows = _history_viewport_rows(&termui->history, termui->rows);
+	int active_rows_shown = termui->rows - viewport_rows;
+
+	/* Send update if the cells are visible in viewport. */
+	if (termui->update_cb && active_rows_shown > row)
+		termui->update_cb(termui->update_udata, col, row + viewport_rows, &_screen_cell(termui, col, row), cells);
+}
+
+static void _update_current_cell(termui_t *termui)
+{
+	_update_active_cells(termui, termui->col, termui->row, 1);
+}
+
+static void _cursor_off(termui_t *termui)
+{
+	if (termui->cursor_visible) {
+		_current_cell(termui).cursor = 0;
+		_update_current_cell(termui);
+	}
+}
+
+static void _cursor_on(termui_t *termui)
+{
+	if (termui->cursor_visible) {
+		_current_cell(termui).cursor = 1;
+		_update_current_cell(termui);
+	}
+}
+
+static void _advance_line(termui_t *termui)
+{
+	if (termui->row + 1 >= termui->rows) {
+		size_t old_top = termui->history.viewport_top;
+
+		_termui_evict_row(termui);
+
+		if (old_top != termui->history.viewport_top && termui->refresh_cb)
+			termui->refresh_cb(termui->refresh_udata);
+
+		if (termui->scroll_cb && !_scrollback_active(&termui->history))
+			termui->scroll_cb(termui->scroll_udata, 1);
+	}
+
+	termui->row++;
+
+	if (termui->row >= termui->used_rows)
+		termui->used_rows = termui->row + 1;
+
+	assert(termui->row < termui->rows);
+}
+
+void termui_put_lf(termui_t *termui)
+{
+	_cursor_off(termui);
+	termui->overflow = false;
+	_advance_line(termui);
+	_cursor_on(termui);
+}
+
+void termui_put_cr(termui_t *termui)
+{
+	_cursor_off(termui);
+
+	/* CR right after overflow from previous row. */
+	if (termui->overflow && termui->row > 0) {
+		termui->row--;
+		_overflow_flag(termui, termui->row) = 0;
+	}
+
+	termui->overflow = false;
+
+	// Set position to start of current line.
+	termui->col = 0;
+
+	_cursor_on(termui);
+}
+
+/* Combined CR & LF to cut down on cursor update callbacks. */
+void termui_put_crlf(termui_t *termui)
+{
+	_cursor_off(termui);
+
+	/* CR right after overflow from previous row. */
+	if (termui->overflow && termui->row > 0) {
+		termui->row--;
+		_overflow_flag(termui, termui->row) = 0;
+	}
+
+	termui->overflow = false;
+
+	// Set position to start of next row.
+	_advance_line(termui);
+	termui->col = 0;
+
+	_cursor_on(termui);
+}
+
+void termui_put_tab(termui_t *termui)
+{
+	_cursor_off(termui);
+
+	termui->overflow = false;
+
+	int new_col = (termui->col / 8 + 1) * 8;
+	if (new_col >= termui->cols)
+		new_col = termui->cols - 1;
+	termui->col = new_col;
+
+	_cursor_on(termui);
+}
+
+void termui_put_backspace(termui_t *termui)
+{
+	_cursor_off(termui);
+
+	termui->overflow = false;
+
+	if (termui->col == 0) {
+		if (termui->row > 0 && _overflow_flag(termui, termui->row - 1)) {
+			termui->row--;
+			termui->col = termui->cols - 1;
+			_overflow_flag(termui, termui->row) = false;
+		}
+	} else {
+		termui->col--;
+	}
+
+	_cursor_on(termui);
+}
+
+/**
+ * Put glyph at current position, and advance column by width, overflowing into
+ * next row and scrolling the active screen if necessary.
+ *
+ * If width > 1, the function makes sure the glyph isn't split by end of row.
+ * The following (width - 1) cells are filled with padding cells,
+ * and it's the user's responsibility to render this correctly.
+ */
+void termui_put_glyph(termui_t *termui, uint32_t glyph_idx, int width)
+{
+	if (termui->row >= termui->used_rows)
+		termui->used_rows = termui->row + 1;
+
+	termui_cell_t padding_cell = termui->style;
+	padding_cell.padding = 1;
+	termui_cell_t cell = termui->style;
+	cell.glyph_idx = glyph_idx;
+
+	// FIXME: handle wide glyphs in history correctly after resize
+
+	if (termui->col + width > termui->cols) {
+		/* Have to go to next row first. */
+		int blanks = termui->cols - termui->col;
+		for (int i = 0; i < blanks; i++)
+			_screen_cell(termui, termui->col + i, termui->row) = padding_cell;
+
+		_update_active_cells(termui, termui->col, termui->row, blanks);
+
+		_overflow_flag(termui, termui->row) = 1;
+		_advance_line(termui);
+		termui->col = 0;
+	}
+
+	_current_cell(termui) = cell;
+	termui->col++;
+
+	for (int i = 1; i < width; i++) {
+		_current_cell(termui) = padding_cell;
+		termui->col++;
+	}
+
+	if (termui->col < termui->cols) {
+		/* The changed cells are all adjacent. */
+		if (termui->cursor_visible)
+			_current_cell(termui).cursor = 1;
+		_update_active_cells(termui, termui->col - width, termui->row, width + 1);
+		termui->overflow = false;
+	} else {
+		/* Update the written cells and then update cursor on next row. */
+		_update_active_cells(termui, termui->col - width, termui->row, width);
+
+		_overflow_flag(termui, termui->row) = 1;
+		_advance_line(termui);
+		termui->col = 0;
+		termui->overflow = true;
+
+		_cursor_on(termui);
+	}
+}
+
+termui_color_t termui_color_from_rgb(uint8_t r, uint8_t g, uint8_t b)
+{
+	r = r >> 3;
+	g = g >> 3;
+	b = b >> 3;
+
+	return 0x8000 | r << 10 | g << 5 | b;
+}
+
+void termui_color_to_rgb(const termui_color_t c, uint8_t *r, uint8_t *g, uint8_t *b)
+{
+	assert((c & 0x8000) != 0);
+
+	/* 15b encoding, bit 15 is set to reserve lower half for other uses. */
+
+	int bb = c & 0x1f;
+	int gg = (c >> 5) & 0x1f;
+	int rr = (c >> 10) & 0x1f;
+
+	/*
+	 * 3 extra low order bits are filled from high-order bits to get the full
+	 * range instead of topping out at 0xf8.
+	 */
+	*r = (rr << 3) | (rr >> 2);
+	*g = (gg << 3) | (gg >> 2);
+	*b = (bb << 3) | (bb >> 2);
+
+	assert(termui_color_from_rgb(*r, *g, *b) == c);
+}
+
+/** Get terminal width.
+ */
+int termui_get_cols(const termui_t *termui)
+{
+	return termui->cols;
+}
+
+/** Get terminal height.
+ */
+int termui_get_rows(const termui_t *termui)
+{
+	return termui->rows;
+}
+
+/** Get cursor position
+ */
+void termui_get_pos(const termui_t *termui, int *col, int *row)
+{
+	*col = termui->col;
+	*row = termui->row;
+}
+
+/** Set cursor position.
+ */
+void termui_set_pos(termui_t *termui, int col, int row)
+{
+	if (col < 0)
+		col = 0;
+
+	if (col >= termui->cols)
+		col = termui->cols - 1;
+
+	if (row < 0)
+		row = 0;
+
+	if (row >= termui->rows)
+		row = termui->rows - 1;
+
+	_cursor_off(termui);
+
+	termui->col = col;
+	termui->row = row;
+
+	_cursor_on(termui);
+}
+
+/** Clear screen by scrolling out all text currently on screen.
+ * Sets position to (0, 0).
+ */
+void termui_clear_screen(termui_t *termui)
+{
+	_cursor_off(termui);
+	termui_put_crlf(termui);
+
+	int unused_rows = termui->rows - termui->used_rows;
+
+	while (termui->used_rows > 0)
+		_termui_evict_row(termui);
+
+	/* Clear out potential garbage left by direct screen access. */
+	for (int row = 0; row < unused_rows; row++) {
+		for (int col = 0; col < termui->cols; col++) {
+			_screen_cell(termui, col, row) = termui->default_cell;
+		}
+	}
+
+	termui->row = 0;
+	termui->col = 0;
+
+	_cursor_on(termui);
+
+	if (termui->refresh_cb)
+		termui->refresh_cb(termui->refresh_udata);
+}
+
+/** Erase all text starting at the given row.
+ * Erased text is not appended to history.
+ * If cursor was in the erased section, it's set to the beginning of it.
+ */
+void termui_wipe_screen(termui_t *termui, int first_row)
+{
+	if (first_row >= termui->rows)
+		return;
+
+	if (first_row < 0)
+		first_row = 0;
+
+	for (int row = first_row; row < termui->rows; row++) {
+		for (int col = 0; col < termui->cols; col++)
+			_screen_cell(termui, col, row) = termui->default_cell;
+
+		_update_active_cells(termui, 0, row, termui->cols);
+	}
+
+	if (termui->used_rows > first_row)
+		termui->used_rows = first_row;
+
+	if (termui->row >= first_row) {
+		termui->row = first_row;
+		termui->col = 0;
+		_cursor_on(termui);
+	}
+}
+
+void termui_set_scroll_cb(termui_t *termui, termui_scroll_cb_t cb, void *userdata)
+{
+	termui->scroll_cb = cb;
+	termui->scroll_udata = userdata;
+}
+
+void termui_set_update_cb(termui_t *termui, termui_update_cb_t cb, void *userdata)
+{
+	termui->update_cb = cb;
+	termui->update_udata = userdata;
+}
+
+void termui_set_refresh_cb(termui_t *termui, termui_refresh_cb_t cb, void *userdata)
+{
+	termui->refresh_cb = cb;
+	termui->refresh_udata = userdata;
+}
+
+/** Makes update callbacks for all indicated viewport rows.
+ * Useful when refreshing the screens or handling a scroll callback.
+ */
+void termui_force_viewport_update(const termui_t *termui, int first_row, int rows)
+{
+	assert(first_row >= 0);
+	assert(rows >= 0);
+	assert(first_row + rows <= termui->rows);
+
+	if (!termui->update_cb)
+		return;
+
+	int sb_rows = _history_viewport_rows(&termui->history, termui->rows);
+	int updated = _history_iter_rows(&termui->history, first_row, rows, termui->update_cb, termui->update_udata);
+
+	first_row += updated;
+	rows -= updated;
+
+	assert(sb_rows <= first_row);
+
+	for (int row = first_row; row < first_row + rows; row++) {
+		//printf("Iterating active screen row %d as viewport row %d\n", row-sb_rows, row);
+		termui->update_cb(termui->update_udata, 0, row, &_screen_cell(termui, 0, row - sb_rows), termui->cols);
+	}
+}
+
+bool termui_scrollback_is_active(const termui_t *termui)
+{
+	return _scrollback_active(&termui->history);
+}
+
+termui_t *termui_create(int cols, int rows, size_t history_lines)
+{
+	printf("termui_create(%d, %d, %zu)\n", cols, rows, history_lines);
+
+	/* Prevent numerical overflows. */
+	if (cols < 2 || rows < 1 || INT_MAX / cols < rows)
+		return NULL;
+
+	int cells = cols * rows;
+
+	termui_t *termui = calloc(1, sizeof(termui_t));
+	if (!termui)
+		return NULL;
+
+	termui->cols = cols;
+	termui->rows = rows;
+	termui->history.lines.max_len = history_lines;
+	if (history_lines > SIZE_MAX / cols)
+		termui->history.cells.max_len = SIZE_MAX;
+	else
+		termui->history.cells.max_len = history_lines * cols;
+	termui->history.cols = cols;
+
+	termui->screen = calloc(cells, sizeof(termui->screen[0]));
+	if (!termui->screen) {
+		free(termui);
+		return NULL;
+	}
+
+	termui->overflow_flags = calloc(rows, sizeof(termui->overflow_flags[0]));
+	if (!termui->overflow_flags) {
+		free(termui->screen);
+		free(termui);
+		return NULL;
+	}
+
+	return termui;
+}
+
+void termui_destroy(termui_t *termui)
+{
+	free(termui->screen);
+	free(termui);
+}
+
+/** Scrolls the viewport.
+ * Negative delta scrolls towards older rows, positive towards newer.
+ * Scroll callback is called with the actual number of rows scrolled.
+ * No callback is called for rows previously off-screen.
+ *
+ * @param termui
+ * @param delta  Number of rows to scroll.
+ */
+void termui_history_scroll(termui_t *termui, int delta)
+{
+	//printf("Termui scroll %d\n", delta);
+	int scrolled = _history_scroll(&termui->history, delta);
+
+	if (scrolled != 0 && termui->scroll_cb)
+		termui->scroll_cb(termui->scroll_udata, scrolled);
+}
+
+void termui_set_cursor_visibility(termui_t *termui, bool visible)
+{
+	if (termui->cursor_visible == visible)
+		return;
+
+	termui->cursor_visible = visible;
+
+	_current_cell(termui).cursor = visible;
+	_update_current_cell(termui);
+}
+
+bool termui_get_cursor_visibility(const termui_t *termui)
+{
+	return termui->cursor_visible;
+}
+
+static void _termui_put_cells(termui_t *termui, const termui_cell_t *cells, int n)
+{
+	while (n > 0) {
+		_current_cell(termui) = cells[0];
+		cells++;
+		n--;
+
+		termui->col++;
+
+		if (termui->col == termui->cols) {
+			_overflow_flag(termui, termui->row) = 1;
+			_advance_line(termui);
+			termui->col = 0;
+			termui->overflow = true;
+		} else {
+			termui->overflow = false;
+		}
+	}
+
+	if (termui->row >= termui->used_rows)
+		termui->used_rows = termui->row + 1;
+}
+
+/** Resize active screen and scrollback depth.
+ */
+errno_t termui_resize(termui_t *termui, int cols, int rows, size_t history_lines)
+{
+	/* Prevent numerical overflows. */
+	if (cols < 2 || rows < 1 || INT_MAX / cols < rows)
+		return ERANGE;
+
+	int cells = cols * rows;
+
+	termui_cell_t *new_screen = calloc(cells, sizeof(new_screen[0]));
+	if (!new_screen)
+		return ENOMEM;
+
+	uint8_t *new_flags = calloc(rows, sizeof(new_flags[0]));
+	if (!new_flags) {
+		free(new_screen);
+		return ENOMEM;
+	}
+
+	termui_t old_termui = *termui;
+
+	termui->rows = rows;
+	termui->cols = cols;
+	termui->row = 0;
+	termui->col = 0;
+	termui->used_rows = 0;
+	termui->first_row = 0;
+	termui->screen = new_screen;
+	termui->overflow_flags = new_flags;
+	termui->overflow = false;
+
+	bool cursor_visible = termui->cursor_visible;
+	termui->cursor_visible = false;
+
+	termui->history.lines.max_len = history_lines;
+
+	if (history_lines > SIZE_MAX / cols)
+		termui->history.cells.max_len = SIZE_MAX;
+	else
+		termui->history.cells.max_len = history_lines * cols;
+
+	/* Temporarily remove callbacks. */
+	termui->scroll_cb = NULL;
+	termui->update_cb = NULL;
+	termui->refresh_cb = NULL;
+
+	size_t recouped;
+	const termui_cell_t *c = _history_reflow(&termui->history, cols, &recouped);
+
+	/* Return piece of the incomplete line in scrollback back to active screen. */
+	if (recouped > 0)
+		_termui_put_cells(termui, c, recouped);
+
+	/* Mark cursor position. */
+	_current_cell(&old_termui).cursor = 1;
+
+	/* Write the contents of old screen into the new one. */
+	for (int row = 0; row < old_termui.used_rows; row++) {
+		int real_row_offset = _real_row(&old_termui, row) * old_termui.cols;
+
+		if (_overflow_flag(&old_termui, row)) {
+			_termui_put_cells(termui, &old_termui.screen[real_row_offset], old_termui.cols);
+		} else {
+			/* Trim trailing blanks. */
+			int len = old_termui.cols;
+			while (len > 0 && _cell_is_empty(old_termui.screen[real_row_offset + len - 1]))
+				len--;
+
+			_termui_put_cells(termui, &old_termui.screen[real_row_offset], len);
+
+			/* Mark cursor at the end of row, if any. */
+			if (len < old_termui.cols)
+				_current_cell(termui).cursor = old_termui.screen[real_row_offset + len].cursor;
+
+			if (row < old_termui.used_rows - 1)
+				termui_put_crlf(termui);
+		}
+	}
+
+	/* Find cursor */
+	int new_col = 0;
+	int new_row = 0;
+	for (int col = 0; col < termui->cols; col++) {
+		for (int row = 0; row < termui->rows; row++) {
+			if (_screen_cell(termui, col, row).cursor) {
+				_screen_cell(termui, col, row).cursor = 0;
+				new_col = col;
+				new_row = row;
+			}
+		}
+	}
+
+	free(old_termui.screen);
+	free(old_termui.overflow_flags);
+
+	termui->col = new_col;
+	termui->row = new_row;
+
+	termui->cursor_visible = cursor_visible;
+	_cursor_on(termui);
+
+	termui->scroll_cb = old_termui.scroll_cb;
+	termui->update_cb = old_termui.update_cb;
+	termui->refresh_cb = old_termui.refresh_cb;
+
+	if (termui->refresh_cb)
+		termui->refresh_cb(termui->refresh_udata);
+
+	return EOK;
+}
Index: uspace/lib/ui/src/ui.c
===================================================================
--- uspace/lib/ui/src/ui.c	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/lib/ui/src/ui.c	(revision 3d588becf95d128a09fc35cb7d9360abe025be9e)
@@ -357,4 +357,9 @@
 
 		break;
+	case CEV_RESIZE:
+		ui_lock(ui);
+		ui_window_send_resize(awnd);
+		ui_unlock(ui);
+		break;
 	}
 }
