From 46bf99b0abe61fb9ae080728832bd65d145ada8e Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 19 May 2026 11:38:23 +0700 Subject: [PATCH] Improve chat rendering: word wrap, label on separate line, blank line separators --- tui/render.py | 144 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 101 insertions(+), 43 deletions(-) diff --git a/tui/render.py b/tui/render.py index 3ad3dbe..b92555f 100644 --- a/tui/render.py +++ b/tui/render.py @@ -4,6 +4,7 @@ import curses import os +import textwrap # -- Color pair IDs (id 1-9, id 0 = default curses) -- C_HEADER = 1 # header bar: biru @@ -70,32 +71,62 @@ def draw_chat(app, stdscr): return # Render log ke list of (color, text) agar scroll calculation akurat + # Setiap baris di-wrap sesuai lebar terminal rendered = [] - for item in app.log: + + def _wrap_render(text, indent=0, color=C_INPUT): + available = w - indent + if available <= 0: + rendered.append((color, " " * indent)) + return + for line in text.split("\n"): + if not line: + rendered.append((color, " " * indent)) + continue + start = 0 + while start < len(line): + chunk = line[start:start + available] + rendered.append((color, " " * indent + chunk)) + start += available + + for idx, item in enumerate(app.log): role, text = item["role"], item["text"] if role == "sep": rendered.append((None, "")) rendered.append((None, "")) continue - label = "" - color = None + + # Tambah blank line sebelum system log setelah user/ai response + if role == "system" and idx > 0 and app.log[idx - 1]["role"] in ("user", "ai"): + rendered.append((None, "")) + + # Tambah blank line sebelum ai response setelah user (langsung, tanpa tools) + if role == "ai" and idx > 0 and app.log[idx - 1]["role"] in ("user", "ai"): + rendered.append((None, "")) + + # Tambah blank line sebelum ai response setelah system log + if role == "ai" and idx > 0 and app.log[idx - 1]["role"] == "system": + rendered.append((None, "")) + if role == "user": label = f" You ({item['time']}) " - color = C_USER + rendered.append((C_USER, label)) + _wrap_render(text, indent=1, color=C_INPUT) elif role == "ai": label = f" Hendrik ({item['time']}) " - color = C_AI + rendered.append((C_AI, label)) + _wrap_render(text, indent=3, color=C_INPUT) elif role == "system": - label = " \u25e6 " - color = C_SYSTEM + lines = text.split("\n") + rendered.append((C_SYSTEM, lines[0])) + for line in lines[1:]: + rendered.append((C_SYSTEM, " " + line)) elif role == "error": label = " \u2717 " - color = C_ERROR - - lines = text.split("\n") - rendered.append((color, label + (lines[0] if lines else ""))) - for line in lines[1:]: - rendered.append((color, " " + line)) + lines = text.split("\n") + rendered.append((C_ERROR, label + (lines[0] if lines else ""))) + for line in lines[1:]: + rendered.append((C_ERROR, " " + line)) # Clamp scroll agar tidak melebihi total baris total = len(rendered) @@ -126,12 +157,15 @@ def draw_chat(app, stdscr): def draw_input(app, stdscr): - # Kotak input multi-line (maks 6 baris). + # Kotak input multi-line (maks 6 baris visual). # Ada border atas dan bawah. # Baris aktif ditandai "> " di depannya. + # Soft word wrap: logical line panjang dipecah jadi beberapa visual line. h, w = app.h, app.w iy = h - 8 border_attr = curses.color_pair(C_INPUT_BORDER) | curses.A_BOLD + text_attr = curses.color_pair(C_INPUT) + max_chars = w - 6 # space for text (border + "> " = 4 chars, border = 2 chars) # Border atas ┌───┐ dan bawah └───┘ for off, text in [ @@ -143,51 +177,75 @@ def draw_input(app, stdscr): except curses.error: pass - # Scroll input horizontal jika buffer > 6 baris - total = len(app.input_buffer) - if total <= 6: + # Build visual lines dari buffer dengan soft wrap + visual = [] # list of (logical_line_idx, start_col, text_chunk) + for i, line in enumerate(app.input_buffer): + if not line: + visual.append((i, 0, "")) + else: + for start in range(0, max(len(line), 1), max_chars): + chunk = line[start:start + max_chars] + visual.append((i, start, chunk)) + + total_visual = len(visual) + + # Find cursor visual line + cur_visual = 0 + for idx, (li, start, text) in enumerate(visual): + if li == app.input_line and start <= app.input_col <= start + len(text): + cur_visual = idx + break + if li == app.input_line and app.input_col > start + len(text): + cur_visual = idx # edge: cursor at end of last chunk + + # Scroll input visual lines supaya cursor terlihat + if total_visual <= 6: show = 0 else: - show = max(0, min(app.input_line - 3, total - 6)) + show = max(0, min(cur_visual - 3, total_visual - 6)) cursor_yx = None - text_attr = curses.color_pair(C_INPUT) for i in range(6): idx = show + i y = iy + 1 + i - line = app.input_buffer[idx] if idx < total else "" - max_text = w - 6 - if len(line) > max_text: - line = line[:max_text] + if idx < total_visual: + li, start, chunk = visual[idx] + line_is_active = (li == app.input_line) - # Border kiri - try: - stdscr.addstr(y, 0, "\u2502 ", border_attr) - except curses.error: - pass + # Border kiri + try: + stdscr.addstr(y, 0, "\u2502 ", border_attr) + except curses.error: + pass - # Isi baris - if idx < total: - content = "> " + line + " " * (w - 4 - 2 - len(line)) + # Isi baris + if line_is_active: + prefix = "> " + else: + prefix = " " + + content = prefix + chunk + " " * (w - 4 - len(prefix) - len(chunk)) try: stdscr.addstr(y, 2, content, text_attr) except curses.error: pass - else: + + # Cursor position + if line_is_active and not app.processing: + col_on_visual = app.input_col - start + cursor_yx = (y, 4 + min(col_on_visual, len(chunk))) + + # Border kanan try: - stdscr.addstr(y, 2, " " * (w - 4), border_attr) + stdscr.addstr(y, w - 2, " \u2502", border_attr) + except curses.error: + pass + else: + # Empty row + try: + stdscr.addstr(y, 0, "\u2502" + " " * (w - 2) + "\u2502", border_attr) except curses.error: pass - - # Border kanan - try: - stdscr.addstr(y, w - 2, " \u2502", border_attr) - except curses.error: - pass - - # Catat posisi kursor (hanya untuk baris aktif) - if idx == app.input_line and not app.processing: - cursor_yx = (y, 4 + min(app.input_col, max_text)) if cursor_yx: stdscr.move(*cursor_yx)