Fix agent terminal redraws, Unicode input and Markdown rendering

Buffer complete terminal updates, repaint status without erasing live input, and retain pending redraws across throttling. Preserve fragmented UTF-8, navigation and paste input, and bound tab completion safely.

Use shared Unicode width handling for input, output and queued prompts. Preserve unmatched Markdown delimiters and publish code recoloring as one update. Add focused terminal regression tests.
This commit is contained in:
Salvatore Sanfilippo committed 2026-09-06 22:50:20 +02:00
1 parent 90aa4b560a
commit db1942148c
4 files changed
+840 -328

No files matched your search

+270 -150
View File
@@ -179,7 +179,6 @@ typedef struct agent_tail_capture {
typedef enum {
AGENT_MD_PENDING_NONE,
AGENT_MD_PENDING_STAR,
AGENT_MD_PENDING_BACKTICK,
} agent_markdown_pending;
@@ -198,6 +197,9 @@ typedef struct {
bool md_bold;
bool md_italic;
bool md_inline_code;
char md_inline[4096];
size_t md_inline_len;
bool md_escape;
bool md_code_block;
bool md_fence_info;
bool md_code_line_start;
@@ -474,10 +476,14 @@ static bool agent_worker_images_fit_tokens(const agent_worker *w,
}
static void write_all(int fd, const char *p, size_t n) {
if (fd == STDOUT_FILENO) {
linenoiseWrite(fd, p, n);
return;
}
while (n) {
ssize_t wr = write(fd, p, n);
if (wr < 0) {
if (errno == EINTR) continue;
if (wr <= 0) {
if (wr < 0 && errno == EINTR) continue;
return;
}
p += wr;
@@ -2090,8 +2096,7 @@ static void agent_dsml_feed(agent_dsml_parser *p, const char *s, size_t n) {
*
* This renderer handles only the cheap markdown cues that make terminal output
* readable: **bold**, *italic*, inline code, and fenced code blocks. It is a
* streaming parser, so it buffers only ambiguous marker bytes long enough to
* decide whether they are formatting or literal text.
* streaming parser with a bounded buffer for ambiguous inline spans.
*/
static void agent_tail_capture_append(agent_tail_capture *t,
@@ -2880,10 +2885,21 @@ static void renderer_code_emit_buffered_line(agent_token_renderer *r,
bool changed = renderer_code_scan_line(r, &final_ml_comment);
bool repaint = changed && renderer_code_line_can_repaint(r);
if (repaint) {
agent_tail_capture frame = {
.cap = 64 * (r->md_code_line_len +
(r->md_code_line_prefix ? strlen(r->md_code_line_prefix) : 0) + 1) + 256
};
agent_tail_capture *capture = r->capture;
r->capture = &frame;
renderer_reset_color(r);
renderer_write(r, "\r\x1b[0K", 5);
renderer_code_write_line_prefix(r);
renderer_syntax_emit_line(r, r->md_code_line, r->md_code_line_len);
r->capture = capture;
size_t len;
char *text = agent_tail_capture_take(&frame, &len);
renderer_write(r, text, len);
free(text);
} else {
r->md_code_in_ml_comment = final_ml_comment;
}
@@ -2981,34 +2997,58 @@ static void renderer_code_end(agent_token_renderer *r) {
r->md_code_line_prefix_color = NULL;
}
/* Tiny streaming Markdown highlighter for assistant prose. It deliberately
* recognizes only delimiters that the model commonly emits in short answers:
* **bold**, *italic*, `inline code`, ``inline code`` and fenced code blocks.
* The state machine holds only possible delimiter bytes; once a byte is known
* to be ordinary text it is sent to the raw UTF-8 writer above. Tool
* visualization and redirected output bypass this layer. */
/* Tool visualization and redirected output bypass Markdown formatting. */
static void renderer_markdown_clear_pending(agent_token_renderer *r) {
r->md_pending = AGENT_MD_PENDING_NONE;
r->md_pending_len = 0;
}
static bool renderer_markdown_inline_pair(agent_token_renderer *r, size_t *delimiter) {
size_t n = r->md_inline_len, open = 1, close = 0;
char ch = r->md_inline[0];
while (open < n && r->md_inline[open] == ch) open++;
while (close < n && r->md_inline[n - close - 1] == ch) close++;
*delimiter = open;
if (open > 2 || close != open || n <= open + close) return false;
if (ch == '*') {
if (isspace((unsigned char)r->md_inline[open]) ||
isspace((unsigned char)r->md_inline[n - close - 1])) return false;
size_t escapes = 0, pos = n - close;
while (pos && r->md_inline[--pos] == '\\') escapes++;
if (escapes & 1) return false;
}
return true;
}
static void renderer_markdown_inline_flush(agent_token_renderer *r, bool format) {
size_t n = r->md_inline_len, delimiter = 0;
if (!n) return;
bool paired = format && renderer_markdown_inline_pair(r, &delimiter);
bool code = r->md_inline[0] == '`' && paired;
r->md_inline_code = code;
r->md_bold = paired && !code && delimiter == 2;
r->md_italic = paired && !code && delimiter == 1;
size_t from = paired ? delimiter : 0, to = paired ? n - delimiter : n;
for (size_t i = from; i < to; i++) {
if (!code && r->md_inline[i] == '\\' && i + 1 < to &&
ispunct((unsigned char)r->md_inline[i + 1])) i++;
renderer_write_char_raw(r, r->md_inline[i]);
}
r->md_inline_len = 0;
r->md_inline_code = r->md_bold = r->md_italic = false;
renderer_reset_color(r);
}
static void renderer_markdown_emit_pending_literals(agent_token_renderer *r) {
char c;
if (r->md_pending == AGENT_MD_PENDING_STAR) {
c = '*';
} else if (r->md_pending == AGENT_MD_PENDING_BACKTICK) {
c = '`';
} else {
return;
renderer_markdown_inline_flush(r, false);
if (r->md_escape) {
renderer_write_char_raw(r, '\\');
r->md_escape = false;
}
size_t count = r->md_pending_len;
renderer_markdown_clear_pending(r);
if (r->md_code_block) {
if (c == '`') renderer_code_emit_backtick_literals(r, count);
else for (size_t i = 0; i < count; i++) renderer_code_byte(r, c);
return;
}
for (size_t i = 0; i < count; i++) renderer_write_char_raw(r, c);
if (r->md_code_block) renderer_code_emit_backtick_literals(r, count);
else for (size_t i = 0; i < count; i++) renderer_write_char_raw(r, '`');
}
static void renderer_markdown_commit_backticks(agent_token_renderer *r) {
@@ -3018,24 +3058,14 @@ static void renderer_markdown_commit_backticks(agent_token_renderer *r) {
for (size_t i = 0; i < count; i++) renderer_write_plain_byte(r, '`');
if (r->md_code_block) renderer_code_end(r);
else renderer_code_begin(r);
return;
}
if (r->md_code_block) {
} else if (r->md_code_block) {
renderer_code_emit_backtick_literals(r, count);
return;
}
/* Support both `code` and ``code``. The latter is uncommon in model
* replies, but accepting it costs nothing and avoids leaking delimiters. */
r->md_inline_code = !r->md_inline_code;
}
static bool renderer_space_byte(char c) {
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
}
/* Consume one byte of markdown-aware assistant output. Backticks and stars
* are held in r->pending until the parser knows whether they form a marker;
* all ordinary text is emitted with the current terminal attributes. */
/* Plain text streams immediately. An ambiguous inline span is held until its
* closing delimiter, a newline, or 4 KiB; without a pair it is printed literally.
* Code fences keep their existing line-at-a-time streaming highlighter. */
static void renderer_markdown_feed(agent_token_renderer *r, char c) {
if (r->md_fence_info) {
if (c == '\n') {
@@ -3049,60 +3079,71 @@ static void renderer_markdown_feed(agent_token_renderer *r, char c) {
unsigned char uc = (unsigned char)c;
if (r->md_fence_lang_len + 1 < sizeof(r->md_fence_lang) &&
(isalnum(uc) || c == '_' || c == '-' || c == '+' || c == '#'))
{
r->md_fence_lang[r->md_fence_lang_len++] = c;
}
renderer_write_plain_byte(r, c);
}
return;
}
if (r->md_pending == AGENT_MD_PENDING_BACKTICK) {
if (c == '`') {
r->md_pending_len++;
return;
}
if (c == '`') { r->md_pending_len++; return; }
renderer_markdown_commit_backticks(r);
renderer_markdown_feed(r, c);
return;
}
if (r->md_pending == AGENT_MD_PENDING_STAR) {
renderer_markdown_clear_pending(r);
if (!r->md_inline_code && !r->md_code_block && c == '*') {
r->md_bold = !r->md_bold;
return;
}
if (!r->md_inline_code && !r->md_code_block &&
(r->md_italic || !renderer_space_byte(c)))
{
r->md_italic = !r->md_italic;
if (r->md_code_block) {
if (c == '`' && r->md_code_line_start) {
r->md_pending = AGENT_MD_PENDING_BACKTICK;
r->md_pending_len = 1;
} else renderer_code_byte(r, c);
return;
}
if (r->md_inline_len) {
size_t delimiter;
if (c != r->md_inline[0] && renderer_markdown_inline_pair(r, &delimiter)) {
renderer_markdown_inline_flush(r, true);
renderer_markdown_feed(r, c);
return;
}
renderer_write_char_raw(r, '*');
renderer_markdown_feed(r, c);
size_t n = r->md_inline_len, run = 0;
while (run < n && r->md_inline[run] == r->md_inline[0]) run++;
if (run == n && c != r->md_inline[0]) {
if (r->md_inline[0] == '`' && run >= 3) {
r->md_inline_len = 0;
for (size_t i = 0; i < run; i++) renderer_write_plain_byte(r, '`');
renderer_code_begin(r);
renderer_markdown_feed(r, c);
return;
}
if (isspace((unsigned char)c) || run > 2) {
renderer_markdown_inline_flush(r, false);
renderer_markdown_feed(r, c);
return;
}
}
if (c == '\n' || r->md_inline_len == sizeof(r->md_inline)) {
renderer_markdown_inline_flush(r, false);
renderer_markdown_feed(r, c);
return;
}
r->md_inline[r->md_inline_len++] = c;
return;
}
if (c == '`' && (!r->md_code_block || r->md_code_line_start)) {
r->md_pending = AGENT_MD_PENDING_BACKTICK;
r->md_pending_len = 1;
return;
if (r->md_escape) {
r->md_escape = false;
if (ispunct((unsigned char)c)) { renderer_write_char_raw(r, c); return; }
renderer_write_char_raw(r, '\\');
}
if (r->md_code_block) {
renderer_code_byte(r, c);
return;
}
if (!r->md_inline_code && !r->md_code_block && c == '*') {
r->md_pending = AGENT_MD_PENDING_STAR;
r->md_pending_len = 1;
if (c == '\\') { r->md_escape = true; return; }
if (c == '*' || c == '`') {
r->md_inline[0] = c;
r->md_inline_len = 1;
return;
}
renderer_write_char_raw(r, c);
}
static void renderer_markdown_finish(agent_token_renderer *r) {
renderer_markdown_inline_flush(r, true);
/* A closing code fence can be the final bytes of the assistant reply. In
* that case no following character arrives to force the pending backticks
* through the normal streaming path, so commit a full fence here instead of
@@ -10220,43 +10261,46 @@ static void build_footer_text(const agent_status *st, const agent_prompt_queue *
}
const char *queued = agent_prompt_queue_peek(queue);
if (cols < 40) cols = 40;
if (cols < 1) cols = 1;
int max_rows = 3;
size_t budget = (size_t)cols * (size_t)max_rows;
const char *plain_suffix = " (ctrl+x to edit, ESC to send ASAP)";
size_t queued_len = strlen(queued);
char more_suffix[160];
const char *suffix = plain_suffix;
size_t take = queued_len;
if (queued_len + strlen(plain_suffix) > budget) {
size_t reserve = 72;
take = budget > reserve ? budget - reserve : budget / 2;
snprintf(more_suffix, sizeof(more_suffix),
"... %zu characters more ..., (ctrl+x to edit, ESC to send ASAP)",
queued_len - take);
suffix = more_suffix;
}
size_t queued_len = strlen(queued), pos = 0, cells = 8;
size_t reserve = strlen(plain_suffix) + 4;
size_t preview_bytes = len > 1024 ? len - 1024 : 0;
agent_buf msg = {0};
agent_buf_puts(&msg, "queued: ");
for (size_t i = 0; i < take; i++) {
char c = queued[i];
if (c == '\n' || c == '\r' || c == '\t') c = ' ';
agent_buf_append(&msg, &c, 1);
while (pos < queued_len) {
int width;
size_t n = linenoiseNextGrapheme(queued + pos, queued_len - pos, &width);
if (!n) break;
bool control = (unsigned char)queued[pos] < 32 || queued[pos] == 127;
if (control) width = 1;
if (cells + (size_t)width + reserve > budget || msg.len + n > preview_bytes) break;
agent_buf_append(&msg, control ? " " : queued + pos, control ? 1 : n);
cells += (size_t)width;
pos += n;
}
agent_buf_puts(&msg, suffix);
if (pos < queued_len) agent_buf_puts(&msg, "...");
agent_buf_puts(&msg, plain_suffix);
char *preview = agent_buf_take(&msg);
agent_buf out = {0};
size_t pos = 0, preview_len = strlen(preview);
pos = 0;
size_t preview_len = strlen(preview);
for (int row = 0; row < max_rows && pos < preview_len; row++) {
if (row) agent_buf_puts(&out, "\n");
if (stdout_is_tty()) agent_buf_puts(&out, AGENT_QUEUE_STYLE);
size_t part = preview_len - pos;
if (part > (size_t)cols) part = (size_t)cols;
agent_buf_append(&out, preview + pos, part);
cells = 0;
while (pos < preview_len) {
int width;
size_t n = linenoiseNextGrapheme(preview + pos, preview_len - pos, &width);
if (!n || cells + (size_t)width > (size_t)cols) break;
agent_buf_append(&out, preview + pos, n);
cells += (size_t)width;
pos += n;
}
if (stdout_is_tty()) agent_buf_puts(&out, "\x1b[0m");
pos += part;
}
agent_buf_puts(&out, "\n");
if (stdout_is_tty()) agent_buf_puts(&out, AGENT_STATUS_STYLE_START);
@@ -10271,10 +10315,17 @@ typedef struct {
char *input;
char prompt[160];
char status[4096];
bool prompt_dirty, status_dirty;
int old_stdin_flags;
bool active;
bool hidden;
bool output_line_open;
bool output_pending_wrap;
char output_utf8[4];
size_t output_utf8_len;
int output_escape;
bool output_zwj, output_regional;
int output_glyph_width;
bool prompt_below_output;
int output_col;
bool scroll_region;
@@ -10460,52 +10511,74 @@ static void editor_replace_input(agent_editor *ed, const char *text) {
if (text && text[0]) linenoiseEditInsert(&ed->edit, text, strlen(text));
}
/* Fallback cursor tracking for terminals that do not answer CPR quickly. It is
* intentionally approximate for wide Unicode; the CPR path handles exact
* positioning in normal interactive terminals. */
/* Track streamed text with the same widths as linenoise. Partial UTF-8 and
* escape sequences remain pending across worker-output chunks. */
static void editor_note_output(agent_editor *ed, const char *text, size_t len) {
int cols = ed->edit.cols > 0 ? (int)ed->edit.cols : 80;
for (size_t i = 0; i < len; i++) {
size_t start = i;
unsigned char c = (unsigned char)text[i];
if (c == 0x1b && i + 1 < len && text[i + 1] == '[') {
(void)start;
i += 2;
while (i < len) {
unsigned char e = (unsigned char)text[i];
if (e >= 0x40 && e <= 0x7e) break;
i++;
if (ed->output_escape) {
if (ed->output_escape == 1) ed->output_escape = c == '[' ? 2 : 0;
else if (c >= 0x40 && c <= 0x7e) ed->output_escape = 0;
continue;
}
if (c == 0x1b && !ed->output_utf8_len) { ed->output_escape = 1; continue; }
uint32_t cp = c;
if (ed->output_utf8_len || c >= 0x80) {
if (ed->output_utf8_len && (c & 0xc0) != 0x80) {
ed->output_utf8_len = 0;
ed->output_col = (ed->output_col + 1) % cols;
}
continue;
ed->output_utf8[ed->output_utf8_len++] = (char)c;
size_t n = linenoiseUtf8Decode(ed->output_utf8, ed->output_utf8_len, &cp);
if (!n) continue;
ed->output_utf8_len = 0;
}
if (c == '\n') {
if (cp == '\n' || cp == '\r') {
ed->output_col = 0;
ed->output_line_open = false;
ed->output_pending_wrap = false;
if (cp == '\n') ed->output_line_open = false;
ed->output_zwj = ed->output_regional = false;
ed->output_glyph_width = 0;
continue;
}
if (c == '\r') {
ed->output_col = 0;
if (cp == '\b') {
if (ed->output_pending_wrap) ed->output_col = cols - 1;
else if (ed->output_col > 0) ed->output_col--;
ed->output_pending_wrap = false;
continue;
}
if (c == '\b') {
if (ed->output_col > 0) ed->output_col--;
if (cp == '\t') {
int col = ed->output_pending_wrap ? cols - 1 : ed->output_col;
ed->output_col = (col | 7) + 1;
if (ed->output_col >= cols) ed->output_col = cols - 1;
ed->output_pending_wrap = false;
ed->output_line_open = true;
continue;
}
int width = 1;
if (c == '\t') {
width = 8 - (ed->output_col & 7);
} else if (c < 0x20 || c == 0x7f) {
width = 0;
} else if (c >= 0xc0) {
while (i + 1 < len && (((unsigned char)text[i + 1]) & 0xc0) == 0x80)
i++;
} else if ((c & 0xc0) == 0x80) {
width = 0;
int width = linenoiseCharacterWidth(cp);
bool regional = cp >= 0x1f1e6 && cp <= 0x1f1ff;
if (cp == 0x200d) {
ed->output_zwj = true;
continue;
}
if (cp == 0xfe0f && ed->output_glyph_width == 1) {
width = 1;
ed->output_glyph_width = 2;
} else if (width) {
if (ed->output_zwj || (regional && ed->output_regional)) {
width = 0;
ed->output_zwj = false;
ed->output_regional = false;
} else {
ed->output_glyph_width = width;
ed->output_regional = regional;
}
}
if (width > 0) {
if (ed->output_col + width > cols) ed->output_col = 0;
ed->output_col = (ed->output_col + width) % cols;
ed->output_pending_wrap = ed->output_col == 0;
ed->output_line_open = true;
}
}
@@ -10848,6 +10921,11 @@ static int editor_start(agent_editor *ed, const char *prompt,
ed->output_line_open = false;
ed->prompt_below_output = false;
ed->output_col = 0;
ed->output_pending_wrap = false;
ed->output_utf8_len = 0;
ed->output_escape = 0;
ed->output_zwj = ed->output_regional = false;
ed->output_glyph_width = 0;
ed->cpr_len = 0;
ed->paste_open = false;
ed->paste_start_pending = false;
@@ -10875,6 +10953,11 @@ static void editor_stop(agent_editor *ed) {
ed->output_line_open = false;
ed->prompt_below_output = false;
ed->output_col = 0;
ed->output_pending_wrap = false;
ed->output_utf8_len = 0;
ed->output_escape = 0;
ed->output_zwj = ed->output_regional = false;
ed->output_glyph_width = 0;
ed->cpr_len = 0;
ed->paste_open = false;
ed->paste_start_pending = false;
@@ -10925,16 +11008,20 @@ static void editor_show(agent_editor *ed) {
* colored parameter. */
write_all(STDOUT_FILENO, "\x1b[0m", 4);
linenoiseShow(&ed->edit);
ed->prompt_dirty = ed->status_dirty = false;
ed->last_prompt_redraw_time = now_sec();
ed->hidden = false;
}
static void editor_update_prompt(agent_editor *ed, const char *prompt) {
if (strcmp(ed->prompt, prompt)) ed->prompt_dirty = true;
snprintf(ed->prompt, sizeof(ed->prompt), "%s", prompt);
ed->edit.prompt = ed->prompt;
ed->edit.plen = strlen(ed->prompt);
}
static void editor_update_status(agent_editor *ed, const char *status) {
if (strcmp(ed->status, status ? status : "")) ed->status_dirty = true;
snprintf(ed->status, sizeof(ed->status), "%s", status ? status : "");
bool embedded_status = agent_footer_is_multiline(ed->status);
const char *status_start = stdout_is_tty() && !embedded_status ?
@@ -10945,29 +11032,16 @@ static void editor_update_status(agent_editor *ed, const char *status) {
status_start, status_end);
}
static void editor_set_prompt_status(agent_editor *ed, const char *prompt,
const char *status) {
bool prompt_changed = strcmp(ed->prompt, prompt) != 0;
bool status_changed = strcmp(ed->status, status ? status : "") != 0;
if (!ed->active || (!prompt_changed && !status_changed)) return;
if (ed->hidden) {
if (prompt_changed) editor_update_prompt(ed, prompt);
if (status_changed) editor_update_status(ed, status);
return;
}
editor_hide(ed);
if (prompt_changed) editor_update_prompt(ed, prompt);
if (status_changed) editor_update_status(ed, status);
editor_show(ed);
}
static void editor_redraw_visible_prompt(agent_editor *ed) {
if (!ed->active || !ed->scroll_region) return;
linenoiseBeginUpdate(STDOUT_FILENO);
editor_clear_prompt_region(ed);
editor_move_to_prompt_row(ed);
write_all(STDOUT_FILENO, "\x1b[0m", 4);
linenoiseShow(&ed->edit);
ed->prompt_dirty = ed->status_dirty = false;
ed->last_prompt_redraw_time = now_sec();
linenoiseEndUpdate();
}
static bool editor_prompt_redraw_due(agent_editor *ed) {
@@ -10980,25 +11054,66 @@ static bool editor_prompt_redraw_due(agent_editor *ed) {
return false;
}
static void editor_flush_prompt_status(agent_editor *ed, bool force) {
if (!ed->active || ed->hidden) return;
int rows, cols;
if (ed->scroll_region && editor_get_terminal_size(&rows, &cols) &&
(rows != ed->term_rows || cols != ed->term_cols)) {
ed->edit.cols = cols;
ed->prompt_dirty = true;
force = true;
}
if (!ed->prompt_dirty && !ed->status_dirty) return;
if (!force && !editor_prompt_redraw_due(ed)) return;
linenoiseBeginUpdate(STDOUT_FILENO);
if (ed->scroll_region) {
if (ed->prompt_dirty || !linenoiseRefreshStatus(&ed->edit))
editor_redraw_visible_prompt(ed);
} else {
editor_hide(ed);
editor_show(ed);
}
ed->prompt_dirty = ed->status_dirty = false;
ed->last_prompt_redraw_time = now_sec();
linenoiseEndUpdate();
}
static void editor_set_prompt_status(agent_editor *ed, const char *prompt,
const char *status) {
if (strcmp(ed->prompt, prompt)) editor_update_prompt(ed, prompt);
if (strcmp(ed->status, status ? status : "")) editor_update_status(ed, status);
editor_flush_prompt_status(ed, false);
}
static bool editor_write_scroll_output_preserve_prompt(agent_editor *ed,
const char *text,
size_t len,
bool settle_boundary) {
static const char sync_start[] = "\x1b[?2026h";
static const char sync_end[] = "\x1b[?2026l";
agent_input_buf_append(&ed->deferred_output, text, len);
if (!ed->deferred_output.len) return false;
agent_editor predicted = *ed;
editor_note_output(&predicted, ed->deferred_output.ptr,
ed->deferred_output.len);
bool at_boundary = predicted.output_line_open && predicted.output_col == 0;
if ((predicted.output_utf8_len || predicted.output_escape) && settle_boundary) {
if (predicted.output_utf8_len) {
ed->deferred_output.len -= predicted.output_utf8_len;
agent_input_buf_append(&ed->deferred_output, "\xef\xbf\xbd", 3);
} else {
while (ed->deferred_output.len &&
ed->deferred_output.ptr[--ed->deferred_output.len] != 0x1b) {}
}
predicted = *ed;
editor_note_output(&predicted, ed->deferred_output.ptr, ed->deferred_output.len);
}
bool at_boundary = predicted.output_pending_wrap;
if (predicted.output_utf8_len || predicted.output_escape) return false;
/* DECSC/DECRC does not reliably preserve pending auto-wrap. Keep a chunk
* that ends at the margin until the next content can trigger the wrap in
* the same terminal write. */
if (at_boundary && !settle_boundary) return false;
write_all(STDOUT_FILENO, sync_start, sizeof(sync_start) - 1);
linenoiseBeginUpdate(STDOUT_FILENO);
editor_restore_output_cursor(ed);
editor_write_terminal_text(ed->deferred_output.ptr,
ed->deferred_output.len);
@@ -11010,12 +11125,13 @@ static bool editor_write_scroll_output_preserve_prompt(agent_editor *ed,
write_all(STDOUT_FILENO, "\r\n", 2);
ed->output_col = 0;
ed->output_line_open = false;
ed->output_pending_wrap = false;
}
agent_input_buf_free(&ed->deferred_output);
editor_save_output_cursor(ed);
write_all(STDOUT_FILENO, "\x1b[0m", 4);
editor_move_to_prompt_cursor(ed);
write_all(STDOUT_FILENO, sync_end, sizeof(sync_end) - 1);
linenoiseEndUpdate();
ed->output_at_scroll_boundary = true;
return true;
}
@@ -11064,6 +11180,9 @@ static void test_agent_terminal_wrap_output_is_deferred(void) {
AGENT_TEST_ASSERT(ed.output_col == 0);
AGENT_TEST_ASSERT(!ed.output_line_open);
AGENT_TEST_ASSERT(!editor_write_scroll_output_preserve_prompt(&ed, "\xe4", 1, false));
AGENT_TEST_ASSERT(editor_write_scroll_output_preserve_prompt(&ed, NULL, 0, true));
AGENT_TEST_ASSERT(ed.deferred_output.len == 0 && ed.output_utf8_len == 0);
AGENT_TEST_ASSERT(dup2(saved_stdout, STDOUT_FILENO) >= 0);
close(saved_stdout);
fclose(sink);
@@ -11080,17 +11199,16 @@ static void editor_write_async(agent_editor *ed, const char *text, size_t len,
bool force_show) {
if (ed->scroll_region && ed->active && !ed->hidden &&
(len || ed->deferred_output.len)) {
editor_flush_prompt_status(ed, false);
bool prompt_changed = strcmp(ed->prompt, prompt) != 0;
bool status_changed = strcmp(ed->status, status ? status : "") != 0;
linenoiseBeginUpdate(STDOUT_FILENO);
editor_write_scroll_output_preserve_prompt(ed, text, len, force_show);
if (prompt_changed) editor_update_prompt(ed, prompt);
if (status_changed) editor_update_status(ed, status);
if ((force_show || editor_prompt_redraw_due(ed)) &&
(prompt_changed || status_changed))
{
editor_redraw_visible_prompt(ed);
}
editor_flush_prompt_status(ed, force_show);
linenoiseEndUpdate();
return;
}
@@ -11665,6 +11783,8 @@ static int run_agent(ds4_engine *engine, agent_config *cfg) {
editor_write_async(&editor, out, out_len, prompt, statusline, force_show);
} else {
editor_set_prompt_status(&editor, prompt, statusline);
editor_flush_prompt_status(&editor, st.state == AGENT_WORKER_IDLE ||
st.state == AGENT_WORKER_ERROR || st.state == AGENT_WORKER_STOPPED);
if (editor.hidden && (st.state == AGENT_WORKER_IDLE ||
st.state == AGENT_WORKER_ERROR ||
st.state == AGENT_WORKER_STOPPED))
+288 -177
View File
@@ -118,6 +118,60 @@
#include <stdint.h>
#include "linenoise.h"
static struct {
char *text;
size_t len, cap;
int fd, depth;
} terminal_update;
static int terminalWrite(int fd, const char *text, size_t len) {
while (len) {
ssize_t n = write(fd, text, len);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) { if (!n) errno = EIO; return -1; }
text += n;
len -= (size_t)n;
}
return 0;
}
int linenoiseWrite(int fd, const char *text, size_t len) {
if (!terminal_update.depth || terminal_update.fd != fd)
return terminalWrite(fd, text, len);
if (len > SIZE_MAX - terminal_update.len) { errno = ENOMEM; return -1; }
size_t need = terminal_update.len + len;
if (need > terminal_update.cap) {
size_t cap = need <= SIZE_MAX / 2 ? need * 2 : need;
char *p = realloc(terminal_update.text, cap);
if (!p) return -1;
terminal_update.text = p;
terminal_update.cap = cap;
}
if (len) memcpy(terminal_update.text + terminal_update.len, text, len);
terminal_update.len = need;
return 0;
}
void linenoiseBeginUpdate(int fd) {
if (terminal_update.depth++) return;
terminal_update.fd = fd;
terminal_update.len = 0;
linenoiseWrite(fd, "\x1b[?2026h", 8);
}
void linenoiseEndUpdate(void) {
if (!terminal_update.depth || terminal_update.depth > 1) {
if (terminal_update.depth) terminal_update.depth--;
return;
}
linenoiseWrite(terminal_update.fd, "\x1b[?2026l", 8);
terminal_update.depth = 0;
terminalWrite(terminal_update.fd, terminal_update.text, terminal_update.len);
free(terminal_update.text);
terminal_update.text = NULL;
terminal_update.len = terminal_update.cap = 0;
}
#define LINENOISE_DEFAULT_HISTORY_MAX_LEN 100
#define LINENOISE_MAX_LINE (1024*1024) // That will get dynamically allocated
#define LINENOISE_INITIAL_BUFLEN 4096
@@ -197,6 +251,25 @@ static uint32_t utf8DecodeChar(const char *s, size_t *len) {
return *p; /* Fallback for invalid sequences. */
}
/* Zero means more bytes are needed. Invalid sequences consume one byte. */
size_t linenoiseUtf8Decode(const char *s, size_t len, uint32_t *cp) {
if (!len) return 0;
unsigned char lead = (unsigned char)s[0];
size_t n = (size_t)utf8ByteLen(s[0]);
*cp = 0xfffd;
if (lead < 0x80) { *cp = lead; return 1; }
if (lead < 0xc2 || lead > 0xf4) return 1;
for (size_t i = 1; i < n && i < len; i++)
if (((unsigned char)s[i] & 0xc0) != 0x80) return 1;
if (len < n) return 0;
size_t decoded;
uint32_t value = utf8DecodeChar(s, &decoded);
if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff) ||
(n == 3 && value < 0x800) || (n == 4 && value < 0x10000)) return 1;
*cp = value;
return n;
}
/* Check if codepoint is a variation selector (emoji style modifiers). */
static int isVariationSelector(uint32_t cp) {
return cp == 0xFE0E || cp == 0xFE0F; /* Text/emoji style */
@@ -313,49 +386,8 @@ static size_t utf8PrevCharLen(const char *buf, size_t pos) {
* grapheme cluster at the current position. */
static size_t utf8NextCharLen(const char *buf, size_t pos, size_t len) {
if (pos >= len) return 0;
size_t total = 0;
size_t curpos = pos;
/* Get the first codepoint. */
size_t cplen;
uint32_t cp = utf8DecodeChar(buf + curpos, &cplen);
total += cplen;
curpos += cplen;
int isRI = isRegionalIndicator(cp);
/* Consume any extending characters that follow. */
while (curpos < len) {
size_t nextlen;
uint32_t nextcp = utf8DecodeChar(buf + curpos, &nextlen);
if (isZWJ(nextcp) && curpos + nextlen < len) {
/* ZWJ: include it and the following character. */
total += nextlen;
curpos += nextlen;
/* Get the character after ZWJ. */
nextcp = utf8DecodeChar(buf + curpos, &nextlen);
total += nextlen;
curpos += nextlen;
continue; /* Check for more extending after the joined char. */
} else if (isGraphemeExtend(nextcp)) {
/* Variation selector, skin tone, combining mark, etc. */
total += nextlen;
curpos += nextlen;
continue;
} else if (isRI && isRegionalIndicator(nextcp)) {
/* Second regional indicator for a flag pair. */
total += nextlen;
curpos += nextlen;
isRI = 0; /* Only pair once. */
continue;
} else {
break;
}
}
return total;
size_t n = linenoiseNextGrapheme(buf + pos, len - pos, NULL);
return n ? n : 1;
}
/* Return the display width of a Unicode codepoint. This is a heuristic
@@ -416,6 +448,34 @@ static int utf8CharWidth(uint32_t cp) {
return 1; /* Default: single width */
}
int linenoiseCharacterWidth(uint32_t cp) {
return utf8CharWidth(cp);
}
size_t linenoiseNextGrapheme(const char *s, size_t len, int *width) {
uint32_t cp;
size_t used = linenoiseUtf8Decode(s, len, &cp);
if (!used) { if (width) *width = 0; return 0; }
int w = utf8CharWidth(cp), regional = isRegionalIndicator(cp), joined = 0;
while (used < len) {
size_t n = linenoiseUtf8Decode(s + used, len - used, &cp);
if (!n) break;
if (joined) {
if (utf8CharWidth(cp) > w) w = utf8CharWidth(cp);
joined = 0;
} else if (isZWJ(cp)) {
joined = 1;
} else if (isGraphemeExtend(cp)) {
if (cp == 0xfe0f && w) w = 2;
} else if (regional && isRegionalIndicator(cp)) {
regional = 0;
} else break;
used += n;
}
if (width) *width = w;
return used;
}
/* If s[] points at an ANSI CSI escape sequence (e.g. a color change like
* ESC [ 1 ; 32 m), return its length in bytes. Otherwise return 0.
*
@@ -440,49 +500,25 @@ static size_t ansiEscapeLen(const char *s, size_t len) {
* as zero-width. */
static size_t utf8StrWidth(const char *s, size_t len) {
size_t width = 0;
size_t i = 0;
int after_zwj = 0; /* Track if previous char was ZWJ */
while (i < len) {
size_t clen;
uint32_t cp = utf8DecodeChar(s + i, &clen);
/* Skip ANSI CSI escape sequences entirely: they produce no
* glyph, so they must not contribute to the display width.
* Checked before the ZWJ state so a stray ZWJ immediately
* followed by ESC cannot swallow the ESC byte. */
if (cp == 0x1b) {
for (size_t i = 0; i < len;) {
if (s[i] == 0x1b) {
size_t skip = ansiEscapeLen(s + i, len - i);
if (skip > 0) {
i += skip;
continue;
}
if (skip) { i += skip; continue; }
}
if (after_zwj) {
/* Character after ZWJ: don't add width, it's joined.
* But do check for extending chars after it. */
after_zwj = 0;
} else {
width += utf8CharWidth(cp);
}
/* Check if this is a ZWJ - next char will be joined. */
if (isZWJ(cp)) {
after_zwj = 1;
}
i += clen;
int w = 0;
size_t n = linenoiseNextGrapheme(s + i, len - i, &w);
if (!n) break;
width += (size_t)w;
i += n;
}
return width;
}
/* Return the display width of a single UTF-8 character at position 's'. */
static int utf8SingleCharWidth(const char *s, size_t len) {
if (len == 0) return 0;
size_t clen;
uint32_t cp = utf8DecodeChar(s, &clen);
return utf8CharWidth(cp);
int width = 0;
linenoiseNextGrapheme(s, len, &width);
return width;
}
enum KEY_ACTION{
@@ -786,9 +822,10 @@ static void refreshLineWithCompletion(struct linenoiseState *ls, linenoiseComple
* the input was consumed by the completeLine() function to navigate the
* possible completions, and the caller should read for the next characters
* from stdin. */
static int linenoiseEditGrow(struct linenoiseState *l, size_t needed);
static int completeLine(struct linenoiseState *ls, int keypressed) {
linenoiseCompletions lc = { 0, NULL };
int nwritten;
char c = keypressed;
completionCallback(ls->buf,&lc);
@@ -817,9 +854,15 @@ static int completeLine(struct linenoiseState *ls, int keypressed) {
default:
/* Update buffer and return */
if (ls->completion_idx < lc.len) {
nwritten = snprintf(ls->buf,ls->buflen,"%s",
lc.cvec[ls->completion_idx]);
ls->len = ls->pos = nwritten;
size_t len = strlen(lc.cvec[ls->completion_idx]);
if (linenoiseEditGrow(ls, len) == -1) {
linenoiseBeep();
ls->in_completion = 0;
c = 0;
break;
}
memcpy(ls->buf, lc.cvec[ls->completion_idx], len + 1);
ls->len = ls->pos = len;
linenoiseFoldClear(ls);
}
ls->in_completion = 0;
@@ -924,11 +967,8 @@ static int linenoiseStatusRows(struct linenoiseState *l) {
static size_t abAppendUtf8Clipped(struct abuf *ab, const char *s, size_t len,
size_t maxcols) {
size_t i = 0, width = 0;
int after_zwj = 0;
while (i < len) {
size_t clen;
uint32_t cp = utf8DecodeChar(s + i, &clen);
if (cp == 0x1b) {
if (s[i] == 0x1b) {
size_t skip = ansiEscapeLen(s + i, len - i);
if (skip > 0) {
abAppend(ab, s + i, (int)skip);
@@ -937,22 +977,26 @@ static size_t abAppendUtf8Clipped(struct abuf *ab, const char *s, size_t len,
}
}
int cwidth = after_zwj ? 0 : utf8CharWidth(cp);
int cwidth;
size_t clen = linenoiseNextGrapheme(s + i, len - i, &cwidth);
if (!clen) break;
if (width + (size_t)cwidth > maxcols) break;
abAppend(ab, s + i, (int)clen);
width += cwidth;
after_zwj = isZWJ(cp);
if (!isZWJ(cp) && after_zwj) after_zwj = 0;
i += clen;
}
return width;
}
static void refreshStatusLinePart(struct abuf *ab, struct linenoiseState *l,
const char *s, size_t len) {
const char *s, size_t len, int row) {
size_t width = 0;
size_t cols = l->cols ? l->cols : 80;
abAppend(ab, "\r\n", 2);
if (row > 0) {
char seq[64];
int n = snprintf(seq, sizeof(seq), "\x1b[%d;1H", row);
abAppend(ab, seq, n);
} else abAppend(ab, "\r\n", 2);
/* Fill the complete status row explicitly. Some terminals do not paint
* cells cleared by EL with the current SGR background, so relying on
* ESC[0K makes an inverse-video status line stop at the text. Disable
@@ -969,17 +1013,37 @@ static void refreshStatusLinePart(struct abuf *ab, struct linenoiseState *l,
abAppend(ab, "\x1b[?7h", 5);
}
static void refreshStatusLine(struct abuf *ab, struct linenoiseState *l) {
static void refreshStatusLine(struct abuf *ab, struct linenoiseState *l, int row) {
const char *p = l->status;
while (p && *p) {
const char *nl = strchr(p, '\n');
size_t len = nl ? (size_t)(nl - p) : strlen(p);
refreshStatusLinePart(ab, l, p, len);
refreshStatusLinePart(ab, l, p, len, row);
if (row > 0) row++;
if (!nl) break;
p = nl + 1;
}
}
int linenoiseRefreshStatus(struct linenoiseState *l) {
if (!l->oldrows || l->screen_cursor_row <= 0 || l->screen_cursor_col <= 0 ||
l->oldstatusrows != (size_t)linenoiseStatusRows(l)) return 0;
char seq[64];
struct abuf ab;
abInit(&ab);
int last_input_row = l->screen_cursor_row - l->oldrpos + (int)l->oldrows;
int n = snprintf(seq, sizeof(seq), "\x1b[0m\x1b[%d;1H", last_input_row);
abAppend(&ab, seq, n);
refreshStatusLine(&ab, l, last_input_row + 1);
n = snprintf(seq, sizeof(seq), "\x1b[0m\x1b[%d;%dH", l->screen_cursor_row, l->screen_cursor_col);
abAppend(&ab, seq, n);
linenoiseBeginUpdate(l->ofd);
linenoiseWrite(l->ofd, ab.b, ab.len);
linenoiseEndUpdate();
abFree(&ab);
return 1;
}
/* A fold is a display-only replacement for a range in l->buf. The edited
* buffer always keeps the real bytes; refresh code asks linenoiseRenderBuffer()
* for a temporary printable version plus the cursor position inside it. */
@@ -1409,7 +1473,7 @@ static void refreshSingleLine(struct linenoiseState *l, int flags) {
abAppend(&ab,seq,strlen(seq));
}
if (write(fd,ab.b,ab.len) == -1) {} /* Can't recover from write error. */
if (linenoiseWrite(fd,ab.b,ab.len) == -1) {} /* Can't recover from write error. */
abFree(&ab);
free(render);
}
@@ -1485,7 +1549,7 @@ static void refreshMultiLine(struct linenoiseState *l, int flags) {
* phase first, then let the owner resize/reposition the prompt area before
* this refresh writes the new prompt. */
if ((flags & REFRESH_WRITE) && l->layout_callback) {
if (write(fd,ab.b,ab.len) == -1) {} /* Can't recover. */
if (linenoiseWrite(fd,ab.b,ab.len) == -1) {} /* Can't recover. */
abFree(&ab);
abInit(&ab);
layout_prompt_row =
@@ -1532,7 +1596,7 @@ static void refreshMultiLine(struct linenoiseState *l, int flags) {
lndebug("rpos2 %d", rpos2);
if (linenoiseStatusActive(l)) {
refreshStatusLine(&ab, l);
refreshStatusLine(&ab, l, layout_prompt_row > 0 ? layout_prompt_row + rows : 0);
}
/* Set column. */
@@ -1581,7 +1645,7 @@ static void refreshMultiLine(struct linenoiseState *l, int flags) {
l->screen_cursor_col = 0;
}
if (write(fd,ab.b,ab.len) == -1) {} /* Can't recover from write error. */
if (linenoiseWrite(fd,ab.b,ab.len) == -1) {} /* Can't recover from write error. */
abFree(&ab);
free(render);
}
@@ -1589,10 +1653,14 @@ static void refreshMultiLine(struct linenoiseState *l, int flags) {
/* Calls the two low level functions refreshSingleLine() or
* refreshMultiLine() according to the selected mode. */
static void refreshLineWithFlags(struct linenoiseState *l, int flags) {
struct winsize ws;
if (ioctl(l->ofd, TIOCGWINSZ, &ws) == 0 && ws.ws_col) l->cols = ws.ws_col;
linenoiseBeginUpdate(l->ofd);
if (mlmode || linenoiseStatusActive(l) || l->oldstatusrows)
refreshMultiLine(l,flags);
else
refreshSingleLine(l,flags);
linenoiseEndUpdate();
}
/* Utility function to avoid specifying REFRESH_ALL all the times. */
@@ -1618,6 +1686,11 @@ void linenoiseShow(struct linenoiseState *l) {
* used by clients that want Ctrl+C to cancel the current edit without tearing
* down the line editor. */
void linenoiseEditClear(struct linenoiseState *l) {
l->pending_key_len = 0;
free(l->paste_buf);
l->paste_buf = NULL;
l->paste_len = l->paste_cap = l->paste_match = 0;
l->paste_active = l->paste_overflowed = 0;
l->buf[0] = '\0';
l->len = 0;
l->pos = 0;
@@ -1701,9 +1774,9 @@ int linenoiseEditInsert(struct linenoiseState *l, const char *c, size_t clen) {
/* Avoid a full update of the line in the trivial case:
* single-width char, no hints, fits in one line. */
if (maskmode == 1) {
if (write(l->ofd,"*",1) == -1) return -1;
if (linenoiseWrite(l->ofd,"*",1) == -1) return -1;
} else {
if (write(l->ofd,c,clen) == -1) return -1;
if (linenoiseWrite(l->ofd,c,clen) == -1) return -1;
}
return 0;
}
@@ -1970,6 +2043,10 @@ int linenoiseEditStart(struct linenoiseState *l, int stdin_fd, int stdout_fd, ch
l->queued_input_len = 0;
l->queued_input_pos = 0;
l->queued_input_cap = 0;
l->pending_key_len = 0;
l->paste_buf = NULL;
l->paste_len = l->paste_cap = l->paste_match = 0;
l->paste_active = l->paste_overflowed = 0;
linenoiseFoldClear(l);
/* Enter raw mode. */
@@ -1995,7 +2072,7 @@ int linenoiseEditStart(struct linenoiseState *l, int stdin_fd, int stdout_fd, ch
* initially is just an empty string. */
linenoiseHistoryAdd("");
if (write(l->ofd,prompt,l->plen) == -1) return -1;
if (linenoiseWrite(l->ofd,prompt,l->plen) == -1) return -1;
l->oldrows = 1;
return 0;
}
@@ -2050,13 +2127,13 @@ static int pasteBufferAppend(struct linenoiseState *l, char **buf, size_t *cap,
/* Read a bracketed paste until ESC[201~ and insert the real bytes. If folding
* is needed, remember the inserted range so only rendering is shortened. */
static void linenoiseEditPaste(struct linenoiseState *l) {
static int linenoiseEditPaste(struct linenoiseState *l) {
static const char END[] = "\x1b[201~";
const size_t ENDLEN = sizeof(END)-1;
char *buf = NULL;
size_t cap = 0, len = 0, match = 0;
char *buf = l->paste_buf;
size_t cap = l->paste_cap, len = l->paste_len, match = l->paste_match;
size_t maxlen = l->buflen_max ? l->buflen_max : l->buflen;
int overflowed = 0;
int overflowed = l->paste_overflowed;
maxlen = maxlen > l->len ? maxlen - l->len : 0;
if (maxlen > PASTE_MAX_BYTES) maxlen = PASTE_MAX_BYTES;
@@ -2065,7 +2142,17 @@ static void linenoiseEditPaste(struct linenoiseState *l) {
while (1) {
char c;
if (linenoiseReadByte(l, &c) != 1) break;
int n = linenoiseReadByte(l, &c);
if (n != 1) {
if (n < 0 && errno == EINTR) continue;
l->paste_buf = buf;
l->paste_cap = cap;
l->paste_len = len;
l->paste_match = match;
l->paste_overflowed = overflowed;
l->paste_active = 1;
return n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) ? 0 : -1;
}
/* Track a possible ESC[201~ terminator without copying it into the
* paste. If it turns out to be ordinary input, flush the partial
@@ -2092,12 +2179,16 @@ static void linenoiseEditPaste(struct linenoiseState *l) {
overflowed = 1;
}
l->paste_buf = NULL;
l->paste_len = l->paste_cap = l->paste_match = 0;
l->paste_active = l->paste_overflowed = 0;
if (overflowed) {
free(buf);
linenoiseBeep();
return;
return 1;
}
if (buf == NULL) return;
if (buf == NULL) return 1;
{
/* Normalize pasted CR and CRLF to LF, so the edit buffer uses one
@@ -2114,12 +2205,27 @@ static void linenoiseEditPaste(struct linenoiseState *l) {
len = w;
}
/* Paste is text input. Reject malformed UTF-8 and terminal controls rather
* than putting invisible commands or partial characters in the edit buffer. */
for (size_t pos = 0; pos < len;) {
uint32_t cp;
size_t n = linenoiseUtf8Decode(buf + pos, len - pos, &cp);
if (!n || (n == 1 && (unsigned char)buf[pos] >= 0x80) ||
(cp < 32 && cp != '\n' && cp != '\t') || cp == 127 ||
(cp >= 0x80 && cp <= 0x9f)) {
free(buf);
linenoiseBeep();
return 1;
}
pos += n;
}
if (!maskmode && shouldFoldText(buf,len)) {
size_t start = l->pos;
if (linenoiseEditInsertNoRefresh(l,buf,len) == -1) {
free(buf);
linenoiseBeep();
return;
return 1;
}
linenoiseFoldAdd(l,start,start+len);
refreshLine(l);
@@ -2127,6 +2233,7 @@ static void linenoiseEditPaste(struct linenoiseState *l) {
linenoiseEditInsert(l,buf,len);
}
free(buf);
return 1;
}
char *linenoiseEditMore = "If you see this, you are misusing the API: when linenoiseEditFeed() is called, if it returns linenoiseEditMore the user is yet editing the line. See the README file for more information.";
@@ -2156,23 +2263,72 @@ char *linenoiseEditFeed(struct linenoiseState *l) {
char c;
int nread;
char seq[3];
char seq[32];
size_t seq_len = 0;
nread = linenoiseReadByte(l, &c);
if (l->paste_active)
return linenoiseEditPaste(l) < 0 ? NULL : linenoiseEditMore;
do { nread = linenoiseReadByte(l, &c); } while (nread < 0 && errno == EINTR);
if (nread < 0) {
return (errno == EAGAIN || errno == EWOULDBLOCK) ? linenoiseEditMore : NULL;
} else if (nread == 0) {
return NULL;
}
/* Only autocomplete when the callback is set. completeLine()
* returns the character to be handled next, or zero when the
* key was consumed to navigate completions. */
if ((l->in_completion || c == 9 /* TAB */) && completionCallback != NULL) {
int retval = completeLine(l,c);
/* Read next character when 0 */
if (retval == 0) return linenoiseEditMore;
c = retval;
/* Resolve completion on the first byte of a key, including UTF-8. An ESC
* cancels the preview immediately but can still begin a navigation key. */
if (!l->pending_key_len && (l->in_completion || c == '\t') && completionCallback) {
int next = completeLine(l, c);
if (!next && c != ESC) return linenoiseEditMore;
if (next) c = (char)next;
}
/* Packet boundaries are unrelated to characters or keys. Keep incomplete
* input in the editor, rather than blocking the application's event loop. */
if (l->pending_key_len && l->pending_key[0] != ESC) {
if (((unsigned char)c & 0xc0) != 0x80) {
l->pending_key_len = 0;
if (linenoiseEditInsert(l, "\xef\xbf\xbd", 3)) return NULL;
} else {
l->pending_key[l->pending_key_len++] = c;
size_t need = (size_t)utf8ByteLen(l->pending_key[0]);
if (l->pending_key_len < need) return linenoiseEditMore;
size_t decoded;
uint32_t cp = utf8DecodeChar(l->pending_key, &decoded);
int valid = cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff) &&
(need != 2 || cp >= 0x80) && (need != 3 || cp >= 0x800) &&
(need != 4 || cp >= 0x10000);
l->pending_key_len = 0;
if (linenoiseEditInsert(l, valid ? l->pending_key : "\xef\xbf\xbd", valid ? need : 3))
return NULL;
return linenoiseEditMore;
}
}
if (l->pending_key_len) {
if (l->pending_key_len == 1 && c != '[' && c != 'O') {
l->pending_key_len = 0; /* Preserve the character after a lone ESC. */
} else {
l->pending_key[l->pending_key_len++] = c;
if (l->pending_key_len > 2 && c >= '@' && c <= '~') {
seq_len = l->pending_key_len - 1;
memcpy(seq, l->pending_key + 1, seq_len);
l->pending_key_len = 0;
c = ESC;
} else {
if (l->pending_key_len == sizeof(l->pending_key)) l->pending_key_len = 0;
return linenoiseEditMore;
}
}
}
if (!seq_len && (c == ESC || (unsigned char)c >= 0xc2)) {
if ((unsigned char)c > 0xf4) {
if (linenoiseEditInsert(l, "\xef\xbf\xbd", 3)) return NULL;
} else {
l->pending_key[0] = c;
l->pending_key_len = 1;
}
return linenoiseEditMore;
}
switch(c) {
@@ -2240,39 +2396,13 @@ char *linenoiseEditFeed(struct linenoiseState *l) {
linenoiseEditHistoryNext(l, LINENOISE_HISTORY_NEXT);
break;
case ESC: /* escape sequence */
/* Read the next two bytes representing the escape sequence.
* Use two calls to handle slow terminals returning the two
* chars at different times. */
if (linenoiseReadByte(l, seq) != 1) break;
if (linenoiseReadByte(l, seq + 1) != 1) break;
/* ESC [ sequences. */
if (seq[0] == '[') {
if (seq[1] >= '0' && seq[1] <= '9') {
char param[8];
size_t plen = 1;
char final = 0;
param[0] = seq[1];
while (plen < sizeof(param)) {
char p;
if (linenoiseReadByte(l, &p) != 1) break;
if (p >= '0' && p <= '9') {
param[plen++] = p;
} else {
final = p;
break;
}
}
if (final == '~') {
if (plen == 1 && param[0] == '3') {
linenoiseEditDelete(l);
} else if (plen == 3 && memcmp(param,"200",3) == 0) {
linenoiseEditPaste(l);
}
}
if (seq[0] == '[' || seq[0] == 'O') {
if (seq_len == 3 && !memcmp(seq, "[3~", 3)) {
linenoiseEditDelete(l);
} else if (seq_len == 5 && !memcmp(seq, "[200~", 5)) {
if (linenoiseEditPaste(l) < 0) return NULL;
} else {
switch(seq[1]) {
switch(seq[seq_len - 1]) {
case 'A': /* Up */
linenoiseEditHistoryNext(l, LINENOISE_HISTORY_PREV);
break;
@@ -2295,35 +2425,11 @@ char *linenoiseEditFeed(struct linenoiseState *l) {
}
}
/* ESC O sequences. */
else if (seq[0] == 'O') {
switch(seq[1]) {
case 'H': /* Home */
linenoiseEditMoveHome(l);
break;
case 'F': /* End*/
linenoiseEditMoveEnd(l);
break;
}
}
break;
default:
/* Handle UTF-8 multi-byte sequences. When we receive the first byte
* of a multi-byte UTF-8 character, read the remaining bytes to
* complete the sequence before inserting. */
{
char utf8[4];
int utf8len = utf8ByteLen(c);
utf8[0] = c;
if (utf8len > 1) {
/* Read remaining bytes of the UTF-8 sequence. */
int i;
for (i = 1; i < utf8len; i++) {
if (linenoiseReadByte(l, utf8+i) != 1) break;
}
}
if (linenoiseEditInsert(l, utf8, utf8len)) return NULL;
}
if ((unsigned char)c >= 0x80) {
if (linenoiseEditInsert(l, "\xef\xbf\xbd", 3)) return NULL;
} else if ((unsigned char)c >= 32 && linenoiseEditInsert(l, &c, 1)) return NULL;
break;
case CTRL_U: /* Ctrl+u, delete the whole line. */
l->buf[0] = '\0';
@@ -2381,6 +2487,11 @@ void linenoiseEditStop(struct linenoiseState *l) {
if (write(l->ofd, "\r\x1b[0K", 5) == -1) {}
}
free(l->queued_input);
free(l->paste_buf);
l->paste_buf = NULL;
l->paste_len = l->paste_cap = l->paste_match = 0;
l->paste_active = l->paste_overflowed = 0;
l->pending_key_len = 0;
l->queued_input = NULL;
l->queued_input_len = 0;
l->queued_input_pos = 0;
+15
View File
@@ -44,6 +44,7 @@ extern "C" {
#endif
#include <stddef.h> /* For size_t. */
#include <stdint.h>
extern char *linenoiseEditMore;
@@ -91,6 +92,11 @@ struct linenoiseState {
size_t queued_input_len;
size_t queued_input_pos;
size_t queued_input_cap;
char pending_key[32]; /* Incomplete UTF-8 or escape sequence. */
size_t pending_key_len;
char *paste_buf;
size_t paste_len, paste_cap, paste_match;
int paste_active, paste_overflowed;
};
typedef struct linenoiseCompletions {
@@ -113,6 +119,15 @@ void linenoiseEditSetLayoutCallback(struct linenoiseState *l,
void linenoiseEditStop(struct linenoiseState *l);
void linenoiseHide(struct linenoiseState *l);
void linenoiseShow(struct linenoiseState *l);
/* UI-thread-only buffered updates; nested updates share one terminal frame. */
void linenoiseBeginUpdate(int fd);
void linenoiseEndUpdate(void);
int linenoiseWrite(int fd, const char *text, size_t len);
/* Repaint only the footer if its geometry and absolute cursor are known. */
int linenoiseRefreshStatus(struct linenoiseState *l);
size_t linenoiseUtf8Decode(const char *text, size_t len, uint32_t *cp);
int linenoiseCharacterWidth(uint32_t cp);
size_t linenoiseNextGrapheme(const char *text, size_t len, int *width);
/* Blocking API. */
char *linenoise(const char *prompt);
+267 -1
View File
@@ -2,6 +2,266 @@
#define DS4_AGENT_TEST_NO_MAIN
#include "../ds4_agent.c"
static const char *test_output_dir;
static void test_fixture(const char *name, const char *data, size_t len) {
if (!test_output_dir) return;
char path[PATH_MAX];
snprintf(path, sizeof(path), "%s/%s", test_output_dir, name);
FILE *fp = fopen(path, "wb");
AGENT_TEST_ASSERT(fp != NULL);
if (!fp) return;
AGENT_TEST_ASSERT(fwrite(data, 1, len, fp) == len);
AGENT_TEST_ASSERT(fclose(fp) == 0);
}
static void test_completion(const char *text, linenoiseCompletions *completions) {
(void)text;
linenoiseAddCompletion(completions, "example");
}
static void test_fragmented_terminal_input(void) {
int input[2];
AGENT_TEST_ASSERT(pipe(input) == 0);
fcntl(input[0], F_SETFL, O_NONBLOCK);
FILE *sink = tmpfile();
AGENT_TEST_ASSERT(sink != NULL);
if (!sink) { close(input[0]); close(input[1]); return; }
setenv("LINENOISE_ASSUME_TTY", "1", 1);
struct linenoiseState l = {0};
char buffer[1024] = "";
l.ifd = input[0]; l.ofd = fileno(sink);
l.buf = buffer; l.buflen = sizeof(buffer) - 1;
l.cols = 80; l.prompt = "";
const char *samples[] = {"\xc3\xa9", "\xe4\xb8\xad", "\xf0\x9f\x98\x80"};
for (size_t s = 0; s < sizeof(samples)/sizeof(samples[0]); s++) {
const char *sample = samples[s];
for (size_t split = 1; split < strlen(sample); split++) {
linenoiseEditClear(&l);
for (size_t i = 0; i < split; i++)
AGENT_TEST_ASSERT(linenoiseEditFeedByte(&l, sample[i]) == linenoiseEditMore);
AGENT_TEST_ASSERT(l.len == 0);
AGENT_TEST_ASSERT(linenoiseEditFeed(&l) == linenoiseEditMore);
AGENT_TEST_ASSERT(l.len == 0);
for (size_t i = split; i < strlen(sample); i++)
AGENT_TEST_ASSERT(linenoiseEditFeedByte(&l, sample[i]) == linenoiseEditMore);
AGENT_TEST_ASSERT(!strcmp(l.buf, sample));
}
}
linenoiseEditClear(&l);
const char sequence[] = "ab\x1b[DZ\x1b[3~\x1b[H!";
for (size_t i = 0; i < sizeof(sequence) - 1; i++) {
AGENT_TEST_ASSERT(linenoiseEditFeedByte(&l, sequence[i]) == linenoiseEditMore);
AGENT_TEST_ASSERT(linenoiseEditFeed(&l) == linenoiseEditMore);
}
AGENT_TEST_ASSERT(!strcmp(l.buf, "!aZ"));
linenoiseEditClear(&l);
const char paste[] = "\x1b[200~one\r\n\xe4\xb8\xad\nthree\x1b[201~";
for (size_t i = 0; i < sizeof(paste) - 1; i++) {
AGENT_TEST_ASSERT(linenoiseEditFeedByte(&l, paste[i]) == linenoiseEditMore);
AGENT_TEST_ASSERT(linenoiseEditFeed(&l) == linenoiseEditMore);
if (i < sizeof(paste) - 2) AGENT_TEST_ASSERT(l.len == 0);
}
AGENT_TEST_ASSERT(!strcmp(l.buf, "one\n\xe4\xb8\xad\nthree"));
linenoiseEditClear(&l);
linenoiseEditFeedByte(&l, '\xe4');
linenoiseEditFeedByte(&l, 'X');
AGENT_TEST_ASSERT(!strcmp(l.buf, "\xef\xbf\xbdX"));
linenoiseEditClear(&l);
const char invalid_paste[] = "\x1b[200~bad\xe4\x1b[201~";
for (size_t i = 0; i < sizeof(invalid_paste) - 1; i++)
AGENT_TEST_ASSERT(linenoiseEditFeedByte(&l, invalid_paste[i]) == linenoiseEditMore);
AGENT_TEST_ASSERT(l.len == 0 && !l.paste_active);
linenoiseSetCompletionCallback(test_completion);
linenoiseEditFeedByte(&l, 'e');
linenoiseEditFeedByte(&l, '\t');
AGENT_TEST_ASSERT(l.in_completion);
linenoiseEditFeedByte(&l, '\x1b');
AGENT_TEST_ASSERT(!l.in_completion && !strcmp(l.buf, "e"));
linenoiseEditFeedByte(&l, '[');
linenoiseEditFeedByte(&l, 'D');
AGENT_TEST_ASSERT(l.pos == 0);
linenoiseEditClear(&l);
linenoiseEditFeedByte(&l, '\t');
const char unicode[] = "\xe4\xb8\xad";
for (size_t i = 0; i < sizeof(unicode) - 1; i++) linenoiseEditFeedByte(&l, unicode[i]);
AGENT_TEST_ASSERT(!l.in_completion && !strcmp(l.buf, "example\xe4\xb8\xad"));
linenoiseEditClear(&l);
l.buflen = 3;
linenoiseEditFeedByte(&l, 'e');
linenoiseEditFeedByte(&l, '\t');
linenoiseEditFeedByte(&l, ' ');
AGENT_TEST_ASSERT(!l.in_completion && !strcmp(l.buf, "e") && l.len == 1);
l.buflen = sizeof(buffer) - 1;
linenoiseSetCompletionCallback(NULL);
free(l.queued_input);
free(l.paste_buf);
close(input[0]); close(input[1]); fclose(sink);
unsetenv("LINENOISE_ASSUME_TTY");
}
static void test_markdown_literals(void) {
const char *input[] = {"Use *.c files.", "The literal is \\*.", "An unmatched `tick",
"**bold** and *italic* and `code`.", "``a ` b``", "*unclosed",
"* list item\n", "trailing \\", "**unclosed", "`a``", "\\`literal\\`"};
const char *expected[] = {"Use *.c files.", "The literal is *.", "An unmatched `tick",
"bold and italic and code.", "a ` b", "*unclosed",
"* list item\n", "trailing \\", "**unclosed", "`a``", "`literal`"};
for (size_t i = 0; i < sizeof(input)/sizeof(input[0]); i++) {
agent_tail_capture capture = {.cap = 16384};
agent_token_renderer r = {.capture = &capture, .format_markdown = true};
for (size_t j = 0; j < strlen(input[i]); j++) renderer_markdown_feed(&r, input[i][j]);
renderer_markdown_finish(&r);
renderer_flush_utf8(&r);
size_t len;
char *out = agent_tail_capture_take(&capture, &len);
AGENT_TEST_ASSERT(!strcmp(out, expected[i]));
if (strcmp(out, expected[i])) fprintf(stderr, "markdown: %s => %s\n", input[i], out);
free(out);
}
agent_tail_capture capture = {.cap = 20000};
agent_token_renderer r = {.capture = &capture, .format_markdown = true};
renderer_markdown_feed(&r, '*');
for (int i = 0; i < 8192; i++) renderer_markdown_feed(&r, 'x');
renderer_markdown_finish(&r);
size_t len;
char *out = agent_tail_capture_take(&capture, &len);
AGENT_TEST_ASSERT(len == 8193 && out[0] == '*');
free(out);
}
static void test_unicode_output_and_footer(void) {
agent_editor ed = {0};
ed.edit.cols = 80;
char ascii[78];
memset(ascii, 'a', sizeof(ascii));
editor_note_output(&ed, ascii, sizeof(ascii));
editor_note_output(&ed, "\xe4", 1);
AGENT_TEST_ASSERT(ed.output_col == 78 && ed.output_utf8_len == 1);
editor_note_output(&ed, "\xb8\xad", 2);
AGENT_TEST_ASSERT(ed.output_col == 0 && ed.output_pending_wrap);
editor_note_output(&ed, "\xcc\x81", 2);
AGENT_TEST_ASSERT(ed.output_col == 0 && ed.output_pending_wrap);
editor_note_output(&ed, "x", 1);
AGENT_TEST_ASSERT(ed.output_col == 1 && !ed.output_pending_wrap);
editor_note_output(&ed, "\r", 1);
AGENT_TEST_ASSERT(ed.output_col == 0 && !ed.output_pending_wrap);
const char family[] = "\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x92\xbb";
for (size_t i = 0; i < sizeof(family) - 1; i++) editor_note_output(&ed, family + i, 1);
AGENT_TEST_ASSERT(ed.output_col == 2);
int width;
AGENT_TEST_ASSERT(linenoiseNextGrapheme(family, sizeof(family) - 1, &width) == sizeof(family) - 1 && width == 2);
const char flag[] = "\xf0\x9f\x87\xae\xf0\x9f\x87\xb9";
AGENT_TEST_ASSERT(linenoiseNextGrapheme(flag, sizeof(flag) - 1, &width) == sizeof(flag) - 1 && width == 2);
editor_note_output(&ed, flag, sizeof(flag) - 1);
AGENT_TEST_ASSERT(ed.output_col == 4);
editor_note_output(&ed, "\x1b[", 2);
AGENT_TEST_ASSERT(ed.output_escape == 2);
editor_note_output(&ed, "31mX", 4);
AGENT_TEST_ASSERT(ed.output_col == 5 && !ed.output_escape);
agent_prompt_queue q = {0};
char queued[181];
for (int i = 0; i < 60; i++) memcpy(queued + i * 3, "\xe4\xb8\xad", 3);
queued[180] = 0;
agent_prompt_queue_push(&q, xstrdup(queued));
agent_status st = {0};
char footer[4096];
build_footer_text(&st, &q, 40, footer, sizeof(footer));
test_fixture("queue-footer.txt", footer, strlen(footer));
for (size_t pos = 0; pos < strlen(footer);) {
uint32_t cp;
size_t n = linenoiseUtf8Decode(footer + pos, strlen(footer) - pos, &cp);
AGENT_TEST_ASSERT(n && cp != 0xfffd);
if (!n) break;
pos += n;
}
agent_prompt_queue_free(&q);
}
static void test_footer_only_updates(void) {
FILE *sink = tmpfile();
AGENT_TEST_ASSERT(sink != NULL);
if (!sink) return;
int saved = dup(STDOUT_FILENO);
dup2(fileno(sink), STDOUT_FILENO);
agent_editor ed = {.active = true, .scroll_region = true, .term_rows = 24,
.term_cols = 80, .output_bottom = 22, .prompt_row = 23};
snprintf(ed.prompt, sizeof(ed.prompt), "ds4-agent> ");
snprintf(ed.status, sizeof(ed.status), "generation 0");
char buffer[] = "draft";
ed.edit = (struct linenoiseState){.ifd = -1, .ofd = STDOUT_FILENO,
.buf = buffer, .buflen = sizeof(buffer), .len = 5, .pos = 5, .oldpos = 5,
.prompt = ed.prompt, .plen = strlen(ed.prompt), .cols = 80,
.oldrows = 1, .oldstatusrows = 1, .oldrpos = 1,
.screen_cursor_row = 23, .screen_cursor_col = 17};
linenoiseEditSetStatus(&ed.edit, ed.status, "", "");
const char initial[] = "\x1b[23;1Hds4-agent> draft\r\ngeneration 0\x1b[23;17H";
write_all(STDOUT_FILENO, initial, sizeof(initial) - 1);
editor_set_prompt_status(&ed, ed.prompt, "generation 1");
off_t first = lseek(STDOUT_FILENO, 0, SEEK_CUR);
editor_set_prompt_status(&ed, ed.prompt, "generation 2");
AGENT_TEST_ASSERT(lseek(STDOUT_FILENO, 0, SEEK_CUR) == first && ed.status_dirty);
ed.last_prompt_redraw_time -= 1;
editor_set_prompt_status(&ed, ed.prompt, "generation 2");
AGENT_TEST_ASSERT(!ed.status_dirty);
editor_set_prompt_status(&ed, ed.prompt, "done");
editor_flush_prompt_status(&ed, true);
AGENT_TEST_ASSERT(!ed.status_dirty && !strcmp(buffer, "draft"));
dup2(saved, STDOUT_FILENO);
close(saved);
fseek(sink, 0, SEEK_END);
size_t len = (size_t)ftell(sink);
rewind(sink);
char *text = xmalloc(len + 1);
AGENT_TEST_ASSERT(fread(text, 1, len, sink) == len);
text[len] = 0;
AGENT_TEST_ASSERT(strstr(text, "\x1b[?2026h") && !strstr(text, "\x1b[0K"));
test_fixture("status.ansi", text, len);
free(text);
free(ed.edit.status); free(ed.edit.status_start); free(ed.edit.status_end);
fclose(sink);
}
/* Model-free real-PTY driver for tests/ds4_agent_terminal_test.py. */
static int test_terminal_driver(void) {
agent_editor ed = {0};
linenoiseSetMultiLine(1);
if (editor_start(&ed, "ds4-agent> ", "ready", NULL)) return 2;
double start = now_sec(), next = start;
char *answer = NULL;
unsigned tick = 0;
while (now_sec() - start < 10 && !answer) {
struct pollfd pfd = {.fd = STDIN_FILENO, .events = POLLIN};
poll(&pfd, 1, 10);
if (pfd.revents & POLLIN) editor_read_stdin(&ed);
while (linenoiseEditQueuedInput(&ed.edit)) {
char *line = linenoiseEditFeed(&ed.edit);
if (line == linenoiseEditMore) continue;
if (line) answer = line;
else answer = xstrdup("<input error>");
break;
}
if (now_sec() >= next) {
char status[80];
snprintf(status, sizeof(status), "generation %u", ++tick);
if (tick % 4 == 0) {
const char output[] = "model output \xe4\xb8\xad\n";
editor_write_async(&ed, output, sizeof(output) - 1, "ds4-agent> ", status, false);
} else editor_set_prompt_status(&ed, "ds4-agent> ", status);
next = now_sec() + 0.05;
}
}
editor_stop(&ed);
editor_restore_terminal_layout(&ed);
if (!answer) return 3;
printf("\nRESULT:");
for (size_t i = 0; i < strlen(answer); i++) printf("%02x", (unsigned char)answer[i]);
puts("");
free(answer);
return 0;
}
static void test_observation_error_is_not_context_exhaustion(void) {
agent_worker worker = {0};
ds4_tokens_push(&worker.transcript, 42);
@@ -21,9 +281,15 @@ static void test_observation_error_is_not_context_exhaustion(void) {
ds4_tokens_free(&worker.transcript);
}
int main(void) {
int main(int argc, char **argv) {
if (argc == 2 && !strcmp(argv[1], "--terminal-driver")) return test_terminal_driver();
if (argc == 3 && !strcmp(argv[1], "--terminal-fixtures")) test_output_dir = argv[2];
ds4_agent_unit_tests_run();
test_observation_error_is_not_context_exhaustion();
test_fragmented_terminal_input();
test_markdown_literals();
test_unicode_output_and_footer();
test_footer_only_updates();
if (agent_test_failures) {
fprintf(stderr, "ds4-agent tests: %d failure(s)\n",
agent_test_failures);