ui(chat): 💄 Improve message rendering and scroll behavior in ChatDisplay and ChatWindow

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-29 06:03:17 -07:00
parent 2c17dbfafd
commit 5eaa3bcbb1
2 changed files with 142 additions and 35 deletions

View file

@ -1,6 +1,7 @@
extends VBoxContainer
## Renders chat messages using a single RichTextLabel with BBCode.
## Handles its own scrolling and text selection — no ScrollContainer needed.
## Supports mark_speaking() to underline the currently-playing TTS sentence.
const SELECTION_BG_COLOR := Color("#1A5C56")
@ -12,9 +13,21 @@ const EMOTION_HEX: Dictionary = {
"relaxed": "#80CBC4",
}
class _Msg:
var role: String # "user" | "assistant" | "error"
var emotion: String # assistant only
var parts: Array[String] # sentences (assistant) or [text] (user/error)
func _init(p_role: String, p_emotion: String = "") -> void:
role = p_role
emotion = p_emotion
parts = []
var _rtl: RichTextLabel
var _has_messages: bool = false
var _current_emotion: String = "neutral"
var _messages: Array[_Msg] = []
var _speaking_msg: int = -1
var _speaking_part: int = -1
func setup() -> void:
@ -45,13 +58,115 @@ func setup() -> void:
add_child(_rtl)
func _separator() -> void:
if _has_messages:
_rtl.append_text("\n\n")
var message_count: int:
get:
return _messages.size()
func add_user_message(text: String) -> void:
_separator()
var msg := _Msg.new("user")
msg.parts.append(text)
_messages.append(msg)
_append_user_block(text)
_scroll_to_bottom()
func start_miku_message(emotion: String) -> void:
var msg := _Msg.new("assistant", emotion)
_messages.append(msg)
_append_miku_header(emotion)
func append_miku_text(text: String) -> void:
var msg: _Msg = _messages.back()
msg.parts.append(text)
_rtl.append_text("[color=%s]%s[/color]" % [UiTheme.text_primary.to_html(false), _escape(text)])
_scroll_to_bottom()
func update_miku_emotion(emotion: String) -> void:
if _messages.is_empty():
return
var msg: _Msg = _messages.back()
if msg.role == "assistant":
msg.emotion = emotion
func add_error_message(text: String) -> void:
var msg := _Msg.new("error")
msg.parts.append(text)
_messages.append(msg)
_append_error_block(text)
_scroll_to_bottom()
func add_assistant_message(text: String, emotion: String = "neutral") -> void:
start_miku_message(emotion)
append_miku_text(text)
func mark_speaking(msg_idx: int, part_idx: int) -> void:
_speaking_msg = msg_idx
_speaking_part = part_idx
_render()
func clear_messages() -> void:
_rtl.clear()
_messages.clear()
_speaking_msg = -1
_speaking_part = -1
func _render() -> void:
_rtl.clear()
for i: int in _messages.size():
var msg: _Msg = _messages[i]
if i > 0:
_rtl.append_text("\n\n")
match msg.role:
"user":
(
_rtl
. append_text(
(
"[right][color=%s]You[/color]\n[color=%s]%s[/color][/right]"
% [
UiTheme.text_muted.to_html(false),
UiTheme.text_primary.to_html(false),
_escape(msg.parts[0]),
]
)
)
)
"assistant":
var accent_hex: String = EMOTION_HEX.get(msg.emotion, UiTheme.accent.to_html(false))
_rtl.append_text(
(
"[color=%s]✦ Miku[/color] [color=%s]● %s[/color]\n"
% [UiTheme.accent.to_html(false), accent_hex, msg.emotion]
)
)
for j: int in msg.parts.size():
var part: String = msg.parts[j]
if i == _speaking_msg and j == _speaking_part:
_rtl.append_text(
"[u][color=%s]%s[/color][/u]" % [UiTheme.text_primary.to_html(false), _escape(part)]
)
else:
_rtl.append_text(
"[color=%s]%s[/color]" % [UiTheme.text_primary.to_html(false), _escape(part)]
)
"error":
_rtl.append_text(
"[color=#D45F5F]⚠[/color] [color=#FF9999]%s[/color]" % _escape(msg.parts[0])
)
_scroll_to_bottom()
func _append_user_block(text: String) -> void:
if _rtl.get_parsed_text().length() > 0:
_rtl.append_text("\n\n")
(
_rtl
. append_text(
@ -65,47 +180,24 @@ func add_user_message(text: String) -> void:
)
)
)
_has_messages = true
_scroll_to_bottom()
func start_miku_message(emotion: String) -> void:
_current_emotion = emotion
func _append_miku_header(emotion: String) -> void:
if _rtl.get_parsed_text().length() > 0:
_rtl.append_text("\n\n")
var accent_hex: String = EMOTION_HEX.get(emotion, UiTheme.accent.to_html(false))
_separator()
_rtl.append_text(
(
"[color=%s]✦ Miku[/color] [color=%s]● %s[/color]\n"
% [UiTheme.accent.to_html(false), accent_hex, emotion]
)
)
_has_messages = true
func append_miku_text(text: String) -> void:
_rtl.append_text("[color=%s]%s[/color]" % [UiTheme.text_primary.to_html(false), _escape(text)])
_scroll_to_bottom()
func update_miku_emotion(emotion: String) -> void:
_current_emotion = emotion
func add_error_message(text: String) -> void:
_separator()
func _append_error_block(text: String) -> void:
if _rtl.get_parsed_text().length() > 0:
_rtl.append_text("\n\n")
_rtl.append_text("[color=#D45F5F]⚠[/color] [color=#FF9999]%s[/color]" % _escape(text))
_has_messages = true
_scroll_to_bottom()
func add_assistant_message(text: String, emotion: String = "neutral") -> void:
start_miku_message(emotion)
append_miku_text(text)
func clear_messages() -> void:
_rtl.clear()
_has_messages = false
func _scroll_to_bottom() -> void:

View file

@ -29,6 +29,8 @@ var _status_label: Label
var _conversation_list: VBoxContainer
var _miku_streaming: bool = false
var _replay_regex: RegEx
var _current_miku_msg_idx: int = -1
var _speaking_sentence_idx: int = -1
func setup() -> void:
@ -37,6 +39,7 @@ func setup() -> void:
EventBus.sentence_ready.connect(_on_sentence_ready)
EventBus.state_changed.connect(_on_state_changed)
EventBus.emotion_changed.connect(_on_emotion_changed)
EventBus.audio_started.connect(_on_audio_started)
func _ready() -> void:
@ -140,6 +143,8 @@ func replay_messages(messages: Array[Dictionary]) -> void:
func clear_conversation() -> void:
_display.clear_messages()
_miku_streaming = false
_current_miku_msg_idx = -1
_speaking_sentence_idx = -1
func show_error(message: String) -> void:
@ -171,6 +176,8 @@ func _on_text_submitted_display(text: String) -> void:
func _on_sentence_ready(text: String, emotion: String) -> void:
if not _miku_streaming:
_current_miku_msg_idx = _display.message_count
_speaking_sentence_idx = -1
_display.start_miku_message(emotion)
_miku_streaming = true
var trimmed := text.strip_edges()
@ -178,11 +185,19 @@ func _on_sentence_ready(text: String, emotion: String) -> void:
_display.append_miku_text(trimmed + " ")
func _on_audio_started() -> void:
_speaking_sentence_idx += 1
_display.mark_speaking(_current_miku_msg_idx, _speaking_sentence_idx)
func _on_state_changed(_from: String, to: String) -> void:
if _status_label != null:
_status_label.text = STATE_LABELS.get(to, "")
if to == "idle":
_miku_streaming = false
if to in ["idle", "listening", "processing"]:
_speaking_sentence_idx = -1
_display.mark_speaking(-1, -1)
func _on_emotion_changed(emotion: String) -> void: