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/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..9c29ca5 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): @@ -111,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 @@ -242,6 +254,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 +265,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) @@ -374,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 @@ -660,6 +674,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 +753,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 +838,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 +893,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 +907,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 +921,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, @@ -864,16 +961,10 @@ 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 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>.*'") 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..8b788f9 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,8 @@ def handle_response(self, result): ) vim.command(f"call setbufvar({buf_num}, '&filetype', 'diff')") + util.setup_highlights() + vim.command("syntax match ViminiThinking '^T>.*'") base_buffer_name = f"[{self.req_id}] Vimini Code" try: @@ -82,6 +85,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 +180,8 @@ def code(prompt, verbose=False): vim.command("setlocal bufhidden=wipe") vim.command("setlocal noswapfile") vim.command("setlocal filetype=markdown") + util.setup_highlights() + vim.command("syntax match ViminiThinking '^T>.*'") code_buffer = vim.current.buffer code_buffer_num = code_buffer.number 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/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..1fe8437 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,8 @@ def review( vim.command("setlocal bufhidden=wipe") vim.command("setlocal noswapfile") vim.command("setlocal filetype=markdown") - 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 2959f10..86fb234 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 @@ -255,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: @@ -771,6 +847,8 @@ def create_thoughts_buffer(job_id): vim.command("setlocal bufhidden=wipe") vim.command("setlocal noswapfile") vim.command("setlocal filetype=markdown") + setup_highlights() + 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_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_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..759fa66 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 @@ -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