[Concept,19/19] cli: Add multi-level undo/redo support

Message ID 20260130035849.3580212-20-simon.glass@canonical.com
State New
Headers
Series Enhanced command-line editing with undo/redo support |

Commit Message

Simon Glass Jan. 30, 2026, 3:58 a.m. UTC
  Convert the single-level undo buffer to a ring buffer supporting multiple
undo/redo levels. This allows users to undo multiple editing operations
and redo them if needed.

Key changes:
- Replace single undo state with ring buffer (struct cli_undo_state)
- Add redo ring buffer for undone states
- Add Ctrl+Shift+Z (via Ctrl+G) for redo operation
- Track undo/redo counts separately from buffer allocation
- Clear redo history on new edits (standard editor behaviour)

The number of undo levels is configurable via CONFIG_CMDLINE_UNDO_COUNT
(default 64). Each level stores a complete copy of the edit buffer plus
cursor position.

Also fix scene_txtin_open() which was allocating the yank buffer twice -
once in cli_cread_init_undo() and again directly. Remove the duplicate.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Simon Glass <simon.glass@canonical.com>

---

 boot/scene_txtin.c    |  36 +++++++++++-
 cmd/Kconfig           |  15 +++++
 common/cli_getch.c    |  19 +++++-
 common/cli_readline.c |  40 ++++++++++---
 common/cli_undo.c     | 133 ++++++++++++++++++++++++++++++++++++++----
 doc/develop/expo.rst  |  14 +++++
 doc/usage/cmdline.rst |  68 +++++++++++++++++++++
 include/cli.h         |  86 ++++++++++++++++++++++++---
 test/boot/editenv.c   |  62 ++++++++++++++++++--
 test/boot/expo.c      |  12 +++-
 10 files changed, 446 insertions(+), 39 deletions(-)
  

Patch

diff --git a/boot/scene_txtin.c b/boot/scene_txtin.c
index ab4fd5056a0..101616acc07 100644
--- a/boot/scene_txtin.c
+++ b/boot/scene_txtin.c
@@ -11,6 +11,7 @@ 
 #include <cli.h>
 #include <expo.h>
 #include <log.h>
+#include <malloc.h>
 #include <menu.h>
 #include <video_console.h>
 #include <linux/errno.h>
@@ -18,6 +19,12 @@ 
 #include <linux/string.h>
 #include "scene_internal.h"
 
+#ifdef CONFIG_CMDLINE_UNDO_COUNT
+#define UNDO_COUNT	CONFIG_CMDLINE_UNDO_COUNT
+#else
+#define UNDO_COUNT	64
+#endif
+
 int scene_txtin_init(struct scene_txtin *tin, uint size, uint line_chars)
 {
 	char *buf;
@@ -161,6 +168,10 @@  static void scene_txtin_putch(struct cli_line_state *cls, int ch)
 
 void scene_txtin_close(struct scene *scn, struct scene_txtin *tin)
 {
+	struct cli_line_state *cls = &tin->cls;
+
+	cli_cread_uninit(cls);
+
 	/* cursor is not needed now */
 	vidconsole_readline_end(scn->expo->cons, tin->ctx);
 }
@@ -265,7 +276,7 @@  int scene_txtin_open(struct scene *scn, struct scene_obj *obj,
 	struct udevice *cons = scn->expo->cons;
 	struct scene_obj_txt *txt;
 	void *ctx;
-	int ret;
+	int ret, i;
 
 	ctx = tin->ctx;
 	if (!ctx) {
@@ -286,10 +297,31 @@  int scene_txtin_open(struct scene *scn, struct scene_obj *obj,
 
 	vidconsole_set_cursor_pos(cons, ctx, txt->obj.bbox.x0, txt->obj.bbox.y0);
 	vidconsole_entry_start(cons, ctx);
-	cli_cread_init(cls, abuf_data(&tin->buf), abuf_size(&tin->buf));
+	cli_cread_init_undo(cls, abuf_data(&tin->buf), abuf_size(&tin->buf));
 	cls->insert = true;
 	ed->putch = scene_txtin_putch;
 	cls->priv = scn;
+
+	/* Initialise undo ring buffer */
+	alist_init_struct(&ed->undo.pos, struct cli_undo_pos);
+	for (i = 0; i < UNDO_COUNT; i++) {
+		struct cli_undo_pos *pos;
+
+		pos = alist_ensure(&ed->undo.pos, i, struct cli_undo_pos);
+		abuf_init_size(&pos->buf, abuf_size(&tin->buf));
+	}
+
+	/* Initialise redo ring buffer */
+	alist_init_struct(&ed->redo.pos, struct cli_undo_pos);
+	for (i = 0; i < UNDO_COUNT; i++) {
+		struct cli_undo_pos *pos;
+
+		pos = alist_ensure(&ed->redo.pos, i, struct cli_undo_pos);
+		abuf_init_size(&pos->buf, abuf_size(&tin->buf));
+	}
+
+	/* yank buffer is initialised by cli_cread_init_undo() above */
+
 	if (obj->type == SCENEOBJT_TEXTEDIT) {
 		ed->multiline = true;
 		ed->line_nav = scene_txtin_line_nav;
diff --git a/cmd/Kconfig b/cmd/Kconfig
index 9d96c12fd86..03e8014786b 100644
--- a/cmd/Kconfig
+++ b/cmd/Kconfig
@@ -66,6 +66,8 @@  config CMDLINE_EDITOR
 	  - Undo/redo support (Ctrl+Z / Ctrl+Shift+Z)
 	  - Yank/paste of killed text (Ctrl+Y)
 
+	  This uses additional memory to store the undo, redo, and yank buffers.
+
 config CMDLINE_UNDO
 	bool "Support undo in command-line editing"
 	depends on CMDLINE_EDITOR
@@ -75,6 +77,19 @@  config CMDLINE_UNDO
 	  pressing Ctrl+Z restores the previous state of the edit buffer.
 	  This uses additional memory to store the undo state.
 
+config CMDLINE_UNDO_COUNT
+	int "Number of undo levels"
+	depends on CMDLINE_UNDO
+	default 64
+	range 1 64
+	help
+	  Number of undo/redo levels to support. Each level requires memory
+	  to store a copy of the edit buffer. With multiple levels,
+	  pressing Ctrl+Z repeatedly undoes progressively older changes, and
+	  Ctrl+Shift+Z redoes them. Set to 1 for single-level undo/redo, or
+	  higher for multi-level. Note that any new edit clears the redo
+	  history.
+
 config CMDLINE_PS_SUPPORT
 	bool "Enable support for changing the command prompt string at run-time"
 	depends on HUSH_PARSER
diff --git a/common/cli_getch.c b/common/cli_getch.c
index 8df1997911a..810128a9fca 100644
--- a/common/cli_getch.c
+++ b/common/cli_getch.c
@@ -134,14 +134,20 @@  static int cli_ch_esc(struct cli_ch_state *cch, int ichar,
 			    cch->esc_save[3] == ';')
 				act = ESC_SAVE;
 			break;
+		case '6':
+			/* Ctrl+Shift+key: ESC [ 1 ; 6 */
+			if (CONFIG_IS_ENABLED(CMDLINE_EDITOR) &&
+			    cch->esc_save[3] == ';')
+				act = ESC_SAVE;
+			break;
 		}
 		break;
 	case 5:
 		if (ichar == '~') {	/* bracketed paste */
 			ichar = 0;
 			act = ESC_CONVERTED;
-		} else if (CONFIG_IS_ENABLED(CMDLINE_EDITOR) &&
-			   cch->esc_save[4] == '5') {
+		}
+		if (CONFIG_IS_ENABLED(CMDLINE_EDITOR) && cch->esc_save[4] == '5') {
 			/* Ctrl+arrow: ESC [ 1 ; 5 D/C */
 			switch (ichar) {
 			case 'D':	/* Ctrl+<- key */
@@ -154,6 +160,15 @@  static int cli_ch_esc(struct cli_ch_state *cch, int ichar,
 				break;	/* pass to forward-word handler */
 			}
 		}
+		if (CONFIG_IS_ENABLED(CMDLINE_EDITOR) && cch->esc_save[4] == '6') {
+			/* Ctrl+Shift+key: ESC [ 1 ; 6 x */
+			switch (ichar) {
+			case 'z':	/* Ctrl+Shift+Z: redo */
+				ichar = CTL_CH('g');
+				act = ESC_CONVERTED;
+				break;
+			}
+		}
 	}
 
 	*actp = act;
diff --git a/common/cli_readline.c b/common/cli_readline.c
index d554b7241c6..fac5080cc07 100644
--- a/common/cli_readline.c
+++ b/common/cli_readline.c
@@ -113,6 +113,14 @@  static char hist_data[HIST_MAX][HIST_SIZE + 1];
 #endif
 static char *hist_list[HIST_MAX];
 
+#if CONFIG_IS_ENABLED(CMDLINE_EDITOR)
+#ifdef CONFIG_CMDLINE_UNDO_COUNT
+#define UNDO_COUNT	CONFIG_CMDLINE_UNDO_COUNT
+#else
+#define UNDO_COUNT	64
+#endif
+#endif
+
 #define add_idx_minus_one() ((hist_add_idx == 0) ? hist_max : hist_add_idx-1)
 
 /**
@@ -358,7 +366,7 @@  static void cread_end_of_line(struct cli_line_state *cls)
 #define REFRESH_TO_EOL() GOTO_LINE_END(cls->eol_num)
 #endif
 
-/* undo/yank functions are in cli_undo.c when CMDLINE_UNDO is enabled */
+/* undo/redo/yank functions are in cli_undo.c when CMDLINE_EDITOR is enabled */
 
 static void cread_add_char(struct cli_line_state *cls, char ichar, int insert,
 			   uint *num, uint *eol_num, char *buf, uint len)
@@ -374,6 +382,9 @@  static void cread_add_char(struct cli_line_state *cls, char ichar, int insert,
 		(*eol_num)++;
 	}
 
+	/* new edit invalidates redo history */
+	cread_clear_redo(cls);
+
 	if (insert) {
 		wlen = *eol_num - *num;
 		if (wlen > 1)
@@ -472,6 +483,7 @@  int cread_line_process_ch(struct cli_line_state *cls, char ichar)
 		if (cls->num < cls->eol_num) {
 			uint wlen;
 
+			cread_save_undo(cls);
 			wlen = cls->eol_num - cls->num - 1;
 			if (wlen) {
 				memmove(&buf[cls->num], &buf[cls->num + 1],
@@ -511,6 +523,7 @@  int cread_line_process_ch(struct cli_line_state *cls, char ichar)
 		if (cls->num) {
 			uint base, wlen;
 
+			cread_save_undo(cls);
 			for (base = cls->num - 1;
 			     base >= 0 && buf[base] == ' ';)
 				base--;
@@ -519,7 +532,6 @@  int cread_line_process_ch(struct cli_line_state *cls, char ichar)
 
 			/* now delete chars from base to cls->num */
 			wlen = cls->num - base;
-			cread_save_undo(cls);
 			cread_save_yank(cls, &buf[base], wlen);
 			cls->eol_num -= wlen;
 			memmove(&buf[base], &buf[cls->num],
@@ -541,13 +553,18 @@  int cread_line_process_ch(struct cli_line_state *cls, char ichar)
 		}
 		break;
 	case CTL_CH('y'):
-#if CONFIG_IS_ENABLED(CMDLINE_UNDO)
+#if CONFIG_IS_ENABLED(CMDLINE_EDITOR)
 		cread_yank(cls);
 #endif
 		break;
 	case CTL_CH('z'):
 		cread_restore_undo(cls);
 		break;
+	case CTL_CH('g'):
+#if CONFIG_IS_ENABLED(CMDLINE_EDITOR)
+		cread_redo(cls);
+#endif
+		break;
 	case CTL_CH('u'):
 		cread_save_undo(cls);
 		cread_save_yank(cls, buf, cls->eol_num);
@@ -560,6 +577,7 @@  int cread_line_process_ch(struct cli_line_state *cls, char ichar)
 		if (cls->num) {
 			uint wlen;
 
+			cread_save_undo(cls);
 			wlen = cls->eol_num - cls->num;
 			cls->num--;
 			memmove(&buf[cls->num], &buf[cls->num + 1], wlen);
@@ -605,6 +623,8 @@  int cread_line_process_ch(struct cli_line_state *cls, char ichar)
 				break;
 			}
 
+			cread_save_undo(cls);
+
 			/* nuke the current line */
 			/* first, go home */
 			BEGINNING_OF_LINE();
@@ -632,6 +652,7 @@  int cread_line_process_ch(struct cli_line_state *cls, char ichar)
 			buf[cls->num] = '\0';
 			col = strlen(cls->prompt) + cls->eol_num;
 			num2 = cls->num;
+			cread_save_undo(cls);
 			if (cmd_auto_complete(cls->prompt, buf, &num2, &col)) {
 				col = num2 - cls->num;
 				cls->num += col;
@@ -669,17 +690,22 @@  void cli_cread_init_undo(struct cli_line_state *cls, char *buf, uint buf_size)
 	if (CONFIG_IS_ENABLED(CMDLINE_UNDO)) {
 		struct cli_editor_state *ed = cli_editor(cls);
 
-		abuf_init_size(&ed->undo.buf, buf_size);
 		abuf_init_size(&ed->yank, buf_size);
 	}
 }
 
 void cli_cread_uninit(struct cli_line_state *cls)
 {
-	if (CONFIG_IS_ENABLED(CMDLINE_UNDO)) {
+	if (CONFIG_IS_ENABLED(CMDLINE_EDITOR)) {
 		struct cli_editor_state *ed = cli_editor(cls);
-
-		abuf_uninit(&ed->undo.buf);
+		struct cli_undo_pos *pos;
+
+		alist_for_each(pos, &ed->undo.pos)
+			abuf_uninit(&pos->buf);
+		alist_uninit(&ed->undo.pos);
+		alist_for_each(pos, &ed->redo.pos)
+			abuf_uninit(&pos->buf);
+		alist_uninit(&ed->redo.pos);
 		abuf_uninit(&ed->yank);
 	}
 }
diff --git a/common/cli_undo.c b/common/cli_undo.c
index 4aa9a719ebf..43858d4636d 100644
--- a/common/cli_undo.c
+++ b/common/cli_undo.c
@@ -1,6 +1,6 @@ 
 // SPDX-License-Identifier: GPL-2.0+
 /*
- * CLI undo/yank support
+ * CLI undo/redo/yank support
  *
  * Copyright 2025 Google LLC
  * Written by Simon Glass <sjg@chromium.org>
@@ -55,26 +55,134 @@  static void cls_putchars(struct cli_line_state *cls, int count, int ch)
 
 #define getcmd_cbeep(cls)	cls_putch(cls, '\a')
 
+void cread_save_redo(struct cli_line_state *cls)
+{
+	struct cli_editor_state *ed = cli_editor(cls);
+	struct cli_undo_state *redo = &ed->redo;
+	struct cli_undo_pos *pos;
+	uint idx;
+
+	if (!redo->pos.alloc)
+		return;
+
+	/* save at current head position */
+	idx = redo->head;
+	pos = alist_getw(&redo->pos, idx, struct cli_undo_pos);
+	memcpy(abuf_data(&pos->buf), cls->buf, cls->len);
+	pos->num = cls->num;
+	pos->eol_num = cls->eol_num;
+
+	/* advance head (ring buffer) */
+	redo->head = (redo->head + 1) % redo->pos.alloc;
+
+	/* track how many redo levels are available */
+	if (redo->count < redo->pos.alloc)
+		redo->count++;
+}
+
+void cread_clear_redo(struct cli_line_state *cls)
+{
+	struct cli_editor_state *ed = cli_editor(cls);
+
+	ed->redo.count = 0;
+	ed->redo.head = 0;
+}
+
 void cread_save_undo(struct cli_line_state *cls)
 {
 	struct cli_editor_state *ed = cli_editor(cls);
 	struct cli_undo_state *undo = &ed->undo;
+	struct cli_undo_pos *pos;
+	uint idx;
 
-	if (abuf_size(&undo->buf)) {
-		memcpy(abuf_data(&undo->buf), cls->buf, cls->len);
-		undo->num = cls->num;
-		undo->eol_num = cls->eol_num;
-	}
+	if (!undo->pos.alloc)
+		return;
+
+	/* save at current head position */
+	idx = undo->head;
+	pos = alist_getw(&undo->pos, idx, struct cli_undo_pos);
+	memcpy(abuf_data(&pos->buf), cls->buf, cls->len);
+	pos->num = cls->num;
+	pos->eol_num = cls->eol_num;
+
+	/* advance head (ring buffer) */
+	undo->head = (undo->head + 1) % undo->pos.alloc;
+
+	/* track how many undo levels are available */
+	if (undo->count < undo->pos.alloc)
+		undo->count++;
+
+	/* new edit invalidates redo history */
+	cread_clear_redo(cls);
 }
 
 void cread_restore_undo(struct cli_line_state *cls)
+{
+	struct cli_undo_state *undo = &cli_editor(cls)->undo;
+	const struct cli_undo_pos *pos;
+	uint idx;
+
+	if (!undo->pos.alloc || !undo->count)
+		return;
+
+	/* save current state to redo buffer before restoring */
+	cread_save_redo(cls);
+
+	/* move back to previous undo state */
+	undo->head = undo->head ? undo->head - 1 : undo->pos.alloc - 1;
+	undo->count--;
+	idx = undo->head;
+
+	/* go to start of line */
+	while (cls->num) {
+		cls_putch(cls, CTL_BACKSPACE);
+		cls->num--;
+	}
+
+	/* erase current content on screen */
+	cls_putchars(cls, cls->eol_num, ' ');
+	cls_putchars(cls, cls->eol_num, CTL_BACKSPACE);
+
+	/* restore from undo buffer */
+	pos = alist_get(&undo->pos, idx, struct cli_undo_pos);
+	memcpy(cls->buf, abuf_data(&pos->buf), cls->len);
+	cls->eol_num = pos->eol_num;
+
+	/* display restored content */
+	cls_putnstr(cls, cls->buf, cls->eol_num);
+
+	/* position cursor */
+	cls_putchars(cls, cls->eol_num - pos->num, CTL_BACKSPACE);
+	cls->num = pos->num;
+}
+
+void cread_redo(struct cli_line_state *cls)
 {
 	struct cli_editor_state *ed = cli_editor(cls);
 	struct cli_undo_state *undo = &ed->undo;
+	struct cli_undo_state *redo = &ed->redo;
+	struct cli_undo_pos *pos;
+	const struct cli_undo_pos *rpos;
+	uint idx;
 
-	if (!abuf_size(&undo->buf))
+	if (!redo->pos.alloc || !redo->count)
 		return;
 
+	/* save current state to undo buffer */
+	idx = undo->head;
+	pos = alist_getw(&undo->pos, idx, struct cli_undo_pos);
+	memcpy(abuf_data(&pos->buf), cls->buf, cls->len);
+	pos->num = cls->num;
+	pos->eol_num = cls->eol_num;
+	undo->head = (undo->head + 1) % undo->pos.alloc;
+	if (undo->count < undo->pos.alloc)
+		undo->count++;
+
+	/* move back to previous redo state */
+	redo->head = redo->head ? redo->head - 1 : redo->pos.alloc - 1;
+	redo->count--;
+	idx = redo->head;
+
 	/* go to start of line */
 	while (cls->num) {
 		cls_putch(cls, CTL_BACKSPACE);
@@ -85,16 +193,17 @@  void cread_restore_undo(struct cli_line_state *cls)
 	cls_putchars(cls, cls->eol_num, ' ');
 	cls_putchars(cls, cls->eol_num, CTL_BACKSPACE);
 
-	/* restore from undo buffer */
-	memcpy(cls->buf, abuf_data(&undo->buf), cls->len);
-	cls->eol_num = undo->eol_num;
+	/* restore from redo buffer */
+	rpos = alist_get(&redo->pos, idx, struct cli_undo_pos);
+	memcpy(cls->buf, abuf_data(&rpos->buf), cls->len);
+	cls->eol_num = rpos->eol_num;
 
 	/* display restored content */
 	cls_putnstr(cls, cls->buf, cls->eol_num);
 
 	/* position cursor */
-	cls_putchars(cls, cls->eol_num - undo->num, CTL_BACKSPACE);
-	cls->num = undo->num;
+	cls_putchars(cls, cls->eol_num - rpos->num, CTL_BACKSPACE);
+	cls->num = rpos->num;
 }
 
 void cread_save_yank(struct cli_line_state *cls, const char *text, uint len)
diff --git a/doc/develop/expo.rst b/doc/develop/expo.rst
index 71e227c532d..046b464d9f2 100644
--- a/doc/develop/expo.rst
+++ b/doc/develop/expo.rst
@@ -366,6 +366,9 @@  type
     "textline"
         A line of text which can be edited
 
+    "textedit"
+        A multi-line text editor
+
     "box"
         A rectangle with a given line width (not filled)
 
@@ -446,6 +449,17 @@  max-chars:
     Specifies the maximum number of characters permitted to be in the textline.
     The user will be prevented from adding more.
 
+Textedit nodes have the same properties as textline nodes, with the following
+differences:
+
+- The editor supports multiple lines of text
+- Pressing Enter inserts a newline instead of closing the editor
+- Home/End move to start/end of the current line
+- Ctrl+K kills to end of the current line (not entire buffer)
+- Up/Down (Ctrl+P/N) navigate between lines
+
+See :doc:`../usage/cmdline` for a full list of editing keys.
+
 Box nodes have the following additional properties:
 
 width
diff --git a/doc/usage/cmdline.rst b/doc/usage/cmdline.rst
index 58240c5279c..e1525b12884 100644
--- a/doc/usage/cmdline.rst
+++ b/doc/usage/cmdline.rst
@@ -91,3 +91,71 @@  convenient::
 
   => i2c speed 0x30000
   Setting bus speed to 196608 Hz
+
+Command-line editing
+--------------------
+
+U-Boot supports command-line editing when `CONFIG_CMDLINE_EDITING` is enabled.
+This provides an Emacs-like interface for editing commands before they are
+executed. The following key bindings are available:
+
+Cursor movement
+~~~~~~~~~~~~~~~
+
+- **Left arrow** or **Ctrl+B**: Move cursor left one character
+- **Right arrow** or **Ctrl+F**: Move cursor right one character
+- **Ctrl+Left** or **Alt+B**: Move cursor left one word
+- **Ctrl+Right** or **Alt+F**: Move cursor right one word
+- **Home** or **Ctrl+A**: Move to beginning of line
+- **End** or **Ctrl+E**: Move to end of line
+
+Character deletion
+~~~~~~~~~~~~~~~~~~
+
+- **Backspace** or **Ctrl+H**: Delete character before cursor
+- **Delete** or **Ctrl+D**: Delete character at cursor
+- **Ctrl+K**: Kill (delete) from cursor to end of line
+- **Ctrl+W**: Kill word before cursor
+- **Ctrl+U**: Kill entire line
+- **Ctrl+X**: Kill entire line (same as Ctrl+U)
+
+History
+~~~~~~~
+
+- **Up arrow** or **Ctrl+P**: Previous command in history
+- **Down arrow** or **Ctrl+N**: Next command in history
+
+Undo, redo, and yank
+~~~~~~~~~~~~~~~~~~~~
+
+When `CONFIG_CMDLINE_UNDO` is enabled, the following features are available:
+
+- **Ctrl+Z**: Undo the last edit operation
+- **Ctrl+Shift+Z**: Redo the last undone operation
+- **Ctrl+Y**: Yank (paste) previously killed text
+
+Text killed by Ctrl+K, Ctrl+W, Ctrl+U, or Ctrl+X is saved to a yank buffer
+and can be pasted with Ctrl+Y.
+
+The number of undo/redo levels can be configured with `CONFIG_CMDLINE_UNDO_COUNT`
+(default 1, maximum 64). Each level saves the complete buffer state,
+so higher values use more memory. Note that any new edit clears the redo
+history.
+
+Other
+~~~~~
+
+- **Tab**: Command and argument completion (if `CONFIG_AUTO_COMPLETE` is enabled)
+- **Ctrl+C**: Cancel current input
+- **Enter**: Execute command
+
+Multiline editing
+~~~~~~~~~~~~~~~~~
+
+In multiline mode (used by expo text editors), some keys have modified
+behaviour:
+
+- **Home/End**: Move to start/end of current line (not entire buffer)
+- **Ctrl+K**: Kill to end of current line (not entire buffer)
+- **Ctrl+P/N** or **Up/Down**: Navigate between lines
+- **Enter**: Insert newline (instead of executing)
diff --git a/include/cli.h b/include/cli.h
index 3040342de8e..1b23caaa078 100644
--- a/include/cli.h
+++ b/include/cli.h
@@ -8,6 +8,7 @@ 
 #define __CLI_H
 
 #include <abuf.h>
+#include <alist.h>
 #include <stdbool.h>
 #include <linux/types.h>
 
@@ -29,18 +30,44 @@  struct cli_ch_state {
 struct cli_line_state;
 
 /**
- * struct cli_undo_state - state for undo buffer
+ * struct cli_undo_pos - saved state for a single undo/redo level
  *
- * @buf: Buffer for saved state
- * @num: Saved cursor position
- * @eol_num: Saved end-of-line position
+ * Before any editing operation (insert, delete, kill, etc.), the entire
+ * buffer state is saved so it can be restored on undo. The buffer contents,
+ * cursor position, and line length are captured together.
+ *
+ * @buf: Complete copy of the edit buffer at the time of save
+ * @num: Cursor position (offset from start of buffer)
+ * @eol_num: Number of characters in the buffer (end-of-line position)
  */
-struct cli_undo_state {
+struct cli_undo_pos {
 	struct abuf buf;
 	uint num;
 	uint eol_num;
 };
 
+/**
+ * struct cli_undo_state - state for undo/redo ring buffer
+ *
+ * This implements a ring buffer for storing undo or redo states. Each state
+ * consists of a complete copy of the edit buffer plus the cursor position.
+ * The ring buffer allows multiple levels of undo/redo up to alloc entries.
+ *
+ * When saving a new state, it is written at the @head index, then @head
+ * advances (wrapping at alloc). When restoring, @head moves back and the
+ * state at that index is restored. The @count tracks how many valid states
+ * are available for undo/redo.
+ *
+ * @pos: List of &struct cli_undo_pos entries
+ * @head: Index where the next state will be saved (0 to alloc-1)
+ * @count: Number of valid states available (0 to alloc)
+ */
+struct cli_undo_state {
+	struct alist pos;
+	uint head;
+	uint count;
+};
+
 /**
  * struct cli_editor_state - state for enhanced editing features
  *
@@ -51,6 +78,7 @@  struct cli_undo_state {
  * @multiline: true if input may contain multiple lines (enables
  *	Ctrl-P/N for line navigation instead of history)
  * @undo: Undo ring buffer state
+ * @redo: Redo ring buffer state
  * @yank: Buffer for killed text (for Ctrl+Y yank)
  * @yank_len: Length of killed text in yank buffer
  */
@@ -81,6 +109,9 @@  struct cli_editor_state {
 	/** @undo: Undo state (if CONFIG_CMDLINE_UNDO) */
 	struct cli_undo_state undo;
 
+	/** @redo: Redo state (if CONFIG_CMDLINE_UNDO) */
+	struct cli_undo_state redo;
+
 	/** @yank: Buffer for killed text (for Ctrl+Y yank) */
 	struct abuf yank;
 
@@ -393,13 +424,14 @@  void cli_cread_add_initial(struct cli_line_state *cls);
 /** cread_print_hist_list() - Print the command-line history list */
 void cread_print_hist_list(void);
 
-/*
- * Undo/yank functions - implementations in cli_undo.c when CMDLINE_UNDO is enabled
- */
-#if CONFIG_IS_ENABLED(CMDLINE_UNDO)
+#if CONFIG_IS_ENABLED(CMDLINE_EDITOR)
 /**
  * cread_save_undo() - Save current state for undo
  *
+ * Saves the buffer contents and cursor position to the undo ring buffer.
+ * Each call pushes a new undo state that can be restored with Ctrl+Z.
+ * Also clears the redo buffer since a new edit invalidates redo history.
+ *
  * @cls: CLI line state
  */
 void cread_save_undo(struct cli_line_state *cls);
@@ -407,13 +439,29 @@  void cread_save_undo(struct cli_line_state *cls);
 /**
  * cread_restore_undo() - Restore previous state from undo buffer
  *
+ * Restores the buffer contents and cursor position from the most recent
+ * undo state. Multiple calls restore progressively older states. The
+ * current state is saved to the redo buffer before restoring.
+ *
  * @cls: CLI line state
  */
 void cread_restore_undo(struct cli_line_state *cls);
 
+/**
+ * cread_redo() - Redo previously undone change
+ *
+ * Restores the buffer contents and cursor position from the redo buffer.
+ * The current state is saved to the undo buffer before restoring.
+ *
+ * @cls: CLI line state
+ */
+void cread_redo(struct cli_line_state *cls);
+
 /**
  * cread_save_yank() - Save killed text to yank buffer
  *
+ * Saves the specified text so it can be yanked (pasted) later with Ctrl+Y.
+ *
  * @cls: CLI line state
  * @text: Text to save
  * @len: Length of text
@@ -423,9 +471,21 @@  void cread_save_yank(struct cli_line_state *cls, const char *text, uint len);
 /**
  * cread_yank() - Insert yanked text at cursor position
  *
+ * Inserts the previously killed text at the current cursor position.
+ *
  * @cls: CLI line state
  */
 void cread_yank(struct cli_line_state *cls);
+
+/**
+ * cread_clear_redo() - Clear the redo buffer
+ *
+ * Called when a new edit is made to invalidate the redo history. This should
+ * be called for any edit operation that modifies the buffer.
+ *
+ * @cls: CLI line state
+ */
+void cread_clear_redo(struct cli_line_state *cls);
 #else
 static inline void cread_save_undo(struct cli_line_state *cls)
 {
@@ -435,6 +495,10 @@  static inline void cread_restore_undo(struct cli_line_state *cls)
 {
 }
 
+static inline void cread_redo(struct cli_line_state *cls)
+{
+}
+
 static inline void cread_save_yank(struct cli_line_state *cls, const char *text,
 				   uint len)
 {
@@ -443,6 +507,10 @@  static inline void cread_save_yank(struct cli_line_state *cls, const char *text,
 static inline void cread_yank(struct cli_line_state *cls)
 {
 }
+
+static inline void cread_clear_redo(struct cli_line_state *cls)
+{
+}
 #endif
 
 #endif
diff --git a/test/boot/editenv.c b/test/boot/editenv.c
index ab3c6648886..9a41d269d17 100644
--- a/test/boot/editenv.c
+++ b/test/boot/editenv.c
@@ -74,8 +74,8 @@  static int editenv_test_base(struct unit_test_state *uts)
 	int ret;
 
 	/*
-	 * Type "test" then press Ctrl-S to save
-	 * \x13 is Ctrl-S
+	 * Type "test" then press Ctrl-S to accept (Enter inserts newline in
+	 * multiline mode)
 	 */
 	console_in_puts("test\x13");
 	ret = expo_editenv("myvar", NULL, buf, sizeof(buf));
@@ -94,7 +94,7 @@  static int editenv_test_initial(struct unit_test_state *uts)
 
 	/*
 	 * Start with "world", go to start with Ctrl-A, type "hello ", then
-	 * press Ctrl-S to save
+	 * press Ctrl-S to accept
 	 */
 	console_in_puts("\x01hello \x13");
 	ret = expo_editenv("myvar", "world", buf, sizeof(buf));
@@ -190,11 +190,61 @@  static int editenv_test_funcs(struct unit_test_state *uts)
 	ut_assertok(editenv_send(&info, CTL_CH('k')));
 	ut_asserteq(16033, ut_check_video(uts, "kill"));
 
+	/* Test undo - should restore the killed text */
+	ut_assertok(editenv_send(&info, CTL_CH('z')));
+	ut_asserteq(16877, ut_check_video(uts, "undo"));
+
+	/* Kill again and yank it back - text should be restored */
+	ut_assertok(editenv_send(&info, CTL_CH('k')));
+	ut_asserteq(16033, ut_check_video(uts, "kill2"));
+
+	ut_assertok(editenv_send(&info, CTL_CH('y')));
+	ut_asserteq(16808, ut_check_video(uts, "yank"));
+
+	/* Test Home - should go to start of current line */
+	ut_assertok(editenv_send(&info, CTL_CH('a')));
+	ut_asserteq(0, info.ted->tin.cls.num);
+	ut_asserteq(16845, ut_check_video(uts, "home"));
+
+	/* Test End - should go to end of current line */
+	ut_assertok(editenv_send(&info, CTL_CH('e')));
+	ut_asserteq(16808, ut_check_video(uts, "end"));
+
+	/* Go left two words with Ctrl+R */
+	ut_assertok(editenv_send(&info, CTL_CH('r')));
+	ut_asserteq(16838, ut_check_video(uts, "left1"));
+
+	ut_assertok(editenv_send(&info, CTL_CH('r')));
+	ut_asserteq(16812, ut_check_video(uts, "left2"));
+
+	/* Delete three words with Ctrl+W */
+	ut_assertok(editenv_send(&info, CTL_CH('w')));
+	ut_asserteq(16691, ut_check_video(uts, "delw1"));
+
+	ut_assertok(editenv_send(&info, CTL_CH('w')));
+	ut_asserteq(16445, ut_check_video(uts, "delw2"));
+
+	ut_assertok(editenv_send(&info, CTL_CH('w')));
+	ut_asserteq(16118, ut_check_video(uts, "delw3"));
+
+	/* Undo to restore one deleted word */
+	ut_assertok(editenv_send(&info, CTL_CH('z')));
+	ut_asserteq(16445, ut_check_video(uts, "undo1"));
+
+	/* Type a character - this clears the redo buffer */
+	ut_assertok(editenv_send(&info, '!'));
+	ut_asserteq(16469, ut_check_video(uts, "type"));
+
+	/* Redo (Ctrl+G) should do nothing since typing cleared the redo buffer */
+	ut_assertok(editenv_send(&info, CTL_CH('g')));
+	ut_asserteq(16469, ut_check_video(uts, "redo"));
+
+	/* Press Ctrl-S to save */
 	ut_asserteq(1, editenv_send(&info, BKEY_SAVE));
 
-	/* The '*' is inserted after "tes", Ctrl-K killed "ted properly." */
-	ut_assert(strstr(expo_editenv_result(&info), "tes*\n"));
-	ut_asserteq(16033, ut_check_video(uts, "save"));
+	/* The '*' and '!' are inserted; redo did nothing since it was cleared */
+	ut_assert(strstr(expo_editenv_result(&info), "tes*ted"));
+	ut_asserteq(16469, ut_check_video(uts, "save"));
 
 	expo_editenv_uninit(&info);
 
diff --git a/test/boot/expo.c b/test/boot/expo.c
index 5445fed19c1..7a3285535b5 100644
--- a/test/boot/expo.c
+++ b/test/boot/expo.c
@@ -1735,6 +1735,16 @@  static int expo_render_textedit(struct unit_test_state *uts)
 	ut_asserteq(90, ted->tin.cls.eol_num);
 	ut_asserteq('\n', ((char *)abuf_data(&ted->tin.buf))[89]);
 
+	/* Ctrl+Z undoes the yank */
+	ut_assertok(expo_send_key(exp, CTL_CH('z')));
+	ut_asserteq(85, ted->tin.cls.num);
+	ut_asserteq(86, ted->tin.cls.eol_num);
+
+	/* Ctrl+Shift+Z (internal code 'g') redoes the yank */
+	ut_assertok(expo_send_key(exp, CTL_CH('g')));
+	ut_asserteq(89, ted->tin.cls.num);
+	ut_asserteq(90, ted->tin.cls.eol_num);
+
 	/* clear multiline mode, close the textedit with Enter (BKEY_SELECT) */
 	ted->obj.flags &= ~SCENEOF_MULTILINE;
 	ut_assertok(expo_send_key(exp, BKEY_SELECT));
@@ -1743,7 +1753,7 @@  static int expo_render_textedit(struct unit_test_state *uts)
 	ut_asserteq(OBJ_TEXTED, act.select.id);
 	ut_assertok(scene_set_open(scn, act.select.id, false));
 
-	/* check the textedit is closed and text is changed */
+	/* check the textedit is closed and text is changed (redo restored latr) */
 	ut_asserteq(0, ted->obj.flags & SCENEOF_OPEN);
 	ut_asserteq_str("This\ns the initial contents of the text "
 		"editor but it is ely that more will be added latr\n",