From 101821816b1d7e2aa5ad963836a2dfec203db50b Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Wed, 28 Oct 2026 14:57:37 -0400 Subject: [PATCH 1/2] Format thought stream and add model chat prefix Format streaming thoughts across handlers with a 'T>' prefix and add syntax highlighting. In chat buffers, prepend the active model name to answer prefixes and properly clear pending prefixes when streaming thoughts or tool outputs to visually separate reasoning traces from responses. Assisted-by: Gemini 3.8 Flash Signed-off-by: Simo Sorce --- python3/vimini/agent/chat.py | 25 ++++- python3/vimini/chat.py | 117 ++++++++++++++++++++-- python3/vimini/code.py | 8 ++ python3/vimini/handler.py | 52 +++++++++- python3/vimini/review.py | 5 + python3/vimini/util.py | 13 ++- tests/test_chat_prompt.py | 185 ++++++++++++++++++++++++++++++++++- tests/test_handler.py | 62 ++++++++++-- tests/test_util_buffer.py | 2 +- 9 files changed, 441 insertions(+), 28 deletions(-) diff --git a/python3/vimini/agent/chat.py b/python3/vimini/agent/chat.py index 339b153..d9531d3 100644 --- a/python3/vimini/agent/chat.py +++ b/python3/vimini/agent/chat.py @@ -404,7 +404,9 @@ def execute_project_tool(self, tool, cmd, current_req_id, conn): f" {project_file_path}\n" ) self.send_response( - current_req_id, conn, result={"status": "chunk", "text": msg} + current_req_id, + conn, + result={"status": "chunk", "text": msg, "tool_output": True}, ) return f"The tool '{tool}' is not available for this project because no {tool_label} command is configured in project options." @@ -414,6 +416,7 @@ def execute_project_tool(self, tool, cmd, current_req_id, conn): result={ "status": "chunk", "text": f"\n[Executing {tool_label} command: {cmd}...]\n", + "tool_output": True, }, ) @@ -433,7 +436,9 @@ def execute_project_tool(self, tool, cmd, current_req_id, conn): for line in iter(proc.stdout.readline, ""): output_lines.append(line) self.send_response( - current_req_id, conn, result={"status": "chunk", "text": line} + current_req_id, + conn, + result={"status": "chunk", "text": line, "tool_output": True}, ) proc.wait() @@ -442,7 +447,9 @@ def execute_project_tool(self, tool, cmd, current_req_id, conn): except Exception as e: err_text = f"Execution error: {e}\n" self.send_response( - current_req_id, conn, result={"status": "chunk", "text": err_text} + current_req_id, + conn, + result={"status": "chunk", "text": err_text, "tool_output": True}, ) returncode = -1 full_output = err_text.strip() @@ -452,7 +459,9 @@ def execute_project_tool(self, tool, cmd, current_req_id, conn): ) chat_msg = f"\n[{tool_label.capitalize()} command {status_str}]\n" self.send_response( - current_req_id, conn, result={"status": "chunk", "text": chat_msg} + current_req_id, + conn, + result={"status": "chunk", "text": chat_msg, "tool_output": True}, ) if returncode == 0: @@ -899,7 +908,11 @@ def process_prompt_stream(current_prompt, current_req_id): self.send_response( current_req_id, conn, - result={"status": "chunk", "text": msg}, + result={ + "status": "chunk", + "text": msg, + "tool_output": True, + }, ) responses.append( ToolResponse( @@ -923,6 +936,7 @@ def process_prompt_stream(current_prompt, current_req_id): result={ "status": "chunk", "text": f"\n[Agent {tool_label} command rejected due to schema error:\n{res_or_err}]\n", + "tool_output": True, }, ) responses.append( @@ -1157,6 +1171,7 @@ def process_prompt_stream(current_prompt, current_req_id): result={ "status": "chunk", "text": f"\n[{tool_label.capitalize()} command execution rejected by user]\n", + "tool_output": True, }, ) result_text = ( diff --git a/python3/vimini/chat.py b/python3/vimini/chat.py index d61f2c9..cf92f1f 100644 --- a/python3/vimini/chat.py +++ b/python3/vimini/chat.py @@ -3,7 +3,13 @@ import subprocess import os from vimini import util, context -from vimini.handler import StreamBufferHandler, register_handler, unregister_handler +from vimini.handler import ( + StreamBufferHandler, + register_handler, + unregister_handler, + get_handler, + T_PREFIX, +) from vimini.common.util import get_project_config from vimini.code import _DIFF_SEPARATOR, _process_x_diff_chunks @@ -14,6 +20,14 @@ Q_prefix = "< " A_prefix = "> " +T_prefix = T_PREFIX + + +def _get_a_prefix(): + model_name = util.get_model_name() + if model_name: + return f"{model_name}{A_prefix}" + return A_prefix def _to_str(val): @@ -242,6 +256,10 @@ def submit_prompt(prompt_buf_num=None, prompt_text=None): finally: chat_buf.options["modifiable"] = 0 + h = get_handler(req_id) + if isinstance(h, ChatChannelHandler): + h.reset_turn_state() + lines_to_add = [] last_line = chat_buf[-1] if len(chat_buf) > 0 else "" if last_line != "": @@ -249,7 +267,7 @@ def submit_prompt(prompt_buf_num=None, prompt_text=None): for pl in feedback.split("\n"): lines_to_add.append(f"{Q_prefix}{pl}") lines_to_add.append("---") - lines_to_add.append(A_prefix) + lines_to_add.append(_get_a_prefix()) _write_to_buffer(chat_buf, lines_to_add) _set_waiting(chat_buf, True) @@ -660,6 +678,65 @@ class ChatChannelHandler(StreamBufferHandler): def __init__(self, req_id, buffer=None): super().__init__(req_id, buffer=buffer, name_hint="Vimini Chat") + self.reset_turn_state() + + def reset_turn_state(self): + super().reset_turn_state() + self.in_response = False + + def _has_pending_a_prefix(self, buffer): + if not buffer: + return False + last = str(buffer[-1]) + if last.startswith(T_prefix): + return False + if last == _get_a_prefix() or last == A_prefix: + return True + if len(buffer) >= 2 and buffer[-2] == "---" and last.endswith(A_prefix): + model_part = last[: -len(A_prefix)] + return bool(model_part) and all( + c.isalnum() or c in "-._ " for c in model_part + ) + return False + + def _clear_pending_a_prefix(self, buffer): + if self._has_pending_a_prefix(buffer): + try: + was_modifiable = bool(buffer.options["modifiable"]) + except Exception: + was_modifiable = True + try: + buffer.options["modifiable"] = 1 + if len(buffer) == 1: + buffer[:] = [] + else: + del buffer[-1] + except Exception: + pass + finally: + if not was_modifiable: + try: + buffer.options["modifiable"] = 0 + except Exception: + pass + + def _write_response_chunk(self, buffer, text): + if not text: + return + if not self.in_response: + clean_text = text.lstrip("\n") + if not clean_text: + return + if self._has_pending_a_prefix(buffer): + _write_to_buffer(buffer, clean_text, append_to_last=True) + else: + prefixed_text = f"{_get_a_prefix()}{clean_text}" + if len(buffer) > 0 and not (len(buffer) == 1 and buffer[0] == ""): + prefixed_text = "\n" + prefixed_text + _write_to_buffer(buffer, prefixed_text, append_to_last=True) + self.in_response = True + else: + _write_to_buffer(buffer, text, append_to_last=True) def handle_response(self, result): if not isinstance(result, dict): @@ -680,14 +757,26 @@ def handle_response(self, result): except Exception: verbose = True if verbose and thought_text: - _write_to_buffer(buffer, thought_text, append_to_last=True) + self.in_response = False + if not self.in_thought: + self._clear_pending_a_prefix(buffer) + self.handle_thought(thought_text, verbose=True) elif status == "chunk": text = result.get("text", "") if text: - _write_to_buffer(buffer, text, append_to_last=True) + self.in_thought = False + self.thought_at_line_start = True + if result.get("tool_output"): + self.reset_turn_state() + self._clear_pending_a_prefix(buffer) + _write_to_buffer(buffer, text, append_to_last=True) + else: + self._write_response_chunk(buffer, text) elif status == "tool_use_requested": + self.reset_turn_state() + self._clear_pending_a_prefix(buffer) tool = result.get("tool", "") temp_file = result.get("temp_file") @@ -753,15 +842,22 @@ def handle_response(self, result): _set_waiting(buffer, False) text = result.get("text", "") if text: - _write_to_buffer(buffer, text, append_to_last=True) + self.in_thought = False + self.thought_at_line_start = True + self._write_response_chunk(buffer, text) + self.reset_turn_state() _write_to_buffer(buffer, ["", WAITING_MSG]) elif status == "terminated": _set_waiting(buffer, False) + self.reset_turn_state() + self._clear_pending_a_prefix(buffer) self.finished = True elif status == "error": _set_waiting(buffer, False) + self.reset_turn_state() + self._clear_pending_a_prefix(buffer) err_msg = result.get("error", "Unknown error") error_lines = [""] for line in str(err_msg).split("\n"): @@ -801,6 +897,11 @@ def _send_prompt(prompt, buffer): finally: buffer.options["modifiable"] = 0 + req_id = _to_str(buffer.vars.get("vimini_job_id", "")) + h = get_handler(req_id) + if isinstance(h, ChatChannelHandler): + h.reset_turn_state() + last_line = buffer[-1] if len(buffer) > 0 else "" lines_to_add = [] if last_line != "": @@ -810,7 +911,7 @@ def _send_prompt(prompt, buffer): for pl in prompt_lines: lines_to_add.append(f"{Q_prefix}{pl}") lines_to_add.append("---") - lines_to_add.append(A_prefix) + lines_to_add.append(_get_a_prefix()) _write_to_buffer(buffer, lines_to_add) @@ -824,7 +925,7 @@ def _send_prompt(prompt, buffer): verbose = True req = { "jsonrpc": "2.0", - "id": _to_str(buffer.vars.get("vimini_job_id", "")), + "id": req_id, "method": "chat", "params": { "verbose": verbose, @@ -866,6 +967,7 @@ def chat(): buffer.options["modifiable"] = 0 vim.command("highlight default ViminiWaiting ctermfg=Green guifg=Green") vim.command("highlight default ViminiPrompt ctermfg=DarkBlue guifg=DarkBlue") + vim.command("highlight default ViminiThinking ctermfg=DarkGray guifg=DarkGray") vim.command( "highlight default ViminiService ctermfg=Green guifg=Green cterm=italic gui=italic" ) @@ -874,6 +976,7 @@ def chat(): ) vim.command("syntax match ViminiWaiting '^\\(Welcome.*\\|Waiting for prompt.*\\)'") vim.command("syntax match ViminiPrompt '^< .*'") + vim.command("syntax match ViminiThinking '^T>.*'") vim.command("syntax match ViminiService '^Agent Requested: .*'") vim.command("syntax match ViminiError '^\\(\\[Error:.*\\|Error:.*\\)'") vim.command("syntax match ViminiError '^Please prompt again to retry later.*'") diff --git a/python3/vimini/code.py b/python3/vimini/code.py index 3805de6..87ed4cb 100644 --- a/python3/vimini/code.py +++ b/python3/vimini/code.py @@ -47,6 +47,7 @@ def handle_response(self, result): elif status == "completed": self.finished = True + self.reset_turn_state() files_to_process = result.get("files", []) diff_output = result.get("diff_output", "") project_root = ( @@ -73,6 +74,10 @@ def handle_response(self, result): ) vim.command(f"call setbufvar({buf_num}, '&filetype', 'diff')") + vim.command( + "highlight default ViminiThinking ctermfg=DarkGray guifg=DarkGray" + ) + vim.command("syntax match ViminiThinking '^T>.*'") base_buffer_name = f"[{self.req_id}] Vimini Code" try: @@ -82,6 +87,7 @@ def handle_response(self, result): elif status == "error": self.finished = True + self.reset_turn_state() err_msg = result.get("error", "Unknown error") if buf_num: util.write_to_buffer( @@ -176,6 +182,8 @@ def code(prompt, verbose=False): vim.command("setlocal bufhidden=wipe") vim.command("setlocal noswapfile") vim.command("setlocal filetype=markdown") + vim.command("highlight default ViminiThinking ctermfg=DarkGray guifg=DarkGray") + vim.command("syntax match ViminiThinking '^T>.*'") code_buffer = vim.current.buffer code_buffer_num = code_buffer.number diff --git a/python3/vimini/handler.py b/python3/vimini/handler.py index a6cf66f..4102e12 100644 --- a/python3/vimini/handler.py +++ b/python3/vimini/handler.py @@ -3,6 +3,7 @@ from vimini import util _ACTIVE_HANDLERS = {} +T_PREFIX = "T> " def register_handler(req_id, handler): @@ -68,6 +69,11 @@ def __init__(self, req_id, buffer=None, name_hint=""): self.buffer = buffer self.name_hint = name_hint self.first_chunk = False + self.reset_turn_state() + + def reset_turn_state(self): + self.in_thought = False + self.thought_at_line_start = True def get_buffer(self): if self.buffer is None: @@ -103,7 +109,40 @@ def handle_thought(self, thought, verbose=None): verbose = True buf = self.get_buffer() if verbose and thought and buf is not None: - util.write_to_buffer(buf, thought, append_to_last=True, redraw=True) + if not self.in_thought: + thought = thought.lstrip("\n") + if not thought: + try: + vim.command("redraw") + except Exception: + pass + return + self.in_thought = True + self.thought_at_line_start = True + + ends_with_nl = thought.endswith("\n") + body = thought[:-1] if ends_with_nl else thought + + if body == "" and not self.thought_at_line_start: + self.thought_at_line_start = True + try: + vim.command("redraw") + except Exception: + pass + return + + parts = body.split("\n") + is_empty_buf = len(buf) == 0 or (len(buf) == 1 and buf[0] == "") + if self.thought_at_line_start: + lines = [f"{T_PREFIX}{p}" if p else "" for p in parts] + if not is_empty_buf: + lines.insert(0, "") + util.write_to_buffer(buf, lines, append_to_last=True, redraw=True) + else: + lines = [parts[0]] + [f"{T_PREFIX}{p}" if p else "" for p in parts[1:]] + util.write_to_buffer(buf, lines, append_to_last=True, redraw=True) + + self.thought_at_line_start = ends_with_nl else: try: vim.command("redraw") @@ -114,9 +153,19 @@ def handle_chunk(self, chunk_text): self.update_spinner() buf = self.get_buffer() if buf is not None and chunk_text: + if self.in_thought: + self.in_thought = False + self.thought_at_line_start = True + if ( + len(buf) > 0 + and not (len(buf) == 1 and buf[0] == "") + and not chunk_text.startswith("\n") + ): + chunk_text = "\n" + chunk_text util.write_to_buffer(buf, chunk_text, append_to_last=True, redraw=True) def handle_tool_request(self, result): + self.reset_turn_state() buf = self.get_buffer() text = result.get("text") if text: @@ -136,6 +185,7 @@ def handle_tool_request(self, result): util.write_to_buffer(buf, req_line, append_to_last=True, redraw=True) def handle_error(self, error): + self.reset_turn_state() err_msg = ( error.get("message", "Unknown error") if isinstance(error, dict) diff --git a/python3/vimini/review.py b/python3/vimini/review.py index caabc85..7e714ab 100644 --- a/python3/vimini/review.py +++ b/python3/vimini/review.py @@ -52,6 +52,7 @@ def handle_response(self, result): elif status == "chunk": if not self.first_chunk: self.first_chunk = True + self.reset_turn_state() util.write_to_buffer( buf, "\n========== REVIEW START ==========\n", append_to_last=True ) @@ -60,6 +61,7 @@ def handle_response(self, result): elif status in ("completed", "terminated"): self.finished = True + self.reset_turn_state() base_buffer_name = f"[{self.req_id}] Vimini Review" try: buf.name = base_buffer_name @@ -72,6 +74,7 @@ def handle_response(self, result): elif status == "error": self.finished = True + self.reset_turn_state() err_msg = result.get("error", "Unknown error") util.write_to_buffer( buf, f"\nError: {err_msg}\n", append_to_last=True, redraw=True @@ -708,9 +711,11 @@ def review( vim.command("setlocal bufhidden=wipe") vim.command("setlocal noswapfile") vim.command("setlocal filetype=markdown") + vim.command("highlight default ViminiThinking ctermfg=DarkGray guifg=DarkGray") vim.command( "highlight default ViminiService ctermfg=Green guifg=Green cterm=italic gui=italic" ) + vim.command("syntax match ViminiThinking '^T>.*'") vim.command( "syntax match ViminiService '^\\[Agent requested tool execution: .*\\]'" ) diff --git a/python3/vimini/util.py b/python3/vimini/util.py index 2959f10..78e9365 100644 --- a/python3/vimini/util.py +++ b/python3/vimini/util.py @@ -87,11 +87,10 @@ def write_to_buffer( if buf is None: return - was_modifiable = ( - bool(buf.options.get("modifiable", True)) - if hasattr(buf, "options") and hasattr(buf.options, "get") - else True - ) + try: + was_modifiable = bool(buf.options["modifiable"]) + except Exception: + was_modifiable = True try: buf.options["modifiable"] = 1 except Exception: @@ -222,6 +221,8 @@ def get_model_name(): global _MODEL_NAME if _MODEL_NAME is not None: return _MODEL_NAME + if not _MODEL: + return _MODEL backend = _BACKEND api_type = None @@ -771,6 +772,8 @@ def create_thoughts_buffer(job_id): vim.command("setlocal bufhidden=wipe") vim.command("setlocal noswapfile") vim.command("setlocal filetype=markdown") + vim.command("highlight default ViminiThinking ctermfg=DarkGray guifg=DarkGray") + vim.command("syntax match ViminiThinking '^T>.*'") return vim.current.buffer.number diff --git a/tests/test_chat_prompt.py b/tests/test_chat_prompt.py index e28b7a2..d7d1bf6 100644 --- a/tests/test_chat_prompt.py +++ b/tests/test_chat_prompt.py @@ -149,17 +149,19 @@ def test_handle_channel_response_done_does_not_open_prompt_window(): patch.object(vim.current, "buffer", chat_buf), patch("vimini.chat._find_chat_buffer", return_value=chat_buf), patch("vimini.chat._open_prompt_window") as mock_open_prompt, + patch("vimini.util.get_model_name", return_value="gpt-4o"), ): h = chat.ChatChannelHandler("1", buffer=chat_buf) h.handle_response({"status": "done", "text": "Task finished."}) # Verify prompt window was NOT opened mock_open_prompt.assert_not_called() + assert "gpt-4o> Task finished." in chat_buf # Verify waiting message was added to chat buffer assert chat.WAITING_MSG in chat_buf -def test_chat_command_registers_p_mapping(): +def test_chat_command_registers_p_mapping_and_thinking_highlight(): import vim chat_buf = MockBuffer([], number=1) @@ -169,6 +171,7 @@ def test_chat_command_registers_p_mapping(): patch("vimini.util.new_split"), patch("vimini.chat._open_prompt_window"), ): + vim.command.reset_mock() chat.chat() commands_run = [call_args[0][0] for call_args in vim.command.call_args_list] @@ -177,8 +180,14 @@ def test_chat_command_registers_p_mapping(): in cmd for cmd in commands_run ) + assert any( + "highlight default ViminiThinking ctermfg=DarkGray guifg=DarkGray" in cmd + for cmd in commands_run + ) + assert any("syntax match ViminiThinking '^T>.*'" in cmd for cmd in commands_run) # Ensure BufEnter autocmd is not registered assert not any("autocmd BufEnter " in cmd for cmd in commands_run) + vim.command.reset_mock() def test_open_prompt_from_chat(): @@ -292,6 +301,7 @@ def test_submit_denial_prompt_with_feedback(): patch.object(vim.current, "buffer", prompt_buf), patch("vimini.chat._find_chat_buffer", return_value=chat_buf), patch("vimini.chat.send_agent_approval") as mock_send_approval, + patch("vimini.util.get_model_name", return_value="Gemini 3.8 Flash"), ): chat.submit_prompt(prompt_buf_num=2) @@ -300,11 +310,12 @@ def test_submit_denial_prompt_with_feedback(): mock_send_approval.assert_called_once_with( False, "1", error=expected_feedback, feedback=expected_feedback ) - # Verify chat buffer updated with feedback + # Verify chat buffer updated with feedback and model-prefixed > assert any( "Patch denied: I want native JS methods instead of lodash" in line for line in chat_buf ) + assert chat_buf[-1] == "Gemini 3.8 Flash> " assert chat_buf.vars.get("vimini_waiting") is True vim.command.reset_mock() @@ -402,3 +413,173 @@ def test_handle_channel_response_fix_format_approved_by_permission(): ) mock_send_approval.assert_called_once_with(True, "1") + + +def test_chat_prompt_and_response_with_thinking_and_model_prefix(): + import vim + + chat_buf = MockBuffer([chat.WELCOME_MSG], number=1, name="[1] Vimini Chat") + chat_buf.vars["vimini_job_id"] = "1" + + h = chat.ChatChannelHandler("1", buffer=chat_buf) + chat.register_handler("1", h) + + with ( + patch.object(vim, "buffers", [chat_buf]), + patch("vimini.util.send_channel_request", return_value=True), + patch("vimini.util.get_model_name", return_value="gpt-5.5"), + ): + chat._send_prompt("How does this work?", chat_buf) + + # Immediately after _send_prompt, model prefix is prepared + assert list(chat_buf) == [ + "< How does this work?", + "---", + "gpt-5.5> ", + ] + + # Stream thought chunks (including partial lines and newlines) + h.handle_response( + {"status": "thought", "thought": "Let me analyze ", "verbose": True} + ) + h.handle_response( + { + "status": "thought", + "thought": "the code.\nChecking module structure.", + "verbose": True, + } + ) + + assert list(chat_buf) == [ + "< How does this work?", + "---", + "T> Let me analyze the code.", + "T> Checking module structure.", + ] + + # Stream model response chunks + h.handle_response({"status": "chunk", "text": "It works by "}) + h.handle_response( + {"status": "chunk", "text": "dispatching requests.\nDetails below."} + ) + h.handle_response({"status": "done"}) + + assert list(chat_buf) == [ + "< How does this work?", + "---", + "T> Let me analyze the code.", + "T> Checking module structure.", + "gpt-5.5> It works by dispatching requests.", + "Details below.", + "", + chat.WAITING_MSG, + ] + + +def test_chat_thought_line_ending_with_prompt_symbol_not_deleted(): + import vim + + chat_buf = MockBuffer( + ["< Review template usage", "---", "gpt-4o> "], + number=1, + name="[1] Vimini Chat", + ) + chat_buf.vars["vimini_job_id"] = "1" + + h = chat.ChatChannelHandler("1", buffer=chat_buf) + + with ( + patch.object(vim, "buffers", [chat_buf]), + patch("vimini.util.get_model_name", return_value="gpt-4o"), + ): + # First thought chunk whose line ends with "> " (like C++ template syntax) + # buffer[-2] is "---" + h.handle_response( + { + "status": "thought", + "thought": "Checking std::vector ", + "verbose": True, + } + ) + assert list(chat_buf) == [ + "< Review template usage", + "---", + "T> Checking std::vector ", + ] + + # Second thought chunk arrives; _clear_pending_a_prefix must NOT delete the thought line + h.handle_response( + {"status": "thought", "thought": "and map types.\n", "verbose": True} + ) + assert list(chat_buf) == [ + "< Review template usage", + "---", + "T> Checking std::vector and map types.", + ] + + # First response chunk arrives; must NOT append onto the thought line + h.handle_response({"status": "chunk", "text": "Templates are supported."}) + assert list(chat_buf) == [ + "< Review template usage", + "---", + "T> Checking std::vector and map types.", + "gpt-4o> Templates are supported.", + ] + + +def test_clear_pending_a_prefix_restores_unmodifiable_buffer(): + chat_buf = MockBuffer(["---", "gpt-4o> "], number=1) + chat_buf.options["modifiable"] = False + + h = chat.ChatChannelHandler("1", buffer=chat_buf) + with patch("vimini.util.get_model_name", return_value="gpt-4o"): + h._clear_pending_a_prefix(chat_buf) + + assert list(chat_buf) == ["---"] + assert not chat_buf.options["modifiable"] + + +def test_chat_terminated_clears_pending_prefix(): + chat_buf = MockBuffer( + ["< hello", "---", "gpt-4o> "], number=1, name="[1] Vimini Chat" + ) + chat_buf.vars["vimini_job_id"] = "1" + + h = chat.ChatChannelHandler("1", buffer=chat_buf) + with patch("vimini.util.get_model_name", return_value="gpt-4o"): + h.handle_response({"status": "terminated"}) + + assert list(chat_buf) == ["< hello", "---"] + + +def test_clear_pending_a_prefix_only_called_at_start_of_thoughts(): + chat_buf = MockBuffer( + ["< question", "---", "gpt-4o> "], + number=1, + name="[1] Vimini Chat", + ) + chat_buf.vars["vimini_job_id"] = "1" + + h = chat.ChatChannelHandler("1", buffer=chat_buf) + + with ( + patch.object(chat.vim, "buffers", [chat_buf]), + patch("vimini.util.get_model_name", return_value="gpt-4o"), + patch.object( + h, "_clear_pending_a_prefix", wraps=h._clear_pending_a_prefix + ) as mock_clear, + ): + # First thought chunk: starting thoughts, must invoke _clear_pending_a_prefix + h.handle_response( + {"status": "thought", "thought": "Chunk 1...", "verbose": True} + ) + assert mock_clear.call_count == 1 + + # Second and third thought chunks: already in thought, must not invoke _clear_pending_a_prefix + h.handle_response( + {"status": "thought", "thought": "Chunk 2...", "verbose": True} + ) + h.handle_response( + {"status": "thought", "thought": "Chunk 3...\n", "verbose": True} + ) + assert mock_clear.call_count == 1 diff --git a/tests/test_handler.py b/tests/test_handler.py index 0f8580a..065042c 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -74,16 +74,61 @@ def test_stream_buffer_handler_thought_and_chunk(): # Test thought h.handle_thought("Analyzing input...", verbose=True) - assert any("Analyzing input..." in line for line in buf) + assert buf[0] == "T> Analyzing input..." assert "[<-G]" in buf.name - # Test chunk + # Test multiline thought continuation + h.handle_thought(" more details\nSecond thought line\n", verbose=True) + assert buf[0] == "T> Analyzing input... more details" + assert buf[1] == "T> Second thought line" + + # Test chunk starts on a new line without T> h.handle_chunk("Streamed response chunk.") - assert any("Streamed response chunk." in line for line in buf) + assert buf[2] == "Streamed response chunk." # Spinner should have advanced to [<-\] assert "[<-\\]" in buf.name +def test_stream_buffer_handler_empty_lines_not_prefixed(): + buf = MockBuffer([], number=43, name="[43] Job [->G?]") + buf.vars["vimini_job_id"] = "43" + + h = handler.StreamBufferHandler("43", buffer=buf) + + # Thought with an empty line inside a single chunk + h.handle_thought( + "First thought paragraph.\n\nSecond thought paragraph.\n", verbose=True + ) + assert buf[0] == "T> First thought paragraph." + assert buf[1] == "" + assert buf[2] == "T> Second thought paragraph." + + # Thought with an empty line streamed as an isolated chunk + h.handle_thought("\n", verbose=True) + assert buf[3] == "" + + # Following thought chunk on a new line + h.handle_thought("Third thought paragraph.\n", verbose=True) + assert buf[4] == "T> Third thought paragraph." + + +def test_stream_buffer_handler_leading_newlines_in_thoughts(): + buf = MockBuffer([], number=44, name="[44] Job [->G?]") + buf.vars["vimini_job_id"] = "44" + + h = handler.StreamBufferHandler("44", buffer=buf) + + # Chunk containing only newlines should not prematurely set in_thought + h.handle_thought("\n\n", verbose=True) + assert not h.in_thought + assert len(buf) == 0 + + # Next chunk with content starts thought cleanly without stray lines + h.handle_thought("Beginning thought process.\n", verbose=True) + assert h.in_thought + assert list(buf) == ["T> Beginning thought process."] + + def test_stream_buffer_handler_tool_request(): buf = MockBuffer([], number=50, name="[50] Job") h = handler.StreamBufferHandler("50", buffer=buf) @@ -162,7 +207,10 @@ def test_chat_channel_handler_turn_persists(): h = chat.ChatChannelHandler("60", buffer=buf) handler.register_handler("60", h) - with patch.object(vim, "buffers", [buf]): + with ( + patch.object(vim, "buffers", [buf]), + patch("vimini.util.get_model_name", return_value="Gemini 3.8 Flash"), + ): # Send first turn chunk main.handle_channel_message( { @@ -170,7 +218,7 @@ def test_chat_channel_handler_turn_persists(): "result": {"status": "chunk", "text": "Turn 1 answer"}, } ) - assert any("Turn 1 answer" in line for line in buf) + assert any("Gemini 3.8 Flash> Turn 1 answer" in line for line in buf) # Send done: chat handler must NOT be finished (multi-turn conversation) main.handle_channel_message( @@ -190,7 +238,7 @@ def test_chat_channel_handler_turn_persists(): "result": {"status": "chunk", "text": "Turn 2 answer"}, } ) - assert any("Turn 2 answer" in line for line in buf) + assert any("Gemini 3.8 Flash> Turn 2 answer" in line for line in buf) # Send terminated: handler is now finished main.handle_channel_message( @@ -229,7 +277,7 @@ def test_code_channel_handler_flow(): }, } ) - assert any("Refactoring code..." in line for line in buf) + assert any("T> Refactoring code..." in line for line in buf) assert "[<-G]" in buf.name # Send chunk diff --git a/tests/test_util_buffer.py b/tests/test_util_buffer.py index d215a50..dc0acf0 100644 --- a/tests/test_util_buffer.py +++ b/tests/test_util_buffer.py @@ -111,7 +111,7 @@ def test_review_channel_handler_thought_and_chunk(): }, } ) - assert any("Thinking about security..." in line for line in buf) + assert any("T> Thinking about security..." in line for line in buf) assert "[<-G]" in buf.name # Test tool_use_requested response From c9a718e89cf6986b494a8ad844fb2226aabbd16d Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Wed, 28 Oct 2026 16:38:32 -0400 Subject: [PATCH 2/2] Add customizable UI highlight colors Introduce a UI preferences section to the global configuration, allowing users to customize syntax highlight colors for prompts, thoughts, status messages, errors, and tool execution notices. Highlight setup is now centralized in a shared utility function, and preferences can be edited or reset interactively in the global configuration manager. Assisted-by: Gemini 3.8 Flash Signed-off-by: Simo Sorce --- README.md | 25 +++++------ python3/vimini/chat.py | 18 ++------ python3/vimini/code.py | 6 +-- python3/vimini/common/config.py | 21 +++++++++ python3/vimini/config.py | 60 +++++++++++++++++++++++++ python3/vimini/review.py | 5 +-- python3/vimini/util.py | 77 ++++++++++++++++++++++++++++++++- tests/test_global_config.py | 22 ++++++++++ tests/test_global_config_ui.py | 55 +++++++++++++++++++++++ tests/test_util_buffer.py | 37 ++++++++++++++++ 10 files changed, 287 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 775f5eb..adef2bd 100644 --- a/README.md +++ b/README.md @@ -167,20 +167,15 @@ customizations. "api_key_file": "~/.config/gemini.token", "default_model": "gemini-3.8-flash" } - } - } - ``` - Configure per-project settings such as whether compilation is needed, shell commands for compiling/building code and running tests when invoked by chat tools (`build_code` and `test_code`), and the directory path where temporary git worktrees can be created. Project configuration is stored in `~/.var/vimini/projects/`: - ```json - { - "version": "0.1", - "configuration": { - "build-command": "make -j$(nproc)", - "test-command": "pytest", - "compilation-needed": false, - "worktree": "~/.var/vimini/worktree" }, - "files": [] + "ui": { + "thinking_color": "DarkGray", + "prompt_color": "DarkBlue", + "waiting_color": "Green", + "service_color": "Green", + "error_color": "Red", + "prompt_hint_color": "Green" + } } ``` @@ -198,9 +193,9 @@ Opens an interactive configuration editor in a new split window to configure glo **How to use the global configuration manager:** * Move the cursor to any configuration option or backend line. -* **`` or `e`**: Edits the selected value. On `active_backend`, enter one of the configured backend names. On a backend header, rename the backend. +* **`` or `e`**: Edits the selected value. On `active_backend`, enter one of the configured backend names. On a backend header, rename the backend. Under `UI PREFERENCES`, customize colors (e.g. `thinking_color`, `prompt_color`, `waiting_color`, `service_color`, `error_color`, `prompt_hint_color`). * **`a`**: Adds a new backend and prompts for API type, default model, API key file, and base URL for OpenAI-compatible backends. -* **`d`**: Clears the selected backend field or deletes the selected backend after confirmation. The last backend cannot be deleted. +* **`d`**: Clears the selected backend field, deletes a backend after confirmation, or resets a UI preference back to default. The last backend cannot be deleted. * **`r`**: Resets all global configuration options to their last saved state. * **`q`**: Closes the configuration window. If modifications were made, a confirmation prompt asks whether to save changes permanently to `~/.var/vimini/config.json`. diff --git a/python3/vimini/chat.py b/python3/vimini/chat.py index cf92f1f..9c29ca5 100644 --- a/python3/vimini/chat.py +++ b/python3/vimini/chat.py @@ -125,9 +125,7 @@ def _open_prompt_window(req_id): prompt_buf[:] = [HINT_MSG, ""] - vim.command( - "highlight default ViminiPromptHint ctermfg=Green guifg=Green cterm=italic gui=italic" - ) + util.setup_highlights() vim.command("syntax match ViminiPromptHint '^Press .* to submit prompt$'") # Buffer-local mappings to submit prompt @@ -392,9 +390,7 @@ def _open_denial_prompt_window(req_id): prompt_buf[:] = [HINT_MSG, DENIAL_PREFIX] - vim.command( - "highlight default ViminiPromptHint ctermfg=Green guifg=Green cterm=italic gui=italic" - ) + util.setup_highlights() vim.command("syntax match ViminiPromptHint '^Press .* to submit prompt$'") # Buffer-local mappings to submit prompt @@ -965,15 +961,7 @@ def chat(): buffer.options["filetype"] = "markdown" buffer.options["swapfile"] = 0 buffer.options["modifiable"] = 0 - vim.command("highlight default ViminiWaiting ctermfg=Green guifg=Green") - vim.command("highlight default ViminiPrompt ctermfg=DarkBlue guifg=DarkBlue") - vim.command("highlight default ViminiThinking ctermfg=DarkGray guifg=DarkGray") - vim.command( - "highlight default ViminiService ctermfg=Green guifg=Green cterm=italic gui=italic" - ) - vim.command( - "highlight default ViminiError ctermfg=Red guifg=Red cterm=italic gui=italic" - ) + util.setup_highlights() vim.command("syntax match ViminiWaiting '^\\(Welcome.*\\|Waiting for prompt.*\\)'") vim.command("syntax match ViminiPrompt '^< .*'") vim.command("syntax match ViminiThinking '^T>.*'") diff --git a/python3/vimini/code.py b/python3/vimini/code.py index 87ed4cb..8b788f9 100644 --- a/python3/vimini/code.py +++ b/python3/vimini/code.py @@ -74,9 +74,7 @@ def handle_response(self, result): ) vim.command(f"call setbufvar({buf_num}, '&filetype', 'diff')") - vim.command( - "highlight default ViminiThinking ctermfg=DarkGray guifg=DarkGray" - ) + util.setup_highlights() vim.command("syntax match ViminiThinking '^T>.*'") base_buffer_name = f"[{self.req_id}] Vimini Code" @@ -182,7 +180,7 @@ def code(prompt, verbose=False): vim.command("setlocal bufhidden=wipe") vim.command("setlocal noswapfile") vim.command("setlocal filetype=markdown") - vim.command("highlight default ViminiThinking ctermfg=DarkGray guifg=DarkGray") + util.setup_highlights() vim.command("syntax match ViminiThinking '^T>.*'") code_buffer = vim.current.buffer diff --git a/python3/vimini/common/config.py b/python3/vimini/common/config.py index 5b273c8..16cc935 100644 --- a/python3/vimini/common/config.py +++ b/python3/vimini/common/config.py @@ -11,6 +11,15 @@ DEFAULT_GEMINI_KEY_FILE = "~/.config/gemini.token" SUPPORTED_API_TYPES = {"genai", "openai"} +DEFAULT_UI_CONFIG = { + "thinking_color": "DarkGray", + "prompt_color": "DarkBlue", + "waiting_color": "Green", + "service_color": "Green", + "error_color": "Red", + "prompt_hint_color": "Green", +} + def _to_bool(value, default=False): if isinstance(value, bool): @@ -60,6 +69,7 @@ def create_default_global_config(default_model=None, api_key_file=None): "default_model": default_model or DEFAULT_MODEL, } }, + "ui": dict(DEFAULT_UI_CONFIG), } @@ -120,6 +130,17 @@ def validate_global_config(config): f"Backend '{name}' must specify a non-empty 'default_model'" ) + ui = config.get("ui") + if ui is not None: + if not isinstance(ui, dict): + raise ValueError("Global configuration 'ui' must be a dictionary") + for key, val in ui.items(): + if not isinstance(key, str) or not key.strip(): + raise ValueError( + "Global configuration 'ui' keys must be non-empty strings" + ) + if val is not None and not isinstance(val, str): + raise ValueError(f"Global configuration 'ui.{key}' must be a string") return config diff --git a/python3/vimini/config.py b/python3/vimini/config.py index 801e47c..8934451 100644 --- a/python3/vimini/config.py +++ b/python3/vimini/config.py @@ -119,6 +119,9 @@ def _draw_global_config_listing(config_data, config_path=None): config_path = config_path or get_global_config_path() active_backend = config_data.get("active_backend", "") backends = config_data.get("backends", {}) + ui_cfg = config_data.get("ui", {}) + if not isinstance(ui_cfg, dict): + ui_cfg = {} lines = [] line_map = {} @@ -144,6 +147,34 @@ def _draw_global_config_listing(config_data, config_path=None): ) _add_mapped_line(lines, line_map, " # Currently selected model backend") _add_mapped_line(lines, line_map, "") + _add_mapped_line(lines, line_map, "> UI PREFERENCES") + + from vimini.common.config import DEFAULT_UI_CONFIG + + ui_fields = [ + ("thinking_color", "Color for streamed thoughts (T> ...)"), + ("prompt_color", "Color for chat user prompts (< ...)"), + ("waiting_color", "Color for status and waiting messages"), + ("service_color", "Color for agent service/tool execution notices"), + ("error_color", "Color for error messages"), + ("prompt_hint_color", "Color for prompt buffer hints"), + ] + for field, desc in ui_fields: + val = ui_cfg.get(field) + val_str = ( + str(val) + if val is not None and val != "" + else f"{DEFAULT_UI_CONFIG.get(field, '')} (default)" + ) + _add_mapped_line( + lines, + line_map, + f" {field} = {val_str}", + ("ui", field), + ) + if desc: + _add_mapped_line(lines, line_map, f" # {desc}") + _add_mapped_line(lines, line_map, "") _add_mapped_line(lines, line_map, "> BACKENDS") if not isinstance(backends, dict) or not backends: @@ -332,6 +363,26 @@ def edit_global_config_option(): ) return + if kind == "ui": + field = target[1] + from vimini.common.config import DEFAULT_UI_CONFIG + + ui_cfg = _VIMINI_PENDING_GLOBAL_CONFIG.setdefault("ui", {}) + current = ui_cfg.get(field, "") + default_val = DEFAULT_UI_CONFIG.get(field, "") + prompt = f"{field} (default: {default_val}): " + new_val = _to_str( + vim.eval(f"input({_vim_string(prompt)}, {_vim_string(current)})") + ).strip() + if not new_val: + ui_cfg.pop(field, None) + else: + ui_cfg[field] = new_val + _refresh_global_config_buffer(win, line_num, col) + val_disp = new_val if new_val else f"{default_val} (default)" + util.display_message(f"Set 'ui.{field}' to '{val_disp}'.") + return + if kind == "backend_field": backend_name, field = target[1], target[2] backend_cfg = backends.setdefault(backend_name, {}) @@ -476,6 +527,14 @@ def delete_or_clear_global_config_option(): win = vim.current.window line_num, col = win.cursor backends = _VIMINI_PENDING_GLOBAL_CONFIG.setdefault("backends", {}) + if target[0] == "ui": + field = target[1] + ui_cfg = _VIMINI_PENDING_GLOBAL_CONFIG.get("ui") + if isinstance(ui_cfg, dict): + ui_cfg.pop(field, None) + _refresh_global_config_buffer(win, line_num, col) + util.display_message(f"Reset 'ui.{field}' to default.") + return kind = target[0] if kind == "backend_field": backend_name, field = target[1], target[2] @@ -1328,6 +1387,7 @@ def _apply_saved_global_config_runtime(config_data): backend_name = config_data.get("active_backend") if backend_name: main.apply_backend_config(config_data, backend_name, persist=False) + util.setup_highlights(config_data) except Exception as e: util.log_info(f"Failed to apply saved global config at runtime: {e}") diff --git a/python3/vimini/review.py b/python3/vimini/review.py index 7e714ab..1fe8437 100644 --- a/python3/vimini/review.py +++ b/python3/vimini/review.py @@ -711,10 +711,7 @@ def review( vim.command("setlocal bufhidden=wipe") vim.command("setlocal noswapfile") vim.command("setlocal filetype=markdown") - vim.command("highlight default ViminiThinking ctermfg=DarkGray guifg=DarkGray") - vim.command( - "highlight default ViminiService ctermfg=Green guifg=Green cterm=italic gui=italic" - ) + util.setup_highlights() vim.command("syntax match ViminiThinking '^T>.*'") vim.command( "syntax match ViminiService '^\\[Agent requested tool execution: .*\\]'" diff --git a/python3/vimini/util.py b/python3/vimini/util.py index 78e9365..86fb234 100644 --- a/python3/vimini/util.py +++ b/python3/vimini/util.py @@ -256,6 +256,81 @@ def get_model_name(): return _MODEL_NAME +def setup_highlights(config=None): + """ + Applies Vimini syntax highlight groups across buffers. + Uses colors from the provided config or global configuration's 'ui' preferences, + falling back to standard defaults. + """ + ui_cfg = {} + if config is not None and isinstance(config, dict): + ui_cfg = config.get("ui") or {} + else: + try: + from vimini.common.config import load_global_config + + cfg = load_global_config() + ui_cfg = cfg.get("ui") or {} + except Exception: + ui_cfg = {} + + if not isinstance(ui_cfg, dict): + ui_cfg = {} + + from vimini.common.config import DEFAULT_UI_CONFIG + + def _build_highlight_command(group, color_val, is_custom, extra=""): + default_clause = "" if is_custom else "default " + if "=" in str(color_val): + attrs = f"{color_val} {extra}".strip() + else: + attrs = f"ctermfg={color_val} guifg={color_val} {extra}".strip() + return f"highlight {default_clause}{group} {attrs}" + + thinking_color = ui_cfg.get("thinking_color") or DEFAULT_UI_CONFIG["thinking_color"] + is_custom_thinking = "thinking_color" in ui_cfg and bool(ui_cfg["thinking_color"]) + + prompt_color = ui_cfg.get("prompt_color") or DEFAULT_UI_CONFIG["prompt_color"] + is_custom_prompt = "prompt_color" in ui_cfg and bool(ui_cfg["prompt_color"]) + + waiting_color = ui_cfg.get("waiting_color") or DEFAULT_UI_CONFIG["waiting_color"] + is_custom_waiting = "waiting_color" in ui_cfg and bool(ui_cfg["waiting_color"]) + + service_color = ui_cfg.get("service_color") or DEFAULT_UI_CONFIG["service_color"] + is_custom_service = "service_color" in ui_cfg and bool(ui_cfg["service_color"]) + + error_color = ui_cfg.get("error_color") or DEFAULT_UI_CONFIG["error_color"] + is_custom_error = "error_color" in ui_cfg and bool(ui_cfg["error_color"]) + + prompt_hint_color = ( + ui_cfg.get("prompt_hint_color") or DEFAULT_UI_CONFIG["prompt_hint_color"] + ) + is_custom_hint = "prompt_hint_color" in ui_cfg and bool(ui_cfg["prompt_hint_color"]) + + cmds = [ + _build_highlight_command("ViminiWaiting", waiting_color, is_custom_waiting), + _build_highlight_command("ViminiPrompt", prompt_color, is_custom_prompt), + _build_highlight_command("ViminiThinking", thinking_color, is_custom_thinking), + _build_highlight_command( + "ViminiService", service_color, is_custom_service, "cterm=italic gui=italic" + ), + _build_highlight_command( + "ViminiError", error_color, is_custom_error, "cterm=italic gui=italic" + ), + _build_highlight_command( + "ViminiPromptHint", + prompt_hint_color, + is_custom_hint, + "cterm=italic gui=italic", + ), + ] + for cmd in cmds: + try: + vim.command(cmd) + except Exception: + pass + + def new_split(split_method=None): """Creates a new split using the user's preferred method or the specified split_method.""" if split_method is None: @@ -772,7 +847,7 @@ def create_thoughts_buffer(job_id): vim.command("setlocal bufhidden=wipe") vim.command("setlocal noswapfile") vim.command("setlocal filetype=markdown") - vim.command("highlight default ViminiThinking ctermfg=DarkGray guifg=DarkGray") + setup_highlights() vim.command("syntax match ViminiThinking '^T>.*'") return vim.current.buffer.number diff --git a/tests/test_global_config.py b/tests/test_global_config.py index 04a2c6c..86ced25 100644 --- a/tests/test_global_config.py +++ b/tests/test_global_config.py @@ -12,6 +12,7 @@ DEFAULT_BACKEND, DEFAULT_MODEL, DEFAULT_GEMINI_KEY_FILE, + DEFAULT_UI_CONFIG, create_default_global_config, validate_global_config, load_global_config, @@ -28,6 +29,8 @@ def test_create_default_global_config(): cfg = create_default_global_config() assert cfg["active_backend"] == DEFAULT_BACKEND assert DEFAULT_BACKEND in cfg["backends"] + assert "ui" in cfg + assert cfg["ui"]["thinking_color"] == DEFAULT_UI_CONFIG["thinking_color"] gemini = cfg["backends"][DEFAULT_BACKEND] assert gemini["api"] == "genai" assert gemini["api_key_file"] == DEFAULT_GEMINI_KEY_FILE @@ -187,6 +190,25 @@ def test_validate_global_config_errors(): } ) + # UI preference validation + with pytest.raises(ValueError, match="'ui' must be a dictionary"): + validate_global_config( + { + "active_backend": "Gemini", + "backends": {"Gemini": {"api": "genai", "api_key_file": "~/.token"}}, + "ui": "invalid", + } + ) + + with pytest.raises(ValueError, match="'ui.thinking_color' must be a string"): + validate_global_config( + { + "active_backend": "Gemini", + "backends": {"Gemini": {"api": "genai", "api_key_file": "~/.token"}}, + "ui": {"thinking_color": 123}, + } + ) + def test_save_global_config(tmp_path): config_file = tmp_path / "subdir" / "config.json" diff --git a/tests/test_global_config_ui.py b/tests/test_global_config_ui.py index 876859c..7f8c560 100644 --- a/tests/test_global_config_ui.py +++ b/tests/test_global_config_ui.py @@ -46,9 +46,12 @@ def test_draw_global_config_listing_contains_backends(): assert "Vimini Global Configuration" in text assert "active_backend = Gemini" in text + assert "> UI PREFERENCES" in text + assert "thinking_color = DarkGray (default)" in text assert "* Gemini" in text assert " Proxy" in text assert "base_url = https://proxy.example/v1" in text + assert ("ui", "thinking_color") in line_map.values() assert ("global", "active_backend") in line_map.values() assert ("backend", "Proxy") in line_map.values() assert ("backend_field", "Proxy", "default_model") in line_map.values() @@ -268,3 +271,55 @@ def mock_vim_eval(expr): ): config.confirm_global_config() mock_save.assert_called_once_with(pending, config_path="/tmp/test_config.json") + + +def test_edit_ui_preference_option(): + import vim + from vimini import config + + config._VIMINI_PENDING_GLOBAL_CONFIG = _sample_global_config() + mock_win = MagicMock() + mock_win.cursor = (1, 0) + mock_win.buffer = MockBuffer() + + with ( + patch.object(vim.current, "window", mock_win), + patch( + "vimini.config._get_global_target_at_cursor", + return_value=("ui", "thinking_color"), + ), + patch.object(vim, "eval", return_value="Cyan"), + patch("vimini.config._refresh_global_config_buffer"), + patch("vimini.util.display_message") as mock_msg, + ): + config.edit_global_config_option() + + assert config._VIMINI_PENDING_GLOBAL_CONFIG["ui"]["thinking_color"] == "Cyan" + mock_msg.assert_called_once_with("Set 'ui.thinking_color' to 'Cyan'.") + + +def test_delete_or_clear_ui_preference_option(): + import vim + from vimini import config + + cfg = _sample_global_config() + cfg["ui"] = {"thinking_color": "Cyan", "prompt_color": "Blue"} + config._VIMINI_PENDING_GLOBAL_CONFIG = cfg + mock_win = MagicMock() + mock_win.cursor = (1, 0) + mock_win.buffer = MockBuffer() + + with ( + patch.object(vim.current, "window", mock_win), + patch( + "vimini.config._get_global_target_at_cursor", + return_value=("ui", "thinking_color"), + ), + patch("vimini.config._refresh_global_config_buffer"), + patch("vimini.util.display_message") as mock_msg, + ): + config.delete_or_clear_global_config_option() + + assert "thinking_color" not in config._VIMINI_PENDING_GLOBAL_CONFIG["ui"] + assert config._VIMINI_PENDING_GLOBAL_CONFIG["ui"]["prompt_color"] == "Blue" + mock_msg.assert_called_once_with("Reset 'ui.thinking_color' to default.") diff --git a/tests/test_util_buffer.py b/tests/test_util_buffer.py index dc0acf0..759fa66 100644 --- a/tests/test_util_buffer.py +++ b/tests/test_util_buffer.py @@ -271,3 +271,40 @@ def test_get_model_name_gemini_calls_client(): assert name == "Gemini 3.8 Flash" assert util._MODEL_NAME == "Gemini 3.8 Flash" mock_client.models.get.assert_called_once_with(model="gemini-3.8-flash") + + +def test_setup_highlights_default_and_custom(): + import vim + + with patch.object(vim, "command") as mock_cmd: + # Test with default config + util.setup_highlights({}) + commands = [call.args[0] for call in mock_cmd.call_args_list] + assert ( + "highlight default ViminiThinking ctermfg=DarkGray guifg=DarkGray" + in commands + ) + assert ( + "highlight default ViminiPrompt ctermfg=DarkBlue guifg=DarkBlue" in commands + ) + assert "highlight default ViminiWaiting ctermfg=Green guifg=Green" in commands + assert any("ViminiService" in c and "cterm=italic" in c for c in commands) + + with patch.object(vim, "command") as mock_cmd: + # Test with custom UI preferences + custom_cfg = { + "ui": { + "thinking_color": "Cyan", + "prompt_color": "Yellow", + "service_color": "ctermfg=244 guifg=#808080", + } + } + util.setup_highlights(custom_cfg) + commands = [call.args[0] for call in mock_cmd.call_args_list] + assert "highlight ViminiThinking ctermfg=Cyan guifg=Cyan" in commands + assert "highlight ViminiPrompt ctermfg=Yellow guifg=Yellow" in commands + assert ( + "highlight ViminiService ctermfg=244 guifg=#808080 cterm=italic gui=italic" + in commands + ) + assert "highlight default ViminiWaiting ctermfg=Green guifg=Green" in commands