Improve chat rendering: word wrap, label on separate line, blank line separators
This commit is contained in:
parent
1f036e06a2
commit
46bf99b0ab
122
tui/render.py
122
tui/render.py
@ -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")
|
lines = text.split("\n")
|
||||||
rendered.append((color, label + (lines[0] if lines else "")))
|
rendered.append((C_ERROR, label + (lines[0] if lines else "")))
|
||||||
for line in lines[1:]:
|
for line in lines[1:]:
|
||||||
rendered.append((color, " " + line))
|
rendered.append((C_ERROR, " " + 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,22 +177,40 @@ 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:
|
||||||
@ -167,27 +219,33 @@ def draw_input(app, stdscr):
|
|||||||
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:
|
|
||||||
try:
|
# Cursor position
|
||||||
stdscr.addstr(y, 2, " " * (w - 4), border_attr)
|
if line_is_active and not app.processing:
|
||||||
except curses.error:
|
col_on_visual = app.input_col - start
|
||||||
pass
|
cursor_yx = (y, 4 + min(col_on_visual, len(chunk)))
|
||||||
|
|
||||||
# Border kanan
|
# Border kanan
|
||||||
try:
|
try:
|
||||||
stdscr.addstr(y, w - 2, " \u2502", border_attr)
|
stdscr.addstr(y, w - 2, " \u2502", border_attr)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
|
else:
|
||||||
# Catat posisi kursor (hanya untuk baris aktif)
|
# Empty row
|
||||||
if idx == app.input_line and not app.processing:
|
try:
|
||||||
cursor_yx = (y, 4 + min(app.input_col, max_text))
|
stdscr.addstr(y, 0, "\u2502" + " " * (w - 2) + "\u2502", border_attr)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
if cursor_yx:
|
if cursor_yx:
|
||||||
stdscr.move(*cursor_yx)
|
stdscr.move(*cursor_yx)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user