Index | Thread | Search

From:
Job Snijders <job@openbsd.org>
Subject:
watch(1) - periodically execute a command and display its output
To:
tech@openbsd.org
Date:
Mon, 19 May 2025 20:00:37 +0000

Download raw body.

Thread
Dear all,

I often find myself reaching for a type of utility not in base, "watch".

A makeshift solution is "$ while true; do THING; sleep 1; clear; done",
but I often end up spending too much time tweaking such impromptu
oneliners, essentially cooking up hairy shell scripts with no newlines.

For years I used 'gnuwatch' from ports, it works okayish, but the name
spells out an issue. Then, this weekend I discovered yazuoka@'s
excellent ports/sysutils/iwatch and decided to prepare it for OpenBSD.

https://github.com/iij/iwatch v1.0.4 was my starting point.

compared to iwatch:
 - removed colorizing capability / style via environment variable
 - changed a number of keyboard commands (added arrows/pgup/pgdown)
 - rewrote the man page
 - made interactive mode more like top(1)/systat(1)
 - added 'pause on error' feature
 - added pledge/unveil

watch compared to gnuwatch:
  - also uses '-n' to set the desired interval (this aspect probably
    makes the tools interchangable for 99% of users)
  - gnuwatch lacks some interactive toggles
  - gnuwatch can highlight changed characters, but not words and lines;
    the latter two often provide great clarity in a visual fashion.
  - gnuwatch lacks the ability to scroll through the command's output

This is still a bit rough around the edges, but perhaps we can work on
it together in tree? Your feedback is most welcome!

お疲れ様でした

Job

Index: usr.bin/watch/watch.1
===================================================================
RCS file: usr.bin/watch/watch.1
diff -N usr.bin/watch/watch.1
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ usr.bin/watch/watch.1	19 May 2025 19:17:31 -0000
@@ -0,0 +1,138 @@
+.\"	$OpenBSD$
+.\"
+.\" Copyright (c) 2025 Job Snijders <job@openbsd.org>
+.\" Copyright (c) 2000, 2001, 2014, 2016 Internet Initiative Japan Inc.
+.\"
+.\" Permission to use, copy, modify, and distribute this software for any
+.\" purpose with or without fee is hereby granted, provided that the above
+.\" copyright notice and this permission notice appear in all copies.
+.\"
+.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+.\"
+.Dd $Mdocdate$
+.Dt WATCH 1
+.Os
+.Sh NAME
+.Nm watch
+.Nd periodically execute a command and display its output
+.Sh SYNOPSIS
+.Nm
+.Op Fl celwx
+.Op Fl n Ar seconds
+.Ar command Op Ar argument ...
+.Sh DESCRIPTION
+The
+.Nm
+utility periodically executes and displays the output of
+.Ar command
+with
+.Ar argument .
+Differences between successive runs can be highlighted.
+.Pp
+The options are as follows:
+.Bl -tag -width Ds
+.It Fl c
+Highlight changed characters.
+.It Fl e
+Pause updating if
+.Ar command
+exits with a non-zero exit code.
+.It Fl l
+Highlight changed lines.
+.It Fl n Ar seconds
+Set the interval between updates to
+.Ar seconds .
+The value may be zero or fractional.
+The default is 1 second.
+.It Fl w
+Highlight changed words.
+.It Fl x
+Pass
+.Ar command
+and
+.Ar arguments
+to
+.Xr execl 3
+instead of
+.Ic sh -c ,
+for different quoting and shell escaping behaviour.
+.El
+.Sh INTERACTIVE COMMANDS
+Certain characters cause immediate action by
+.Nm .
+These are:
+.Bl -tag -width Ds
+.It Aq Ic Space
+Run
+.Ar command
+again.
+.It Aq Ic Page Down
+Scroll down a screenful.
+.It Aq Ic Page Up
+Scroll up a screenful.
+.It Ic \&[ | Aq Ic left arrow
+Scroll left by one column.
+.It Ic \&] | Aq Ic right arrow
+Scroll right by one column.
+.It Ic c
+Highlight changed characters.
+.It Ic G
+Scroll to bottom.
+.It Ic g
+Scroll to top.
+.It Ic H
+Scroll left half a screen.
+.It Ic h | Ic \&?
+Display a summary of the commands (help screen).
+.It Ic J
+Scroll down half a screen.
+.It Ic j | Aq Ic down arrow
+Scroll down one line.
+.It Ic K
+Scroll up half a screen.
+.It Ic k | Aq Ic up arrow
+Scroll up one line.
+.It Ic L
+Scroll right half a screen.
+.It Ic l
+Highlight changed lines.
+.It Ic n
+Change the update interval.
+.It Ic p
+Pause or resume command executions.
+.Aq Ic Space
+can be used while paused.
+.It Ic t
+Toggle the display of highlights.
+.It Ic w
+Highlight changed words.
+.It Ic q
+Quit
+.Nm .
+.El
+.Sh SEE ALSO
+.Xr sh 1 ,
+.Xr execl 3
+.Sh HISTORY
+A
+.Nm
+utility written by Tony Rems first appeared in the procps collection in 1991.
+This
+.Nm
+utility was originally named iwatch, a from-scratch rewrite for BSD by IIJ.
+.Pp
+.Nm
+first appeared in
+.Ox 7.8 .
+.Sh AUTHORS
+This
+.Nm
+utility was written by Takuya Sato, Kazumasa Utashiro, and YASUOKA Masahiko
+from Internet Initiative Japan Inc., with contributions from
+.An Job Snijders Aq Mt job@openbsd.org .
Index: usr.bin/watch/watch.c
===================================================================
RCS file: usr.bin/watch/watch.c
diff -N usr.bin/watch/watch.c
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ usr.bin/watch/watch.c	19 May 2025 19:17:31 -0000
@@ -0,0 +1,735 @@
+/* $OpenBSD$ */
+/*
+ * Copyright (c) 2025 Job Snijders <job@openbsd.org>
+ * Copyright (c) 2000, 2001 Internet Initiative Japan Inc.
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistribution with functional modification must include
+ *    prominent notice stating how and when and by whom it is
+ *    modified.
+ *
+ * THIS SOFTWARE IS PROVIDED BY ``AS IS'' AND ANY EXPRESS OR IMPLIED
+ * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED.
+ */
+
+#include <sys/types.h>
+#include <sys/wait.h>
+
+#include <curses.h>
+#include <err.h>
+#include <errno.h>
+#include <locale.h>
+#include <paths.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <time.h>
+#include <unistd.h>
+#include <wchar.h>
+#include <wctype.h>
+
+#define DEFAULT_INTERVAL 1
+
+/* XXX: are the below 3 defines really enough? */
+#define MAXLINE 300
+#define MAXCOLUMN 180
+#define MAX_COMMAND_LENGTH 128
+
+/* XXX: is this really needed? */
+#define NUM_FRAQ_DIGITS_USEC 6	/* number of fractal digits for usec */
+
+#define	addwch(_x)	addnwstr(&(_x), 1);
+#define	WCWIDTH(_x)	((wcwidth((_x)) > 0)? wcwidth((_x)) : 1)
+#define MAX(x, y)	((x) > (y) ? (x) : (y))
+#define MIN(x, y)	((x) < (y) ? (x) : (y))
+#define ctrl(c)         ((c) & 037)
+
+typedef wchar_t BUFFER[MAXLINE][MAXCOLUMN + 1];
+
+typedef enum {
+	HIGHLIGHT_NONE,
+	HIGHLIGHT_CHAR,
+	HIGHLIGHT_WORD,
+	HIGHLIGHT_LINE
+} highlight_mode_t;
+
+typedef enum {
+	RSLT_UPDATE,
+	RSLT_REDRAW,
+	RSLT_NOTOUCH,
+	RSLT_ERROR
+} kbd_result_t;
+
+int start_line = 0;
+int start_column = 0;
+
+struct timeval opt_interval = { DEFAULT_INTERVAL, 0 };
+highlight_mode_t highlight_mode = HIGHLIGHT_NONE;
+highlight_mode_t last_highlight_mode = HIGHLIGHT_CHAR;
+
+int pause_on_error = 0;
+int paused = 0;			/* pause status */
+int last_exitcode = 0;
+time_t lastupdate;
+int xflag = 0;
+
+static char *cmdstr;
+static char **cmdv;
+
+void command_loop(void);
+int display(BUFFER *, BUFFER *, highlight_mode_t);
+void read_result(BUFFER *);
+kbd_result_t kbd_command(int);
+void show_help(void);
+void untabify(wchar_t *, int);
+void on_signal(int);
+void quit(void);
+static void __dead usage(void);
+
+int
+main(int argc, char *argv[])
+{
+	int i, ch, cmdsiz = 0;
+	char *endp, *s;
+	double intvl;
+
+	if (pledge("exec proc rpath stdio tty unveil", NULL) == -1)
+		err(1, "pledge");
+
+	while ((ch = getopt(argc, argv, "celn:wx")) != -1)
+		switch (ch) {
+		case 'c':
+			highlight_mode = HIGHLIGHT_CHAR;
+			break;
+		case 'e':
+			pause_on_error = 1;
+			break;
+		case 'l':
+			highlight_mode = HIGHLIGHT_LINE;
+			break;
+		case 'n':
+			intvl = strtod(optarg, &endp);
+
+			if (intvl >= 0 && intvl <= 1000000 && *endp == '\0') {
+				opt_interval.tv_sec = (int)intvl;
+				opt_interval.tv_usec =
+				    (u_long)(intvl * 1000000UL) % 1000000UL;
+				break;
+			} else {
+				errx(1, "-n: bad value: %s", optarg);
+			}
+			break;
+		case 'w':
+			highlight_mode = HIGHLIGHT_WORD;
+			break;
+		case 'x':
+			xflag = 1;
+			break;
+		default:
+			usage();
+		}
+
+	argc -= optind;
+	argv += optind;
+
+	/*
+	 * Build command string to give to popen
+	 */
+	if (argc <= 0)
+		usage();
+
+	if ((cmdv = calloc(argc + 1, sizeof(char *))) == NULL)
+		err(1, NULL);
+
+	cmdstr = "";
+	for (i = 0; i < argc; i++) {
+		cmdv[i] = argv[i];
+		while (strlen(cmdstr) + strlen(argv[i]) + 3 > cmdsiz) {
+			if (cmdsiz == 0) {
+				cmdsiz = 128;
+				if ((s = calloc(cmdsiz, 1)) == NULL)
+					err(1, NULL);
+			} else {
+				cmdsiz *= 2;
+				s = realloc(cmdstr, cmdsiz);
+			}
+			if (s == NULL)
+				err(EX_OSERR, "malloc");
+			cmdstr = s;
+		}
+		if (i != 0)
+			strlcat(cmdstr, " ", cmdsiz);
+		strlcat(cmdstr, argv[i], cmdsiz);
+	}
+	cmdv[i++] = NULL;
+
+	/*
+	 * Initialize signal
+	 */
+	(void) signal(SIGINT, on_signal);
+	(void) signal(SIGTERM, on_signal);
+	(void) signal(SIGHUP, on_signal);
+
+	/*
+	 * Initialize curses environment
+	 */
+	initscr();
+	noecho();
+	crmode();
+	keypad(stdscr, TRUE);
+
+	/*
+	 * Enter main processing loop and never come back here
+	 */
+	command_loop();
+
+	/* NOTREACHED */
+	abort();
+}
+
+void
+command_loop(void)
+{
+	int i, nfds;
+	BUFFER buf0, buf1;
+	fd_set readfds;
+	struct timeval to;
+
+	for (i = 0; ; i++) {
+		BUFFER *cur, *prev;
+
+		if (i == 0) {
+			cur = prev = &buf0;
+		} else if (i % 2 == 0) {
+			cur = &buf0;
+			prev = &buf1;
+		} else {
+			cur = &buf1;
+			prev = &buf0;
+		}
+
+		read_result(cur);
+
+ redraw:
+		display(cur, prev, highlight_mode);
+
+ input:
+		to = opt_interval;
+		FD_ZERO(&readfds);
+		FD_SET(fileno(stdin), &readfds);
+
+		nfds = select(1, &readfds, NULL, NULL, (paused) ? NULL : &to);
+		if (nfds < 0) {
+			switch (errno) {
+			case EINTR:
+				/*
+				 * ncurses has changed the window size with
+				 * SIGWINCH. Call doupdate() to use the
+				 * updated window size.
+				 */
+				doupdate();
+				goto redraw;
+			default:
+				perror("select");
+			}
+		} else if (nfds > 0) {
+			int ch = getch();
+			kbd_result_t result = kbd_command(ch);
+
+			switch (result) {
+			case RSLT_UPDATE:	/* update buffer */
+				break;
+			case RSLT_REDRAW:	/* scroll with current buffer */
+				goto redraw;
+			case RSLT_NOTOUCH:	/* silently loop again */
+				goto input;
+			case RSLT_ERROR:	/* error */
+				fprintf(stderr, "\007");
+				goto input;
+			}
+		}
+	}
+}
+
+int
+display(BUFFER *cur, BUFFER *prev, highlight_mode_t highlight)
+{
+	int i, val, screen_x, screen_y, cw, line, rl;
+	static char buf[30];
+
+	erase();
+
+	move(0, 0);
+
+	if (paused)
+		printw("--PAUSED-- ");
+
+	if (opt_interval.tv_usec == 0) {
+		printw("Every %ds: ", (int)opt_interval.tv_sec);
+	} else {
+		for (i = NUM_FRAQ_DIGITS_USEC, val = opt_interval.tv_usec;
+		    val % 10 == 0; val /= 10) {
+			i--;
+		}
+		printw("Every %d.%0*ds: ", (int)opt_interval.tv_sec, i, val);
+	}
+
+	if ((int)strlen(cmdstr) > COLS - 47)
+		printw("%-.*s...", COLS - 49, cmdstr);
+	else
+		printw("%s", cmdstr);
+
+	if (pause_on_error)
+		printw(" (%d)", last_exitcode);
+
+	if (buf[0] == '\0')
+		gethostname(buf, sizeof(buf));
+
+	move(0, COLS - 8 - strlen(buf) - 1);
+	printw("%s %-8.8s", buf, &(ctime(&lastupdate)[11]));
+
+	move(1, 1);
+
+	if (!prev || (cur == prev))
+		highlight = HIGHLIGHT_NONE;
+
+	for (line = start_line, screen_y = 2;
+	    screen_y < LINES && line < MAXLINE && (*cur)[line][0];
+	    line++, screen_y++) {
+		wchar_t	*cur_line, *prev_line, *p, *pp;
+
+		rl = 0;	/* reversing line */
+		cur_line = (*cur)[line];
+		prev_line = (*prev)[line];
+
+		for (p = cur_line, cw = 0; cw < start_column; p++)
+			cw += WCWIDTH(*p);
+		screen_x = cw - start_column;
+		for (pp = prev_line, cw = 0; cw < start_column; pp++)
+			cw += WCWIDTH(*pp);
+
+		switch (highlight) {
+		case HIGHLIGHT_LINE:
+			if (wcscmp(cur_line, prev_line)) {
+				standout();
+				rl = 1;
+				for (i = 0; i < screen_x; i++) {
+					move(screen_y, i);
+					addch(' ');
+				}
+			}
+			/* FALLTHROUGH */
+
+		case HIGHLIGHT_NONE:
+			move(screen_y, screen_x);
+			while (screen_x < COLS) {
+				if (*p && *p != L'\n') {
+					cw = wcwidth(*p);
+					if (screen_x + cw >= COLS)
+						break;
+					addwch(*p++);
+					pp++;
+					screen_x += cw;
+				} else if (rl) {
+					addch(' ');
+					screen_x++;
+				} else
+					break;
+			}
+			standend();
+			break;
+
+		case HIGHLIGHT_WORD:
+		case HIGHLIGHT_CHAR:
+			move(screen_y, screen_x);
+			while (*p && screen_x < COLS) {
+				cw = wcwidth(*p);
+				if (screen_x + cw >= COLS)
+					break;
+				if (*p == *pp) {
+					addwch(*p++);
+					pp++;
+					screen_x += cw;
+					continue;
+				}
+				/*
+				 * If the word highlight option is specified and
+				 * the current character is not a space, track
+				 * back to the beginning of the word.
+				 */
+				if (highlight == HIGHLIGHT_WORD
+				    && !iswspace(*p)) {
+					while (cur_line + start_column < p
+					    && !iswspace(*(p - 1))) {
+						p--;
+						pp--;
+						screen_x -= wcwidth(*p);
+					}
+					move(screen_y, screen_x);
+				}
+				standout();
+
+				/* Print character itself.  */
+				cw = wcwidth(*p);
+				addwch(*p++);
+				pp++;
+				screen_x += cw;
+
+				/*
+				 * If the word highlight option is specified,
+				 * and the current character is not a space,
+				 * print the whole word which includes current
+				 * character.
+				 */
+				if (highlight == HIGHLIGHT_WORD) {
+					while (*p && !iswspace(*p) &&
+					    screen_x < COLS) {
+						cw = wcwidth(*p);
+						addwch(*p++);
+						pp++;
+						screen_x += cw;
+					}
+				}
+				standend();
+			}
+			break;
+		}
+	}
+	move(1, 0);
+	refresh();
+	return (1);
+}
+
+void
+read_result(BUFFER *buf)
+{
+	int i, st, fds[2];
+	pid_t pipe_pid, pid;
+	FILE *fp;
+
+	/* Clear buffer */
+	memset(buf, 0, sizeof(*buf));
+
+	if (pipe(fds) == -1)
+		err(EX_OSERR, "pipe()");
+
+	if ((pipe_pid = vfork()) == -1)
+		err(EX_OSERR, "vfork()");
+	else if (pipe_pid == 0) {
+		close(fds[0]);
+		if (fds[1] != STDOUT_FILENO) {
+			dup2(fds[1], STDOUT_FILENO);
+			close(fds[1]);
+		}
+		if (xflag) {
+			if (unveil(cmdv[0], "x") == -1)
+				err(1, "%s: unveil", cmdv[0]);
+			execvp(cmdv[0], cmdv);
+		} else {
+			if (unveil(_PATH_BSHELL, "x") == -1)
+				err(1, "%s: unveil", _PATH_BSHELL);
+			execl(_PATH_BSHELL, _PATH_BSHELL, "-c", cmdstr, NULL);
+		}
+
+		/* use warn(3) + _exit(2) not to call exit(3) */
+		warn("exec(%s)", cmdstr);
+		_exit(EX_OSERR);
+
+		/* NOTREACHED */
+	}
+	if ((fp = fdopen(fds[0], "r")) == NULL)
+		err(EX_OSERR, "fdopen()");
+	close(fds[1]);
+
+	/* Read command output and convert tab to spaces * */
+	for (i = 0; i < MAXLINE && fgetws((*buf)[i], MAXCOLUMN, fp) != NULL;
+	    i++)
+		untabify((*buf)[i], sizeof((*buf)[i]));
+	fclose(fp);
+	do {
+		pid = waitpid(pipe_pid, &st, 0);
+	} while (pid == -1 && errno == EINTR);
+
+	/* Remember update time */
+	time(&lastupdate);
+
+	if (WIFEXITED(st))
+		last_exitcode = WEXITSTATUS(st);
+	if (pause_on_error && last_exitcode)
+		paused = 1;
+}
+
+kbd_result_t
+kbd_command(int ch)
+{
+	char buf[10], *endp;
+	double intvl;
+
+	switch (ch) {
+
+	case 'h':
+	case '?':
+		show_help();
+		refresh();
+		return (RSLT_REDRAW);
+
+	/*
+	 * Control-L redraws the screen without executing the command.
+	 */
+	/*
+	 * TODO: Redrawing is sometimes needed when programs emit to stderr.
+	 * gnuwatch captures output on stderr a bit nicer, we should
+	 * investigate how to capture & display stdout+stderr in a fashion like
+	 * one'd see it in the shell.
+	 */
+	case ctrl('l'):
+		clear();
+		return (RSLT_REDRAW);
+
+	/*
+	 * (Space) re-run the command.
+	 */
+	case ' ':
+		return (RSLT_UPDATE);
+
+	case 'e':
+		if (pause_on_error == 1)
+			pause_on_error = 0;
+		else
+			pause_on_error = 1;
+		return (RSLT_REDRAW);
+
+	case 'p':
+		if ((paused = !paused) != 0)
+			return (RSLT_REDRAW);
+		else
+			return (RSLT_UPDATE);
+
+	case 't':
+		if (highlight_mode != HIGHLIGHT_NONE) {
+			last_highlight_mode = highlight_mode;
+			highlight_mode = HIGHLIGHT_NONE;
+		} else {
+			highlight_mode = last_highlight_mode;
+		}
+		break;
+
+	case 'c':
+		if (highlight_mode == HIGHLIGHT_CHAR)
+			highlight_mode = HIGHLIGHT_NONE;
+		else
+			highlight_mode = HIGHLIGHT_CHAR;
+		break;
+
+	case 'l':
+		if (highlight_mode == HIGHLIGHT_LINE)
+			highlight_mode = HIGHLIGHT_NONE;
+		else
+			highlight_mode = HIGHLIGHT_LINE;
+		break;
+
+	case 'w':
+		if (highlight_mode == HIGHLIGHT_WORD)
+			highlight_mode = HIGHLIGHT_NONE;
+		else
+			highlight_mode = HIGHLIGHT_WORD;
+		break;
+
+	case 'n':
+		move(1, 0);
+		standout();
+		printw("New interval: ");
+		standend();
+
+		echo();
+		getnstr(buf, sizeof(buf));
+		noecho();
+
+		intvl = strtod(buf, &endp);
+		if (intvl >= 0 && intvl <= 1000000 && *endp == '\0') {
+			opt_interval.tv_sec = (int)intvl;
+			opt_interval.tv_usec =
+			    (u_long)(intvl * 1000000UL) % 1000000UL;
+		} else {
+			move(1, 0);
+			standout();
+			printw("Interval should be a non-negative number");
+			standend();
+			refresh();
+			return (RSLT_ERROR);
+		}
+
+		return (RSLT_REDRAW);
+
+	case KEY_DOWN:
+	case 'j':
+		start_line = MIN(start_line + 1, MAXLINE - 1);
+		break;
+
+	case KEY_UP:
+	case 'k':
+		start_line = MAX(start_line - 1, 0);
+		break;
+
+	case 'J':
+		start_line = MIN(start_line + ((LINES - 2) / 2), MAXLINE - 1);
+		break;
+
+	case 'K':
+		start_line = MAX(start_line - ((LINES - 2) / 2), 0);
+		break;
+
+	case KEY_NPAGE:
+		start_line = MIN(start_line + (LINES - 2), MAXLINE - 1);
+		break;
+
+	case KEY_PPAGE:
+		start_line = MAX(start_line - (LINES - 2), 0);
+		break;
+
+	case 'g':
+		start_line = 0;
+		break;
+
+	case 'G':
+		start_line = LINES - 1;
+		break;
+
+	case 'L':
+		start_column = MIN(start_column + ((COLS - 2) / 2),
+		    MAXCOLUMN - 1);
+		break;
+
+	case 'H':
+		start_column = MAX(start_column - ((COLS - 2) / 2), 0);
+		break;
+
+	case KEY_LEFT:
+	case '[':
+		start_column = MAX(start_column - 1, 0);
+		break;
+
+	case KEY_RIGHT:
+	case ']':
+		start_column = MIN(start_column + 1, MAXCOLUMN - 1);
+		break;
+
+	case 'q':
+		quit();
+
+	default:
+		return (RSLT_ERROR);
+
+	}
+
+	return (RSLT_REDRAW);
+}
+
+void
+show_help(void)
+{
+	int ch;
+	ssize_t len;
+
+	clear();
+	nl();
+
+	printw("These commands are available:\n"
+	    "\n"
+	    "Movement:\n"
+	    "j | k          - scroll down/up one line\n"
+	    "[ | ]          - scroll left/right one column\n"
+	    "(arrow keys)   - scroll left/down/up/right one line or column\n"
+	    "H | J | K | L  - scroll left/down/up/right half a screen\n"
+	    "(Page Down)    - scroll down a full screen\n"
+	    "(Page Up)      - scroll up a full screen\n"
+	    "g              - go to top\n"
+	    "G              - go to bottom\n"
+	    "\n"
+	    "Other:\n"
+	    "(Space)        - run command again\n"
+	    "c              - highlight changed characters\n"
+	    "l              - highlight changed lines\n"
+	    "w              - highlight changed words\n"
+	    "t              - toggle highlight mode on/off\n"
+	    "n              - change update interval\n"
+	    "e              - pause after non-zero exit\n"
+	    "p              - toggle pause / resume\n"
+	    "h | ?          - show this message\n"
+	    "q              - quit\n\n");
+
+	standout();
+	printw("Hit any key to continue.");
+	standend();
+	refresh();
+
+	while (1) {
+		len = read(STDIN_FILENO, &ch, 1);
+		if (len == -1 && errno == EINTR)
+			continue;
+		if (len == 0)
+			exit(1);
+		break;
+	}
+}
+
+void
+untabify(wchar_t *buf, int maxlen)
+{
+	int	 i, tabstop = 8, len, spaces, width = 0, maxcnt;
+	wchar_t *p = buf;
+
+	maxcnt = maxlen / sizeof(wchar_t);
+	while (*p && p - buf < maxcnt - 1) {
+		if (*p != L'\t') {
+			width += wcwidth(*p);
+			p++;
+		} else {
+			spaces = tabstop - (width % tabstop);
+			len = MIN(maxcnt - (p + spaces - buf),
+			    (int)wcslen(p + 1) + 1);
+			if (len > 0)
+				memmove(p + spaces, p + 1,
+				    len * sizeof(wchar_t));
+			len = MIN(spaces, maxcnt - 1 - (p - buf));
+			for (i = 0; i < len; i++)
+				p[i] = L' ';
+			p += len;
+			width += len;
+		}
+	}
+	*p = L'\0';
+}
+
+void
+on_signal(int signum)
+{
+	quit();
+}
+
+void
+quit(void)
+{
+	erase();
+	refresh();
+	endwin();
+	free(cmdv);
+	exit(EXIT_SUCCESS);
+}
+
+static void __dead
+usage(void)
+{
+	fprintf(stderr, "usage: %s [-celwx] [-n seconds] command [arg ...]\n",
+	    getprogname());
+	exit(1);
+}
Index: usr.bin/watch/Makefile
===================================================================
RCS file: usr.bin/watch/Makefile
diff -N usr.bin/watch/Makefile
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ usr.bin/watch/Makefile	19 May 2025 19:17:31 -0000
@@ -0,0 +1,11 @@
+#	$OpenBSD$
+
+PROG=	watch
+
+# XXX: why is this needed?
+CFLAGS+= -D_XOPEN_SOURCE_EXTENDED
+
+LDADD+= -lcurses
+DPADD+= ${LIBCURSES}
+
+.include <bsd.prog.mk>