Index: uspace/app/terminal/meson.build
===================================================================
--- uspace/app/terminal/meson.build	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/app/terminal/meson.build	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -27,5 +27,5 @@
 #
 
-deps = [ 'fbfont', 'display', 'ui' ]
+deps = [ 'fbfont', 'display', 'ui', 'termui' ]
 src = files(
 	'main.c',
Index: uspace/app/terminal/terminal.c
===================================================================
--- uspace/app/terminal/terminal.c	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/app/terminal/terminal.c	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -40,5 +40,4 @@
 #include <errno.h>
 #include <fbfont/font-8x16.h>
-#include <io/chargrid.h>
 #include <fibril.h>
 #include <gfx/bitmap.h>
@@ -49,9 +48,11 @@
 #include <io/console.h>
 #include <io/pixelmap.h>
-#include <task.h>
+#include <macros.h>
 #include <stdarg.h>
 #include <stdio.h>
 #include <stdlib.h>
+#include <str_error.h>
 #include <str.h>
+#include <task.h>
 #include <ui/resource.h>
 #include <ui/ui.h>
@@ -71,5 +72,31 @@
 	(CONSOLE_CAP_STYLE | CONSOLE_CAP_INDEXED | CONSOLE_CAP_RGB)
 
+#define SCROLLBACK_MAX_LINES 1000
+#define MIN_WINDOW_COLS 8
+#define MIN_WINDOW_ROWS 4
+
 static LIST_INITIALIZE(terms);
+
+#define COLOR_BRIGHT 8
+
+static const pixel_t _basic_colors[16] = {
+	[COLOR_BLACK]       = PIXEL(255, 0, 0, 0),
+	[COLOR_RED]         = PIXEL(255, 170, 0, 0),
+	[COLOR_GREEN]       = PIXEL(255, 0, 170, 0),
+	[COLOR_YELLOW]      = PIXEL(255, 170, 85, 0),
+	[COLOR_BLUE]        = PIXEL(255, 0, 0, 170),
+	[COLOR_MAGENTA]     = PIXEL(255, 170, 0, 170),
+	[COLOR_CYAN]        = PIXEL(255, 0, 170, 170),
+	[COLOR_WHITE]       = PIXEL(255, 170, 170, 170),
+
+	[COLOR_BLACK   | COLOR_BRIGHT] = PIXEL(255, 85, 85, 85),
+	[COLOR_RED     | COLOR_BRIGHT] = PIXEL(255, 255, 85, 85),
+	[COLOR_GREEN   | COLOR_BRIGHT] = PIXEL(255, 85, 255, 85),
+	[COLOR_YELLOW  | COLOR_BRIGHT] = PIXEL(255, 255, 255, 85),
+	[COLOR_BLUE    | COLOR_BRIGHT] = PIXEL(255, 85, 85, 255),
+	[COLOR_MAGENTA | COLOR_BRIGHT] = PIXEL(255, 255, 85, 255),
+	[COLOR_CYAN    | COLOR_BRIGHT] = PIXEL(255, 85, 255, 255),
+	[COLOR_WHITE   | COLOR_BRIGHT] = PIXEL(255, 255, 255, 255),
+};
 
 static errno_t term_open(con_srvs_t *, con_srv_t *);
@@ -119,14 +146,20 @@
 static void terminal_close_event(ui_window_t *, void *);
 static void terminal_focus_event(ui_window_t *, void *, unsigned);
+static void terminal_resize_event(ui_window_t *, void *);
 static void terminal_kbd_event(ui_window_t *, void *, kbd_event_t *);
 static void terminal_pos_event(ui_window_t *, void *, pos_event_t *);
 static void terminal_unfocus_event(ui_window_t *, void *, unsigned);
+static void terminal_maximize_event(ui_window_t *, void *);
+static void terminal_unmaximize_event(ui_window_t *, void *);
 
 static ui_window_cb_t terminal_window_cb = {
 	.close = terminal_close_event,
 	.focus = terminal_focus_event,
+	.resize = terminal_resize_event,
 	.kbd = terminal_kbd_event,
 	.pos = terminal_pos_event,
-	.unfocus = terminal_unfocus_event
+	.unfocus = terminal_unfocus_event,
+	.maximize = terminal_maximize_event,
+	.unmaximize = terminal_unmaximize_event,
 };
 
@@ -144,57 +177,66 @@
 }
 
-static pixel_t color_table[16] = {
-	[COLOR_BLACK]       = PIXEL(255, 0, 0, 0),
-	[COLOR_BLUE]        = PIXEL(255, 0, 0, 170),
-	[COLOR_GREEN]       = PIXEL(255, 0, 170, 0),
-	[COLOR_CYAN]        = PIXEL(255, 0, 170, 170),
-	[COLOR_RED]         = PIXEL(255, 170, 0, 0),
-	[COLOR_MAGENTA]     = PIXEL(255, 170, 0, 170),
-	[COLOR_YELLOW]      = PIXEL(255, 170, 85, 0),
-	[COLOR_WHITE]       = PIXEL(255, 170, 170, 170),
-
-	[COLOR_BLACK + 8]   = PIXEL(255, 85, 85, 85),
-	[COLOR_BLUE + 8]    = PIXEL(255, 85, 85, 255),
-	[COLOR_GREEN + 8]   = PIXEL(255, 85, 255, 85),
-	[COLOR_CYAN + 8]    = PIXEL(255, 85, 255, 255),
-	[COLOR_RED + 8]     = PIXEL(255, 255, 85, 85),
-	[COLOR_MAGENTA + 8] = PIXEL(255, 255, 85, 255),
-	[COLOR_YELLOW + 8]  = PIXEL(255, 255, 255, 85),
-	[COLOR_WHITE + 8]   = PIXEL(255, 255, 255, 255),
-};
-
-static inline void attrs_rgb(char_attrs_t attrs, pixel_t *bgcolor, pixel_t *fgcolor)
-{
-	switch (attrs.type) {
+static pixel_t termui_color_to_pixel(termui_color_t c)
+{
+	uint8_t r, g, b;
+	termui_color_to_rgb(c, &r, &g, &b);
+	return PIXEL(255, r, g, b);
+}
+
+static termui_color_t termui_color_from_pixel(pixel_t pixel)
+{
+	return termui_color_from_rgb(RED(pixel), GREEN(pixel), BLUE(pixel));
+}
+
+static termui_cell_t charfield_to_termui_cell(terminal_t *term, const charfield_t *cf)
+{
+	termui_cell_t cell = { };
+
+	cell.glyph_idx = fb_font_glyph(cf->ch, NULL);
+
+	switch (cf->attrs.type) {
 	case CHAR_ATTR_STYLE:
-		switch (attrs.val.style) {
+		switch (cf->attrs.val.style) {
 		case STYLE_NORMAL:
-			*bgcolor = color_table[COLOR_WHITE + 8];
-			*fgcolor = color_table[COLOR_BLACK];
+			cell.bgcolor = term->default_bgcolor;
+			cell.fgcolor = term->default_fgcolor;
 			break;
 		case STYLE_EMPHASIS:
-			*bgcolor = color_table[COLOR_WHITE + 8];
-			*fgcolor = color_table[COLOR_RED + 8];
+			cell.bgcolor = term->emphasis_bgcolor;
+			cell.fgcolor = term->emphasis_fgcolor;
 			break;
 		case STYLE_INVERTED:
-			*bgcolor = color_table[COLOR_BLACK];
-			*fgcolor = color_table[COLOR_WHITE + 8];
+			cell.bgcolor = term->default_bgcolor;
+			cell.fgcolor = term->default_fgcolor;
+			cell.inverted = 1;
 			break;
 		case STYLE_SELECTED:
-			*bgcolor = color_table[COLOR_RED + 8];
-			*fgcolor = color_table[COLOR_WHITE + 8];
+			cell.bgcolor = term->selection_bgcolor;
+			cell.fgcolor = term->selection_fgcolor;
 			break;
 		}
 		break;
+
 	case CHAR_ATTR_INDEX:
-		*bgcolor = color_table[(attrs.val.index.bgcolor & 7)];
-		*fgcolor = color_table[(attrs.val.index.fgcolor & 7) |
-		    ((attrs.val.index.attr & CATTR_BRIGHT) ? 8 : 0)];
+		char_attr_index_t index = cf->attrs.val.index;
+
+		int bright = (index.attr & CATTR_BRIGHT) ? COLOR_BRIGHT : 0;
+		pixel_t bgcolor = _basic_colors[index.bgcolor | bright];
+		pixel_t fgcolor = _basic_colors[index.fgcolor | bright];
+		cell.bgcolor = termui_color_from_pixel(bgcolor);
+		cell.fgcolor = termui_color_from_pixel(fgcolor);
+
+		if (index.attr & CATTR_BLINK)
+			cell.blink = 1;
+
 		break;
+
 	case CHAR_ATTR_RGB:
-		*bgcolor = 0xff000000 | attrs.val.rgb.bgcolor;
-		*fgcolor = 0xff000000 | attrs.val.rgb.fgcolor;
+		cell.bgcolor = termui_color_from_pixel(cf->attrs.val.rgb.bgcolor);
+		cell.fgcolor = termui_color_from_pixel(cf->attrs.val.rgb.fgcolor);
 		break;
 	}
+
+	return cell;
 }
 
@@ -214,27 +256,33 @@
 }
 
-static void term_update_char(terminal_t *term, pixelmap_t *pixelmap,
-    sysarg_t col, sysarg_t row)
-{
-	charfield_t *field =
-	    chargrid_charfield_at(term->backbuf, col, row);
-
-	bool inverted = chargrid_cursor_at(term->backbuf, col, row);
-
-	sysarg_t bx = col * FONT_WIDTH;
-	sysarg_t by = row * FONT_SCANLINES;
-
-	pixel_t bgcolor = 0;
-	pixel_t fgcolor = 0;
-
-	if (inverted)
-		attrs_rgb(field->attrs, &fgcolor, &bgcolor);
-	else
-		attrs_rgb(field->attrs, &bgcolor, &fgcolor);
-
-	// FIXME: Glyph type should be actually uint32_t
-	//        for full UTF-32 coverage.
-
-	uint16_t glyph = fb_font_glyph(field->ch, NULL);
+static void term_draw_cell(terminal_t *term, pixelmap_t *pixelmap, int col, int row, const termui_cell_t *cell)
+{
+	termui_color_t bg = cell->bgcolor;
+	if (bg == TERMUI_COLOR_DEFAULT)
+		bg = term->default_bgcolor;
+
+	termui_color_t fg = cell->fgcolor;
+	if (fg == TERMUI_COLOR_DEFAULT)
+		fg = term->default_fgcolor;
+
+	pixel_t bgcolor = termui_color_to_pixel(bg);
+	pixel_t fgcolor = termui_color_to_pixel(fg);
+
+	int bx = col * FONT_WIDTH;
+	int by = row * FONT_SCANLINES;
+
+	// TODO: support bold/italic/underline/strike/blink styling
+
+	if (cell->inverted ^ cell->cursor) {
+		pixel_t tmp = bgcolor;
+		bgcolor = fgcolor;
+		fgcolor = tmp;
+	}
+
+	uint32_t glyph = cell->glyph_idx;
+	assert(glyph < FONT_GLYPHS);
+
+	if (glyph == 0)
+		glyph = fb_font_glyph(U' ', NULL);
 
 	for (unsigned int y = 0; y < FONT_SCANLINES; y++) {
@@ -248,190 +296,72 @@
 		}
 	}
+
 	term_update_region(term, bx, by, FONT_WIDTH, FONT_SCANLINES);
 }
 
-static bool term_update_scroll(terminal_t *term, pixelmap_t *pixelmap)
-{
-	sysarg_t top_row = chargrid_get_top_row(term->frontbuf);
-
-	if (term->top_row == top_row) {
-		return false;
-	}
-
-	term->top_row = top_row;
-
-	for (sysarg_t row = 0; row < term->rows; row++) {
-		for (sysarg_t col = 0; col < term->cols; col++) {
-			charfield_t *front_field =
-			    chargrid_charfield_at(term->frontbuf, col, row);
-			charfield_t *back_field =
-			    chargrid_charfield_at(term->backbuf, col, row);
-			bool update = false;
-
-			if (front_field->ch != back_field->ch) {
-				back_field->ch = front_field->ch;
-				update = true;
-			}
-
-			if (!attrs_same(front_field->attrs, back_field->attrs)) {
-				back_field->attrs = front_field->attrs;
-				update = true;
-			}
-
-			front_field->flags &= ~CHAR_FLAG_DIRTY;
-
-			if (update) {
-				term_update_char(term, pixelmap, col, row);
-			}
-		}
-	}
-
-	return true;
-}
-
-static bool term_update_cursor(terminal_t *term, pixelmap_t *pixelmap)
-{
-	bool update = false;
-
-	sysarg_t front_col;
-	sysarg_t front_row;
-	chargrid_get_cursor(term->frontbuf, &front_col, &front_row);
-
-	sysarg_t back_col;
-	sysarg_t back_row;
-	chargrid_get_cursor(term->backbuf, &back_col, &back_row);
-
-	bool front_visibility =
-	    chargrid_get_cursor_visibility(term->frontbuf) &&
-	    term->is_focused;
-	bool back_visibility =
-	    chargrid_get_cursor_visibility(term->backbuf);
-
-	if (front_visibility != back_visibility) {
-		chargrid_set_cursor_visibility(term->backbuf,
-		    front_visibility);
-		term_update_char(term, pixelmap, back_col, back_row);
-		update = true;
-	}
-
-	if ((front_col != back_col) || (front_row != back_row)) {
-		chargrid_set_cursor(term->backbuf, front_col, front_row);
-		term_update_char(term, pixelmap, back_col, back_row);
-		term_update_char(term, pixelmap, front_col, front_row);
-		update = true;
-	}
-
-	return update;
-}
-
-static void term_update(terminal_t *term)
-{
-	pixelmap_t pixelmap;
+static void term_render(terminal_t *term)
+{
+	gfx_coord2_t pos = { .x = 4, .y = 26 };
+	(void) gfx_bitmap_render(term->bmp, &term->update, &pos);
+
+	term->update.p0.x = 0;
+	term->update.p0.y = 0;
+	term->update.p1.x = 0;
+	term->update.p1.y = 0;
+}
+
+static void termui_refresh_cb(void *userdata)
+{
+	terminal_t *term = userdata;
+
+	termui_force_viewport_update(term->termui, 0, termui_get_rows(term->termui));
+}
+
+static void termui_scroll_cb(void *userdata, int delta)
+{
+	(void) delta;
+
+	// Until we have support for hardware accelerated scrolling, just redraw everything.
+	termui_refresh_cb(userdata);
+}
+
+static pixelmap_t term_get_pixelmap(terminal_t *term)
+{
+	pixelmap_t pixelmap = { };
 	gfx_bitmap_alloc_t alloc;
-	gfx_coord2_t pos;
-	errno_t rc;
-
-	rc = gfx_bitmap_get_alloc(term->bmp, &alloc);
-	if (rc != EOK) {
-		return;
-	}
-
-	fibril_mutex_lock(&term->mtx);
+
+	errno_t rc = gfx_bitmap_get_alloc(term->bmp, &alloc);
+	if (rc != EOK)
+		return pixelmap;
+
 	pixelmap.width = term->w;
 	pixelmap.height = term->h;
 	pixelmap.data = alloc.pixels;
-
-	bool update = false;
-
-	if (term_update_scroll(term, &pixelmap)) {
-		update = true;
-	} else {
-		for (sysarg_t y = 0; y < term->rows; y++) {
-			for (sysarg_t x = 0; x < term->cols; x++) {
-				charfield_t *front_field =
-				    chargrid_charfield_at(term->frontbuf, x, y);
-				charfield_t *back_field =
-				    chargrid_charfield_at(term->backbuf, x, y);
-				bool cupdate = false;
-
-				if ((front_field->flags & CHAR_FLAG_DIRTY) ==
-				    CHAR_FLAG_DIRTY) {
-					if (front_field->ch != back_field->ch) {
-						back_field->ch = front_field->ch;
-						cupdate = true;
-					}
-
-					if (!attrs_same(front_field->attrs,
-					    back_field->attrs)) {
-						back_field->attrs = front_field->attrs;
-						cupdate = true;
-					}
-
-					front_field->flags &= ~CHAR_FLAG_DIRTY;
-				}
-
-				if (cupdate) {
-					term_update_char(term, &pixelmap, x, y);
-					update = true;
-				}
-			}
-		}
-	}
-
-	if (term_update_cursor(term, &pixelmap))
-		update = true;
-
-	if (update) {
-		pos.x = 4;
-		pos.y = 26;
-		(void) gfx_bitmap_render(term->bmp, &term->update, &pos);
-
-		term->update.p0.x = 0;
-		term->update.p0.y = 0;
-		term->update.p1.x = 0;
-		term->update.p1.y = 0;
-	}
-
-	fibril_mutex_unlock(&term->mtx);
-}
-
-static void term_repaint(terminal_t *term)
-{
-	pixelmap_t pixelmap;
-	gfx_bitmap_alloc_t alloc;
-	errno_t rc;
-
-	rc = gfx_bitmap_get_alloc(term->bmp, &alloc);
-	if (rc != EOK) {
-		printf("Error getting bitmap allocation info.\n");
+	return pixelmap;
+}
+
+static void term_clear_bitmap(terminal_t *term, pixel_t color)
+{
+	pixelmap_t pixelmap = term_get_pixelmap(term);
+	if (pixelmap.data == NULL)
 		return;
-	}
-
-	fibril_mutex_lock(&term->mtx);
-
-	pixelmap.width = term->w;
-	pixelmap.height = term->h;
-	pixelmap.data = alloc.pixels;
-
-	if (!term_update_scroll(term, &pixelmap)) {
-		for (sysarg_t y = 0; y < term->rows; y++) {
-			for (sysarg_t x = 0; x < term->cols; x++) {
-				charfield_t *front_field =
-				    chargrid_charfield_at(term->frontbuf, x, y);
-				charfield_t *back_field =
-				    chargrid_charfield_at(term->backbuf, x, y);
-
-				back_field->ch = front_field->ch;
-				back_field->attrs = front_field->attrs;
-				front_field->flags &= ~CHAR_FLAG_DIRTY;
-
-				term_update_char(term, &pixelmap, x, y);
-			}
-		}
-	}
-
-	term_update_cursor(term, &pixelmap);
-
-	fibril_mutex_unlock(&term->mtx);
+
+	sysarg_t pixels = pixelmap.height * pixelmap.width;
+	for (sysarg_t i = 0; i < pixels; i++)
+		pixelmap.data[i] = color;
+
+	term_update_region(term, 0, 0, pixelmap.width, pixelmap.height);
+}
+
+static void termui_update_cb(void *userdata, int col, int row, const termui_cell_t *cell, int len)
+{
+	terminal_t *term = userdata;
+
+	pixelmap_t pixelmap = term_get_pixelmap(term);
+	if (pixelmap.data == NULL)
+		return;
+
+	for (int i = 0; i < len; i++)
+		term_draw_cell(term, &pixelmap, col + i, row, &cell[i]);
 }
 
@@ -497,28 +427,24 @@
 static void term_write_char(terminal_t *term, wchar_t ch)
 {
-	sysarg_t updated = 0;
-
-	fibril_mutex_lock(&term->mtx);
-
 	switch (ch) {
-	case '\n':
-		updated = chargrid_newline(term->frontbuf);
+	case L'\n':
+		termui_put_crlf(term->termui);
 		break;
-	case '\r':
+	case L'\r':
+		termui_put_cr(term->termui);
 		break;
-	case '\t':
-		updated = chargrid_tabstop(term->frontbuf, 8);
+	case L'\t':
+		termui_put_tab(term->termui);
 		break;
-	case '\b':
-		updated = chargrid_backspace(term->frontbuf);
+	case L'\b':
+		termui_put_backspace(term->termui);
 		break;
 	default:
-		updated = chargrid_putuchar(term->frontbuf, ch, true);
-	}
-
-	fibril_mutex_unlock(&term->mtx);
-
-	if (updated > 1)
-		term_update(term);
+		// TODO: For some languages, we might need support for combining
+		//       characters. Currently, we assume every unicode code point is
+		//       an individual printed character, which is not always the case.
+		termui_put_glyph(term->termui, fb_font_glyph(ch, NULL), 1);
+		break;
+	}
 }
 
@@ -526,4 +452,6 @@
 {
 	terminal_t *term = srv_to_terminal(srv);
+
+	fibril_mutex_lock(&term->mtx);
 
 	size_t off = 0;
@@ -531,6 +459,10 @@
 		term_write_char(term, str_decode(data, &off, size));
 
+	fibril_mutex_unlock(&term->mtx);
+
+	term_render(term);
 	gfx_update(term->gc);
 	*nwritten = size;
+
 	return EOK;
 }
@@ -540,5 +472,5 @@
 	terminal_t *term = srv_to_terminal(srv);
 
-	term_update(term);
+	term_render(term);
 	gfx_update(term->gc);
 }
@@ -549,8 +481,8 @@
 
 	fibril_mutex_lock(&term->mtx);
-	chargrid_clear(term->frontbuf);
-	fibril_mutex_unlock(&term->mtx);
-
-	term_update(term);
+	termui_clear_screen(term->termui);
+	fibril_mutex_unlock(&term->mtx);
+
+	term_render(term);
 	gfx_update(term->gc);
 }
@@ -561,8 +493,8 @@
 
 	fibril_mutex_lock(&term->mtx);
-	chargrid_set_cursor(term->frontbuf, col, row);
-	fibril_mutex_unlock(&term->mtx);
-
-	term_update(term);
+	termui_set_pos(term->termui, col, row);
+	fibril_mutex_unlock(&term->mtx);
+
+	term_render(term);
 	gfx_update(term->gc);
 }
@@ -573,6 +505,10 @@
 
 	fibril_mutex_lock(&term->mtx);
-	chargrid_get_cursor(term->frontbuf, col, row);
-	fibril_mutex_unlock(&term->mtx);
+	int irow, icol;
+	termui_get_pos(term->termui, &icol, &irow);
+	fibril_mutex_unlock(&term->mtx);
+
+	*col = icol;
+	*row = irow;
 
 	return EOK;
@@ -584,6 +520,6 @@
 
 	fibril_mutex_lock(&term->mtx);
-	*cols = term->cols;
-	*rows = term->rows;
+	*cols = termui_get_cols(term->termui);
+	*rows = termui_get_rows(term->termui);
 	fibril_mutex_unlock(&term->mtx);
 
@@ -603,6 +539,28 @@
 	terminal_t *term = srv_to_terminal(srv);
 
-	fibril_mutex_lock(&term->mtx);
-	chargrid_set_style(term->frontbuf, style);
+	termui_cell_t cellstyle = { };
+
+	switch (style) {
+	case STYLE_NORMAL:
+		cellstyle.bgcolor = term->default_bgcolor;
+		cellstyle.fgcolor = term->default_fgcolor;
+		break;
+	case STYLE_EMPHASIS:
+		cellstyle.bgcolor = term->emphasis_bgcolor;
+		cellstyle.fgcolor = term->emphasis_fgcolor;
+		break;
+	case STYLE_INVERTED:
+		cellstyle.bgcolor = term->default_bgcolor;
+		cellstyle.fgcolor = term->default_fgcolor;
+		cellstyle.inverted = 1;
+		break;
+	case STYLE_SELECTED:
+		cellstyle.bgcolor = term->selection_bgcolor;
+		cellstyle.fgcolor = term->selection_fgcolor;
+		break;
+	}
+
+	fibril_mutex_lock(&term->mtx);
+	termui_set_style(term->termui, cellstyle);
 	fibril_mutex_unlock(&term->mtx);
 }
@@ -613,6 +571,15 @@
 	terminal_t *term = srv_to_terminal(srv);
 
-	fibril_mutex_lock(&term->mtx);
-	chargrid_set_color(term->frontbuf, bgcolor, fgcolor, attr);
+	int bright = (attr & CATTR_BRIGHT) ? COLOR_BRIGHT : 0;
+
+	termui_cell_t cellstyle = { };
+	cellstyle.bgcolor = termui_color_from_pixel(_basic_colors[bgcolor | bright]);
+	cellstyle.fgcolor = termui_color_from_pixel(_basic_colors[fgcolor | bright]);
+
+	if (attr & CATTR_BLINK)
+		cellstyle.blink = 1;
+
+	fibril_mutex_lock(&term->mtx);
+	termui_set_style(term->termui, cellstyle);
 	fibril_mutex_unlock(&term->mtx);
 }
@@ -622,7 +589,11 @@
 {
 	terminal_t *term = srv_to_terminal(srv);
-
-	fibril_mutex_lock(&term->mtx);
-	chargrid_set_rgb_color(term->frontbuf, bgcolor, fgcolor);
+	termui_cell_t cellstyle = {
+		.bgcolor = termui_color_from_pixel(bgcolor),
+		.fgcolor = termui_color_from_pixel(fgcolor),
+	};
+
+	fibril_mutex_lock(&term->mtx);
+	termui_set_style(term->termui, cellstyle);
 	fibril_mutex_unlock(&term->mtx);
 }
@@ -633,8 +604,8 @@
 
 	fibril_mutex_lock(&term->mtx);
-	chargrid_set_cursor_visibility(term->frontbuf, visible);
-	fibril_mutex_unlock(&term->mtx);
-
-	term_update(term);
+	termui_set_cursor_visibility(term->termui, visible);
+	fibril_mutex_unlock(&term->mtx);
+
+	term_render(term);
 	gfx_update(term->gc);
 }
@@ -655,5 +626,5 @@
 	fibril_mutex_unlock(&term->mtx);
 
-	term_update(term);
+	term_render(term);
 	gfx_update(term->gc);
 	return EOK;
@@ -703,4 +674,8 @@
 	term->urows = rows;
 	term->ubuf = buf;
+
+	/* Scroll back to active screen. */
+	termui_history_scroll(term->termui, INT_MAX);
+
 	fibril_mutex_unlock(&term->mtx);
 
@@ -723,8 +698,14 @@
 	term->ubuf = NULL;
 
+	termui_wipe_screen(term->termui, 0);
+
+	fibril_mutex_unlock(&term->mtx);
+
+	/* Update terminal */
+	term_render(term);
+	gfx_update(term->gc);
+
 	if (buf != NULL)
 		as_area_destroy(buf);
-
-	fibril_mutex_unlock(&term->mtx);
 }
 
@@ -741,6 +722,4 @@
 {
 	terminal_t *term = srv_to_terminal(srv);
-	charfield_t *ch;
-	sysarg_t col, row;
 
 	fibril_mutex_lock(&term->mtx);
@@ -752,29 +731,24 @@
 
 	/* Make sure we have meaningful coordinates, within bounds */
-
-	if (c1 > term->ucols)
-		c1 = term->ucols;
-	if (c1 > term->cols)
-		c1 = term->cols;
-	if (c0 >= c1) {
+	c1 = min(c1, term->ucols);
+	c1 = min(c1, (sysarg_t) termui_get_cols(term->termui));
+	r1 = min(r1, term->urows);
+	r1 = min(r1, (sysarg_t) termui_get_rows(term->termui));
+
+	if (c0 >= c1 || r0 >= r1) {
 		fibril_mutex_unlock(&term->mtx);
 		return;
 	}
-	if (r1 > term->urows)
-		r1 = term->urows;
-	if (r1 > term->rows)
-		r1 = term->rows;
-	if (r0 >= r1) {
-		fibril_mutex_unlock(&term->mtx);
-		return;
-	}
 
 	/* Update front buffer from user buffer */
 
-	for (row = r0; row < r1; row++) {
-		for (col = c0; col < c1; col++) {
-			ch = chargrid_charfield_at(term->frontbuf, col, row);
-			*ch = term->ubuf[row * term->ucols + col];
+	for (sysarg_t row = r0; row < r1; row++) {
+		termui_cell_t *cells = termui_get_active_row(term->termui, row);
+
+		for (sysarg_t col = c0; col < c1; col++) {
+			cells[col] = charfield_to_termui_cell(term, &term->ubuf[row * term->ucols + col]);
 		}
+
+		termui_update_cb(term, c0, row, &cells[c0], c1 - c0);
 	}
 
@@ -782,22 +756,64 @@
 
 	/* Update terminal */
-	term_update(term);
+	term_render(term);
 	gfx_update(term->gc);
 }
 
-static void deinit_terminal(terminal_t *term)
+static errno_t terminal_window_resize(terminal_t *term)
+{
+	gfx_rect_t rect;
+	ui_window_get_app_rect(term->window, &rect);
+
+	int width = rect.p1.x - rect.p0.x;
+	int height = rect.p1.y - rect.p0.y;
+
+	if (!term->gc)
+		term->gc = ui_window_get_gc(term->window);
+	else
+		assert(term->gc == ui_window_get_gc(term->window));
+
+	if (!term->ui_res)
+		term->ui_res = ui_window_get_res(term->window);
+	else
+		assert(term->ui_res == ui_window_get_res(term->window));
+
+	gfx_bitmap_t *new_bmp;
+	gfx_bitmap_params_t params;
+	gfx_bitmap_params_init(&params);
+	params.rect.p0.x = 0;
+	params.rect.p0.y = 0;
+	params.rect.p1.x = width;
+	params.rect.p1.y = height;
+
+	errno_t rc = gfx_bitmap_create(term->gc, &params, NULL, &new_bmp);
+	if (rc != EOK) {
+		fprintf(stderr, "Error allocating new screen bitmap: %s\n", str_error(rc));
+		return rc;
+	}
+
+	if (term->bmp) {
+		rc = gfx_bitmap_destroy(term->bmp);
+		if (rc != EOK)
+			fprintf(stderr, "Error deallocating old screen bitmap: %s\n", str_error(rc));
+	}
+
+	term->bmp = new_bmp;
+	term->w = width;
+	term->h = height;
+
+	term_clear_bitmap(term, termui_color_to_pixel(term->default_bgcolor));
+
+	return EOK;
+}
+
+void terminal_destroy(terminal_t *term)
 {
 	list_remove(&term->link);
 
-	if (term->frontbuf)
-		chargrid_destroy(term->frontbuf);
-
-	if (term->backbuf)
-		chargrid_destroy(term->backbuf);
-}
-
-void terminal_destroy(terminal_t *term)
-{
-	deinit_terminal(term);
+	termui_destroy(term->termui);
+
+	if (term->ubuf)
+		as_area_destroy(term->ubuf);
+
 	free(term);
 }
@@ -833,6 +849,44 @@
 	(void)nfocus;
 	term->is_focused = true;
-	term_update(term);
+	term_render(term);
 	gfx_update(term->gc);
+}
+
+static void terminal_resize_handler(ui_window_t *window, void *arg)
+{
+	terminal_t *term = (terminal_t *) arg;
+
+	fibril_mutex_lock(&term->mtx);
+
+	errno_t rc = terminal_window_resize(term);
+	if (rc == EOK) {
+		(void) termui_resize(term->termui, term->w / FONT_WIDTH, term->h / FONT_SCANLINES, SCROLLBACK_MAX_LINES);
+		termui_refresh_cb(term);
+		term_render(term);
+		gfx_update(term->gc);
+
+		cons_event_t event = { .type = CEV_RESIZE };
+		terminal_queue_cons_event(term, &event);
+	}
+
+	fibril_mutex_unlock(&term->mtx);
+}
+
+static void terminal_resize_event(ui_window_t *window, void *arg)
+{
+	ui_window_def_resize(window);
+	terminal_resize_handler(window, arg);
+}
+
+static void terminal_maximize_event(ui_window_t *window, void *arg)
+{
+	ui_window_def_maximize(window);
+	terminal_resize_handler(window, arg);
+}
+
+static void terminal_unmaximize_event(ui_window_t *window, void *arg)
+{
+	ui_window_def_unmaximize(window);
+	terminal_resize_handler(window, arg);
 }
 
@@ -847,5 +901,21 @@
 	event.ev.key = *kbd_event;
 
-	terminal_queue_cons_event(term, &event);
+	const int PAGE_ROWS = (termui_get_rows(term->termui) * 2) / 3;
+
+	fibril_mutex_lock(&term->mtx);
+
+	if (!term->ubuf && kbd_event->type == KEY_PRESS &&
+	    (kbd_event->key == KC_PAGE_UP || kbd_event->key == KC_PAGE_DOWN)) {
+
+		termui_history_scroll(term->termui,
+		    (kbd_event->key == KC_PAGE_UP) ? -PAGE_ROWS : PAGE_ROWS);
+
+		term_render(term);
+		gfx_update(term->gc);
+	} else {
+		terminal_queue_cons_event(term, &event);
+	}
+
+	fibril_mutex_unlock(&term->mtx);
 }
 
@@ -856,18 +926,37 @@
 	terminal_t *term = (terminal_t *) arg;
 
+	switch (event->type) {
+	case POS_UPDATE:
+		return;
+
+	case POS_PRESS:
+	case POS_RELEASE:
+	case POS_DCLICK:
+	}
+
+	/* Ignore mouse events when we're in scrollback mode. */
+	if (termui_scrollback_is_active(term->termui))
+		return;
+
 	sysarg_t sx = -term->off.x;
 	sysarg_t sy = -term->off.y;
 
-	if (event->type == POS_PRESS || event->type == POS_RELEASE ||
-	    event->type == POS_DCLICK) {
-		cevent.type = CEV_POS;
-		cevent.ev.pos.type = event->type;
-		cevent.ev.pos.pos_id = event->pos_id;
-		cevent.ev.pos.btn_num = event->btn_num;
-
-		cevent.ev.pos.hpos = (event->hpos - sx) / FONT_WIDTH;
-		cevent.ev.pos.vpos = (event->vpos - sy) / FONT_SCANLINES;
+	if (event->hpos < sx || event->vpos < sy)
+		return;
+
+	cevent.type = CEV_POS;
+	cevent.ev.pos.type = event->type;
+	cevent.ev.pos.pos_id = event->pos_id;
+	cevent.ev.pos.btn_num = event->btn_num;
+
+	cevent.ev.pos.hpos = (event->hpos - sx) / FONT_WIDTH;
+	cevent.ev.pos.vpos = (event->vpos - sy) / FONT_SCANLINES;
+
+	/* Filter out events outside the terminal area. */
+	int cols = termui_get_cols(term->termui);
+	int rows = termui_get_rows(term->termui);
+
+	if (cevent.ev.pos.hpos < (sysarg_t) cols && cevent.ev.pos.vpos < (sysarg_t) rows)
 		terminal_queue_cons_event(term, &cevent);
-	}
 }
 
@@ -880,5 +969,5 @@
 	if (nfocus == 0) {
 		term->is_focused = false;
-		term_update(term);
+		term_render(term);
 		gfx_update(term->gc);
 	}
@@ -902,7 +991,47 @@
 
 	if (!atomic_flag_test_and_set(&term->refcnt))
-		chargrid_set_cursor_visibility(term->frontbuf, true);
+		termui_set_cursor_visibility(term->termui, true);
 
 	con_conn(icall, &term->srvs);
+}
+
+static errno_t term_init_window(terminal_t *term, const char *display_spec,
+    gfx_coord_t width, gfx_coord_t height,
+    gfx_coord_t min_width, gfx_coord_t min_height,
+    terminal_flags_t flags)
+{
+	gfx_rect_t min_rect = { { 0, 0 }, { min_width, min_height } };
+
+	ui_wnd_params_t wparams;
+	ui_wnd_params_init(&wparams);
+	wparams.caption = "Terminal";
+	wparams.style |= ui_wds_maximize_btn | ui_wds_resizable;
+	if ((flags & tf_topleft) != 0)
+		wparams.placement = ui_wnd_place_top_left;
+
+	errno_t rc = ui_create(display_spec, &term->ui);
+	if (rc != EOK) {
+		printf("Error creating UI on %s.\n", display_spec);
+		return rc;
+	}
+
+	/* Compute wrect such that application area corresponds to rect. */
+	gfx_rect_t wrect;
+	ui_wdecor_rect_from_app(term->ui, wparams.style, &min_rect, &wrect);
+	gfx_rect_rtranslate(&wrect.p0, &wrect, &wparams.rect);
+
+	rc = ui_window_create(term->ui, &wparams, &term->window);
+	if (rc != EOK)
+		return rc;
+
+	gfx_rect_t rect = { { 0, 0 }, { width, height } };
+	ui_wdecor_rect_from_app(term->ui, wparams.style, &rect, &rect);
+	term->off = rect.p0;
+	gfx_rect_rtranslate(&term->off, &rect, &wrect);
+
+	ui_window_resize(term->window, &wrect);
+	ui_window_set_cb(term->window, &terminal_window_cb, (void *) term);
+
+	return terminal_window_resize(term);
 }
 
@@ -911,13 +1040,9 @@
     terminal_t **rterm)
 {
-	terminal_t *term;
-	gfx_bitmap_params_t params;
-	ui_wnd_params_t wparams;
-	gfx_rect_t rect;
-	gfx_coord2_t off;
-	gfx_rect_t wrect;
+	printf("terminal_create(%zu, %zu)\n", width, height);
+
 	errno_t rc;
 
-	term = calloc(1, sizeof(terminal_t));
+	terminal_t *term = calloc(1, sizeof(terminal_t));
 	if (term == NULL) {
 		printf("Out of memory.\n");
@@ -932,81 +1057,31 @@
 	term->char_remains_len = 0;
 
-	term->w = width;
-	term->h = height;
-
-	term->cols = width / FONT_WIDTH;
-	term->rows = height / FONT_SCANLINES;
-
-	term->frontbuf = NULL;
-	term->backbuf = NULL;
-
-	term->frontbuf = chargrid_create(term->cols, term->rows,
-	    CHARGRID_FLAG_NONE);
-	if (!term->frontbuf) {
-		printf("Error creating front buffer.\n");
+	term->default_bgcolor = termui_color_from_pixel(_basic_colors[COLOR_WHITE | COLOR_BRIGHT]);
+	term->default_fgcolor = termui_color_from_pixel(_basic_colors[COLOR_BLACK]);
+
+	term->emphasis_bgcolor = termui_color_from_pixel(_basic_colors[COLOR_WHITE | COLOR_BRIGHT]);
+	term->emphasis_fgcolor = termui_color_from_pixel(_basic_colors[COLOR_RED | COLOR_BRIGHT]);
+
+	term->selection_bgcolor = termui_color_from_pixel(_basic_colors[COLOR_RED | COLOR_BRIGHT]);
+	term->selection_fgcolor = termui_color_from_pixel(_basic_colors[COLOR_WHITE | COLOR_BRIGHT]);
+
+	term->termui = termui_create(width / FONT_WIDTH, height / FONT_SCANLINES,
+	    SCROLLBACK_MAX_LINES);
+	if (!term->termui) {
+		printf("Error creating terminal UI.\n");
 		rc = ENOMEM;
 		goto error;
 	}
 
-	term->backbuf = chargrid_create(term->cols, term->rows,
-	    CHARGRID_FLAG_NONE);
-	if (!term->backbuf) {
-		printf("Error creating back buffer.\n");
-		rc = ENOMEM;
+	termui_set_refresh_cb(term->termui, termui_refresh_cb, term);
+	termui_set_scroll_cb(term->termui, termui_scroll_cb, term);
+	termui_set_update_cb(term->termui, termui_update_cb, term);
+
+	rc = term_init_window(term, display_spec, width, height,
+	    MIN_WINDOW_COLS * FONT_WIDTH, MIN_WINDOW_ROWS * FONT_SCANLINES, flags);
+	if (rc != EOK) {
+		printf("Error creating window (%s).\n", str_error(rc));
 		goto error;
 	}
-
-	rect.p0.x = 0;
-	rect.p0.y = 0;
-	rect.p1.x = width;
-	rect.p1.y = height;
-
-	ui_wnd_params_init(&wparams);
-	wparams.caption = "Terminal";
-	if ((flags & tf_topleft) != 0)
-		wparams.placement = ui_wnd_place_top_left;
-
-	rc = ui_create(display_spec, &term->ui);
-	if (rc != EOK) {
-		printf("Error creating UI on %s.\n", display_spec);
-		goto error;
-	}
-
-	/*
-	 * Compute window rectangle such that application area corresponds
-	 * to rect
-	 */
-	ui_wdecor_rect_from_app(term->ui, wparams.style, &rect, &wrect);
-	off = wrect.p0;
-	gfx_rect_rtranslate(&off, &wrect, &wparams.rect);
-
-	term->off = off;
-
-	rc = ui_window_create(term->ui, &wparams, &term->window);
-	if (rc != EOK) {
-		printf("Error creating window.\n");
-		goto error;
-	}
-
-	term->gc = ui_window_get_gc(term->window);
-	term->ui_res = ui_window_get_res(term->window);
-
-	ui_window_set_cb(term->window, &terminal_window_cb, (void *) term);
-
-	gfx_bitmap_params_init(&params);
-	params.rect.p0.x = 0;
-	params.rect.p0.y = 0;
-	params.rect.p1.x = width;
-	params.rect.p1.y = height;
-
-	rc = gfx_bitmap_create(term->gc, &params, NULL, &term->bmp);
-	if (rc != EOK) {
-		printf("Error allocating screen bitmap.\n");
-		goto error;
-	}
-
-	chargrid_clear(term->frontbuf);
-	chargrid_clear(term->backbuf);
-	term->top_row = 0;
 
 	async_set_fallback_port_handler(term_connection, NULL);
@@ -1046,10 +1121,5 @@
 	term->is_focused = true;
 
-	term->update.p0.x = 0;
-	term->update.p0.y = 0;
-	term->update.p1.x = 0;
-	term->update.p1.y = 0;
-
-	term_repaint(term);
+	termui_refresh_cb(term);
 
 	*rterm = term;
@@ -1064,8 +1134,6 @@
 	if (term->ui != NULL)
 		ui_destroy(term->ui);
-	if (term->frontbuf != NULL)
-		chargrid_destroy(term->frontbuf);
-	if (term->backbuf != NULL)
-		chargrid_destroy(term->backbuf);
+	if (term->termui != NULL)
+		termui_destroy(term->termui);
 	free(term);
 	return rc;
Index: uspace/app/terminal/terminal.h
===================================================================
--- uspace/app/terminal/terminal.h	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/app/terminal/terminal.h	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -45,5 +45,4 @@
 #include <gfx/context.h>
 #include <gfx/coord.h>
-#include <io/chargrid.h>
 #include <io/con_srv.h>
 #include <loc.h>
@@ -51,4 +50,5 @@
 #include <str.h>
 #include <task.h>
+#include <termui.h>
 #include <ui/ui.h>
 #include <ui/window.h>
@@ -81,9 +81,12 @@
 	size_t char_remains_len;
 
-	sysarg_t cols;
-	sysarg_t rows;
-	chargrid_t *frontbuf;
-	chargrid_t *backbuf;
-	sysarg_t top_row;
+	termui_t *termui;
+
+	termui_color_t default_bgcolor;
+	termui_color_t default_fgcolor;
+	termui_color_t emphasis_bgcolor;
+	termui_color_t emphasis_fgcolor;
+	termui_color_t selection_bgcolor;
+	termui_color_t selection_fgcolor;
 
 	sysarg_t ucols;
Index: uspace/app/tetris/screen.c
===================================================================
--- uspace/app/tetris/screen.c	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/app/tetris/screen.c	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -79,4 +79,5 @@
 static usec_t timeleft = 0;
 
+bool size_changed;
 console_ctrl_t *console;
 
@@ -217,5 +218,5 @@
 
 	fprintf(stderr, "aborting: %s", why);
-	abort();
+	exit(1);
 }
 
@@ -410,4 +411,7 @@
 			exit(1);
 
+		if (event.type == CEV_RESIZE)
+			size_changed = true;
+
 		if (event.type == CEV_KEY && event.ev.key.type == KEY_PRESS)
 			c = event.ev.key.c;
Index: uspace/app/tetris/screen.h
===================================================================
--- uspace/app/tetris/screen.h	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/app/tetris/screen.h	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -67,4 +67,5 @@
 extern console_ctrl_t *console;
 extern winsize_t winsize;
+extern bool size_changed;
 
 extern void moveto(sysarg_t r, sysarg_t c);
Index: uspace/app/tetris/tetris.c
===================================================================
--- uspace/app/tetris/tetris.c	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/app/tetris/tetris.c	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -330,4 +330,10 @@
 
 		while (true) {
+			if (size_changed) {
+				size_changed = false;
+				scr_set();
+				scr_msg(key_msg, 1);
+			}
+
 			place(curshape, pos, 1);
 			scr_update();
Index: uspace/lib/clui/src/tinput.c
===================================================================
--- uspace/lib/clui/src/tinput.c	(revision dd50aa1911e36b82569a97729165ef1d797d3cd1)
+++ uspace/lib/clui/src/tinput.c	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -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 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -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 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -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 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -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 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -91,4 +91,5 @@
 	'sif',
 	'tbarcfg',
+	'termui',
 	'trackmod',
 	'untar',
Index: uspace/lib/termui/include/termui.h
===================================================================
--- uspace/lib/termui/include/termui.h	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
+++ uspace/lib/termui/include/termui.h	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -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 899bdfda27b53bba2ce96d32870c576201ab6ba8)
+++ uspace/lib/termui/meson.build	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -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 899bdfda27b53bba2ce96d32870c576201ab6ba8)
+++ uspace/lib/termui/src/history.c	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -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 899bdfda27b53bba2ce96d32870c576201ab6ba8)
+++ uspace/lib/termui/src/history.h	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -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 899bdfda27b53bba2ce96d32870c576201ab6ba8)
+++ uspace/lib/termui/src/termui.c	(revision 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -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 899bdfda27b53bba2ce96d32870c576201ab6ba8)
@@ -357,4 +357,9 @@
 
 		break;
+	case CEV_RESIZE:
+		ui_lock(ui);
+		ui_window_send_resize(awnd);
+		ui_unlock(ui);
+		break;
 	}
 }
