diff --git a/AGENTS.md b/AGENTS.md index 4cd2ffd..418f068 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,8 @@ pip install -e . ### Testing +Agents should not write tests or run tests unless explicitly asked to do so by the user. + To run the tests, use `pytest`: ```bash diff --git a/src/jrdev/agents/pipeline/plan_phase.py b/src/jrdev/agents/pipeline/plan_phase.py index edfd2c8..264c10a 100644 --- a/src/jrdev/agents/pipeline/plan_phase.py +++ b/src/jrdev/agents/pipeline/plan_phase.py @@ -116,7 +116,7 @@ async def request_step_plan(self, files_to_send: List[str], user_task: str) -> s self.agent.worker_id, update={"new_sub_task": sub_task_str, "description": "create plan"} ) - response = await generate_llm_response(self.app, model, messages, task_id=sub_task_str) + response = await generate_llm_response(self.app, model, messages, task_id=sub_task_str, json_output=True) # mark sub_task complete if self.agent.worker_id: diff --git a/src/jrdev/commands/help.py b/src/jrdev/commands/help.py index 3454047..dc0046a 100644 --- a/src/jrdev/commands/help.py +++ b/src/jrdev/commands/help.py @@ -87,7 +87,7 @@ async def handle_help(app: Any, args: List[str], _worker_id: str): ) app.ui.print_text(f" {cmd_format}/models{reset} - List all available models", print_type=None) app.ui.print_text( - f" {cmd_format}{format_command_with_args('/modelprofile', '')}" + f" {cmd_format}{format_command_with_args('/modelprofile', '')}" f"{reset} - Manage model profiles for different task types", print_type=None, ) @@ -220,7 +220,7 @@ async def handle_help_plain(app: Any, _args: List[str]): ) app.ui.print_text(" /models - List all available models", print_type=None) app.ui.print_text( - " /modelprofile - Manage model profiles for different task types", + " /modelprofile - Manage model profiles for different task types", print_type=None, ) app.ui.print_text(" /init - Index important project files and familiarize LLM with project", print_type=None) diff --git a/src/jrdev/commands/modelprofile.py b/src/jrdev/commands/modelprofile.py index fa8b4de..6e990dd 100644 --- a/src/jrdev/commands/modelprofile.py +++ b/src/jrdev/commands/modelprofile.py @@ -37,6 +37,7 @@ async def handle_modelprofile(app: Any, args: List[str], _worker_id: str) -> Non /modelprofile list - Shows all profiles and their assigned models. /modelprofile get - Shows the model assigned to a specific profile. /modelprofile set - Sets a profile to use a specific model. + /modelprofile setall - Sets every profile to use a specific model. /modelprofile default - Sets the default profile for general tasks. /modelprofile showdefault - Shows the current default profile. """ @@ -47,6 +48,7 @@ async def handle_modelprofile(app: Any, args: List[str], _worker_id: str) -> Non /modelprofile list - Show all profiles and their assigned models /modelprofile get [profile] - Show the model assigned to a profile /modelprofile set [profile] [model] - Set a profile to use a specific model + /modelprofile setall [model] - Set every profile to use a specific model /modelprofile default [profile] - Set the default profile /modelprofile showdefault - Show the current default profile """ @@ -102,6 +104,25 @@ def _handle_set(app: Any, args: List[str], manager: Any) -> None: app.ui.model_list_updated() +def _handle_setall(app: Any, args: List[str], manager: Any) -> None: + # Set every profile to use the same model + if len(args) < 3: + app.ui.print_text( + "Missing arguments. Usage: /modelprofile setall [model]", + PrintType.ERROR, + ) + return + + model = args[2] + success = manager.update_all_profiles(model, app.state.model_list) + + if not success: + app.ui.print_text("Failed to update all profiles", PrintType.ERROR) + else: + app.ui.print_text(f"Updated all profiles to use model: {model}") + app.ui.model_list_updated() + + async def _handle_subcommand(app: Any, subcommand: str, args: List[str], manager: Any) -> None: """Returns true if handled""" if subcommand == "list": @@ -111,6 +132,8 @@ async def _handle_subcommand(app: Any, subcommand: str, args: List[str], manager _handle_get(app, args, manager) elif subcommand == "set": _handle_set(app, args, manager) + elif subcommand == "setall": + _handle_setall(app, args, manager) elif subcommand == "default": # Set the default profile if len(args) < 3: @@ -137,7 +160,7 @@ async def _handle_subcommand(app: Any, subcommand: str, args: List[str], manager else: app.ui.print_text(f"Unknown subcommand: {subcommand}", PrintType.ERROR) app.ui.print_text( - "Available subcommands: list, get, set, default, showdefault", + "Available subcommands: list, get, set, setall, default, showdefault", PrintType.INFO, ) diff --git a/src/jrdev/models/model_profiles.py b/src/jrdev/models/model_profiles.py index 1142f21..0eff3c9 100644 --- a/src/jrdev/models/model_profiles.py +++ b/src/jrdev/models/model_profiles.py @@ -261,6 +261,48 @@ def update_profile(self, profile_type: str, model_name: str, model_list: Optiona logger.error(f"Error updating profile: {str(e)}") return False + def update_all_profiles(self, model_name: str, model_list: Optional[ModelList] = None) -> bool: + """ + Update every profile to use the same model. + + Args: + model_name: The model name to assign to every profile + model_list: Optional ModelList instance for validation + + Returns: + True if update successful, False otherwise + """ + if model_list is None: + model_list = ModelList() + + if not model_name: + logger.error("Invalid model name") + return False + + if not model_list.validate_model_exists(model_name): + for profile_model in self.profiles["profiles"].values(): + if model_name == profile_model: + logger.info(f"Accepting model '{model_name}' which exists in profiles") + break + else: + logger.error(f"Model '{model_name}' does not exist in available models. Options:") + for model in model_list.get_model_list(): + logger.error(f"{model}") + return False + + try: + for profile_type in self.profiles["profiles"]: + self.profiles["profiles"][profile_type] = model_name + + if write_json_file(self.config_path, self.profiles): + logger.info(f"Updated all profiles to use model '{model_name}'") + return True + return False + + except Exception as e: + logger.error(f"Error updating all profiles: {str(e)}") + return False + def list_available_profiles(self) -> Dict[str, str]: """ Return all available profile:model mappings. diff --git a/src/jrdev/prompts/code/create_steps.md b/src/jrdev/prompts/code/create_steps.md index 3743fad..4dca2e3 100644 --- a/src/jrdev/prompts/code/create_steps.md +++ b/src/jrdev/prompts/code/create_steps.md @@ -1,29 +1,33 @@ Instructions: -You are a professor of computer science, currently teaching a basic CS1000 course to some new students with -little experience programming. The requested task is one that will be given to the students. -CRITICAL: Do not provide any code for the students, only textual aide. +Generate a discrete implementation plan for the requested task. The plan is for a beginning programming student, so it must be textual guidance only and must not include source code. -**Instruction 1**:Generate a list of discrete steps. The plan must be formatted as a numbered list where each step corresponds to a single operation (DELETE or WRITE). Use only one step per file. There should only be one step for each file. Each step should be self-contained and include: +Return only one JSON object. Do not include markdown fences, prose, analysis, comments, or any text before or after the JSON object. -- The operation type. -- Filename -- The target location or reference (such as a function name, marker, or global scope). -- A description of the intended change. The description should include a brief explanation of the reason this change is being made. +The JSON object must have this shape: -Ensure that a student can follow each step independently. Provide only the plan in your response, with no -additional commentary or extraneous information. Some tasks for the students may be doable in a single step. -CRITICAL: Writing must be in a neutral, observer-style exposition that avoids any references to speakers or listeners. +{ + "steps": [ + { + "operation_type": "WRITE", + "filename": "path/to/file", + "target_location": "function name, marker, or global scope", + "description": "Neutral observer-style description of the intended change and why it is needed." + } + ], + "use_context": ["path/to/file"] +} -**Instruction 2**:Generate a list of context files needed to complete the steps. The list should only include file paths that you currently have in your context. +Rules for `steps`: +- Use only one step per file. +- Use `WRITE` for any change to an existing file or for creating a file. +- Use `DELETE` only when removing a file or code element completely. +- Each step must be self-contained and independently actionable. +- The description must avoid references to speakers or listeners. +- Some tasks may only need a single step. -- Include a file that will be altered, deleted, or otherwise changed. -- Include a file that is related to any of the tasks and provides beneficial information about the task -- including information about modules, libraries, dependencies, templates, functions, globals, etc that will be used in the task. -- Include a file if the user specifically mentioned it. -- Include a file if seeing the patterns used in it could be generally helpful. -- Do not include a file if it is generally unrelated, not helpful, and may be an overall distraction to the student. - -The response should be in json format example: {"steps": [{"operation_type": "WRITE", "filename": "src/test_file.py", "target_location": "after function X scope end", "description": "Adjust the code so that it prints hello world"}], "use_context": ["path/file.txt", "path2/file2.md"]} - -Operation Type User Guide: -WRITE: Every change to existing code requires a full rewrite of the file. -DELETE: Use when removing code elements completely \ No newline at end of file +Rules for `use_context`: +- Include every file that will be altered, deleted, or otherwise changed. +- Include files that provide useful patterns, related modules, dependencies, templates, functions, or globals for the task. +- Include files specifically mentioned by the user. +- Include only file paths currently available in the provided context. +- Exclude unrelated files that would distract from the task. diff --git a/src/jrdev/prompts/router/salvage_response.md b/src/jrdev/prompts/router/salvage_response.md index 190d35e..4ae91d0 100644 --- a/src/jrdev/prompts/router/salvage_response.md +++ b/src/jrdev/prompts/router/salvage_response.md @@ -1,28 +1,40 @@ -**INSTRUCTIONS**: Parse the included message. It is a malformed response with JSON that failed to parse. Salvage this response by formatting it in the following format: +**INSTRUCTIONS**: Parse the included message. It is a malformed response with JSON that failed to parse. Salvage this response by returning a valid JSON object with these fields: + +- `decision`: one of `execute_action`, `clarify`, `chat`, or `summary` +- `reasoning`: always required +- `action`: required only for `execute_action`, with `type`, `name`, and `args` +- `final_command`: required only for `execute_action`; false for tools, true for commands +- `question`: required only for `clarify` +- `response`: required only for `chat` and `summary` + +Example: ```json { - decision: "execute_action" | "clarify" | "chat" | "summary", - reasoning: string, // Always required - explain your decision - - // For execute_action only: - action?: { - type: "tool" | "command", - name: string, - args: string[] + "decision": "execute_action", + "reasoning": "The request requires a command.", + "action": { + "type": "command", + "name": "command_or_tool_name", + "args": [] }, - final_action?: boolean, // false for tools, true for commands - - // For clarify only: - question?: string, - - // For chat/summary only: - response?: string + "final_command": true } ``` -**CRITICAL** -- Your response must begin with ```json and it must end with ``` -- The must be no text or characters between ```json and the json object. Likewise, there must be no characters after the JSON object ends and the ```. -- There must be no comments included within the JSON object or anywhere else. -- You must not alter the text in anyway. You are only able to alter formatting. -- If a field in the object is missing, include the key and use a blank - parsable value. For example `"question": ""`. \ No newline at end of file +**CRITICAL RULES** +- Your response must begin with ```json and end with ``` +- There must be no text or characters between ```json and the JSON object, or after the JSON object ends and before ``` +- There must be no comments included within the JSON object or anywhere else +- You must not alter the semantic meaning of the content - only fix formatting issues +- Common failure patterns to fix: + - Missing closing quotes on string fields, especially `response` + - Missing closing backticks in code blocks within strings + - Missing closing braces for the JSON object + - Unescaped backslashes in strings, such as `\n` when it should be `\\n` + - Missing commas between fields +- If a field is missing, include the key with a blank parsable value, such as `"question": ""` +- Preserve all original content exactly as written, only fixing structural JSON issues +- Special attention: When the `response` field contains markdown or code blocks, ensure: + - All backticks are properly closed + - The entire markdown content is properly quoted + - No unescaped special characters break the JSON string diff --git a/src/jrdev/prompts/router/select_command.md b/src/jrdev/prompts/router/select_command.md index 7926106..342bf9a 100644 --- a/src/jrdev/prompts/router/select_command.md +++ b/src/jrdev/prompts/router/select_command.md @@ -13,26 +13,26 @@ You must make decisions in this hierarchical order: - If requires system interaction → continue to step 3 3. **Check Information Availability** - - If missing critical information → `execute_action` with tool (`final_action: false`) + - If missing critical information → `execute_action` with tool (`final_command: false`) - If have all needed information → continue to step 4 4. **Execute Final Action** - - `execute_action` with command (`final_action: true`) + - `execute_action` with command (`final_command: true`) - Follow up with `summary` to present results ## Available Actions -### Information Gathering Tools (`final_action: false`) +### Information Gathering Tools (`final_command: false`) tools_list -### Execution Commands (`final_action: true`) +### Execution Commands (`final_command: true`) commands_list ## Critical Rules 1. **NEVER guess file paths** - always verify with tools first 2. **NEVER use multiple commands in one response** - one decision per response -3. **ALWAYS set `final_action: false`** when gathering information +3. **ALWAYS set `final_command: false`** when gathering information 4. **ALWAYS provide reasoning** for your decision 5. **PREFER specific questions** in clarify responses 6. **IGNORE commands marked "Router:Ignore"** in the available commands list @@ -63,25 +63,22 @@ When multiple decisions could apply, use this priority: ```json { - decision: "execute_action" | "clarify" | "chat" | "summary", - reasoning: string, // Always required - explain your decision - - // For execute_action only: - action?: { - type: "tool" | "command", - name: string, - args: string[] + "decision": "execute_action", + "reasoning": "Explain why this action is needed.", + "action": { + "type": "tool", + "name": "tool_name", + "args": [] }, - final_action?: boolean, // false for tools, true for commands - - // For clarify only: - question?: string, - - // For chat/summary only: - response?: string + "final_command": false } ``` +Valid `decision` values are `execute_action`, `clarify`, `chat`, and `summary`. +For `execute_action`, include `action` and `final_command`. +For `clarify`, include `question`. +For `chat` or `summary`, include `response`. + ## Example Workflows ### Scenario 1: "Add error handling to the main function" @@ -95,7 +92,7 @@ When multiple decisions could apply, use this priority: "name": "read_files", "args": ["main.py"] }, - "final_action": false + "final_command": false } ``` @@ -109,7 +106,7 @@ When multiple decisions could apply, use this priority: "name": "/code", "args": ["Add try-catch error handling to the main function in main.py"] }, - "final_action": true + "final_command": true } ``` @@ -123,7 +120,7 @@ When multiple decisions could apply, use this priority: "name": "/init", "args": [] }, - "final_action": true + "final_command": true } ``` @@ -163,4 +160,4 @@ When multiple decisions could apply, use this priority: --- -Analyze the user's request based on the available tools and commands provided below. Be precise and follow the decision process outlined above. \ No newline at end of file +Analyze the user's request based on the available tools and commands provided below. Be precise and follow the decision process outlined above. diff --git a/src/jrdev/ui/tui/git/git_overview_widget.py b/src/jrdev/ui/tui/git/git_overview_widget.py index 7e99631..a63b4b4 100644 --- a/src/jrdev/ui/tui/git/git_overview_widget.py +++ b/src/jrdev/ui/tui/git/git_overview_widget.py @@ -1,5 +1,6 @@ import logging -from typing import Any +import os +from typing import Any, Optional from jrdev.ui.tui.model_listview import ModelListView from textual.app import ComposeResult @@ -344,6 +345,7 @@ def refresh_git_status(self) -> None: self.button_stage.disabled = True self.button_unstage.disabled = True self.button_reset.disabled = True + self._set_reset_button_delete_mode(False) branch_label = self.query_one("#branch-label", Label) branch_name = get_current_branch() @@ -405,6 +407,10 @@ def refresh_git_status(self) -> None: # also update current model self.commit_model_btn.label = self.core_app.state.model + def _set_reset_button_delete_mode(self, is_delete: bool) -> None: + """Switch the unstaged destructive action between reset and delete.""" + self.button_reset.label = "Delete" if is_delete else "Reset" + @on(ListView.Selected, "#unstaged-files-list") @on(ListView.Selected, "#staged-files-list") @on(ListView.Selected, "#commit-history-list") @@ -431,6 +437,7 @@ def show_diff(self, event: ListView.Selected) -> None: if commit_history_list.index is not None: commit_history_list.index = None self.button_stage.disabled = True self.button_reset.disabled = True + self._set_reset_button_delete_mode(False) self.button_unstage.disabled = False elif event.list_view.id == "unstaged-files-list": if staged_list.index is not None: staged_list.index = None @@ -443,6 +450,7 @@ def show_diff(self, event: ListView.Selected) -> None: if unstaged_list.index is not None: unstaged_list.index = None self.button_stage.disabled = True self.button_reset.disabled = True + self._set_reset_button_delete_mode(False) self.button_unstage.disabled = True item = event.item @@ -451,6 +459,7 @@ def show_diff(self, event: ListView.Selected) -> None: diff_log.clear() if isinstance(item, FileListItem): + self._set_reset_button_delete_mode(not item.staged and item.is_untracked) file_label.update(f"File Diff: [green]{item.filepath}[/]") diff_content = get_file_diff( item.filepath, staged=item.staged, is_untracked=item.is_untracked @@ -538,9 +547,51 @@ def _toggle_reset_confirmation(self, show: bool) -> None: self.button_reset.display = not show @on(Button.Pressed, "#reset-button") - def handle_reset_pressed(self): + async def handle_reset_pressed(self): + selected_item = self._get_selected_unstaged_file() + if selected_item is None: + return + + if selected_item.is_untracked: + await self._delete_untracked_file(selected_item.filepath) + return + self._toggle_reset_confirmation(True) + def _get_selected_unstaged_file(self) -> Optional[FileListItem]: + """Return the selected unstaged file item, if any.""" + unstaged_list = self.query_one("#unstaged-files-list", ListView) + if unstaged_list.index is None: + return None + + item = unstaged_list.children[unstaged_list.index] + if isinstance(item, FileListItem): + return item + + return None + + async def _delete_untracked_file(self, filepath: str) -> None: + """Confirm and delete an untracked file from the working tree.""" + confirmed = await self.core_app.ui.prompt_for_deletion(filepath) + if not confirmed: + self.notify(f"Delete cancelled: {filepath}", severity="information") + return + + try: + os.remove(filepath) + except FileNotFoundError: + self.notify(f"File not found: {filepath}", severity="warning") + self.refresh_git_status() + self.reset_file_label() + return + except OSError as exc: + self.notify(f"Failed to delete: {exc}", severity="error", timeout=10) + return + + self.notify(f"Deleted: {filepath}", severity="information") + self.refresh_git_status() + self.reset_file_label() + @on(Button.Pressed, "#confirm-reset-button") def handle_confirm_reset_pressed(self): unstaged_list = self.query_one("#unstaged-files-list", ListView) diff --git a/src/jrdev/ui/tui/settings/model_profile_widget.py b/src/jrdev/ui/tui/settings/model_profile_widget.py index 2f1a405..c943452 100644 --- a/src/jrdev/ui/tui/settings/model_profile_widget.py +++ b/src/jrdev/ui/tui/settings/model_profile_widget.py @@ -11,7 +11,7 @@ logger = logging.getLogger("jrdev") -class ModelSelectionModal(ModalScreen[str]): +class ModelSelectionModal(ModalScreen[Any]): """Modal screen for selecting a model""" DEFAULT_CSS = """ @@ -55,6 +55,11 @@ class ModelSelectionModal(ModalScreen[str]): background: $success; margin-right: 1; } + + #use-model-for-all-btn { + background: $primary; + margin-right: 1; + } #cancel-btn { background: $error; @@ -79,6 +84,7 @@ def __init__(self, profile_name: str, current_model: str, models: List[Dict[str, self.model_selection_widget: ModelSelectionWidget = None self.selected_model: Optional[str] = None self.button_save = Button("Save", variant="success", id="save-btn") + self.button_use_model_for_all = Button("Use Model for All", variant="primary", id="use-model-for-all-btn") self.button_cancel = Button("Cancel", variant="default", id="cancel-btn") def compose(self) -> Any: @@ -92,6 +98,7 @@ def compose(self) -> Any: with Horizontal(id="modal-footer"): yield self.button_save + yield self.button_use_model_for_all yield self.button_cancel async def on_mount(self) -> None: @@ -100,6 +107,7 @@ async def on_mount(self) -> None: self.model_selection_widget.styles.border = "none" self.model_selection_widget.styles.height = "90%" self.button_save.styles.border = "none" + self.button_use_model_for_all.styles.border = "none" self.button_cancel.styles.border = "none" @@ -112,6 +120,15 @@ def handle_save(self) -> None: else: self.dismiss(None) + @on(Button.Pressed, "#use-model-for-all-btn") + def handle_use_model_for_all(self) -> None: + selected_button = self.model_selection_widget.pressed_button + if selected_button: + self.selected_model = str(selected_button.label) + self.dismiss({"action": "all", "model": self.selected_model}) + else: + self.dismiss(None) + @on(Button.Pressed, "#cancel-btn") def handle_cancel(self) -> None: self.dismiss(None) @@ -208,12 +225,24 @@ class ModelProfileScreen(ModalScreen): color: $text-muted; } + #model-actions { + height: auto; + margin-top: 1; + } + #change-model-btn { margin-top: 1; background: $primary; width: 20; } + #change-all-models-btn { + margin-top: 1; + margin-left: 1; + background: $primary; + width: 24; + } + #footer { dock: bottom; height: 3; @@ -247,7 +276,9 @@ def compose(self) -> Any: with Vertical(id="model-info"): yield Label("Current Model", id="model-info-title") yield Label("", id="current-model") - yield Button("Change Model", id="change-model-btn") + with Horizontal(id="model-actions"): + yield Button("Change Model", id="change-model-btn") + yield Button("Use Model for All", id="change-all-models-btn") with Horizontal(id="footer"): yield Button("Close", variant="default", id="close-btn") @@ -256,8 +287,9 @@ async def on_mount(self) -> None: """Load profiles and set up the UI when the screen is mounted""" self.load_profiles() - # Initially hide the change button until a profile is selected - self.query_one("#change-model-btn", Button).disabled = True + has_profile = bool(self.selected_profile) + self.query_one("#change-model-btn", Button).disabled = not has_profile + self.query_one("#change-all-models-btn", Button).disabled = not bool(self.profiles) self.profile_richlog.wrap = True self.profile_richlog.markup = True @@ -318,6 +350,7 @@ def update_content_area(self, profile_name: str) -> None: # Enable the change button self.query_one("#change-model-btn", Button).disabled = False + self.query_one("#change-all-models-btn", Button).disabled = False @on(Button.Pressed, ".profile-button") def handle_profile_button(self, event: Button.Pressed) -> None: @@ -343,29 +376,69 @@ def handle_change_model(self) -> None: """Handle clicking the Change Model button""" if not self.selected_profile: return + selected_profile = self.selected_profile # Get the current model for the selected profile - current_model = self.profiles[self.selected_profile] + current_model = self.profiles[selected_profile] # Show the model selection modal models = self.core_app.get_models() #modal = ModelSelectionModal(self.selected_profile, current_model, models) - def save_profile_model(selected_model): - if selected_model: + def save_profile_model(selection): + if selection: + if isinstance(selection, dict) and selection.get("action") == "all": + selected_model = str(selection.get("model", "")) + if not selected_model: + return + self.post_message(CommandRequest(f"/modelprofile setall {selected_model}")) + for profile in self.profiles: + self.profiles[profile] = selected_model + self.update_content_area(selected_profile) + return + + selected_model = str(selection) self.post_message( - CommandRequest(f"/modelprofile set {str(self.selected_profile)} {str(selected_model)}") + CommandRequest(f"/modelprofile set {selected_profile} {str(selected_model)}") ) # Update the profiles dictionary - self.profiles[self.selected_profile] = selected_model + self.profiles[selected_profile] = selected_model # Update the content area - self.update_content_area(self.selected_profile) + self.update_content_area(selected_profile) # push model screen with callback to save profile - self.app.push_screen(ModelSelectionModal(self.selected_profile, current_model, models), save_profile_model) + self.app.push_screen(ModelSelectionModal(selected_profile, current_model, models), save_profile_model) + + @on(Button.Pressed, "#change-all-models-btn") + def handle_change_all_models(self) -> None: + """Handle clicking the Use Model for All button""" + if not self.profiles: + return + + selected_profile = self.selected_profile or self.default_profile + current_model = self.profiles.get(selected_profile, next(iter(self.profiles.values()))) + models = self.core_app.get_models() + + def save_all_profiles(selection): + if not selection: + return + if isinstance(selection, dict): + selected_model = str(selection.get("model", "")) + else: + selected_model = str(selection) + if not selected_model: + return + + self.post_message(CommandRequest(f"/modelprofile setall {selected_model}")) + for profile in self.profiles: + self.profiles[profile] = selected_model + if selected_profile: + self.update_content_area(selected_profile) + + self.app.push_screen(ModelSelectionModal("All Profiles", current_model, models), save_all_profiles) @on(Button.Pressed, "#close-btn") def close_screen(self) -> None: diff --git a/src/jrdev/ui/tui/terminal/input_widget.py b/src/jrdev/ui/tui/terminal/input_widget.py index 4fad215..00d66ce 100644 --- a/src/jrdev/ui/tui/terminal/input_widget.py +++ b/src/jrdev/ui/tui/terminal/input_widget.py @@ -5,6 +5,7 @@ from textual import events from dataclasses import dataclass from typing import ClassVar +import inspect import json import logging import os @@ -16,6 +17,9 @@ class CommandTextArea(TextArea): """A command input widget based on TextArea for multi-line input.""" MAX_HISTORY = 20 + MIN_RESIZE_HEIGHT = 3 + RESIZE_HANDLE_DOT = "•" + RESIZE_HANDLE_HIT_PADDING = 2 DEFAULT_CSS = """ CommandTextArea { @@ -61,6 +65,29 @@ def control(self) -> "CommandTextArea": """Alias for self.text_area.""" return self.text_area + def __setattr__(self, name, value): + """Keep the resize handle visible when the border title is changed externally.""" + if name == "border_title" and isinstance(value, str) and value: + value = self._format_border_title_with_resize_handle(value) + super().__setattr__(name, value) + + @classmethod + def _strip_resize_handle_from_title(cls, title: str) -> str: + """Return the border title without the resize handle marker.""" + title = title.rstrip() + handle_suffix = f" {cls.RESIZE_HANDLE_DOT}" + if title.endswith(handle_suffix): + return title[:-len(handle_suffix)].rstrip() + if title.endswith(cls.RESIZE_HANDLE_DOT): + return title[:-len(cls.RESIZE_HANDLE_DOT)].rstrip() + return title + + @classmethod + def _format_border_title_with_resize_handle(cls, title: str) -> str: + """Append the resize handle marker to a border title, avoiding duplicates.""" + title_without_handle = cls._strip_resize_handle_from_title(title) + return f"{title_without_handle} {cls.RESIZE_HANDLE_DOT}" + def __init__( self, placeholder: str = "Enter Command", @@ -81,6 +108,9 @@ def __init__( self.styles.border = ("round", Color.parse("#63f554")) self.styles.height = height self._placeholder = placeholder + self._resize_dragging = False + self._resize_start_screen_y = 0 + self._resize_start_height = height # Disable features we don't need self.show_line_numbers = False @@ -104,6 +134,133 @@ def __init__( self._draft = None + def _get_plain_border_title(self) -> str: + """Return the visible border title text without the resize handle marker.""" + border_title = getattr(self, "border_title", "") or "" + return self._strip_resize_handle_from_title(str(border_title)) + + def _get_event_position(self, event: events.MouseEvent) -> tuple[int, int]: + """Return the mouse position relative to this widget when possible.""" + x = int(getattr(event, "x", 0)) + y = int(getattr(event, "y", 0)) + width = int(getattr(self.size, "width", 0) or 0) + height = int(getattr(self.size, "height", 0) or 0) + + if 0 <= x < max(width, 1) and 0 <= y < max(height, 1): + return x, y + + screen_x = int(getattr(event, "screen_x", x)) + screen_y = int(getattr(event, "screen_y", y)) + region = getattr(self, "region", None) + if region is not None: + return screen_x - int(region.x), screen_y - int(region.y) + + return x, y + + def _get_event_screen_y(self, event: events.MouseEvent) -> int: + """Return a stable vertical coordinate for drag calculations.""" + return int(getattr(event, "screen_y", getattr(event, "y", 0))) + + def _is_resize_handle_event(self, event: events.MouseEvent) -> bool: + """Check whether a mouse event occurred over the border title resize dot.""" + x, y = self._get_event_position(event) + if y != 0: + return False + + plain_title = self._get_plain_border_title() + if not plain_title: + return False + + # Textual renders left-aligned border titles just inside the left border. + # The small hit window allows for border glyph spacing/theme differences. + expected_dot_x = len(plain_title) + 3 + return abs(x - expected_dot_x) <= self.RESIZE_HANDLE_HIT_PADDING + + def _current_widget_height(self) -> int: + """Return the current widget height in rows, falling back to the minimum.""" + current_height = int(getattr(self.size, "height", 0) or 0) + return max(current_height, self.MIN_RESIZE_HEIGHT) + + def _capture_resize_mouse(self) -> None: + """Capture mouse events while resizing, supporting multiple Textual versions.""" + capture_mouse = getattr(self, "capture_mouse", None) + if callable(capture_mouse): + capture_mouse() + return + + app_capture_mouse = getattr(getattr(self, "app", None), "capture_mouse", None) + if callable(app_capture_mouse): + app_capture_mouse(self) + + def _release_resize_mouse(self) -> None: + """Release mouse capture after resizing, supporting multiple Textual versions.""" + release_mouse = getattr(self, "release_mouse", None) + if callable(release_mouse): + release_mouse() + return + + app_release_mouse = getattr(getattr(self, "app", None), "release_mouse", None) + if callable(app_release_mouse): + app_release_mouse() + + def _begin_resize_drag(self, event: events.MouseEvent) -> None: + """Start resizing the command input height.""" + self._resize_dragging = True + self._resize_start_screen_y = self._get_event_screen_y(event) + self._resize_start_height = self._current_widget_height() + self.focus() + self._capture_resize_mouse() + event.stop() + event.prevent_default() + + def _update_resize_drag(self, event: events.MouseEvent) -> None: + """Update the command input height while the resize handle is dragged.""" + # Negate delta_y so dragging UP (decreasing screen_y) increases height + delta_y = self._resize_start_screen_y - self._get_event_screen_y(event) + new_height = max(self.MIN_RESIZE_HEIGHT, self._resize_start_height + delta_y) + if new_height != self._current_widget_height(): + self.styles.height = new_height + self.refresh(layout=True) + event.stop() + event.prevent_default() + + def _end_resize_drag(self, event: events.MouseEvent) -> None: + """Finish resizing the command input height.""" + self._resize_dragging = False + self._release_resize_mouse() + event.stop() + event.prevent_default() + + async def _call_super_mouse_handler(self, handler_name: str, event: events.MouseEvent) -> None: + """Call TextArea's mouse handler for normal editing interactions.""" + handler = getattr(super(), handler_name, None) + if handler is None: + return + result = handler(event) + if inspect.isawaitable(result): + await result + + async def _on_mouse_down(self, event: events.MouseDown) -> None: + """Start a resize drag when the border-title dot is pressed.""" + if self._is_resize_handle_event(event): + self._begin_resize_drag(event) + return + await self._call_super_mouse_handler("_on_mouse_down", event) + + async def _on_mouse_move(self, event: events.MouseMove) -> None: + """Resize vertically while dragging the border-title dot.""" + if self._resize_dragging: + self._update_resize_drag(event) + return + await self._call_super_mouse_handler("_on_mouse_move", event) + + async def _on_mouse_up(self, event: events.MouseUp) -> None: + """End a resize drag when the mouse button is released.""" + if self._resize_dragging: + self._end_resize_drag(event) + return + await self._call_super_mouse_handler("_on_mouse_up", event) + def render_line(self, y: int) -> "Strip": """Render a line of the widget, adding placeholder text if empty.""" # Get the normal strip from the TextArea @@ -184,4 +341,4 @@ async def _on_key(self, event: events.Key) -> None: self.action_submit() else: # For all other keys, use the default TextArea behavior - await super()._on_key(event) + await super()._on_key(event) \ No newline at end of file diff --git a/src/jrdev/ui/tui/terminal/terminal_output_widget.py b/src/jrdev/ui/tui/terminal/terminal_output_widget.py index 94abfbc..b83c564 100644 --- a/src/jrdev/ui/tui/terminal/terminal_output_widget.py +++ b/src/jrdev/ui/tui/terminal/terminal_output_widget.py @@ -154,7 +154,7 @@ async def on_mount(self) -> None: self.styles.height = "1fr" else: self.terminal_input.focus() - self.terminal_input.border_title = "Command Input" + self.terminal_input.border_title = CommandTextArea._format_border_title_with_resize_handle("Command Input") self.terminal_input.styles.border = ("round", Color.parse("#5e5e5e")) self.terminal_input.styles.border_title_color = "#fabd2f" self.terminal_input.styles.height = 6 @@ -312,4 +312,4 @@ async def confirmation_callback(self, result: CommandConfirmationWidget.Result) self.confirmation_container.display = False if not self.output_widget_mode: self.terminal_input.focus() - self.command_confirmation_future = None \ No newline at end of file + self.command_confirmation_future = None