ascii-chat
Binary v0.15.83-53-gb711d8720
Library lib/v0.3.2-701-gb711d8720
Video chat in your terminal
Loading...
Searching...
No Matches
tui/input.c
Go to the documentation of this file.
1
2/* Storage capacities in elements; text buffers include the terminating NUL. */
3#define TUI_INPUT_MESSAGE_BUFFER_SIZE 512
4#define TUI_INPUT_BURST_BUFFER_SIZE 8192
5#define TUI_INPUT_BUFFER_BUFFER_SIZE 8192
6#define TUI_INPUT_TIMED_PROMPT_BUFFER_SIZE 8192
7#define TUI_INPUT_PASTED_BUFFER_SIZE 8192
8
9/* Existing buffer budgets in bytes, including NUL for text storage. */
10#define TUI_SYNC_KEY_CAPACITY 256
11
13#include <ascii-chat/otel.h>
16#include <ascii-chat/ui/input.h>
18#include <sodium.h>
21#include <ascii-chat/util/env.h>
28#include <ascii-chat/tui/sync.h>
36#include <ascii-chat/common.h>
38#include <string.h>
40#include <libplatform/compiler.h>
41#include <libplatform/system.h>
42#include <libplatform/terminal.h>
43#include <libplatform/lifecycle.h>
46#include <libplatform/atomic.h>
47
48uint64_t ui_input_deadline(unsigned seconds) {
49 return time_get_ns() + (uint64_t)seconds * NS_PER_SEC_INT;
50}
51
52bool ui_input_expired(uint64_t deadline) {
53 return time_get_ns() >= deadline;
54}
55
56void ui_input_timeout_report(unsigned seconds, const char *consequence) {
58 int length = snprintf(message, sizeof(message), "\nPrompt timed out after %u seconds; %s.\n", seconds, consequence);
59 // This diagnostic must remain visible even while a background screen owns the terminal.
60 if (length > 0)
61 platform_write_all(STDERR_FILENO, message, (size_t)length < sizeof(message) ? (size_t)length : sizeof(message) - 1);
62 SET_ERRNO(ERROR_PROMPT_TIMEOUT, "Prompt timed out after %u seconds: %s", seconds, consequence);
63}
64
65static lifecycle_t g_input_lifecycle = LIFECYCLE_INIT;
66static mutex_t g_input_mutex;
67static mutex_t g_input_prompt_mutex;
68static _Atomic bool g_discarding_covered_input;
69static keyboard_key_t g_sync_keys[TUI_SYNC_KEY_CAPACITY];
70static unsigned g_sync_key_head, g_sync_key_count;
71static bool g_switch_pending;
72static _Atomic bool g_input_buffered;
73
74static bool input_switch_key(keyboard_key_t key) {
78}
79
81 bool (*classify_paste)(const char *)) {
82 if (s->ready)
83 return KEY_NONE;
84 for (unsigned i = 0; i < 1024; ++i) {
85 bool buffered = s->count != 0;
86 keyboard_key_t key = buffered ? s->keys[s->head++] : read_key();
87 if (buffered)
88 --s->count;
89 if (key == KEY_NONE)
90 return KEY_NONE;
91 if (key == KEY_PASTE_BEGIN) {
92 s->pasting = true;
93 s->invalid = false;
94 s->length = 0;
95 continue;
96 }
97 if (key == KEY_PASTE_END) {
98 if (!s->pasting)
99 continue;
100 s->pasting = false;
101 s->paste[s->length] = '\0';
102 s->ready = true;
103 return KEY_PASTE;
104 }
105 char text[UTF8_BUFFER_SIZE];
106 size_t bytes = keyboard_text_encode(key, text);
107 if (s->pasting) {
108 // Paste payloads remain raw UTF-8 bytes until whole-paste validation.
109 if (key >= 128 && key <= 255) {
110 text[0] = (char)key;
111 bytes = 1;
112 }
113 if (!bytes || (key < 32 && key != '\r' && key != '\n' && key != '\t') || key == 127 ||
114 bytes >= sizeof(s->paste) - s->length)
115 s->invalid = true;
116 else {
117 memcpy(s->paste + s->length, text, bytes);
118 s->length += bytes;
119 }
120 continue;
121 }
122 if (buffered || !bytes || key < 32 || key == 127)
123 return key;
124 s->head = 0;
125 s->keys[0] = key;
126 size_t count = 1;
127 keyboard_key_t next;
128 while (count < 8192 && (next = read_key()) != KEY_NONE) {
129 s->keys[count++] = next;
130 if (next == KEY_PASTE_BEGIN || next == KEY_PASTE_END)
131 break;
132 }
133 bool boundary = s->keys[count - 1] == KEY_PASTE_BEGIN || s->keys[count - 1] == KEY_PASTE_END;
134 size_t consumed = boundary ? count - 1 : count;
135 size_t text_count = consumed;
136 while (text_count && (s->keys[text_count - 1] == '\r' || s->keys[text_count - 1] == '\n'))
137 --text_count;
138 char burst[TUI_INPUT_BURST_BUFFER_SIZE];
139 size_t length = 0;
140 bool valid = true;
141 for (size_t n = 0; n < text_count; ++n) {
142 bytes = keyboard_text_encode(s->keys[n], text);
143 if (!bytes || s->keys[n] < 32 || s->keys[n] == 127 || bytes >= sizeof(burst) - length) {
144 valid = false;
145 break;
146 }
147 memcpy(burst + length, text, bytes);
148 length += bytes;
149 }
150 burst[length] = '\0';
151 if (text_count > 1 && classify_paste && classify_paste(burst)) {
152 // Keep the delimiter queued so the following payload remains framed.
153 s->head = consumed;
154 s->count = count - consumed;
155 s->invalid = !valid;
156 s->length = valid ? length : 0;
157 memcpy(s->paste, burst, s->length);
158 s->paste[s->length] = '\0';
159 s->ready = true;
160 return KEY_PASTE;
161 }
162 s->head = 1;
163 s->count = count - 1;
164 return key;
165 }
166 return KEY_NONE;
167}
168
169static tui_input_events_t g_events;
170
182static input_editor_t g_media_editor;
183
184// Publish only whether routing work remains; the actual queues stay serialized.
185static void input_unlock(void) {
186 atomic_store(&g_input_buffered,
187 g_sync_key_count || g_events.count || g_events.ready || g_events.pasting || g_media_editor.active);
188 mutex_unlock(&g_input_mutex);
189}
190
191static bool input_ready(void) {
192 return atomic_load(&g_input_buffered) || keyboard_input_available();
193}
194
195static bool input_media_paste(const char *text) {
196 return text[0] == '.' || text[0] == '/' || text[0] == '\\' || text[0] == '~' || text[0] == '\'' || text[0] == '"' ||
197 (strlen(text) > 2 && text[1] == ':') || strstr(text, "://") != NULL;
198}
199
200static keyboard_key_t input_read_event(void) {
201 return tui_input_events_read(&g_events, keyboard_read_nonblocking, input_media_paste);
202}
203
204// Bound each poll so a continuous input stream cannot monopolize the input mutex.
205static bool input_discard_pending_events(void) {
206 const unsigned discard_budget = 256;
207 g_sync_key_count = 0;
208 for (unsigned n = 0; n < discard_budget; ++n) {
209 // Release completed paste events before reading again; retain unfinished
210 // paste framing so its remaining bytes cannot become application shortcuts.
211 g_events.ready = false;
212 if (input_read_event() == KEY_NONE)
213 return !g_events.pasting;
214 }
215 g_events.ready = false;
216 return false;
217}
218
219static bool input_discard_covered(bool covered) {
220 if (!covered && !atomic_load(&g_discarding_covered_input))
221 return false;
222 bool drained = input_discard_pending_events();
223 // Recovery ends only when both the size cover and its queued input are gone.
224 atomic_store(&g_discarding_covered_input, covered || !drained);
225 return true;
226}
227
228/* Background debug polling can retain an event before a modal screen appears. */
229static keyboard_key_t input_next_key(void) {
230 if (g_sync_key_count) {
231 keyboard_key_t key = g_sync_keys[g_sync_key_head];
232 g_sync_key_head = (g_sync_key_head + 1) % 256;
233 --g_sync_key_count;
234 return key;
235 }
236 return input_read_event();
237}
238
239static bool input_initialize(void) {
240 if (lifecycle_init_once(&g_input_lifecycle)) {
241 if (mutex_init(&g_input_mutex, "ui_input") != 0) {
242 lifecycle_init_abort(&g_input_lifecycle);
243 return false;
244 }
245 if (mutex_init(&g_input_prompt_mutex, "ui_prompt_input") != 0) {
246 mutex_destroy(&g_input_mutex);
247 lifecycle_init_abort(&g_input_lifecycle);
248 return false;
249 }
250 NAMED_REGISTER_ATOMIC(&g_input_lifecycle.state, "ui_input_lifecycle", NULL);
251 g_discarding_covered_input = false;
252 g_switch_pending = false;
253 lifecycle_init_commit(&g_input_lifecycle);
254 }
255 uint64_t deadline = time_get_ns() + NS_PER_SEC_INT;
256 while (atomic_load(&g_input_lifecycle.state) == LIFECYCLE_INITIALIZING) {
257 if (time_get_ns() >= deadline) {
258 SET_ERRNO(ERROR_THREAD, "Timed out waiting for terminal input initialization");
259 return false;
260 }
262 }
263 return lifecycle_is_initialized(&g_input_lifecycle);
264}
265
266asciichat_error_t ui_input_take_paste(char *buffer, size_t capacity) {
267 if (!buffer || !capacity || !input_initialize())
268 return SET_ERRNO(ERROR_INVALID_PARAM, "Invalid paste buffer");
269 mutex_lock(&g_input_mutex);
271 if (g_events.invalid || g_events.length >= capacity || !utf8_is_valid(g_events.paste))
272 result = SET_ERRNO(ERROR_INVALID_PARAM, "Paste media paths or URLs without terminal controls (maximum 8191 bytes)");
273 else
274 memcpy(buffer, g_events.paste, g_events.length + 1);
275 g_events.length = 0;
276 sodium_memzero(g_events.paste, sizeof(g_events.paste));
277 g_events.ready = false;
278 input_unlock();
279 return result;
280}
281
283#ifndef NDEBUG
284 if (atomic_load(&g_input_lifecycle.state) == LIFECYCLE_INITIALIZING || !input_initialize() || !input_ready() ||
285 mutex_trylock(&g_input_mutex) != 0)
286 return;
287 if (g_switch_pending) {
288 input_unlock();
289 return;
290 }
293 keyboard_key_t key = input_read_event();
294 if (key != KEY_PASTE && !log_search_is_entering())
295 key = ui_sync_handle_key(key);
296 if (key && g_sync_key_count < TUI_SYNC_KEY_CAPACITY) {
297 g_sync_keys[(g_sync_key_head + g_sync_key_count++) % 256] = key;
298 g_switch_pending = input_switch_key(key);
299 }
300 }
301 input_unlock();
302#endif
303}
304
305static keyboard_key_t input_common_key(keyboard_key_t key, int current) {
306 if (key == KEY_NONE || key == KEY_PASTE || log_search_is_entering())
307 return key;
309 if (action == TUI_SHORTCUT_STATS) {
310 ascii_otel_ui_usage("stats", "shortcut", 0);
312 return KEY_NONE;
313 }
314 if (action == TUI_SHORTCUT_HELP && current != UI_SCREEN_LOGS) {
316 return KEY_NONE;
317 }
318 bool server = GET_OPTION(detected_mode) == MODE_SERVER || GET_OPTION(detected_mode) == MODE_DISCOVERY_SERVICE ||
319 GET_OPTION(detected_mode) == MODE_MCP_SERVER;
320 if (action == TUI_SHORTCUT_LOGS && !server) {
321 ascii_otel_ui_usage("logs", "shortcut", 0);
323 return KEY_NONE;
324 }
326 ascii_otel_ui_usage("errno", "shortcut", 0);
328 return KEY_NONE;
329 }
330 if (key == KEY_ESCAPE && current == UI_SCREEN_HELP) {
332 return KEY_NONE;
333 }
334 if (server && current == UI_SCREEN_LOGS) {
335 if (ui_session_logs_key(key))
336 return KEY_NONE;
337 if (key == KEY_ESCAPE || key == 'q') {
339 return KEY_NONE;
340 }
341 }
342 if (server && (key == 'Q' || key == KEY_SHIFT_ESCAPE)) {
344 return KEY_NONE;
345 }
346 if (server && (key == 'q' || key == 3)) {
348 return KEY_NONE;
349 }
350 return key;
351}
352
354 if (!input_initialize())
355 return KEY_NONE;
356 if (!input_ready()) {
357 // Once recovery has an empty input queue, the next key is a fresh action.
358 if (atomic_load(&g_discarding_covered_input) && !ui_controller_state().covered)
359 atomic_store(&g_discarding_covered_input, false);
360 return KEY_NONE;
361 }
362 mutex_lock(&g_input_mutex);
364 // The previous event has now been dispatched by the session. Background
365 // polling must not overtake a pending switch with the next shortcut.
366 g_switch_pending = false;
367 if (input_discard_covered(state.covered)) {
368 input_unlock();
369 return KEY_NONE;
370 }
371 if (state.screen == UI_SCREEN_HELP &&
374 if (key == KEY_ESCAPE || key == KEY_QUESTION)
376 if (key == KEY_PASTE)
377 g_events.ready = false;
378 input_unlock();
379 return KEY_NONE;
380 }
381 if (state.screen == UI_SCREEN_ERROR && terminal_screen_logs_help_open()) {
382 keyboard_key_t key = input_next_key();
383 if (key != KEY_NONE)
385 if (key == KEY_PASTE)
386 g_events.ready = false;
387 input_unlock();
388 return KEY_NONE;
389 }
390 if (state.screen == UI_SCREEN_ERROR) {
391 keyboard_key_t key = input_next_key();
392 if (key == KEY_QUESTION)
394 else if (key != KEY_NONE && key != KEY_PASTE && key != 3) {
396 /* The first q dismisses the modal error. Only a later q, after the
397 * final error is gone, is allowed to reach the session exit handler. */
398 if (key == 'q' && !ui_error_popup_visible()) {
399 input_unlock();
400 return key;
401 }
402 }
403 if (key == KEY_PASTE)
404 g_events.ready = false;
405 input_unlock();
406 return key == 3 ? key : KEY_NONE;
407 }
408 if (state.screen == UI_SCREEN_NOTICE) {
409 g_events.ready = false;
410 g_sync_key_count = 0;
411 keyboard_key_t notice_key = input_read_event();
412 if (notice_key == KEY_PASTE)
413 g_events.ready = false;
414 bool first_use = ui_controller_first_use_key(notice_key);
415 if (!first_use &&
416 (notice_key == KEY_ESCAPE || notice_key == '\r' || notice_key == '\n' || notice_key == 'q' || notice_key == 3))
418 input_unlock();
419 return notice_key == 'q' || notice_key == 3 ? notice_key : KEY_NONE;
420 }
421 if (state.screen == UI_SCREEN_UPDATE && update_banner_is_overlay_open()) {
422 keyboard_key_t update_key = input_read_event();
423 if (update_key == '=' || update_key == KEY_0 || update_key == KEY_QUESTION || update_key == ']') {
424 if (update_key != KEY_QUESTION)
426 if (update_key == KEY_QUESTION) {
428 } else {
429 update_key = input_common_key(update_key, UI_SCREEN_UPDATE);
430 (void)ui_sync_handle_key(update_key);
431 }
432 } else if (update_key != KEY_NONE)
433 (void)update_banner_handle_key(update_key);
434 input_unlock();
435 return KEY_NONE;
436 }
437 if (screen == UI_SCREEN_MEDIA && g_media_editor.active && state.screen == UI_SCREEN_PROMPT) {
438 keyboard_key_t key = input_next_key();
439 bool switch_screen = input_switch_key(key) && key != '=' && (g_media_editor.length == 0 || key == 15);
440#ifndef NDEBUG
441 switch_screen |= key == KEY_0 && g_media_editor.length == 0;
442#endif
443 if (switch_screen) {
444 // Keep the old prompt on screen until its replacement is published.
445 if (key != KEY_QUESTION)
446 g_media_editor.active = false;
447 if (key == KEY_0)
448 key = ui_sync_handle_key(key);
449 key = input_common_key(key, state.screen);
450 g_switch_pending = input_switch_key(key);
451 input_unlock();
452 return key;
453 }
454 if (key == KEY_ESCAPE || key == 3 || ui_input_expired(g_media_editor.deadline)) {
455 if (key == KEY_PASTE)
456 g_events.ready = false;
457 ascii_otel_ui_usage("media_input_prompt", ui_input_expired(g_media_editor.deadline) ? "timed_out" : "cancelled",
458 0);
459 g_media_editor.active = false;
461 } else if (key == '\r' || key == '\n') {
462 g_media_editor.active = false;
463 g_media_editor.submitted = true;
464 ascii_otel_ui_usage("media_input_prompt", "submitted", 0);
465 input_unlock();
466 return KEY_MEDIA_SUBMIT;
467 } else if (key != KEY_NONE) {
468 if (key == KEY_PASTE)
469 ascii_otel_ui_usage("media_input_prompt", "paste", 0);
470 if (key == KEY_PASTE) {
471 if (!g_events.invalid && utf8_is_valid(g_events.paste)) {
472 media_playlist_t items = {0}, added = {0};
473 bool valid = !g_media_editor.length || media_playlist_parse(g_media_editor.buffer, &items) == ASCIICHAT_OK;
474 valid = valid && media_playlist_parse(g_events.paste, &added) == ASCIICHAT_OK;
475 if (valid) {
476 for (size_t i = 0; i < added.count && valid; ++i)
477 valid = media_playlist_append(&items, added.paths[i], added.urls[i]) == ASCIICHAT_OK;
478 char formatted[sizeof(g_media_editor.buffer)];
479 valid = valid && media_playlist_format(&items, formatted, sizeof(formatted)) == ASCIICHAT_OK;
480 if (valid) {
481 memcpy(g_media_editor.buffer, formatted, strlen(formatted) + 1);
482 g_media_editor.length = g_media_editor.cursor = strlen(formatted);
483 }
484 }
485 if (valid)
486 ascii_otel_ui_usage("media_input_prompt", "queue_changed", 0);
487 CLEAR_ERRNO();
488 }
489 g_events.ready = false;
490 g_media_editor.pasted = true;
491 } else if (!(key == ' ' && g_media_editor.pasted && g_media_editor.cursor == g_media_editor.length)) {
492 g_media_editor.pasted = false;
493 ui_input_edit_key(g_media_editor.buffer, sizeof(g_media_editor.buffer), &g_media_editor.length,
494 &g_media_editor.cursor, key);
495 }
496 if (ui_prompt_present_kind("Open media: files or URLs\nDrop more files to queue; Enter plays; Esc cancels",
497 g_media_editor.buffer, g_media_editor.cursor, false,
498 UI_PROMPT_MEDIA_INPUT) != ASCIICHAT_OK) {
499 g_media_editor.active = false;
501 CLEAR_ERRNO();
502 }
503 }
504 input_unlock();
505 return KEY_NONE;
506 }
507 bool primary =
509 bool server = GET_OPTION(detected_mode) == MODE_SERVER || GET_OPTION(detected_mode) == MODE_DISCOVERY_SERVICE ||
510 GET_OPTION(detected_mode) == MODE_MCP_SERVER;
511 bool server_owner = screen == UI_SCREEN_STATS && server && !ui_controller_screen_live(UI_SCREEN_STATUS);
512 bool active = state.screen < 0 || state.screen == (int)screen ||
513 ((primary || server_owner) && state.screen <= UI_SCREEN_SYNC && state.screen != UI_SCREEN_STATS);
515 if (active) {
516 if (g_sync_key_count) {
517 key = g_sync_keys[g_sync_key_head];
518 g_sync_key_head = (g_sync_key_head + 1) % 256;
519 --g_sync_key_count;
520 }
521 if (key == KEY_NONE)
522 key = state.screen == UI_SCREEN_HELP && keyboard_help_check_signal_cancel() ? KEY_QUESTION : input_read_event();
523 if (key == KEY_F12) {
524 // Synchronous prompts own their input, including function keys.
525 // Opening the update checker here would suspend credential entry.
528 key = KEY_NONE;
529 }
530 if (screen != UI_SCREEN_PROMPT) {
531 key = input_common_key(key, state.screen);
532 if (key != KEY_PASTE && !log_search_is_entering() &&
533 !(screen == UI_SCREEN_MDNS && state.screen == UI_SCREEN_MDNS))
534 key = ui_sync_handle_key(key);
535 }
536 }
537 if (key == KEY_PASTE && log_search_is_entering()) {
538 if (!g_events.invalid)
539 (void)log_search_insert_text(g_events.paste);
540 g_events.ready = false;
541 key = KEY_NONE;
542 }
543 if (key == KEY_PASTE && screen != UI_SCREEN_MEDIA && screen != UI_SCREEN_PROMPT) {
544 g_events.ready = false;
545 key = KEY_NONE;
546 }
548 key = KEY_NONE;
549 g_switch_pending |= input_switch_key(key);
550 input_unlock();
551 return key;
552}
553
555 uint64_t deadline = time_get_ns() + (uint64_t)timeout_ms * NS_PER_MS_INT;
556 do {
558 if (key != KEY_NONE || shutdown_is_requested())
559 return key;
560 platform_sleep_ns(NS_PER_MS_INT);
561 } while (time_get_ns() < deadline);
562 return KEY_NONE;
563}
564
566 if (!lifecycle_destroy_once(&g_input_lifecycle))
567 return;
568 mutex_destroy(&g_input_prompt_mutex);
569 mutex_destroy(&g_input_mutex);
570 NAMED_UNREGISTER(&g_input_lifecycle.state);
571 lifecycle_destroy_commit(&g_input_lifecycle);
572}
573
579static asciichat_error_t input_prompt(const char *prompt, char *buffer, size_t max_len, prompt_opts_t opts,
580 bool password_dialog, bool incorrect) {
581 if (!prompt || !buffer || max_len < 2)
582 return SET_ERRNO(ERROR_INVALID_PARAM, "Invalid prompt buffer");
584 return SET_ERRNO(ERROR_PLATFORM_INIT, "Cannot initialize prompt input");
585 const char *usage_name = ui_prompt_kind_name(
586 password_dialog || (opts.kind == UI_PROMPT_INPUT && !opts.echo) ? UI_PROMPT_PASSWORD : opts.kind);
587 if (password_dialog && incorrect)
588 ascii_otel_ui_usage(usage_name, "rejected", 0);
589 char *visible = SAFE_CALLOC(max_len, 1, char *);
590 if (!visible)
591 return SET_ERRNO(ERROR_MEMORY, "Cannot allocate prompt display");
592 if (!input_initialize()) {
593 SAFE_FREE(visible);
594 return SET_ERRNO(ERROR_THREAD, "Cannot initialize prompt input lock");
595 }
596 mutex_lock(&g_input_prompt_mutex);
597 const char *end = memchr(buffer, '\0', max_len);
598 if (!end) {
599 mutex_unlock(&g_input_prompt_mutex);
600 SAFE_FREE(visible);
601 return SET_ERRNO(ERROR_INVALID_PARAM, "Prompt text is too long");
602 }
603 int previous_interactive = ui_controller_interactive_screen();
604 size_t len = (size_t)(end - buffer), cursor = len;
605 unsigned seconds = opts.timeout_seconds ? opts.timeout_seconds : (opts.echo ? 30 : 60);
606 uint64_t deadline = ui_input_deadline(seconds);
607 char timed_prompt[TUI_INPUT_TIMED_PROMPT_BUFFER_SIZE];
608 snprintf(timed_prompt, sizeof(timed_prompt), "%s\n(Timeout: %us; cancels without an answer)", prompt, seconds);
610 bool newline_limit = false, cancelled = false;
611 // Request modifier-aware Enter while this dialog owns terminal input.
612 if (password_dialog) {
613 const char *enable = "\033[>1u\033[>4;2m";
614 platform_write_all(STDERR_FILENO, enable, strlen(enable));
615 }
616 ui_buttons_t buttons = {0};
617 ui_button_t actions[] = {{"[ Confirm ]", 0, false, 0}, {"[ Cancel ]", 0, false, 0}};
618 while (!shutdown_is_requested()) {
619 if (ui_input_expired(deadline)) {
620 result = ERROR_PROMPT_TIMEOUT;
621 break;
622 }
623 size_t display_cursor = cursor;
624 if (opts.echo) {
625 memcpy(visible, buffer, len + 1);
626 } else {
627 size_t chars = 0;
628 for (size_t i = 0; i < len; ++i) {
629 if (((unsigned char)buffer[i] & 0xc0) != 0x80)
630 visible[chars++] = password_dialog && buffer[i] == '\n' ? '\n' : opts.mask_char;
631 }
632 visible[opts.mask_char ? chars : 0] = '\0';
633 display_cursor = 0;
634 for (size_t i = 0; i < cursor; ++i)
635 if (((unsigned char)buffer[i] & 0xc0) != 0x80)
636 ++display_cursor;
637 if (!opts.mask_char)
638 display_cursor = 0;
639 }
640 // Presentation owns only the mask; the caller retains the credential.
641 result = password_dialog
642 ? ui_password_prompt_present(prompt, visible, display_cursor, buttons.selected, incorrect,
643 len >= MIN_PASSWORD_LENGTH, newline_limit)
644 : ui_prompt_present_kind(timed_prompt, visible, display_cursor, !opts.echo,
645 opts.kind == UI_PROMPT_INPUT && !opts.echo ? UI_PROMPT_PASSWORD : opts.kind);
646 if (result != ASCIICHAT_OK)
647 break;
649 if (ui_input_expired(deadline)) {
650 result = ERROR_PROMPT_TIMEOUT;
651 break;
652 }
653 if (key == KEY_NONE)
654 continue;
655 newline_limit = false;
656 if (password_dialog && key == KEY_SHIFT_ENTER) {
657 size_t newlines = 0;
658 for (size_t i = 0; i < len; ++i)
659 newlines += buffer[i] == '\n';
660 if (newlines >= 3)
661 newline_limit = true;
662 else
663 (void)ui_input_insert(buffer, max_len, &len, &cursor, "\n");
664 continue;
665 }
666 actions[0].disabled = password_dialog && len < MIN_PASSWORD_LENGTH;
667 if (password_dialog && (key == '\t' || key == '\r' || key == '\n')) {
668 int activated = ui_buttons_key(&buttons, actions, 2, key);
669 if (activated == 0)
670 break;
671 if (activated == 1) {
672 cancelled = true;
673 result = SET_ERRNO(ERROR_CRYPTO_AUTH, "Password entry cancelled");
674 break;
675 }
676 continue;
677 }
678 // Validate the whole paste before editing so rejection cannot leave a partial password.
679 if (key == KEY_PASTE || key == 22) {
680 char pasted[TUI_INPUT_PASTED_BUFFER_SIZE] = {0};
681 asciichat_error_t pasted_result =
682 key == 22 ? platform_clipboard_read(pasted, sizeof(pasted)) : ui_input_take_paste(pasted, sizeof(pasted));
683 if (pasted_result == ASCIICHAT_OK) {
684 if (key == 22) {
685 size_t write = 0;
686 for (size_t read = 0; pasted[read]; ++read) {
687 if (pasted[read] == '\r' && pasted[read + 1] == '\n')
688 continue;
689 pasted[write++] = pasted[read] == '\r' ? '\n' : pasted[read];
690 }
691 pasted[write] = '\0';
692 }
693 size_t newlines = 0;
694 for (size_t i = 0; i < len; ++i)
695 newlines += buffer[i] == '\n';
696 for (size_t i = 0; pasted[i]; ++i)
697 newlines += pasted[i] == '\n';
698 if (password_dialog && newlines > 3)
699 newline_limit = true;
700 else if (!strpbrk(pasted, password_dialog ? "\r\t" : "\r\n\t") && strlen(pasted) + len < max_len)
701 (void)ui_input_insert(buffer, max_len, &len, &cursor, pasted);
702 else
703 CLEAR_ERRNO();
704 } else {
705 CLEAR_ERRNO();
706 }
707 // Clipboard and terminal paste buffers can contain unmasked credentials.
708 sodium_memzero(pasted, sizeof(pasted));
709 continue;
710 }
711 if (key == '\r' || key == '\n') {
712 if (!password_dialog || len >= MIN_PASSWORD_LENGTH)
713 break;
714 continue;
715 }
716 if (key == KEY_ESCAPE || key == 3) {
717 cancelled = true;
718 result = SET_ERRNO(ERROR_GENERAL, "Prompt cancelled");
719 break;
720 }
721 ui_input_edit_key(buffer, max_len, &len, &cursor, key);
722 }
724 result = SET_ERRNO(ERROR_GENERAL, "Prompt interrupted");
725 ascii_otel_ui_usage(usage_name,
726 result == ASCIICHAT_OK ? "submitted"
727 : result == ERROR_PROMPT_TIMEOUT ? "timed_out"
728 : shutdown_is_requested() ? "interrupted"
729 : cancelled ? "cancelled"
730 : "failed",
731 0);
732 ui_prompt_dismiss(previous_interactive);
733 if (password_dialog) {
734 const char *disable = "\033[<u\033[>4;0m";
735 platform_write_all(STDERR_FILENO, disable, strlen(disable));
736 }
737 mutex_unlock(&g_input_prompt_mutex);
738 SAFE_FREE(visible);
739 if (result == ERROR_PROMPT_TIMEOUT)
740 ui_input_timeout_report(seconds, opts.echo ? "answer declined" : "password entry cancelled");
741 if (result != ASCIICHAT_OK)
742 sodium_memzero(buffer, max_len);
743 return result;
744}
745
746asciichat_error_t ui_input_prompt_prefilled(const char *prompt, char *buffer, size_t max_len, prompt_opts_t opts) {
747 return input_prompt(prompt, buffer, max_len, opts, false, false);
748}
749
750asciichat_error_t ui_input_password(const char *target, char *buffer, size_t capacity, bool incorrect) {
751 if (!target || !buffer || capacity < 2)
752 return SET_ERRNO(ERROR_INVALID_PARAM, "Invalid password dialog");
753 sodium_memzero(buffer, capacity);
754 if (env_pop_prompt_response(buffer, capacity))
755 return ASCIICHAT_OK;
756 if (!platform_is_interactive())
757 return SET_ERRNO(ERROR_CRYPTO_AUTH, "Server password requires an interactive terminal or --password");
758 asciichat_error_t result = input_prompt(target, buffer, capacity, PROMPT_OPTS_PASSWORD, true, incorrect);
759 if (result != ASCIICHAT_OK) {
762 return SET_ERRNO(ERROR_CRYPTO_AUTH, "Password entry cancelled");
763 }
764 return result;
765}
766
767asciichat_error_t ui_input_prompt(const char *prompt, char *buffer, size_t max_len, prompt_opts_t opts) {
768 if (buffer && max_len)
769 buffer[0] = '\0';
770 return ui_input_prompt_prefilled(prompt, buffer, max_len, opts);
771}
772
774 if (!text || !utf8_is_valid(text) || strlen(text) >= sizeof(g_media_editor.buffer) || !input_initialize())
775 return SET_ERRNO(ERROR_INVALID_PARAM, "Invalid media prompt text");
776 mutex_lock(&g_input_mutex);
777 memset(&g_media_editor, 0, sizeof(g_media_editor));
778 memcpy(g_media_editor.buffer, text, strlen(text) + 1);
779 g_media_editor.length = g_media_editor.cursor = strlen(text);
780 g_media_editor.deadline = ui_input_deadline(120);
781 asciichat_error_t result =
782 ui_prompt_present_kind("Open media: files or URLs\nDrop more files to queue; Enter plays; Esc cancels",
783 g_media_editor.buffer, g_media_editor.cursor, false, UI_PROMPT_MEDIA_INPUT);
784 g_media_editor.active = result == ASCIICHAT_OK;
785 input_unlock();
786 return result;
787}
788
789asciichat_error_t ui_input_media_take(char *output, size_t capacity) {
790 if (!output || !input_initialize())
791 return SET_ERRNO(ERROR_INVALID_PARAM, "Invalid media prompt destination");
792 mutex_lock(&g_input_mutex);
794 if (!g_media_editor.submitted || g_media_editor.length >= capacity)
795 result = SET_ERRNO(ERROR_INVALID_STATE, "No submitted media prompt");
796 else {
797 memcpy(output, g_media_editor.buffer, g_media_editor.length + 1);
799 media_playlist_t playlist = {0};
800 if (media_playlist_parse(output, &playlist) == ASCIICHAT_OK) {
801 bool file = false, url = false;
802 for (size_t i = 0; i < playlist.count; ++i) {
803 file |= !playlist.urls[i];
804 url |= playlist.urls[i];
805 }
806 if (file)
807 ascii_otel_ui_usage("media_input_prompt", "source_file", 0);
808 if (url)
809 ascii_otel_ui_usage("media_input_prompt", "source_url", 0);
810 ascii_otel_ui_usage("media_input_prompt", "queue_submitted", 0);
811 } else {
812 ascii_otel_ui_usage("media_input_prompt", "rejected", 0);
813 }
815 }
816 g_media_editor.submitted = false;
817 input_unlock();
818 return result;
819}
deferred_action_t action
Definition actions.c:77
Application-level callbacks for library code.
#define APP_CALLBACK_VOID(callback_name)
void errno_scope_end(asciichat_errno_scope_t scope, asciichat_errno_outcome_t outcome)
asciichat_errno_scope_t errno_scope_begin(void)
#define SET_ERRNO(code,...)
#define CLEAR_ERRNO()
@ ASCIICHAT_ERRNO_DISMISSED
Common buffer size definitions.
int ui_buttons_key(ui_buttons_t *s, const ui_button_t *b, size_t n, keyboard_key_t key)
Definition buttons.c:8
asciichat_error_t platform_clipboard_read(char *output, size_t capacity)
⚙️ Common definitions, error codes, macros, and types shared throughout the application
Named object registry for debugging — log identifiable resource names.
bool env_pop_prompt_response(char *response_out, size_t response_size)
Pop the first response from ASCII_CHAT_QUESTION_PROMPT_RESPONSE stack.
Definition env.c:96
Environment variable utilities with stack-based prompt responses.
void ui_errno_stacks_toggle(void)
bool ui_error_popup_visible(void)
Definition error_popup.c:71
void ui_error_popup_key(keyboard_key_t key)
Definition error_popup.c:87
bool valid
Definition fft.c:27
#define UTF8_BUFFER_SIZE
#define SAFE_FREE(ptr)
Definition common.h:377
#define SAFE_CALLOC(count, size, cast)
Definition common.h:275
unsigned long long uint64_t
Definition common.h:63
#define NAMED_REGISTER_ATOMIC(a, name, parent_ptr)
Register an atomic_t with automatic format specifier.
#define NAMED_UNREGISTER(ptr)
Unregister a pointer.
asciichat_error_t
Error and exit codes - unified status values (0-255).
Definition error_codes.h:44
@ ERROR_INVALID_STATE
@ ERROR_PLATFORM_INIT
Definition error_codes.h:55
@ ERROR_PROMPT_TIMEOUT
@ ERROR_MEMORY
Definition error_codes.h:51
@ ASCIICHAT_OK
Definition error_codes.h:46
@ ERROR_GENERAL
Definition error_codes.h:47
@ ERROR_CRYPTO_AUTH
Definition error_codes.h:95
@ ERROR_INVALID_PARAM
@ ERROR_THREAD
#define NS_PER_SEC_INT
Definition time.h:169
#define NS_PER_MS_INT
Definition time.h:168
#define GET_OPTION(field)
Safely get a specific option field (lock-free read).
Definition options.h:1336
@ MODE_MCP_SERVER
Model Context Protocol rendering server.
Definition options.h:925
@ MODE_DISCOVERY_SERVICE
Discovery server mode - session management and WebRTC signaling.
Definition options.h:923
@ MODE_SERVER
Server mode - network server options.
Definition options.h:920
asciichat_error_t keyboard_init(void)
Initialize keyboard input system.
size_t platform_write_all(int fd, const void *buf, size_t count)
Write all bytes to a file descriptor, handling partial writes.
keyboard_key_t keyboard_read_nonblocking(void)
Read next keyboard input without blocking.
void platform_force_exit(int exit_code)
Forcefully terminate the process immediately without cleanup.
void platform_sleep_ms(unsigned int ms)
Sleep for a specified number of milliseconds.
keyboard_key_t
Options for interactive line editing.
Definition keyboard.h:53
bool keyboard_input_available(void)
@ KEY_SHIFT_ESCAPE
Shift+Escape (immediate exit).
Definition keyboard.h:73
@ KEY_ESCAPE
Escape key (ESC).
Definition keyboard.h:55
@ KEY_MEDIA_SUBMIT
Nonblocking media prompt submitted.
Definition keyboard.h:68
@ KEY_0
'0' key - toggle sync debug screen
Definition keyboard.h:75
@ KEY_PASTE_BEGIN
Definition keyboard.h:65
@ KEY_F12
Open the version update checker TUI.
Definition keyboard.h:69
@ KEY_NONE
No key pressed or no input available.
Definition keyboard.h:54
@ KEY_PASTE
Definition keyboard.h:67
@ KEY_QUESTION
'?' key - show help screen
Definition keyboard.h:83
@ KEY_PASTE_END
Definition keyboard.h:66
@ KEY_SHIFT_ENTER
Shift+Enter (insert a newline in multiline prompts).
Definition keyboard.h:72
void keyboard_help_toggle_terminal(int fd)
int keyboard_help_parent_screen(void)
bool update_banner_handle_key(int key)
bool keyboard_help_check_signal_cancel(void)
Check if help screen closure was requested by signal handler.
void update_banner_toggle_overlay(void)
bool update_banner_is_overlay_open(void)
void shutdown_request(void)
Definition common.c:82
bool shutdown_is_requested(void)
Check if shutdown has been requested.
Definition common.c:86
bool utf8_is_valid(const char *str)
Check if a string is valid UTF-8.
Definition utf8.c:128
size_t keyboard_text_encode(keyboard_key_t key, char text[UTF8_BUFFER_SIZE])
🆘 Interactive keyboard help overlay for session keyboard shortcuts
bool ui_session_logs_key(keyboard_key_t key)
void signal_exit(void)
Definition main.c:253
⚙️ Unified options parsing system for ascii-chat with builder pattern and lock-free access
void ascii_otel_ui_usage(const char *screen, const char *event, uint64_t duration_ns)
Definition otel.c:434
asciichat_error_t media_playlist_format(const media_playlist_t *list, char *text, size_t capacity)
Definition playlist.c:89
asciichat_error_t media_playlist_append(media_playlist_t *list, const char *path, bool url)
Definition playlist.c:7
asciichat_error_t media_playlist_parse(const char *text, media_playlist_t *list)
Definition playlist.c:32
asciichat_error_t ui_password_prompt_present(const char *target, const char *masked, size_t cursor, size_t selected_button, bool incorrect, bool can_confirm, bool newline_limit)
Definition prompt.c:337
void ui_prompt_remove(void)
Definition prompt.c:307
asciichat_error_t ui_prompt_present_kind(const char *prompt, const char *visible, size_t cursor, bool secret, ui_prompt_kind_t kind)
Definition prompt.c:270
const char * ui_prompt_kind_name(ui_prompt_kind_t kind)
Definition prompt.c:235
void ui_prompt_dismiss(int previous)
Definition prompt.c:301
void stats_runtime_toggle(void)
Definition runtime.c:92
bool log_search_is_entering(void)
Check if currently in input mode (typing pattern).
Definition search.c:408
asciichat_error_t log_search_insert_text(const char *text)
Definition search.c:480
Interactive search filtering for terminal screens.
tui_shortcut_t tui_shortcut_action(keyboard_key_t key)
Definition shortcuts.c:30
tui_shortcut_t
Definition shortcuts.h:4
@ TUI_SHORTCUT_LOGS
Definition shortcuts.h:7
@ TUI_SHORTCUT_MEDIA
Definition shortcuts.h:12
@ TUI_SHORTCUT_HELP
Definition shortcuts.h:6
@ TUI_SHORTCUT_STATS
Definition shortcuts.h:8
@ TUI_SHORTCUT_ERRORS
Definition shortcuts.h:10
Shutdown check system for clean library/application separation.
#define MIN_PASSWORD_LENGTH
Minimum password length (8 characters).
Definition sizes.h:19
🔤 String Manipulation and Shell Escaping Utilities
When code needs to work with asciichat errno snapshot, it keeps total, copied here instead of scatter...
uint64_t deadline
Definition tui/input.c:174
char buffer[8192]
Definition tui/input.c:172
bool urls[64]
Definition playlist.h:11
char paths[64][256]
Definition playlist.h:10
keyboard_key_t keys[8192]
Definition tui/input.h:43
char paste[8192]
Definition tui/input.h:41
bool disabled
Definition buttons.h:8
When code needs to work with ui button, it keeps label, shortcut, disabled, color here instead of sca...
Definition buttons.h:16
size_t selected
Definition buttons.h:17
keyboard_key_t ui_sync_handle_key(keyboard_key_t key)
Definition sync.c:364
bool terminal_screen_logs_help_open(void)
bool terminal_screen_logs_handle_key(int key)
Reusable "fixed header + scrolling logs" terminal screen abstraction.
⏱️ High-precision timing utilities using sokol_time.h and uthash
void ui_controller_remove(ui_screen_t screen)
bool ui_controller_first_use_key(int key)
void ui_controller_toggle_logs(void)
int ui_controller_current_screen(void)
void ui_controller_hide_logs(void)
int ui_controller_interactive_screen(void)
bool ui_controller_is_blocked(void)
ui_presentation_state_t ui_controller_state(void)
bool ui_controller_screen_live(ui_screen_t screen)
int ui_controller_active_fd(void)
ui_screen_t
@ UI_SCREEN_PROMPT
@ UI_SCREEN_NOTICE
@ UI_SCREEN_LOGS
@ UI_SCREEN_MEDIA
@ UI_SCREEN_STATS
@ UI_SCREEN_UPDATE
@ UI_SCREEN_SYNC
@ UI_SCREEN_SPLASH
@ UI_SCREEN_STATUS
@ UI_SCREEN_MDNS
@ UI_SCREEN_HELP
@ UI_SCREEN_ERROR
void ui_input_poll_sync(void)
Definition tui/input.c:282
keyboard_key_t tui_input_events_read(tui_input_events_t *s, keyboard_key_t(*read_key)(void), bool(*classify_paste)(const char *))
Definition tui/input.c:80
#define TUI_INPUT_MESSAGE_BUFFER_SIZE
Definition tui/input.c:3
#define TUI_SYNC_KEY_CAPACITY
Definition tui/input.c:10
keyboard_key_t ui_input_wait_key(ui_screen_t screen, unsigned timeout_ms)
Definition tui/input.c:554
keyboard_key_t ui_input_read_key(ui_screen_t screen)
Definition tui/input.c:353
asciichat_error_t ui_input_prompt_prefilled(const char *prompt, char *buffer, size_t max_len, prompt_opts_t opts)
Definition tui/input.c:746
asciichat_error_t ui_input_media_take(char *output, size_t capacity)
Definition tui/input.c:789
void ui_input_shutdown(void)
Definition tui/input.c:565
#define TUI_INPUT_BUFFER_BUFFER_SIZE
Definition tui/input.c:5
#define TUI_INPUT_TIMED_PROMPT_BUFFER_SIZE
Definition tui/input.c:6
asciichat_error_t ui_input_take_paste(char *buffer, size_t capacity)
Definition tui/input.c:266
asciichat_error_t ui_input_media_begin(const char *text)
Definition tui/input.c:773
asciichat_error_t ui_input_prompt(const char *prompt, char *buffer, size_t max_len, prompt_opts_t opts)
Definition tui/input.c:767
#define TUI_INPUT_BURST_BUFFER_SIZE
Definition tui/input.c:4
void ui_input_timeout_report(unsigned seconds, const char *consequence)
Definition tui/input.c:56
uint64_t ui_input_deadline(unsigned seconds)
Definition tui/input.c:48
asciichat_error_t ui_input_password(const char *target, char *buffer, size_t capacity, bool incorrect)
Definition tui/input.c:750
bool ui_input_expired(uint64_t deadline)
Definition tui/input.c:52
#define TUI_INPUT_PASTED_BUFFER_SIZE
Definition tui/input.c:7
asciichat_error_t ui_input_insert(char *buffer, size_t capacity, size_t *length, size_t *cursor, const char *text)
Definition ui/input.c:130
void ui_input_edit_key(char *buffer, size_t capacity, size_t *length, size_t *position, keyboard_key_t key)
Definition ui/input.c:83
Update available prompt screen shown after splash.
🔤 UTF-8 Encoding and Decoding Utilities