From 94c34ed5e2ff5495273c517aaf04a370aaf33328 Mon Sep 17 00:00:00 2001
From: Felix Fietkau <nbd@nbd.name>
Date: Mon, 13 Jan 2025 12:28:30 +0100
Subject: [PATCH] ucode-mod-uline: add package for ucode terminal line editing

Signed-off-by: Felix Fietkau <nbd@nbd.name>
---
 package/utils/ucode-mod-uline/Makefile        |  32 +
 .../utils/ucode-mod-uline/src/CMakeLists.txt  |  44 +
 package/utils/ucode-mod-uline/src/private.h   | 194 ++++
 package/utils/ucode-mod-uline/src/ucode.c     | 748 ++++++++++++++
 package/utils/ucode-mod-uline/src/uline.c     | 909 ++++++++++++++++++
 package/utils/ucode-mod-uline/src/uline.h     | 151 +++
 package/utils/ucode-mod-uline/src/utf8.c      | 340 +++++++
 package/utils/ucode-mod-uline/src/vt100.c     |  93 ++
 8 files changed, 2511 insertions(+)
 create mode 100644 package/utils/ucode-mod-uline/Makefile
 create mode 100644 package/utils/ucode-mod-uline/src/CMakeLists.txt
 create mode 100644 package/utils/ucode-mod-uline/src/private.h
 create mode 100644 package/utils/ucode-mod-uline/src/ucode.c
 create mode 100644 package/utils/ucode-mod-uline/src/uline.c
 create mode 100644 package/utils/ucode-mod-uline/src/uline.h
 create mode 100644 package/utils/ucode-mod-uline/src/utf8.c
 create mode 100644 package/utils/ucode-mod-uline/src/vt100.c

diff --git a/package/utils/ucode-mod-uline/Makefile b/package/utils/ucode-mod-uline/Makefile
new file mode 100644
index 0000000000..13d691b0d4
--- /dev/null
+++ b/package/utils/ucode-mod-uline/Makefile
@@ -0,0 +1,32 @@
+include $(TOPDIR)/rules.mk
+
+PKG_NAME:=ucode-mod-uline
+PKG_RELEASE:=1
+PKG_LICENSE:=GPL-2.0-or-later
+PKG_MAINTAINER:=Felix Fietkau <nbd@nbd.name>
+
+include $(INCLUDE_DIR)/package.mk
+include $(INCLUDE_DIR)/cmake.mk
+
+CMAKE_INSTALL := 1
+
+define Package/ucode-mod-uline
+  SECTION:=utils
+  CATEGORY:=Utilities
+  TITLE:=ucode module for terminal line editing
+  DEPENDS:=+libucode +libubox
+endef
+
+CMAKE_OPTIONS += -DUSE_SYSTEM_UTF8=ON
+
+define Package/ucode-mod-uline/description
+This module provides similar functionality as libreadline for ucode, without
+depending on other libraries like ncurses.
+endef
+
+define Package/ucode-mod-uline/install
+	$(INSTALL_DIR) $(1)/usr/lib/ucode
+	$(CP) $(PKG_INSTALL_DIR)/usr/lib/ucode/uline.so $(1)/usr/lib/ucode/
+endef
+
+$(eval $(call BuildPackage,ucode-mod-uline))
diff --git a/package/utils/ucode-mod-uline/src/CMakeLists.txt b/package/utils/ucode-mod-uline/src/CMakeLists.txt
new file mode 100644
index 0000000000..efa2d80f90
--- /dev/null
+++ b/package/utils/ucode-mod-uline/src/CMakeLists.txt
@@ -0,0 +1,44 @@
+cmake_minimum_required(VERSION 3.13)
+
+PROJECT(uline C)
+ADD_DEFINITIONS(-Os -ggdb -Wall -Werror --std=gnu99 -ffunction-sections -fwrapv -D_GNU_SOURCE -Wno-error=unused-function -Wno-parentheses -Wno-sign-compare)
+
+OPTION(USE_SYSTEM_WCHAR "Use system multibyte implementation for UTF-8" OFF)
+IF(CMAKE_C_COMPILER_VERSION VERSION_GREATER 6)
+	ADD_DEFINITIONS(-Wextra -Werror=implicit-function-declaration)
+	ADD_DEFINITIONS(-Wformat -Werror=format-security -Werror=format-nonliteral)
+ENDIF()
+ADD_DEFINITIONS(-Wmissing-declarations -Wno-error=unused-variable -Wno-unused-parameter)
+
+IF(APPLE)
+  SET(UCODE_MODULE_LINK_OPTIONS "LINKER:-undefined,dynamic_lookup")
+ELSE()
+  SET(CMAKE_SHARED_LIBRARY_LINK_C_FLAGS "-Wl,--gc-sections")
+ENDIF()
+
+IF(DEBUG)
+  ADD_DEFINITIONS(-DDEBUG -g3 -O0)
+ELSE()
+  ADD_DEFINITIONS(-DNDEBUG)
+ENDIF()
+
+FIND_LIBRARY(ucode NAMES ucode)
+FIND_LIBRARY(libubox NAMES ubox)
+FIND_PATH(uloop_include_dir NAMES libubox/uloop.h)
+FIND_PATH(ucode_include_dir NAMES ucode/module.h)
+INCLUDE_DIRECTORIES(${ucode_include_dir} ${uloop_include_dir})
+
+ADD_LIBRARY(uline STATIC uline.c utf8.c vt100.c)
+set_property(TARGET uline PROPERTY POSITION_INDEPENDENT_CODE ON)
+IF(USE_SYSTEM_WCHAR)
+  TARGET_COMPILE_DEFINITIONS(uline PUBLIC USE_SYSTEM_WCHAR)
+ENDIF()
+
+ADD_LIBRARY(uline_lib MODULE ucode.c)
+SET_TARGET_PROPERTIES(uline_lib PROPERTIES OUTPUT_NAME uline PREFIX "")
+TARGET_LINK_OPTIONS(uline_lib PRIVATE ${UCODE_MODULE_LINK_OPTIONS})
+TARGET_LINK_LIBRARIES(uline_lib uline ${libubox})
+
+install(FILES uline.h DESTINATION include)
+INSTALL(TARGETS uline LIBRARY DESTINATION lib)
+INSTALL(TARGETS uline_lib LIBRARY DESTINATION lib/ucode)
diff --git a/package/utils/ucode-mod-uline/src/private.h b/package/utils/ucode-mod-uline/src/private.h
new file mode 100644
index 0000000000..fa38d06737
--- /dev/null
+++ b/package/utils/ucode-mod-uline/src/private.h
@@ -0,0 +1,194 @@
+// SPDX-License-Identifier: ISC
+/*
+ * Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+ */
+#ifndef __EDITLINE_PRIVATE_H
+#define __EDITLINE_PRIVATE_H
+
+#include <stdio.h>
+
+#define KEY_NUL	0	// ^@ Null character
+#define KEY_SOH	1	// ^A Start of heading, = console interrupt
+#define KEY_STX	2	// ^B Start of text, maintenance mode on HP console
+#define KEY_ETX	3	// ^C End of text
+#define KEY_EOT	4	// ^D End of transmission, not the same as ETB
+#define KEY_ENQ	5	// ^E Enquiry, goes with ACK; old HP flow control
+#define KEY_ACK	6	// ^F Acknowledge, clears ENQ logon hand
+#define KEY_BEL	7	// ^G Bell, rings the bell
+#define KEY_BS	8	// ^H Backspace, works on HP terminals/computers
+#define KEY_HT	9	// ^I Horizontal tab, move to next tab stop
+#define KEY_LF	10	// ^J Line Feed
+#define KEY_VT	11	// ^K Vertical tab
+#define KEY_FF	12	// ^L Form Feed, page eject
+#define KEY_CR	13	// ^M Carriage Return
+#define KEY_SO	14	// ^N Shift Out, alternate character set
+#define KEY_SI	15	// ^O Shift In, resume defaultn character set
+#define KEY_DLE	16	// ^P Data link escape
+#define KEY_DC1	17	// ^Q XON, with XOFF to pause listings; "okay to send"
+#define KEY_DC2	18	// ^R Device control 2, block-mode flow control
+#define KEY_DC3	19	// ^S XOFF, with XON is TERM=18 flow control
+#define KEY_DC4	20	// ^T Device control 4
+#define KEY_NAK	21	// ^U Negative acknowledge
+#define KEY_SYN	22	// ^V Synchronous idle
+#define KEY_ETB	23	// ^W End transmission block, not the same as EOT
+#define KEY_CAN	24	// ^X Cancel line, MPE echoes !!!
+#define KEY_EM	25	// ^Y End of medium, Control-Y interrupt
+#define KEY_SUB	26	// ^Z Substitute
+#define KEY_ESC	27	// ^[ Escape, next character is not echoed
+#define KEY_FS	28	// ^\ File separator
+#define KEY_GS	29	// ^] Group separator
+#define KEY_RS	30	// ^^ Record separator, block-mode terminator
+#define KEY_US	31	// ^_ Unit separator
+#define KEY_DEL	127	// Delete (not a real control character)
+
+// Types of escape code
+enum vt100_escape {
+	VT100_INCOMPLETE,
+	VT100_UNKNOWN,
+	VT100_IGNORE,
+	VT100_CURSOR_UP,
+	VT100_CURSOR_DOWN,
+	VT100_CURSOR_LEFT,
+	VT100_CURSOR_WORD_LEFT,
+	VT100_CURSOR_RIGHT,
+	VT100_CURSOR_WORD_RIGHT,
+	VT100_HOME,
+	VT100_END,
+	VT100_INSERT,
+	VT100_DELETE,
+	VT100_DELETE_LEFT,
+	VT100_DELETE_LEFT_WORD,
+	VT100_PAGE_UP,
+	VT100_PAGE_DOWN,
+};
+
+ssize_t utf8_nsyms(const char *str, size_t len);
+enum vt100_escape vt100_esc_decode(const char *str);
+
+// helpers:
+void __vt100_csi_num(FILE *out, int num, char code);
+void __vt100_csi2(FILE *out, char c1, char c2);
+void __vt100_esc(FILE *out, char c);
+static inline void __vt100_sgr(FILE *out, int code)
+{
+	__vt100_csi2(out, code + '0', 'm');
+}
+
+
+static inline void vt100_attr_reset(FILE *out)
+{
+	__vt100_sgr(out, 0);
+}
+
+static inline void vt100_attr_bright(FILE *out)
+{
+	__vt100_sgr(out, 1);
+}
+
+static inline void vt100_attr_dim(FILE *out)
+{
+	__vt100_sgr(out, 2);
+}
+
+static inline void vt100_attr_underscore(FILE *out)
+{
+	__vt100_sgr(out, 4);
+}
+
+static inline void vt100_attr_blink(FILE *out)
+{
+	__vt100_sgr(out, 5);
+}
+
+static inline void vt100_attr_reverse(FILE *out)
+{
+	__vt100_sgr(out, 7);
+}
+
+static inline void vt100_attr_hidden(FILE *out)
+{
+	__vt100_sgr(out, 8);
+}
+
+static inline void vt100_erase_line(FILE *out)
+{
+	__vt100_csi2(out, '2', 'K');
+}
+
+static inline void vt100_clear_screen(FILE *out)
+{
+	__vt100_csi2(out, '2', 'J');
+}
+
+static inline void vt100_cursor_save(FILE *out)
+{
+	__vt100_esc(out, '7');
+}
+
+static inline void vt100_cursor_restore(FILE *out)
+{
+	__vt100_esc(out, '8');
+}
+
+static inline void vt100_scroll_up(FILE *out)
+{
+	__vt100_esc(out, 'D');
+}
+
+static inline void vt100_scroll_down(FILE *out)
+{
+	__vt100_esc(out, 'M');
+}
+
+static inline void vt100_next_line(FILE *out)
+{
+	__vt100_esc(out, 'E');
+}
+
+static inline void vt100_cursor_up(FILE *out, int count)
+{
+	__vt100_csi_num(out, count, 'A');
+}
+
+static inline void vt100_cursor_down(FILE *out, int count)
+{
+	__vt100_csi_num(out, count, 'B');
+}
+
+static inline void vt100_cursor_forward(FILE *out, int count)
+{
+	__vt100_csi_num(out, count, 'C');
+}
+
+static inline void vt100_cursor_back(FILE *out, int count)
+{
+	__vt100_csi_num(out, count, 'D');
+}
+
+static inline void vt100_cursor_home(FILE *out)
+{
+	__vt100_csi2(out, 'H', 0);
+}
+
+static inline void vt100_erase(FILE *out, int count)
+{
+	__vt100_csi_num(out, count, 'P');
+}
+
+static inline void vt100_erase_down(FILE *out)
+{
+	__vt100_csi2(out, 'J', 0);
+}
+
+static inline void vt100_erase_right(FILE *out)
+{
+	__vt100_csi2(out, 'K', 0);
+}
+
+static inline void vt100_ding(FILE *out)
+{
+	fputc(7, out);
+	fflush(out);
+}
+
+#endif
diff --git a/package/utils/ucode-mod-uline/src/ucode.c b/package/utils/ucode-mod-uline/src/ucode.c
new file mode 100644
index 0000000000..6a20cd2667
--- /dev/null
+++ b/package/utils/ucode-mod-uline/src/ucode.c
@@ -0,0 +1,748 @@
+// SPDX-License-Identifier: ISC
+/*
+ * Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+ */
+#include <stdio.h>
+#include <string.h>
+#include <ctype.h>
+
+#include <ucode/module.h>
+#include <libubox/list.h>
+#include <libubox/uloop.h>
+
+#include "uline.h"
+
+static uc_value_t *registry;
+static uc_resource_type_t *state_type, *argp_type;
+
+enum {
+	STATE_RES,
+	STATE_CB,
+	STATE_INPUT,
+	STATE_OUTPUT,
+	STATE_POLL_CB,
+};
+
+struct uc_uline_state {
+	struct uloop_fd fd;
+
+	struct uline_state s;
+	int registry_index;
+
+	uc_vm_t *vm;
+	uc_value_t *state, *cb, *res, *poll_cb;
+
+	uc_value_t *line;
+
+	uint32_t input_mask[256 / 32];
+};
+
+struct uc_arg_parser {
+	char line_sep;
+};
+
+static unsigned int
+registry_set(uc_vm_t *vm, uc_value_t *val)
+{
+	uc_value_t *registry;
+	size_t i, len;
+
+	registry = uc_vm_registry_get(vm, "uline.registry");
+	len = ucv_array_length(registry);
+	for (i = 0; i < len; i++)
+		if (ucv_array_get(registry, i) == NULL)
+			break;
+
+	ucv_array_set(registry, i, ucv_get(val));
+	return i;
+}
+
+static uc_value_t *
+uc_uline_poll(uc_vm_t *vm, size_t nargs)
+{
+	struct uc_uline_state *us = uc_fn_thisval("uline.state");
+	uc_value_t *val;
+
+	if (!us)
+		return NULL;
+
+	uline_poll(&us->s);
+	val = us->line;
+	us->line = NULL;
+
+	return val;
+}
+
+static uc_value_t *
+uc_uline_get_window(uc_vm_t *vm, size_t nargs)
+{
+	struct uc_uline_state *us = uc_fn_thisval("uline.state");
+	uc_value_t *val;
+
+	if (!us)
+		return NULL;
+
+	val = ucv_object_new(vm);
+	ucv_object_add(val, "x", ucv_int64_new(us->s.cols));
+	ucv_object_add(val, "y", ucv_int64_new(us->s.rows));
+	return val;
+}
+
+static uc_value_t *
+uc_uline_get_line(uc_vm_t *vm, size_t nargs)
+{
+	struct uc_uline_state *us = uc_fn_thisval("uline.state");
+	uc_value_t *line2 = uc_fn_arg(0);
+	uc_value_t *state, *val;
+	const char *line;
+	size_t len;
+
+	if (!us)
+		return NULL;
+
+	state = ucv_object_new(vm);
+	if (ucv_is_truish(line2))
+		uline_get_line2(&us->s, &line, &len);
+	else
+		uline_get_line(&us->s, &line, &len);
+	val = ucv_string_new_length(line, len);
+	ucv_object_add(state, "line", ucv_get(val));
+	ucv_object_add(state, "pos", ucv_int64_new(us->s.line.pos));
+
+	return state;
+}
+
+static uc_value_t *
+uc_uline_set_state(uc_vm_t *vm, size_t nargs)
+{
+	struct uc_uline_state *us = uc_fn_thisval("uline.state");
+	uc_value_t *state = uc_fn_arg(0);
+	uc_value_t *arg;
+	bool found;
+
+	if (!us || ucv_type(state) != UC_OBJECT)
+		return NULL;
+
+	if ((arg = ucv_object_get(state, "prompt", NULL)) != NULL) {
+	    if (ucv_type(arg) != UC_STRING)
+			return NULL;
+
+		uline_set_prompt(&us->s, ucv_string_get(arg));
+	}
+
+	if ((arg = ucv_object_get(state, "line", NULL)) != NULL) {
+	    if (ucv_type(arg) != UC_STRING)
+			return NULL;
+
+		uline_set_line(&us->s, ucv_string_get(arg), ucv_string_length(arg));
+	}
+
+	if ((arg = ucv_object_get(state, "pos", NULL)) != NULL) {
+		if (ucv_type(arg) != UC_INTEGER)
+			return NULL;
+
+		uline_set_cursor(&us->s, ucv_int64_get(arg));
+	}
+
+	arg = ucv_object_get(state, "line2_prompt", &found);
+	if (found) {
+		if (!arg)
+			uline_set_line2_prompt(&us->s, NULL);
+		else if (ucv_type(arg) == UC_STRING)
+			uline_set_line2_prompt(&us->s, ucv_string_get(arg));
+		else
+			return NULL;
+	}
+
+	if ((arg = ucv_object_get(state, "line2", NULL)) != NULL) {
+	    if (ucv_type(arg) != UC_STRING)
+			return NULL;
+
+		uline_set_line2(&us->s, ucv_string_get(arg), ucv_string_length(arg));
+	}
+
+	if ((arg = ucv_object_get(state, "line2_pos", NULL)) != NULL) {
+		if (ucv_type(arg) != UC_INTEGER)
+			return NULL;
+
+		uline_set_line2_cursor(&us->s, ucv_int64_get(arg));
+	}
+
+	return ucv_boolean_new(true);
+}
+
+static uc_value_t *
+uc_uline_set_hint(uc_vm_t *vm, size_t nargs)
+{
+	struct uc_uline_state *us = uc_fn_thisval("uline.state");
+	uc_value_t *arg = uc_fn_arg(0);
+
+	if (!us || ucv_type(arg) != UC_STRING)
+		return NULL;
+
+	uline_set_hint(&us->s, ucv_string_get(arg), ucv_string_length(arg));
+
+	return ucv_boolean_new(true);
+}
+
+static uc_value_t *
+uc_uline_set_uloop(uc_vm_t *vm, size_t nargs)
+{
+	struct uc_uline_state *us = uc_fn_thisval("uline.state");
+	uc_value_t *cb = uc_fn_arg(0);
+
+	if (!us || (cb && !ucv_is_callable(cb)))
+		return NULL;
+
+	us->poll_cb = cb;
+	ucv_array_set(us->state, STATE_POLL_CB, ucv_get(cb));
+	if (cb) {
+		uloop_fd_add(&us->fd, ULOOP_READ);
+		us->fd.cb(&us->fd, 0);
+	} else {
+		uloop_fd_delete(&us->fd);
+	}
+
+	return ucv_boolean_new(true);
+}
+
+static uc_value_t *
+uc_uline_reset_key_input(uc_vm_t *vm, size_t nargs)
+{
+	struct uc_uline_state *us = uc_fn_thisval("uline.state");
+
+	us->s.repeat_char = 0;
+
+	return ucv_boolean_new(true);
+}
+
+static uc_value_t *
+uc_uline_hide_prompt(uc_vm_t *vm, size_t nargs)
+{
+	struct uc_uline_state *us = uc_fn_thisval("uline.state");
+
+	if (!us)
+		return NULL;
+
+	uline_hide_prompt(&us->s);
+
+	return ucv_boolean_new(true);
+}
+
+static uc_value_t *
+uc_uline_refresh_prompt(uc_vm_t *vm, size_t nargs)
+{
+	struct uc_uline_state *us = uc_fn_thisval("uline.state");
+
+	if (!us)
+		return NULL;
+
+	uline_refresh_prompt(&us->s);
+
+	return ucv_boolean_new(true);
+}
+
+static bool
+cb_prepare(struct uc_uline_state *us, const char *name)
+{
+	uc_value_t *func;
+
+	func = ucv_object_get(us->cb, name, NULL);
+	if (!func)
+		return false;
+
+	uc_vm_stack_push(us->vm, ucv_get(us->res));
+	uc_vm_stack_push(us->vm, ucv_get(func));
+	return true;
+}
+
+static uc_value_t *
+cb_call_ret(struct uc_uline_state *us, size_t args, ...)
+{
+	va_list ap;
+
+	va_start(ap, args);
+	for (size_t i = 0; i < args; i++)
+		uc_vm_stack_push(us->vm, ucv_get(va_arg(ap, void *)));
+	va_end(ap);
+
+	if (uc_vm_call(us->vm, true, args) == EXCEPTION_NONE)
+		return uc_vm_stack_pop(us->vm);
+
+	return NULL;
+}
+#define cb_call(...) ucv_put(cb_call_ret(__VA_ARGS__))
+
+static bool
+uc_uline_cb_line(struct uline_state *s, const char *str, size_t len)
+{
+	struct uc_uline_state *us = container_of(s, struct uc_uline_state, s);
+	bool complete = true;
+	uc_value_t *ret;
+
+	if (cb_prepare(us, "line_check")) {
+		ret = cb_call_ret(us, 1, ucv_string_new_length(str, len));
+		complete = ucv_is_truish(ret);
+		ucv_put(ret);
+	}
+
+	s->stop = complete;
+	if (complete)
+		us->line = ucv_string_new_length(str, len);
+
+	return complete;
+}
+
+static void
+uc_uline_cb_event(struct uline_state *s, enum uline_event ev)
+{
+	struct uc_uline_state *us = container_of(s, struct uc_uline_state, s);
+	static const char * const ev_types[] = {
+		[EDITLINE_EV_CURSOR_UP] = "cursor_up",
+		[EDITLINE_EV_CURSOR_DOWN] = "cursor_down",
+		[EDITLINE_EV_WINDOW_CHANGED] = "window_changed",
+		[EDITLINE_EV_EOF] = "eof",
+		[EDITLINE_EV_INTERRUPT] = "interrupt",
+	};
+
+	if (ev > ARRAY_SIZE(ev_types) || !ev_types[ev])
+		return;
+
+	if (!cb_prepare(us, ev_types[ev]))
+		return;
+
+	if (ev == EDITLINE_EV_WINDOW_CHANGED)
+		cb_call(us, 2, ucv_int64_new(s->cols), ucv_int64_new(s->rows));
+	else
+		cb_call(us, 0);
+}
+
+static void uc_uline_poll_cb(struct uloop_fd *fd, unsigned int events)
+{
+	struct uc_uline_state *us = container_of(fd, struct uc_uline_state, fd);
+	uc_value_t *val;
+
+	while (!uloop_cancelled && us->poll_cb) {
+		uline_poll(&us->s);
+
+		val = us->line;
+		if (!val)
+			break;
+
+		us->line = NULL;
+		if (!ucv_is_callable(us->poll_cb))
+			return;
+
+		uc_vm_stack_push(us->vm, ucv_get(us->res));
+		uc_vm_stack_push(us->vm, ucv_get(us->poll_cb));
+		cb_call(us, 1, val);
+	}
+}
+
+static bool
+uc_uline_cb_key_input(struct uline_state *s, unsigned char c, unsigned int count)
+{
+	struct uc_uline_state *us = container_of(s, struct uc_uline_state, s);
+	uc_value_t *ret;
+	bool retval;
+
+	if (!(us->input_mask[c / 32] & (1 << (c % 32))))
+		return false;
+
+	if (!cb_prepare(us, "key_input"))
+		return false;
+
+	ret = cb_call_ret(us, 2, ucv_string_new_length((char *)&c, 1), ucv_int64_new(count));
+	retval = ucv_is_truish(ret);
+	ucv_put(ret);
+
+	return retval;
+}
+
+static void
+uc_uline_cb_line2_update(struct uline_state *s, const char *str, size_t len)
+{
+	struct uc_uline_state *us = container_of(s, struct uc_uline_state, s);
+
+	if (cb_prepare(us, "line2_update"))
+		cb_call(us, 1, ucv_string_new_length(str, len));
+}
+
+static bool
+uc_uline_cb_line2_cursor(struct uline_state *s)
+{
+	struct uc_uline_state *us = container_of(s, struct uc_uline_state, s);
+	uc_value_t *retval;
+	bool ret = true;
+
+	if (cb_prepare(us, "line2_cursor")) {
+		retval = cb_call_ret(us, 0);
+		ret = ucv_is_truish(retval);
+		ucv_put(retval);
+	}
+
+	return ret;
+}
+
+static bool
+uc_uline_cb_line2_newline(struct uline_state *s, const char *str, size_t len)
+{
+	struct uc_uline_state *us = container_of(s, struct uc_uline_state, s);
+	uc_value_t *retval;
+	bool ret = false;
+
+	if (cb_prepare(us, "line2_newline")) {
+		retval = cb_call_ret(us, 1, ucv_string_new_length(str, len));
+		ret = ucv_is_truish(retval);
+		ucv_put(retval);
+	}
+
+	return ret;
+}
+
+static uc_value_t *
+uc_uline_new(uc_vm_t *vm, size_t nargs)
+{
+	static const struct uline_cb uline_cb = {
+#define _CB(_type) ._type = uc_uline_cb_##_type
+		_CB(key_input),
+		_CB(line),
+		_CB(event),
+		_CB(line2_update),
+		_CB(line2_cursor),
+		_CB(line2_newline),
+#undef _CB
+	};
+	uc_value_t *data = uc_fn_arg(0);
+	struct uc_uline_state *us;
+	FILE *input, *output;
+	uc_value_t *arg, *cb, *state, *res;
+
+	if (ucv_type(data) != UC_OBJECT)
+		return NULL;
+
+	cb = ucv_object_get(data, "cb", NULL);
+	if (ucv_type(cb) != UC_OBJECT)
+		return NULL;
+
+	state = ucv_array_new(vm);
+	ucv_array_set(state, 0, ucv_get(cb));
+	if ((arg = ucv_object_get(data, "input", NULL)) != NULL) {
+		input = ucv_resource_data(arg, "fs.file");
+		ucv_array_set(state, STATE_INPUT, ucv_get(arg));
+	} else {
+		input = stdin;
+	}
+
+	if ((arg = ucv_object_get(data, "output", NULL)) != NULL) {
+		output = ucv_resource_data(arg, "fs.file");
+		ucv_array_set(state, STATE_OUTPUT, ucv_get(arg));
+	} else {
+		output = stdout;
+	}
+
+	if (!input || !output) {
+		input = output = NULL;
+		return NULL;
+	}
+
+	us = calloc(1, sizeof(*us));
+	us->vm = vm;
+	us->state = ucv_array_new(vm);
+	ucv_array_set(us->state, STATE_CB, ucv_get(cb));
+	us->cb = cb;
+	us->registry_index = registry_set(vm, state);
+
+	if ((arg = ucv_object_get(data, "key_input_list", NULL)) != NULL) {
+		uc_value_t *val;
+		size_t len;
+
+		if (ucv_type(arg) != UC_ARRAY)
+			goto free;
+
+		len = ucv_array_length(arg);
+		for (size_t i = 0; i < len; i++) {
+			unsigned char c;
+
+			val = ucv_array_get(arg, i);
+			if (ucv_type(val) != UC_STRING || ucv_string_length(val) != 1)
+				goto free;
+
+			c = ucv_string_get(val)[0];
+			us->input_mask[c / 32] |= 1 << (c % 32);
+		}
+	}
+
+	res = ucv_resource_new(state_type, us);
+	ucv_array_set(us->state, STATE_RES, ucv_get(res));
+	us->res = res;
+	us->fd.fd = fileno(input);
+	us->fd.cb = uc_uline_poll_cb;
+
+	uline_init(&us->s, &uline_cb, us->fd.fd, output, true);
+
+	return res;
+
+free:
+	free(us);
+	return NULL;
+}
+
+static void free_state(void *ptr)
+{
+	struct uc_uline_state *us = ptr;
+	uc_value_t *registry;
+
+	registry = uc_vm_registry_get(us->vm, "uline.registry");
+	ucv_array_set(registry, us->registry_index, NULL);
+	uline_free(&us->s);
+	free(us);
+}
+
+static uc_value_t *
+uc_uline_close(uc_vm_t *vm, size_t nargs)
+{
+	struct uline_state **s = uc_fn_this("uline.state");
+
+	if (!s || !*s)
+		return NULL;
+
+	free_state(*s);
+	*s = NULL;
+
+	return NULL;
+}
+
+static bool
+skip_space(const char **str, const char *end)
+{
+	while (*str < end && isspace(**str))
+		(*str)++;
+	return *str < end;
+}
+
+static void
+add_str(uc_stringbuf_t **buf, const char *str, const char *next)
+{
+	if (str == next)
+		return;
+
+	if (!*buf)
+		*buf = ucv_stringbuf_new();
+	ucv_stringbuf_addstr(*buf, str, next - str);
+}
+
+static uc_value_t *
+uc_uline_parse_args(uc_vm_t *vm, size_t nargs, bool check)
+{
+	struct uc_arg_parser *argp = uc_fn_thisval("uline.argp");
+	uc_value_t *str_arg = uc_fn_arg(0);
+	uc_stringbuf_t *buf = NULL;
+	uc_value_t *missing = NULL;
+	uc_value_t *args = NULL;
+	uc_value_t *list = NULL;
+	uc_value_t *ret;
+	const char *str, *end;
+	enum {
+		UNQUOTED,
+		BACKSLASH,
+		SINGLE_QUOTE,
+		DOUBLE_QUOTE,
+		DOUBLE_QUOTE_BACKSLASH,
+	} state = UNQUOTED;
+	static const char * const state_str[] = {
+		[BACKSLASH] = "\\",
+		[SINGLE_QUOTE] = "'",
+		[DOUBLE_QUOTE] = "\"",
+		[DOUBLE_QUOTE_BACKSLASH] = "\\\"",
+	};
+#define UNQUOTE_TOKENS " \t\r\n'\"\\"
+	char unquote_tok[] = UNQUOTE_TOKENS "\x00";
+	unquote_tok[strlen(UNQUOTE_TOKENS)] = argp->line_sep;
+
+	if (!argp || ucv_type(str_arg) != UC_STRING)
+		return NULL;
+
+	if (!check) {
+		list = ucv_array_new(vm);
+		if (argp->line_sep) {
+			args = ucv_array_new(vm);
+			ucv_array_push(args, ucv_get(list));
+		} else {
+			args = list;
+		}
+	}
+
+	str = ucv_string_get(str_arg);
+	end = str + ucv_string_length(str_arg);
+	skip_space(&str, end);
+
+	while (*str && str < end) {
+		const char *next;
+
+		switch (state) {
+		case UNQUOTED:
+			if (isspace(*str)) {
+				skip_space(&str, end);
+				if (!buf)
+					continue;
+
+				ucv_array_push(list, ucv_stringbuf_finish(buf));
+				buf = NULL;
+				continue;
+			}
+
+			next = str + strcspn(str, unquote_tok);
+			if (list)
+				add_str(&buf, str, next);
+			str = next;
+
+			switch (*str) {
+			case 0:
+				continue;
+			case '\'':
+				state = SINGLE_QUOTE;
+				break;
+			case '"':
+				state = DOUBLE_QUOTE;
+				break;
+			case '\\':
+				state = BACKSLASH;
+				break;
+			default:
+				if (argp->line_sep &&
+				    *str == argp->line_sep) {
+					str++;
+					if (list) {
+						if (buf)
+							ucv_array_push(list, ucv_stringbuf_finish(buf));
+						buf = NULL;
+						list = ucv_array_new(vm);
+						ucv_array_push(args, ucv_get(list));
+					}
+				}
+				continue;
+			}
+			if (!buf)
+				buf = ucv_stringbuf_new();
+			str++;
+			break;
+
+		case BACKSLASH:
+		case DOUBLE_QUOTE_BACKSLASH:
+			if (list && *str != '\n')
+				add_str(&buf, str, str + 1);
+			str++;
+			state--;
+			break;
+
+		case SINGLE_QUOTE:
+			next = str + strcspn(str, "'");
+			if (list)
+				add_str(&buf, str, next);
+			str = next;
+
+			if (*str == '\'') {
+				state = UNQUOTED;
+				str++;
+			}
+			break;
+
+		case DOUBLE_QUOTE:
+			next = str + strcspn(str, "\"\\");
+			if (list)
+				add_str(&buf, str, next);
+			str = next;
+
+			if (*str == '"') {
+				state = UNQUOTED;
+				str++;
+			} else if (*str == '\\') {
+				state = DOUBLE_QUOTE_BACKSLASH;
+				str++;
+			}
+		}
+	}
+
+	if (buf)
+		ucv_array_push(list, ucv_get(ucv_stringbuf_finish(buf)));
+
+	if (state_str[state])
+		missing = ucv_string_new(state_str[state]);
+
+	if (!list)
+		return missing;
+
+	ret = ucv_object_new(vm);
+	ucv_object_add(ret, "args", ucv_get(args));
+	if (missing)
+		ucv_object_add(ret, "missing", ucv_get(missing));
+
+	return ret;
+}
+
+static uc_value_t *
+uc_uline_arg_parser(uc_vm_t *vm, size_t nargs)
+{
+	uc_value_t *opts = uc_fn_arg(0);
+	struct uc_arg_parser *argp;
+	uc_value_t *a;
+	char sep = 0;
+
+	if ((a = ucv_object_get(opts, "line_separator", NULL)) != NULL) {
+		if (ucv_type(a) != UC_STRING || ucv_string_length(a) != 1)
+			return NULL;
+
+		sep = ucv_string_get(a)[0];
+	}
+
+	argp = calloc(1, sizeof(*argp));
+	argp->line_sep = sep;
+
+	return ucv_resource_new(argp_type, argp);
+}
+
+static uc_value_t *
+uc_uline_argp_parse(uc_vm_t *vm, size_t nargs)
+{
+	return uc_uline_parse_args(vm, nargs, false);
+}
+
+static uc_value_t *
+uc_uline_argp_check(uc_vm_t *vm, size_t nargs)
+{
+	return uc_uline_parse_args(vm, nargs, true);
+}
+
+static const uc_function_list_t argp_fns[] = {
+	{ "parse", uc_uline_argp_parse },
+	{ "check", uc_uline_argp_check },
+};
+
+static const uc_function_list_t state_fns[] = {
+	{ "close", uc_uline_close },
+	{ "poll", uc_uline_poll },
+	{ "reset_key_input", uc_uline_reset_key_input },
+	{ "get_line", uc_uline_get_line },
+	{ "get_window", uc_uline_get_window },
+	{ "set_hint", uc_uline_set_hint },
+	{ "set_state", uc_uline_set_state },
+	{ "set_uloop", uc_uline_set_uloop },
+	{ "hide_prompt", uc_uline_hide_prompt },
+	{ "refresh_prompt", uc_uline_refresh_prompt },
+};
+
+static const uc_function_list_t global_fns[] = {
+	{ "new", uc_uline_new },
+	{ "arg_parser", uc_uline_arg_parser },
+};
+
+void uc_module_init(uc_vm_t *vm, uc_value_t *scope)
+{
+	uc_function_list_register(scope, global_fns);
+
+	state_type = uc_type_declare(vm, "uline.state", state_fns, free_state);
+	argp_type = uc_type_declare(vm, "uline.argp", argp_fns, free);
+	registry = ucv_array_new(vm);
+	uc_vm_registry_set(vm, "uline.registry", registry);
+}
diff --git a/package/utils/ucode-mod-uline/src/uline.c b/package/utils/ucode-mod-uline/src/uline.c
new file mode 100644
index 0000000000..1f30325356
--- /dev/null
+++ b/package/utils/ucode-mod-uline/src/uline.c
@@ -0,0 +1,909 @@
+// SPDX-License-Identifier: ISC
+/*
+ * Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+ */
+#include <sys/types.h>
+#include <sys/ioctl.h>
+
+#include <stdint.h>
+#include <stdio.h>
+#include <errno.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdlib.h>
+#include <string.h>
+#include <ctype.h>
+#include <locale.h>
+
+#include <libubox/list.h>
+
+#include "uline.h"
+#include "private.h"
+
+#define LINEBUF_CHUNK 64
+
+static int sigwinch_count;
+
+static size_t
+nsyms(struct uline_state *s, const char *buf, size_t len)
+{
+	if (!s->utf8)
+		return len;
+	return utf8_nsyms(buf, len);
+}
+
+static inline bool
+is_utf8_cont(unsigned char c)
+{
+	return (c & 0xc0) == 0x80;
+}
+
+static size_t
+utf8_move_left(const char *line, size_t pos)
+{
+	if (!pos)
+		return 0;
+	do {
+		pos--;
+	} while (pos > 0 && is_utf8_cont(line[pos]));
+
+	return pos;
+}
+
+static size_t
+utf8_move_right(const char *line, size_t pos, size_t len)
+{
+	if (pos == len)
+		return pos;
+
+	do {
+		pos++;
+	} while (pos < len && is_utf8_cont(line[pos]));
+
+	return pos;
+}
+
+static char *
+linebuf_extend(struct linebuf *l, size_t size)
+{
+	size_t tailroom = l->bufsize - l->len;
+	char *buf;
+
+	if (l->buf && tailroom > size)
+		goto out;
+
+	size -= tailroom;
+	size += LINEBUF_CHUNK - 1;
+	size -= size % LINEBUF_CHUNK;
+
+	buf = realloc(l->buf, l->bufsize + size);
+	if (!buf)
+		return NULL;
+
+	l->buf = buf;
+	l->bufsize += size;
+
+out:
+	return l->buf + l->len;
+}
+
+static void
+linebuf_free(struct linebuf *line)
+{
+	free(line->buf);
+	free(line->prompt);
+}
+
+static void
+update_window_size(struct uline_state *s, bool init)
+{
+	unsigned int cols = 80, rows = 25;
+#ifdef TIOCGWINSZ
+	struct winsize ws = {};
+
+	if (!ioctl(fileno(s->output), TIOCGWINSZ, &ws)) {
+		if (ws.ws_col)
+			cols = ws.ws_col;
+		if (ws.ws_row)
+			rows = ws.ws_row;
+	}
+#endif
+
+	s->sigwinch_count = sigwinch_count;
+	if (s->cols == cols && s->rows == rows)
+		return;
+
+	s->cols = cols;
+	s->rows = rows;
+	s->full_update = true;
+	s->cb->event(s, EDITLINE_EV_WINDOW_CHANGED);
+}
+
+static void
+handle_sigwinch(int signal)
+{
+	sigwinch_count++;
+}
+
+static void
+reset_input_state(struct uline_state *s)
+{
+	s->utf8_cont = 0;
+	s->esc_idx = -1;
+}
+
+static void
+termios_set_native_mode(struct uline_state *s)
+{
+	struct termios t = s->orig_termios;
+
+	if (!s->has_termios)
+		return;
+
+	t.c_iflag = 0;
+	t.c_oflag = OPOST | ONLCR;
+	t.c_lflag = 0;
+	t.c_cc[VMIN] = 1;
+	t.c_cc[VTIME] = 0;
+
+	tcsetattr(s->input, TCSADRAIN, &t);
+}
+
+static void
+termios_set_orig_mode(struct uline_state *s)
+{
+	if (!s->has_termios)
+		return;
+
+	tcsetattr(s->input, TCSADRAIN, &s->orig_termios);
+}
+
+static bool
+check_utf8(struct uline_state *s, unsigned char c)
+{
+	if (!s->utf8)
+		return false;
+	if (s->utf8_cont)
+		return true;
+	return (c & 0xc0) == 0xc0;
+}
+
+static bool
+handle_utf8(struct uline_state *s, unsigned char c)
+{
+	if (!s->utf8)
+		return false;
+
+	if (!s->utf8_cont) {
+		if ((c & 0xc0) != 0xc0)
+			return false;
+
+		c &= 0xf0;
+		c <<= 1;
+		while (c & 0x80) {
+			c <<= 1;
+			s->utf8_cont++;
+		}
+
+		return true;
+	}
+
+	if ((c & 0xc0) != 0x80) {
+		// invalid utf-8
+		s->utf8_cont = 0;
+		return false;
+	}
+
+	s->utf8_cont--;
+
+	return s->utf8_cont;
+}
+
+static bool
+linebuf_insert(struct linebuf *line, char *c, size_t len)
+{
+	char *dest;
+	ssize_t tail;
+
+	if (!linebuf_extend(line, len + 1))
+		return false;
+
+	dest = &line->buf[line->pos];
+	tail = line->len - line->pos;
+	if (tail > 0)
+		memmove(dest + len, dest, tail);
+	else
+		dest[len] = 0;
+
+	if (line->update_pos > line->pos)
+		line->update_pos = line->pos;
+
+	memcpy(dest, c, len);
+	line->len += len;
+	line->pos += len;
+	line->buf[line->len] = 0;
+
+	return true;
+}
+
+static void
+linebuf_delete(struct linebuf *line, size_t len)
+{
+	char *dest = &line->buf[line->pos];
+	ssize_t tail = line->len - line->pos;
+	size_t max_len = line->len - line->pos;
+
+	if (line->update_pos > line->pos)
+		line->update_pos = line->pos;
+
+	if (len > max_len)
+		len = max_len;
+
+	memmove(dest, dest + len, tail + 1);
+	line->len -= len;
+}
+
+static struct pos
+pos_convert(struct uline_state *s, ssize_t offset)
+{
+	struct pos pos;
+	pos.y = offset / s->cols;
+	pos.x = offset - (pos.y * s->cols);
+	return pos;
+}
+
+static void
+pos_add(struct uline_state *s, struct pos *pos, struct pos add)
+{
+	pos->x += add.x;
+	pos->y += add.y;
+	if (pos->x >= (int16_t)s->cols) {
+		pos->x -= s->cols;
+		pos->y++;
+	}
+	if (pos->x < 0) {
+		pos->x += s->cols;
+		pos->y--;
+	}
+	if (pos->y < 0)
+		pos->y = 0;
+}
+
+static void
+pos_add_ofs(struct uline_state *s, struct pos *pos, size_t offset)
+{
+	pos_add(s, pos, pos_convert(s, offset));
+}
+
+static void
+pos_add_newline(struct uline_state *s, struct pos *pos)
+{
+	pos->x = 0;
+	pos->y++;
+}
+
+static void
+__pos_add_string(struct uline_state *s, struct pos *pos, const char *str, size_t len)
+{
+	const char *next;
+
+	while ((next = memchr(str, KEY_ESC, len)) != NULL) {
+		size_t cur_len = next - str;
+
+		pos_add_ofs(s, pos, nsyms(s, str, cur_len));
+		next++;
+
+		if (*next == '[' || *next == 'O') {
+			next++;
+			while (*next <= 63)
+				next++;
+		}
+		next++;
+		len -= next - str;
+		str = next;
+	}
+
+	pos_add_ofs(s, pos, nsyms(s, str, len));
+}
+
+static void
+pos_add_string(struct uline_state *s, struct pos *pos, const char *str, size_t len)
+{
+	const char *next;
+
+	while ((next = memchr(str, '\n', len)) != NULL) {
+		size_t cur_len = next - str;
+		if (cur_len)
+			__pos_add_string(s, pos, str, cur_len);
+		pos_add_newline(s, pos);
+		len -= cur_len + 1;
+		str = next + 1;
+	}
+
+	if (len)
+		__pos_add_string(s, pos, str, len);
+}
+
+static struct pos
+pos_diff(struct pos start, struct pos end)
+{
+	struct pos diff = {
+		.x = end.x - start.x,
+		.y = end.y - start.y
+	};
+
+	return diff;
+}
+
+static void
+set_cursor(struct uline_state *s, struct pos pos)
+{
+	struct pos diff = pos_diff(s->cursor_pos, pos);
+
+	if (diff.x > 0)
+		vt100_cursor_forward(s->output, diff.x);
+	else if (diff.x < 0)
+		vt100_cursor_back(s->output, -diff.x);
+
+	if (diff.y > 0)
+		vt100_cursor_down(s->output, diff.y);
+	else if (diff.y < 0)
+		vt100_cursor_up(s->output, -diff.y);
+
+	s->cursor_pos = pos;
+}
+
+static void
+display_output_string(struct uline_state *s, const char *str,
+		      size_t len)
+{
+	fwrite(str, len, 1, s->output);
+	pos_add_string(s, &s->cursor_pos, str, len);
+}
+
+static void
+display_update_line(struct uline_state *s, struct linebuf *line,
+		    struct pos *pos)
+{
+	char *start = line->buf;
+	char *end = line->buf + line->len;
+	struct pos update_pos;
+	size_t prompt_len = strlen(line->prompt);
+
+	if (s->full_update) {
+		display_output_string(s, line->prompt, prompt_len);
+		*pos = s->cursor_pos;
+		line->update_pos = 0;
+	} else {
+		pos_add_string(s, pos, line->prompt, prompt_len);
+	}
+
+	update_pos = *pos;
+	if (line->update_pos) {
+		start += line->update_pos;
+		pos_add_string(s, &update_pos, line->buf, line->update_pos);
+	}
+	set_cursor(s, update_pos);
+	vt100_erase_right(s->output);
+	line->update_pos = line->len;
+
+	if (end - start <= 0)
+		return;
+
+	display_output_string(s, start, end - start);
+	if (s->cursor_pos.x == 0 && end[-1] != '\n')
+		vt100_next_line(s->output);
+}
+
+static void
+display_update(struct uline_state *s)
+{
+	struct pos edit_pos, end_diff;
+	struct pos base_pos = {};
+	struct linebuf *line = &s->line;
+
+	if (s->full_update) {
+		set_cursor(s, (struct pos){});
+		fputc(KEY_CR, s->output);
+		vt100_erase_down(s->output);
+	}
+
+	display_update_line(s, line, &base_pos);
+
+	if (s->line2) {
+		line = s->line2;
+
+		if (s->cursor_pos.x != 0) {
+			vt100_next_line(s->output);
+			pos_add_newline(s, &s->cursor_pos);
+		}
+
+		base_pos = s->cursor_pos;
+		display_update_line(s, s->line2, &base_pos);
+	}
+
+	edit_pos = base_pos;
+	pos_add_string(s, &edit_pos, line->buf, line->pos);
+
+	end_diff = pos_diff(s->end_pos, s->cursor_pos);
+	s->end_pos = s->cursor_pos;
+
+	if (end_diff.y != 0)
+		vt100_erase_down(s->output);
+	else
+		vt100_erase_right(s->output);
+
+	set_cursor(s, edit_pos);
+	fflush(s->output);
+
+	s->full_update = false;
+}
+
+static bool
+delete_symbol(struct uline_state *s, struct linebuf *line)
+{
+	size_t len = 1;
+
+	if (line->pos == line->len)
+		return false;
+
+	if (s->utf8) {
+		len = utf8_move_right(line->buf, line->pos, line->len);
+		len -= line->pos;
+	}
+
+	linebuf_delete(line, len);
+	return true;
+}
+
+static bool
+move_left(struct uline_state *s, struct linebuf *line)
+{
+	if (!line->pos)
+		return false;
+	if (s->utf8)
+		line->pos = utf8_move_left(line->buf, line->pos);
+	else
+		line->pos--;
+	return true;
+}
+
+static bool
+move_word_left(struct uline_state *s, struct linebuf *line)
+{
+	char *buf = line->buf;
+	size_t pos;
+
+	if (!move_left(s, line))
+		return false;
+
+	pos = line->pos;
+	// remove trailing spaces
+	while (pos > 0 && isspace(buf[pos]))
+		pos--;
+
+	// skip word
+	while (pos > 0 && !isspace(buf[pos]))
+		pos--;
+	if (isspace(buf[pos]))
+		pos++;
+
+	line->pos = pos;
+
+	return true;
+}
+
+static bool
+move_right(struct uline_state *s, struct linebuf *line)
+{
+	if (line->pos >= line->len)
+		return false;
+	if (s->utf8)
+		line->pos = utf8_move_right(line->buf, line->pos, line->len);
+	else
+		line->pos++;
+	return true;
+}
+
+static bool
+move_word_right(struct uline_state *s, struct linebuf *line)
+{
+	char *buf = line->buf;
+	size_t pos = line->pos;
+
+	if (pos == line->len)
+		return false;
+
+	// skip word
+	while (!isspace(buf[pos]) && pos < line->len)
+		pos++;
+
+	// skip trailing whitespace
+	while (isspace(buf[pos]) && pos < line->len)
+		pos++;
+
+	line->pos = pos;
+
+	return true;
+}
+
+static bool
+process_esc(struct uline_state *s, enum vt100_escape esc)
+{
+	struct linebuf *line = &s->line;
+
+	if (s->line2 &&
+	    (esc == VT100_DELETE ||
+	     (s->cb->line2_cursor && s->cb->line2_cursor(s))))
+		line = s->line2;
+
+	switch (esc) {
+	case VT100_CURSOR_LEFT:
+		return move_left(s, line);
+	case VT100_CURSOR_WORD_LEFT:
+		return move_word_left(s, line);
+	case VT100_CURSOR_RIGHT:
+		return move_right(s, line);
+	case VT100_CURSOR_WORD_RIGHT:
+		return move_word_right(s, line);
+	case VT100_HOME:
+		line->pos = 0;
+		return true;
+	case VT100_END:
+		line->pos = line->len;
+		return true;
+	case VT100_CURSOR_UP:
+		s->cb->event(s, EDITLINE_EV_CURSOR_UP);
+		return true;
+	case VT100_CURSOR_DOWN:
+		s->cb->event(s, EDITLINE_EV_CURSOR_DOWN);
+		return true;
+	case VT100_DELETE:
+		return delete_symbol(s, line);
+	default:
+		vt100_ding(s->output);
+		return false;
+	}
+}
+
+static bool
+process_backword(struct uline_state *s, struct linebuf *line)
+{
+	size_t pos, len;
+
+	pos = line->pos - 1;
+	if (!move_word_left(s, line))
+		return false;
+
+	len = pos + 1 - line->pos;
+	linebuf_delete(line, len);
+
+	return true;
+}
+
+static void
+linebuf_reset(struct linebuf *line)
+{
+	line->pos = 0;
+	line->len = 0;
+	line->buf[0] = 0;
+	line->update_pos = 0;
+}
+
+static void
+free_line2(struct uline_state *s)
+{
+	if (!s->line2)
+		return;
+
+	linebuf_free(s->line2);
+	free(s->line2);
+	s->line2 = NULL;
+}
+
+static bool
+process_newline(struct uline_state *s, bool drop)
+{
+	bool ret;
+
+	if (drop)
+		goto reset;
+
+	termios_set_orig_mode(s);
+	if (s->line2 && s->cb->line2_newline &&
+	    s->cb->line2_newline(s, s->line2->buf, s->line2->len)) {
+		termios_set_native_mode(s);
+		return true;
+	}
+
+	free_line2(s);
+	ret = s->cb->line(s, s->line.buf, s->line.len);
+	termios_set_native_mode(s);
+	if (!ret) {
+		linebuf_insert(&s->line, "\n", 1);
+		return true;
+	}
+
+reset:
+	vt100_next_line(s->output);
+	vt100_erase_down(s->output);
+	s->full_update = true;
+	fflush(s->output);
+	if (!s->line.len)
+		return true;
+
+	linebuf_reset(&s->line);
+
+	return true;
+}
+
+static bool
+process_ctrl(struct uline_state *s, char c)
+{
+	struct linebuf *line = s->line2 ? s->line2 : &s->line;
+
+	switch (c) {
+	case KEY_LF:
+	case KEY_CR:
+		return process_newline(s, false);
+	case KEY_ETX:
+		s->cb->event(s, EDITLINE_EV_INTERRUPT);
+		process_newline(s, true);
+		s->stop = true;
+		return true;
+	case KEY_EOT:
+		if (s->line.len)
+			return false;
+		s->cb->event(s, EDITLINE_EV_EOF);
+		s->stop = true;
+		return true;
+	case KEY_BS:
+	case KEY_DEL:
+		if (!move_left(s, line))
+			return false;
+
+		delete_symbol(s, line);
+		if (s->line2 && s->cb->line2_update)
+			s->cb->line2_update(s, line->buf, line->len);
+		return true;
+	case KEY_FF:
+		vt100_cursor_home(s->output);
+		vt100_erase_down(s->output);
+		s->full_update = true;
+		return true;
+	case KEY_NAK:
+		linebuf_reset(line);
+		return true;
+	case KEY_SOH:
+		return process_esc(s, VT100_HOME);
+	case KEY_ENQ:
+		return process_esc(s, VT100_END);
+	case KEY_VT:
+		// TODO: kill
+		return false;
+	case KEY_EM:
+		// TODO: yank
+		return false;
+	case KEY_ETB:
+		return process_backword(s, line);
+	case KEY_ESC:
+		s->esc_idx = 0;
+		return false;
+	case KEY_SUB:
+		kill(getpid(), SIGTSTP);
+		return false;
+	default:
+		return false;
+	}
+}
+
+static void
+check_key_repeat(struct uline_state *s, char c)
+{
+	if (s->repeat_char != c)
+		s->repeat_count = 0;
+
+	s->repeat_char = c;
+	s->repeat_count++;
+}
+
+static void
+process_char(struct uline_state *s, char c)
+{
+	enum vt100_escape esc;
+
+	check_key_repeat(s, c);
+	if (s->esc_idx >= 0) {
+		s->esc_seq[s->esc_idx++] = c;
+		s->esc_seq[s->esc_idx] = 0;
+		esc = vt100_esc_decode(s->esc_seq);
+		if (esc == VT100_INCOMPLETE &&
+		    s->esc_idx < (int)sizeof(s->esc_seq) - 1)
+			return;
+
+		s->esc_idx = -1;
+		if (!process_esc(s, esc))
+			return;
+	} else if (s->cb->key_input &&
+	           !check_utf8(s, (unsigned char )c) &&
+	           s->cb->key_input(s, c, s->repeat_count)) {
+		goto out;
+	} else if ((unsigned char)c < 32 || c == 127) {
+		if (!process_ctrl(s, c))
+			return;
+	} else {
+		struct linebuf *line = s->line2 ? s->line2 : &s->line;
+
+		if (!linebuf_insert(line, &c, 1) ||
+		    handle_utf8(s, (unsigned char )c))
+			return;
+
+		if (s->line2 && s->cb->line2_update)
+			s->cb->line2_update(s, line->buf, line->len);
+	}
+
+out:
+	if (s->stop)
+		return;
+
+	display_update(s);
+}
+
+void uline_poll(struct uline_state *s)
+{
+	int ret;
+	char c;
+
+	uline_refresh_prompt(s);
+	s->stop = false;
+	while (!s->stop) {
+		ret = read(s->input, &c, 1);
+		if (ret < 0) {
+			if (errno == EINTR)
+				continue;
+			if (errno == EAGAIN)
+				return;
+			ret = 0;
+		}
+
+		if (!ret) {
+			s->cb->event(s, EDITLINE_EV_EOF);
+			termios_set_orig_mode(s);
+			return;
+		}
+
+		process_char(s, c);
+	}
+}
+
+void uline_set_prompt(struct uline_state *s, const char *str)
+{
+	if (s->line.prompt && !strcmp(s->line.prompt, str))
+		return;
+
+	free(s->line.prompt);
+	s->line.prompt = strdup(str);
+	s->full_update = true;
+}
+
+void uline_set_line2_prompt(struct uline_state *s, const char *str)
+{
+	if (!!str != !!s->line2) {
+		if (!str)
+			free_line2(s);
+		else
+			s->line2 = calloc(1, sizeof(*s->line2));
+	}
+
+	if (!str || (s->line2->prompt && !strcmp(s->line2->prompt, str)))
+		return;
+
+	free(s->line2->prompt);
+	s->line2->prompt = strdup(str);
+	s->full_update = true;
+}
+
+static void
+__uline_set_line(struct uline_state *s, struct linebuf *line, const char *str, size_t len)
+{
+	size_t i, prev_len = line->len;
+
+	line->len = 0;
+		linebuf_extend(line, len);
+	for (i = 0; i < prev_len && i < len; i++) {
+		if (line->buf[i] != str[i])
+			break;
+	}
+	if (i > prev_len)
+		i--;
+	if (s->utf8) {
+		// move back to the beginning of the utf-8 symbol
+		while (i > 0 && (str[i] & 0xc0) == 0x80)
+			i--;
+	}
+	line->update_pos = i;
+
+	memcpy(line->buf, str, len);
+	line->len = len;
+	if (line->pos > line->len)
+		line->pos = line->len;
+}
+
+void uline_set_line(struct uline_state *s, const char *str, size_t len)
+{
+	__uline_set_line(s, &s->line, str, len);
+}
+
+void uline_set_line2(struct uline_state *s, const char *str, size_t len)
+{
+	if (!s->line2)
+		return;
+	__uline_set_line(s, s->line2, str, len);
+}
+
+void uline_hide_prompt(struct uline_state *s)
+{
+	set_cursor(s, (struct pos){});
+	vt100_erase_down(s->output);
+	s->full_update = true;
+	fflush(s->output);
+}
+
+void uline_refresh_prompt(struct uline_state *s)
+{
+	termios_set_native_mode(s);
+	display_update(s);
+}
+
+void uline_set_hint(struct uline_state *s, const char *str, size_t len)
+{
+	struct pos prev_pos = s->cursor_pos;
+
+	if (len) {
+		vt100_next_line(s->output);
+		pos_add_newline(s, &s->cursor_pos);
+	}
+	vt100_erase_down(s->output);
+
+	if (len) {
+		fwrite(str, len, 1, s->output);
+		pos_add_string(s, &s->cursor_pos, str, len);
+	}
+
+	set_cursor(s, prev_pos);
+	fflush(s->output);
+}
+
+void uline_init(struct uline_state *s, const struct uline_cb *cb,
+                int in_fd, FILE *out_stream, bool utf8)
+{
+	struct sigaction sa = {
+		.sa_handler = handle_sigwinch,
+	};
+	s->cb = cb;
+	s->utf8 = utf8;
+	s->input = in_fd;
+	s->output = out_stream;
+	update_window_size(s, true);
+	reset_input_state(s);
+
+#ifdef USE_SYSTEM_WCHAR
+	if (utf8)
+		setlocale(LC_CTYPE, "C.UTF-8");
+#endif
+
+	sigaction(SIGWINCH, &sa, NULL);
+	s->full_update = true;
+
+	if (!tcgetattr(s->input, &s->orig_termios)) {
+		s->has_termios = true;
+		termios_set_native_mode(s);
+	}
+}
+
+void uline_free(struct uline_state *s)
+{
+	free_line2(s);
+	termios_set_orig_mode(s);
+	linebuf_free(&s->line);
+}
diff --git a/package/utils/ucode-mod-uline/src/uline.h b/package/utils/ucode-mod-uline/src/uline.h
new file mode 100644
index 0000000000..6f7b75542f
--- /dev/null
+++ b/package/utils/ucode-mod-uline/src/uline.h
@@ -0,0 +1,151 @@
+// SPDX-License-Identifier: ISC
+/*
+ * Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+ */
+#ifndef __EDITLINE_H
+#define __EDITLINE_H
+
+#include <stdint.h>
+#include <stdbool.h>
+#include <termios.h>
+#include <stdio.h>
+
+#include <libubox/utils.h>
+
+struct uline_state;
+
+struct linebuf {
+	char *buf;
+	size_t len;
+	size_t bufsize;
+
+	char *prompt;
+	size_t pos;
+	size_t update_pos;
+};
+
+struct pos {
+	int16_t x;
+	int16_t y;
+};
+
+enum uline_event {
+	EDITLINE_EV_CURSOR_UP,
+	EDITLINE_EV_CURSOR_DOWN,
+
+	EDITLINE_EV_WINDOW_CHANGED,
+	EDITLINE_EV_LINE_INPUT,
+
+	EDITLINE_EV_INTERRUPT,
+	EDITLINE_EV_EOF,
+};
+
+struct uline_cb {
+	// called on every key input. return true if handled by callback
+	bool (*key_input)(struct uline_state *s, unsigned char c, unsigned int count);
+
+	void (*event)(struct uline_state *s, enum uline_event ev);
+
+	// line: called on newline, returns true to accept the line, false to keep
+	// editing a multi-line string
+	bool (*line)(struct uline_state *s, const char *str, size_t len);
+
+	// called on any changes to the buffer of the secondary line editor
+	void (*line2_update)(struct uline_state *s, const char *str, size_t len);
+
+	// called on cursor button press during line2 editing
+	// return true to handle in line2, false to handle in primary line
+	bool (*line2_cursor)(struct uline_state *s);
+
+	// called on newline on the secondary line editor
+	// return true to ignore, false to process as primary line newline event
+	bool (*line2_newline)(struct uline_state *s, const char *str, size_t len);
+};
+
+struct uline_state {
+	const struct uline_cb *cb;
+
+	int input;
+	FILE *output;
+
+	int sigwinch_count;
+
+	struct termios orig_termios;
+	bool has_termios;
+
+	struct linebuf line;
+	struct linebuf *line2;
+
+	unsigned int repeat_count;
+	char repeat_char;
+
+	unsigned int rows, cols;
+	struct pos cursor_pos;
+	struct pos end_pos;
+	bool full_update;
+	bool stop;
+
+	bool utf8;
+
+	char esc_seq[8];
+	int8_t esc_idx;
+	uint8_t utf8_cont;
+};
+
+void uline_init(struct uline_state *s, const struct uline_cb *cb,
+                int in_fd, FILE *out_stream, bool utf8);
+void uline_poll(struct uline_state *s);
+
+void uline_set_line(struct uline_state *s, const char *str, size_t len);
+void uline_set_prompt(struct uline_state *s, const char *str);
+static inline void
+uline_set_cursor(struct uline_state *s, size_t pos)
+{
+	s->line.pos = pos;
+	if (s->line.pos > s->line.len)
+		s->line.pos = s->line.len;
+}
+static inline void
+uline_get_line(struct uline_state *s, const char **str, size_t *len)
+{
+	if (s->line.buf) {
+		*str = s->line.buf;
+		*len = s->line.len;
+	} else{
+		*str = "";
+		*len = 0;
+	}
+}
+
+
+
+void uline_set_line2(struct uline_state *s, const char *str, size_t len);
+void uline_set_line2_prompt(struct uline_state *s, const char *str);
+static inline void
+uline_set_line2_cursor(struct uline_state *s, size_t pos)
+{
+	if (!s->line2)
+		return;
+
+	s->line2->pos = pos;
+	if (s->line2->pos > s->line2->len)
+		s->line2->pos = s->line2->len;
+}
+static inline void
+uline_get_line2(struct uline_state *s, const char **str, size_t *len)
+{
+	if (s->line2 && s->line2->buf) {
+		*str = s->line2->buf;
+		*len = s->line2->len;
+	} else{
+		*str = "";
+		*len = 0;
+	}
+}
+
+void uline_set_hint(struct uline_state *s, const char *str, size_t len);
+void uline_hide_prompt(struct uline_state *s);
+void uline_refresh_prompt(struct uline_state *s);
+void uline_free(struct uline_state *s);
+
+#endif
diff --git a/package/utils/ucode-mod-uline/src/utf8.c b/package/utils/ucode-mod-uline/src/utf8.c
new file mode 100644
index 0000000000..3f7c75e68e
--- /dev/null
+++ b/package/utils/ucode-mod-uline/src/utf8.c
@@ -0,0 +1,340 @@
+// SPDX-License-Identifier: ISC
+/*
+ * Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+ */
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <ctype.h>
+#include <errno.h>
+#include <unistd.h>
+#include <stdint.h>
+#include <wchar.h>
+
+#include "private.h"
+
+#ifndef USE_SYSTEM_WCHAR
+/*
+ * adapted from musl code:
+ *
+ * Copyright © 2005-2020 Rich Felker, et al.
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+#undef MB_CUR_MAX
+#define MB_CUR_MAX 4
+
+static const unsigned char table[] = {
+16,16,16,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,16,33,16,16,16,34,35,36,
+37,38,39,40,16,16,41,16,16,16,16,16,16,16,16,16,16,16,42,43,16,16,44,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,45,16,46,47,48,49,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,50,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,51,16,16,52,
+53,16,54,55,56,16,16,16,16,16,16,57,16,16,58,16,59,60,61,62,63,64,65,66,67,68,
+69,70,16,71,72,73,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,74,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,75,76,16,16,16,77,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,78,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,79,80,16,16,16,16,16,16,16,81,16,16,16,16,16,82,83,84,16,16,16,16,16,85,
+86,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,
+255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,248,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,254,255,255,255,255,191,182,0,0,0,0,0,0,0,63,0,255,23,0,0,0,0,0,248,255,
+255,0,0,1,0,0,0,0,0,0,0,0,0,0,0,192,191,159,61,0,0,0,128,2,0,0,0,255,255,255,
+7,0,0,0,0,0,0,0,0,0,0,192,255,1,0,0,0,0,0,0,248,15,32,0,0,192,251,239,62,0,0,
+0,0,0,14,0,0,0,0,0,0,0,0,0,0,0,0,0,0,248,255,255,255,255,
+255,7,0,0,0,0,0,0,20,254,33,254,0,12,0,0,0,2,0,0,0,0,0,0,16,30,32,0,0,12,0,0,
+64,6,0,0,0,0,0,0,16,134,57,2,0,0,0,35,0,6,0,0,0,0,0,0,16,190,33,0,0,12,0,0,
+252,2,0,0,0,0,0,0,144,30,32,64,0,12,0,0,0,4,0,0,0,0,0,0,0,1,32,0,0,0,0,0,0,17,
+0,0,0,0,0,0,192,193,61,96,0,12,0,0,0,2,0,0,0,0,0,0,144,64,48,0,0,12,0,0,0,3,0,
+0,0,0,0,0,24,30,32,0,0,12,0,0,0,0,0,0,0,0,0,0,0,0,4,92,0,0,0,0,0,0,0,0,0,0,0,
+242,7,128,127,0,0,0,0,0,0,0,0,0,0,0,0,242,31,0,63,0,0,0,0,0,0,0,0,0,3,0,0,160,
+2,0,0,0,0,0,0,254,127,223,224,255,254,255,255,255,31,64,0,0,0,0,0,0,0,0,0,0,0,
+0,224,253,102,0,0,0,195,1,0,30,0,100,32,0,32,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,224,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,28,0,0,0,28,0,0,0,12,0,0,0,12,0,0,0,0,0,0,0,176,63,64,254,
+15,32,0,0,0,0,0,120,0,0,0,0,0,0,0,0,0,0,0,0,0,0,96,0,0,0,0,2,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,135,1,4,14,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+128,9,0,0,0,0,0,0,64,127,229,31,248,159,0,0,0,0,0,0,255,127,0,0,0,0,0,0,0,0,
+15,0,0,0,0,0,208,23,4,0,0,0,0,248,15,0,3,0,0,0,60,59,0,0,0,0,0,0,64,163,3,0,0,
+0,0,0,0,240,207,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,247,255,253,33,16,
+3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,
+251,0,248,0,0,0,124,0,0,0,0,0,0,223,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,
+255,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,128,3,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,128,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,
+0,60,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,128,247,63,0,0,0,192,0,0,0,0,0,0,0,0,0,0,3,0,68,8,0,0,96,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,48,0,0,0,255,255,3,128,0,0,0,0,192,63,0,0,128,255,3,0,
+0,0,0,0,7,0,0,0,0,0,200,51,0,0,0,0,32,0,0,
+0,0,0,0,0,0,126,102,0,8,16,0,0,0,0,0,16,0,0,0,0,0,0,157,193,2,0,0,0,0,48,64,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,32,33,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,0,0,0,
+64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,0,0,255,
+255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,128,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,14,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,32,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,1,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,110,240,0,
+0,0,0,0,135,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,96,0,0,0,0,0,0,0,240,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,255,1,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,255,127,0,0,0,0,0,0,128,
+3,0,0,0,0,0,120,38,0,32,0,0,0,0,0,0,7,0,0,0,128,239,31,0,0,0,0,0,0,0,8,0,3,0,
+0,0,0,0,192,127,0,30,0,0,0,0,0,0,0,0,0,0,0,128,211,64,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,128,248,7,0,0,3,0,0,0,0,0,0,24,1,0,0,0,192,31,31,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,255,92,0,0,64,0,0,0,0,0,0,0,0,0,0,248,133,13,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,60,176,1,0,0,48,0,0,0,0,0,0,0,0,0,0,
+248,167,1,0,0,0,0,0,0,0,0,0,0,0,0,40,191,0,0,0,0,0,0,0,0,0,0,0,0,224,188,15,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,128,255,6,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,240,12,1,0,0,0,254,7,0,0,0,0,248,121,128,0,126,14,0,0,0,0,0,252,
+127,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,127,191,0,0,0,0,0,0,0,0,0,0,252,255,
+255,252,109,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,126,180,191,0,0,0,0,0,0,0,0,0,163,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24,0,0,0,0,0,0,0,255,
+1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,31,0,0,0,0,0,0,0,127,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,128,0,0,0,0,0,0,0,128,7,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,96,15,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,128,3,248,255,231,15,0,0,0,60,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,28,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,
+255,255,255,255,127,248,255,255,255,255,255,31,32,0,16,0,0,248,254,255,0,0,0,
+0,0,0,0,0,0,0,127,255,255,249,219,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,127,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,240,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,127,0,0,0,0,0,0,0,0,0,0,0,0,0,240,7,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,};
+
+static const unsigned char wtable[] = {
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,18,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,19,16,20,21,22,16,16,16,23,16,16,24,25,26,27,28,17,
+17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,29,
+17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,
+17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,
+17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,
+17,17,17,17,17,17,17,17,30,16,16,16,16,31,16,16,17,17,17,17,17,17,17,17,17,17,
+17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,
+17,17,17,17,17,17,17,32,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,17,17,16,16,16,33,
+34,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,35,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,
+17,17,17,17,17,17,36,17,17,37,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,17,38,39,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,
+16,16,16,16,16,16,16,40,41,42,43,44,45,46,47,16,48,49,16,16,16,16,
+16,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,
+255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,12,0,6,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,30,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,96,0,0,48,0,0,0,0,0,0,255,15,0,0,0,0,128,0,0,8,
+0,2,12,0,96,48,64,16,0,0,4,44,36,32,12,0,0,0,1,0,0,0,80,184,0,0,0,0,0,0,0,224,
+0,0,0,1,128,0,0,0,0,0,0,0,0,0,0,0,24,0,0,0,0,0,0,33,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,251,255,255,255,255,255,255,255,
+255,255,255,15,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,255,255,255,255,255,255,255,255,255,255,63,0,0,0,255,15,255,255,255,255,
+255,255,255,127,254,255,255,255,255,255,255,255,255,255,127,254,255,255,255,
+255,255,255,255,255,255,255,255,255,224,255,255,255,255,255,254,255,255,255,
+255,255,255,255,255,255,255,127,255,255,255,255,255,7,255,255,255,255,15,0,
+255,255,255,255,255,127,255,255,255,255,255,0,255,255,255,255,255,255,255,255,
+255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,
+0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,31,255,255,255,255,255,255,127,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,
+255,255,31,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,15,0,0,0,0,0,0,0,0,0,0,0,0,0,255,3,0,0,255,255,255,255,247,255,127,15,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,254,255,255,255,255,255,255,255,255,255,255,
+255,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,127,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,15,0,0,0,255,255,255,255,255,255,255,255,255,255,255,
+255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,255,255,255,255,255,255,255,255,255,255,255,7,0,255,255,255,127,0,0,0,0,0,
+0,7,0,240,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+15,16,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,128,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,0,64,254,7,0,0,0,0,0,0,0,0,0,0,0,0,7,0,255,255,255,
+255,255,15,255,1,3,0,63,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,
+1,224,191,255,255,255,255,255,255,255,255,223,255,255,15,0,255,255,255,255,
+255,135,15,0,255,255,17,255,255,255,255,255,255,255,255,127,253,255,255,255,
+255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
+159,255,255,255,255,255,255,255,63,0,120,255,255,255,0,0,4,0,0,96,0,16,0,0,0,
+0,0,0,0,0,0,0,248,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,255,255,
+255,255,255,255,255,255,63,16,39,0,0,24,240,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,0,0,0,0,0,0,0,0,0,0,0,255,15,0,
+0,0,224,255,255,255,255,255,255,255,255,255,255,255,255,123,252,255,255,255,
+255,231,199,255,255,255,231,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
+0,15,7,7,0,63,0,0,0,0,0,0,0,0,0,0,0,0,0,
+};
+
+/* Upper 6 state bits are a negative integer offset to bound-check next byte */
+/*    equivalent to: ( (b-0x80) | (b+offset) ) & ~0x3f      */
+#define OOB(c,b) (((((b)>>3)-0x10)|(((b)>>3)+((int32_t)(c)>>26))) & ~7)
+
+/* Interval [a,b). Either a must be 80 or b must be c0, lower 3 bits clear. */
+#define R(a,b) ((uint32_t)((a==0x80 ? 0x40u-b : 0u-a) << 23))
+#define FAILSTATE R(0x80,0x80)
+
+#define SA 0xc2u
+#define SB 0xf4u
+
+/* Arbitrary encoding for representing code units instead of characters. */
+#define CODEUNIT(c) (0xdfff & (signed char)(c))
+#define IS_CODEUNIT(c) ((unsigned)(c)-0xdf80 < 0x80)
+
+static int
+internal_mbtowc(wchar_t *restrict wc, const char *restrict src, size_t n)
+{
+#define C(x) ( x<2 ? -1 : ( R(0x80,0xc0) | x ) )
+#define D(x) C((x+16))
+#define E(x) ( ( x==0 ? R(0xa0,0xc0) : \
+                 x==0xd ? R(0x80,0xa0) : \
+                 R(0x80,0xc0) ) \
+             | ( R(0x80,0xc0) >> 6 ) \
+             | x )
+#define F(x) ( ( x>=5 ? 0 : \
+                 x==0 ? R(0x90,0xc0) : \
+                 x==4 ? R(0x80,0x90) : \
+                 R(0x80,0xc0) ) \
+             | ( R(0x80,0xc0) >> 6 ) \
+             | ( R(0x80,0xc0) >> 12 ) \
+             | x )
+
+	static const uint32_t bittab[] = {
+					  C(0x2),C(0x3),C(0x4),C(0x5),C(0x6),C(0x7),
+		C(0x8),C(0x9),C(0xa),C(0xb),C(0xc),C(0xd),C(0xe),C(0xf),
+		D(0x0),D(0x1),D(0x2),D(0x3),D(0x4),D(0x5),D(0x6),D(0x7),
+		D(0x8),D(0x9),D(0xa),D(0xb),D(0xc),D(0xd),D(0xe),D(0xf),
+		E(0x0),E(0x1),E(0x2),E(0x3),E(0x4),E(0x5),E(0x6),E(0x7),
+		E(0x8),E(0x9),E(0xa),E(0xb),E(0xc),E(0xd),E(0xe),E(0xf),
+		F(0x0),F(0x1),F(0x2),F(0x3),F(0x4)
+	};
+	unsigned c;
+	const unsigned char *s = (const void *)src;
+	wchar_t dummy;
+
+	if (!s) return 0;
+	if (!n) goto ilseq;
+	if (!wc) wc = &dummy;
+
+	if (*s < 0x80) return !!(*wc = *s);
+	if (MB_CUR_MAX==1) return (*wc = CODEUNIT(*s)), 1;
+	if (*s-SA > SB-SA) goto ilseq;
+	c = bittab[*s++-SA];
+
+	/* Avoid excessive checks against n: If shifting the state n-1
+	 * times does not clear the high bit, then the value of n is
+	 * insufficient to read a character */
+	if (n<4 && ((c<<(6*n-6)) & (1U<<31))) goto ilseq;
+
+	if (OOB(c,*s)) goto ilseq;
+	c = c<<6 | *s++-0x80;
+	if (!(c&(1U<<31))) {
+		*wc = c;
+		return 2;
+	}
+
+	if (*s-0x80u >= 0x40) goto ilseq;
+	c = c<<6 | *s++-0x80;
+	if (!(c&(1U<<31))) {
+		*wc = c;
+		return 3;
+	}
+
+	if (*s-0x80u >= 0x40) goto ilseq;
+	*wc = c<<6 | *s++-0x80;
+	return 4;
+
+ilseq:
+	errno = EILSEQ;
+	return -1;
+}
+
+static int internal_wcwidth(wchar_t wc)
+{
+	if (wc < 0xff)
+		return (wc+1 & 0x7f) >= 0x21 ? 1 : wc ? -1 : 0;
+	if ((wc & 0xfffeffffU) < 0xfffe) {
+		if ((table[table[wc>>8]*32+((wc&255)>>3)]>>(wc&7))&1)
+			return 0;
+		if ((wtable[wtable[wc>>8]*32+((wc&255)>>3)]>>(wc&7))&1)
+			return 2;
+		return 1;
+	}
+	if ((wc & 0xfffe) == 0xfffe)
+		return -1;
+	if (wc-0x20000U < 0x20000)
+		return 2;
+	if (wc == 0xe0001 || wc-0xe0020U < 0x5f || wc-0xe0100U < 0xef)
+		return 0;
+	return 1;
+}
+
+#define mbtowc internal_mbtowc
+#define wcwidth internal_wcwidth
+
+#endif
+
+ssize_t utf8_nsyms(const char *str, size_t len)
+{
+	size_t nsyms = 0;
+	size_t ofs = 0;
+
+	while (ofs < len) {
+		wchar_t sym;
+		int ret;
+
+		ret = mbtowc(&sym, str + ofs, len - ofs);
+		if (ret <= 0) {
+			ret = 1;
+			sym = 'A';
+		} else if ((size_t)ret > len) {
+			ret = len;
+		}
+
+		ofs += ret;
+		ret = wcwidth(sym);
+		if (ret < 0)
+			continue;
+
+		nsyms += ret;
+	}
+
+	return nsyms;
+}
diff --git a/package/utils/ucode-mod-uline/src/vt100.c b/package/utils/ucode-mod-uline/src/vt100.c
new file mode 100644
index 0000000000..522dd7f564
--- /dev/null
+++ b/package/utils/ucode-mod-uline/src/vt100.c
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: ISC
+/*
+ * Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+ */
+#include <string.h>
+#include <stdlib.h>
+#include "uline.h"
+#include "private.h"
+
+enum vt100_escape vt100_esc_decode(const char *str)
+{
+	unsigned long code;
+	size_t idx;
+
+	switch (*(str++)) {
+	case 0:
+		return VT100_INCOMPLETE;
+	case '[':
+	case 'O':
+		switch (*(str++)) {
+		case 0:
+			return VT100_INCOMPLETE;
+		case 'A':
+			return VT100_CURSOR_UP;
+		case 'B':
+			return VT100_CURSOR_DOWN;
+		case 'C':
+			return VT100_CURSOR_RIGHT;
+		case 'D':
+			return VT100_CURSOR_LEFT;
+		case 'H':
+			return VT100_HOME;
+		case '5':
+			switch (*str) {
+			case 'C':
+				return VT100_CURSOR_WORD_RIGHT;
+			case 'D':
+				return VT100_CURSOR_WORD_LEFT;
+			default:
+				break;
+			}
+			/* fallthrough */
+		case '0' ... '4':
+		case '6' ... '9':
+			str--;
+			idx = strspn(str, "0123456789");
+			if (!str[idx])
+				return VT100_INCOMPLETE;
+			if (str[idx] != '~')
+				return VT100_UNKNOWN;
+			code = strtoul(str, NULL, 10);
+			switch (code) {
+			case 1:
+				return VT100_HOME;
+			case 3:
+				return VT100_DELETE;
+			case 4:
+				return VT100_END;
+			case 200:
+			case 201:
+				// paste start/end
+				return VT100_IGNORE;
+			default:
+				return VT100_UNKNOWN;
+			}
+		default:
+			return VT100_UNKNOWN;
+		}
+	default:
+		return VT100_UNKNOWN;
+	}
+}
+
+void __vt100_csi_num(FILE *out, int num, char code)
+{
+	fprintf(out, "\e[%d%c", num, code);
+}
+
+void __vt100_esc(FILE *out, char c)
+{
+	char seq[] = "\eX";
+	seq[1] = c;
+	fputs(seq, out);
+}
+
+void __vt100_csi2(FILE *out, char c1, char c2)
+{
+	char seq[] = "\e[XX";
+
+	seq[2] = c1;
+	seq[3] = c2;
+	fputs(seq, out);
+}
-- 
2.30.2