Improve chat rendering: word wrap, label on separate line, blank line separators

This commit is contained in:
Dita Aji Pratama 2026-05-19 11:38:23 +07:00
parent 1f036e06a2
commit 46bf99b0ab

View File

@ -4,6 +4,7 @@
import curses import curses
import os import os
import textwrap
# -- Color pair IDs (id 1-9, id 0 = default curses) -- # -- Color pair IDs (id 1-9, id 0 = default curses) --
C_HEADER = 1 # header bar: biru C_HEADER = 1 # header bar: biru
@ -70,32 +71,62 @@ def draw_chat(app, stdscr):
return return
# Render log ke list of (color, text) agar scroll calculation akurat # Render log ke list of (color, text) agar scroll calculation akurat
# Setiap baris di-wrap sesuai lebar terminal
rendered = [] 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"] role, text = item["role"], item["text"]
if role == "sep": if role == "sep":
rendered.append((None, "")) rendered.append((None, ""))
rendered.append((None, "")) rendered.append((None, ""))
continue 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": if role == "user":
label = f" You ({item['time']}) " label = f" You ({item['time']}) "
color = C_USER rendered.append((C_USER, label))
_wrap_render(text, indent=1, color=C_INPUT)
elif role == "ai": elif role == "ai":
label = f" Hendrik ({item['time']}) " label = f" Hendrik ({item['time']}) "
color = C_AI rendered.append((C_AI, label))
_wrap_render(text, indent=3, color=C_INPUT)
elif role == "system": elif role == "system":
label = " \u25e6 " lines = text.split("\n")
color = C_SYSTEM rendered.append((C_SYSTEM, lines[0]))
for line in lines[1:]:
rendered.append((C_SYSTEM, " " + line))
elif role == "error": elif role == "error":
label = " \u2717 " label = " \u2717 "
color = C_ERROR lines = text.split("\n")
rendered.append((C_ERROR, label + (lines[0] if lines else "")))
lines = text.split("\n") for line in lines[1:]:
rendered.append((color, label + (lines[0] if lines else ""))) rendered.append((C_ERROR, " " + line))
for line in lines[1:]:
rendered.append((color, " " + line))
# Clamp scroll agar tidak melebihi total baris # Clamp scroll agar tidak melebihi total baris
total = len(rendered) total = len(rendered)
@ -126,12 +157,15 @@ def draw_chat(app, stdscr):
def draw_input(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. # Ada border atas dan bawah.
# Baris aktif ditandai "> " di depannya. # Baris aktif ditandai "> " di depannya.
# Soft word wrap: logical line panjang dipecah jadi beberapa visual line.
h, w = app.h, app.w h, w = app.h, app.w
iy = h - 8 iy = h - 8
border_attr = curses.color_pair(C_INPUT_BORDER) | curses.A_BOLD 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 └───┘ # Border atas ┌───┐ dan bawah └───┘
for off, text in [ for off, text in [
@ -143,51 +177,75 @@ def draw_input(app, stdscr):
except curses.error: except curses.error:
pass pass
# Scroll input horizontal jika buffer > 6 baris # Build visual lines dari buffer dengan soft wrap
total = len(app.input_buffer) visual = [] # list of (logical_line_idx, start_col, text_chunk)
if total <= 6: 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 show = 0
else: else:
show = max(0, min(app.input_line - 3, total - 6)) show = max(0, min(cur_visual - 3, total_visual - 6))
cursor_yx = None cursor_yx = None
text_attr = curses.color_pair(C_INPUT)
for i in range(6): for i in range(6):
idx = show + i idx = show + i
y = iy + 1 + i y = iy + 1 + i
line = app.input_buffer[idx] if idx < total else "" if idx < total_visual:
max_text = w - 6 li, start, chunk = visual[idx]
if len(line) > max_text: line_is_active = (li == app.input_line)
line = line[:max_text]
# Border kiri # Border kiri
try: try:
stdscr.addstr(y, 0, "\u2502 ", border_attr) stdscr.addstr(y, 0, "\u2502 ", border_attr)
except curses.error: except curses.error:
pass pass
# Isi baris # Isi baris
if idx < total: if line_is_active:
content = "> " + line + " " * (w - 4 - 2 - len(line)) prefix = "> "
else:
prefix = " "
content = prefix + chunk + " " * (w - 4 - len(prefix) - len(chunk))
try: try:
stdscr.addstr(y, 2, content, text_attr) stdscr.addstr(y, 2, content, text_attr)
except curses.error: except curses.error:
pass 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: 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: except curses.error:
pass 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: if cursor_yx:
stdscr.move(*cursor_yx) stdscr.move(*cursor_yx)